Single-binary self-hosted Markdown blog on Rust axum: no database, live search, Typst PDF export, and strong SEO.
axumblogdockermarkdownminijinjarustself-hostedtypstvite
1use axum::{http::header, middleware as axum_middleware, Router};
2use minijinja::Environment;
3use std::collections::HashMap;
4use std::path::PathBuf;
5use std::sync::Arc;
6use tower_http::services::ServeDir;
7use tower_http::set_header::SetResponseHeaderLayer;
8
9use crate::middleware::log_requests;
10use crate::pdf::PdfRenderer;
11use crate::posts::{self, Post};
12use crate::routes;
13use crate::templates;
14
15#[derive(Clone)]
16pub struct AppState {
17 pub env: Arc<Environment<'static>>,
18 pub posts: Arc<Vec<Post>>,
19 pub posts_by_slug: Arc<HashMap<String, usize>>,
20 pub content_dir: PathBuf,
21 pub dist_dir: PathBuf,
22 pub pdf_renderer: Arc<PdfRenderer>,
23 /// Rendered PDF bytes per slug. Posts are immutable for the process
24 /// lifetime (restart-to-publish), so a PDF never goes stale, and the
25 /// cache is bounded by the number of posts. Without it every hit paid
26 /// the full CPU-bound Typst compile.
27 pub pdf_cache: Arc<std::sync::Mutex<HashMap<String, axum::body::Bytes>>>,
28}
29
30impl AppState {
31 pub fn from_env() -> Self {
32 let project_root: PathBuf = std::env::var("BLOG_ROOT")
33 .map(PathBuf::from)
34 .unwrap_or_else(|_| PathBuf::from("."));
35
36 let templates_dir = project_root.join("templates");
37 let dist_dir = project_root.join("dist");
38 let content_dir = project_root.join("content");
39 let manifest_path = dist_dir.join(".vite/manifest.json");
40
41 let env = templates::build_env(&templates_dir, &manifest_path);
42 let loaded = posts::load_posts(&content_dir);
43 let posts_by_slug: HashMap<String, usize> = loaded
44 .iter()
45 .enumerate()
46 .map(|(i, p)| (p.slug.clone(), i))
47 .collect();
48 let pdf_renderer = Arc::new(PdfRenderer::new(project_root));
49
50 Self {
51 env: Arc::new(env),
52 posts: Arc::new(loaded),
53 posts_by_slug: Arc::new(posts_by_slug),
54 content_dir,
55 dist_dir,
56 pdf_renderer,
57 pdf_cache: Arc::new(std::sync::Mutex::new(HashMap::new())),
58 }
59 }
60}
61
62pub fn router(state: AppState) -> Router {
63 let cache_static = || {
64 SetResponseHeaderLayer::if_not_present(
65 header::CACHE_CONTROL,
66 header::HeaderValue::from_static("public, max-age=31536000"),
67 )
68 };
69 let static_files = tower::ServiceBuilder::new()
70 .layer(cache_static())
71 .service(ServeDir::new(&state.dist_dir));
72 // Images are served under their real (unhashed) filenames, so a year of
73 // immutable caching would pin a replaced image in browsers until 2027.
74 // One day keeps them cheap without making edits invisible.
75 let images = tower::ServiceBuilder::new()
76 .layer(SetResponseHeaderLayer::if_not_present(
77 header::CACHE_CONTROL,
78 header::HeaderValue::from_static("public, max-age=86400"),
79 ))
80 .service(ServeDir::new(state.content_dir.join("images")));
81
82 Router::new()
83 .merge(routes::home::router())
84 .merge(routes::blog::router())
85 .merge(routes::post::router())
86 .merge(routes::search::router())
87 .merge(routes::seo::router())
88 .nest_service("/static", static_files)
89 .nest_service("/content/images", images)
90 .fallback(routes::errors::not_found)
91 .layer(axum_middleware::from_fn(log_requests))
92 .with_state(state)
93}