EleventyVite.js(11ty公式Viteプラグイン)から読み解くベスト設定ガイド

元々がどのようにして作られているかを知らなければ、正しい設定などできはしないとGithubのソースコードを読み解いてどう設定するのが良いのだろうかを考えるという話です。
どういう経緯でViteだとなったか
11tyだけでもライブ(自動)リロードはあります。記事を更新して保存するとブラウザに反映させるというやつです。記事を書くだけならこれで問題はないと思いますが、JavaScriptや画像の追加をしてもそれは更新されないのです。
と言っても画像の追加などはブラウザの更新をする必要があったりなので、そのあたりはViteでも完全にとは言いにくいところではあるのですが、その他は少なからず何か変更があれば反映はされると思います。以下の解説ではViteでは前述したように「必ずとは言い難くとも」ほぼ更新されるようにはしてあります。
更新したものが反映されないと、ちょっとした変更をするだけでもブラウザを手動更新となり、それがもう面倒くさくて。
しかしその設定が大変なんですよ。
公式Viteプラグインの役割
ここでの重要なことは11tyが先にビルドしてViteがその結果を後処理するということです。Viteは11tyを知らず、11tyはViteを知りません。そこをこのプラグインがその橋渡しをしています。
- Viteにそれらの処理も任せるように書く
- Viteは開発段階の表示のみ担当して、ビルドは11tyに任せる
という割り切り方が必要になると思います。これらは11tyのv3から、
const isServe = process.env.ELEVENTY_RUN_MODE === "serve";
というような書き方で判定できるので、開発段階だけViteを使用することができます。これは最後の方で書いておきたいと思います。
もしViteで画像他も扱うという場合については、そのディレクトリ構造などの環境によっても異なる部分がありますが、サイト全体で使用する共用画像他(sitemapやrobots.txt等)はpublic/にぶち込めばおおよそコピーされます。
dev時( getServer() )
- 11tyがビルドをし、outputディレクトリにHTMLを生成
- Viteがoutputディレクトリをrootとしてmiddlewareで起動
- HMRとasset解決はViteが担う
build時( runBuild() )
- outputディレクトリを
.11ty-viteにリネーム(丸ごと移動) .11ty-viteをrootとしてViteのbuildを実行- 入力は
.htmlファイルのみに絞られる
- 入力は
- viteのoutDirをoutputに設定して書き出し
.11ty-viteを削除- エラー時は
.11ty-viteをoutput(_site/等)に設定して書き出し
- エラー時は
Viteがわかるものわからないもの
わかるもの
- HTML内の
<script src="...">、<link rel="stylesheet">は自動でバンドル対象 - CSS内の
url("/assets/font/...")なども自動でコピー対象になる。 - 使用している画像などは何も設定しないとbase64でcssとしてまとめられる
Viteに明示的に教える必要があるもの
<img src="...">、<a href="...pdf">をはじめ、robots.txt、sitemap.xml等も教えないとコピーされない
こういう感じです。Viteに教える最も確実な方法は、public/に入れることですが、出力もpublic/の中に入るのでパスを工夫する必要があります。
例えばですが、アセットのコピーを半ば強引にするためにviteのプラグインでvite-plugin-static-copyというのがあるので別途導入して、設定ファイルであるeleventy.config.jsの先頭でインポートすることができます。src/からのパスで管理する場合、
viteOptions: {
plugins: [
viteStaticCopy({
targets: [
{
// .11ty-vite内のパス
// コメントのため縦二行で書いているが実際は下のassets/fonts/**/*のような感じでもよい
src: "blog/img/**/*", // 入力ソース
dest: "blog/img" // 出力
},
{ src: "assets/fonts/**/*", dest: "assets/fonts" },
]
})
],
// ...
}
と言う方法もありますし、ファイルとして認識させる場合は、
assetsInclude: ["**/*.pdf", "**/*.xml", "**/*.xsl"]
などとして書くこともできます。
これ(assetsInclude)は、アセットとして認識しろという宣言だけなのでHTMLから参照されていないとやはりコピーはされません。vite-plugin-static-copyは参照の有無に関係なく指定したファイルを強制的にコピーするものです。robots.txtのようにルート下においてあるだけのものはこちらでやる必要があると思います。あるいはpublic/に入れるかなどでしょうか。
上記サンプルコードでは、blog/img/**/*、つまりblog記事が入っているディレクトリにimg/ディレクトリもあり、記事で使用する画像はこのimg/に入れて管理するなどの方がわかりやすいと思います。blog/imgにまとめると言う感じの使い方をする記述です。
_skipCopy_

