HERON

Контракт данных · часть 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 — он языку не относителен и пишется полностью.

Старые адреса: кириллица и 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.