HERON

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) and raw (the source markdown);
  • and the parsed parts: lead (paragraphs before the main node), body (the node itself), note with note_title (paragraphs after), callout (a quote) and links;
  • the intro comes in three fields: lead is the first paragraph, promise the second, intro the 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:

  1. there is no section with that id: return an empty string and render nothing;
  2. 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 through media_url(src, ratio, width);
  • only what the built site links to is cut: a ratio no template asked for does not reach the build;
  • srcset and sizes are set by the module;
  • width and height are 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.xml is itself the list of addresses (a urlset). An index over a single file adds nothing but an extra request;
  • two or more: sitemap.xml becomes an index, and next to it lies sitemap-LANG.xml for every language in languages that 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.txt is 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.txt is 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.txt is the same with full texts, when seo.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
  • --drafts includes published: false; --strict turns warnings into errors, the mode for CI;
  • --env prod is the production build: indexing and counters on. Without it the build is dev: robots.txt with Disallow: /, noindex, no counters. An empty site and an empty default language are allowed only in dev (E017, E021);
  • heron new creates 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, README and LICENSE are 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 use heron init;
  • in a terminal, new asks 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 run without -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. -y turns questions off in a terminal too. The answers go into site.yaml, and for every language content/LANG/index.md and 404.md are created at once with a full SEO block: the starter site builds with --env prod --strict without a single warning. From then on site.yaml is the only source of truth, and heron init completes the folders to match it;
  • --notes ru|en is the language of notes: comments in all scaffold files (site.yaml, theme.yaml, the SEO block of pages), the heron-readme.md notes and messages on screen: the steps and results of new, init, page, check, build, and errors. The detailed build report (the warning summary) is in Russian for now. The order is: flag → HERON_NOTES environment variable → build.notes in site.yaml → en. new writes the choice into build.notes, so init and page without a flag keep the language the site was set up in. The languages of the site itself do not depend on it;
  • heron init looks 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 no site.yaml, the site is only being set up: in a terminal it asks the same questions as new, with the same flags; the folders already in content/ are offered as default languages. If site.yaml exists, there are no questions: it is the source of truth;
  • new in a folder that already has site.yaml does not list «files in the way»; it says there is already a site here, in that site's language (build.notes), and suggests init, check, build or how to start over;
  • the default name is the repository name from origin in .git/config, otherwise the folder name. In a container the folder is always /site, so without a repository my-site is suggested there;
  • new and init print 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 /site is 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 (or HERON_DEBUG=1), which prints the full traceback;
  • heron page creates a file with a full SEO block and all the sections of the type from theme.yaml;
  • heron docs is 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 title and description;
  • the redirect map and its conflicts;
  • files in media/ that no page expects, and content that settled in static/;
  • folders without a heron-readme.md note: 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.md and 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;
  • hreflang is symmetric: if A points to B, B points to A;
  • the sitemap contains no pages with noindex and 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.