Nuxtライクな拡張機能フレームワーク『WXT』使ってみた（１）

# はじめに

10月にあった、[Vue Fes Japan 2024](https://vuefes.jp/2024/)のセッションで、WXTについて紹介している[セッション](https://vuefes.jp/2024/sessions/masaki-hoshino)があり、気になったので触ってみることにしました。

# WXTとは

Chromeや、Firefoxで使える拡張機能を作るためのフレームワーク。
オートインポート機能やComposablesなど、Nuxtに似ている部分がかなりあり、Nuxtを使っている人には手に馴染むフレームワークなのではないかと思います。
Vue.jsやReactを使うことができます。

https://wxt.dev/

# 拡張機能を作ってみる

## 今回作るもの

セッションで作ったと紹介されていた、環境ラベルを表示する拡張機能を作ってみます。
次の機能を実装する予定です。

- URLを見て、ラベルの表示文字列と色を変える
- トグルスイッチで、ラベルの表示のオンオフを切り替えることができる
- 設定画面で、各種設定ができる

## インストール

CLIでプロジェクトを始めることができます。npmだと以下のような感じ。

```bash
npx wxt@latest init
```

対話形式でフレームワークやパッケージマネージャを決めます。

## ディレクトリ構成

ドキュメントより。ここに記載されているものはオプショナルなものが多くて、componentsとかcomposablesを実際に使っていくかは開発者によるのかな？と思います。`wxt.config.ts`の設定で、srcディレクトリにソースコードをまとめることもできる！

```
📂 {rootDir}/
   📁 .output/
   📁 .wxt/
   📁 assets/
   📁 components/
   📁 composables/
   📁 entrypoints/
   📁 hooks/
   📁 modules/
   📁 public/
   📁 utils/
   📄 .env
   📄 .env.publish
   📄 app.config.ts
   📄 package.json
   📄 tsconfig.json
   📄 web-ext.config.ts
   📄 wxt.config.ts
```

`entrypoints`ディレクトリの中にあるものがバンドルされます。（出力先は`.output`ディレクトリ）
`entrypoints`ディレクトリの中には、拡張機能の表示の種類によって、いろいろ入れます。

- 入れられるものの一覧

https://wxt.dev/guide/essentials/entrypoints.html

- 今回使うもの

  - Content Scripts
  - Popup
  - Options

## 設定画面を作る

まずデータを用意したいので設定画面を作ります。

設定画面には、拡張機能の管理画面や、拡張機能のアイコンのメニューからアクセスできます。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3892166/5f54eb3b-9b70-54d3-9cf9-7759fb81dfbc.png)



設定画面では、ラベルに関する設定ができるようにします。
設定項目は以下です。

- ラベル名
- ラベルを表示するURL
- ラベルの色
- ラベルの表示位置

### データを永続化する

データをブラウザのストレージに保存するAPIがWXTから用意されています。

https://wxt.dev/storage

`wxt/storage`APIを使うと、簡単にデータの保存、読み取り、変更の検知ができます。
保存場所は、chromeやfirefoxで拡張機能用に用意されている場所です。

composablesを使って、データの書き込み、読み込み、変更検知をする関数を定義してみます。

```typescript:composables/useStorage.ts
import type { Label } from '@/types/Label';

export const useStorage = () => {
	const getLabelListFromLocalStorage = async (): Promise<Label[]> => {
		const result = await storage.getItem<Map<number, Label>>(
			'local:labelList'
		);
		return result ? Object.values(result) : [];
	};

	const saveLabelList = async (labelList: Label[]): Promise<boolean> => {
		try {
			await storage.setItem<Label[]>('local:labelList', labelList);
			return true;
		} catch (error) {
			return false;
		}
	};

	const watchLabelListChangeInStorage = (
		changeEvent: (newLabelList: Label[]) => void
	) => {
		storage.watch<Map<number, Label>>('local:labelList', (newLabelList) => {
			changeEvent(newLabelList ? Object.values(newLabelList) : []);
		});
	};

	return {
		getLabelListFromLocalStorage,
		saveLabelList,
		watchLabelListChangeInStorage,
	};
};

```

`storage.getItem`などがAPIを使っている部分です。`storage`はauto importされています。

### UIを作る

Optionsは以下のどちらかのファイルで作成します。ディレクトリ切るか切らないかの違いです。

- `entrypoints/options.html`
- `entrypoints/options/index.html`

今回は、コンポーネントファイルやTypeScriptファイルを使いたいので整理するためにディレクトリを切ります。

optionsディレクトリ下に以下の3ファイルを作成します。
- index.html
- main.ts
- OptionsPage.vue

`main.ts`でVueコンポーネントをマウントします。

```html:options/index.html
<!doctype html>
<html lang="en">

<head>
	<meta charset="UTF-8" />
	<meta name="viewport" content="width=device-width, initial-scale=1.0" />
	<meta name="manifest.open_in_tab" content="true" />
	<title>設定</title>
</head>

<body>
	<div id="app"></div>
	<script type="module" src="./main.ts"></script>
</body>

</html>
```

```typescript:options/main.ts
import { createApp } from 'vue';
import OptionsPage from './OptionsPage.vue';

createApp(OptionsPage).mount('#app');
```

HTMLに`<meta name="manifest.open_in_tab" content="true" />`をいれると、設定画面が新しいタブで開きます。デフォルトだとダイアログのような感じで開きます。

