Контракт данных · часть 3
site.yaml
Третья часть контракта данных: site.yaml, единственный конфиг сайта. Всё, что повторяется на каждой странице, живёт здесь в одном экземпляре.
Страница нужна тем, кто настраивает сайт: домен, языки, меню, счётчики, разметку schema.org, файлы для нейросетей, переадресации и 410.
Простыми словами
site.yaml — единственный файл настроек сайта. Всё, что одинаково на каждой странице, живёт здесь один раз: название, домен, языки, меню, реквизиты компании, счётчики аналитики. Поменяли телефон здесь — он сразу поменялся на всех страницах, в разметке для Google и в файлах для нейросетей.
Заполнять всё не нужно. Минимум — название, домен и языки; конфигуратор (heron setup .) задаёт вопросы и пишет файл за вас.
Пример. Небольшой, но полный site.yaml:
heron: ">=0.1,<0.2" # какие версии HERON могут собирать этот сайт
site:
domain: example.com
name: "Кровля Смит"
theme: main
default_lang: ru
languages: [ru, en]
organization:
name: "ООО «Кровля Смит»"
about: "Ремонт крыш в Вене."
about_en: "Roof repair in Vienna."
nav:
main: [services, prices, contacts]
Ключ с кодом языка на конце (about_en) — значение для этого языка; HERON сам подставляет нужное на каждой странице.
Когда нужна эта страница. Когда вы настраиваете сайт, добавляете язык, подключаете аналитику, описываете компанию для поисковиков или убираете старые страницы с ответом «удалено».
Общий вид
Адрес, вписанный руками в шапку и в подвал, рано или поздно расходится — это ровно та проблема, которую решает этот файл.
heron: ">=0.1,<0.2" # версия HERON, которую требует сайт. Обязательна
site:
domain: example.com
theme: main # имя темы. Локальная theme/ перекрывает
default_lang: en
languages: [en, ru]
name: Название сайта
# Блоки ниже HERON не разбирает — они целиком уходят в шаблоны.
# Их имена и состав придумывает тема, см. «Что ядро проверяет, а что пропускает».
contact:
phone: "+00 000 000 0000"
address: "улица и дом"
city: Город
hours_display: "Пн–Пт 09:00–18:00"
organization:
name: Название организации
same_as:
- https://example.com/social
seo:
title_suffix: " — Название сайта"
og_default_image: media/examples/og.jpg
twitter_card: summary_large_image
llms_txt: true
llms_note: # сведения о сайте для файлов, которые читает нейросеть
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 # тип страниц для ленты. Нет ключа — нет ленты
nav:
main: [about, services, contacts]
build:
fail_on_warning: false
allow_raw_html: false # сырой HTML в markdown. По умолчанию запрещён
updated_from: manual # откуда дата обновления: manual | git | file
workers: 0 # процессов на нарезку картинок. 0 — по числу ядер
plugins: [] # имена установленных плагинов
gone: # адреса, на которые сервер отвечает 410. См. ниже
- /sample-page/
- /wp/*
- /?p=*
# Формы принимает heron-relay, а не сборщик. HERON описание нужно, чтобы
# отдать теме состав полей и упасть, если тема рисует форму, которой здесь нет
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 и sinks читает не HERON, а heron-relay. HERON использует их только чтобы отдать теме описание формы для разметки и чтобы упасть, если тема рисует форму, которой нет в конфиге.
Значения из contact и organization идут одновременно в разметку страницы, в JSON-LD и в блоки темы. Меняются в одном месте — это то, что не даёт адресу разойтись между шапкой и подвалом.
Блок analytics
Публичные идентификаторы счётчиков. Это ключ ядра, он проверяется: неизвестный ключ — E011.
analytics:
ga4: G-XXXXXXX # Google Analytics 4
gtm: GTM-XXXXXX # Google Tag Manager
metrika: 12345678 # Яндекс Метрика, число или строка
- это не секреты: идентификаторы видны в исходнике любой страницы. Поэтому они в
site.yaml, а не в.env; - выводит счётчики тема (
partials/analytics.htmlв заготовке), HERON лишь решает, можно ли: шаблону доступен признакenv.analytics. Он включён в итоговой сборке (--env prod) и выключен в остальных; перебивается переменной окруженияSITE_ANALYTICS=0|1. На черновых сборках счётчики не выводятся, чтобы тестовые заходы не попадали в статистику живого сайта; - заготовка темы выводит
ga4иgtm. Яндекс Метрику заготовка не выводит: ключmetrikaHERON принимает, но вывести его — дело темы сайта, которой он нужен.
seo.llms_note — сведения о сайте для нейросетей
Файлы llms*.txt состоят из заголовков и описаний страниц. Того, что нужно читателю в первую очередь — как связаться, когда работаете, чего сайт не делает, — в них нет: на сайте эти сведения разбросаны по страницам, а в перечень попадают только h1 и description.
seo.llms_note — готовый markdown по коду языка, который HERON вставляет в файлы этого языка дословно. Подстановок и шаблонизации нет: это данные, а не шаблон. Языка нет в словаре — в его файлах блока нет, молча: сайт вправе описать не все языки.
Про объём: файл читает модель с ограниченным контекстом, и всё, что вы туда положите, отъедает место у самого содержимого сайта. Несколько строк фактов — то, что нужно; страница текста — нет.
Про уровень заголовка: начинать блок с ### или ниже. # того же уровня, что заголовок файла, формально допустим, но читается как начало нового документа.
Блок предназначен для фактов о сайте, а не для указаний читающей модели. Текст, загруженный с чужого сайта, потребитель вправе считать данными, а не инструкциями, — и правильно делает. Попытки управлять чужим агентом через этот файл бесполезны, а выглядят как внедрение инструкций: такому сайту перестают доверять целиком, вместе с честной его частью.
Пример. Короткая справка на двух языках:
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.
Запись — только по телефону.
Куда попадает блок, дословно, без шаблонизации:
| Файл | Текст какого языка | Место |
|---|---|---|
/llms-LANG.txt |
этого языка | после заголовка и описания, до перечня страниц |
/llms-full-LANG.txt |
этого языка | после заголовка, до текстов страниц |
/llms.txt на многоязычном сайте |
языка по умолчанию | после описания, до списка языковых версий |
/llms.txt и /llms-full.txt на одноязычном |
единственного языка | как в языковых файлах |
Что такое llms.txt и зачем он сайту — на странице llms.txt.
Значения по языкам
Название сайта на языках пишется по-разному, а суффикс title на чужом языке в выдаче — брак. Для трёх ключей ядра значение задаётся на каждый язык суффиксом _LANG:
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
| Ключ | По языкам |
|---|---|
site.name |
site.name_LANG |
seo.title_suffix |
seo.title_suffix_LANG |
seo.og_default_image |
seo.og_default_image_LANG |
КЛЮЧ_LANGсильнее ключа без суффикса; языку без своего значения достаётся значение без суффикса;- суффикс языка, которого нет в
site.languages, —E011; - шаблон получает готовые значения языка страницы:
site.site.name,site.seo.title_suffix,site.seo.og_default_imageуже подставлены, фильтр не нужен. То же видят подстановки блокаschema({{ site.name }}), заголовкиllms*.txtи ленты; - обе картинки
og_default_image*объявлены сайтом: HERON проверяет их наличие и кладёт в сборку, как любые файлы изmedia/; - остальные ключи ядра от языка не зависят;
- блоки сайта (
contact,organization,schemaи свои) переводятся тем же суффиксом, на любой глубине и в списках: шаблон страницы языкаruвидитsite.contact.address=address_ru; есть толькоaddress_ru— на русской странице появляетсяaddress. Ключи с хвостом остаются доступны. Подстановки блокаschema(ниже) работают по тому же правилу.
Заготовка темы (heron new) уже выводит заголовок окна с суффиксом, og:*, название в шапке и подвале из этих значений.
Блок build
| Ключ | Значения | По умолчанию | Что делает |
|---|---|---|---|
notes |
ru | en |
en |
язык комментариев и пояснений, которые кладут heron init и heron page. Перебивается флагом --notes и переменной HERON_NOTES. На сборку не влияет |
fail_on_warning |
true/false |
false |
предупреждения валят сборку, как --strict |
allow_raw_html |
true/false |
false |
сырой HTML в markdown; при false — E015 |
updated_from |
manual | git | file |
manual |
откуда дата обновления страницы, если updated не поставлен руками |
workers |
0–64 | 0 |
процессов на нарезку картинок; 0 — по числу ядер |
Пример. Даты из истории git и предупреждения, которые останавливают сборку, — как на живом сайте:
build:
updated_from: git
fail_on_warning: true
updated_from. updated во фронтматтере перебивает автоматику всегда. Без него:
manual— даты нет вовсе;git— время коммитера последнего коммита, затронувшего файл, с его поясом (git log --format=%cI); подпись на странице — день этого времени. Файл с незакоммиченными правками или которого нет в истории — время файла и предупреждение с путём. История недоступна — не репозиторий, нетgit, клон обрезан (--depth 1) — сборка не падает: HERON берёт время файлов и пишет одно предупреждение на сборку. В CI для этого режима нужен полный клон (fetch-depth: 0, развёртывание, раздел 4);file— время файла на диске. Если у всех страниц оно совпадает с точностью до минуты, это копия, а не правки, и дата не ставится вовсе.
Подробности режимов — дата обновления.
Блок schema: разметка без дублирования данных
Что размечать, решает тема (types.ТИП.jsonld) и сама страница (блок schema во фронтматтере). Чем заполнять — site.yaml, блок schema. HERON подставляет то, что знает сам: name, description, url, inLanguage, dateModified, хлебные крошки и вопросы-ответы.
schema:
entity: # сквозной узел сайта
"@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 }}"
Подстановки, а не копии. {{ contact.city }} читается из того же site.yaml, откуда значение берёт тема. Второй копии адреса не заводится: разошедшийся адрес в разметке никто не заметит глазами — его видит только поисковик.
Язык подставляется сам. На русской странице {{ contact.address }} — это address_ru, на английской — address_en; нет языкового ключа — берётся ключ без хвоста. Один блок работает для всех языков.
Списки разворачиваются звёздочкой. {{ contact.social.*.href }} — список адресов из списка карт {href, name}. Иначе соцсети переписывают в разметку руками и забывают обновить.
Адреса страниц и базовый адрес доступны подстановкам: {{ site.base }} — корень сайта, {{ pages.КЛЮЧ.url }} — адрес страницы с таким ключом на языке текущей страницы, {{ pages.КЛЮЧ.image }} — её картинка, {{ pages.КЛЮЧ.h1 }}, .title, .description. Вписывать адрес страницы руками не нужно: он меняется вместе со слагом, а разметку после переименования никто не перечитывает. Главная в подстановках называется home (в остальных местах её ключ — /, см. ключ главной).
Связи страницы доступны подстановкам: {{ related.topics.*.url }} — адреса связанных страниц, .h1, .note, .description. Перечень уже собран HERON по правилам темы, и переписывать те же ссылки во фронтматтере значит завести второй список, который разойдётся с первым на третьей правке.
Якорный @id становится абсолютным. "@id": "#owner" внутри узла страницы HERON превращает в https://домен/#owner: относительный идентификатор разрешается от адреса документа, и на каждой странице получилась бы своя сущность, а связь между узлами молча распалась бы.
Чего нет — того нет. Ключ, для которого значения не нашлось, не выводится вовсе. Пустое поле в разметке хуже отсутствующего: поисковик считает его заявленным.
Сквозной узел попадает на каждую индексируемую страницу целиком, а не ссылкой: страницы независимы, и краулер не обязан сначала зайти на главную. @id один на весь сайт и на все языки — это одна сущность, а не три. На странице с noindex узла нет: разметка там бессмысленна.
Проверки на сборке (E020):
- разметка каждой страницы разбирается как JSON;
- объявленный темой тип присутствует в графе;
- если
schema.entityобъявлен — он есть на каждой индексируемой странице; если он объявляетaddress.addressLocalityилиgeo, их значения не пустые (подстановка не нашла данных — значит их забыли заполнить). Узел без адреса вовсе — сайт без офиса — это нормально: HERON предметную область не решает; - поля про деньги —
offers,price,priceRange,priceCurrency,paymentAccepted,lowPrice,highPrice,priceSpecification— запрещены, пока сайт не разрешил их явно:
seo:
allow_prices: false # по умолчанию. true — цены в разметке разрешены
Запрет по умолчанию — не забывчивость в данных, а защита: если сайт не публикует стоимость на страницах, она не должна утечь через разметку. Сайт, который цены публикует, ставит allow_prices: true.
Отчёт после сборки считает страницы по типам узлов, чтобы было видно, что разметка доехала.
Блок nginx
nginx:
hash_sizes: auto # auto — HERON пишет map_hash_* в конфиг сайта; off — их задаёт хост
Ключ ядра, проверяется (E011). Подробно — спецификация поставки, «Страница 404 и отдача», и гайд выкладка, «Свой nginx на сервере».
Блок gone: 410 для того, чего больше нет
После переезда и после взлома остаются адреса, у которых нет и не будет страницы: архивы категорий, пагинация, служебные пути старой системы, дорвеи. На них сервер отвечает 410 Gone — «этого больше нет, выкиньте из индекса». 404 говорит поисковику «может, появится», и он продолжает проверять адрес месяцами.
Страницы у таких адресов нет, поэтому живут они в site.yaml, ключом ядра gone (проверяется, ошибка — E011). Формы записи:
| Запись | Что закрывает |
|---|---|
/sample-page/ |
ровно этот адрес |
/wp/* |
всё, что начинается с /wp/: /wp/, /wp/wp-login.php, /wp/a/b/c/ |
/?p=* |
адрес / с параметром p любого значения: /?p=123, /?utm=1&p=5 |
/?p=12 |
адрес / с параметром p, равным 12 |
- звёздочка допустима только в конце пути или вместо значения параметра целиком. Параметр в записи один. Якорь (
#…) серверу не приходит и в записи запрещён; - пересечение с адресом существующей страницы (точное или префиксом) —
E022, сборка останавливается. Сайт не может одновременно иметь страницу и объявлять, что её нет; - пересечение с
redirect_fromлюбой страницы — тожеE022: адрес либо переехал (301), либо исчез (410); - запись-параметр с адресом страницы не пересекается:
/?p=*не закрывает главную, только главную с этим параметром; - одна и та же запись дважды — предупреждение;
- кириллица и percent-encoding — по тем же правилам, что
redirect_from(старые адреса).
Пример. На старом сайте на WordPress были тестовая страница, вся папка /wp/ и короткие ссылки вида /?p=123. В site.yaml нового сайта:
gone:
- /sample-page/
- /wp/*
- /?p=*
Теперь поисковик, который зайдёт по любому из этих адресов, услышит «удалено навсегда» и перестанет их проверять.
Сервер отвечает 410 до обращения к файлам: HERON вписывает список картой в конфиг nginx, который собирает вместе с сайтом (спецификация поставки, «Страница 404 и отдача»). Человекочитаемая копия — dist/gone.map.
Поле gone на странице устарело: адрес, которого больше нет, некуда честно приписать. Оно ещё читается — адреса уходят в тот же список, — но каждая такая страница даёт предупреждение «перенесите в site.yaml».
Что ядро проверяет, а что пропускает насквозь
Уточнение от 2026-09-14, по итогам реализации контрактов.
HERON строго проверяет только свои ключи: heron, site, seo, nav, build, analytics, plugins, forms, sinks, gone. Опечатка внутри них — остановка с E011.
Блоки, которых HERON не знает — contact, organization, любые свои, — проходят насквозь и достаются шаблонам как есть. HERON в их смысл не вникает: иначе он начнёт знать предметную область каждого сайта по отдельности, и граница, ради которой всё затевалось, исчезнет.
Проверку наличия при этом мы не теряем. Тема объявляет, что ей нужно, в theme.yaml:
requires:
- contact.phone
- organization.name
forms:
- callback
HERON проверяет, что эти пути в site.yaml заполнены, и останавливается с понятным перечнем, если нет. Тема просит — сайт даёт — HERON сверяет, не понимая предметной области.
Туда же переехали типы JSON-LD: types.ТИП.jsonld задаётся в theme.yaml. Соответствие «такой-то тип страницы → такой-то тип разметки schema.org» принадлежит теме: у сайта услуг, магазина и справочника оно разное, и модуль HERON знать его не обязан.
Источник
Эта страница повторяет раздел 5 файла docs/spec/20-data-contract.md из ветки prod. Репозиторий обновляется первым: открыть файл на GitHub.