Привет, меня зовут Даша Витер, я iOS-разработчик команды дизайн-системы в hh. У нас в компании очень любят автоматизации и любую рутину стараются свести на нет, чтобы оставить время для важных и интересных задач. Один из способов автоматизировать рутину – использовать шаблоны. Cегодня я расскажу, как в этом может помочь Stencil – язык для написания шаблонов. Статья будет интересна тем, кто не сталкивался с этим языком или ищет новые пути упростить свою работу с помощью генерации кода.
Пролог
Такие инструменты как SwiftGen или Sourcery известны любому iOS-разработчику. Но не все знают, что в основе лежит язык для написания шаблонов Stencil, созданный в далёком 2015 году. Stencil позволяет разрабатывать гибкие шаблоны, которые заполняются данными в рантайме.
Область применения Stencil-шаблонов не ограничивается разработкой на Swift – с помощью него можно создавать файлы для любого другого языка. Например, в hh мы создали свой инструмент для выгрузки цветов, иконок и других параметров нашей дизайн-системы из Figma – Figmagen. Также используем Stencil в нашем внутреннем инструменте для генерации событий аналитики. А ещё благодаря таким шаблонам генерируем файлы дизайн-системы для нашего проекта на Kotlin.
В этой статье я подробнее расскажу про Stencil, покажу, как создавать файлы из шаблонов, и разберу, как можно расширить базовые возможности языка. В конце будет ссылка на проект универсального рендера, который создаёт файл на основе данных в JSON-формате и шаблон для дальнейшего использования.

