Синтаксис Jet

Jet — движок шаблонов для Go. Синтаксис близок к text/template из Go, плюс то, что взято из Jinja2 и Twig: наследование через extends и блоки, фильтры через |, тернарный оператор условие ? a : b.

Если вы знаете шаблоны Go, держите в голове главные отличия:

  • переменные без $: x := note.Title(), а не $x := .Title;
  • методы вызываются со скобками: note.Title();
  • операторы как в C: ==, &&, !, +, а не функции eq, and, not;
  • if с объявлением принимает любое условие: if x := f(); x != "";
  • range с одной переменной даёт индекс, а не элемент;
  • в пайпе a | f: b значение a становится первым аргументом, а в Go — последним;
  • повторяемый кусок — block с именованными параметрами и yield, а не define и template;
  • экранирование одно и то же везде, без учёта контекста, как в html/template.

Если вы знаете Jinja2 или Twig: extends, блоки, import, include и фильтры работают похоже, но все теги пишутся в {{ }}, а цикл — это range, а не for x in list. Полное сравнение — в Справочнике функций Jet.

Справочник синтаксиса от автора движка

Все встроенные функции, фильтры и то, что добавляет trip2g, — в Справочнике функций Jet. Как собирать шаблон из компонентов — в Компонентах шаблонов.

Вывод переменных

Двойные фигурные скобки выводят значение:

{{ note.Title() }}
{{ user.Firstname }}

Методы вызываются со скобками: {{ note.Title }} без () выведет адрес функции, а не заголовок.

Вывод экранируется: {{ value }} выводит < и & текстом. Методы, которые возвращают готовый HTML (note.HTMLString(), ContentHTML и другие), выводятся как есть. Подробнее — в разделе #Экранирование.

Доступ к полям структур, методам, элементам массивов и карт:

{{ user.Fullname() }}
{{ items[0] }}
{{ config["theme"] }}

Объявление переменных

{{ item := items[0] }}
{{ name := "Иван" }}

Комментарии

{* это комментарий, не попадёт в HTML *}

Условия

if / else / else if

{{ if note.Title() }}
  <h1>{{ note.Title() }}</h1>
{{ else }}
  <h1>Без заголовка</h1>
{{ end }}
{{ if len(items) > 0 }}
  Есть элементы
{{ else if len(items) == 0 }}
  Пусто
{{ end }}

Объявление в условии

{{ if value, ok := myMap["key"]; ok }}
  {{ value }}
{{ end }}

{{ if about := nvs.ByPermalink("/about"); about }}
  {{ about.Title() }}
{{ else }}
  Страницы нет
{{ end }}

Переменная видна только внутри if, его else if и else. Подробнее — в присваивании в if.

Тернарный оператор

{{ note.Title() ? note.Title() : "Заголовок не задан" }}

Циклы

range — перебор

Важно: при одной переменной range отдаёт индекс (0, 1, 2…), а не значение.
Чтобы получить значение, всегда указывайте две переменные.

{* item = 0, 1, 2 — это индексы, не значения! *}
{{ range item := items }}
  {{ item }}
{{ end }}

Чтобы получить значения — используйте две переменные:

{{ range idx, item := items }}
  {{ idx }}: {{ item }}
{{ end }}

Если индекс не нужен — поставьте _ вместо первой переменной:

{{ range _, item := items }}
  {{ item }}
{{ end }}

Блок else — если коллекция пуста:

{{ range i, item := items }}
  {{ item }}
{{ else }}
  Список пуст
{{ end }}

Срезы (slice) — начало:конец, конец не включается. Границы могут быть переменными:

{{ from := 1 }}
{{ to := len(items) - 1 }}
{{ range i, item := items[from:to] }}
  {{ item }}
{{ end }}

Подробнее и с выводом — в Справочнике функций Jet.

Операторы

Арифметика

+, -, *, /, %

{{ 1 + 2 * 3 }}
{{ (10 - 2) / 4 }}

Сравнение

==, !=, <, >, <=, >=

{{ if count > 0 }}...{{ end }}

Логические

&& (и), || (или), ! (не)

{{ if isAdmin && isActive }}...{{ end }}
{{ if !isDeleted }}...{{ end }}

Конкатенация строк

{{ "Привет, " + user.Name + "!" }}

Фильтры (пайплайны)

Фильтры преобразуют значения. Передаются через |:

{{ "ТЕКСТ" | lower }}
{{ name | upper }}

Цепочка фильтров:

{{ text | lower | trimSpace }}

Встроенные фильтры

Фильтр Описание
lower В нижний регистр
upper В верхний регистр
trimSpace Убрать пробелы по краям
split Разбить строку
replace Заменить подстроку
repeat Повторить строку
hasPrefix Начинается с...
hasSuffix Заканчивается на...

Экранирование

