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

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

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

Понимание процесса компиляции

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

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

  1. Лексический анализ: Лексер читает исходный код шаблона (файл .latte) и разбивает его на последовательность мелких, отдельных частей, называемых токенами (например, {, foreach, $variable, }, HTML-текст и т.д.).
  2. Парсинг: Парсер берет этот поток токенов и строит из него осмысленную древовидную структуру, представляющую логику и содержимое шаблона. Это дерево называется абстрактным синтаксическим деревом (AST).
  3. Проходы компиляции: Перед генерацией PHP-кода Latte запускает проходы компиляции. Это функции, которые проходят по всему AST и могут его изменять или собирать информацию. Этот шаг ключевой для функций, таких как безопасность (Sandbox) или оптимизация.
  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 и Document Object Model (DOM).

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

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

Latte работает аналогично:

  • В файле .latte шаблона вы пишете теги Latte, такие как {foreach ...} и {/foreach}. Это синтаксис, с которым вы, как автор шаблона, работаете.
  • Когда Latte парсит шаблон, он строит Abstract Syntax Tree (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\TagParser, доступный через $tag->parser (это другой парсер, чем тот, который парсит весь шаблон).
  • Для парных тегов она использует yield для сигнализации Latte о необходимости парсить внутреннее содержимое между начальным и конечным тегами.
  • Конечной целью функции парсинга является создание и возврат экземпляра класса узла, который добавляется в AST.
  • Принято (хотя и не требуется) реализовывать функцию парсинга как статический метод (часто называемый create) непосредственно в соответствующем классе узла. Это позволяет аккуратно упаковать логику парсинга и представление узла, дает доступ к приватным/защищенным членам класса, если необходимо, и улучшает организацию.

Класс узла

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

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

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

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

Теперь мы рассмотрим, как реализовать эти компоненты шаг за шагом.

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

Давайте приступим к созданию вашего первого пользовательского тега Latte. Начнем с очень простого примера: тег с именем {datetime}, который выводит текущую дату и время. Изначально этот тег не будет принимать никаких аргументов, но мы улучшим его позже в разделе “Парсинг аргументов тега”. У него также нет внутреннего содержимого.

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

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

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

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

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

<?php

namespace App\Latte;

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
	{
		// Наш простой тег в настоящее время не принимает аргументов, поэтому нам не нужно ничего парсить
		$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.

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

Свойство $this->position наследуется от базового класса Node и автоматически устанавливается парсером Latte. Оно содержит объект Latte\Compiler\Position, который указывает, где был найден тег в исходном файле .latte.

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

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

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

<?php

namespace App\Latte;

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\Latte\MyLatteExtension);

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

<p>Страница сгенерирована: {datetime}</p>

Ожидаемый вывод: <p>Страница сгенерирована: 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\Latte;

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 выведет представление PHP-кода $formatNode.
		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;
		}
	}

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

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

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

Формат по умолчанию: {datetime}
Пользовательский формат: {datetime 'd.m.Y'}
Использование переменной: {datetime $userDateFormatPreference}

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

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

Обработка парных тегов

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

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

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

Представление поставщиков

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

Поставщики регистрируются в вашем расширении с помощью метода 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\Latte;

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>.

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

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

	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 во время выполнения будет оценено как true.

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

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

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

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

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

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

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 App\Latte\MyLatteExtension($isDev));

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

<p>Обычное содержимое, видимое всегда.</p>

{debug}
	<div class="debug-panel">
		ID текущего пользователя: {$user->id}
		Время запроса: {=time()}
	</div>
{/debug}

<p>Другое обычное содержимое.</p>

Интеграция n:атрибутов

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

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

{* Стандартное использование парного тега *}
{debug}<div>Информация для отладки</div>{/debug}

{* Эквивалентное использование с n:атрибутом *}
<div n:debug>Информация для отладки</div>

Обе версии рендерят <div> только если $this->global->appDevMode равно true. Префиксы 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\Latte;

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, основанную на поставщике devMode.

	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>Отображение отладочной информации, так как devMode ВКЛЮЧЕН.</p>
{else}
	<p>Отладочная информация скрыта, так как devMode ВЫКЛЮЧЕН.</p>
{/debug}

Обработка состояния и вложенности

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

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

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

