orchard
mirrorEvery site I host, in one repo, along with the Cloudflare Tunnel and Caddy that front them. It's all Go, Vite, and SQLite, and it runs on a desktop at home with nothing listening on an inbound port.
blogbuncaddycloudflare-tunneldockergogolanghomelabhtml-templatemonorepoself-hostedseosqlitestatic-sitetypstuptime-monitoringviteweb-analytics
1---
2title: The Rust ecosystem is unreasonably good
3slug: the-rust-ecosystem-is-unreasonably-good
4date: 2026-05-09
5publish_date: 2026-05-09
6tags: rust, webdev, performance
7description: A second pass on the Rust port of my blog, where I dropped the chromium PDF subprocess for embedded Typst. Some notes on axum, comrak, minijinja and Typst.
8cover_image: rust-ecosystem-cargo.webp
9---
10
11A few days ago I [rewrote this blog from Flask to Rust](/posts/rewriting-my-blog-in-rust/) and wrote up the benchmarks. What I didn't get to was that a day later I deleted `chrome-headless-shell` from the runtime image and replaced it with [Typst](https://typst.app) embedded as a library, which took most of a gigabyte off the Docker image without really changing the PDF route.
12
13So this is the follow up, a closer look at the four crates the blog actually runs on.
14
15## axum
16
17[axum](https://docs.rs/axum) is pretty small. A handler is an async function, its arguments are extractors, and its return type implements `IntoResponse`.
18
19```rust
20pub async fn show(
21 State(s): State<AppState>,
22 Path(slug): Path<String>,
23) -> impl IntoResponse {
24 // ...
25}
26```
27
28State is a clone-cheap struct, `Arc`'d once at startup. Routers compose with `merge`, so I keep one router file per feature (`routes/post.rs`, `routes/blog.rs`, `routes/search.rs`, `routes/seo.rs`) and stitch them together in `app.rs`:
29
30```rust
31Router::new()
32 .merge(routes::home::router())
33 .merge(routes::blog::router())
34 .merge(routes::post::router())
35 .merge(routes::search::router())
36 .merge(routes::seo::router())
37 .nest_service("/static", static_files)
38 .nest_service("/content/images", images)
39 .fallback(routes::errors::not_found)
40 .layer(axum_middleware::from_fn(log_requests))
41 .with_state(state)
42```
43
44Middleware is a tower layer, so request logging, cache headers on static files, and the 404 fallback all end up the same shape. The whole request logger is about twenty lines.
45
46```rust
47pub async fn log_requests(req: Request, next: Next) -> Response {
48 let method = req.method().clone();
49 let path = req.uri().path_and_query()
50 .map(|p| p.as_str().to_string())
51 .unwrap_or_default();
52 let start = Instant::now();
53 let response = next.run(req).await;
54 let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0;
55 let status = response.status().as_u16();
56 eprintln!("{method:<5} {status} {elapsed_ms:>7.2}ms {path}");
57 response
58}
59```
60
61I haven't pulled in `tracing` yet and I don't expect to.
62
63## comrak
64
65[comrak](https://docs.rs/comrak) parses CommonMark + GFM into an AST. Most markdown libraries either render straight to HTML or hand back an event stream, which makes any non-trivial customization annoying, but comrak gives you the whole tree to walk.
66
67I render every post twice from the same source. Once to HTML for `/posts/<slug>/`, once to Typst markup for `/posts/<slug>/pdf/`. Both walks read the same arena, so a typo in markdown fails both renders identically.
68
69For HTML, comrak's `create_formatter!` macro overrides individual node types and inherits the rest. I use it to wrap blocks in `div.block-*` classes the CSS hooks into, the same shape the Mistune custom renderer in the Flask version produced. The Typst pass is a hand-written walker, about 250 lines in `src/pdf.rs`.
70
71## minijinja
72
73I came in expecting to rewrite my templates and didn't have to. [minijinja](https://docs.rs/minijinja), by Armin Ronacher who also wrote Jinja2, is faithful enough that the entire `templates/` directory came over with two whitespace tweaks and a parens fix on a ternary.
74
75There are two things worth knowing:
76
77- Jinja2 escapes `/` in URLs to `/` and minijinja doesn't, which is
78technically more correct but it broke the OG image template and a couple of expected-string snapshots. About thirty lines of formatter to match Jinja2 sorted it out.
79- In debug builds, minijinja re-reads templates from disk on every render. Gate the loader on `cfg(debug_assertions)` and you get template hot-reload without restarting `cargo run`.
80
81## Typst, embedded
82
83[Typst](https://typst.app) is a typesetting system, and the part I care about is that the entire compiler is on crates.io as [typst](https://docs.rs/typst), [typst-pdf](https://docs.rs/typst-pdf) and [typst-kit](https://docs.rs/typst-kit) for font discovery. That means there's no binary to ship alongside the app and no subprocess to manage. You call `typst::compile(&world)` and get back a `PagedDocument`, then `typst_pdf::pdf(&doc, ...)` and get bytes.
84
85End to end:
86
87```rust
88let main = Source::new(
89 FileId::new(None, VirtualPath::new("/main.typ")),
90 source,
91);
92let world = PdfWorld { library, book, fonts, root, main };
93let document = typst::compile::<PagedDocument>(&world).output?;
94let bytes = typst_pdf::pdf(&document, &PdfOptions::default())?;
95```
96
97The type that took me a minute was `World`, which is the trait Typst uses to ask for the source of a file id, the bytes of an asset, a font by index, or today's date. You implement it once. Mine resolves Typst paths against the project root, so a snippet like this:
98
99```typst
100#image("/content/images/cover.webp")
101```
102
103reads `content/images/cover.webp` from the running binary's working directory, and it behaves the same on macOS, alpine and CI without any bind mounts or temp files.
104
105Fonts are found once at startup with `typst-kit`'s `FontSearcher`. The runtime alpine image installs `font-jetbrains-mono`, `ttf-dejavu`, and `ttf-liberation` so there's always a sans, mono, and fallback available.
106
107The size difference is what got my attention. The chromium runtime image was 1.16 GB and the Typst image is a few hundred MB, most of which is the font packages. The PDF route used to spawn a process and write a temp file and now it's just a function call. Every other PDF tool I've shipped, WeasyPrint and wkhtmltopdf and headless Chromium, added a binary to the runtime image and a process boundary at request time.
108
109## What I keep noticing
110
111Coming from [uv](https://docs.astral.sh/uv/) on the Python side, `cargo add` and `Cargo.lock` felt familiar, since uv already does the single tool and single lockfile thing for Python. What you give up is build time. A Docker image for this blog takes tens of seconds to build incrementally and a couple of minutes cold, where the Flask and uv version was a few seconds either way. What I get back is a binary that idles at 24 MB and serves [an order of magnitude more traffic](/posts/rewriting-my-blog-in-rust/), which seems worth it to me.
112
113Underneath axum, comrak, minijinja, and typst, the project pulls in [tokio](https://tokio.rs) for the runtime, [tower-http](https://docs.rs/tower-http) for middleware and static files, [serde](https://serde.rs) for frontmatter parsing, [chrono](https://docs.rs/chrono) for dates, and [anyhow](https://docs.rs/anyhow) for error handling. The whole `Cargo.toml` fits on a screen.
114
115Every time I've gone looking for something in Rust so far there's been a decent answer sitting there already, which is more than I expected going in.