You can skip this one too.

The site you're reading is built by a static site generator I wrote in Rust. The previous one was Haskell, and before that MoonBit, and before that SvelteKit, and before that Gatsby, and before that jQuery. This is version six. The Haskell one lasted a day.

That is the honest reason for this post: not that Haskell was wrong, but that I am not going to keep writing Haskell, and a generator nobody wants to edit is a generator that rots. What follows is what the port actually involved, which turned out to be less than I expected.

Almost nothing about the site changed

The generator is a Cargo project with five library modules and one binary. It reads Markdown from pages/, turns each file into a complete HTML page, and writes the result to dist/. After that, two build steps add syntax highlighting and compile the CSS. There is no database and no server-side rendering.

That is the same paragraph I wrote about the Haskell version, with the nouns changed, and that is the point. The inputs are identical:

text
config.toml          site title and base URL
layout/default.html  shared HTML shell
pages/*.md           one file per post
dist/                generated output

Three things carried over without an edit: the TOML front matter, layout/default.html, and the Shiki highlighting script. The posts moved from portfolios/v5/pages to portfolios/v6/pages unchanged, the same way they moved from v4 to v5.

The layout survived because of Mustache

This was the decision that saved the most work, and it nearly went the other way.

The obvious Rust templating crates are Jinja-flavored — minijinja, Tera, Askama. All of them escape by default and offer a filter to opt out. None of them have Mustache's triple-brace form:

html
<main class="max-w-[42rem] mx-auto px-5 py-12 prose">
  {{{content}}}
</main>

{{{content}}} means "this value is already HTML, insert it as-is". Picking a Jinja engine would have meant rewriting the layout and every future layout in a second syntax, for no gain. So the generator uses ramhorns, which is a Mustache implementation, and layout/default.html is byte-for-byte the file v5 rendered.

The context it receives is a plain struct:

rust
#[derive(Renderable)]
struct TemplateContext<'a> {
    content: &'a str,
    site_title: &'a str,
    base_url: &'a str,
}

A post becomes structured content

Each post starts with TOML front matter followed by Markdown:

toml
---
title = "Post title"
date = "2026-02-15"
---

The post starts here.

content.rs separates those two parts. It removes an optional byte-order mark, recognizes either Unix or Windows line endings on the opening fence, and hands the TOML block to toml. The result is a Content value holding a map of metadata and the untouched Markdown body:

rust
pub struct Content {
    pub front_matter: BTreeMap<String, String>,
    pub body: String,
}

A file without a front-matter fence is still accepted as a body-only page, although it will have no title or date in the index. Site-wide configuration follows the same shape, decoded straight into a struct by serde:

rust
#[derive(Deserialize)]
pub struct Config {
    #[serde(rename = "baseURL")]
    pub base_url: String,
    pub title: String,
}

Bad TOML, invalid UTF-8, a missing pages/ directory, or a rendering error stops the build with the path and an explanation. All file reads and writes are explicitly UTF-8: the generator reads bytes, decodes them, and reports the filename if decoding fails. That keeps the build independent of the machine's locale, which matters in minimal build containers where the default locale is C.

CommonMark turns the body into HTML

markdown.rs passes the body to pulldown-cmark, which is CommonMark-compliant, as Haskell's commonmark was:

rust
let mut options = Options::empty();
options.insert(Options::ENABLE_FOOTNOTES);
options.insert(Options::ENABLE_STRIKETHROUGH);

Footnotes and strikethrough carried over. Fancy lists — the (a) and (i) markers commonmark-extensions supports — did not, because pulldown-cmark has no equivalent. No post has ever used them, so this cost nothing, but it is the one place where the two renderers genuinely differ in capability rather than in output.

The highlighter did not move to Rust, and that surprised me

The tidy version of this rewrite collapses the toolchain: replace Shiki with syntect, drop Bun, ship one binary. I tried that first. It doesn't work, for a boring and specific reason:

text
moonbit    ** NOT SUPPORTED **
toml       ** NOT SUPPORTED **
haskell    Haskell
rust       Rust

syntect's default set is 75 Sublime syntaxes, and MoonBit isn't one of them. MoonBit is still the most common language in these posts by a wide margin — I wrote a whole generator in it. MoonBit ships a TextMate grammar for VS Code, which is what Shiki consumes and not what syntect's loader wants, so this is a vendoring project rather than a swap.

So scripts/highlight-codeblocks.mjs is unchanged and Bun stays in the build. This is the part of the rewrite that produced no simplification at all.

It carried over untouched for a happier reason, though. v4's hand-rolled renderer emitted <pre class="code-block" data-language="x">, and porting the highlighter to v5 meant changing that regex. pulldown-cmark emits the same standard markup commonmark did:

html
<pre><code class="language-rust">

The script's regex already matched it. CI now asserts that it still does — if a future markdown crate changes its code-fence markup, the build fails instead of quietly shipping a page of unhighlighted code.

The lint file became a lint section

v5 had a 98-line .hlint.yaml, and most of it was not style rules. It banned head, error, fromJust and undefined; it banned Data.Text.IO because it is locale-dependent; it restricted System.Directory to Main so the library stayed pure.

Those invariants matter more than the language they were written for, so they moved into Cargo.toml:

toml
[lints.rust]
unsafe_code = "forbid"
missing_docs = "warn"

[lints.clippy]
unwrap_used = "deny"
expect_used = "deny"
panic = "deny"
indexing_slicing = "deny"

Same intent, a third of the size: the library reports failure with Result<_, String>, the binary owns every side effect, and a malformed post cannot panic the build. One rule genuinely didn't survive. hlint could say "System.Directory may only be imported by Main", and clippy has no equivalent, so "effects stay in main.rs" is now a convention that a person has to enforce rather than a rule the build does.

Two things got stricter. cargo fmt --check runs in CI, which v5 had no equivalent for — its sources were hand-aligned and no Haskell formatter preserves column alignment. And there are now tests, seventeen of them, which v5 had exactly zero of.

The build is still three commands

bash
cargo run --release --bin ssg
bun run highlight:code
bun run build:tailwind

The binary renders the HTML first. Shiki then scans every file in dist/ and replaces the plain code blocks. Finally Tailwind reads the classes used by the layout and the generated pages and writes dist/style.css.

What did get simpler is everything around those three commands. v5's mise.toml needed explicit plugin declarations to resolve GHC at all, because mise tried conda first and conda's GHC catalog doesn't carry every pinned version, plus a note that cabal must stay above 3.10 or it can't validate Hackage's TUF root. v6 has a four-line rust-toolchain.toml that rustup honors automatically, in CI and Docker and locally, with no setup step. mise.toml is now only the publishing tool.

A cold release build went from minutes to about twelve seconds, which matters less than it sounds — but it does mean a CI cache miss is no longer an event.

The finished site is still just index.html, one HTML file per post, and a stylesheet. The generator has already done all of its work by the time a browser requests the page. That hasn't changed since v4, and it is the only part of any of this that was ever load-bearing.