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::{Path as AxumPath, State},
3 http::{header, HeaderMap, StatusCode, Uri},
4 response::{IntoResponse, Response},
5 routing::get,
6 Router,
7};
8use minijinja::{context, Value};
9use std::collections::HashMap;
10
11use crate::app::AppState;
12use crate::error::AppError;
13use crate::posts;
14use crate::render::build_request;
15
16pub fn router() -> Router<AppState> {
17 Router::new()
18 .route("/og/{slug_svg}", get(og_image))
19 .route("/favicon.ico", get(favicon))
20 .route("/robots.txt", get(robots))
21 .route("/sitemap.xml", get(sitemap))
22}
23
24fn wrap_title(title: &str, max_chars: usize, max_lines: usize) -> Vec<String> {
25 let mut lines: Vec<String> = Vec::new();
26 let mut current = String::new();
27 for word in title.split_whitespace() {
28 if !current.is_empty() && current.len() + word.len() + 1 > max_chars {
29 lines.push(current);
30 current = word.to_string();
31 } else if current.is_empty() {
32 current = word.to_string();
33 } else {
34 current.push(' ');
35 current.push_str(word);
36 }
37 }
38 if !current.is_empty() {
39 lines.push(current);
40 }
41 lines.into_iter().take(max_lines).collect()
42}
43
44async fn og_image(
45 State(state): State<AppState>,
46 AxumPath(slug_svg): AxumPath<String>,
47 uri: Uri,
48 headers: HeaderMap,
49) -> Result<Response, AppError> {
50 let request = build_request(&uri, &headers);
51 let slug = slug_svg
52 .strip_suffix(".svg")
53 .unwrap_or(&slug_svg)
54 .to_string();
55 let (title, tags) = match state.posts_by_slug.get(&slug).copied() {
56 Some(idx) => (state.posts[idx].title.clone(), state.posts[idx].tags.clone()),
57 None => ("Isaac Bythewood's Blog".to_string(), Vec::new()),
58 };
59 let title_lines = wrap_title(&title, 35, 3);
60 let tmpl = state.env.get_template("og.svg")?;
61 let body = tmpl.render(context! { title_lines, tags, request => &request })?;
62 let mut h = HeaderMap::new();
63 h.insert(header::CONTENT_TYPE, "image/svg+xml".parse().unwrap());
64 Ok((StatusCode::OK, h, body).into_response())
65}
66
67async fn favicon(State(state): State<AppState>) -> Result<Response, AppError> {
68 let tmpl = state.env.get_template("favicon.svg")?;
69 let body = tmpl.render(Value::UNDEFINED)?;
70 let mut h = HeaderMap::new();
71 h.insert(header::CONTENT_TYPE, "image/svg+xml".parse().unwrap());
72 Ok((StatusCode::OK, h, body).into_response())
73}
74
75async fn robots(
76 State(state): State<AppState>,
77 uri: Uri,
78 headers: HeaderMap,
79) -> Result<Response, AppError> {
80 let request = build_request(&uri, &headers);
81 let tmpl = state.env.get_template("robots.txt")?;
82 let body = tmpl.render(context! { request => &request })?;
83 let mut h = HeaderMap::new();
84 h.insert(header::CONTENT_TYPE, "text/plain".parse().unwrap());
85 Ok((StatusCode::OK, h, body).into_response())
86}
87
88async fn sitemap(
89 State(state): State<AppState>,
90 uri: Uri,
91 headers: HeaderMap,
92) -> Result<Response, AppError> {
93 let request = build_request(&uri, &headers);
94 let published = posts::published(&state.posts);
95 let tags = posts::collect_tags(&published);
96 let years = posts::collect_years(&published);
97 let mut tag_lastmod: HashMap<String, String> = HashMap::new();
98 let mut year_lastmod: HashMap<String, String> = HashMap::new();
99 for p in &published {
100 for t in &p.tags {
101 tag_lastmod
102 .entry(t.clone())
103 .and_modify(|cur| {
104 if p.date > *cur {
105 *cur = p.date.clone();
106 }
107 })
108 .or_insert_with(|| p.date.clone());
109 }
110 if p.date.len() >= 4 {
111 let y = p.date[..4].to_string();
112 year_lastmod
113 .entry(y)
114 .and_modify(|cur| {
115 if p.date > *cur {
116 *cur = p.date.clone();
117 }
118 })
119 .or_insert_with(|| p.date.clone());
120 }
121 }
122 let tmpl = state.env.get_template("sitemap.xml")?;
123 let body = tmpl.render(context! {
124 posts => published, tags, years, tag_lastmod, year_lastmod, request => &request,
125 })?;
126 let mut h = HeaderMap::new();
127 h.insert(header::CONTENT_TYPE, "application/xml".parse().unwrap());
128 Ok((StatusCode::OK, h, body).into_response())
129}