Erstellen einer Erweiterung

Eine Erweiterung ist eine wiederverwendbare Klasse, die benutzerdefinierte Tags, Filter, Funktionen, Anbieter usw. definieren kann.

Wir erstellen Erweiterungen, wenn wir unsere Latte-Anpassungen in verschiedenen Projekten wiederverwenden oder sie mit anderen teilen wollen. Es ist auch nützlich, für jedes Webprojekt eine Erweiterung zu erstellen, die alle spezifischen Tags und Filter enthält, die Sie in den Projektvorlagen verwenden möchten.

Erweiterung Klasse

Extension ist eine Klasse, die von Latte\Extension erbt. Sie wird bei Latte mit addExtension() (oder über eine Konfigurationsdatei) registriert:

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

Wenn Sie mehrere Erweiterungen registrieren und diese identisch benannte Tags, Filter oder Funktionen definieren, gewinnt die zuletzt hinzugefügte Erweiterung. Dies bedeutet auch, dass Ihre Erweiterungen native Tags/Filter/Funktionen außer Kraft setzen können.

Immer wenn Sie eine Klasse ändern und die automatische Aktualisierung nicht ausgeschaltet ist, kompiliert Latte Ihre Vorlagen automatisch neu.

Eine Klasse kann jede der folgenden Methoden implementieren:

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

Um eine Vorstellung davon zu bekommen, wie die Erweiterung aussieht, sehen Sie sich die eingebaute CoreExtension an.

beforeCompile (Latte\Engine $engine)void

Wird aufgerufen, bevor die Vorlage kompiliert wird. Die Methode kann z. B. für kompilierungsbezogene Initialisierungen verwendet werden.

getTags(): array

Wird aufgerufen, wenn die Vorlage kompiliert wird. Gibt ein assoziatives Array Tagname ⇒ callable zurück, das Tag-Parsing-Funktionen enthält.

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

Das Tag n:baz stellt ein reines n:Attribut dar, d. h. es ist ein Tag, das nur als Attribut geschrieben werden kann.

Im Falle der Tags foo und bar erkennt Latte automatisch, ob es sich um Paare handelt, und wenn ja, können sie automatisch mit n:Attributen geschrieben werden, einschließlich der Varianten mit den Präfixen n:inner-foo und n:tag-foo.

Die Reihenfolge der Ausführung solcher n:Attribute wird durch ihre Reihenfolge in dem von getTags() zurückgegebenen Array bestimmt. So wird n:foo immer vor n:bar ausgeführt, auch wenn die Attribute im HTML-Tag in umgekehrter Reihenfolge aufgeführt sind als <div n:bar="..." n:foo="...">.

Wenn Sie die Reihenfolge von n:Attributen über mehrere Erweiterungen hinweg bestimmen müssen, verwenden Sie die Hilfsmethode order(), bei der der Parameter before xor after bestimmt, welche Tags vor oder nach dem Tag angeordnet werden.

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

getPasses(): array

Sie wird aufgerufen, wenn die Vorlage kompiliert wird. Gibt ein assoziatives Array Name pass ⇒ callable zurück, das Funktionen repräsentiert, die sogenannte Compiler-Passes darstellen, die den AST durchlaufen und verändern.

Auch hier kann die Hilfsmethode order() verwendet werden. Der Wert der Parameter before oder after kann * mit der Bedeutung before/after all sein.

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

beforeRender (Latte\Engine $engine)void

Sie wird vor jedem Rendering einer Vorlage aufgerufen. Die Methode kann z. B. dazu verwendet werden, Variablen zu initialisieren, die während des Renderings verwendet werden.

getFilters(): array

Sie wird aufgerufen, bevor die Vorlage gerendert wird. Gibt Filter als assoziatives Array zurück Filtername ⇒ aufrufbar.

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

getFunctions(): array

Wird aufgerufen, bevor die Vorlage gerendert wird. Gibt Funktionen als assoziatives Array zurück Funktionsname ⇒ aufrufbar.

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

getProviders(): array

Sie wird aufgerufen, bevor die Vorlage gerendert wird. Gibt ein Array von Anbietern zurück, bei denen es sich in der Regel um Objekte handelt, die Tags zur Laufzeit verwenden. Auf sie wird über $this->global->... zugegriffen.

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

getCacheKey (Latte\Engine $engine)mixed

Sie wird aufgerufen, bevor die Vorlage gerendert wird. Der Rückgabewert wird Teil des Schlüssels, dessen Hash im Namen der kompilierten Vorlagendatei enthalten ist. Für unterschiedliche Rückgabewerte erzeugt Latte also unterschiedliche Cache-Dateien.

Wie funktioniert Latte?

Um zu verstehen, wie man benutzerdefinierte Tags oder Compilerübergänge definiert, ist es wichtig zu verstehen, wie Latte unter der Haube funktioniert.

