HERON

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.html in the starter); HERON only decides whether it may: the template gets the env.analytics flag. It is on in the final build (--env prod) and off in all others; the SITE_ANALYTICS=0|1 environment variable overrides it. Draft builds output no counters, so test visits never reach the live site's statistics;
  • the starter theme outputs ga4 and gtm. It does not output Yandex Metrica: HERON accepts the metrika key, 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_LANG beats 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.languages is E011;
  • the template gets ready values for the page's language: site.site.name, site.seo.title_suffix, site.seo.og_default_image are already substituted, no filter needed. schema block substitutions ({{ site.name }}), llms*.txt headings 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 from media/;
  • the other core keys do not depend on language;
  • site blocks (contact, organization, schema and your own) are translated with the same suffix, at any depth and in lists: the template of a ru page sees site.contact.address = address_ru; if there is only address_ru, address appears on the Russian page. Keys with the suffix stay available. schema block 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, no git, 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.entity is declared, it is on every indexable page; if it declares address.addressLocality or geo, 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_from is also E022: 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.