Tworzenie rozszerzenia

Rozszerzenie to klasa wielokrotnego użytku, która może definiować niestandardowe tagi, filtry, funkcje, dostawców itp.

Tworzymy rozszerzenia, gdy chcemy ponownie użyć naszych dostosowań Latte w różnych projektach lub podzielić się nimi z innymi. Warto również utworzyć rozszerzenie dla każdego projektu internetowego, które będzie zawierało wszystkie konkretne tagi i filtry, które chcesz użyć w szablonach projektu.

Klasa rozszerzona

Extension jest klasą dziedziczącą po Latte\Extension. Jest ona rejestrowana do Latte za pomocą addExtension() (lub pliku konfiguracyjnego):

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

Jeśli zarejestrujesz wiele rozszerzeń i zdefiniują one identycznie nazwane tagi, filtry lub funkcje, wygrywa ostatnio dodane rozszerzenie. Oznacza to również, że twoje rozszerzenia mogą nadpisać natywne tagi / filtry / funkcje.

Za każdym razem, gdy dokonasz zmiany w klasie i autoodświeżanie nie jest wyłączone, Latte automatycznie przekompiluje twoje szablony.

Klasa może implementować dowolną z poniższych metod:

abstract class Extension
{
	/**
	 * Inicializace před kompilací šablony.
	 */
	public function beforeCompile(Engine $engine): void;

	/**
	 * Vrací seznam parserů pro značky Latte.
	 * @return array<string, callable>
	 */
	public function getTags(): array;

	/**
	 * Vrací seznam průchodů kompilátoru.
	 * @return array<string, callable>
	 */
	public function getPasses(): array;

	/**
	 * Vrací seznam |filtrů.
	 * @return array<string, callable>
	 */
	public function getFilters(): array;

	/**
	 * Vrací seznam funkcí použitých v šablonách.
	 * @return array<string, callable>
	 */
	public function getFunctions(): array;

	/**
	 * Vrací seznam providerů.
	 * @return array<mixed>
	 */
	public function getProviders(): array;

	/**
	 * Vrací hodnotu pro rozlišení více verzí šablony.
	 */
	public function getCacheKey(Engine $engine): mixed;

	/**
	 * Inicializace před vykreslením šablony.
	 */
	public function beforeRender(Template $template): void;
}

Aby uzyskać pomysł, jak wygląda rozszerzenie, zobacz wbudowany CoreExtension.

beforeCompile(Latte\Engine $engine)void

Jest on wywoływany przed kompilacją szablonu. Metoda ta może być używana na przykład do inicjalizacji związanych z kompilacją.

getTags(): array

Wywoływany podczas kompilacji szablonu. Zwraca tablicę asocjacyjną nazwa tagu⇒ callable, które są funkcjami parsowania tagów.

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

Znacznik n:baz reprezentuje czysty n:atrybut, czyli jest to znacznik, który może być zapisany tylko jako atrybut.

W przypadku znaczników foo i bar, Latte automatycznie rozpozna, czy są one sparowane, a jeśli tak, to automatycznie zapisze je z użyciem n:attributes, w tym warianty z przedrostkami n:inner-foo i n:tag-foo.

O kolejności wykonania takich n:atrybutów decyduje ich kolejność w polu zwracanym przez getTags(). Tak więc, n:foo jest zawsze wykonywany przed n:bar, nawet jeśli atrybuty w znaczniku HTML są wymienione w odwrotnej kolejności jako <div n:bar="..." n:foo="...">.

Jeśli musisz określić kolejność n:atrybutów w wielu rozszerzeniach, użyj metody pomocniczej order(), gdzie parametr before lub after określa przed lub po jakich znacznikach znacznik jest uporządkowany.

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

getPasses(): array

Wywoływany podczas kompilacji szablonu. Zwraca tablicę asocjacyjną name pass ⇒ callable, która jest funkcją reprezentującą tzw. przejścia kompilatora, które przemierzają i modyfikują AST.

Również w tym przypadku można zastosować metodę pomocniczą order(). Wartość parametrów before lub after może być '*' ze znaczeniem before/after all.

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

beforeRender(Latte\Engine $engine)void

Jest on wywoływany przed każdym renderowaniem szablonu. Metoda może być wykorzystana np. do inicjalizacji zmiennych używanych podczas renderowania.

getFilters(): array

Jest on wywoływany przed wyrenderowaniem szablonu. Zwraca filtry jako tablicę asocjacyjną nazwa filtra ⇒ callable.

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

getFunctions(): array

Wywoływany przed wyrenderowaniem szablonu. Zwraca funkcję jako tablicę asocjacyjną nazwa funkcji ⇒ callable.

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

