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

На этом наше знакомство со Stencil подошло к концу. Буду рада, если поделитесь своими идеями применения генерации файлов из шаблонов в комментариях!

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

Комментарии (2)


  1. SClown
    04.12.2025 10:06

    "созданный в далёком 2025" - мы вроде пока не так далеко)


    1. CleverDevilV Автор
      04.12.2025 10:06

      намётанный глаз ) конечно, он родом из 2015 года, спасибо