Single-binary self-hosted Markdown blog on Rust axum: no database, live search, Typst PDF export, and strong SEO.
axumblogdockermarkdownminijinjarustself-hostedtypstvite
1use axum::{
2 extract::{Query, State},
3 http::{header, HeaderMap, StatusCode, Uri},
4 response::{Html, IntoResponse, Response},
5 routing::get,
6 Router,
7};
8use minijinja::context;
9use rand::seq::SliceRandom;
10use serde::Deserialize;
11
12use crate::app::AppState;
13use crate::error::AppError;
14use crate::posts::{self, Post};
15use crate::render::{build_request, render_html, Crumb};
16
17#[derive(Deserialize)]
18struct SearchQuery {
19 #[serde(default)]
20 q: String,
21}
22
23pub fn router() -> Router<AppState> {
24 Router::new()
25 .route("/search/", get(page))
26 .route("/search/live/", get(live))
27}
28
29fn matches(post: &Post, needle_lower: &str) -> bool {
30 post.title.to_lowercase().contains(needle_lower)
31 || post.description.to_lowercase().contains(needle_lower)
32 || post
33 .tags
34 .iter()
35 .any(|t| t.to_lowercase().contains(needle_lower))
36}
37
38async fn page(
39 State(state): State<AppState>,
40 Query(q): Query<SearchQuery>,
41 uri: Uri,
42 headers: HeaderMap,
43) -> Result<Html<String>, AppError> {
44 let request = build_request(&uri, &headers);
45 let published = posts::published(&state.posts);
46 let mut results: Vec<Post> = Vec::new();
47 let mut random_posts: Option<Vec<Post>> = None;
48 if !q.q.is_empty() {
49 let needle = q.q.to_lowercase();
50 for p in &published {
51 if matches(p, &needle) {
52 results.push(p.clone());
53 }
54 }
55 } else {
56 let mut rng = rand::thread_rng();
57 let mut shuffled = published.clone();
58 shuffled.shuffle(&mut rng);
59 random_posts = Some(shuffled.into_iter().take(6).collect());
60 }
61 let breadcrumbs = vec![Crumb {
62 title: "Home".into(),
63 url: "/".into(),
64 }];
65 let page = context! {
66 title => "Search",
67 slug => "search",
68 description => "Search posts on webdev, coding, security, and sysadmin.",
69 };
70 render_html(
71 &state,
72 "search.html",
73 context! { page, results, random_posts, q => &q.q, breadcrumbs },
74 &request,
75 )
76}
77
78async fn live(State(state): State<AppState>, Query(q): Query<SearchQuery>) -> Response {
79 let published = posts::published(&state.posts);
80 let mut out = Vec::new();
81 if !q.q.is_empty() {
82 let needle = q.q.to_lowercase();
83 for p in &published {
84 if matches(p, &needle) {
85 out.push(serde_json::json!({
86 "title": p.title,
87 "description": p.description,
88 "url": format!("/posts/{}/", p.slug),
89 }));
90 if out.len() >= 5 {
91 break;
92 }
93 }
94 }
95 }
96 // Match Flask's jsonify: trailing newline.
97 let body = serde_json::to_string(&out).unwrap_or_default() + "\n";
98 let mut h = HeaderMap::new();
99 h.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());
100 (StatusCode::OK, h, body).into_response()
101}