やりたいこと

  • webpackでejsをhtmlに変換する
  • フッターやヘッダーなどの共通部分をincludeで読み込む
  • sassをcssに変換する
  • 画像やフォントなどを使用する

想像以上にハマったのでテンプレートを公開する。

TL;DR

githubならココ
https://github.com/kajikentaro/webpack-ejs-html

ソースのzipがほしいならココ
https://github.com/kajikentaro/webpack-ejs-html/archive/refs/tags/1.3.zip

ダウンロード後、必要に応じて以下のコマンドを実行する

npm install #パッケージのインストール
npm run build #ビルドする
npm run watch #差分ビルドする
npm run server #開発用サーバーを起動する

環境

  • node 18.6

古すぎるとうまく行かないかも

コマンド解説

npm run install
webpackやwebpackを動かすのに必要なプラグインをインストールする

npm run build
本番環境用に、圧縮されたHTMLやCSSファイルをdistディレクトリに生成する

npm run watch
開発環境用に、普通のビルドよりも高速に差分ビルドを行う。srcディレクトリのファイルが更新されると、それを検知して自動で再ビルドが走る(便利)

npm run server
開発用のサーバーがlocalhost:3000に起動する。dist内のファイルが更新されるとブラウザが自動でリロードされる。

注意

できれば絶対パスを使う

includeを使う際、できれば/srcからの絶対パスで表記するようにする。
なぜなら、もしsrc/sub/index.ejsが別のejsから読み込まれた場合は、「別のejsからの相対パス」に解釈されてしまうため。

<html>
  <%- include("/template/_head.ejs") %>
  <body>
    <%- include("../template/_header.ejs") %>
    <div>index.ejs</div>
    <img src="/sample.png"/>
    <%- include("../template/_footer.ejs") %>
  </body>
</html>
<link rel="stylesheet" href="./style/index.scss" />

src/sub/index.ejs

静的ファイル(ejs, sass, js以外)は全てpublicフォルダに置く

画像やフォントなどwebpackで変換が不要なものはpublicに配置する。
CopyWebpackPluginがpublicディレクトリをまるごとdistにコピーしているため

こちらも<img src="/sample.png"/>のようにできれば絶対パスで記述する。

テンプレートはファイル名先頭に_をつける

拡張子がejsのファイルは全てwebpackのエントリーポイントになるが、
アンダーバー_ をファイル名の先頭につけることで例外になる。

例えば共通フッターを_footer.ejsではなくfooter.ejsのファイル名にするとfooter.htmlというファイルが作成されてしまう。

webpack.confing.js

const glob = require("glob");
const fs = require("fs");
const path = require("path");
const CopyWebpackPlugin = require("copy-webpack-plugin");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const RemoveEmptyScriptsPlugin = require("webpack-remove-empty-scripts");