Die Kompilierung von Vorlagen in Latte funktioniert vereinfacht gesagt wie folgt:

  • Zuerst zerlegt der Lexer den Quellcode der Vorlage in kleine Stücke (Token), um die Verarbeitung zu erleichtern.
  • Dann wandelt der Parser den Token-Strom in einen sinnvollen Baum von Knoten um (den Abstract Syntax Tree, AST)
  • Schließlich generiert der Compiler aus dem AST eine PHP-Klasse, die die Vorlage wiedergibt und im Zwischenspeicher ablegt.

Eigentlich ist die Kompilierung ein bisschen komplizierter. Latte hat zwei Lexer und Parser: einen für die HTML-Vorlage und einen für den PHP-ähnlichen Code innerhalb der Tags. Außerdem läuft das Parsen nicht nach der Tokenisierung, sondern der Lexer und der Parser laufen parallel in zwei “Threads” und koordinieren sich. Das ist Raketentechnik :-)

Außerdem haben alle Tags ihre eigenen Parsing-Routinen. Wenn der Parser auf ein Tag stößt, ruft er seine Parsing-Funktion auf (sie liefert Extension::getTags()). Ihre Aufgabe ist es, die Tag-Argumente und, im Falle von gepaarten Tags, den inneren Inhalt zu analysieren. Sie gibt einen Knoten zurück, der Teil des AST wird. Siehe Tag-Parsing-Funktion für Details.

Wenn der Parser seine Arbeit beendet hat, haben wir einen vollständigen AST, der die Vorlage repräsentiert. Der Wurzelknoten ist Latte\Compiler\Nodes\TemplateNode. Die einzelnen Knoten innerhalb des Baums repräsentieren dann nicht nur die Tags, sondern auch die HTML-Elemente, ihre Attribute, alle Ausdrücke, die innerhalb der Tags verwendet werden, usw.

Danach kommen die so genannten Compiler-Passes ins Spiel, d. h. Funktionen (die von Extension::getPasses() zurückgegeben werden), die den AST verändern.

Der gesamte Prozess, vom Laden des Vorlageninhalts über das Parsen bis hin zur Erzeugung der resultierenden Datei, lässt sich mit diesem Code abbilden, mit dem Sie experimentieren und die Zwischenergebnisse ausgeben können:

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

Beispiel für AST

Um eine bessere Vorstellung vom AST zu bekommen, fügen wir ein Beispiel hinzu. Dies ist die Quelltextvorlage:

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

Und dies ist ihre Darstellung in Form von 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')
            )
        )
   )
)

Benutzerdefinierte Tags

Zur Definition eines neuen Tags sind drei Schritte erforderlich:

Tag-Parsing-Funktion

Das Parsen von Tags wird von der Parsing-Funktion durchgeführt (die von Extension::getTags() zurückgegeben wird). Ihre Aufgabe ist es, alle Argumente innerhalb des Tags zu analysieren und zu überprüfen (sie verwendet TagParser, um dies zu tun). Wenn das Tag ein Paar ist, bittet sie außerdem TemplateParser, den inneren Inhalt zu analysieren und zurückzugeben. Die Funktion erstellt und gibt einen Knoten zurück, der in der Regel ein Kind von Latte\Compiler\Nodes\StatementNode ist und Teil des AST wird.

Wir erstellen eine Klasse für jeden Knoten, was wir jetzt tun werden, und platzieren die Parsing-Funktion elegant als statische Factory in dieser Klasse. Versuchen wir als Beispiel, den bekannten Tag {foreach} zu erstellen:

use Latte\Compiler\Nodes\StatementNode;

class ForeachNode extends StatementNode
{
	// eine Parsing-Funktion, die vorerst nur einen Knoten erstellt
	public static function create(Latte\Compiler\Tag $tag): self
	{
		$node = $tag->node = new self;
		return $node;
	}

	public function print(Latte\Compiler\PrintContext $context): string
	{
		// Code wird später hinzugefügt
	}

	public function &getIterator(): \Generator
	{
		// Code wird später hinzugefügt
	}
}

Der Parsing-Funktion create() wird ein Objekt Latte\Compiler\Tag übergeben, das grundlegende Informationen über das Tag enthält (ob es sich um ein klassisches Tag oder ein n:-Attribut handelt, in welcher Zeile es sich befindet usw.) und hauptsächlich auf das Latte\Compiler\TagParser in $tag->parser zugreift.

