Eleventyとwebpackの連携

Eleventyは静的サイトジェネレーターとして柔軟かつ欲しい機能も揃っていて、僕が携わるほぼすべての静的HTMLベースのプロジェクトで採用している。一方でアセットのビルドなどは責務外なので別途webpack等と組み合わせる必要がある。

そこで困るのがCache bustingをどのように実装するかだ。たとえばHTMLファイルが1枚しかないクライアントサイドレンダリング前提のSPAであれば、HtmlWebpackPluginでHTMLファイルも丸ごとwebpackで生成してしまえるので、パスの解決についてユーザーがケアする必要がない。

しかしEleventyをベースにする場合では、HTMLの生成はEleventy側で行うため、何かしらのやり方でEleventyにwebpackのManifestを渡してやらないといけない。この受け渡しのためには、ManifestをJSONファイルとして出力するWebpack Manifest Pluginを使うのが一般的であるようだ。このJSONファイルには、MiniCssExtractPluginが出力するCSSfile-loaderによって読み込まれた画像などのパスも含まれるので、一通りのアセット類はwebpackでビルドするようにすればCache bustingの対象にできる。

EleventyではDataディレクトリにJSONファイルなどを配置するとHTMLテンプレートから参照できるようになるので、Manifestをまずはそこに出力してみる。説明を単純にするためにかなり簡略化したビルド。

.
├── dist/
│   ├── build/
│   │   ├── assets/
│   │   │   └── background.d41d8cd9.svg
│   │   ├── main.095d67a3.css
│   │   └── main.3a6b57da.js
│   └── index.html
├── src/
│   ├── assets/
│   │   ├── background.svg
│   │   └── logo.svg
│   ├── scripts/
│   │   └── main.js
│   ├── site/
│   │   ├── _data/
│   │   │   └── assetManifest.json
│   │   ├── build/
│   │   │   ├── assets/
│   │   │   │   └── background.d41d8cd9.svg
│   │   │   ├── main.095d67a3.css
│   │   │   └── main.3a6b57da.js
│   │   └── index.njk
│   └── styles/
│       └── main.css
├── gulpfile.js
├── package-lock.json
├── package.json
└── webpack.config.js

.eleventy.js:

module.exports = (eleventyConfig) => {
  eleventyConfig.addPassthroughCopy('src/site/build')
  eleventyConfig.setUseGitIgnore(false)

  return {
    dir: {
      input: 'src/site',
      output: 'dist',
    },
  }
}

webpack.config.js:

const path = require('path')
const MiniCssExtractPlugin = require('mini-css-extract-plugin')
const ManifestPlugin = require('webpack-manifest-plugin')

const srcDir = path.join(__dirname, 'src')

module.exports = {
  mode: 'production',
  context: srcDir,
  entry: ['./scripts/main.js', './styles/main.css'],
  output: {
    path: path.join(srcDir, 'site/build'),
    filename: '[name].[contenthash:8].js',
    publicPath: '/build/',
  },
  module: {
    rules: [
      {
        oneOf: [
          {
            test: /\.css$/i,
            use: [
              { loader: MiniCssExtractPlugin.loader },
              { loader: 'css-loader' },
            ],
          },
          {
            exclude: [/\.js$/, /\.json$/],
            use: {
              loader: 'file-loader',
              options: {
                name: '[path][name].[contenthash:8].[ext]',
              },
            },
          },
        ],
      },
    ],
  },
  resolve: {
    alias: {
      '~assets': path.join(srcDir, 'assets'),
      assets: path.join(srcDir, 'assets'),
    },
  },
  plugins: [
    new MiniCssExtractPlugin({ filename: 'main.[contenthash:8].css' }),
    new ManifestPlugin({
      fileName: path.join(srcDir, 'site/_data/assetManifest.json'),
    }),
  ],
}

gulpfile.js:

const spawn = require('cross-spawn')
const gulp = require('gulp')
const del = require('del')
const webpack = require('webpack')
const webpackConfig = require('./webpack.config')

const clean = () => {
  return del(['dist', 'src/site/_data/assetManifest.json', 'src/site/build'])
}

const assets = (done) => {
  webpack(webpackConfig).run((err, stats) => {
    if (err) {
      console.error(err.stack || err)

      if (err.details) {
        console.error(err.details)
      }

      done(err)
      return
    }

    console.log(stats.toString('minimal'))

    if (stats.hasErrors()) {
      done(new Error('webpack compilation errors'))
      return
    }

    done()
  })
}

