HERON

Контракт данных · часть 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). Откуда берётся тип, по убыванию силы:

  1. поле type в самом файле — для любой страницы, включая одиночную вне раздела (about.md с type: about рисуется templates/about.html);
  2. для _index.md — type этого же файла; для обычной страницы — children_type из _index.md её папки;
  3. для _index.md подраздела — children_type родительской папки;
  4. главная (index.md в корне языка) — home;
  5. иначе — 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 - ![подпись](media/examples/photo.jpg)

Кроме items, у любой секции есть разобранные части — lead, body, note, note_title, callout, links (см. «Состав секции» выше), — и html целиком.

Порядок распознавания

Класс в якоре сильнее содержимого. Без класса форма определяется по первому подошедшему правилу, в таком порядке:

  1. table — markdown-таблица со строкой-разделителем;
  2. facts — строки ключ | значение без строки-разделителя;
  3. steps — нумерованный список;
  4. группы ### со списками: ровно две — two-lists, три и больше — checklist. Одна группа со списком формой не считается и уходит дальше по порядку (обычно в list, заголовок группы при этом в items не попадает — нужен класс .checklist);
  5. faq — группы ###, ни в одной нет списка;
  6. list — маркированный список;
  7. callout — цитата >, если списка в секции нет. Секция «список и совет к нему» остаётся list, совет доступен теме как section.callout;
  8. 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.