// srcディレクトリと拡張子を消す
// ex: ./src/index.html -> ./index
const arrangePath = (fullPath) => {
  const removedExtension = fullPath.match(/(.+\/.+?).[a-z]+([?#;].*)?$/)[1];
  const removedSrcDir = removedExtension.replace(/src\//, "");
  return removedSrcDir;
};

// src配下の全てのejsパスを取得する
const getAllEjs = () => {
  const ejsList = glob.sync("./src/**/[!_]*.ejs");
  return ejsList;
};

const getEntry = () => {
  const entry = {};
  getAllEjs().forEach((v) => {
    entry[arrangePath(v)] = v;
  });
  return entry;
};

// src配下の全てのejsを、ejsからhtmlに変換するプラグインを作成する
const getHtmlPlugins = () => {
  const ejsList = getAllEjs();
  const htmlWebpackPlugins = ejsList.map((v) => {
    return new HtmlWebpackPlugin({
      filename: arrangePath(v) + ".html",
      template: v,
      chunks: [arrangePath(v)], // デフォルトはallなので全てのcssやjsが挿入されてしまう
    });
  });
  return htmlWebpackPlugins;
};

module.exports = {
  entry: getEntry(),
  output: {
    path: path.resolve(__dirname, "dist"),
    filename: "[name].js",
    clean: true,
    publicPath: "/",
  },
  module: {
    rules: [
      {
        test: /\.ejs$/,
        use: [
          {
            loader: "html-loader",
            options: {
              sources: {
                // copy-webpack-pluginでpublicをまるごとコピーするためcss, js以外の名前解決は行わない
                urlFilter: (attribute, value, resourcePath) => {
                  if (/\.(scss|sass)$/.test(value)) {
                    return true;
                  }
                  if (/\.(js)$/.test(value)) {
                    return true;
                  }
                  return false;
                },
              },
              minimize: false,
            },
          },
          {
            loader: "template-ejs-loader",
            options: {
              includer: (originalPath, parsedPath) => {
                let filename = "";
                if (/^\./.test(originalPath)) {
                  // includeでパスが'.'から始まる場合
                  filename = parsedPath;
                } else if (/^\//.test(originalPath)) {
                  // includeでパスが'/'から始まる場合
                  filename = path.resolve(__dirname, "src", "." + originalPath);
                } else {
                  filename = path.resolve(__dirname, "src", originalPath);
                }

                if (filename && fs.existsSync(filename)) {
                  return { filename };
                }
                // ファイルが存在しない場合
                throw new Error("Not Found: could not resolve " + originalPath);
              },
            },
          },
        ],
      },
      {
        test: /\.(scss|sass|css)$/,
        use: [
          MiniCssExtractPlugin.loader,
          { loader: "css-loader", options: { url: false } },
          "sass-loader",
        ],
      },
    ],
  },
  resolve: {
    modules: [
      path.resolve(__dirname, "node_modules"),
      path.resolve(__dirname, "src"),
    ],
    roots: [path.resolve(__dirname, "src")],
  },
  plugins: [
    // webpackの仕様上, 余計なjsファイルが生まれるので削除
    new RemoveEmptyScriptsPlugin({
      extensions: /\.(css|scss|sass|less|styl|ejs|html)([?].*)?$/,
      remove: /main\.(js|mjs)$/,
    }),
    // htmlをdistに出力
    ...getHtmlPlugins(),
    // cssをdistに出力
    new MiniCssExtractPlugin({
      filename: "[name]-[contenthash].css",
    }),
    // publicフォルダーをdistにコピー
    new CopyWebpackPlugin({
      patterns: [
        {
          from: path.resolve(__dirname, "public"),
          to: path.resolve(__dirname, "dist"),
        },
      ],
    }),
  ],
  devtool: "source-map",
};

解説

srcフォルダ内の全てのejsのパスをエントリーポイント(webpackが最初に読み込むファイル)にする。
ファイル名先頭が_で始まるものはスキップ。

template-ejs-loader ejsのパスをHTMLに変換する。includeが使われていたらそれも処理する
html-loader HTMLを読み込む。ファイル内でsassや外部JavaScriptが使われていたらsass, 外部JavaScriptのパスをwebpackに渡す
HtmlWebpackPlugin HTMLをファイルに出力する。改行やコメントが使われていたら消す

webpack-remove-empty-scripts webpackはejsやsassなどの入力ファイルを全てjs(main.jsなど)として出力してしまうので、その余計なファイルを消す

sass-loader sassのパスをcssに変換する
css-loader cssを読み込む。ほぼ何もしないで右から左に渡す
mini-css-extract-plugin cssをファイルに出力する

copy-webpack-plugin src/publicフォルダーをdist/publicにコピーする

package.json

npm run dev を実行することでdist フォルダに開発用のビルドされたファイルが生成される

npm run build を実行することでdist フォルダに本番用のビルドされたファイルが生成される

npm run watch を実行することでWebサーバーを起動する。ファイルを編集すると自動でリロードされる

{
  "name": "test",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "scripts": {
    "dev": "webpack --mode=development",
    "build": "webpack --mode=production",
    "watch": "webpack --mode=development --watch",
    "server": "browser-sync start -s dist -w dist/**"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "browser-sync": "^2.27.10",
    "copy-webpack-plugin": "^11.0.0",
    "css-loader": "^6.7.1",
    "ejs": "^3.1.8",
    "file-loader": "^6.2.0",
    "glob": "^8.0.3",
    "html-loader": "^4.1.0",
    "html-webpack-plugin": "^5.5.0",
    "mini-css-extract-plugin": "^2.6.1",
    "sass": "^1.45.1",
    "sass-loader": "^12.4.0",
    "template-ejs-loader": "^0.9.3",
    "webpack": "^5.73.0",
    "webpack-cli": "^4.10.0",
    "webpack-remove-empty-scripts": "^0.8.1"
  }
}

まとめ

webpackを使用することでejsをhtmlにビルドすることができた。

ejsを用いればヘッダーやフッター、各コンポーネントなどの共通化を行うことができる。
もちろんfor文で同じ処理を繰り返すみたいな事もできるので便利。

変換したファイル群はdist/に出力されるので、それをS3に保存したりやftpでアップロードすれば公開ができる。

webpackで生成したファイルは、コメントや無駄な空白改行が削除されているので、容量の削減やユーザーの信頼性の向上が期待できる。