HERON

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 keywords meta 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 from og_*; 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:

  1. the type field in the file itself, for any page, including a single page outside a section (about.md with type: about is drawn by templates/about.html);
  2. for _index.md, the type of that same file; for a plain page, children_type from the _index.md of its folder;
  3. for the _index.md of a subsection, children_type of the parent folder;
  4. the home page (index.md in the language root): home;
  5. 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 schema block substitutions: {{ pages.home.url }}. A substitution path is written with dots and cannot contain /, so here the home page is called home. A home.md file 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: datePublished from date, dateModified from updated (or from build.updated_from). No field, no key in the markup: HERON will not invent a date;
  • the feed (seo.feed) is sorted by date; a page without it sorts by its update date; an entry with date has 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 check report;
  • 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 - ![caption](media/examples/photo.jpg)

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:

  1. table: a markdown table with a separator row;
  2. facts: key | value lines without a separator row;
  3. steps: a numbered list;
  4. ### groups with lists: exactly two give two-lists, three or more give checklist. One group with a list is not a form and moves on down the order (usually to list, and the group heading does not reach items; you need the .checklist class);
  5. faq: ### groups, none with a list;
  6. list: a bulleted list;
  7. callout: a > quote, if the section has no list. A "list plus a tip" section stays list, and the tip is available to the theme as section.callout;
  8. prose: everything else.

How similar forms differ

  • checklist and two-lists are 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;
  • facts and timeline are written the same way, as key | value lines. You cannot tell them apart by content: without a class it is always facts, a timeline needs the .timeline class;
  • facts and table: facts is 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 a table with 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.