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

3.5 KB · 115 lines · Rust Raw History
  1use axum::{
  2    body::Body,
  3    extract::{Path as AxumPath, State},
  4    http::{header, HeaderMap, StatusCode, Uri},
  5    response::{Html, IntoResponse, Response},
  6    routing::get,
  7    Router,
  8};
  9use minijinja::context;
 10
 11use crate::app::AppState;
 12use crate::error::AppError;
 13use crate::pdf;
 14use crate::posts::{self, Post};
 15use crate::render::{build_request, render_html, Crumb};
 16
 17pub fn router() -> Router<AppState> {
 18    Router::new()
 19        .route("/posts/{slug}/", get(show))
 20        .route("/posts/{slug}/pdf/", get(pdf_route))
 21        .route("/posts/{slug}/md/", get(markdown_route))
 22}
 23
 24fn lookup(state: &AppState, slug: &str) -> Result<Post, AppError> {
 25    let idx = state
 26        .posts_by_slug
 27        .get(slug)
 28        .copied()
 29        .ok_or_else(AppError::not_found)?;
 30    let post = state.posts[idx].clone();
 31    if !posts::is_published(&post) {
 32        return Err(AppError::not_found());
 33    }
 34    Ok(post)
 35}
 36
 37async fn show(
 38    State(state): State<AppState>,
 39    AxumPath(slug): AxumPath<String>,
 40    uri: Uri,
 41    headers: HeaderMap,
 42) -> Result<Html<String>, AppError> {
 43    let request = build_request(&uri, &headers);
 44    let post = lookup(&state, &slug)?;
 45    let published = posts::published(&state.posts);
 46    let related_posts = posts::related(&post, &published, 3);
 47    let breadcrumbs = vec![
 48        Crumb {
 49            title: "Home".into(),
 50            url: "/".into(),
 51        },
 52        Crumb {
 53            title: "Blog".into(),
 54            url: "/blog/".into(),
 55        },
 56    ];
 57    render_html(
 58        &state,
 59        "blog_post.html",
 60        context! { page => &post, post => &post, related_posts, breadcrumbs },
 61        &request,
 62    )
 63}
 64
 65async fn pdf_route(
 66    State(state): State<AppState>,
 67    AxumPath(slug): AxumPath<String>,
 68) -> Result<Response, AppError> {
 69    let post = lookup(&state, &slug)?;
 70    // Posts are immutable for the process lifetime, so render each PDF once
 71    // and serve the cached bytes after that (the compile is CPU-bound Typst).
 72    let cached = state.pdf_cache.lock().expect("pdf cache poisoned").get(&post.slug).cloned();
 73    let bytes = match cached {
 74        Some(b) => b,
 75        None => {
 76            let source = pdf::build_source(&post);
 77            let renderer = state.pdf_renderer.clone();
 78            let rendered = tokio::task::spawn_blocking(move || renderer.render(source))
 79                .await
 80                .map_err(AppError::from)?
 81                .map_err(AppError::from)?;
 82            let b = axum::body::Bytes::from(rendered);
 83            state
 84                .pdf_cache
 85                .lock()
 86                .expect("pdf cache poisoned")
 87                .insert(post.slug.clone(), b.clone());
 88            b
 89        }
 90    };
 91    let mut h = HeaderMap::new();
 92    h.insert(header::CONTENT_TYPE, "application/pdf".parse().unwrap());
 93    h.insert(
 94        header::CONTENT_DISPOSITION,
 95        format!("inline; filename=\"{}.pdf\"", post.slug).parse().unwrap(),
 96    );
 97    Ok((StatusCode::OK, h, Body::from(bytes)).into_response())
 98}
 99
100async fn markdown_route(
101    State(state): State<AppState>,
102    AxumPath(slug): AxumPath<String>,
103) -> Result<Response, AppError> {
104    let post = lookup(&state, &slug)?;
105    let path = state.content_dir.join("posts").join(&post.filename);
106    let bytes = tokio::fs::read(&path).await?;
107    let mut h = HeaderMap::new();
108    h.insert(header::CONTENT_TYPE, "text/markdown".parse().unwrap());
109    h.insert(
110        header::CONTENT_DISPOSITION,
111        format!("inline; filename=\"{}.md\"", post.slug).parse().unwrap(),
112    );
113    Ok((StatusCode::OK, h, Body::from(bytes)).into_response())
114}