Wenn das Tag Argumente haben muss, prüfen Sie deren Vorhandensein, indem Sie $tag->expectArguments() aufrufen. Zum Parsen stehen die Methoden des $tag->parser Objekts zur Verfügung:

  • parseExpression(): ExpressionNode für einen PHP-ähnlichen Ausdruck (z. B. 10 + 3)
  • parseUnquotedStringOrExpression(): ExpressionNode für einen Ausdruck oder eine unquoted-string
  • parseArguments(): ArrayNode für den Inhalt des Arrays (z.B. 10, true, foo => bar)
  • parseModifier(): ModifierNode für einen Modifikator (z. B. |upper|truncate:10)
  • parseType(): expressionNode für einen Typ-Hinweis (z. B. int|string oder Foo\Bar[])

und ein low-level Latte\Compiler\TokenStream, das direkt mit Token arbeitet:

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

Latte erweitert die PHP-Syntax in kleinen Schritten, z.B. durch Hinzufügen von Modifikatoren, verkürzten ternären Operatoren oder der Möglichkeit, einfache alphanumerische Zeichenketten ohne Anführungszeichen zu schreiben. Aus diesem Grund verwenden wir den Begriff PHP-ähnlich anstelle von PHP. Die Methode parseExpression() analysiert also beispielsweise foo als 'foo'. Darüber hinaus ist unquoted-string ein Spezialfall einer Zeichenkette, die ebenfalls nicht in Anführungszeichen gesetzt werden muss, aber gleichzeitig nicht alphanumerisch sein muss. Dies ist zum Beispiel der Pfad zu einer Datei im Tag {include ../file.latte}. Die Methode parseUnquotedStringOrExpression() wird verwendet, um ihn zu parsen.

Das Studium der Knotenklassen, die Teil von Latte sind, ist der beste Weg, um alle Feinheiten des Parsing-Prozesses zu lernen.

Kehren wir zum Tag {foreach} zurück. In diesem Tag erwarten wir Argumente der Form expression + 'as' + second expression, die wir wie folgt parsen:

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

Die Ausdrücke, die wir in die Variablen $expression und $value geschrieben haben, stellen Unterknoten dar.

Definieren Sie Variablen mit Unterknoten als öffentlich, damit sie bei Bedarf in weiteren Verarbeitungsschritten geändert werden können. Außerdem ist es notwendig, sie für die Durchquerungverfügbar zu machen.

Bei gepaarten Tags, wie dem unseren, muss die Methode auch den TemplateParser den inneren Inhalt des Tags parsen lassen. Dies wird von yield erledigt, das ein Paar [innerer Inhalt, End-Tag] zurückgibt. Wir speichern den inneren Inhalt in der Variablen $node->content.

public AreaNode $content;

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

Mit dem Schlüsselwort yield wird die Methode create() beendet und die Kontrolle an den TemplateParser zurückgegeben, der das Parsen des Inhalts fortsetzt, bis er auf das End-Tag stößt. Dann übergibt er die Kontrolle zurück an create(), der dort weitermacht, wo er aufgehört hat. Die Verwendung der Methode yield gibt automatisch Generator zurück.

Sie können auch ein Array von Tag-Namen an yield übergeben, für die Sie das Parsen stoppen möchten, wenn sie vor dem End-Tag auftreten. Dies hilft uns bei der Implementierung des {foreach}...{else}...{/foreach} Konstrukts. Wenn {else} auftritt, parsen wir den Inhalt danach in $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;
}

Mit der Rückgabe des Knotens ist das Tag-Parsing abgeschlossen.

Erzeugen von PHP-Code

Jeder Knoten muss die Methode print() implementieren. Gibt PHP-Code zurück, der den angegebenen Teil der Vorlage wiedergibt (Laufzeitcode). Als Parameter wird ein Objekt Latte\Compiler\PrintContext übergeben, das über eine nützliche Methode format() verfügt, die das Zusammensetzen des resultierenden Codes vereinfacht.

Die Methode format(string $mask, ...$args) akzeptiert die folgenden Platzhalter in der Maske:

  • %node gibt Node aus
  • %dump exportiert den Wert nach PHP
  • %raw fügt den Text direkt ohne Transformation ein
  • %args gibt ArrayNode als Argumente für den Funktionsaufruf aus
  • %line gibt einen Kommentar mit einer Zeilennummer aus
  • %escape(...) entschlüsselt den Inhalt
  • %modify(...) wendet einen Modifikator an
  • %modifyContent(...) wendet einen Modifikator auf Blöcke an

Unsere Funktion print() könnte wie folgt aussehen (der Einfachheit halber vernachlässigen wir den Zweig 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,
	);
}

Die Variable $this->position ist bereits durch die Klasse Latte\Compiler\Node definiert und wird durch den Parser gesetzt. Sie enthält ein Latte\Compiler\Position Objekt mit der Position des Tags im Quellcode in Form einer Zeilen- und Spaltennummer.

Der Laufzeitcode kann Hilfsvariablen verwenden. Um Kollisionen mit Variablen zu vermeiden, die von der Vorlage selbst verwendet werden, ist es üblich, ihnen die Zeichen $ʟ__ voranzustellen.

