Single-binary self-hosted Markdown blog on Rust axum: no database, live search, Typst PDF export, and strong SEO.
axumblogdockermarkdownminijinjarustself-hostedtypstvite
1use axum::{
2 body::Body,
3 extract::{Path as AxumPath, State},
4 http::{header, HeaderMap, StatusCode, Uri},
5 response::{Html, IntoResponse, Response},
6 routing::get,
7 Router,
8};
9use minijinja::context;
10
11use crate::app::AppState;
12use crate::error::AppError;
13use crate::pdf;
14use crate::posts::{self, Post};
15use crate::render::{build_request, render_html, Crumb};
16
17pub fn router() -> Router<AppState> {
18 Router::new()
19 .route("/posts/{slug}/", get(show))
20 .route("/posts/{slug}/pdf/", get(pdf_route))
21 .route("/posts/{slug}/md/", get(markdown_route))
22}
23
24fn lookup(state: &AppState, slug: &str) -> Result<Post, AppError> {
25 let idx = state
26 .posts_by_slug
27 .get(slug)
28 .copied()
29 .ok_or_else(AppError::not_found)?;
30 let post = state.posts[idx].clone();
31 if !posts::is_published(&post) {
32 return Err(AppError::not_found());
33 }
34 Ok(post)
35}
36
37async fn show(
38 State(state): State<AppState>,
39 AxumPath(slug): AxumPath<String>,
40 uri: Uri,
41 headers: HeaderMap,
42) -> Result<Html<String>, AppError> {
43 let request = build_request(&uri, &headers);
44 let post = lookup(&state, &slug)?;
45 let published = posts::published(&state.posts);
46 let related_posts = posts::related(&post, &published, 3);
47 let breadcrumbs = vec![
48 Crumb {
49 title: "Home".into(),
50 url: "/".into(),
51 },
52 Crumb {
53 title: "Blog".into(),
54 url: "/blog/".into(),
55 },
56 ];
57 render_html(
58 &state,
59 "blog_post.html",
60 context! { page => &post, post => &post, related_posts, breadcrumbs },
61 &request,
62 )
63}
64
65async fn pdf_route(
66 State(state): State<AppState>,
67 AxumPath(slug): AxumPath<String>,
68) -> Result<Response, AppError> {
69 let post = lookup(&state, &slug)?;
70 // Posts are immutable for the process lifetime, so render each PDF once
71 // and serve the cached bytes after that (the compile is CPU-bound Typst).
72 let cached = state.pdf_cache.lock().expect("pdf cache poisoned").get(&post.slug).cloned();
73 let bytes = match cached {
74 Some(b) => b,
75 None => {
76 let source = pdf::build_source(&post);
77 let renderer = state.pdf_renderer.clone();
78 let rendered = tokio::task::spawn_blocking(move || renderer.render(source))
79 .await
80 .map_err(AppError::from)?
81 .map_err(AppError::from)?;
82 let b = axum::body::Bytes::from(rendered);
83 state
84 .pdf_cache
85 .lock()
86 .expect("pdf cache poisoned")
87 .insert(post.slug.clone(), b.clone());
88 b
89 }
90 };
91 let mut h = HeaderMap::new();
92 h.insert(header::CONTENT_TYPE, "application/pdf".parse().unwrap());
93 h.insert(
94 header::CONTENT_DISPOSITION,
95 format!("inline; filename=\"{}.pdf\"", post.slug).parse().unwrap(),
96 );
97 Ok((StatusCode::OK, h, Body::from(bytes)).into_response())
98}
99
100async fn markdown_route(
101 State(state): State<AppState>,
102 AxumPath(slug): AxumPath<String>,
103) -> Result<Response, AppError> {
104 let post = lookup(&state, &slug)?;
105 let path = state.content_dir.join("posts").join(&post.filename);
106 let bytes = tokio::fs::read(&path).await?;
107 let mut h = HeaderMap::new();
108 h.insert(header::CONTENT_TYPE, "text/markdown".parse().unwrap());
109 h.insert(
110 header::CONTENT_DISPOSITION,
111 format!("inline; filename=\"{}.md\"", post.slug).parse().unwrap(),
112 );
113 Ok((StatusCode::OK, h, Body::from(bytes)).into_response())
114}