@@ -1,4 +1,5 @@.env/target/dist/frontend/dist/frontend/node_modules
@@ -4,7 +4,7 @@ This file provides guidance to Claude Code (claude.ai/code) when working with co## What This IsA personal blog (blog.bythewood.me) built as a Rust axum app that renders markdown files. No database — blog posts are `.md` files in `content/posts/` with YAML frontmatter. Uses Typst (embedded as a library) for PDF export and comrak for markdown rendering.A personal blog (blog.bythewood.me) built as a Rust axum app that renders markdown files. No database: blog posts are `.md` files in `content/posts/` with YAML frontmatter. Uses Typst (embedded as a library) for PDF export and comrak for markdown rendering.This is a Rust port of the original Flask version (now deleted). Performance: ~30-50x lower memory, ~10-20x higher RPS, sub-millisecond per-request latency in release mode.
@@ -24,9 +24,9 @@ There are no tests or linters configured.**Frontend pipeline:** Vite (run from `frontend/`) builds `frontend/static_src/` → `dist/`. Entry point is `frontend/static_src/index.js` which imports SCSS and JS. Output filenames are content-hashed (`base-[hash].js`, `base-[hash].css`) and a Vite manifest (`dist/.vite/manifest.json`) is read at runtime so templates can resolve the hashed names. Uses Bootstrap 5, CodeMirror, and Monaspace Argon font.**Templates:** Jinja2 templates in `templates/` rendered by minijinja (Jinja2-faithful Rust engine by Armin Ronacher). `base.html` is the layout. Markdown post content is rendered through comrak with a custom renderer (`src/markdown.rs`) that wraps blocks in `div.block-*` classes — mirrors the original Mistune renderer pattern.**Templates:** Jinja2 templates in `templates/` rendered by minijinja (Jinja2-faithful Rust engine by Armin Ronacher). `base.html` is the layout. Markdown post content is rendered through comrak with a custom renderer (`src/markdown.rs`) that wraps blocks in `div.block-*` classes, mirroring the original Mistune renderer pattern.**PDF generation:** `src/pdf.rs` embeds the Typst compiler (`typst` + `typst-pdf`) as a library — no Chromium subprocess. At startup `PdfRenderer::new` runs `typst-kit`'s font searcher to discover system fonts (and bundle a few embedded ones via the `embed-fonts` feature). Per request: comrak walks the post's markdown AST to a Typst markup string (`markdown::render_typst`), `main.rs::build_typst_source` wraps it in a `#import "/templates/blog_post.typ": render` + `#render(title: ..., body: [...])` invocation, and `PdfRenderer::render` compiles that source to a `PagedDocument` then calls `typst_pdf::pdf` for the bytes. The `World` impl resolves absolute paths against the project root, so `image("/content/images/foo.webp")` reads `content/images/foo.webp` from disk.**PDF generation:** `src/pdf.rs` embeds the Typst compiler (`typst` + `typst-pdf`) as a library, no chromium subprocess. At startup `PdfRenderer::new` runs `typst-kit`'s font searcher to discover system fonts (and bundle a few embedded ones via the `embed-fonts` feature). Per request: comrak walks the post's markdown AST to a Typst markup string (`markdown::render_typst`), `main.rs::build_typst_source` wraps it in a `#import "/templates/blog_post.typ": render` + `#render(title: ..., body: [...])` invocation, and `PdfRenderer::render` compiles that source to a `PagedDocument` then calls `typst_pdf::pdf` for the bytes. The `World` impl resolves absolute paths against the project root, so `image("/content/images/foo.webp")` reads `content/images/foo.webp` from disk.**Manifest reload:** `templates.rs::build_env` re-reads `dist/.vite/manifest.json` per `vite_asset()` call in debug builds (so Vite watcher rebuilds are picked up immediately). Release builds load it once at startup. Gated on `cfg(debug_assertions)`.
@@ -64,10 +64,10 @@ The binary reads `templates/`, `dist/`, and `content/` from the current working## Key Routes- `/posts/<slug>/` — single post (old `/blog/<slug>/` 301-redirects here)- `/posts/<slug>/pdf/` — PDF export via embedded Typst (template at `templates/blog_post.typ`)- `/posts/<slug>/md/` — raw markdown download- `/blog/` — post index (also `/blog/tag/<tag>/` and `/blog/year/<year>/`)- `/search/?q=...` — server-rendered search page- `/search/live/?q=...` — JSON endpoint for live search- `/og/<slug>.svg` — dynamic OG image generation- `/posts/<slug>/`: single post (old `/blog/<slug>/` 301-redirects here)- `/posts/<slug>/pdf/`: PDF export via embedded Typst (template at `templates/blog_post.typ`)- `/posts/<slug>/md/`: raw markdown download- `/blog/`: post index (also `/blog/tag/<tag>/` and `/blog/year/<year>/`)- `/search/?q=...`: server-rendered search page- `/search/live/?q=...`: JSON endpoint for live search- `/og/<slug>.svg`: dynamic OG image generation
@@ -249,6 +249,7 @@ dependencies = [ "axum", "chrono", "comrak", "dotenvy", "mime_guess", "minijinja", "once_cell",
@@ -258,6 +259,8 @@ dependencies = [ "tokio", "tower", "tower-http", "tracing", "tracing-subscriber", "typst", "typst-kit", "typst-pdf",
@@ -679,6 +682,12 @@ dependencies = [ "syn",][[package]]name = "dotenvy"version = "0.15.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "1aaf95b3e5c8f23aa320147307562d361db0ae0d51242340f558153b4eb2439b"[[package]]name = "ecow"version = "0.2.6"
@@ -1603,6 +1612,12 @@ dependencies = [ "smallvec",][[package]]name = "lazy_static"version = "1.5.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "bbd2bcb4c963f2ddae06a2efc7e9f3591312473c50c6685e1f298068316e66fe"[[package]]name = "libc"version = "0.2.186"
@@ -1667,6 +1682,15 @@ version = "0.4.29"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "5e5032e24019045c762d3c0f28f5b6b8bbf38563a65908389bf7978758920897"[[package]]name = "matchers"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "d1525a2a28c7f4fa0fc98bb91ae755d1e2d1505079e05539e35bc876b5d65ae9"dependencies = [ "regex-automata",][[package]]name = "matchit"version = "0.8.4"
@@ -1767,6 +1791,15 @@ version = "0.1.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "13d2233c9842d08cfe13f9eac96e207ca6a2ea10b80259ebe8ad0268be27d2af"[[package]]name = "nu-ansi-term"version = "0.50.3"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7957b9740744892f114936ab4a57b3f487491bbeafaf8083688b16841a4240e5"dependencies = [ "windows-sys",][[package]]name = "num-bigint"version = "0.4.6"
@@ -2436,6 +2469,15 @@ dependencies = [ "unsafe-libyaml",][[package]]name = "sharded-slab"version = "0.1.7"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f40ca3c46823713e0d4209592e8d6e826aa57e928f09752619fc696c499637f6"dependencies = [ "lazy_static",][[package]]name = "shell-words"version = "1.1.1"
@@ -2699,6 +2741,15 @@ dependencies = [ "syn",][[package]]name = "thread_local"version = "1.1.9"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "f60246a4944f24f6e018aa17cdeffb7818b76356965d03b07d6a9886e8962185"dependencies = [ "cfg-if",][[package]]name = "time"version = "0.3.47"
@@ -2936,9 +2987,21 @@ checksum = "63e71662fa4b2a2c3a26f570f037eb95bb1f85397f3cd8076caed2f026a6d100"dependencies = [ "log", "pin-project-lite", "tracing-attributes", "tracing-core",][[package]]name = "tracing-attributes"version = "0.1.31"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "7490cfa5ec963746568740651ac6781f701c9c5ea257c58e057f3ba8cf69e8da"dependencies = [ "proc-macro2", "quote", "syn",][[package]]name = "tracing-core"version = "0.1.36"
@@ -2946,6 +3009,36 @@ source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "db97caf9d906fbde555dd62fa95ddba9eecfd14cb388e4f491a66d74cd5fb79a"dependencies = [ "once_cell", "valuable",][[package]]name = "tracing-log"version = "0.2.0"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ee855f1f400bd0e5c02d150ae5de3840039a3f54b025156404e34c23c03f47c3"dependencies = [ "log", "once_cell", "tracing-core",][[package]]name = "tracing-subscriber"version = "0.3.23"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "cb7f578e5945fb242538965c2d0b04418d38ec25c79d160cd279bf0731c8d319"dependencies = [ "matchers", "nu-ansi-term", "once_cell", "regex-automata", "sharded-slab", "smallvec", "thread_local", "tracing", "tracing-core", "tracing-log",][[package]]
@@ -3475,6 +3568,12 @@ version = "0.2.2"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821"[[package]]name = "valuable"version = "0.1.1"source = "registry+https://github.com/rust-lang/crates.io-index"checksum = "ba73ea9cf16a25df0c8caa16c51acb937d5712a8429db78a3ee29d5dcacd3a65"[[package]]name = "version_check"version = "0.9.5"
@@ -17,6 +17,9 @@ rand = "0.8"urlencoding = "2"once_cell = "1"anyhow = "1"dotenvy = "0.15"tracing = "0.1"tracing-subscriber = { version = "0.3", features = ["env-filter"] }mime_guess = "2"typst = "0.14"typst-pdf = "0.14"
@@ -1,22 +1,71 @@# blog.bythewood.mePersonal blog, served by a single Rust axum binary. Self-contained: posts,templates, Vite-built static assets, and the binary all live here.Personal blog, served by a single Rust axum binary. Self-contained: posts, templates, Vite-built static assets, and the binary all live here. Markdown content in `content/posts/`, no database.## Features- Markdown posts with YAML frontmatter (title, slug, date, publish_date, tags, description, cover_image)- Server-rendered tag and year archives- Server-rendered + live JSON search (`/search/` and `/search/live/`)- Per-post PDF export via embedded Typst (no chromium subprocess)- Per-post raw markdown download- Dynamic OG image generation per post- Single-binary deploy via `git push server master`## Stack| Concern | Crate / Tool ||-----------------|----------------------------|| Web framework | axum + tokio || Template engine | minijinja || Markdown | comrak || PDF | headless Chromium || Static assets | Vite + Bun || Concern | Crate / Tool ||-----------------|------------------------------|| Web framework | axum + tokio || Template engine | minijinja || Markdown | comrak || PDF | embedded Typst (no chromium) || Static assets | Vite + Bun |Crate selection rationale: axum is the most-pulled async framework, minijinja is the only Rust engine that accepts upstream Jinja2 syntax, comrak's `partial-formatter` story is the closest match to Mistune's renderer-override pattern.## System dependenciesLocal dev needs all of these on your `PATH`:| Tool | Why | Version ||---|---|---|| `rustc` / `cargo` | Build the axum binary | 2021 edition, current stable is fine (1.70+) || `bun` | Frontend deps + Vite | 1.x || `make` | Run the dev/build targets | any |The Docker build (see `Dockerfile`) reproduces this on `rust:alpine` + `alpine:3.23`. If you only care about Docker, you do not need any of the above on the host. Runtime image installs `font-jetbrains-mono`, `ttf-dejavu`, `ttf-liberation`, and `fontconfig` so the embedded Typst renderer can find body sans, mono, and fallback fonts.## Quickstart```shcp samplefiles/env.sample .envmake````make` (alias `make run`) installs frontend deps if needed, then runs Vite watch and `cargo run` concurrently on port 8000. Visit http://localhost:8000.## ConfigurationCrate selection rationale (short version): axum is the most-pulled asyncframework, minijinja is the only Rust engine that accepts upstream Jinja2syntax, comrak's `partial-formatter` story is the closest match toMistune's renderer-override pattern.All config comes from `.env` (loaded via `dotenvy`):| Variable | Required | Purpose ||---|---|---|| `PORT` | no (default `8000`) | HTTP listen port || `BLOG_ROOT` | no | Override the project root (where `templates/`, `dist/`, and `content/` are read from) |## Make targets| Target | What it does ||---|---|| `make run` (default) | Vite watch + `cargo run` on port 8000 || `make build` | Vite assets + release binary (`target/release/blog`) || `make start` | Run the release binary (after `make build`) || `make bench` | `oha` load test sweep across the main routes. Compares against a Flask server on port 8002 (the original `blog.bythewood.me`) if running || `make push` | `git push` to every configured remote || `make clean` | Remove `target/`, `dist/`, and `frontend/node_modules/` |There are no tests or linters configured.## Layout
@@ -25,52 +74,37 @@ blog.bythewood.me/├── Cargo.toml, Cargo.lock # rust deps├── Makefile, README.md, bench/ # top-level├── src/ # rust source│ ├── main.rs # axum routes│ ├── main.rs # tiny entry: server boot│ ├── app.rs # AppState + Router assembly│ ├── render.rs # render_html helper│ ├── middleware.rs # request log│ ├── routes/ # blog, post, search, seo, errors│ ├── posts.rs # frontmatter + post loading│ ├── markdown.rs # comrak custom renderer│ ├── templates.rs # minijinja env, url_for, vite_asset, Jinja2-compat formatter│ └── pdf.rs # chrome-headless-shell subprocess├── templates/ # jinja2 source (minijinja-compatible)│ └── pdf.rs # embedded Typst renderer├── templates/ # jinja2 source (minijinja-compatible) + blog_post.typ├── content/ # markdown source│ ├── posts/ # markdown posts with YAML frontmatter│ └── images/ # served at /content/images/├── frontend/ # JS pipeline│ ├── package.json, bun.lock, vite.config.js│ ├── static_src/ # SCSS, JS, font imports│ └── node_modules/ # gitignored├── dist/ # vite build output (gitignored, rebuilt by Docker, served at /static/)└── target/ # cargo build output (gitignored)```The binary reads `templates/`, `dist/`, and `content/` from the currentworking directory. Override with `BLOG_ROOT=<path>`.## RunningDev (Vite watch + cargo run, port 8000):```shmake run├── frontend/ # JS pipeline (package.json, vite.config.js, static_src/)├── dist/ # vite build output (gitignored, served at /static/)├── target/ # cargo build output (gitignored)└── samplefiles/ # Caddyfile.sample, env.sample, post-receive.sample```Production build (Vite assets + release binary):The binary reads `templates/`, `dist/`, and `content/` from the current working directory. Override with `BLOG_ROOT=<path>`.```shmake buildmake start```## DeployOverride port: `PORT=8001 make start`.Production runs on Docker. The standard flow is `git push server master` to a remote whose post-receive hook runs `docker compose up --build --detach`. Sample files in `samplefiles/`:## Bench- `Caddyfile.sample`: reverse proxy with TLS- `env.sample`: the `.env` shown above- `post-receive.sample`: the git hook`bench/run.sh` runs an `oha` load test sweep across the main routes. Bydefault it compares against a Flask server on port 8002 (the original`blog.bythewood.me`).See [CLAUDE.md](CLAUDE.md) for the full architecture rundown, route table, and PDF pipeline details.## Caveats- **PDF cold start.** Every PDF request spawns a fresh chromium process (~500 ms first hit). A persistent chromium with a remote-debugging port would close this gap but adds significant code.- **Posts loaded once at startup.** Add a post, restart the process.
modified
content/posts/cool-uris-dont-change-unless-an-ai-rewrites-your-blog.md
@@ -8,13 +8,13 @@ description: A short post-mortem on letting an AI port my blog from Django to Flcover_image: cool-uris-w3c.webp---I let an AI port this blog from Django to Flask. The Flask app came out fine — single file, `app.py`, markdown rendered through Mistune, a few hundred lines, the whole rewrite took an afternoon. I was pleased with myself for about a week.I let an AI port this blog from Django to Flask. The Flask app came out fine: single file, `app.py`, markdown rendered through Mistune, a few hundred lines, the whole rewrite took an afternoon. I was pleased with myself for about a week.Then I noticed the 404s in [analytics](https://analytics.bythewood.me/).The Django version served posts at `/posts/<slug>/`. The Flask version served them at `/blog/<slug>/`. Nobody asked for that. The AI just decided. Probably because the route handler was called `blog_post` and the directory below it was `templates/`, so `/blog/` felt right. Probably because someone, somewhere in the training data, namespaced their Flask blog routes under `/blog/`. I don't really care. The point is it changed every URL on the site without flagging it, and I didn't notice on review because I was looking at code, not links.Tim Berners-Lee wrote [Cool URIs don't change](https://www.w3.org/Provider/Style/URI) in 1998. It is not a long document. The TL;DR is the title. URLs that you publish — to Hacker News, to Reddit, to search engines, to your own RSS feed — are a contract with everyone who linked them. Breaking that contract because your route function got renamed is a small act of vandalism against the open web.Tim Berners-Lee wrote [Cool URIs don't change](https://www.w3.org/Provider/Style/URI) in 1998. It is not a long document. The TL;DR is the title. URLs that you publish (to Hacker News, to Reddit, to search engines, to your own RSS feed) are a contract with everyone who linked them. Breaking that contract because your route function got renamed is a small act of vandalism against the open web.The fix was a one-liner once I noticed: move the post routes back to `/posts/<slug>/` and 301 the old `/blog/<slug>/` URLs. Five minutes. The cost was a week of broken inbound links and however many readers bounced off a 404 in the meantime.
modified
content/posts/optimizing-sqlite-for-django-in-production.md
@@ -10,11 +10,11 @@ cover_image: sqlite-django-logs.webpSQLite runs most of my smaller Django projects in production. It's fast, it's one file to back up, and it takes an entire service out of my stack. The problem is the default Django config is tuned for development, not production. The first time a background worker writes while a request reads you'll start seeing `database is locked` in your logs. A few PRAGMAs and one Django option fix most of it.> **Update — 2026-04-26.** A week after publishing this, my SQLite-backed status monitor corrupted with `database disk image is malformed` and ran broken for several days before I noticed. The recipe below is still what I run, with one line removed: `PRAGMA mmap_size=134217728`.> **Update, 2026-04-26.** A week after publishing this, my SQLite-backed status monitor corrupted with `database disk image is malformed` and ran broken for several days before I noticed. The recipe below is still what I run, with one line removed: `PRAGMA mmap_size=134217728`.>> Here's what bit me. SQLite has a WAL-reset race (introduced in 3.7.0, fixed in **3.51.3** released 2026-03-13) that triggers when two or more connections on the same file write or checkpoint simultaneously — exactly what you have with multi-worker Gunicorn, or a worker plus a background scheduler. The race itself is rare and usually self-corrects on the next checkpoint. mmap is what turns a transient race into a structurally broken file. [SQLite's mmap docs](https://www.sqlite.org/mmap.html) warn that it "is more sensitive to bugs in the application code or undefined behavior" and "if a corruption happens, mmap can spread it more widely." The integrity check on my dead database came back full of *child page depth differs* and *2nd reference to page X* errors — textbook mmap-spread signatures, not vanilla WAL-race ones.> Here's what bit me. SQLite has a WAL-reset race (introduced in 3.7.0, fixed in **3.51.3** released 2026-03-13) that triggers when two or more connections on the same file write or checkpoint simultaneously, exactly what you have with multi-worker Gunicorn, or a worker plus a background scheduler. The race itself is rare and usually self-corrects on the next checkpoint. mmap is what turns a transient race into a structurally broken file. [SQLite's mmap docs](https://www.sqlite.org/mmap.html) warn that it "is more sensitive to bugs in the application code or undefined behavior" and "if a corruption happens, mmap can spread it more widely." The integrity check on my dead database came back full of *child page depth differs* and *2nd reference to page X* errors, textbook mmap-spread signatures, not vanilla WAL-race ones.>> So: if you have multiple processes writing to the same SQLite file and your base image still has SQLite < 3.51.3 (Alpine 3.21 ships 3.48, 3.22 ships 3.49 as of this writing), drop the `PRAGMA mmap_size` line until you can upgrade. The rest of the config stands. Single-worker Gunicorn with no background processes is unaffected. Recovery, for the curious, was `sqlite3 .recover` into a fresh file — kept all but five rows out of eight thousand.> So: if you have multiple processes writing to the same SQLite file and your base image still has SQLite < 3.51.3 (Alpine 3.21 ships 3.48, 3.22 ships 3.49 as of this writing), drop the `PRAGMA mmap_size` line until you can upgrade. The rest of the config stands. Single-worker Gunicorn with no background processes is unaffected. Recovery, for the curious, was `sqlite3 .recover` into a fresh file. Kept all but five rows out of eight thousand.Here's the full `DATABASES` block I use. Requires Django 5.1 or newer.
modified
content/posts/using-vite-with-django-in-2026.md
@@ -88,7 +88,7 @@ MIDDLEWARE = []````STATICFILES_DIRS` points at Vite's output directory so `collectstatic` and the dev server pick it up. `CompressedManifestStaticFilesStorage` hashes every file during `collectstatic` and rewrites references in CSS to point at the hashed names. Don't append `?v=...` query strings to `{% static %}` — WhiteNoise expects to control the URLs and the manifest will get out of sync.`STATICFILES_DIRS` points at Vite's output directory so `collectstatic` and the dev server pick it up. `CompressedManifestStaticFilesStorage` hashes every file during `collectstatic` and rewrites references in CSS to point at the hashed names. Don't append `?v=...` query strings to `{% static %}`. WhiteNoise expects to control the URLs and the manifest will get out of sync.Templates stay boring:
@@ -11,16 +11,26 @@ mod templates;use std::net::SocketAddr;#[tokio::main]async fn main() { let state = app::AppState::from_env(); let router = app::router(state);async fn main() -> anyhow::Result<()> { dotenvy::dotenv().ok(); tracing_subscriber::fmt() .with_env_filter( tracing_subscriber::EnvFilter::try_from_default_env() .unwrap_or_else(|_| tracing_subscriber::EnvFilter::new("info")), ) .init(); let port: u16 = std::env::var("PORT") .ok() .and_then(|v| v.parse().ok()) .unwrap_or(8000); let state = app::AppState::from_env(); let router = app::router(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, router).await.unwrap(); let listener = tokio::net::TcpListener::bind(addr).await?; tracing::info!("blog listening on http://{addr}"); axum::serve(listener, router).await?; Ok(())}