Liquid に自作タグを足す ― partial タグで見る作り方
連載の最後は、Liquid に自作タグを 1 つ足す具体的な手順です。題材は、これまで見てきた {% partial "名前" %} タグそのもの。前回(なぜ Liquid に自作タグを 1 枚だけ足したのか)は「素の Liquid に partial を 1 枚足し、それを支える足場を用意した」という設計の話でした。今回はそのタグの中身を開けて、何をどう書けば 1 つのタグになるのかを追います。読者として、テンプレートエンジンを自作・拡張する立場を想定しています。
タグは「基底クラスを継いだクラス」1 つで作る
Liquid(LiquidJS 実装)の自作タグは、エンジンが用意しているタグの基底クラスを継いだクラスとして書きます。書くべきメソッドは実質 2 つだけです。タグを構文解析する瞬間に 1 回だけ走る部分と、ページを描画するたびに走る部分です。骨格はこうなります。
import { Tag } from "liquidjs";
class PartialTag extends Tag {
// ① 構文解析時に 1 回だけ走る
constructor(token, remainTokens, liquid) {
super(token, remainTokens, liquid);
// token.args = "site-header" や "page-heading" title="募集要項" の部分
}
// ② 描画のたびに走る(先頭の * = ジェネレーター)
*render(ctx, emitter) {
// ctx = その時の文脈(変数)、emitter = 出力先
}
}
この 2 つの役割の分担が、自作タグの肝です。タグの「形」を読み取るのは解析時に 1 回でよく、実際に中身を組み立てるのは描画のたびに必要 ― だから前者を constructor、後者を render に分けます。
constructor ― タグの引数を読み取る(解析時に 1 回)
{% partial "site-header" %} や {% partial "page-heading" title="募集要項" %} と書いたとき、タグ名 partial の後ろの文字列がまるごと token.args に入ってきます。constructor の仕事は、ここから「共通パーツの名前」と「key=value の追加引数」を取り出して、インスタンスに覚えておくことです。
constructor(token, remainTokens, liquid) {
super(token, remainTokens, liquid);
// 先頭のクォート文字列を名前として取り出す
const m = token.args.match(/^"([^"]+)"|^'([^']+)'/);
this.partialName = m ? (m[1] ?? m[2]) : "";
// 残り(title="募集要項" など)は描画時に解決するため取っておく
this.extraArgs = m ? token.args.slice(m[0].length).trim() : "";
}
ここで実際の値(title に何が入るか)まで解決しないのがポイントです。解析はテンプレートにつき 1 回ですが、同じテンプレートは違うデータで何度も描画されます。値の解決は描画ごとの文脈に依存するので、constructor では「名前」と「未解決の引数文字列」を取り分けるところまでにとどめ、解決は render に回します。
*render ― 描画のたびに中身を書き出す
描画の本体は render です。先頭に * が付いているのはジェネレーターだからで、これには理由があります。共通パーツの中身はデータベースから読み込むので、その読み込みの完了を待つ必要があります。ジェネレーターにしておくと、処理の途中で「ここで読み込みを待つ」と中断し、終わったら再開する、という書き方ができます。
render は 2 つの道具を受け取ります。ctx はその時の文脈 ― いまどのサイトのページを描いているか、プレビューか、予約時刻が渡っているか、といった情報を含む変数の入れ物です。emitter は出力先で、ここに書いたものがページのその位置に出ます。
*render(ctx, emitter) {
// 文脈から「いまどのサイトか」「プレビュー対象か」を読む
const all = ctx.getAll();
const siteId = all["_publishedSiteId"];
const target = all["_previewPartialName"];
const isPreview = target === this.partialName;
// 名前 + サイトで版を読む(プレビュー時は下書き/予約版を優先)
const templates = isPreview
? yield loadPartialTemplatesDraft(this.partialName, siteId)
: yield loadPartialTemplates(this.partialName, siteId);
// 読み込んだ中身を描画して出力する
yield this.liquid.renderer.renderTemplates(templates, ctx, emitter);
}
yield と書いた箇所が「ここで待つ」地点です。データベースからの読み込みのように時間のかかる処理を yield で待ち、完了してから次へ進みます。文脈を見て公開版か下書き版かを選び、読み込んだテンプレートをその場で描画して emitter に流す ― これが partial の中核です。
プレビュー対象のときは、出力の前後にコメントの目印を書き加えることもできます。ページ全体ができあがった後、画面側がこの目印を手がかりに「いま編集しているパーツの範囲」を色付けします。emitter に好きな文字列を書けるので、こうした「描画結果に印を残す」処理も render の中で完結します。
呼び出し側から値を受け取る ― 文脈に積んで、描き終えたら戻す
{% partial "page-heading" title="募集要項" %} の title のような追加引数は、描画の直前に解決して文脈に積みます。積んだ値は、その共通パーツを描画する間だけ変数として見え、描き終えたら取り除きます。
if (this.extraArgs) {
const resolved = yield resolveAttrs(this.extraArgs, ctx); // { title: "募集要項" }
ctx.push(resolved); // この間だけ {{ title }} が使える
yield this.liquid.renderer.renderTemplates(templates, ctx, emitter);
ctx.pop(); // 描き終えたら元に戻す
}
push と pop で挟むことで、渡した値の有効範囲をその共通パーツの内側だけに閉じ込めます。ある呼び出しで渡した title が、別の場所の title を上書きしてしまう、といった漏れが起きません。値の解決の仕方にも約束があり、クォート付きはその文字どおりのリテラル、クォート無しは文脈から変数として引く ― 前回まで触れた「固定文言はクォート、ページのデータはクォート無し」は、この解決処理が担っています。
タグ 1 つの失敗を、ページ全体に波及させない
描画の途中では何が起きるか分かりません。共通パーツが見つからない、読み込みに失敗する ― そうした事態でページ全体が壊れるのは避けたい。そこで render の本体は、エラーを内側で受け止める囲みの中で走らせます。
*render(ctx, emitter) {
yield* safeRenderBlock("partial", function* () {
// ... 上で見た描画の本体 ...
});
}
この囲みの中で例外が起きても、外へは投げず、そのタグの範囲が出ないだけでページの残りは描画を続けます。起きた失敗は握りつぶさずログに残し、管理画面からも後で確認できます。「一部が欠けてもページは出し続ける/失敗は運用側で拾える」という、公開面で取りたい振る舞いを、この 1 枚の囲みで全タグに共通して効かせています。
出力ではなく <title> を差し替えたいタグの作り方
タグの中には、「その場に何かを出力する」のではなく、ページの別の場所 ― たとえば <head> の中の <title> ― を書き換えたいものもあります。記事本文の途中に置いたタグから、ページの <title> を変えたい、という場合です。出力位置が違うので、その場で emitter.write しても届きません。
これは 2 段構えで解けます。タグはまず、その場に目印のコメントを出力しておきます。ページ全体の描画が終わった後、出力文字列に対して後処理をかけ、その目印を探して本物の <title> に置き換え、目印自体は消します。@nano-cms/core でもこの方式で、本文側のタグからページのタイトルや説明文を差し替えています。自作タグは「その場の出力」だけでなく、「目印を置いて後で回収する」というパターンも取れる、ということです。
登録すれば、テンプレートで使えるようになる
クラスができたら、最後にエンジンへ登録します。タグの名前と、いま書いたクラスを渡すだけです。
export function registerCoreLiquidTags() {
registerLiquidTag("partial", PartialTag);
}
あとは、この登録関数を起動時に 1 回呼べば、テンプレートのどこでも {% partial "名前" %} が使えるようになります。前回触れた「登録はモジュール登録より前に」「開発中に登録が消えないよう控えを持つ」という足場は、ちょうどこの登録関数を呼ぶ起動の段取りの話でした。タグを足す側はこの 1 行を書くだけで、順序や復元といった足場はエンジン側が引き受けます。
自作タグは「基底クラスを継いだクラス 1 つ」と「登録 1 行」でできます。クラスには、引数を読み取る
constructor(解析時に 1 回)と、中身を組み立てる render(描画のたび・ジェネレーターで読み込みを待てる)を書きます。値は文脈に push して描画後に pop し、本体はエラー隔離の囲みに入れる。出力ではなく別の場所を書き換えたいときは、目印を置いて後処理で回収します。3 回を通して、共通パーツの {% partial %} タグを、使う側・なぜ 1 枚だけ足したか・どう作るか、の順に見てきました。テンプレートエンジンを丸ごと作り込まなくても、標準の Liquid に「基底を継いだクラス 1 つ+登録 1 行」を足すだけで、このサイトのデータの持ち方に合った振る舞いを 1 つ加えられます。partial はその最小の実例として、共通パーツの読み込み・値渡し・プレビューでの差し替え・予約版の先取り・サイトごとのスコープを、この 1 枚の中に畳み込んでいます。使うところから作るところまでは、第 1 回(使う側)と第 2 回(なぜ拡げたか)に戻れます。

