Markuplintでbuilt-in control flowを解釈できるプラグインを試作した

ゼロバンク・デザインファクトリー株式会社(ZDF)でフロントエンドエンジニアをしている長島です。これは[Angular Advent Calendar 2024](https://qiita.com/advent-calendar/2024/angular)の21日目の記事です。昨日はkpondaさんの記事でした。

みんなの銀行ではMarkuplintというライブラリを使い、HTMLの静的解析を行っています。この記事ではBuilt-in control flowを使ったテンプレートをMarkuplintでチェックできるようにするプラグインとその課題について書きます。

試作なので諸々の体裁を整えていませんが、一応[markuplint-angular-control-flow-visitor](https://www.npmjs.com/package/markuplint-angular-control-flow-visitor)という名前で公開しています。

## なぜプラグインが必要なのか

大きく2つ理由があります。

1. 条件次第で、妥当だったり妥当でなかったりするテンプレートがある
2. Built-in control flowがMarkuplintでテキストノードと判定されてしまう

### 条件次第で、妥当だったり妥当でなかったりするテンプレートがある

Built-in control flowに限らず`*ngIf`のようなstructural directiveでも同じことが起きますが、条件分岐次第で妥当だったり妥当でなかったりするテンプレートがあります。例えば以下のようなテンプレートです。

```HTML
<dl>
  <dt>Authors</dt>
  @for(author of authors; track author){
  <dd>{{author}}</dd>
  }
</dl>
```

`authors`が空の配列だった場合、以下のようなHTMLとして評価されます。

```HTML
<dl>
  <dt>Authors</dt>
</dl>
```

このような子要素に`dt`しかない`dl`は妥当ではありません。参考として、`authors`が空でも妥当なHTMLにするには、以下のようなテンプレートにする必要があります。

```HTML
@if(authors.length > 0){
<dl>
  <dt>Authors</dt>
  @for(author of authors; track author){
  <dd>{{author}}</dd>
  }
</dl>
}
```

### Built-in control flowがMarkuplintでテキストノードと判定されてしまう

例えば以下のようなテンプレートを考えてみます。

```HTML
@if(items.length > 0){
  <ul>
  @for(item of items; track item){
  <li>{{item}}</li>
  }
  </ul>
}
```

実際に動かして`item`の中身を評価すると妥当なHTMLになります。しかしMarkuplintで静的解析すると、<span>@</span>for...の部分がAngularの構文であることをMarkuplintは知らないのでテキストノードだと判定してしまいます。ul直下にテキストノードがあると妥当ではないHTMLになるので、上記のテンプレートも妥当でないという判定になってしまいます。

[markuplint-angular-parser](https://www.npmjs.com/package/markuplint-angular-parser)というパッケージがありますが、2024年12月の時点ではBuilt-in control flowを考慮したパーサーになっていません。

## どう実現したか

以下の手順で処理を行うプラグインを作成すると、上記2点の課題を解決できます

1. Markuplintからテンプレートのファイルパスを受け取る
2. テンプレートファイルの中身を読み込む
3. `@angular/compiler`の`parseTemplate`を使ってテンプレートをAST(`ParsedTemplate`)に変換する
4. ASTの条件分岐を評価した結果のASTの配列(`ParsedTemplate[]`)を作る
5. ASTの配列をHTMLフラグメントの配列にする(各ASTをHTMLフラグメントにする)
6. 各HTMLフラグメントの配列に対し、Markuplintを実行

1,2,3,6は特に説明不要かと思いますので、4,5だけ簡単に説明します。詳しいコードを知りたい方は、[GitHub](https://github.com/nagashimam/markuplint-angular-control-flow-visitor-poc)にコードがあります。(元々プライベートでオープンソースで開発していたものを業務でも使うことになったのですが、AngularとMarkuplintに関するロジックだけで勤務先固有のコードが全くないのでオープンソースのままにしています)Visitor、Template Method、Decoratorを使っているので、その点を踏まえると読みやすくなるかもしれません。

### ASTの条件分岐を評価した結果のASTの配列(ParsedTemplate[])を作る

```HTML
@if(flagA){
<p>flagAがtrue</p>
}@else if(flagB){
<p>flagBがtrue</p>
}else{
<p>flagAもflagBもfalse</p>
}
```

というテンプレートを考えます。これをパースした結果のASTをクローン&直接書き換えを繰り返し、

```HTML
<p>flagAがtrue</p>
```

```HTML
<p>flagBがtrue</p>
```

```HTML
<p>flagAもflagBもfalse</p>
```

というテンプレートをパースした結果と同じになるようなASTの配列を作っています。

同様に

```HTML
@switch (str){
  @case("foo"){
  <p>strの中身は「foo」</p>
  }
  @case("bar"){
  <p>strの中身は「bar」</p>
  }
  @default{
  <p>strの中身は「foo」でも「bar」でもない</p>
  }
}
```

は

```HTML
<p>strの中身は「foo」</p>
```

```HTML
<p>strの中身は「bar」</p>
```

```HTML
<p>strの中身は「foo」でも「bar」でもない</p>
```

をパースした結果と同じになるようなASTの配列にします。

`@for`はループしない、1回ループするの2通りの配列を作ります。

```HTML
<ul>
  @for(item of items;track item){
  <li>{{item}}</li>
  }
</ul>
```

は

```HTML
<ul>
</ul>
```

```HTML
<ul>
  <li>{{item}}</li>
</ul>
```

をパースした結果と同じになるようなASTの配列にします。

### `ASTの配列をHTMLフラグメントの配列にする(各ASTをHTMLフラグメントにする)`

前節の処理をした結果、1つのテンプレートファイルから複数のASTができます。その1つ1つのASTの木構造の頂点から葉までを走査して、ASTのノードをそれぞれ対応するDocumentのノードにします。例えば[TmplAstElement](https://github.com/angular/angular/blob/main/packages/compiler/src/compiler.ts#L131)は対応するElementに、[TmplAstText](https://github.com/angular/angular/blob/main/packages/compiler/src/compiler.ts#L138)はTextにというようにマップしていきます。

## 課題

実際に試作してみたところ、ng newでできるapp.component.htmlを静的解析するのに700msほどかかります。

```cmd
npx markuplint src/app/app.component.html
```

ちなみに通常のmarkuplint.config.jsの内容をplugins.settingsに記載することでオプションを渡せるようにしています。
```javascript
module.exports = {
  plugins: [
    {
      name: "node_modules/markuplint-angular-control-flow-visitor/lib/index.js",
      settings: {
        config: {
          extends: ["markuplint:recommended"],
          rules: {
            "required-h1": false,
            "invalid-attr": {
              options: {
                ignoreAttrNamePrefix: ["app", "ng"],
                allowAttrs: ["formgroup"],
              },
            },
          },
          nodeRules: [
            {
              selector: "img",
              rules: {
                "required-attr": ["src", "alt"],
              },
            },
          ],
        },
      },
    },
  ],
  rules: {
    "markuplint-angular-control-flow-visitor/control-flow-visitor": true,
  },
};
```

計測すると上記5番の処理で500msほど掛かっていました。今回の試作で確認したかったのは、条件分岐の分解析する量が増えるので、現実的な時間内でHTMLのチェックができるのかでした。懸念していた通りと言えばそうなのですが、4で時間がかかると思っていたので5で時間がかかるのは意外でした。

`child_process`や`worker_threads`などを用いて高速化できないか検証しましたが、プロセスやスレッドを立ち上げるオーバーヘッドの方が大きく却って遅くなってしまいました。他に高速化するアイディアがあればコメント欄で教えてください。

## まとめ

当たり前と言えば当たり前ですが、`@angular/compiler`のコードを使って開発ツールを作ることができるというのが自分にとっては新鮮な発見でした。普段Angularを使っていてもAngularのソースコードを読む機会はなかったので、今回一部分だけですが読んでみて面白かったです。

明日は@motchi0214さんの記事です。