Створення розширення

Розширення – це багаторазово використовуваний клас, який може визначати користувацькі теги, фільтри, функції, провайдери тощо.

Ми створюємо розширення, коли хочемо повторно використати наші налаштування Latte в різних проєктах або поділитися ними з іншими. Також корисно створювати розширення для кожного веб-проєкту, яке міститиме всі специфічні теги та фільтри, які ви хочете використовувати в шаблонах проєкту.

Клас розширення

Extension – це клас, що успадковує від Latte\Extension. Він реєструється в Latte за допомогою addExtension() (або через конфігураційний файл):

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

Якщо ви зареєстрували кілька розширень і вони визначають однаково названі теги, фільтри або функції, перемагає останнє додане розширення. Це також передбачає, що ваші розширення можуть перевизначати власні теги/фільтри/функції.

Щоразу, коли ви вносите зміни в клас і автооновлення не вимкнено, Latte автоматично перекомпілює ваші шаблони.

Клас може реалізовувати будь-який із таких методів:

abstract class Extension
{
	/**
	 * Initializes before template is compiler.
	 */
	public function beforeCompile(Engine $engine): void;

	/**
	 * Returns a list of parsers for Latte tags.
	 * @return array<string, callable>
	 */
	public function getTags(): array;

	/**
	 * Returns a list of compiler passes.
	 * @return array<string, callable>
	 */
	public function getPasses(): array;

	/**
	 * Returns a list of |filters.
	 * @return array<string, callable>
	 */
	public function getFilters(): array;

	/**
	 * Returns a list of functions used in templates.
	 * @return array<string, callable>
	 */
	public function getFunctions(): array;

	/**
	 * Returns a list of providers.
	 * @return array<mixed>
	 */
	public function getProviders(): array;

	/**
	 * Returns a value to distinguish multiple versions of the template.
	 */
	public function getCacheKey(Engine $engine): mixed;

	/**
	 * Initializes before template is rendered.
	 */
	public function beforeRender(Template $template): void;
}

Щоб отримати уявлення про те, як виглядає розширення, подивіться на вбудоване CoreExtension.

beforeCompile(Latte\Engine $engine)void

Викликається перед компіляцією шаблону. Метод може використовуватися, наприклад, для ініціалізації, пов'язаної з компіляцією.

getTags(): array

Викликається під час компіляції шаблону. Повертає асоціативний масив ім'я тега ⇒ callable, які є функціями розбору тегів.

public function getTags(): array
{
	return [
		'foo' => [FooNode::class, 'create'],
		'bar' => [BarNode::class, 'create'],
		'n:baz' => [NBazNode::class, 'create'],
		// ...
	];
}

Тег n:baz являє собою чистий n:attribute, тобто це тег, який може бути записаний тільки як атрибут.

У разі тегів foo і bar Latte автоматично розпізнає, чи є вони парами, і якщо так, то їх можна автоматично записати з використанням n:атрибутів, включно з варіантами з префіксами n:inner-foo і n:tag-foo.

Порядок виконання таких n:атрибутів визначається їхнім порядком у масиві, що повертається getTags(). Таким чином, n:foo завжди виконується перед n:bar, навіть якщо атрибути перераховані у зворотному порядку в HTML-тезі як <div n:bar="..." n:foo="...">.

Якщо вам потрібно визначити порядок виконання n:атрибутів для декількох розширень, використовуйте допоміжний метод order(), де параметр before xor after визначає, які теги будуть упорядковані до або після тега .

public function getTags(): array
{
	return [
		'foo' => self::order([FooNode::class, 'create'], before: 'bar')]
		'bar' => self::order([BarNode::class, 'create'], after: ['block', 'snippet'])]
	];
}

getPasses(): array

Викликається під час компіляції шаблону. Повертає асоціативний масив name pass ⇒ callable, які є функціями, що представляють так звані проходи компілятора, що обходять і змінюють AST.

Знову ж таки, може бути використаний допоміжний метод order(). Значенням параметрів before або after може бути * зі значенням before/after all.

public function getPasses(): array
{
	return [
		'optimize' => [Passes::class, 'optimizePass'],
		'sandbox' => self::order([$this, 'sandboxPass'], before: '*'),
		// ...
	];
}

