This blog has been live for two weeks. During that time, the build system was rewritten three times — from a few dozen lines of Node.js script to an independently released Rust compiler called kiln. The blog repository itself no longer contains any build tool code.

I want to write down these design decisions. Not to document "what was done" but to explain the reasoning behind each choice — why modules are divided this way, why configuration is organized like this, why CI is designed this way. If you maintain your own site or are interested in build tool design, this might be useful.

System Overview

Two independent Git repositories, connected by a pre-compiled binary and a version file.

graph TB
    subgraph kiln["rhczz/kiln compiler repo"]
        src["Rust source<br/>~4000 lines"]
        ci1["CI: fmt + clippy + test"]
        release["GitHub Release<br/>5 platform pre-compiled binaries"]
        src --> ci1
        ci1 --> release
    end

    subgraph content_repo["Blog content repo"]
        content["Markdown posts"]
        config["site.config.toml"]
        templates["Tera templates + CSS"]
        version[".compiler-version<br/>one line: v1.0.0"]
        build["npm run build"]
        deploy["dist/ → Cloudflare Pages"]
        content --> build
        config --> build
        templates --> build
        version --> build
        build --> deploy
    end

    release -->|"download platform binary"| build

The compiler iterates and releases independently; the content project writes posts and deploys independently. The two share no Git history and no CI pipeline. The compiler's only interface to the content project: kiln build --config site.config.toml --output dist.

The Reasoning Behind Several Design Choices

No Premature Abstraction

Collection types (posts / pages) were initially hardcoded. Only when both collections stabilized and their differences were clear — one has date sorting and RSS, the other doesn't — were they abstracted into configurable Collections.

Incremental building has three modes: Full, Content (re-render), and Public (re-copy). Not planned — the file watcher during serve genuinely triggered three kinds of rebuilds: template changes need full rebuild, content changes only need re-rendering, static file changes only need copying.

If a parameter combination must be passed through four functions, wrap it in a struct. Not for "clean architecture" — because manual parameter passing became annoying enough to warrant elimination.

Every abstraction was driven by friction. No predictive design. No "might need this later."

The most subtle part here is judging when to abstract and when not to. The rule is simple: with only one use case, hardcoding is correct. If an abstraction has only one implementation, it isn't an abstraction — it's an unnecessary layer of indirection. Wait for the second use case to actually arrive, then abstract — at that point you know where the generalization boundaries are and won't guess wrong.

The Compiler Doesn't Know About Your Config

Site customization — navigation, homepage image, footer links — the compiler doesn't parse any of it. Any field the compiler hasn't defined a schema for passes through as opaque data directly to templates.

The experience this creates: site authors add any field in TOML and use it directly in templates — no compiler modification, no PR, no waiting for a release. The compiler plays "I carry your stuff to the templates" rather than gatekeeping "these are the things you're allowed to configure."

graph LR
    toml["site.config.toml"]
    compiler["Compiler parses"]
    extra["Pass-through fields<br/>intro, email, nav,<br/>footer_links, home_image"]

    toml --> compiler
    compiler -->|"parsed as standard fields"| site["site.title<br/>site.base_url<br/>site.author.name<br/>..."]
    compiler -->|"not parsed, passed through"| extra
    extra -->|"{{ theme.intro }}"| template["Templates"]
    site -->|"{{ site.title }}"| template

Two layers of variables: site.* is what the compiler guarantees to exist, theme.* is what the site adds itself. Glance at a template variable and immediately know where it comes from.

Templates: Built-in Defaults, Override as Needed

The compiler binary embeds four complete templates. An empty directory — just three things: config, Markdown, CSS — running build produces a complete site. Simultaneously, placing same-named files under site/templates/ partially overrides them. Change one without touching the other three.

