ユーザースクリプトをTypeScriptで書く（rollup編）

rollupを用いて、ユーザースクリプトをTypeScriptで書く環境を構築する手順を示します。
先日投稿した、ブックマークレットをTypeScriptで書く記事のユーザースクリプト版です。

https://qiita.com/munieru_jp/items/036f047441466642740a

## ユーザースクリプトをTypeScriptで書きたい

私は、日々のウェブブラウジングを快適にするためのユーザースクリプトを自作しています。 
ユーザースクリプトの魅力は、ブックマークレットよりは高度なことがやりやすく、拡張機能よりはお手軽というちょうどよさにあります。
しかしながら、ある程度の規模を超えたユーザースクリプトを素のJavaScriptで、それも1つのファイルの中に書いていくのは、なかなかつらいものです。
そこで、ユーザースクリプトをTypeScriptで書きたいと思いました。
 
## ユーザースクリプトをTypeScriptで書くには 

ユーザースクリプトをTypeScriptで書くには、おおむね次のようなことをおこなう必要があります。

- TypeScriptをJavaScriptにトランスパイル
- ファイルが分かれている場合は1ファイルにバンドル
- グローバル空間の汚染を避けるために、スクリプト全体をIIFEでラップ
- ファイルの先頭にユーザースクリプト用のヘッダーを付与
- ファイルの拡張子を`.user.js`とする

