genbitのためのテンプレート言語bitviewを作った
このブログを生成しているgenbitのテンプレートを書くために、bitviewという小さい言語を作った。前の記事ではページの枠をTeraのテンプレートで書くと書いたけど、genbit v0.11.0からTeraをやめてbitviewで書いている。この記事の内容はgenbit v0.14.0とbitview v0.3.0の時点のもので、Teraの話はTeraを使っていた最後の版のgenbit v0.10.2(Tera 2.4.0)のもの。
Teraをやめたのは、記事一覧のようなコンポーネントごとにCSSを置けなかったから。Teraでも記事一覧などのHTMLはincludeで別のテンプレートに分けられたけど、CSSはページ単位でしか分けられなかった。bitviewではコンポーネントが関数になったので、関数ごとにCSSを置けるようにした。
Teraのようなテンプレートエンジンは、HTMLの断片を文字列としてつないでページを作る。bitviewでは、HTMLの要素を関数の呼び出しで作り、ページができあがるまで文字列ではなく要素の木として持つようにした。文字列をつないでいるとエスケープの漏れやタグの入れ子の誤りは出力を見るまで分からないけど、木として持っていれば、bitviewが組み立てるときや文字列にするときにまとめて確かめられる。Teraの機能は次のように置き換えている。
| Tera | bitview |
|---|---|
extends・block | 関数の合成 |
include | 普通の関数呼び出し |
for | map |
safe | 文字列とHTMLを別の型にする |
bitviewの書き方
記事のページはこんな感じで書いている。
(defn entry [ctx EntryPage]
(layout ctx (concat ctx.entry.title " | " ctx.site.title) ctx.entry.description "article"
(home-link ctx.site)
(article
(h1 ctx.entry.title (draft-badge ctx.entry))
ctx.content)))
(タグや日時の部分は省いている)
構文はClojureに似たS式にした。最初はfn entry(ctx) => ...のような独自の構文にしていたけど、もっとLispっぽくしたくなって変えた。
(defn 名前 [引数 型] 本体)で関数を定義する。- HTMLの要素も関数で、
(a {:href "/"} "top")のように最初の引数がレコードなら属性になる。 - 値は文字列、真偽値、リスト、レコード、HTMLの5種類だけで、数値はない。
- 条件は
(if 条件 値 値)、繰り返しは(map リスト 関数名)だけで書く。
Markdownからページまで
genbitが1ページを作る流れは次のようになっている。
views/*.bv を読み込む
│ 全部の関数を引数の型で検査する
▼
Markdownの本文をHTMLの値にする
│ 記事のタイトルやタグと一緒に ctx にまとめる
▼
ページの関数(entry など)に ctx を渡して評価する
│
▼
HTMLの値を文字列にする
│
▼
minifyして dist/ に書き出す
Teraのときは、Markdownの本文をHTMLの文字列にしてから{{ content | safe }}で埋め込んでいた。今は本文もMarkdownのパーサーが出すイベントからHTMLの値として組み立てていて、最後に1回だけ文字列にする。
HTMLの木を文字列にするとき、テンプレートが渡した文字列は必ずエスケープする。文字列をそのままHTMLとして出す手段(Teraのsafeにあたるもの)はないので、記事のタイトルに<が入っていてもタグにはならない。script要素やonclickのような属性、style属性もテンプレートからは書けず、CSSは.cssファイルに書く。
描画する前の検査
bitviewの型を結構厳しくしたくて、テンプレートの誤りはできるだけページを描画する前に見つかるようにした。
関数の引数には必ず型を書く。最初は型を書かずに済むようにしていたけど、コンポーネントを読んだときに引数に何が来るのか分からないのがちょっと嫌で、書くようにした。
(defn draft-badge [entry {:draft Bool}]
(if entry.draft (span {:class "draft-badge"} "draft") []))
EntryPageのようなページ全体の型はgenbitがRustで定義して名前を付けていて、テンプレートでは[ctx EntryPage]のように名前で書く。項目の一覧をテンプレートにもう一度書かないので、genbitとテンプレートで同じ形を二重に持たずに済む。
genbitはビューを読み込むとき、どのページを描画するよりも前に、すべての関数をそれぞれの引数の型で検査する。描画して確かめるだけだと、そのページで通らなかったifの側の誤りは見つからない。型の検査ではifの両側とmapの中も見るので、下書きのときにしか出ない印のような部分の誤りも、ビルドの最初に見つかる。例えば項目の名前を打ち間違えると、こんなエラーになる。
views/components/badge.bv:2:30: unknown field "titel" (fields: title, draft)
in badge
HTMLとしての誤りも検査している。herfのような属性の綴り間違いや、pの中のdivのように、ブラウザが勝手に組み替えてしまう入れ子はビルドエラーになる。
views/page.bv:2:6: <p> cannot contain block elements such as <p> and <div>; it takes text, phrasing elements such as <span> and <a>
in page
コンポーネントごとのCSS
genbitは各ページのHTMLにCSSを埋め込んでいる。Teraのときは、全ページ共通のcommon.cssに、描画するテンプレートと同じ名前のCSSを足していた。この作りだとincludeで読み込むテンプレートにはCSSを付けられないので、トップとタグ別のページでしか使わない記事一覧のCSSもcommon.cssに書くしかなく、記事や404のページにも入っていた(genbitのIssue)。
Teraのcomponentという機能を使って、コンポーネントのHTMLとCSSを同じファイルに置くことも試したけど、genbitがcommon.cssとページのCSSを1つにつないで渡していたので、コンポーネントのCSSを間に挟めなかった。後ろに置くと、ページのCSSで記事一覧の余白を上書きしていたのがコンポーネントのCSSに負けた。
bitviewでは、views/の関数と同じ名前のCSSを並べて置く。
views/
pages/
home.bv home.css
entry.bv entry.css
...
components/
document.bv document.css
entry-list.bv entry-list.css
...
X.cssは関数XのCSSで、ページの関数から呼ばれうる関数のCSSだけがそのページに入る。bitviewは評価が必ず止まるように再帰を禁止していて、そのために読み込むときに関数の呼び出し関係を調べているので、そこから呼ばれうる関数の一覧を取り出している。生成したHTMLを見ると、.entry-listのルールはトップとタグ別のページにだけ入っている。
一覧は呼ばれる側が先になる順で返すので、CSSは骨組み、コンポーネント、ページの順に並ぶ。CSSは詳細度が同じなら後に書いたルールが勝つので、ページのCSSでコンポーネントの見た目を上書きできる。記事のページなら次の順になる。
document.css → draft-badge.css → entry.css
ifは両方の側を数えるので、下書きの印のように記事によって出したり出さなかったりするコンポーネントでも、そのCSSが入るかどうかは記事の内容によらず、ページの関数ごとに決まる。対応する関数がないCSSファイルはビルドエラーにしたので、関数の名前を変えたときにCSSだけ残ることもない。
入れなかったもの
テンプレートでは、genbitが整形した値をHTMLに配置するだけにして、それ以外はなるべく入れなかった。
- 数値や比較の演算子はない。日付や件数は、genbitが整形した文字列で渡している。
- 再帰とループはなく、繰り返しは
mapだけ。 - ファイルの読み込みやテンプレートの継承もない。共通の部分は関数にして呼ぶ。
- 無名関数も、関数を値として渡すこともない。
mapの2つ目の引数にだけ関数の名前を書ける。
標準で使える関数もHTMLの要素とconcat、mapくらいで、テンプレートからファイルやネットワーク、現在時刻のような外のものは読めない。テンプレートが使えるのはgenbitから渡された値だけなので、同じ値を渡せば必ず同じHTMLができる。
エディタの支援
自分で言語を作ると、エディタでシンタックスハイライトも定義へのジャンプも効かないのが地味にきつかったので、VS Codeの拡張機能を作った。
拡張機能では、色付けと、関数の定義への移動と、使っている場所の検索ができる。型の名前からも宣言へ移動できて、genbitの型はgenbit typesというコマンドで書き出したものを読んでいる。
作ってみて
bitviewの相談を始めてから、genbitで使えるようになるまでは1日もかからなかったし、今のv0.3.0までも2日かかっていない。実装はClaude Codeに書いてもらった。何を作ってどういう規則にするかは自分で決めたけど、型の検査や入れ子の検査がどう実装されているのかはよく分かっていない。
自分で言語を作るのはいつか真剣に取り組みたいと思っていたことだったので、理解していなくてもすんなり形になってしまったのは、なんだか複雑な気持ち。
コンポーネントのCSSのIssueはまだ閉じていなくて、Teraで依存するテンプレートを取り出せるようになったら、Teraに戻すかもしれない。