1,100 超えコンポーネントの Jade / Pug テンプレートを移行した話

こんにちは!
銀座でお昼パクチー食べれるお店があって大変満足している @kazupon です。

PLAID は KARTEという「リアルタイムユーザー解析プラットフォーム」を提供しており、様々な機能を提供します。また、その様々機能を制御するために管理コンソールを Web アプリケーションとして提供しており、Vue.js で作られています。

管理コンソールは、1,100 超える単一ファイルコンポーネント (Single-file components)で構築されており、テンプレートのマークアップとして Jade / Pug を使用しています。

この記事では、単一ファイルコンポーネントでこれまでテンプレートとして利用してきた Jade / Pug を Web 標準のマークアップ HTML への移行について紹介します。

そもそも何で移行しようと思ったのか

移行の話に入る前に、ちょっと背景的な話をします。

i18n 対応効率化のため導入しようとしたが...

筆者は KARTE 管理コンソール画面の i18n 対応を効率化するために、筆者が作成した ESLint 向けのプラグイン eslint-plugin-vue-i18n を導入を試みました。

pug-to-html-image1

ESLint は、標準では JavaScript で書かれたコードを解析します。TypeScript や Vue.js の単一ファイルコンポーネントのような JavaScript 以外の場合はカスタムパーサーが必要になります。

Vue.js では ESLint をサポートするために、eslint-plugin-vue という ESLint プラグインを公式で提供していますが、カスタムパーサーは vue-eslint-parser を利用しています。

vue-eslint-parser は単一ファイルコンポーネントのテンプレートのマークアップとして標準である HTML には対応していますが、Jade / Pug には対応していません。

vue-eslint-parser の GitHub のレポジトリに、Pug の対応をサポートするための Pull Request がありますが、残念ながら以下のように放棄してしまっている状況でした。

pug-to-html-image2

Jade / Pug 辞めよう運動勃発

PLAID 社内ではセキュリティ強化の取り組みとして、セキュリティ組がセキュリティハッカソンを定期的に開催しています。

セキュリティハンズオンの他に、セキュリティ組は社内で様々な取り組みをしていますが、ESLint を利用した 静的コード解析による Vue.js アプリケーションコードの脆弱性の洗い出しを試みてはいます。しかし、vue-eslint-parser の Jade / Pug の未サポート問題に遭遇し、断念しています。

そんな中、筆者が eslint-plugin-vue-i18n の導入撃沈ボヤキを社内 Slack でコメントしたところ、Jade / Pug 辞めよう運動が勃発しました。

pug-to-html-image3

移行 issue が爆誕する

その後、以下のような issue が誕生し、メリット/デメリットの内容を元に社内で検討した結果、Jade / Pug のテンプレートを HTML に移行することになりました。

pug-to-html-image4

移行に当たり対応したこと

変換ツールを機能拡張する

移行するに当たり、まずツールを探しました。探したところ、vue-pug-to-html という変換ツールを発見しましたが、vue-pug-to-html は Pug しか対応していませんでした。

KARTE の管理コンソールは、構築されている単一ファイルコンポーネントのテンプレートは、名称が商標の問題で Pug に変わる前の Jade も利用しています。

Jade と Pug の構文は微妙に違いがありますが、移行するあたって Jade もサポートする必要があります。そこで、PLAID では vue-pug-to-html をfork (フォーク) して、独自に拡張した変換ツール pug-to-html を作ることに決めました。

vue-pug-to-html を拡張させることによって Jade をサポートできるようにしましたが、対応した重要な点としては、以下があります。

  • Jade / Pug のテンプレートエンジンのオプションに doctype を指定する
  • タグの属性中の HTML Entities の変換

Jade / Pug のテンプレートエンジンのオプションに doctype を指定する

Jade / Pug では、HTML の DOCTYPE と同様に doctype で文書型を宣言することができます。

しかしながら、単一ファイルコンポーネントのテンプレートでは、doctype を指定することができないので、 Jade / Pug のテンプレートエンジンのオプションに、 doctype: 'html' を指定する必要があります。

doctype: 'html' オプションを指定しないと、例えば、

<template lang="jade">
  #app
   p(v-if="toggle") hello!
   p(v-else) world!
</template>

このようなテンプレートは、以下のような HTML に変換されてしまいます。

