Read in:
Русский

Сколько работы прячет шаблонизатор

О чём это: за неделю мы нашли в шаблонах 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&amp;y&lt;/textarea&gt;&lt;script&gt;.

Выключили экранирование не по небрежности. Это было исправление другой ошибки, которую я описал в анатомии 15 месяцев. Функция asset() возвращает подписанную ссылку на хранилище, в ней параметры разделены &. Jet по умолчанию превращал & в &amp;. В атрибуте href это правильно: браузер раскодирует &amp; обратно. А внутри <style>, в url(...), ничего не раскодируется, и хранилище получало подпись с &amp; и отказывало. Экранирование выключили целиком, CSS заработал, а всё остальное осталось без защиты.

Ошибка тут не в одном решении. Экранирование без учёта контекста ошибается в одну или в другую сторону. Одно и то же значение нужно обрабатывать по-разному в тексте, в атрибуте, в <style>, в <script> и в URL. Go-шный html/template так и делает: он разбирает HTML вокруг каждой вставки и выбирает экранирование по месту. Я прогнал ту же ссылку через него: в href он написал &amp;, а в 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> &amp; не раскодируется.
  • Проверяйте результат поиска одним тегом: {{ if x := nvs.ByPath(...); x }}. == nil теперь тоже работает.
  • В range пишите две переменные: {{ range i, item := list }}, а ненужную назовите _.
  • Вызывайте методы со скобками: note.Title().
  • exec — для данных, общих для нескольких макетов, путь пишется без .html; try — вокруг того одного виджета, который может упасть.
  • Ссылайтесь на заголовки по id, а не по тексту.

Подробнее — в Шаблонах, синтаксисе Jet и синтаксисе Markdown.