AAoM-01:Pug テンプレートエンジン
本記事は「Agentic Adventures of MoonBit(AAoM)」シリーズの第 1 回です。
MoonBit と AI の支援を活用し、実用的なライブラリを一つ完成させるまでの過程を、できるだけ透明に記録していきます。
今回は Pug テンプレートエンジン を MoonBit で実装しました。
開発には Claude Code(Opus 4.5) を使用し、MoonBit 用に用意した system prompt と IDE の支援機能を組み合わせています。
基本的な流れは非常にシンプルです。
要件を私が説明する
Claude がコードを書く
私がレビューし、修正・改善する
Claude は moon new によるプロジェクト生成、
moon.mod.json / moon.pkg.json の理解、
MoonBit らしいコードの生成において、かなり高い精度を示しました。
課題
生の HTML は冗長で、ネスト構造を手書きするのは面倒かつミスが起きやすいものです。
Pug(旧 Jade) は、インデントベースの簡潔な記法によって、この問題を解決します。
doctype html
html
head
title My Site
body
h1#greeting.hero Hello, World!
今回の目標は、MoonBit で Pug → HTML のコンパイラを実装することです。
対応範囲は、Pug の主要機能を網羅することを目指しました。
タグ、属性
文字列補間
条件分岐、ループ
mixin
include / extends によるテンプレート継承
アプローチ
まず Claude に Pug の公式仕様を読ませ、テストを先に書かせました。
以下はその一例です。
test "case with fall through" {
let pug =
#|case num
#| when 0
#| when 1
#| when 2
#| p Small number
#| default
#| p Large number
let locals = Locals::new()
locals.set("num", "1")
let html = render_with_locals(pug, locals)
inspect(html, content="<p>Small number</p>")
}
最終的に 18 個のブラックボックステストファイルに 153 個のテストが含まれています。
この テスト駆動(TDD)スタイルにより、仕様の抜け漏れやエッジケースを早期に洗い出すことができました。
実装が始まった後は、生成されたテストを都度レビューしながら進めています。
コンパイラの構成は、比較的オーソドックスです。
lexer.mbt
parser.mbt
render.mbt
実装上のポイント
● ファイルシステム対応(include / extends)
ファイル読み込み周りは Claude が苦手とする部分だったため、
@moonbitlang/x/fs を使って明示的に補強しました。
最初に Claude が提案した API は以下のようなものでした。
let registry = TemplateRegistry::new()
registry.register("includes/head.pug", @fs.read_file_to_string("example/includes/head.pug"))
registry.register("includes/foot.pug", @fs.read_file_to_string("example/includes/foot.pug"))
let html = render_with_registry(source, Locals::new(), registry)
最終的には、より自然な API に整理しました。
let html = render_file("example/index.pug")
render_file は入力ファイルのパスを基準に、
include / extends を自動的に解決します。
● JavaScript 式の補間
最も難しかったのは、#{msg.toUpperCase()} のような
JavaScript 式の評価です。
MoonBit は複数バックエンド(Wasm / Native / JS)を想定しているため、
JS インタプリタを実装するのは現実的ではありません。
そこで、JS バックエンドでは直接 eval を呼び出す設計にしました(疑似コード)。
extern "js" fn eval(expr : String) -> String = "(expr) => eval(expr)"
JS 以外のバックエンドでは
「JS バックエンドのみ対応」と明示的にエラーを出すようにしています。
● CLI(コマンドラインインターフェース)
最後に CLI を実装しました。
この部分はほぼ Claude に任せ、私は実行結果を確認しながら微調整しています。
結果
最終的に、以下の機能を備えた 実用的な Pug テンプレートエンジンが完成しました。
タグ、ID、クラス、属性
インデントベースのネスト
文字列補間(#{} / !{})
条件分岐(if / else if / else / unless)
ループ(each / while)
引数・ブロック付き mixin
テンプレート継承(include / extends / block)
JSON locals を受け取る CLI
テストはすべて成功しています
(共通テスト 140、JS 専用テスト 13)。
● CLI 使用例
$ echo '{"name": "World"}' > data.json
$ echo 'h1 Hello #{name}!' > greeting.pug
$ moon run cmd/main -- -O data.json greeting.pug
<h1>Hello World!</h1>
● コンパイル済みテンプレートの再利用
テンプレートは一度コンパイルすれば、
異なる locals で何度でもレンダリングできます。
test "compile api" {
// Compile template once
let template = @pug.compile("p Hello #{name}!")
// Render with different locals
let locals1 = @pug.Locals::new()
locals1.set("name", "Alice")
inspect(template.render(locals1), content="<p>Hello Alice!</p>")
let locals2 = @pug.Locals::new()
locals2.set("name", "Bob")
inspect(template.render(locals2), content="<p>Hello Bob!</p>")
}
API は Pug 本家に近い設計になっています。
振り返り
テスト先行は非常に効果的
Claude に仕様を読ませてテストを書かせることで、抜け漏れを防げました。
MoonBit のパターンマッチは AST 処理を明快にします。
ファイル I/O やエラーハンドリングなど、
Claude が MoonBit に不慣れな部分は、人間側の補助がまだ必要です。
私はよく Claude に
「まだ実装されていない Pug の機能は?」
と聞き、TODO を洗い出させてから実装させています。
所要時間
実作業時間はおよそ 6 時間でした。
テスト生成:数分
コア機能:3 時間
include / extends:1 時間
JS 補間・CLI:2 時間
Code is available on github.