末尾スラッシュポリシー
zfbがビルド時とランタイムで末尾スラッシュの適用をどのように分担するかについての解説。
概要
zudo-docは「常に末尾スラッシュあり」のURLポリシーを採用しています。すべてのページは末尾が / で終わるパスに配置されます。例えば / ではなく / です。
zfbは静的サイトビルダーのみであり、サーバーサイドのミドルウェアレイヤーはありません。そのため、適用は異なる担当者を持つ2つの関心事に明確に分割されます。
Astroについての背景
zudo-docはもともとAstroで構築されており、2箇所で末尾スラッシュを強制していました。ビルド時には trailingSlash: "always" の設定により dist/ が出力され、src/ がパスに末尾スラッシュのないリクエストに対して301リダイレクトを発行していました。 zfbでは、この2つの関心事は以下で説明するのと同じ分割(ビルド時とデプロイホスト)に対応しますが、それぞれの強制メカニズムは異なります。
ビルド時 — zfb / url-normalizerの担当
静的ビルド中に出力されるすべてのURLは / で終わる必要があります。これは @takazudo/ の buildUrl() によって強制されます:
import { buildUrl } from "@takazudo/zudo-doc/url-normalizer";
// paths()の実装やインテグレーションで:
const url = buildUrl("docs", slug); // "/docs/my-slug/"
const root = buildUrl(); // "/"
const localized = buildUrl("ja", "docs", slug); // "/ja/docs/my-slug/"buildUrl() は主要なヘルパーです。パスセグメントを結合し、冗長なスラッシュを除去し、結果を / でラップします。以下の箇所で使用されています:
pages/—sitemap. xml. tsx <loc>エントリの生成。コレクションスラッグをルートパスにマップするzfbの
paths()エクスポート。インテグレーションレベルのURL出力(llms.txt、claude-resourcesなど)。
外部データからパス名が届き、末尾スラッシュがあるかどうか不明な場合のために、補助ヘルパー normalizePathname() も用意されています:
import { normalizePathname } from "@takazudo/zudo-doc/url-normalizer";
const canonical = normalizePathname(rawPathname); // 末尾スラッシュがない場合は追加スキップルール
normalizePathname() とその内部の shouldSkipNormalization() は、以下のスキップルールを適用します:
| 条件 | 動作 |
|---|---|
すでに / で終わっている | 何もしない |
最後のセグメントに文字で始まる拡張子がある(.js、.css、.png など) | 何もしない — 静的アセット |
/ または / で始まる | 何もしない — クロスフレームワーク間のアセットパス互換性のため保持 |
/ のようなバージョン文字列(ドットの後に数字) | 正規化される — 拡張子とは扱われない |
ランタイム — デプロイホストの担当
訪問者が末尾スラッシュなしで https: と入力した場合、リクエストをインターセプトするフレームワークのコードはありません。静的ファイルはすでにデプロイされています。デプロイホストがリダイレクトを発行する責任を持ちます。
Cloudflare Pages
public/_redirects に以下のルールを追加します。ページ固有のリダイレクトルールより前に記述する必要があります(Cloudflare Pagesはルールを上から順に評価し、最初にマッチしたルールで停止します):
# 末尾スラッシュなしのURLを正規形にリダイレクトする。
/:splat /:splat/ 301ホストがランタイムを担当する理由
ランタイムリダイレクトをフレームワークコードではなくホストに委ねることで、_redirects(または同等の)ファイルを変更するだけでデプロイターゲットを切り替えられます。Cloudflare Pages、Netlify、Vercel、plain nginxのいずれでも対応可能です。フレームワークはプラットフォームに依存しない状態を保てます。
Cloudflare Workers Static Assets
zudo-docは現在、Cloudflare PagesではなくCloudflare Workersにデプロイされ(wrangler.toml)、ビルド済みの dist/ をWorkersの静的アセットレイヤーを通じて配信します。静的ビルドはすでに末尾スラッシュのディレクトリ構造(dist/)を出力しているため、Workersのアセットレイヤーは各正規 / URLをビルド済みの index.html から直接配信します。
そのため、Pagesセクションで説明している手動の public/_redirects ルールはWorkersでは配信されません。これはPages → Workersへの移行(zudolab/zudo-doc#1691)で廃止されました。関連する wrangler.toml の設定は以下のとおりです:
[assets]
directory = "./dist"
not_found_handling = "404-page" # マッチしないGETに対して dist/404.html を配信
run_worker_first = false # アセットレイヤーがワーカーより先に参照されるNote
上記のPagesセクションは参考のために残してあります。このサイトをCloudflare Pagesや他のホストにデプロイする場合は、引き続き適用されます。Workersの静的アセットでは、プラットフォーム組み込みのアセット処理が末尾スラッシュのケースをカバーするため、_redirects ファイルはデプロイ成果物に含まれません。
その他のホスト
| ホスト | メカニズム |
|---|---|
| Netlify | public/_redirects — 同じ / 構文 |
| Vercel | vercel.json の "trailingSlash": true |
| nginx | try_files + return 301 $uri/ |
| AWS CloudFront | viewer-requestイベント用のLambda@EdgeまたはCloudFront Function |
まとめ
| レイヤー | 担当 | メカニズム |
|---|---|---|
| ビルド時のURL構築 | zfb / url-normalizer | buildUrl() — 常に / を出力 |
| ビルド時のパス名補正 | zfb / url-normalizer | normalizePathname() |
| ランタイムリダイレクト(スラッシュなし→あり) | デプロイホスト | _redirects またはホスト設定 |
この分割により、各関心事が適切な場所に置かれ、問題が発生した際にどのレイヤーが担当しているかを容易に把握できます。