jade から pug にアップデートした話 in 開発合宿

pug-logo

こんにちは。Tokyo Otaku Mode(以下 TOM)ソフトウェアエンジニアの稲田です。

TOM では恒例となっている開発合宿が 4 月に行われました。開発合宿ではタスクに応じてチームをわけて取り組みます。今回は主に 3 つのタスクが設定されました。その中で自分が選択した jade2pug チームとして取り組んだことを紹介します。他のチームについては後日記事が出ると思いますのでご期待下さい。

はじめに

JavaScript のテンプレートエンジンである jade は現在 pug と名前を変えてリリースされています。本記事執筆時点でのバージョンは 2.0.0-beta.12 です。しかし TOM ではこれまでかなり古いバージョンの jade を使い続けていました。これによって最新の機能が使えなかったり、公式ドキュメントとの剥離があり微妙に使いづらい部分がありました。はじめは徐々に移行していくことを考えましたが、調べていくうちに古い jade と新しい pug を混在させることは難しいという結論に至りました1。そうなると一気に移行する必要があるのですが、いかんせん既存の jade ファイルの数が多く、通常業務の合間に行うにはタスクが大きすぎます。また、移行している間にもどんどん view template は作成/更新されていくので追従するのも難しい状況でした。

そこで、開発チーム全員が参加する開発合宿で行うことにしました。開発チームが全員参加しているということは、view template が新規作成されたり更新されたりすることがないので影響が抑えられます。こういった影響の大きい変更を行うにはうってつけです。

pug-cli を使ったエラーの修正

TOM では主に Express の render として jade を使っています。最終的にはこの render に pug を使うようにしたいのですが、1 つ 1 つレンダリングして動くかどうかをブラウザで確認していては時間が掛かり過ぎます2。これを解決するには jade <=> pug 間の非互換によるエラーが出るかどうかを機械的に確認できると良さそうです。そこで、コマンドラインで pug のコンパイルができる pug-cli を使用します。

pug-cli をそのまま使う場合の問題点

pug-cli をそのまま使う場合には 2 つの問題があります。

  1. pug-cli は .jade のファイルも対象にしてくれるが、include 文の指定に拡張子が無い場合は .pug として扱われてしまう

    include ../layout.jade //- ../layout.jade が読み込まれる
    include ../layout      //- ../layout.pug が読み込まれる
    
  2. pug-cli はデフォルトで静的な .html を出力しようとするが、その際に各変数参照などが評価されてしまうため、Express のコントローラーから渡している変数を使っていた場合にエラーになってしまう

これら 2 つの問題を回避するために少し工夫が必要です。

pug-cli の動作オプションを変更する

まず下記を pug-options.js として保存します。

'use strict';

const fs = require('fs');
const path = require('path');
const pathJoin = path.join;
const dirname = path.dirname;
const basename = path.basename;

const _read = (filename/*, options*/)=> {
  try {
    return fs.readFileSync(filename, 'utf8');
  } catch (err) {
    const p = pathJoin(dirname(filename), `${basename(filename, '.pug')}.jade`);
    return fs.readFileSync(p, 'utf8');
  }
};

console.warn = ()=> {}

module.exports = {
  client: true,
  plugins: [{read: _read}],
};

このファイルは pug-cli に読み込ませる設定ファイルです。pug-cli には -O, --obj という、設定を JavaScript オブジェクトの文字列またはファイルから読み込むオプションがあります。これを利用して pug および pug-cli 本体には手を加えずに挙動を変えます。

このファイルで行っていることは 2 つです。

1 つは .pug ファイルが読めなかった場合に .jade ファイルにフォールバックする read プラグインを書いて指定します。これによって 1. の問題を回避します。ちなみに pug のプラグインに関するドキュメントは何故かありませんが、ソースコードを読めばどのように作用するかがわかりますのでぜひソースコードを読んでみましょう。

もう 1 つは client: true オプションを指定して、HTML を出力する JavaScript コード へのコンパイルまでに留めて 2. の問題を回避します。

pug-cli の実行

pug-options.js を指定して jade ファイルが置かれているディレクトリを対象に pug-cli を実行してエラーを確認します。

% ./node_modules/.bin/pug -O pug-option.js views/

/path/to/repo/node_modules/pug/node_modules/pug-parser/index.js:59
    throw err;
    ^

Error: views/shared/_ogp.jade:40:88
    38| meta(name="twitter:image", content= og.twitterImage || og_img)
    39| link(rel='shortcut icon', href="#{static_path}/favicon.ico")
  > 40| link(rel='apple-touch-icon', href="#{static_path}/images/common/apple-touch-icon.png", rel='apple-touch-icon-precomposed')
-----------------------------------------------------------------------------------------------^
    41| link(rel='alternate', type='application/rss+xml', href='#{req.conf.url}news/feed', title="Tokyo Otaku Mode News")
    42|

