Skip to content

Шаблонизатор ​

Фреймворк включает встроенный шаблонизатор для рендеринга текста (HTML, письма, любые текстовые форматы) с подстановкой динамических данных из JSON-документа.

Принцип работы ​

Шаблон — это текстовый файл со специальными тегами. Источником данных выступает json_doc_t*, а сам файл читается из хранилища, объявленного в config.json.

Парсинг и компиляция шаблона в дерево тегов выполняются один раз; результат кэшируется в viewstore по пути к файлу. При последующих вызовах render() с тем же путём используется уже построенное дерево, поэтому повторный рендеринг отрабатывает быстро.

Настройка хранилища ​

Шаблоны хранятся в обычном хранилище (filesystem или s3). Имя хранилища далее передаётся в функции рендеринга.

json
{
    "storages": {
        "views": {
            "type": "filesystem",
            "root": "/var/www/app/views"
        }
    }
}

API ​

render() ​

c
#include "view.h"

char* render(json_doc_t* document, view_escape_e escape,
             const char* storage_name, const char* path_format, ...);

Рендерит шаблон и возвращает готовую строку.

Параметры

  • document — JSON-документ с данными для шаблона.
  • escape — режим вывода значений {{ }} (см. ниже).
  • storage_name — имя хранилища, где расположен шаблон.
  • path_format — путь к файлу шаблона внутри хранилища. Поддерживает printf-форматирование через вариативные аргументы.

Возвращаемое значение

Указатель на строку с отрендеренным результатом или NULL при ошибке (файл не найден, ошибка синтаксиса). Память необходимо освободить функцией free().

Режимы экранирования

РежимДля чегоЧто происходит со значениями {{ }}
VIEW_ESCAPEHTML-страницы, HTML-письма&, <, >, ", ' заменяются на &amp;, &lt;, &gt;, &quot;, &#39;
VIEW_RAWтекстовые письма, другие не-HTML форматывыводятся как есть

Режим действует на весь рендер, включая шаблоны, подключённые через {* include *}. Экранирование выполняется при выводе, а не при разборе, поэтому один закэшированный шаблон обслуживает оба режима. Подробнее — в разделе Экранирование и безопасность.

send_view() ​

Метод объекта ответа — рендерит шаблон и сразу отправляет результат клиенту.

c
void (*send_view)(struct httpresponse* response, json_doc_t* document, view_escape_e escape,
                  const char* storage_name, const char* path_format, ...);
c
ctx->response->send_view(ctx->response, document, VIEW_ESCAPE, "views", "/index.tpl");

Изменение сигнатуры

Параметр escape добавлен в render() и send_view() перед именем хранилища. Вызов в старой форме (render(doc, "views", "/index.tpl")) не соберётся: ядро, обработчики и миграции, собранные через CMake-функции фреймворка, компилируются с -Werror=int-conversion. Если код приложения собирается своими правилами со старым gcc (до 14), включите этот флаг сами — без него такой вызов соберётся с предупреждением и упадёт при работе.

Синтаксис ​

Шаблонизатор различает три вида тегов:

ТегНазначениеПример
{{ ... }}Вывод значения выражения{{ user.name }}
{% ... %}Управляющие конструкции (if, for){% if active %}
{* include ... *}Включение другого шаблона{* include /header.tpl *}

Переменные ​

Значение выводится через {{ }}. Поддерживается доступ к вложенным полям через точку и к элементам массива по индексу в квадратных скобках. Внутри тега может стоять любое выражение, к значению можно применить модификаторы.

html
<p>Привет, {{ name }}!</p>
<p>Email: {{ user.email }}</p>
<p>Первый элемент: {{ items[0] }}</p>
<p>Имя первого пользователя: {{ users[0].name }}</p>

Как выводится значение каждого типа:

ТипВывод
строкатекст (в режиме VIEW_ESCAPE — экранированный); NUL-байт выводится как � (U+FFFD)
числоцелые без дробной части (42), дробные — в кратчайшей точной форме (9.99)
booltrue / false
null, отсутствующее значениепустая строка
объект / массивпустая строка (выводите поля или обходите циклом)

Выражения ​

