ゴール
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
| 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 に手を加えることができるようになった。