【2024年版】Webpack×TypeScript×Pug×Stylusで作る爆速コーディング環境

# 　制作の方針
- webpack5を採用し汎用性の高い構成を採用しています。
- Pug、Stylusを採用することで既存の関数を複数のプロジェクトで使用し効率化を図ります。
- プロジェクトに参画しやすいようわかりやすい構成を意識しています。

## ディレクトリ構成

今回以下の構成にします。
開発環境用は**dist**へ、本番環境用ファイルは**prod**へビルドを想定しています。

```
├─ src/
│  ├─ html/
│  │   └─ index.pug
│  ├─ static/
│  ├─ js/
│  │   └─ index.ts
│  ├─ stylus/
│  │   └─ index.styl
│  └─ images/
├─ dist/
├─ prod/
├─ package.json
├─ postcss.config.js
└─ tsconfig.json
```

## 事前準備

以下の設定ファイルを予め用意して```npm install```を行います。

- package.json
- postcss.config.js
- tsconfig.json

### package.json
今回使用するライブラリを**devDependencies**に記載します。
開発用には```npm run start```
本番公開用には```npm run build```
を実行致します。

```package.json
{
  "name": "sample",
  "version": "1.0.0",
  "description": "",
  "main": "index.js",
  "config": {
    "path": "/"
  },
  "scripts": {
    "start": "webpack --watch --mode development",
    "build": "webpack --mode production"
  },
  "author": "",
  "license": "ISC",
  "devDependencies": {
    "autoprefixer": "^10.4.16",
    "browser-sync-webpack-plugin": "^2.3.0",
    "clean-webpack-plugin": "^4.0.0",
    "copy-webpack-plugin": "^11.0.0",
    "css-loader": "^6.8.1",
    "css-minimizer-webpack-plugin": "^5.0.1",
    "globule": "^1.3.4",
    "html-loader": "^4.2.0",
    "html-webpack-plugin": "^5.5.4",
    "html-webpack-pug-plugin": "^4.0.0",
    "image-webpack-loader": "^8.1.0",
    "mini-css-extract-plugin": "^2.7.6",
    "postcss-loader": "^7.3.3",
    "pug-html-loader": "^1.1.5",
    "stylus-loader": "^7.1.3",
    "terser-webpack-plugin": "^5.3.9",
    "ts-loader": "^9.5.1",
    "typescript": "^5.3.3",
    "webpack": "^5.89.0",
    "webpack-cli": "^5.1.4"
  }
}

```

### postcss.config.js

```postcss.config.js
module.exports = {
  plugins: [
    require("autoprefixer")({
      cascade: false,
      grid: "autoplace",
    }),
  ],
};

```

### tsconfig.json

```tsconfig.json
{
  "compilerOptions": {
    "outDir": "./dist/",
    "sourceMap": true,
    "strict": true,
    "module": "es6",
    "moduleResolution": "node",
    "target": "es5",
    "jsx": "react"
  },
  "include": ["src/**/*"],
  "exclude": ["./dist/"]
}

```

こちらで``` npm install ```を行います。

## TypeScriptをコンパイル
まずはエントリーファイルとなるTypeScriptのコンパイルを実装致します。
こちらを実行すると```/assets/js/bundle.js```が出力されます。

※**TerserPlugin**を使用して本番環境からconsole.logを削除しています。
※**fullhash**で公開時はファイル名にハッシュ値を付与します。
※開発環境のみ**source-map**の出力を行います。

```webpack.config.js
const path = require("path");
const TerserPlugin = require("terser-webpack-plugin");

module.exports = (_, args) => {
  const isDevelopment = args.mode === "development";
  const outputPath = isDevelopment ? "dist" : "prod";
  const jsName = isDevelopment ? "bundle.js" : "bundle.[fullhash].js";

  const config = {
    entry: path.resolve(__dirname, "src/js/index.ts"),
    output: {
      path: path.resolve(__dirname, outputPath),
      publicPath: "/",
      filename: `assets/js/${jsName}`,
    },
    module: {
      rules: [
        {
          test: /\.tsx?$/,
          use: "ts-loader",
          exclude: /node_modules/,
        },
      ],
    },
    resolve: {
      extensions: [".tsx", ".ts", ".js"],
    },
    plugins: [],
    optimization: {
      minimize: true,
      minimizer: [
        new TerserPlugin({
          terserOptions: {
            compress: {
              drop_console: isDevelopment ? false : true,
            },
          },
        }),
      ],
    },
  };
  
  if (isDevelopment) {
    config.devtool = "source-map";
  } else {
    config.devtool = false;
  }
  return config;
};

```

## stylusをコンパイル

※**MiniCssExtractPlugin**を使用してビルドされるJavaScriptファイルからCSSファイルとして抽出します。

```webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");

module.exports = (_, args) => {
  const cssName = isDevelopment ? "style.css" : "style.[hash].css";

  const config = {
    module: {
      rules: [
         {
          test: /\.styl$/,
          use: [
            MiniCssExtractPlugin.loader,
            {
              loader: "css-loader",
              options: {
                url: true,
                import: true,
              },
            },
            "postcss-loader",
            "stylus-loader",
          ],
        },
      ],
    },
    plugins: [
      new MiniCssExtractPlugin({
        filename: `assets/css/${cssName}`,
      }),
    ],
    optimization: {
      minimizer: [
        new CssMinimizerPlugin(),
      ],
    },
  };
};

```

#### index.tsでindex.stylを読み込む

```index.ts
import "../stylus/style.styl";
```

## 画像をコンパイル
デフォルトの設定ではビルド時にファイル名が変更されます。
そこで**image-webpack-loader**を使いディレクトリ構成とファイル名を変えずに最適化してコンパイルします。


```webpack.config.js
module.exports = (_, args) => {
  const config = {
    module: {
      rules: [
         {
          test: /\.(png|jpe?g|gif|svg)$/i,
          type: "asset/resource",
          generator: {
            filename: (pathData) => {
              const newPath = pathData.filename.replace("src/", "");
              return `assets/${newPath}`;
            },
          },
          use: [
            {
              loader: "image-webpack-loader",
              options: {
                mozjpeg: {
                  progressive: true,
                  quality: 75,
                },
                optipng: {
                  enabled: true,
                  optimizationLevel: 7,
                },
                pngquant: {
                  quality: [0.65, 0.75],
                  speed: 4,
                },
                gifsicle: {
                  interlaced: false,
                  optimizationLevel: 1,
                },
                webp: {
                  quality: 75,
                },
              },
            },
          ],
        },
      ],
    },
  };
};

```


## pugをコンパイル

まずは```src/html/```内のpugファイルをで先頭に_の無いファイルを除外して一覧で取得します。
※本番環境ではhtmlに```pretty```オプションでminify化しています。
※```data```オプションでpugファイルで使用する変数を渡しています。
今回は```if(isDevelopment === false)```で開発環境の条件分岐を行うことができます。

```webpack.config.js
const globule = require("globule");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const HtmlWebpackPugPlugin = require("html-webpack-pug-plugin");


module.exports = (_, args) => {

  const config = {
    module: {
      rules: [
         {
          test: /\.pug$/,
          use: [
            "html-loader",
            {
              loader: "pug-html-loader",
              options: {
                pretty: isDevelopment ? true : false,
                basepath: path.resolve(__dirname, outputPath),
                data: {
                  isDevelopment,
                },
              },
            },
          ],
        },
      ],
    },
    plugins: [
      new HtmlWebpackPugPlugin({
        adjustIndent: true,
      }),
    ],
    optimization: {
      ...
    },
  };
  
  const pugFiles = globule.find("./src/html/**/*.pug", {
    ignore: ["./src/html/**/_*.pug"],
  });

  pugFiles.forEach((file) => {
    const filename = file.replace("./src/html/", "").replace(".pug", ".html");
    config.plugins.push(
      new HtmlWebpackPlugin({
        filename: filename,
        template: file,
        inject: "body",
        minify: false,
      })
    );
  });
  
  return config;
};

```

### ローカルサーバーの設定
**browser-sync-webpack-plugin**を使用してローカルサーバーを立ち上げます。

```webpack.config.js
const BrowserSyncPlugin = require("browser-sync-webpack-plugin");

module.exports = (_, args) => {

  const config = {
    module: {
      ...
    },
    plugins: [
      ...
    ],
    optimization: {
      ...
    },
  };

  if (isDevelopment) {
    config.plugins.push(
      new BrowserSyncPlugin({
        host: "localhost",
        port: 3000,
        server: { baseDir: [outputPath] },
        startPath: "/",
        files: ['./src/**/*.*'],
      })
    );
  }

  return config;

};

```


## その他設定

### その他ファイルのビルド
**CopyFilePlugin**を使用してファビコンなどその他のファイルもビルドファイルに含めます。
今回```src/static/```に入れたファイルがビルドファイルのルートディレクトリにコピーされます。

### ビルド時に既存のファイルを削除
**clean-webpack-plugin**を使用して既存ファイルを削除致します。
ハッシュ値の異なるビルドファイルが残ってしまうのを防ぎます。


```webpack.config.js
const CopyFilePlugin = require("copy-webpack-plugin");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");

module.exports = (_, args) => {

  const config = {
    module: {
      rules: [
         {
          test: /\.pug$/,
          use: [
            "html-loader",
            {
              loader: "pug-html-loader",
              options: {
                pretty: isDevelopment ? true : false,
                basepath: path.resolve(__dirname, outputPath),
                data: {
                  isDevelopment,
                },
              },
            },
          ],
        },
      ],
    },
    plugins: [
      new CleanWebpackPlugin(),
      new CopyFilePlugin({
        patterns: [
          {
            from: "./src/static",
            to: path.resolve(__dirname, outputPath),
          },
        ],
      }),
    ],
    optimization: {
      ...
    },
  };

};

```
### ビルドファイルの構成
最終的に```dist```、```prod```それぞれこの様な構成でビルドされます。

