Русский
Сколько работы прячет шаблонизатор
О чём это: за неделю мы нашли в шаблонах trip2g выключенное экранирование, nil, который не равен nil, якоря, на которые не попадает ни одна wikilink, и справочник, который в трёх местах говорил обратное коду. Всё это генератор статических сайтов или CMS делает за вас, и вы об этом не знаете. Читать, если вы пишете свои макеты для trip2g или выбираете шаблонизатор для своего продукта.
Много лет я пользовался генераторами статических сайтов и CMS и ни разу не задумался, сколько работы стоит за строчкой {{ title }}. Пишешь переменную, она появляется на странице. Если в заголовке есть <, он не ломает разметку. Если ссылаешься на заголовок, браузер к нему прокручивает. Если заметки нет, шаблон показывает запасной текст.
В trip2g шаблонизатор свой, точнее, своя обвязка вокруг Jet. И на этой неделе я увидел, что каждая из этих мелочей — отдельное решение, которое кто-то должен был принять. В нескольких местах мы его не приняли.
Экранирование и его контексты
Начну с самого неприятного. Макеты в trip2g собирались с jet.WithSafeWriter(nil) (internal/layoutloader/loader.go). Эта опция выключает экранирование целиком. Любое текстовое значение печаталось как есть, а фильтры | unsafe и | raw ничего не делали: отключать было нечего. При этом три страницы документации, Шаблоны, Шаблоны: продвинутое и английская версия первой, говорили, что HTML экранируется по умолчанию.
Чем это грозит, проще показать. Возьмём макет канбан-доски, который даёт править карточку прямо на странице:
<textarea>{{ note.ContentString() }}</textarea>
Если в тексте любой карточки встретится </textarea><script>…, браузер закроет поле и выполнит скрипт у каждого, кто откроет доску. Я проверил это на голом Jet: с выключенным экранированием строка x&y</textarea><script> уходит в страницу без изменений, со включённым становится x&y</textarea><script>.
Выключили экранирование не по небрежности. Это было исправление другой ошибки, которую я описал в анатомии 15 месяцев. Функция asset() возвращает подписанную ссылку на хранилище, в ней параметры разделены &. Jet по умолчанию превращал & в &. В атрибуте href это правильно: браузер раскодирует & обратно. А внутри <style>, в url(...), ничего не раскодируется, и хранилище получало подпись с & и отказывало. Экранирование выключили целиком, CSS заработал, а всё остальное осталось без защиты.
Ошибка тут не в одном решении. Экранирование без учёта контекста ошибается в одну или в другую сторону. Одно и то же значение нужно обрабатывать по-разному в тексте, в атрибуте, в <style>, в <script> и в URL. Go-шный html/template так и делает: он разбирает HTML вокруг каждой вставки и выбирает экранирование по месту. Я прогнал ту же ссылку через него: в href он написал &, а в url() внутри <style> оставил &. Поэтому Hugo, построенный на html/template, этой ошибки не знает. Jinja2 умеет только HTML-экранирование, и в голом Environment оно даже выключено, пока его не включат. Jet тоже знает один способ экранирования на весь вывод. Пользователь Hugo о контекстах не думает, потому что за него подумали авторы Go.
Контекстам Jet не научится, поэтому в PR #387 мы закрыли дыру иначе. Макеты снова экранируют по умолчанию. HTML, который собирает сам сервер (note.HTMLString(), HTML секций, defaultTemplate.*), получил свой тип, model.SafeHTML, и Jet печатает его как есть, так что старые макеты выводят то же, что раньше. asset() возвращает тот же тип и кодирует через % те немногие символы, которые могли бы вырваться из url(...) или из атрибута, поэтому & в подписанной ссылке внутри <style> остаётся &.
nil, который не nil
Вторая история про проверку на пустоту. Шаблон ищет заметку по пути: {{ x := nvs.ByPath("/about.md") }}. Если заметки нет, функция возвращала типизированный нулевой указатель, *Note(nil). Для Jet это не одно и то же с литералом nil. В итоге:
{{ if x == nil }}для отсутствующей заметки давал ложь;{{ if x }}работал правильно;x.Title()падал с паникой внутри метода и обрывал рендер страницы.
Я воспроизвёл все три случая на чистом Jet v6.3.1. Это классическая ловушка Go, и шаблонизатор передаёт её прямо автору макета, который о типах вообще не думал. Исправление влито в PR #384: поиски, которые могут промахнуться, теперь возвращают нетипизированный nil. Обе формы проверки работают, а вызов метода на промахе даёт ошибку рендера вместо паники. Заодно мы описали идиоматичный способ из Go, который в Jet уже есть: {{ if x := nvs.ByPath("/about.md"); x }}…{{ end }}. Переменная живёт только внутри if и else.
Якоря и id
У каждого заголовка на странице есть id, чтобы на него можно было сослаться. Кто-то должен решить, как этот id получается. В trip2g его строят из текста: кириллица переводится в латиницу, всё кроме букв и цифр заменяется на _, регистр нижний, повтор получает -2. ## Цены и тарифы становится cenyi_i_tarifyi.
Поменяли текст заголовка — поменялся id, и старые ссылки сломались. Поэтому в PR #385 мы включили разбор атрибутов в goldmark: ## Цены и тарифы {#pricing} задаёт id явно, а скобки пропадают из текста, оглавления и поиска. Obsidian такие скобки показывает как есть. Раньше я бы счёл это причиной не делать. Теперь заметки на сайтах trip2g всё чаще пишут агенты, и им скобки в редакторе не мешают.
Третья часть этой истории осталась открытой. Wikilink [[Тарифы#Цены и тарифы]] передаёт фрагмент как написан, и ссылка заканчивается на #Цены%20и%20тарифы. Такого id на странице нет, браузер откроет её с начала. В Obsidian та же ссылка работает, потому что Obsidian ищет заголовок по тексту, а не по id. Пока мы описали это в документации и советуем писать id после решётки.
Справочник, который врал
Чтобы всё это поймать, я сел писать полный справочник того, что можно вызвать в макете (PR #383). Условие было одно: каждое утверждение проверить на коде. Кроме экранирования нашлось вот что:
rangeс одной переменной даёт индекс, а не значение:{{ range x := list }}печатает0,1, а не элементы;note.Titleбез скобок печатает адрес функции, что-то вроде0xc8e90;- в документации был метод
GetStringSlice, которого нет; настоящий называетсяGetStrings; _как переменная цикла,returnиtryне давали макету загрузиться: trip2g при загрузке обходит дерево шаблона, а этих узлов обходчик не знал. Из-заreturnбыл бесполезен иexec, который возвращает именно его значение. PR #389 научил обходчик этим узлам, и все четыре теперь работают;- фильтры через
|разрешены только в выводе: в присваивании и вifнужен вызов функции,parseJSON(s), а неs | parseJSON.
Ни одна из этих вещей не сложная. Но каждую кто-то должен был найти, проверить и записать. В SSG за вас это сделала команда и тысячи пользователей до вас.
Почему это важнее, когда пишет агент
Человек, который скопировал пример из документации и увидел адрес функции на странице, поправит его за минуту. Агент скопирует пример так же, как он написан, а ошибку может не заметить вовсе: страница же отрисовалась. И сделает это на каждом сайте, где его попросят собрать макет.
Поэтому документация шаблонов для нас теперь не справка, а исходник поведения. Неверный пример в ней становится неверным шаблоном во многих местах сразу. Отсюда и требование к справочнику: в нём есть тест, который рендерит рабочий пример из документации и сверяет вывод, чтобы пример не устарел молча. Отсюда же автоимпорт компонентов: макет, который вызывает {{ yield card() }}, получает компонент без явного import. Чем меньше правил нужно знать, тем меньше правил можно нарушить. Но каждое такое удобство — ещё один кусок работы, спрятанный от автора, и его тоже надо описать честно.
Есть и обратная сторона. Агенты всё чаще используют шаблоны как потребителя данных. Заметка несёт данные графика в блоке кода с языком mychart, а макет достаёт их через CodeBlocks("mychart") и разбирает parseJSON. Для этого в #385 появились parseJSON, parseYAML и parseCSV. Шаблонизатор, который недавно печатал заголовок, превращается в маленький язык программирования. И у него те же проблемы, что у любого языка: пустые значения, ошибки разбора, области видимости.
Что мы поменяли
- Промах поиска возвращает настоящий
nil(#384). - Явные id заголовков через
{#id}, данные из блоков кода, разбор JSON, YAML и CSV (#385). - Экранирование по умолчанию: методы, которые возвращают готовый HTML, помечены безопасными через интерфейс
Rendererиз Jet, поэтому старые макеты сnote.HTMLString()работают без| unsafe(#387). - Работают
try/catch,return,execи_вrange(#389). - Справочник функций Jet и правки трёх страниц, которые расходились с кодом (#383).
Всё это уже влито.
Если вы пишете макеты для trip2g сегодня
- Экранирование оставьте макету:
{{ value }}теперь экранируется,| htmlбольше не нужен.| unsafe— только для разметки, которую вы собираете в самом шаблоне, и никогда для текста из заметки или от пользователя. - Выводите
asset()как есть, без| html: внутри<style>&не раскодируется. - Проверяйте результат поиска одним тегом:
{{ if x := nvs.ByPath(...); x }}.== nilтеперь тоже работает. - В
rangeпишите две переменные:{{ range i, item := list }}, а ненужную назовите_. - Вызывайте методы со скобками:
note.Title(). exec— для данных, общих для нескольких макетов, путь пишется без.html;try— вокруг того одного виджета, который может упасть.- Ссылайтесь на заголовки по id, а не по тексту.
Подробнее — в Шаблонах, синтаксисе Jet и синтаксисе Markdown.