Контракт данных · часть 5
Картинки, файлы и правила
Пятая часть контракта данных: картинки, видео и файлы, папка static/, что тема требует от HERON, секреты сервисов и ограничения, которые нельзя нарушать.
Страница нужна тем, кто кладёт на сайт фотографии и документы, и тем, кто делает тему.
Простыми словами
Кладите фотографии, видео и документы в папку media/ и ссылайтесь на них со страницы. Остальное HERON сделает сам: из каждой фотографии он сделает лёгкие копии нескольких размеров в современных форматах (WebP, AVIF), и телефон скачает маленькую картинку, а большой экран — чёткую. Уменьшать руками ничего не нужно.
Пример. Одна фотография на странице:

Из этой строки HERON делает несколько версий картинки и разметку, по которой браузер сам выбирает подходящий размер. Текст в квадратных скобках — описание для тех, кто не видит картинку, и для поисковиков; заполняйте его всегда.
Здесь же описаны ещё две вещи. static/ — для редких файлов, которые должны остаться ровно такими, как есть (например, файл подтверждения от Google). .env хранит секреты для сервера и никогда не попадает ни в папку сайта, ни в git.
Когда нужна эта страница. Когда вы добавляете фото, видео или PDF, когда картинка выглядит обрезанной или когда настраиваете сервер.
Картинки
media/ — одна общая папка на картинки и видео, по языкам не делится. Ссылка и alt живут в md, поэтому для разных языков можно указать разные файлы — например скан документа на каждом из языков.
- ссылки в md относительные от корня:
media/services/design.png; altобязателен. Картинка безalt— предупреждение сборки;- HERON сам делает варианты под размеры экранов, конвертирует в WebP и AVIF, проставляет
width,height,srcset,loading="lazy"кроме самой крупной картинки первого экрана (LCP). Оригиналы не трогаются; - в сборку попадает только то, на что есть ссылка. Пропорции из
images.ratios— это то, что тема может попросить; режется то, что шаблоны попросили черезpicture()иmedia_url(). Запасной файл в исходном формате — один на пропорцию, ширинойimages.fallback_width(по умолчанию 800). Мастер целиком едет в сборку, если на него ссылаются (media_url(src), og:image, картинка из текста страницы), иначе его там нет; og_imageне режется и не конвертируется — соцсети ждут JPEG или PNG 1200×630.
Папка ресурсов называется media
Раньше она называлась img. Имя обещало только изображения, поэтому видео класть было некуда — оно оседало в static/ и ехало в сборку мимо всякой обработки. Теперь в media/ лежит и то, и другое.
Переезд — разовый проход по сайту, и пока он не сделан, сайт обязан собираться: ронять чужую работу ради своего переименования HERON не вправе. Поэтому:
- есть
media/— тихо, это норма; - есть только
img— собирается как раньше, одно предупреждение о переименовании; - есть обе —
E018, сборка останавливается. Это не переходное состояние, а развилка: в какой папке правда, HERON не знает, а гадать дороже; - есть
media/и при этомstatic/media— тожеE018: статика копируется в корень, и обе папки метят в одни адреса. Совпадение имён там — тихая перезапись, которую замечают уже в сети.
Пути внутри сайта пишутся от корня сайта: media/examples/photo.jpg. Форма ./media/… тоже принимается.
Где HERON ищет файлы
Картинка узнаётся по значению, а не по имени поля: имена HERON не угадывает, тема вправе назвать своё поле как угодно. Значением считается путь, который начинается с папки медиа и заканчивается известным расширением; строка из прозы под это не попадает, потому что проверяется целиком, а не вхождением.
В media/ лежит любой файл контента, а не только изображение: ролик, памятка в PDF, презентация. Изображения HERON режет под пропорции и конвертирует, всё остальное копирует как есть — список обрабатываемых расширений остаётся явным, но файл не из него не ошибка. Иначе каждый новый тип файла требовал бы правки HERON, а до неё оседал бы в static/ мимо всех проверок.
В сборку попадают только объявленные файлы. Лежащее в media/, но никем не упомянутое, никуда не едет и попадает в отчёт строкой «файлы в media, которых никто не ждёт»: это не ошибка — мастер часто кладут раньше, чем пишут страницу, — но и не повод копить в папке старые версии годами.
Файл тяжелее 10 МБ даёт предупреждение с именем страницы, независимо от типа. Это не ошибка: бывает и репортаж на двадцать минут. Но узнать об этом человек должен на сборке, а не от посетителя с мобильным интернетом.
Ищутся файлы везде, где тема может их объявить:
- поля
imageиog_image; - любое дополнительное поле фронтматтера, на любой глубине: строка, карта, список, список карт, карта со списками внутри. Раскладывать пути и подписи в два параллельных списка, связанных только порядком, больше не нужно;
- тело страницы — обычные ссылки на картинки в markdown;
- справочники
data/: сквозные блоки живут там, и картинка оттуда такая же настоящая. В сообщении об ошибке владельцем назовётся файл справочника.
Глубина обхода ограничена: YAML-якоря дают общие объекты, и предел дешевле, чем однажды ловить зациклившийся обход.
Значение с собственной схемой (https://…, //…) картинкой сайта не считается — это чужой файл, и искать его на своём диске HERON не идёт.
Опечатка в пути даёт E007 с именем страницы — и во вложенном поле тоже. Раньше такая картинка терялась молча: ни ошибки, ни предупреждения, просто пустое место на странице.
Что не надо резать
Тема вправе сказать, что файлы по такому-то пути трогать не нужно:
images:
ratios: ["1:1", "16:9", "4:3"]
widths: [400, 800, 1200, 1600]
formats: [avif, webp]
as_is: [media/docs/]
Такой файл копируется в сборку как есть: ни вариантов, ни конвертации. Скан документа не иллюстрация — его открывают целиком и в одном виде, адаптивные размеры ему не нужны. Три соотношения на четыре ширины в двух форматах — это два десятка файлов на каждый скан, которые никто никогда не запросит, и минуты сборки на каждую дюжину.
Путь пишется началом, как и crop. Проверка существования, E007 и предупреждение о весе работают как для любого другого объявленного файла.
Адрес файла для темы: media_url
Раньше тема умела получить от HERON только готовый элемент picture. Адрес файла как такового было не получить — а он нужен постоянно: ссылка «открыть скан», href модального окна, адрес для скачивания, картинка в разметке для соцсетей, ссылка на документ из текста. Без него файл дублировали в static/: два места, которые надо не забыть поправить вместе.
{{ media_url('media/examples/scan.webp') }}
{{ media_url('media/examples/photo.jpg', ratio='4:3', width=1200) }}
{{ 'media/examples/leaflet.pdf'|media_url }}
- без параметров — адрес самого файла в сборке. Мастер копируется в сборку рядом с вариантами именно ради этого;
- с
ratio— адрес нарезанного варианта;widthберёт ближайший размер не меньше запрошенного, а если таких нет — самый крупный: отдать крупнее и дать браузеру сжать честнее, чем показать мыло; formatвыбирает формат варианта; без него — исходный формат мастера, потому что это адрес для ссылки и скачивания, а не дляpicture;- запрошенного варианта нет — предупреждение с путём и адрес исходника. Молча отдавать пустую строку нельзя: именно так теряются картинки, и замечают это глазами через неделю после выката;
- файла нет на диске — та же ошибка
E007, что и для любого объявленного файла; - работает и для файлов, которые HERON не обрабатывает: PDF, ролик, архив.
Видео
video: media/examples/tour.mp4
video_poster: media/examples/tour-poster.jpg
video_title: "Обзор мастерской"
В шаблон это приходит готовым объектом page.video с полями src, poster, title и external. Тема не разбирает схемы адресов сама: сегодня это свой файл, завтра сторонний хостинг, и каждая тема училась бы этому заново.
- значением
videoможет быть внешняя ссылка — различается по схеме адреса. Локальный путь отдаётся файлом, полный адрес — вставкой стороннего плеера. Список площадок не зашит; video_posterобязателен при локальном файле, иначеE019. Без постера браузер тянет первый кадр, то есть грузит видео до того, как посетитель нажал «играть»; на мобильном интернете это десятки мегабайт за просмотр страницы, которую пролистали дальше. У стороннего плеера свой постер — там ничего не требуется;- видео не перекодируется и не режется, копируется как есть. Проверяется наличие файла и наличие постера;
- нет поля
video—page.videoпустой, и блок не рисуется; - секция
{#video}в контенте даёт подпись и описание, сам файл берётся из фронтматтера.
Иллюстрация вписывается, фотография кадрируется
В репозитории лежит один файл на сущность — мастер любого соотношения с прозрачным фоном. HERON вписывает его в каждое соотношение, которое просит тема, и добивает прозрачными полями до нужного размера:
4:3 мастер, лежит в media/ — соотношение любое
16:9 карточка в каталоге и на главной — вписывается, поля прозрачные
1:1 квадратный слот — вписывается, поля прозрачные
Кадрировать иллюстрации запрещено. Фотографии — портреты, интерьеры, работы — режутся центральным кропом, как раньше: у них нет альфа-канала, и поля им взять неоткуда. Признак один: есть альфа — вписываем, нет — кадрируем. Тема вправе назвать исключения явно.
Предупреждения «объект упирается в край мастера» больше нет: при вписывании это перестало быть проблемой.
Почему так. Квадратный мастер с запасом по краям — требование, которое иллюстраторы не выполняют: рисунок почти всегда доходит до границы кадра, и любой центральный кроп его срезает. Перерисовывать файлы под каждую пропорцию дороже, чем вписывать, а вписывание даёт тот же результат без запаса по краям.
Прозрачность и темы
Иллюстрации хранятся с альфа-каналом (PNG или WebP) и кладутся на подложку, которую даёт тема. Следствия:
- JPEG для иллюстраций запрещён — нет альфы, белая плашка останется намертво и в тёмной теме будет светиться прямоугольником. JPEG остаётся только для фотографий;
- заливать фон в цвет темы нельзя — это тот же прямоугольник, просто в цвет;
- белый фон допустим только как промежуточный этап генерации: модели проще нарисовать чистый контур на плоском фоне, чем сразу выдать корректную альфу. В репозиторий уходит вырезанное;
- второй файл под тёмную тему не делается. Одно изображение работает в обеих, если тело объекта светлое, а контур средней светлоты: тёмный контур на тёмном фоне даёт около 2:1 по контрасту, и силуэт расплывается. Конкретные тона задаёт документ темы, а не этот;
- подложка под иллюстрацией всегда нейтральная. Карточка в цвет объекта съедает силуэт;
- фотографии в тёмной теме не инвертируются и не затемняются фильтром.
static — запасной выход, а не вторая папка для картинок
static/ копируется в dist/ байт в байт по тому же пути: без обработки, без проверок и без упоминания в контенте. Это её единственное назначение и единственная причина существования.
Она нужна файлам, которые обязаны лежать по точному адресу и которые HERON трогать не должен:
- подтверждение прав вебмастера;
.well-known/security.txt;ads.txt, проверочные файлы платёжных систем;site.webmanifest, favicon.
Картинкам, видео и документам там не место — их дом media/, где они получают проверку на существование, варианты под размеры экранов и внятную ошибку при опечатке. Оформлению сайта — логотипу, иконкам интерфейса, шрифтам — тоже не место: их дом theme/assets/, потому что это часть темы, а не контента.
Правило держится не на честном слове: heron check называет файл с расширением картинки или видео, найденный в static/, и предлагает перенести его в media/. Без этого через полгода там снова окажутся фотографии — положить файл в обход обработки всегда быстрее, чем объявить его в контенте.
Пример. Google просит подтвердить права на сайт файлом в корне. Положите его в static/, и после сборки он отвечает по тому же адресу:
static/google1a2b3c4d.html → https://example.com/google1a2b3c4d.html
static/.well-known/security.txt → https://example.com/.well-known/security.txt
Пустая static/ — нормальное состояние сайта. У большинства сайтов там не лежит ничего.
Чего тема ждёт от HERON
Тема живёт в репозитории сайта и обновляется отдельно от HERON. Поэтому она объявляет свои требования к нему прямо в theme.yaml:
heron: ">=0.1" # версия HERON, на которую рассчитана тема
fields: [nav_title] # поля фронтматтера, к которым обращаются шаблоны
HERON проверяет это на первом этапе, до обхода контента. Версия не та — E012; в fields поле, которого HERON не знает, — E023 со списком полей, а не по ошибке на каждой странице с бесполезной подсказкой «опечатка во фронтматтере».
fields — только поля самого HERON (таблица фронтматтера). Свои поля темы (topic, eyebrow) туда не пишут: HERON их не знает и знать не должен. В шаблоне они доступны как page.meta.get('topic').
Оба ключа необязательны. Тема, не объявившая ничего, собирается как раньше — до первого расхождения, которое тогда вылезет при отрисовке.
.env
Сборке секреты не нужны ни одного. Она идёт без сети и читает только файлы сайта. .env принадлежит сервисам — heron-relay, позже админке — и в контейнер сборки не монтируется никогда.
Файл закладывается сразу, чтобы потом не переделывать.
SMTP_HOST=
SMTP_PORT=
SMTP_USER=
SMTP_PASSWORD=
TELEGRAM_BOT_TOKEN=
ADMIN_USER=
ADMIN_PASSWORD_HASH=
SESSION_SECRET=
DB_PATH=
Правила обращения:
.envв.gitignoreи в.dockerignore. В репозитории лежит.env.exampleс пустыми значениями;- монтируется файлом, не через
environment:и не через--env-file: переменные окружения видны вdocker inspectлюбому с доступом к сокету, смонтированный файл — нет; - читается в момент использования, не при старте;
- монтируется только в тот контейнер, которому нужен. nginx про почту не знает;
- пароль администратора хранится хешем, не открытым текстом;
- секреты никогда не попадают в логи. Самая частая утечка — не взлом, а трейсбек при ошибке подключения.
Отдельно: для отправки почты брать API-ключ почтового сервиса, а не SMTP-пароль от ящика. Утечка ключа — спам с домена. Утечка пароля от ящика — доступ ко всей переписке владельца.
Что генерируется, а что лежит руками
Генерируется HERON в dist/, в репозитории отсутствует:
sitemap.xml (на многоязычном сайте — индекс и по файлу на язык) · robots.txt · llms.txt (на многоязычном — и по файлу на язык) · 404.html · redirects.map для nginx · JSON-LD внутрь страниц · hreflang и канонические адреса · feed.xml, когда появятся статьи.
Лежит руками в static/: favicon и его варианты · site.webmanifest · .well-known/security.txt.
robots.txt, положенный руками, через полгода начинает врать: адреса меняются, а файл — нет. С картой сайта, которую строит сторонний плагин, происходит то же самое.
Ограничения, которые нельзя нарушать
- Никакого JavaScript для отображения контента. Скрипты обслуживают только тему, куки-баннер, мобильное меню и аккордеон FAQ. Числа и текст обязаны быть в HTML: счётчик, который рисует скрипт, для робота стоит нулём;
- одна страница — один H1, иерархия заголовков без пропусков уровней;
- пустой блок не выводится. Заголовок без содержимого хуже отсутствующего;
- факты не выдумываются. Данные, которых заказчик не подтвердил, остаются отсутствующими и попадают в сводку долгов, а не заполняются правдоподобным текстом. Пустой блок лучше вымысла;
- целевое действие доступно с любой страницы — это требование темы, и тема отвечает за то, чтобы оно выполнялось на каждом шаблоне.
Источник
Эта страница повторяет разделы 7–10 файла docs/spec/20-data-contract.md из ветки prod. Репозиторий обновляется первым: открыть файл на GitHub.