Привет, меня зовут Даша Витер, я 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-формате и передавать их для рендеринга файлов из шаблонов.
