🐈

Viteで純粋なPugを使う

2022/04/08に公開
3

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"),
  },
});

rootindex.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の知識も必要となります。

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) => {
  try {
    const compiled = compileFile(path)();
    return server.transformIndexHtml(path, compiled);
  } catch (error) {
    console.log(error);
    return server.transformIndexHtml(path, "Pug Compile Error");
  }
};

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

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

Pugコンパイル時のオプションと変数の設定

せっかくなのでvite.config.jsからPugコンパイル時のオプションと変数を設定できるようにします。
ここは必要なければ(もしくはvite-plugin-pug-**.tsに直接書くのであれば)飛ばしても問題ありません。

本記事ではpug.compileFileを使用しているので、詳しくは以下のドキュメントを参照してください。

https://pugjs.org/api/reference.html#pugcompilefilepath-options

Pug - API Referenceからの引用
// pug.compileFile
// 下記の options と locals を vite.config.js から渡せるようにする

var pug = require('pug');
// Compile a function
var fn = pug.compileFile('path to pug file', options);
// Render the function
var html = fn(locals);
// => '<string>of pug</string>'

さてドキュメントを確認したところで、以下のようにvite.config.jsからoptionslocalsを渡せるようにします。
本記事ではbuildserveで別々に設定できるようにしていますが、プロジェクトによって同一で問題なければ、適宜修正してください。

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: [
      // options と locals の設定例
      vitePluginPug({
+      build: {
+        locals: { hoge: "hoge" },
+        options: { pretty: true },
+      },
+      serve: {
+        locals: { hoge: "hoge" },
+        options: { pretty: true },
+      },
      }),
    ],
  });

まずvite-puglin-pug.tsを以下のように修正します。
やっていることは単純でvite.config.jsから渡された引数を、それぞれvite-plugin-pug-build.tsvite-plugin-pug-serve.tsに渡している形になります。

plugins/vite-puglin-pug.ts
import type { LocalsObject, Options } from "pug";
import { vitePluginPugBuild } from "./vite-plugin-pug-build";
import { vitePluginPugServe } from "./vite-plugin-pug-serve";

// 引数の型定義
type PugSettings = {
  options: Options;
  locals: LocalsObject;
};
// オプショナルな引数として、options と locals を受け取る
const vitePluginPug = (settings?: {
  build?: Partial<PugSettings>;
  serve?: Partial<PugSettings>;
}) => {
  // build用の options と locals
  const buildSettings = {
    options: { ...settings?.build?.options },
    locals: { ...settings?.build?.locals },
  };
  // serve用の options と locals
  const serveSettings = {
    options: { ...settings?.serve?.options },
    locals: { ...settings?.serve?.locals },
  };

  // それぞれ引数として渡す
  return [
    vitePluginPugBuild({
      options: buildSettings.options,
      locals: buildSettings.locals,
    }),
    vitePluginPugServe({
      options: serveSettings.options,
      locals: serveSettings.locals,
    }),
  ];
};

export default vitePluginPug;

次にvite-plugin-pug-build.tsを修正します。
受け取ったoptionslocalscompileFileにセットしています。

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

// 引数の型定義
type PugSettings = {
  options: Options;
  locals: LocalsObject;
};
// options と locals を引数として受け取る
export const vitePluginPugBuild = ({options, locals}: PugSettings): Plugin => {
  const pathMap: Record<string, string> = {};
  return {
    name: "vite-plugin-pug-build",
    enforce: "pre",
    apply: "build",
    resolveId(source: string) {
      if (source.endsWith(".pug")) {
        const dummy = `${
          source.slice(0, Math.max(0, source.lastIndexOf("."))) || source
        }.html`;
        pathMap[dummy] = source;
        return dummy;
      }
    },
    load(id: string) {
      if (id.endsWith(".html")) {
        if (pathMap[id]) {
          // options locals を compileFile にセットする
          const html = compileFile(pathMap[id], options)(locals);
          return html;
        }
        return fs.readFileSync(id, "utf-8");
      }
    },
  };
};

最後にvite-plugin-pug-serve.tsを修正します。
build時と同様にoptionslocalscompileFileにセットしています。

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

// 引数の型定義
type PugSettings = {
  options: Options;
  locals: LocalsObject;
};

const transformPugToHtml = (
  server: ViteDevServer,
  path: string,
  options: Options,
  locals: LocalsObject
) => {
  try {
    // options と locals を compileFile にセットする
    const compiled = compileFile(path, options)(locals);
    return server.transformIndexHtml(path, compiled);
  } catch (error) {
    console.log(error);
    return server.transformIndexHtml(path, "Pug Compile Error");
  }
};
// options と locals を引数として受け取る
export const vitePluginPugServe = ({ options, locals }: PugSettings): Plugin => {
  return {
    name: "vite-plugin-pug-serve",
    enforce: "pre",
    apply: "serve",
    handleHotUpdate(context) {
      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")) {
          if (fs.existsSync(fullReqPath)) {
            return next();
          }

          const pugPath = `${
            fullReqPath.slice(0, Math.max(0, fullReqPath.lastIndexOf("."))) ||
            fullReqPath
          }.pug`;
          if (!fs.existsSync(pugPath)) {
            return send(req, res, "404 Not Found", "html", {});
          }

          // options と locals も渡す
          const html = await transformPugToHtml(server, pugPath, options, locals);
          return send(req, res, html, "html", {});
        } else {
          return next();
        }
      });
    },
  };
};

お疲れ様でした。以上で完成となります。
実際にoptionslocals を設定して、ビルドもしくは開発サーバーを立ち上げてみると、設定が反映されていることがわかると思います。

ボイラープレート

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)を触ったのですが、自作プラグインの作り方やビルドフックについて理解が進んだので、違う機会でも活かせそうだなと感じました。ぜひ皆さんもいろいろ触ってみてください。

vite-plugin-pug-staticについて

【追記 - 2022.07.17】
現在、Pugを静的HTMLとして配信・出力するプラグイン、vite-plugin-pug-staticが開発されています。
実は本記事を書くにあたって、誰か良い感じにプラグインを作ってくれ(他人任せ)という気持ちがあったので、大変ありがたく思います。

詳しくは以下の記事をご参照ください。

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

参考

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

Discussion

tamon.kondotamon.kondo

こんにちは、参考にさせていただきました。とてもわかりやすく実行できました。

一点ご質問なのですが、pug構文でのエラーの際にnpm run devが落ちてしまうのですが、こちらは参考にさせて頂いたコード上そうなってしまうのでしょうか?もしくはviteがそれに対応していないのでしょうか?
もし御時間ありましたらご回答いただけると助かります。

YENDYEND

tamon.kondo さん、ご指摘ありがとうございます。
これは私の参考コードが良くなかったですね。失礼しました。

plugins/vite-plugin-pug-serve.ts
const transformPugToHtml = (server: ViteDevServer, path: string) => {
-  const compiled = compileFile(path)();
-  return server.transformIndexHtml(path, compiled);
+  try {
+    const compiled = compileFile(path)();
+    return server.transformIndexHtml(path, compiled);
+  } catch (error) {
+    console.log(error);
+    return server.transformIndexHtml(path, "Pug Compile Error");
+  }
};

plugins/vite-plugin-pug-serve.tstransformPugToHtmlに対して上記のように例外処理を挟んでいただくと、Pugのコンパイルエラー時でも開発サーバーが落ちなくなると思います。
本記事内の方も修正させていただきました。