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が出力するCSSやfile-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.js
やsrc/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する方法についてもメモしておこうかと思ったが、解説量が膨大になりそうなのでリポジトリだけ置いておく。