Шаблонизатор
Фреймворк включает встроенный шаблонизатор для рендеринга текста (HTML, письма, любые текстовые форматы) с подстановкой динамических данных из JSON-документа.
Принцип работы
Шаблон — это текстовый файл со специальными тегами. Источником данных выступает json_doc_t*, а сам файл читается из хранилища, объявленного в config.json.
Парсинг и компиляция шаблона в дерево тегов выполняются один раз; результат кэшируется в viewstore по пути к файлу. При последующих вызовах render() с тем же путём используется уже построенное дерево, поэтому повторный рендеринг отрабатывает быстро.
Настройка хранилища
Шаблоны хранятся в обычном хранилище (filesystem или s3). Имя хранилища далее передаётся в функции рендеринга.
{
"storages": {
"views": {
"type": "filesystem",
"root": "/var/www/app/views"
}
}
}API
render()
#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_ESCAPE | HTML-страницы, HTML-письма | &, <, >, ", ' заменяются на &, <, >, ", ' |
VIEW_RAW | текстовые письма, другие не-HTML форматы | выводятся как есть |
Режим действует на весь рендер, включая шаблоны, подключённые через {* include *}. Экранирование выполняется при выводе, а не при разборе, поэтому один закэшированный шаблон обслуживает оба режима. Подробнее — в разделе Экранирование и безопасность.
send_view()
Метод объекта ответа — рендерит шаблон и сразу отправляет результат клиенту.
void (*send_view)(struct httpresponse* response, json_doc_t* document, view_escape_e escape,
const char* storage_name, const char* path_format, ...);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 *} |
Переменные
Значение выводится через {{ }}. Поддерживается доступ к вложенным полям через точку и к элементам массива по индексу в квадратных скобках. Внутри тега может стоять любое выражение, к значению можно применить модификаторы.
<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) |
| bool | true / 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; больше — ошибка разбора.
{% if (a + b) * 2 > limit && !(banned || muted) %} ... {% endif %}
<p>Цена: {{ price * (1 - discount) }}</p>
<p>Следующий: {{ items[pos + 1].title }}</p>Модификаторы
Модификатор преобразует значение и пишется после него через вертикальную черту: значение | имя или значение | имя(аргументы). Модификаторы выстраиваются в цепочку и применяются слева направо:
<h1>{{ title | trim | upper }}</h1>
<p>{{ description | truncate(120) }}</p>
<p>{{ price | number(2, ',', ' ') }} ₽</p>Модификатор относится к ближайшему операнду — переменной, литералу или выражению в скобках — и связывает сильнее любого оператора:
{{ 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() равнозначны. После модификатора можно продолжить путь к полю или элементу результата:
<p>{{ (users | first).name }}</p>
<p>{{ users | last.name }}</p>Модификаторы работают и в условиях, и в циклах:
<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, тег выводит пустую строку.
Условия
{% if is_authenticated %}
<p>Добро пожаловать, {{ user.name }}!</p>
{% elseif is_guest %}
<p>Вы вошли как гость</p>
{% else %}
<p>Пожалуйста, войдите в систему</p>
{% endif %}Инверсия условия через !:
{% if !is_authenticated %}
<a href="/login">Войти</a>
{% endif %}Условие — любое выражение. Истинны: непустая строка, ненулевое число, true, непустой объект или массив. Ложны: '', 0, false, null, пустой контейнер, отсутствующее значение. В условии можно обращаться к вложенным полям и индексам и применять модификаторы:
{% if users[0].authorized %}
{{ users[0].name }} авторизован
{% endif %}Циклы
Итерация по массиву:
<ul>
{% for product in products %}
<li>{{ product.name }} — {{ product.price }}</li>
{% endfor %}
</ul>С доступом к индексу (второе имя после запятой):
<ol>
{% for product, i in products %}
<li>{{ i }}: {{ product.name }}</li>
{% endfor %}
</ol>Если имя индекса не задано, оно по умолчанию равно index:
{% for product in products %}
<p>№ {{ index }}: {{ product.name }}</p>
{% endfor %}Циклы также обходят объекты — в этом случае значением индекса служит имя ключа (строка), а элементом — значение поля:
{% for value, key in settings %}
<p>{{ key }} = {{ value }}</p>
{% endfor %}Внутри цикла становятся доступны переменные с именами элемента и индекса; к их полям можно обращаться как к обычным переменным.
Включение шаблонов
Включение другого файла выполняется тегом {* include ... *}. Путь указывается как есть, без кавычек — это путь внутри хранилища шаблонов.
{* include /partials/header.tpl *}
<main>
<h1>{{ title }}</h1>
</main>
{* include /partials/footer.tpl *}Лимит вложенности
Глубина включений ограничена 100 уровнями. Циклические включения (шаблон включает сам себя без условий) приведут к ошибке парсинга.
Примеры использования
Простой рендеринг
#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-форматирование — удобно, когда имя шаблона зависит от входных данных:
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);Список товаров
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:
<!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>Рендеринг с данными модели
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);
}Вложенные циклы
Допустим, есть документ с пользователями и их задачами:
{
"company": "My Company",
"users": [
{
"name": "Alex",
"authorized": true,
"tasks": ["Task 1", "Task 2", "Task 3"]
},
{
"name": "Bonnie",
"authorized": false,
"tasks": ["Task 4", "Task 5"]
}
]
}Шаблон, обходящий пользователей и их задачи:
{* 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.tplpartials/header.tpl
<!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>partials/footer.tpl
<footer>
<p>© 2024 Example App</p>
</footer>
</body>
</html>Экранирование и безопасность
В режиме VIEW_ESCAPE каждое значение {{ }} проходит HTML-экранирование: &, <, >, ", ' заменяются на &, <, >, ", '. Этого достаточно для текста элемента и для значения атрибута в кавычках. Экранирование выполняется после всех модификаторов, поэтому, например, truncate режет исходный текст и не может разрезать & пополам.
Как выводить значение в зависимости от места:
<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.