beforeRender(Latte\Engine $engine)void

Викликається перед кожним рендерингом шаблону. Метод можна використовувати, наприклад, для ініціалізації змінних, що використовуються під час рендерингу.

getFilters(): array

Викликається перед відтворенням шаблону. Повертає фільтри у вигляді асоціативного масиву ім'я фільтра ⇒ фільтр, що викликається.

public function getFilters(): array
{
	return [
		'batch' => [$this, 'batchFilter'],
		'trim' => [$this, 'trimFilter'],
		// ...
	];
}

getFunctions(): array

Викликається перед відтворенням шаблону. Повертає функції у вигляді асоціативного масиву ім'я функції ⇒ callable.

public function getFunctions(): array
{
	return [
		'clamp' => [$this, 'clampFunction'],
		'divisibleBy' => [$this, 'divisibleByFunction'],
		// ...
	];
}

getProviders(): array

Викликається перед відтворенням шаблону. Повертає масив провайдерів, які зазвичай є об'єктами, що використовують теги під час виконання. Доступ до них здійснюється через $this->global->....

public function getProviders(): array
{
	return [
		'myFoo' => $this->foo,
		'myBar' => $this->bar,
		// ...
	];
}

getCacheKey(Latte\Engine $engine)mixed

Викликається перед відтворенням шаблону. Значення, що повертається, стає частиною ключа, хеш якого міститься в імені скомпільованого файлу шаблону. Таким чином, для різних значень, що повертаються, Latte генеруватиме різні файли кешу.

Як працює Latte?

Щоб зрозуміти, як визначити користувацькі теги або передачі компілятора, необхідно зрозуміти, як Latte працює під капотом.

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

  • Спочатку лексор розбиває вихідний код шаблону на невеликі фрагменти (лексеми) для зручнішого опрацювання.
  • Потім парсер перетворює потік лексем в осмислене дерево вузлів (Abstract Syntax Tree, AST).
  • Нарешті, компілятор генерує клас PHP з AST, який відображає шаблон і зберігає його в кеші.

Насправді, компіляція трохи складніша. У Latte є два лексера і парсера: один для HTML-шаблону, інший для PHP-подібного коду всередині тегів. Крім того, парсинг не виконується після токенізації, а лексер і парсер працюють паралельно у двох “потоках” і координуються. Це ракетобудування :-)

Ба більше, усі теги мають свої власні процедури синтаксичного аналізу. Коли парсер зустрічає тег, він викликає свою функцію розбору (вона повертає Extension::getTags()). Її робота полягає в розборі аргументів тега і, в разі парних тегів, внутрішнього вмісту. Вона повертає вузол, який стає частиною AST. Подробиці див. у розділі Функція розбору те гів.

Коли парсер завершує свою роботу, ми отримуємо повний AST, що представляє шаблон. Кореневим вузлом є Latte\Compiler\Nodes\TemplateNode. Окремі вузли всередині дерева представляють не тільки теги, а й елементи HTML, їхні атрибути, будь-які вирази, що використовуються всередині тегів, тощо.

Після цього в гру вступають так звані проходи компілятора, які являють собою функції (повертаються Extension::getPasses()), що змінюють AST.

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

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

Приклад AST

Щоб отримати краще уявлення про AST, ми додамо приклад. Це вихідний шаблон:

{foreach $category->getItems() as $item}
	<li>{$item->name|upper}</li>
	{else}
	no items found
{/foreach}

А це його подання у вигляді AST:

