Контракт данных · часть 1
Папка сайта, адреса и языки
Первая часть контракта данных: что такое HERON, как устроена папка сайта, как путь файла становится адресом и как сайт живёт на нескольких языках.
Эту страницу читают все, кто создаёт сайт или переносит старый: человек, архитектор, ИИ-агент.
Простыми словами
Сайт на HERON — это обычная папка на компьютере, как папка с документами. Внутри несколько подпапок с постоянными именами, и у каждой своя задача: страницы лежат в content/, фотографии в media/, внешний вид в theme/, а все настройки — в одном файле site.yaml.
Главная мысль этой страницы: где лежит файл, такой и адрес на сайте. Отдельный список адресов вы не пишете никогда. Положили файл в папку — страница появилась по нужному адресу, в меню, в карте сайта и в файлах для нейросетей.
Пример. Четыре файла и адреса, которые из них получаются, на сайте с английским основным языком и русским вторым:
my-site/
site.yaml один файл настроек
content/
en/index.md → /
en/services/_index.md → /services/
en/services/roof.md → /services/roof/
ru/index.md → /ru/
У основного языка в адресе нет приставки, у остальных впереди код языка: /ru/. Перевод — это файл с тем же именем в папке другого языка, поэтому HERON знает, что две страницы — одна и та же на разных языках.
Когда нужна эта страница. Когда вы продумываете структуру нового сайта, переименовываете раздел, добавляете язык или переносите старый сайт со старыми адресами.
Что это за система
Генератор статического сайта из markdown-файлов. На вход — папка с контентом, картинками, темой и одним конфигом. На выходе — готовый HTML в dist/, который отдаёт nginx.
Ключевое правило, из которого выводится всё остальное:
Markdown-файл — это именованный склад контента. Тема — это раскладка. Файл не знает, где что покажется. Тема не знает, что написано. Связь между ними — по идентификаторам секций.
Практическое следствие: порядок секций в файле не влияет на порядок блоков на странице. Редактор пишет в удобном ему порядке, тема раскладывает в правильном.
Формы — свойство сайта, а не сборщика. Сам сайт остаётся статикой при любом раскладе: если форма есть, её POST принимает отдельный сервис heron-relay, а не сборщик. На одном сайте форм нет вовсе — единственная конверсия телефонный звонок; на другом есть. Контракт формы описывается в site.yaml, реализация — в спецификации поставки.
Как пользоваться документацией агенту — в разделе роли для ИИ-агентов: архитектор, контент и SEO, дизайнер, сборщик.
Как поставляется HERON
Решение от 2026-09-14; оно отменяет прежнее «HERON, контент и тема в одном репозитории».
Кода HERON в папке сайта нет. Он поставляется одним артефактом — образом ghcr.io/fmstm/heron:TAG, он же публикуется pip-пакетом для локальной разработки. Папка сайта содержит только контент, данные, картинки, тему и конфиг; строк Python в ней нет.
Сайт объявляет требуемую версию HERON первой строкой site.yaml (heron: ">=0.1,<0.2"). HERON отказывается собирать сайт, версию которого не поддерживает. Это единственное, что не даст сайтам разъехаться по несовместимым версиям.
Всё, что описано ниже, — контракт между HERON и папкой сайта. Он одинаков для всех сайтов, какой бы теме они ни принадлежали.
Структура проекта
<сайт>/
├── site.yaml конфиг сайта, первой строкой — версия HERON
├── .gitignore dist/, .heron-cache/ — кладёт heron new
│
├── content/
│ ├── en/ по папке на каждый язык из site.languages;
│ │ ├── index.md главная — кладёт heron new/init
│ │ └── 404.md страница «не найдено» — кладёт heron new/init
│ └── ru/
│
├── data/ yaml-справочники: перечни, таблицы, реквизиты
├── media/ картинки, видео и файлы контента, мастера любого соотношения
├── static/ только то, что обязано лежать по точному адресу: webmanifest, .well-known
│
├── theme/ тема этого сайта
│ ├── theme.yaml
│ ├── base.html
│ ├── templates/ шаблоны типов страниц
│ ├── modules/ шаблоны модулей
│ ├── partials/ шапка, подвал, счётчики
│ ├── i18n/ строки интерфейса темы по языкам
│ └── assets/ css, js, логотип, иконки
│
└── plugins/ локальные плагины этого сайта. Обычно пусто
Результат сборки — dist/. heron build без --out кладёт его внутрь папки сайта (он в .gitignore), а scripts/site.sh — в out/САЙТ-ОКРУЖЕНИЕ в репозитории HERON, не трогая папку сайта вовсе. Кэш нарезок — .heron-cache/ рядом с результатом.
Dockerfile, compose, build.sh и .env в папке сайта запрещены. Вся сборочная обвязка живёт в репозитории HERON (scripts/site.sh, Dockerfile.site, env/.env.САЙТ.ОКРУЖЕНИЕ), секреты сервисов — у сервисов (раздел .env).
Кода HERON здесь нет. Папка heron/ внутри сайта — ошибка, а не структура.
data/ — справочники, которым не место в теле markdown: перечни услуг с атрибутами, таблицы сравнения, реквизиты, списки документов. Доступны шаблонам как data.ИМЯ_ФАЙЛА.
theme/ — тема этого сайта, одна. Внутри неё лежат Jinja-шаблоны (templates/): слово «шаблон» закреплено за ними, «тема» — за пакетом целиком. Если темы в папке нет, HERON находит её по имени из site.yaml (установленный пакет heron-theme-ИМЯ или git-ссылка); локальная theme/ всегда сильнее.
Человек, ведущий сайт, работает только с site.yaml, content/, data/, media/. Всё остальное трогает разработчик.
Пояснительная записка в каждой папке: heron-readme.md
heron new кладёт heron-readme.md в корень и в каждый созданный каталог. В записке три части: что это за папка, что сюда класть, чего сюда не класть и куда вместо этого. Третья часть обязательна: файл кладут не туда не потому, что не знают назначения папки, а потому, что не знают, чем соседняя лучше.
Документация в отдельном файле спецификации эту задачу не решает: её читают один раз при знакомстве, а решение «куда положить» принимается через месяц и в другом окне.
Имя зарезервировано, и HERON такие файлы не замечает нигде — на любом уровне вложенности, в любом каталоге, независимо от регистра:
- в
content/— не страница: не разбирается, не заводит адрес, не считается в статистике; - в
static/— не копируется в собранный сайт; - в
media/— не ресурс: не проверяется, не копируется, не значится неиспользуемым; - в
theme/,data/,plugins/— просто не замечается.
Правило одно на все каталоги, без исключений. Обычный README.md эту роль исполнить не мог: в content/ он не становился страницей по случайности, а не по правилу, а из static/ уезжал в сеть — эта папка копируется байт в байт, и на сайте появлялся /README.md.
Язык записок — тот, что выбрали при создании сайта; нет перевода — кладётся английский, без ошибки. Сайтам, заведённым раньше, записки не нужны: их отсутствие — строка в отчёте, а не ошибка.
Мусор операционной системы — .DS_Store, Thumbs.db, desktop.ini — в собранный сайт не попадает ниоткуда. Он заводится в static/ сам, без участия человека, и раньше уезжал в сеть по той же причине.
Дерево контента и адреса
Путь файла определяет адрес. Никаких отдельных карт маршрутов.
default_lang: en, languages: [en, ru]
content/en/index.md → /
content/en/about.md → /about/
content/en/contacts.md → /contacts/
content/en/guides/_index.md → /guides/
content/en/guides/getting-started.md → /guides/getting-started/
content/en/services/_index.md → /services/
content/en/services/design.md → /services/design/
content/ru/index.md → /ru/
content/ru/about.md → /ru/o-nas/ slug: o-nas
content/ru/services/_index.md → /ru/uslugi/ slug: uslugi
content/ru/services/design.md → /ru/uslugi/dizajn/ slug: dizajn
Правила:
- имя папки — сегмент адреса, имя файла без расширения — слаг;
_index.md— страница самой папки;- язык по умолчанию (
default_langизsite.yaml) отдаётся из корня, остальные — с префиксом; - все адреса заканчиваются слешем, канонический вид — со слешем;
- слаг — только латиница, цифры и дефис. Файл с иным именем валит сборку;
- слаг можно переопределить полем
slugво фронтматтере, но обычно это не нужно.
Добавление страницы — это создание одного md-файла. Она сама появляется в меню, в каталоге своей папки, в sitemap, в hreflang, в перелинковке и в llms.txt. Удаление файла убирает её отовсюду. Никаких списков, которые надо править параллельно.
Имя файла — это ключ, slug — это адрес
Две разные вещи, которые по умолчанию совпадают:
- имя файла и путь папок — идентификатор страницы. По нему сводятся переводы, по нему страница называется в
navизsite.yaml, по нему тема находит её черезpage_of('КЛЮЧ'). Ключ главной —/; slug— сегмент адреса, который видит посетитель.
# content/ru/services/_index.md
slug: uslugi
content/en/services/_index.md → /services/
content/en/services/design.md → /services/design/
content/ru/services/_index.md → /ru/uslugi/
content/ru/services/design.md → /ru/uslugi/design/ slug у страницы нет
content/ru/services/design.md → /ru/uslugi/dizajn/ со slug: dizajn
Слаг раздела уводит за собой всех детей: адрес собирается из цепочки сегментов, а не из строки пути. Слаг обычной страницы меняет только её последний сегмент.
Чего переименование адреса не делает: не рвёт связь между языками, не требует править nav в site.yaml, не меняет ключ страницы. Английский раздел живёт по /services/, русский — по /ru/uslugi/, и это по-прежнему одна страница на двух языках, потому что файл у них называется одинаково: ключ у обоих — services.
Слаг подчиняется тем же правилам, что имя файла: латиница, цифры и дефис, один сегмент, без слешей. Негодное значение — ошибка сборки, а не тихий откат к имени файла: подставить молча значит отдать странице адрес, которого автор не ждёт, и узнает он об этом из выдачи.
Два файла, дающие один адрес, — ошибка E004, независимо от того, чем именно они его дали: одинаковым слагом или слагом, совпавшим с именем соседнего файла.
Старый адрес после переименования закрывается redirect_from — он языку не относителен и пишется полностью.
Ссылки внутри контента относительны языку
Внутренняя ссылка пишется от корня и без языкового префикса — HERON сам приводит её к адресу на языке страницы, со всеми слагами и префиксом.
Написанный путь /a/b/ HERON разбирает так:
- Как ключ страницы. Ключ — путь папок плюс имя файла без расширения (
guides/x), ключ главной —/. Нашёлся ключ — подставляется адрес этой страницы на языке текущей страницы. - Как адрес в языке текущей страницы — тот же путь с её префиксом (
/ru/a/b/на русской странице). - Как адрес в языке по умолчанию или как полный адрес с префиксом. Нашлась страница чужого языка — подставляется её версия на языке текущей страницы.
Ключ сильнее адреса: он не меняется, когда переименовывают слаг. Поэтому надёжнее всего писать ссылки ключами — путём файлов, а не тем, что видно в адресной строке.
Пример на разных слагах — на документации этого сайта:
content/en/docs/_index.md (slug нет) → /docs/
content/ru/docs/_index.md slug: dokumentaciya → /ru/dokumentaciya/
content/en/docs/seo.md → /docs/seo/
content/ru/docs/seo.md → /ru/dokumentaciya/seo/
| Написано | На en-странице | На ru-странице |
|---|---|---|
[x](/docs/seo/) — ключ |
/docs/seo/ |
/ru/dokumentaciya/seo/ |
[x](/docs/seo/?a=1#faq) |
/docs/seo/?a=1#faq |
/ru/dokumentaciya/seo/?a=1#faq |
[x](/ru/dokumentaciya/seo/) — полный адрес ru с префиксом |
/docs/seo/ |
/ru/dokumentaciya/seo/ |
[главная](/) — ключ главной |
/ |
/ru/ |
Адрес ru без префикса (/dokumentaciya/seo/) на русской странице тоже найдётся — по второму правилу.
Один и тот же markdown даёт правильные адреса в любом языковом дереве. Перевод — это копия файла с переписанным текстом, а не с переписанными ссылками: иначе одна забытая ссылка после перевода становится битой ссылкой в проде.
- ключ есть, а перевода на язык страницы нет — ссылка ведёт на версию языка по умолчанию (нет и её — на первую версию по порядку
languages), в отчёт, в группу «ссылки», идёт предупреждение. Читатель оказывается на существующей странице другого языка, а не в 404; - не нашлось ничего — ссылка остаётся как написана, в отчёт идёт предупреждение с файлом, номером строки и самой ссылкой. Это не ошибка сборки;
- якорь и параметры (
#faq,?a=1) сохраняются; - не трогаются внешние адреса,
mailto:,tel:, адреса без схемы (//cdn…), чистые якоря, пути к/media/,/static/,/assets/и пути к файлам с расширением (/llms.txt,/feed.xml): слаг страницы точки не содержит.
Отдельно: redirect_from языку не относителен. Там лежат реальные старые адреса, с которыми сайт жил раньше, — они историчны, а не относительны.
Старые адреса: кириллица и percent-encoding
Старый адрес с кириллицей существует в двух видах: /новости/ и /%D0%BD%D0%BE%D0%B2%D0%BE%D1%81%D1%82%D0%B8/. В redirect_from и в gone его можно писать в любом из них. HERON приводит запись к той форме, в какой адрес сравнивает nginx (декодированный $uri), и 301 или 410 срабатывает на обе:
redirect_from:
- /новости-компании/ # как в адресной строке
- /%d0%ba%d1%80%d0%b5%d0%b4%d0%b8%d1%82%d1%8b/ # как в выгрузке sitemap
Правила нормализации:
- percent-encoding раскрывается, регистр шестнадцатеричных цифр не важен (
%D0=%d0); - текст приводится к Unicode NFC: так кодируют адреса браузеры, а редактор мог сохранить «й» двумя символами;
- регистр букв сохраняется:
/About/и/about/— разные адреса; - завершающий слеш: запись без слеша получает его (
/новости=/новости/), кроме адресов, похожих на файл (/index.php,/page.html). Сервер отвечает 301 или 410 и на вариант без слеша, и со слешем. Префикс вgone(/wp-*) слеш не получает; - слаг, обрезанный старой системой посреди буквы (
/%d0%ba%d1%80%d0/— последний байт без пары), после декодирования не является текстом. Такая запись остаётся в percent-форме, и сервер сверяет её с сырым адресом запроса без учёта регистра%xx. Писать её нужно именно в percent-форме — как она пришла из старого sitemap; - дубли, совпадение с адресом страницы (
E009) и пересечения сgone(E022) проверяются по нормализованной форме:/кредит/и его percent-запись — один и тот же адрес.
В dist/redirects.map и dist/gone.map адреса лежат уже в нормализованной форме. В конфиге nginx ключи карт стоят в кавычках, пробелы и спецсимволы экранированы.
Страница 404
Текст страницы «не найдено» — контент, как любой другой: файл 404.md в корне каждого языка.
content/en/404.md → dist/404.html (и /404/index.html)
content/ru/404.md → dist/ru/404/index.html
- тип —
404по имени файла, объявлять его не нужно (как главнойindex.md—home). Рисуется шаблоном темыtemplates/404.html; - поля — как у любой страницы:
title,h1,descriptionобязательны (E002).descriptionв выдачу не попадёт, но правило одно на все файлы; - в карту сайта,
llms*.txt, hreflang и ленту страница 404 не попадает никогда,noindexставить не нужно; - отдача — сервер отвечает кодом 404 и телом страницы языка из адреса:
/ru/…→/ru/404/index.html, всё остальное →/404.html. Сама страница помеченаinternal: по прямой ссылке её не открыть (подробнее — спецификация поставки, «Страница 404 и отдача»); - файла нет — HERON собирает 404 этого языка из шаблона темы без текста контента (заголовок и пояснение тема берёт из своих строк
t) и пишет предупреждение в группу «контент». Нет и шаблонаtemplates/404.html— предупреждение, и сервер отдаёт свою встроенную заглушку. Язык без единой страницы 404 не получает.
Языки
Набор языков сайт объявляет сам, в site.yaml:
site:
default_lang: en
languages: [en, ru] # от одного до любого числа кодов
languages— от одного до N кодов видаen,ru,en-gb. Повтор кода или пустой список —E011;default_langобязан входить вlanguages, иначеE011. Язык по умолчанию отдаётся из корня (/about/), остальные — с префиксом (/ru/about/, или/ru/o-nas/соslug: o-nas);- дерево контента на каждый язык —
content/LANG/. Папки языков, которых нет вlanguages, HERON не читает; - соответствие страниц между языками — по ключу: относительный путь папок плюс имя файла.
services/design.mdв двух деревьях — одна страница на двух языках, какие быslugу них ни стояли; - одна страница — один язык. Смешение языков внутри файла недопустимо.
Слаги в разных языках могут отличаться (design / dizajn), связь идёт по ключу — и когда слаг взят из имени файла, и когда он объявлен полем slug.
Что зависит от числа языков
| Один язык | Два и больше | |
|---|---|---|
| адреса | всё из корня | основной из корня, остальные /LANG/… |
sitemap.xml |
сам список адресов (urlset) |
индекс, плюс sitemap-LANG.xml на каждый язык из languages, где есть индексируемые страницы |
hreflang в head |
не выводится | на каждой индексируемой странице: все её языковые версии, включая её саму, плюс x-default |
xhtml:link в карте сайта |
нет | у страниц, которые существуют хотя бы на двух языках |
page.translations |
всегда пуст | версии страницы на других языках, по ключу |
| файлы для нейросетей | /llms.txt — полный перечень страниц, /llms-full.txt — полные тексты. Языковых файлов нет |
/llms.txt — оглавление, /llms-LANG.txt и /llms-full-LANG.txt на каждый язык |
| переключатель языка | в теме не выводится (languages длиной 1) |
пункт на каждый язык; если перевода нет — пункт неактивен |
| nginx | одна корневая локация | плюс локация /LANG/ на каждый неосновной язык со своей 404 |
x-default ведёт на версию языка по умолчанию. Если у страницы такой версии нет — x-default не выводится вовсе: указывать на чужую страницу поисковику хуже, чем промолчать.
Перевода нет — страница просто отсутствует на этом языке, а в переключателе её пункт неактивен. Молча вести на главную или показывать 404 запрещено. Тема получает список кодов как languages, текущий язык как lang, версии страницы как page.translations[code].
Страница только не на основном языке
Такая страница собирается как обычная:
- попадает в карту сайта своего языка;
hreflangуказывает на неё саму, безx-default;- в отчёт, в группу «переводы», идёт предупреждение «страница есть на
ru, но нет на языке по умолчанию». Пока основной язык пуст целиком, это предупреждение не повторяется на каждой странице — достаточно строки о пустом языке.
Пустой язык
Язык объявлен в languages, но папки content/LANG/ нет или в ней нет ни одной страницы:
| Ситуация | Черновая сборка | Итоговая (--env prod) |
|---|---|---|
| пуст неосновной язык | предупреждение в группе «языки» | то же предупреждение, сборка идёт |
| пуст язык по умолчанию, другие заполнены | предупреждение, сборка идёт | E021 с кодом языка: корень сайта ответил бы 404 |
| пусты все языки | предупреждение, сборка идёт | E017 |
Так второй язык можно наполнять раньше основного: черновая сборка показывает готовое, а в работу сайт без главной не уедет.
Источник
Эта страница повторяет разделы 0–3 файла docs/spec/20-data-contract.md из ветки prod. Репозиторий обновляется первым: открыть файл на GitHub.