Спецификация 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.<модуль>('<якорь секции>'). Логика одинаковая для всех:
- секции с таким id нет → вернуть пустую строку, ничего не рендерить;
- секция есть → отрендерить
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.