Необходимость временных и уникальных переменных

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

{repeat rand(1, 5)} Содержимое {/repeat}

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

// Упрощенный, НЕПРАВИЛЬНЫЙ сгенерированный код
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(). Этот метод возвращает уникальное целое число во время фазы компиляции. Мы можем добавить этот ID к именам наших временных переменных.

Так, вместо $__count мы будем генерировать $__count_1 для первого тега repeat, $__count_2 для второго и т.д. Аналогично для счетчика цикла мы будем использовать $__i_1, $__i_2 и т.д.

Реализация RepeatNode

Давайте создадим класс узла.

<?php

namespace App\Latte;

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_1, $__count_2, и т.д.
		$iteratorVar = '$__i_' . $id;  // напр. $__i_1, $__i_2, и т.д.

		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\Latte\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>Внутренний цикл</td>
		{/repeat}
	</tr>
{/repeat}

Этот пример демонстрирует, как обрабатывать состояние (счетчики циклов) и потенциальные проблемы с вложенностью с помощью временных переменных с префиксом $__ и уникальных с ID от 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="'Вы уверены?'", который добавляет обработчик onclick для предотвращения действия по умолчанию, если пользователь отменяет диалоговое окно подтверждения.

Реализация ConfirmNode

Нам нужен класс Node и функция парсинга.

<?php

namespace App\Latte;

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\Filters::escapeHtmlAttr('return confirm(' . LR\Filters::escapeJs(%node) . ')'), '"' %line;
				XX,
			$this->message,
			$this->position,
		);
	}

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

Метод print() генерирует PHP-код, который в конечном итоге во время рендеринга шаблона выводит HTML-атрибут onclick="...". Обработка вложенных контекстов (JavaScript внутри HTML-атрибута) требует тщательного экранирования. Фильтр LR\Filters::escapeJs(%node) вызывается во время выполнения и экранирует сообщение правильно для использования внутри JavaScript (вывод был бы как "Sure?"). Затем фильтр LR\Filters::escapeHtmlAttr(...) экранирует символы, которые являются специальными в 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='"Действительно хотите удалить элемент {$id}?"'>Удалить</a>

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

<a href="delete.php?id=123" onclick="return confirm(&quot;Действительно хотите удалить элемент 123?&quot;)">Удалить</a>

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

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

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

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

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

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

  • Tag::OutputKeepIndentation (По умолчанию для большинства тегов, таких как {=...}): Latte пытается сохранить отступ перед тегом. Новые строки после тега обычно сохраняются. Это подходит для тегов, которые выводят содержимое в строке.
  • Tag::OutputRemoveIndentation (По умолчанию для блочных тегов, таких как {if}, {foreach}): Latte удаляет начальный отступ и потенциально одну следующую новую строку. Это помогает поддерживать чистоту сгенерированного PHP-кода и предотвращает появление дополнительных пустых строк в HTML-выводе, вызванных самим тегом. Используйте это для тегов, представляющих управляющие структуры или блоки, которые сами по себе не должны добавлять пробелы.
  • Tag::OutputNone (Используется тегами, такими как {var}, {default}): Похоже на RemoveIndentation, но сигнализирует более сильно, что сам тег не производит прямого вывода, потенциально влияя на обработку пробелов вокруг него еще более агрессивно. Подходит для декларативных или установочных тегов.

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

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

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

Этот метод ищет вверх по иерархии текущих открытых тегов (включая 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 (обычно $this->position). Вставляет PHP-комментарий /* line X */, указывающий номер строки источника.
    • $context->format('echo "Hi" %line;', $this->position)echo "Hi" /* line 42 */;
  • %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}. Мы хотим распарсить обязательный ID видео (строку или переменную), за которым следуют необязательные пары ключ-значение для размеров.

<?php
namespace App\Latte;

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;
		// Парсинг обязательного ID видео
		$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("Неизвестный аргумент '$key'. Ожидалось 'width' или 'height'.", $keyToken->position);
			}
		}

		return $node;
	}
}

Этот уровень контроля позволяет вам определять очень специфические и комплексные синтаксисы для ваших пользовательских тегов путем прямого взаимодействия с потоком токенов.

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

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

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

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

Пример:

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

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

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

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

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

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

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

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

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

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

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

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

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

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

версия: 3.0