ブックマークレットをTypeScriptで書く（rollup編）

rollupを用いて、ブックマークレットをTypeScriptで書く環境を構築する手順を示します。

## ブックマークレットをTypeScriptで書きたい

私は、日々のウェブブラウジングを快適にするためのブックマークレットを自作しています。
ブックマークレットの魅力は、なんといっても1行でさくっと書けるお手軽さにあります。
しかしながら、ある程度の規模を超えたブックマークレットを素のJavaScriptで、それも1行の中に書いていくのは、なかなかつらいものです。
そこで、ブックマークレットをTypeScriptで書きたいと思いました。

## ブックマークレットをTypeScriptで書くには
 
ブックマークレットをTypeScriptで書くには、おおむね次のようなことをおこなう必要があります。

- TypeScriptをJavaScriptにトランスパイル
- ファイルが分かれている場合は1ファイルにバンドル
- グローバル空間の汚染を避けるために、スクリプト全体をIIFEでラップ
- 複数行に渡る場合は1行に圧縮
- ファイルの先頭に`javascript:`を付与

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

## 特徴

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

### ファイル分割

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

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

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

### ウォッチモード

開発時に使用するウォッチモードではソースコードを保存する度にビルドが走り、ブックマークレットが更新されます。

## ディレクトリ構成

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

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

- `.eslintrc.js`：ESLintの設定ファイル
- `.gitignore`：Gitの除外設定ファイル
- `dist`：ブックマークレットが出力されるディレクトリ
  - `dist/**/*.min.js`：ブックマークレット
- `jest.config.ts`：Jestの設定ファイル
- `package-lock.json`：npmのパッケージ情報のロックファイル
- `package.json`：npmのパッケージ情報ファイル
- `rollup.config.ts`：rollupの設定ファイル
- `src`：ブックマークレットのソースコードを配置するディレクトリ
  - `src/**/main.ts`：ブックマークレットの本体にあたるエントリーファイル
- `tsconfig.json`：TypeScriptの設定ファイル

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

## 使用環境

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

### 開発環境

ブックマークレットの開発には、次のような環境を使っています。

- 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

## 環境構築

ブックマークレットを開発するための環境を構築します。

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

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

```sh
npm i -D \
  @munierujp/eslint-config-typescript \
  @rollup/plugin-terser \
  @rollup/plugin-typescript \
  @tsconfig/node16 \
  @types/glob \
  @types/node@^16.11 \
  eslint \
  eslint-plugin-jest \
  glob \
  jest \
  jest-environment-jsdom \
  rimraf \
  rollup \
  rollup-plugin-bookmarklet \
  ts-jest \
  typescript
```

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

- `@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",
    "clean": "rimraf dist",
    "predev": "npm run clean",
    "dev": "rollup --config rollup.config.ts --configPlugin typescript --watch",
    "lint": "eslint '**/*.{js,ts}'",
    "test": "jest --passWithNoTests"
  }
}
```

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

- `build`：ソースコードをビルドしてブックマークレットを生成
- `clean`：ビルド結果を削除
- `dev`：開発用のウォッチモードを起動
- `lint`：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`を指定することで、生成されたブックマークレットには適用されないようにしています。

### `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 terser from '@rollup/plugin-terser'
import typescript from '@rollup/plugin-typescript'
import glob from 'glob'
import type { RollupOptions } from 'rollup'
import bookmarklet from 'rollup-plugin-bookmarklet'

const entryPaths = glob.sync('src/**/main.ts')
const configs: RollupOptions[] = entryPaths.map(entryPath => ({
  input: entryPath,
  output: {
    file: entryPath.replace(/^src\//, 'dist/').replace(/\/(.+)\/main\.ts$/, '/$1.min.js'),
    format: 'iife'
  },
  plugins: [
    typescript(),
    terser(),
    bookmarklet()
  ]
}))

export default configs
```

## ブックマークレットのサンプル

ブックマークレットを開発するための環境が整ったので、いくつかの例を示します。

### Hello World

はじめにもっともシンプルな例として、`Hello World`というアラートを表示するブックマークレットをつくります。
まずは`src`ディレクトリ以下に`hello`ディレクトリを切り、その中にエントリーファイルとして`main.ts`をつくります。

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

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

```js:dist/hello.min.js
javascript:!function(){"use strict";alert("Hello World")}();
```

### タイトルをコピー

次にもう少し複雑な例として、ページのタイトルをコピーするブックマークレットをつくります。
まずは`src`ディレクトリ以下に`copy-title`ディレクトリを切り、その中にエントリーファイルとして`main.ts`をつくります。

```ts:src/copy-title/main.ts
import { copy } from './copy'

copy(document.title).catch(error => {
  throw error
})
```

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

```ts:src/copy-title/copy.ts
export const copy = async (text: string): Promise<void> => {
  await navigator.clipboard.writeText(text)
}
```

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

```js:dist/copy-title.min.js
javascript:!function(){"use strict";(async t=>{await navigator.clipboard.writeText(t)})(document.title).catch((t=>{throw t}))}();
```

## サンプルリポジトリ

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

https://github.com/munierujp/bookmarklets

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

## 次回予告

次回は、ユーザースクリプトをTypeScriptで書く方法について紹介します。

https://qiita.com/munieru_jp/items/ce57ec8c839f4a8727be

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