repos

blog.bythewood.me-rust

mirror archived upstream

Single-binary self-hosted Markdown blog on Rust axum: no database, live search, Typst PDF export, and strong SEO.

axumblogdockermarkdownminijinjarustself-hostedtypstvite

3.3 KB · 94 lines · Rust Raw History
 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}