@@ -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; }}
@@ -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)}
@@ -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() }}
@@ -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();}
@@ -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("&"), '<' => out.push_str("<"), '>' => out.push_str(">"), '"' => out.push_str("""), _ => 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)}
@@ -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}
@@ -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}
@@ -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}
@@ -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))}
@@ -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(), }}
@@ -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, )}
@@ -0,0 +1,6 @@pub mod blog;pub mod errors;pub mod home;pub mod post;pub mod search;pub mod seo;
@@ -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()}
@@ -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())}