graph TB
    subgraph binary["kiln binary"]
        l1["layout.html embedded"]
        h1["home.html embedded"]
        p1["post.html embedded"]
        pg1["page.html embedded"]
    end

    subgraph external["site/templates/ (optional)"]
        l2["layout.html"]
        h2["home.html"]
    end

    l2 -.->|"overrides"| l1
    h2 -.->|"overrides"| h1
    p1 -.->|"uses embedded default"| output1["post rendering"]
    pg1 -.->|"uses embedded default"| output2["page rendering"]

Get it running first, modify what you don't like. No need to write full-site HTML from scratch.

CSS Hash, End to End

CSS file changes → hash necessarily changes → filename changes → browser re-downloads. The entire process has zero manual steps. No version bumping, no filename editing, no remembering to update cache strategy. The build tool does what humans would forget.

The key: the hash is content-based, not time-based or random. Same content across different builds in the same deploy produces the same hash? Fine — same filename, same content guaranteed. Different content produces different hash? Exactly what's wanted — browser sees a new filename and downloads.

graph LR
    css["styles.css"] --> hash["SHA-256"]
    hash --> hex["first 12 hex chars"]
    hex --> file["dist/assets/styles.a1b2c3.css"]
    hex --> link["all HTML &lt;link&gt; auto-references"]
    hex --> f04["404.html style reference auto-rewrite"]
    hex --> hdr["_headers appends immutable rule"]

Why Config Validation Splits Into Two Layers

Not "more proper" — the two layers depend on different information and fail at different times.

graph TD
    config["site.config.toml"] --> v1{"Layer 1 validation<br/>(no filesystem needed)"}
    v1 -->|"format check"| resolve["Path resolution<br/>relative → absolute"]
    resolve --> v2{"Layer 2 validation<br/>(filesystem needed)"}
    v2 -->|"files exist check"| ok["Pass"]
    v1 -->|"fail: config error"| err1["Immediate error"]
    v2 -->|"fail: environment issue"| err2["Immediate error<br/>with path and field name"]

Layer 1 catches "you wrote it wrong." Layer 2 catches "the environment has a problem." Mixing them makes error messages unreadable.

Build Cache Isn't a Simple Toggle

The first version's cache logic was a single if — file unchanged, skip. When serve added incremental rebuilds, caching couldn't stay single-layer.

graph TB
    subgraph cache["BuildCache"]
        ci["content_items<br/>parsed Markdown"]
        rp["rendered_pages<br/>rendered HTML"]
        cp["copied_public<br/>copied static files"]
        po["page_outputs<br/>current output list"]
    end

    subgraph rebuild["During Full rebuild"]
        clear1["Don't clear ✗"] --> ci
        clear2["Clear ✓"] --> rp
        clear3["Clear ✓"] --> cp
    end

Full rebuild clears render cache and static file cache, preserves content cache. Simple reasoning: templates and styles changed, HTML must be re-rendered, but Markdown parsing results haven't changed — no need to re-read files, split frontmatter, run the markdown parser. Clear what "might be affected by external dependencies," keep what "definitely hasn't changed."

How Serve Determines Rebuild Scope

After the file watcher detects changes, it classifies by scope. Not a complex state machine — just three rules.

graph TD
    event["File change"] --> classify{"Which files changed?"}
    classify -->|"templates, styles, config"| full["Full<br/>complete rebuild"]
    classify -->|"content/*.md"| content["Content<br/>reload + re-render"]
    classify -->|"public/**"| public["Public<br/>re-copy + rewrite 404"]
    full --> merge["Merge rule: Full absorbs all<br/>Public upgrades to Content"]
    content --> merge
    public --> merge

Three states, no over-engineering.

The Full Build Sequence

The sequence isn't arbitrary — each step depends on the previous step's output.