const eleventy = () => {
  return spawn('./node_modules/.bin/eleventy', [], {
    stdio: 'inherit',
  })
}

const build = gulp.series(clean, assets, eleventy)
exports.build = build

src/site/index.njk:

<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Hello</title>
    <link rel="stylesheet" href="{{ assetManifest['main.css'] }}">
    <script src="{{ assetManifest['main.js'] }}"></script>
  </head>
  <body>
    <h1><img src="{{ assetManifest['assets/logo.svg'] }}" alt="Hello"></h1>
  </body>
</html>

src/styles/main.css:

body {
  background-image: url("~assets/background.svg");
}

src/scripts/main.js:

console.log(require('~assets/background.svg'))

gulp buildを実行すると次のファイルが出力されて、HTMLテンプレートはそれを読み込んでパスを解決する。

src/site/_data/assetManifest.json:

{
  "main.css": "/build/main.095d67a3.css",
  "main.js": "/build/main.3a6b57da.js",
  "assets/background.svg": "/build/assets/background.d41d8cd9.svg"
}

ただしこれだとsrc/assets/logo.svgがManifestに含まれておらず、EleventyをビルドしてもassetManifest['assets/logo.svg']の部分が空文字列になってしまう。これはwebpack側ではsrc/assets/logo.svgを読み込んでいないからだ。src/scripts/main.jssrc/styles/main.cssからファイルを指定するとwebpackのManifestに入りつつパス解決をしているが、Eleventyはその結果を参照しているだけなので、HTML側でファイルを指定していることはwebpackに関係がない。なのでHTML側だけで参照するファイルもManifestに含めるために、それらのファイルをあらかじめwebpackに読み込ませておく。

src/assets/_importer.js:

require.context('.', true)

webpack.config.js:

- entry: ['./scripts/main.js', './styles/main.css'],
+ entry: ['./assets/_importer.js', './scripts/main.js', './styles/main.css'],

これでsrc/assetsディレクトリに配置されたすべてのファイルがManifestに入るようになる。

しかしまだHTMLテンプレートに{{ assetManifest['assets/logo.svg'] }}"のように記述しないといけないのが冗長でめんどくさく、もう少し書きやすくしたい。NuxtJSではVueテンプレートが次のように変換される。

<template>
  <img src="~/assets/image.png">
</template>
<img src="/_nuxt/img/image.0c61159.png">

これを参考にEleventyでも同じようにパスが変換されるようにしてみた。

.eleventy.js:

const { JSDOM } = require('jsdom')
const importFresh = require('import-fresh')

const transformAssetAttrs = {
  video: ['src', 'poster'],
  source: ['src'],
  img: ['src'],
  image: ['xlink:href', 'href'],
  use: ['xlink:href', 'href'],
}

const transformAssetSelector = Object.entries(transformAssetAttrs)
  .map(([element, attrs]) => {
    return attrs.map((attr) => `${element}[${attr}]`).join(',')
  })
  .join(',')

module.exports = (eleventyConfig) => {
  eleventyConfig.addPassthroughCopy('src/site/build')

  eleventyConfig.addTransform('assetPath', (content, outputPath) => {
    if (!outputPath.endsWith('.html')) {
      return content
    }

    const assetManifest = importFresh('./src/site/_data/assetManifest.json')

    const dom = new JSDOM(content)
    dom.window.document
      .querySelectorAll(transformAssetSelector)
      .forEach((element) => {
        transformAssetAttrs[element.tagName.toLowerCase()].forEach((attr) => {
          const value = element.getAttribute(attr)
          if (value && value.startsWith('~assets/')) {
            const id = value.replace('~assets/', 'assets/')
            const resolved = assetManifest[id]
            if (!resolved) {
              throw new Error(`Can't resolve '${value}' for '${outputPath}'`)
            }
            element.setAttribute(attr, resolved)
          }
        })
      })
    return dom.serialize()
  })

  eleventyConfig.setUseGitIgnore(false)

  return {
    dir: {
      input: 'src/site',
      output: 'dist',
    },
  }
}

これによりHTMLテンプレートに次のように書いたパスは自動的に解決されるようになった。

<img src="~assets/logo.svg" alt="Hello">

存在しないファイルを指定するとエラーが出てくれるようにもなったので安心できる。

そういうわけでここまででビルドする方法に書いてきたが、実際にはwatchできないと使いものにならない。いい感じにwatchする方法についてもメモしておこうかと思ったが、解説量が膨大になりそうなのでリポジトリだけ置いておく。

_shifted/boilerplate-static at master · yuheiy/_shifted