cheerio での文字列補間関数出力

この記事も、昔のプロジェクトで使われていたテンプレートエンジンを Node で無理やり動かそうとする試みの一つです。
今回は Java の Spring の Thymeleaf expression を使って書かれた HTML ファイルをターゲットとして、サンプル程度に処理を実装してあります。

この記事にある内容を思いつく直前までは xml2js を使っていましたが、ツリーの親子関係・兄弟関係を維持したままの加工が少々面倒でした。
そこで、 cheerio を少し捻って使うことにしました。

サンプルプロジェクトはこちら

簡単な説明としては、 cheerio は jQuery のような操作感覚で HTML や XML の構造を解析したり編集したりできるライブラリです。
JavaScript の 文字列補間 ( String Interpolation ) は、`Name: ${name}` などと記述することで、 name の内容を展開して文字列を作成できる文法です。

今回は beforeafter を駆使してハック的に生成用文字列補間文法を形成していくわけですが、 HTML として不適切な文字を文法に含めると失敗します。
たとえば、 () => "1" などを途中に入れるなどした場合、一部が >" などへと変換されるため失敗します。
また、 $` といった文字列補間構文に使う文字をエスケープするなどの工夫が必要です。

前置きはここまでにして、例を提示します。

入力用のファイルとしては、 Thymeleaf っぽい HTML ファイルを用意しました。(このサンプルでは th:textth:each だけが、機能の制限付きで使えます。)

1
2
3
4
5
6
7
8
9
10
11
12
13
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<body>
<div th:each="ta: ${greetings}">
<span th:text="${ta}"></span>
</div>
<div th:text="${hoge}">b</div>
<div th:each="t: ${greetings}" th:text="${t}">a</div>
<div th:each="t: ${aa}">
<div th:each="tv: ${t}" th:text="${tv}">a</div>
</div>
</body>
</html>

まずは、ファイルの内容を、文字列補間構文に使う記号として間違えられないようにエスケープする前処理を行います。

5
6
7
function preprocess(str) {
return str.replace(/\${/g, "\\${").replace(/`/g, "\\`");
}

次に、入力するページのツリー変換を行います。最初からバインドする変数のデータが存在していれば何も難しいことはありませんが、今回はテンプレートを効率よく実行したいため、事前に関数オブジェクトとしてすぐに動く状態まで変換しておきます。(強いていえば ssr-with-prepack-hackathon に近い?)

3
const IDENTIFIER = '$_$';
0
/* omit */
38
39
40
41
42
43
if ($(this).is('[th\\:text]')) {
let str = $(this).attr('th:text');
str = str.replace(/\\\${/g, `\${${IDENTIFIER}.`);
$(this).text(str);
$(this).removeAttr('th:text');
}

単純な代入部分ですが、文字列補間構文化のために少々複雑になっています。
テンプレートに渡す引数の数を固定したいため、 IDENTIFIER が示す変数にまとめるように処理しています。
cheerio には、 HTML に出力し直した後から文字列補間として解釈できるような形式で変換しておくのがポイントです。

繰り返しが含まれるようになると、かなり複雑になってしまいます。

3
const IDENTIFIER = '$_$';
0
/* omit */
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
if ($(this).is('[th\\:each]')) {
let str = $(this).attr('th:each');
const match = /^(\w+):\s*?\\\${(\w.*?)}$/.exec(str);
const varName = match[1];
const iterName = match[2];
const arrayName = `${IDENTIFIER}_array`;
const bufName = `${IDENTIFIER}_buf`;
const codeBefore = `\${
(function(){
const ${arrayName} = [];
for (const ${varName} of ${IDENTIFIER}.${iterName}) {
const ${bufName} = ${IDENTIFIER};
${IDENTIFIER} = { ${varName}, ...${IDENTIFIER}};
${arrayName}.push(\``;
const codeAfter = `\`);
${IDENTIFIER} = ${bufName};
}
return ${arrayName}.join(\`\`);
})()
}`;
$(this).before(codeBefore);
$(this).after(codeAfter);
$(this).removeAttr('th:each');
}

まず、 beforeafter を使い、前後に関数を構成する文字列を挿入します。
> が使えない制約上、関数の定義には必ず function(){} を使います。また、境界・影響範囲を制限するため即時呼び出しとオブジェクトのコピーを行っています。
加えて、 '" も使えないので、 \` を使って凌ぎます。

面倒な部分が多いですが、呪術みたいなものなので仕方がありません。

これらの処理を再帰的に実行させるようにすると、ツリーの変更は終了となります。

最後に、これを文字列補間構文として機能させます。 Webpack の loader として処理する場合は eval 相当の処理が必要無いので、 Webpack 向きかもしれませんね。

3
const IDENTIFIER = '$_$';
0
/* omit */
9
10
11
function generateInterpolateFunc(str) {
return new Function(IDENTIFIER, `return \`${str}\`;`);
}

この処理により生成されたテンプレートに対して、 {hoge: 2, greetings: ['bonjour', 'hello'], aa: [['!', '?'], ['@', '#']]} というデータを与えると、以下のような出力になります。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="http://www.thymeleaf.org">
<body>
<div>
<span>bonjour</span>
</div><div>
<span>hello</span>
</div>
<div>2</div>
<div>bonjour</div><div>hello</div>
<div>
<div>!</div><div>?</div>
</div><div>
<div>@</div><div>#</div>
</div>
</body>
</html>

(多分) Spring の Thymeleaf で、 Java で対応するデータを使って生成させた場合に近いコードが生成されると思います。