Latte\Compiler\Nodes\TemplateNode(
   Latte\Compiler\Nodes\FragmentNode(
      - Latte\Essential\Nodes\ForeachNode(
           expression: Latte\Compiler\Nodes\Php\Expression\MethodCallNode(
              object: Latte\Compiler\Nodes\Php\Expression\VariableNode('$category')
              name: Latte\Compiler\Nodes\Php\IdentifierNode('getItems')
           )
           value: Latte\Compiler\Nodes\Php\Expression\VariableNode('$item')
           content: Latte\Compiler\Nodes\FragmentNode(
              - Latte\Compiler\Nodes\TextNode('  ')
              - Latte\Compiler\Nodes\Html\ElementNode('li')(
                   content: Latte\Essential\Nodes\PrintNode(
                      expression: Latte\Compiler\Nodes\Php\Expression\PropertyFetchNode(
                         object: Latte\Compiler\Nodes\Php\Expression\VariableNode('$item')
                         name: Latte\Compiler\Nodes\Php\IdentifierNode('name')
                      )
                      modifier: Latte\Compiler\Nodes\Php\ModifierNode(
                         filters:
                            - Latte\Compiler\Nodes\Php\FilterNode('upper')
                      )
                   )
                )
            )
            else: Latte\Compiler\Nodes\FragmentNode(
               - Latte\Compiler\Nodes\TextNode('no items found')
            )
        )
   )
)

Користувацькі теги

Для визначення нового тега необхідно виконати три кроки:

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

Розбір тегів виконується функцією розбору (та, яка повертається функцією Extension::getTags()). Її завдання – розібрати і перевірити всі аргументи всередині тега (для цього вона використовує TagParser). Крім того, якщо тег є парою, вона попросить TemplateParser розібрати і повернути внутрішній вміст. Функція створює і повертає вузол, який зазвичай є дочірнім вузлом Latte\Compiler\Nodes\StatementNode, і він стає частиною AST.

Ми створюємо клас для кожного вузла, що ми зараз і зробимо, і елегантно поміщаємо в нього функцію парсингу у вигляді статичної фабрики. Як приклад спробуємо створити знайомий тег {foreach}:

use Latte\Compiler\Nodes\StatementNode;

class ForeachNode extends StatementNode
{
	// функція розбору, яка поки що просто створює вузол
	public static function create(Latte\Compiler\Tag $tag): self
	{
		$node = $tag->node = new self;
		return $node;
	}

	public function print(Latte\Compiler\PrintContext $context): string
	{
		// код буде додано пізніше
	}

	public function &getIterator(): \Generator
	{
		// код буде додано пізніше
	}
}

Функції парсингу create() передається об'єкт Latte\Compiler\Tag, який несе основну інформацію про тег (чи є він класичним тегом, чи n:attribute, на якому рядку він знаходиться тощо) та здебільшого звертається до об'єкта Latte\Compiler\TagParser в $tag->parser.

Якщо тег повинен мати аргументи, перевірте їх наявність, викликавши $tag->expectArguments(). Для їх розбору доступні методи об'єкта $tag->parser:

  • parseExpression(): ExpressionNode для PHP-подібного виразу (наприклад, 10 + 3).
  • parseUnquotedStringOrExpression(): ExpressionNode для виразу або рядка без лапок
  • parseArguments(): ArrayNode вміст масиву (наприклад, 10, true, foo => bar)
  • parseModifier(): ModifierNode для модифікатора (наприклад, |upper|truncate:10)
  • parseType(): expressionNode для підказки типу (наприклад, int|string або Foo\Bar[])

і низькорівневий Latte\Compiler\TokenStream, що працює безпосередньо з лексемами:

  • $tag->parser->stream->consume(...): Token
  • $tag->parser->stream->tryConsume(...): ?Token

Latte розширює синтаксис PHP невеликими способами, наприклад, додаючи модифікатори, скорочені трійкові оператори або дозволяючи записувати прості літерно-цифрові рядки без лапок. Саме тому ми використовуємо термін PHP-подібний замість PHP. Так, наприклад, метод parseExpression() аналізує foo як 'foo'. Крім того, unquoted-string – це особливий випадок рядка, який також не потребує лапок, але в той самий час не обов'язково має бути буквено-цифровим. Наприклад, це шлях до файлу в тезі {include ../file.latte}. Для його розбору використовується метод parseUnquotedStringOrExpression().

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

Давайте повернемося до тегу {foreach}. У ньому ми очікуємо аргументи виду expression + 'as' + second expression, які ми розбираємо наступним чином:

use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\Nodes\Php\ExpressionNode;
use Latte\Compiler\Nodes\AreaNode;

class ForeachNode extends StatementNode
{
	public ExpressionNode $expression;
	public ExpressionNode $value;

