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::{HeaderMap, Uri},
4 response::{Html, Redirect},
5 routing::get,
6 Router,
7};
8use minijinja::context;
9
10use crate::app::AppState;
11use crate::error::AppError;
12use crate::posts::{self, Post};
13use crate::render::{build_request, render_html, Crumb};
14
15pub fn router() -> Router<AppState> {
16 Router::new()
17 .route("/blog/", get(index))
18 .route("/blog/tag/{tag}/", get(by_tag))
19 .route("/blog/year/{year}/", get(by_year))
20 .route("/blog/{slug}/", get(post_redirect))
21 .route("/blog/{slug}/pdf/", get(post_pdf_redirect))
22 .route("/blog/{slug}/md/", get(post_md_redirect))
23}
24
25async fn index(
26 State(state): State<AppState>,
27 uri: Uri,
28 headers: HeaderMap,
29) -> Result<Html<String>, AppError> {
30 let request = build_request(&uri, &headers);
31 let published = posts::published(&state.posts);
32 let tags = posts::collect_tags(&published);
33 let years = posts::collect_years(&published);
34 let breadcrumbs = vec![Crumb {
35 title: "Home".into(),
36 url: "/".into(),
37 }];
38 let page = context! {
39 title => "Blog",
40 slug => "blog",
41 description => "Posts on webdev, coding, security, and sysadmin by Isaac Bythewood.",
42 };
43 render_html(
44 &state,
45 "blog_index.html",
46 context! { page, blog_posts => published, tags, years, breadcrumbs },
47 &request,
48 )
49}
50
51async fn by_tag(
52 State(state): State<AppState>,
53 AxumPath(tag): AxumPath<String>,
54 uri: Uri,
55 headers: HeaderMap,
56) -> Result<Html<String>, AppError> {
57 let request = build_request(&uri, &headers);
58 let published = posts::published(&state.posts);
59 let filtered: Vec<Post> = published
60 .iter()
61 .filter(|p| p.tags.contains(&tag))
62 .cloned()
63 .collect();
64 if filtered.is_empty() {
65 return Err(AppError::not_found());
66 }
67 let extra_posts: Option<Vec<Post>> = if filtered.len() < 5 {
68 Some(
69 published
70 .iter()
71 .filter(|p| !p.tags.contains(&tag))
72 .take(4)
73 .cloned()
74 .collect(),
75 )
76 } else {
77 None
78 };
79 let tags = posts::collect_tags(&published);
80 let years = posts::collect_years(&published);
81 let active_tag = context! { name => &tag, slug => &tag };
82 let page = context! {
83 title => format!("Tag: {tag}"),
84 slug => format!("tag-{tag}"),
85 description => format!("Posts tagged {tag}"),
86 };
87 let breadcrumbs = vec![
88 Crumb {
89 title: "Home".into(),
90 url: "/".into(),
91 },
92 Crumb {
93 title: "Blog".into(),
94 url: "/blog/".into(),
95 },
96 ];
97 render_html(
98 &state,
99 "blog_index.html",
100 context! { page, blog_posts => filtered, extra_posts, active_tag, tags, years, breadcrumbs },
101 &request,
102 )
103}
104
105async fn by_year(
106 State(state): State<AppState>,
107 AxumPath(year): AxumPath<String>,
108 uri: Uri,
109 headers: HeaderMap,
110) -> Result<Html<String>, AppError> {
111 let request = build_request(&uri, &headers);
112 let published = posts::published(&state.posts);
113 let filtered: Vec<Post> = published
114 .iter()
115 .filter(|p| p.date.starts_with(&year))
116 .cloned()
117 .collect();
118 if filtered.is_empty() {
119 return Err(AppError::not_found());
120 }
121 let extra_posts: Option<Vec<Post>> = if filtered.len() < 5 {
122 Some(
123 published
124 .iter()
125 .filter(|p| !p.date.starts_with(&year))
126 .take(4)
127 .cloned()
128 .collect(),
129 )
130 } else {
131 None
132 };
133 let tags = posts::collect_tags(&published);
134 let years = posts::collect_years(&published);
135 let page = context! {
136 title => format!("Year: {year}"),
137 slug => format!("year-{year}"),
138 description => format!("Posts from {year}"),
139 };
140 let breadcrumbs = vec![
141 Crumb {
142 title: "Home".into(),
143 url: "/".into(),
144 },
145 Crumb {
146 title: "Blog".into(),
147 url: "/blog/".into(),
148 },
149 ];
150 render_html(
151 &state,
152 "blog_index.html",
153 context! { page, blog_posts => filtered, extra_posts, active_year => &year, tags, years, breadcrumbs },
154 &request,
155 )
156}
157
158async fn post_redirect(AxumPath(slug): AxumPath<String>) -> Redirect {
159 Redirect::permanent(&format!("/posts/{slug}/"))
160}
161
162async fn post_pdf_redirect(AxumPath(slug): AxumPath<String>) -> Redirect {
163 Redirect::permanent(&format!("/posts/{slug}/pdf/"))
164}
165
166async fn post_md_redirect(AxumPath(slug): AxumPath<String>) -> Redirect {
167 Redirect::permanent(&format!("/posts/{slug}/md/"))
168}