Nette Documentation Preview

syntax
Создание пользовательских тегов
*******************************

.[perex]
Эта страница представляет собой исчерпывающее руководство по созданию собственных тегов в Latte. Мы разберём всё: от простых тегов до более сложных сценариев с вложенным содержимым и особыми потребностями разбора, опираясь на понимание того, как Latte компилирует шаблоны.

Пользовательские теги дают наивысший уровень контроля над синтаксисом шаблонов и логикой отрисовки, но они же являются и самой сложной точкой расширения. Прежде чем решиться на создание собственного тега, всегда проверьте, [нет ли более простого решения |extending-latte#Способы расширения Latte] и нет ли подходящего тега в [стандартном наборе |tags]. Используйте пользовательские теги только тогда, когда более простые варианты не отвечают вашим потребностям.


Как устроен процесс компиляции
==============================

Чтобы уверенно создавать собственные теги, полезно понимать, как Latte обрабатывает шаблоны. Понимание этого процесса объясняет, почему теги устроены именно так и как они вписываются в общую картину.

Компиляция шаблона в Latte, если упростить, включает следующие ключевые шаги:

1.  **Лексический разбор:** лексер читает исходный код шаблона (файл `.latte`) и разбивает его на последовательность небольших отдельных частей, называемых **токенами** (например, `{`, `foreach`, `$variable`, `}`, HTML-текст и так далее).
2.  **Синтаксический разбор:** парсер берёт этот поток токенов и строит осмысленную древовидную структуру, представляющую логику и содержимое шаблона. Это дерево называется **абстрактным синтаксическим деревом (AST)**.
3.  **Проходы компилятора:** перед генерацией PHP-кода Latte запускает [проходы компилятора|compiler-passes]. Это функции, которые обходят всё AST и могут изменять его или собирать сведения. Этот шаг принципиально важен для таких возможностей, как безопасность ([песочница|sandbox]) или оптимизации.
4.  **Генерация кода:** наконец, компилятор обходит (возможно, изменённое) AST и генерирует соответствующий код PHP-класса. Именно этот PHP-код и отрисовывает шаблон при выполнении.
5.  **Кеширование:** сгенерированный PHP-код кешируется на диске, благодаря чему последующие отрисовки идут очень быстро, ведь шаги 1-4 пропускаются.

На самом деле компиляция чуть сложнее. В Latte **два** лексера и парсера: один для HTML-шаблона и один для PHP-подобного кода внутри тегов. Кроме того, разбор не идёт после токенизации: лексер и парсер работают параллельно в двух "потоках" и согласуют свои действия. Поверьте мне, Давиду Грудлу: программирование этого ощущалось как ракетостроение :-)

Весь процесс, от загрузки содержимого шаблона через разбор до генерации итогового файла, можно разложить на такую последовательность, с которой вы можете поэкспериментировать и вывести промежуточные результаты:

```php
$latte = new Latte\Engine;
$source = $latte->getLoader()->getContent($file);
$ast = $latte->parse($source);
$latte->applyPasses($ast);
$code = $latte->generate($ast, $file);
```


Анатомия тега
=============

Создание полноценного пользовательского тега в Latte состоит из нескольких взаимосвязанных частей. Прежде чем погрузиться в реализацию, разберёмся с основными понятиями и терминологией, проведя аналогию с HTML и объектной моделью документа (DOM).


Теги против узлов (аналогия с HTML)
-----------------------------------

В HTML мы пишем **теги** вроде `<p>` или `<div>...</div>`. Эти теги - синтаксис в исходном коде. Когда браузер разбирает такой HTML, он создаёт представление в памяти, называемое **объектной моделью документа (DOM)**. В DOM HTML-теги представлены **узлами** (точнее, узлами `Element` в терминологии DOM для JavaScript). С этими *узлами* мы работаем программно (например, `document.getElementById(...)` в JavaScript возвращает узел Element). Тег - это лишь текстовое представление в исходном файле, а узел - объектное представление в логическом дереве.

Latte работает похоже:

- В файле шаблона `.latte` вы пишете **теги Latte**, такие как `{foreach ...}` и `{/foreach}`. Это синтаксис, с которым вы как автор шаблона имеете дело.
- Когда Latte **разбирает** шаблон, он строит **абстрактное синтаксическое дерево (AST)**. Это дерево состоит из **узлов**. Каждый тег Latte, HTML-элемент, кусочек текста или выражение в шаблоне становится одним или несколькими узлами этого дерева.
- Базовый класс для всех узлов AST - `Latte\Compiler\Node`. Так же как в DOM есть разные типы узлов (Element, Text, Comment), в AST Latte есть разные типы узлов. Вам встретятся `Latte\Compiler\Nodes\TextNode` для статического текста, `Latte\Compiler\Nodes\Html\ElementNode` для HTML-элементов, `Latte\Compiler\Nodes\Php\ExpressionNode` для выражений внутри тегов и, что особенно важно для пользовательских тегов, узлы, наследующие от `Latte\Compiler\Nodes\StatementNode`.


Почему `StatementNode`?
-----------------------

HTML-элементы (`Html\ElementNode`) представляют прежде всего структуру и содержимое. Выражения PHP (`Php\ExpressionNode`) представляют значения или вычисления. А что насчёт тегов Latte вроде `{if}`, `{foreach}` или нашего собственного `{datetime}`? Эти теги *выполняют действия*, управляют ходом программы или порождают вывод на основе логики. Это функциональные единицы, которые делают Latte мощным *движком* шаблонов, а не просто языком разметки.

В программировании такие выполняющие действия единицы часто называют "инструкциями" (statements). Поэтому узлы, представляющие такие функциональные теги Latte, обычно наследуют от `Latte\Compiler\Nodes\StatementNode`. Это отличает их от чисто структурных узлов (как HTML-элементы) или узлов, представляющих значения (как выражения).


Ключевые составляющие
=====================

Пройдёмся ещё раз по главным составляющим, нужным для создания пользовательского тега:


Функция разбора тега
--------------------

- Этот PHP-callable разбирает синтаксис тега Latte (`{...}`) в исходном коде шаблона.
- Он получает сведения о теге (имя, позицию и то, является ли он n:атрибутом) через объект [api:Latte\Compiler\Tag], а вторым аргументом - главный [api:Latte\Compiler\TemplateParser]. Его полная сигнатура: `callable(Tag, TemplateParser): (Node|\Generator|void)`.
- Основной инструмент для разбора аргументов и выражений внутри ограничителей тега - объект [api:Latte\Compiler\TagParser], доступный через `$tag->parser` (это другой парсер, не тот, который разбирает весь шаблон).
- Для парных тегов он использует `yield`, чтобы дать Latte сигнал разобрать внутреннее содержимое между открывающим и закрывающим тегами.
- Конечная цель функции разбора - создать и вернуть экземпляр **класса узла**, который попадёт в AST.
- Принято (хотя и не обязательно) реализовывать функцию разбора как статический метод (часто с именем `create`) прямо в соответствующем классе узла. Так логика разбора и представление узла остаются аккуратно собраны вместе, при необходимости доступны приватные и защищённые члены класса, и порядка становится больше.


Класс узла
----------

- Представляет *логическую функцию* вашего тега внутри **абстрактного синтаксического дерева (AST)**.
- Хранит разобранные сведения (аргументы или содержимое) в публичных свойствах. Эти свойства часто содержат другие экземпляры `Node` (например, `ExpressionNode` для разобранных аргументов, `AreaNode` для разобранного содержимого).
- Метод `print(PrintContext $context): string` генерирует *PHP-код* (инструкцию или ряд инструкций), который выполняет действие тега при отрисовке шаблона.
- Метод `getIterator(): \Generator` делает дочерние узлы (аргументы, содержимое) доступными для обхода **проходами компилятора**. Он должен отдавать ссылки (`&`), чтобы проходы могли изменять или заменять подузлы.
- После того как весь шаблон разобран в AST, Latte запускает череду [проходов компилятора|compiler-passes]. Эти проходы обходят *всё* AST, используя метод `getIterator()`, предоставляемый каждым узлом. Они могут исследовать узлы, собирать сведения и даже *изменять* дерево (например, меняя публичные свойства узлов или заменяя узлы целиком). Такое устройство, требующее полноценного `getIterator()`, принципиально важно. Оно позволяет мощным механизмам вроде [песочницы|sandbox] анализировать и при необходимости менять поведение *любой* части шаблона, включая ваши собственные теги, обеспечивая безопасность и единообразие.


Регистрация через расширение
----------------------------

