Specification 21
Build: how HERON turns a folder into a site
This specification describes the build itself: what HERON does with a site folder, in which order, what a template can see, which commands exist and what every error code means.
Read it when a build stops with a code, when you write a theme, or when you want to know exactly why HERON behaves the way it does.
In plain words
The build is the moment when HERON turns your folder into a finished site. It reads every page, checks it, puts each section into the right block of the theme, makes the picture sizes, and writes the result into the dist/ folder: ready pages, a sitemap, files for AI and a config for the web server.
The build is also a strict proofreader. If a page has no description, a link leads nowhere or a photo is missing, it tells you the file, the line and what to do. A normal build only warns; the final build (--strict) refuses to finish until everything is fixed, so a broken page never reaches visitors.
Example. A typical message and what it means:
E007 en/services/roof.md:14: the image is not on disk: roof.jpg
what to do: put the photo into the media folder or fix the path
E007 is the error code (look it up in the error list), en/services/roof.md:14 is the file and line, and the last line tells you how to fix it.
When you need this page. When a build stops with a code, when you write a theme and want to know what a template can see, or when you need the full list of commands.
What the build does
HERON reads the site folder (site.yaml, content/, data/, media/, static/ and the theme) and produces a ready static site in dist/.
One command, one pass, no state between runs. Build twice in a row and you get a byte-for-byte identical result.
HERON lives in the image heron:VERSION and in a pip package, not in the site folder. The first thing it does is compare its own version with the heron: requirement in site.yaml, and it refuses to build an incompatible site (E012).
The build runs without network access. No stage reaches outside: everything a site needs is in the site folder.
Stack
Python 3.12. Dependencies are kept to a minimum: each one has to earn its place, because a beginner developer will maintain this.
| Package | Why |
|---|---|
markdown-it-py |
markdown parsing, {#id} support through attrs |
PyYAML |
front matter and configs |
Jinja2 |
templates |
Pillow |
resizing and converting images |
pydantic |
validating front matter and site.yaml with clear messages |
click |
command line |
Package manager: uv. Linter and formatter: ruff. Tests: pytest.
How the code is organised
Three levels: contracts, core, modules. The split is not cosmetic: the plugin boundary runs along it, because a module and a plugin are written against the same hooks.
heron/
├── cli.py commands new, init, check, serve, build
├── contracts/
│ ├── site.py pydantic schema of site.yaml, including the version requirement
│ ├── theme.py schema of theme.yaml
│ ├── frontmatter.py schema of the front matter
│ └── version.py checks the HERON version against site.yaml
├── core/
│ ├── models.py Site, Page, Section, Nav
│ ├── parser/
│ │ ├── frontmatter.py
│ │ ├── sections.py cuts the page by headings with anchors
│ │ └── blocks.py parses structured sections: facts, faq, steps
│ ├── tree.py walks content/, builds the page tree and URLs
│ ├── data.py loads data/ into the template namespace
│ ├── links.py resolves links: catalogs, languages, fields declared by the theme
│ ├── render/
│ │ ├── env.py Jinja2 environment, mod.* macros
│ │ └── pages.py renders pages
│ ├── media.py image processing
│ ├── resolver.py finds themes and plugins: local → packages → git
│ ├── hooks.py registry of extension points and their calls
│ ├── report.py check report, warning summary
│ └── errors.py error types and codes
├── modules/ the same as plugins, only shipped in the core
│ ├── sitemap.py
│ ├── robots.py
│ ├── llms.py
│ ├── jsonld.py
│ ├── redirects.py
│ └── feed.py
└── scaffold/ sample files for new and init
The boundaries are strict: the core knows nothing about the subject of any site. A client name, a domain, a phone number or the slug of a specific page in the core code is a mistake. This is checked by a pre-commit hook in the HERON repository, not by a developer's memory.
Everything site-specific lives in the site folder: content/, data/, site.yaml and the theme.
Build pipeline: eight stages
Eight stages, strictly in order. Any of them can stop the build.
1. Loading configs. The HERON version is checked against the heron: key in site.yaml, then site.yaml itself and the theme.yaml of the theme it names are loaded and validated with pydantic. Wrong version, missing theme or a page type without a template: the build stops.
2. Walking the tree. A recursive pass over content/LANG/. For every file HERON works out the URL, the type (from the _index.md of its folder, or the default) and the language. The result is a flat list of pages.
The address and the key are built separately:
- key: the path of folders plus the file name without the extension. It never looks at the slug: gluing them together would forbid languages from having different addresses, and renaming an address would break the link between translations;
- address: the same chain of segments, but each one replaced by the slug of its section, if the section declared one, and by the page's own
slug, if the page declared one.
Section slugs are collected in a separate pass over the _index.md files before the files are walked: HERON meets a child page before the index of its section, and the child's address depends on what the parent calls itself.
The update date is worked out here too. Its source is set by build.updated_from (manual by default, git, file). The priority is the same for any source: updated in the front matter always wins over the automatic date.
The git mode asks the history twice for the whole build, not twice per file: one pass over the history returns the dates of all paths at once, the second returns the list of uncommitted files. On a site with a few hundred pages the difference between this and git log -1 per file is seconds against minutes. HERON takes the committer time (%cI) with its time zone: the moment the change entered the history, and so the site, not earlier. The author time can be weeks older (rebase, cherry-pick). After the dates are resolved, dates.check looks for dates later than the build moment and updates earlier than publication: warnings of the «dates» kind, errors with --strict (see front matter). Renames are not tracked: for a site, renaming a file is a change of address, so the page really did change.
If the history is unavailable (not a repository, no git in the image, a shallow clone --depth 1), the build does not fail: HERON falls back to file times and says so with one warning per build. A warning with a file path stays where it is about a specific file: the file is not in the history or has uncommitted changes. Otherwise a build from a copy without .git would print two hundred identical lines and flood the summary.
The date is reduced to a calendar date in UTC, without time: the result must not depend on the time zone of the machine running the build.
3. Parsing. For every page: front matter → model, body → sections, structured sections → data. Errors are collected without stopping the pass, so a developer sees all of them at once, not one per run.
4. Resolving links. Child pages of catalogs, link fields declared by the theme in links, language versions, menus, breadcrumbs, back links. A link to a page that does not exist stops the build.
Internal links in the text are converted to the page's language not by gluing on a prefix, but through the page: the written path is read first as a key, then as an address in the page's language, then as an address in the default language (full rule and example: content tree and addresses). The language version of the found page that matches the current page is taken, and its address is used. Pages in all languages are processed, the default one included: a key link to a section with a slug differs from the address there too. If there is no translation, there is a warning, and the link leads to the default-language version: the reader lands on an existing page, not on a 404.
Links typed by hand in the text are checked separately: each one of the form /…/ must resolve to an existing page by the same rule. If it does not, there is a warning with the file path, the line number and the link itself. It is a warning, not an error: a broken link inside a text is no reason not to publish the rest of the site. But staying silent is not an option either: a section address is set by its slug, and renaming a section leaves links written as addresses pointing nowhere until the first click. External addresses, anchors, paths to /media/, /static/, /assets/ and to files with an extension are skipped.
Large sections. HERON has no pagination, and that is a decision, not a gap. A section with a hundred or more children is one catalog page: page.children gives the theme all the children at once, sorted by order, then by h1. HERON creates no addresses like /faq/page/2/, so there are no questions about their canonical address, prev/next or their place in the sitemap: the sitemap gets the section and every child, one entry each.
How to show a long list comfortably is the theme's job: group the children by a field (groupby in Jinja), show a table of contents with anchors, fold the groups into a details element. All links must still be in the HTML (constraints): loading a list with a script hides pages from search engines. A hundred links on a catalog page is a normal size; if a section grows to thousands, split it into subsections with folders, each with its own _index.md, its own address and its own sitemap entry.
Tested on a section of 120 pages (tests/test_large_section.py).
5. Image plan. For every master image: the names and sizes of all cuts the theme may ask for (ratios × widths × formats; the fallback format gets one width, fallback_width). Nothing is encoded yet: templates need only names and width/height, and the geometry of the master gives those. Originals are never changed.
After rendering and generators: cutting. HERON looks for links to media/ in all the built text: pages, maps, markup, plugin files, theme styles and static files. Only the intersection of those links with the plan is encoded and put into the build. The full master is copied only if something links to it (media_url(src), og:image) or it is declared «raw»: a picture from page text, og_image, twitter_image, video_poster, seo.og_default_image. Files HERON does not cut (video, PDF, svg, as_is) are copied as before.
The cuts live in the cache and reach the build as hard links (if that fails, as copies: the file system may not allow links). Otherwise the cache would be useless: dist is disposable and is wiped before every build, and the cuts would be wiped with it: the hash matches, the file is gone, everything is encoded again.
A cache record keeps the hash of the master together with the theme's image spec and a ready piece of the manifest. So a master whose hash matches and whose cuts are all in place is not even opened: the list of widths depends on the width of the source, and without the saved manifest the picture would have to be decoded just for its sizes.
At the end of the pass, everything not in this build's plan is removed from the cache: a master removed from content, or a width removed from the theme. What the theme did not ask for in this build but may ask for stays: a template change that brings back a ratio does not re-encode it. Cleanup goes by the manifest, not by the list of what was recalculated: the build may have failed earlier, and then there is nothing to remove.
Encoding is spread across CPU cores; the unit of work is a master, not a single cut: splitting finer would mean decoding a source seven hundred times instead of ninety-five. The number of processes is build.workers; zero means one per core. Progress ticks in the order of the source list, not in the order of completion: otherwise the log becomes unreadable, and people look at it precisely during a long build.
Measured on a site with 95 masters and 2062 cuts, four cores: cold build 157 s, repeat build 4 s, of which images 0.3 s.
6. Rendering. Every page goes through the theme's templates/TYPE.html (or the template named in types.TYPE.template). The 404 page of the default language is also placed in the root as 404.html (see the 404 page).
7. Generating service files. sitemap, robots, llms.txt, redirects, feed. JSON-LD is embedded during stage 6.
8. Copying. static/ into dist/ as is, theme/assets/ into dist/assets/.
After that comes a summary: how many pages per language and type, how many warnings, and the list of content debts.
Warnings are grouped by kind: images, links, references, menu, translations, languages, other (in the report they are named in Russian: «картинки», «связи», «ссылки», «меню», «переводы», «языки», «прочее»). At the top is what breaks a page for a visitor, at the bottom what only an editor will notice. Each group shows six examples; the full list is written to .heron-cache/warnings.txt next to dist. Without grouping, a hundred similar notes about translations hid the ten important ones about images missing from disk.
Progress is visible in the terminal: the stage, the time since it started, a counter and a bar inside long stages (cutting images, rendering, writing files). When the output is not a terminal (CI, docker without -t, redirect to a file) there is nothing to redraw, so a stage is announced as soon as it starts, long stages report every two seconds, and a total is printed at the end. Every line is flushed at once: otherwise the whole log reaches the person in one batch at the end, and a build looks the same as a frozen one.
Section parser
The heart of the system, so in detail.
The body of a file is parsed by markdown-it-py with the attributes plugin on. A section is a heading that has an anchor {#id}, and everything after it up to the next heading of the same level with an anchor, or to the end of the file.
Rules:
- headings without an anchor do not start a section and belong to the current one;
- text before the first section is available as
intro; - the identifier is
[a-z0-9-]+; anything else is an error; - a duplicate identifier in one file is an error;
- a section keeps
id,title(the heading text),level,html(the rendered content without the heading) andraw(the source markdown); - and the parsed parts:
lead(paragraphs before the main node),body(the node itself),notewithnote_title(paragraphs after),callout(a quote) andlinks; - the intro comes in three fields:
leadis the first paragraph,promisethe second,introthe whole text. A third paragraph and beyond is a warning: the template has no place for them.
The theme decides whether to show a section's title: mod.prose('what') prints both the title and the text, while mod.facts('quick-facts') ignores the title because it draws a frame of facts.
Section forms
After parsing, every section gets a kind field, one of twelve forms: prose, list, alert-list, checklist, two-lists, steps, table, facts, faq, timeline, callout, gallery, and data with the parsed data of that form. The table of forms, the signs, the order of detection and the shape of the data are in page format and section forms: that is the only place where they are described. The list of forms in code is heron.contracts.theme.KINDS, the auto-detection order is heron.core.parser.blocks.AUTO_ORDER. How each form looks on this site: the forms reference.
Parsing fails softly: if a class is set and the content does not fit it, the section is given out as prose and a warning is written. Failing a build over a missing bar in a table is not acceptable.
The section model:
class Section:
id: str # anchor from {#anchor}
title: str # heading text
level: int
classes: list[str] # classes from the anchor: alert, timeline, …
raw: str # source markdown without the heading
html: str # rendered content without the heading
kind: str # form: prose, list, … (see the contract, 4.4)
data: Any # parsed data of the form; in a module template: items
lead: str # paragraphs before the main node
note: str # paragraphs after
note_title: str # bold lead-in of the note
callout: str # the quote, wherever it stands
links: list[dict] # links of the section {title, href}
body: Any # property: data, and html for prose
Page model
Front matter fields live in page.meta, one attribute for each row of the front matter table (page format), plus theme fields the core does not interpret (page.meta.FIELD, and all of them at once as page.meta.extra). The page itself adds what HERON learned while walking and resolving:
class Page:
lang: str # language code
source: str # file path from content/: ru/services/x.md
key: str # key: services/x; empty for the home page, "/" in templates and nav
meta: PageMeta # front matter
url: str # address with the language prefix and slugs: /ru/rukovodstva/x/
type: str # page type
sections: dict[str, Section] # sections by anchor
intro: Section | None # text before the first section
auto_updated: date | None # date from git or the file, if updated is not set by hand
links: list[tuple[int, str]] # text links as written: line and address
# filled in while resolving
parent: Page | None
children: list[Page]
translations: dict[str, Page] # versions in other languages, by key
breadcrumbs: list[Page]
related: dict[str, list[Page]] # links declared by the theme, and back links
# computed
title: str # meta.title
h1: str # meta.h1
updated: date | None # meta.updated, otherwise auto_updated
nav_title: str # meta.nav_title, otherwise the file name
video: Video | None # parsed meta.video*
slug: str # last segment of url
name: str # file name without the extension
is_index: bool # _index.md or the home page
Site.page(key, lang) finds a page by key in a language; in a template the same is page_of(key).
Rendering: what a template sees
Jinja2 with autoescape on and StrictUndefined: using a variable that does not exist fails the build instead of printing nothing. This catches typos in a theme at build time.
A template has access to: page, site (the whole site.yaml), data (the contents of data/), nav, t (the theme's interface strings), mod (module macros), lang and languages, hreflang, page_of(key) (a page by key in the current language; the home page is '/'), picture, media_url, absolute, jsonld(), env (the build environment: env.indexable, env.analytics, the build stamp).
Module macros
mod.MODULE('SECTION_ID'). The logic is the same for all of them:
- there is no section with that id: return an empty string and render nothing;
- the section exists: render
theme/modules/MODULE.html, passing it the section's data.
Modules without an argument (mod.call(), mod.author(), mod.breadcrumbs()) take their data from site and page, not from sections.
An empty string instead of a block is a principle: a heading with nothing under it is worse than no block at all.
Interface strings
Texts that belong to the theme rather than to content («Book now», «Read more») live in theme/i18n/LANG.yaml and are available as t.book_now. Otherwise they would be hard-coded in templates, and the theme would stop being portable between languages.
Images
For every image the content links to:
- width variants: 400, 800, 1200, 1600, but never wider than the source;
- formats: AVIF and WebP at every width; the original format (PNG/JPEG) is the fallback for
img src, at one width,images.fallback_width(800), never wider than the frame. Other widths of the original format are cut only if asked for throughmedia_url(src, ratio, width); - only what the built site links to is cut: a ratio no template asked for does not reach the build;
srcsetandsizesare set by the module;widthandheightare mandatory in the markup, otherwise the layout jumps and CLS grows;loading="lazy"on everything except the image of the first screen;- cuts are kept in the cache and reach the build as hard links.
og_image is not processed: social networks expect a JPEG or PNG 1200×630 at a fixed address.
An image that is linked but missing from disk stops the build. A broken image in production costs more than a failed build.
Cut cache
Encoding is the most expensive part of a build: 95 masters with three ratios, four widths and three formats give more than two thousand files and minutes of CPU time. Changing one line of CSS must not cost that.
Where it lives. Next to the build folder, not inside it:
.heron-cache/
├── media.json plan of masters: hash, names and sizes of cuts
├── media/MASTER_PATH/… the cuts themselves
└── warnings.txt full list of warnings from the last build
The cache has no place inside dist/, and this is not a matter of taste. dist is disposable: it is wiped before every build, because otherwise files of deleted pages pile up in it. A cache inside a disposable folder is wiped with it. Before this version that was the case, and the cache did not work even once: the hash matched, the file was gone, everything was encoded again.
How it reaches the build. As a hard link. A copy means a second gigabyte on disk and a minute of writing on every build; a link costs nothing. The file system may refuse (network volume, another partition, FAT): then HERON copies and stays quiet. That is no reason to fail.
A consequence worth knowing: a file in dist and a file in the cache are the same file. Do not edit images directly in the build: the change goes into the cache and survives rm -rf dist. Nobody does that, but it is useful to know.
What counts as a change. The hash of a record is the hash of the master together with the theme's image spec. Change a file, and only that file is recalculated. Change widths, ratios, formats, crop or as_is in theme.yaml, and the whole site is recalculated, because the set of cuts changed for everyone. That is deliberate: half a site in old widths is worse than a long build.
Why a record holds a piece of the manifest. To decide «this master needs no work», comparing the hash is not enough: the names of the cuts are also needed, and the set of widths depends on the width of the source (a narrow picture is not stretched to 1600). Without saved names every master would have to be opened and decoded just for its sizes. With them, a cache hit means zero openings: only links.
Cleanup. At the end of the pass, everything not in this build's plan is removed from the cache: a master removed from content, a width removed from the theme. Leftovers do not affect the site (only what the site links to reaches the build), but they would take space forever.
Cleanup goes by the manifest, not by the list of what was recalculated: the build may have failed earlier, and then there is nothing to remove. An empty build does not wipe the cache.
The cache format is versioned. A record of another version is ignored entirely: the cache goes stale once and fills again. That is cheaper than keeping readers for every past format for the sake of one build.
The cache can be deleted at any moment. It affects only speed: the first build after deleting it is cold. That is exactly why it is in .gitignore, and why production and CI do not need to have it.
How many processes
Encoding is independent and CPU-bound, so it is spread across cores. The unit of work is a master, not a single cut: splitting finer would mean decoding a source seven hundred times instead of ninety-five.
build.workers in site.yaml; zero means one per core of the machine. Set it by hand where cores are shared with other jobs: in CI, a site build is not alone on the machine, and taking all cores slows the neighbours down.
Progress ticks in the order of the source list, not in the order of completion. Otherwise the log stops being readable, and people look at it precisely during a long build.
Encoder speed
WebP and AVIF have explicit speed settings, not library defaults. A default can change with a dependency update and make the build five times slower without a single line changing in our code.
AVIF measured on a set of 95 masters, 800 px, quality 82:
| speed | time per variant | size | vs chosen |
|---|---|---|---|
| 0 | 5.33 s | 43.0K | −5.7% |
| 4 | 0.69 s | 44.4K | −2.6% |
| 6 | 0.13 s | 45.6K | — |
| 8 | 0.05 s | 47.3K | +3.6% |
| 10 | 0.02 s | 57.1K | +25.0% |
Six was chosen: four costs five times more time for two and a half percent of size, ten saves time at the cost of a quarter of the weight. For WebP, method=4 was chosen the same way: the slowest mode gave 7 seconds per variant instead of 0.1 for a gain of about two percent.
Generators
sitemap
What it holds depends on the number of languages in site.languages:
- one language:
sitemap.xmlis itself the list of addresses (aurlset). An index over a single file adds nothing but an extra request; - two or more:
sitemap.xmlbecomes an index, and next to it liessitemap-LANG.xmlfor every language inlanguagesthat has indexable pages. A language not in the list is not in the map either.
Pages with published: true and noindex: false are included, except the 404 page. lastmod comes from updated, as a full date with time zone (see front matter in page format). An entry that has versions in at least two languages gets xhtml:link to all versions, plus x-default if there is a version in the default language. The same set is printed in the page head (the template variable hreflang); full rules are in languages.
There are no service entry types in the sitemap by definition: the system has no entities without a URL. The service entries that CMS plugins leave in sitemaps are impossible here by design.
robots.txt
Built from seo.robots_extra plus the mandatory Sitemap: line with the absolute address. It is generated rather than kept as a file: a hand-written one starts lying within six months.
llms.txt
Follows the llmstxt.org format: the site title, a short description, sections with lists of pages and one-line descriptions taken from description. It is built from the same tree as the menu.
With seo.llms_txt: true there is also llms-full.txt: all the text of the site in one file.
A single-language site gets no language files: /llms.txt holds the full list of pages straight away (with that language's seo.llms_note block), /llms-full.txt holds the full texts, and they link to each other. A table of contents with a single link would make a model take an extra step for the same content.
On a multilingual site a file is built for every language, not only for the default one:
/llms.txtis the root file: site name, description and the list of language versions. It has no list of pages; it is a table of contents;/llms-LANG.txtis the full list of pages in that language. Its header links to the other versions and to the root file, so a model that lands on, say, the Russian file knows the others exist;/llms-full-LANG.txtis the same with full texts, whenseo.llms_txt: true.
Navigation is closed: the set has no dead ends. From any file you can reach any other:
| File | Where its header leads |
|---|---|
/llms.txt |
to the short and the full version of each language |
/llms-LANG.txt |
to the root file, to the short versions of other languages, to its own full version |
/llms-full-LANG.txt |
to the root file, to the full versions of other languages, to its own short version |
The link between the short and the full version of one language is not about languages, so it also exists on a single-language site, where there is no «other languages» block at all. If full files are off (seo.llms_txt: false), there are no links to them anywhere.
Line labels are in the language of the file itself; for the root file, in the site's default language. A language missing from the set of labels gets English ones: they are clear to both a model and a person, and an invented translation helps nobody.
Languages are not mixed in one file on purpose: a model answers in the language of the text it read, and close languages, like Ukrainian and Russian, are the ones it confuses most often. Separate files let it pick the right one, cross-links let it find the rest.
The llmstxt.org format does not describe language versions, so the convention is our own: the file is read by a model, and a clear line of human text is enough for it.
A language that has nothing but a placeholder is not listed: a link to an empty file is worse than none, because a model will follow it and come back with nothing.
Facts about the site. seo.llms_note gives each language a piece of markdown that is inserted word for word:
- in
/llms-LANG.txt: after the short description, before the list of pages; - in
/llms-full-LANG.txt: after the title, before the page texts; - in
/llms.txt: the text of the site's default language.
The order is deliberate: first «what this site is», then «how to deal with it», then the content. The reader needs the answer before the table of contents; that is what they came for.
The block is not searched for in the finished text and is not «inserted» afterwards: the file is assembled as a list of lines, and the block's place is fixed at build time. Searching the finished file would trip over page texts that contain headings and quotes looking like the header.
The block holds facts about the site, not instructions for the reading model. A consumer has every right to treat text from someone else's site as data, not instructions; trying to steer someone else's agent through it is useless and looks like instruction injection.
JSON-LD
Embedded in the head of every page; the type depends on the page type.
Markup types are declared by the theme, with the key types.TYPE.jsonld in theme.yaml: a services site, a shop and a directory map types differently, and the core does not have to know that.
The core builds what it really knows itself:
| What | Where it comes from |
|---|---|
@type declared by the theme |
theme.yaml |
name, description, url, inLanguage |
the page |
datePublished |
the date field, midnight UTC: 2026-03-14T00:00:00+00:00 |
dateModified |
updated or build.updated_from; from git, the commit time with time zone |
| organisation fields and other details | the schema block in site.yaml |
| refinements for a particular page | the schema block in the front matter |
BreadcrumbList |
the built breadcrumbs, everywhere except the home page |
FAQPage |
a section parsed as faq |
The merge has three levels: site, then page. Organisation data lives in site.yaml and is not duplicated in content.
redirects
Collects every redirect_from from every page into dist/redirects.map in nginx format:
/old/address/ /new/address/;
Old addresses are normalised first: percent-encoding is decoded, the text is brought to NFC, case is kept, and a trailing slash is added to everything except files (see old addresses in content tree and addresses). A Cyrillic example: /%D0%BA%D1%80%D0%B5%D0%B4%D0%B8%D1%82 and /кредит/ give one map line, /кредит/ /credits/;, and the 301 works for both forms, with and without the slash. An address that is not text after decoding (a slug cut in the middle of a letter) stays in percent form and is compared with $request_uri.
Checks at build time use the normalised form: an old URL does not match any existing one, one old URL does not lead to two places, there are no chains (E009).
Next to it is the list of 410 answers from the gone key in site.yaml (site.yaml): exact addresses, prefixes like /wp/* and addresses with a parameter like /?p=*. An overlap with a page or with redirect_from is E022. A copy of the list is dist/gone.map.
HERON writes both maps into the nginx config it builds together with the site (dist/.heron-nginx.conf): map $uri for 301 and 410, map "$uri?$args" for entries with a parameter, and the checks at the start of server, before a location is chosen. The answer is sent without touching any file; there is no need to include the maps by hand.
feed
dist/feed.xml in Atom format, only if seo.feed is set in site.yaml: the type of pages that go into the feed. Published and indexable pages of that type in the default language are taken, no more than seo.feed_limit (20). Order is by publication date (date); pages without it go by update date, newest first. An entry has a published element if date is set, and an updated element as a full date with time zone, as in the markup.
Commands
These commands are the whole surface of HERON for a person. There is no need to go inside it.
heron new [path] [--notes ru|en] [--name …] [--domain …] [--lang en,ru] [-y] [-v]
create a site from scratch; path defaults to «.»
heron init [path] [--force] [--notes ru|en] [--name …] [--domain …] [--lang …] [-y] [-v]
complete the folder to match site.yaml
heron check [path] [--strict] [--drafts] report without building
heron serve [path] [--port 8000] local preview with rebuilds
heron build [path] [--out folder] [--env prod] [--strict] [--drafts] build
heron page TYPE SLUG [path] [--lang ru] [--folder section] page template
heron docs [topic | template FILE] [--search text] [--export folder] docs of this version
--draftsincludespublished: false;--strictturns warnings into errors, the mode for CI;--env prodis the production build: indexing and counters on. Without it the build is dev:robots.txtwithDisallow: /,noindex, no counters. An empty site and an empty default language are allowed only in dev (E017,E021);heron newcreates a site in a new folder or in the current one (.). The folder may be under git:.git,.gitignore,.gitattributes,.gitmodules,.github,.gitlab,.DS_Store,.editorconfig,READMEandLICENSEare no obstacle and are not overwritten. Any other file means a refusal with a list of what is in the way and a suggestion to useheron init;- in a terminal,
newasks questions: the language of notes, the name, the domain, the site languages and the default language (the last one only if there is more than one language). Every question has a default, a wrong answer is asked again, and at the end there is a summary and a confirmation; saying no creates nothing. Without a terminal (an agent, CI,docker runwithout-it) there are no questions: values come from the flags--name,--domain,--lang(comma-separated, the first is the default) or from defaults, and a line says so.-yturns questions off in a terminal too. The answers go intosite.yaml, and for every languagecontent/LANG/index.mdand404.mdare created at once with a full SEO block: the starter site builds with--env prod --strictwithout a single warning. From then onsite.yamlis the only source of truth, andheron initcompletes the folders to match it; --notes ru|enis the language of notes: comments in all scaffold files (site.yaml,theme.yaml, the SEO block of pages), theheron-readme.mdnotes and messages on screen: the steps and results ofnew,init,page,check,build, and errors. The detailed build report (the warning summary) is in Russian for now. The order is: flag →HERON_NOTESenvironment variable →build.notesinsite.yaml→en.newwrites the choice intobuild.notes, soinitandpagewithout a flag keep the language the site was set up in. The languages of the site itself do not depend on it;heron initlooks at what is in the folder, adds what is missing, overwrites nothing without--force, and prints what it found and what it added. If there is nosite.yaml, the site is only being set up: in a terminal it asks the same questions asnew, with the same flags; the folders already incontent/are offered as default languages. Ifsite.yamlexists, there are no questions: it is the source of truth;newin a folder that already hassite.yamldoes not list «files in the way»; it says there is already a site here, in that site's language (build.notes), and suggestsinit,check,buildor how to start over;- the default name is the repository name from
originin.git/config, otherwise the folder name. In a container the folder is always/site, so without a repositorymy-siteis suggested there; newandinitprint a summary by meaning (config, content per language, theme, folders, notes, what was left untouched) and a «What next» block with ready commands for this folder; the full list of files with-v;- paths are relative to the current folder. In the image (
HERON_IN_CONTAINER=1) only the mounted folder/siteis visible; a path from the person's computer that does not exist inside gives an explanation about mounting and a suggestion to pass.or the name of a folder inside; - no traceback goes out. An error is printed in a fixed shape: code and name, where, details, what to do. No write permission: a hint about
--user $(id -u):$(id -g). An unexpected error: its kind, text and a suggestion to repeat with--debug(orHERON_DEBUG=1), which prints the full traceback; heron pagecreates a file with a full SEO block and all the sections of the type fromtheme.yaml;heron docsis the documentation of exactly this version, from the package: without arguments, the table of contents; with a topic, the whole file in markdown;template FILE, a sample;--search, lines with context;--export FOLDER, everything as a folder with the same paths as in the repository. Only text goes into the package and the image (about 400 KB, about 130 KB compressed), not brand images; a test makes sure every documentation file is available as a topic.
Packing into an image is not among HERON's commands: the nginx image with a site is built by the host, with scripts/site.sh SITE image ENV from the HERON repository (delivery, outputs). More on the commands in practice: docs in the terminal.
Errors and warnings
The split is strict: an error stops the build, a warning goes into the report.
These stop the build:
| Code | Condition |
|---|---|
E001 |
broken YAML in the front matter |
E002 |
no title, h1 or description, or a front matter field of the wrong type (a date that is not a date, order that is not a number) |
E003 |
duplicate section identifier in a file |
E004 |
two files give one URL |
E005 |
invalid slug |
E006 |
a link field points to a page that does not exist |
E007 |
an image is missing from disk |
E008 |
a page type has no template in the theme |
E009 |
redirect_from matches an existing URL |
E010 |
a template uses a variable that does not exist |
E011 |
site.yaml fails validation |
E012 |
the HERON version does not meet the heron: requirement in site.yaml |
E013 |
the theme was found neither locally nor among installed packages |
E014 |
a plugin from plugins: is not installed or failed to load |
E015 |
raw HTML in markdown with build.allow_raw_html: false |
E016 |
invalid section identifier |
E017 |
a production build without a single page |
E018 |
the site's resource folder is doubled: media and the old img, or media and static/media |
E019 |
a local video has no poster |
E020 |
schema.org markup does not build: not JSON, the type declared by the theme is missing, the site-wide node declares an address or coordinates that are empty, or there are money fields with seo.allow_prices: false |
E021 |
a production build where the default language is empty: the site root would answer 404 |
E022 |
a gone entry closes the address of an existing page or overlaps with redirect_from |
E023 |
fields in theme.yaml names fields HERON does not know: your own front matter fields do not go there, or a newer HERON is needed |
An error message must contain the file path, the line number, what is wrong and what to do. «Error in template» without a location is not a message.
Warnings:
title longer than 60 characters · description longer than 155 · an image without alt · a page without image in a catalog · a section in a file that the theme does not use · a missing optional section.
The last one is collected into a summary of debts:
Not filled in:
contra — 6 pages: design, audit-basic, …
price — 6 pages
risks — 6 pages
That is exactly the list for the client that otherwise gets lost. It is printed after every build instead of being remembered once a quarter.
The heron check report
Without building, it reads content and theme and prints:
- pages per language and type, what is translated and what is not;
- how full the sections are for each type;
- sections in files that the theme does not use: catches a typo in an anchor;
- theme modules that no type uses;
- internal links: broken ones, and pages nobody links to;
- pages without a link to a record: a breach of the internal linking rule;
- lengths of
titleanddescription; - the redirect map and its conflicts;
- files in
media/that no page expects, and content that settled instatic/; - folders without a
heron-readme.mdnote: information, not an error; - fields that not every item of a list has: HERON is strict about undefined values, and using a missing key fails the build. A template is written against the first item and fails on the sixth. This is not a content error (half of the documents really have no place of issue); it is a warning to the template author to write
d.get("place").
It runs in CI on every push. It replaces the validation schema we gave up for an easier start: control stays, but nobody has to write and maintain schemas.
Performance
A site with 40 pages in three languages is 120 pages. Everything except images (parsing, links, rendering, generators, writing) fits into a couple of seconds. There is only one expensive part: encoding the cuts.
Measured: 95 masters, 2062 cuts, four cores.
| Build | Time |
|---|---|
| cold, no cache | 157 s |
repeat, cache intact, dist wiped |
4 s, of which images 0.3 s |
Hence two rules. First: the cut cache must survive the removal of dist, otherwise there is no cache at all. Second: only cores save a cold build, and production and CI have no cache by definition.
Incremental page builds are not needed and are not done: heron serve rebuilds everything when a file changes, and without images that takes a couple of seconds.
Tests
Mandatory; work is not accepted without them:
- the section parser: anchors, nested headings, duplicates, text before the first section;
- structured sections: facts, faq, steps, timeline on valid and broken data;
- building URLs from paths, including
_index.mdand language prefixes; - resolving links, including a link to nowhere;
- every URL in the redirect map answers 301 to an address that exists and answers 200. This is the only thing that protects the search index during a move. It is checked on a real nginx, including non-ASCII addresses in both forms, without a slash and with a truncated slug (
tests/test_redirects_unicode.py,tests/test_gone.py); - every built page contains exactly one
h1; - every internal link in the built HTML leads to an existing file;
hreflangis symmetric: if A points to B, B points to A;- the sitemap contains no pages with
noindexand contains all the others; - two builds in a row give a byte-for-byte identical result.
Separately, beyond unit tests: a run of the built site through Lighthouse in the site's CI. The site sets the thresholds (deploy, CI); the guideline is LCP under 2 seconds and CLS under 0.05.
Extension points
Modules from heron/modules/ and external plugins are written against one set of hooks. The only difference is delivery: a module lives in the core, a plugin is installed as a package and declared in site.yaml.
| Hook | When it is called | What for |
|---|---|---|
on_config |
after loading site.yaml and theme.yaml |
add to the config or validate it |
block:NAME |
while parsing a structured section | your own section type beyond the built-in ones |
on_page_parsed |
after each page is parsed | change the page model |
on_tree_built |
after walking content/ |
your own links and groupings |
filter / global |
while building the Jinja2 environment | your own functions for the theme |
on_page_rendered |
after a page is rendered | post-process the HTML |
on_build_finished |
after copying static/ |
your own generators: another sitemap, an export, a feed |
Nothing more for now: an API invented for the future is always invented wrong.
Hooks work only at build time. A plugin that «receives a form» does not exist: at the moment of a POST, HERON has long finished and exited. Everything that lives at run time is heron-relay, a separate service (delivery, forms).
Sources of plugins and the resolution order are in core/resolver.py: the site's local plugins/ folder → an installed package (entry point of the heron.plugins group) → a git link. Local wins.
Source
This page mirrors docs/spec/21-engine.md from the prod branch of the HERON repository (document 2 of 4; dated 2026-09-08, revised 2026-09-14). The repository is updated first, and this page follows right after. The latest version is always here: 21-engine.md on GitHub.