jade から pug にアップデートした話 in 開発合宿
こんにちは。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 つの問題があります。
- pug-cli は
.jade
のファイルも対象にしてくれるが、include 文の指定に拡張子が無い場合は.pug
として扱われてしまう
include ../layout.jade //- ../layout.jade が読み込まれる
include ../layout //- ../layout.pug が読み込まれる
- 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 の対応
今回使ったのは preFilters
と postFilters
の 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 が渡されてきます。処理としては、
- AST を再帰的に走査してフィルターを表す AST ノードを探す
- 見つかった場合、その AST ノードの名前を
options.filterNodeNames
というオブジェクトに保持する
ということを行います。ここでの AST ノードの名前にはフィルター名が入ります。例えば
:coffee-script
console.log 'test'
というフィルターの場合のフィルター名は coffee-script
になります。
フィルターの AST ノードの名前を保持する理由については後述します。
postFilters プラグイン
次に postFilters
です。こちらも preFilters
と同じく AST ノードと options が渡ってきます。postFilters
の処理は
- AST を再帰的に走査して
options.filterNodeNames
に含まれている名前を持つ AST ノードを探す - AST ノードの値の先頭に
|
を付けて pug における PlainText にする - の結果の値を pug-lexer と pug-parser にかけて String Interpolation を処理した結果の AST を得る
- フィルターの AST ノードを 3. の AST ノードに置き換える
となっています。preFilters
で保持した options.filterNodeNames
を使う理由ですが、postFilters
だけではフィルターの AST かどうかの判定ができないからです。postFilters
の時点ではフィルターが処理された状態の AST が渡ってきます。ですので、node.type
は Filter
ではなく 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
- jade ファイルの中で pug ファイルを extends/include するのが難しい。pug から jade も同様。 [return]
- もちろん最終的な動作確認は必要です。 [return]
- https://pugjs.org/api/migration-v2.html#attribute-interpolation [return]
- https://pugjs.org/language/filters.html [return]