HERON

Контракт данных · часть 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. Яндекс Метрику заготовка не выводит: ключ metrika HERON принимает, но вывести его — дело темы сайта, которой он нужен.

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.