`OptionsPage.vue`は次のような感じです。
```vuejs
<script setup lang="ts">
import type { Label } from '@/types/Label';

const { createNewLabel } = useLabelFactory();

const {
	saveLabelList,
	watchLabelListChangeInStorage,
	getLabelListFromLocalStorage,
} = useStorage();

// Constants
const LABEL_POSITIONS = [
	'top-left',
	'top-right',
	'bottom-left',
	'bottom-right',
] as const;

// Refs
const labelList = ref<Label[]>([]);

// Methods
const createAndSaveNewLabel = async () => {
	const newLabel = createNewLabel();
	await saveLabelList([...labelList.value, newLabel]);
};

const deleteLabel = async (index: number) => {
	labelList.value.splice(index, 1);
	await saveLabelList(labelList.value);
};

const _saveLabelList = async () => {
	try {
		await saveLabelList(labelList.value);
		window.alert('設定を保存しました');
	} catch {
		window.alert('保存できませんでした');
	}
};

// Watch(wxt/storage)
watchLabelListChangeInStorage((newLabelList: Label[]) => {
	labelList.value = newLabelList;
});

// BeforeMount
onBeforeMount(async () => {
	labelList.value = await getLabelListFromLocalStorage();
});
</script>

<template>
	<table>
		<tbody>
			<tr>
				<th>ラベル名</th>
				<th>Origin URL</th>
				<th>ラベルカラー</th>
				<th>表示位置</th>
			</tr>
			<tr v-for="(label, index) in labelList" :key="index">
				<td><input type="text" v-model="label.name" /></td>
				<td>
					<input
						type="text"
						placeholder="https://..."
						v-model="label.url"
					/>
				</td>
				<td><input type="color" v-model="label.color" /></td>
				<td>
					<select name="" id="" v-model="label.position">
						<option
							v-for="position in LABEL_POSITIONS"
							:key="position"
							:value="position"
						>
							{{ position }}
						</option>
					</select>
				</td>

				<td><button @click="deleteLabel(index)">消去</button></td>
			</tr>
			<tr>
				<td><button @click="createAndSaveNewLabel()">追加</button></td>
			</tr>
		</tbody>
	</table>
	<button @click="_saveLabelList">保存</button>
</template>
```

### 動作確認する

WXTでは、開発者モードで実行すると（`npm run dev`などで）自動で開発している拡張機能がインストールされたブラウザウィンドウが開きます。
さらにHMRが有効で、ソースコードの変更が即座に反映されます。

設定画面を開いてみると、保存したデータがリロードしても保持されていることが確認できました。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3892166/b9caf431-d49d-bfc8-1f54-983ae166b100.png)


## ラベル部分を作る

表示するラベルを作成していきます。
DOMを操作できるContent Scriptsを使用します。

Content Scriptsにもディレクトリを切らずにファイルを格納する方法、切ってファイルを格納する方法など、[いろいろ](https://wxt.dev/guide/essentials/entrypoints.html#content-scripts)ありますが、Optionsと同様ディレクトリを切ります。`content/index.ts`に記載していきます。

### Content Scriptsの設定

Content Scripts作成用のAPIをWXT側で用意してくれています。

APIは3種類あります。CSSをサイトのものと分離するか否かなどが変わります。
今回は、CSSが分離された（サイト側のCSSから影響を受けない、こちらもサイト側に影響を及ぼさない）要素を作るAPIを使います。

以下の項目を設定していきます。

- どの要素にマウントするか
- マウントタイプ

```typescript:content/index.ts
import { createApp } from 'vue';
import Label from './Label.vue';
import './style.css';

export default defineContentScript({
	matches: ['<all_urls>'],
	cssInjectionMode: 'ui',
	async main(ctx) {
		const ui = await createShadowRootUi(ctx, {
			name: 'example-ui',
			position: 'overlay',
			anchor: 'body',
			onMount(container) {
				const app = createApp(Label);
				app.mount(container);
			},
		});
		ui.mount();
	},
});
```

設定方法の詳細は[ドキュメント](https://wxt.dev/guide/essentials/content-scripts.html#shadow-root)をご覧ください。
今回はbody要素にLabelコンポーネントをマウントしています。

### ラベルを作成

Labelコンポーネントを作成します。
```vue
<script setup lang="ts">
import { Label } from '@/types/Label';

const { getLabelListFromLocalStorage } = useStorage();

const label = ref<Label | undefined>(undefined);
const labelList = ref<Label[]>([]);

// Methods
const labelingSite = () => {
	const originUrl = `${window.location.origin}/`;
	label.value = labelList.value.some(
		(labelItem) => labelItem.url == originUrl
	)
		? labelList.value.find((labelItem) => labelItem.url == originUrl)
		: undefined;
};

// BeforeMount
onBeforeMount(async () => {
	labelList.value = await getLabelListFromLocalStorage();
	labelingSite();
});
</script>

<template>
	<div
		v-if="label"
		class="label"
		:class="label.position"
		:style="{ backgroundColor: label.color }"
	>
		<p class="text">{{ label.name }}</p>
	</div>
</template>

<style scoped>
.label {
	position: fixed;
	margin: 16px;
	z-index: 1000;
}
.top-left {
	top: 0;
	left: 0;
}
.top-right {
	top: 0;
	right: 0;
}
.bottom-left {
	bottom: 0;
	left: 0;
}
.bottom-right {
	bottom: 0;
	right: 0;
}

.text {
	margin: 0px;
	padding: 10px;
	font-size: x-large;
}
</style>
```

設定を読みこみ、urlのorigin部分とマッチするものがあったら設定を適用したラベルを表示します。

### 動作確認する

設定通り、ラベルが表示されました🙌

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3892166/358f30b8-6989-67b9-dbc7-db33a24230cc.png)



# つづく

ここまでで、最低限ラベル付けの拡張機能としては使えるレベルになりました。
次はPopupを実装して、Popup - Content Script間でメッセージパッシングできるようにします。
ラベルをつけるか判定する部分が微妙なのでそこも改善したいです。

# 参考
https://wxt.dev/
https://eiji.page/blog/crx-wxt-options/