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

5.8 KB · 197 lines · Rust Raw History
  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}