Trailing-Slash Policy
How zfb splits trailing-slash enforcement between build time and runtime.
Overview
zudo-doc enforces a "trailing slash always" URL policy: every page lives at a path
ending with / — for example / rather than
/.
zfb is a static-only builder — there is no server-side middleware layer. The enforcement therefore splits cleanly into two concerns with different owners.
Astro background
zudo-doc originated on Astro, which enforced trailing slashes in two places: the build emitted dist/ (controlled by trailingSlash: "always"), and src/ 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/:
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 /. It is used by:
pages/— generatessitemap. xml. tsx <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:
import { normalizePathname } from "@takazudo/zudo-doc/url-normalizer";
const canonical = normalizePathname(rawPathname); // adds "/" if missingSkip 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 / or / | no-op — preserved for cross-framework asset path compatibility |
Version string like / (digit after dot) | normalised — not treated as extension |
Runtime — owned by the deployment host
When a visitor types https: 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/ 301Why 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/), the Workers asset layer serves
each canonical / 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). The
relevant wrangler.toml settings are:
[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 workerNote
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 / 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 / |
| 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.