Duplicate attribute "rel" is not allowed.
    at makeError (/path/to/repo/node_modules/pug/node_modules/pug-parser/node_modules/pug-error/index.js:32:13)
    at Parser.error (/path/to/repo/node_modules/pug/node_modules/pug-parser/index.js:53:15)
    at Parser.attrs (/path/to/repo/node_modules/pug/node_modules/pug-parser/index.js:1092:16)
    at Parser.tag (/path/to/repo/node_modules/pug/node_modules/pug-parser/index.js:1012:47)
    at Parser.parseTag (/path/to/repo/node_modules/pug/node_modules/pug-parser/index.js:977:17)
    at Parser.parseExpr (/path/to/repo/node_modules/pug/node_modules/pug-parser/index.js:202:21)
    at Parser.parse (/path/to/repo/node_modules/pug/node_modules/pug-parser/index.js:112:25)
    at parse (/path/to/repo/node_modules/pug/node_modules/pug-parser/index.js:12:20)
    at Object.load.string.parse (/path/to/repo/node_modules/pug/lib/index.js:126:22)
    at Function.loadString [as string] (/path/to/repo/node_modules/pug/node_modules/pug-load/index.js:45:21)

これでエラーがある場合は上記のように表示されます。ここまできたら後はエラーを修正していくだけです。

ですが、この方法にはまだ欠点があります。それは、複数のファイルにエラーがある場合、1 つエラーになるとそこで pug-cli の実行が終了してしまい、後続のファイルのエラーは出力されないことです。ファイル数が少ない場合はあまり問題になりませんが、多い場合には全てのファイルのエラーが出たほうが効率的です。

これは簡単なワンライナーを書くことによって解決できます。

find views/ -type f -name '*.jade' -exec ./node_modules/.bin/pug -O pug-option.js {}>/dev/null \;

ここで find -exec の最後を \+ としてまとめてファイル名を渡すこともできますが、これだと結局 1 つのエラーで後続の処理が止まってしまうことになるので敢えて \; を指定します。

Attribute Interpolation の対応

pug 2 では、属性での Interpolation の記法が変更になりました3

//- old
a(href="before#{link}after")

//- new
a(href=`before${link}after`)

このように ES6 でのテンプレート文字列と同じ記法になりました。これはあくまで属性の場合だけで、通常の String Interpolation は今までどおりの記法で書く必要があります。

//- incorrect
p before${sentence}after

//- correct
p before#{sentence}after

TOM ではこの記法をかなり多用していたため、人力での置換は現実ではありませんでした。そこで、正規表現を使ってざっくりと置換して、取りこぼしたところは人力でカバーしました。これは、完璧な正規表現を作るのを頑張るよりも、そちらのほうが実質早いだろうという判断によるものです。

find views -type f -name '*.jade' -exec perl -p -i -e 's/(\w+=\s*)"([^"]*)#\{(.+?)\}([^"]*)"/\1\`\2\${\3}\4\`/g;' {} \;

フィルター内での String Interpolation の対応

こちらも pug 2 での変更点で、フィルター内で String Interpolation が使えなくなりました。フィルターというのは、pug テンプレートの中で coffee-script や stylus、markdown などの他の言語が使えるようになる機能です4。TOM では下記のようにフィルター内で String Interpolation を利用して直接スクリプトにデータを埋め込むということをしていました。

//- do not work
:coffee
  val = !{JSON.stringify(obj)}

これはあまり良いやり方ではないため、HTML タグの属性に値を埋め込み、それをスクリプト側で取ってくるという方法が望ましいです。

#js-container(data-obj=`${JSON.stringify(obj)}`)

:coffee-script
  val = JSON.parse($('#js-container').attr('data-obj'))

ですが、こちらもそこそこ多用されており、Attribute Interpolation と違ってフィルター内は複数行になることが多いため単純に正規表現で置換することができず、一気に直すには時間が掛かり過ぎると思われました。そこで、以前の挙動に戻してしまうことにしました。

ここで真っ先に思いつくのが、pug 自体に手を入れて、以前の挙動に戻してしまう方法です。pug 本体は何らかの理由でフィルター内での String Interpolation を行わなくしたはずなので、本体に Pull-Request を送ったところで Reject されるのは目に見えています。しかし、fork してしまうと、本体のアップデートへの追従が大変になってしまいます。どうにか上手くできないかと pug のソースを眺めていたところ、前述したプラグイン機構が使えそうだとわかりました。

プラグイン機構を使ったフィルター内 String Interpolation の対応

今回使ったのは preFilterspostFilters の 2 つです。これらのプラグインはそれぞれフィルターが処理される前と処理されたあとに呼び出されます。この 2 つのプラグインを使って、コンパイル済み pug の Abstract Syntax Tree (抽象構文木、以下 AST) を書き換えて、フィルター内の String Interpolation を実現するという力技で解決します。

最終的なコードは下記です。

const walkAST = (node, fn)=> {
  // extends/include except RawInclude
  if (node.file && node.file.ast) {
    walkAST(node.file.ast, fn);
  }
  if (node.block) {
    walkAST(node.block, fn);
  }
  // if
  if (node.consequent) {
    walkAST(node.consequent, fn);
  }
  // else
  if (node.alternate) {
    walkAST(node.alternate, fn);
  }
  if (node.nodes) {
    node.nodes.forEach((node)=> {
      walkAST(node, fn);
    });
  }
  fn(node);
};

