Viteで純粋なPugを使う


VitePugを使おうとすると少し厄介だったので、環境構築の手順をまとめました。

「vite pug」等で検索するとVueやReact、HTMLを噛ませる方法はヒットするのですが、Pugを単体で使う方法はなかなかヒットしません。なのでViteで純粋なPugを使えるようにしようというのが本記事の趣旨です。

環境構築

まずは普通に環境構築をします。

Viteプロジェクトの作成

https://vitejs.dev/guide/

公式ガイドの手順に従ってViteの環境構築を行います。
本記事ではyarnを使いますが、npmでも問題ないと思います。

shell
$ yarn create vite
#...
✔ Project name: … vite-pug-project
✔ Select a framework: › vanilla
✔ Select a variant: › vanilla-ts
#...

プロジェクト名はvite-pug-project、フレームワークはなし(vanilla)でTypeScript(vanilla-ts)を選択しました。

shell
$ cd vite-pug-project # プロジェクトディレクトリに移動
$ yarn # yarn install でモジュールをインストール
$ yarn dev # 開発サーバーの立ち上げ

指示された手順に従っているだけですが、これで開発サーバーが立ち上がります。
非常に簡単ですね。

Hello Vite!
Documentation

という文字がブラウザに表示されていれば問題ありません。

環境のカスタマイズ

現場のディクレクトリ構造は以下のようになっていると思います。

vite-pug-project
  ├── node_modules - ...略
  ├── favicon.svg
  ├── index.html
  ├── package.json
  ├── src
  │   ├── main.ts
  │   ├── style.css
  │   └── vite-env.d.ts
  ├── tsconfig.json
  └── yarn.lock

このままでもいいのですが、index.htmlがルートにあるとMulti-Page Appでは扱いづらいのでsrcフォルダの中に移動します(ついでにfaviconもいらないので削除してください)。

それに伴って、Viteの設定ファイルをいじります。

https://vitejs.dev/config/

プロジェクトのルートにvite.config.jsを作成して、下記のように記述してください。

vite.config.js
import { resolve } from "path";
import { defineConfig } from "vite";

export default defineConfig({
  root: "src",
  build: {
    outDir: resolve(__dirname, "dist"),
  },
});

root(index.htmlが置かれる場所)をsrcに設定して、ビルドするディレクトリをdistに指定しています。

場所を移動したのでindex.htmlも書き換えます。

src/index.html
 <!DOCTYPE html>
 <html lang="ja">
  <head>
    <meta charset="UTF-8" />
-   <link rel="icon" type="image/svg+xml" href="favicon.svg" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <title>Vite App</title>
  </head>
  <body>
    <div id="app"></div>
-   <script type="module" src="/src/main.ts"></script>
+   <script type="module" src="/main.ts"></script>
  </body>
 </html>

一度yarn devで開発サーバー立ち上げてみて、問題なければyarn buildコマンドでビルドしてください。

vite-pug-project
  ├── node_modules - ...略
  ├── dist
  │   ├── assets
  │   │   ├── index.06d14ce2.css
  │   │   └── index.ad4f7fa4.js
  │   └── index.html
  ├── package.json
  ├── src
  │   ├── index.html
  │   ├── main.ts
  │   ├── style.css
  │   └── vite-env.d.ts
  ├── tsconfig.json
  ├── vite.config.js
  └── yarn.lock

無事ビルドができれば上記のようなディレクトリ構造になると思います。

Pugを使えるようにする

ここまできたらPugを使うための準備をします。
まずはPugに必要なモジュールと型定義ファイルをインストールしましょう。

shell
$ yarn add --dev pug @types/pug @types/node

次にindex.htmlを削除して、index.pugを追加しましょう。
以下は単純にindex.html内容をindex.pugに書き換えただけのものになります(変化がわかるようにtitleだけ変更)。

index.pug
doctype html
html(lang="ja")
  head
    meta(charset="UTF-8")
    meta(name="viewport", content="width=device-width, initial-scale=1.0")
    //- index.pugからビルドされたものだとわかるようにtitleだけ変更する
    title Vite Pug App
  body
    #app
    script(src="/main.ts" type="module")

ビルド用プラグインの作成

Pugをビルドするためにプラグインを自作します。
プラグインを自作する方法は以下に載っています。
ちゃんと作ろうとするとrollup.jsの知識も必要となります(なお、私はViterollup.jsも初めて学びました)。

https://vitejs.dev/guide/api-plugin.html
https://rollupjs.org/guide/en/

ルートにpluginsディレクトリを作成して、その中にプラグインを作成しましょう。

