
Why I built Heine
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 weitzel.dev and pureandroid.com. Both made it easy to turn content into pages. Over time, I found myself working around a different class of problem: 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 problems were less visible, but no less frustrating. A page could point at something that was not there. Localized interface text needed an explicit fallback model. A local development server could behave differently from the host that would eventually serve the site. Math notation could need special treatment before Markdown had already interpreted part of it.
None of these problems is dramatic on its own. Together, they changed the kind of tool I wanted to use.
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 Markdown link names that page
- this image is 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 name a page through 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, Series, 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 membership where they apply.
This prevents each feature from discovering posts independently and gradually forming a different idea of what belongs in the blog.
Markdown should not damage mathematics
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, rewritten 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.
A site design can travel as a kit
The same question appears when a site design moves between projects: which parts may a consuming site replace, and which parts should remain internal? Heine calls these reusable site 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.
That boundary matters for the same reason as the rest of Heine’s design. Templates and assets that a kit intends a site to customize are declared public. Private kit internals stay private. A root template can extend a declared public kit template, but a kit cannot depend on an arbitrary consuming site.
The downloadable starter site is also the first starter kit. It is both a working site and an example of the contract a kit must satisfy.
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. Dedicated tools already handle those jobs. 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 toolsThis 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 currently 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.