heartwood every commit a ring

Split main.rs into route modules and simplify markdown rendering

a9478caa by Isaac Bythewood · 2 days ago

Split main.rs into route modules and simplify markdown rendering

main.rs went from 725 lines to 26 by moving handlers into routes/ (one
module per feature: home, blog, post, search, seo, errors), state and
router wiring into app.rs, and cross-cutting concerns into error.rs,
middleware.rs, and render.rs. markdown.rs dropped from 541 to 18 lines
by replacing the bespoke AST walker and CodeMirror textarea wrappers
with comrak's built-in HTML renderer plus a SyntectAdapter for
server-side syntax highlighting; the markdown-to-Typst converter moved
to pdf.rs alongside its only consumer. Frontend dropped the codemirror
npm dep, code.js, and code.scss; blocks.scss became post.scss
restyled around the existing <article> element. Visual parity verified
against the live site for home, blog index, post pages, search, tag,
and 404; PDF, markdown download, OG image, sitemap, and robots all
return 200.
modified Cargo.toml
@@ -9,7 +9,7 @@ tokio = { version = "1", features = ["full"] }tower = { version = "0.5", features = ["util"] }tower-http = { version = "0.6", features = ["fs", "set-header"] }minijinja = { version = "2", features = ["loader", "loop_controls"] }comrak = "0.30"comrak = { version = "0.30", features = ["syntect"] }serde = { version = "1", features = ["derive"] }serde_json = "1"chrono = { version = "0.4", default-features = false, features = ["clock"] }
modified frontend/bun.lock
@@ -7,7 +7,6 @@        "@fontsource/monaspace-argon": "^5.2.5",        "@popperjs/core": "^2.11.5",        "bootstrap": "^5.1.3",        "codemirror": "^5.65.5",      },      "devDependencies": {        "sass": "^1.52.3",
@@ -156,8 +155,6 @@    "chokidar": ["chokidar@4.0.3", "", { "dependencies": { "readdirp": "^4.0.1" } }, "sha512-Qgzu8kfBvo+cA4962jnP1KkS6Dop5NS6g7R5LFYJr4b8Ub94PPQXUksCw9PvXoeXPRRddRNC5C1JQUR2SMGtnA=="],    "codemirror": ["codemirror@5.65.21", "", {}, "sha512-6teYk0bA0nR3QP0ihGMoxuKzpl5W80FpnHpBJpgy66NK3cZv5b/d/HY8PnRvfSsCG1MTfr92u2WUl+wT0E40mQ=="],    "detect-libc": ["detect-libc@2.1.2", "", {}, "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ=="],    "esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
modified frontend/package.json
@@ -8,8 +8,7 @@  "dependencies": {    "@fontsource/monaspace-argon": "^5.2.5",    "@popperjs/core": "^2.11.5",    "bootstrap": "^5.1.3",    "codemirror": "^5.65.5"    "bootstrap": "^5.1.3"  },  "devDependencies": {    "sass": "^1.52.3",
modified frontend/static_src/index.js
@@ -4,7 +4,6 @@ import "@fontsource/monaspace-argon";// scriptsimport "./scripts/bootstrap.js";import "./scripts/dark.js";import "./scripts/code.js";import "./scripts/search.js";// styles
@@ -12,5 +11,4 @@ import "./styles/bootstrap.scss";import "./styles/base.scss";import "./styles/dark.scss";import "./styles/home.scss";import "./styles/blocks.scss";import "./styles/code.scss";import "./styles/post.scss";
deleted frontend/static_src/scripts/code.js
@@ -1,29 +0,0 @@import CodeMirror from "codemirror/lib/codemirror.js";import "codemirror/mode/python/python.js";import "codemirror/mode/javascript/javascript.js";import "codemirror/mode/htmlmixed/htmlmixed.js";import "codemirror/mode/css/css.js";import "codemirror/mode/shell/shell.js";import "codemirror/mode/toml/toml.js";import "codemirror/lib/codemirror.css";import "codemirror/theme/material-darker.css";document.addEventListener("DOMContentLoaded", () => {  const blockCode = document.querySelectorAll(".block-code");  if (blockCode) {    Array.prototype.forEach.call(blockCode, (block) => {      const textarea = block.querySelector("textarea");      CodeMirror.fromTextArea(textarea, {        theme: "material-darker",        lineNumbers: true,        readOnly: true,        viewportMargin: Infinity,        mode: textarea.dataset.language,      });    });  }});
deleted frontend/static_src/styles/blocks.scss
@@ -1,76 +0,0 @@.block-title {  max-width: 600px;  margin: 2rem auto;}.block-rich-text {  max-width: 600px;  margin: 2rem auto;  font-size: 1.05em;  line-height: 1.9;  color: #c4bdb2;  a {    color: #7db88c;    text-decoration: underline;    text-underline-offset: 3px;    text-decoration-color: rgba(125, 184, 140, 0.3);    transition: text-decoration-color 200ms ease;    &:hover {      text-decoration-color: #7db88c;    }  }  blockquote {    border-left: 2px solid rgba(201, 168, 76, 0.3);    padding-left: 1.25rem;    color: #a09890;  }  h1, h2, h3, h4, h5, h6 {    color: #ddd7cd;  }  strong {    color: #ddd7cd;  }  code {    background: rgba(107, 158, 120, 0.1);    color: #8dc49c;    padding: 0.15em 0.4em;    border-radius: 0.2rem;    font-size: 0.9em;  }  hr {    border-color: rgba(107, 158, 120, 0.08);  }}.block-image {  display: flex;  align-items: center;  justify-content: center;  margin: 2rem 0;}.block-image * {  width: auto;  height: auto;  max-height: 75vh;  max-width: 100%;  border-radius: 0.25rem;}.block-embed {  margin: 2rem auto;  width: 800px !important;  height: 50vh;}.block-embed * {  width: 800px !important;  height: 50vh;}
deleted frontend/static_src/styles/code.scss
@@ -1,23 +0,0 @@.CodeMirror {  margin: 2rem auto;  height: auto;  max-width: 800px;  border-radius: 0.25rem;  border: 1px solid rgba(107, 158, 120, 0.08);}.CodeMirror-line {  margin: 0;}.CodeMirror-wrap pre {  word-break: break-word;}.CodeMirror-linenumber.CodeMirror-gutter-elt {  color: #4a443c;}.cm-s-material-darker .cm-comment {  color: #665f56;}
added frontend/static_src/styles/post.scss
@@ -0,0 +1,118 @@// Styles the <article> body of a blog post. Comrak emits standard HTML// (<p>, <h2>, <pre><code>, <img>, etc.) — no custom block wrappers.article {  font-size: 1.05em;  line-height: 1.9;  color: #c4bdb2;  // Text content stays in a 600px reading column.  > p,  > ul,  > ol,  > blockquote,  > h1, > h2, > h3, > h4, > h5, > h6,  > hr {    max-width: 600px;    margin-left: auto;    margin-right: auto;  }  // Code blocks, tables, and image-only paragraphs break wider.  > pre,  > table,  > p:has(> img:only-child) {    max-width: 800px;    margin-left: auto;    margin-right: auto;  }  a {    color: #7db88c;    text-decoration: underline;    text-underline-offset: 3px;    text-decoration-color: rgba(125, 184, 140, 0.3);    transition: text-decoration-color 200ms ease;    &:hover {      text-decoration-color: #7db88c;    }  }  blockquote {    border-left: 2px solid rgba(201, 168, 76, 0.3);    padding-left: 1.25rem;    color: #a09890;  }  h1, h2, h3, h4, h5, h6 {    color: #ddd7cd;  }  strong {    color: #ddd7cd;  }  // Inline code (the `:not(pre)` rules out the <code> inside a <pre>).  :not(pre) > code {    background: rgba(107, 158, 120, 0.1);    color: #8dc49c;    padding: 0.15em 0.4em;    border-radius: 0.2rem;    font-size: 0.9em;  }  hr {    border-color: rgba(107, 158, 120, 0.08);  }  // Block-level code (background-color comes from the syntect inline style).  pre {    margin: 2rem auto;    padding: 1rem 1.25rem;    border-radius: 0.25rem;    border: 1px solid rgba(107, 158, 120, 0.08);    overflow-x: auto;    font-size: 0.9em;    line-height: 1.5;    code {      background: transparent;      color: inherit;      padding: 0;      font-size: inherit;      font-family: "Monaspace Argon", ui-monospace, monospace;    }  }  table {    width: 100%;    margin: 2rem auto;    border-collapse: collapse;    th, td {      padding: 0.5rem 0.75rem;      border-bottom: 1px solid rgba(107, 158, 120, 0.08);    }    th {      color: #ddd7cd;      font-weight: 600;    }  }  img {    display: block;    margin: 0 auto;    max-width: 100%;    max-height: 75vh;    height: auto;    border-radius: 0.25rem;  }  // Center an image-only paragraph and add vertical breathing room.  p:has(> img:only-child) {    margin: 2rem auto;    text-align: center;  }}
added src/app.rs
@@ -0,0 +1,81 @@use axum::{http::header, middleware as axum_middleware, Router};use minijinja::Environment;use std::collections::HashMap;use std::path::PathBuf;use std::sync::Arc;use tower_http::services::ServeDir;use tower_http::set_header::SetResponseHeaderLayer;use crate::middleware::log_requests;use crate::pdf::PdfRenderer;use crate::posts::{self, Post};use crate::routes;use crate::templates;#[derive(Clone)]pub struct AppState {    pub env: Arc<Environment<'static>>,    pub posts: Arc<Vec<Post>>,    pub posts_by_slug: Arc<HashMap<String, usize>>,    pub content_dir: PathBuf,    pub dist_dir: PathBuf,    pub pdf_renderer: Arc<PdfRenderer>,}impl AppState {    pub fn from_env() -> Self {        let project_root: PathBuf = std::env::var("BLOG_ROOT")            .map(PathBuf::from)            .unwrap_or_else(|_| PathBuf::from("."));        let templates_dir = project_root.join("templates");        let dist_dir = project_root.join("dist");        let content_dir = project_root.join("content");        let manifest_path = dist_dir.join(".vite/manifest.json");        let env = templates::build_env(&templates_dir, &manifest_path);        let loaded = posts::load_posts(&content_dir);        let posts_by_slug: HashMap<String, usize> = loaded            .iter()            .enumerate()            .map(|(i, p)| (p.slug.clone(), i))            .collect();        let pdf_renderer = Arc::new(PdfRenderer::new(project_root));        Self {            env: Arc::new(env),            posts: Arc::new(loaded),            posts_by_slug: Arc::new(posts_by_slug),            content_dir,            dist_dir,            pdf_renderer,        }    }}pub fn router(state: AppState) -> Router {    let cache_static = || {        SetResponseHeaderLayer::if_not_present(            header::CACHE_CONTROL,            header::HeaderValue::from_static("public, max-age=31536000"),        )    };    let static_files = tower::ServiceBuilder::new()        .layer(cache_static())        .service(ServeDir::new(&state.dist_dir));    let images = tower::ServiceBuilder::new()        .layer(cache_static())        .service(ServeDir::new(state.content_dir.join("images")));    Router::new()        .merge(routes::home::router())        .merge(routes::blog::router())        .merge(routes::post::router())        .merge(routes::search::router())        .merge(routes::seo::router())        .nest_service("/static", static_files)        .nest_service("/content/images", images)        .fallback(routes::errors::not_found)        .layer(axum_middleware::from_fn(log_requests))        .with_state(state)}
added src/error.rs
@@ -0,0 +1,25 @@use axum::http::StatusCode;use axum::response::{IntoResponse, Response};pub struct AppError(pub StatusCode, pub String);impl AppError {    pub fn not_found() -> Self {        AppError(StatusCode::NOT_FOUND, "not found".to_string())    }}impl<E: std::fmt::Display> From<E> for AppError {    fn from(e: E) -> Self {        AppError(            StatusCode::INTERNAL_SERVER_ERROR,            format!("internal error: {e}"),        )    }}impl IntoResponse for AppError {    fn into_response(self) -> Response {        (self.0, self.1).into_response()    }}
modified src/main.rs
@@ -1,725 +1,26 @@mod app;mod error;mod markdown;mod middleware;mod pdf;mod posts;mod render;mod routes;mod templates;use axum::{    body::Body,    extract::{Path as AxumPath, Query, Request, State},    http::{header, HeaderMap, StatusCode, Uri},    middleware::{self, Next},    response::{Html, IntoResponse, Redirect, Response},    routing::get,    Router,};use chrono::{Datelike, Local};use minijinja::{context, Environment, Value};use rand::seq::SliceRandom;use serde::Deserialize;use std::collections::{BTreeMap, HashMap};use std::net::SocketAddr;use std::path::PathBuf;use std::sync::Arc;use std::time::Instant;use tower_http::services::ServeDir;use tower_http::set_header::SetResponseHeaderLayer;use posts::Post;use templates::RequestCtx;#[derive(Clone)]struct AppState {    env: Arc<Environment<'static>>,    posts: Arc<Vec<Post>>,    posts_by_slug: Arc<HashMap<String, usize>>,    content_dir: PathBuf,    pdf_renderer: Arc<pdf::PdfRenderer>,}#[tokio::main]async fn main() {    let project_root: PathBuf = std::env::var("BLOG_ROOT")        .map(PathBuf::from)        .unwrap_or_else(|_| PathBuf::from("."));    let templates_dir = project_root.join("templates");    let dist_dir = project_root.join("dist");    let content_dir = project_root.join("content");    let manifest_path = dist_dir.join(".vite/manifest.json");    let env = templates::build_env(&templates_dir, &manifest_path);    let posts = posts::load_posts(&content_dir);    let posts_by_slug: HashMap<String, usize> = posts        .iter()        .enumerate()        .map(|(i, p)| (p.slug.clone(), i))        .collect();    let state = app::AppState::from_env();    let router = app::router(state);    let port: u16 = std::env::var("PORT")        .ok()        .and_then(|v| v.parse().ok())        .unwrap_or(8000);    let pdf_renderer = Arc::new(pdf::PdfRenderer::new(project_root.clone()));    let state = AppState {        env: Arc::new(env),        posts: Arc::new(posts),        posts_by_slug: Arc::new(posts_by_slug),        content_dir: content_dir.clone(),        pdf_renderer,    };    let app = Router::new()        .route("/", get(index))        .route("/blog/", get(blog_index))        .route("/posts/{slug}/", get(blog_post))        .route("/posts/{slug}/pdf/", get(blog_post_pdf))        .route("/posts/{slug}/md/", get(blog_post_md))        .route("/blog/{slug}/", get(blog_post_redirect))        .route("/blog/{slug}/pdf/", get(blog_post_pdf_redirect))        .route("/blog/{slug}/md/", get(blog_post_md_redirect))        .route("/blog/tag/{tag}/", get(blog_tag))        .route("/blog/year/{year}/", get(blog_year))        .route("/search/", get(search_page))        .route("/search/live/", get(search_live))        .route("/og/{slug_svg}", get(og_image))        .route("/favicon.ico", get(favicon))        .route("/robots.txt", get(robots))        .route("/sitemap.xml", get(sitemap))        .nest_service(            "/static",            tower::ServiceBuilder::new()                .layer(SetResponseHeaderLayer::if_not_present(                    header::CACHE_CONTROL,                    header::HeaderValue::from_static("public, max-age=31536000"),                ))                .service(ServeDir::new(&dist_dir)),        )        .nest_service(            "/content/images",            tower::ServiceBuilder::new()                .layer(SetResponseHeaderLayer::if_not_present(                    header::CACHE_CONTROL,                    header::HeaderValue::from_static("public, max-age=31536000"),                ))                .service(ServeDir::new(content_dir.join("images"))),        )        .fallback(not_found)        .layer(middleware::from_fn(log_requests))        .with_state(state);    let addr = SocketAddr::from(([0, 0, 0, 0], port));    let listener = tokio::net::TcpListener::bind(addr).await.unwrap();    eprintln!("blog listening on http://{addr}");    axum::serve(listener, app).await.unwrap();}fn today() -> String {    Local::now().date_naive().format("%Y-%m-%d").to_string()}async fn log_requests(req: Request, next: Next) -> Response {    let method = req.method().clone();    let path = req        .uri()        .path_and_query()        .map(|p| p.as_str().to_string())        .unwrap_or_else(|| req.uri().path().to_string());    let start = Instant::now();    let response = next.run(req).await;    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;    let status = response.status().as_u16();    let now = Local::now().format("%H:%M:%S");    let color = match status {        200..=299 => "\x1b[32m", // green        300..=399 => "\x1b[36m", // cyan        400..=499 => "\x1b[33m", // yellow        _ => "\x1b[31m",          // red    };    eprintln!(        "{now} {method:<5} {color}{status}\x1b[0m {elapsed_ms:>7.2}ms  {path}"    );    response}fn published_posts(state: &AppState) -> Vec<Post> {    let today = today();    state        .posts        .iter()        .filter(|p| p.publish_date.as_str() <= today.as_str())        .cloned()        .collect()}fn collect_tags(posts: &[Post]) -> Vec<TagEntry> {    let mut counts: BTreeMap<String, usize> = BTreeMap::new();    for p in posts {        for t in &p.tags {            *counts.entry(t.clone()).or_insert(0) += 1;        }    }    let mut out: Vec<TagEntry> = counts        .into_iter()        .map(|(name, count)| TagEntry {            url: format!("/blog/tag/{}/", urlencoding::encode(&name)),            slug: name.clone(),            name,            count,        })        .collect();    out.sort_by(|a, b| a.name.cmp(&b.name));    out}fn collect_years(posts: &[Post]) -> Vec<String> {    let mut years: Vec<String> = posts        .iter()        .filter(|p| !p.date.is_empty())        .map(|p| p.date[..4.min(p.date.len())].to_string())        .collect();    years.sort();    years.dedup();    years.reverse();    years}fn related(post: &Post, posts: &[Post], count: usize) -> Vec<Post> {    if post.tags.is_empty() {        return posts.iter().take(count).cloned().collect();    }    let post_tags: std::collections::HashSet<&String> = post.tags.iter().collect();    let mut scored: Vec<(usize, &Post)> = posts        .iter()        .filter(|p| p.slug != post.slug)        .map(|p| {            let overlap = p.tags.iter().filter(|t| post_tags.contains(t)).count();            (overlap, p)        })        .filter(|(o, _)| *o > 0)        .collect();    scored.sort_by(|a, b| b.0.cmp(&a.0));    let mut out: Vec<Post> = scored        .into_iter()        .take(count)        .map(|(_, p)| p.clone())        .collect();    if out.len() < count {        let have: std::collections::HashSet<String> =            out.iter().map(|p| p.slug.clone()).collect();        for p in posts {            if p.slug == post.slug || have.contains(&p.slug) {                continue;            }            out.push(p.clone());            if out.len() >= count {                break;            }        }    }    out}#[derive(Debug, Clone, serde::Serialize)]struct TagEntry {    name: String,    slug: String,    count: usize,    url: String,}#[derive(Debug, Clone, serde::Serialize)]struct Crumb {    title: String,    url: String,}fn build_request(uri: &Uri, headers: &HeaderMap) -> RequestCtx {    let host = headers        .get(header::HOST)        .and_then(|v| v.to_str().ok())        .unwrap_or("localhost");    let scheme = headers        .get("x-forwarded-proto")        .and_then(|v| v.to_str().ok())        .unwrap_or("http");    let url_root = format!("{scheme}://{host}/");    let path_and_query = uri.path_and_query().map(|p| p.as_str()).unwrap_or("/");    let url = format!("{scheme}://{host}{path_and_query}");    let base_url = format!("{scheme}://{host}{}", uri.path());    RequestCtx {        url,        url_root,        base_url,    }}fn render_html(    state: &AppState,    template: &str,    extra: minijinja::Value,    request: &RequestCtx,) -> Result<Html<String>, AppError> {    let posts = published_posts(state);    let nav_items = collect_tags(&posts);    let now = NowCtx { year: Local::now().year() };    let tmpl = state.env.get_template(template)?;    let ctx = context! {        nav_items => nav_items,        now => now,        debug => false,        request => request,        ..extra    };    let body = tmpl.render(ctx)?;    Ok(Html(body))}#[derive(Debug, serde::Serialize)]struct NowCtx {    year: i32,}struct AppError(StatusCode, String);impl AppError {    fn not_found() -> Self {        AppError(StatusCode::NOT_FOUND, "not found".to_string())    }}impl<E: std::fmt::Display> From<E> for AppError {    fn from(e: E) -> Self {        AppError(StatusCode::INTERNAL_SERVER_ERROR, format!("internal error: {e}"))    }}impl IntoResponse for AppError {    fn into_response(self) -> Response {        (self.0, self.1).into_response()    }}async fn index(    State(state): State<AppState>,    uri: Uri,    headers: HeaderMap,) -> Result<Html<String>, AppError> {    let request = build_request(&uri, &headers);    let posts = published_posts(&state);    let latest_post = posts.first().cloned();    let rest: Vec<Post> = match &latest_post {        Some(latest) => posts.iter().filter(|p| p.slug != latest.slug).cloned().collect(),        None => Vec::new(),    };    let mut rng = rand::thread_rng();    let mut shuffled = rest.clone();    shuffled.shuffle(&mut rng);    let random_blog_posts: Vec<Post> = shuffled.into_iter().take(3).collect();    let page = context! {        title => "Isaac Bythewood's Blog",        slug => "home",        description => "Writing about webdev, infrastructure, security, and tooling by Isaac Bythewood, a Senior Solutions Architect in Elkin, NC.",    };    render_html(&state, "home.html", context! { page, latest_post, random_blog_posts }, &request)}async fn blog_index(    State(state): State<AppState>,    uri: Uri,    headers: HeaderMap,) -> Result<Html<String>, AppError> {    let request = build_request(&uri, &headers);    let posts = published_posts(&state);    let tags = collect_tags(&posts);    let years = collect_years(&posts);    let breadcrumbs = vec![Crumb { title: "Home".into(), url: "/".into() }];    let page = context! {        title => "Blog",        slug => "blog",        description => "Posts on webdev, coding, security, and sysadmin by Isaac Bythewood.",    };    render_html(        &state,        "blog_index.html",        context! { page, blog_posts => posts, tags, years, breadcrumbs },        &request,    )}async fn blog_post(    State(state): State<AppState>,    AxumPath(slug): AxumPath<String>,    uri: Uri,    headers: HeaderMap,) -> Result<Html<String>, AppError> {    let request = build_request(&uri, &headers);    let idx = state.posts_by_slug.get(&slug).copied().ok_or_else(AppError::not_found)?;    let post = state.posts[idx].clone();    if post.publish_date.as_str() > today().as_str() {        return Err(AppError::not_found());    }    let posts = published_posts(&state);    let related_posts = related(&post, &posts, 3);    let breadcrumbs = vec![        Crumb { title: "Home".into(), url: "/".into() },        Crumb { title: "Blog".into(), url: "/blog/".into() },    ];    render_html(        &state,        "blog_post.html",        context! { page => &post, post => &post, related_posts, breadcrumbs },        &request,    )}async fn blog_post_pdf(    State(state): State<AppState>,    AxumPath(slug): AxumPath<String>,) -> Result<Response, AppError> {    let idx = state.posts_by_slug.get(&slug).copied().ok_or_else(AppError::not_found)?;    let post = state.posts[idx].clone();    if post.publish_date.as_str() > today().as_str() {        return Err(AppError::not_found());    }    let source = build_typst_source(&post);    let renderer = state.pdf_renderer.clone();    let pdf = tokio::task::spawn_blocking(move || renderer.render(source))        .await        .map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?        .map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "application/pdf".parse().unwrap());    h.insert(        header::CONTENT_DISPOSITION,        format!("filename=\"{}.pdf\"", post.slug).parse().unwrap(),    );    Ok((StatusCode::OK, h, Body::from(pdf)).into_response())}fn build_typst_source(post: &Post) -> String {    use std::fmt::Write;    let q = markdown::typst_str_lit;    let mut s = String::with_capacity(post.body_typst.len() + 512);    s.push_str("#import \"/templates/blog_post.typ\": render\n");    s.push_str("#render(\n");    writeln!(s, "  title: \"{}\",", q(&post.title)).ok();    writeln!(s, "  date: \"{}\",", q(&post.date)).ok();    writeln!(s, "  read_time: {},", post.read_time).ok();    s.push_str("  tags: (");    for (i, t) in post.tags.iter().enumerate() {        if i > 0 {            s.push_str(", ");        }        write!(s, "\"{}\"", q(t)).ok();    }    if post.tags.len() == 1 {        s.push(',');    }    s.push_str("),\n");    writeln!(s, "  description: \"{}\",", q(&post.description)).ok();    if post.cover_image.is_empty() {        s.push_str("  cover_image: none,\n");    } else {        writeln!(            s,            "  cover_image: \"/content/images/{}\",",            q(&post.cover_image)        )        .ok();    }    s.push_str("  body: [\n");    s.push_str(&post.body_typst);    s.push_str("\n  ],\n)\n");    s}async fn blog_post_md(    State(state): State<AppState>,    AxumPath(slug): AxumPath<String>,) -> Result<Response, AppError> {    let idx = state.posts_by_slug.get(&slug).copied().ok_or_else(AppError::not_found)?;    let post = state.posts[idx].clone();    if post.publish_date.as_str() > today().as_str() {        return Err(AppError::not_found());    }    let path = state.content_dir.join("posts").join(&post.filename);    let bytes = tokio::fs::read(&path).await?;    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "text/markdown".parse().unwrap());    h.insert(        header::CONTENT_DISPOSITION,        format!("filename=\"{}.md\"", post.slug).parse().unwrap(),    );    Ok((StatusCode::OK, h, Body::from(bytes)).into_response())}async fn blog_post_redirect(AxumPath(slug): AxumPath<String>) -> Redirect {    Redirect::permanent(&format!("/posts/{slug}/"))}async fn blog_post_pdf_redirect(AxumPath(slug): AxumPath<String>) -> Redirect {    Redirect::permanent(&format!("/posts/{slug}/pdf/"))}async fn blog_post_md_redirect(AxumPath(slug): AxumPath<String>) -> Redirect {    Redirect::permanent(&format!("/posts/{slug}/md/"))}async fn blog_tag(    State(state): State<AppState>,    AxumPath(tag): AxumPath<String>,    uri: Uri,    headers: HeaderMap,) -> Result<Html<String>, AppError> {    let request = build_request(&uri, &headers);    let posts = published_posts(&state);    let filtered: Vec<Post> = posts.iter().filter(|p| p.tags.contains(&tag)).cloned().collect();    if filtered.is_empty() {        return Err(AppError::not_found());    }    let extra_posts: Option<Vec<Post>> = if filtered.len() < 5 {        Some(posts.iter().filter(|p| !p.tags.contains(&tag)).take(4).cloned().collect())    } else {        None    };    let tags = collect_tags(&posts);    let years = collect_years(&posts);    let active_tag = context! { name => &tag, slug => &tag };    let page = context! {        title => format!("Tag: {tag}"),        slug => format!("tag-{tag}"),        description => format!("Posts tagged {tag}"),    };    let breadcrumbs = vec![        Crumb { title: "Home".into(), url: "/".into() },        Crumb { title: "Blog".into(), url: "/blog/".into() },    ];    render_html(        &state,        "blog_index.html",        context! { page, blog_posts => filtered, extra_posts, active_tag, tags, years, breadcrumbs },        &request,    )}async fn blog_year(    State(state): State<AppState>,    AxumPath(year): AxumPath<String>,    uri: Uri,    headers: HeaderMap,) -> Result<Html<String>, AppError> {    let request = build_request(&uri, &headers);    let posts = published_posts(&state);    let filtered: Vec<Post> = posts.iter().filter(|p| p.date.starts_with(&year)).cloned().collect();    if filtered.is_empty() {        return Err(AppError::not_found());    }    let extra_posts: Option<Vec<Post>> = if filtered.len() < 5 {        Some(posts.iter().filter(|p| !p.date.starts_with(&year)).take(4).cloned().collect())    } else {        None    };    let tags = collect_tags(&posts);    let years = collect_years(&posts);    let page = context! {        title => format!("Year: {year}"),        slug => format!("year-{year}"),        description => format!("Posts from {year}"),    };    let breadcrumbs = vec![        Crumb { title: "Home".into(), url: "/".into() },        Crumb { title: "Blog".into(), url: "/blog/".into() },    ];    render_html(        &state,        "blog_index.html",        context! { page, blog_posts => filtered, extra_posts, active_year => &year, tags, years, breadcrumbs },        &request,    )}#[derive(Deserialize)]struct SearchQuery {    #[serde(default)]    q: String,}async fn search_page(    State(state): State<AppState>,    Query(q): Query<SearchQuery>,    uri: Uri,    headers: HeaderMap,) -> Result<Html<String>, AppError> {    let request = build_request(&uri, &headers);    let posts = published_posts(&state);    let mut results: Vec<Post> = Vec::new();    let mut random_posts: Option<Vec<Post>> = None;    if !q.q.is_empty() {        let ql = q.q.to_lowercase();        for p in &posts {            if p.title.to_lowercase().contains(&ql)                || p.description.to_lowercase().contains(&ql)                || p.tags.iter().any(|t| t.to_lowercase().contains(&ql))            {                results.push(p.clone());            }        }    } else {        let mut rng = rand::thread_rng();        let mut shuffled = posts.clone();        shuffled.shuffle(&mut rng);        random_posts = Some(shuffled.into_iter().take(6).collect());    }    let breadcrumbs = vec![Crumb { title: "Home".into(), url: "/".into() }];    let page = context! {        title => "Search",        slug => "search",        description => "Search posts on webdev, coding, security, and sysadmin.",    };    render_html(        &state,        "search.html",        context! { page, results, random_posts, q => &q.q, breadcrumbs },        &request,    )}async fn search_live(    State(state): State<AppState>,    Query(q): Query<SearchQuery>,) -> Response {    let posts = published_posts(&state);    let mut out = Vec::new();    if !q.q.is_empty() {        let ql = q.q.to_lowercase();        for p in &posts {            if p.title.to_lowercase().contains(&ql)                || p.description.to_lowercase().contains(&ql)                || p.tags.iter().any(|t| t.to_lowercase().contains(&ql))            {                out.push(serde_json::json!({                    "title": p.title,                    "description": p.description,                    "url": format!("/posts/{}/", p.slug),                }));                if out.len() >= 5 {                    break;                }            }        }    }    // Match Flask's jsonify: trailing newline.    let body = serde_json::to_string(&out).unwrap_or_default() + "\n";    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());    (StatusCode::OK, h, body).into_response()}async fn og_image(    State(state): State<AppState>,    AxumPath(slug_svg): AxumPath<String>,    uri: Uri,    headers: HeaderMap,) -> Result<Response, AppError> {    let request = build_request(&uri, &headers);    let slug = slug_svg.strip_suffix(".svg").unwrap_or(&slug_svg).to_string();    let (title, tags) = match state.posts_by_slug.get(&slug).copied() {        Some(idx) => (state.posts[idx].title.clone(), state.posts[idx].tags.clone()),        None => ("Isaac Bythewood's Blog".to_string(), Vec::new()),    };    let mut lines: Vec<String> = Vec::new();    let mut current = String::new();    for word in title.split_whitespace() {        if !current.is_empty() && current.len() + word.len() + 1 > 35 {            lines.push(current);            current = word.to_string();        } else if current.is_empty() {            current = word.to_string();        } else {            current.push(' ');            current.push_str(word);        }    }    if !current.is_empty() {        lines.push(current);    }    let title_lines: Vec<String> = lines.into_iter().take(3).collect();    let tmpl = state.env.get_template("og.svg")?;    let body = tmpl.render(context! { title_lines, tags, request => &request })?;    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "image/svg+xml".parse().unwrap());    Ok((StatusCode::OK, h, body).into_response())}async fn favicon(State(state): State<AppState>) -> Result<Response, AppError> {    let tmpl = state.env.get_template("favicon.svg")?;    let body = tmpl.render(Value::UNDEFINED)?;    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "image/svg+xml".parse().unwrap());    Ok((StatusCode::OK, h, body).into_response())}async fn robots(    State(state): State<AppState>,    uri: Uri,    headers: HeaderMap,) -> Result<Response, AppError> {    let request = build_request(&uri, &headers);    let tmpl = state.env.get_template("robots.txt")?;    let body = tmpl.render(context! { request => &request })?;    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "text/plain".parse().unwrap());    Ok((StatusCode::OK, h, body).into_response())}async fn sitemap(    State(state): State<AppState>,    uri: Uri,    headers: HeaderMap,) -> Result<Response, AppError> {    let request = build_request(&uri, &headers);    let posts = published_posts(&state);    let tags = collect_tags(&posts);    let years = collect_years(&posts);    let mut tag_lastmod: HashMap<String, String> = HashMap::new();    let mut year_lastmod: HashMap<String, String> = HashMap::new();    for p in &posts {        for t in &p.tags {            tag_lastmod                .entry(t.clone())                .and_modify(|cur| { if p.date > *cur { *cur = p.date.clone(); } })                .or_insert_with(|| p.date.clone());        }        if p.date.len() >= 4 {            let y = p.date[..4].to_string();            year_lastmod                .entry(y)                .and_modify(|cur| { if p.date > *cur { *cur = p.date.clone(); } })                .or_insert_with(|| p.date.clone());        }    }    let tmpl = state.env.get_template("sitemap.xml")?;    let body = tmpl.render(context! {        posts, tags, years, tag_lastmod, year_lastmod, request => &request,    })?;    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "application/xml".parse().unwrap());    Ok((StatusCode::OK, h, body).into_response())}async fn not_found(    State(state): State<AppState>,    uri: Uri,    headers: HeaderMap,) -> Response {    let request = build_request(&uri, &headers);    let page = context! { title => "404", description => "Page not found" };    match render_html(&state, "404.html", context! { page }, &request) {        Ok(html) => (StatusCode::NOT_FOUND, html).into_response(),        Err(_) => (StatusCode::NOT_FOUND, "404 Not Found").into_response(),    }    axum::serve(listener, router).await.unwrap();}
modified src/markdown.rs
@@ -1,541 +1,18 @@use comrak::{    nodes::{AstNode, NodeValue, TableAlignment},    Arena, ComrakOptions,};use std::cell::RefCell;use std::fmt::Write;use comrak::plugins::syntect::SyntectAdapter;use comrak::{markdown_to_html_with_plugins, Options, Plugins};use once_cell::sync::Lazy;fn options() -> ComrakOptions {    let mut opts = ComrakOptions::default();    opts.extension.strikethrough = true;    opts.extension.table = true;    opts.render.unsafe_ = true;    opts}pub fn render_blog(md: &str) -> String {    let arena = Arena::new();    let opts = options();    let root = comrak::parse_document(&arena, md, &opts);    let mut out = String::with_capacity(md.len() * 2);    render_node(root, &mut out, &opts);    out}pub fn render_typst(md: &str) -> String {    let arena = Arena::new();    let opts = options();    let root = comrak::parse_document(&arena, md, &opts);    let mut out = String::with_capacity(md.len() * 2);    render_block_typst(root, &mut out);    out}fn render_node<'a>(node: &'a AstNode<'a>, out: &mut String, opts: &ComrakOptions) {    let value = &node.data.borrow().value;    match value {        NodeValue::Document => {            for child in node.children() {                render_node(child, out, opts);            }        }        NodeValue::Paragraph => {            // Handle the BlogRenderer paragraph -> block-image short-circuit:            // if the paragraph contains exactly one image, render it as block-image            // without wrapping in block-rich-text.            let only_image = is_paragraph_only_image(node);            if only_image {                for child in node.children() {                    render_inline(child, out, opts);                }            } else {                out.push_str("<div class=\"block-rich-text\"><p>");                for child in node.children() {                    render_inline(child, out, opts);                }                out.push_str("</p></div>\n");            }        }        NodeValue::Heading(h) => {            let level = h.level;            write!(out, "<div class=\"block-rich-text\"><h{level}>").ok();            for child in node.children() {                render_inline(child, out, opts);            }            write!(out, "</h{level}></div>\n").ok();        }        NodeValue::List(_) => {            let ordered = matches!(                value,                NodeValue::List(l) if l.list_type == comrak::nodes::ListType::Ordered            );            let tag = if ordered { "ol" } else { "ul" };            write!(out, "<div class=\"block-rich-text\"><{tag}>").ok();            for child in node.children() {                render_node(child, out, opts);            }            write!(out, "</{tag}></div>\n").ok();        }        NodeValue::Item(_) => {            out.push_str("<li>");            for child in node.children() {                // Inside list items, render children directly without re-wrapping                // (paragraph children become inline text).                match &child.data.borrow().value {                    NodeValue::Paragraph => {                        for c in child.children() {                            render_inline(c, out, opts);                        }                    }                    _ => render_node(child, out, opts),                }            }            out.push_str("</li>");        }        NodeValue::BlockQuote => {            out.push_str("<div class=\"block-rich-text\"><blockquote>");            for child in node.children() {                render_node(child, out, opts);            }            out.push_str("</blockquote></div>\n");        }        NodeValue::ThematicBreak => {            out.push_str("<div class=\"block-rich-text\"><hr></div>\n");        }        NodeValue::CodeBlock(c) => {            let mut lang = c.info.split_whitespace().next().unwrap_or("").to_string();            if lang == "html" {                lang = "htmlmixed".to_string();            }            let escaped = html_escape(&c.literal);            write!(                out,                "<div class=\"block-code\"><textarea data-language=\"{lang}\">{escaped}</textarea></div>\n"            )            .ok();        }        NodeValue::HtmlBlock(h) => {            out.push_str(&h.literal);        }        NodeValue::Table(t) => {            let alignments = &t.alignments;            out.push_str(                "<div class=\"block-rich-text\"><div class=\"table-responsive\"><table class=\"table\">",            );            let mut rows = node.children();            if let Some(header) = rows.next() {                out.push_str("<thead><tr>");                for (i, cell) in header.children().enumerate() {                    let style = align_style(alignments.get(i).copied());                    write!(out, "<th{style}>").ok();                    for child in cell.children() {                        render_inline(child, out, opts);                    }                    out.push_str("</th>");                }                out.push_str("</tr></thead>");            }            out.push_str("<tbody>");            for row in rows {                out.push_str("<tr>");                for (i, cell) in row.children().enumerate() {                    let style = align_style(alignments.get(i).copied());                    write!(out, "<td{style}>").ok();                    for child in cell.children() {                        render_inline(child, out, opts);                    }                    out.push_str("</td>");                }                out.push_str("</tr>");            }            out.push_str("</tbody></table></div></div>\n");        }        _ => {            // Fallback: emit raw HTML for unhandled block types via comrak            let buf = RefCell::new(String::new());            // For simplicity, just iterate children            for child in node.children() {                render_node(child, out, opts);            }            drop(buf);        }    }}fn align_style(a: Option<TableAlignment>) -> &'static str {    match a {        Some(TableAlignment::Left) => " style=\"text-align:left\"",        Some(TableAlignment::Right) => " style=\"text-align:right\"",        Some(TableAlignment::Center) => " style=\"text-align:center\"",        _ => "",    }}fn is_paragraph_only_image<'a>(para: &'a AstNode<'a>) -> bool {    let mut iter = para.children();    let first = iter.next();    let second = iter.next();    if second.is_some() {        return false;    }    match first {        Some(child) => matches!(child.data.borrow().value, NodeValue::Image(_)),        None => false,    }}fn render_inline<'a>(node: &'a AstNode<'a>, out: &mut String, opts: &ComrakOptions) {    let value = &node.data.borrow().value;    match value {        NodeValue::Text(t) => out.push_str(&html_escape(t)),        NodeValue::SoftBreak => out.push('\n'),        NodeValue::LineBreak => out.push_str("<br>\n"),        NodeValue::Code(c) => {            write!(out, "<code>{}</code>", html_escape(&c.literal)).ok();        }        NodeValue::HtmlInline(s) => out.push_str(s),        NodeValue::Emph => {            out.push_str("<em>");            for child in node.children() {                render_inline(child, out, opts);            }            out.push_str("</em>");        }        NodeValue::Strong => {            out.push_str("<strong>");            for child in node.children() {                render_inline(child, out, opts);            }            out.push_str("</strong>");        }        NodeValue::Strikethrough => {            out.push_str("<del>");            for child in node.children() {                render_inline(child, out, opts);            }            out.push_str("</del>");        }        NodeValue::Link(l) => {            write!(out, "<a href=\"{}\"", html_escape(&l.url)).ok();            if !l.title.is_empty() {                write!(out, " title=\"{}\"", html_escape(&l.title)).ok();            }            out.push_str(">");            for child in node.children() {                render_inline(child, out, opts);            }            out.push_str("</a>");        }        NodeValue::Image(l) => {            // BlogRenderer.image: strip "images/" prefix, wrap in block-image div            let mut url = l.url.clone();            if let Some(stripped) = url.strip_prefix("images/") {                url = stripped.to_string();            }            // The "alt text" in comrak is the children rendered as plain text            let mut alt = String::new();            for child in node.children() {                collect_text(child, &mut alt);            }            write!(                out,                "<div class=\"block-image\"><img src=\"/content/images/{}\" class=\"rounded\" alt=\"{}\"></div>\n",                html_escape(&url),                html_escape(&alt),            )            .ok();        }        _ => {            // Fallback: just render children inline            for child in node.children() {                render_inline(child, out, opts);            }        }    }}fn collect_text<'a>(node: &'a AstNode<'a>, buf: &mut String) {    match &node.data.borrow().value {        NodeValue::Text(t) => buf.push_str(t),        NodeValue::Code(c) => buf.push_str(&c.literal),        _ => {            for child in node.children() {                collect_text(child, buf);            }        }    }}static HIGHLIGHTER: Lazy<SyntectAdapter> =    Lazy::new(|| SyntectAdapter::new(Some("base16-ocean.dark")));// Match Mistune's escape: & < > " (no apostrophe).fn html_escape(s: &str) -> String {    let mut out = String::with_capacity(s.len());    for c in s.chars() {        match c {            '&' => out.push_str("&amp;"),            '<' => out.push_str("&lt;"),            '>' => out.push_str("&gt;"),            '"' => out.push_str("&quot;"),            _ => out.push(c),        }    }    out}// ----- Typst conversion (used for PDF export) -----fn render_block_typst<'a>(node: &'a AstNode<'a>, out: &mut String) {    let value = &node.data.borrow().value;    match value {        NodeValue::Document => {            for child in node.children() {                render_block_typst(child, out);            }        }        NodeValue::Paragraph => {            if is_paragraph_only_image(node) {                if let Some(child) = node.children().next() {                    if let NodeValue::Image(l) = &child.data.borrow().value {                        emit_block_image(l, child, out);                    }                }            } else {                for child in node.children() {                    render_inline_typst(child, out);                }                out.push_str("\n\n");            }        }        NodeValue::Heading(h) => {            for _ in 0..h.level {                out.push('=');            }            out.push(' ');            for child in node.children() {                render_inline_typst(child, out);            }            out.push_str("\n\n");        }        NodeValue::List(l) => {            let ordered = matches!(l.list_type, comrak::nodes::ListType::Ordered);            for child in node.children() {                emit_list_item_typst(child, out, ordered);            }            out.push('\n');        }        NodeValue::Item(_) => {            // Handled by parent List        }        NodeValue::BlockQuote => {            out.push_str("#quote(block: true)[\n");            for child in node.children() {                render_block_typst(child, out);            }            out.push_str("]\n\n");        }        NodeValue::ThematicBreak => {            out.push_str(                "#align(center)[#line(length: 30%, stroke: 0.5pt + gray)]\n\n",            );        }        NodeValue::CodeBlock(c) => {            let lang = c.info.split_whitespace().next().unwrap_or("");            out.push_str("#raw(block: true");            if !lang.is_empty() {                out.push_str(", lang: \"");                out.push_str(&typst_str_lit(lang));                out.push('"');            }            out.push_str(", \"");            out.push_str(&typst_str_lit(&c.literal));            out.push_str("\")\n\n");        }        NodeValue::HtmlBlock(_) => {            // Skip raw HTML in PDF output.        }        NodeValue::Table(t) => {            emit_table_typst(node, &t.alignments, out);        }        _ => {            for child in node.children() {                render_block_typst(child, out);            }        }    }}fn emit_list_item_typst<'a>(item: &'a AstNode<'a>, out: &mut String, ordered: bool) {    out.push_str(if ordered { "+ " } else { "- " });    for child in item.children() {        match &child.data.borrow().value {            NodeValue::Paragraph => {                for c in child.children() {                    render_inline_typst(c, out);                }            }            NodeValue::List(l) => {                let nested_ordered =                    matches!(l.list_type, comrak::nodes::ListType::Ordered);                out.push('\n');                for grandchild in child.children() {                    out.push_str("  ");                    emit_list_item_typst(grandchild, out, nested_ordered);                }            }            _ => render_block_typst(child, out),        }    }    out.push('\n');}fn emit_block_image<'a>(    l: &comrak::nodes::NodeLink,    node: &'a AstNode<'a>,    out: &mut String,) {    let url = strip_images_prefix(&l.url);    let mut alt = String::new();    for child in node.children() {        collect_text(child, &mut alt);    }    out.push_str("#align(center)[#image(\"/content/images/");    out.push_str(&typst_str_lit(&url));    out.push_str("\", width: 100%");    if !alt.is_empty() {        out.push_str(", alt: \"");        out.push_str(&typst_str_lit(&alt));        out.push('"');    }    out.push_str(")]\n\n");}pub fn render(md: &str) -> String {    let mut options = Options::default();    options.extension.strikethrough = true;    options.extension.table = true;    options.render.unsafe_ = true;fn emit_table_typst<'a>(    node: &'a AstNode<'a>,    alignments: &[TableAlignment],    out: &mut String,) {    let mut rows = node.children().peekable();    let columns = match rows.peek() {        Some(first) => first.children().count(),        None => return,    };    if columns == 0 {        return;    }    out.push_str("#table(columns: ");    out.push_str(&columns.to_string());    out.push_str(", align: (");    for (i, _) in (0..columns).enumerate() {        if i > 0 {            out.push_str(", ");        }        out.push_str(match alignments.get(i).copied() {            Some(TableAlignment::Right) => "right",            Some(TableAlignment::Center) => "center",            _ => "left",        });    }    out.push_str("),\n");    for row in rows {        for cell in row.children() {            out.push_str("  [");            for child in cell.children() {                render_inline_typst(child, out);            }            out.push_str("],\n");        }    }    out.push_str(")\n\n");}fn render_inline_typst<'a>(node: &'a AstNode<'a>, out: &mut String) {    let value = &node.data.borrow().value;    match value {        NodeValue::Text(t) => out.push_str(&typst_escape(t)),        NodeValue::SoftBreak => out.push(' '),        NodeValue::LineBreak => out.push_str(" \\\n"),        NodeValue::Code(c) => {            out.push_str("#raw(\"");            out.push_str(&typst_str_lit(&c.literal));            out.push_str("\")");        }        NodeValue::HtmlInline(_) => {            // Drop raw HTML inline in PDF output.        }        NodeValue::Emph => {            out.push_str("#emph[");            for child in node.children() {                render_inline_typst(child, out);            }            out.push(']');        }        NodeValue::Strong => {            out.push_str("#strong[");            for child in node.children() {                render_inline_typst(child, out);            }            out.push(']');        }        NodeValue::Strikethrough => {            out.push_str("#strike[");            for child in node.children() {                render_inline_typst(child, out);            }            out.push(']');        }        NodeValue::Link(l) => {            out.push_str("#link(\"");            out.push_str(&typst_str_lit(&l.url));            out.push_str("\")[");            for child in node.children() {                render_inline_typst(child, out);            }            out.push(']');        }        NodeValue::Image(l) => {            let url = strip_images_prefix(&l.url);            out.push_str("#image(\"/content/images/");            out.push_str(&typst_str_lit(&url));            out.push_str("\")");        }        _ => {            for child in node.children() {                render_inline_typst(child, out);            }        }    }}fn strip_images_prefix(url: &str) -> String {    url.strip_prefix("images/").unwrap_or(url).to_string()}/// Backslash-escape characters that have meaning in Typst markup mode.fn typst_escape(s: &str) -> String {    let mut out = String::with_capacity(s.len());    for c in s.chars() {        match c {            '\\' | '[' | ']' | '*' | '_' | '`' | '#' | '$' | '<' | '@' | '~' => {                out.push('\\');                out.push(c);            }            _ => out.push(c),        }    }    out}    let mut plugins = Plugins::default();    plugins.render.codefence_syntax_highlighter = Some(&*HIGHLIGHTER);/// Escape a string for use inside a `"..."` Typst string literal.pub fn typst_str_lit(s: &str) -> String {    let mut out = String::with_capacity(s.len());    for c in s.chars() {        match c {            '\\' => out.push_str("\\\\"),            '"' => out.push_str("\\\""),            '\n' => out.push_str("\\n"),            '\r' => out.push_str("\\r"),            '\t' => out.push_str("\\t"),            _ => out.push(c),        }    }    out    markdown_to_html_with_plugins(md, &options, &plugins)}
added src/middleware.rs
@@ -0,0 +1,25 @@use axum::{extract::Request, middleware::Next, response::Response};use chrono::Local;use std::time::Instant;pub async fn log_requests(req: Request, next: Next) -> Response {    let method = req.method().clone();    let path = req        .uri()        .path_and_query()        .map(|p| p.as_str().to_string())        .unwrap_or_else(|| req.uri().path().to_string());    let start = Instant::now();    let response = next.run(req).await;    let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;    let status = response.status().as_u16();    let now = Local::now().format("%H:%M:%S");    let color = match status {        200..=299 => "\x1b[32m",        300..=399 => "\x1b[36m",        400..=499 => "\x1b[33m",        _ => "\x1b[31m",    };    eprintln!("{now} {method:<5} {color}{status}\x1b[0m {elapsed_ms:>7.2}ms  {path}");    response}
modified src/pdf.rs
@@ -1,18 +1,25 @@use std::fmt::Write as _;use std::path::{Path, PathBuf};use std::sync::Arc;use chrono::{Datelike, Local};use comrak::{    nodes::{AstNode, NodeValue, TableAlignment},    Arena, Options,};use typst::{    Library, LibraryExt, World,    diag::{FileError, FileResult, SourceDiagnostic},    foundations::{Bytes, Datetime},    layout::PagedDocument,    syntax::{FileId, Source, VirtualPath},    text::{Font, FontBook},    utils::LazyHash,    Library, LibraryExt, World,};use typst_kit::fonts::{FontSearcher, FontSlot, Fonts};use crate::posts::Post;/// Pre-built renderer state. Fonts and the standard library are loaded once at/// startup and shared across renders.pub struct PdfRenderer {
@@ -146,3 +153,303 @@ fn path_within(path: &Path, root: &Path) -> bool {    };    canon.starts_with(canon_root)}/// Wrap a post's pre-rendered Typst body in the `blog_post.typ` template call.pub fn build_source(post: &Post) -> String {    let mut s = String::with_capacity(post.body_typst.len() + 512);    s.push_str("#import \"/templates/blog_post.typ\": render\n");    s.push_str("#render(\n");    writeln!(s, "  title: \"{}\",", str_lit(&post.title)).ok();    writeln!(s, "  date: \"{}\",", str_lit(&post.date)).ok();    writeln!(s, "  read_time: {},", post.read_time).ok();    s.push_str("  tags: (");    for (i, t) in post.tags.iter().enumerate() {        if i > 0 {            s.push_str(", ");        }        write!(s, "\"{}\"", str_lit(t)).ok();    }    if post.tags.len() == 1 {        s.push(',');    }    s.push_str("),\n");    writeln!(s, "  description: \"{}\",", str_lit(&post.description)).ok();    if post.cover_image.is_empty() {        s.push_str("  cover_image: none,\n");    } else {        writeln!(            s,            "  cover_image: \"/content/images/{}\",",            str_lit(&post.cover_image)        )        .ok();    }    s.push_str("  body: [\n");    s.push_str(&post.body_typst);    s.push_str("\n  ],\n)\n");    s}/// Convert markdown source into Typst markup body (used as `body` argument to/// `blog_post.typ::render`). No off-the-shelf md→Typst converter exists, so we/// walk comrak's AST and emit Typst by hand.pub fn typst_from_markdown(md: &str) -> String {    let arena = Arena::new();    let mut opts = Options::default();    opts.extension.strikethrough = true;    opts.extension.table = true;    opts.render.unsafe_ = true;    let root = comrak::parse_document(&arena, md, &opts);    let mut out = String::with_capacity(md.len() * 2);    render_block(root, &mut out);    out}fn render_block<'a>(node: &'a AstNode<'a>, out: &mut String) {    match &node.data.borrow().value {        NodeValue::Document => {            for child in node.children() {                render_block(child, out);            }        }        NodeValue::Paragraph => {            if is_paragraph_only_image(node) {                if let Some(child) = node.children().next() {                    if let NodeValue::Image(l) = &child.data.borrow().value {                        emit_block_image(l, child, out);                    }                }            } else {                for child in node.children() {                    render_inline(child, out);                }                out.push_str("\n\n");            }        }        NodeValue::Heading(h) => {            for _ in 0..h.level {                out.push('=');            }            out.push(' ');            for child in node.children() {                render_inline(child, out);            }            out.push_str("\n\n");        }        NodeValue::List(l) => {            let ordered = matches!(l.list_type, comrak::nodes::ListType::Ordered);            for child in node.children() {                emit_list_item(child, out, ordered);            }            out.push('\n');        }        NodeValue::Item(_) => {} // handled by parent List        NodeValue::BlockQuote => {            out.push_str("#quote(block: true)[\n");            for child in node.children() {                render_block(child, out);            }            out.push_str("]\n\n");        }        NodeValue::ThematicBreak => {            out.push_str("#align(center)[#line(length: 30%, stroke: 0.5pt + gray)]\n\n");        }        NodeValue::CodeBlock(c) => {            let lang = c.info.split_whitespace().next().unwrap_or("");            out.push_str("#raw(block: true");            if !lang.is_empty() {                out.push_str(", lang: \"");                out.push_str(&str_lit(lang));                out.push('"');            }            out.push_str(", \"");            out.push_str(&str_lit(&c.literal));            out.push_str("\")\n\n");        }        NodeValue::HtmlBlock(_) => {} // skip raw HTML in PDF output        NodeValue::Table(t) => emit_table(node, &t.alignments, out),        _ => {            for child in node.children() {                render_block(child, out);            }        }    }}fn render_inline<'a>(node: &'a AstNode<'a>, out: &mut String) {    match &node.data.borrow().value {        NodeValue::Text(t) => out.push_str(&escape_markup(t)),        NodeValue::SoftBreak => out.push(' '),        NodeValue::LineBreak => out.push_str(" \\\n"),        NodeValue::Code(c) => {            out.push_str("#raw(\"");            out.push_str(&str_lit(&c.literal));            out.push_str("\")");        }        NodeValue::HtmlInline(_) => {} // drop raw HTML inline in PDF        NodeValue::Emph => wrap_inline(node, "#emph[", "]", out),        NodeValue::Strong => wrap_inline(node, "#strong[", "]", out),        NodeValue::Strikethrough => wrap_inline(node, "#strike[", "]", out),        NodeValue::Link(l) => {            out.push_str("#link(\"");            out.push_str(&str_lit(&l.url));            out.push_str("\")[");            for child in node.children() {                render_inline(child, out);            }            out.push(']');        }        NodeValue::Image(l) => {            out.push_str("#image(\"/content/images/");            out.push_str(&str_lit(strip_images_prefix(&l.url)));            out.push_str("\")");        }        _ => {            for child in node.children() {                render_inline(child, out);            }        }    }}fn wrap_inline<'a>(node: &'a AstNode<'a>, open: &str, close: &str, out: &mut String) {    out.push_str(open);    for child in node.children() {        render_inline(child, out);    }    out.push_str(close);}fn emit_list_item<'a>(item: &'a AstNode<'a>, out: &mut String, ordered: bool) {    out.push_str(if ordered { "+ " } else { "- " });    for child in item.children() {        match &child.data.borrow().value {            NodeValue::Paragraph => {                for c in child.children() {                    render_inline(c, out);                }            }            NodeValue::List(l) => {                let nested_ordered = matches!(l.list_type, comrak::nodes::ListType::Ordered);                out.push('\n');                for grandchild in child.children() {                    out.push_str("  ");                    emit_list_item(grandchild, out, nested_ordered);                }            }            _ => render_block(child, out),        }    }    out.push('\n');}fn emit_block_image<'a>(l: &comrak::nodes::NodeLink, node: &'a AstNode<'a>, out: &mut String) {    let url = strip_images_prefix(&l.url);    let mut alt = String::new();    for child in node.children() {        collect_text(child, &mut alt);    }    out.push_str("#align(center)[#image(\"/content/images/");    out.push_str(&str_lit(url));    out.push_str("\", width: 100%");    if !alt.is_empty() {        out.push_str(", alt: \"");        out.push_str(&str_lit(&alt));        out.push('"');    }    out.push_str(")]\n\n");}fn emit_table<'a>(node: &'a AstNode<'a>, alignments: &[TableAlignment], out: &mut String) {    let mut rows = node.children().peekable();    let columns = match rows.peek() {        Some(first) => first.children().count(),        None => return,    };    if columns == 0 {        return;    }    out.push_str("#table(columns: ");    out.push_str(&columns.to_string());    out.push_str(", align: (");    for i in 0..columns {        if i > 0 {            out.push_str(", ");        }        out.push_str(match alignments.get(i).copied() {            Some(TableAlignment::Right) => "right",            Some(TableAlignment::Center) => "center",            _ => "left",        });    }    out.push_str("),\n");    for row in rows {        for cell in row.children() {            out.push_str("  [");            for child in cell.children() {                render_inline(child, out);            }            out.push_str("],\n");        }    }    out.push_str(")\n\n");}fn collect_text<'a>(node: &'a AstNode<'a>, buf: &mut String) {    match &node.data.borrow().value {        NodeValue::Text(t) => buf.push_str(t),        NodeValue::Code(c) => buf.push_str(&c.literal),        _ => {            for child in node.children() {                collect_text(child, buf);            }        }    }}fn is_paragraph_only_image<'a>(para: &'a AstNode<'a>) -> bool {    let mut iter = para.children();    let first = iter.next();    if iter.next().is_some() {        return false;    }    match first {        Some(child) => matches!(child.data.borrow().value, NodeValue::Image(_)),        None => false,    }}fn strip_images_prefix(url: &str) -> &str {    url.strip_prefix("images/").unwrap_or(url)}/// Backslash-escape characters with meaning in Typst markup mode.fn escape_markup(s: &str) -> String {    let mut out = String::with_capacity(s.len());    for c in s.chars() {        match c {            '\\' | '[' | ']' | '*' | '_' | '`' | '#' | '$' | '<' | '@' | '~' => {                out.push('\\');                out.push(c);            }            _ => out.push(c),        }    }    out}/// Escape a string for use inside a `"..."` Typst string literal.fn str_lit(s: &str) -> String {    let mut out = String::with_capacity(s.len());    for c in s.chars() {        match c {            '\\' => out.push_str("\\\\"),            '"' => out.push_str("\\\""),            '\n' => out.push_str("\\n"),            '\r' => out.push_str("\\r"),            '\t' => out.push_str("\\t"),            _ => out.push(c),        }    }    out}
modified src/posts.rs
@@ -1,9 +1,11 @@use chrono::Local;use serde::Serialize;use std::collections::HashMap;use std::collections::{BTreeMap, HashMap, HashSet};use std::fs;use std::path::PathBuf;use crate::markdown;use crate::pdf;#[derive(Debug, Clone, Serialize)]pub struct Post {
@@ -74,8 +76,8 @@ pub fn load_posts(content_dir: &PathBuf) -> Vec<Post> {            .unwrap_or_default();        let date = meta.get("date").cloned().unwrap_or_default();        let publish_date = meta.get("publish_date").cloned().unwrap_or_else(|| date.clone());        let body_html = markdown::render_blog(body);        let body_typst = markdown::render_typst(body);        let body_html = markdown::render(body);        let body_typst = pdf::typst_from_markdown(body);        let word_count = body.split_whitespace().count();        let read_time = ((word_count as f64) / 200.0).ceil() as usize;        let read_time = read_time.max(1);
@@ -102,3 +104,90 @@ pub fn load_posts(content_dir: &PathBuf) -> Vec<Post> {    posts.sort_by(|a, b| b.date.cmp(&a.date));    posts}#[derive(Debug, Clone, Serialize)]pub struct TagEntry {    pub name: String,    pub slug: String,    pub count: usize,    pub url: String,}pub fn today() -> String {    Local::now().date_naive().format("%Y-%m-%d").to_string()}pub fn is_published(post: &Post) -> bool {    post.publish_date.as_str() <= today().as_str()}pub fn published(posts: &[Post]) -> Vec<Post> {    posts.iter().filter(|p| is_published(p)).cloned().collect()}pub fn collect_tags(posts: &[Post]) -> Vec<TagEntry> {    let mut counts: BTreeMap<String, usize> = BTreeMap::new();    for p in posts {        for t in &p.tags {            *counts.entry(t.clone()).or_insert(0) += 1;        }    }    let mut out: Vec<TagEntry> = counts        .into_iter()        .map(|(name, count)| TagEntry {            url: format!("/blog/tag/{}/", urlencoding::encode(&name)),            slug: name.clone(),            name,            count,        })        .collect();    out.sort_by(|a, b| a.name.cmp(&b.name));    out}pub fn collect_years(posts: &[Post]) -> Vec<String> {    let mut years: Vec<String> = posts        .iter()        .filter(|p| !p.date.is_empty())        .map(|p| p.date[..4.min(p.date.len())].to_string())        .collect();    years.sort();    years.dedup();    years.reverse();    years}pub fn related(post: &Post, posts: &[Post], count: usize) -> Vec<Post> {    if post.tags.is_empty() {        return posts.iter().take(count).cloned().collect();    }    let post_tags: HashSet<&String> = post.tags.iter().collect();    let mut scored: Vec<(usize, &Post)> = posts        .iter()        .filter(|p| p.slug != post.slug)        .map(|p| {            let overlap = p.tags.iter().filter(|t| post_tags.contains(t)).count();            (overlap, p)        })        .filter(|(o, _)| *o > 0)        .collect();    scored.sort_by(|a, b| b.0.cmp(&a.0));    let mut out: Vec<Post> = scored        .into_iter()        .take(count)        .map(|(_, p)| p.clone())        .collect();    if out.len() < count {        let have: HashSet<String> = out.iter().map(|p| p.slug.clone()).collect();        for p in posts {            if p.slug == post.slug || have.contains(&p.slug) {                continue;            }            out.push(p.clone());            if out.len() >= count {                break;            }        }    }    out}
added src/render.rs
@@ -0,0 +1,64 @@use axum::http::{header, HeaderMap, Uri};use axum::response::Html;use chrono::{Datelike, Local};use minijinja::context;use serde::Serialize;use crate::app::AppState;use crate::error::AppError;use crate::posts::{self, collect_tags};use crate::templates::RequestCtx;#[derive(Debug, Clone, Serialize)]pub struct Crumb {    pub title: String,    pub url: String,}#[derive(Debug, Serialize)]struct NowCtx {    year: i32,}pub fn build_request(uri: &Uri, headers: &HeaderMap) -> RequestCtx {    let host = headers        .get(header::HOST)        .and_then(|v| v.to_str().ok())        .unwrap_or("localhost");    let scheme = headers        .get("x-forwarded-proto")        .and_then(|v| v.to_str().ok())        .unwrap_or("http");    let url_root = format!("{scheme}://{host}/");    let path_and_query = uri.path_and_query().map(|p| p.as_str()).unwrap_or("/");    let url = format!("{scheme}://{host}{path_and_query}");    let base_url = format!("{scheme}://{host}{}", uri.path());    RequestCtx {        url,        url_root,        base_url,    }}pub fn render_html(    state: &AppState,    template: &str,    extra: minijinja::Value,    request: &RequestCtx,) -> Result<Html<String>, AppError> {    let published = posts::published(&state.posts);    let nav_items = collect_tags(&published);    let now = NowCtx {        year: Local::now().year(),    };    let tmpl = state.env.get_template(template)?;    let ctx = context! {        nav_items => nav_items,        now => now,        debug => false,        request => request,        ..extra    };    let body = tmpl.render(ctx)?;    Ok(Html(body))}
added src/routes/blog.rs
@@ -0,0 +1,168 @@use axum::{    extract::{Path as AxumPath, State},    http::{HeaderMap, Uri},    response::{Html, Redirect},    routing::get,    Router,};use minijinja::context;use crate::app::AppState;use crate::error::AppError;use crate::posts::{self, Post};use crate::render::{build_request, render_html, Crumb};pub fn router() -> Router<AppState> {    Router::new()        .route("/blog/", get(index))        .route("/blog/tag/{tag}/", get(by_tag))        .route("/blog/year/{year}/", get(by_year))        .route("/blog/{slug}/", get(post_redirect))        .route("/blog/{slug}/pdf/", get(post_pdf_redirect))        .route("/blog/{slug}/md/", get(post_md_redirect))}async fn index(    State(state): State<AppState>,    uri: Uri,    headers: HeaderMap,) -> Result<Html<String>, AppError> {    let request = build_request(&uri, &headers);    let published = posts::published(&state.posts);    let tags = posts::collect_tags(&published);    let years = posts::collect_years(&published);    let breadcrumbs = vec![Crumb {        title: "Home".into(),        url: "/".into(),    }];    let page = context! {        title => "Blog",        slug => "blog",        description => "Posts on webdev, coding, security, and sysadmin by Isaac Bythewood.",    };    render_html(        &state,        "blog_index.html",        context! { page, blog_posts => published, tags, years, breadcrumbs },        &request,    )}async fn by_tag(    State(state): State<AppState>,    AxumPath(tag): AxumPath<String>,    uri: Uri,    headers: HeaderMap,) -> Result<Html<String>, AppError> {    let request = build_request(&uri, &headers);    let published = posts::published(&state.posts);    let filtered: Vec<Post> = published        .iter()        .filter(|p| p.tags.contains(&tag))        .cloned()        .collect();    if filtered.is_empty() {        return Err(AppError::not_found());    }    let extra_posts: Option<Vec<Post>> = if filtered.len() < 5 {        Some(            published                .iter()                .filter(|p| !p.tags.contains(&tag))                .take(4)                .cloned()                .collect(),        )    } else {        None    };    let tags = posts::collect_tags(&published);    let years = posts::collect_years(&published);    let active_tag = context! { name => &tag, slug => &tag };    let page = context! {        title => format!("Tag: {tag}"),        slug => format!("tag-{tag}"),        description => format!("Posts tagged {tag}"),    };    let breadcrumbs = vec![        Crumb {            title: "Home".into(),            url: "/".into(),        },        Crumb {            title: "Blog".into(),            url: "/blog/".into(),        },    ];    render_html(        &state,        "blog_index.html",        context! { page, blog_posts => filtered, extra_posts, active_tag, tags, years, breadcrumbs },        &request,    )}async fn by_year(    State(state): State<AppState>,    AxumPath(year): AxumPath<String>,    uri: Uri,    headers: HeaderMap,) -> Result<Html<String>, AppError> {    let request = build_request(&uri, &headers);    let published = posts::published(&state.posts);    let filtered: Vec<Post> = published        .iter()        .filter(|p| p.date.starts_with(&year))        .cloned()        .collect();    if filtered.is_empty() {        return Err(AppError::not_found());    }    let extra_posts: Option<Vec<Post>> = if filtered.len() < 5 {        Some(            published                .iter()                .filter(|p| !p.date.starts_with(&year))                .take(4)                .cloned()                .collect(),        )    } else {        None    };    let tags = posts::collect_tags(&published);    let years = posts::collect_years(&published);    let page = context! {        title => format!("Year: {year}"),        slug => format!("year-{year}"),        description => format!("Posts from {year}"),    };    let breadcrumbs = vec![        Crumb {            title: "Home".into(),            url: "/".into(),        },        Crumb {            title: "Blog".into(),            url: "/blog/".into(),        },    ];    render_html(        &state,        "blog_index.html",        context! { page, blog_posts => filtered, extra_posts, active_year => &year, tags, years, breadcrumbs },        &request,    )}async fn post_redirect(AxumPath(slug): AxumPath<String>) -> Redirect {    Redirect::permanent(&format!("/posts/{slug}/"))}async fn post_pdf_redirect(AxumPath(slug): AxumPath<String>) -> Redirect {    Redirect::permanent(&format!("/posts/{slug}/pdf/"))}async fn post_md_redirect(AxumPath(slug): AxumPath<String>) -> Redirect {    Redirect::permanent(&format!("/posts/{slug}/md/"))}
added src/routes/errors.rs
@@ -0,0 +1,22 @@use axum::{    extract::State,    http::{HeaderMap, StatusCode, Uri},    response::{IntoResponse, Response},};use minijinja::context;use crate::app::AppState;use crate::render::{build_request, render_html};pub async fn not_found(    State(state): State<AppState>,    uri: Uri,    headers: HeaderMap,) -> Response {    let request = build_request(&uri, &headers);    let page = context! { title => "404", description => "Page not found" };    match render_html(&state, "404.html", context! { page }, &request) {        Ok(html) => (StatusCode::NOT_FOUND, html).into_response(),        Err(_) => (StatusCode::NOT_FOUND, "404 Not Found").into_response(),    }}
added src/routes/home.rs
@@ -0,0 +1,53 @@use axum::{    extract::State,    http::{HeaderMap, Uri},    response::Html,    routing::get,    Router,};use minijinja::context;use rand::seq::SliceRandom;use crate::app::AppState;use crate::error::AppError;use crate::posts::{self, Post};use crate::render::{build_request, render_html};pub fn router() -> Router<AppState> {    Router::new().route("/", get(index))}async fn index(    State(state): State<AppState>,    uri: Uri,    headers: HeaderMap,) -> Result<Html<String>, AppError> {    let request = build_request(&uri, &headers);    let published = posts::published(&state.posts);    let latest_post = published.first().cloned();    let rest: Vec<Post> = match &latest_post {        Some(latest) => published            .iter()            .filter(|p| p.slug != latest.slug)            .cloned()            .collect(),        None => Vec::new(),    };    let mut rng = rand::thread_rng();    let mut shuffled = rest;    shuffled.shuffle(&mut rng);    let random_blog_posts: Vec<Post> = shuffled.into_iter().take(3).collect();    let page = context! {        title => "Isaac Bythewood's Blog",        slug => "home",        description => "Writing about webdev, infrastructure, security, and tooling by Isaac Bythewood, a Senior Solutions Architect in Elkin, NC.",    };    render_html(        &state,        "home.html",        context! { page, latest_post, random_blog_posts },        &request,    )}
added src/routes/mod.rs
@@ -0,0 +1,6 @@pub mod blog;pub mod errors;pub mod home;pub mod post;pub mod search;pub mod seo;
added src/routes/post.rs
@@ -0,0 +1,99 @@use axum::{    body::Body,    extract::{Path as AxumPath, State},    http::{header, HeaderMap, StatusCode, Uri},    response::{Html, IntoResponse, Response},    routing::get,    Router,};use minijinja::context;use crate::app::AppState;use crate::error::AppError;use crate::pdf;use crate::posts::{self, Post};use crate::render::{build_request, render_html, Crumb};pub fn router() -> Router<AppState> {    Router::new()        .route("/posts/{slug}/", get(show))        .route("/posts/{slug}/pdf/", get(pdf_route))        .route("/posts/{slug}/md/", get(markdown_route))}fn lookup(state: &AppState, slug: &str) -> Result<Post, AppError> {    let idx = state        .posts_by_slug        .get(slug)        .copied()        .ok_or_else(AppError::not_found)?;    let post = state.posts[idx].clone();    if !posts::is_published(&post) {        return Err(AppError::not_found());    }    Ok(post)}async fn show(    State(state): State<AppState>,    AxumPath(slug): AxumPath<String>,    uri: Uri,    headers: HeaderMap,) -> Result<Html<String>, AppError> {    let request = build_request(&uri, &headers);    let post = lookup(&state, &slug)?;    let published = posts::published(&state.posts);    let related_posts = posts::related(&post, &published, 3);    let breadcrumbs = vec![        Crumb {            title: "Home".into(),            url: "/".into(),        },        Crumb {            title: "Blog".into(),            url: "/blog/".into(),        },    ];    render_html(        &state,        "blog_post.html",        context! { page => &post, post => &post, related_posts, breadcrumbs },        &request,    )}async fn pdf_route(    State(state): State<AppState>,    AxumPath(slug): AxumPath<String>,) -> Result<Response, AppError> {    let post = lookup(&state, &slug)?;    let source = pdf::build_source(&post);    let renderer = state.pdf_renderer.clone();    let bytes = tokio::task::spawn_blocking(move || renderer.render(source))        .await        .map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?        .map_err(|e| AppError(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "application/pdf".parse().unwrap());    h.insert(        header::CONTENT_DISPOSITION,        format!("filename=\"{}.pdf\"", post.slug).parse().unwrap(),    );    Ok((StatusCode::OK, h, Body::from(bytes)).into_response())}async fn markdown_route(    State(state): State<AppState>,    AxumPath(slug): AxumPath<String>,) -> Result<Response, AppError> {    let post = lookup(&state, &slug)?;    let path = state.content_dir.join("posts").join(&post.filename);    let bytes = tokio::fs::read(&path).await?;    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "text/markdown".parse().unwrap());    h.insert(        header::CONTENT_DISPOSITION,        format!("filename=\"{}.md\"", post.slug).parse().unwrap(),    );    Ok((StatusCode::OK, h, Body::from(bytes)).into_response())}
added src/routes/search.rs
@@ -0,0 +1,101 @@use axum::{    extract::{Query, State},    http::{header, HeaderMap, StatusCode, Uri},    response::{Html, IntoResponse, Response},    routing::get,    Router,};use minijinja::context;use rand::seq::SliceRandom;use serde::Deserialize;use crate::app::AppState;use crate::error::AppError;use crate::posts::{self, Post};use crate::render::{build_request, render_html, Crumb};#[derive(Deserialize)]struct SearchQuery {    #[serde(default)]    q: String,}pub fn router() -> Router<AppState> {    Router::new()        .route("/search/", get(page))        .route("/search/live/", get(live))}fn matches(post: &Post, needle_lower: &str) -> bool {    post.title.to_lowercase().contains(needle_lower)        || post.description.to_lowercase().contains(needle_lower)        || post            .tags            .iter()            .any(|t| t.to_lowercase().contains(needle_lower))}async fn page(    State(state): State<AppState>,    Query(q): Query<SearchQuery>,    uri: Uri,    headers: HeaderMap,) -> Result<Html<String>, AppError> {    let request = build_request(&uri, &headers);    let published = posts::published(&state.posts);    let mut results: Vec<Post> = Vec::new();    let mut random_posts: Option<Vec<Post>> = None;    if !q.q.is_empty() {        let needle = q.q.to_lowercase();        for p in &published {            if matches(p, &needle) {                results.push(p.clone());            }        }    } else {        let mut rng = rand::thread_rng();        let mut shuffled = published.clone();        shuffled.shuffle(&mut rng);        random_posts = Some(shuffled.into_iter().take(6).collect());    }    let breadcrumbs = vec![Crumb {        title: "Home".into(),        url: "/".into(),    }];    let page = context! {        title => "Search",        slug => "search",        description => "Search posts on webdev, coding, security, and sysadmin.",    };    render_html(        &state,        "search.html",        context! { page, results, random_posts, q => &q.q, breadcrumbs },        &request,    )}async fn live(State(state): State<AppState>, Query(q): Query<SearchQuery>) -> Response {    let published = posts::published(&state.posts);    let mut out = Vec::new();    if !q.q.is_empty() {        let needle = q.q.to_lowercase();        for p in &published {            if matches(p, &needle) {                out.push(serde_json::json!({                    "title": p.title,                    "description": p.description,                    "url": format!("/posts/{}/", p.slug),                }));                if out.len() >= 5 {                    break;                }            }        }    }    // Match Flask's jsonify: trailing newline.    let body = serde_json::to_string(&out).unwrap_or_default() + "\n";    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "application/json".parse().unwrap());    (StatusCode::OK, h, body).into_response()}
added src/routes/seo.rs
@@ -0,0 +1,129 @@use axum::{    extract::{Path as AxumPath, State},    http::{header, HeaderMap, StatusCode, Uri},    response::{IntoResponse, Response},    routing::get,    Router,};use minijinja::{context, Value};use std::collections::HashMap;use crate::app::AppState;use crate::error::AppError;use crate::posts;use crate::render::build_request;pub fn router() -> Router<AppState> {    Router::new()        .route("/og/{slug_svg}", get(og_image))        .route("/favicon.ico", get(favicon))        .route("/robots.txt", get(robots))        .route("/sitemap.xml", get(sitemap))}fn wrap_title(title: &str, max_chars: usize, max_lines: usize) -> Vec<String> {    let mut lines: Vec<String> = Vec::new();    let mut current = String::new();    for word in title.split_whitespace() {        if !current.is_empty() && current.len() + word.len() + 1 > max_chars {            lines.push(current);            current = word.to_string();        } else if current.is_empty() {            current = word.to_string();        } else {            current.push(' ');            current.push_str(word);        }    }    if !current.is_empty() {        lines.push(current);    }    lines.into_iter().take(max_lines).collect()}async fn og_image(    State(state): State<AppState>,    AxumPath(slug_svg): AxumPath<String>,    uri: Uri,    headers: HeaderMap,) -> Result<Response, AppError> {    let request = build_request(&uri, &headers);    let slug = slug_svg        .strip_suffix(".svg")        .unwrap_or(&slug_svg)        .to_string();    let (title, tags) = match state.posts_by_slug.get(&slug).copied() {        Some(idx) => (state.posts[idx].title.clone(), state.posts[idx].tags.clone()),        None => ("Isaac Bythewood's Blog".to_string(), Vec::new()),    };    let title_lines = wrap_title(&title, 35, 3);    let tmpl = state.env.get_template("og.svg")?;    let body = tmpl.render(context! { title_lines, tags, request => &request })?;    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "image/svg+xml".parse().unwrap());    Ok((StatusCode::OK, h, body).into_response())}async fn favicon(State(state): State<AppState>) -> Result<Response, AppError> {    let tmpl = state.env.get_template("favicon.svg")?;    let body = tmpl.render(Value::UNDEFINED)?;    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "image/svg+xml".parse().unwrap());    Ok((StatusCode::OK, h, body).into_response())}async fn robots(    State(state): State<AppState>,    uri: Uri,    headers: HeaderMap,) -> Result<Response, AppError> {    let request = build_request(&uri, &headers);    let tmpl = state.env.get_template("robots.txt")?;    let body = tmpl.render(context! { request => &request })?;    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "text/plain".parse().unwrap());    Ok((StatusCode::OK, h, body).into_response())}async fn sitemap(    State(state): State<AppState>,    uri: Uri,    headers: HeaderMap,) -> Result<Response, AppError> {    let request = build_request(&uri, &headers);    let published = posts::published(&state.posts);    let tags = posts::collect_tags(&published);    let years = posts::collect_years(&published);    let mut tag_lastmod: HashMap<String, String> = HashMap::new();    let mut year_lastmod: HashMap<String, String> = HashMap::new();    for p in &published {        for t in &p.tags {            tag_lastmod                .entry(t.clone())                .and_modify(|cur| {                    if p.date > *cur {                        *cur = p.date.clone();                    }                })                .or_insert_with(|| p.date.clone());        }        if p.date.len() >= 4 {            let y = p.date[..4].to_string();            year_lastmod                .entry(y)                .and_modify(|cur| {                    if p.date > *cur {                        *cur = p.date.clone();                    }                })                .or_insert_with(|| p.date.clone());        }    }    let tmpl = state.env.get_template("sitemap.xml")?;    let body = tmpl.render(context! {        posts => published, tags, years, tag_lastmod, year_lastmod, request => &request,    })?;    let mut h = HeaderMap::new();    h.insert(header::CONTENT_TYPE, "application/xml".parse().unwrap());    Ok((StatusCode::OK, h, body).into_response())}