Single-binary self-hosted Markdown blog on Rust axum: no database, live search, Typst PDF export, and strong SEO.
axumblogdockermarkdownminijinjarustself-hostedtypstvite
1use chrono::Local;
2use serde::Serialize;
3use std::collections::{BTreeMap, HashMap, HashSet};
4use std::fs;
5use std::path::PathBuf;
6
7use crate::markdown;
8use crate::pdf;
9
10#[derive(Debug, Clone, Serialize)]
11pub struct Post {
12 pub filename: String,
13 pub title: String,
14 pub slug: String,
15 pub date: String,
16 pub publish_date: String,
17 pub tags: Vec<String>,
18 pub description: String,
19 pub cover_image: String,
20 pub body_html: String,
21 pub body_typst: String,
22 pub read_time: usize,
23}
24
25pub fn parse_frontmatter(text: &str) -> (HashMap<String, String>, &str) {
26 let mut meta = HashMap::new();
27 if !text.starts_with("---") {
28 return (meta, text);
29 }
30 let after_first = &text[3..];
31 // The closing delimiter must start a line; a bare find("---") would stop
32 // at a "---" inside a frontmatter value (e.g. an ISO date range or a
33 // description containing a horizontal rule).
34 let end_rel = match after_first.find("\n---") {
35 Some(e) => e + 1,
36 None => return (meta, text),
37 };
38 let block = &after_first[..end_rel];
39 let body_start = 3 + end_rel + 3;
40 let body = text[body_start..].trim_start_matches(['\r', '\n', ' ', '\t']);
41 for line in block.trim().lines() {
42 if let Some((k, v)) = line.split_once(": ") {
43 meta.insert(k.trim().to_string(), v.trim().to_string());
44 }
45 }
46 (meta, body)
47}
48
49pub fn load_posts(content_dir: &PathBuf) -> Vec<Post> {
50 let posts_dir = content_dir.join("posts");
51 let mut posts = Vec::new();
52 let entries = match fs::read_dir(&posts_dir) {
53 Ok(e) => e,
54 Err(_) => return posts,
55 };
56 for entry in entries.flatten() {
57 let path = entry.path();
58 if path.extension().and_then(|s| s.to_str()) != Some("md") {
59 continue;
60 }
61 let filename = path
62 .file_name()
63 .and_then(|s| s.to_str())
64 .unwrap_or("")
65 .to_string();
66 let text = match fs::read_to_string(&path) {
67 Ok(t) => t,
68 Err(_) => continue,
69 };
70 let (meta, body) = parse_frontmatter(&text);
71 let tags: Vec<String> = meta
72 .get("tags")
73 .map(|s| {
74 s.split(',')
75 .map(|t| t.trim().to_string())
76 .filter(|t| !t.is_empty())
77 .collect()
78 })
79 .unwrap_or_default();
80 let date = meta.get("date").cloned().unwrap_or_default();
81 let publish_date = meta.get("publish_date").cloned().unwrap_or_else(|| date.clone());
82 let body_html = markdown::render(body);
83 let body_typst = pdf::typst_from_markdown(body);
84 let word_count = body.split_whitespace().count();
85 let read_time = ((word_count as f64) / 200.0).ceil() as usize;
86 let read_time = read_time.max(1);
87
88 let slug = meta
89 .get("slug")
90 .cloned()
91 .unwrap_or_else(|| filename.trim_end_matches(".md").to_string());
92
93 posts.push(Post {
94 filename,
95 title: meta.get("title").cloned().unwrap_or_default(),
96 slug,
97 date,
98 publish_date,
99 tags,
100 description: meta.get("description").cloned().unwrap_or_default(),
101 cover_image: meta.get("cover_image").cloned().unwrap_or_default(),
102 body_html,
103 body_typst,
104 read_time,
105 });
106 }
107 posts.sort_by(|a, b| b.date.cmp(&a.date));
108 posts
109}
110
111#[derive(Debug, Clone, Serialize)]
112pub struct TagEntry {
113 pub name: String,
114 pub slug: String,
115 pub count: usize,
116 pub url: String,
117}
118
119pub fn today() -> String {
120 Local::now().date_naive().format("%Y-%m-%d").to_string()
121}
122
123pub fn is_published(post: &Post) -> bool {
124 post.publish_date.as_str() <= today().as_str()
125}
126
127pub fn published(posts: &[Post]) -> Vec<Post> {
128 posts.iter().filter(|p| is_published(p)).cloned().collect()
129}
130
131pub fn collect_tags(posts: &[Post]) -> Vec<TagEntry> {
132 let mut counts: BTreeMap<String, usize> = BTreeMap::new();
133 for p in posts {
134 for t in &p.tags {
135 *counts.entry(t.clone()).or_insert(0) += 1;
136 }
137 }
138 let mut out: Vec<TagEntry> = counts
139 .into_iter()
140 .map(|(name, count)| TagEntry {
141 url: format!("/blog/tag/{}/", urlencoding::encode(&name)),
142 slug: name.clone(),
143 name,
144 count,
145 })
146 .collect();
147 out.sort_by(|a, b| a.name.cmp(&b.name));
148 out
149}
150
151pub fn collect_years(posts: &[Post]) -> Vec<String> {
152 let mut years: Vec<String> = posts
153 .iter()
154 .filter(|p| !p.date.is_empty())
155 .map(|p| p.date[..4.min(p.date.len())].to_string())
156 .collect();
157 years.sort();
158 years.dedup();
159 years.reverse();
160 years
161}
162
163pub fn related(post: &Post, posts: &[Post], count: usize) -> Vec<Post> {
164 if post.tags.is_empty() {
165 return posts.iter().take(count).cloned().collect();
166 }
167 let post_tags: HashSet<&String> = post.tags.iter().collect();
168 let mut scored: Vec<(usize, &Post)> = posts
169 .iter()
170 .filter(|p| p.slug != post.slug)
171 .map(|p| {
172 let overlap = p.tags.iter().filter(|t| post_tags.contains(t)).count();
173 (overlap, p)
174 })
175 .filter(|(o, _)| *o > 0)
176 .collect();
177 scored.sort_by(|a, b| b.0.cmp(&a.0));
178 let mut out: Vec<Post> = scored
179 .into_iter()
180 .take(count)
181 .map(|(_, p)| p.clone())
182 .collect();
183 if out.len() < count {
184 let have: HashSet<String> = out.iter().map(|p| p.slug.clone()).collect();
185 for p in posts {
186 if p.slug == post.slug || have.contains(&p.slug) {
187 continue;
188 }
189 out.push(p.clone());
190 if out.len() >= count {
191 break;
192 }
193 }
194 }
195 out
196}