カスタムタグの作成
このページでは、Latte でカスタムタグを作るための総合的なガイドを示します。Latte がテンプレートをどうコンパイルするかの理解を土台に、単純なタグから、入れ子の内容や独自の解析が必要な複雑な場面まで扱います。
カスタムタグは、テンプレートの構文とレンダリングのロジックに対する最も高い水準の制御を与えてくれますが、同時に最も複雑な拡張点でもあります。カスタムタグを作ると決める前に、必ずもっと簡単な解がないか、あるいは標準のセットにふさわしいタグがすでにないかを検討してください。カスタムタグは、より簡単な選択肢では要求を満たせないときにだけ使いましょう。
コンパイル過程を理解する
カスタムタグを効果的に作るには、Latte がテンプレートをどう処理するかを知っておくと役立ちます。この過程を理解すれば、タグがなぜそういう構造になっているのか、全体の中でどこに位置づくのかがはっきりします。
Latte でのテンプレートのコンパイルは、単純化すると次の重要な段階から成ります。
- 字句解析: レクサーがテンプレートのソースコード(
.latteファイル)を読み、トークンと呼ばれる小さく独立した断片の並びに分解します({、foreach、$variable、}、HTML テキストなど)。 - 構文解析: パーサーがこのトークンの流れを受け取り、テンプレートのロジックと内容を表す意味のある木構造を組み立てます。この木を抽象構文木(AST)と呼びます。
- コンパイラパス: PHP コードを生成する前に、Latte はコンパイラパスを実行します。これは AST 全体を走査して、それを書き換えたり情報を集めたりする関数です。この段階は、セキュリティ(サンドボックス)や最適化のような機能に欠かせません。
- コード生成: 最後にコンパイラが(書き換えられたかもしれない)AST を辿り、対応する PHP クラスのコードを生成します。この PHP コードが、実行されたときに実際にテンプレートをレンダリングします。
- キャッシュ: 生成された PHP コードはディスクにキャッシュされ、以降のレンダリングでは 1〜4 の段階が飛ばされるのでとても高速になります。
実際のところ、コンパイルはもう少し複雑です。Latte にはレクサーとパーサーが2 組あります。HTML テンプレート用と、タグの中の PHP に似たコード用です。しかも構文解析はトークン化のあとに走るのではなく、レクサーとパーサーが 2 つの「スレッド」で並行して動き、協調します。これを書いた私、David Grudl の実感としては、まるでロケット科学のようでした :-)
テンプレートの内容の読み込みから、解析を経て、結果のファイルの生成までの全過程は、次のコードで順に追えます。中間結果をダンプしながら試してみてください。
$latte = new Latte\Engine;
$source = $latte->getLoader()->getContent($file);
$ast = $latte->parse($source);
$latte->applyPasses($ast);
$code = $latte->generate($ast, $file);
タグの解剖
Latte で完全に機能するカスタムタグを作るには、互いに結びついたいくつかの部品が必要です。実装に入る前に、HTML とドキュメントオブジェクトモデル(DOM)に例えながら、中心となる考え方と用語を押さえましょう。
タグとノード(HTML との対比)
HTML では <p> や <div>...</div>
のようなタグを書きます。これらのタグはソースコード上の構文です。ブラウザがこの HTML
を解析すると、ドキュメントオブジェクトモデル(DOM)と呼ばれるメモリ上の表現を作ります。DOM
では、HTML のタグはノード(JavaScript の DOM の用語でいえば Element
ノード)で表されます。私たちはプログラムからこのノードを扱います(たとえば JavaScript
の document.getElementById(...) は Element
ノードを返します)。タグはソースファイル上のテキスト表現にすぎず、ノードは論理的な木の中のオブジェクト表現です。
Latte も同じように働きます。
.latteテンプレートファイルには{foreach ...}や{/foreach}のような Latte のタグを書きます。これがテンプレート作者として扱う構文です。- Latte がテンプレートを解析すると、抽象構文木(AST)を組み立てます。この木はノードから成ります。テンプレート内の各 Latte タグ、HTML 要素、テキストの断片、式が、この木の中の 1 つ以上のノードになります。
- AST のすべてのノードの基底クラスは
Latte\Compiler\Nodeです。DOM に異なるノード型(Element、Text、Comment)があるように、Latte の AST にもさまざまなノード型があります。静的テキストにはLatte\Compiler\Nodes\TextNode、HTML 要素にはLatte\Compiler\Nodes\Html\ElementNode、タグの中の式にはLatte\Compiler\Nodes\Php\ExpressionNode、そしてカスタムタグにとって決定的に重要なLatte\Compiler\Nodes\StatementNodeを継承したノードに出会います。
なぜ StatementNode なのか
HTML 要素(Html\ElementNode)は主に構造と内容を表します。PHP
の式(Php\ExpressionNode)は値や計算を表します。では
{if}、{foreach}、あるいは私たちの {datetime} のような Latte
のタグはどうでしょうか。これらのタグは動作を行い、プログラムの流れを制御し、ロジックにもとづいて出力を生みます。単なるマークアップ言語ではなく、Latte
を強力なテンプレートエンジンにしている機能の単位なのです。
プログラミングでは、そうした動作を行う単位はしばしば「文(statement)」と呼ばれます。ですから、こうした機能を担う
Latte のタグを表すノードは、ふつう Latte\Compiler\Nodes\StatementNode
を継承します。これによって、純粋に構造的なノード(HTML
要素など)や値を表すノード(式など)と区別されます。
主な部品
カスタムタグを作るのに必要な主な部品を、あらためて見ていきましょう。
タグの解析関数
- この PHP の callable が、テンプレートのソースにある Latte
タグの構文(
{...})を解析します。 - タグについての情報(名前、位置、n:属性かどうかなど)を Latte\Compiler\Tag オブジェクトで受け取り、第 2
引数として主要な Latte\Compiler\TemplateParser
を受け取ります。完全なシグネチャは
callable(Tag, TemplateParser): (Node|\Generator|void)です。 - タグの区切りの中にある引数や式を解析する主な道具は Latte\Compiler\TagParser
オブジェクトで、
$tag->parserからアクセスします(これはテンプレート全体を解析するパーサーとは別物です)。 - ペアタグでは
yieldを使って、開始タグと終了タグのあいだの内側の内容を解析するよう Latte に伝えます。 - 解析関数の最終的な目的は、ノードクラスのインスタンスを作って返すことです。それが AST に追加されます。
- 解析関数は、対応するノードクラスの中に静的メソッド(
createという名前がよく使われます)として実装するのが慣習です(必須ではありません)。こうすると解析のロジックとノードの表現がきれいにまとまり、必要なら private/protected の要素にもアクセスでき、整理しやすくなります。
ノードクラス
- 抽象構文木(AST)の中で、タグの論理的な機能を表します。
- 解析された情報(引数や内容)を公開プロパティとして保持します。これらのプロパティにはほかの
Nodeインスタンスが入ることがよくあります(解析済みの引数ならExpressionNode、解析済みの内容ならAreaNodeなど)。 print(PrintContext $context): stringメソッドが、テンプレートのレンダリング時にタグの動作を行う *PHP コード*(文、または一連の文)を生成します。getIterator(): \Generatorメソッドが、コンパイラパスによる走査のために子ノード(引数、内容)を辿れるようにします。パスがサブノードを書き換えたり置き換えたりできるよう、参照(&)を yield しなければなりません。- テンプレート全体が AST に解析されたあと、Latte は一連のコンパイラパスを実行します。これらのパスは、各ノードが提供する
getIterator()メソッドを使って *AST 全体を走査します。ノードを調べ、情報を集め、木を書き換える*こともできます(ノードの公開プロパティを変えたり、ノードそのものを置き換えたり)。網羅的なgetIterator()を求めるこの設計は決定的に重要です。おかげでサンドボックスのような強力な機能が、あなたのカスタムタグを含むテンプレートのどの部分でも解析し、必要に応じて振る舞いを変えられ、安全性と一貫性が保たれます。
Extension による登録
- 新しいタグと、それに使う解析関数を Latte に伝える必要があります。これは Latte Extensionの中で行います。
- 拡張クラスの中で
getTags(): arrayメソッドを実装します。このメソッドは連想配列を返し、キーはタグ名('mytag'、'n:myattribute'など)、値はそれぞれの解析関数を表す PHP の callable(MyNamespace\DatetimeNode::create(...)など)です。
まとめると、タグの解析関数がタグのテンプレートのソースコードを AST
のノードに変えます。ノードクラスは、自分自身をコンパイル済みテンプレート用の実行可能な
*PHP コード*に変える方法を知っていて、getIterator()
を通じてサブノードをコンパイラパスに見せます。Extension
による登録が、タグ名を解析関数に結びつけ、Latte にそれを知らせます。
これらの部品をどう実装するか、順を追って見ていきましょう。
単純なタグを作る
最初のカスタム Latte
タグを作ってみましょう。まずはごく単純な例から始めます。現在の日付と時刻を出力する
{datetime}
というタグです。はじめのうちこのタグは引数を受け取りませんが、あとでタグの引数の解析の節で強化します。内側の内容も持ちません。
この例を通して、ノードクラスの定義、print() と getIterator()
メソッドの実装、解析関数の作成、そして最後にタグの登録という基本の段階を辿ります。
目標: PHP の date() 関数を使って現在の日付と時刻を出力する
{datetime} を実装する。
ノードクラスの作成
まず、抽象構文木(AST)の中でタグを表すクラスが必要です。先ほど述べたとおり、Latte\Compiler\Nodes\StatementNode
を継承します。
ファイル(たとえば DatetimeNode.php)を作り、クラスを定義します。
<?php
namespace App\Templating;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;
class DatetimeNode extends StatementNode
{
/**
* タグの解析関数。{datetime} が見つかったときに呼ばれます。
*/
public static function create(Tag $tag): self
{
// このタグは内容を出力するので、周囲のインデントを保ちます
$tag->outputMode = $tag::OutputKeepIndentation;
// この単純なタグは今のところ引数を取らないので、何も解析しません
$node = $tag->node = new self;
return $node;
}
/**
* テンプレートのレンダリング時に実行される PHP コードを生成します。
*/
public function print(PrintContext $context): string
{
return $context->format(
'echo date(\'Y-m-d H:i:s\') %line;',
$this->position,
);
}
/**
* Latte のコンパイラパスに子ノードへのアクセスを提供します。
*/
public function &getIterator(): \Generator
{
false && yield;
}
}
テンプレートで {datetime} に出会うと、Latte はタグの解析関数 create()
を呼びます。その役目は DatetimeNode のインスタンスを返すことです。あわせて
$tag->outputMode を OutputKeepIndentation に設定しています。タグは既定では
OutputNone モードで動く(タグの出力モードで説明します)ため、テンプレートの最初のテキストより前に置かれたタグは、そうしないと
main() ではなく生成された prepare()
メソッドの中で出力を出してしまいかねません。このモードを設定すれば、出力はタグが置かれた場所に現れます。
print() メソッドは、テンプレートのレンダリング時に実行される PHP
コードを生成します。ここでは $context->format()
メソッドを呼び、コンパイル済みテンプレート用の PHP
コード文字列を組み立てます。最初の引数 'echo date('Y-m-d H:i:s') %line;'
は、後続のパラメータが差し込まれるマスクです。%line
のプレースホルダーは、次の引数(ここでは
$this->position)を取り、/* pos 15:1 */ のようなコメントを挿入するよう
format() メソッドに伝えます。これは生成された PHP
コードをもとのテンプレートの行に結びつけるもので、デバッグに欠かせません。
$this->position プロパティは基底の Node クラスから受け継いだもので、Latte
のパーサーが自動的に設定します。ここには Latte\Compiler\Range
オブジェクト(Position を継承し、バイト単位の length
を加えたもの)が入り、タグがソースの .latte
ファイルのどこにあるかを示します。ペアタグでは範囲が開始タグから終了タグまでを覆い、StatementNode
の子孫はさらに $this->tagRanges
を公開して、構成する各タグ(開始タグ、{else}/{case}
のような中間タグ、終了タグ)の Range を並べます。
getIterator() メソッドはコンパイラパスにとって不可欠です。すべての子ノードを yield
しなければなりませんが、単純な DatetimeNode
は今のところ引数も内容も持たないので、子ノードがありません。それでもこのメソッドは存在し、ジェネレータでなければなりません。つまり本体のどこかに
yield キーワードがある必要があります。
Extension による登録
最後に、新しいタグを Latte に伝えます。Extension クラス(たとえば
MyLatteExtension.php)を作り、その getTags() メソッドでタグを登録します。
<?php
namespace App\Templating;
use Latte\Extension;
class MyLatteExtension extends Extension
{
/**
* この拡張が提供するタグの一覧を返します。
* @return array<string, callable> 対応: 'tag-name' => 解析関数
*/
public function getTags(): array
{
return [
'datetime' => DatetimeNode::create(...),
// あとでここにタグを追加します
];
}
}
そして、この拡張を Latte のエンジンに登録します。
$latte = new Latte\Engine;
$latte->addExtension(new App\Templating\MyLatteExtension);
テンプレートを作ります。
<p>Page generated on: {datetime}</p>
期待される出力: <p>Page generated on: 2023-10-27 11:00:00</p>
この段階のまとめ
基本的なカスタムタグ {datetime} をうまく作れました。AST
での表現(DatetimeNode)を定義し、解析(create())を扱い、PHP
コードの生成方法(print())を決め、子ノードを辿れるようにし(getIterator())、Latte
に登録しました。
次の節では、このタグを引数を受け取れるように強化し、式の解析と子ノードの管理を見ていきます。
タグの引数の解析
単純な {datetime}
タグは動きますが、あまり柔軟ではありません。省略可能な引数、つまり date()
関数のためのフォーマット文字列を受け取れるようにしましょう。目指す構文は
{datetime $format} です。
目標: {datetime} が省略可能な PHP の式を引数として受け取り、それを
date() のフォーマット文字列として使うようにする。
TagParser の紹介
コードを直す前に、これから使う道具 Latte\Compiler\TagParser
を理解しておくことが大切です。Latte の主要なパーサー(TemplateParser)が
{datetime ...} のような Latte のタグや
n:属性に出会うと、タグの内側の内容({ と }
のあいだ、または属性の値)の解析を専用の TagParser に委ねます。
この TagParser
はタグの引数だけを扱います。その仕事は、引数を表すトークンを消費することです。重要なのは、渡された内容をすべて解析しなければならないという点です。解析関数が終わったのに
TagParser が引数の末尾に達していない場合($tag->parser->isEnd()
で確認できます)、タグの中に予期しないトークンが残っていることを意味するので、Latte
は例外を投げます。逆にタグが引数を必要とするなら、解析関数の冒頭で
$tag->expectArguments()
を呼ぶべきです。このメソッドは引数があるかを確認し、引数なしでタグが使われた場合に分かりやすい例外を投げます。
TagParser にはさまざまな種類の引数を解析する便利なメソッドがあります。
parseExpression(): ExpressionNode: PHP に似た式(変数、リテラル、演算子、関数やメソッドの呼び出しなど)を解析します。単純な英数字の文字列を引用符付きの文字列として扱う(たとえばfooは'foo'のように解析される)といった Latte のシンタックスシュガーも扱います。parseUnquotedStringOrExpression(): ExpressionNode: 通常の式か引用符のない文字列のどちらかを解析します。引用符のない文字列とは Latte が引用符なしで許す並びのことで、ファイルパスなどによく使われます({include ../file.latte}など)。引用符のない文字列を解析した場合はStringNodeを返します。parseArguments(): ArrayNode:10, name: 'John', trueのように、カンマ区切りの引数(キー付きのものも含む)を解析します。parseModifier(): ModifierNode:|upper|truncate:10のようなフィルタを解析します。parseType(): ?SuperiorTypeNode:int、?string、array|Fooのような PHP の型宣言を解析します。
もっと複雑で低水準の解析が必要なら、$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() の更新
以上を踏まえて、DatetimeNode の create()
メソッドを直し、$tag->parser
を使って省略可能なフォーマット引数を解析しましょう。
<?php
namespace App\Templating;
use Latte\Compiler\Nodes\Php\ExpressionNode;
use Latte\Compiler\Nodes\Php\Scalar\StringNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;
class DatetimeNode extends StatementNode
{
// 解析されたフォーマットの式ノードを保持する公開プロパティを追加します
public ?ExpressionNode $format = null;
public static function create(Tag $tag): self
{
$node = $tag->node = new self;
// トークンがあるか確認します
if (!$tag->parser->isEnd()) {
// TagParser を使って引数を PHP に似た式として解析します。
$node->format = $tag->parser->parseExpression();
}
return $node;
}
// ... print() と getIterator() メソッドは次に更新します ...
}
公開プロパティ $format を追加しました。create() では
$tag->parser->isEnd() で引数があるかを確認します。あれば
$tag->parser->parseExpression() がその式のトークンを消費します。TagParser
は入力トークンをすべて消費しなければならないので、フォーマットの式のあとに予期しないものが書かれていれば({datetime 'Y-m-d', unexpected}
など)、Latte が自動的にエラーを投げます。
print() メソッドの更新
次に、$this->format に保存された解析済みのフォーマット式を使うよう
print()
メソッドを直します。フォーマットが渡されなかった場合($this->format が
null の場合)は、たとえば 'Y-m-d H:i:s'
のような既定のフォーマット文字列を使うべきです。
public function print(PrintContext $context): string
{
$formatNode = $this->format ?? new StringNode('Y-m-d H:i:s');
// %node は $formatNode の PHP コード表現を出力します。
return $context->format(
'echo date(%node) %line;',
$formatNode,
$this->position
);
}
$formatNode 変数には、PHP の date() 関数のためのフォーマット文字列を表す AST
ノードを入れます。ここでは null
合体演算子(??)を使っています。ユーザーがテンプレートで引数を渡していれば({datetime 'd.m.Y'}
など)、$this->format プロパティに対応するノード(この場合は値 'd.m.Y'
を持つ StringNode)が入っていて、それが使われます。引数を渡していなければ(単に
{datetime} と書いた場合)、$this->format プロパティは null
なので、代わりに既定のフォーマット 'Y-m-d H:i:s' を持つ新しい StringNode
を作ります。これで $formatNode には常にフォーマット用の正しい AST
ノードが入ります。
マスク 'echo date(%node) %line;' では新しいプレースホルダー %node
を使っています。これは、次に続く最初の引数(ここでは $formatNode)を取り、その
print() メソッド(PHP
コード表現を返します)を呼び、その結果をプレースホルダーの位置に差し込むよう
format() メソッドに伝えます。
サブノードのための getIterator() の実装
DatetimeNode は子ノード、つまり $format
の式を持つようになりました。この子ノードは getIterator() メソッドで yield
して、コンパイラパスから辿れるようにしなければなりません。パスがノードを置き換えられるよう、参照(&)を
yield することを忘れないでください。
public function &getIterator(): \Generator
{
if ($this->format) {
yield $this->format;
}
}
なぜこれが決定的に重要なのでしょうか。$format
引数に禁止された関数呼び出し({datetime dangerousFunction()}
など)が含まれていないかを調べるサンドボックスのパスを想像してみてください。getIterator()
が $this->format を yield しなければ、サンドボックスのパスはタグの引数の中の
dangerousFunction()
の呼び出しをまったく見られず、セキュリティホールになりかねません。yield
することで、サンドボックス(やほかのパス)が $format
の式ノードを調べ、必要なら書き換えられるようになります。
強化したタグの使い方
タグは省略可能な引数を正しく扱えるようになりました。
Default format: {datetime}
Custom format: {datetime 'd.m.Y'}
Using variable: {datetime $userDateFormatPreference}
{* 'd.m.Y' の解析後に ", foo" が予期しないものなのでエラーになります *}
{* {datetime 'd.m.Y', foo} *}
次は、あいだの内容を処理するペアタグの作り方を見ていきます。
ペアタグの扱い
ここまでの {datetime}
タグは(概念的には)自己完結型でした。開始タグと終了タグのあいだに内容を持ちません。しかし多くの便利なタグは、テンプレートの内容の塊に対して働きます。これをペアタグと呼びます。{if}...{/if}、{block}...{/block}、そしてこれから作るカスタムタグ
{debug}...{/debug} などです。
このタグを使うと、開発中にだけ見えるべきデバッグ情報をテンプレートに入れられます。
目標:
特定の「開発モード」フラグが有効なときにだけ内容がレンダリングされるペアタグ
{debug} を作る。
プロバイダの紹介
タグが、テンプレートのパラメータとして直接渡されないデータやサービスにアクセスする必要が出てくることがあります。たとえばアプリケーションが開発モードかを判定する、ユーザーオブジェクトにアクセスする、設定値を取得するといった場合です。Latte にはそのためのプロバイダというしくみがあります。
プロバイダは Extensionの中で
getProviders()
メソッドを使って登録します。このメソッドは連想配列を返し、キーはテンプレートの実行時コードからプロバイダにアクセスするための名前、値は実際のデータやオブジェクトです。
タグの print() メソッドが生成する PHP
コードの中では、特別なオブジェクトのプロパティ $this->global
からこれらのプロバイダにアクセスできます。このプロパティはすべての拡張で共有されるので、Latte
の中核のプロバイダやほかの拡張のプロバイダと名前が衝突しないよう、プロバイダ名に接頭辞を付けるのがよい習慣です。ベンダー名や拡張名に関係する短く一意な接頭辞を使うのが一般的です。この例では接頭辞
app を使い、開発モードのフラグは $this->global->appDevMode
として使えるようにします。
内容を解析するための yield キーワード
{debug} と {/debug} のあいだの内容を処理するよう、Latte
のパーサーにどう伝えればよいでしょうか。ここで yield キーワードが登場します。
create() 関数の中で yield を使うと、その関数は PHP
のジェネレータになります。実行が一時停止し、制御が主要な TemplateParser
に戻ります。すると TemplateParser は、対応する終了タグ(ここでは
{/debug})に出会うまでテンプレートの内容の解析を続けます。
終了タグが見つかると、TemplateParser は yield 文の直後から create()
関数の実行を再開します。yield が返す値は、2 つの要素を持つ配列です。
- 開始タグと終了タグのあいだの解析済みの内容を表す
AreaNode。 - 終了タグ(
{/debug}など)を表すTagオブジェクト。
yield を使って DebugNode クラスとその create
メソッドを作りましょう。
<?php
namespace App\Templating;
use Latte\Compiler\Nodes\AreaNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;
class DebugNode extends StatementNode
{
// 解析された内側の内容を保存する公開プロパティ
public AreaNode $content;
/**
* ペアタグ {debug} ... {/debug} の解析関数。
*/
public static function create(Tag $tag): \Generator // 戻り値の型に注目
{
$node = $tag->node = new self;
// 解析を一時停止し、{/debug} が見つかったら内側の内容と終了タグを受け取ります
[$node->content, $endTag] = yield;
return $node;
}
// ... print() と getIterator() は次に実装します ...
}
注意: タグが n:属性として使われた場合、つまり <div n:debug>...</div>
の場合、$endTag は null です。
ペアタグは {debug/}(あるいは
<div n:debug/>)のようにスラッシュで閉じることもできます。その場合、内側の内容はなく、ジェネレータは
[$emptyFragmentNode, $startTag] を受け取ります。2 つめの要素は null
ではなく開始タグそのものです。
条件つきレンダリングのための print()
の実装
print() メソッドでは、実行時に appDevMode
プロバイダを調べ、フラグが真のときだけ内側の内容のコードを実行する PHP
コードを生成する必要があります。
public function print(PrintContext $context): string
{
// 実行時にプロバイダを調べる PHP の 'if' 文を生成します
return $context->format(
<<<'XX'
if ($this->global->appDevMode) %line {
// 開発モードなら内側の内容を出力します
%node
}
XX,
$this->position, // %line コメント用
$this->content, // 内側の内容の AST を持つノード
);
}
これは分かりやすいですね。PrintContext::format() を使って標準的な PHP の if
文を作ります。if の中には $this->content のための %node
プレースホルダーを置きます。Latte は再帰的に $this->content->print($context)
を呼んでタグの内側の PHP コードを生成しますが、実行時に $this->global->appDevMode
が真と評価された場合にだけ実行されます。
内容のための getIterator() の実装
先ほどの例の引数ノードと同じく、DebugNode も子ノード AreaNode $content
を持つようになりました。getIterator() で yield
して辿れるようにしなければなりません。
public function &getIterator(): \Generator
{
// 内容ノードへの参照を yield します
yield $this->content;
}
これにより、コンパイラパスが {debug}
タグの内容の中に下りていけます。内容が条件つきでレンダリングされる場合でもこれは重要です。たとえばサンドボックスは、appDevMode
が真か偽かに関係なく内容を解析する必要があります。
登録と使い方
拡張の中でタグとプロバイダを登録します。
class MyLatteExtension extends Extension
{
// $isDevelopmentMode はどこか(設定など)で決まっているとします
public function __construct(
private bool $isDevelopmentMode,
) {
}
public function getTags(): array
{
return [
'datetime' => DatetimeNode::create(...),
'debug' => DebugNode::create(...), // 新しいタグを登録します
];
}
public function getProviders(): array
{
return [
'appDevMode' => $this->isDevelopmentMode, // プロバイダを登録します
];
}
}
// 拡張を登録するとき:
$isDev = true; // アプリケーションの環境にもとづいて決めます
$latte->addExtension(new MyLatteExtension($isDev));
そしてテンプレートで使います。
<p>Regular content visible always.</p>
{debug}
<div class="debug-panel">
Current user ID: {$user->id}
Request time: {=time()}
</div>
{/debug}
<p>More regular content.</p>
n:属性との統合
Latte は多くのペアタグに便利な短縮形、n:属性を用意しています。{tag}...{/tag}
のようなペアタグがあり、その効果をひとつの HTML 要素に直接適用したいなら、その要素に
n:tag 属性としてより簡潔に書けることがよくあります。
あなたが定義するたいていの標準的なペアタグ({debug} など)に対して、Latte
は対応する n:
属性版を自動的に有効にします。登録時に追加で何かする必要はありません。
{* 標準的なペアタグの使い方 *}
{debug}<div>Debug info</div>{/debug}
{* n:属性を使った同等の書き方 *}
<div n:debug>Debug info</div>
どちらも $this->global->appDevMode が真のときだけ <div>
をレンダリングします。inner- と tag- の接頭辞も期待どおりに働きます。
タグのロジックが、標準的なペアタグとして使われたか n:属性として使われたか、あるいは
n:inner-tag や n:tag-tag
のような接頭辞が使われたかによって、少し違う振る舞いを必要とすることもあります。create()
解析関数に渡される Latte\Compiler\Tag オブジェクトが、その情報を提供します。
$tag->isNAttribute(): bool: タグが n:属性として解析されている場合にtrueを返します$tag->prefix: ?string: n:属性に使われた接頭辞を返します。null(n:属性でない)、Tag::PrefixNone、Tag::PrefixInner、Tag::PrefixTagのいずれかです
単純なタグ、引数の解析、ペアタグ、プロバイダ、n:属性を理解できたので、{debug}
タグを出発点に、ほかのタグの中に入れ子になるタグというもっと複雑な場面に取り組みましょう。
中間タグ
ペアタグの中には、最後の終了タグより前に、ほかのタグが内側に現れるのを許す、あるいは必要とするものがあります。これを中間タグと呼びます。典型的な例は
{if}...{elseif}...{else}...{/if} や {switch}...{case}...{default}...{/switch} です。
{debug}
タグを拡張して、アプリケーションが開発モードでないときにレンダリングされる、省略可能な
{else} 節に対応させましょう。
目標: {debug} が省略可能な {else}
の中間タグに対応するようにする。最終的な構文は {debug} ... {else} ... {/debug} です。
yield による中間タグの解析
yield が解析関数 create()
を一時停止し、解析された内容を終了タグとともに返すことはすでに分かっています。しかし
yield
はさらに細かい制御を可能にします。中間タグの名前の配列を渡せるのです。パーサーが同じ入れ子の水準(つまり親タグの直接の子として。その中のほかのブロックやタグの中ではありません)でこれらのタグのいずれかに出会うと、そこでも内容の解析を止めます。
中間タグによって解析が止まると、内容の解析をやめ、create()
のジェネレータを再開し、部分的に解析された内容と(最後の終了タグではなく)中間タグそのものを返します。すると
create() 関数はこの中間タグを扱い(引数があれば解析するなど)、もう一度
yield
して、最後の終了タグや別の期待される中間タグが見つかるまで内容の次の部分を解析できます。
DebugNode::create() を直して {else} を期待するようにしましょう。
<?php
namespace App\Templating;
use Latte\Compiler\Nodes\AreaNode;
use Latte\Compiler\Nodes\NopNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;
class DebugNode extends StatementNode
{
// {debug} の部分の内容
public AreaNode $thenContent;
// {else} の部分の省略可能な内容
public ?AreaNode $elseContent = null;
public static function create(Tag $tag): \Generator
{
$node = $tag->node = new self;
// yield して {/debug} か {else} のどちらかを期待します
[$node->thenContent, $nextTag] = yield ['else'];
// 止まったタグが {else} だったか確認します
if ($nextTag?->name === 'else') {
// もう一度 yield して {else} と {/debug} のあいだの内容を解析します
[$node->elseContent, $endTag] = yield;
}
return $node;
}
// ... print() と getIterator() は次に更新します ...
}
yield ['else'] は、{/debug} だけでなく {else} でも解析を止めるよう
Latte に伝えます。{else} に出会うと、$nextTag に {else} の Tag
オブジェクトが入ります。そこで引数なしでもう一度 yield し、今度は最後の
{/debug} タグだけを期待して、結果を $node->elseContent
に入れます。{else} が見つからなければ、$nextTag は {/debug} の
Tag(n:属性として使われた場合は null)になり、$node->elseContent は
null のままです。
{else} を伴う print() の実装
print() メソッドは新しい構造を反映する必要があります。appDevMode
プロバイダにもとづく PHP の if/else 文を生成すべきです。
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, // 1 つめの %node プレースホルダー
$this->elseContent ?? new NopNode, // 2 つめの %node プレースホルダー
);
}
これは標準的な PHP の if/else の構造です。%node を 2 回使い、format()
は渡されたノードを順に差し込みます。$this->elseContent が null
のときのエラーを避けるために ?? new NopNode を使っています。NopNode
は何も出力しません。
両方の内容のための getIterator()
の実装
これで子の内容ノードが 2 つ($thenContent と
$elseContent)ありうるようになりました。存在するものを両方 yield
しなければなりません。
public function &getIterator(): \Generator
{
yield $this->thenContent;
if ($this->elseContent) {
yield $this->elseContent;
}
}
強化したタグの使い方
タグは省略可能な {else} 節とともに使えるようになりました。
{debug}
<p>Showing debug info because devMode is ON.</p>
{else}
<p>Debug info is hidden because devMode is OFF.</p>
{/debug}
状態と入れ子の扱い
これまでの例({datetime}、{debug})は、print()
メソッドの中では比較的状態を持ちませんでした。内容を直接出力するか、グローバルなプロバイダにもとづく単純な条件判定をするだけでした。しかし多くのタグは、レンダリング中に何らかの状態を管理する必要があったり、性能や正しさのために一度だけ評価すべきユーザー指定の式を扱ったりします。さらに、カスタムタグが入れ子になったときに何が起こるかも考える必要があります。
これらの考え方を、{repeat $count}...{/repeat}
タグを作りながら説明しましょう。このタグは内側の内容を $count 回繰り返します。
目標: 内容を指定した回数だけ繰り返す {repeat $count} を実装する。
一時変数と一意な変数の必要性
ユーザーが次のように書いたとしましょう。
{repeat rand(1, 5)} Content {/repeat}
print() メソッドの中で素朴に次のような PHP の for
ループを生成したとします。
// 単純化した、誤りのある生成コード
for ($i = 0; $i < rand(1, 5); $i++) {
// 内容を出力
}
これは間違いです。rand(1, 5)
の式がループの反復ごとに再評価され、繰り返し回数が予測できなくなってしまいます。$count
の式はループが始まる前に一度だけ評価し、その結果を保存する必要があります。
まずカウントの式を評価して実行時の一時変数に保存する PHP
コードを生成しましょう。テンプレートのユーザーが定義した変数や Latte
の内部変数($ʟ_... など)との衝突を避けるため、一時変数には
$__(アンダースコア 2 つ)の接頭辞を使う慣習に従います。
生成されるコードは次のようになります。
$__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_0
のように一意な数値の接尾辞を付けた名前を生成し、ループのカウンタも同じく $__i_0
などにします。実際の数値はすべてのノードが共有するコンパイル全体のカウンタから来るので、一意であることだけが保証され、タグごとに連番になるわけではありません。
RepeatNode の実装
ノードクラスを作りましょう。
<?php
namespace App\Templating;
use Latte\CompileException;
use Latte\Compiler\Nodes\AreaNode;
use Latte\Compiler\Nodes\Php\ExpressionNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;
class RepeatNode extends StatementNode
{
public ExpressionNode $count;
public AreaNode $content;
/**
* {repeat $count} ... {/repeat} の解析関数
*/
public static function create(Tag $tag): \Generator
{
$tag->expectArguments(); // $count が渡されていることを確かめます
$node = $tag->node = new self;
// カウントの式を解析します
$node->count = $tag->parser->parseExpression();
// 内側の内容を受け取ります
[$node->content] = yield;
return $node;
}
/**
* 一意な変数名を使う PHP の 'for' ループを生成します。
*/
public function print(PrintContext $context): string
{
// 一意な変数名を生成します
$id = $context->generateId();
$countVar = '$__count_' . $id; // 一意な名前、たとえば $__count_0
$iteratorVar = '$__i_' . $id; // 一意な名前、たとえば $__i_0
return $context->format(
<<<'XX'
// カウントの式を*一度だけ*評価して保存します
%raw = (int) (%node);
// 保存したカウントと一意なイテレータ変数を使ってループします
for (%raw = 0; %2.raw < %0.raw; %2.raw++) %line {
%node // 内側の内容をレンダリングします
}
XX,
$countVar, // %0 - カウントを保存する変数
$this->count, // %1 - カウントの式ノード
$iteratorVar, // %2 - ループのイテレータ変数名
$this->position, // %3 - ループ自体の行番号コメント
$this->content // %4 - 内側の内容ノード
);
}
/**
* 子ノード(カウントの式と内容)を yield します。
*/
public function &getIterator(): \Generator
{
yield $this->count;
yield $this->content;
}
}
create() メソッドは parseExpression() を使って必須の $count
の式を解析します。まず $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)を取り、その生の文字列を差し込みます。おかげで
format() の引数リストに何度も渡さずに $iteratorVar
の文字列を再利用できます。
丁寧に組み立てられたこの format()
の呼び出しは、カウントの式を正しく扱い、{repeat}
タグが入れ子になっても変数名の衝突を避ける、効率的で安全な PHP のループを生成します。
登録と使い方
拡張の中でタグを登録します。
use App\Templating\RepeatNode;
class MyLatteExtension extends Extension
{
public function getTags(): array
{
return [
'datetime' => DatetimeNode::create(...),
'debug' => DebugNode::create(...),
'repeat' => RepeatNode::create(...), // repeat タグを登録します
];
}
}
入れ子も含め、テンプレートで使ってみます。
{var $rows = rand(5, 7)}
{var $cols = rand(3, 5)}
{repeat $rows}
<tr>
{repeat $cols}
<td>Inner loop</td>
{/repeat}
</tr>
{/repeat}
この例は、$__ を接頭辞に持ち PrintContext::generateId() の ID
で一意にした一時変数を使って、状態(ループのカウンタ)と入れ子の問題を扱う方法を示しています。
純粋な n:属性
n:if や n:foreach のような多くの n:属性
は、対応するペアタグ({if}...{/if}、{foreach}...{/foreach})の便利な短縮形として働きますが、Latte
では n:属性の形でしか存在しないタグも定義できます。これらは、付いている HTML
要素の属性や振る舞いを変えるためによく使われます。
Latte に組み込まれた標準の例としては、class 属性を動的に組み立てる n:classや、任意の属性を複数設定できる n:attrがあります。
独自の純粋な n:属性 n:confirm
を作ってみましょう。これは、リンクをたどる、フォームを送信するといった動作の前に
JavaScript の確認ダイアログを出します。
目標: ユーザーが確認ダイアログを取り消したときに既定の動作を止める onclick
ハンドラを追加する n:confirm="'Are you sure?'" を実装する。
ConfirmNode の実装
ノードクラスと解析関数が必要です。
<?php
namespace App\Templating;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\PrintContext;
use Latte\Compiler\Tag;
use Latte\Compiler\Nodes\Php\ExpressionNode;
use Latte\Compiler\Nodes\Php\Scalar\StringNode;
class ConfirmNode extends StatementNode
{
public ExpressionNode $message;
public static function create(Tag $tag): self
{
$tag->expectArguments();
$node = $tag->node = new self;
$node->message = $tag->parser->parseExpression();
return $node;
}
/**
* 適切なエスケープを伴う 'onclick' 属性のコードを生成します。
*/
public function print(PrintContext $context): string
{
// JavaScript と HTML 属性の両方の文脈で正しくエスケープされるようにします。
return $context->format(
<<<'XX'
echo ' onclick="', LR\HtmlHelpers::escapeAttr('return confirm(' . LR\Helpers::escapeJs(%node) . ')'), '"' %line;
XX,
$this->message,
$this->position,
);
}
public function &getIterator(): \Generator
{
yield $this->message;
}
}
print() メソッドは、テンプレートのレンダリング時に最終的に onclick="..." の
HTML 属性を出力する PHP コードを生成します。入れ子の文脈(HTML 属性の中の
JavaScript)を扱うには、注意深いエスケープが必要です。LR\Helpers::escapeJs(%node)
ヘルパーは実行時に呼ばれ、JavaScript
の中で使えるようメッセージを正しくエスケープします(出力は "Sure?"
のようになります)。次に LR\HtmlHelpers::escapeAttr(...) ヘルパーが HTML
属性の中で特別な意味を持つ文字をエスケープするので、出力は
return confirm("Sure?") になります。この 2
段階の実行時エスケープにより、メッセージが JavaScript にとって安全になり、できあがった
JavaScript コードが HTML の onclick 属性に埋め込んでも安全になります。
登録と使い方
拡張の中で n:属性を登録します。キーの n: 接頭辞を忘れないでください。
class MyLatteExtension extends Extension
{
public function getTags(): array
{
return [
'datetime' => DatetimeNode::create(...),
'debug' => DebugNode::create(...),
'repeat' => RepeatNode::create(...),
'n:confirm' => ConfirmNode::create(...), // n:confirm を登録します
];
}
}
これでリンク、ボタン、フォーム要素に n:confirm を使えます。
<a href="delete.php?id=123" n:confirm='"Do you really want to delete item {$id}?"'>Delete</a>
生成される HTML:
<a href="delete.php?id=123" onclick="return confirm("Do you really want to delete item 123?")">Delete</a>
ユーザーがリンクをクリックすると、ブラウザは onclick
のコードを実行して確認ダイアログを表示し、ユーザーが「OK」をクリックしたときにだけ
delete.php に進みます。
この例は、純粋な n:属性が print() メソッドの中で適切な PHP
コードを生成することによって、付いている HTML
要素の振る舞いや属性を変えられることを示しています。しばしば二重のエスケープが必要になることを覚えておいてください。まず対象の文脈(ここでは
JavaScript)のために、次に HTML 属性の文脈のために行います。
純粋な n:属性を書くときには、Tag オブジェクトのもう 2
つのメンバーが役立ちます。$tag->htmlElement は周囲の HTML
要素(ElementNode)へのアクセスを与えるので、それを調べたり調整したりできます。$tag->replaceNAttribute($node)
は、その属性を自分が組み立てたノードと差し替えられます。実際、純粋な n:属性の
create() が返したノードは、その要素の属性を自動的に置き換えます。
高度な話題
前の節までで中心となる考え方は網羅しましたが、カスタム Latte タグを作るときに出会うかもしれない、もう少し進んだ話題をいくつか挙げます。
タグの出力モード
create() 関数に渡される Tag オブジェクトには outputMode
プロパティがあります。このプロパティは、とくにタグが単独で 1 行に置かれたとき、Latte
が周囲の空白とインデントをどう扱うかに影響します。create()
関数の中でこのプロパティを変えられます。
Tag::OutputNone(すべてのタグの既定で、{if}や{foreach}のような制御構造はこれを保ちます): タグの周りの空白はOutputRemoveIndentationとまったく同じに扱われます。前のインデントと末尾の改行 1 つが取り除かれます。本当の違いは内部にあり、このモードはテンプレートのパーサーをテンプレートの「head」モードのままに保ちます。{var}や{default}のように直接の出力を生まない宣言・準備のタグに向いています。Tag::OutputRemoveIndentation(ブロック系のタグ{block}、{embed}、{include}、{sandbox}が明示的に設定します): タグの前のインデントと末尾の改行 1 つを取り除きます。生成される PHP コードがきれいに保たれ、タグ自体が原因で HTML の出力に余分な空行ができるのを防げます。Tag::OutputKeepIndentation({=...}のような出力タグが明示的に設定します): Latte はタグの前のインデントを保とうとし、タグのあとの改行はふつう残されます。行内に内容を出力するタグに向いています。上の{datetime}の例がまさにその理由でこのモードを設定しています。
タグの目的に最も合うモードを選んでください。既定が OutputNone
なので、制御構造や宣言のタグは変える必要がなく、自分の行に内容を出力するタグには
OutputKeepIndentation を設定します。
親タグや最も近いタグへのアクセス
タグの振る舞いが、使われている文脈、とくにどの親タグの内側にあるかに依存する必要が出てくることがあります。create()
関数に渡される Tag オブジェクトは、まさにそのために
closestTag(array $classes, ?callable $condition = null): ?Tag メソッドを提供します。
このメソッドは、現在開いている Latte タグの階層($tag->parent の連なり。周囲の
HTML 要素はその一部ではありません)を上に向かって探し、指定した条件に合う最も近い祖先の
Tag オブジェクトを返します。合う祖先がなければ null を返します。
$classes
配列は、どんな種類の祖先タグを探しているかを指定します。祖先タグに結びついたノード($ancestorTag->node)のクラスが、並べたクラスのいずれかとちょうど一致するかを調べます。サブクラスは一致とみなされません。
function create(Tag $tag)
{
// ノードが ForeachNode のインスタンスである最も近い祖先タグを探します
$foreachTag = $tag->closestTag([ForeachNode::class]);
if ($foreachTag) {
// ForeachNode のインスタンス自体にアクセスできます:
$foreachNode = $foreachTag->node;
}
}
$foreachTag->node
に注目してください。これが働くのは、私たちがずっとそうしてきたように、create()
メソッドの中で作ったノードをすぐ $tag->node に代入するのが Latte
のタグ開発の慣習だからです。
ノードの型が一致するだけでは足りないこともあります。祖先になり得るタグやそのノードの特定のプロパティを調べたい場合です。closestTag()
の省略可能な第 2 引数は、祖先候補の Tag
オブジェクトを受け取り、それが有効な一致かどうかを返す callable です。
function create(Tag $tag)
{
$dynamicBlockTag = $tag->closestTag(
[BlockNode::class],
// 条件: ブロックは動的でなければならない
fn(Tag $blockTag) => $blockTag->node->block->isDynamic(),
);
}
closestTag()
を使えば、文脈を認識してテンプレートの構造の中で正しい使い方を強制するタグを作れ、より堅牢で分かりやすいテンプレートにつながります。
PrintContext::format() のプレースホルダー
ノードの print() メソッドで PHP コードを生成するために PrintContext::format()
を何度も使ってきました。これはマスク文字列と、マスクのプレースホルダーを置き換える後続の引数を受け取ります。使えるプレースホルダーをまとめておきます。
%node: 引数はNodeのインスタンスでなければなりません。そのノードのprint()メソッドを呼び、できあがった PHP コード文字列を差し込みます。%dump: 引数は任意の PHP の値です。その値を正しい PHP コードに書き出します。スカラー、配列、null に向いています。$context->format('echo %dump;', 'Hello')→echo 'Hello';$context->format('$arr = %dump;', [1, 2])→$arr = [1, 2];
%raw: 引数をエスケープも変更もせずに、そのまま出力の PHP コードに差し込みます。慎重に使ってください。主に生成済みの PHP コードの断片や変数名を差し込むためのものです。$context->format('%raw = 1;', '$variableName')→$variableName = 1;
%args: 引数はExpression\ArrayNodeでなければなりません。配列の要素を関数やメソッドの呼び出しの引数として整形して出力します(カンマ区切り。名前付き引数があればそれも扱います)。$argsNode = new ArrayNode([...]);$context->format('myFunc(%args);', $argsNode)→myFunc(1, name: 'Joe');
%line: 引数はPosition(またはRange)オブジェクトでなければなりません(ふつうは$this->position)。ソースの行と列を示す PHP のコメント/* pos X:Y */を差し込みます。$context->format('echo "Hi" %line;', $this->position)→echo "Hi" /* pos 42:1 */;
%escape(...): 実行時に、現在のコンテキストに応じたエスケープ規則で内側の式をエスケープする PHP コードを生成します。$context->format('echo %escape(%node);', $variableNode)
%modify(...): 引数はModifierNodeでなければなりません。ModifierNodeに指定されたフィルタを内側の内容に適用する PHP コードを生成します。|noescapeで無効にされていなければ、コンテキストに応じたエスケープも含みます。$context->format('%modify(%node);', $modifierNode, $variableNode)
%modifyContent(...):%modifyに似ていますが、取り込んだ内容(しばしば HTML)の塊を加工するためのものです。
引数はゼロから始まるインデックスで明示的に参照できます:
%0.node、%1.dump、%2.raw など。おかげで format()
に何度も渡さずに、マスクの中で引数を複数回使えます。%0.raw と %2.raw
を使った {repeat} タグの例をご覧ください。
複雑な引数解析の例
parseExpression()、parseArguments()
などは多くの場合をカバーしますが、$tag->parser->stream から使える低水準の
TokenStream を用いた、もっと入り組んだ解析ロジックが必要になることもあります。
目標: {embedYoutube $videoID, width: 640, height: 480} というタグを作る。必須の動画
ID(文字列または変数)に続いて、寸法のための省略可能なキーと値の組を解析したいとします。
<?php
namespace App\Templating;
use Latte\CompileException;
use Latte\Compiler\Nodes\Php\ExpressionNode;
use Latte\Compiler\Nodes\StatementNode;
use Latte\Compiler\Tag;
use Latte\Compiler\Token;
class YoutubeNode extends StatementNode
{
public ExpressionNode $videoId;
public ?ExpressionNode $width = null;
public ?ExpressionNode $height = null;
public static function create(Tag $tag): self
{
$tag->expectArguments();
$node = $tag->node = new self;
// 必須の動画 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("Unknown argument '$key'. Expected 'width' or 'height'.", $keyToken->position);
}
}
return $node;
}
// ... print() と getIterator() ...
}
この水準の制御があれば、トークンストリームと直接やり取りすることで、カスタムタグにきわめて限定的で複雑な構文を定義できます。
AuxiliaryNode の使用
Latte
は、コード生成中やコンパイラパスの中の特別な場面のために、汎用の「ヘルパー」ノードを用意しています。AuxiliaryNode
と Php\Expression\AuxiliaryNode です。
AuxiliaryNode
は、中心となる機能、すなわちコード生成と子ノードの公開を、コンストラクタに渡された引数に委ねる柔軟な容れ物のノードだと考えてください。
print()の委譲: 第 1 コンストラクタ引数は PHP のクロージャです。Latte がAuxiliaryNodeのprint()メソッドを呼ぶと、渡されたこのクロージャを実行します。クロージャはPrintContextと、第 2 コンストラクタ引数で渡されたノードを受け取るので、その場でまったく独自の PHP コード生成ロジックを定義できます。getIterator()の委譲: 第 2 コンストラクタ引数はNodeオブジェクトの配列です。Latte がAuxiliaryNodeの子を走査する必要があるとき(コンパイラパスなど)、そのgetIterator()メソッドはこの配列のノードをそのまま yield します。
例:
$node = new AuxiliaryNode(
// 1. このクロージャが print() の本体になります
fn(PrintContext $context, $arg1, $arg2) => $context->format('...%node...%node...', $arg1, $arg2),
// 2. これらのノードは getIterator() が yield し、上のクロージャに渡されます
[$argumentNode1, $argumentNode2]
);
Latte は、生成したコードをどこに差し込む必要があるかに応じて 2 つの型を用意しています。
Latte\Compiler\Nodes\Php\Expression\AuxiliaryNode: 式を表す PHP コードの断片を生成する必要があるときに使いますLatte\Compiler\Nodes\AuxiliaryNode: 1 つ以上の文を表す PHP コードの塊を差し込む、より一般的な用途に使います
print() メソッドやコンパイラパスの中で標準のノード(StaticMethodCallNode
など)ではなく AuxiliaryNode
を使う大事な理由は、後続のコンパイラパス、とくにサンドボックスのようなセキュリティ関連のパスに対する見え方を制御するためです。
こんな場面を考えてみましょう。あなたのコンパイラパスは、ユーザーが渡した式($userExpr)を、信頼できる特定のヘルパー関数の呼び出し
myInternalSanitize($userExpr) で包む必要があります。標準のノード
new FunctionCallNode('myInternalSanitize', [$userExpr]) を作ると、それは AST
の走査器から完全に見えます。あとでサンドボックスのパスが走り、myInternalSanitize
がその許可リストにない場合、サンドボックスはこの呼び出しを遮ったり変えたりして、タグ作者であるあなたはその呼び出しが安全で必要だと分かっているのに、タグの内部ロジックが壊れるかもしれません。そこで、その呼び出しを
AuxiliaryNode のクロージャの中で直接生成できます。
use Latte\Compiler\Nodes\Php\Expression\AuxiliaryNode;
// ... print() やコンパイラパスの中で ...
$wrappedNode = new AuxiliaryNode(
fn(PrintContext $context, $userExpr) => $context->format(
'myInternalSanitize(%node)', // 直接 PHP コードを生成します
$userExpr,
),
// 重要: もとのユーザーの式ノードはここに渡してください!
[$userExpr],
);
この場合、サンドボックスのパスは AuxiliaryNode
を見はしますが、そのクロージャが生成する PHP
コードは解析しません。クロージャの中で生成される myInternalSanitize
の呼び出しを直接遮ることはできません。
生成される PHP
コード自体はパスから隠れますが、そのコードへの入力(ユーザーのデータや式を表すノード)は引き続き走査可能にしなければなりません。だからこそ
AuxiliaryNode のコンストラクタの第 2
引数が決定的に重要なのです。クロージャが使うもとのノード(上の例の $userExpr
など)をすべて含む配列を必ず渡してください。AuxiliaryNode の getIterator()
がそれらのノードを yield
するので、サンドボックスのようなコンパイラパスが問題の有無を解析できます。
ベストプラクティス
- 目的をはっきりさせる: タグに明確で必要な目的があるか確かめましょう。フィルタや関数で簡単に解ける仕事のためにタグを作らないでください。
getIterator()を正しく実装する: 必ずgetIterator()を実装し、テンプレートから解析されたすべての子ノード(引数、内容)への参照(&)を yield してください。これはコンパイラパス、セキュリティ(サンドボックス)、将来の最適化にとって欠かせません。- ノードは公開プロパティに: コンパイラパスが必要に応じて書き換えられるよう、子ノードを保持するプロパティは公開にしましょう。
PrintContext::format()を使う: PHP コードの生成にはformat()メソッドを活用しましょう。引用やプレースホルダーのエスケープを正しく扱い、行番号のコメントも自動で付けてくれます。- 一時変数(
$__): 実行時の PHP コードで一時変数が必要なとき(中間結果の保存、ループのカウンタなど)は、ユーザーの変数や Latte の内部変数$ʟ_との衝突を避けるため、$__の接頭辞の慣習に従ってください。 - 入れ子と一意な ID:
タグが入れ子になり得る、あるいは実行時にインスタンスごとの状態が必要なら、
print()メソッドの中で$context->generateId()を使って$__の一時変数に一意な接尾辞を付けましょう。 - 外部データにはプロバイダを:
値をハードコードしたりグローバルな状態に頼ったりせず、プロバイダ(
Extension::getProviders()で登録)を使って実行時のデータやサービス($this->global->…)にアクセスしましょう。プロバイダ名にはベンダーの接頭辞を使ってください。 - n:属性を検討する: ペアタグが論理的にひとつの HTML 要素に対して働くなら、Latte
はおそらく自動的に
n:属性に対応してくれます。ユーザーの利便性のために覚えておきましょう。属性を変えるタグを作るなら、純粋なn:属性が最もふさわしい形かどうかを検討してください。 - テスト: タグのテストを書きましょう。さまざまな構文の入力の解析と、生成される PHP コードの出力の正しさの両方をカバーしてください。
これらの指針に従えば、Latte のテンプレートエンジンにきれいに溶け込む、強力で堅牢で保守しやすいカスタムタグを作れます。
解析過程の細部をすべて学ぶには、Latte に含まれるノードクラスを読むのが一番です。