getProviders(): array

Wywoływany przed wyrenderowaniem szablonu. Zwraca tablicę dostawców, które są zwykle obiektami, które używają tagów w czasie rzeczywistym. Dostęp do nich uzyskuje się poprzez stronę $this->global->....

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

getCacheKey(Latte\Engine $engine)mixed

Jest on wywoływany przed wyrenderowaniem szablonu. Zwracana wartość staje się częścią klucza, którego hash zawarty jest w nazwie skompilowanego pliku szablonu. Tak więc dla różnych wartości zwrotnych Latte wygeneruje różne pliki pamięci podręcznej.

Jak działa Latte?

Aby zrozumieć, jak zdefiniować niestandardowe tagi lub przejścia kompilatora, konieczne jest zrozumienie, jak Latte działa pod maską.

Kompilacja szablonów w Latte jest uproszczona w następujący sposób:

  • Najpierw lexer tokenizuje kod źródłowy szablonu na małe części (tokeny) dla łatwiejszego przetwarzania.
  • Następnie parser przekształca strumień tokenów w sensowne drzewo węzłów (abstrakcyjne drzewo składniowe, AST).
  • Na koniec kompilator generuje klasę PHP z AST, która renderuje szablon i buforuje go.

W rzeczywistości kompilacja jest nieco bardziej skomplikowana. Latte posiada dwa lexery i parsery: jeden dla szablonu HTML, a drugi dla kodu podobnego do PHP wewnątrz znaczników. Również parsowanie nie jest uruchamiane po tokenizacji, ale lexer i parser działają równolegle w dwóch “wątkach” i koordynują. To rocket science :-)

Ponadto wszystkie tagi mają swoje własne procedury parsowania. Gdy parser napotka znacznik, wywołuje swoją funkcję parsującą (zwraca ona Extension::getTags()). Ich zadaniem jest parsowanie argumentów znaczników oraz, w przypadku znaczników sparowanych, wewnętrznej treści. Zwraca węzeł, który staje się częścią AST. Zobacz sekcję Funkcje parsowania znaczników, aby uzyskać szczegółowe informacje.

Kiedy parser zakończy swoją pracę, mamy kompletny AST reprezentujący szablon. Węzeł główny to Latte\Compiler\Nodes\TemplateNode. Poszczególne węzły wewnątrz drzewa reprezentują więc nie tylko znaczniki, ale także elementy HTML, ich atrybuty, wszelkie wyrażenia użyte wewnątrz znaczników itd.

Następnie przychodzą tzw. Compiler Passes, czyli funkcje (zwracane przez Extension::getPasses()), które modyfikują AST.

Cały proces, od ładowania zawartości szablonu, przez parsowanie, po generowanie ostatecznego pliku, może być sekwencjonowany za pomocą tego kodu, z którym możesz eksperymentować i zrzucać pośrednie kroki:

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

Przykład AST

Aby lepiej zapoznać się z formą AST, dodajemy próbkę. To jest szablon źródłowy:

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

I to jest jego reprezentacja w postaci 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')
            )
        )
   )
)

Tagi własne

Do zdefiniowania nowego znacznika wymagane są trzy kroki:

Funkcja parsowania znaczników

Parsowanie tagów jest obsługiwane przez funkcję parsującą (tę zwróconą przez Extension::getTags()). Jej zadaniem jest parsowanie i sprawdzenie, czy wewnątrz tagu nie ma żadnych argumentów (wykorzystuje do tego TagParser). Ponadto, jeśli tag jest parą, poprosi TemplateParser o parsowanie i zwrócenie wewnętrznej zawartości. Funkcja tworzy i zwraca węzeł, który zwykle jest dzieckiem Latte\Compiler\Nodes\StatementNode, a ten staje się częścią AST.

Tworzymy klasę dla każdego węzła, co teraz zrobimy, i zgrabnie umieszczamy w niej funkcję parsowania jako statyczną fabrykę. Jako przykład spróbujmy stworzyć znany nam już znacznik {foreach}:

use Latte\Compiler\Nodes\StatementNode;

class ForeachNode extends StatementNode
{
	// funkcja parsowania, która na razie tworzy tylko węzeł
	public static function create(Latte\Compiler\Tag $tag): self
	{
		$node = $tag->node = new self;
		return $node;
	}

	public function print(Latte\Compiler\PrintContext $context): string
	{
		// kod, który zostanie dodany później
	}

	public function &getIterator(): \Generator
	{
		// kod, który zostanie dodany później
	}
}

