HERON

Спецификация 21

Сборка: как HERON превращает папку в сайт

Эта спецификация описывает саму сборку: что HERON делает с папкой сайта, в каком порядке, что видит шаблон, какие есть команды и что значит каждый код ошибки.

Читайте её, когда сборка остановилась с кодом, когда пишете тему или когда нужно точно знать, почему HERON ведёт себя именно так.

Простыми словами

Сборка — это момент, когда HERON превращает вашу папку в готовый сайт. Он читает каждую страницу, проверяет её, ставит каждую секцию в нужный блок темы, нарезает картинки и записывает результат в папку dist/: готовые страницы, карту сайта, файлы для нейросетей и настройки для веб-сервера.

Ещё сборка — строгий корректор. Если у страницы нет описания, ссылка ведёт в никуда или нет фотографии, она назовёт файл, строку и что сделать. Обычная сборка только предупреждает; итоговая (--strict) не закончит работу, пока всё не исправлено, поэтому сломанная страница не попадёт к посетителям.

Пример. Типичное сообщение и что оно значит:

E007 ru/services/roof.md:14: картинки нет на диске: roof.jpg
  что сделать: положите фото в папку media или исправьте путь

E007 — код ошибки (смотрите в списке ошибок), ru/services/roof.md:14 — файл и строка, последняя строка подсказывает, как исправить.

Когда нужна эта страница. Когда сборка остановилась с кодом, когда вы пишете тему и хотите знать, что видит шаблон, или когда нужен полный список команд.

Что делает сборка

Читает папку сайта — site.yaml, content/, data/, media/, static/ и тему — и отдаёт готовый статический сайт в dist/.

Одна команда, один проход, никакого состояния между запусками. Собрать дважды подряд — получить побайтово одинаковый результат.

HERON живёт в образе heron:<версия> и в pip-пакете, а не в папке сайта. Первым делом он сверяет свою версию с требованием heron: из site.yaml и отказывается собирать несовместимый сайт (E012).

Сборка идёт без сети. Ни один этап не ходит наружу: всё, что нужно сайту, лежит в папке сайта.

Стек

Python 3.12. Зависимости минимальны — каждая должна быть оправдана, потому что сопровождать это будет начинающий разработчик.