graph TD
    s1["1. Load config<br/>TOML → defaults → two-layer validation → path resolution"] --> s2["2. Load templates and styles<br/>Engine init + CSS hash<br/>once, reused for all pages"]
    s2 --> s3["3. Copy static assets<br/>public/ → dist/<br/>before rendering, pages can override static copies"]
    s3 --> s4["4. Load content<br/>glob → parse frontmatter → render Markdown<br/>drafts filtered here"]
    s4 --> s5["5. Render pages<br/>build context per post/page → template → layout wrap → write"]
    s5 --> s6["6. Render homepage<br/>featured + archive → index.html"]
    s6 --> s7["7. Write hashed CSS<br/>assets/styles.{hash}.css<br/>_headers appends immutable"]
    s7 --> s8["8. Rewrite 404 style reference<br/>404 is hand-written HTML, bypasses template engine"]
    s8 --> s9["9. Generate aggregate files<br/>RSS + Sitemap + robots.txt<br/>must be after all pages produced"]
    s9 --> s10["10. Clean up remnants<br/>diff output lists, remove old files, clean empty dirs"]

CI Pipeline Design

CI evolved alongside the compiler architecture through several versions. Initially: cargo build --release directly in the repo — slow, two-minute builds. After splitting the compiler into a separate repo: npm install with postinstall downloading the binary via gh CLI — no compilation, but Node deps and binary download still took 30 seconds. Then npm install was eliminated entirely: the build only needs the compiler binary, PRs don't need Node.js — cache the binary, pull wrangler on-demand with npx for deploy. Key insight: npm install in CI is habit, not necessity.

graph TD
    push["Git Push"] --> cache{"Cache hit?<br/>key = hash(.compiler-version)"}
    cache -->|"yes"| build["kiln build<br/>seconds"]
    cache -->|"no"| download["curl download pre-compiled binary"]
    download --> save["store in cache"] --> build
    build --> smoke["smoke-check"]
    smoke --> main{"push to main?"}
    main -->|"yes"| deploy["npx wrangler pages deploy"]
    main -->|"no (PR)"| done["Done"]
    deploy --> done

What Never Changed

Through three build system versions, the following remained untouched: the Markdown + YAML frontmatter content format, the /posts/<slug>/ and /<page-slug>/ URL structure, the template collaboration pattern of layout wrapping page/post/home, the same styles.css. The migration boundary was always the build tool itself, never the content. Readers perceived no difference; published links never broke.

There's a deeper implication: build tools are means, content is the end. Don't let changes in means retroactively change the end. If switching generators forces URL format changes, frontmatter field changes, or template logic changes — the generator's boundaries are drawn wrong.

The Costs

This architecture isn't cost-free.

Building your own compiler means maintaining it. The codebase is small — kiln is about 4,000 lines of Rust — but bugs are yours to fix, platform compatibility is yours to handle. With Hugo or Zola, others have already hit and fixed these.

The feature boundary is extremely narrow. No admin panel, no online editor, no comment system, no search. Not "not yet implemented" — intentionally not done. Every additional feature makes configuration more complex and builds slower.

The template engine has a learning curve. Tera is natural enough for anyone who's used Jinja2, but {{ }}, {% %}, | safe aren't intuitive without prior exposure.

I'm completely fine with these costs — maintaining tools I wrote is par for the course. But if you're considering a similar choice for your team, weigh these honestly.

About Architecture Evolution

The system was rewritten three times. V1: a 200-line Node.js script. V2: a Rust compiler (but compiler source and blog content still in the same repo). V3: compiler split out to independent release.

Each version wasn't "fixing what the previous one did wrong." Each was correct at its point in time — V1 got the blog live in half an hour, V2 separated build logic cleanly, V3 addressed repo coupling and compilation wait times. The friction that emerged later wasn't from bad initial design — it was from the system's scale and usage patterns changing. Architecture adapting accordingly is healthy.

This evolution confirmed one thing: don't abstract before the first use case appears. Wait for friction, then solve it. Every design decision — how to divide modules, how many cache layers, how config passes through, how many build modes — traces back to a specific pain point. None exist because "good architecture should be this way."

If an abstraction has only one use case, it's premature. Wait for the second use case — then you know where the generalization boundaries actually are and won't guess wrong.

kiln's current state isn't the endpoint either. When the next friction accumulates, it'll keep evolving.