<template>
  <div id="app">
   <p v-if="toggle">hello!</p>
   <p v-else="v-else">world!</p>
  </div>
</template>

上記の v-else="v-else" のように、Jade / Pug テンプレートエンジンは、内部で勝手に文書型を解釈してしまいます。Vue.js のテンプレート構文としてこのような正しくない形式に変換されてしまうと、アプリケーションも正しく動作しなくなってしまいます。このため、 Jade / Pug のテンプレートエンジンに、Vue.js のコンパイラが解釈する HTML5 の文書型になるよう doctype: 'html' オプションの指定は必須です。

タグの属性中の HTML Entities の変換

Jade / Pug のテンプレートエンジンは、例えば、タグの class といった属性において属性の文字列において >< といった HTML Entities はデフォルトでエスケープするようになっています。

しかしながら、Vue.js のおいて、v-if のような一部ディレクティブは、HTML5 の data-* のカスタムデータ属性のように、属性に一部 JavaScript のロジックようなものを記述する場合があります。

なので、例えば

<template lang="jade">
  p(v-if="Math.random() > 0.5") こんにちは!
</template>

このようなテンプレートは、何も対応しないままだとテンプレートエンジンでデフォルトエスケープされてしまい、以下のようになってしまいます。

<template>
  <p v-if="Math.random() &gt; 0.5">こんにちは!</p>
</template>

このようにエスケープされて HTML に変換されてしまうと、Vue.js は v-if の属性に記述されたロジックを正しく解釈できず、単一ファイルコンポーネントは正しく動きません。このため、Jade / Pug のテンプレート解析処理において、Vue.js のテンプレート構文の場合は、属性のエスケープをしないように対応する必要があります。

この問題に対応するために、今回作成した変換ツールにおいて、Pug の方は、Pug のプラグイン機構と pug-walk という Visitor 関数を使うことで Pug の AST (Abstract Syntax Tree: 抽象構文木) の要素 (Node) 対してハンドリングできます。Vue.js のテンプレート構文の場合は、属性のエスケープしないように対応しています。

Jade の場合も同様のことがしたいのですが、調べたのですが、 pug-walk のような AST でハンドリングできる機構や他の方法を見つけることができませんでした。そこで、Pug の Jade からのマイグレーションガイドを確認しつつも、 Jade のテンプレートを Pug のテンプレートとして強制するよう修正し、さまざま Jade テンプレートにいろいろ検証し、問題なさそうでした。

しかし、このよう強制変換処理してしまうようにしたことが、後々痛い目に会うことになります。

Jade / Pug の mixin を Vue.js のコンポーネントで置き換える

Jade / Pug には、mixin というテンプレートをモジュール化する機能があります。KARTE の管理コンソールのテンプレートで、この mixin を使っている単一ファイルコンポーネントがあります。

今回拡張した変換ツールをかけると、 Jade / Pug の mixin は、呼び出している箇所も変換しますが、呼び出している箇所の分、同じ内容で変換されてしまいます。例えば、以下のようになってしまいます。

変換前:

<template lang="pug">
  mixin msgboard(msg)
    .message
      span #{msg}

  #app
    +msgboard('hello')
    +msgboard('world')
</template>
...

変換後:

<template >
  <div id="app">
    <div class="message">
      <span>hello</span>
    </div>
    <div class="message">
      <span>world</span>
    </div>
  </div>
</template>
...

KARTE の管理コンソールは、現在進行系で開発が進んでおり、重複したテンプレート箇所が存在するのは、メンテナンスしにくくなります。そして修正漏れでバグを生み出しやすいくなってしまうため、そのまま変換ツールにかけてしまうのは、あまりよろしくありません。

Jade / Pug の mixin は、Vue.js でコンポーネントとして実装し、mixin を呼び出している箇所をそのコンポーネントで置き換えていきました。

Jade / Pug の mixin を Vue.js のコンポーネントに置き換える(実装)方法はいろいろありますが、 Jade / Pug の mixin を単一ファイルコンポーネントとして .vue ファイルに抽出する方法として、先程の msgboard を例を使うと以下のようになります。

msgboard mixin を MsgBoard コンポーネントとして抽出:

<template lang="pug">
  div.message
    span #{msg}
</template>

<script>
export default {
  name: 'MsgBoard',
  props: ['msg']
}
</script>

抽出した MsgBoard コンポーネントをインポートし、msgboard mixin の呼び出し部分を MsgBoard コンポーネントで置き換え:

 <template lang="pug">