Пакет Зачем
markdown-it-py парсинг markdown, поддержка {#id} через attrs
PyYAML фронтматтер и конфиги
Jinja2 шаблоны
Pillow ресайз и конвертация картинок
pydantic валидация фронтматтера и site.yaml со внятными сообщениями
click командная строка

Пакетный менеджер — uv. Линтер и форматтер — ruff. Тесты — pytest.

Как устроен код

Три уровня: контракты, ядро, модули. Деление не косметическое — по нему проходит граница плагинов: модуль и плагин пишутся против одних и тех же хуков.

heron/
├── cli.py                команды new, init, check, serve, build
├── contracts/
│   ├── site.py           pydantic-схема site.yaml, включая требование версии
│   ├── theme.py          схема theme.yaml
│   ├── frontmatter.py    схема фронтматтера
│   └── version.py        сверка версии HERON с site.yaml
├── core/
│   ├── models.py         Site, Page, Section, Nav
│   ├── parser/
│   │   ├── frontmatter.py
│   │   ├── sections.py   нарезка по заголовкам с якорями
│   │   └── blocks.py     разбор структурированных секций: facts, faq, steps
│   ├── tree.py           обход content/, построение дерева страниц и URL
│   ├── data.py           загрузка data/ в пространство имён шаблонов
│   ├── links.py          резолв связей: каталоги, языки, поля, объявленные темой
│   ├── render/
│   │   ├── env.py        окружение Jinja2, макросы mod.*
│   │   └── pages.py      рендеринг страниц
│   ├── media.py          обработка картинок
│   ├── resolver.py       поиск темы и плагинов: локальные → пакеты → git
│   ├── hooks.py          реестр точек расширения и их вызов
│   ├── report.py         отчёт check, сводка предупреждений
│   └── errors.py         типы ошибок и коды
├── modules/              то же, что плагины, только поставляются в ядре
│   ├── sitemap.py
│   ├── robots.py
│   ├── llms.py
│   ├── jsonld.py
│   ├── redirects.py
│   └── feed.py
└── scaffold/             образцы файлов для new и init

Границы соблюдаются строго: ядро не знает предметную область ни одного сайта. Имя клиента, домен, телефон, слаг конкретной страницы в коде ядра — ошибка. Это проверяет pre-commit-хук репозитория HERON, а не память разработчика.

Всё специфичное живёт в папке сайта: content/, data/, site.yaml и тема.

Конвейер сборки: восемь этапов

Восемь этапов, строго по порядку. Каждый может остановить сборку.

1. Загрузка конфигов. Сверка версии HERON с ключом heron: из site.yaml, затем сам site.yaml и theme.yaml найденной темы. Валидация pydantic. Версия не подходит, тема не найдена или у типа страницы нет шаблона — остановка.

2. Обход дерева. Рекурсивный проход content/<язык>/. Для каждого файла определяется URL, тип (из _index.md папки или по умолчанию), язык. Формируется плоский список страниц.

Адрес и ключ строятся порознь:

  • ключ — путь папок плюс имя файла без расширения. Он никогда не смотрит на слаг: склеить их значит запретить языкам разные адреса, а переименование адреса сделать разрывом связи между переводами;
  • адрес — та же цепочка сегментов, но каждый заменён слагом своего раздела, если раздел его объявил, и своим slug, если объявила сама страница.

Слаги разделов собираются отдельным проходом по _index.md до обхода файлов: дочернюю страницу HERON встречает раньше, чем индекс её раздела, а адрес ребёнка зависит от того, как назвал себя родитель.

Здесь же выясняется дата обновления, источник задаёт build.updated_from (manual по умолчанию, git, file). Приоритет один и тот же при любом источнике: updated из фронтматтера перебивает автоматику всегда.

Режим git спрашивает историю двумя вызовами на всю сборку, а не двумя на файл: один обход истории отдаёт даты по всем путям сразу, второй — список незакоммиченных файлов. На сайте в несколько сотен страниц разница между этим и git log -1 на каждый файл — секунды против минут. Берётся время коммитера (%cI) с его поясом: момент, когда правка попала в историю, а значит и на сайт не раньше. Время автора бывает старше на недели (ребейз, черри-пик). После разрешения дат dates.check ищет даты позже момента сборки и обновления раньше публикации — предупреждения вида «даты», при --strict ошибки (см. фронтматтер в формате страницы). Переименования не отслеживаются: для сайта переименование файла — это смена адреса, страница действительно изменилась.

История недоступна — не репозиторий, гита нет в образе, история обрезана (clone --depth 1) — сборка не падает: HERON откатывается на время файлов и говорит об этом одним предупреждением на сборку. Предупреждение с путём файла остаётся там, где оно про конкретный файл: его нет в истории или в нём незакоммиченные правки. Иначе сборка из копии без .git дала бы двести одинаковых строк и забила бы сводку.

Дата приводится к календарной дате в UTC, без времени: результат не должен зависеть от часового пояса машины, на которой запущена сборка.

3. Парсинг. Для каждой страницы: фронтматтер → модель, тело → секции, структурированные секции → данные. Ошибки собираются, не прерывая проход, — чтобы разработчик увидел все сразу, а не по одной за прогон.

4. Резолв связей. Дочерние страницы каталогов, поля связей, объявленные темой в links, языковые соответствия, меню, хлебные крошки, обратные ссылки. Ссылка на несуществующую страницу — остановка.

Внутренние ссылки текста приводятся к языку страницы не приклеиванием префикса, а через страницу: написанный путь разбирается сначала как ключ, потом как адрес в языке страницы, потом как адрес языка по умолчанию (полное правило и пример — в разделе дерево контента и адреса). У найденной страницы берётся версия языка текущей страницы, и подставляется её адрес. Обрабатываются страницы всех языков, включая основной: ссылка-ключ на раздел со слагом и там отличается от адреса. Перевода нет — предупреждение, и ссылка ведёт на версию языка по умолчанию: читатель попадает на существующую страницу, а не в 404.

Отдельно проверяются ссылки, написанные руками в тексте: каждая вида /…/ обязана разрешаться в существующую страницу по тому же правилу. Не разрешается — предупреждение с путём файла, номером строки и самой ссылкой. Это предупреждение, а не ошибка: битая ссылка внутри текста не повод не выкладывать остальной сайт. Но и молчать нельзя — адрес раздела задаётся слагом, и переименование раздела оставляет написанные адресом ссылки указывать в никуда до первого нажатия. Пропускаются внешние адреса, якоря, пути к /media/, /static/, /assets/ и к файлам с расширением.

Большие разделы. Пагинации у HERON нет, и это решение, а не пробел. Раздел со ста и более детьми — это одна страница каталога: page.children отдаёт теме всех детей сразу, отсортированных по order, затем по h1. Адресов вида /faq/page/2/ HERON не создаёт, поэтому вопросов с их каноникалом, prev/next и попаданием в карту сайта не возникает: в sitemap попадают раздел и каждый ребёнок, по одной записи.

Как показать длинный список удобно — забота темы: сгруппировать детей по полю (groupby в Jinja), вывести оглавление с якорями, свернуть группы в элемент details. Все ссылки при этом обязаны быть в HTML (ограничения): подгрузка списка скриптом прячет страницы от поисковика. Сто ссылок на странице каталога — нормальный размер; если раздел вырастет до тысяч, его делят на подразделы папками — у каждого свой _index.md, свой адрес и своя запись в карте.

Проверено на разделе из 120 страниц (tests/test_large_section.py).

5. План картинок. Для каждого мастера — имена и размеры всех нарезок, которые тема может попросить (пропорции × ширины × форматы, запасной формат — одна ширина fallback_width). Ничего не кодируется: шаблонам нужны только имена и width/height, а их даёт геометрия мастера. Оригиналы не трогаются.

После рендера и генераторов — нарезка. HERON ищет ссылки на media/ во всём собранном тексте: страницах, картах, разметке, файлах плагинов, стилях темы и статике. Кодируется и кладётся в сборку только пересечение этих ссылок с планом. Мастер целиком кладётся, только если на него есть ссылка (media_url(src), og:image) или он объявлен «сырым»: картинка из текста страницы, og_image, twitter_image, video_poster, seo.og_default_image. Файлы, которые HERON не режет (видео, PDF, svg, as_is), копируются как раньше.

Нарезки живут в кэше, а в сборку попадают жёсткой ссылкой (не вышло — копией: файловая система может её не дать). Иначе кэш бесполезен: dist расходный и стирается перед каждой сборкой, а вместе с ним стирались бы и нарезки — хеш совпадает, файла нет, всё кодируется заново.

Запись кэша хранит хеш мастера вместе со спеком темы и готовый кусок манифеста. Поэтому мастер, у которого хеш совпал и все нарезки на месте, не открывается вовсе: список ширин зависит от ширины исходника, и без сохранённого манифеста пришлось бы декодировать картинку только ради размеров.

В конце прохода из кэша удаляется всё, чего нет в плане этой сборки: мастер убрали из контента или из темы убрали ширину. То, что тема в этой сборке не попросила, но может попросить, в кэше остаётся: правка шаблона, вернувшая пропорцию, не кодирует её заново. Уборка идёт по манифесту, а не по списку пересчитанного — сборка могла упасть раньше, и тогда сносить нечего.

Кодирование раскидывается по ядрам, задача — мастер, а не отдельная нарезка: дробить мельче значит декодировать исходник семьсот раз вместо девяноста пяти. Число процессов — build.workers, ноль означает по числу ядер. Прогресс тикает в порядке исходного списка, а не готовности: иначе лог перестаёт читаться, а смотрят в него как раз на долгой сборке.

Замер на сайте с 95 мастерами и 2062 нарезками, четыре ядра: холодная сборка 157 с, повторная — 4 с, из них картинки 0,3 с.

6. Рендеринг. Каждая страница через templates/<тип>.html темы (или шаблон, названный в types.<тип>.template). Страница 404 основного языка дополнительно кладётся в корень как 404.html (см. страницу 404).

7. Генерация служебного. sitemap, robots, llms.txt, redirects, feed. JSON-LD встраивается на шаге 6.

8. Копирование. static/ в dist/ как есть, theme/assets/ в dist/assets/.

После — сводка: сколько страниц по языкам и типам, сколько предупреждений, список долгов по контенту.

Предупреждения группируются по виду: картинки, связи, ссылки, меню, переводы, языки, прочее. Сверху то, что ломает страницу для посетителя, снизу то, что заметит только редактор. В каждой группе шесть примеров, полный список пишется в .heron-cache/warnings.txt рядом с dist. Без группировки сотня однотипных замечаний про переводы прятала десяток важных про картинки, которых нет на диске.

Ход сборки виден в терминале: этап, время с его начала, счётчик и полоска внутри длинных этапов — нарезки картинок, рендера, записи файлов. В не-терминал (CI, докер без -t, перенаправление в файл) перерисовывать нечего, поэтому этап объявляется сразу, как начался, длинные этапы отмечаются раз в две секунды, в конце пишется итог. Каждая строка выталкивается из буфера сразу: иначе весь лог доезжает до человека одной пачкой в конце и сборка неотличима от зависшей.

Разбор секций

Сердце системы, поэтому подробно.

Тело файла разбирается markdown-it-py с включённым плагином атрибутов. Секция — заголовок, у которого есть якорь {#id}, и всё содержимое до следующего заголовка того же уровня с якорем либо до конца файла.

Правила:

  • заголовки без якоря секцию не начинают и принадлежат текущей;
  • текст до первой секции доступен как intro;
  • идентификатор: [a-z0-9-]+, иное — ошибка;
  • дубликат идентификатора в файле — ошибка;
  • у секции сохраняются: id, title (текст заголовка), level, html (отрендеренное содержимое без заголовка), raw (исходный markdown);
  • и разобранные части: lead (абзацы до основного узла), body (сам узел), note с note_title (абзацы после), callout (цитата) и links;
  • вводный блок отдаётся тремя полями: lead — первый абзац, promise — второй, intro — весь текст целиком. Третий абзац и далее — предупреждение: в шаблоне для них места нет.

Тема сама решает, показывать ли заголовок секции: mod.prose('what') выводит и заголовок, и текст, а mod.facts('quick-facts') заголовок игнорирует, потому что рисует рамку с фактами.

Формы секций

Каждая секция после разбора получает поле kind — одну из двенадцати форм: prose, list, alert-list, checklist, two-lists, steps, table, facts, faq, timeline, callout, gallery — и data с разобранными данными этой формы. Таблица форм, признаки, порядок распознавания и форма данных — в разделе формат страницы и формы секций, это единственное место, где они описаны. Список форм в коде — heron.contracts.theme.KINDS, порядок автоопределения — heron.core.parser.blocks.AUTO_ORDER. Как каждая форма выглядит на этом сайте — справочник форм.

Разбор ошибается мягко: класс задан, а содержимое не подходит, — секция отдаётся прозой и пишется предупреждение. Валить сборку из-за пропущенной палочки в таблице нельзя.

Модель секции:

class Section:
    id: str                # якорь из {#anchor}
    title: str             # текст заголовка
    level: int
    classes: list[str]     # классы из якоря: alert, timeline, …
    raw: str               # исходный markdown без заголовка
    html: str              # отрендеренное содержимое без заголовка
    kind: str              # форма: prose, list, … — см. контракт, 4.4
    data: Any              # разобранные данные формы; в шаблоне модуля — items
    lead: str              # абзацы до основного узла
    note: str              # абзацы после
    note_title: str        # жирный зачин примечания
    callout: str           # цитата, где бы она ни стояла
    links: list[dict]      # ссылки секции {title, href}
    body: Any              # свойство: data, а для прозы — html

Модель страницы

Поля фронтматтера живут в page.meta — по одному атрибуту на каждую строку таблицы фронтматтера (формат страницы), плюс поля темы, которые ядро не трактует (page.meta.<поле>, а все разом — page.meta.extra). Сама страница добавляет то, что HERON выяснил при обходе и резолве:

class Page:
    lang: str                      # код языка
    source: str                    # путь файла от content/: ru/services/x.md
    key: str                       # ключ: services/x; у главной — пустая строка, в шаблонах и nav — "/"
    meta: PageMeta                 # фронтматтер
    url: str                       # адрес с префиксом языка и слагами: /ru/rukovodstva/x/
    type: str                      # тип страницы
    sections: dict[str, Section]   # секции по якорю
    intro: Section | None          # текст до первой секции
    auto_updated: date | None      # дата из git или файла, если updated не задан руками
    links: list[tuple[int, str]]   # ссылки текста как написаны: строка и адрес

    # заполняется на этапе резолва
    parent: Page | None
    children: list[Page]
    translations: dict[str, Page]  # версии на других языках, по ключу
    breadcrumbs: list[Page]
    related: dict[str, list[Page]] # связи, объявленные темой, и обратные

    # вычисляемые
    title: str                     # meta.title
    h1: str                        # meta.h1
    updated: date | None           # meta.updated, иначе auto_updated
    nav_title: str                 # meta.nav_title, иначе имя файла
    video: Video | None            # разобранные meta.video*
    slug: str                      # последний сегмент url
    name: str                      # имя файла без расширения
    is_index: bool                 # _index.md или главная

Site.page(key, lang) находит страницу по ключу на языке; в шаблоне то же самое — page_of(key).

Рендеринг: что видит шаблон

Jinja2, autoescape включён, StrictUndefined — обращение к несуществующей переменной валит сборку, а не выводит пустоту. Это ловит опечатки в теме на этапе сборки.

Шаблону доступны: page, site (весь site.yaml), data (содержимое data/), nav, t (строки интерфейса темы), mod (макросы модулей), lang и languages, hreflang, page_of(key) (страница по ключу на текущем языке, главная — '/'), picture, media_url, absolute, jsonld(), env (окружение сборки: env.indexable, env.analytics, метка сборки).

Макросы модулей

mod.<модуль>('<якорь секции>'). Логика одинаковая для всех:

  1. секции с таким id нет → вернуть пустую строку, ничего не рендерить;
  2. секция есть → отрендерить theme/modules/<модуль>.html, передав ей данные секции.

Модули без аргумента (mod.call(), mod.author(), mod.breadcrumbs()) берут данные из site и page, а не из секций.

Пустая строка вместо блока — принципиально: заголовок без содержимого хуже отсутствующего блока.

Строки интерфейса

Тексты, принадлежащие теме, а не контенту («Book now» / «Записаться», «Read more» / «Читать далее»), живут в theme/i18n/<язык>.yaml и доступны как t.book_now. Иначе они окажутся зашиты в шаблоны и тема перестанет быть переносимой между языками.

Картинки

Для каждой картинки, на которую ссылается контент:

  • варианты по ширине: 400, 800, 1200, 1600 — но не больше исходной;
  • форматы: AVIF и WebP на всех ширинах; исходный (PNG/JPEG) — запасной для img src, одной шириной images.fallback_width (800), не больше ширины кадра. Другие ширины исходного формата режутся, только если их попросили через media_url(src, ratio, width);
  • режется только то, на что есть ссылка в собранном сайте: пропорция, которую ни один шаблон не попросил, в сборку не попадает;
  • srcset и sizes проставляются модулем;
  • width и height обязательны в разметке — иначе прыгает вёрстка и растёт CLS;
  • loading="lazy" на всё, кроме картинки первого экрана;
  • нарезки хранятся в кэше, а в сборку попадают жёсткой ссылкой.

og_image не обрабатывается: соцсети ждут JPEG или PNG 1200×630 по фиксированному адресу.

Картинка, на которую есть ссылка, но которой нет на диске, — остановка сборки. Битая картинка в проде дороже, чем упавшая сборка.

Кэш нарезок

Кодирование — самая дорогая часть сборки: 95 мастеров при трёх пропорциях, четырёх ширинах и трёх форматах дают больше двух тысяч файлов и минуты работы процессора. Правка одной строки в CSS не должна их стоить.

Где лежит. Рядом с папкой сборки, не внутри неё:

.heron-cache/
├── media.json                     план мастеров: хеш и имена с размерами нарезок
├── media/<путь мастера>/…         сами нарезки
└── warnings.txt                   полный список предупреждений последней сборки

Внутри dist/ кэшу не место, и это не вкусовщина. dist расходный: его стирают перед каждой сборкой, потому что иначе в нём копятся файлы удалённых страниц. Кэш, лежащий внутри расходной папки, стирается вместе с ней — до этой версии так и было, и кэш не срабатывал ни разу: хеш совпадал, файла не было, всё кодировалось заново.

Как попадает в сборку. Жёсткой ссылкой. Копия — это второй гигабайт на диске и минута на запись при каждой сборке, ссылка не стоит ничего. Файловая система может её не дать — сетевой том, другой раздел, FAT — тогда HERON копирует и молчит: это не повод падать.

Следствие, о котором стоит знать: файл в dist и файл в кэше — это один и тот же файл. Править картинки прямо в сборке нельзя: правка уедет в кэш и переживёт rm -rf dist. Никто так и не делает, но знать полезно.

Что считается изменением. Хеш записи — это хеш мастера вместе со спеком картинок из темы. Поменяли файл — пересчитывается он один. Поменяли widths, ratios, formats, crop или as_is в theme.yaml — пересчитывается весь сайт, потому что изменился состав нарезок у всех. Это осознанно: половина сайта в старых ширинах хуже долгой сборки.

Почему в записи лежит кусок манифеста. Чтобы решить «этот мастер трогать не надо», мало сравнить хеш: нужны ещё имена нарезок, а набор ширин зависит от ширины исходника — узкую картинку в 1600 не растягиваем. Без сохранённых имён пришлось бы открывать и декодировать каждый мастер ради одних размеров. С ними попадание в кэш означает ноль открытий: только ссылки.

Уборка. В конце прохода из кэша удаляется всё, чего нет в плане этой сборки: мастер убрали из контента, из темы убрали ширину. На сайт мусор не влияет — в сборку попадает только то, на что ссылается сайт, — но место занимал бы бесконечно.

Уборка идёт по манифесту, а не по списку пересчитанного: сборка могла упасть раньше, и тогда сносить нечего. Пустая сборка не выносит кэш.

Формат кэша версионирован. Запись другой версии игнорируется целиком — кэш протухает один раз и наполняется заново. Это дешевле, чем поддерживать чтение всех прошлых форматов ради одной сборки.

Кэш можно удалять в любой момент. Он влияет только на скорость: первая сборка после удаления будет холодной. Именно поэтому он в .gitignore и именно поэтому прод и CI не обязаны его иметь.

Сколько процессов

Кодирование независимо и упирается в процессор, поэтому раскидывается по ядрам. Задача — мастер, а не отдельная нарезка: дробить мельче значит декодировать исходник семьсот раз вместо девяноста пяти.

build.workers в site.yaml, ноль — по числу ядер машины. Ставят руками там, где ядра делят с другими задачами: в CI сборка сайта не одна на машине, и забрать все ядра значит замедлить соседей.

Прогресс тикает в порядке исходного списка, а не готовности. Иначе лог перестаёт читаться, а смотрят в него как раз на долгой сборке.

Скорость кодировщиков

У webp и avif выставлены явные регуляторы скорости — не умолчания библиотеки. Умолчание может смениться с обновлением зависимости и сделать сборку впятеро медленнее без единой строчки в нашем коде.

Замер avif на наборе из 95 мастеров, 800 px, quality 82:

speed время на вариант размер к выбранному
0 5,33 с 43.0K −5,7%
4 0,69 с 44.4K −2,6%
6 0,13 с 45.6K —
8 0,05 с 47.3K +3,6%
10 0,02 с 57.1K +25,0%

Выбрана шестёрка: четвёрка стоит впятеро дороже по времени ради двух с половиной процентов размера, десятка экономит время ценой четверти веса. У webp тем же способом выбран method=4: самый медленный режим давал 7 секунд на вариант вместо 0,1 при выигрыше около двух процентов.

Генераторы

sitemap

Состав зависит от числа языков в site.languages:

  • один язык — sitemap.xml и есть список адресов (urlset). Индекс над единственным файлом ничего не добавляет, кроме лишнего запроса;
  • два и больше — sitemap.xml становится индексом, рядом лежит sitemap-<язык>.xml на каждый язык из languages, у которого есть индексируемые страницы. Языка, которого нет в списке, нет и в карте.

Включаются страницы с published: true и noindex: false, кроме страницы 404. lastmod из updated, полной датой с поясом (фронтматтер, формат страницы). У записи, у которой есть версии хотя бы на двух языках, — xhtml:link на все версии плюс x-default, если есть версия языка по умолчанию. Тот же набор выводится в шапку страницы (переменная шаблона hreflang); полные правила — в разделе языки.

Служебных типов записей в sitemap нет по определению: в системе нет сущностей без URL. Служебные записи, какие оставляют в картах сайта плагины CMS, здесь невозможны архитектурно.

robots.txt

Из seo.robots_extra плюс обязательная строка Sitemap: с абсолютным адресом. Собирается, а не лежит файлом: положенный руками, он через полгода начинает врать.

llms.txt

По формату llmstxt.org: заголовок сайта, краткое описание, разделы со списком страниц и однострочными описаниями из description. Строится из того же дерева, что и меню.

При seo.llms_txt: true дополнительно llms-full.txt — весь текст сайта одним файлом.

Одноязычный сайт языковых файлов не получает: /llms.txt сразу содержит полный перечень страниц (с блоком seo.llms_note этого языка), /llms-full.txt — полные тексты, и они ссылаются друг на друга. Оглавление из одной ссылки заставило бы модель сделать лишний шаг ради того же содержимого.

На многоязычном сайте файл собирается на каждый язык, а не только на основной:

  • /llms.txt — корневой: название сайта, описание и список языковых версий. Перечня страниц в нём нет, это оглавление;
  • /llms-<язык>.txt — полный перечень страниц своего языка. В шапке ссылки на остальные версии и на корневой файл, чтобы модель, попавшая сразу на русский, знала о существовании других;
  • /llms-full-<язык>.txt — то же с полным текстом, при seo.llms_txt: true.

Навигация замкнута: тупиков в наборе нет. Из любого файла можно дойти до любого другого:

Файл Куда ведёт из шапки
/llms.txt на краткую и на полную версию каждого языка
/llms-<язык>.txt на корневой, на краткие версии других языков, на свою полную
/llms-full-<язык>.txt на корневой, на полные версии других языков, на свою краткую

Ссылка между краткой и полной версией одного языка — не про языки, поэтому она есть и на одноязычном сайте, где блока «другие языки» нет вовсе. Полные файлы отключены (seo.llms_txt: false) — ссылок на них нет нигде.

Подписи строк — на языке самого файла, для корневого — на языке сайта по умолчанию. Языку, которого нет в наборе подписей, достаётся английская: она понятна и модели, и человеку, а выдуманный перевод — никому.

Языки не смешиваются в одном файле намеренно: модель отвечает на языке того текста, который прочла, а близкие языки — украинский с русским — она путает чаще всего. Раздельные файлы дают ей выбрать нужный, перекрёстные ссылки — найти остальные.

Формат llmstxt.org языковых версий не описывает, поэтому конвенция своя: файл читает модель, ей достаточно внятной строки человеческим текстом.

Язык, у которого нет ничего, кроме заглушки, в список не попадает: ссылка на пустой файл хуже её отсутствия — модель уйдёт по ней и вернётся ни с чем.

Сведения о сайте. seo.llms_note даёт на каждый язык кусок markdown, который вставляется дословно:

  • в /llms-<язык>.txt — после краткого описания, до перечня страниц;
  • в /llms-full-<язык>.txt — после заголовка, до текстов страниц;
  • в /llms.txt — текст языка сайта по умолчанию.

Порядок именно такой: сначала «что это за сайт», потом «как с ним иметь дело», потом содержимое. Читателю нужен ответ раньше оглавления — за ответом он и пришёл.

Блок не ищется в готовом тексте и никуда не «вставляется» постфактум: файл собирается списком строк, и место блока задано в момент сборки. Поиск по готовому файлу спотыкался бы о тексты страниц, где встречаются и заголовки, и цитаты, похожие на шапку.

Содержимое блока — факты о сайте, а не указания читающей модели. Текст с чужого сайта потребитель вправе считать данными, а не инструкциями; попытка управлять через него чужим агентом бесполезна и выглядит как внедрение инструкций.

JSON-LD

Встраивается в шапку каждой страницы, тип зависит от типа страницы.

Типы разметки объявляет тема, ключом types.<тип>.jsonld в theme.yaml: у сайта услуг, магазина и справочника соответствие разное, и ядро его знать не обязано.

Ядро строит то, что действительно знает само:

Что Откуда берётся
@type, объявленный темой theme.yaml
name, description, url, inLanguage страница
datePublished поле date, полночь UTC: 2026-03-14T00:00:00+00:00
dateModified updated или build.updated_from; из git — время коммита с поясом
поля организации и прочие реквизиты блок schema в site.yaml
уточнения по конкретной странице блок schema во фронтматтере
BreadcrumbList построенные крошки, везде кроме главной
FAQPage секция, разобранная как faq

Слияние трёхуровневое: сайт, затем страница. Данные организации живут в site.yaml и не дублируются в контенте.

redirects

Собирает все redirect_from со всех страниц в dist/redirects.map формата nginx:

/old/address/  /new/address/;

Старые адреса сначала нормализуются: percent-encoding раскрывается, текст приводится к NFC, регистр сохраняется, завершающий слеш добавляется всем, кроме файлов (см. старые адреса в разделе дерево контента и адреса). Пример с кириллицей: /%D0%BA%D1%80%D0%B5%D0%B4%D0%B8%D1%82 и /кредит/ дают одну строку карты /кредит/ /credits/;, и 301 срабатывает на обе формы, со слешем и без. Адрес, который после декодирования не текст (слаг обрезан посреди буквы), остаётся в percent-форме и сверяется с $request_uri.

Проверки на этапе сборки — по нормализованной форме: старый URL не совпадает ни с одним существующим, один старый URL не ведёт в два места, цепочек нет (E009).

Рядом — список 410 из ключа gone в site.yaml (site.yaml): точные адреса, префиксы /wp/* и адреса с параметром /?p=*. Пересечение со страницей или с redirect_from — E022. Копия списка — dist/gone.map.

Обе карты HERON вписывает в конфиг nginx, который собирает вместе с сайтом (dist/.heron-nginx.conf): map $uri для 301 и 410, map "$uri?$args" для записей с параметром, проверки — в начале server, до выбора location. Ответ уходит без обращения к файлам; подключать карты руками не нужно.

feed

dist/feed.xml в формате Atom — только если в site.yaml задан seo.feed: тип страниц, которые идут в ленту. Берутся опубликованные и индексируемые страницы этого типа на языке по умолчанию, не больше seo.feed_limit (20). Порядок — по дате публикации (date), у страниц без неё — по дате обновления, новые сверху. У записи есть элемент published, если задана date, и updated — полной датой с поясом, как в разметке.

Команды

Эти команды — вся поверхность HERON для человека. Внутрь него заходить не нужно.

heron new [путь] [--notes ru|en] [--name …] [--domain …] [--lang en,ru] [-y] [-v]
                                             создать сайт с нуля; путь по умолчанию — «.»
heron init [путь] [--force] [--notes ru|en] [--name …] [--domain …] [--lang …] [-y] [-v]
                                             достроить папку под написанное в site.yaml
heron check [путь] [--strict] [--drafts]     отчёт без сборки
heron serve [путь] [--port 8000]             локальный просмотр с пересборкой
heron build [путь] [--out папка] [--env prod] [--strict] [--drafts]   собрать
heron page <тип> <слаг> [путь] [--lang ru] [--folder раздел]          заготовка страницы
heron docs [тема | template <файл>] [--search текст] [--export папка]  документация этой версии
  • --drafts включает published: false; --strict превращает предупреждения в ошибки — режим для CI;
  • --env prod — боевая сборка: индексация и счётчики. Без него — дев: robots.txt с Disallow: /, noindex, без счётчиков. Пустой сайт и пустой основной язык допустимы только в деве (E017, E021);
  • heron new создаёт сайт в новой папке или в текущей (.). Папка может быть под git: .git, .gitignore, .gitattributes, .gitmodules, .github, .gitlab, .DS_Store, .editorconfig, README, LICENSE не мешают и не перезаписываются. Любой другой файл — отказ с перечнем того, что мешает, и советом heron init;
  • в терминале new задаёт вопросы: язык пояснений, название, домен, языки сайта, основной язык (последний — только если языков больше одного). У каждого вопроса есть значение по умолчанию, неверный ответ переспрашивается, в конце — сводка и подтверждение; отказ ничего не создаёт. Без терминала (агент, CI, docker run без -it) вопросов нет: значения из флагов --name, --domain, --lang (через запятую, первый — основной) или по умолчанию, и об этом печатается строка. -y отключает вопросы и в терминале. Ответы ложатся в site.yaml, под каждый язык сразу создаются content/<язык>/index.md и 404.md с полным SEO-блоком: стартовый сайт собирается --env prod --strict без единого предупреждения. Дальше site.yaml — единственный источник правды, heron init достраивает папки под написанное;
  • --notes ru|en — язык пояснений: комментарии во всех файлах скелета (site.yaml, theme.yaml, SEO-блок страниц), записки heron-readme.md и сообщения на экране: шаги и итог new, init, page, check, build, ошибки. Подробный отчёт сборки (сводка предупреждений) пока только по-русски. Порядок: флаг → переменная окружения HERON_NOTES → build.notes из site.yaml → en. new записывает выбор в build.notes, поэтому init и page без флага держат язык, на котором сайт заведён. Языки самого сайта от него не зависят;
  • heron init смотрит, что в папке есть, дописывает недостающее, ничего не перезаписывает без --force и печатает, что нашёл и что добавил. Нет site.yaml — сайт только заводится: в терминале те же вопросы, что у new, с теми же флагами; языками по умолчанию предлагаются папки, которые уже лежат в content/. Есть site.yaml — вопросов нет, источник правды он;
  • new в папке, где уже есть site.yaml, не перечисляет «мешающие файлы», а говорит, что сайт здесь уже есть, — на языке этого сайта (build.notes) — и предлагает init, check, build или как начать заново;
  • название по умолчанию — имя репозитория из origin в .git/config, иначе имя папки. В контейнере папка всегда /site, поэтому без репозитория там предлагается my-site;
  • new и init печатают сводку по смыслу (конфиг, контент по языкам, тема, папки, записки, что не тронуто) и блок «Что дальше» с готовыми командами под эту папку; полный список файлов — -v;
  • пути — относительно текущей папки. В образе (HERON_IN_CONTAINER=1) видна только смонтированная папка /site; путь с компьютера человека, которого внутри нет, даёт объяснение про монтирование и совет передать . или имя вложенной папки;
  • трейсбек наружу не выходит. Ошибка печатается по форме: код и название, где, подробно, что сделать. Нет прав на запись — совет про --user $(id -u):$(id -g). Непредвиденная ошибка — её вид, текст и совет повторить с --debug (или HERON_DEBUG=1), который отдаёт трейсбек целиком;
  • heron page создаёт файл с полным SEO-блоком и всеми секциями типа из theme.yaml;
  • heron docs — документация ровно этой версии HERON, из пакета: без аргументов — оглавление, с темой — файл целиком в markdown, template <файл> — образец, --search — строки с контекстом, --export <папка> — всё папкой с теми же путями, что в репозитории. В пакет и образ едет только текст (около 400 КБ, сжатым — около 130 КБ), картинки бренда — нет; тест следит, чтобы каждый файл документации был доступен как тема.

Упаковки в образ среди команд HERON нет: образ nginx с сайтом собирает хост — scripts/site.sh <сайт> image <окружение> из репозитория HERON (поставка, выходы). Как пользоваться командами на практике — документация в терминале.

Ошибки и предупреждения

Разделение жёсткое: ошибка останавливает сборку, предупреждение попадает в отчёт.

Останавливают сборку:

Код Условие
E001 битый YAML во фронтматтере
E002 нет title, h1 или description, или поле фронтматтера не того типа (дата не дата, order не число)
E003 дубликат идентификатора секции в файле
E004 два файла дают один URL
E005 недопустимый слаг
E006 поле связи ссылается на несуществующую страницу
E007 картинки нет на диске
E008 у типа страницы нет шаблона в теме
E009 redirect_from совпадает с существующим URL
E010 обращение к несуществующей переменной в шаблоне
E011 site.yaml не проходит валидацию
E012 версия HERON не удовлетворяет требованию heron: из site.yaml
E013 тема не найдена ни локально, ни среди установленных пакетов
E014 плагин из plugins: не установлен или упал при загрузке
E015 сырой HTML в markdown при build.allow_raw_html: false
E016 недопустимый идентификатор секции
E017 сборка прода без единой страницы
E018 папка ресурсов сайта задвоена: media и старая img либо media и static/media
E019 у локального видео нет постера
E020 разметка schema.org не собирается: не JSON, нет объявленного темой типа, в сквозном узле объявлены, но пусты адрес или координаты, есть поля про деньги при seo.allow_prices: false
E021 сборка прода, в которой пуст язык по умолчанию: корень сайта ответил бы 404
E022 запись gone закрывает адрес существующей страницы или пересекается с redirect_from
E023 fields в theme.yaml называет поля, которых HERON не знает: свои поля фронтматтера туда не пишут, либо нужна более новая версия HERON

Сообщение об ошибке обязано содержать путь к файлу, номер строки, что не так и что сделать. «Ошибка в шаблоне» без адреса — не сообщение.

Предупреждения:

title длиннее 60 знаков · description длиннее 155 · картинка без alt · страница без image в каталоге · секция в файле, которую тема не использует · отсутствие опциональной секции.

Последнее собирается в сводку долгов:

Не заполнено:
  contra  — 6 страниц: design, audit-basic, …
  price   — 6 страниц
  risks   — 6 страниц

Это тот самый список к заказчику, который иначе теряется. Он печатается после каждой сборки, а не вспоминается раз в квартал.

Отчёт heron check

Без сборки читает контент и тему и печатает:

  • страницы по языкам и типам, что переведено, а что нет;
  • заполненность секций по каждому типу;
  • секции в файлах, которые тема не использует, — ловит опечатку в якоре;
  • модули темы, которые не использует ни один тип;
  • внутренние ссылки: битые, и страницы, на которые никто не ссылается;
  • страницы без ссылки на запись — нарушение правила перелинковки;
  • длины title и description;
  • карта редиректов и её конфликты;
  • файлы в media/, которых не ждёт ни одна страница, и контент, осевший в static/;
  • папки без пояснительной записки heron-readme.md — информация, не ошибка;
  • поля, которые есть не у всех элементов списка: у HERON строгий режим неопределённых значений, и обращение к отсутствующему ключу роняет сборку. Шаблон пишут по первому элементу, а падает он на шестом. Это не ошибка контента — у половины документов действительно нет места выдачи; это предупреждение автору шаблона писать d.get("place").

Гоняется в CI на каждый push. Это замена схемы валидации, от которой мы отказались ради простоты входа: контроль остаётся, но не требует писать и поддерживать схемы.

Производительность

Сайт на 40 страниц в трёх языках — это 120 страниц. Всё, кроме картинок, — разбор, связи, рендеринг, генераторы, запись — укладывается в пару секунд. Дорогая часть одна: кодирование нарезок.

Замер: 95 мастеров, 2062 нарезки, четыре ядра.

Сборка Время
холодная, кэша нет 157 с
повторная, кэш цел, dist стёрт 4 с, из них картинки 0,3 с

Отсюда два правила. Первое: кэш нарезок обязан переживать удаление dist, иначе его нет вовсе. Второе: холодную сборку спасают только ядра — у прода и CI кэша нет по определению.

Инкрементальная сборка страниц не нужна и не делается: heron serve пересобирает всё при изменении файла, и без картинок это пара секунд.

Тесты

Обязательные, без них работа не принимается:

  • парсер секций: якоря, вложенные заголовки, дубликаты, текст до первой секции;
  • структурированные секции: facts, faq, steps, timeline на валидных и битых данных;
  • построение URL из путей, включая _index.md и языковые префиксы;
  • резолв связей, включая ссылку в никуда;
  • каждый URL из карты редиректов отдаёт 301 на адрес, который существует и отвечает 200. Это единственное, что защищает индекс при переезде. Проверяется на настоящем nginx, в том числе на не-ASCII адресах в обеих формах, без слеша и на обрезанном слаге (tests/test_redirects_unicode.py, tests/test_gone.py);
  • каждая собранная страница содержит ровно один h1;
  • все внутренние ссылки в собранном HTML ведут на существующие файлы;
  • hreflang симметричен: если A ссылается на B, то B ссылается на A;
  • sitemap не содержит страниц с noindex и содержит все остальные;
  • две сборки подряд дают побайтово одинаковый результат.

Отдельно, за пределами unit-тестов: прогон собранного сайта через Lighthouse в CI сайта. Пороги задаёт сайт (развёртывание, CI); ориентир — LCP менее 2 секунд, CLS менее 0,05.

Точки расширения

Модули из heron/modules/ и внешние плагины пишутся против одного набора хуков. Разница только в поставке: модуль лежит в ядре, плагин ставится пакетом и объявляется в site.yaml.

Хук Когда вызывается Зачем
on_config после загрузки site.yaml и theme.yaml дописать или провалидировать конфиг
block:<имя> при разборе структурированной секции свой тип секции сверх встроенных
on_page_parsed после парсинга каждой страницы правка модели страницы
on_tree_built после обхода content/ свои связи и группировки
filter / global при сборке окружения Jinja2 свои функции для темы
on_page_rendered после рендера страницы постобработка HTML
on_build_finished после копирования static/ свои генераторы: ещё один sitemap, экспорт, фид

Больше на старте не заводим: API, придуманный под будущее, всегда придуман неправильно.

Хуки действуют только на этапе сборки. Плагина, который «принимает форму», не существует — в момент POST HERON уже отработал и вышел. Всё, что живёт в рантайме, — это heron-relay, отдельный сервис (поставка, формы).

Источники плагинов и порядок разрешения — core/resolver.py: локальная папка plugins/ сайта → установленный пакет (entry point группы heron.plugins) → git-ссылка. Локальный сильнее.

Источник

Эта страница повторяет docs/spec/21-engine.md из ветки prod репозитория HERON (документ 2 из 4; дата 2026-09-08, правка 2026-09-14). Первым обновляется репозиторий, эта страница — сразу следом. Последняя версия всегда здесь: 21-engine.md на GitHub.