Funkcji parsującej create() przekazywany jest obiekt Latte\Compiler\Tag, który przenosi podstawowe informacje o znaczniku (czy jest to klasyczny znacznik, czy n:atrybut, w jakiej linii się znajduje itp.), a przede wszystkim udostępnia Latte\Compiler\TagParser w $tag->parser.

Jeśli znacznik musi mieć argumenty, sprawdzamy ich istnienie wywołując $tag->expectArguments(). Do ich parsowania dostępne są metody obiektu $tag->parser:

  • parseExpression(): ExpressionNode dla wyrażenia podobnego do PHP (np. 10 + 3)
  • parseUnquotedStringOrExpression(): ExpressionNode dla wyrażenia lub unquoted-string.
  • parseArguments(): ArrayNode dla zawartości tablicy (np. 10, true, foo => bar)
  • parseModifier(): ModifierNode dla modyfikatora (np. |upper|truncate:10)
  • parseType(): ExpressionNode dla typehint (np. int|string lub Foo\Bar[])

a następnie niskopoziomowy Latte\Compiler\TokenStream działający bezpośrednio na tokenach:

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

Latte rozszerza składnię PHP w drobny sposób, na przykład dodając modyfikatory, skrócone operatory trójskładnikowe lub pozwalając na pisanie prostych ciągów alfanumerycznych bez cudzysłowów. Dlatego właśnie używamy określenia PHP-like zamiast PHP. W ten sposób metoda parseExpression() parsuje np. foo jako 'foo'. Ponadto unquoted-string jest specjalnym przypadkiem ciągu znaków, który również nie musi być cytowany, ale jednocześnie nie musi być alfanumeryczny. Na przykład ścieżka do pliku w znaczniku {include ../file.latte}. Do jego parsowania używana jest metoda parseUnquotedStringOrExpression().

Studiowanie klas węzłów, które są częścią Latte, jest najlepszym sposobem na poznanie wszystkich szczegółów procesu parsowania.

Wróćmy do znacznika {foreach}. W nim oczekujemy argumentów o postaci výraz + 'as' + druhý výraz i parsujemy je w następujący sposób:

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;
	}
}

Wyrażenia, które wpisaliśmy do zmiennych $expression i $value, reprezentują węzły podrzędne.

Definiuj zmienne z węzłami podrzędnymi jako publiczne, aby można je było modyfikować w kolejnych krokach przetwarzania. Jednocześnie muszą być one udostępnione do przeglądania.

Dla sparowanych tagów, takich jak nasze, metoda musi nadal pozwalać TemplateParser parsować wnętrze tagu. Zajmuje się tym yield, który zwraca parę [zawartość wewnętrzna, tag końcowy]. Zawartość wewnętrzną przechowujemy w zmiennej $node->content.

public AreaNode $content;

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

Słowo kluczowe yield powoduje przerwanie metody create(), kierując ją z powrotem do TemplateParser, który kontynuuje parsowanie treści aż do trafienia na znacznik end. Następnie przekazuje kontrolę z powrotem do create(), która kontynuuje od miejsca, w którym się skończyła. Użycie metody yield automatycznie zwraca Generator.

Możesz również przekazać tablicę nazw tagów do yield, aby zatrzymać przetwarzanie, jeśli wystąpią one przed tagiem końcowym. Dzięki temu będziemy mogli zaimplementować konstrukcję {foreach}...{else}...{/foreach}. Jeśli pojawia się {else}, to treść po nim parsujemy na $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;
}

Zwrócenie węzła kończy parsowanie znacznika.

Generowanie kodu PHP

Każdy węzeł musi implementować metodę print(). Zwraca ona kod PHP renderujący podany fragment szablonu (kod runtime). Jako parametr przekazywany jest obiekt Latte\Compiler\PrintContext, który posiada przydatną metodę format() upraszczającą kompilację kodu wynikowego.

Metoda format(string $mask, ...$args) akceptuje w masce następujące placeholdery:

  • %node wymienia węzeł
  • %dump eksportuje wartość do PHP
  • %raw wstawia tekst bezpośrednio bez żadnych przekształceń
  • %args wypisuje ArrayNode jako argumenty do wywołania funkcji
  • %line wypisuje komentarz z numerem linii
  • %escape(...) ucieka z treści
  • %modify(...) stosuje modyfikator
  • %modifyContent(...) stosuje modyfikator dla bloków

Nasza funkcja print() może wyglądać tak (dla uproszczenia zaniedbujemy gałąź 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,
	);
}

Zmienna $this->position jest już zdefiniowana przez klasę Latte\Compiler\Node i ustawiona przez parser. Zawiera obiekt Latte\Compiler\Position z pozycją znacznika w kodzie źródłowym w postaci numeru wiersza i kolumny.