Литералы: числа (42, 9.99, -5.5e2), строки в одинарных или двойных кавычках ('текст', "текст", работают \', \", \\, \n, \t, \r), true, false, null.

Переменные: имя ([A-Za-z_][A-Za-z0-9_]*), затем путь из .поле и [индекс]. Индекс — любое выражение: число для массива, строка для объекта (obj[key], obj['literal']). Выход за границы массива, обращение к полю скаляра, отрицательный или дробный индекс дают null, а не ошибку.

Операторы (по убыванию приоритета):

ПриоритетОператорыЗамечания
1|модификатор, привязывается к ближайшему операнду
2! - (унарные)! — логическое отрицание, - — только для чисел
3* / %только числа; / 0 и % 0 дают null
4+ -только числа (конкатенации строк нет)
5< <= > >=числа — численно, строки — лексикографически; значения разных типов не упорядочены (false)
6== !=значения разных типов не равны ('1' != 1)
7&&короткозамкнутый
8||короткозамкнутый

Скобки ( ... ) группируют как обычно. После скобок тоже можно обращаться к полям и индексам: (obj).list[0]. Операции над несовместимыми типами ('a' + 1) дают null: рендер не падает, значение выводится пустым. Глубина вложенности выражения ограничена 32, число операндов и модификаторов в одном выражении — 1024; больше — ошибка разбора.

html
{% if (a + b) * 2 > limit && !(banned || muted) %} ... {% endif %}
<p>Цена: {{ price * (1 - discount) }}</p>
<p>Следующий: {{ items[pos + 1].title }}</p>

Модификаторы ​

Модификатор преобразует значение и пишется после него через вертикальную черту: значение | имя или значение | имя(аргументы). Модификаторы выстраиваются в цепочку и применяются слева направо:

html
<h1>{{ title | trim | upper }}</h1>
<p>{{ description | truncate(120) }}</p>
<p>{{ price | number(2, ',', ' ') }} ₽</p>

Модификатор относится к ближайшему операнду — переменной, литералу или выражению в скобках — и связывает сильнее любого оператора:

text
{{ price * qty | round(2) }}     →  price * (qty | round(2))
{{ (price * qty) | round(2) }}   →  округляется всё произведение
{{ a || b | upper }}             →  a || (b | upper)
{{ count | default(0) + 1 }}     →  (count | default(0)) + 1
{{ -x | abs }}                   →  -(x | abs)

Аргументы — любые выражения, в том числе с модификаторами. Скобки у модификатора без аргументов можно опустить: upper и upper() равнозначны. После модификатора можно продолжить путь к полю или элементу результата:

html
<p>{{ (users | first).name }}</p>
<p>{{ users | last.name }}</p>

Модификаторы работают и в условиях, и в циклах:

html
<p>{{ name | default(login | upper) }}</p>
{% if items | length > 3 %} ... {% endif %}
{% for tag in post.tags | default(fallback_tags) %} ... {% endfor %}

Строковые модификаторы принимают и числа, и bool — значение сначала приводится к тексту по правилам вывода (42, 9.99, true). Длина и обрезка считаются в символах UTF-8, а не в байтах. Строки из документа могут содержать NUL-байты ("a\u0000b"): модификаторы и сравнения учитывают их как обычный символ, json записывает их как \u0000, url — как %00, а при выводе в шаблон NUL заменяется на �.

Управление выводом ​

МодификаторЧто делает
rawВыводит значение без экранирования. Допустим только в корне тега {{ }}, последним в цепочке
nl2brЭкранирует значение и заменяет переносы строк (\r\n, \n, \r) на <br>. Допустим только в корне тега {{ }}, последним в цепочке. В режиме VIEW_RAW выводит текст как есть
urlКодирует значение для URL: всё, кроме A-Z a-z 0-9 - _ . ~, превращается в %XX, пробел — в %20
jsonСериализует значение любого типа, включая объекты и массивы, в JSON. <, >, &, ', U+2028, U+2029 кодируются как \u003c и т. д., поэтому результат с raw безопасен внутри <script>. Вложенность глубже 32 — пустой вывод

Строки ​

МодификаторЧто делает
upper, lowerВерхний / нижний регистр, включая кириллицу. Битые байты UTF-8 заменяются на � (U+FFFD); остальные строковые модификаторы оставляют их как есть, считая каждый байт одним символом
capitalizeПервая буква — заглавная, остальное без изменений (iPhone → IPhone)
trimУбирает пробельные символы по краям, включая неразрывный пробел
truncate(n, suffix='…')Обрезает до n символов и добавляет suffix, если что-то обрезано. Суффикс в n не входит
replace(from, to)Заменяет все вхождения from на to слева направо
default(value)Подставляет value, если значение — null или пустая строка. 0 и false не заменяются

Числа ​

Принимают только числа: числовая строка (например, "50000.00" из базы) даёт пустой вывод.

МодификаторЧто делает
absМодуль числа
round(n=0)Округляет до n знаков (0–15), половина — от нуля. Результат — число: 9.50 с round(2) выводится как 9.5
number(decimals=0, dec_sep='.', thousands_sep='')Форматирует с фиксированным числом знаков (0–15) и разделителями: 50000 с number(2, ',', ' ') → 50 000,00

Коллекции ​

МодификаторЧто делает
lengthДлина строки в символах, число элементов массива или объекта; для null — 0
first, lastПервый / последний элемент массива, значение объекта или символ строки; для числа и bool — null
join(sep='')Склеивает элементы массива (значения объекта) через разделитель

Ошибки

Неизвестный модификатор, неверное число аргументов, raw или nl2br не в корне тега {{ }} (в том числе в {% if %} и {% for %}) — ошибка разбора: render() возвращает NULL, причина пишется в лог. Неподходящий тип значения или аргумента (upper от массива, round от строки) — не ошибка: модификатор возвращает null, тег выводит пустую строку.

Условия ​

html
{% if is_authenticated %}
    <p>Добро пожаловать, {{ user.name }}!</p>
{% elseif is_guest %}
    <p>Вы вошли как гость</p>
{% else %}
    <p>Пожалуйста, войдите в систему</p>
{% endif %}

Инверсия условия через !:

html
{% if !is_authenticated %}
    <a href="/login">Войти</a>
{% endif %}

Условие — любое выражение. Истинны: непустая строка, ненулевое число, true, непустой объект или массив. Ложны: '', 0, false, null, пустой контейнер, отсутствующее значение. В условии можно обращаться к вложенным полям и индексам и применять модификаторы:

html
{% if users[0].authorized %}
    {{ users[0].name }} авторизован
{% endif %}

Циклы ​

Итерация по массиву:

html
<ul>
{% for product in products %}
    <li>{{ product.name }} — {{ product.price }}</li>
{% endfor %}
</ul>

С доступом к индексу (второе имя после запятой):

html
<ol>
{% for product, i in products %}
    <li>{{ i }}: {{ product.name }}</li>
{% endfor %}
</ol>

Если имя индекса не задано, оно по умолчанию равно index:

html
{% for product in products %}
    <p>№ {{ index }}: {{ product.name }}</p>
{% endfor %}

Циклы также обходят объекты — в этом случае значением индекса служит имя ключа (строка), а элементом — значение поля:

html
{% for value, key in settings %}
    <p>{{ key }} = {{ value }}</p>
{% endfor %}

Внутри цикла становятся доступны переменные с именами элемента и индекса; к их полям можно обращаться как к обычным переменным.

Включение шаблонов ​

Включение другого файла выполняется тегом {* include ... *}. Путь указывается как есть, без кавычек — это путь внутри хранилища шаблонов.

html
{* include /partials/header.tpl *}

<main>
    <h1>{{ title }}</h1>
</main>

{* include /partials/footer.tpl *}

Лимит вложенности

Глубина включений ограничена 100 уровнями. Циклические включения (шаблон включает сам себя без условий) приведут к ошибке парсинга.

Примеры использования ​

Простой рендеринг ​

c
#include "http.h"
#include "view.h"

void home(httpctx_t* ctx) {
    json_doc_t* doc = json_root_create_object();
    json_token_t* root = json_root(doc);

    json_object_set(root, "title", json_create_string("Главная"));
    json_object_set(root, "username", json_create_string("Иван"));

    ctx->response->send_view(ctx->response, doc, VIEW_ESCAPE, "views", "/pages/home.tpl");

    json_free(doc);
}

Рендеринг с форматированием пути ​

Путь поддерживает printf-форматирование — удобно, когда имя шаблона зависит от входных данных:

c
char* html = render(doc, VIEW_ESCAPE, "views", "/pages/%s.tpl", page_name);

if (html == NULL) {
    ctx->response->send_default(ctx->response, 404);
    return;
}

ctx->response->header_add(ctx->response, "Content-Type", "text/html; charset=utf-8");
ctx->response->send_data(ctx->response, html);
free(html);

Список товаров ​

c
void products_list(httpctx_t* ctx) {
    json_doc_t* doc = json_root_create_object();
    json_token_t* root = json_root(doc);

    json_object_set(root, "title", json_create_string("Товары"));

    json_token_t* products = json_create_array(doc);

    json_token_t* p1 = json_create_object(doc);
    json_object_set(p1, "name", json_create_string("Ноутбук"));
    json_object_set(p1, "price", json_create_number(50000));
    json_array_append(products, p1);

    json_token_t* p2 = json_create_object(doc);
    json_object_set(p2, "name", json_create_string("Телефон"));
    json_object_set(p2, "price", json_create_number(30000));
    json_array_append(products, p2);

    json_object_set(root, "products", products);

    ctx->response->send_view(ctx->response, doc, VIEW_ESCAPE, "views", "/pages/products.tpl");

    json_free(doc);
}

Шаблон /pages/products.tpl:

html
<!DOCTYPE html>
<html>
<head><title>{{ title }}</title></head>
<body>
    <h1>{{ title }}</h1>

    {% if products %}
        <table>
            <tr><th>#</th><th>Название</th><th>Цена</th></tr>
            {% for product, i in products %}
            <tr>
                <td>{{ i }}</td>
                <td>{{ product.name }}</td>
                <td>{{ product.price }} руб.</td>
            </tr>
            {% endfor %}
        </table>
    {% else %}
        <p>Товары не найдены</p>
    {% endif %}
</body>
</html>

Рендеринг с данными модели ​

c
void user_profile(httpctx_t* ctx) {
    array_t* params = array_create();
    mparams_fill_array(params, mparam_int(id, 1));
    user_t* user = user_get(params);
    array_free(params);

    if (user == NULL) {
        ctx->response->send_default(ctx->response, 404);
        return;
    }

    json_doc_t* doc = json_root_create_object();
    json_token_t* root = json_root(doc);

    json_object_set(root, "user", model_to_json(user, NULL));
    json_object_set(root, "title", json_create_string("Профиль пользователя"));

    user_free(user);

    ctx->response->send_view(ctx->response, doc, VIEW_ESCAPE, "views", "/pages/profile.tpl");

    json_free(doc);
}

Вложенные циклы ​

Допустим, есть документ с пользователями и их задачами:

json
{
    "company": "My Company",
    "users": [
        {
            "name": "Alex",
            "authorized": true,
            "tasks": ["Task 1", "Task 2", "Task 3"]
        },
        {
            "name": "Bonnie",
            "authorized": false,
            "tasks": ["Task 4", "Task 5"]
        }
    ]
}

Шаблон, обходящий пользователей и их задачи:

html
{* include /header.tpl *}

Пользователи компании {{ company }}:
{% for user, i in users %}
    {{ i }}. {{ user.name }}
    Авторизован: {% if user.authorized %}да{% else %}нет{% endif %}
    Задачи:
    {% for task, j in user.tasks %}
        - {{ j }}: {{ task }}
    {% endfor %}
{% endfor %}

Внешний цикл объявляет элемент user и индекс i, внутренний — элемент task и индекс j.

Организация шаблонов ​

Рекомендуемая структура хранилища:

views/
├── layouts/
│   └── base.tpl
├── partials/
│   ├── header.tpl
│   └── footer.tpl
└── pages/
    ├── home.tpl
    ├── products.tpl
    └── profile.tpl

partials/header.tpl ​

html
<!DOCTYPE html>
<html>
<head>
    <meta charset="utf-8">
    <title>{{ title }}</title>
    <link rel="stylesheet" href="/css/style.css">
</head>
<body>
    <header>
        <nav>
            <a href="/">Главная</a>
            {% if is_authenticated %}
                <a href="/profile">Профиль</a>
                <a href="/logout">Выход</a>
            {% else %}
                <a href="/login">Вход</a>
            {% endif %}
        </nav>
    </header>
html
    <footer>
        <p>&copy; 2024 Example App</p>
    </footer>
</body>
</html>

Экранирование и безопасность ​

В режиме VIEW_ESCAPE каждое значение {{ }} проходит HTML-экранирование: &, <, >, ", ' заменяются на &amp;, &lt;, &gt;, &quot;, &#39;. Этого достаточно для текста элемента и для значения атрибута в кавычках. Экранирование выполняется после всех модификаторов, поэтому, например, truncate режет исходный текст и не может разрезать &amp; пополам.

Как выводить значение в зависимости от места:

html
<p>{{ comment }}</p>                                    <!-- текст элемента -->
<input value="{{ name }}">                              <!-- атрибут в кавычках -->
<a href="/search?q={{ query | url }}">Найти</a>         <!-- параметр URL -->
<p>{{ message | nl2br }}</p>                            <!-- многострочный текст -->
<div data-config="{{ config | json }}"></div>           <!-- JSON в атрибуте -->
<script>const data = {{ data | json | raw }};</script>  <!-- JSON в скрипте -->
<div>{{ trusted_html | raw }}</div>                     <!-- разметка, которой вы доверяете -->

Внутри <script> HTML-сущности не раскодируются, поэтому там нужен json | raw: модификатор json сам кодирует <, >, & и ', и последовательность </script> не может появиться в выводе.

raw отключает защиту

Используйте raw только для разметки, которую сформировал сам сервер. Значение из запроса, базы данных или от другого пользователя, выведенное через raw, — это XSS.

Адрес в href

Экранирование не проверяет схему URL: значение javascript:alert(1) в href пройдёт без изменений, потому что в нём нет экранируемых символов. Проверяйте адрес до рендеринга функцией validate_url().

Режим VIEW_RAW не экранирует ничего. Используйте его только для форматов, где HTML не интерпретируется: текстовые письма, plain text.

Выпущено под лицензией MIT.