Синтаксис языка
О редактировании и редакторах
В XCode не добавили поддержку языка Stencil, поэтому для удобства работы с шаблонами можно использовать Sublime Text, выбрав язык AppleScript или VSCode с расширением Stencil (Steven Van Impe).
Stencil поддерживает множество стандартных для языков программирования типов данных и конструкций:
-
Комментарии
{# Это комментарий, он не попадет в вывод #}
-
Переменные (простые значения, а также массивы и словари):
{{ variable }}– доступ к значению переменной{{ variable.value }}– доступ к внутреннему параметру переменной{{ variable['someKey'] }},{{ users.first }},{{ users.last }},{{ users.3 }},{{ users.count }}– доступ по ключу, к первому и последнему значению из коллекции, а также доступ по индексу, получение количества параметров в коллекции
-
Фильтры capitalize, uppercase, lowercase, join, split, indent (добавляет отступ перед выводом параметра), filter, а также возможность создавать свои фильтры:
{{ variable|capitalize }}
-
Теги – механизм выполнения фрагмента кода, позволяющий управлять потоком внутри шаблона или выполнять заранее описанные действия:
{% now %}– этот тег выводит в шаблон текущую дату и время
-
Булевые значения и условные ветвления с применением операторов and, or, not:
{% if user.age == 100 and user.name.count == 5 %} Админ {% elif user %} Обычный пользователь {% else %} Нет пользователя {% endif %}
-
Циклы и его специальные переменные: forloop.first, forloop.counter, конструкции break и continue с метками:
{% for item in items %} {{ item.value }} {% if item.value.first == 'T' %} {% break %} {% endif %} {% endfor %}
Наследование шаблонов с использованием блоков (
{% block %}) и расширений({% extends %}), переиспользования других шаблонов ({% include %}) для построения структурных шаблонов и повторно используемых участков кода.
Более подробно о базовых возможностях языка можно почитать в документации Stencil.
Теория, практика и код
Для получения файла из шаблона необходима программа, которая берёт ваши данные и пропускает их через шаблон. Для создания такой программы нам понадобится одноименная библиотека – Stencil. Основные объекты, которыми мы будем оперировать: окружение, контекст и сами шаблоны.
Окружение (Environment)
Окружение – это модель для передачи загрузчика шаблонов, расширений (кастомных фильтров и тегов), класса для шаблонов и правила обработки пустых строк.
let environment = Environment(
loader: nil,
extensions: [],
templateClass: Template.self,
trimBehaviour: .smart
)
В примере выше мы создали окружение без загрузчика и расширений, со стандартным классом шаблонов и умной обработкой пустых строк, удаляющей пустые пробелы перед блоками, а также пробелы и строки после блоков.
Контекст (Context)
Контекст – это контейнер с данными для рендеринга. Он включает в себя словарь типа [String: Any?] и объект environment.
let context = Context(
dictionary: ["name": "iOS-разработчик"],
environment: environment
)
Шаблон (Template)
Шаблоны можно разделить на два вида: простые шаблоны (однострочные или многострочные), которые описываются непосредственно в коде программы-обработчика, и отдельные шаблоны-файлы с расширением .stencil (которые также могут быть однострочными или многострочными).
// простой шаблон, находящийся в программе-обработчике
let template = Template(templateString: "Привет, {{ name }}!")

Перейдём от теории к практике. Для этого нам понадобится создать приложение для командной строки, подключить к нему библиотеку для работы со Stencil. И желание исследовать, конечно же!
Для начала создадим приложение, вызвав в корневой папке:
swift package init --name StencilExamples --type executable
Далее откроем Package.swift и добавим в его указание платформы и зависимость – библиотеку Stencil:
import PackageDescription
let package = Package(
name: "StencilExamples",
platforms: [
.macOS(.v12)
],
dependencies: [
.package(url: "https://github.com/stencilproject/Stencil.git", from: "0.13.0")
],
targets: [
.executableTarget(
name: "StencilExamples",
dependencies: [
"Stencil"
]
)
]
)
Откроем в нашем приложении файл main. Добавим окружение, контекст, наш простой шаблон – и запустим рендеринг:
import Foundation
import Stencil
let template = Template(templateString: "Привет, {{ name }}!")
let environment = Environment(
loader: nil,
extensions: [],
templateClass: Template.self,
trimBehaviour: .smart
)
let context = Context(
dictionary: ["name": "iOS-разработчик"],
environment: environment
)
do {
let result = try template.render(context)
print(result) // Привет, iOS-разработчик!
} catch {
print(error)
}
Так как при обработке шаблона могут возникнуть ошибки, метод рендеринга необходимо вызывать с использованием конструкции do – try – catch.
Для загрузки шаблонов-файлов нужно передать в окружение загрузчик. Можно создать свой загрузчик, подписав класс с логикой загрузки под протокол Loader из библиотеки Stencil, либо воспользоваться готовым загрузчиком из библиотеки. В загрузчик достаточно передать путь к папке с шаблонами (pathToTheProject – это путь к Sources вашего проекта).
// путь к папке с шаблонами
let templatePath = Path(pathToTheProject.appending("/Templates"))
let environment = Environment(
loader: FileSystemLoader(paths: [templatePath]), // загрузчик шаблонов
extensions: [ ],
templateClass: Template.self,
trimBehaviour: .smart
)
let context = Context(
dictionary: [
"className": "MyGeneratedClass",
"parameters": [
["name": "firstName", "type": "String"],
["name": "lastName", "type": "String"],
["name": "age", "type": "Int"]
]
],
environment: environment
)
do {
// 1
let template = try environment.loadTemplate(name: "TemplateExample.stencil")
let result = try template.render(context)
print(result)
// 2
let resultFromEnvironment = try environment.renderTemplate(
name: "TemplateExample.stencil",
context: context.flatten()
)
print(resultFromEnvironment)
} catch {
print(error)
}
При работе с шаблонами-файлами можно сперва загрузить шаблон, а потом (1) вызвать у него метод для рендеринга или (2) передать в окружение наименование шаблона-файла и содержимое контекста и запустить рендеринг у него, а загрузку самого шаблона оставить под капотом.
Кастомные фильтры и теги
Язык Stencil позволяет расширить стандартный набор фильтров и тегов для удобства обработки данных, передаваемых через контекст. Каждый фильтр или тег нужно зарегистрировать в окружении. Для удобства примеры будем рассматривать в контексте работы с простым шаблоном.
Кастомные фильтры
Фильтр – функция для обработки и форматирования данных внутри шаблона (например, преобразование регистра, форматирование даты). Фильтры можно условно разделить на простые (работают только со значением, к которому применяются) и сложные (позволяют использовать в логике дополнительные аргументы и контекст). Для применения фильтра к какому-то значению необходимо добавить конструкцию вида |myFilter , где myFilter – название зарегистрированного фильтра.
Пример: простой кастомный фильтр
Для создания своего фильтра необходимо определить его как функцию, которая в качестве входного параметра получает какое-то значение – value, производит преобразования и возвращает результат:
let reverseFilterMethod: ((Any?) -> Any?) = { value in
if let text = value as? String {
return String(text.reversed())
} else {
return value
}
}
Наш кастомный фильтр, если параметр перед ним – строка, применит к нему метод reversed() – развернёт строку и вернёт её.
Далее необходимо создать новое расширение и зарегистрировать в нём наш фильтр:
let reverseFilterExtension = Extension()
reverseFilterExtension.registerFilter("reverse", filter: reverseFilterMethod)
Передаём наше расширение при инициализации окружения:
let environment = Environment(
loader: nil,
extensions: [reverseFilterExtension],
templateClass: Template.self,
trimBehaviour: .smart
)
В шаблоне-строке добавляем вызов нашего фильтра:
let template = Template(templateString: "Привет, {{ name|reverse }}!")
В контексте оставляем всё неизменным и вызываем рендеринг. В результате в консоли отобразится следующий результат:
Привет, кичтобарзар-SOi!
Пример: фильтр с несколькими аргументами
Также можно создавать более сложные фильтры, включающие в себя аргументы:
let removeWordMethod: ((Any?, [Any?]) -> Any?) = { value, arguments in
if let text = value as? String, let argument = arguments.first as? String {
return text
.split(separator: "-")
.filter({ !$0.contains(argument) })
.joined()
} else {
return value
}
}
Этот фильтр разделит строку на части, ориентируясь на сепаратор "-", отфильтрует результат, проверив, что часть не содержит переданную в качестве аргумента строку, и вернёт отфильтрованный массив, соединив его в единую строку.
Далее регистрируем фильтр, как в примере выше, а в шаблон добавляем вызов нашего сложного фильтра и передаем в него аргумент – строку "iOS":
let template = Template(templateString: "Привет, {{ name|remove:'iOS' }}!")
Вызываем рендеринг и в результате получаем строку с применённым фильтром:
Привет, разработчик!
Если для фильтра нужно передать несколько аргументов, перечисляем их через запятую:
let templateString = "{{ 'Привет' | customFilter:'iOS', 123 }}"
Кастомные теги
Тег – это конструкция вида {% mytag ... %} для выполнения более сложных операций в шаблоне (циклы, условия, особая логика). Теги бывают одиночными (подходят для простого добавления произвольной строки или какого-то значения из контекста) и парными, когда для корректной работы необходим закрывающий тег (например, теги {% if … %} … {% endif %} и {% for …%} … {% endfor %}). Для работы тег использует токены и парсер токенов.
Токен (Token) – это лексическая единица синтаксиса шаблона (текст, переменная, комментарий, блок), из которого строится дерево шаблона (парсинг). В нём хранятся данные, нужные для понимания, как именно интерпретировать соответствующую часть шаблона при рендеринге.
Парсер токенов (TokenParser) – это компонент, который отвечает за разбор последовательности токенов из шаблона для конкретного тега. Он используется в механизме парсинга шаблонов для того, чтобы обработать синтаксис тега – распарсить аргументы и тело тега. Проще говоря, TokenParser принимает поток токенов и превращает их в дерево узлов (Node), которое потом используется для рендеринга шаблона.
Пример: базовый кастомный парный тег
Сперва создадим свой тип узла (NodeType) для рендеринга шаблона:
class UpdateLanguageTagNode: NodeType {
let token: Token? = nil
let arguments: String
let body: [NodeType]
init(arguments: String, body: [NodeType]) {
self.arguments = arguments
self.body = body
}
func render(_ context: Context) throws -> String {
// из первого токена-переменной достаем значение его исходной строки
if let variableTokenContents = body.first(where: { $0.token?.kind == .variable })?.token?.contents {
// и подменяем значение в контексте
context[variableTokenContents] = arguments
}
// рендерим итоговую строку
let bodyString = try body.map { try $0.render(context) }.joined()
return bodyString
}
}
Далее создаём расширение и регистрируем наш тег:
let updateLanguageExtension = Extension()
updateLanguageExtension.registerTag("updateLang") { tokenParser, token in
let components = token.components // достаем компоненты тега
let tagName = components.first // первый компонент - наименование тега
guard let tagName, components.count == 2 else {
throw TemplateSyntaxError(" 'updateLang' tag takes one argument, the text fo
}
// достаем все аргументы тега кроме его названия и создаем строку
let arguments = components.dropFirst().joined()
// парсим всю строку до закрывающего тега и достаем из нее имеющиеся узлы (Nod
let bodyNodes = try? tokenParser.parse(until(["end\(tagName)"]))
// проверяем наличие закрывающего тега
guard tokenParser.nextToken() != nil else {
throw TemplateSyntaxError("endupdateLang was not found.")
}
return UpdateLanguageTagNode(arguments: arguments, body: bodyNodes ?? [])
}
Обновим шаблон, добавив в него вызов нашего тега:
let template = Template(
templateString: "{% updateLang Swift %}Привет, {{ language }} - разработчик{% endupdateLang %}!"
)
Создадим окружение, передав в него наше расширение:
let environment = Environment(
loader: nil,
extensions: [updateLanguageExtension],
templateClass: Template.self,
trimBehaviour: .smart
)
И немного изменим контекст:
let context = Context(
dictionary: ["language": "Objective-C"],
environment: environment
)
Запустим рендеринг, и в консоли отобразится преобразованная тегом строка:
Привет, Swift - разработчик!
Эпилог
В 2017 году была создана библиотека StencilSwiftKit, расширяющая базовые возможности библиотеки Stencil. Появилось несколько фильтров и тегов, в частности парный тег macro, позволяющий писать небольшие функции внутри шаблона, а также тег map, аналогичный одноименному методу в Swift. Благодаря простоте и универсальности механизма создания шаблонов область применения ограничивается лишь вашей фантазией.
В hh генерация файлов-токенов для нашей дизайн-системы позволила автоматизировать изменение множества однотипных строк кода, описывающих цвета, когда мы оптимизировали их хранение. Кроме того, генерация событий аналитики теперь запускается при переключении веток, и нам не приходится следить за актуальностью файлов.

StencilSwiftKitНа этом наше знакомство со Stencil подошло к концу. Буду рада, если поделитесь своими идеями применения генерации файлов из шаблонов в комментариях!
По ссылке вы найдете приложение-пример универсального парсера, который может работать с любыми данными в JSON-формате и передавать их для рендеринга файлов из шаблонов.
SClown
"созданный в далёком 2025" - мы вроде пока не так далеко)
CleverDevilV Автор
намётанный глаз ) конечно, он родом из 2015 года, спасибо