## Overview

zudo-doc enforces a "trailing slash always" URL policy: every page lives at a path
ending with `/` — for example `/docs/getting-started/` rather than
`/docs/getting-started`.

zfb is a **static-only** builder — there is no server-side middleware layer. The
enforcement therefore splits cleanly into two concerns with different owners.

:::note[Astro background]

zudo-doc originated on Astro, which enforced trailing slashes in two places: the build
emitted `dist/docs/getting-started/index.html` (controlled by `trailingSlash: "always"`),
and `src/middleware-handler.ts` issued 301 redirects for bare-path requests.
In zfb those two concerns map to the same split described below — build time and
deployment host — but the enforcement mechanism is different in each case.

:::

## Build time — owned by zfb / url-normalizer

All URLs emitted during the static build must end with `/`. This is enforced by
`buildUrl()` in `@takazudo/zudo-doc/url-normalizer`:

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

// In a paths() implementation or integration:
const url = buildUrl("docs", slug);          // "/docs/my-slug/"
const root = buildUrl();                      // "/"
const localized = buildUrl("ja", "docs", slug); // "/ja/docs/my-slug/"
```

`buildUrl()` is the primary helper. It joins path segments, strips redundant
slashes, and wraps the result in `/<joined>/`. It is used by:

- `pages/sitemap.xml.tsx` — generates `<loc>` entries.
- Any zfb `paths()` export that maps collection slugs to route paths.
- Integration-level URL emitters (llms.txt, claude-resources, etc.).

A secondary helper `normalizePathname()` is available for cases where a
pathname arrives from external data and may or may not already carry a trailing
slash:

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

const canonical = normalizePathname(rawPathname); // adds "/" if missing
```

### Skip rules

`normalizePathname()` and its underlying `shouldSkipNormalization()` apply these skip rules:

| Condition | Behaviour |
|---|---|
| Already ends with `/` | no-op |
| Last segment has a letter-starting extension (`.js`, `.css`, `.png` …) | no-op — static asset |
| Starts with `/_astro/` or `/_image` | no-op — preserved for cross-framework asset path compatibility |
| Version string like `/docs/v2.0` (digit after dot) | **normalised** — not treated as extension |

## Runtime — owned by the deployment host

When a visitor types `https://example.com/docs/guide` without the trailing slash,
there is no framework code to intercept the request — the static files have already
been deployed. The deployment host is responsible for issuing a redirect.

### Cloudflare Pages

Add the following rule to `public/_redirects`. It must come **before** any
page-specific redirect rules (Cloudflare Pages evaluates rules top-to-bottom and
stops at the first match):

```
# Redirect non-trailing-slash URLs to their canonical form.
/:splat  /:splat/  301
```

:::note[Why the host owns this]

Keeping runtime redirects on the host rather than in framework code means you can
swap deployment targets — Cloudflare Pages, Netlify, Vercel, a plain nginx — by
changing only the `_redirects` (or equivalent) file. The framework stays
platform-agnostic.

:::

### Cloudflare Workers Static Assets

zudo-doc now deploys to **Cloudflare Workers** (`wrangler.toml`), serving the
built `dist/` through the Workers static-assets layer rather than Cloudflare
Pages. Because the static build already emits the trailing-slash directory
layout (`dist/docs/getting-started/index.html`), the Workers asset layer serves
each canonical `/<route>/` URL from its pre-built `index.html` directly.

As a result, the manual `public/_redirects` rule the Pages section describes is
**not** shipped on Workers — it was dropped in the Pages → Workers migration
([zudolab/zudo-doc#1691](https://github.com/zudolab/zudo-doc/issues/1691)). The
relevant `wrangler.toml` settings are:

```toml
[assets]
directory = "./dist"
not_found_handling = "404-page"   # serves dist/404.html for unmatched GETs
run_worker_first = false          # asset layer is consulted before the worker
```

:::note

The Pages section above is retained for reference — it still applies if you
deploy this site to Cloudflare Pages or another host. On Workers static assets,
the platform's built-in asset handling covers the trailing-slash case, so no
`_redirects` file is part of the deployed artifact.

:::

### Other hosts

| Host | Mechanism |
|---|---|
| Netlify | `public/_redirects` — same `/:splat /:splat/ 301` syntax |
| Vercel | `vercel.json` `"trailingSlash": true` |
| nginx | `try_files` + `return 301 $uri/` |
| AWS CloudFront | Lambda@Edge or CloudFront Function for the viewer-request event |

## Summary

| Layer | Owner | Mechanism |
|---|---|---|
| Build-time URL construction | zfb / `url-normalizer` | `buildUrl()` — always emits `/<path>/` |
| Build-time pathname correction | zfb / `url-normalizer` | `normalizePathname()` |
| Runtime redirect (no-slash → slash) | Deployment host | `_redirects` or host config |

The split keeps each concern in the right place and makes it easy to reason about
which layer is responsible when something goes wrong.