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 には includesextends といった、他のファイルを参照するしくみがある。

これを実現するため、実際の 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 に手を加えることができるようになった。