これには様々な手法がありますが、今回はモジュールバンドラーの[rollup](https://rollupjs.org/)を採用しました。

## 特徴

この記事で紹介する手法には、次のような特徴があります。
これらの特徴によって、快適なDX（開発者体験）がもたらされることでしょう。

### ヒューマンリーダブルなファイル出力

生成されるユーザースクリプトはミニファイされていないため人間が書くJavaScriptのコードに近く、高いリーダビリティがあります。
ユーザースクリプトの購読者（開発者自身を含む）がちょっとしたデバッグのためにコードの一部を変更したい場合、ユーザースクリプトマネージャー上で容易に編集することができます。

### ファイル分割

適切にファイルを分割することで、大規模なユーザースクリプトでもメンテナビリティを高く保つことができます。
また、ユーザースクリプト間で共通する処理を切り出して共用することもできます。
たとえば、スリープ処理をよく使うのであれば`sleep.ts`のようなファイルに関数を定義しておき、それを各所でインポートするとよいでしょう。

### ESLintによる静的解析

ソースコードはもちろんのこと、生成されたユーザースクリプトをESLintで静的解析することで、ユーザースクリプトとしてのベストプラクティスが適用されます。

### Jestによるテスト実行

テストを書くことで、ユーザースクリプトの品質を担保することができます。
もちろん、テストコードの中でDOMを扱うこともできます。

### ローカル開発用ユーザースクリプトの出力

通常のユーザースクリプトとは別に、ローカル開発用のユーザースクリプトも生成されます。
これをユーザースクリプトマネージャーに登録し、開発時はそちらを使うようにすると、ソースコードの更新が即座に反映されるので便利です。

### ウォッチモード

開発時に使用するウォッチモードではソースコードを保存する度にビルドが走り、ユーザースクリプトが更新されます。
ローカル開発用ユーザースクリプトと合わせて使用することで、常に最新の状態が適用されるようになります。

### 外部パッケージの使用

通常のユーザースクリプトと同様に、`@require`にURLを指定することで外部パッケージを使用することができます。
有名どころだと、[Day.js](https://day.js.org/en/)、[jQuery](https://jquery.com/)、[Lodash](https://lodash.com/)などが利用できます。

## ディレクトリ構成

この記事で紹介する手法では、次のようなディレクトリ構成になります。

```
.
├── .eslintrc.js
├── .eslintrc.userscripts.js
├── .gitignore
├── dist
│   ├── foo.dev.user.js
│   └── foo.user.js
├── jest.config.ts
├── package-lock.json
├── package.json
├── rollup.config.ts
├── src
│   ├── dev.ts
│   └── foo
│       ├── main.ts
│       └── manifest.json
└── tsconfig.json
```

- `.eslintrc.js`：ESLintの設定ファイル
- `.eslintrc.userscripts.js`：生成されたユーザースクリプト用のESLintの設定ファイル
- `.gitignore`：Gitの除外設定ファイル
- `dist`：ユーザースクリプトが出力されるディレクトリ
  - `dist/**/*.dev.user.js`：ローカル開発用ユーザースクリプト
  - `dist/**/*.user.js`：ユーザースクリプト
- `jest.config.ts`：Jestの設定ファイル
- `package-lock.json`：npmのパッケージ情報のロックファイル
- `package.json`：npmのパッケージ情報ファイル
- `rollup.config.ts`：rollupの設定ファイル
- `src`：ユーザースクリプトのソースコードを配置するディレクトリ
  - `src/**/main.ts`：ユーザースクリプトの本体にあたるエントリーファイル
  - `src/**/manifest.json`：ユーザースクリプトのメタデータが記述されたマニフェストファイル
  - `src/dev.ts`：ローカル開発用ユーザースクリプトの本体にあたるエントリーファイル
- `tsconfig.json`：TypeScriptの設定ファイル

このようにファイルパスのルールを定めることで、新たにユーザースクリプトを追加する場合は`src`ディレクトリ以下に任意のディレクトリを切り、最低限`main.ts`と`manifest.json`の2ファイルさえつくればいい状態になります。

## 使用環境

記事執筆時点で使用している環境について、記載しておきます。

### 開発環境

ユーザースクリプトの開発には、次のような環境を使っています。

- OS：macOS Monterey 12.6
- JSランタイム：Node.js v16.11.1
- パッケージマネージャー：npm v8.0.0

実行するコマンドやインストールするパッケージは、使用するNode.jsのバージョンやパッケージマネージャーに応じてよしなに読み替えてください。

### 実行環境

ユーザースクリプトの実行には、次のような環境を使っています。

- OS：macOS Monterey 12.6
- ブラウザ：Google Chrome v108.0.5359.94
- ユーザースクリプトマネージャー：Tampermonkey v4.18.1

おそらくTampermonkey独自の機能などは使っていないので、他のユーザースクリプトマネージャーでも問題なく動作するかと思います。

## 環境構築

ユーザースクリプトを開発するための環境を構築します。

### パッケージをインストール

開発に必要なパッケージをひととおりインストールします。

```sh
npm i -D \
  @munierujp/eslint-config-typescript \
  @rollup/plugin-typescript \
  @tsconfig/node16 \
  @types/glob \
  @types/node@^16.11 \
  eslint \
  eslint-plugin-jest \
  eslint-plugin-userscripts \
  glob \
  jest \
  jest-environment-jsdom \
  rimraf \
  rollup \
  rollup-plugin-cleanup \
  rollup-plugin-watch \
  ts-jest \
  typescript \
  userscript-metadata
```

ただし、使用するパッケージやバージョンは状況に応じて適切なものを選んでください。

- `@tsconfig/node16`：使用するNode.jsのバージョンに対応したパッケージを使うこと[^1]
- `@types/glob`：使用するglobのバージョンに対応したバージョンを使うこと[^2]
- `@types/node`：使用するNode.jsのバージョンに対応したバージョンを使うこと[^2]

### `package.json`の`scripts`を定義

`package.json`に、次のようなscriptsを定義します。

```json:package.json
{
  "scripts": {
    "prebuild": "npm run clean",
    "build": "rollup --config rollup.config.ts --configPlugin typescript",
    "postbuild": "npm run lint:userscripts",
    "clean": "rimraf dist",
    "predev": "npm run clean",
    "dev": "rollup --config rollup.config.ts --configPlugin typescript --watch",
    "lint": "eslint '**/*.{js,ts}'",
    "lint:userscripts": "eslint --no-eslintrc --config .eslintrc.userscripts.js 'dist/**/*.js'",
    "test": "jest --passWithNoTests"
  }
}
```

それぞれの役割は以下の通りです。

- `build`：ソースコードをビルドしてユーザースクリプトを生成
- `clean`：ビルド結果を削除
- `dev`：開発用のウォッチモードを起動
- `lint`：ESLintによる静的解析を実行
- `lint:userscripts`：ユーザースクリプトに対してESLintによる静的解析を実行
- `test`：Jestによるテストを実行

### `tsconfig.json`を作成

TypeScriptの設定ファイルとして、`tsconfig.json`をつくります。

```jsonc:tsconfig.json
{
  "extends": "@tsconfig/node16/tsconfig.json",
  "compilerOptions": {
    "lib": [
      // see https://www.npmjs.com/package/@tsconfig/node16
      "es2021",
      "dom"
    ]
  }
}
```

`@tsconfig/node16/tsconfig.json`を継承していますが、DOMを扱うため`compilerOptions.lib`は`dom`を含めたものを再定義しています。

### `.eslintrc.js`を作成

ESLintの設定ファイルとして、`.eslintrc.js`をつくります。

```js:.eslintrc.js
module.exports = {
  ignorePatterns: [
    'dist/**/*.js'
  ],
  extends: [
    '@munierujp/eslint-config-typescript',
    'plugin:jest/recommended'
  ],
  parserOptions: {
    project: './tsconfig.json'
  }
}
```

`ignorePatterns`に`dist/**/*.js`を指定することで、生成されたユーザースクリプトには適用されないようにしています。

### `.eslintrc.userscripts.js`を作成

生成されたユーザースクリプト用のESLintの設定ファイルとして、`.eslintrc.userscripts.js`をつくります。

```js:.eslintrc.userscripts.js
module.exports = {
  extends: [
    'plugin:userscripts/recommended'
  ],
  parserOptions: {
    ecmaVersion: 'latest'
  }
}
```

### `jest.config.ts`を作成

Jestの設定ファイルとして、`jest.config.ts`をつくります。

```ts:jest.config.ts
import type { Config } from 'jest'

const config: Config = {
  preset: 'ts-jest',
  testEnvironment: 'jsdom',
  collectCoverage: true,
  collectCoverageFrom: [
    'src/**/*.ts'
  ]
}

export default config
```

TypeScriptを扱うため`preset`には`ts-jest`を、DOMを扱うため`testEnvironment`には`jsdom`をそれぞれ指定しています。

### `rollup.config.ts`を作成

rollupの設定ファイルとして、`rollup.config.ts`をつくります。

```ts:rollup.config.ts
import { readFileSync } from 'node:fs'
import typescript from '@rollup/plugin-typescript'
import glob from 'glob'
import type { RollupOptions } from 'rollup'
import cleanup from 'rollup-plugin-cleanup'
import watch from 'rollup-plugin-watch'
import { stringify } from 'userscript-metadata'
import type { Metadata } from 'userscript-metadata'

const readMetadata = (path: string): Metadata => JSON.parse(readFileSync(path, 'utf8'))
const rootDir = process.cwd()
const entryPaths = glob.sync('src/**/main.ts')
const configs: RollupOptions[] = entryPaths.flatMap(entryPath => {
  const manifestPath = entryPath.replace(/\/main\.ts$/, '/manifest.json')
  const mainScriptPath = entryPath.replace(/^src\//, 'dist/').replace(/\/(.+)\/main\.ts$/, '/$1.user.js')
  const mainScriptUrl = `file://${rootDir}/${mainScriptPath}`
  const devScriptPath = entryPath.replace(/^src\//, 'dist/').replace(/\/(.+)\/main\.ts$/, '/$1.dev.user.js')
  const devify = (metadata: Metadata): Metadata => {
    const requires: string[] = []

    if (typeof metadata.require === 'string') {
      requires.push(metadata.require)
    } else if (Array.isArray(metadata.require)) {
      requires.push(...metadata.require)
    }

    requires.push(mainScriptUrl)
    return {
      ...metadata,
      name: `[dev] ${String(metadata.name)}`,
      require: requires
    }
  }
  const mainConfig: RollupOptions = {
    input: entryPath,
    output: {
      file: mainScriptPath,
      format: 'iife',
      banner: () => `${stringify(readMetadata(manifestPath))}\n`
    },
    plugins: [
      typescript(),
      cleanup({
        extensions: [
          'ts'
        ]
      }),
      watch({
        dir: 'src'
      })
    ]
  }
  const devConfig: RollupOptions = {
    input: 'src/dev.ts',
    output: {
      file: devScriptPath,
      banner: () => `${stringify(devify(readMetadata(manifestPath)))}\n`
    },
    plugins: [
      typescript(),
      cleanup({
        extensions: [
          'ts'
        ]
      }),
      watch({
        dir: 'src'
      })
    ]
  }
  return [
    mainConfig,
    devConfig
  ]
})

export default configs
```

### `src/dev.ts`を作成

ローカル開発用ユーザースクリプトのソースコードとして、`src/dev.ts`をつくります。
なにかしら処理を書いてもいいですが、特になければ空でもかまいません。

```ts:src/dev.ts
```

ただし、中身が空だとビルド時に`(!) Generated an empty chunk`のような警告が表示されるので、気になる場合はテキトーに中身を書いておきましょう。

```ts:src/dev.ts
console.debug('This script is for development purposes only.')
```

### `.gitignore`を更新

ローカル開発用ユーザースクリプトにはファイルパスが含まれるので、Gitの管理対象外にするために`.gitignore`に`/dist/**/*.dev.user.js`を追加します。

```
/dist/**/*.dev.user.js
```

## ユーザースクリプトのサンプル

ユーザースクリプトを開発するための環境が整ったので、いくつかの例を示します。

### Hello World

はじめにもっともシンプルな例として、[example.com](https://example.com/)にアクセスするとコンソールに`Hello World`と表示するユーザースクリプトをつくります。
まずは`src`ディレクトリ以下に`hello`ディレクトリを切り、その中にエントリーファイルとして`main.ts`をつくります。

```ts:src/hello/main.ts
console.log('Hello World')
```

`main.ts`と同じディレクトリに、マニフェストファイルとして`manifest.json`をつくります。

```json:src/hello/manifest.json
{
  "name": "Hello World",
  "namespace": "https://github.com/munierujp",
  "version": "1.0.0",
  "description": "Say hello",
  "author": "https://github.com/munierujp",
  "homepage": "https://github.com/munierujp/userscripts",
  "homepageURL": "https://github.com/munierujp/userscripts",
  "updateURL": "https://github.com/munierujp/userscripts/raw/master/dist/hello.user.js",
  "downloadURL": "https://github.com/munierujp/userscripts/raw/master/dist/hello.user.js",
  "supportURL": "https://github.com/munierujp/userscripts/issues",
  "match": "*://example.com/*",
  "grant": "none"
}
```

`npm run build`または`npm run dev`でソースコードをビルドすると、`dist`ディレクトリにユーザースクリプトが生成されます。

```js:dist/hello.user.js
// ==UserScript==
// @name         Hello World
// @namespace    https://github.com/munierujp
// @version      1.0.0
// @description  Say hello
// @author       https://github.com/munierujp
// @homepage     https://github.com/munierujp/userscripts
// @homepageURL  https://github.com/munierujp/userscripts
// @updateURL    https://github.com/munierujp/userscripts/raw/master/dist/hello.user.js
// @downloadURL  https://github.com/munierujp/userscripts/raw/master/dist/hello.user.js
// @supportURL   https://github.com/munierujp/userscripts/issues
// @match        *://example.com/*
// @grant        none
// ==/UserScript==

(function () {
	'use strict';

	console.log('Hello World');

})();
```

### Qiitaの古い記事のアラートを改善

次にもう少し複雑な例として、Qiitaで更新日が古い記事に表示されるアラートの年数表示をより細かくするユーザースクリプトをつくります。
まずは、日付に関する操作を楽にするために[dayjs](https://www.npmjs.com/package/dayjs)をインストールします。

```sh
npm i -D dayjs
```

`src`ディレクトリ以下に`qiita/improve-old-article-alert`ディレクトリを切り、その中にエントリーファイルとして`main.ts`をつくります。

```ts:src/qiita/improve-old-article-alert/main.ts
import { findAlertTextElement } from './findAlertTextElement'
import { updateAlertTextElement } from './updateAlertTextElement'

const alertTextElement = findAlertTextElement()

if (alertTextElement !== undefined) {
  updateAlertTextElement(alertTextElement)
}
```

また、他にも必要なファイルをひととおりつくります。

```ts:src/qiita/improve-old-article-alert/findAlertTextElement.ts
import { isAlertTextElement } from './isAlertTextElement'

export const findAlertTextElement = (): HTMLParagraphElement | undefined => {
  return Array.from(document.getElementsByTagName('p')).find(element => isAlertTextElement(element))
}
```

```ts:src/qiita/improve-old-article-alert/isAlertTextElement.ts
export const isAlertTextElement = (node: Node): boolean => {
  const { textContent } = node
  return textContent !== null && /^この記事は最終更新日から\d+年以上が経過しています。$/.test(textContent)
}
```

```ts:src/qiita/improve-old-article-alert/updateAlertTextElement.ts
import dayjs from 'dayjs'
import { findArticle } from './findArticle'

export const updateAlertTextElement = (alertTextElement: HTMLElement): void => {
  const article = findArticle()

  if (article === undefined) {
    throw new Error('Missing article.')
  }

  const { dateModified } = article

  if (dateModified === undefined) {
    throw new Error('Missing dateModified.')
  }

  const now = new Date()
  const year = dayjs(now).diff(dateModified, 'year')
  alertTextElement.textContent = `この記事は最終更新日から${year}年以上が経過しています。`
}
```

```ts:src/qiita/improve-old-article-alert/findArticle.ts
import { isArticle } from './Article'
import type { Article } from './Article'

export const findArticle = (): Article | undefined => {
  return Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
    .map(({ textContent }) => textContent)
    .filter((json): json is NonNullable<typeof json> => json !== null)
    .map(json => JSON.parse(json) as unknown)
    // eslint-disable-next-line unicorn/no-array-callback-reference
    .find(isArticle)
}
```

```ts:src/qiita/improve-old-article-alert/Article.ts
export interface Article {
  '@type': 'Article'
  'dateModified'?: string
}

export const isArticle = (value: any): value is Article => {
  return typeof value === 'object' &&
    value !== null &&
    value['@type'] === 'Article' &&
    (typeof value.dateModified === 'string' || typeof value.dateModified === 'undefined')
}
```

`main.ts`と同じディレクトリに、マニフェストファイルとして`manifest.json`をつくります。

```json:src/hello/manifest.json
{
  "name": "古い記事のアラートを改善",
  "namespace": "https://github.com/munierujp/",
  "version": "1.0.0",
  "description": "Qiitaの古い記事のアラートにおいて、年数をより正確に表示します。",
  "author": "https://github.com/munierujp/",
  "homepage": "https://github.com/munierujp/userscripts",
  "homepageURL": "https://github.com/munierujp/userscripts",
  "updateURL": "https://github.com/munierujp/userscripts/raw/master/dist/qiita/improve-old-article-alert.user.js",
  "downloadURL": "https://github.com/munierujp/userscripts/raw/master/dist/qiita/improve-old-article-alert.user.js",
  "supportURL": "https://github.com/munierujp/userscripts/issues",
  "match": [
    "*://qiita.com/*/items/*"
  ],
  "require": [
    "https://cdn.jsdelivr.net/npm/dayjs@1.11.6/dayjs.min.js"
  ],
  "grant": "none"
}
```

`npm run build`または`npm run dev`でソースコードをビルドすると、`dist`ディレクトリにユーザースクリプトが生成されます。

```js:dist/qiita/improve-old-article-alert.user.js
// ==UserScript==
// @name         古い記事のアラートを改善
// @namespace    https://github.com/munierujp/
// @version      1.0.0
// @description  Qiitaの古い記事のアラートにおいて、年数をより正確に表示します。
// @author       https://github.com/munierujp/
// @homepage     https://github.com/munierujp/userscripts
// @homepageURL  https://github.com/munierujp/userscripts
// @updateURL    https://github.com/munierujp/userscripts/raw/master/dist/qiita/improve-old-article-alert.user.js
// @downloadURL  https://github.com/munierujp/userscripts/raw/master/dist/qiita/improve-old-article-alert.user.js
// @supportURL   https://github.com/munierujp/userscripts/issues
// @match        *://qiita.com/*/items/*
// @require      https://cdn.jsdelivr.net/npm/dayjs@1.11.6/dayjs.min.js
// @grant        none
// ==/UserScript==

(function (dayjs) {
    'use strict';

    const isAlertTextElement = (node) => {
        const { textContent } = node;
        return textContent !== null && /^この記事は最終更新日から\d+年以上が経過しています。$/.test(textContent);
    };

    const findAlertTextElement = () => {
        return Array.from(document.getElementsByTagName('p')).find(element => isAlertTextElement(element));
    };

    const isArticle = (value) => {
        return typeof value === 'object' &&
            value !== null &&
            value['@type'] === 'Article' &&
            (typeof value.dateModified === 'string' || typeof value.dateModified === 'undefined');
    };

    const findArticle = () => {
        return Array.from(document.querySelectorAll('script[type="application/ld+json"]'))
            .map(({ textContent }) => textContent)
            .filter((json) => json !== null)
            .map(json => JSON.parse(json))
            .find(isArticle);
    };

    const updateAlertTextElement = (alertTextElement) => {
        const article = findArticle();
        if (article === undefined) {
            throw new Error('Missing article.');
        }
        const { dateModified } = article;
        if (dateModified === undefined) {
            throw new Error('Missing dateModified.');
        }
        const now = new Date();
        const year = dayjs(now).diff(dateModified, 'year');
        alertTextElement.textContent = `この記事は最終更新日から${year}年以上が経過しています。`;
    };

    const alertTextElement = findAlertTextElement();
    if (alertTextElement !== undefined) {
        updateAlertTextElement(alertTextElement);
    }

})(dayjs);
```

## サンプルリポジトリ

この記事で紹介している手法は、こちらのリポジトリで実際に採用しているものです。

https://github.com/munierujp/userscripts

よければ参考にしてみてください。

[^1]: [tsconfig.jsonを書くときはTSConfig Basesを使うと便利 - Qiita](https://qiita.com/munieru_jp/items/a67ac782bbf099d90128)
[^2]: [TypeScriptの型定義パッケージ（@types/~）はどのバージョンをインストールすればいいのか - Qiita](https://qiita.com/munieru_jp/items/f2fec5580f53044a8496)