rollupを用いて、ユーザースクリプトをTypeScriptで書く環境を構築する手順を示します。
先日投稿した、ブックマークレットをTypeScriptで書く記事のユーザースクリプト版です。
ユーザースクリプトをTypeScriptで書きたい
私は、日々のウェブブラウジングを快適にするためのユーザースクリプトを自作しています。
ユーザースクリプトの魅力は、ブックマークレットよりは高度なことがやりやすく、拡張機能よりはお手軽というちょうどよさにあります。
しかしながら、ある程度の規模を超えたユーザースクリプトを素のJavaScriptで、それも1つのファイルの中に書いていくのは、なかなかつらいものです。
そこで、ユーザースクリプトをTypeScriptで書きたいと思いました。
ユーザースクリプトをTypeScriptで書くには
ユーザースクリプトをTypeScriptで書くには、おおむね次のようなことをおこなう必要があります。
- TypeScriptをJavaScriptにトランスパイル
- ファイルが分かれている場合は1ファイルにバンドル
- グローバル空間の汚染を避けるために、スクリプト全体をIIFEでラップ
- ファイルの先頭にユーザースクリプト用のヘッダーを付与
- ファイルの拡張子を
.user.js
とする
これには様々な手法がありますが、今回はモジュールバンドラーのrollupを採用しました。
特徴
この記事で紹介する手法には、次のような特徴があります。
これらの特徴によって、快適なDX(開発者体験)がもたらされることでしょう。
ヒューマンリーダブルなファイル出力
生成されるユーザースクリプトはミニファイされていないため人間が書くJavaScriptのコードに近く、高いリーダビリティがあります。
ユーザースクリプトの購読者(開発者自身を含む)がちょっとしたデバッグのためにコードの一部を変更したい場合、ユーザースクリプトマネージャー上で容易に編集することができます。
ファイル分割
適切にファイルを分割することで、大規模なユーザースクリプトでもメンテナビリティを高く保つことができます。
また、ユーザースクリプト間で共通する処理を切り出して共用することもできます。
たとえば、スリープ処理をよく使うのであればsleep.ts
のようなファイルに関数を定義しておき、それを各所でインポートするとよいでしょう。
ESLintによる静的解析
ソースコードはもちろんのこと、生成されたユーザースクリプトをESLintで静的解析することで、ユーザースクリプトとしてのベストプラクティスが適用されます。
Jestによるテスト実行
テストを書くことで、ユーザースクリプトの品質を担保することができます。
もちろん、テストコードの中でDOMを扱うこともできます。
ローカル開発用ユーザースクリプトの出力
通常のユーザースクリプトとは別に、ローカル開発用のユーザースクリプトも生成されます。
これをユーザースクリプトマネージャーに登録し、開発時はそちらを使うようにすると、ソースコードの更新が即座に反映されるので便利です。
ウォッチモード
開発時に使用するウォッチモードではソースコードを保存する度にビルドが走り、ユーザースクリプトが更新されます。
ローカル開発用ユーザースクリプトと合わせて使用することで、常に最新の状態が適用されるようになります。
外部パッケージの使用
通常のユーザースクリプトと同様に、@require
にURLを指定することで外部パッケージを使用することができます。
有名どころだと、Day.js、jQuery、Lodashなどが利用できます。
ディレクトリ構成
この記事で紹介する手法では、次のようなディレクトリ構成になります。
.
├── .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独自の機能などは使っていないので、他のユーザースクリプトマネージャーでも問題なく動作するかと思います。
環境構築
ユーザースクリプトを開発するための環境を構築します。
パッケージをインストール
開発に必要なパッケージをひととおりインストールします。
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を定義します。
{
"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
をつくります。
{
"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
をつくります。
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
をつくります。
module.exports = {
extends: [
'plugin:userscripts/recommended'
],
parserOptions: {
ecmaVersion: 'latest'
}
}
jest.config.ts
を作成
Jestの設定ファイルとして、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
をつくります。
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
をつくります。
なにかしら処理を書いてもいいですが、特になければ空でもかまいません。
ただし、中身が空だとビルド時に(!) Generated an empty chunk
のような警告が表示されるので、気になる場合はテキトーに中身を書いておきましょう。
console.debug('This script is for development purposes only.')
.gitignore
を更新
ローカル開発用ユーザースクリプトにはファイルパスが含まれるので、Gitの管理対象外にするために.gitignore
に/dist/**/*.dev.user.js
を追加します。
/dist/**/*.dev.user.js
ユーザースクリプトのサンプル
ユーザースクリプトを開発するための環境が整ったので、いくつかの例を示します。
Hello World
はじめにもっともシンプルな例として、example.comにアクセスするとコンソールにHello World
と表示するユーザースクリプトをつくります。
まずはsrc
ディレクトリ以下にhello
ディレクトリを切り、その中にエントリーファイルとしてmain.ts
をつくります。
console.log('Hello World')
main.ts
と同じディレクトリに、マニフェストファイルとして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
ディレクトリにユーザースクリプトが生成されます。
// ==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をインストールします。
npm i -D dayjs
src
ディレクトリ以下にqiita/improve-old-article-alert
ディレクトリを切り、その中にエントリーファイルとしてmain.ts
をつくります。
import { findAlertTextElement } from './findAlertTextElement'
import { updateAlertTextElement } from './updateAlertTextElement'
const alertTextElement = findAlertTextElement()
if (alertTextElement !== undefined) {
updateAlertTextElement(alertTextElement)
}
また、他にも必要なファイルをひととおりつくります。
import { isAlertTextElement } from './isAlertTextElement'
export const findAlertTextElement = (): HTMLParagraphElement | undefined => {
return Array.from(document.getElementsByTagName('p')).find(element => isAlertTextElement(element))
}
export const isAlertTextElement = (node: Node): boolean => {
const { textContent } = node
return textContent !== null && /^この記事は最終更新日から\d+年以上が経過しています。$/.test(textContent)
}
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}年以上が経過しています。`
}
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)
}
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
をつくります。
{
"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
ディレクトリにユーザースクリプトが生成されます。
// ==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);
サンプルリポジトリ
この記事で紹介している手法は、こちらのリポジトリで実際に採用しているものです。
よければ参考にしてみてください。