-  mixin msgboard(msg)
-    .message
-      span #{msg}
-
   #app
-    +msgboard('hello')
-    +msgboard('world')
+    MsgBoard(msg="hello")
+    MsgBoard(msg="world")
 </template>

 <script>
+import MsgBoard from './MsgBoard.vue'
+
 export default {
   // ... 
   components: {
    // ...
+   MsgBoard
   }
   // ... 
 }
 </script>

Jade/Pug のコメントを残すようにする

Jade / Pug のコメントは、以下の2種類あります。

  • // - : HTML コメント <-- --> として残さない
  • // : HTML コメント <-- --> として残す

KARTE の管理コンソールの単一ファイルコンポーネントは、他の人がメンテナンスできるよう //- でコメントしています。 UI として表示される際、Vue.js のコンパイル処理前に、Jade / Pug のテンプレートエンジンによって取り除かれているため、HTML にはコメントがない状態になっています。

単一ファイルコンポーネントのテンプレートを Jade / Pug から HTML に変換する際には、このまま変換をかけてしまうと、コメントが削除されてしまうため、開発上具合がよくありません。

このため、Jade / Pug から HTML に変換した祭に、//- でコメントされた内容が残るよう、以下のようにコマンドですべての単一ファイルコンポーネントに //- から // に置換する処理をしました。

$ find ./src/components -name "*.vue" -not -path "*/node_modules/*" | xargs sed -i -e "s/\/\/\-/\/\//"

変換ツールで Jade / Pug を HTML に変換する

