Data contract · part 1
Site folder, addresses and languages
The first part of the data contract: what HERON is, how a site folder is laid out, how a file path becomes an address and how a site lives in several languages.
Everyone who creates a site or moves an old one reads this page: a person, an architect, an AI agent.
In plain words
A HERON site is just a folder on your computer, like a folder of documents. Inside it there are a few sub-folders with fixed names, and each has one job: pages go to content/, photos to media/, the look to theme/, and all settings to one file, site.yaml.
The most important idea on this page: where a file lies is its address on the site. You never write a separate list of addresses. Put a file into a folder, and the page appears at the matching address, in the menu, in the sitemap and in the files for AI.
Example. Three files and the addresses they become on a site with English as the main language and Russian as the second:
my-site/
site.yaml one settings file
content/
en/index.md → /
en/services/_index.md → /services/
en/services/roof.md → /services/roof/
ru/index.md → /ru/
The main language has no prefix in the address, every other language gets its code in front: /ru/. A translation is the same file name in the other language folder, so HERON knows the two pages belong together.
When you need this page. When you plan the structure of a new site, rename a section, add a language or move an old site with its old addresses.
What this system is
A static site generator for markdown files. In: a folder with content, images, a theme and one config. Out: finished HTML in dist/, served by nginx.
The key rule everything else follows from:
A markdown file is a named store of content. The theme is the layout. The file does not know where things will appear. The theme does not know what is written. They are connected by section identifiers.
In practice: the order of sections in a file does not affect the order of blocks on the page. The editor writes in whatever order suits them, the theme lays it out in the right one.
Forms belong to the site, not to the builder. The site itself stays static either way: if there is a form, its POST goes to a separate service, heron-relay, not to the builder. One site has no forms at all, the only conversion being a phone call; another has them. The form contract is described in site.yaml, the implementation in the distribution spec.
How an agent should use the documentation is in roles for AI agents: architect, content and SEO, designer, builder.
How HERON is delivered
Decision of 2026-09-14; it replaces the earlier "HERON, content and theme in one repository".
There is no HERON code in the site folder. HERON ships as one artifact, the image ghcr.io/fmstm/heron:TAG, also published as a pip package for local work. The site folder holds only content, data, images, the theme and the config; not a single line of Python.
A site declares the HERON version it needs in the first line of site.yaml (heron: ">=0.1,<0.2"). HERON refuses to build a site whose version it does not support. That is the one thing that keeps sites from drifting onto incompatible versions.
Everything below is the contract between HERON and a site folder. It is the same for every site, whatever theme it uses.
Project structure
my-site/
├── site.yaml site config; the first line is the HERON version
├── .gitignore dist/, .heron-cache/ — written by heron new
│
├── content/
│ ├── en/ one folder per language from site.languages;
│ │ ├── index.md home page — written by heron new/init
│ │ └── 404.md "not found" page — written by heron new/init
│ └── ru/
│
├── data/ yaml reference data: lists, tables, company details
├── media/ images, video and content files, masters of any ratio
├── static/ only what must sit at an exact address: webmanifest, .well-known
│
├── theme/ this site's theme
│ ├── theme.yaml
│ ├── base.html
│ ├── templates/ page type templates
│ ├── modules/ module templates
│ ├── partials/ header, footer, counters
│ ├── i18n/ theme interface strings per language
│ └── assets/ css, js, logo, icons
│
└── plugins/ this site's local plugins. Usually empty
The build result is dist/. heron build without --out puts it inside the site folder (it is in .gitignore), while scripts/site.sh puts it in out/SITE-ENVIRONMENT in the HERON repository and does not touch the site folder at all. The image cache, .heron-cache/, sits next to the result.
A Dockerfile, compose, build.sh and .env are forbidden in the site folder. All build tooling lives in the HERON repository (scripts/site.sh, Dockerfile.site, env/.env.SITE.ENVIRONMENT); service secrets belong to the services (see .env).
There is no HERON code here. A heron/ folder inside a site is a mistake, not structure.
data/ holds reference data that has no place in markdown text: lists of services with attributes, comparison tables, company details, document lists. Templates read it as data.FILE_NAME.
theme/ is this site's theme, exactly one. Inside are Jinja templates (templates/): the word "template" means them, "theme" means the whole package. If there is no theme in the folder, HERON finds it by the name in site.yaml (an installed heron-theme-NAME package or a git link); a local theme/ always wins.
The person who runs the site works only with site.yaml, content/, data/, media/. Everything else is for the developer.
A note in every folder: heron-readme.md
heron new puts a heron-readme.md in the root and in every folder it creates. The note has three parts: what this folder is, what goes here, and what does not go here and where it goes instead. The third part is mandatory: people put a file in the wrong place not because they do not know what the folder is for, but because they do not know why the next one is better.
A separate specification does not solve this: people read it once when they start, and the "where does this go" decision comes a month later, in another window.
The name is reserved, and HERON ignores these files everywhere, at any depth, in any folder, in any letter case:
- in
content/it is not a page: not parsed, gets no address, not counted; - in
static/it is not copied into the built site; - in
media/it is not a resource: not checked, not copied, not reported as unused; - in
theme/,data/,plugins/it is simply ignored.
One rule for every folder, no exceptions. A plain README.md could not do this job: in content/ it failed to become a page by accident, not by rule, and from static/ it went live, because that folder is copied byte for byte and the site grew a /README.md.
The notes are in the language chosen when the site was created; if there is no translation, English is used, with no error. Older sites do not need the notes: their absence is a line in the report, not an error.
Operating system clutter — .DS_Store, Thumbs.db, desktop.ini — never reaches the built site from anywhere. It appears in static/ by itself, with no one adding it, and used to go live for the same reason.
Content tree and addresses
The file path defines the address. No separate route maps.
default_lang: en, languages: [en, ru]
content/en/index.md → /
content/en/about.md → /about/
content/en/contacts.md → /contacts/
content/en/guides/_index.md → /guides/
content/en/guides/getting-started.md → /guides/getting-started/
content/en/services/_index.md → /services/
content/en/services/design.md → /services/design/
content/ru/index.md → /ru/
content/ru/about.md → /ru/o-nas/ slug: o-nas
content/ru/services/_index.md → /ru/uslugi/ slug: uslugi
content/ru/services/design.md → /ru/uslugi/dizajn/ slug: dizajn
Rules:
- a folder name is an address segment, a file name without extension is the slug;
_index.mdis the page of the folder itself;- the default language (
default_langinsite.yaml) is served from the root, the others with a prefix; - every address ends with a slash; the canonical form has the slash;
- a slug uses only Latin letters, digits and hyphens. A file with any other name fails the build;
- the
slugfield in front matter can override the slug, but usually you do not need it.
Adding a page means creating one md file. It appears by itself in the menu, in its folder's catalog, in the sitemap, in hreflang, in internal links and in llms.txt. Deleting the file removes it everywhere. No lists to keep in sync by hand.
The file name is the key, slug is the address
Two different things that match by default:
- the file name and folder path identify the page. Translations are matched by it,
navinsite.yamlnames the page by it, the theme finds the page by it withpage_of('KEY'). The home page key is/; slugis the address segment the visitor sees.
# content/ru/services/_index.md
slug: uslugi
content/en/services/_index.md → /services/
content/en/services/design.md → /services/design/
content/ru/services/_index.md → /ru/uslugi/
content/ru/services/design.md → /ru/uslugi/design/ the page has no slug
content/ru/services/design.md → /ru/uslugi/dizajn/ with slug: dizajn
A section's slug carries all its children with it: the address is built from a chain of segments, not from a path string. A plain page's slug changes only its last segment.
What renaming an address does not do: it does not break the link between languages, does not require editing nav in site.yaml, does not change the page key. The English section lives at /services/, the Russian one at /ru/uslugi/, and it is still one page in two languages, because the file has the same name in both: the key of both is services.
A slug follows the same rules as a file name: Latin letters, digits and hyphens, one segment, no slashes. An invalid value is a build error, not a quiet fallback to the file name: substituting silently would give the page an address the author does not expect, and they would learn about it from search results.
Two files that produce one address is error E004, however they produced it: the same slug, or a slug equal to a neighbouring file's name.
After a rename, close the old address with redirect_from; it is not relative to the language and is written in full.
Links inside content are relative to the language
An internal link is written from the root and without a language prefix. HERON turns it into the address in the page's language, with all slugs and the prefix.
HERON reads a written path /a/b/ like this:
- As a page key. A key is the folder path plus the file name without extension (
guides/x); the home page key is/. If the key is found, the address of that page in the current page's language is used. - As an address in the current page's language: the same path with its prefix (
/ru/a/b/on a Russian page). - As an address in the default language, or as a full address with a prefix. If a page in another language is found, its version in the current page's language is used.
A key beats an address: it does not change when a slug is renamed. So the most reliable links are keys, the file paths, not what you see in the address bar.
An example with different slugs, using this site's documentation:
content/en/docs/_index.md (no slug) → /docs/
content/ru/docs/_index.md slug: dokumentaciya → /ru/dokumentaciya/
content/en/docs/seo.md → /docs/seo/
content/ru/docs/seo.md → /ru/dokumentaciya/seo/
| Written | On an en page | On a ru page |
|---|---|---|
[x](/docs/seo/) — a key |
/docs/seo/ |
/ru/dokumentaciya/seo/ |
[x](/docs/seo/?a=1#faq) |
/docs/seo/?a=1#faq |
/ru/dokumentaciya/seo/?a=1#faq |
[x](/ru/dokumentaciya/seo/) — full ru address with prefix |
/docs/seo/ |
/ru/dokumentaciya/seo/ |
[home](/) — the home page key |
/ |
/ru/ |
A ru address without the prefix (/dokumentaciya/seo/) is found on a Russian page too, by the second rule.
The same markdown gives correct addresses in every language tree. A translation is a copy of the file with rewritten text, not rewritten links; otherwise one forgotten link after translation becomes a broken link on the live site.
- the key exists but there is no translation into the page's language: the link goes to the default language version (if there is none, to the first version in
languagesorder), and a warning goes to the report, group "links". The reader lands on an existing page in another language, not on a 404; - nothing found: the link stays as written, and a warning with the file, line number and link goes to the report. It is not a build error;
- anchors and parameters (
#faq,?a=1) are kept; - left untouched: external addresses,
mailto:,tel:, scheme-less addresses (//cdn…), bare anchors, paths to/media/,/static/,/assets/and paths to files with an extension (/llms.txt,/feed.xml): a page slug has no dot.
Separately: redirect_from is not relative to the language. It holds real old addresses the site used to live at; they are historical, not relative.
Old addresses: Cyrillic and percent-encoding
An old address with Cyrillic letters exists in two forms: /новости/ and /%D0%BD%D0%BE%D0%B2%D0%BE%D1%81%D1%82%D0%B8/. In redirect_from and in gone you can write it in either form. HERON brings the entry to the form nginx compares addresses in (the decoded $uri), and the 301 or 410 works for both:
redirect_from:
- /новости-компании/ # as in the address bar
- /%d0%ba%d1%80%d0%b5%d0%b4%d0%b8%d1%82%d1%8b/ # as in a sitemap export
Normalisation rules:
- percent-encoding is decoded; the case of hex digits does not matter (
%D0=%d0); - text is brought to Unicode NFC: browsers encode addresses this way, while an editor may have saved "й" as two characters;
- letter case is kept:
/About/and/about/are different addresses; - trailing slash: an entry without a slash gets one (
/новости=/новости/), except addresses that look like a file (/index.php,/page.html). The server answers 301 or 410 both with and without the slash. A prefix ingone(/wp-*) gets no slash; - a slug that the old system cut in the middle of a letter (
/%d0%ba%d1%80%d0/, the last byte without its pair) is not text after decoding. Such an entry stays in percent form, and the server compares it with the raw request address, ignoring the case of%xx. Write it in percent form, exactly as it came from the old sitemap; - duplicates, a clash with a page address (
E009) and overlaps withgone(E022) are checked on the normalised form:/кредит/and its percent form are one and the same address.
In dist/redirects.map and dist/gone.map addresses are already normalised. In the nginx config the map keys are quoted, spaces and special characters escaped.
The 404 page
The text of the "not found" page is content like any other: a 404.md file in the root of each language.
content/en/404.md → dist/404.html (and /404/index.html)
content/ru/404.md → dist/ru/404/index.html
- type:
404, from the file name; no need to declare it (just as the homeindex.mdgetshome). It is drawn by the theme'stemplates/404.html; - fields: as on any page,
title,h1,descriptionare required (E002). Thedescriptionnever reaches search results, but the rule is the same for all files; - the 404 page never appears in the sitemap,
llms*.txt, hreflang or the feed; no need to setnoindex; - serving: the server answers with code 404 and the body of the page in the language of the address:
/ru/…→/ru/404/index.html, everything else →/404.html. The page itself is markedinternal: it cannot be opened by a direct link (details in the distribution spec, "The 404 page and serving"); - no file: HERON builds the 404 for that language from the theme template without content text (the theme takes the heading and explanation from its own
tstrings) and writes a warning to the "content" group. If there is notemplates/404.htmleither, there is a warning and the server serves its built-in placeholder. A language without a single page gets no 404.
Languages
The site declares its set of languages itself, in site.yaml:
site:
default_lang: en
languages: [en, ru] # from one to any number of codes
languages: from one to N codes such asen,ru,en-gb. A repeated code or an empty list isE011;default_langmust be inlanguages, otherwiseE011. The default language is served from the root (/about/), the others with a prefix (/ru/about/, or/ru/o-nas/withslug: o-nas);- each language has its content tree,
content/LANG/. HERON does not read folders of languages that are not inlanguages; - pages are matched across languages by key: the relative folder path plus the file name.
services/design.mdin two trees is one page in two languages, whateverslugeach has; - one page, one language. Mixing languages inside a file is not allowed.
Slugs may differ between languages (design / dizajn); the link goes by key, both when the slug comes from the file name and when it is set with the slug field.
What depends on the number of languages
| One language | Two or more | |
|---|---|---|
| addresses | everything from the root | the default from the root, the others /LANG/… |
sitemap.xml |
the list of addresses itself (urlset) |
an index, plus sitemap-LANG.xml for each language in languages that has indexable pages |
hreflang in head |
not output | on every indexable page: all its language versions, itself included, plus x-default |
xhtml:link in the sitemap |
none | for pages that exist in at least two languages |
page.translations |
always empty | the page's versions in other languages, by key |
| files for AI | /llms.txt: the full list of pages, /llms-full.txt: full texts. No per-language files |
/llms.txt: a table of contents, /llms-LANG.txt and /llms-full-LANG.txt for each language |
| language switch | not output by the theme (languages of length 1) |
one item per language; if there is no translation, the item is inactive |
| nginx | one root location | plus a /LANG/ location for each non-default language, with its own 404 |
x-default points to the default language version. If the page has no such version, x-default is not output at all: pointing a search engine to some other page is worse than saying nothing.
No translation: the page simply does not exist in that language, and its item in the switch is inactive. Silently sending people to the home page or showing a 404 is forbidden. The theme gets the list of codes as languages, the current language as lang, the page's versions as page.translations[code].
A page only in a non-default language
Such a page is built as usual:
- it goes into the sitemap of its language;
hreflangpoints to itself, with nox-default;- a warning goes to the report, group "translations": "the page exists in
rubut not in the default language". While the default language is entirely empty, this warning is not repeated for every page; one line about the empty language is enough.
An empty language
The language is declared in languages, but content/LANG/ does not exist or has no pages:
| Situation | Draft build | Final build (--env prod) |
|---|---|---|
| a non-default language is empty | a warning in the "languages" group | the same warning, the build continues |
| the default language is empty, others are filled | a warning, the build continues | E021 with the language code: the site root would answer 404 |
| all languages are empty | a warning, the build continues | E017 |
So a second language can be filled before the main one: a draft build shows what is ready, and a site without a home page never goes live.
Source
This page mirrors sections 0–3 of docs/spec/20-data-contract.md on the prod branch. The repository is updated first: open the file on GitHub.