	public static function create(Latte\Compiler\Tag $tag): self
	{
		$tag->expectArguments();
		$node = $tag->node = new self;
		$node->expression = $tag->parser->parseExpression();
		$tag->parser->stream->consume('as');
		$node->value = $parser->parseExpression();
		return $node;
	}
}

Вирази, які ми записали у змінні $expression і $value, являють собою вкладені вузли.

Визначте змінні з підвузлами як публічні, щоб за необхідності їх можна було змінити на наступних етапах обробки. Також необхідно зробити їх доступними для обходу.

Для парних тегів, таких як наш, метод повинен також дозволити TemplateParser розібрати внутрішній вміст тега. Цим займається yield, який повертає пару [внутрішній вміст, кінцевий тег]. Ми зберігаємо внутрішній вміст у змінній $node->content.

public AreaNode $content;

public static function create(Latte\Compiler\Tag $tag): \Generator
{
	// ...
	[$node->content, $endTag] = yield;
	return $node;
}

Ключове слово yield викликає завершення методу create(), повертаючи керування назад у TemplateParser, який продовжує розбір вмісту, поки не досягне кінцевого тега. Потім він передає управління назад методу create(), який продовжує з того місця, на якому зупинився. Використання методу yield, автоматично повертає Generator.

Ви також можете передати в yield масив імен тегів, для яких ви хочете зупинити розбір, якщо вони зустрічаються до кінцевого тега. Це допомагає нам реалізувати {foreach}...{else}...{/foreach} конструкцію. Якщо зустрічається {else}, ми розбираємо вміст після нього в $node->elseContent:

public AreaNode $content;
public ?AreaNode $elseContent = null;

public static function create(Latte\Compiler\Tag $tag): \Generator
{
	// ...
	[$node->content, $nextTag] = yield ['else'];
	if ($nextTag?->name === 'else') {
		[$node->elseContent] = yield;
	}

	return $node;
}

Вузол, що повертається, завершує розбір тега.

Генерація PHP-коду

Кожен вузол повинен реалізувати метод print(). Повертає PHP-код, який рендерить задану частину шаблону (runtime-код). Як параметр йому передається об'єкт Latte\Compiler\PrintContext, який має корисний метод format(), що спрощує складання результуючого коду.

Метод format(string $mask, ...$args) приймає такі заповнювачі в масці:

  • %node друкує Node
  • %dump експортує значення в PHP
  • %raw вставляє текст безпосередньо без будь-яких перетворень
  • %args друкує ArrayNode в якості аргументів виклику функції
  • %line друкує коментар із номером рядка
  • %escape(...) екранує вміст
  • %modify(...) застосовує модифікатор
  • %modifyContent(...) застосовує модифікатор до блоків

Наша функція print() може мати такий вигляд (для простоти ми нехтуємо гілкою else ):

public function print(Latte\Compiler\PrintContext $context): string
{
	return $context->format(
		<<<'XX'
			foreach (%node as %node) %line {
				%node
			}

			XX,
		$this->expression,
		$this->value,
		$this->position,
		$this->content,
	);
}

Змінна $this->position вже визначена класом Latte\Compiler\Node і встановлюється парсером. Вона містить об'єкт Latte\Compiler\Position з позицією тега у вихідному коді у вигляді номера рядка і стовпця.

Код часу виконання може використовувати допоміжні змінні. Щоб уникнути зіткнення зі змінними, використовуваними самим шаблоном, заведено префіксувати їх символами $ʟ__.

Також під час виконання можуть використовуватися довільні значення, які передаються шаблону у вигляді провайдерів за допомогою методу Extension::getProviders(). Доступ до них здійснюється за допомогою $this->global->....

Обхід AST

Для того щоб переглянути дерево AST углиб, необхідно реалізувати метод getIterator(). Це забезпечить доступ до вкладених вузлів:

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

Зверніть увагу, що getIterator() повертає посилання. Саме це дозволяє відвідувачам вузла замінювати окремі вузли іншими вузлами.

Якщо вузол має підвузли, необхідно реалізувати цей метод і зробити доступними всі підвузли. В іншому разі може бути створено пролом у безпеці. Наприклад, режим пісочниці не зможе контролювати підноди і гарантувати, що в них не будуть викликатися недозволені конструкції.