plugins/vite-plugin-pug-build.ts
import fs from "fs";
import type { Plugin } from "vite";
import { compileFile } from "pug";

export const vitePluginPugBuild = (): Plugin => {
  const pathMap: Record<string, string> = {};
  return {
    // Vite専用プラグインの命名には「vite-plugin-」のプレフィックスをつけるらしい
    name: "vite-plugin-pug-build",
    enforce: "pre",
    // ビルド時のみ
    apply: "build",
    // カスタムリゾルバーを定義できる
    // エントリーポイントの加工をできる
    resolveId(source: string) {
      if (source.endsWith(".pug")) {
        // xxxx.pug へのリクエストを
        // xxxx.html へのリクエストに偽る
        const dummy = `${
          source.slice(0, Math.max(0, source.lastIndexOf("."))) || source
        }.html`;
        // xxxx.pug と xxxx.html 対応表を作る
        pathMap[dummy] = source;
        // xxxx.html を返す
        return dummy;
      }
    },
    // ローダーを定義できる
    // ここでファイルの中身を読み込む
    load(id: string) {
      if (id.endsWith(".html")) {
        // xxxx.html へのリクエストがあった時
        if (pathMap[id]) {
          // もとのファイルが xxxx.pug の時は  pug をコンパイルして返す
          const html = compileFile(pathMap[id])();
          return html;
        }
        // もとのファイルも xxxx.html の時は xxxx.html の中身をそのまま返す
        return fs.readFileSync(id, "utf-8");
      }
    },
  };
};

上記で何をやっているか簡単に説明すると、xxxx.pugに来たリクエストをxxxx.htmlと偽って(?)、xxxx.pugをコンパイルした結果を返すということをしています。

resolveIdloadrollup.jsに存在するビルドフックで、ビルド時のそれぞれのタイミングで呼び出される関数です。
詳しくは以下のドキュメントをご参照ください。

https://rollupjs.org/guide/en/#build-hooks

後ほどもう1つプラグインを作成するので、vite-plugin-pug.tsを作成しラップしてexportします。

plugin/vite-plugin-pug.ts
import { vitePluginPugBuild } from "./vite-plugin-pug-build";

const vitePluginPug = () => {
  return [vitePluginPugBuild()];
};
export default vitePluginPug;

プラグインの読み込みとindex.pugをエントリーポイントとして明示的に指定するためにvite.config.jsを次のように書き換えます。

vite.config.js
import { resolve } from "path";
import { defineConfig } from "vite";
import vitePluginPug from "./plugins/vite-plugin-pug";

export default defineConfig({
  root: "src",
  build: {
    outDir: resolve(__dirname, "dist"),
    rollupOptions: {
      input: {
        main: resolve(__dirname, "src", "index.pug"),
      },
    },
  },
  plugins: [vitePluginPug()],
});

現状のディクトリ構造は下記のようになります。

vite-pug-project
  ├── dist
  │   ├── assets
  │   │   ├── index.06d14ce2.css
  │   │   └── index.ad4f7fa4.js
  │   └── index.html
  ├── package.json
  ├── plugins
  │   ├── vite-plugin-pug-build.ts
  │   └── vite-plugin-pug.ts
  ├── src
  │   ├── index.pug
  │   ├── main.ts
  │   ├── style.css
  │   └── vite-env.d.ts
  ├── tsconfig.json
  ├── vite.config.js
  └── yarn.lock

ここまできたら一旦yarn buildしてみます。

dist/index.html
<!DOCTYPE html>
<html lang="ja">
  <head>
    <meta charset="UTF-8" />
    <meta name="viewport" content="width=device-width, initial-scale=1.0" />
    <!-- Vite Pug App なので index.pug がビルドされている -->
    <title>Vite Pug App</title>
    <script type="module" crossorigin src="/assets/main.94834e27.js"></script>
    <link rel="stylesheet" href="/assets/main.b16cdc1a.css" />
  </head>
  <body>
    <div id="app"></div>
  </body>
</html>

distディレクトリにindex.htmlがビルドされていることがわかります!

開発サーバー用のプラグインの作成

ビルドはできましたが、この状態でyarn devをして開発サーバーを立ち上げても、index.pugファイルを確認することはできません。
この問題を解決するためには開発サーバー用のプラグインも作成する必要があります。

vite-plugin-pug-serve.tsというファイルを作成して、下記のように記述します。

plugins/vite-plugin-pug-serve.ts
import fs from "fs";
import { send } from "vite";
import type { ViteDevServer, Plugin } from "vite";
import { compileFile } from "pug";