Фильтр Описание
html Функция: экранирует HTML один раз и возвращает значение. Для текста не нужна, вывод и так экранируется. Нужна, чтобы показать HTML-код текстом: {{ html(note.HTMLString()) }}
safeHtml То же экранирование, но работает только последним фильтром в теге вывода
url Экранировать одно значение параметра URL
unsafe / raw Без экранирования; только последним фильтром в теге вывода
json / writeJson Преобразовать в JSON
safeJs Безопасный вывод в JS-строку

Чем html отличается от safeHtml и когда нужны url, raw, unsafe — с примерами в Справочнике функций Jet.

Функции

isset — проверка на существование

{{ isset(author) ? author : "Аноним" }}

Неопределённая переменная внутри isset — не ошибка, а false.

Работает и для проверки ключей в карте:

{{ if isset(config["theme"]) }}...{{ end }}

len — длина

{{ len(items) }}
{{ if len(text) > 100 }}...{{ end }}

Блоки

Блоки — фрагменты, которые вызываете в разных местах.

Определение блока

{{ block card(title="", text="") }}
  <div class="card">
    <h3>{{ title }}</h3>
    <p>{{ text }}</p>
  </div>
{{ end }}

Задавайте значение по умолчанию каждому параметру. Блок, объявленный в самой странице, выводится ещё и там, где объявлен, — без аргументов, и параметр без значения по умолчанию падает с missing name for block parameter. Слово content зарезервировано: так нельзя назвать ни параметр, ни блок.

Параметры по умолчанию:

{{ block button(text="", type="primary") }}
  <button class="btn-{{ type }}">{{ text }}</button>
{{ end }}

Вызов блока

Важно: параметры передаются по имени, не по позиции. Позиционные аргументы игнорируются, параметры остаются со значениями по умолчанию.

{* Правильно — именованные параметры *}
{{ yield card(title="Заголовок", text="Текст") }}

{* Неправильно — позиционные параметры *}
{{ yield card("Заголовок", "Текст") }}  {* title и text останутся пустыми *}

Порядок параметров неважен:

{{ yield card(text="Текст", title="Заголовок") }}  {* работает *}

Параметры с дефолтами можно не передавать:

{{ yield button(text="Отправить") }}  {* type="primary" по умолчанию *}
{{ yield button(text="Отмена", type="secondary") }}

Блок с вложенным контентом

Определение:

{{ block link(href="") }}
  <a href="{{ href }}">{{ yield content }}</a>
{{ end }}

Вызов:

{{ yield link(href="https://example.com") content }}
  Перейти на сайт
{{ end }}

Рекурсивные блоки

{{ block menu() }}
  <ul>
    {{ range i, item := . }}
      <li>
        {{ item.Name }}
        {{ if len(item.Children) > 0 }}
          {{ yield menu() item.Children }}
        {{ end }}
      </li>
    {{ end }}
  </ul>
{{ end }}

{{ yield menu() navItems }}

Композиция шаблонов

import — импорт блоков

Загружает блоки из другого файла. Странице, которая вызывает блок через yield, импорт обычно не нужен: trip2g импортирует файл сам (см. автоимпорт).

{{ import "blocks" }}
{{ yield main_layout() content }}
  ...
{{ end }}

include — вставка шаблона

Вставляет шаблон целиком с передачей данных:

{{ include "partials/user-card" user }}

Условная вставка:

{{ if ok := includeIfExists("sidebar"); !ok }}
  <p>Боковая панель не найдена</p>
{{ end }}

extends — наследование layout

Шаблон наследует базовый layout и переопределяет его блоки:

{{ extends "base" }}

{{ block main() }}
  <p>This replaces the main block of base.html</p>
{{ end }}

extends должен быть первым тегом шаблона. Что такое базовый шаблон, что где выводится и как наследование сочетается с компонентами, — в Шаблонах: наследование, с проверенным примером.

exec и return — данные из другого файла

exec("путь", данные) выполняет другой файл из _layouts/ и возвращает значение, которое этот файл отдал через return. Всё, что файл напечатал, отбрасывается — назад приходит только значение. Внутри файла данные доступны как ., переменные страницы (note, nvs, …) работают как обычно.

exec — для данных, а не для разметки. Компонент ({{ block }} + {{ yield }}) выдаёт HTML; файл для exec выдаёт список, map или число, а вызывающий шаблон рисует их по-своему. Главный случай — универсальный способ задать выборку в одном месте: какие заметки раздела считаются «избранными», какая у раздела статистика. Файл вызывают несколько шаблонов, а правило живёт в одном месте.

Избранные заметки раздела. _layouts/lib/featured.html:

{{ return nvs.ByGlob(. + "/*.md").Public().SortByMeta("order").Limit(3).All() }}

Любой шаблон получает избранное нужного раздела:

<ul class="featured">
{{ range _, post := exec("lib/featured", "blog") }}
  <li><a href="{{ post.Permalink() }}">{{ post.Title() }}</a></li>
{{ end }}
</ul>

Поменять смысл «избранного» — другая сортировка, пять заметок вместо трёх, только с флагом во frontmatter — это одна правка в lib/featured.html.

Статистика раздела в виде map. _layouts/lib/section_stats.html:

