ルーティング規約
zfbにおけるpaths()のルート列挙方法 — 同期的、コレクションベース、SSGセーフ。
zfbでは、動的ルートやキャッチオールページはすべて paths() 関数をエクスポートし、 ビルド時に出力すべき具体的なURLを指定します。 このページでは paths() の仕組み、内部で使用すべきもの、避けるべきことを説明します。
paths()は同期的に実行される
paths()はビルド時にminiflare内で実行され、同期的であることが契約上の仕様です。 関数シグネチャはasyncではなくfunction paths(): PathEntry[]です。
// pages/docs/[[...slug]].tsx
import { getCollection, getEntry } from "zfb/content";
export function paths() {
const entries = getCollection("docs");
return entries.map((entry) => ({
params: { slug: entry.slug.split("/") },
}));
}
export default function DocsPage({ params }: { params: { slug: string[] } }) {
const slugPath = params.slug.join("/");
const entry = getEntry("docs", slugPath);
if (!entry) return <p>Not found.</p>;
return <entry.Content />;
}データアクセスにはgetCollectionとgetEntryを使用する
paths()内では、zfb/contentからgetCollection(name)とgetEntry(name, slug)を使ってコンテンツを読み込みます。 どちらも同期的で、awaitは不要です。
import { getCollection } from "zfb/content";
export function paths() {
const docs = getCollection("docs"); // synchronous — no await
return docs
.filter((entry) => !entry.data.draft)
.map((entry) => ({
params: { slug: entry.slug.split("/") },
props: { title: entry.data.title },
}));
}これらのヘルパーはContentSnapshotから読み取ります。ContentSnapshotとは、TypeScriptモジュールが実行される前にRustで構築された、全コンテンツコレクションのインメモリスナップショットです。 paths()が実行される時点で、全データはすでにメモリ上にあるため、呼び出し時にI/Oは発生しません。
なぜ同期的なのか?
ContentSnapshotはランタイム起動時にJSONにシリアライズされ、最初のTSXモジュールが評価される前にglobalThis.__zfbに埋め込まれます。getCollectionとgetEntryはそのインメモリマップから直接読み取るため、非同期の境界を越える必要がなく、呼び出し元にawaitを連鎖させる必要もありません。 Rust ↔ JSブリッジ契約の詳細はADR-004を参照してください。
paths()内でライブデータをフェッチしないこと
paths()は技術的にはasyncとして書けますが、そうするとSSGの保証が破れます。
// ❌ Anti-pattern — live fetch inside paths()
export async function paths() {
const res = await fetch("https://api.example.com/items");
const items = await res.json();
return items.map((item) => ({ params: { slug: item.id } }));
}このパターンにはいくつかの問題があります:
非決定論的 — リモートAPIはビルドごとに異なるデータを返す可能性がある
再現性の欠如 — 同じコミットから異なるHTML出力が生成される可能性がある
オフラインでの障害 — エアギャップ環境やネットワークのないCIでビルドが失敗する
ビルド遅延 — ルート列挙のたびにネットワークラウンドトリップが発生する
コンテンツレイヤーはまさにこれを避けるために存在します。 一度フェッチしてビルド時にスナップショットし、そのスナップショットから同期的にクエリします。
コレクションベースのpaths():最小限の例
以下のスニペットは、このパターンの最小限の例です — getCollection/getEntryから直接組み立てた 手書きのpaths()です。これはzudo-doc自身のdocsキャッチオールルートが実際に行っていることでは ありません。実際の実装は下記のパッケージルートの注入とシャドーイングを参照してください。 各エントリのスラグはURLセグメントのstring[]にマッピングされます:
// pages/docs/[[...slug]].tsx
import { getCollection, getEntry } from "zfb/content";
export function paths() {
const docs = getCollection("docs");
return docs
.filter((entry) => !entry.data.draft)
.map((entry) => ({
params: { slug: entry.slug.split("/") },
}));
}
export default function DocsPage({ params }: { params: { slug: string[] } }) {
const slugPath = params.slug.join("/");
const entry = getEntry("docs", slugPath);
if (!entry) return <p>Not found.</p>;
return <entry.Content />;
}/はparams.slug === ["guides", "configuration"]でマッチします。 slug.で再結合してgetEntryのルックアップキーを再構築します。
パッケージルートの注入とシャドーイング
実際のpages/docs/[[...slug]].tsxドキュメントルートスタブは、上記の最小限の例のように getCollection/getEntryを手書きしません。routeCtx.buildDocRouteEntries()とcreateChrome() でルートを再構築しており、生成されたプロジェクトは通常このファイル自体を持ちません — 詳細は以下を参照してください。
packageOwnedRoutes(デフォルトでtrue)が有効な場合、 @takazudo/zudo-docのroutesプラグインはビルド時にドキュメントルートを注入します。 これには/、そのロケール・バージョン別のバリアント、タグとバージョンのルートが含まれます。 したがって、生成されたプロジェクト自身が持つpages/ファイルはほとんどありません。
同じパスでは、プロジェクトのルートが注入されたルートより常に優先されます。 この場合、ビルドログには次のように表示されます。
shadowed by a user pages/ route (user wins); skippingユーザーファイルが使用され、パッケージルートは注入されません。プロジェクト固有の実装が必要なルートでは、これを意図的に使用します。
/ルートは異なります。zfbは/の注入を許可しないため、スキャフォールドのpages/は @takazudo/を再エクスポートする1行のファイルです。 これはホームページを提供する仕組みであり、ルートのシャドーイング例ではありません。
スキャフォールド内の自己完結型ドキュメントルートスタブも、任意のシャドーイング例ではありません。 注入された動的ルートは現在zfb devで404を返すため、ドキュメントページでpnpm devを動作させ続けるにはスタブが必要です。 これは/の注入制限とは別の、上流の制約です。
注入されたルートをシャドーするドキュメントルートスタブをカスタマイズする場合は、 virtual:zudo-doc-chrome-bindingsからchromeBindingsもインポートし、createChromeへ渡す必要があります。 そうしなければ、ドキュメントページではchromeBindingsModuleチャネルが切断されます。 完全なスタブ配線パターンについてはカスタムコンポーネントを参照してください。
AstroのgetStaticPathsからの移行
Astroからプロジェクトを移行する場合、paths()がgetStaticPaths()の代わりになります。 主な違いは、paths()が同期的であり、ビルド実行前にすべてのデータをコンテンツコレクションに 事前ロードしておく必要があることです。
AstroのgetStaticPaths()は任意の非同期操作を受け付けていました。 次のようなパターンがよく見られました:
// ❌ Astro — before migration
// pages/docs/tags/[tag].tsx
export async function getStaticPaths() {
const res = await fetch("https://api.example.com/tags");
const tags = await res.json();
return tags.map((tag: { slug: string }) => ({
params: { tag: tag.slug },
}));
}zfbでは、これを2ステップのプロセスに変換します。
ステップ1 — フェッチをビルド時ジェネレータースクリプトに移動します。 zfb buildの前に実行し、その結果をコンテンツコレクションまたは静的JSONファイルに書き込みます:
// scripts/fetch-tags.ts — run as a pre-build step
import { writeFileSync, mkdirSync } from "fs";
const res = await fetch("https://api.example.com/tags");
const tags = await res.json();
mkdirSync("src/content/tags", { recursive: true });
for (const tag of tags) {
writeFileSync(
`src/content/tags/${tag.slug}.json`,
JSON.stringify(tag),
);
}ステップ2 — getCollectionを使ってpaths()内でプリフェッチ済みデータを読み込みます:
// ✅ zfb — after migration
// pages/tags/[tag].tsx
import { getCollection, getEntry } from "zfb/content";
export function paths() {
const tags = getCollection("tags"); // reads the pre-generated content
return tags.map((tag) => ({
params: { tag: tag.slug },
props: { label: tag.data.label },
}));
}
export default function TagPage({
params,
props,
}: {
params: { tag: string };
props: { label: string };
}) {
return <h1>{props.label}</h1>;
}デプロイ間で変わらないデータ(例:リポジトリにコミットされた初期データセット)の場合は、 ジェネレータースクリプトを省略して、JSONファイルを直接src/content/に配置することもできます。
簡易比較
Astro getStaticPaths | zfb paths | |
|---|---|---|
| シグネチャ | async function getStaticPaths() | function paths() |
| ランタイム | Node.js | miniflare |
| データアクセス | await getCollection(...) または await fetch(...) | getCollection(...) / getEntry(...) (同期) |
| ライブHTTPフェッチ | 許可(SSGでは非推奨) | ビルド前ステップに移動 |
| 返り値の形状 | { params, props }[] | { params, props }[] |
| 下書き除外 | getStaticPaths内でフィルタリング | paths()内でフィルタリング |