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

4.3 KB · 130 lines · Rust Raw History
  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}