Оскільки ключове слово yield має бути присутнім у тілі методу, навіть якщо в нього немає дочірніх вузлів, запишіть його таким чином:

public function &getIterator(): \Generator
{
	if (false) {
		yield;
	}
}

AuxiliaryNode

Якщо ви створюєте новий тег для Latte, бажано створити для нього спеціальний клас вузла, який буде представляти його в дереві AST (див. клас ForeachNode у прикладі вище). У деяких випадках вам може стати в нагоді тривіальний допоміжний клас вузлів AuxiliaryNode, який дозволяє передавати тіло методу print() і список вузлів, доступних за допомогою методу getIterator(), як параметри конструктора:

// Latte\Compiler\Nodes\Php\Expression\AuxiliaryNode
// or Latte\Compiler\Nodes\AuxiliaryNode

$node = new AuxiliaryNode(
	// body of the print() method:
	fn(PrintContext $context, $argNode) => $context->format('myFunc(%node)', $argNode),
	// nodes accessed via getIterator() and also passed into the print() method:
	[$argNode],
);

Компілятор передає

Паси компілятора – це функції, які змінюють AST або збирають інформацію в них. Вони повертаються методом Extension::getPasses().

Траверсер вузлів

Найпоширенішим способом роботи з AST є використання Latte\Compiler\NodeTraverser:

use Latte\Compiler\Node;
use Latte\Compiler\NodeTraverser;

$ast = (new NodeTraverser)->traverse(
	$ast,
	enter: fn(Node $node) => ...,
	leave: fn(Node $node) => ...,
);

Функція enter (тобто відвідувач) викликається при першій зустрічі з вузлом, до того, як будуть оброблені його підвузли. Функція leave викликається після відвідування всіх підвузлів. Загальним шаблоном є те, що enter використовується для збору деякої інформації, а потім leave виконує модифікації на основі цієї інформації. До моменту виклику leave весь код усередині вузла вже буде відвідано і зібрано необхідну інформацію.

Як модифікувати AST? Найпростіший спосіб – просто змінити властивості вузлів. Другий спосіб – повністю замінити вузол, повернувши новий вузол. Приклад: наступний код змінить усі цілі числа в AST на рядки (наприклад, 42 буде замінено на '42').

use Latte\Compiler\Nodes\Php;

$ast = (new NodeTraverser)->traverse(
	$ast,
	leave: function (Node $node) {
		if ($node instanceof Php\Scalar\IntegerNode) {
            return new Php\Scalar\StringNode((string) $node->value);
        }
	},
);

AST може містити тисячі вузлів, і обхід усіх вузлів може бути повільним. У деяких випадках можна обійтися без повного обходу.

Якщо ви шукаєте всі Html\ElementNode у дереві, ви знаєте, що після перегляду Php\ExpressionNode немає сенсу перевіряти всі його дочірні вузли, тому що HTML не може бути всередині виразів. У цьому випадку ви можете вказати обхіднику не переходити до вузла класу:

$ast = (new NodeTraverser)->traverse(
	$ast,
	enter: function (Node $node) {
		if ($node instanceof Php\ExpressionNode) {
			return NodeTraverser::DontTraverseChildren;
        }
        // ...
	},
);

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

$ast = (new NodeTraverser)->traverse(
	$ast,
	enter: function (Node $node) {
		if ($node instanceof Nodes\ParametersNode) {
			return NodeTraverser::StopTraversal;
        }
        // ...
	},
);

Помічники вузлів

Клас Latte\Compiler\NodeHelpers надає кілька методів, які можуть знайти AST-вузли, що задовольняють певному зворотному виклику тощо. Показано кілька прикладів:

use Latte\Compiler\NodeHelpers;

// знаходить усі вузли HTML-елементів
$elements = NodeHelpers::find($ast, fn(Node $node) => $node instanceof Nodes\Html\ElementNode);

// знаходимо перший текстовий вузол
$text = NodeHelpers::findFirst($ast, fn(Node $node) => $node instanceof Nodes\TextNode);

// перетворює PHP-значення node в дійсне значення
$value = NodeHelpers::toValue($node);

// перетворює статичний текстовий вузол node в рядок
$text = NodeHelpers::toText($node);
версію: 3.0