pug でカスタムタグをあつかう
ゴール
form-button(label="Label")
のような入力を与えたときに
<button type="button">Label</button>
のような出力を得る。
pug の処理の流れ
おおまかにいうと、 字句解析 → 構文解析 → コード生成、の順をおって最終成果物 (html) が生成されている。 各フェーズは npm module (pug のソースコード的には packages ディレクトリ) に分割されており、個別の処理を追うのはそこまで難しくない。また、全体の処理は pug/lib/index.js を読むと流れがわかる。
pug-lexer (字句解析器)
以下、下記の pug ソースを処理していくこととする。
div.container#main(style="margin: 2rem;") h1 Heading //- comment | Hello, #{ world + '?' }!
まずは pug-lexer による字句解析フェーズ。
const lex = require('pug-lexer'); const source = ` div.container#main(style="margin: 2rem;") h1 Heading //- comment | Hello, #{ world + '?' }! `; console.log(JSON.stringify(lex(source), null, ' '));
この出力は以下のような感じ (長過ぎるので抜粋)。
[ { "type": "newline", "loc": { "start": { "line": 2, "column": 1 }, "end": { "line": 2, "column": 1 } } }, { "type": "tag", "loc": { "start": { "line": 2, "column": 1 }, "end": { "line": 2, "column": 4 } }, "val": "div" }, // ... ]
pug-strip-comment (コメントの削除)
pug-strip-comment によってコード中のコメントを削除する。
たいした内容ではないので、このフェーズのコードは省略する。
pug-parser (構文解析器)
lexer により解析された token の構文解析をおこなうのが pug-parser である。
const lex = require('pug-lexer'); const stripComments = require('pug-strip-comments'); const parse = require('pug-parser'); const source = `...`; console.log(JSON.stringify(parse(stripComments(lex(source))), null, ' '));
少し長くなるが、結果を掲出する。
{ "type": "Block", "nodes": [ { "type": "Tag", "name": "div", "selfClosing": false, "block": { "type": "Block", "nodes": [ { "type": "Tag", "name": "h1", "selfClosing": false, "block": { "type": "Block", "nodes": [ { "type": "Text", "val": "Heading", "line": 3, "column": 6 } ], "line": 3 }, "attrs": [], "attributeBlocks": [], "isInline": false, "line": 3, "column": 3 }, { "type": "Text", "val": "Hello, ", "line": 5, "column": 5 }, { "type": "Code", "val": " world + '?' ", "buffer": true, "mustEscape": true, "isInline": true, "line": 5, "column": 12 }, { "type": "Text", "val": "!", "line": 5, "column": 28 } ], "line": 2 }, "attrs": [ { "name": "class", "val": "'container'", "line": 2, "column": 4, "mustEscape": false }, { "name": "id", "val": "'main'", "line": 2, "column": 14, "mustEscape": false }, { "name": "style", "val": "\"margin: 2rem;\"", "line": 2, "column": 20, "mustEscape": true } ], "attributeBlocks": [], "isInline": false, "line": 2, "column": 1 } ], "line": 0 }
これが pug における、いわゆる AST (抽象構文木) となる。
pug-load (ローダ)
字句解析からスタートしたが、 pug には includes や extends といった、他のファイルを参照するしくみがある。
これを実現するため、実際の pug では、ファイル読み込みを担う pug-load から lexer や parser を呼び出す形になっている。
(もちろん、 pug-load を利用せずに、これまでみてきたように lexer や parser を直接呼び出すやりかたでも正常に動作する)
pug-link (最適化)
pug-load により、外部ファイルを参照して include したり extend したりすることができるようになっているわけであるが、その参照先を実際に埋め込んだり flat 化したりするのが pug-link 、らしい。 しかしちゃんと調べていない。
pug-code-gen (コード生成)
AST をもとに、 JavaScript コードを生成するのが pug-code-gen である。
const lex = require('pug-lexer'); const stripComments = require('pug-strip-comments'); const parse = require('pug-parser'); const generateCode = require('pug-code-gen'); const source = `...`; const code = generateCode(parse(stripComments(lex(source))), { pretty: true, compileDebug: false, }); console.log(code);
実行結果は以下のようになる。
function template(locals) {var pug_html = "", pug_mixins = {}, pug_interp;;var locals_for_with = (locals || {});(function (world) {var pug_indent = []; pug_html = pug_html + "\n\u003Cdiv class=\"container\" id=\"main\" style=\"margin: 2rem;\"\u003E\n \u003Ch1\u003EHeading\u003C\u002Fh1\u003EHello, " + (pug.escape(null == (pug_interp = world + '?') ? "" : pug_interp)) + "!\n\u003C\u002Fdiv\u003E";}.call(this,"world" in locals_for_with?locals_for_with.world:typeof world!=="undefined"?world:undefined));;return pug_html;}
関数オブジェクトが戻るわけではなく、 JavaScript のソースコードが文字列形式で戻ることに注意。
見づらいので整形すると、以下のとおり。
function template(locals) { let pug_html = ''; const pug_mixins = {}; let pug_interp; const locals_for_with = locals || {}; (function (world) { const pug_indent = []; pug_html = `${pug_html}\n\u003Cdiv class="container" id="main" style="margin: 2rem;"\u003E\n \u003Ch1\u003EHeading\u003C\u002Fh1\u003EHello, ${pug.escape( (pug_interp = `${world}?`) == null ? '' : pug_interp )}!\n\u003C\u002Fdiv\u003E`; }.call( this, 'world' in locals_for_with ? locals_for_with.world : typeof world !== 'undefined' ? world : undefined )); return pug_html; }
レンダリング
ブラウザ上でレンダリングする場合、単純に上記で得られた JavaScript コードを実行すればレンダリングできる。
const compiled = new Function('', `${code};return template;`); console.log(compiled());
Node.js 上で実行する場合、ブラウザでは用意されている関数等が足りていないので pug-runtime によるラッパーを利用してコードを生成する必要がある。
const runtimeWrap = require('pug-runtime/wrap'); const compiled = runtimeWrap(code); console.log(compiled());
実行結果は (code-gen で pretty: true
を指定したため) 以下のようになる。
<div class="container" id="main" style="margin: 2rem;"> <h1>Heading</h1>Hello, undefined?! </div>
カスタムタグの実装
ゴールで明示したとおり、
form-button(label="Label")
のようなカスタムタグ (form-button
) を記述したときに
<button type="button">Label</button>
のような出力を得たい。
そのためになにをやればよいかというと、 AST を解析して、 form-button
というタグが指定された場合に、 <button>
タグとして出力される AST node 群で差し替えればよい。
差し替えするノードの算出
これまで見てきたのと同じしくみをもちいて、差し替え後の AST を取得する。
const lex = require('pug-lexer'); const stripComments = require('pug-strip-comments'); const parse = require('pug-parser'); const macro = ` button(type="button") {{label}} `; console.log(JSON.stringify(parse(stripComments(lex(macro))), null, ' '));
結果は、
{ "type": "Block", "nodes": [ { "type": "Tag", "name": "button", "selfClosing": false, "block": { "type": "Block", "nodes": [ { "type": "Text", "val": "{{label}}", "line": 2, "column": 23 } ], "line": 2 }, "attrs": [ { "name": "type", "val": "\"button\"", "line": 2, "column": 8, "mustEscape": true } ], "attributeBlocks": [], "isInline": false, "line": 2, "column": 1 } ], "line": 0 }
差し替えするときには、大外の Block
ノードは不要なので、下位の nodes の先頭を用いればよい。
したがって、引数 label
を与えられたときに、変換後の node を返す関数は以下のようになる。
function renderMacro(label) { return { type: 'Tag', name: 'button', selfClosing: false, block: { type: 'Block', nodes: [ { type: 'Text', val: label, }, ] }, attrs: [ { name: 'type', val: '"button"', mustEscape: true, }, ], attributeBlocks: [], isInline: false, }; }
pug-walk によるトラバーサル
AST の差し替えは、自力で再帰を利用したりしてトラバーサルするのも手であるが、便利なツールが pug ファミリーに存在する。 それが pug-walk である。
walk 関数に、もとの AST と、ツリーをたどるときに呼ばれる関数をわたして呼び出すと、変換後の AST が返る。といいたいところだが、残念ながら mutable な関数なので、もとの AST 自身も変換される。
pug-walk を利用した変換器は以下のようになる。
const walk = require('pug-walk'); function renderMacro(label) { return { // 略 }; } function stripQuote(src) { return src.replace(/^"(.*)"$/, '$1'); } const source = ` div form-button(label="Label") `; const ast = walk(parse(stripComments(lex(source))), null, (node, replace) => { if (node.name === 'form-button') { const targetAttrs = node.attrs.filter(it => { return it.name === 'label'; }); const label = targetAttrs.length > 0 ? stripQuote(targetAttrs[0].val) : 'LABEL'; replace(renderMacro(label)); } }); console.log(runtimeWrap(generateCode(ast, { pretty:true }))());
結果は、
<div> <button type="button">Label</button> </div>
無事ゴールが達成できた。
pug の plugin
以上のように、 pug-parser や pug-code-gen を自力で呼び出して AST を変換すればカスタムタグを実装することができるが、実は pug には plugin system があり、これを利用することで、 pug の処理の途中に介入することができる。
pug の plugin system については、なぜか公式ドキュメントで言及されていない気がするが、 pug の compileBody() メソッド を読むとその挙動 (仕様) がわかる。
だいたい以下のような処理を経るようだ。
preLex
plugin (引数: source string)- lex phase
postLex
plugin (引数: tokens)- stripComments phase
preParse
plugin (引数: tokens)- parse phase
postParse
plugin (引数: ast)preLoad
plugin (引数: ast)- (load 処理)
postLoad
plugin (引数: ast)preFilters
plugin (引数: ast)- handleFilters phase
postFilters
plugin (引数: ast)preLink
plugin (引数: ast)- link phase
postLink
plugin (引数: ast)preCodeGen
plugin (引数: ast)- generateCode phase
postCodeGen
plugin (引数: JavaScript source string)- execute phase
pug plugin として実装する
上記のように ast をさわれる plugin phase はいくつかあるのだが、今回のカスタムタグについては、とりあえず preCodeGen
phase にしかけることにした。
const pug = require('pug'); const walk = require('pug-walk'); function renderMacro(label) { return { // 略 }; } function stripQuote(src) { return src.replace(/^"(.*)"$/, '$1'); } const source = ` div form-button(label="Label") `; console.log( pug.render(source, { plugins: [ { preCodeGen: (ast, options) => { return walk(ast, null, (node, replace) => { if (node.name === 'form-button') { const targetAttrs = node.attrs.filter(it => { return it.name === 'label'; }); const label = targetAttrs.length > 0 ? stripQuote(targetAttrs[0].val) : 'LABEL'; replace(renderMacro(label)); } }); }, }, ], }) );
これで自力で lexer や parser をよびだすことなく、処理途中で ast に手を加えることができるようになった。