Es kann zur Laufzeit auch beliebige Werte verwenden, die dem Template in Form von Providern mit der Methode Extension::getProviders() übergeben werden. Der Zugriff auf sie erfolgt über $this->global->....

AST-Traversierung

Um den AST-Baum in der Tiefe zu durchlaufen, ist es notwendig, die Methode getIterator() zu implementieren. Dies ermöglicht den Zugriff auf Unterknoten:

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

Beachten Sie, dass getIterator() einen Verweis zurückgibt. Dies ermöglicht es den Besuchern von Knoten, einzelne Knoten durch andere Knoten zu ersetzen.

Wenn ein Knoten Unterknoten hat, ist es notwendig, diese Methode zu implementieren und alle Unterknoten verfügbar zu machen. Andernfalls könnte eine Sicherheitslücke entstehen. So wäre der Sandbox-Modus beispielsweise nicht in der Lage, Unterknoten zu kontrollieren und sicherzustellen, dass in ihnen keine unerlaubten Konstrukte aufgerufen werden.

Da das Schlüsselwort yield im Körper der Methode vorhanden sein muss, auch wenn sie keine Unterknoten hat, schreiben Sie sie wie folgt:

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

AuxiliaryNode

Wenn Sie ein neues Tag für Latte erstellen, ist es ratsam, eine eigene Knotenklasse dafür zu erstellen, die es im AST-Baum repräsentiert (siehe die Klasse ForeachNode im obigen Beispiel). In einigen Fällen kann die triviale Hilfsknotenklasse AuxiliaryNode nützlich sein, die es Ihnen ermöglicht, den Körper der Methode print() und die Liste der Knoten, die durch die Methode getIterator() zugänglich gemacht werden, als Konstruktorparameter zu übergeben:

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

Compiler übergibt

Compiler-Passes sind Funktionen, die ASTs modifizieren oder Informationen in ihnen sammeln. Sie werden von der Methode Extension::getPasses() zurückgegeben.

Node Traverser

Die häufigste Art, mit dem AST zu arbeiten, ist die Verwendung eines Latte\Compiler\NodeTraverser:

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

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

Die Funktion enter (d.h. visitor) wird aufgerufen, wenn ein Knoten zum ersten Mal gefunden wird, bevor seine Unterknoten verarbeitet werden. Die Funktion leave wird aufgerufen, nachdem alle Unterknoten besucht worden sind. Ein gängiges Muster ist, dass enter dazu verwendet wird, einige Informationen zu sammeln, und leave dann Änderungen auf der Grundlage dieser Informationen vornimmt. Zu dem Zeitpunkt, an dem leave aufgerufen wird, ist der gesamte Code innerhalb des Knotens bereits besucht und die notwendigen Informationen gesammelt worden.

Wie ändert man AST? Am einfachsten ist es, einfach die Eigenschaften der Knoten zu ändern. Die zweite Möglichkeit besteht darin, den Knoten vollständig zu ersetzen, indem ein neuer Knoten zurückgegeben wird. Beispiel: Der folgende Code ändert alle Ganzzahlen im AST in Strings (z.B. 42 wird in '42' geändert).

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

Ein AST kann leicht Tausende von Knoten enthalten, und das Durchlaufen all dieser Knoten kann langsam sein. In einigen Fällen ist es möglich, eine vollständige Durchquerung zu vermeiden.

Wenn Sie nach allen Html\ElementNode in einem Baum suchen, wissen Sie, dass es keinen Sinn macht, auch alle untergeordneten Knoten zu überprüfen, sobald Sie Php\ExpressionNode gesehen haben, da HTML nicht in Ausdrücken enthalten sein kann. In diesem Fall können Sie den Traverser anweisen, nicht in den Klassenknoten zu rekursieren:

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

Wenn Sie nur nach einem bestimmten Knoten suchen, ist es auch möglich, den Traversal nach dem Auffinden des Knotens ganz abzubrechen.

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

Knoten-Helfer

Die Klasse Latte\Compiler\NodeHelpers bietet einige Methoden, mit denen AST-Knoten gefunden werden können, die entweder einen bestimmten Callback etc. erfüllen. Ein paar Beispiele werden gezeigt:

use Latte\Compiler\NodeHelpers;

// findet alle HTML-Element-Knoten
$elements = NodeHelpers::find($ast, fn(Node $node) => $node instanceof Nodes\Html\ElementNode);

// findet den ersten Textknoten
$text = NodeHelpers::findFirst($ast, fn(Node $node) => $node instanceof Nodes\TextNode);

// wandelt PHP-Wert-Knoten in realen Wert um
$value = NodeHelpers::toValue($node);

// konvertiert statische Textknoten in eine Zeichenkette
$text = NodeHelpers::toText($node);
Version: 3.0