と書けます。これは(記事の)permalinkの設定がどう書いてあろうと、使用する画像は/blog/img/にあるので絶対パスで書くということです。src/blog/img/に画像を入れていくという事だけです。もちろん画像のファイル名を間違えてはいけません。vite-plugin-static-copyで書けばよいわけですがやっぱり面倒なのは変わりません。こういうプラグインがあること自体皆同じような悩みを持っているんだなぁとも思います。
Viteはあくまで JS/CSS のバンドラーであり、それ以外は明示的に伝えない限り存在を知らないという前提で設計する必要があります。
どういうものかのまとめ
11ty公式のViteプラグインは、Viteをフル活用するという設計ではなくあくまでも開発時により便利に表示させるものとしてViteを活用するという意味合いが強く、11tyがHTMLを生成し、Viteがjsやcss等を含めビルドで完了となっています。
これがもし、11tyがHTMLを生成し、Viteがjsやcss等を含めビルド、finallyとして静的アセットのコピーができるようにフックが用意されていれば技術的に11ty + Viteはもっと融合して連携できるようになるでしょう。
- 11tyはHTMLを作るもの
- ViteはJS/CSSを最適化するもの
として設計されていますので、public/を経由させる、あるいはvite-plugin-static-copyを使って補完するというのがプラグイン側の現在の答えとなっています。
11ty側の設定で守るべきこと
outputディレクトリは./プレフィックス付きで書く事が必要となります。
# skipCopy
dir: { output: "dist" } // これは壊れる
dir: { output: "./dist" } // 安全
dir: { output: "_site" } // デフォルトのまま使用が最も安全
これらの理由として、runBuild()内のコードでパスの比較が行われる所からもわかる部分です。
# skipCopy
if (!entry.outputPath.startsWith(this.directories.output)) {
throw new Error(`Unexpected output path ...`);
}
"dist"と"./dist/index.html"はstartWithで一致しないので、コマンドライン環境でクラッシュします(ローカルは偶然通ることもあるかも知れませんが)。
startsWith() は String 値のメソッドで、文字列が引数で指定された文字列で始まるかを判定して true か false を返します
.11ty-vite を .gitignore と watchIgnores の除外に入れる
eleventyConfig.watchIgnores.add(".11ty-vite/**");
ブラグインのgetIgnoreDirectory()がこのパスを返していて、11tyのwatchIgnoreとし設定されることを前提にしています。ビルドエラー時にこのフォルダが残ることがあります。
- 過去にVite buildを試していた残留
- 何らかの異常終了で残ってしまった
- 将来的にVite buildを試す可能性がある
等で .11ty-vite/ が存在していると、11tyがそのフォルダを監視・処理しようとしてエラーになります。
Viteを表示のみに使用する場合は不要ともいえますが、置いておいて問題ではないのでお守り的に書いておくのが良いと思います。.gitignore に .11ty-vite を追加しておくのも同様の理由でセットでやっておくと安心です。
addPassthroughCopy と setServerPassthroughCopyBehavior
# skipCopy
// Vite の publicDir と対応させるため
eleventyConfig.setServerPassthroughCopyBehavior("copy"); // "passthrough"ではなく"copy"
eleventyConfig.addPassthroughCopy("public");
dev時にpassthrough(デフォルト)だとファイルがoutputにコピーされずViteのrootから見えなくなってしまいます。"copy"にするとoutputにコピーされてViteが正しく見つけられます。
これは上記のwatchIgnoresの件と同様でdev時に必要になるものです。ビルドは11tyがするという方法であれば不要です。
Vite側の設定で守るべきこと
デフォルトでそうなっていますが、appType: "mpa"は変更しないようにすることが最重要設定です。
"spa"にすると単一エントリーを前提にした処理になり、複数ページで構成される11tyサイトではルーティングが壊れます。input管理が複雑になるため、理由がない場合は"mpa"のままが一番無難です。
viteOptions.rootを自分で設定してはいけない
# skipCopy
// getServer() / runBuild() 内で自動設定される
viteOptions.root = this.directories.output; // dev時
viteOptions.root = tempFolderPath; // build時
viteOptionsにrootを自分で書いても、プラグイン側で上書きされるようになっていますので、書いても無意味であり混乱の元になります。
publicDir の設定
# skipCopy
viteOptions: {
publicDir: "public", // プロジェクトルートからの相対パス
}
ViteのpublicDirはプラグイン設定時点でのroot(プロジェクトルート)からの相対パスで解釈されます。ただしビルド時はrootが11ty-viteに変わるために注意が必要です。publicと言う名前をoutputディレクトリに使うと衝突が起こるため、outputは"_site"や"dist"を使って下さい。
resolve.alias の /node_modules エイリアス
# skipCopy
resolve: {
alias: {
"/node_modules": path.resolve(".", "node_modules"),
},
},
これにより、HTMLテンプレート内から/node_modules/some-lib/file.jsと言う絶対パスで直接インポートできます。Viteはデフォルトでは node_modules への直接パス参照を許可しないため、このエイリアスが必要です。
Tailwind cssは別プロセスが安定する
公式Viteプラグインのソースコードには Tailwindを意識したコードがどこにもありません。
build フローでのタイミング問題
- 11tyがビルド
- この時点でTailwindが未処理だとHTMLにTailwindCSSのClassは書いてあるがCSSがないということになります
- 11tyが出力する前にTailwindのcssが存在する必要がありますが、デフォルトではそれら処理がないのでhtmlにクラスがあるだけという状態です
- outputを
.11ty-viteにリネームする - ViteがHTMLをバンドル処理
- ここで各種設定が行われるわけなので、素のHTMLが表示された後それをViteが上書きしてようやく完成形が表示される
つまりTailwindCSSは予め使用できる状態になったものを用意してからHTMLを用意しないといけないが、HTMLの記載がなければ使用するものが何かを判断もできないというタイミング問題があるのです。
Tailwind を ViteのPostCSSプラグインとして組み込んだ場合
- TailwindのJITはHTMLを走査して必要なクラスを確定する
- Viteの
rootはoutput(HTMLが生成された後のフォルダ、デフォルトは_site/)なので、元のテンプレートファイル(src/)を参照できない - Tailwindcssの
contentのパスをsrc/**/*.njkなどと明示すると動作するが、build時/dev時でrootが変わるためcontentの相対パスが壊れやすい
JITモードとは
テンプレートを解析してスタイルをオンデマンドで生成する仕組みです。
これらのことから、package.jsonでは、別プロセスとしてTailwindの出力CSSを11tyのaddPassthroughCopy対処に含め、Viteがそれをバンドルする流れにするのが最も良い方法であると考えられます。@importベースになるので、ViteのCSS処理と相性が良くなりますが、それでもcontentのパス問題は残ります。
別プロセス化するために
11tyとViteを別物として動作させる場合はまず、並行してプロセスを起動させるためにnpmパッケージのconcurrentlyが必要になります。また別プロセスでTailwind cssを動作させるために@tailwindcss/cliが必要((package.jsonのコマンドで使用するため))になります。
npm install -D concurrently @tailwindcss/cli
そして、package.jsonは、11tyを--serve --incrementalで起動し、src/assets/css/main.cssをsrc/assets/css/output.cssに変更させます。スタイルシートの名称は自身の使用するものに合わせて下さい。
{
"scripts": {
"build:css": "tailwindcss -i ./src/assets/css/main.css -o ./src/assets/css/output.css",
"build": "npm run build:css && eleventy",
"dev": "concurrently \"tailwindcss -i ./src/assets/css/main.css -o ./src/assets/css/output.css --watch\" \"eleventy --serve --incremental\" "
}
}
| コマンド | 注意点・解説 |
|---|---|
build:css |
-i: inputのcssを -o: outputのcssに変更する。 名称が同じだと止まるので作業するcssをmain.css、出力をstyle.cssのようにする。 Tailwindcssもこの時に組み込まれる |
build |
build:cssを動作させてからeleventyを起動して最終出力にする |
dev |
ローカルで作業するためのコマンド。 Tailwindcssでまず処理させてから変更を監視。その後11tyを起動して作業できるようにする |
build時、つまりはリモートではまずTailwindcssを用意してから11tyをビルドします。concurrentlyでこれらを同時にやります。build時にもなぜ同様にしないかは、htmlの各種変更の監視、Tailwindcssの監視とかが必要になるからです。build時には監視するという作業は必要ありません。
バージョンの違いによって書くものが変わる
- Tailwindcss v3を使用している場合、別途
tailwind.config.jsが必要になります。 - v4を使用している場合
tailwind.config.jsは不要でスタイルシートにソースも記載することになります。
v4では、スタイルシート先頭にこのように書きます。作り方によってはこれら(content部分に当たる@souce)は不要です。
/* main.css */
@import "tailwindcss";
/* contentの代わり。@sourceでCSS内に直接書く */
@source "../src/**/*.njk";
@source "../src/**/*.html";
@source "../src/**/*.md";
v3を使用している場合は、まずスタイルシートには、
/* main.css */
/* @importではなく@tailwindディレクティブで読み込む */
@tailwind base;
@tailwind components;
@tailwind utilities;
そして別途、tailwind.config.jsが必要。プロジェクトフォルダ直下(eleventy.config.jsと同じ場所)に置いて次のように書きます。
// v3 では tailwind.config.js 必須
export default {
content: [
"./src/**/*.njk",
"./src/**/*.html",
"./src/**/*.md",
],
// ...
};
という設定になります。
他のプラグインとの兼ね合い
eleventy-plugin-viteは必ず最後に addPlugin する
プラグインは afterBuild イベントと serverMiddleware を登録します。他プラグインが transform や addPassthroughCopy を行うより後に Vite が動く必要があるため、呼び出し順が重要になります。
// skipCopy
eleventyConfig.addPlugin(EleventyPluginNavigation);
eleventyConfig.addPlugin(EleventyPluginRss);
eleventyConfig.addPlugin(EleventyPluginSyntaxhighlight);
eleventyConfig.addPlugin(EleventyVitePlugin, { ... }); // 最後
こういう感じに最後に呼び出す。
eleventy-img との組み合わせ
eleventy-img が生成する画像はoutputディレクトリに書き出されます。Viteは .html 以外のアセットをassetsIncludeで明示しない限り処理対象外とするため、画像はViteのバンドル対象にならず、そのままoutputにコピーされます。
RSS プラグインの XML / sitemap の txt
# skipCopy
assetsInclude: ["**/*.xml", "**/*.txt"],
これを設定しないと Vite が XML/TXT ファイルをモジュールとして解釈しようとしてエラーになります。
ここまでを踏まえて
基本的にここまでの話は、11tyとViteを別物として動作させる場合の話です。
ディレクトリ構造
project/
├── src/ # 11ty の input
│ ├── _includes/
│ ├── _data/
│ ├── assets/
│ │ ├── css/main.css # CSSエントリー(Viteが処理)
│ │ └── js/main.js # JSエントリー(Viteが処理)
│ └── posts/
├── public/ # Vite の publicDir(そのままコピー)
│ └── favicon.ico
├── _site/ # 11ty output(Viteのroot)← "./dist"でなくこちら推奨
├── .11ty-vite/ # 一時フォルダ(.gitignoreに追加)
├── eleventy.config.js # ESM形式推奨
└── package.json
eleventy.config.js 例
// eleventy.config.js
import path from "path";
import EleventyVitePlugin from "@11ty/eleventy-plugin-vite";
export default function (eleventyConfig) {
// passthrough を実コピーにする(Vite の root から見えるようにするため)
eleventyConfig.setServerPassthroughCopyBehavior("copy");
eleventyConfig.addPassthroughCopy("public");
// Vite プラグインは最後に追加
eleventyConfig.addPlugin(EleventyVitePlugin, {
tempFolderName: ".11ty-vite",
serverOptions: {
domDiff: false, // Vite HMR と競合するため無効化推奨
},
viteOptions: {
publicDir: "public",
clearScreen: false,
appType: "mpa", // ← MPA必須。変えない
assetsInclude: ["**/*.xml", "**/*.txt"],
server: {
middlewareMode: true,
},
build: {
emptyOutDir: true,
manifest: true,
rollupOptions: {
output: {
assetFileNames: "assets/css/[name].[hash].css",
chunkFileNames: "assets/js/[name].[hash].js",
entryFileNames: "assets/js/[name].[hash].js",
},
},
},
resolve: {
alias: {
"/node_modules": path.resolve(".", "node_modules"),
},
},
},
});
return {
dir: {
input: "src",
output: "_site", // ← "./dist" でなく "_site" 推奨
includes: "_includes",
layouts: "_layouts",
data: "_data",
},
templateFormats: ["md", "njk", "html"],
htmlTemplateEngine: "njk",
};
}
デフォルトで設定されているもので省略できる項目をもう少しわかりやすいように。
import EleventyVitePlugin from "@11ty/eleventy-plugin-vite";
export default function (eleventyConfig) {
// 必須:実コピーにしないとViteのrootから見えない
eleventyConfig.setServerPassthroughCopyBehavior("copy");
eleventyConfig.addPassthroughCopy("public");
eleventyConfig.addPlugin(EleventyVitePlugin, {
// tempFolderName: ".11ty-vite" ← デフォルト、省略可
// appType: "mpa" ← デフォルト、省略可
// clearScreen: false ← デフォルト、省略可
// server.middlewareMode: true ← デフォルト、省略可
// build.emptyOutDir: true ← デフォルト、省略可
// resolve.alias["/node_modules"]← デフォルト、省略可
viteOptions: {
// 必須:rootがbuild時に変わるため明示が必要
publicDir: "public",
// RSSやsitemapを使うなら必要(デフォルト外)
assetsInclude: ["**/*.xml", "**/*.txt"],
build: {
// アセットにハッシュを付けるなら必要(デフォルトはfalse)
manifest: true,
rollupOptions: {
output: {
assetFileNames: "assets/css/[name].[hash].css",
chunkFileNames: "assets/js/[name].[hash].js",
entryFileNames: "assets/js/[name].[hash].js",
},
},
},
},
});
return {
dir: {
input: "src", // デフォルトは"."なので必須
// output: "_site" ← デフォルト、省略可
// includes: "_includes ← デフォルト、省略可
layouts: "_layouts", // デフォルトは"_includes"と共用なので分けるなら必須
// data: "_data" ← デフォルト、省略可
},
// デフォルトは"liquid"なのでnjkを使うなら必須
htmlTemplateEngine: "njk",
};
}
こんな感じにまとめられます。
更にViteをdev(開発・ローカル編集)時のみに使用する設定
// import path from "path"; //resolve.alias["/node_modules"]が不要になればこれも
import EleventyVitePlugin from "@11ty/eleventy-plugin-vite";
const isServe = process.env.ELEVENTY_RUN_MODE === "serve";
export default function (eleventyConfig) {
// 必須:実コピーにしないとViteのrootから見えない
eleventyConfig.addPassthroughCopy("public");
if (isServe) {
eleventyConfig.watchIgnores.add(".11ty-vite/**"); //おまじない的な意味で
// Viteがミドルウェアとして動く
// "copy"にしないとViteのrootから静的ファイルが見えないので
// Viteが動作する際には 必須
eleventyConfig.setServerPassthroughCopyBehavior("copy");
eleventyConfig.addPlugin(EleventyVitePlugin, {
// tempFolderName: ".11ty-vite" ← デフォルト、省略可
// appType: "mpa" ← デフォルト、省略可
// clearScreen: false ← デフォルト、省略可
// server.middlewareMode: true ← デフォルト、省略可
// build.emptyOutDir: true ← デフォルト、省略可
// resolve.alias["/node_modules"]← デフォルト、省略可
serverOptions: {
domDiff: false, // Vite HMR と競合するため無効化推奨
},
viteOptions: {
// 必須:rootがbuild時に変わるため明示が必要
publicDir: "public",
// RSSやsitemapを使うなら必要(デフォルト外)
assetsInclude: ["**/*.xml", "**/*.xsl", "**/*.txt"],
build: {
// アセットにハッシュを付けるなら必要(デフォルトはfalse)
//manifest: true,
//rollupOptions: {
// output: {
// assetFileNames: "assets/css/[name].[hash].css",
// chunkFileNames: "assets/js/[name].[hash].js",
// entryFileNames: "assets/js/[name].[hash].js",
// },
//},
},
},
});
}
return {
dir: {
input: "src", // デフォルトは"."なので必須
// output: "_site", ← デフォルト、省略可
// includes: "_includes", ← デフォルト、省略可
layouts: "_layouts", // デフォルトは"_includes"と共用なので分けるなら必須
// data: "_data", ← デフォルト、省略可
},
// デフォルトは"liquid"なのでnjkを使うなら必須
htmlTemplateEngine: "njk",
};
}
コメントを取り除くとこうなります。
import EleventyVitePlugin from "@11ty/eleventy-plugin-vite";
export default function (eleventyConfig) {
eleventyConfig.addPassthroughCopy("public");
const isServe = process.env.ELEVENTY_RUN_MODE === "serve";
if (isServe) {
eleventyConfig.watchIgnores.add(".11ty-vite/**"); //おまじない的な意味で
eleventyConfig.setServerPassthroughCopyBehavior("copy");
eleventyConfig.addPlugin(EleventyVitePlugin, {
serverOptions: {
domDiff: false,
},
viteOptions: {
publicDir: "public",
assetsInclude: ["**/*.xml", "**/*.xsl", "**/*.txt"],
build: {},
},
});
}
return {
dir: {
input: "src",
layouts: "_layouts",
},
htmlTemplateEngine: "njk",
};
}
この例はMarkdownの設定なども書いていませんのでこのままで動作を保証するものではありません。あくまで例です。
その際のpackage.jsonは、
{
"name": "",
"version": "1.0.0",
"main": "index.js",
"devDependencies": {
"@11ty/eleventy": "^3.1.2",
"@11ty/eleventy-img": "^6.0.4",
"@11ty/eleventy-navigation": "^1.0.5",
"@11ty/eleventy-plugin-rss": "^2.0.4",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
"@11ty/eleventy-plugin-vite": "^7.0.0",
"@quasibit/eleventy-plugin-sitemap": "^2.2.0",
"@tailwindcss/cli": "^4.2.2",
"@tailwindcss/vite": "^4.2.1",
"concurrently": "^9.2.1",
"cross-env": "^10.1.0",
"lightningcss": "^1.32.0",
"markdown-it": "^14.1.1",
"markdown-it-attrs": "^4.3.1",
"markdown-it-multimd-table-ext": "^4.2.35",
"markdown-it-ruby": "^1.1.2",
"tailwindcss": "^4.2.1"
},
"keywords": [],
"author": "",
"license": "ISC",
"type": "module",
"scripts": {
"build:css": "npx @tailwindcss/cli -i ./src/assets/css/main.css -o ./src/assets/css/style.css",
"build": "npm run build:css && eleventy",
"dev": "concurrently \"eleventy --serve --incremental\" \"npx @tailwindcss/cli -i ./src/assets/css/styles.css -o ./src/assets/css/style.css --watch\" ",
"preview": "npx serve _site",
"clean": "node --eval \"fs.rmSync('_site',{recursive:true,force:true}); fs.rmSync('.cache',{recursive:true,force:true}); fs.rmSync('.vite',{recursive:true,force:true}); fs.rmSync('.11ty-vite-temp',{recursive:true,force:true})\""
},
"description": ""
}
上記"main":"index.js"はなんで入っているのかわかりません笑 そんなもの使用していないしで。
build:cssがtailwindcssのcssを作る所です。ローカルで作業するcssはmain.cssです。それをsrc/assets/css/style.cssに書き出します- 書き出されたcssを元に、eleventyが動作します。
- eleventy.config.jsでは、
eleventyConfig.addPassthroughCopy("src/assets/css/style.css");をコピーします。他必要なアセットも。 - cssは3.でできているのでViteの管理から外す必要があります
const isServe = process.env.ELEVENTY_RUN_MODE === "serve";
if (isServe) {
eleventyConfig.setServerPassthroughCopyBehavior("copy");
eleventyConfig.watchIgnores.add(".11ty-vite/**");
eleventyConfig.addPlugin(vitePlugin, {
serverOptions: {
domDiff: false,
},
viteOptions: {
publicDir: "public",
assetsInclude: ["**/*.xml", "**/*.txt"],
server: {
mode: "development",
middlewareMode: true,
hmr: { overlay: true },
watch: {
// パススルーコピーされるJSはViteの監視対象から除外
ignored: [
"**/assets/js/*.js",
"**/assets/js/**/*.js",
//"**/assets/css/style.css",
],
},
},
build: {
emptyOutDir: true,
},
},
});
}
Viteの設定はこんなふうに書いていました。コメントにもあるように、jsを監視させないようにしてあるため、純粋に「11tyがファイルを書き出してTailwindがcssを作って」の部分にViteは一切関与しません。単にHMRとリロードをするだけとも言えます。jsは変更があればこれらが走ります。cssも一応見てますが関与はしていません。HMRをするだけです。ファイルが変更されたというのを監視しているという方が正しいのでしょうか。
しかしながら、それらを設定して、他のプラグインなどは試していませんが、11tyとviteとtailwindcss(v4)を個別に動作させ最終的にまとめるという方法であれば機能しました。
- 11tyだけでhtmlの生成、アセット(画像やrobots.txt等)はパススルーコピー
- ViteはHMRと自動ページリロード
- tailwindは自身がcssをビルド
を、行うため一番間違いがない構成であると思います。ほとんど11tyがしてますから。
このまま真似てもエラーが出ることもあるでしょうから諸々は修正しないといけませんが、基本的には11tyがほとんどサイトを作っているため問題が起きにくい構成とも言えると思います。
当サイトの独自構造
11tyとViteを別々で動作させる方の検証等に色々と時間がかかってしまったと言う感じですが、当サイトでは別々ではなくある意味簡易的に融合させてあります。
当サイトのディレクトリ構成
project/
├── src/ # 11ty の input
│ ├── _data/
│ ├── _includes/
│ │ ├── components/
│ │ └── layouts/
│ ├── _plugins/
│ │ └── eleventy-passthrough-bridge.js
│ ├── assets/
│ │ ├── css/
│ │ │ ├── main.css # CSSエントリー(Viteが処理)
│ │ │ ├── font-settings.css
│ │ │ └── 各種リセットcssなど
│ │ ├── fonts/
│ │ ├── images/ # サイト全体で使用する画像など
│ │ └── js/
│ │ ├── modules/
│ │ └── main.js # JSエントリー(Viteが処理)
│ ├── blog/
│ ├── guitar/
│ ├── public/
│ │ ├── ogp画像
│ │ └── robots.txt
│ ├── about.md
│ ├── index.njk
│ └── sitemap.njk
├── _site/ # 11ty output(Viteのroot)← "./dist"でなくこちら推奨
├── .11ty-vite/ # 一時フォルダ(.gitignoreに追加)
├── feed.xml
├── eleventy.config.js # ESM形式推奨
└── package.json
別々に11tyとViteを動かす時のディレクトリ例からすると多少ややこしくなっていますが基本的には同じような構造をしています。
sitemap.njk等のパスに注意
通常は、
---
permalink: /sitemap.xml
eleventyExcludeFromCollections: true
---
こんな感じにhttps://ドメインの後にsitemapが来るように出力するわけですが、
---
permalink: /public/sitemap.xml
eleventyExcludeFromCollections: true
---
このようにしてViteがわかるようにパスを書き換える必要があります。11tyをビルドした後、sitemap.xmlが_site/直下にない場合はパスの設定をまず疑って下さい。パスも配置も正しいのに出力されていないという場合はViteの設定に不備があるかも知れません。
JS/CSSを完全にViteに任せるには
これはViteの本来の使い方です。11tyが作ったHTMLからViteが<script type="module" src="/assets/js/scripts.js></script>などを調べ、そのscripts.jsに記載があるimport "@css/style.css"などを解析しTailwindのcssもJavascriptも適用しながら、各アセットも場合によっては[file-name]-[hash].[css]としてBase64で変換したりしてまとめられたり、同様の形式でjsもViteから操作ができる形に加工されて処理されます。
ここで、
- 新たに記事などを修正すると11tyがそれらを書き換えます。
- ファイルが変更されたと感知すると、Viteはそれらを再処理してHMRもしくはPage-Reloadを行います
問題は11tyも_site/にファイルを書き出していて、Viteは11ty-vite/と一時的に作業フォルダを作り、そこに処理したものを書き出して_siteにリネームされている点です。_site/に書き出してくれればよいですが、プラグイン側の設定にはhtmlの受け渡ししかないためアセットが消えるという事態になります。
そこで、だったら11tyのpassthroughCopyするアセットをインターセプトして、別で保持した後Viteに普通に処理をさせた後で戻せば良いとなったわけです。
// eleventy-passthrough-bridge.js
import path from 'path';
import fs from 'fs-extra';
import fg from 'fast-glob';
export default function EleventyPassthroughBridge(eleventyConfig, userOptions = {}) {
const passthroughEntries = [];
const verbose = userOptions.verbose ?? false;
const projectRoot = process.cwd(); // ← プロジェクトルートを基準にする
const _orig = eleventyConfig.addPassthroughCopy.bind(eleventyConfig);
eleventyConfig.addPassthroughCopy = function (entry, copyOptions = {}) {
passthroughEntries.push({ entry, copyOptions });
return _orig(entry, copyOptions);
};
eleventyConfig.on('eleventy.after', async ({ dir, runMode }) => {
if (runMode !== 'build') return;
if (verbose) console.log('[passthrough-bridge] Restoring passthrough files after Vite...');
for (const { entry } of passthroughEntries) {
await restoreEntry(entry, dir.input, dir.output, projectRoot, verbose);
}
});
}
async function restoreEntry(entry, inputDir, outputDir, projectRoot, verbose) {
try {
if (typeof entry === 'string') {
const isGlob = fg.isDynamicPattern(entry);
// cwd はプロジェクトルート、entryはそのまま使う
const files = await fg(isGlob ? entry : `${entry}/**/*`, {
cwd: projectRoot,
dot: true,
onlyFiles: true,
});
for (const file of files) {
// inputDir プレフィックス(例: "src/")を除去してdestを計算
const inputPrefix = inputDir.endsWith('/') ? inputDir : inputDir + '/';
const destRelative = file.startsWith(inputPrefix)
? file.slice(inputPrefix.length)
: file;
const src = path.join(projectRoot, file);
const dest = path.join(projectRoot, outputDir, destRelative);
await fs.ensureDir(path.dirname(dest));
await fs.copy(src, dest, { overwrite: true });
if (verbose) console.log(` [restore] ${src} → ${dest}`);
}
} else if (typeof entry === 'object' && entry !== null) {
for (const [srcRel, destRel] of Object.entries(entry)) {
const srcAbs = path.resolve(projectRoot, srcRel);
const destAbs = path.resolve(projectRoot, outputDir, destRel || path.basename(srcRel));
const stat = await fs.stat(srcAbs).catch(() => null);
if (!stat) continue;
if (stat.isDirectory()) {
const files = await fg('**/*', { cwd: srcAbs, dot: true, onlyFiles: true });
for (const f of files) {
const src = path.join(srcAbs, f);
const dest = path.join(destAbs, f);
await fs.ensureDir(path.dirname(dest));
await fs.copy(src, dest, { overwrite: true });
if (verbose) console.log(` [restore] ${src} → ${dest}`);
}
} else {
await fs.ensureDir(path.dirname(destAbs));
await fs.copy(srcAbs, destAbs, { overwrite: true });
if (verbose) console.log(` [restore] ${srcAbs} → ${destAbs}`);
}
}
}
} catch (err) {
console.error('[passthrough-bridge] Error restoring entry:', entry, err);
}
}
11tyの内部構造はわかりませんから、何がどうなった時にこうしたいというのをおおよそ書いた仕様をAIに渡して、組み上げてもらったコードですが、これを使用することで、11tyのaddPassthroughCopyをそのままに使って、画像が消えること無く_site/をビルドできました。実際に今使用しているものもこれです。eleventy-passthrough-bridge.jsとして新たにsrc/_plugins/を作成し、その中に入れます。呼び出しはeleventy.config.jsでインポートしています。
11tyの処理、Viteの処理の後で動くものですからその分全体のビルドでは本来の11tyより多少時間がかかります。規模によりけりとなるでしょうが極端な待ち時間があるわけではありません。そもそもViteは高速ですから。
これらはおそらく11tyで使用する際の最大の問題点であるアセット(画像やhtml,css,js以外の部品的なもの)が消えてしまうのを回避できるのと、ほぼ11tyの設定はそのままに書けるため現時点でのベストプラクティスだろうと思います。
- 11tyがhtmlやその他を作り、ViteはHMRや自動リロードのみを担当するセパレート型
- 11tyは本来の働きをし、ViteはViteの働きをし、としながらもその問題点をスクリプトで解決する
このスクリプトは後者に当たります。
当サイトのeleventy.config.js抜粋
// 必要なものだけ抜粋してあるのでフィルターその他で必要であれば別途導入・インポートが必要
import path from "path";
import tailwind from "@tailwindcss/vite";
import EleventyVitePlugin from "@11ty/eleventy-plugin-vite";
import markdownIt from "markdown-it";
import EleventyPassthroughBridge from './src/_plugins/eleventy-passthrough-bridge.js';
const isServe = process.env.ELEVENTY_RUN_MODE === "serve";
export default function (eleventyConfig) {
//ignore
eleventyConfig.watchIgnores.add("src/assets");
// passthrough を実コピーにする(Vite の root から見えるようにするため)
eleventyConfig.setServerPassthroughCopyBehavior("copy");
eleventyConfig.addPassthroughCopy("public");
// -----------------------------------------------------------------
// plugins
// -----------------------------------------------------------------
// ここに各種プラグインの読み込み
// Vite プラグインは最後に追加
eleventyConfig.addPlugin(EleventyVitePlugin, {
tempFolderName: ".11ty-vite",
serverOptions: {
domDiff: false, // Vite HMR と競合するため無効化推奨
},
viteOptions: {
plugins: [tailwind({
content: ['./src/**/*.{html,njk,md,js}'],
})],
publicDir: "public",
clearScreen: false,
appType: "mpa",
assetsInclude: ["**/*.xml", "**/*.txt"],
server: {
middlewareMode: true,
watch: {
ignored: [
'**/.11ty-vite/assets/js/**',
'**/.11ty-vite/assets/images/**',
'**/.11ty-vite/assets/fonts/**',
'**/_site/**',
]
}
},
build: {
emptyOutDir: true,
manifest: true,
assetsInlineLimit: 0,
rollupOptions: {
output: {
/*
// Viteがアセットをbase64でcss化してしまう場合に
assetFileNames: (assetInfo) => {
if (assetInfo.name?.endsWith('.css')) {
return 'assets/css/[name].[hash][extname]';
}
if (/\.(png|jpe?g|gif|svg|avif|webp|ico)$/.test(assetInfo.name ?? '')) {
return 'assets/images/[name].[hash][extname]';
}
if (/\.(woff2?|ttf|eot|otf)$/.test(assetInfo.name ?? '')) {
return 'assets/fonts/[name].[hash][extname]';
}
return 'assets/[name].[hash][extname]';
},
*/
// assetFileNames: "assets/css/[name].[hash].css",
chunkFileNames: "assets/js/[name].[hash].js",
entryFileNames: "assets/js/[name].[hash].js",
},
},
},
resolve: {
alias: {
'@css': path.resolve('./src/assets/css'),
"/node_modules": path.resolve(".", "node_modules"),
},
},
},
});
eleventyConfig.addPlugin(EleventyPassthroughBridge, { verbose: true });
// -----------------------------------------------------------------
// Passthrough
// -----------------------------------------------------------------
// ここは各々コピーするものが変わると思いますが、
// 例として書いておきます
eleventyConfig.addPassthroughCopy("src/assets");
eleventyConfig.addPassthroughCopy("src/assets/fonts");
eleventyConfig.addPassthroughCopy("src/assets/images");
eleventyConfig.addPassthroughCopy("src/assets/js");
eleventyConfig.addPassthroughCopy("src/_plugins");
eleventyConfig.addPassthroughCopy("src/blog/img");
eleventyConfig.addPassthroughCopy("src/guitar/img");
eleventyConfig.addPassthroughCopy("src/guitar/sound/**/*.ogg");
eleventyConfig.addPassthroughCopy("src/public/*.{txt,xsl,jpg}");
// -----------------------------------------------------------------
// filter
// -----------------------------------------------------------------
// ここに各種フィルター
//eleventyConfig.addFilter("dateToRfc3339", pluginRss.dateToRfc3339);
// -----------------------------------------------------------------
// collections
// -----------------------------------------------------------------
// ここに各種コレクション
// 本来の書き方 例
// eleventyConfig.addCollection("blog", (api) =>
// api.getFilteredByGlob("src/blog/**/*.md").reverse()
// );
// ドラフト記事を除外する関数
// isServeの設定必須
// const isServe = process.env.ELEVENTY_RUN_MODE === "serve";
const noDraft = (items) => {
return isServe ? [...items] : items.filter(item => !item.data.draft);
};
eleventyConfig.addCollection("blog", (api) => {
return noDraft(api.getFilteredByGlob("src/blog/**/*.md")).reverse();
});
eleventyConfig.addCollection("guitar", (api) => {
return noDraft(api.getFilteredByGlob("src/guitar/**/*.md"));
});
// -----------------------------------------------------------------
// shortcode
// -----------------------------------------------------------------
// ここにショートコード
// -----------------------------------------------------------------
// Markdown Library
// -----------------------------------------------------------------
const mdLib = markdownIt({
html: true,
xhtmlOut: true,
breaks: true,
linkify: true,
typographer: true,
})
.use(rubyPlugin, { rp: ["(", ")"] }); // markdown-itの各種プラグインを使用する場合
// Chrome(blink系)で<br>に高さ・パディングなどを持たせられないため、
// 新たに<br>を<cr></cr>で書き出す設定
mdLib.renderer.rules.softbreak = () => '<cr></cr>';
mdLib.renderer.rules.hardbreak = () => '<cr></cr>';
eleventyConfig.setLibrary("md", mdLib);
return {
// テンプレートエンジンにnunjucksを使用している場合
templateFormats: ["md", "njk", "html"],
markdownTemplateEngine: "njk",
htmlTemplateEngine: "njk",
dir: {
input: "src",
output: "_site",
includes: "_includes",
layouts: "_includes/layouts"
}
};
}
JavaScriptとCSSはどう書いているか
import "@css/style.css";
//他各種import
document.addEventListener("DOMContentLoaded", async () => {
// スクリプトを書く
}
jsは上記のように書いています。1行目はstyle.cssをjsでインポートしてViteで処理させるための記述です。
@import "tailwindcss";
@import "./uaplus.css"; /* リセットcss */
@import "./font-settings.css";
/* ============================================================
@layer 構造の宣言
優先度(低→高): base → layout → component → utility
Tailwind v4 の @layer との共存のため、カスタム層は
tailwind の layer より後に定義する。
実際の詳細度は宣言順ではなく @layer の順序で決まる。
============================================================ */
@layer base, layout, component, utility;
/* ============================================================
@theme — Tailwind v4 のカスタムトークン定義
(@layer の外に置く。これは Tailwind の管轄)
============================================================ */
@theme {
/* フォント (theme.fontFamily.sans) */
--font-sans: "Reddit Sans", sans-serif;
/* カスタムスペーシング (theme.extend.spacing.1px) */
--spacing-1px: 1px;
}
@layer base {
:root {
...
}
}
cssはこういう感じです。@layerとかを使用していますが別に使用しなくてもよいです。ごく普通のcssで問題ありませんが、最初に@import "tailwindcss";でTailwindCSS(v4なのでこれだけでok)を読み込み、必要であればリセットcssなどを読み込み、サイト内で使用(self-host)しているフォントの設定を読み込みます。@import "tailwindcss";はnpmパッケージでインストールしておく必要があります。当サイトではviteのプラグインとしてのtailwindcssと本体もインストールしてあります。
package.jsonはどうなっているか
{
"name": "blazechariot",
"version": "1.0.0",
"type": "module",
"scripts": {
"dev": "eleventy --serve",
"build": "eleventy",
"preview": "npx serve _site",
"clean": "node --eval \"fs.rmSync('_site',{recursive:true,force:true});fs.rmSync('.cache',{recursive:true,force:true});fs.rmSync('.vite',{recursive:true,force:true})\""
},
"devDependencies": {
"@11ty/eleventy": "^3.0.0",
"@11ty/eleventy-img": "^6.0.4",
"@11ty/eleventy-navigation": "^1.0.5",
"@11ty/eleventy-plugin-rss": "^3.0.0",
"@11ty/eleventy-plugin-syntaxhighlight": "^5.0.2",
"@11ty/eleventy-plugin-vite": "^7.0.0",
"@quasibit/eleventy-plugin-sitemap": "^2.2.0",
"@tailwindcss/vite": "^4.0.0",
"fast-glob": "^3.3.0",
"fs-extra": "^11.2.0",
"markdown-it-attrs": "^4.3.1",
"markdown-it-multimd-table-ext": "^4.2.35",
"markdown-it-ruby": "^1.1.2",
"nunjucks": "^3.2.4",
"tailwindcss": "^4.0.0",
"vite": "^6.0.0"
}
}
一応、(npm run buildをして)でき上がった_site/も確認するためserveというパッケージも入れていますが、たいていはいらないと思います。fast-globやfs-extraはeleventy-passthrough-bridge.jsに必要になります。後は編集を楽にするmarkdown-itのプラグインパッケージぐらいです。特殊なものは他にはないだろうと思います。
script:部の、
"scripts": {
"dev": "eleventy --serve",
"build": "eleventy",
"preview": "npx serve _site",
"clean": "node --eval \"fs.rmSync('_site',{recursive:true,force:true});fs.rmSync('.cache',{recursive:true,force:true});fs.rmSync('.vite',{recursive:true,force:true})\""
},
devにしろ、buildにしろ11tyのデフォルトで何かしら特殊な事をしているわけではありません。Viteの設定と追加した独自プラグインeleventy-passthrough-bridge.jsそれと各種ファイルのパスやインポートの仕方を上手くすれば、ほぼ11tyのデフォルトの振る舞いでViteを組み合わせられます。Ctrl+Cをして停止してから、私のpackage.jsonの場合ならcleanをして再度devをするだけです。
既に行っていてキーボードの↑↓矢印キーで過去の履歴も見れるでしょうから、そこでnpm run devなりを探すだけという感じです。
まとめ
11tyの公式プラグインViteの本質としては、11tyとViteを繋ぐためのアダプターであり、どちらかがもう一方を内包しているわけではないので、互いに知らない者同士を設定することになるため起点を正しく設定することが大切になります。
Tailwindの別プロセス化も、RSSのassetsIncludeも全てこのViteはoutputしか見えていないということから理解できるのではないかと思います。
この記事では、
- 11tyで全部作って、Viteの便利な機能(HMRや自動リロード)だけを使う
- Viteのプラグインの力も借りてアセットを強制的に使用できるようにする
- 11tyで普通に編集し、独自スクリプトを入れて、なるべくViteでビルドをする
というような方法を書いてみました。
11tyは他の静的ジェネレーターと違って特殊な記載方法を勉強したりする必要もなく、基本自由ですが内部での処理が見えない所もあり、ドキュメントの通りやってはみるものの思った通りにはできないと言う場面もあります。
基本11tyだけで全ては完結できるようになっています。ライブリロードもあるので記事だけを書くのであれば何も問題はありません。しかしそのライブリロードはJavaScriptを再読込してくれないとか、画像を新たに追加しているのに読み込めないとか、微妙に面倒くさい(手動でブラウザリロードが必要になる)所もあるわけけです。
AstroなどはViteありきで作ってあるのでもっと融合していますが、11tyは使えるなら使ったら良いんじゃない?というスタンスでとにかくシンプルにHTML/JS/CSSで静的サイトを生成するという目的と哲学ですから、シンプルがゆえに自由度は高いが難しい点があるわけです。
何もかもを個人が作成するのは難しいですが、その何かしらの助けになれればと記事を書きました。
昔はhtmlとcss/JSで1ページずつ作っていたサイトよりも便利に、ブログやその他にもデータベース的なサイトまでおそらくどんなものでも作れると思います。