Viteで従来型静的サイトの開発環境を構築するためにプラグインをたくさん作った

3個以上をたくさんとする。V3対応。

:::note warn
今後のメンテナンスについて (2024-10-07)

- 作者が静的サイトの開発環境をAstroに移行したため、今後本記事内の各プラグインにRollup/Viteの仕様変更に起因する不具合が生じた場合、対応に必要な工数によっては開発を打ち切る可能性がありますのでご了承ください
:::

:::note warn
Vite V5に対応しました (2024-03-19)

- 確認が不十分なため不具合がある可能性があります
:::

:::note warn
Vite V4に対応しました (2022-12-24)

- Rollup 3 の破壊的変更の影響で `vite-plugin-imagemin-cache` のディレクトリ構造再現オプションが廃止されました
:::

# 概要

ソースの物量に関わらず瞬時に起動する開発サーバに惹かれて [Vite](https://ja.vitejs.dev/) を古典的な従来型静的Webサイトの開発環境に利用しようとしたものの、今時のSPA/MPAがターゲットのためそのようなナレッジもプラグインもほぼほぼ存在せず、「なければ作ればいい」を地で行く羽目になった、というオチ。

Pug+SCSS+TypeScriptとimageminを使用して基本的な従来型静的サイトの制作要件に対応するため、以下のプラグインを作成した。

- Pugを静的HTMLとして配信・出力する `vite-plugin-pug-static` プラグイン
- ビルド対象をglobパターンで取得する `vite-plugin-glob-input` プラグイン
- imageminにキャッシュ機能を組み込んだ `vite-plugin-imagemin-cache` プラグイン
- 開発サーバ・プレビューサーバのミドルウェア設定を外に出すだけの `vite-plugin-connect-middleware` プラグイン

これらを使用した開発環境の特徴は――概ねViteとRollupの特徴そのものだが――以下の通り。

- 事前ビルドなしで高速起動するViteのビルトイン開発サーバ
- [Rollup](https://rollupjs.org/) と圧縮済画像のキャッシュによる比較的高速なビルド
- 特有の作法は多少あるものの、Gulp等で構築された既存の開発環境からの移行は比較的容易
- Viteが対応しているVue・React・SolidJS等のフレームワークはCSRや部分的なSPA/MPAとして使用可能
- Rollupがバンドル処理するリソース（CSS/JS/画像等）の配置自由度が高い
    - ファイルツリー上でPugパーシャルに寄せてSCSS/TSを配置し、パーシャル内でインラインに配置したlink/script要素から読み込むような構成も可
    - Pugから出力したHTMLもソース上に再配置してバンドル処理に組み込める
- PostCSS使用可

ESBuildエコシステムか、またはNode.js以外の選択肢が十分に成熟するまでの繋ぎとしては悪くないと思われる。
なお、これらのプラグインを使用した開発環境は数万UU/日規模の案件で実戦投入済。

# 作成したプラグイン

- TypeScript製
- [勉強](https://qiita.com/macropygia/items/7d9a06cf33ec4997dcfa)も兼ねてpnpm Workspaceによるmonorepoに[収めた](https://github.com/macropygia/static-site-stack)

:::note warn
使用上の注意
- プラグインは全てESM専用モジュールのため、使用するプロジェクトではpackage.jsonで `"type": "module"` を指定する必要がある
- 2022-07-17の時点ではいずれもunstableのため、予告なく破壊的変更が行われる可能性がある
    - 安定稼働が要求される用途に使用する場合はバージョン固定を推奨
- Windows環境でのテストが全体的に不十分
:::

## vite-plugin-pug-static

https://www.npmjs.com/package/@macropygia/vite-plugin-pug-static

その1。ビルド・開発サーバ両用。
Viteは標準ではPugに対応していないため、Pugを静的HTMLとして処理するプラグインを作成した。

- 開発サーバにおいてはmiddlewareを使用し、HTMLにアクセスがあったらPugのコンパイル結果を返すアプローチで対応
    - Pugをコンパイルする際に `include` と `extends` による依存関係が取得できるため、これをVite内部の依存関係ストアに反映しておき、ファイル更新イベント発生時に参照して必要な時だけコンパイルがトリガされるようにした
    - リロードはファイル更新イベント発生時に常に行うようにした
        - コンパイル結果のHTMLをパースして画像等の「全ての依存関係」を依存関係ストアに注入すればリロードのタイミングも最適化できるが、実装コストや取りこぼしのリスクに対して効能が小さそうなので一旦見送り
- ビルドにおいてはViteの設定でエントリーポイントとしてPugを登録しておき、ビルド中に拡張子をhtmlに書き換えつつコンパイルした内容を返す、というアプローチで対応

```ts:vite.config.ts
import path from 'path'
import pugPlugin from '@macropygia/vite-plugin-pug-static'

const basedir = path.resolve(__dirname, 'src')

export default defineConfig({
  // ...
  root: 'src',
  build: {
    // この部分は次節の vite-plugin-glob-input で自動処理可能
    rollupOptions: {
      input: {
        home: 'src/index.pug',
        foo: 'src/foo/index.pug',
        bar: 'src/bar/index.pug',
      }
    }
  },
  plugins: [
    pugPlugin({
      buildOptions: { basedir },
      serveOptions: { basedir },
    }),
  ]
  // ...
})
```

なお、作成にあたり以下の記事を参考にさせていただいた。

https://zenn.dev/yend724/articles/20220408-tfq16buha8ctdzp7

## vite-plugin-glob-input

https://www.npmjs.com/package/@macropygia/vite-plugin-glob-input

その2。ビルド用。

ViteはMPAに対応しているが、標準機能では出力対象を設定ファイルに全数列挙する必要がある。
従来型静的サイトではソースのファイルツリーからHTML（今回は実体はPug）をディレクトリ構造を保ったまま出力する必要があり、数も出入りも多い。
それらを全て手動で設定に反映するのは現実的ではないため、globパターンで出力ファイルを洗い出して登録するプラグインを作成した。

処理的にはほぼ [fast-glob](https://www.npmjs.com/package/fast-glob) そのものであり、ignoreオプションも使用可能。
「まだ表に出したくないけどブランチを分けるのも面倒」な場合に一時的にビルドから除外するような使い方もできる。

```ts:vite.config.ts
import inputPlugin from '@macropygia/vite-plugin-glob-input'

export default defineConfig({
  // ...
  plugins: [
    inputPlugin({
      patterns: [
        'src/**/[^_]*.pug', // パーシャルではないPugファイルを全て出力ファイルに設定
        'src/foo/*/*.html', // HTMLをバンドル処理に含めることもできる
      ],
      options: {
        ignore: [
          'src/bar/baz/**/*.pug' // 一部だけビルドから除外（開発サーバでは表示される）
        ]
      }
    }),
  ]
  // ...
})
```

## vite-plugin-imagemin-cache

https://www.npmjs.com/package/@macropygia/vite-plugin-imagemin-cache

その3。ビルド用。
従来型静的サイトでは大量・大容量の画像を扱うことは珍しくないため、imageminを使用した画像圧縮にキャッシュを組み込んだプラグインを作成した。

- バンドル処理される画像についてはViteが付与するハッシュを利用して変更の検知を行い、静的配置の画像については [PolyCRC](https://www.npmjs.com/package/polycrc) を使用して取得したCRC32で変更を検知する仕様とした
- キャッシュの情報は [LokiJS](https://www.npmjs.com/package/lokijs) で永続化し、処理履歴を保持することで「直近のn回以内のビルドないし直近のm秒以内のビルドのいずれでも使用されなかったキャッシュは削除」する仕様とした
    - 標準設定は「10回以内」および「10日以内」
- imageminの同時実行数は [p-limit](https://www.npmjs.com/package/p-limit) で制御する
    - 標準設定は「実行環境のCPU数」
- 対応画像フォーマットはPNG/JPEG/SVGとし、 `imagemin-mozjpeg` `imagemin-pngquant` `imagemin-optipng` `imagemin-svgo` を採用した
    - `imagemin-webp` はなんでもかんでもWebPに変換しようとするため一旦採用を見送った
- 圧縮に耐えない画像が存在する場合に備え、picomatchで除外パターンを設定できるようにした
    - 静的サイト開発では10年以上前に作られた小さな低画質JPEG画像が素材として提供されることはそれほど珍しくない
- もちろんCIでも使用できる、キャッシュディレクトリは変更可能

```ts:vite.config.ts
import imageminPlugin from '@macropygia/vite-plugin-imagemin-cache'

export default defineConfig({
  // ...
  plugins: [
    imageminPlugin ({
      exclude: [
        '**/old_*.jpg', // 除外パターン
      ],
      plugins: { // imageminプラグインの設定
        pngquant: {
          speed: 1,
          quality: [0.65, 1],
        },
        mozjpeg: {
          quality: 85,
        },
        svgo: {
          plugins: [
              {
              name: 'removeDimensions',
              active: true,
              },
              // ...
          ],
        },
      },
    }),
  ]
  // ...
})
```

### v0.1.0の追加機能

（2022-07-21追記, 2022-12-24編集）

- ~~静的アセットをソース上のディレクトリ構造とファイル名を再現して出力する機能を追加~~
    - ~~次項のキャッシュバスターと併用可能~~
- HTML内で静的アセット画像を参照している属性にクエリパラメータとしてハッシュを追加するキャッシュバスター機能を追加
    - ハッシュにはViteが内部で保持しているSHA256を使用
- 静的アセットでも同一性判定にCRC32を使用できるように改修
    - 静的アセットの出力ファイル名にハッシュが含まれているかどうかで自動判定を行う
    - 明示的な指定も可
- Vite標準のpublicディレクトリのコピーを停止し、圧縮対象の画像とそれ以外のファイルを分けてコピーする機能を追加
    - 標準ではpublicディレクトリ内の全てのファイルをコピーした後に圧縮された画像が上書きされるため、数が多い場合は非効率

## vite-plugin-connect-middleware

https://www.npmjs.com/package/@macropygia/vite-plugin-connect-middleware

その4。開発サーバ・プレビューサーバ用。

一部のリソースが外部のサーバを参照している・部分的にURLを参照した出し分けを行う・一部が丸ごとReact――等々、静的サイトではリダイレクトは珍しいことではないため、開発サーバ・プレビューサーバのリダイレクト設定を簡単に行うためのプラグインを作成した。

いずれも内部的には [connect](https://www.npmjs.com/package/connect) を使用しているため、connectのmiddlewareを丸ごと注入するだけの単純な構造となっている。

```ts:vite.config.ts
import middlewarePlugin from '@macropygia/vite-plugin-connect-middleware'

middlewarePlugin ((req, res, next) => {
  if (req.url) {
    if (req.url.startsWith('/app/')) {
      req.url = '/app/'
    } else if (req.url.startsWith('/downloads/')) {
      res.writeHead(301, {
        Location: `https://example.com${req.url}`,
      })
    }
  }
  return next()
}),
```

この手の開発環境を弄っている人なら [Browsersync](https://browsersync.io/) でお馴染みのはず。

# 動作サンプル

https://github.com/macropygia/vite-trad-static-boilerplates

## 使用時の留意点

Vite特有の作法として、ビルド時のRollupによるバンドル処理に起因するものがある。
詳細はVite公式ドキュメントの [静的アセットの取り扱い](https://ja.vitejs.dev/guide/assets.html) に譲るが、主立った所では以下の通り。

- HTMLを除いてソース上のディレクトリ構造とファイル名は保持されない
    - そのためJavaScript内で使用するリソースはimportでJavaScriptとしての依存関係に組み込む必要がある
        - 例: `import baz from '/foo/bar/baz.png'` → `imgElement.src = baz`
    - 厳密には再現できるが、特殊な使い方と言える
        - 再現する場合でも引き続きリソース類は依存関係に組み込む必要がある
- ディレクトリ構造とファイル名を保持する必要がある場合は基本的にpublicディレクトリを使用する
    - 公式が例に出している `robots.txt` や絶対パスで指定する必要があるOGP画像など
    - 前項の例なら `src/public/foo/bar/baz.png` に配置すればハードコーディングできる
    - ~~画像については `vite-plugin-imagemin-cache` プラグインにディレクトリ構造を再現する機能を追加予定~~
- 開発サーバ上の動作がビルド後も完全に再現されるとは限らない
    - これまでに手元で確認した例としては、JavaScript側でimportしているスタイルとSCSSで定義しているスタイルの読込順序の違いから最終的に当たるスタイルが異なる場合があった
    - JavaScriptについては [V3ではそのような症状は発生しないらしい](https://ja.vitejs.dev/guide/migration.html#%E3%82%A2%E3%83%BC%E3%82%AD%E3%83%86%E3%82%AF%E3%83%81%E3%83%A3%E3%81%AE%E5%A4%89%E6%9B%B4%E3%81%A8%E3%83%AC%E3%82%AC%E3%82%B7%E3%83%BC%E3%82%AA%E3%83%97%E3%82%B7%E3%83%A7%E3%83%B3)