const preFilters = (node, options)=> {
  walkAST(node, (node)=> {
    if (node.type.toLowerCase() === 'filter') {
      (options.filterNodeNames = options.filterNodeNames || {})[node.name.toLowerCase()] = null;
    }
  });
  return node;
};

const postFilters = (node, options)=> {
  walkAST(node, (node)=> {
    if (node.name && options.filterNodeNames && options.filterNodeNames[node.name.toLowerCase()] !== undefined) {
      const val = node.val.replace(/^/gm, '| ')
      const tokens = require('pug/node_modules/pug-lexer')(val, {});
      const ast = require('pug/node_modules/pug-parser')(tokens, {});
      delete node.block;
      node.nodes = ast.nodes[0].nodes;
      node.type = 'Block';
    }
  });
  return node;
};

const __express = (path, options, fn)=> {
  options.plugins = options.plugins || [];
  options.plugins.push({preFilters: preFilters, postFilters, postFilters});
  pug.__express(path, options, fn);
}

const app = express();
app.engine 'pug', __express

preFilters プラグイン

まず preFilters プラグインですが、これには pug-parser で構築された AST ノードと options が渡されてきます。処理としては、

  1. AST を再帰的に走査してフィルターを表す AST ノードを探す
  2. 見つかった場合、その AST ノードの名前を options.filterNodeNames というオブジェクトに保持する

ということを行います。ここでの AST ノードの名前にはフィルター名が入ります。例えば

:coffee-script
  console.log 'test'

というフィルターの場合のフィルター名は coffee-script になります。
フィルターの AST ノードの名前を保持する理由については後述します。

postFilters プラグイン

次に postFilters です。こちらも preFilters と同じく AST ノードと options が渡ってきます。postFilters の処理は

  1. AST を再帰的に走査して options.filterNodeNames に含まれている名前を持つ AST ノードを探す
  2. AST ノードの値の先頭に | を付けて pug における PlainText にする
    1. の結果の値を pug-lexer と pug-parser にかけて String Interpolation を処理した結果の AST を得る
  3. フィルターの AST ノードを 3. の AST ノードに置き換える

となっています。
preFilters で保持した options.filterNodeNames を使う理由ですが、postFilters だけではフィルターの AST かどうかの判定ができないからです。postFilters の時点ではフィルターが処理された状態の AST が渡ってきます。ですので、node.typeFilter ではなく Text になってしまっているためフィルターかどうかの判定ができません。以上の理由から、preFilters の時点でフィルターの名前を保存しておき、postFilters でそれを使ってフィルターかどうかの判定をしています。

フィルターの AST ノードが見つかったら次の処理に移ります。フィルターが処理された後の AST ノードには val というプロパティあり、ここにフィルターが処理された後の値が入ってきます。下記のようなフィルターの場合

- var str = 'hoge'
:cofee-script
  console.log '#{str}'

次のように coffee-script でコンパイルされた文字列が val に入ります。

(function() {
  console.log('#{str}');

}).call(this);

この文字列に対して String Interpolation を処理するようにします。そのためにはフィルターが処理された後の文字列を再度 pug に処理させます。このまま pug に渡してもエラーになるので pug の PlainText に変換します。

const val = node.val.replace(/^/gm, '| ')

これにより下記のような結果が得られます。

| (function() {
|   console.log('#{str}');
| 
| }).call(this);

これで準備ができました。上記を pug-lexer と pug-parser にかけて AST を得たあとにそれをフィルターの AST ノードと置き換えて出力すると最終的に下記のような出力が得られます。

(function() {
  console.log('hoge');

}).call(this);

ちゃんと String Interpolation が処理されていることがわかります。

まとめ

今回の開発合宿では TOM の 1 つのネックだった jade を pug に置き換えました。はじめはエラーをつぶすだけの、ただの単純作業になると思っていましたが、Attribute Interpolation の対応や、フィルター内の String Interpolation など解決するべき問題が出てきました。しかし pug のソースコードを読むことで解決方法を見つけ出しました。特に pug のプラグイン機構は公式ドキュメントにも書かれていないので、ソースを読まなければ jade から pug への移行は難しかったのではないかと思います。

最後になりますが、TOM ではフロントエンド/サーバーサイドエンジニアを募集中です。興味があれば pug のソースを読んでいなくても大丈夫ですので、ぜひ話を訊きに来てください。

越境EC: フロントエンドエンジニア
https://www.wantedly.com/projects/53079

越境EC: サーバーサイドエンジニア
https://www.wantedly.com/projects/8214


  1. jade ファイルの中で pug ファイルを extends/include するのが難しい。pug から jade も同様。 [return]
  2. もちろん最終的な動作確認は必要です。 [return]
  3. https://pugjs.org/api/migration-v2.html#attribute-interpolation [return]
  4. https://pugjs.org/language/filters.html [return]