Kod runtime może używać zmiennych pomocniczych. Aby uniknąć kolizji ze zmiennymi używanymi przez sam szablon, zwyczajowo poprzedzamy je znakiem $ʟ__.

Może również używać dowolnych wartości w czasie runtime, które przekazuje do szablonu w postaci tzw. providerów za pomocą metody Extension::getProviders(). Dostęp do nich uzyskuje się za pomocą $this->global->....

Przeglądanie AST

W celu dogłębnego przeglądania drzewa AST konieczne jest zaimplementowanie metody getIterator():

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

Zauważ, że getIterator() zwraca referencje. Dzięki temu odwiedzający węzły mogą zastępować poszczególne węzły innymi.

Jeśli węzeł ma subnody, konieczne jest zaimplementowanie tej metody i udostępnienie wszystkich subnodów. W przeciwnym razie może powstać dziura w zabezpieczeniach. Na przykład tryb piaskownicy nie byłby w stanie sprawdzić węzłów podrzędnych i zapewnić, że nie są na nich wywoływane nieautoryzowane konstrukcje.

Ponieważ słowo kluczowe yield musi być obecne w ciele metody, nawet jeśli nie ma węzłów dzieci, napisz to w następujący sposób:

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

AuxiliaryNode

Jeśli tworzysz nowy tag dla Latte, zaleca się utworzenie dla niego dedykowanej klasy węzła, która będzie reprezentować go w drzewie AST (patrz klasa ForeachNode w powyższym przykładzie). W niektórych przypadkach przydatna może okazać się trywialna klasa węzła pomocniczego AuxiliaryNode, która pozwala przekazać ciało metody print() i listę węzłów udostępnionych przez metodę getIterator() jako parametry konstruktora:

// 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],
);

Kompilator przechodzi

Przejścia kompilatora to funkcje, które modyfikują AST lub zbierają w nich informacje. Są one zwracane przez metodę Extension::getPasses().

Przeszukiwanie węzłów

Najczęstszym sposobem pracy z AST jest użycie Latte\Compiler\NodeTraverser:

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

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

Funkcja enter (tj. node visitor) jest wywoływana przy pierwszym napotkaniu węzła, zanim zostaną przetworzone jego podwęzły. Funkcja leave jest wywoływana po odwiedzeniu wszystkich węzłów podrzędnych. Powszechną praktyką jest to, że funkcja enter służy do zebrania pewnych informacji, a następnie funkcja leave dokonuje korekt na podstawie tych informacji. Do czasu wywołania funkcji leave, cały kod wewnątrz węzła zostanie odwiedzony i zebrane zostaną niezbędne informacje.

Jak zmodyfikować AST? Najprostszym sposobem jest po prostu zmodyfikowanie właściwości węzłów. Drugim sposobem jest całkowite zastąpienie węzła poprzez zwrócenie nowego węzła. Przykład: poniższy kod zmieni wszystkie liczby całkowite w AST na łańcuchy (np. 42 zostanie zmienione na '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);
        }
	},
);

Moduł AST może z łatwością zawierać tysiące węzłów, a przemierzanie ich wszystkich może być powolne. W niektórych przypadkach można uniknąć całkowitego traversal.

Jeśli przeszukasz drzewo dla wszystkich węzłów Html\ElementNode, to wiesz, że gdy zobaczysz węzeł Php\ExpressionNode, nie ma sensu sprawdzać również wszystkich jego węzłów dziecięcych, ponieważ HTML nie może być wewnątrz wyrażeń. W tym przypadku możesz powiedzieć traverserowi, aby nie wykonywał rekurencji do węzła klasy:

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

Jeśli szukasz tylko jednego konkretnego węzła, możliwe jest również całkowite przerwanie traversal po znalezieniu go.

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

Pomocnicy dla węzłów

Klasa Latte\Compiler\NodeHelpers zapewnia pewne metody, które mogą znaleźć węzły AST, które albo spełniają określony warunek, itp. Kilka przykładów:

use Latte\Compiler\NodeHelpers;

// znajduje wszystkie węzły elementów HTML
$elements = NodeHelpers::find($ast, fn(Node $node) => $node instanceof Nodes\Html\ElementNode);

// znajduje pierwszy węzeł tekstowy
$text = NodeHelpers::findFirst($ast, fn(Node $node) => $node instanceof Nodes\TextNode);

// konwertuje węzeł PHP na wartość rzeczywistą
$value = NodeHelpers::toValue($node);

// konwertuje statyczny węzeł tekstowy na ciąg znaków
$text = NodeHelpers::toText($node);
wersja: 3.0