急にVueの中身でなにかする必要が生まれた時に見る記事

## 概要
みなさんはVueのファイル自体をパースしたり中身を書き換えたりしたくなったことはありますか？
私はあります。
みなさんはそういった極限の状況に追い込まれないように願っていますが、どうしてもというときのために記録を残すことにしました。

## ケース
Gitのリビジョン間でVueファイルに加えられた差分を構造的に検出し、条件にマッチした場合はそのコミットのrevert commitを差し込む処理のために必要でした。

## 実装
今回はTypeScriptを使います。

### 環境
* npm 10.8.2
* TypeScript 5.5.4
* NodeJS 22.6.0

### セットアップ
今回は**あえて** `npm` を使います。気に食わない人は脳内で`yarn`や`pnpm`などのお好みのパッケージマネージャーに読み替えてください。

1. `npm init` で必要事項を入力します
1. `npm install -D typescript` します
1. `npx tsc --init` します
1. `tsconfig.json` で次のことをします。ただしこれらは必須でなく任意です
    1. `"target"` を `"esnext"` にします (最新のバージョンを使うため)
    1. `"module"` を `"NodeNext"` にします (ESM)
    1. `"moduleResolution"` を `"NodeNext"`にします (ESM)
    1. `"rootDir"` を `"./src"` にします (散らかるため)
1. `package.json` で次のことをします
    1. `"type": "module"` を追記します (ESM)
    1. `"scripts"` の `"launch"` を `"npx tsc && node ./src/index.js"` にします \[\*]
    1. `"main"` を `"src/index.js"` にします (散らかるため)

\[\*\] 実行する時は常に `npm run launch` で呼び出します。

### Vue SFCのパース
さて、ここからはソースコードの話に移ります。

Vueのファイル、より正式にはVue [SFC](https://ja.vuejs.org/guide/scaling-up/sfc.html)はおおまかに

1. `<template>`内部で宣言されたコンポーネント
1. `<script>`内部のcomposition APIのsetupスクリプト
1. `<style>`内部のスタイル

というパートに分かれています。私のケースではコンポーネント及びsetupスクリプトを触る必要がありました。

まずは `@vue/compiler-dom` を依存に追加し、SFC全体をパースさせます。

```ts
import { parse as parseVueDOM, type RootNode } from '@vue/compiler-dom';
const document: RootNode = parseVueDOM(source);
```

失敗したら例外が投げられるはず[^1]です。今回は解説のため、また予めvalidなVue SFCを読み込めるという前提に私が立てるためエラーハンドリングは省略します。

`RootNode`などの全てのノードは`type`プロパティでその種別が区別されます。詳しいことは`d.ts`を読んでもらうとして、`NodeTypes`と比較するのが定石です。例えばノードが要素であるかどうかを知りたい時は

```ts
function isElement(node: Node): e is ElementNode {
	return e.type === NodeTypes.ELEMENT;
}
```

とします。

また、要素名は `tag` プロパティで取れます。

### setupスクリプトのTypeScriptをトランスパイルする

厄介なことに、`setup`スクリプトがTypeScriptの場合は自分でトランスパイルしなければなりません。

幸い、`@babel/core` 、`@babel/preset-typescript`[^2]、`@types/babel__core`[^3] を依存に加えて自分で呼び出してこの問題は解決できます。型チェックは走らずに、型アノテーションだけが削ぎ落とされます。

```ts
import babel from '@babel/core';
import { type ElementNode } from '@vue/compiler-dom';

const setupScriptCandidate = document.children.filter(n => n.type === NodeTypes.ELEMENT && n.tag === "script");
if (!(setupScriptCandidate.length === 1 && setupScriptCandidate[0].type === NodeTypes.ELEMENT)) {
	throw new Error('script');
}

const scriptElement = setupScriptCandidate[0];
if (!(scriptElement.children.length === 1 && scriptElement.children[0].type === NodeTypes.TEXT)) {
	throw new Error('script');
}
 
const language: string = scriptElement.props
		.filter(p => p.type === NodeTypes.ATTRIBUTE)
		.filter(p => p.name === "lang")[0]
		?.value?.content ?? 'js';

const untypedScript = language === "ts"
	? babel.transformSync(script, {
		presets: ["@babel/preset-typescript"],
		filename: 'input.ts',
	})
	: { code: script };
```

`untypedScript.code` が通常のJavaScriptであることに注意すると、あとは普通にパースしてもらうだけです。

```ts
const parsed = babel.parseSync(untypedScript.code);
if (!parsed) {
	throw new Error('babel failed to parse script!');
}
```

`parsed.program` は `babel.types.Program` 型を持ち、食わせたJavaScriptの抽象構文木のルートです。通常はここを探索の起点にするでしょう。

### 要件に合わせてデータを加工する
VueのDOMもBabelのASTも、どちらも一方向の木構造になっているので、うまいこと必要な階層まで木構造を掘ってください。必要であれば位置情報もあります。

## あとがき
本題とは関係ありませんが、`if`文をネストさせると地獄になるので早い段階で随時early-`return`に書き直しましょう。

この記事はこれで終わりです。楽しい構文木ライフを！

[^1]: 詳しく調べていません
[^2]: インストールしておかないと動的インポートで失敗します
[^3]: これがないと`@babel/core`の型が解決できない！と怒ってきます