## 概要

zudo-docは「常に末尾スラッシュあり」のURLポリシーを採用しています。すべてのページは末尾が `/` で終わるパスに配置されます。例えば `/docs/getting-started` ではなく `/docs/getting-started/` です。

zfbは**静的サイトビルダーのみ**であり、サーバーサイドのミドルウェアレイヤーはありません。そのため、適用は異なる担当者を持つ2つの関心事に明確に分割されます。

:::note[Astroについての背景]

zudo-docはもともとAstroで構築されており、2箇所で末尾スラッシュを強制していました。ビルド時には `trailingSlash: "always"` の設定により `dist/docs/getting-started/index.html` が出力され、`src/middleware-handler.ts` がパスに末尾スラッシュのないリクエストに対して301リダイレクトを発行していました。
zfbでは、この2つの関心事は以下で説明するのと同じ分割（ビルド時とデプロイホスト）に対応しますが、それぞれの強制メカニズムは異なります。

:::

## ビルド時 — zfb / url-normalizerの担当

静的ビルド中に出力されるすべてのURLは `/` で終わる必要があります。これは `@takazudo/zudo-doc/url-normalizer` の `buildUrl()` によって強制されます：

```ts
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()` は主要なヘルパーです。パスセグメントを結合し、冗長なスラッシュを除去し、結果を `/<joined>/` でラップします。以下の箇所で使用されています：

- `pages/sitemap.xml.tsx` — `<loc>` エントリの生成。
- コレクションスラッグをルートパスにマップするzfbの `paths()` エクスポート。
- インテグレーションレベルのURL出力（llms.txt、claude-resourcesなど）。

外部データからパス名が届き、末尾スラッシュがあるかどうか不明な場合のために、補助ヘルパー `normalizePathname()` も用意されています：

```ts
import { normalizePathname } from "@takazudo/zudo-doc/url-normalizer";

const canonical = normalizePathname(rawPathname); // 末尾スラッシュがない場合は追加
```

### スキップルール

`normalizePathname()` とその内部の `shouldSkipNormalization()` は、以下のスキップルールを適用します：

| 条件 | 動作 |
|---|---|
| すでに `/` で終わっている | 何もしない |
| 最後のセグメントに文字で始まる拡張子がある（`.js`、`.css`、`.png` など） | 何もしない — 静的アセット |
| `/_astro/` または `/_image` で始まる | 何もしない — クロスフレームワーク間のアセットパス互換性のため保持 |
| `/docs/v2.0` のようなバージョン文字列（ドットの後に数字） | **正規化される** — 拡張子とは扱われない |

## ランタイム — デプロイホストの担当

訪問者が末尾スラッシュなしで `https://example.com/docs/guide` と入力した場合、リクエストをインターセプトするフレームワークのコードはありません。静的ファイルはすでにデプロイされています。デプロイホストがリダイレクトを発行する責任を持ちます。

### Cloudflare Pages

`public/_redirects` に以下のルールを追加します。ページ固有のリダイレクトルールより**前に**記述する必要があります（Cloudflare Pagesはルールを上から順に評価し、最初にマッチしたルールで停止します）：

```
# 末尾スラッシュなしのURLを正規形にリダイレクトする。
/:splat  /:splat/  301
```

:::note[ホストがランタイムを担当する理由]

ランタイムリダイレクトをフレームワークコードではなくホストに委ねることで、`_redirects`（または同等の）ファイルを変更するだけでデプロイターゲットを切り替えられます。Cloudflare Pages、Netlify、Vercel、plain nginxのいずれでも対応可能です。フレームワークはプラットフォームに依存しない状態を保てます。

:::

### Cloudflare Workers Static Assets

zudo-docは現在、Cloudflare Pagesではなく**Cloudflare Workers**にデプロイされ（`wrangler.toml`）、ビルド済みの `dist/` をWorkersの静的アセットレイヤーを通じて配信します。静的ビルドはすでに末尾スラッシュのディレクトリ構造（`dist/docs/getting-started/index.html`）を出力しているため、Workersのアセットレイヤーは各正規 `/<route>/` URLをビルド済みの `index.html` から直接配信します。

そのため、Pagesセクションで説明している手動の `public/_redirects` ルールはWorkersでは**配信されません**。これはPages → Workersへの移行（[zudolab/zudo-doc#1691](https://github.com/zudolab/zudo-doc/issues/1691)）で廃止されました。関連する `wrangler.toml` の設定は以下のとおりです：

```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` — 同じ `/:splat /:splat/ 301` 構文 |
| Vercel | `vercel.json` の `"trailingSlash": true` |
| nginx | `try_files` + `return 301 $uri/` |
| AWS CloudFront | viewer-requestイベント用のLambda@EdgeまたはCloudFront Function |

## まとめ

| レイヤー | 担当 | メカニズム |
|---|---|---|
| ビルド時のURL構築 | zfb / `url-normalizer` | `buildUrl()` — 常に `/<path>/` を出力 |
| ビルド時のパス名補正 | zfb / `url-normalizer` | `normalizePathname()` |
| ランタイムリダイレクト（スラッシュなし→あり） | デプロイホスト | `_redirects` またはホスト設定 |

この分割により、各関心事が適切な場所に置かれ、問題が発生した際にどのレイヤーが担当しているかを容易に把握できます。