const transformPugToHtml = (server: ViteDevServer, path: string) => {
  const compliled = compileFile(path)();
  return server.transformIndexHtml(path, compliled);
};

export const vitePluginPugServe = (): Plugin => {
  return {
    name: "vite-plugin-pug-serve",
    enforce: "pre",
    // 開発サーバー時のみ
    apply: "serve",
    handleHotUpdate(context) {
      // ファイルが保存された時にホットリロードする
      // この記述がないと xxxx.pug を保存した時にリロードされない
      context.server.ws.send({
        type: "full-reload",
      });
      return [];
    },
    configureServer(server) {
      server.middlewares.use(async (req, res, next) => {
        const root = server.config.root;
        let fullReqPath = root + req.url;

        if(fullReqPath.endsWith("/")){
          fullReqPath += "index.html"
        }

        if (fullReqPath.endsWith(".html")) {
          // xxxx.html にリクエストがきた時
          if (fs.existsSync(fullReqPath)) {
            // xxxx.html が存在するならそのまま次の処理へ
            return next();
          }

          // xxxx.htmlが存在しないときは xxxx.pug があるか確認する
          const pugPath = `${
            fullReqPath.slice(0, Math.max(0, fullReqPath.lastIndexOf("."))) ||
            fullReqPath
          }.pug`;
          if(!fs.existsSync(pugPath)){
            // xxxx.pug が存在しないなら 404 を返す
            return send(req, res, "404 Not Found", "html", {});
          }

          // xxxx.pug が存在するときは  xxxx.pug をコンパイルした結果を返す
          const html = await transformPugToHtml(server, pugPath);
          return send(req, res, html, "html", {});
        } else {
          // xxxx.html 以外へのリクエストはそのまま次の処理へ
          return next();
        }
      });
    },
  };
};

上記では開発サーバーを立ち上げた状態でxxxx.htmlへのリクエストがきたらxxxx.pugをコンパイルして結果を返すという処理を記述しています(xxxx.htmlファイルが存在するときはそれをそのまま返す)。

https://github.com/vitejs/vite/blob/main/packages/vite/src/node/server/send.ts

send()メソッドについては公式ドキュメントに記載があったわけではないのですが、他の方のプラグインで使っていたので存在を知りました。
上記を見る限りだと、拡張子の種類によって適切なヘッダーを付与してレスポンスを返してくれるメソッドのようです。

最後にvite-plugin-pug-build.tsと同様にvite-plugin-pug.tsからまとめてexportします。

plugins/vite-plugin-pug.ts
import { vitePluginPugBuild } from "./vite-plugin-pug-build";
import { vitePluginPugServe } from "./vite-plugin-pug-serve";

const vitePluginPug = () => {
  return [vitePluginPugBuild(), vitePluginPugServe()];
};
export default vitePluginPug;

ここまで書いたらyarn devして開発サーバーを立ち上げてください。

Hello Vite!
Documentation

が表示されていれば完璧です。

ボイラープレート

https://github.com/yend724/vite-pug-boilerplate

ここまでの作業をボイラープレートとしてまとめてあります。
全体の完成コードを確認したい方は上記をご参照ください。

パフォーマンスについて

本記事の内容は必ずしもViteに最適化された(想定された)方法ではない可能性があります。
一旦細かなパフォーマンスのことは置いておいて、従来の方法でPugを使えるようしようというのが趣旨になります。

(!) Could not auto-determine entry point from rollupOptions or html files and there are no explicit optimizeDeps.include patterns. Skipping dependency pre-bundling.

例えば本記事の内容で開発サーバーを立ち上げるとターミナルに上記のような文言が表示されます。
vite.config.jsで指定したエントリーポイントがxxxx.pugなので、(おそらく想定している拡張子とは違って?)うまくパスの解決ができていないようです。
それによりdependency pre-bundlingがされないようなのですが、パフォーマンスへの影響がどのくらいあるのかは未検証です(というか詳しい方いたら教えたください)。

dependency pre-bundlingについては以下に載っています。

https://vitejs.dev/guide/dep-pre-bundling.html

まとめ

ViteでPug単体を使う方法でした。正攻法ではないかもしれませんが、webpackや他のビルドツールに変わるオプションの1つとして備えておくのは十分ありだと思います。

今回はじめてVite(またrollup.js)を触ったのですが、自作プラグインの作り方やビルドフックについて理解が進んだので、違う機会でも活かせそうだなと感じました。ぜひ皆さんもいろいろ触ってみてください。

参考

https://vitejs.dev/
https://pugjs.org/api/getting-started.html