Why I built Heine

Why I built Heine

· Updated · ⁨996⁩ words · ⁨5⁩ min read

I built Heine because I wanted a static site generator that would catch the mistakes I had already learned to look for.

I first used Hugo and later moved to Zola, which still builds pureandroid.com. Both made it easy to turn content into pages.

Over time, I found a recurring weakness: relationships that the generator could resolve from the site, but did not consistently check before publication.

The clearest example occurred while I was using Zola. On one site, an authored page route collided with a generated taxonomy route. At the time, both looked valid in the source and one generated file overwrote the other without a build diagnostic.

The issue was later fixed, but the experience made one design rule non-negotiable for me: resolve and claim every output path before writing any of them.

Other failures were less visible but equally frustrating. Links could name pages or assets that no longer existed. Localized interface text needed a defined fallback. A development server could differ from the production host. Markdown could rewrite TeX escapes before a math renderer saw them.

None was a dealbreaker. Together, they made me want a different tool.

Heine is my attempt to make those relationships part of the build.

A site has more structure than a directory of files

A static site starts with files, but it quickly becomes a network of claims:

  • this page uses that template
  • this checked Markdown link names that page
  • this checked image source names a copied asset
  • these pages form one ordered collection
  • this translation belongs to that translation group
  • this route is owned by this generated page

Heine resolves those claims before it publishes output. When a claim is wrong, the build fails at the source that made it.

For example, a template can look up a page with page():

{% set target = page(id="my-post") %}
<a href="{{ target.url }}">{{ target.title }}</a>

If my-post is an excluded draft, a report for that call begins like this:

heine::template::invalid

  × render error in template "page.tera": page "my-post" for locale "en" is
  │ excluded because content/en/my-post.page declares `draft = true`; ordinary
  │ builds cannot link to drafts
   ╭─[templates/page.tera:1:17]
 1 │ {% set target = page(id="my-post") %}
   ·                 ────────┬─────────
   ·                         ╰── error here
   ╰────

The same principle applies to copied assets, collections, pagination, taxonomies, locale-aware routes, generated feeds and search indexes, and output paths. Two things cannot silently claim the same output file.

The point is not to turn writing into a negotiation with a compiler. The point is to move mistakes from readers and deployment logs back to the build, where the relevant source is still easy to find.

One model, several uses

A blog index, a tag listing, an Atom feed, and a search index often describe the same set of posts in different forms. Heine treats that set as a checked collection rather than asking each feature to discover its own pages.

# heine.toml
[collections.posts]
order = "id-asc"
# content/en/blog/first.page
template = "post.tera"
title = "First post"
collections = ["posts"]

A collection does not create an index page on its own. You write the index page and let its template read the already sorted posts. Pagination, taxonomies, feeds, and search can then use that same resolved post list where they apply.

Using one resolved collection prevents features from discovering posts independently and gradually forming different ideas of what belongs in the blog.

Math

Mathematics was one of the problems that pushed me toward a different design.

TeX uses backslashes as syntax. Markdown also interprets a backslash before punctuation, but for a different purpose: it makes the following character literal. Without math-aware parsing, Markdown can process TeX before it knows that the text is mathematics. For example:

$\{ a = 2 * b \}$

can lose its TeX escapes and leave:

${ a = 2 * b }$

In MathML mode, Heine recognizes supported TeX delimiters while it builds the page. The parser therefore treats the expression as mathematics before ordinary Markdown escape rules can rewrite it.

[markdown]
math = "mathml"

For a site that uses a browser-side renderer, math = "tex" instead preserves escaped TeX delimiters for KaTeX or MathJax. The choice is explicit because the two modes have different responsibilities: one produces MathML during the build, while the other leaves rendering to the site.

Local development should use the real site

The development server serves Heine’s generated output from disk. It does not maintain a separate in-memory approximation of the site. That matters when testing generated routes, versioned asset URLs, or media.

In particular, the server supports byte-range requests, so browsers can seek through local audio and video as they do on a properly configured production host.

When a watched rebuild fails, the server keeps the last successful output available and does not reload the browser into a partial result. Drafts can be included with heine serve --drafts, where they participate in the same collections, navigation, feeds, and search relationships as they would after publication.

Kits

When moving a design between sites, you need a clear boundary between what can be customized and what stays internal. Heine calls these reusable designs kits.

A kit packages templates, assets, localized messages, license texts, and a reference site. A site selects at most one kit, then keeps ownership of its content, data, output configuration, and root templates.

A kit manifest declares its public templates, assets, and components. Root Fluent resources may also replace existing messages that use the kit’s prefix. Other kit templates and assets remain private. A root template can extend a declared public kit template, but a kit cannot depend on an arbitrary consuming site.

The starter kit includes a working reference site. It shows the kit contract in use.

The boundary is intentional

Heine does not try to become a frontend toolchain.

It does not bundle JavaScript, compile Sass, optimize images, deploy a site, or run accessibility audits. Heine checks the relationships that require its resolved content and output model.

That boundary leaves room for ordinary tooling around the generator:

content and templates → Heine → generated site → host and deployment tools

This scope keeps Heine small. It turns authored content into static output while refusing to guess when a site makes a claim it cannot satisfy.

Heine is written in Rust and is available from heine.rs. The site includes a tutorial, a complete reference, and a starter site that builds as-is.

I built Heine for my own sites, and it remains shaped by what those sites need: a generator that explains a broken relationship before it becomes a broken page.