Контракт данных · часть 2
Формат страницы
Вторая часть контракта данных: из чего состоит markdown-файл страницы — SEO-блок, поля фронтматтера, секции и их формы.
Эта страница нужна тем, кто пишет страницы руками или поручает это агенту, и тем, кто делает тему: тема получает ровно то, что здесь описано.
Простыми словами
Каждая страница — текстовый файл из двух частей. Верхняя часть, между двумя строками ---, — паспорт страницы: заголовок для поисковой выдачи, заголовок на самой странице, короткое описание. Нижняя часть — текст, который читают посетители, разбитый на секции.
Секция начинается со строки вида ## Наши цены {#prices}. Слова — заголовок, который видят люди. Часть в фигурных скобках — короткая метка (якорь), по которой тема понимает, куда поставить блок. Заголовок можно переименовывать сколько угодно, метка остаётся.
Пример. Небольшая, но полная страница:
---
title: "Ремонт крыши в Вене: цены и сроки"
h1: "Ремонт крыши"
description: "Ремонт крыши в Вене: что чиним, сколько длится, сколько стоит."
nav_title: "Крыши"
---
Ремонтируем плоские и скатные крыши в Вене и окрестностях.
## Что чиним {#what}
- Протечки после бурь.
- Старые покрытия и водостоки.
## Вопросы {#faq}
### Сколько длится ремонт?
От одного до трёх дней для большинства крыш.
HERON смотрит, как написана секция, и сам определяет её форму: список выше станет списком, вопросы ### — раскрывающимся блоком. Форму никогда не выбирают в настройках; живые примеры всех форм — в справочнике форм.
Когда нужна эта страница. Когда вы пишете страницы, выясняете, почему поле обязательное, или удивляетесь, почему секция нарисована именно так.
Общий вид файла
Каждый файл начинается с SEO-блока, и только потом идёт контент. Порядок обязателен и одинаков во всех файлах всех типов.
SEO-блок — это фронтматтер между ---, разделённый на восемь пронумерованных групп рамками из комментариев и прокомментированный построчно. Человек, открывший файл впервые, должен понять, за что отвечает каждая строка, не сверяясь с этим документом.
Группы и их порядок:
| № | Группа | Что внутри |
|---|---|---|
| 1 | Основное | title description h1 canonical robots |
| 2 | Ключевые запросы | только комментарий: основной ключ, дополнительные, сколько всего |
| 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 | блок schema — уточнения разметки для этой страницы |
| 6 | Карточка и каталог | image image_alt order children_type type |
| 7 | Служебное | updated published priority |
| 8 | Связи | поля связей, объявленные темой, плюс redirect_from |
Блок со всеми восемью группами и комментариями кладут heron new и heron init в стартовые страницы (index.md, 404.md) и heron page в каждую новую. Новая страница — копия файла с правкой значений; комментарии остаются как есть, их язык задаёт build.notes (ru или en).
Что живёт в site.yaml, а не в файле страницы: имя сайта (по языкам — см. значения по языкам), локаль, twitter:site, og_default_image, реквизиты организации для разметки, ссылки на соцсети для sameAs, суффикс title. В странице — только то, что у неё своё.
Что HERON генерирует сам и в файле не пишется: hreflang на все существующие языковые версии плюс x-default (правила — языки), canonical по собственному адресу, BreadcrumbList, FAQPage при наличии секции faq, datePublished из date, dateModified из updated, запись в sitemap.xml и в llms.txt.
Правила блока:
- порядок групп фиксирован. Человек находит нужное на одном и том же месте в любом файле;
- комментарии обязательны и переносятся из файла в файл без изменений. Это не украшение: сайт ведёт не разработчик, подсказка должна быть под рукой, а не в спецификации;
- группа 2 обязательна на каждой контентной странице. HERON её не читает, но она удерживает правило «один интент — одна страница»: чтобы дописать чужой кластер, надо сначала вписать его туда и увидеть, что ему нужна своя страница;
- мета-тег
keywordsне выводится. Google игнорирует его с 2009 года, а конкурентам он показывает всю семантику проекта; - пустые необязательные поля остаются с комментарием, а не удаляются — иначе через полгода никто не вспомнит, что их можно заполнить;
twitter_*заполняются только когда должны отличаться отog_*; пустые HERON подставляет из Open Graph.
Дальше — контент. Текст до первого заголовка становится секцией intro, затем идут секции вида ## Заголовок {#anchor}, при необходимости с классом модуля: {#red-flags .alert}, {#timeline .timeline}.
## Что это такое {#what}
Короткое объяснение простыми словами…
## Кому подходит {#who}
- Первый признак
- Второй признак
Фронтматтер
YAML между ---. Это единственный авторитетный список полей: модель страницы (сборка, раздел 6) и образец heron page следуют за ним.
Колонка «ядро» говорит, что HERON делает с полем: да — читает и проверяет сам (E002 — нет обязательного поля или значение не того типа; E001 — фронтматтер не разбирается как YAML); тема — HERON поле не трактует, оно проходит насквозь и доступно шаблону как page.meta.ПОЛЕ. Колонка «группа» — номер группы SEO-блока из таблицы выше.
| Поле | Тип | По умолчанию | Ядро | Группа | Что делает |
|---|---|---|---|---|---|
title |
строка | — обязательно | да; длиннее 60 — предупреждение | 1 | заголовок окна и выдачи (тег title) |
h1 |
строка | — обязательно | да | 1 | заголовок на странице, name в разметке |
description |
строка | — обязательно | да; длиннее 155 — предупреждение | 1 | meta description, описание в llms.txt и в разметке |
slug |
строка | имя файла | да: латиница, цифры, дефис | 1 | последний сегмент адреса; ключ не меняет (ключ и адрес) |
canonical |
строка | собственный адрес | тема | 1 | тема выводит, если задано |
robots |
строка | — | тема | 1 | тема выводит; индексацию HERON решает по noindex |
og_title |
строка | title |
да | 3 | заголовок в соцсетях |
og_description |
строка | description |
да | 3 | описание в соцсетях |
og_image |
путь в media/ |
seo.og_default_image |
да: файл обязан существовать (E007) |
3 | картинка 1200×630, не режется |
og_type, og_image_alt |
строка | — | тема | 3 | |
twitter_card, twitter_title, twitter_description, twitter_image |
строка | из og_* |
тема | 4 | заполняются, только если должны отличаться от og_* |
schema |
карта | — | да: сливается в JSON-LD поверх site.yaml |
5 | уточнения разметки страницы (блок schema) |
image |
путь в media/ |
— | да: E007, если файла нет |
6 | превью в каталоге и в карточках связей |
image_alt |
строка | — | да: предупреждение, если есть image без alt |
6 | |
nav_title |
строка | имя файла | да | 6 | короткое имя в меню и крошках (см. ниже) |
order |
целое | 999 |
да | 6 | порядок в каталогах и меню, по возрастанию |
type |
строка | из папки, см. ниже | да: у типа должен быть шаблон (E008) |
6 | тип страницы — какой шаблон её рисует |
children_type |
строка | — | да; читается только в _index.md |
6 | тип детей раздела |
video |
путь в media/ или полный адрес |
— | да: файл обязан существовать | 6 | ролик страницы, page.video (видео) |
video_poster |
путь в media/ |
— | да: обязателен при локальном video (E019) |
6 | |
video_title |
строка | — | да | 6 | |
date |
дата ГГГГ-ММ-ДД |
— | да | 7 | дата публикации: datePublished в разметке, порядок и дата выхода в ленте; в шаблоне page.date |
updated |
дата ГГГГ-ММ-ДД |
см. build.updated_from |
да | 7 | подпись, lastmod, dateModified |
published |
true/false |
true |
да | 7 | false — страница не собирается и нигде не появляется (--drafts включает) |
noindex |
true/false |
false |
да | 7 | robots: noindex, исключение из sitemap и llms.txt |
priority |
число | — | тема | 7 | в sitemap HERON его не выводит |
redirect_from |
список путей | [] |
да: путь от корня, нормализация — старые адреса | 8 | старые адреса, с которых 301 на эту страницу |
| поля связей | список имён или карт {slug, note} |
— | да, если объявлены темой в links: имя обязано найтись (E006) |
8 | связи между страницами (theme.yaml) |
gone |
список путей | — | устарело: читается с предупреждением | 8 | перенесите в gone в site.yaml |
| любые свои | любое | — | тема | — | в шаблоне page.meta.get('поле') — пустое поле не валит сборку; все свои поля разом — page.meta.extra. Объявлять их нигде не нужно: fields в theme.yaml — только для полей HERON (чего тема ждёт) |
Поля «тема» в группах 1, 3, 4 и 7 — часть SEO-блока, который кладёт заготовка: их заполняют там, где тема умеет их вывести. HERON не придумывает за тему, как их рисовать, и не ругается на пустые.
redirect_from — рабочий инструмент переезда с WordPress: старый адрес пишется рядом с новым контентом, а не в отдельной таблице, которая разъедется.
Тип страницы
Тип выбирает шаблон: страница типа service рисуется theme/templates/service.html (или шаблоном, который тема назвала в types.service.template). Откуда берётся тип, по убыванию силы:
- поле
typeв самом файле — для любой страницы, включая одиночную вне раздела (about.mdсtype: aboutрисуетсяtemplates/about.html); - для
_index.md—typeэтого же файла; для обычной страницы —children_typeиз_index.mdеё папки; - для
_index.mdподраздела —children_typeродительской папки; - главная (
index.mdв корне языка) —home; - иначе —
page.
Пример. Раздел услуг, где каждую страницу рисует шаблон услуги, и одна страница, которая просит свой:
content/ru/services/_index.md children_type: service
content/ru/services/roof.md (type нет: станет service)
content/ru/services/gutters.md (type нет: станет service)
content/ru/about.md type: about → templates/about.html
Шаблона для получившегося типа в теме нет — E008.
Ключ главной
Ключ страницы — путь папок плюс имя файла без расширения (ключ и адрес). У главной — index.md в корне языка — ключ /. Одно правило на всех:
- в меню:
nav: {main: ["/", about, services]}; - в ссылке из текста:
[на главную](/)— главная на языке страницы; - в шаблоне:
page_of('/')— главная на языке текущей страницы,page_of('services')— любая другая страница по ключу; нет такой —none; - в подстановках блока
schema:{{ pages.home.url }}. Путь подстановки пишется через точки, и/в нём не написать, поэтому здесь главная называетсяhome. Файлhome.mdв корне языка подстановкам недоступен — HERON предупреждает о нём.
Дата публикации
date — когда страница вышла, ГГГГ-ММ-ДД, необязательное. Не путать с published: то — флаг «собирать ли страницу вообще», и с updated: то — дата последней правки.
date: 2026-03-14 # вышла
updated: 2026-09-02 # последний раз правили
- разметка:
datePublishedизdate,dateModifiedизupdated(или изbuild.updated_from). Нет поля — нет ключа в разметке, выдумывать дату HERON не будет; - лента (
seo.feed) сортируется поdate, страница без неё — по дате обновления; у записи сdateесть дата выхода (published); - шаблону —
page.date(объект даты,{{ page.date.isoformat() }}или свой формат).
Формат дат для машин. В разметку, в lastmod и в Atom дата уходит полной датой со временем и поясом, ISO 8601: так требует Google (dateModified: 2026-10-14 Search Console отклоняет). Время коммита (build.updated_from: git) — как его отдаёт git log -1 --format=%cI, например 2026-10-17T11:30:12+02:00. День без времени — date и updated из фронтматтера, источник file — полночь UTC: 2026-10-14T00:00:00+00:00. Подпись на странице и page.date/page.updated в шаблоне остаются днём; полный момент — page.date_at/page.updated_at.
Проверки. Дата позже момента сборки — предупреждение с путём файла (поисковик сочтёт её ошибкой), при --strict — ошибка. Ручной день сравнивается полночью UTC, ровно тем, что уйдёт в разметку. Обновление раньше публикации — тоже предупреждение; порядок сверяется по дням, так что правка в день публикации ранним утром по местному поясу ошибкой не считается.
Дата обновления
updated во фронтматтере — то, что поставил человек. Оно перебивает всё и всегда: есть страницы, где дата — факт, а не метка сборки, и двигать её перекомпиляцией нельзя.
Не поставил — источник выбирает сайт ключом build.updated_from:
| Значение | Откуда дата |
|---|---|
manual (по умолчанию) |
ниоткуда. Нет поля — нет даты, как было всегда |
git |
время последнего коммита, затронувшего файл, с поясом коммита (%cI) |
file |
время файла на диске |
Разрешённое значение одно и питает всех троих сразу: подпись под текстом (page.updated в шаблоне), lastmod в карте сайта, dateModified в разметке. Разъехаться они не могут по устройству.
Про file нужно знать главное: время файла — это время записи на диск, а не время правки текста. Копирование без сохранения атрибутов (cp без -a, распаковка архива, выкладка через панель хостинга, свежий клон) ставит всем файлам одно время, и сайт объявит себя обновлённым целиком, не изменившись ни на букву. Поисковик такую карту сайта перестаёт принимать всерьёз, а подпись под текстом врёт уже людям. Поэтому HERON эту подпись распознаёт: если у всех страниц время совпадает с точностью до минуты — это копия, и дата не проставляется вовсе. Отсутствие даты честнее выдуманной.
git таких оговорок не требует: история хранит время изменения содержимого, а не файла, и переживает копирование, клонирование и переезд.
Секции
Секция — блок контента с идентификатором. Синтаксис — обычный markdown-заголовок с якорем:
## Что это такое {#what}
Секция — всё от этого заголовка до следующего заголовка того же уровня с якорем или до конца файла. Вложенные заголовки (###) принадлежат секции.
Правила:
- идентификатор: латиница, цифры, дефис;
- порядок секций в файле безразличен;
- один идентификатор дважды в файле — ошибка сборки;
- текст до первой секции считается вводным блоком, доступен теме как
intro; - секция, которую тема не использует, не ошибка — попадает в отчёт
heron check; - секция, которую тема ждёт, а в файле её нет, тоже не ошибка: значение пустое, блок не рендерится, в отчёт идёт строка «тема ждёт секцию X, в файле её нет».
Заголовок секции — это контент, который увидит посетитель. Якорь — это адрес для темы. Их разделение принципиально: заголовок правят, адрес — нет.
Состав секции
Секция приезжает в тему не плоским куском разметки, а разобранной на части. Макет режет страницу мельче, чем заголовок и текст под ним: подводку рисуют одним кеглем, примечание другим, цитату — врезкой.
| Поле | Что это |
|---|---|
id |
якорь из {#anchor} |
title |
текст заголовка |
classes |
классы из якоря: .alert, .timeline |
lead |
абзацы до первого списка, таблицы или группы ### |
body |
основной узел секции, разобранный по своему формату |
note |
абзацы после основного узла |
note_title |
жирный зачин первого абзаца примечания, если он есть |
callout |
содержимое цитаты >, где бы она ни стояла в секции |
links |
ссылки секции списком {title, href} |
Нет ни списка, ни таблицы, ни групп — вся секция это body типа «проза», lead и note пустые.
Жирный зачин. Абзац вида **Текст до точки.** Дальше обычный текст разбирается надвое: зачин уезжает в note_title (или в term элемента списка), из тела абзаца он удаляется. Признак — жирный фрагмент в самом начале абзаца, заканчивающийся точкой, двоеточием или тире.
Пункт списка вида **Термин.** Пояснение разбирается в пару {term, text}. Пункт без зачина в таком списке — пара с пустым term. Список, где зачина нет ни у одного пункта, остаётся списком строк.
Разметка внутри значений сохраняется: **жирный** и [текст](ссылка) доезжают до темы разметкой, а не экранированным текстом, включая ссылки внутри жирного фрагмента. Ссылки собираются ещё и отдельным списком links того элемента, где встретились, — тема вправе нарисовать их и в тексте, и строкой чипов.
Вводный блок
Текст до первого заголовка разбирается на абзацы:
| Поле | Что это |
|---|---|
lead |
первый абзац |
promise |
второй абзац, необязательный |
intro |
весь вводный блок целиком, как раньше |
Три абзаца и больше — предупреждение heron check с именем файла: третий и далее не попадут в шаблон. intro сохраняется, чтобы не ломать темы, написанные до этого правила.
Формы секций
Некоторые модули ждут не прозу, а данные. Особого синтаксиса нет — всё это валидный markdown, читаемый глазами и в любом редакторе. HERON определяет форму секции по содержимому или по классу в якоре и отдаёт её теме полем section.kind, а разобранные данные — section.data (в шаблоне модуля — items).
Как каждая форма выглядит на сайте, с живыми примерами, — в справочнике форм.
Таблица форм
kind |
Разметка | Признак | Что получает тема (items) |
Пример |
|---|---|---|---|---|
prose |
любой текст | ничего из ниже перечисленного не подошло; или класс .prose |
строка html всей секции (то же, что html) |
Просто абзац. |
list |
маркированный список | - пункт |
список строк html. Если хоть у одного пункта жирный зачин **Термин.** — список пар: строка html с полями .term, .text, .links |
- раз / - **Термин.** Пояснение |
alert-list |
маркированный список | только класс .alert |
как у list |
## Тревожные признаки {#flags .alert} |
checklist |
группы ###, в каждой список |
три и больше групп со списками; или класс .checklist |
список групп {title, html, items, has_list}; items — как у list |
### Документы + - паспорт, три группы |
two-lists |
группы ###, в каждой список |
ровно две группы со списками; или класс .two-lists |
как у checklist |
### Подходит / ### Не подходит |
steps |
нумерованный список | 1. пункт |
список {n, tag, title, text, html, links, top} |
1. **[ЧАЩЕ] Заявка.** Текст |
table |
markdown-таблица | есть строка-разделитель |---| |
{head: [ячейки], rows: [[ячейки]]} |
| Что | Где | + |---|---| |
facts |
строки Ключ | Значение |
строки с | без строки-разделителя |
список {key, value} |
Формат | очно |
faq |
группы ### без списков |
заголовки ###, ни в одном нет списка |
список {q, a}; a — html ответа |
### Сколько длится? + Около месяца. |
timeline |
строки Период | Событие или нумерованный список |
только класс .timeline |
как у facts ({key, value}) или как у steps |
## Восстановление {#recovery .timeline} |
callout |
цитата > |
в секции есть цитата и нет списка; или класс .callout |
строка html цитаты | > Если сроки горят — скажите сразу. |
gallery |
маркированный список картинок | только класс .gallery |
как у list |
-  |
Кроме items, у любой секции есть разобранные части — lead, body, note, note_title, callout, links (см. «Состав секции» выше), — и html целиком.
Порядок распознавания
Класс в якоре сильнее содержимого. Без класса форма определяется по первому подошедшему правилу, в таком порядке:
table— markdown-таблица со строкой-разделителем;facts— строкиключ | значениебез строки-разделителя;steps— нумерованный список;- группы
###со списками: ровно две —two-lists, три и больше —checklist. Одна группа со списком формой не считается и уходит дальше по порядку (обычно вlist, заголовок группы при этом вitemsне попадает — нужен класс.checklist); faq— группы###, ни в одной нет списка;list— маркированный список;callout— цитата>, если списка в секции нет. Секция «список и совет к нему» остаётсяlist, совет доступен теме какsection.callout;prose— всё остальное.
Чем похожие формы отличаются
checklistиtwo-listsзаписываются одинаково — группы###со списками. Различает их число групп (две или три и больше) либо класс. Смысл разный: две колонки противопоставляют («подходит / не подходит»), чек-лист перечисляет («документы / дополнительно / на приём»), и тема рисует их по-разному;factsиtimelineзаписываются одинаково — строкиключ | значение. По содержимому их не различить: без класса это всегдаfacts, шкала — только с классом.timeline;factsиtable:facts— это не markdown-таблица, у неё нет шапки и строки-разделителя, ведущая|не нужна; markdown её рендерит абзацем, а HERON разбирает в пары. Как только появляется строка|---|---|, этоtableс шапкой и любым числом колонок.
Классы в якоре, которые задают форму
| Класс | Форма |
|---|---|
.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 |
Отдельно {.top} на пункте нумерованного списка — признак главного шага (без него top у первого шага). Неизвестный класс — предупреждение, форма определяется по содержимому.
Битый ввод
Разбор ошибается мягко: класс задан, а содержимое ему не подходит (.steps над маркированным списком, .facts без единой строки с |), — секция отдаётся как prose, в отчёт идёт предупреждение «класс не подходит к содержимому». В facts строки без | среди нормальных пропускаются с предупреждением. Валить сборку из-за пропущенной палочки в таблице нельзя.
Шаг подробнее
Шаг может нести метку и ссылки:
1. **[ЧАЩЕ ВСЕГО] Без визита.** Заявка и [проверка документов](/get-started/).
превращается в {n: 1, tag: "ЧАЩЕ ВСЕГО", title: "Без визита", text: "…", links: [{title, href}]}. Метка — фрагмент в квадратных скобках в самом начале пункта, целиком в верхнем регистре; из текста он вырезается. Метки нет — поле пустое.
Тема называет ожидаемую форму
Одна и та же секция на разных страницах бывает разной формы: {#recovery} на одной странице — шкала, на другой — проза. Обе валидны, тема получает section.kind и решает сама.
Тема вправе назвать ожидаемую форму в theme.yaml: uses: ["recovery:timeline"]. Имя формы обязано быть из таблицы выше, иначе тема не загрузится. Расхождение формы на конкретной странице — не ошибка сборки, а строка в отчёте heron check.
Необязательные секции
HERON не знает, какие секции у страниц бывают: это предметная область сайта. Все секции типа — и обязательные по смыслу, и необязательные — объявляет тема в types.ТИП.uses (theme.yaml). Секция из uses, которой нет в файле, — строка в сводке долгов, а не ошибка; секция в файле, которой нет в uses, — строка в отчёте heron check («тема не использует»), это ловит опечатку в якоре.
Источник
Эта страница повторяет раздел 4 файла docs/spec/20-data-contract.md из ветки prod. Репозиторий обновляется первым: открыть файл на GitHub.