- Вам нужно сообщить Latte о вашем новом теге и о том, какую функцию разбора для него использовать. Это происходит внутри [расширения Latte |extending-latte#Расширение Latte].
- В классе расширения вы реализуете метод `getTags(): array`. Он возвращает ассоциативный массив, где ключи - имена тегов (например, `'mytag'`, `'n:myattribute'`), а значения - PHP-callable, представляющие соответствующие функции разбора (например, `MyNamespace\DatetimeNode::create(...)`).

Итого: **функция разбора тега** превращает *исходный код шаблона* с вашим тегом в **узел AST**. **Класс узла** затем знает, как превратить *самого себя* в исполняемый *PHP-код* скомпилированного шаблона, и делает свои подузлы доступными для **проходов компилятора** через `getIterator()`. **Регистрация через расширение** связывает имя тега с функцией разбора и сообщает о нём Latte.

Теперь мы шаг за шагом разберём, как реализовать эти составляющие.


Создание простого тега
======================

Погрузимся в создание вашего первого пользовательского тега Latte. Начнём с очень простого примера: тега `{datetime}`, который выводит текущие дату и время. **Поначалу этот тег не будет принимать аргументов**, но позже мы улучшим его в разделе [#Разбор аргументов тега]. Внутреннего содержимого у него тоже нет.

Этот пример проведёт вас по основным шагам: определение класса узла, реализация его методов `print()` и `getIterator()`, создание функции разбора и, наконец, регистрация тега.

**Цель:** реализовать `{datetime}`, который выводит текущие дату и время с помощью функции PHP `date()`.


Создание класса узла
--------------------

Сначала нам нужен класс, представляющий наш тег в абстрактном синтаксическом дереве (AST). Как говорилось выше, мы наследуем от `Latte\Compiler\Nodes\StatementNode`.

Создайте файл (например, `DatetimeNode.php`) и определите класс:

```php
<?php

namespace App\Templating;

use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;

class DatetimeNode extends StatementNode
{
	/**
	 * Функция разбора тега, вызывается при обнаружении {datetime}.
	 */
	public static function create(Tag $tag): self
	{
		// Наш тег выводит содержимое, поэтому сохраняем окружающий отступ
		$tag->outputMode = $tag::OutputKeepIndentation;
		// Наш простой тег пока не принимает аргументов, так что разбирать нечего
		$node = $tag->node = new self;
		return $node;
	}

	/**
	 * Генерирует PHP-код, который выполнится при отрисовке шаблона.
	 */
	public function print(PrintContext $context): string
	{
		return $context->format(
			'echo date(\'Y-m-d H:i:s\') %line;',
			$this->position,
		);
	}

	/**
	 * Даёт доступ к дочерним узлам для проходов компилятора Latte.
	 */
	public function &getIterator(): \Generator
	{
		false && yield;
	}
}
```

Когда Latte встречает в шаблоне `{datetime}`, он вызывает функцию разбора тега `create()`. Её задача - вернуть экземпляр `DatetimeNode`. Мы также устанавливаем `$tag->outputMode` в `OutputKeepIndentation`: поскольку тег работает в режиме по умолчанию `OutputNone` (объяснено в разделе [#Режимы вывода тега]), тег, поставленный перед первым текстом шаблона, иначе мог бы выдать свой вывод в сгенерированном методе `prepare()` вместо `main()`. Установка этого режима гарантирует, что вывод окажется там, где стоит тег.

Метод `print()` генерирует PHP-код, который выполнится при отрисовке шаблона. Мы вызываем метод `$context->format()`, который собирает итоговую строку PHP-кода скомпилированного шаблона. Первый аргумент, `'echo date('Y-m-d H:i:s') %line;'`, - это маска, в которую подставляются последующие параметры. Заполнитель `%line` говорит методу `format()` взять следующий аргумент, то есть `$this->position`, и вставить комментарий вроде `/* pos 15:1 */`, связывающий сгенерированный PHP-код с исходной строкой шаблона, что принципиально важно для отладки.

Свойство `$this->position` унаследовано от базового класса `Node` и автоматически заполняется парсером Latte. Оно содержит объект [api:Latte\Compiler\Range] (потомок `Position`, дополненный длиной `length` в байтах), указывающий, где тег находится в исходном файле `.latte`. Для парных тегов диапазон охватывает всё от открывающего до закрывающего тега, а потомки `StatementNode` дополнительно предоставляют `$this->tagRanges` со списком `Range` каждого составляющего тега (открывающего, промежуточных вроде `{else}`/`{case}` и закрывающего).

Метод `getIterator()` жизненно важен для проходов компилятора. Он должен отдавать все дочерние узлы, но у нашего простого `DatetimeNode` пока нет ни аргументов, ни содержимого, а значит, и дочерних узлов. Тем не менее метод должен существовать и быть генератором, то есть ключевое слово `yield` должно каким-то образом присутствовать в его теле.


Регистрация через расширение
----------------------------

Наконец, сообщите Latte о новом теге. Создайте [класс расширения |extending-latte#Расширение Latte] (например, `MyLatteExtension.php`) и зарегистрируйте тег в его методе `getTags()`.

```php
<?php

namespace App\Templating;

use Latte\Extension;

class MyLatteExtension extends Extension
{
	/**
	 * Возвращает список тегов, предоставляемых этим расширением.
	 * @return array<string, callable> Соответствие: 'имя-тега' => функция-разбора
	 */
	public function getTags(): array
	{
		return [
			'datetime' => DatetimeNode::create(...),
			// Здесь позже зарегистрируем другие теги
		];
	}
}
```

Затем зарегистрируйте это расширение в Latte Engine:

```php
$latte = new Latte\Engine;
$latte->addExtension(new App\Templating\MyLatteExtension);
```

Создайте шаблон:

```latte
<p>Page generated on: {datetime}</p>
```

Ожидаемый вывод: `<p>Page generated on: 2023-10-27 11:00:00</p>`


Итоги этого этапа
-----------------

Мы успешно создали простой пользовательский тег `{datetime}`. Мы определили его представление в AST (`DatetimeNode`), позаботились о его разборе (`create()`), задали, как он должен генерировать PHP-код (`print()`), обеспечили обход его потомков (`getIterator()`) и зарегистрировали его в Latte.

В следующем разделе мы улучшим этот тег, чтобы он принимал аргументы, и покажем, как разбирать выражения и управлять дочерними узлами.


Разбор аргументов тега
======================

Наш простой тег `{datetime}` работает, но он не слишком гибок. Улучшим его, чтобы он принимал необязательный аргумент - строку формата для функции `date()`. Желаемый синтаксис: `{datetime $format}`.

**Цель:** изменить `{datetime}` так, чтобы он принимал необязательное выражение PHP как аргумент, который будет использован как строка формата для `date()`.


Знакомство с `TagParser`
------------------------

Прежде чем менять код, важно разобраться с инструментом, который мы будем использовать, - [api:Latte\Compiler\TagParser]. Когда главный парсер Latte (`TemplateParser`) встречает тег Latte вроде `{datetime ...}` или n:атрибут, он передаёт разбор содержимого *внутри* тега (части между `{` и `}` или значения атрибута) специализированному `TagParser`.

Этот `TagParser` работает исключительно с **аргументами тега**. Его задача - поглотить токены, представляющие эти аргументы. Принципиально важно, что он **должен разобрать всё переданное ему содержимое**. Если ваша функция разбора завершится, а `TagParser` не дойдёт до конца аргументов (проверяется через `$tag->parser->isEnd()`), Latte выбросит исключение, потому что это означает, что внутри тега остались неожиданные токены. И наоборот, если тег *требует* аргументы, вам следует вызвать `$tag->expectArguments()` в начале своей функции разбора. Этот метод проверяет наличие аргументов и выбрасывает понятное исключение, если тег использован без них.

`TagParser` предлагает полезные методы для разбора разных видов аргументов:

- `parseExpression(): ExpressionNode`: разбирает PHP-подобное выражение (переменные, литералы, операторы, вызовы функций и методов и так далее). Он учитывает синтаксический сахар Latte, например трактует простые строки из букв и цифр как строки в кавычках (то есть `foo` разбирается так, будто это `'foo'`).
- `parseUnquotedStringOrExpression(): ExpressionNode`: разбирает либо обычное выражение, либо *строку без кавычек*. Строки без кавычек - это последовательности, разрешённые Latte без кавычек, часто используемые, например, для путей к файлам (`{include ../file.latte}`). Если разобрана строка без кавычек, возвращается `StringNode`.
- `parseArguments(): ArrayNode`: разбирает аргументы через запятую, возможно с ключами, вида `10, name: 'John', true`.
- `parseModifier(): ModifierNode`: разбирает фильтры вроде `|upper|truncate:10`.
- `parseType(): ?SuperiorTypeNode`: разбирает объявления типов PHP, такие как `int`, `?string`, `array|Foo`.

Для более сложных или низкоуровневых потребностей разбора вы можете напрямую работать с [потоком токенов|api:Latte\Compiler\TokenStream] через `$tag->parser->stream`. Этот объект предоставляет методы для осмотра и поглощения отдельных токенов:

- `$tag->parser->stream->is(...): bool`: проверяет, соответствует ли *текущий* токен какому-либо из указанных типов (например, `Token::Php_Variable`) или литеральных значений (например, `'as'`), не поглощая его. Удобно для заглядывания вперёд.
- `$tag->parser->stream->consume(...): Token`: поглощает *текущий* токен и сдвигает позицию в потоке. Если в аргументах заданы ожидаемые типы или значения токенов и текущий токен им не соответствует, метод выбрасывает `CompileException`. Используйте его, когда *ожидаете* определённый токен.
- `$tag->parser->stream->tryConsume(...): ?Token`: пытается поглотить *текущий* токен, *только если* он соответствует одному из указанных типов или значений. Если соответствует, поглощает токен и возвращает его. Если нет, оставляет позицию в потоке без изменений и возвращает `null`. Используйте его для необязательных токенов или при выборе между разными вариантами синтаксиса.


Обновление функции разбора `create()`
-------------------------------------

С этим пониманием изменим метод `create()` в `DatetimeNode`, чтобы он разбирал необязательный аргумент формата с помощью `$tag->parser`.

```php
<?php

namespace App\Templating;

use Latte\Compiler\Nodes\Php\ExpressionNode;
use Latte\Compiler\Nodes\Php\Scalar\StringNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;

class DatetimeNode extends StatementNode
{
	// Добавляем публичное свойство для узла разобранного выражения формата
	public ?ExpressionNode $format = null;

	public static function create(Tag $tag): self
	{
		$node = $tag->node = new self;

		// Проверяем, есть ли какие-нибудь токены
		if (!$tag->parser->isEnd()) {
			// Разбираем аргумент как PHP-подобное выражение с помощью TagParser.
			$node->format = $tag->parser->parseExpression();
		}

		return $node;
	}

	// ... методы print() и getIterator() обновим дальше ...
}
```

Мы добавили публичное свойство `$format`. В `create()` мы теперь используем `$tag->parser->isEnd()`, чтобы проверить, *есть ли* аргументы. Если есть, `$tag->parser->parseExpression()` поглощает токены выражения. Поскольку `TagParser` обязан поглотить все переданные ему токены, Latte автоматически выдаст ошибку, если пользователь напишет что-то неожиданное после выражения формата (например, `{datetime 'Y-m-d', unexpected}`).


Обновление метода `print()`
---------------------------

Теперь изменим метод `print()` так, чтобы он использовал разобранное выражение формата, хранящееся в `$this->format`. Если формат не указан (`$this->format` равно `null`), нам следует использовать строку формата по умолчанию, например `'Y-m-d H:i:s'`.

```php
	public function print(PrintContext $context): string
	{
		$formatNode = $this->format ?? new StringNode('Y-m-d H:i:s');

		// %node выводит представление $formatNode в виде PHP-кода.
		return $context->format(
			'echo date(%node) %line;',
			$formatNode,
			$this->position
		);
	}
```

В переменную `$formatNode` мы сохраняем узел AST, представляющий строку формата для функции PHP `date()`. Здесь мы используем оператор объединения с null (`??`). Если пользователь передал в шаблоне аргумент (например, `{datetime 'd.m.Y'}`), то свойство `$this->format` содержит соответствующий узел (в данном случае `StringNode` со значением `'d.m.Y'`), и используется этот узел. Если пользователь аргумент не передал (написал просто `{datetime}`), свойство `$this->format` равно `null`, и вместо этого мы создаём новый `StringNode` с форматом по умолчанию `'Y-m-d H:i:s'`. Так гарантируется, что `$formatNode` всегда содержит корректный узел AST для формата.

В маске `'echo date(%node) %line;'` использован новый заполнитель `%node`, который говорит методу `format()` взять первый следующий аргумент (это наш `$formatNode`), вызвать его метод `print()` (возвращающий его представление в виде PHP-кода) и вставить результат на место заполнителя.


Реализация `getIterator()` для подузлов
---------------------------------------

Теперь у нашего `DatetimeNode` есть дочерний узел: выражение `$format`. Мы **обязаны** сделать этот дочерний узел доступным для проходов компилятора, отдав его в методе `getIterator()`. Не забудьте отдавать *ссылку* (`&`), чтобы проходы могли при необходимости заменить узел.

```php
	public function &getIterator(): \Generator
	{
		if ($this->format) {
			yield $this->format;
		}
	}
```

Почему это принципиально важно? Представьте проход песочницы, которому нужно проверить, не содержит ли аргумент `$format` запрещённый вызов функции (например, `{datetime dangerousFunction()}`). Если `getIterator()` не отдаст `$this->format`, проход песочницы никогда не увидит вызов `dangerousFunction()` внутри аргумента нашего тега, и возникнет потенциальная дыра в безопасности. Отдавая его, мы позволяем песочнице (и другим проходам) исследовать и при необходимости изменять узел выражения `$format`.


Использование улучшенного тега
------------------------------

Теперь тег правильно обрабатывает необязательный аргумент:

```latte
Default format: {datetime}
Custom format: {datetime 'd.m.Y'}
Using variable: {datetime $userDateFormatPreference}

{* Это привело бы к ошибке после разбора 'd.m.Y', потому что ", foo" неожиданно *}
{* {datetime 'd.m.Y', foo} *}
```

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


Работа с парными тегами
=======================

До сих пор наш тег `{datetime}` был *самозакрывающимся* (концептуально). У него нет никакого содержимого между открывающим и закрывающим тегами. Однако многие полезные теги работают с блоком содержимого шаблона. Они называются **парными тегами**. Примеры: `{if}...{/if}`, `{block}...{/block}` или тот тег, который мы сейчас построим: `{debug}...{/debug}`.

Этот тег позволит нам включать в шаблоны отладочные сведения, которые должны быть видны только во время разработки.

**Цель:** создать парный тег `{debug}`, содержимое которого отрисовывается только тогда, когда включён определённый флаг "режима разработки".


Знакомство с провайдерами
-------------------------

Иногда вашим тегам нужен доступ к данным или сервисам, которые не передаются напрямую как параметры шаблона. Например, определение того, находится ли приложение в режиме разработки, доступ к объекту пользователя или получение значений конфигурации. Latte предлагает для этого механизм под названием **провайдеры**.

Провайдеры регистрируются в вашем [расширении |extending-latte#Расширение Latte] методом `getProviders()`. Этот метод возвращает ассоциативный массив, где ключи - имена, под которыми провайдеры будут доступны в коде времени выполнения шаблона, а значения - сами данные или объекты.

В PHP-коде, сгенерированном методом `print()` вашего тега, вы затем можете обратиться к этим провайдерам через специальное свойство объекта `$this->global`. Поскольку это свойство общее для всех расширений, хорошей практикой будет **добавлять префикс к именам провайдеров**, чтобы избежать возможных столкновений с базовыми провайдерами Latte или провайдерами других сторонних расширений. Принято использовать короткий уникальный префикс, связанный с вашим вендором или именем расширения. Для нашего примера возьмём префикс `app`, и флаг режима разработки будет доступен как `$this->global->appDevMode`.


Ключевое слово `yield` для разбора содержимого
----------------------------------------------

Как сказать парсеру Latte обработать содержимое *между* `{debug}` и `{/debug}`? Вот здесь и вступает в игру ключевое слово `yield`.

Когда `yield` используется в функции `create()`, она становится [генератором PHP |https://www.php.net/manual/en/language.generators.overview.php]. Её выполнение приостанавливается, и управление возвращается главному `TemplateParser`. Затем `TemplateParser` продолжает разбирать содержимое шаблона *до тех пор*, пока не встретит соответствующий закрывающий тег (в нашем случае `{/debug}`).

Как только закрывающий тег найден, `TemplateParser` возобновляет выполнение нашей функции `create()` сразу после инструкции `yield`. Значение, *возвращаемое* `yield`, - это массив из двух элементов:

1.  `AreaNode`, представляющий разобранное содержимое между открывающим и закрывающим тегами.
2.  Объект `Tag`, представляющий закрывающий тег (например, `{/debug}`).

Создадим класс `DebugNode` и его метод `create` с использованием `yield`.

```php
<?php

namespace App\Templating;

use Latte\Compiler\Nodes\AreaNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;

class DebugNode extends StatementNode
{
	// Публичное свойство для хранения разобранного внутреннего содержимого
	public AreaNode $content;

	/**
	 * Функция разбора парного тега {debug} ... {/debug}.
	 */
	public static function create(Tag $tag): \Generator // обратите внимание на возвращаемый тип
	{
		$node = $tag->node = new self;

		// Приостанавливаем разбор, получаем внутреннее содержимое и закрывающий тег, когда найден {/debug}
		[$node->content, $endTag] = yield;

		return $node;
	}

	// ... print() и getIterator() реализуем дальше ...
}
```

Замечание: `$endTag` равно `null`, если тег использован как n:атрибут, то есть `<div n:debug>...</div>`.

Парный тег можно также закрыть слешем, как `{debug/}` (или `<div n:debug/>`). Тогда у него нет внутреннего содержимого: генератор получает `[$emptyFragmentNode, $startTag]`, где второй элемент - сам *открывающий* тег, а не `null`.


Реализация `print()` для условной отрисовки
-------------------------------------------

Теперь методу `print()` нужно сгенерировать PHP-код, который во время выполнения проверяет провайдер `appDevMode` и выполняет код внутреннего содержимого, только если флаг истинен.

```php
	public function print(PrintContext $context): string
	{
		// Генерируем инструкцию PHP 'if', проверяющую провайдер во время выполнения
		return $context->format(
			<<<'XX'
				if ($this->global->appDevMode) %line {
					// Если режим разработки включён, выводим внутреннее содержимое
					%node
				}

				XX,
			$this->position, // Для комментария %line
			$this->content,  // Узел с AST внутреннего содержимого
		);
	}
```

Это просто. Мы используем `PrintContext::format()`, чтобы создать обычную инструкцию PHP `if`. Внутри `if` мы ставим заполнитель `%node` для `$this->content`. Latte рекурсивно вызовет `$this->content->print($context)`, чтобы сгенерировать PHP-код внутренней части тега, но только если `$this->global->appDevMode` во время выполнения окажется истинным.


Реализация `getIterator()` для содержимого
------------------------------------------

Как и с узлом аргумента в предыдущем примере, у нашего `DebugNode` теперь есть дочерний узел: `AreaNode $content`. Мы обязаны сделать его обходимым, отдав в `getIterator()`:

```php
	public function &getIterator(): \Generator
	{
		// Отдаём ссылку на узел содержимого
		yield $this->content;
	}
```

Это позволяет проходам компилятора спускаться в содержимое нашего тега `{debug}`, что важно, даже если содержимое отрисовывается по условию. Например, песочнице нужно проанализировать содержимое независимо от того, истинно `appDevMode` или ложно.


Регистрация и использование
---------------------------

Зарегистрируйте тег и провайдер в своём расширении:

```php
class MyLatteExtension extends Extension
{
	// Предполагаем, что $isDevelopmentMode определяется где-то (например, из конфигурации)
	public function __construct(
		private bool $isDevelopmentMode,
	) {
	}

	public function getTags(): array
	{
		return [
			'datetime' => DatetimeNode::create(...),
			'debug' => DebugNode::create(...), // Регистрируем новый тег
		];
	}

	public function getProviders(): array
	{
		return [
			'appDevMode' => $this->isDevelopmentMode, // Регистрируем провайдер
		];
	}
}

// При регистрации расширения:
$isDev = true; // Определите это по окружению вашего приложения
$latte->addExtension(new MyLatteExtension($isDev));
```

И используйте его в шаблоне:

```latte
<p>Regular content visible always.</p>

{debug}
	<div class="debug-panel">
		Current user ID: {$user->id}
		Request time: {=time()}
	</div>
{/debug}

<p>More regular content.</p>
```


Интеграция с n:атрибутами
-------------------------

Latte предлагает для многих парных тегов удобное сокращение: [n:атрибуты |syntax#n:атрибуты]. Если у вас есть парный тег вида `{tag}...{/tag}` и вы хотите, чтобы его действие относилось прямо к одному HTML-элементу, часто можно записать его короче как атрибут `n:tag` на этом элементе.

Для большинства определяемых вами обычных парных тегов (как наш `{debug}`) Latte автоматически включает соответствующую версию с `n:`. При регистрации ничего дополнительного делать не нужно:

```latte
{* Обычное использование парного тега *}
{debug}<div>Debug info</div>{/debug}

{* Равнозначное использование с n:атрибутом *}
<div n:debug>Debug info</div>
```

Оба варианта отрисуют `<div>`, только если `$this->global->appDevMode` истинно. Префиксы `inner-` и `tag-` тоже работают как ожидается.

Иногда логика вашего тега должна вести себя немного иначе в зависимости от того, использован ли он как обычный парный тег или как n:атрибут, либо если применён префикс вроде `n:inner-tag` или `n:tag-tag`. Объект `Latte\Compiler\Tag`, передаваемый в вашу функцию разбора `create()`, даёт эти сведения:

- `$tag->isNAttribute(): bool`: возвращает `true`, если тег разбирается как n:атрибут
- `$tag->prefix: ?string`: возвращает префикс, использованный с n:атрибутом; это может быть `null` (не n:атрибут), `Tag::PrefixNone`, `Tag::PrefixInner` или `Tag::PrefixTag`

Теперь, когда мы разобрались с простыми тегами, разбором аргументов, парными тегами, провайдерами и n:атрибутами, возьмёмся за более сложный сценарий с тегами, вложенными в другие теги, взяв за отправную точку наш тег `{debug}`.


Промежуточные теги
==================

Некоторые парные теги позволяют или даже требуют, чтобы *внутри* них перед итоговым закрывающим тегом появлялись другие теги. Они называются **промежуточными тегами**. Классические примеры: `{if}...{elseif}...{else}...{/if}` или `{switch}...{case}...{default}...{/switch}`.

Расширим наш тег `{debug}`, чтобы он поддерживал необязательную ветвь `{else}`, которая будет отрисовываться, когда приложение *не* находится в режиме разработки.

**Цель:** изменить `{debug}` так, чтобы он поддерживал необязательный промежуточный тег `{else}`. Итоговый синтаксис должен быть `{debug} ... {else} ... {/debug}`.


Разбор промежуточных тегов с помощью `yield`
--------------------------------------------

Мы уже знаем, что `yield` приостанавливает функцию разбора `create()` и возвращает разобранное содержимое вместе с закрывающим тегом. Однако `yield` даёт больше контроля: вы можете передать ему массив *имён промежуточных тегов*. Когда парсер встретит любой из указанных тегов **на том же уровне вложенности** (то есть как прямых потомков родительского тега, а не внутри других блоков или тегов внутри него), он тоже прекратит разбор содержимого.

Когда разбор останавливается из-за промежуточного тега, он прекращает разбирать содержимое, возобновляет генератор `create()` и передаёт обратно частично разобранное содержимое и сам **промежуточный тег** (вместо итогового закрывающего). Наша функция `create()` затем может обработать этот промежуточный тег (например, разобрать его аргументы, если они есть) и снова вызвать `yield`, чтобы разобрать *следующую* часть содержимого до *итогового* закрывающего тега или до другого ожидаемого промежуточного тега.

Изменим `DebugNode::create()` так, чтобы он ожидал `{else}`:

```php
<?php

namespace App\Templating;

use Latte\Compiler\Nodes\AreaNode;
use Latte\Compiler\Nodes\NopNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;

class DebugNode extends StatementNode
{
	// Содержимое части {debug}
	public AreaNode $thenContent;
	// Необязательное содержимое части {else}
	public ?AreaNode $elseContent = null;

	public static function create(Tag $tag): \Generator
	{
		$node = $tag->node = new self;

		// yield и ожидание либо {/debug}, либо {else}
		[$node->thenContent, $nextTag] = yield ['else'];

		// Проверяем, был ли тег, на котором мы остановились, тегом {else}
		if ($nextTag?->name === 'else') {
			// Снова yield, чтобы разобрать содержимое между {else} и {/debug}
			[$node->elseContent, $endTag] = yield;
		}

		return $node;
	}

	// ... print() и getIterator() обновим дальше ...
}
```

Теперь `yield ['else']` говорит Latte остановить разбор не только на `{/debug}`, но и на `{else}`. Если встретится `{else}`, `$nextTag` будет содержать объект `Tag` для `{else}`. Затем мы снова вызываем `yield` без аргументов, то есть теперь ожидаем только итоговый тег `{/debug}`, и сохраняем результат в `$node->elseContent`. Если `{else}` не найден, `$nextTag` будет `Tag` для `{/debug}` (или `null`, если тег использован как n:атрибут), а `$node->elseContent` останется `null`.


Реализация `print()` с `{else}`
-------------------------------

Метод `print()` должен отразить новую структуру. Он должен генерировать инструкцию PHP `if/else` на основе провайдера `appDevMode`.

```php
	public function print(PrintContext $context): string
	{
		return $context->format(
			<<<'XX'
				if ($this->global->appDevMode) %line {
					%node // Код ветви 'then' (содержимое {debug})
				} else {
					%node // Код ветви 'else' (содержимое {else})
				}

				XX,
			$this->position,    // Номер строки для условия 'if'
			$this->thenContent, // Первый заполнитель %node
			$this->elseContent ?? new NopNode, // Второй заполнитель %node
		);
	}
```

Это обычная структура PHP `if/else`. Мы используем `%node` дважды; `format()` подставляет переданные узлы по порядку. Мы используем `?? new NopNode`, чтобы избежать ошибок, если `$this->elseContent` равно `null`: `NopNode` просто ничего не выводит.


Реализация `getIterator()` для обоих содержимых
-----------------------------------------------

Теперь у нас потенциально два дочерних узла содержимого (`$thenContent` и `$elseContent`). Мы обязаны отдать оба, если они есть:

```php
	public function &getIterator(): \Generator
	{
		yield $this->thenContent;
		if ($this->elseContent) {
			yield $this->elseContent;
		}
	}
```


Использование улучшенного тега
------------------------------

Теперь тег можно использовать с необязательной ветвью `{else}`:

```latte
{debug}
	<p>Showing debug info because devMode is ON.</p>
{else}
	<p>Debug info is hidden because devMode is OFF.</p>
{/debug}
```


Состояние и вложенность
=======================

Наши предыдущие примеры (`{datetime}`, `{debug}`) были относительно лишены состояния внутри методов `print()`. Они либо напрямую выводили содержимое, либо делали простую условную проверку по глобальному провайдеру. Однако многим тегам нужно управлять каким-то **состоянием** во время отрисовки или вычислять переданные пользователем выражения, которые ради производительности или правильности должны выполниться лишь однажды. Кроме того, нам нужно подумать о том, что происходит, когда наши теги **вложены** друг в друга.

Проиллюстрируем эти понятия созданием тега `{repeat $count}...{/repeat}`. Этот тег будет повторять своё внутреннее содержимое `$count` раз.

**Цель:** реализовать `{repeat $count}`, который повторяет своё содержимое указанное число раз.


Зачем нужны временные и уникальные переменные
---------------------------------------------

Представьте, что пользователь пишет:

```latte
{repeat rand(1, 5)} Content {/repeat}
```

Если бы мы наивно сгенерировали в методе `print()` такой цикл PHP `for`:

```php
// Упрощённо, НЕПРАВИЛЬНЫЙ сгенерированный код
for ($i = 0; $i < rand(1, 5); $i++) {
	// вывод содержимого
}
```
Это было бы неверно! Выражение `rand(1, 5)` **вычислялось бы заново на каждой итерации цикла**, что привело бы к непредсказуемому числу повторов. Нам нужно вычислить выражение `$count` *один раз* до начала цикла и сохранить его результат.

Мы сгенерируем PHP-код, который сначала вычисляет выражение счётчика и сохраняет его во **временную переменную времени выполнения**. Чтобы избежать столкновений с переменными, определёнными пользователем шаблона, *и* с внутренними переменными Latte (вроде `$ʟ_...`), мы возьмём для своих временных переменных соглашение о префиксе **`$__` (двойное подчёркивание)**.

Сгенерированный код тогда выглядел бы так:

```php
$__count = rand(1, 5);
for ($__i = 0; $__i < $__count; $__i++) {
	// вывод содержимого
}
```

Теперь подумаем о вложенности:

```latte
{repeat $countA}       {* Внешний цикл *}
	{repeat $countB}   {* Внутренний цикл *}
		...
	{/repeat}
{/repeat}
```

Если бы и внешний, и внутренний теги `{repeat}` генерировали код с *одинаковыми* именами временных переменных (например, `$__count` и `$__i`), внутренний цикл перезаписал бы переменные внешнего и сломал логику.

Нам нужно обеспечить, чтобы временные переменные, порождаемые для каждого экземпляра тега `{repeat}`, были **уникальными**. Мы добиваемся этого с помощью `PrintContext::generateId()`. Этот метод возвращает уникальное целое число на этапе компиляции. Мы можем добавить этот идентификатор к именам своих временных переменных.

Так что вместо `$__count` мы сгенерируем имя с уникальным числовым суффиксом, например `$__count_0`, и точно так же для счётчика цикла, например `$__i_0`. Сами числа берутся из общего для всей компиляции счётчика, разделяемого всеми узлами, поэтому они гарантированно уникальны, но не образуют последовательность в пределах одного тега.


Реализация `RepeatNode`
-----------------------

Создадим класс узла.

```php
<?php

namespace App\Templating;

use Latte\CompileException;
use Latte\Compiler\Nodes\AreaNode;
use Latte\Compiler\Nodes\Php\ExpressionNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;

class RepeatNode extends StatementNode
{
	public ExpressionNode $count;
	public AreaNode $content;

	/**
	 * Функция разбора для {repeat $count} ... {/repeat}
	 */
	public static function create(Tag $tag): \Generator
	{
		$tag->expectArguments(); // убеждаемся, что $count передан
		$node = $tag->node = new self;
		// Разбираем выражение счётчика
		$node->count = $tag->parser->parseExpression();
		// Получаем внутреннее содержимое
		[$node->content] = yield;
		return $node;
	}

	/**
	 * Генерирует цикл PHP 'for' с уникальными именами переменных.
	 */
	public function print(PrintContext $context): string
	{
		// Генерируем уникальные имена переменных
		$id = $context->generateId();
		$countVar = '$__count_' . $id; // уникальное имя, например $__count_0
		$iteratorVar = '$__i_' . $id;  // уникальное имя, например $__i_0

		return $context->format(
			<<<'XX'
				// Вычисляем выражение счётчика *один раз* и сохраняем его
				%raw = (int) (%node);
				// Цикл с сохранённым счётчиком и уникальной переменной итератора
				for (%raw = 0; %2.raw < %0.raw; %2.raw++) %line {
					%node // Отрисовываем внутреннее содержимое
				}

				XX,
			$countVar,          // %0 - Переменная для хранения счётчика
			$this->count,       // %1 - Узел выражения счётчика
			$iteratorVar,       // %2 - Имя переменной итератора цикла
			$this->position,    // %3 - Комментарий с номером строки для самого цикла
			$this->content      // %4 - Узел внутреннего содержимого
		);
	}

	/**
	 * Отдаёт дочерние узлы (выражение счётчика и содержимое).
	 */
	public function &getIterator(): \Generator
	{
		yield $this->count;
		yield $this->content;
	}
}
```

Метод `create()` разбирает обязательное выражение `$count` с помощью `parseExpression()`. Сначала вызывается `$tag->expectArguments()`. Это гарантирует, что пользователь передал *хоть что-то* после `{repeat}`. Хотя `$tag->parser->parseExpression()` и так завершился бы ошибкой, если бы ничего не было передано, сообщение об ошибке говорило бы о неожиданном синтаксисе. Использование `expectArguments()` даёт куда более понятную ошибку, прямо сообщающую, что тегу `{repeat}` не хватает аргументов.

Метод `print()` генерирует PHP-код, отвечающий за выполнение логики повторения во время выполнения. Он начинается с генерации уникальных имён временных переменных PHP, которые ему понадобятся.

Метод `$context->format()` вызывается с новым заполнителем `%raw`, который вставляет *сырую строку*, переданную в соответствующем аргументе. Здесь он вставляет уникальное имя переменной, хранящееся в `$countVar` (например, `$__count_1`). А что насчёт `%0.raw` и `%2.raw`? Это демонстрация **позиционных заполнителей**. Вместо простого `%raw`, который берёт *следующий* доступный сырой аргумент, `%2.raw` явно берёт аргумент с индексом 2 (это `$iteratorVar`) и вставляет его сырое строковое значение. Это позволяет нам переиспользовать строку `$iteratorVar`, не передавая её несколько раз в списке аргументов `format()`.

Этот аккуратно выстроенный вызов `format()` порождает эффективный и безопасный цикл PHP, который правильно обрабатывает выражение счётчика и избегает столкновений имён переменных даже при вложенных тегах `{repeat}`.


Регистрация и использование
---------------------------

Зарегистрируйте тег в своём расширении:

```php
use App\Templating\RepeatNode;

class MyLatteExtension extends Extension
{
	public function getTags(): array
	{
		return [
			'datetime' => DatetimeNode::create(...),
			'debug' => DebugNode::create(...),
			'repeat' => RepeatNode::create(...), // Регистрируем тег repeat
		];
	}
}
```

Используйте его в шаблоне, в том числе с вложенностью:

```latte
{var $rows = rand(5, 7)}
{var $cols = rand(3, 5)}

{repeat $rows}
	<tr>
		{repeat $cols}
			<td>Inner loop</td>
		{/repeat}
	</tr>
{/repeat}
```

Этот пример показывает, как обращаться с состоянием (счётчиками цикла) и возможными проблемами вложенности с помощью временных переменных с префиксом `$__`, уникальность которым придают идентификаторы из `PrintContext::generateId()`.


Чистые n:атрибуты
-----------------

Многие `n:атрибуты` вроде `n:if` или `n:foreach` служат удобными сокращениями для соответствующих парных тегов (`{if}...{/if}`, `{foreach}...{/foreach}`), но Latte позволяет определять и теги, существующие *только* в форме n:атрибута. Их часто используют, чтобы изменить атрибуты или поведение HTML-элемента, к которому они прикреплены.

Стандартные примеры, встроенные в Latte, - [`n:class` |tags#n:class], помогающий динамически собирать атрибут `class`, и [`n:attr` |tags#n:attr], который может задавать несколько произвольных атрибутов.

Создадим собственный чистый n:атрибут: `n:confirm`, который добавит диалог подтверждения на JavaScript перед выполнением действия (перехода по ссылке или отправки формы).

**Цель:** реализовать `n:confirm="'Are you sure?'"`, который добавляет обработчик `onclick`, отменяющий действие по умолчанию, если пользователь откажется в диалоге подтверждения.


Реализация `ConfirmNode`
------------------------

Нам нужен класс узла и функция разбора.

```php
<?php

namespace App\Templating;

use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;
use Latte\Compiler\Nodes\Php\ExpressionNode;
use Latte\Compiler\Nodes\Php\Scalar\StringNode;

class ConfirmNode extends StatementNode
{
	public ExpressionNode $message;

	public static function create(Tag $tag): self
	{
		$tag->expectArguments();
		$node = $tag->node = new self;
		$node->message = $tag->parser->parseExpression();
		return $node;
	}

	/**
	 * Генерирует код атрибута 'onclick' с правильным экранированием.
	 */
	public function print(PrintContext $context): string
	{
		// Он обеспечивает правильное экранирование и для JavaScript, и для контекста HTML-атрибута.
		return $context->format(
			<<<'XX'
				echo ' onclick="', LR\HtmlHelpers::escapeAttr('return confirm(' . LR\Helpers::escapeJs(%node) . ')'), '"' %line;
				XX,
			$this->message,
			$this->position,
		);
	}

	public function &getIterator(): \Generator
	{
		yield $this->message;
	}
}
```

Метод `print()` генерирует PHP-код, который в конечном счёте выведет HTML-атрибут `onclick="..."` при отрисовке шаблона. Работа с вложенными контекстами (JavaScript внутри HTML-атрибута) требует аккуратного экранирования. Помощник `LR\Helpers::escapeJs(%node)` вызывается во время выполнения и правильно экранирует сообщение для использования внутри JavaScript (на выходе получилось бы `"Sure?"`). Затем помощник `LR\HtmlHelpers::escapeAttr(...)` экранирует символы, которые особенны внутри HTML-атрибутов, и превращает вывод в `return confirm(&quot;Sure?&quot;)`. Такое двухступенчатое экранирование во время выполнения гарантирует, что сообщение безопасно для JavaScript, а получившийся код JavaScript безопасен для вставки в HTML-атрибут `onclick`.


Регистрация и использование
---------------------------

Зарегистрируйте n:атрибут в своём расширении. Не забудьте про префикс `n:` в ключе:

```php
class MyLatteExtension extends Extension
{
	public function getTags(): array
	{
		return [
			'datetime' => DatetimeNode::create(...),
			'debug' => DebugNode::create(...),
			'repeat' => RepeatNode::create(...),
			'n:confirm' => ConfirmNode::create(...), // Регистрируем n:confirm
		];
	}
}
```

Теперь вы можете использовать `n:confirm` на ссылках, кнопках или элементах форм:

```latte
<a href="delete.php?id=123" n:confirm='"Do you really want to delete item {$id}?"'>Delete</a>
```

Сгенерированный HTML:

```latte
<a href="delete.php?id=123" onclick="return confirm(&quot;Do you really want to delete item 123?&quot;)">Delete</a>
```

Когда пользователь щёлкнет по ссылке, браузер выполнит код `onclick`, покажет диалог подтверждения и перейдёт к `delete.php`, только если пользователь нажмёт "OK".

Этот пример показывает, как чистый n:атрибут можно создать для изменения поведения или атрибутов своего HTML-элемента, генерируя подходящий PHP-код в методе `print()`. Помните о двойном экранировании, которое часто требуется: один раз для целевого контекста (в данном случае JavaScript) и ещё раз для контекста HTML-атрибута.

При написании чистых n:атрибутов пригождаются ещё два члена объекта `Tag`: `$tag->htmlElement` даёт вам доступ к окружающему HTML-элементу (`ElementNode`), чтобы вы могли осмотреть или подправить его, а `$tag->replaceNAttribute($node)` позволяет заменить атрибут собранным вами узлом. Более того, узел, возвращённый из `create()` чистого n:атрибута, автоматически заменяет атрибут на его элементе.


Продвинутые темы
================

Предыдущие разделы охватывают основные понятия, а здесь несколько более продвинутых тем, с которыми вы можете столкнуться при создании собственных тегов Latte.


Режимы вывода тега
------------------

У объекта `Tag`, передаваемого в вашу функцию `create()`, есть свойство `outputMode`. Оно влияет на то, как Latte обходится с окружающими пробельными символами и отступами, особенно когда тег стоит на строке один. Вы можете изменить это свойство внутри своей функции `create()`.

- `Tag::OutputNone` (**значение по умолчанию** для каждого тега, и именно его сохраняют управляющие конструкции вроде `{if}` или `{foreach}`): пробельные символы вокруг тега обрабатываются ровно так же, как при `OutputRemoveIndentation` - ведущий отступ и один завершающий перевод строки убираются. Настоящее отличие внутреннее: этот режим оставляет парсер шаблонов в режиме "шапки" шаблона. Он подходит для объявляющих или настроечных тегов вроде `{var}` или `{default}`, которые ничего не выводят напрямую.
- `Tag::OutputRemoveIndentation` (явно устанавливается блочными тегами `{block}`, `{embed}`, `{include}` и `{sandbox}`): убирает ведущий отступ перед тегом и один завершающий перевод строки. Это помогает держать сгенерированный PHP-код чище и избегать лишних пустых строк в HTML-выводе, вызванных самим тегом.
- `Tag::OutputKeepIndentation` (явно устанавливается выводящими тегами вроде `{=...}`): Latte старается сохранить отступ перед тегом; переводы строк *после* тега обычно сохраняются. Это подходит тегам, которые выводят содержимое по месту, - см. пример `{datetime}` выше, который устанавливает этот режим именно поэтому.

Выбирайте режим, лучше всего отвечающий назначению вашего тега. Поскольку по умолчанию действует `OutputNone`, тегам управления ходом и объявлений менять ничего не нужно; установите `OutputKeepIndentation` для тегов, которые выводят содержимое на собственной строке.


Доступ к родительским и ближайшим тегам
---------------------------------------

Иногда поведение тега должно зависеть от контекста, в котором он используется, а именно от того, внутри каких родительских тегов он находится. Объект `Tag`, передаваемый в вашу функцию `create()`, предоставляет для этого метод `closestTag(array $classes, ?callable $condition = null): ?Tag`.

Этот метод ищет вверх по иерархии открытых в данный момент тегов Latte (цепочке `$tag->parent`; окружающие HTML-элементы в неё не входят) и возвращает объект `Tag` ближайшего предка, отвечающего заданным условиям. Если подходящего предка нет, он возвращает `null`.

Массив `$classes` указывает, каких предков вы ищете. Проверяется, совпадает ли класс узла, связанного с тегом-предком (`$ancestorTag->node`), ровно с одним из перечисленных классов; потомки этих классов не подходят.

```php
function create(Tag $tag)
{
	// Ищем ближайший тег-предок, узел которого является экземпляром ForeachNode
	$foreachTag = $tag->closestTag([ForeachNode::class]);
	if ($foreachTag) {
		// Мы можем обратиться к самому экземпляру ForeachNode:
		$foreachNode = $foreachTag->node;
	}
}
```

Обратите внимание на `$foreachTag->node`: это работает только потому, что в разработке тегов Latte принято сразу присваивать созданный узел в `$tag->node` внутри метода `create()`, как мы всегда и делали.

Иногда одного совпадения по типу узла недостаточно. Вам может понадобиться проверить конкретное свойство потенциального тега-предка или его узла. Необязательный второй аргумент `closestTag()` - это callable, который получает потенциальный объект `Tag` предка и должен вернуть, подходит ли он.

```php
function create(Tag $tag)
{
	$dynamicBlockTag = $tag->closestTag(
		[BlockNode::class],
		// Условие: блок должен быть динамическим
		fn(Tag $blockTag) => $blockTag->node->block->isDynamic(),
	);
}
```

Использование `closestTag()` позволяет создавать теги, учитывающие контекст и требующие правильного применения в структуре шаблона, что делает шаблоны надёжнее и понятнее.


Заполнители `PrintContext::format()`
------------------------------------

Мы часто использовали `PrintContext::format()` для генерации PHP-кода в методах `print()` наших узлов. Он принимает строку-маску и последующие аргументы, заменяющие заполнители в маске. Вот сводка доступных заполнителей:

- **`%node`**: аргументом должен быть экземпляр `Node`. Вызывает метод `print()` узла и вставляет получившуюся строку PHP-кода.
- **`%dump`**: аргументом может быть любое значение PHP. Экспортирует значение в корректный PHP-код. Подходит для скаляров, массивов, null.
	- `$context->format('echo %dump;', 'Hello')` -> `echo 'Hello';`
	- `$context->format('$arr = %dump;', [1, 2])` -> `$arr = [1, 2];`
- **`%raw`**: вставляет аргумент прямо в выходной PHP-код без какого-либо экранирования или изменения. **Используйте осторожно**, в первую очередь для вставки заранее сгенерированных фрагментов PHP-кода или имён переменных.
	- `$context->format('%raw = 1;', '$variableName')` -> `$variableName = 1;`
- **`%args`**: аргументом должен быть `Expression\ArrayNode`. Выводит элементы массива, отформатированные как аргументы вызова функции или метода (через запятую, с учётом именованных аргументов, если они есть).
	- `$argsNode = new ArrayNode([...]);`
	- `$context->format('myFunc(%args);', $argsNode)` -> `myFunc(1, name: 'Joe');`
- **`%line`**: аргументом должен быть объект `Position` (или `Range`), обычно `$this->position`. Вставляет комментарий PHP `/* pos X:Y */`, указывающий строку и столбец в исходнике.
	- `$context->format('echo "Hi" %line;', $this->position)` -> `echo "Hi" /* pos 42:1 */;`
- **`%escape(...)`**: генерирует PHP-код, который *во время выполнения* экранирует внутреннее выражение по текущим правилам контекстно-зависимого экранирования.
	- `$context->format('echo %escape(%node);', $variableNode)`
- **`%modify(...)`**: аргументом должен быть `ModifierNode`. Генерирует PHP-код, применяющий к внутреннему содержимому фильтры, указанные в `ModifierNode`, включая контекстно-зависимое экранирование, если оно не отключено через `|noescape`.
	- `$context->format('%modify(%node);', $modifierNode, $variableNode)`
- **`%modifyContent(...)`**: похож на `%modify`, но предназначен для изменения блоков захваченного содержимого (часто HTML).

Вы можете явно ссылаться на аргументы по их индексу с нуля: `%0.node`, `%1.dump`, `%2.raw` и так далее. Это позволяет переиспользовать аргумент в маске несколько раз, не передавая его повторно в `format()`. См. пример тега `{repeat}`, где использованы `%0.raw` и `%2.raw`.


Пример сложного разбора аргументов
----------------------------------

`parseExpression()`, `parseArguments()` и прочие покрывают много случаев, но иногда нужна более замысловатая логика разбора с использованием более низкоуровневого `TokenStream`, доступного через `$tag->parser->stream`.

**Цель:** создать тег `{embedYoutube $videoID, width: 640, height: 480}`. Мы хотим разобрать обязательный идентификатор видео (строку или переменную), за которым следуют необязательные пары ключ-значение для размеров.

```php
<?php
namespace App\Templating;

use Latte\CompileException;
use Latte\Compiler\Nodes\Php\ExpressionNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\Tag;
use Latte\Compiler\Token;

class YoutubeNode extends StatementNode
{
	public ExpressionNode $videoId;
	public ?ExpressionNode $width = null;
	public ?ExpressionNode $height = null;

	public static function create(Tag $tag): self
	{
		$tag->expectArguments();
		$node = $tag->node = new self;
		// Разбираем обязательный идентификатор видео
		$node->videoId = $tag->parser->parseExpression();

		// Разбираем необязательные пары ключ-значение
		$stream = $tag->parser->stream; // Получаем поток токенов
		while ($stream->tryConsume(',')) { // Требуется разделение запятыми
			// Ожидаем идентификатор 'width' или 'height'
			$keyToken = $stream->consume(Token::Php_Identifier);
			$key = strtolower($keyToken->text);

			$stream->consume(':'); // Ожидаем разделитель-двоеточие

			$value = $tag->parser->parseExpression(); // Разбираем выражение значения

			if ($key === 'width') {
				$node->width = $value;
			} elseif ($key === 'height') {
				$node->height = $value;
			} else {
				throw new CompileException("Unknown argument '$key'. Expected 'width' or 'height'.", $keyToken->position);
			}
		}

		return $node;
	}

	// ... print() и getIterator() ...
}
```

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


Использование `AuxiliaryNode`
-----------------------------

Latte предоставляет обобщённые "вспомогательные" узлы для особых ситуаций при генерации кода или внутри проходов компилятора. Это `AuxiliaryNode` и `Php\Expression\AuxiliaryNode`.

Считайте `AuxiliaryNode` гибким узлом-контейнером, который делегирует свои основные обязанности - генерацию кода и доступ к дочерним узлам - аргументам, переданным в конструктор:

- Делегирование `print()`: первый аргумент конструктора - **замыкание** PHP. Когда Latte вызывает метод `print()` у `AuxiliaryNode`, он выполняет это замыкание. Замыкание получает `PrintContext` и все узлы, переданные во втором аргументе конструктора, что позволяет вам на лету определить совершенно свою логику генерации PHP-кода.
- Делегирование `getIterator()`: второй аргумент конструктора - **массив объектов `Node`**. Когда Latte нужно обойти потомков `AuxiliaryNode` (например, во время проходов компилятора), его метод `getIterator()` просто отдаёт узлы из этого массива.

Пример:

```php
$node = new AuxiliaryNode(
    // 1. Это замыкание становится телом print()
    fn(PrintContext $context, $arg1, $arg2) => $context->format('...%node...%node...', $arg1, $arg2),

    // 2. Эти узлы отдаёт getIterator(), и они же передаются в замыкание выше
    [$argumentNode1, $argumentNode2]
);
```

Latte предлагает два разных типа в зависимости от того, куда нужно вставить сгенерированный код:

- `Latte\Compiler\Nodes\Php\Expression\AuxiliaryNode`: используйте, когда нужно сгенерировать кусок PHP-кода, представляющий **выражение**
- `Latte\Compiler\Nodes\AuxiliaryNode`: используйте для более общих целей, когда нужно вставить блок PHP-кода, представляющий одну или несколько **инструкций**

Важная причина использовать `AuxiliaryNode` вместо обычных узлов (вроде `StaticMethodCallNode`) внутри метода `print()` или прохода компилятора - **управление видимостью для последующих проходов компилятора**, особенно связанных с безопасностью, как песочница.

Представьте ситуацию: вашему проходу компилятора нужно обернуть переданное пользователем выражение (`$userExpr`) вызовом определённой доверенной вспомогательной функции `myInternalSanitize($userExpr)`. Если вы создадите обычный узел `new FunctionCallNode('myInternalSanitize', [$userExpr])`, он будет полностью виден обходчику AST. Если позже запустится проход песочницы, а `myInternalSanitize` не будет в её списке разрешённых, песочница может *заблокировать* или изменить этот вызов и тем самым сломать внутреннюю логику вашего тега, хотя *вы*, автор тега, знаете, что этот конкретный вызов безопасен и необходим. Поэтому вы можете сгенерировать вызов прямо внутри замыкания `AuxiliaryNode`.

```php
use Latte\Compiler\Nodes\Php\Expression\AuxiliaryNode;

// ... внутри print() или прохода компилятора ...
$wrappedNode = new AuxiliaryNode(
	fn(PrintContext $context, $userExpr) => $context->format(
		'myInternalSanitize(%node)', // Прямая генерация PHP-кода
		$userExpr,
	),
	// ВАЖНО: исходный узел пользовательского выражения всё равно передайте сюда!
	[$userExpr],
);
```

В этом случае проход песочницы видит `AuxiliaryNode`, но **не анализирует PHP-код, порождённый его замыканием**. Он не может напрямую заблокировать вызов `myInternalSanitize`, сгенерированный *внутри* замыкания.

Хотя сам сгенерированный PHP-код скрыт от проходов, *входные данные* этого кода (узлы, представляющие пользовательские данные или выражения) **всё равно должны оставаться обходимыми**. Именно поэтому второй аргумент конструктора `AuxiliaryNode` так важен. Вы **обязаны** передать массив со всеми исходными узлами (как `$userExpr` в примере выше), которые использует ваше замыкание. `getIterator()` у `AuxiliaryNode` **отдаст эти узлы**, позволяя проходам вроде песочницы проанализировать их на предмет возможных проблем.


Лучшие практики
===============

- **Ясное назначение:** убедитесь, что у вашего тега есть ясное и действительно нужное назначение. Не создавайте теги для задач, которые легко решаются [фильтрами|custom-filters] или [функциями|custom-functions].
- **Правильно реализуйте `getIterator()`:** всегда реализуйте `getIterator()` и отдавайте *ссылки* (`&`) на *все* дочерние узлы (аргументы, содержимое), разобранные из шаблона. Это необходимо для проходов компилятора, безопасности (песочницы) и возможных будущих оптимизаций.
- **Публичные свойства для узлов:** делайте свойства, хранящие дочерние узлы, публичными, чтобы проходы компилятора при необходимости могли их изменять.
- **Используйте `PrintContext::format()`:** применяйте метод `format()` для генерации PHP-кода. Он заботится о кавычках, правильно экранирует заполнители и автоматически добавляет комментарии с номерами строк.
- **Временные переменные (`$__`):** когда генерируемый код времени выполнения нуждается во временных переменных (например, для промежуточных результатов, счётчиков цикла), используйте соглашение о префиксе `$__`, чтобы избежать столкновений с переменными пользователя и внутренними переменными Latte `$ʟ_`.
- **Вложенность и уникальные идентификаторы:** если ваш тег может быть вложенным или нуждается в состоянии, привязанном к экземпляру, во время выполнения, используйте `$context->generateId()` внутри метода `print()`, чтобы создать уникальные суффиксы для своих временных переменных `$__`.
- **Провайдеры для внешних данных:** используйте провайдеры (зарегистрированные через `Extension::getProviders()`) для доступа к данным или сервисам времени выполнения ($this->global->...) вместо жёстко прописанных значений или глобального состояния. Добавляйте к именам провайдеров префиксы вендора.
- **Подумайте о n:атрибутах:** если ваш парный тег логически работает с одним HTML-элементом, Latte, скорее всего, автоматически поддержит для него `n:атрибут`. Держите это в уме ради удобства пользователей. Если вы создаёте тег, изменяющий атрибуты, подумайте, не будет ли чистый `n:атрибут` наиболее подходящей формой.
- **Тестирование:** пишите тесты для своих тегов, покрывая и разбор различных вариантов синтаксиса, и правильность вывода сгенерированного PHP-кода.

Следуя этим рекомендациям, вы сможете создавать мощные, надёжные и поддерживаемые пользовательские теги, которые органично встраиваются в движок шаблонов Latte.

.[note]
Изучение классов узлов, входящих в состав Latte, - лучший способ узнать все тонкости процесса разбора.

Создание пользовательских тегов

Эта страница представляет собой исчерпывающее руководство по созданию собственных тегов в Latte. Мы разберём всё: от простых тегов до более сложных сценариев с вложенным содержимым и особыми потребностями разбора, опираясь на понимание того, как Latte компилирует шаблоны.

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

Как устроен процесс компиляции

Чтобы уверенно создавать собственные теги, полезно понимать, как Latte обрабатывает шаблоны. Понимание этого процесса объясняет, почему теги устроены именно так и как они вписываются в общую картину.

Компиляция шаблона в Latte, если упростить, включает следующие ключевые шаги:

  1. Лексический разбор: лексер читает исходный код шаблона (файл .latte) и разбивает его на последовательность небольших отдельных частей, называемых токенами (например, {, foreach, $variable, }, HTML-текст и так далее).
  2. Синтаксический разбор: парсер берёт этот поток токенов и строит осмысленную древовидную структуру, представляющую логику и содержимое шаблона. Это дерево называется абстрактным синтаксическим деревом (AST).
  3. Проходы компилятора: перед генерацией PHP-кода Latte запускает проходы компилятора. Это функции, которые обходят всё AST и могут изменять его или собирать сведения. Этот шаг принципиально важен для таких возможностей, как безопасность (песочница) или оптимизации.
  4. Генерация кода: наконец, компилятор обходит (возможно, изменённое) AST и генерирует соответствующий код PHP-класса. Именно этот PHP-код и отрисовывает шаблон при выполнении.
  5. Кеширование: сгенерированный PHP-код кешируется на диске, благодаря чему последующие отрисовки идут очень быстро, ведь шаги 1–4 пропускаются.

На самом деле компиляция чуть сложнее. В Latte два лексера и парсера: один для HTML-шаблона и один для PHP-подобного кода внутри тегов. Кроме того, разбор не идёт после токенизации: лексер и парсер работают параллельно в двух „потоках“ и согласуют свои действия. Поверьте мне, Давиду Грудлу: программирование этого ощущалось как ракетостроение :-)

Весь процесс, от загрузки содержимого шаблона через разбор до генерации итогового файла, можно разложить на такую последовательность, с которой вы можете поэкспериментировать и вывести промежуточные результаты:

$latte = new Latte\Engine;
$source = $latte->getLoader()->getContent($file);
$ast = $latte->parse($source);
$latte->applyPasses($ast);
$code = $latte->generate($ast, $file);

Анатомия тега

Создание полноценного пользовательского тега в Latte состоит из нескольких взаимосвязанных частей. Прежде чем погрузиться в реализацию, разберёмся с основными понятиями и терминологией, проведя аналогию с HTML и объектной моделью документа (DOM).

Теги против узлов (аналогия с HTML)

В HTML мы пишем теги вроде <p> или <div>...</div>. Эти теги – синтаксис в исходном коде. Когда браузер разбирает такой HTML, он создаёт представление в памяти, называемое объектной моделью документа (DOM). В DOM HTML-теги представлены узлами (точнее, узлами Element в терминологии DOM для JavaScript). С этими узлами мы работаем программно (например, document.getElementById(...) в JavaScript возвращает узел Element). Тег – это лишь текстовое представление в исходном файле, а узел – объектное представление в логическом дереве.

Latte работает похоже:

  • В файле шаблона .latte вы пишете теги Latte, такие как {foreach ...} и {/foreach}. Это синтаксис, с которым вы как автор шаблона имеете дело.
  • Когда Latte разбирает шаблон, он строит абстрактное синтаксическое дерево (AST). Это дерево состоит из узлов. Каждый тег Latte, HTML-элемент, кусочек текста или выражение в шаблоне становится одним или несколькими узлами этого дерева.
  • Базовый класс для всех узлов AST – Latte\Compiler\Node. Так же как в DOM есть разные типы узлов (Element, Text, Comment), в AST Latte есть разные типы узлов. Вам встретятся Latte\Compiler\Nodes\TextNode для статического текста, Latte\Compiler\Nodes\Html\ElementNode для HTML-элементов, Latte\Compiler\Nodes\Php\ExpressionNode для выражений внутри тегов и, что особенно важно для пользовательских тегов, узлы, наследующие от Latte\Compiler\Nodes\StatementNode.

Почему StatementNode?

HTML-элементы (Html\ElementNode) представляют прежде всего структуру и содержимое. Выражения PHP (Php\ExpressionNode) представляют значения или вычисления. А что насчёт тегов Latte вроде {if}, {foreach} или нашего собственного {datetime}? Эти теги выполняют действия, управляют ходом программы или порождают вывод на основе логики. Это функциональные единицы, которые делают Latte мощным движком шаблонов, а не просто языком разметки.

В программировании такие выполняющие действия единицы часто называют „инструкциями“ (statements). Поэтому узлы, представляющие такие функциональные теги Latte, обычно наследуют от Latte\Compiler\Nodes\StatementNode. Это отличает их от чисто структурных узлов (как HTML-элементы) или узлов, представляющих значения (как выражения).

Ключевые составляющие

Пройдёмся ещё раз по главным составляющим, нужным для создания пользовательского тега:

Функция разбора тега

  • Этот PHP-callable разбирает синтаксис тега Latte ({...}) в исходном коде шаблона.
  • Он получает сведения о теге (имя, позицию и то, является ли он n:атрибутом) через объект Latte\Compiler\Tag, а вторым аргументом – главный Latte\Compiler\TemplateParser. Его полная сигнатура: callable(Tag, TemplateParser): (Node|\Generator|void).
  • Основной инструмент для разбора аргументов и выражений внутри ограничителей тега – объект Latte\Compiler\TagParser, доступный через $tag->parser (это другой парсер, не тот, который разбирает весь шаблон).
  • Для парных тегов он использует yield, чтобы дать Latte сигнал разобрать внутреннее содержимое между открывающим и закрывающим тегами.
  • Конечная цель функции разбора – создать и вернуть экземпляр класса узла, который попадёт в AST.
  • Принято (хотя и не обязательно) реализовывать функцию разбора как статический метод (часто с именем create) прямо в соответствующем классе узла. Так логика разбора и представление узла остаются аккуратно собраны вместе, при необходимости доступны приватные и защищённые члены класса, и порядка становится больше.

Класс узла

  • Представляет логическую функцию вашего тега внутри абстрактного синтаксического дерева (AST).
  • Хранит разобранные сведения (аргументы или содержимое) в публичных свойствах. Эти свойства часто содержат другие экземпляры Node (например, ExpressionNode для разобранных аргументов, AreaNode для разобранного содержимого).
  • Метод print(PrintContext $context): string генерирует PHP-код (инструкцию или ряд инструкций), который выполняет действие тега при отрисовке шаблона.
  • Метод getIterator(): \Generator делает дочерние узлы (аргументы, содержимое) доступными для обхода проходами компилятора. Он должен отдавать ссылки (&), чтобы проходы могли изменять или заменять подузлы.
  • После того как весь шаблон разобран в AST, Latte запускает череду проходов компилятора. Эти проходы обходят всё AST, используя метод getIterator(), предоставляемый каждым узлом. Они могут исследовать узлы, собирать сведения и даже изменять дерево (например, меняя публичные свойства узлов или заменяя узлы целиком). Такое устройство, требующее полноценного getIterator(), принципиально важно. Оно позволяет мощным механизмам вроде песочницы анализировать и при необходимости менять поведение любой части шаблона, включая ваши собственные теги, обеспечивая безопасность и единообразие.

Регистрация через расширение

  • Вам нужно сообщить Latte о вашем новом теге и о том, какую функцию разбора для него использовать. Это происходит внутри расширения Latte.
  • В классе расширения вы реализуете метод getTags(): array. Он возвращает ассоциативный массив, где ключи – имена тегов (например, 'mytag', 'n:myattribute'), а значения – PHP-callable, представляющие соответствующие функции разбора (например, MyNamespace\DatetimeNode::create(...)).

Итого: функция разбора тега превращает исходный код шаблона с вашим тегом в узел AST. Класс узла затем знает, как превратить самого себя в исполняемый PHP-код скомпилированного шаблона, и делает свои подузлы доступными для проходов компилятора через getIterator(). Регистрация через расширение связывает имя тега с функцией разбора и сообщает о нём Latte.

Теперь мы шаг за шагом разберём, как реализовать эти составляющие.

Создание простого тега

Погрузимся в создание вашего первого пользовательского тега Latte. Начнём с очень простого примера: тега {datetime}, который выводит текущие дату и время. Поначалу этот тег не будет принимать аргументов, но позже мы улучшим его в разделе Разбор аргументов тега. Внутреннего содержимого у него тоже нет.

Этот пример проведёт вас по основным шагам: определение класса узла, реализация его методов print() и getIterator(), создание функции разбора и, наконец, регистрация тега.

Цель: реализовать {datetime}, который выводит текущие дату и время с помощью функции PHP date().

Создание класса узла

Сначала нам нужен класс, представляющий наш тег в абстрактном синтаксическом дереве (AST). Как говорилось выше, мы наследуем от Latte\Compiler\Nodes\StatementNode.

Создайте файл (например, DatetimeNode.php) и определите класс:

<?php

namespace App\Templating;

use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;

class DatetimeNode extends StatementNode
{
	/**
	 * Функция разбора тега, вызывается при обнаружении {datetime}.
	 */
	public static function create(Tag $tag): self
	{
		// Наш тег выводит содержимое, поэтому сохраняем окружающий отступ
		$tag->outputMode = $tag::OutputKeepIndentation;
		// Наш простой тег пока не принимает аргументов, так что разбирать нечего
		$node = $tag->node = new self;
		return $node;
	}

	/**
	 * Генерирует PHP-код, который выполнится при отрисовке шаблона.
	 */
	public function print(PrintContext $context): string
	{
		return $context->format(
			'echo date(\'Y-m-d H:i:s\') %line;',
			$this->position,
		);
	}

	/**
	 * Даёт доступ к дочерним узлам для проходов компилятора Latte.
	 */
	public function &getIterator(): \Generator
	{
		false && yield;
	}
}

Когда Latte встречает в шаблоне {datetime}, он вызывает функцию разбора тега create(). Её задача – вернуть экземпляр DatetimeNode. Мы также устанавливаем $tag->outputMode в OutputKeepIndentation: поскольку тег работает в режиме по умолчанию OutputNone (объяснено в разделе Режимы вывода тега), тег, поставленный перед первым текстом шаблона, иначе мог бы выдать свой вывод в сгенерированном методе prepare() вместо main(). Установка этого режима гарантирует, что вывод окажется там, где стоит тег.

Метод print() генерирует PHP-код, который выполнится при отрисовке шаблона. Мы вызываем метод $context->format(), который собирает итоговую строку PHP-кода скомпилированного шаблона. Первый аргумент, 'echo date('Y-m-d H:i:s') %line;', – это маска, в которую подставляются последующие параметры. Заполнитель %line говорит методу format() взять следующий аргумент, то есть $this->position, и вставить комментарий вроде /* pos 15:1 */, связывающий сгенерированный PHP-код с исходной строкой шаблона, что принципиально важно для отладки.

Свойство $this->position унаследовано от базового класса Node и автоматически заполняется парсером Latte. Оно содержит объект Latte\Compiler\Range (потомок Position, дополненный длиной length в байтах), указывающий, где тег находится в исходном файле .latte. Для парных тегов диапазон охватывает всё от открывающего до закрывающего тега, а потомки StatementNode дополнительно предоставляют $this->tagRanges со списком Range каждого составляющего тега (открывающего, промежуточных вроде {else}/{case} и закрывающего).

Метод getIterator() жизненно важен для проходов компилятора. Он должен отдавать все дочерние узлы, но у нашего простого DatetimeNode пока нет ни аргументов, ни содержимого, а значит, и дочерних узлов. Тем не менее метод должен существовать и быть генератором, то есть ключевое слово yield должно каким-то образом присутствовать в его теле.

Регистрация через расширение

Наконец, сообщите Latte о новом теге. Создайте класс расширения (например, MyLatteExtension.php) и зарегистрируйте тег в его методе getTags().

<?php

namespace App\Templating;

use Latte\Extension;

class MyLatteExtension extends Extension
{
	/**
	 * Возвращает список тегов, предоставляемых этим расширением.
	 * @return array<string, callable> Соответствие: 'имя-тега' => функция-разбора
	 */
	public function getTags(): array
	{
		return [
			'datetime' => DatetimeNode::create(...),
			// Здесь позже зарегистрируем другие теги
		];
	}
}

Затем зарегистрируйте это расширение в Latte Engine:

$latte = new Latte\Engine;
$latte->addExtension(new App\Templating\MyLatteExtension);

Создайте шаблон:

<p>Page generated on: {datetime}</p>

Ожидаемый вывод: <p>Page generated on: 2023-10-27 11:00:00</p>

Итоги этого этапа

Мы успешно создали простой пользовательский тег {datetime}. Мы определили его представление в AST (DatetimeNode), позаботились о его разборе (create()), задали, как он должен генерировать PHP-код (print()), обеспечили обход его потомков (getIterator()) и зарегистрировали его в Latte.

В следующем разделе мы улучшим этот тег, чтобы он принимал аргументы, и покажем, как разбирать выражения и управлять дочерними узлами.

Разбор аргументов тега

Наш простой тег {datetime} работает, но он не слишком гибок. Улучшим его, чтобы он принимал необязательный аргумент – строку формата для функции date(). Желаемый синтаксис: {datetime $format}.

Цель: изменить {datetime} так, чтобы он принимал необязательное выражение PHP как аргумент, который будет использован как строка формата для date().

Знакомство с TagParser

Прежде чем менять код, важно разобраться с инструментом, который мы будем использовать, – Latte\Compiler\TagParser. Когда главный парсер Latte (TemplateParser) встречает тег Latte вроде {datetime ...} или n:атрибут, он передаёт разбор содержимого внутри тега (части между { и } или значения атрибута) специализированному TagParser.

Этот TagParser работает исключительно с аргументами тега. Его задача – поглотить токены, представляющие эти аргументы. Принципиально важно, что он должен разобрать всё переданное ему содержимое. Если ваша функция разбора завершится, а TagParser не дойдёт до конца аргументов (проверяется через $tag->parser->isEnd()), Latte выбросит исключение, потому что это означает, что внутри тега остались неожиданные токены. И наоборот, если тег требует аргументы, вам следует вызвать $tag->expectArguments() в начале своей функции разбора. Этот метод проверяет наличие аргументов и выбрасывает понятное исключение, если тег использован без них.

TagParser предлагает полезные методы для разбора разных видов аргументов:

  • parseExpression(): ExpressionNode: разбирает PHP-подобное выражение (переменные, литералы, операторы, вызовы функций и методов и так далее). Он учитывает синтаксический сахар Latte, например трактует простые строки из букв и цифр как строки в кавычках (то есть foo разбирается так, будто это 'foo').
  • parseUnquotedStringOrExpression(): ExpressionNode: разбирает либо обычное выражение, либо строку без кавычек. Строки без кавычек – это последовательности, разрешённые Latte без кавычек, часто используемые, например, для путей к файлам ({include ../file.latte}). Если разобрана строка без кавычек, возвращается StringNode.
  • parseArguments(): ArrayNode: разбирает аргументы через запятую, возможно с ключами, вида 10, name: 'John', true.
  • parseModifier(): ModifierNode: разбирает фильтры вроде |upper|truncate:10.
  • parseType(): ?SuperiorTypeNode: разбирает объявления типов PHP, такие как int, ?string, array|Foo.

Для более сложных или низкоуровневых потребностей разбора вы можете напрямую работать с потоком токенов через $tag->parser->stream. Этот объект предоставляет методы для осмотра и поглощения отдельных токенов:

  • $tag->parser->stream->is(...): bool: проверяет, соответствует ли текущий токен какому-либо из указанных типов (например, Token::Php_Variable) или литеральных значений (например, 'as'), не поглощая его. Удобно для заглядывания вперёд.
  • $tag->parser->stream->consume(...): Token: поглощает текущий токен и сдвигает позицию в потоке. Если в аргументах заданы ожидаемые типы или значения токенов и текущий токен им не соответствует, метод выбрасывает CompileException. Используйте его, когда ожидаете определённый токен.
  • $tag->parser->stream->tryConsume(...): ?Token: пытается поглотить текущий токен, только если он соответствует одному из указанных типов или значений. Если соответствует, поглощает токен и возвращает его. Если нет, оставляет позицию в потоке без изменений и возвращает null. Используйте его для необязательных токенов или при выборе между разными вариантами синтаксиса.

Обновление функции разбора create()

С этим пониманием изменим метод create() в DatetimeNode, чтобы он разбирал необязательный аргумент формата с помощью $tag->parser.

<?php

namespace App\Templating;

use Latte\Compiler\Nodes\Php\ExpressionNode;
use Latte\Compiler\Nodes\Php\Scalar\StringNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;

class DatetimeNode extends StatementNode
{
	// Добавляем публичное свойство для узла разобранного выражения формата
	public ?ExpressionNode $format = null;

	public static function create(Tag $tag): self
	{
		$node = $tag->node = new self;

		// Проверяем, есть ли какие-нибудь токены
		if (!$tag->parser->isEnd()) {
			// Разбираем аргумент как PHP-подобное выражение с помощью TagParser.
			$node->format = $tag->parser->parseExpression();
		}

		return $node;
	}

	// ... методы print() и getIterator() обновим дальше ...
}

Мы добавили публичное свойство $format. В create() мы теперь используем $tag->parser->isEnd(), чтобы проверить, есть ли аргументы. Если есть, $tag->parser->parseExpression() поглощает токены выражения. Поскольку TagParser обязан поглотить все переданные ему токены, Latte автоматически выдаст ошибку, если пользователь напишет что-то неожиданное после выражения формата (например, {datetime 'Y-m-d', unexpected}).

Обновление метода print()

Теперь изменим метод print() так, чтобы он использовал разобранное выражение формата, хранящееся в $this->format. Если формат не указан ($this->format равно null), нам следует использовать строку формата по умолчанию, например 'Y-m-d H:i:s'.

	public function print(PrintContext $context): string
	{
		$formatNode = $this->format ?? new StringNode('Y-m-d H:i:s');

		// %node выводит представление $formatNode в виде PHP-кода.
		return $context->format(
			'echo date(%node) %line;',
			$formatNode,
			$this->position
		);
	}

В переменную $formatNode мы сохраняем узел AST, представляющий строку формата для функции PHP date(). Здесь мы используем оператор объединения с null (??). Если пользователь передал в шаблоне аргумент (например, {datetime 'd.m.Y'}), то свойство $this->format содержит соответствующий узел (в данном случае StringNode со значением 'd.m.Y'), и используется этот узел. Если пользователь аргумент не передал (написал просто {datetime}), свойство $this->format равно null, и вместо этого мы создаём новый StringNode с форматом по умолчанию 'Y-m-d H:i:s'. Так гарантируется, что $formatNode всегда содержит корректный узел AST для формата.

В маске 'echo date(%node) %line;' использован новый заполнитель %node, который говорит методу format() взять первый следующий аргумент (это наш $formatNode), вызвать его метод print() (возвращающий его представление в виде PHP-кода) и вставить результат на место заполнителя.

Реализация getIterator() для подузлов

Теперь у нашего DatetimeNode есть дочерний узел: выражение $format. Мы обязаны сделать этот дочерний узел доступным для проходов компилятора, отдав его в методе getIterator(). Не забудьте отдавать ссылку (&), чтобы проходы могли при необходимости заменить узел.

	public function &getIterator(): \Generator
	{
		if ($this->format) {
			yield $this->format;
		}
	}

Почему это принципиально важно? Представьте проход песочницы, которому нужно проверить, не содержит ли аргумент $format запрещённый вызов функции (например, {datetime dangerousFunction()}). Если getIterator() не отдаст $this->format, проход песочницы никогда не увидит вызов dangerousFunction() внутри аргумента нашего тега, и возникнет потенциальная дыра в безопасности. Отдавая его, мы позволяем песочнице (и другим проходам) исследовать и при необходимости изменять узел выражения $format.

Использование улучшенного тега

Теперь тег правильно обрабатывает необязательный аргумент:

Default format: {datetime}
Custom format: {datetime 'd.m.Y'}
Using variable: {datetime $userDateFormatPreference}

{* Это привело бы к ошибке после разбора 'd.m.Y', потому что ", foo" неожиданно *}
{* {datetime 'd.m.Y', foo} *}

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

Работа с парными тегами

До сих пор наш тег {datetime} был самозакрывающимся (концептуально). У него нет никакого содержимого между открывающим и закрывающим тегами. Однако многие полезные теги работают с блоком содержимого шаблона. Они называются парными тегами. Примеры: {if}...{/if}, {block}...{/block} или тот тег, который мы сейчас построим: {debug}...{/debug}.

Этот тег позволит нам включать в шаблоны отладочные сведения, которые должны быть видны только во время разработки.

Цель: создать парный тег {debug}, содержимое которого отрисовывается только тогда, когда включён определённый флаг „режима разработки“.

Знакомство с провайдерами

Иногда вашим тегам нужен доступ к данным или сервисам, которые не передаются напрямую как параметры шаблона. Например, определение того, находится ли приложение в режиме разработки, доступ к объекту пользователя или получение значений конфигурации. Latte предлагает для этого механизм под названием провайдеры.

Провайдеры регистрируются в вашем расширении методом getProviders(). Этот метод возвращает ассоциативный массив, где ключи – имена, под которыми провайдеры будут доступны в коде времени выполнения шаблона, а значения – сами данные или объекты.

В PHP-коде, сгенерированном методом print() вашего тега, вы затем можете обратиться к этим провайдерам через специальное свойство объекта $this->global. Поскольку это свойство общее для всех расширений, хорошей практикой будет добавлять префикс к именам провайдеров, чтобы избежать возможных столкновений с базовыми провайдерами Latte или провайдерами других сторонних расширений. Принято использовать короткий уникальный префикс, связанный с вашим вендором или именем расширения. Для нашего примера возьмём префикс app, и флаг режима разработки будет доступен как $this->global->appDevMode.

Ключевое слово yield для разбора содержимого

Как сказать парсеру Latte обработать содержимое между {debug} и {/debug}? Вот здесь и вступает в игру ключевое слово yield.

Когда yield используется в функции create(), она становится генератором PHP. Её выполнение приостанавливается, и управление возвращается главному TemplateParser. Затем TemplateParser продолжает разбирать содержимое шаблона до тех пор, пока не встретит соответствующий закрывающий тег (в нашем случае {/debug}).

Как только закрывающий тег найден, TemplateParser возобновляет выполнение нашей функции create() сразу после инструкции yield. Значение, возвращаемое yield, – это массив из двух элементов:

  1. AreaNode, представляющий разобранное содержимое между открывающим и закрывающим тегами.
  2. Объект Tag, представляющий закрывающий тег (например, {/debug}).

Создадим класс DebugNode и его метод create с использованием yield.

<?php

namespace App\Templating;

use Latte\Compiler\Nodes\AreaNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;

class DebugNode extends StatementNode
{
	// Публичное свойство для хранения разобранного внутреннего содержимого
	public AreaNode $content;

	/**
	 * Функция разбора парного тега {debug} ... {/debug}.
	 */
	public static function create(Tag $tag): \Generator // обратите внимание на возвращаемый тип
	{
		$node = $tag->node = new self;

		// Приостанавливаем разбор, получаем внутреннее содержимое и закрывающий тег, когда найден {/debug}
		[$node->content, $endTag] = yield;

		return $node;
	}

	// ... print() и getIterator() реализуем дальше ...
}

Замечание: $endTag равно null, если тег использован как n:атрибут, то есть <div n:debug>...</div>.

Парный тег можно также закрыть слешем, как {debug/} (или <div n:debug/>). Тогда у него нет внутреннего содержимого: генератор получает [$emptyFragmentNode, $startTag], где второй элемент – сам открывающий тег, а не null.

Реализация print() для условной отрисовки

Теперь методу print() нужно сгенерировать PHP-код, который во время выполнения проверяет провайдер appDevMode и выполняет код внутреннего содержимого, только если флаг истинен.

	public function print(PrintContext $context): string
	{
		// Генерируем инструкцию PHP 'if', проверяющую провайдер во время выполнения
		return $context->format(
			<<<'XX'
				if ($this->global->appDevMode) %line {
					// Если режим разработки включён, выводим внутреннее содержимое
					%node
				}

				XX,
			$this->position, // Для комментария %line
			$this->content,  // Узел с AST внутреннего содержимого
		);
	}

Это просто. Мы используем PrintContext::format(), чтобы создать обычную инструкцию PHP if. Внутри if мы ставим заполнитель %node для $this->content. Latte рекурсивно вызовет $this->content->print($context), чтобы сгенерировать PHP-код внутренней части тега, но только если $this->global->appDevMode во время выполнения окажется истинным.

Реализация getIterator() для содержимого

Как и с узлом аргумента в предыдущем примере, у нашего DebugNode теперь есть дочерний узел: AreaNode $content. Мы обязаны сделать его обходимым, отдав в getIterator():

	public function &getIterator(): \Generator
	{
		// Отдаём ссылку на узел содержимого
		yield $this->content;
	}

Это позволяет проходам компилятора спускаться в содержимое нашего тега {debug}, что важно, даже если содержимое отрисовывается по условию. Например, песочнице нужно проанализировать содержимое независимо от того, истинно appDevMode или ложно.

Регистрация и использование

Зарегистрируйте тег и провайдер в своём расширении:

class MyLatteExtension extends Extension
{
	// Предполагаем, что $isDevelopmentMode определяется где-то (например, из конфигурации)
	public function __construct(
		private bool $isDevelopmentMode,
	) {
	}

	public function getTags(): array
	{
		return [
			'datetime' => DatetimeNode::create(...),
			'debug' => DebugNode::create(...), // Регистрируем новый тег
		];
	}

	public function getProviders(): array
	{
		return [
			'appDevMode' => $this->isDevelopmentMode, // Регистрируем провайдер
		];
	}
}

// При регистрации расширения:
$isDev = true; // Определите это по окружению вашего приложения
$latte->addExtension(new MyLatteExtension($isDev));

И используйте его в шаблоне:

<p>Regular content visible always.</p>

{debug}
	<div class="debug-panel">
		Current user ID: {$user->id}
		Request time: {=time()}
	</div>
{/debug}

<p>More regular content.</p>

Интеграция с n:атрибутами

Latte предлагает для многих парных тегов удобное сокращение: n:атрибуты. Если у вас есть парный тег вида {tag}...{/tag} и вы хотите, чтобы его действие относилось прямо к одному HTML-элементу, часто можно записать его короче как атрибут n:tag на этом элементе.

Для большинства определяемых вами обычных парных тегов (как наш {debug}) Latte автоматически включает соответствующую версию с n:. При регистрации ничего дополнительного делать не нужно:

{* Обычное использование парного тега *}
{debug}<div>Debug info</div>{/debug}

{* Равнозначное использование с n:атрибутом *}
<div n:debug>Debug info</div>

Оба варианта отрисуют <div>, только если $this->global->appDevMode истинно. Префиксы inner- и tag- тоже работают как ожидается.

Иногда логика вашего тега должна вести себя немного иначе в зависимости от того, использован ли он как обычный парный тег или как n:атрибут, либо если применён префикс вроде n:inner-tag или n:tag-tag. Объект Latte\Compiler\Tag, передаваемый в вашу функцию разбора create(), даёт эти сведения:

  • $tag->isNAttribute(): bool: возвращает true, если тег разбирается как n:атрибут
  • $tag->prefix: ?string: возвращает префикс, использованный с n:атрибутом; это может быть null (не n:атрибут), Tag::PrefixNone, Tag::PrefixInner или Tag::PrefixTag

Теперь, когда мы разобрались с простыми тегами, разбором аргументов, парными тегами, провайдерами и n:атрибутами, возьмёмся за более сложный сценарий с тегами, вложенными в другие теги, взяв за отправную точку наш тег {debug}.

Промежуточные теги

Некоторые парные теги позволяют или даже требуют, чтобы внутри них перед итоговым закрывающим тегом появлялись другие теги. Они называются промежуточными тегами. Классические примеры: {if}...{elseif}...{else}...{/if} или {switch}...{case}...{default}...{/switch}.

Расширим наш тег {debug}, чтобы он поддерживал необязательную ветвь {else}, которая будет отрисовываться, когда приложение не находится в режиме разработки.

Цель: изменить {debug} так, чтобы он поддерживал необязательный промежуточный тег {else}. Итоговый синтаксис должен быть {debug} ... {else} ... {/debug}.

Разбор промежуточных тегов с помощью yield

Мы уже знаем, что yield приостанавливает функцию разбора create() и возвращает разобранное содержимое вместе с закрывающим тегом. Однако yield даёт больше контроля: вы можете передать ему массив имён промежуточных тегов. Когда парсер встретит любой из указанных тегов на том же уровне вложенности (то есть как прямых потомков родительского тега, а не внутри других блоков или тегов внутри него), он тоже прекратит разбор содержимого.

Когда разбор останавливается из-за промежуточного тега, он прекращает разбирать содержимое, возобновляет генератор create() и передаёт обратно частично разобранное содержимое и сам промежуточный тег (вместо итогового закрывающего). Наша функция create() затем может обработать этот промежуточный тег (например, разобрать его аргументы, если они есть) и снова вызвать yield, чтобы разобрать следующую часть содержимого до итогового закрывающего тега или до другого ожидаемого промежуточного тега.

Изменим DebugNode::create() так, чтобы он ожидал {else}:

<?php

namespace App\Templating;

use Latte\Compiler\Nodes\AreaNode;
use Latte\Compiler\Nodes\NopNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;

class DebugNode extends StatementNode
{
	// Содержимое части {debug}
	public AreaNode $thenContent;
	// Необязательное содержимое части {else}
	public ?AreaNode $elseContent = null;

	public static function create(Tag $tag): \Generator
	{
		$node = $tag->node = new self;

		// yield и ожидание либо {/debug}, либо {else}
		[$node->thenContent, $nextTag] = yield ['else'];

		// Проверяем, был ли тег, на котором мы остановились, тегом {else}
		if ($nextTag?->name === 'else') {
			// Снова yield, чтобы разобрать содержимое между {else} и {/debug}
			[$node->elseContent, $endTag] = yield;
		}

		return $node;
	}

	// ... print() и getIterator() обновим дальше ...
}

Теперь yield ['else'] говорит Latte остановить разбор не только на {/debug}, но и на {else}. Если встретится {else}, $nextTag будет содержать объект Tag для {else}. Затем мы снова вызываем yield без аргументов, то есть теперь ожидаем только итоговый тег {/debug}, и сохраняем результат в $node->elseContent. Если {else} не найден, $nextTag будет Tag для {/debug} (или null, если тег использован как n:атрибут), а $node->elseContent останется null.

Реализация print() с {else}

Метод print() должен отразить новую структуру. Он должен генерировать инструкцию PHP if/else на основе провайдера appDevMode.

	public function print(PrintContext $context): string
	{
		return $context->format(
			<<<'XX'
				if ($this->global->appDevMode) %line {
					%node // Код ветви 'then' (содержимое {debug})
				} else {
					%node // Код ветви 'else' (содержимое {else})
				}

				XX,
			$this->position,    // Номер строки для условия 'if'
			$this->thenContent, // Первый заполнитель %node
			$this->elseContent ?? new NopNode, // Второй заполнитель %node
		);
	}

Это обычная структура PHP if/else. Мы используем %node дважды; format() подставляет переданные узлы по порядку. Мы используем ?? new NopNode, чтобы избежать ошибок, если $this->elseContent равно null: NopNode просто ничего не выводит.

Реализация getIterator() для обоих содержимых

Теперь у нас потенциально два дочерних узла содержимого ($thenContent и $elseContent). Мы обязаны отдать оба, если они есть:

	public function &getIterator(): \Generator
	{
		yield $this->thenContent;
		if ($this->elseContent) {
			yield $this->elseContent;
		}
	}

Использование улучшенного тега

Теперь тег можно использовать с необязательной ветвью {else}:

{debug}
	<p>Showing debug info because devMode is ON.</p>
{else}
	<p>Debug info is hidden because devMode is OFF.</p>
{/debug}

Состояние и вложенность

Наши предыдущие примеры ({datetime}, {debug}) были относительно лишены состояния внутри методов print(). Они либо напрямую выводили содержимое, либо делали простую условную проверку по глобальному провайдеру. Однако многим тегам нужно управлять каким-то состоянием во время отрисовки или вычислять переданные пользователем выражения, которые ради производительности или правильности должны выполниться лишь однажды. Кроме того, нам нужно подумать о том, что происходит, когда наши теги вложены друг в друга.

Проиллюстрируем эти понятия созданием тега {repeat $count}...{/repeat}. Этот тег будет повторять своё внутреннее содержимое $count раз.

Цель: реализовать {repeat $count}, который повторяет своё содержимое указанное число раз.

Зачем нужны временные и уникальные переменные

Представьте, что пользователь пишет:

{repeat rand(1, 5)} Content {/repeat}

Если бы мы наивно сгенерировали в методе print() такой цикл PHP for:

// Упрощённо, НЕПРАВИЛЬНЫЙ сгенерированный код
for ($i = 0; $i < rand(1, 5); $i++) {
	// вывод содержимого
}

Это было бы неверно! Выражение rand(1, 5) вычислялось бы заново на каждой итерации цикла, что привело бы к непредсказуемому числу повторов. Нам нужно вычислить выражение $count один раз до начала цикла и сохранить его результат.

Мы сгенерируем PHP-код, который сначала вычисляет выражение счётчика и сохраняет его во временную переменную времени выполнения. Чтобы избежать столкновений с переменными, определёнными пользователем шаблона, и с внутренними переменными Latte (вроде $ʟ_...), мы возьмём для своих временных переменных соглашение о префиксе $__ (двойное подчёркивание).

Сгенерированный код тогда выглядел бы так:

$__count = rand(1, 5);
for ($__i = 0; $__i < $__count; $__i++) {
	// вывод содержимого
}

Теперь подумаем о вложенности:

{repeat $countA}       {* Внешний цикл *}
	{repeat $countB}   {* Внутренний цикл *}
		...
	{/repeat}
{/repeat}

Если бы и внешний, и внутренний теги {repeat} генерировали код с одинаковыми именами временных переменных (например, $__count и $__i), внутренний цикл перезаписал бы переменные внешнего и сломал логику.

Нам нужно обеспечить, чтобы временные переменные, порождаемые для каждого экземпляра тега {repeat}, были уникальными. Мы добиваемся этого с помощью PrintContext::generateId(). Этот метод возвращает уникальное целое число на этапе компиляции. Мы можем добавить этот идентификатор к именам своих временных переменных.

Так что вместо $__count мы сгенерируем имя с уникальным числовым суффиксом, например $__count_0, и точно так же для счётчика цикла, например $__i_0. Сами числа берутся из общего для всей компиляции счётчика, разделяемого всеми узлами, поэтому они гарантированно уникальны, но не образуют последовательность в пределах одного тега.

Реализация RepeatNode

Создадим класс узла.

<?php

namespace App\Templating;

use Latte\CompileException;
use Latte\Compiler\Nodes\AreaNode;
use Latte\Compiler\Nodes\Php\ExpressionNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;

class RepeatNode extends StatementNode
{
	public ExpressionNode $count;
	public AreaNode $content;

	/**
	 * Функция разбора для {repeat $count} ... {/repeat}
	 */
	public static function create(Tag $tag): \Generator
	{
		$tag->expectArguments(); // убеждаемся, что $count передан
		$node = $tag->node = new self;
		// Разбираем выражение счётчика
		$node->count = $tag->parser->parseExpression();
		// Получаем внутреннее содержимое
		[$node->content] = yield;
		return $node;
	}

	/**
	 * Генерирует цикл PHP 'for' с уникальными именами переменных.
	 */
	public function print(PrintContext $context): string
	{
		// Генерируем уникальные имена переменных
		$id = $context->generateId();
		$countVar = '$__count_' . $id; // уникальное имя, например $__count_0
		$iteratorVar = '$__i_' . $id;  // уникальное имя, например $__i_0

		return $context->format(
			<<<'XX'
				// Вычисляем выражение счётчика *один раз* и сохраняем его
				%raw = (int) (%node);
				// Цикл с сохранённым счётчиком и уникальной переменной итератора
				for (%raw = 0; %2.raw < %0.raw; %2.raw++) %line {
					%node // Отрисовываем внутреннее содержимое
				}

				XX,
			$countVar,          // %0 - Переменная для хранения счётчика
			$this->count,       // %1 - Узел выражения счётчика
			$iteratorVar,       // %2 - Имя переменной итератора цикла
			$this->position,    // %3 - Комментарий с номером строки для самого цикла
			$this->content      // %4 - Узел внутреннего содержимого
		);
	}

	/**
	 * Отдаёт дочерние узлы (выражение счётчика и содержимое).
	 */
	public function &getIterator(): \Generator
	{
		yield $this->count;
		yield $this->content;
	}
}

Метод create() разбирает обязательное выражение $count с помощью parseExpression(). Сначала вызывается $tag->expectArguments(). Это гарантирует, что пользователь передал хоть что-то после {repeat}. Хотя $tag->parser->parseExpression() и так завершился бы ошибкой, если бы ничего не было передано, сообщение об ошибке говорило бы о неожиданном синтаксисе. Использование expectArguments() даёт куда более понятную ошибку, прямо сообщающую, что тегу {repeat} не хватает аргументов.

Метод print() генерирует PHP-код, отвечающий за выполнение логики повторения во время выполнения. Он начинается с генерации уникальных имён временных переменных PHP, которые ему понадобятся.

Метод $context->format() вызывается с новым заполнителем %raw, который вставляет сырую строку, переданную в соответствующем аргументе. Здесь он вставляет уникальное имя переменной, хранящееся в $countVar (например, $__count_1). А что насчёт %0.raw и %2.raw? Это демонстрация позиционных заполнителей. Вместо простого %raw, который берёт следующий доступный сырой аргумент, %2.raw явно берёт аргумент с индексом 2 (это $iteratorVar) и вставляет его сырое строковое значение. Это позволяет нам переиспользовать строку $iteratorVar, не передавая её несколько раз в списке аргументов format().

Этот аккуратно выстроенный вызов format() порождает эффективный и безопасный цикл PHP, который правильно обрабатывает выражение счётчика и избегает столкновений имён переменных даже при вложенных тегах {repeat}.

Регистрация и использование

Зарегистрируйте тег в своём расширении:

use App\Templating\RepeatNode;

class MyLatteExtension extends Extension
{
	public function getTags(): array
	{
		return [
			'datetime' => DatetimeNode::create(...),
			'debug' => DebugNode::create(...),
			'repeat' => RepeatNode::create(...), // Регистрируем тег repeat
		];
	}
}

Используйте его в шаблоне, в том числе с вложенностью:

{var $rows = rand(5, 7)}
{var $cols = rand(3, 5)}

{repeat $rows}
	<tr>
		{repeat $cols}
			<td>Inner loop</td>
		{/repeat}
	</tr>
{/repeat}

Этот пример показывает, как обращаться с состоянием (счётчиками цикла) и возможными проблемами вложенности с помощью временных переменных с префиксом $__, уникальность которым придают идентификаторы из PrintContext::generateId().

Чистые n:атрибуты

Многие n:атрибуты вроде n:if или n:foreach служат удобными сокращениями для соответствующих парных тегов ({if}...{/if}, {foreach}...{/foreach}), но Latte позволяет определять и теги, существующие только в форме n:атрибута. Их часто используют, чтобы изменить атрибуты или поведение HTML-элемента, к которому они прикреплены.

Стандартные примеры, встроенные в Latte, – n:class, помогающий динамически собирать атрибут class, и n:attr, который может задавать несколько произвольных атрибутов.

Создадим собственный чистый n:атрибут: n:confirm, который добавит диалог подтверждения на JavaScript перед выполнением действия (перехода по ссылке или отправки формы).

Цель: реализовать n:confirm="'Are you sure?'", который добавляет обработчик onclick, отменяющий действие по умолчанию, если пользователь откажется в диалоге подтверждения.

Реализация ConfirmNode

Нам нужен класс узла и функция разбора.

<?php

namespace App\Templating;

use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;
use Latte\Compiler\Nodes\Php\ExpressionNode;
use Latte\Compiler\Nodes\Php\Scalar\StringNode;

class ConfirmNode extends StatementNode
{
	public ExpressionNode $message;

	public static function create(Tag $tag): self
	{
		$tag->expectArguments();
		$node = $tag->node = new self;
		$node->message = $tag->parser->parseExpression();
		return $node;
	}

	/**
	 * Генерирует код атрибута 'onclick' с правильным экранированием.
	 */
	public function print(PrintContext $context): string
	{
		// Он обеспечивает правильное экранирование и для JavaScript, и для контекста HTML-атрибута.
		return $context->format(
			<<<'XX'
				echo ' onclick="', LR\HtmlHelpers::escapeAttr('return confirm(' . LR\Helpers::escapeJs(%node) . ')'), '"' %line;
				XX,
			$this->message,
			$this->position,
		);
	}

	public function &getIterator(): \Generator
	{
		yield $this->message;
	}
}

Метод print() генерирует PHP-код, который в конечном счёте выведет HTML-атрибут onclick="..." при отрисовке шаблона. Работа с вложенными контекстами (JavaScript внутри HTML-атрибута) требует аккуратного экранирования. Помощник LR\Helpers::escapeJs(%node) вызывается во время выполнения и правильно экранирует сообщение для использования внутри JavaScript (на выходе получилось бы "Sure?"). Затем помощник LR\HtmlHelpers::escapeAttr(...) экранирует символы, которые особенны внутри HTML-атрибутов, и превращает вывод в return confirm(&quot;Sure?&quot;). Такое двухступенчатое экранирование во время выполнения гарантирует, что сообщение безопасно для JavaScript, а получившийся код JavaScript безопасен для вставки в HTML-атрибут onclick.

Регистрация и использование

Зарегистрируйте n:атрибут в своём расширении. Не забудьте про префикс n: в ключе:

class MyLatteExtension extends Extension
{
	public function getTags(): array
	{
		return [
			'datetime' => DatetimeNode::create(...),
			'debug' => DebugNode::create(...),
			'repeat' => RepeatNode::create(...),
			'n:confirm' => ConfirmNode::create(...), // Регистрируем n:confirm
		];
	}
}

Теперь вы можете использовать n:confirm на ссылках, кнопках или элементах форм:

<a href="delete.php?id=123" n:confirm='"Do you really want to delete item {$id}?"'>Delete</a>

Сгенерированный HTML:

<a href="delete.php?id=123" onclick="return confirm(&quot;Do you really want to delete item 123?&quot;)">Delete</a>

Когда пользователь щёлкнет по ссылке, браузер выполнит код onclick, покажет диалог подтверждения и перейдёт к delete.php, только если пользователь нажмёт „OK“.

Этот пример показывает, как чистый n:атрибут можно создать для изменения поведения или атрибутов своего HTML-элемента, генерируя подходящий PHP-код в методе print(). Помните о двойном экранировании, которое часто требуется: один раз для целевого контекста (в данном случае JavaScript) и ещё раз для контекста HTML-атрибута.

При написании чистых n:атрибутов пригождаются ещё два члена объекта Tag: $tag->htmlElement даёт вам доступ к окружающему HTML-элементу (ElementNode), чтобы вы могли осмотреть или подправить его, а $tag->replaceNAttribute($node) позволяет заменить атрибут собранным вами узлом. Более того, узел, возвращённый из create() чистого n:атрибута, автоматически заменяет атрибут на его элементе.

Продвинутые темы

Предыдущие разделы охватывают основные понятия, а здесь несколько более продвинутых тем, с которыми вы можете столкнуться при создании собственных тегов Latte.

Режимы вывода тега

У объекта Tag, передаваемого в вашу функцию create(), есть свойство outputMode. Оно влияет на то, как Latte обходится с окружающими пробельными символами и отступами, особенно когда тег стоит на строке один. Вы можете изменить это свойство внутри своей функции create().

  • Tag::OutputNone (значение по умолчанию для каждого тега, и именно его сохраняют управляющие конструкции вроде {if} или {foreach}): пробельные символы вокруг тега обрабатываются ровно так же, как при OutputRemoveIndentation – ведущий отступ и один завершающий перевод строки убираются. Настоящее отличие внутреннее: этот режим оставляет парсер шаблонов в режиме „шапки“ шаблона. Он подходит для объявляющих или настроечных тегов вроде {var} или {default}, которые ничего не выводят напрямую.
  • Tag::OutputRemoveIndentation (явно устанавливается блочными тегами {block}, {embed}, {include} и {sandbox}): убирает ведущий отступ перед тегом и один завершающий перевод строки. Это помогает держать сгенерированный PHP-код чище и избегать лишних пустых строк в HTML-выводе, вызванных самим тегом.
  • Tag::OutputKeepIndentation (явно устанавливается выводящими тегами вроде {=...}): Latte старается сохранить отступ перед тегом; переводы строк после тега обычно сохраняются. Это подходит тегам, которые выводят содержимое по месту, – см. пример {datetime} выше, который устанавливает этот режим именно поэтому.

Выбирайте режим, лучше всего отвечающий назначению вашего тега. Поскольку по умолчанию действует OutputNone, тегам управления ходом и объявлений менять ничего не нужно; установите OutputKeepIndentation для тегов, которые выводят содержимое на собственной строке.

Доступ к родительским и ближайшим тегам

Иногда поведение тега должно зависеть от контекста, в котором он используется, а именно от того, внутри каких родительских тегов он находится. Объект Tag, передаваемый в вашу функцию create(), предоставляет для этого метод closestTag(array $classes, ?callable $condition = null): ?Tag.

Этот метод ищет вверх по иерархии открытых в данный момент тегов Latte (цепочке $tag->parent; окружающие HTML-элементы в неё не входят) и возвращает объект Tag ближайшего предка, отвечающего заданным условиям. Если подходящего предка нет, он возвращает null.

Массив $classes указывает, каких предков вы ищете. Проверяется, совпадает ли класс узла, связанного с тегом-предком ($ancestorTag->node), ровно с одним из перечисленных классов; потомки этих классов не подходят.

function create(Tag $tag)
{
	// Ищем ближайший тег-предок, узел которого является экземпляром ForeachNode
	$foreachTag = $tag->closestTag([ForeachNode::class]);
	if ($foreachTag) {
		// Мы можем обратиться к самому экземпляру ForeachNode:
		$foreachNode = $foreachTag->node;
	}
}

Обратите внимание на $foreachTag->node: это работает только потому, что в разработке тегов Latte принято сразу присваивать созданный узел в $tag->node внутри метода create(), как мы всегда и делали.

Иногда одного совпадения по типу узла недостаточно. Вам может понадобиться проверить конкретное свойство потенциального тега-предка или его узла. Необязательный второй аргумент closestTag() – это callable, который получает потенциальный объект Tag предка и должен вернуть, подходит ли он.

function create(Tag $tag)
{
	$dynamicBlockTag = $tag->closestTag(
		[BlockNode::class],
		// Условие: блок должен быть динамическим
		fn(Tag $blockTag) => $blockTag->node->block->isDynamic(),
	);
}

Использование closestTag() позволяет создавать теги, учитывающие контекст и требующие правильного применения в структуре шаблона, что делает шаблоны надёжнее и понятнее.

Заполнители PrintContext::format()

Мы часто использовали PrintContext::format() для генерации PHP-кода в методах print() наших узлов. Он принимает строку-маску и последующие аргументы, заменяющие заполнители в маске. Вот сводка доступных заполнителей:

  • %node: аргументом должен быть экземпляр Node. Вызывает метод print() узла и вставляет получившуюся строку PHP-кода.
  • %dump: аргументом может быть любое значение PHP. Экспортирует значение в корректный PHP-код. Подходит для скаляров, массивов, null.
    • $context->format('echo %dump;', 'Hello')echo 'Hello';
    • $context->format('$arr = %dump;', [1, 2])$arr = [1, 2];
  • %raw: вставляет аргумент прямо в выходной PHP-код без какого-либо экранирования или изменения. Используйте осторожно, в первую очередь для вставки заранее сгенерированных фрагментов PHP-кода или имён переменных.
    • $context->format('%raw = 1;', '$variableName')$variableName = 1;
  • %args: аргументом должен быть Expression\ArrayNode. Выводит элементы массива, отформатированные как аргументы вызова функции или метода (через запятую, с учётом именованных аргументов, если они есть).
    • $argsNode = new ArrayNode([...]);
    • $context->format('myFunc(%args);', $argsNode)myFunc(1, name: 'Joe');
  • %line: аргументом должен быть объект Position (или Range), обычно $this->position. Вставляет комментарий PHP /* pos X:Y */, указывающий строку и столбец в исходнике.
    • $context->format('echo "Hi" %line;', $this->position)echo "Hi" /* pos 42:1 */;
  • %escape(...): генерирует PHP-код, который во время выполнения экранирует внутреннее выражение по текущим правилам контекстно-зависимого экранирования.
    • $context->format('echo %escape(%node);', $variableNode)
  • %modify(...): аргументом должен быть ModifierNode. Генерирует PHP-код, применяющий к внутреннему содержимому фильтры, указанные в ModifierNode, включая контекстно-зависимое экранирование, если оно не отключено через |noescape.
    • $context->format('%modify(%node);', $modifierNode, $variableNode)
  • %modifyContent(...): похож на %modify, но предназначен для изменения блоков захваченного содержимого (часто HTML).

Вы можете явно ссылаться на аргументы по их индексу с нуля: %0.node, %1.dump, %2.raw и так далее. Это позволяет переиспользовать аргумент в маске несколько раз, не передавая его повторно в format(). См. пример тега {repeat}, где использованы %0.raw и %2.raw.

Пример сложного разбора аргументов

parseExpression(), parseArguments() и прочие покрывают много случаев, но иногда нужна более замысловатая логика разбора с использованием более низкоуровневого TokenStream, доступного через $tag->parser->stream.

Цель: создать тег {embedYoutube $videoID, width: 640, height: 480}. Мы хотим разобрать обязательный идентификатор видео (строку или переменную), за которым следуют необязательные пары ключ-значение для размеров.

<?php
namespace App\Templating;

use Latte\CompileException;
use Latte\Compiler\Nodes\Php\ExpressionNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\Tag;
use Latte\Compiler\Token;

class YoutubeNode extends StatementNode
{
	public ExpressionNode $videoId;
	public ?ExpressionNode $width = null;
	public ?ExpressionNode $height = null;

	public static function create(Tag $tag): self
	{
		$tag->expectArguments();
		$node = $tag->node = new self;
		// Разбираем обязательный идентификатор видео
		$node->videoId = $tag->parser->parseExpression();

		// Разбираем необязательные пары ключ-значение
		$stream = $tag->parser->stream; // Получаем поток токенов
		while ($stream->tryConsume(',')) { // Требуется разделение запятыми
			// Ожидаем идентификатор 'width' или 'height'
			$keyToken = $stream->consume(Token::Php_Identifier);
			$key = strtolower($keyToken->text);

			$stream->consume(':'); // Ожидаем разделитель-двоеточие

			$value = $tag->parser->parseExpression(); // Разбираем выражение значения

			if ($key === 'width') {
				$node->width = $value;
			} elseif ($key === 'height') {
				$node->height = $value;
			} else {
				throw new CompileException("Unknown argument '$key'. Expected 'width' or 'height'.", $keyToken->position);
			}
		}

		return $node;
	}

	// ... print() и getIterator() ...
}

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

Использование AuxiliaryNode

Latte предоставляет обобщённые „вспомогательные“ узлы для особых ситуаций при генерации кода или внутри проходов компилятора. Это AuxiliaryNode и Php\Expression\AuxiliaryNode.

Считайте AuxiliaryNode гибким узлом-контейнером, который делегирует свои основные обязанности – генерацию кода и доступ к дочерним узлам – аргументам, переданным в конструктор:

  • Делегирование print(): первый аргумент конструктора – замыкание PHP. Когда Latte вызывает метод print() у AuxiliaryNode, он выполняет это замыкание. Замыкание получает PrintContext и все узлы, переданные во втором аргументе конструктора, что позволяет вам на лету определить совершенно свою логику генерации PHP-кода.
  • Делегирование getIterator(): второй аргумент конструктора – массив объектов Node. Когда Latte нужно обойти потомков AuxiliaryNode (например, во время проходов компилятора), его метод getIterator() просто отдаёт узлы из этого массива.

Пример:

$node = new AuxiliaryNode(
    // 1. Это замыкание становится телом print()
    fn(PrintContext $context, $arg1, $arg2) => $context->format('...%node...%node...', $arg1, $arg2),

    // 2. Эти узлы отдаёт getIterator(), и они же передаются в замыкание выше
    [$argumentNode1, $argumentNode2]
);

Latte предлагает два разных типа в зависимости от того, куда нужно вставить сгенерированный код:

  • Latte\Compiler\Nodes\Php\Expression\AuxiliaryNode: используйте, когда нужно сгенерировать кусок PHP-кода, представляющий выражение
  • Latte\Compiler\Nodes\AuxiliaryNode: используйте для более общих целей, когда нужно вставить блок PHP-кода, представляющий одну или несколько инструкций

Важная причина использовать AuxiliaryNode вместо обычных узлов (вроде StaticMethodCallNode) внутри метода print() или прохода компилятора – управление видимостью для последующих проходов компилятора, особенно связанных с безопасностью, как песочница.

Представьте ситуацию: вашему проходу компилятора нужно обернуть переданное пользователем выражение ($userExpr) вызовом определённой доверенной вспомогательной функции myInternalSanitize($userExpr). Если вы создадите обычный узел new FunctionCallNode('myInternalSanitize', [$userExpr]), он будет полностью виден обходчику AST. Если позже запустится проход песочницы, а myInternalSanitize не будет в её списке разрешённых, песочница может заблокировать или изменить этот вызов и тем самым сломать внутреннюю логику вашего тега, хотя вы, автор тега, знаете, что этот конкретный вызов безопасен и необходим. Поэтому вы можете сгенерировать вызов прямо внутри замыкания AuxiliaryNode.

use Latte\Compiler\Nodes\Php\Expression\AuxiliaryNode;

// ... внутри print() или прохода компилятора ...
$wrappedNode = new AuxiliaryNode(
	fn(PrintContext $context, $userExpr) => $context->format(
		'myInternalSanitize(%node)', // Прямая генерация PHP-кода
		$userExpr,
	),
	// ВАЖНО: исходный узел пользовательского выражения всё равно передайте сюда!
	[$userExpr],
);

В этом случае проход песочницы видит AuxiliaryNode, но не анализирует PHP-код, порождённый его замыканием. Он не может напрямую заблокировать вызов myInternalSanitize, сгенерированный внутри замыкания.

Хотя сам сгенерированный PHP-код скрыт от проходов, входные данные этого кода (узлы, представляющие пользовательские данные или выражения) всё равно должны оставаться обходимыми. Именно поэтому второй аргумент конструктора AuxiliaryNode так важен. Вы обязаны передать массив со всеми исходными узлами (как $userExpr в примере выше), которые использует ваше замыкание. getIterator() у AuxiliaryNode отдаст эти узлы, позволяя проходам вроде песочницы проанализировать их на предмет возможных проблем.

Лучшие практики

  • Ясное назначение: убедитесь, что у вашего тега есть ясное и действительно нужное назначение. Не создавайте теги для задач, которые легко решаются фильтрами или функциями.
  • Правильно реализуйте getIterator(): всегда реализуйте getIterator() и отдавайте ссылки (&) на все дочерние узлы (аргументы, содержимое), разобранные из шаблона. Это необходимо для проходов компилятора, безопасности (песочницы) и возможных будущих оптимизаций.
  • Публичные свойства для узлов: делайте свойства, хранящие дочерние узлы, публичными, чтобы проходы компилятора при необходимости могли их изменять.
  • Используйте PrintContext::format(): применяйте метод format() для генерации PHP-кода. Он заботится о кавычках, правильно экранирует заполнители и автоматически добавляет комментарии с номерами строк.
  • Временные переменные ($__): когда генерируемый код времени выполнения нуждается во временных переменных (например, для промежуточных результатов, счётчиков цикла), используйте соглашение о префиксе $__, чтобы избежать столкновений с переменными пользователя и внутренними переменными Latte $ʟ_.
  • Вложенность и уникальные идентификаторы: если ваш тег может быть вложенным или нуждается в состоянии, привязанном к экземпляру, во время выполнения, используйте $context->generateId() внутри метода print(), чтобы создать уникальные суффиксы для своих временных переменных $__.
  • Провайдеры для внешних данных: используйте провайдеры (зарегистрированные через Extension::getProviders()) для доступа к данным или сервисам времени выполнения ($this->global->…) вместо жёстко прописанных значений или глобального состояния. Добавляйте к именам провайдеров префиксы вендора.
  • Подумайте о n:атрибутах: если ваш парный тег логически работает с одним HTML-элементом, Latte, скорее всего, автоматически поддержит для него n:атрибут. Держите это в уме ради удобства пользователей. Если вы создаёте тег, изменяющий атрибуты, подумайте, не будет ли чистый n:атрибут наиболее подходящей формой.
  • Тестирование: пишите тесты для своих тегов, покрывая и разбор различных вариантов синтаксиса, и правильность вывода сгенерированного PHP-кода.

Следуя этим рекомендациям, вы сможете создавать мощные, надёжные и поддерживаемые пользовательские теги, которые органично встраиваются в движок шаблонов Latte.

Изучение классов узлов, входящих в состав Latte, – лучший способ узнать все тонкости процесса разбора.