Data contract · part 3
site.yaml
The third part of the data contract: site.yaml, the site's only config. Everything that repeats on every page lives here, once.
This page is for whoever sets the site up: domain, languages, menu, counters, schema.org markup, files for AI, redirects and 410.
In plain words
site.yaml is the one settings file of your site. Everything that is the same on every page lives here once: the site name, the domain, the languages, the menu, your company details, analytics counters. Change a phone number here, and it changes on every page, in the structured data for Google and in the files for AI at the same time.
You do not need to fill in everything. The minimum is a name, a domain and the languages; the configurator (heron setup .) asks the questions and writes the file for you.
Example. A small but complete site.yaml:
heron: ">=0.1,<0.2" # which HERON versions may build this site
site:
domain: example.com
name: "Smith Roofing"
theme: main
default_lang: en
languages: [en, de]
organization:
name: "Smith Roofing GmbH"
about: "Roof repair in Vienna."
about_de: "Dachreparatur in Wien."
nav:
main: [services, prices, contacts]
A key ending in a language code (about_de) is the value for that language; HERON picks the right one on every page by itself.
When you need this page. When you set up a site, add a language, connect analytics, describe your company for search engines, or remove old pages with a «gone» answer.
At a glance
An address typed by hand into the header and the footer drifts apart sooner or later. That is exactly the problem this file solves.
heron: ">=0.1,<0.2" # the HERON version the site requires. Mandatory
site:
domain: example.com
theme: main # theme name. A local theme/ overrides it
default_lang: en
languages: [en, ru]
name: Site name
# HERON does not parse the blocks below: they go to the templates whole.
# The theme invents their names and content, see "What the core checks and what it passes through".
contact:
phone: "+00 000 000 0000"
address: "street and number"
city: City
hours_display: "Mon–Fri 09:00–18:00"
organization:
name: Organisation name
same_as:
- https://example.com/social
seo:
title_suffix: " — Site name"
og_default_image: media/examples/og.jpg
twitter_card: summary_large_image
llms_txt: true
llms_note: # facts about the site for files that AI reads
en: |
### How to reach us
Phone: +00 000 000 00 00, 9 to 18.
Appointments by phone only.
ru: |
### Как с нами связаться
Телефон: +00 000 000 00 00, с 9 до 18.
Запись — только по телефону.
robots_extra: []
feed: article # page type for the feed. No key, no feed
nav:
main: [about, services, contacts]
build:
fail_on_warning: false
allow_raw_html: false # raw HTML in markdown. Forbidden by default
updated_from: manual # where the update date comes from: manual | git | file
workers: 0 # processes for resizing images. 0 = one per core
plugins: [] # names of installed plugins
gone: # addresses the server answers 410 to. See below
- /sample-page/
- /wp/*
- /?p=*
# heron-relay receives forms, not the builder. HERON needs the description to
# give the theme the list of fields and to stop if the theme draws a form missing here
forms:
callback:
fields: [name, phone, message]
sinks: [mail, crm]
sinks:
mail: {to: info@example.com, smtp_env: SMTP_URL}
crm: {webhook: https://crm.example.com/hook, secret_env: CRM_SECRET}
forms and sinks are read by heron-relay, not by HERON. HERON uses them only to give the theme the form description for markup and to stop if the theme draws a form that is not in the config.
Values from contact and organization go at the same time into the page markup, JSON-LD and theme blocks. They change in one place, and that is what keeps the address from drifting between header and footer.
The analytics block
Public counter IDs. This is a core key and it is checked: an unknown key is E011.
analytics:
ga4: G-XXXXXXX # Google Analytics 4
gtm: GTM-XXXXXX # Google Tag Manager
metrika: 12345678 # Yandex Metrica, number or string
- these are not secrets: the IDs are visible in the source of any page. That is why they are in
site.yaml, not in.env; - the theme outputs the counters (
partials/analytics.htmlin the starter); HERON only decides whether it may: the template gets theenv.analyticsflag. It is on in the final build (--env prod) and off in all others; theSITE_ANALYTICS=0|1environment variable overrides it. Draft builds output no counters, so test visits never reach the live site's statistics; - the starter theme outputs
ga4andgtm. It does not output Yandex Metrica: HERON accepts themetrikakey, but outputting it is up to the site's theme that needs it.
seo.llms_note — facts about the site for AI
llms*.txt files consist of page titles and descriptions. What a reader needs first — how to get in touch, when you work, what the site does not do — is not in them: on the site these facts are spread across pages, and only h1 and description reach the list.
seo.llms_note is ready markdown per language code, which HERON puts into that language's files word for word. No substitutions or templating: this is data, not a template. If a language is not in the map, its files have no block, silently: a site may describe only some languages.
On size: a model with a limited context reads the file, and everything you put there takes space from the site's content itself. A few lines of facts is what you need; a page of text is not.
On heading level: start the block with ### or lower. A # at the same level as the file heading is formally allowed but reads as the start of a new document.
The block is for facts about the site, not for instructions to the reading model. A consumer may treat text loaded from someone else's site as data, not instructions, and rightly so. Trying to steer someone else's agent through this file is useless and looks like instruction injection: such a site loses trust entirely, honest part included.
Example. A short note in two languages:
seo:
llms_note:
en: |
### How to reach us
Phone: +43 1 234 56 78, Monday to Friday, 9 to 18.
Appointments by phone only.
ru: |
### Как с нами связаться
Телефон: +43 1 234 56 78, с понедельника по пятницу, с 9 до 18.
Запись — только по телефону.
Where the block goes, word for word, without templating:
| File | Text of which language | Place |
|---|---|---|
/llms-LANG.txt |
that language | after the heading and description, before the list of pages |
/llms-full-LANG.txt |
that language | after the heading, before the page texts |
/llms.txt on a multilingual site |
the default language | after the description, before the list of language versions |
/llms.txt and /llms-full.txt on a single-language site |
the only language | as in the language files |
What llms.txt is and why a site needs it is on the llms.txt page.
Values per language
A site name is written differently in different languages, and a title suffix in the wrong language in search results is a defect. For three core keys, a value is set per language with the _LANG suffix:
site:
name: Daybreak
name_ru: Рассвет
seo:
title_suffix: " — Daybreak"
title_suffix_ru: " — Рассвет"
og_default_image: media/examples/og.jpg
og_default_image_ru: media/examples/og-ru.jpg
| Key | Per language |
|---|---|
site.name |
site.name_LANG |
seo.title_suffix |
seo.title_suffix_LANG |
seo.og_default_image |
seo.og_default_image_LANG |
KEY_LANGbeats the key without the suffix; a language without its own value gets the value without the suffix;- a suffix for a language not in
site.languagesisE011; - the template gets ready values for the page's language:
site.site.name,site.seo.title_suffix,site.seo.og_default_imageare already substituted, no filter needed.schemablock substitutions ({{ site.name }}),llms*.txtheadings and the feed see the same; - both
og_default_image*pictures are declared by the site: HERON checks they exist and puts them into the build like any file frommedia/; - the other core keys do not depend on language;
- site blocks (
contact,organization,schemaand your own) are translated with the same suffix, at any depth and in lists: the template of arupage seessite.contact.address=address_ru; if there is onlyaddress_ru,addressappears on the Russian page. Keys with the suffix stay available.schemablock substitutions (below) follow the same rule.
The starter theme (heron new) already outputs the window title with the suffix, og:*, and the name in the header and footer from these values.
The build block
| Key | Values | Default | What it does |
|---|---|---|---|
notes |
ru | en |
en |
language of the comments and notes that heron init and heron page write. Overridden by the --notes flag and the HERON_NOTES variable. Does not affect the build |
fail_on_warning |
true/false |
false |
warnings fail the build, like --strict |
allow_raw_html |
true/false |
false |
raw HTML in markdown; with false, E015 |
updated_from |
manual | git | file |
manual |
where a page's update date comes from if updated was not set by hand |
workers |
0–64 | 0 |
processes for resizing images; 0 means one per core |
Example. Dates from git history and warnings that fail the build, as on a live site:
build:
updated_from: git
fail_on_warning: true
updated_from. updated in front matter always beats the automatic value. Without it:
manual: no date at all;git: the committer time of the last commit that touched the file, with its time zone (git log --format=%cI); the caption on the page is the day of that time. A file with uncommitted edits, or not in history, gets the file time and a warning with the path. History unavailable — not a repository, nogit, a shallow clone (--depth 1) — the build does not fail: HERON takes file times and writes one warning per build. In CI this mode needs a full clone (fetch-depth: 0, deployment, section 4);file: the file's time on disk. If all pages have the same time to the minute, it is a copy, not edits, and no date is set at all.
Details of the modes: update date.
The schema block: markup without duplicated data
What to mark up is decided by the theme (types.TYPE.jsonld) and by the page itself (the schema block in front matter). What to fill it with comes from site.yaml, the schema block. HERON substitutes what it knows itself: name, description, url, inLanguage, dateModified, breadcrumbs and questions and answers.
schema:
entity: # the site-wide node
"@id": "#owner"
"@type": Person
name: "{{ organization.name }}"
description: "{{ organization.about }}"
telephone: "{{ contact.phone }}"
address:
"@type": PostalAddress
streetAddress: "{{ contact.street }}"
addressLocality: "{{ contact.city }}"
addressCountry: "{{ contact.country }}"
geo:
"@type": GeoCoordinates
latitude: "{{ contact.geo.0 }}"
longitude: "{{ contact.geo.1 }}"
Substitutions, not copies. {{ contact.city }} is read from the same site.yaml the theme takes the value from. No second copy of the address is made: nobody notices a drifted address in markup by eye; only the search engine sees it.
The language is substituted by itself. On a Russian page {{ contact.address }} is address_ru, on an English one address_en; with no language key, the key without the suffix is used. One block works for all languages.
Lists unfold with an asterisk. {{ contact.social.*.href }} is a list of addresses from a list of {href, name} maps. Otherwise social links get retyped into markup by hand and forgotten when they change.
Page addresses and the base address are available to substitutions: {{ site.base }} is the site root, {{ pages.KEY.url }} the address of the page with that key in the current page's language, {{ pages.KEY.image }} its picture, {{ pages.KEY.h1 }}, .title, .description. No need to type a page address by hand: it changes with the slug, and nobody rereads the markup after a rename. In substitutions the home page is called home (elsewhere its key is /, see the home page key).
The page's links are available to substitutions: {{ related.topics.*.url }} gives the addresses of linked pages, plus .h1, .note, .description. HERON has already built the list by the theme's rules, and writing the same links again in front matter means a second list that will drift from the first by the third edit.
An anchor @id becomes absolute. HERON turns "@id": "#owner" inside a page node into https://domain/#owner: a relative identifier resolves against the document address, so each page would get its own entity, and the link between nodes would silently fall apart.
What is missing stays missing. A key with no value found is not output at all. An empty field in markup is worse than a missing one: the search engine treats it as declared.
The site-wide node goes onto every indexable page in full, not as a reference: pages are independent, and a crawler does not have to visit the home page first. There is one @id for the whole site and all languages: it is one entity, not three. A noindex page has no node: markup is pointless there.
Build checks (E020):
- each page's markup parses as JSON;
- the type declared by the theme is present in the graph;
- if
schema.entityis declared, it is on every indexable page; if it declaresaddress.addressLocalityorgeo, their values are not empty (a substitution that found no data means someone forgot to fill it). A node with no address at all — a site without an office — is fine: HERON does not decide the subject area; - money fields —
offers,price,priceRange,priceCurrency,paymentAccepted,lowPrice,highPrice,priceSpecification— are forbidden until the site allows them explicitly:
seo:
allow_prices: false # the default. true allows prices in markup
The default ban is not data forgetfulness but protection: if the site does not publish prices on its pages, they must not leak through markup. A site that publishes prices sets allow_prices: true.
The report after the build counts pages by node type, so you can see the markup arrived.
The nginx block
nginx:
hash_sizes: auto # auto: HERON writes map_hash_* into the site config; off: the host sets them
A core key, checked (E011). Details: the distribution spec, "The 404 page and serving", and the deployment guide, "Your own nginx on a server".
The gone block: 410 for what no longer exists
After a move, or after a hack, there are addresses that have no page and never will: category archives, pagination, service paths of the old system, doorway pages. The server answers them 410 Gone: "this is gone, drop it from the index". A 404 tells a search engine "it may come back", and it keeps checking the address for months.
These addresses have no page, so they live in site.yaml under the core key gone (checked, error E011). Forms of entry:
| Entry | What it closes |
|---|---|
/sample-page/ |
exactly this address |
/wp/* |
everything starting with /wp/: /wp/, /wp/wp-login.php, /wp/a/b/c/ |
/?p=* |
the address / with a parameter p of any value: /?p=123, /?utm=1&p=5 |
/?p=12 |
the address / with the parameter p equal to 12 |
- an asterisk is allowed only at the end of a path or as a whole parameter value. One parameter per entry. An anchor (
#…) never reaches the server and is forbidden in an entry; - an overlap with an existing page's address (exact or by prefix) is
E022, and the build stops. A site cannot have a page and declare it gone at the same time; - an overlap with any page's
redirect_fromis alsoE022: an address either moved (301) or vanished (410); - a parameter entry does not overlap a page address:
/?p=*does not close the home page, only the home page with that parameter; - the same entry twice is a warning;
- Cyrillic and percent-encoding follow the same rules as
redirect_from(old addresses).
Example. An old WordPress site had a sample page, the whole /wp/ folder and short links like /?p=123. In site.yaml of the new site:
gone:
- /sample-page/
- /wp/*
- /?p=*
Now a search engine that visits any of these addresses hears «gone for good» and stops checking them.
The server answers 410 before touching any files: HERON writes the list as a map into the nginx config it builds with the site (distribution spec, "The 404 page and serving"). A human-readable copy is dist/gone.map.
The gone field on a page is outdated: an address that no longer exists has no page to honestly belong to. It is still read, and the addresses go into the same list, but each such page gives a "move it to site.yaml" warning.
What the core checks and what it passes through
Clarified on 2026-09-14, after implementing the contracts.
HERON strictly checks only its own keys: heron, site, seo, nav, build, analytics, plugins, forms, sinks, gone. A typo inside them stops the build with E011.
Blocks HERON does not know — contact, organization, any of your own — pass straight through and reach the templates as they are. HERON does not go into their meaning: otherwise it would start knowing the subject area of every site separately, and the boundary this whole design rests on would vanish.
We do not lose presence checks, though. The theme declares what it needs in theme.yaml:
requires:
- contact.phone
- organization.name
forms:
- callback
HERON checks that these paths are filled in site.yaml, and stops with a clear list if they are not. The theme asks, the site gives, HERON checks, without understanding the subject area.
JSON-LD types moved there too: types.TYPE.jsonld is set in theme.yaml. The mapping "this page type → this schema.org type" belongs to the theme: it differs for a services site, a shop and a directory, and a HERON module does not have to know it.
Source
This page mirrors section 5 of docs/spec/20-data-contract.md on the prod branch. The repository is updated first: open the file on GitHub.