これまでの対応で、Jade / Pug を HTML に変換の準備できたので、あとは拡張した変換ツール pug-to-html で変換するたけです。KARTE の管理コンソールは 1,100 を超える単一ファイルコンポーネントがあるので、以下のコマンドで一括変換しました。(社内の他のチームの諸事情で Pug 何匹か(src/components/segments/*)は残しています )

$ find ./src/components -name "*.vue" -not -path "*/node_modules/*" -not -path "src/components/segments/*"  | xargs -I {} pugToHtml {}

変換した際に、いくつか Jade / Pug のテンプレートエンジンがエラーや警告メッセージを出力されたので、そのメッセージ内容を元に Jade / Pug のテンプレートに修正を入れつつ、上記コマンドを変換をかけて対応していきました。

変換に伴い怒りだした ESLint を鎮める

テンプレートの変換が完了したあとは、eslint-plugin-vue による ESLint のリントが走り始めるのですが、以下のようなリントエラーが大量に出力されました。

$  eslint . --ext .js,.vue

/path/to/karte/src/components/Foo.vue
  3:5  error  Elements in iteration expect to have 'v-bind:key' directives  vue/require-v-for-key

...

/path/to/karte/src/components/Bar.vue
  6:9   error  Elements in iteration expect to have 'v-bind:key' directives                                                          vue/require-v-for-key
  6:14  error  'v-for' directives require that attribute value                                                                       vue/valid-v-for
  6:32  error  Parsing error: Line 1: Unexpected token, expected ;

> 1 | for(let score, idx in scores);
    |                    ^  vue/no-parsing-error

✖ 613 problems (613 errors, 0 warnings)

あまりにも大量の eslint-plugin-vue のリントエラーなので、すべて対応するのが大変です。数が多いルールは、ESLint の設定 .eslintrc.json で該当ルールを off にして ESLint のご機嫌を取りました。

リントエラーの対応が可能な部分は、eslint-plugin-vue の公式ドキュメントで、ルールを検索かけながら、対処していきました。

テストをする

テンプレートの変換、そして ESLint の対応で、コードに修正が入っているのでデグレしていないかどうかテストしなければなりません。

KARTE では、単体テスト、E2E テストが既にあり、Circle CI を GitHub と連携させることによって、CI (継続的インテグレーション)を導入済みです。なので、今回の変換のために、テストコードを追加等の特に大きな作業は必要ありませんでした。

いよいよプロダクションへ投入しようかと思ったら...

今回拡張した変換ツールで Jade / Pug テンプレートから HTML への変換対応後、無事 CI が通りました。その後、GitHub に投げた Pull Request がマージされ、プロダクション環境にデプロイされました。以下のはその時に、Pull Request です。

put-to-html-image5

1,100 を超えるファイル変更数、40,000 を超えるコードの行数変更。圧巻です。

CI も通り、プロダクションにリリースできそうと思ってたところに、問題が発覚。

pug-to-html-image6

変換した単一ファイルコンポーネントのテンプレートでいくつかインデントがうまく変換されていないことが判明。

何でこうなってしまったのか、調べていくと、

pug-to-html-image7

そうです。Jade と Pug とでは改行 | の HTML 変換、互換性がない驚愕の事実が ... 😇

Pug のマイグレーションガイドを確認しつつ、変換ツールで Jade を Pug で強制変換するようにして動作検証したにも関わらず、改行 | が互換性がない問題に遭遇してしまうとは... 検証が漏れてしまったようです... 怖いですね...

Jade に対するモンキーパッチ

これを踏まえ、Jade から HTML に変換したかなりの数の単一ファイルコンポーネントを、もう一度対応する必要あるのですが、変換ツール pug-to-html を Jade に対して正しく変換されるように対応しなければなりません。

Pug の場合 pug-walk を使って、 Visitor 関数でタグの属性が Vue.js のテンプレート構文の場合は、HTML に変換した際にエスケープしないように拡張することができました。しかしながら、Jade の場合は、 pug-walk のように Visitor 関数や他の機構で対応することができません。

さて、どうしたらいいんでしょうか。🤔

属性のエスケープ処理を手がかりに、Jade のコードを読み漁った結果、 Attrs に対して以下のようなモンキーパッチを当てれば、Vue.js のテンプレート構文の場合は、HTML に変換した際にエスケープしないようにできることが分かりました。

// monkey patch for jade
const Attrs = require("jade/lib/nodes/attrs");
Attrs.prototype.setAttribute = function(name, val, escaped) {
  if (name !== "class" && this.attributeNames.indexOf(name) !== -1) {
    throw new Error("Duplicate attribute \"" + name + "\" is not allowed.");
  }
  this.attributeNames.push(name);
  const bindAttrRE = /^v-bind:|^:/;
  const eventAttrRE = /^v-on:|^@/;
  const slotAttrRE = /^v-slot:|^#/;
  const dirAttrRE = /^v-([^:]+)(?:$|:(.*)$)/;
  if (name.match(bindAttrRE) ||
    name.match(eventAttrRE) ||
    name.match(slotAttrRE) ||
    name.match(dirAttrRE)) {
    escaped = false;
  }
  this.attrs.push({ name: name, val: val, escaped: escaped });
  return this;
};

このモンキーパッチを Jade に当てる形で変換ツール pug-to-html をアップデートし、もう一度、Jade のテンプレートを持った単一ファイルコンポーネントに対してこの変換ツールをかけました。

CI も無事通ったので、インデント問題を無事解決することができました!

プロダクションにリリース

Jade のテンプレートのインデント問題を解決後、プロダクション環境にリリースし、数日間運用しました。致命的な問題・バグは発生せず、表示崩れ等問題はいくつかおきましたが、随時すぐに hotfix 的な修正を施して対応していきました。現在では、KARTE の管理コンソールは安定稼働している状況です。

まとめ

vue-pug-to-html を fork して Jade も対応した変換ツール pug-to-html を作成し、1,100 を超える単一ファイルコンポーネントの Jade / Pug のテンプレートを HTML に変換しました。この対応により、ESLint のような Vue.js のエコシステムで提供されているツール導入がしやすくなり、今後、KARTE の DX (Developer Experience: 開発体験) 改善のつながるようになりました。

今回作成した変換ツール pug-to-html は、 npm で @plaidev/pug-to-htmlで公開しています。単一ファイルコンポーネントで テンプレートで Jade / Pug から HTML に移行が必要になった場合は、ぜひ利用してみてください。

また、ソースコードも OSS として plaidev/pug-tom-htmlで公開しています。フィードバックもお待ちしております。

最後に

CX(顧客体験)プラットフォーム「KARTE」を運営するプレイドでは、今回のようにプロダクトをもっと良いものにするため、既存のコードにとらわれずに、時には、必要ならゼロベースで新しいものを作るという、壊しながら進む文化があります。こういった環境で働きたいエンジニアを募集しています。

詳しくは弊社採用ページまたは Wantedly をご覧ください。 もしくはお気軽に、下記の「話を聞きに行きたい」ボタンを押してください!