Data contract · part 2
Page format
The second part of the data contract: what a page's markdown file consists of — the SEO block, the front matter fields, sections and their forms.
This page is for people who write pages by hand or hand them to an agent, and for theme authors: a theme receives exactly what is described here.
In plain words
Every page is a text file in two parts. The top part, between two lines of ---, is the page's ID card: its title for search results, its heading, a short description. The bottom part is the text visitors read, split into sections.
A section starts with a line like ## Our prices {#prices}. The words are the heading people see. The part in curly brackets is a short label (an anchor) that tells the theme where to put this block. You can rename the heading as often as you like; the label stays.
Example. A complete small page:
---
title: "Roof repair in Vienna: prices and terms"
h1: "Roof repair"
description: "Roof repair in Vienna: what we fix, how long it takes, how much it costs."
nav_title: "Roofs"
---
We repair flat and pitched roofs in Vienna and around it.
## What we fix {#what}
- Leaks after storms.
- Old coatings and gutters.
## Questions {#faq}
### How long does it take?
One to three days for most roofs.
HERON looks at how a section is written and decides its form: the list above becomes a list, the ### questions become an accordion. You never choose the form in a setting; see the forms reference with live examples.
When you need this page. When you write pages, check why a field is required, or wonder why a section is drawn the way it is.
The file at a glance
Every file starts with the SEO block, and only then comes the content. The order is mandatory and the same in all files of all types.
The SEO block is the front matter between ---, split into eight numbered groups by comment frames and commented line by line. A person opening the file for the first time should understand what each line does without looking at this document.
The groups and their order:
| # | Group | What is inside |
|---|---|---|
| 1 | Main | title description h1 canonical robots |
| 2 | Search queries | a comment only: the main query, the extra ones, how many in total |
| 3 | Open Graph | og_type og_title og_description og_image og_image_alt |
| 4 | Twitter / X | twitter_card twitter_title twitter_description twitter_image |
| 5 | Schema.org | the schema block: markup refinements for this page |
| 6 | Card and catalog | image image_alt order children_type type |
| 7 | Service | updated published priority |
| 8 | Links | link fields declared by the theme, plus redirect_from |
heron new and heron init put the block with all eight groups and comments into the starter pages (index.md, 404.md), and heron page puts it into every new one. A new page is a copy of a file with the values changed; the comments stay as they are, and build.notes (ru or en) sets their language.
What lives in site.yaml, not in the page file: the site name (per language, see values per language), the locale, twitter:site, og_default_image, organisation details for markup, social links for sameAs, the title suffix. The page holds only what is its own.
What HERON generates itself and you never write in the file: hreflang for every existing language version plus x-default (rules in languages), canonical from the page's own address, BreadcrumbList, FAQPage when there is a faq section, datePublished from date, dateModified from updated, the entry in sitemap.xml and in llms.txt.
Rules of the block:
- the group order is fixed. A person finds what they need in the same place in any file;
- comments are mandatory and move from file to file unchanged. They are not decoration: the site is run by someone who is not a developer, and the hint must be at hand, not in a specification;
- group 2 is mandatory on every content page. HERON does not read it, but it holds the "one intent, one page" rule: to add someone else's cluster you first have to write it there and see that it needs its own page;
- the
keywordsmeta tag is not output. Google has ignored it since 2009, and it shows competitors the whole keyword set of the project; - empty optional fields stay with their comment rather than being deleted; otherwise in six months nobody will remember they can be filled;
twitter_*are filled only when they must differ fromog_*; HERON fills empty ones from Open Graph.
Then comes the content. Text before the first heading becomes the intro section, followed by sections like ## Title {#anchor}, with a module class when needed: {#red-flags .alert}, {#timeline .timeline}.
## What it is {#what}
A short explanation in plain words…
## Who it suits {#who}
- First sign
- Second sign
Front matter
YAML between ---. This is the single authoritative list of fields: the page model (build, section 6) and the heron page sample follow it.
The "core" column says what HERON does with the field: yes — it reads and checks it itself (E002: a required field is missing or has the wrong type; E001: the front matter does not parse as YAML); theme — HERON does not interpret the field, it passes straight through and the template reads it as page.meta.FIELD. The "group" column is the SEO block group number from the table above.
| Field | Type | Default | Core | Group | What it does |
|---|---|---|---|---|---|
title |
string | — required | yes; over 60 characters is a warning | 1 | the window and search title (the title tag) |
h1 |
string | — required | yes | 1 | the heading on the page, name in markup |
description |
string | — required | yes; over 155 characters is a warning | 1 | meta description, the description in llms.txt and in markup |
slug |
string | the file name | yes: Latin letters, digits, hyphen | 1 | the last address segment; does not change the key (key and address) |
canonical |
string | the page's own address | theme | 1 | the theme outputs it if set |
robots |
string | — | theme | 1 | the theme outputs it; HERON decides indexing by noindex |
og_title |
string | title |
yes | 3 | title on social networks |
og_description |
string | description |
yes | 3 | description on social networks |
og_image |
path in media/ |
seo.og_default_image |
yes: the file must exist (E007) |
3 | 1200×630 picture, not resized |
og_type, og_image_alt |
string | — | theme | 3 | |
twitter_card, twitter_title, twitter_description, twitter_image |
string | from og_* |
theme | 4 | filled only if they must differ from og_* |
schema |
map | — | yes: merged into JSON-LD on top of site.yaml |
5 | markup refinements for the page (schema block) |
image |
path in media/ |
— | yes: E007 if the file is missing |
6 | preview in the catalog and in link cards |
image_alt |
string | — | yes: a warning if there is image without alt |
6 | |
nav_title |
string | the file name | yes | 6 | the short name in the menu and breadcrumbs (see below) |
order |
integer | 999 |
yes | 6 | order in catalogs and menus, ascending |
type |
string | from the folder, see below | yes: the type must have a template (E008) |
6 | the page type: which template draws it |
children_type |
string | — | yes; read only in _index.md |
6 | the type of the section's children |
video |
path in media/ or a full address |
— | yes: the file must exist | 6 | the page's video, page.video (video) |
video_poster |
path in media/ |
— | yes: required with a local video (E019) |
6 | |
video_title |
string | — | yes | 6 | |
date |
date YYYY-MM-DD |
— | yes | 7 | publication date: datePublished in markup, order and publish date in the feed; page.date in the template |
updated |
date YYYY-MM-DD |
see build.updated_from |
yes | 7 | the caption, lastmod, dateModified |
published |
true/false |
true |
yes | 7 | false: the page is not built and appears nowhere (--drafts turns it on) |
noindex |
true/false |
false |
yes | 7 | robots: noindex, left out of the sitemap and llms.txt |
priority |
number | — | theme | 7 | HERON does not output it in the sitemap |
redirect_from |
list of paths | [] |
yes: a path from the root, normalised as in old addresses | 8 | old addresses that 301 to this page |
| link fields | list of names or {slug, note} maps |
— | yes, if declared by the theme in links: the name must be found (E006) |
8 | links between pages (theme.yaml) |
gone |
list of paths | — | outdated: read with a warning | 8 | move it to gone in site.yaml |
| any of your own | anything | — | theme | — | in the template page.meta.get('field'); an empty field does not fail the build; all your own fields at once are page.meta.extra. No need to declare them anywhere: fields in theme.yaml is only for HERON's fields (what a theme expects) |
"Theme" fields in groups 1, 3, 4 and 7 are part of the SEO block the starter writes: fill them where the theme can output them. HERON does not decide for the theme how to draw them and does not complain about empty ones.
redirect_from is the working tool for moving from WordPress: the old address is written next to the new content, not in a separate table that will drift apart.
Page type
The type picks the template: a page of type service is drawn by theme/templates/service.html (or by the template the theme names in types.service.template). Where the type comes from, strongest first:
- the
typefield in the file itself, for any page, including a single page outside a section (about.mdwithtype: aboutis drawn bytemplates/about.html); - for
_index.md, thetypeof that same file; for a plain page,children_typefrom the_index.mdof its folder; - for the
_index.mdof a subsection,children_typeof the parent folder; - the home page (
index.mdin the language root):home; - otherwise:
page.
Example. A services section where every page is drawn by the service template, and one page that asks for its own:
content/en/services/_index.md children_type: service
content/en/services/roof.md (no type: becomes service)
content/en/services/gutters.md (no type: becomes service)
content/en/about.md type: about → templates/about.html
If the theme has no template for the resulting type, the error is E008.
The home page key
A page key is the folder path plus the file name without extension (key and address). The home page, index.md in the language root, has the key /. One rule everywhere:
- in the menu:
nav: {main: ["/", about, services]}; - in a link from text:
[home](/), the home page in the page's language; - in a template:
page_of('/')is the home page in the current page's language,page_of('services')is any other page by key; if there is no such page,none; - in
schemablock substitutions:{{ pages.home.url }}. A substitution path is written with dots and cannot contain/, so here the home page is calledhome. Ahome.mdfile in the language root is not available to substitutions; HERON warns about it.
Publication date
date is when the page came out, YYYY-MM-DD, optional. Do not confuse it with published, the "build this page at all" flag, or with updated, the date of the last edit.
date: 2026-03-14 # came out
updated: 2026-09-02 # last edited
- markup:
datePublishedfromdate,dateModifiedfromupdated(or frombuild.updated_from). No field, no key in the markup: HERON will not invent a date; - the feed (
seo.feed) is sorted bydate; a page without it sorts by its update date; an entry withdatehas a publish date (published); - in the template:
page.date(a date object,{{ page.date.isoformat() }}or your own format).
Date format for machines. In markup, lastmod and Atom the date goes out as a full ISO 8601 date with time and time zone, as Google requires (Search Console rejects dateModified: 2026-10-14). Commit time (build.updated_from: git) is as git log -1 --format=%cI gives it, for example 2026-10-17T11:30:12+02:00. A day without time — date and updated from front matter, and the file source — becomes midnight UTC: 2026-10-14T00:00:00+00:00. The caption on the page and page.date/page.updated in the template stay a day; the full moment is page.date_at/page.updated_at.
Checks. A date later than the build moment is a warning with the file path (a search engine would treat it as an error), and an error with --strict. A manual day is compared as midnight UTC, exactly what goes into the markup. An update earlier than publication is also a warning; the order is compared by day, so an edit early in the morning on publication day in your local time zone is not an error.
Update date
updated in front matter is what a person set. It beats everything, always: some pages have a date that is a fact, not a build stamp, and a rebuild must not move it.
If it is not set, the site chooses the source with the build.updated_from key:
| Value | Where the date comes from |
|---|---|
manual (default) |
nowhere. No field, no date, as always |
git |
the time of the last commit that touched the file, with the commit's time zone (%cI) |
file |
the file's time on disk |
The resolved value is one and feeds all three at once: the caption under the text (page.updated in the template), lastmod in the sitemap, dateModified in the markup. By design they cannot drift apart.
The main thing to know about file: a file's time is the time it was written to disk, not the time the text was edited. Copying without keeping attributes (cp without -a, unpacking an archive, uploading through a hosting panel, a fresh clone) gives every file the same time, and the site will declare itself fully updated without a single letter changed. Search engines stop trusting such a sitemap, and the caption under the text lies to people too. So HERON recognises this: if all pages have the same time to the minute, it is a copy, and no date is set at all. No date is more honest than an invented one.
git needs no such caveats: history stores when the content changed, not the file, and survives copying, cloning and moving.
Sections
A section is a block of content with an identifier. The syntax is an ordinary markdown heading with an anchor:
## What it is {#what}
A section is everything from this heading to the next heading of the same level with an anchor, or to the end of the file. Nested headings (###) belong to the section.
Rules:
- identifier: Latin letters, digits, hyphens;
- the order of sections in the file does not matter;
- the same identifier twice in a file is a build error;
- text before the first section is the intro block, available to the theme as
intro; - a section the theme does not use is not an error; it goes to the
heron checkreport; - a section the theme expects but the file lacks is not an error either: the value is empty, the block is not rendered, and the report gets a line "the theme expects section X, the file does not have it".
A section title is content the visitor sees. The anchor is an address for the theme. Keeping them apart is a principle: titles get edited, addresses do not.
What a section is made of
A section reaches the theme not as a flat piece of markup but taken apart. A layout cuts a page finer than a heading and the text below it: the lead in one size, a note in another, a quote as an inset.
| Field | What it is |
|---|---|
id |
the anchor from {#anchor} |
title |
the heading text |
classes |
classes from the anchor: .alert, .timeline |
lead |
paragraphs before the first list, table or ### group |
body |
the main node of the section, parsed by its form |
note |
paragraphs after the main node |
note_title |
the bold opening of the first note paragraph, if there is one |
callout |
the content of a > quote, wherever it is in the section |
links |
the section's links as a {title, href} list |
No list, no table, no groups: the whole section is a body of the "prose" kind, and lead and note are empty.
Bold opening. A paragraph like **Text up to a full stop.** Then ordinary text is split in two: the opening goes to note_title (or to the term of a list item) and is removed from the paragraph body. The sign is a bold fragment at the very start of the paragraph ending with a full stop, colon or dash.
A list item like **Term.** Explanation is parsed into a {term, text} pair. An item without an opening in such a list is a pair with an empty term. A list where no item has an opening stays a list of strings.
Markup inside values is kept: **bold** and [text](link) reach the theme as markup, not escaped text, including links inside a bold fragment. Links are also collected into a separate links list of the element they appear in; the theme may draw them both in the text and as a row of chips.
The intro block
Text before the first heading is split into paragraphs:
| Field | What it is |
|---|---|
lead |
the first paragraph |
promise |
the second paragraph, optional |
intro |
the whole intro block, as before |
Three paragraphs or more is a heron check warning with the file name: the third and later do not reach the template. intro is kept so as not to break themes written before this rule.
Section forms
Some modules expect data, not prose. There is no special syntax: all of it is valid markdown, readable by eye and in any editor. HERON determines a section's form from its content or from a class in the anchor and gives it to the theme as section.kind, and the parsed data as section.data (items in a module template).
How each form looks on the site, with live examples, is in the forms reference.
Forms table
kind |
Markup | Sign | What the theme gets (items) |
Example |
|---|---|---|---|---|
prose |
any text | nothing below matched; or the .prose class |
an html string of the whole section (same as html) |
Just a paragraph. |
list |
bulleted list | - item |
a list of html strings. If at least one item has a bold opening **Term.**, a list of pairs: an html string with .term, .text, .links |
- one / - **Term.** Explanation |
alert-list |
bulleted list | the .alert class only |
as list |
## Warning signs {#flags .alert} |
checklist |
### groups, each with a list |
three or more groups with lists; or the .checklist class |
a list of groups {title, html, items, has_list}; items as in list |
### Documents + - passport, three groups |
two-lists |
### groups, each with a list |
exactly two groups with lists; or the .two-lists class |
as checklist |
### Suits / ### Does not suit |
steps |
numbered list | 1. item |
a list of {n, tag, title, text, html, links, top} |
1. **[MOST OFTEN] Request.** Text |
table |
markdown table | there is a separator row |---| |
{head: [cells], rows: [[cells]]} |
| What | Where | + |---|---| |
facts |
Key | Value lines |
lines with | without a separator row |
a list of {key, value} |
Format | in person |
faq |
### groups without lists |
### headings, none has a list |
a list of {q, a}; a is the answer's html |
### How long does it take? + About a month. |
timeline |
Period | Event lines or a numbered list |
the .timeline class only |
as facts ({key, value}) or as steps |
## Recovery {#recovery .timeline} |
callout |
a > quote |
the section has a quote and no list; or the .callout class |
the quote's html string | > If time is short, say so right away. |
gallery |
bulleted list of images | the .gallery class only |
as list |
-  |
Besides items, every section has its parsed parts — lead, body, note, note_title, callout, links (see "What a section is made of" above) — and the whole html.
Recognition order
A class in the anchor beats the content. Without a class, the form is set by the first rule that fits, in this order:
table: a markdown table with a separator row;facts:key | valuelines without a separator row;steps: a numbered list;###groups with lists: exactly two givetwo-lists, three or more givechecklist. One group with a list is not a form and moves on down the order (usually tolist, and the group heading does not reachitems; you need the.checklistclass);faq:###groups, none with a list;list: a bulleted list;callout: a>quote, if the section has no list. A "list plus a tip" section stayslist, and the tip is available to the theme assection.callout;prose: everything else.
How similar forms differ
checklistandtwo-listsare written the same way, as###groups with lists. The number of groups (two, or three and more) or a class tells them apart. The meaning differs: two columns contrast ("suits / does not suit"), a checklist enumerates ("documents / extra / for the visit"), and the theme draws them differently;factsandtimelineare written the same way, askey | valuelines. You cannot tell them apart by content: without a class it is alwaysfacts, a timeline needs the.timelineclass;factsandtable:factsis not a markdown table; it has no header and no separator row, and needs no leading|. Markdown renders it as a paragraph, and HERON parses it into pairs. As soon as a|---|---|row appears, it is atablewith a header and any number of columns.
Classes in the anchor that set the form
| Class | Form |
|---|---|
.alert |
alert-list |
.callout |
callout |
.checklist |
checklist |
.facts |
facts |
.faq |
faq |
.gallery |
gallery |
.list |
list |
.prose |
prose |
.steps |
steps |
.table |
table |
.timeline |
timeline |
.two-lists |
two-lists |
Separately, {.top} on a numbered list item marks the main step (without it, the first step is top). An unknown class is a warning, and the form is determined by content.
Broken input
Parsing fails softly: if a class is set but the content does not fit it (.steps over a bulleted list, .facts without a single line with |), the section is given as prose and the report gets the warning "class does not fit the content". In facts, lines without | among good ones are skipped with a warning. A missing bar in a table must not fail the build.
A step in more detail
A step can carry a tag and links:
1. **[MOST OFTEN] Without a visit.** A request and [a document check](/get-started/).
becomes {n: 1, tag: "MOST OFTEN", title: "Without a visit", text: "…", links: [{title, href}]}. The tag is a fragment in square brackets at the very start of the item, all in upper case; it is cut out of the text. No tag, empty field.
The theme names the expected form
The same section can take different forms on different pages: {#recovery} is a timeline on one page and prose on another. Both are valid; the theme gets section.kind and decides.
The theme may name the expected form in theme.yaml: uses: ["recovery:timeline"]. The form name must come from the table above, otherwise the theme will not load. A form mismatch on a specific page is not a build error but a line in the heron check report.
Optional sections
HERON does not know which sections pages have: that is the site's subject area. All sections of a type, required in meaning or optional, are declared by the theme in types.TYPE.uses (theme.yaml). A section from uses missing in the file is a line in the debt summary, not an error; a section in the file that is not in uses is a line in the heron check report ("the theme does not use it"), which catches a typo in an anchor.
Source
This page mirrors section 4 of docs/spec/20-data-contract.md on the prod branch. The repository is updated first: open the file on GitHub.