```
├─ dist/
│  └─ index.ｈｔｍｌ
│  └─ assets/
│      ├─ css/
│      │   └─ style.css
│      ├─ js/
│      │   └─ bundle.js
│      └─ images/
│          └─ hogehoge.png
└─ prod/
   └─ index.ｈｔｍｌ
   └─ assets/
       ├─ css/
       │   └─ style.[hash値].css
       ├─ js/
       │   └─ bundle.[hash値].js
       └─ images/
           └─ hogehoge.png
```

### webpack.config

最終的に完成した**webpack.config.js**です。


```webpack.config.js
const path = require("path");
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
const TerserPlugin = require("terser-webpack-plugin");

const globule = require("globule");
const HtmlWebpackPlugin = require("html-webpack-plugin");
const HtmlWebpackPugPlugin = require("html-webpack-pug-plugin");

const CopyFilePlugin = require("copy-webpack-plugin");

const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const BrowserSyncPlugin = require("browser-sync-webpack-plugin");

module.exports = (_, args) => {
  const isDevelopment = args.mode === "development";
  const outputPath = isDevelopment ? "dist" : "prod";
  const jsName = isDevelopment ? "bundle.js" : "bundle.[fullhash].js";
  const cssName = isDevelopment ? "style.css" : "style.[hash].css";

  const config = {
    entry: path.resolve(__dirname, "src/js/index.ts"),
    output: {
      path: path.resolve(__dirname, outputPath),
      publicPath: "/",
      filename: `assets/js/${jsName}`,
    },
    module: {
      rules: [
        {
          test: /\.styl$/,
          use: [
            MiniCssExtractPlugin.loader,
            {
              loader: "css-loader",
              options: {
                url: true,
                import: true,
              },
            },
            "postcss-loader",
            "stylus-loader",
          ],
        },
        {
          test: /\.tsx?$/,
          use: "ts-loader",
          exclude: /node_modules/,
        },
        {
          test: /\.pug$/,
          use: [
            "html-loader",
            {
              loader: "pug-html-loader",
              options: {
                pretty: isDevelopment ? true : false,
                basepath: path.resolve(__dirname, outputPath),
                data: {
                  isDevelopment,
                },
              },
            },
          ],
        },
        {
          test: /\.(png|jpe?g|gif|svg)$/i,
          type: "asset/resource",
          generator: {
            filename: (pathData) => {
              const newPath = pathData.filename.replace("src/", "");
              return `assets/${newPath}`;
            },
          },
          use: [
            {
              loader: "image-webpack-loader",
              options: {
                mozjpeg: {
                  progressive: true,
                  quality: 75,
                },
                optipng: {
                  enabled: true,
                  optimizationLevel: 7,
                },
                pngquant: {
                  quality: [0.65, 0.75],
                  speed: 4,
                },
                gifsicle: {
                  interlaced: false,
                  optimizationLevel: 1,
                },
                webp: {
                  quality: 75,
                },
              },
            },
          ],
        },
      ],
    },
    resolve: {
      extensions: [".tsx", ".ts", ".js"],
    },
    plugins: [
      new MiniCssExtractPlugin({
        filename: `assets/css/${cssName}`,
      }),
      new HtmlWebpackPugPlugin({
        adjustIndent: true,
      }),
      new CleanWebpackPlugin(),
      new CopyFilePlugin({
        patterns: [
          {
            from: "./src/static",
            to: path.resolve(__dirname, outputPath),
          },
        ],
      }),
    ],
    optimization: {
      minimize: true,
      minimizer: [
        new CssMinimizerPlugin(),
        new TerserPlugin({
          terserOptions: {
            compress: {
              drop_console: isDevelopment ? false : true,
            },
          },
        }),
      ],
    },
  };

  const pugFiles = globule.find("./src/html/**/*.pug", {
    ignore: ["./src/html/**/_*.pug"],
  });

  pugFiles.forEach((file) => {
    const filename = file.replace("./src/html/", "").replace(".pug", ".html");
    config.plugins.push(
      new HtmlWebpackPlugin({
        filename: filename,
        template: file,
        inject: "body",
        minify: false,
      })
    );
  });

  if (isDevelopment) {
    config.plugins.push(
      new BrowserSyncPlugin({
        host: "localhost",
        port: 3000,
        server: { baseDir: [outputPath] },
        startPath: "/",
        files: ["./src/**/*.*"],
      })
    );
  }

  if (isDevelopment) {
    config.devtool = "source-map";
  } else {
    config.devtool = false;
  }

  return config;
};

```