{{ notes := nvs.ByGlob(. + "/*.md").Public().All() }}
{{ minutes := 0 }}
{{ range _, n := notes }}
  {{ minutes = minutes + n.ReadingTime() }}
{{ end }}
{{ return map("count", len(notes), "minutes", minutes) }}
{{ stats := exec("lib/section_stats", "blog") }}
<p>{{ stats["count"] }} posts, {{ stats["minutes"] }} min of reading</p>

Что важно знать:

  • Путь отсчитывается от _layouts/ и пишется без расширения. exec("lib/featured") и exec("/lib/featured") загружают _layouts/lib/featured.html из любой папки. exec("lib/featured.html") файл не найдёт. Шаблонами считаются только файлы .html и .html.json внутри _layouts/.
  • Несуществующий файл роняет рендер с ошибкой template /lib/featured could not be found. Если страница должна это пережить — оберните вызов в try (ниже).
  • return имеет смысл только в файле, который вызывают через exec. В обычной странице он ничего не останавливает и ничего не выводит.
  • Компоненты внутри файла для exec нужно импортировать явно. Автоимпорт работает только для самой страницы; файл, до которого дошли через exec, include или extends, должен сам написать {{ import "/путь/к/компоненту" }} (trip2g#388).
  • Каждый вызов выполняет файл заново. exec не кэшируется: lib/section_stats в цикле по двадцати разделам — это двадцать полных выборок на каждый рендер. Если значение зависит только от самой заметки, дешевле frontmatter; тяжёлые вычисления по многим заметкам лучше делать Go-хелпером.

Обработка ошибок: try / catch

{{ try }}…{{ catch err }}…{{ end }} рендерит внутреннюю часть, а если в ней что-то упало — выбрасывает её вывод и рендерит часть catch. Остальная страница рендерится как обычно. Без catch упавший try не выводит ничего.

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

Виджет графика из frontmatter. Если chart нет или он кривой, читатель увидит маленькую карточку, а не сломанную страницу:

{{ try }}
  {{ chart := note.M().Get("chart") }}
  <figure class="chart">
    <figcaption>{{ chart["title"] }}</figcaption>
    {{ range _, v := chart["values"] }}
      <span class="bar" style="--v: {{ v }}"></span>
    {{ end }}
  </figure>
{{ catch err }}
  <div class="chart chart--broken">
    Chart unavailable: {{ err.Error() | html }}
  </div>
{{ end }}

Необязательная связанная заметка. Поле related во frontmatter может указывать на переименованную или удалённую заметку. Если поиск или любой вызов на ней упадёт, блока просто не будет:

{{ try }}
  {{ related := nvs.ByPath(note.M().GetString("related", "")) }}
  <aside class="related">
    See also: <a href="{{ related.Permalink() }}">{{ related.Title() }}</a>
  </aside>
{{ end }}

Строка статистики из файла-помощника. Если lib/section_stats сломается, посетители ничего не увидят, а админ сайта увидит причину:

{{ try }}
  {{ stats := exec("lib/section_stats", "blog") }}
  <p>{{ stats["count"] }} posts, {{ stats["minutes"] }} min of reading</p>
{{ catch err }}
  {{ if currentUser.IsAdmin() }}
    <p class="admin-error">
      lib/section_stats: {{ err.Error() | html }}
    </p>
  {{ end }}
{{ end }}

Что держать в голове:

  • catch — для заглушек, а не чтобы прятать ошибки. try вокруг половины страницы превращает любую ошибку в тишину. Оборачивайте минимальный кусок, который может законно упасть, и показывайте ошибку админам (как выше), чтобы сломанный виджет заметили. Обращение к currentUser делает страницу персонализированной — она не отдаётся из анонимного кэша страниц.
  • Экранируйте сообщение. В err.Error() может попасть текст из заметки или URL — пропускайте его через html. Фильтр экранирует ровно один раз, включено экранирование вывода по умолчанию или нет.
  • try не ловит шаблон, который не загрузился. Синтаксическая ошибка или extends не на своём месте останавливают шаблон до рендера; это видно как предупреждение шаблона, а не как catch.

Отладка шаблонов

debug() — тип, значение и методы объекта

Глобальная функция debug() возвращает строку с Go-типом, значением и списком методов любого выражения:

{{ debug(note.M()) }}
{* → *templateviews.Meta: &{raw:map[title:My Page extra_content:[a b]]}
      methods: [Debug Get GetBool GetInt GetString GetStrings Has Raw] *}

{{ debug(note.Title()) }}
{* → string: My Page *}

Всегда добавляйте скобки при передаче методов: debug(note.Title()) — правильно, debug(note.Title) — вернёт ссылку на функцию.

Meta.Debug() — JSON frontmatter

{{ note.M().Debug() }}
{* → {"extra_content":["channels","prices"],"title":"My Page"} *}

Для полного гайда по отладке шаблонов через /_system/renderlayout — см. skills/check_templates.

Полезные ссылки