create-pracht 0.4.2 → 0.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -15,12 +15,17 @@ npm run dev
15
15
 
16
16
  - Prompts for the target folder.
17
17
  - Detects the active package manager from the current environment.
18
- - Lets the user choose between the Node.js, Cloudflare, and Vercel adapters.
18
+ - Lets the user choose between the Node.js, Cloudflare, Vercel, Netlify, and static adapters.
19
19
  - Optionally wires up Tailwind CSS (`tailwindcss` + `@tailwindcss/vite`, a global stylesheet, and the shell import).
20
- - Scaffolds a minimal app with a route manifest or pages router, shell, home route, sample API route, runnable project README, TypeScript typecheck script, and agent instructions.
20
+ - Scaffolds a minimal app with a route manifest or pages router, shell, home route, not-found page, a sample API route for serverful adapters, runnable project README, TypeScript typecheck script, and (with agent tooling enabled) agent instructions.
21
21
  - Manifest scaffolds include a commented-out `constraints` example in `src/routes.ts`, ready for `pracht verify`.
22
22
  - The generated `.gitignore` keeps `.pracht/app-graph.json` committable, and the README and agent instructions cover the `pracht verify` / `pracht plan` / `pracht report` loop.
23
- - Seeds the pracht Claude Code skills into `.claude/skills/` and writes a `.mcp.json` registering the `pracht mcp` server (yes-default prompt; skipped with `--no-agent-tools`).
23
+ - Every standalone pnpm scaffold includes a narrow lifecycle-script policy for
24
+ its required native dependencies in `pnpm-workspace.yaml`:
25
+ `onlyBuiltDependencies` for pnpm 10 or `allowBuilds` for pnpm 11. All adapters
26
+ allow `esbuild`, Cloudflare also allows `workerd`, and Tailwind starters also
27
+ allow `@tailwindcss/oxide`.
28
+ - Seeds the pracht Claude Code skills into `.claude/skills/`, writes a `.mcp.json` registering the `pracht mcp` server, and writes `AGENTS.md` (yes-default prompt; all of it skipped with `--no-agent-tools`, which leaves a project with no agent files at all).
24
29
  - Initializes a git repository with an initial commit (skipped with `--no-git`, when git is unavailable, or when the target is already inside a repository).
25
30
  - `--dry-run` uses pinned fallback versions and does not require npm registry access.
26
31
 
@@ -30,17 +35,21 @@ npm run dev
30
35
  node ./packages/start/bin/create-pracht.js
31
36
  node ./packages/start/bin/create-pracht.js my-app --adapter=node --skip-install
32
37
  node ./packages/start/bin/create-pracht.js my-app --adapter=vercel --skip-install
38
+ node ./packages/start/bin/create-pracht.js my-app --adapter=netlify --skip-install
39
+ node ./packages/start/bin/create-pracht.js my-app --adapter=static --skip-install
33
40
  node ./packages/start/bin/create-pracht.js my-app --template=tailwind --yes
34
41
  node ./packages/start/bin/create-pracht.js my-app --adapter=node --no-tailwind --no-git --yes
35
42
  ```
36
43
 
37
44
  ## Options
38
45
 
39
- - `--adapter=node|cf|vercel` — choose the hosting adapter (default: node).
46
+ - `--adapter=node|cf|netlify|vercel|static` — choose the hosting adapter (default: node).
47
+ `static` scaffolds a pure static export (`@pracht/adapter-static`): no API route is
48
+ generated, because a static export has no server to answer one.
40
49
  - `--router=manifest|pages` — choose the routing system (default: manifest).
41
50
  - `--template=minimal|tailwind` — non-interactive template selection; `minimal` is the default output, `tailwind` is minimal plus Tailwind CSS wiring.
42
51
  - `--tailwind` / `--no-tailwind` — enable or disable Tailwind CSS without going through the prompt.
43
- - `--agent-tools` / `--no-agent-tools` — seed the Claude Code skills and `.mcp.json` (or skip them) without going through the prompt.
52
+ - `--agent-tools` / `--no-agent-tools` — seed the Claude Code skills, `.mcp.json`, and `AGENTS.md`/`CLAUDE.md` (or skip all of them) without going through the prompt.
44
53
  - `--no-git` — skip `git init` and the initial commit.
45
54
  - `--skip-install` — skip dependency installation.
46
55
  - `--yes`, `-y` — accept defaults (node adapter, manifest router, no Tailwind, agent tooling on) and skip all prompts.
@@ -53,11 +62,14 @@ node ./packages/start/bin/create-pracht.js my-app --adapter=node --no-tailwind -
53
62
  - `vite.config.ts`
54
63
  - `src/routes.ts`
55
64
  - `src/routes/home.tsx`
65
+ - `src/routes/not-found.tsx` — the app's 404 page, wired via `notFound` in the manifest (pages scaffolds get `src/pages/404.tsx`, which pracht wires automatically)
56
66
  - `src/shells/public.tsx`
57
- - `src/api/health.ts`
67
+ - `src/api/health.ts` — serverful adapters only
58
68
  - `.gitignore`
59
69
  - `.claude/skills/<name>/SKILL.md` — the pracht agent skills (unless `--no-agent-tools`)
60
70
  - `.mcp.json` — registers the `pracht mcp` server for MCP clients (unless `--no-agent-tools`)
71
+ - `AGENTS.md` (plus a `CLAUDE.md` symlink pointing at it) — project conventions for coding
72
+ agents (unless `--no-agent-tools`; `README.md` documents the same commands for humans)
61
73
 
62
74
  The skills are copied from the repo's [skills/](../../skills/README.md) directory into this
63
75
  package at build/publish time (`scripts/sync-skills.js`), so the published npm tarball is
@@ -76,6 +88,12 @@ Cloudflare scaffolds also include:
76
88
 
77
89
  - `wrangler.jsonc`
78
90
 
91
+ Standalone pnpm scaffolds for every adapter include `pnpm-workspace.yaml` with
92
+ the version-appropriate lifecycle policy. When the new app belongs to an
93
+ ancestor pnpm workspace, that workspace owns the policy instead: the generated
94
+ README and completion message list the exact entries to add and no nested
95
+ workspace file is created.
96
+
79
97
  ## Generated Scripts
80
98
 
81
99
  - `dev` -> `pracht dev`
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pracht",
3
- "version": "0.4.2",
3
+ "version": "0.6.0",
4
4
  "description": "Interactive and scriptable starter CLI for creating full-stack Preact apps with Pracht.",
5
5
  "keywords": [
6
6
  "pracht",
@@ -1,14 +1,18 @@
1
1
  ---
2
2
  name: add-i18n
3
- version: 1.1.0
3
+ version: 2.1.0
4
4
  description: |
5
- Wire internationalization into a pracht app following the framework's
6
- recommended pattern (middleware detects locale, loaders return translations,
7
- components consume via route data). Generates locale dictionaries, the
8
- detection middleware (URL-prefix, cookie, or `Accept-Language`), and a
9
- helper for in-component translation.
5
+ Wire internationalization into a pracht app with the first-party
6
+ `@pracht/i18n` package, following the framework's recommended pattern
7
+ (middleware detects locale, loaders return translations, components
8
+ consume via route data). Sets up the i18n instance, lazy locale
9
+ dictionaries with typed keys, the detection middleware (URL-prefix,
10
+ cookie, and `Accept-Language`), and either strategy: locale-prefixed
11
+ route groups with hreflang metadata, or one URL per page with a
12
+ cookie-backed switcher that changes no URLs.
10
13
  Use when asked to "add i18n", "set up translations", "make my app
11
- multilingual", "add locale routing", or "extract strings".
14
+ multilingual", "add locale routing", "switch language without changing
15
+ URLs", or "extract strings".
12
16
  allowed-tools:
13
17
  - Bash
14
18
  - Read
@@ -21,219 +25,372 @@ allowed-tools:
21
25
 
22
26
  # Pracht Add i18n
23
27
 
24
- Pracht ships no i18n library the framework gives you primitives. The
25
- recommended recipe lives at
26
- `examples/docs/src/routes/docs/recipes-i18n.md`.
28
+ Pracht ships its i18n primitives as `@pracht/i18n`: locale-detection
29
+ middleware, lazy dictionaries with keys typed from the default locale,
30
+ `t()`/`tPlural()` (plurals via `Intl.PluralRules`), `localePath()`, and an
31
+ `hreflang()` helper for `head()`. The full guide lives at
32
+ `examples/docs/src/routes/docs/recipes-i18n.md`; working setups are in
33
+ `examples/basic` — locale-prefixed (`/welcome`, `/en/welcome`,
34
+ `/nl/welcome`) and prefix-free (`/greeting`, `src/api/locale.ts`). That page
35
+ also keeps a hand-rolled fallback recipe if the user refuses the dependency.
27
36
 
28
37
  If the pracht MCP server is registered (see docs/MCP.md), prefer its tools
29
38
  (`inspect_routes`, `inspect_api`, `inspect_build`, `doctor`, `verify`,
30
39
  `generate_*`) over shelling out. Prerequisite: `pracht inspect` needs a vite
31
40
  config with the pracht plugin registered.
32
41
 
33
- ## Step 1: Pick the locale-detection strategy
42
+ ## Step 1: Pick locales and a URL strategy
34
43
 
35
- Use `AskUserQuestion`:
44
+ Use `AskUserQuestion` once for: supported locales (default: `en` plus one or
45
+ two more), the default locale, and the **URL strategy**:
36
46
 
37
- | Strategy | URL shape | Pros | Cons |
38
- | --------------- | ------------------ | ----------------------------- | -------------------------- |
39
- | URL-prefix | `/fr/about` | Best for SEO; explicit | Requires manifest changes |
40
- | Cookie | `/about` + cookie | URL stays clean | Hidden state; SEO weaker |
41
- | Accept-Language | `/about` (varies) | No user action | Caching/SEO get tricky |
47
+ - **A. Locale-prefixed URLs** (`/en/about`) — the default for public,
48
+ indexable content: each language is its own URL, `hreflang()` works, and
49
+ routes can stay `ssg`/`isg`. Adopting it changes every URL.
50
+ - **B. One URL per page** (`/about`) for an existing site whose URLs
51
+ cannot move, or an app behind a login where indexing does not matter. The
52
+ cookie decides the locale, switching needs no navigation, and no URL
53
+ changes. Cost: `Vary: Cookie, Accept-Language` makes those routes
54
+ per-request (`ssr`/`spa`, never `ssg`/`isg`) and a single URL cannot carry
55
+ hreflang alternates.
42
56
 
43
- Default to **URL-prefix** unless the user explicitly chooses otherwise.
57
+ Ask explicitly if the user already has a live site — never migrate an
58
+ existing app's URLs without saying so. Both strategies can coexist in one
59
+ app on one instance.
44
60
 
45
- ## Step 2: Pick the supported locales
61
+ Keep the default detection order `["path", "cookie", "header"]` in both
62
+ cases (the path source simply never matches a prefix-free route); only
63
+ change it when the user explicitly wants cookie-only or header-only
64
+ detection.
46
65
 
47
- Ask once. Default suggestion: `en` plus one to two more. Confirm a default
48
- locale (used as fallback in `t()`).
66
+ Install the package:
49
67
 
50
- ## Step 3: Translation files
51
-
52
- `src/i18n/<locale>.ts` per locale:
53
-
54
- ```ts
55
- export default {
56
- "home.title": "Welcome",
57
- "home.subtitle": "Built with pracht",
58
- "nav.home": "Home",
59
- "nav.about": "About",
60
- } as const;
68
+ ```bash
69
+ npm install @pracht/i18n
61
70
  ```
62
71
 
63
- `src/i18n/index.ts`:
72
+ ## Step 2: The i18n instance and dictionaries
64
73
 
65
74
  ```ts
66
- import en from "./en";
67
- import fr from "./fr";
75
+ // src/i18n/index.ts
76
+ import { createDictionaries, defineI18n } from "@pracht/i18n";
68
77
 
69
- export const translations = { en, fr } as const;
70
- export const defaultLocale = "en" as const;
71
- export const supportedLocales = Object.keys(translations) as Array<keyof typeof translations>;
78
+ export const i18n = defineI18n({
79
+ locales: ["en", "fr"],
80
+ defaultLocale: "en",
81
+ });
72
82
 
73
- export type Locale = keyof typeof translations;
74
- export type TranslationKey = keyof typeof en;
83
+ export type AppLocale = (typeof i18n.locales)[number];
75
84
 
76
- export function t(locale: string, key: TranslationKey): string {
77
- const dict = (translations as Record<string, Record<string, string>>)[locale]
78
- ?? translations[defaultLocale];
79
- return dict[key] ?? translations[defaultLocale][key] ?? key;
80
- }
85
+ export const dictionaries = createDictionaries(
86
+ {
87
+ en: () => import("./locales/en.ts"),
88
+ fr: () => import("./locales/fr.ts"),
89
+ },
90
+ { defaultLocale: "en" },
91
+ );
81
92
  ```
82
93
 
83
- ## Step 4: Locale-detection middleware
84
-
85
- ### URL-prefix variant
94
+ One dictionary module per locale — flat string keys, default export,
95
+ `as const` so key typing works:
86
96
 
87
97
  ```ts
88
- // src/middleware/i18n.ts
89
- import type { MiddlewareFn } from "@pracht/core";
90
- import { defaultLocale, supportedLocales } from "../i18n";
91
-
92
- export const middleware: MiddlewareFn = ({ request, url }, next) => {
93
- const segments = url.pathname.split("/").filter(Boolean);
94
- const maybe = segments[0] ?? "";
95
- const locale = (supportedLocales as readonly string[]).includes(maybe) ? maybe : defaultLocale;
96
- request.headers.set("x-locale", locale);
97
- return next();
98
- };
98
+ // src/i18n/locales/en.ts
99
+ export default {
100
+ "home.title": "Welcome, {name}",
101
+ "cart.items.one": "{count} item",
102
+ "cart.items.other": "{count} items",
103
+ } as const;
99
104
  ```
100
105
 
101
- Caveat: a `/:locale/...` route pattern matches ANY first segment — `/zz/about`
102
- would happily serve default-locale content at a bogus URL, and search engines
103
- will index it as duplicate content. Guard against unsupported prefixes in the
104
- middleware — 404 (or redirect to the default-locale URL) when the first
105
- segment looks like a locale but isn't supported:
106
+ Plural keys declare one entry per `Intl.PluralRules` category the locale
107
+ needs (`.one`, `.other`, plus `.few`/`.many` for e.g. Polish); `tPlural()`
108
+ falls back to `.other`. Non-default locales may omit keys `load()` merges
109
+ the default locale underneath.
106
110
 
107
- ```ts
108
- // Add before `return next()` in the URL-prefix middleware:
109
- if (maybe.length === 2 && !(supportedLocales as readonly string[]).includes(maybe)) {
110
- return new Response("Not Found", { status: 404 });
111
- // or redirect to the default-locale URL (import `redirect` from "@pracht/core"):
112
- // return redirect(`/${url.pathname.split("/").slice(2).join("/")}`, { request });
113
- }
114
- ```
115
-
116
- ### Cookie variant
111
+ ## Step 3: Detection middleware
117
112
 
118
113
  ```ts
119
- import type { MiddlewareFn } from "@pracht/core";
120
- import { defaultLocale, supportedLocales } from "../i18n";
121
-
122
- export const middleware: MiddlewareFn = ({ request }, next) => {
123
- const cookie = request.headers.get("cookie") ?? "";
124
- const m = cookie.match(/locale=([^;]+)/);
125
- const requested = m?.[1] ?? defaultLocale;
126
- const locale = (supportedLocales as readonly string[]).includes(requested) ? requested : defaultLocale;
127
- request.headers.set("x-locale", locale);
128
- return next();
129
- };
130
- ```
131
-
132
- ### Accept-Language variant
114
+ // src/middleware/i18n.ts
115
+ import { i18n } from "../i18n/index.ts";
133
116
 
134
- ```ts
135
- import type { MiddlewareFn } from "@pracht/core";
136
- import { defaultLocale, supportedLocales } from "../i18n";
137
-
138
- export const middleware: MiddlewareFn = ({ request }, next) => {
139
- const header = request.headers.get("accept-language") ?? "";
140
- const preferred = header.split(",").map(p => p.split(";")[0]?.trim().toLowerCase().slice(0, 2));
141
- const match = preferred.find(p => (supportedLocales as readonly string[]).includes(p));
142
- request.headers.set("x-locale", match ?? defaultLocale);
143
- return next();
144
- };
117
+ export const middleware = i18n.middleware;
145
118
  ```
146
119
 
147
- ## Step 5: Use in a loader
120
+ The middleware sets `context.locale` and persists URL-prefix choices in a
121
+ `SameSite=Lax` cookie — but only on per-request (SSR/SPA) routes: SSG/ISG
122
+ output is stored and replayed to every visitor, so the middleware never
123
+ attaches `Set-Cookie` there (a baked-in cookie would fail the prerender
124
+ build and block ISG revalidation). It also appends `Vary: Cookie` /
125
+ `Accept-Language` when those sources were consulted. Path-resolved SSR/SPA
126
+ responses vary on `Cookie` too, because the presence of their persistence
127
+ `Set-Cookie` depends on the incoming cookie; path-only SSG/ISG output stays
128
+ keyed solely by URL. Type the context once via the Register pattern:
129
+
130
+ Cookie configuration stays browser-valid: `SameSite=None` always forces
131
+ `Secure`, even if an explicit option attempts to disable it.
148
132
 
149
133
  ```ts
150
- import type { LoaderArgs } from "@pracht/core";
151
- import { t } from "../i18n";
134
+ // src/env.d.ts
135
+ import type { I18nRequestContext } from "@pracht/i18n";
152
136
 
153
- export async function loader({ request }: LoaderArgs) {
154
- const locale = request.headers.get("x-locale") ?? "en";
155
- return {
156
- locale,
157
- title: t(locale, "home.title"),
158
- subtitle: t(locale, "home.subtitle"),
159
- };
137
+ declare module "@pracht/core" {
138
+ interface Register {
139
+ context: I18nRequestContext<"en" | "fr">;
140
+ }
160
141
  }
161
142
  ```
162
143
 
163
- ## Step 6: Wire the manifest
144
+ Intersect with the existing registered context type if the app already has
145
+ one.
164
146
 
165
- For the URL-prefix strategy, the routes need to live under per-locale
166
- groups. Update `src/routes.ts`:
147
+ ## Step 4: Wire the manifest
148
+
149
+ ### Strategy A — locale-prefixed URLs
150
+
151
+ One `pathPrefix` group per locale — only registered locales produce URLs, so
152
+ `/zz/about` 404s instead of serving duplicate default-locale content (never
153
+ use a `/:locale` param route for this; it matches any first segment):
167
154
 
168
155
  ```ts
169
156
  import { defineApp, group, route } from "@pracht/core";
170
157
 
158
+ const localizedRoutes = [
159
+ route("/", "./routes/home.tsx", { render: "ssr" }),
160
+ route("/about", "./routes/about.tsx", { render: "ssr" }),
161
+ ];
162
+
171
163
  export const app = defineApp({
172
164
  middleware: { i18n: "./middleware/i18n.ts" },
173
165
  routes: [
174
166
  group({ middleware: ["i18n"] }, [
175
- route("/", "./routes/home.tsx", { id: "home-default" }),
176
- route("/:locale/", "./routes/home.tsx", { id: "home-localized" }),
177
- route("/:locale/about", "./routes/about.tsx"),
167
+ group({ pathPrefix: "/en" }, localizedRoutes),
168
+ group({ pathPrefix: "/fr" }, localizedRoutes),
169
+ route("/", "./routes/locale-redirect.tsx", { render: "ssr" }),
178
170
  ]),
179
171
  ],
180
172
  });
181
173
  ```
182
174
 
183
- For cookie / Accept-Language strategies, just add the middleware to the root
184
- group; no path changes.
175
+ Notes:
185
176
 
186
- This step restructures route paths (`/` `/:locale/...`), so route ids and
187
- generated types change `pracht typegen` in the verification step is
188
- mandatory, not optional.
177
+ - Reusing one `localizedRoutes` array is fine with auto-generated ids; if
178
+ the app sets explicit `id`s, each locale's copy needs unique ids.
179
+ - The unprefixed detector redirects using what the middleware resolved.
180
+ `return` the redirect — a *thrown* Response short-circuits past the
181
+ middleware chain, so the i18n middleware could not stamp
182
+ `Vary: Cookie, Accept-Language` on it (a shared cache could then replay
183
+ one visitor's locale redirect to everyone):
189
184
 
190
- ## Step 7: SEO touch-ups
185
+ ```ts
186
+ // src/routes/locale-redirect.tsx
187
+ import { redirect, type LoaderArgs } from "@pracht/core";
188
+ import { i18n } from "../i18n/index.ts";
191
189
 
192
- - Set `lang` in `head()` per route from the resolved locale.
193
- - For URL-prefix: emit `<link rel="alternate" hreflang="fr" href="...">`
194
- pairs in `head()` so search engines learn the locale graph.
195
- - Update sitemap (cross-reference with `audit-seo`) to include all
196
- per-locale URLs.
190
+ export async function loader({ context, request }: LoaderArgs) {
191
+ return redirect(i18n.localePath("/", context.locale), { request });
192
+ }
197
193
 
198
- ## Step 8: String extraction (optional)
194
+ export function Component() {
195
+ return null;
196
+ }
197
+ ```
198
+
199
+ - Route matching is exact: locale prefixes are lowercase URLs; build links
200
+ with `i18n.localePath()` so they always come out canonical. It resolves
201
+ literal and encoded dot segments before prefixing, so even a path assembled
202
+ from user input cannot escape the locale namespace during browser URL
203
+ normalization.
204
+ - If localized routes are SSG/ISG, their stored response cannot safely carry
205
+ a visitor-specific `Set-Cookie`. In a hydrated component shared by those
206
+ routes, persist the explicit prefix with
207
+ `useEffect(() => { i18n.setLocaleCookie(data.locale); }, [data.locale])` so the
208
+ SSR detector remembers it later. This is harmless on SSR pages. Without
209
+ JavaScript, remembering a prerendered visit requires SSR or platform edge
210
+ middleware before static asset serving.
211
+
212
+ ### Strategy B — one URL per page
213
+
214
+ Nothing about the routes changes: add the middleware to the group and skip
215
+ the prefix groups and the detector route entirely. Detection falls to the
216
+ cookie, then `Accept-Language`; the middleware adds
217
+ `Vary: Cookie, Accept-Language`, so keep those routes `ssr`/`spa`.
218
+
219
+ Because no URL prefix ever signals an explicit choice, the *switcher* writes
220
+ the cookie. Generate an API route (works with JavaScript disabled):
199
221
 
200
- First create `scripts/i18n-extract.mjs`, a script that:
222
+ ```ts
223
+ // src/api/locale.ts
224
+ import { redirect, type BaseRouteArgs } from "@pracht/core";
225
+ import { i18n } from "../i18n/index.ts";
226
+
227
+ function sameOriginPath(value: FormDataEntryValue | null, base: URL, fallback: string): string {
228
+ if (typeof value !== "string" || !value.startsWith("/")) return fallback;
229
+ try {
230
+ const target = new URL(value, base);
231
+ return target.origin === base.origin
232
+ ? `${target.pathname}${target.search}${target.hash}`
233
+ : fallback;
234
+ } catch {
235
+ return fallback;
236
+ }
237
+ }
201
238
 
202
- 1. Greps for `t(locale, "...")` calls.
203
- 2. Builds a key set.
204
- 3. Diffs against each `src/i18n/<locale>.ts`.
205
- 4. Reports missing keys per locale.
239
+ export async function POST({ request, url }: BaseRouteArgs) {
240
+ const form = await request.formData();
241
+ const locale = form.get("locale");
242
+ if (!i18n.isLocale(locale)) return new Response("Unknown locale", { status: 400 });
206
243
 
207
- Then run it:
244
+ // Parse `next` before trusting it: URL normalization can expose an origin.
245
+ const next = form.get("next");
246
+ const target = sameOriginPath(next, url, "/");
208
247
 
209
- ```bash
210
- node scripts/i18n-extract.mjs
248
+ const response = redirect(target, { request, status: 303 });
249
+ response.headers.append("set-cookie", i18n.localeCookie(locale, { url }));
250
+ return response;
251
+ }
211
252
  ```
212
253
 
213
- The output is a TODO list per locale, not auto-translation.
254
+ …and a `<Form method="post" action="/api/locale">` switcher with one
255
+ `<button name="locale" value={locale}>` per locale plus a hidden `next`
256
+ field carrying `useLocation().pathname + useLocation().search` so switching
257
+ does not drop the current query. Hydrated, `<Form>` uses the framework's
258
+ redirect handshake and re-runs the loader; without JavaScript the browser
259
+ follows the 303 normally.
260
+
261
+ For an instant switch with no request at all, `i18n.setLocaleCookie(locale)`
262
+ writes the same cookie from the browser and
263
+ `await dictionaries.load(locale)` swaps the dictionary in place — hold the
264
+ result in state, reset it when loader data changes, and set both
265
+ `document.documentElement.lang` and a localized `document.title` by hand
266
+ (`head()` already ran server-side).
267
+ Load the dictionary before writing the cookie, and guard concurrent lazy
268
+ loads with a monotonically increasing request id so only the latest successful
269
+ selection commits both the cookie and component state. Catch import failures
270
+ instead of leaving an unhandled event-handler rejection or a partially applied
271
+ locale choice. Invalidate that request id from a `useLayoutEffect` cleanup
272
+ keyed by loader messages so a loader-data change or unmount wins during commit;
273
+ a passive `useEffect` cleanup leaves time for a stale import to write its cookie.
274
+ Also increment the shared request id synchronously in the server switcher's
275
+ `<Form onSubmit>` and before any other navigation that can replace loader data.
276
+ Cleanup at commit cannot undo a stale cookie written while that transition was
277
+ still in flight.
278
+ `i18n.detectClient()` is the browser-side `detect()` if a client-only
279
+ surface needs to resolve the locale itself.
280
+
281
+ ## Step 5: Use in loaders and components
282
+
283
+ ```tsx
284
+ import type { HeadArgs, LoaderArgs, RouteComponentProps } from "@pracht/core";
285
+ import { t, tPlural } from "@pracht/i18n";
286
+ import { dictionaries, i18n } from "../i18n/index.ts";
287
+ import { useEffect } from "preact/hooks";
288
+
289
+ export async function loader({ context }: LoaderArgs) {
290
+ const messages = await dictionaries.load(context.locale);
291
+ return { locale: context.locale, messages };
292
+ }
293
+
294
+ export function head({ data, url }: HeadArgs<typeof loader>) {
295
+ return {
296
+ lang: data.locale,
297
+ title: t(data.messages, "home.title"),
298
+ link: i18n.hreflang(url.pathname, { origin: "https://example.com" }),
299
+ };
300
+ }
301
+
302
+ export function Component({ data }: RouteComponentProps<typeof loader>) {
303
+ // Required for SSG/ISG locale routes; harmless when SSR middleware already
304
+ // persisted the matching path locale.
305
+ useEffect(() => {
306
+ i18n.setLocaleCookie(data.locale);
307
+ }, [data.locale]);
308
+ return <h1>{t(data.messages, "home.title", { name: "Jovi" })}</h1>;
309
+ }
310
+ ```
311
+
312
+ `messages` is a plain serializable object, so the same `t()` calls work
313
+ after hydration and on client navigations. `hreflang()` emits one alternate
314
+ link per locale plus `x-default` pointing at the unprefixed detector; pass
315
+ the app's canonical origin. Relative previews remain current-origin because
316
+ `splitLocale()` always keeps the stripped pathname root-relative, and every
317
+ alternate preserves an input query/hash suffix. Under strategy B, omit the
318
+ `link` entry: there is no alternate URL to point at, so emitting hreflang
319
+ would be a lie.
320
+
321
+ ## Step 6: SEO touch-ups
322
+
323
+ - Set `lang` from the resolved locale in `head()` (as above).
324
+ - Keep the detector route SSR; locale-prefixed routes may be `ssg` or
325
+ `isg` — every prefixed URL is a real route, so each locale prerenders,
326
+ and the middleware skips cookie persistence on those routes so no
327
+ `Set-Cookie` lands in stored output. Persist the resolved path locale after
328
+ hydration when the SSR detector should remember it; without JavaScript,
329
+ use SSR or platform edge middleware. Keep `"path"` first in the detect order
330
+ for prerendered routes: cookie/header detection cannot run against a stored
331
+ document (prerender/ISG requests carry no cookies or `Accept-Language`), and
332
+ a route that *depends* on those sources gets `Vary: Cookie` and is refused by
333
+ the ISG cache.
334
+ - Prerendered `head()` runs against a placeholder request origin — pass the
335
+ app's canonical origin to `hreflang()` on SSG/ISG routes instead of
336
+ `url.origin`, or the alternates bake in `http://localhost`.
337
+ - Update the sitemap (cross-reference with `audit-seo`) to include all
338
+ per-locale URLs.
339
+ - Strategy B only: one URL means one indexed language (whatever the
340
+ crawler's `Accept-Language` resolves to). Say this out loud to the user;
341
+ if it matters, that is the argument for strategy A. Still set `lang`, and
342
+ leave sitemap entries as the single canonical URLs they already are.
214
343
 
215
- ## Step 9: Verify
344
+ ## Step 7: Verify
216
345
 
217
- - Step 6 changed route paths run `pracht typegen` to refresh the generated
218
- route types/`href()` helper. Add `pracht typegen --check` to CI so stale
219
- types fail the build.
346
+ - If step 4 changed route paths (strategy A) or added the API route, run
347
+ `pracht typegen` to refresh the generated route types/`href()` helper. Add
348
+ `pracht typegen --check` to CI so stale types fail the build.
220
349
  - Boot dev: `pracht dev`.
221
- - Visit `/` and the locale-prefixed variant; confirm content swaps.
222
- - Visit an unsupported prefix (e.g. `/zz/about`); confirm the middleware
223
- 404s or redirects rather than serving default-locale content.
350
+ - Strategy A: `curl -i` the unprefixed detector with `Accept-Language: fr`
351
+ (expect a 302 to `/fr/...`), with a `pracht_locale` cookie (cookie beats
352
+ header), and with garbage (`;q=`, unknown tags — expect the default
353
+ locale). Visit a locale-prefixed page; confirm translated content and the
354
+ hreflang links in the head. On SSR, confirm `Set-Cookie` on first visit and
355
+ `Vary: Cookie` whether or not the request cookie already matches. On SSG/ISG,
356
+ confirm the stored response has neither `Set-Cookie` nor a path-only `Vary`, hydration writes
357
+ the locale cookie, and then the unprefixed detector returns to that locale.
358
+ Visit an unsupported prefix (e.g. `/zz/about`); confirm it 404s.
359
+ - Strategy B: `curl -i` the page with `Accept-Language: fr` (expect French
360
+ content, `Vary: Cookie, Accept-Language`, no `Set-Cookie`) and with
361
+ `Cookie: pracht_locale=fr` while sending `Accept-Language: en` (cookie
362
+ wins). `curl -i -X POST` the switcher with `-d locale=fr` and an
363
+ `Origin` header matching the host (mutation API routes are
364
+ same-origin-checked): expect a 303 plus `Set-Cookie`. Post an unregistered
365
+ locale and an off-origin `next`; expect a 400 and a same-origin redirect.
366
+ In the browser, switch and confirm the URL never changes.
224
367
  - `pnpm test` and `pnpm e2e` still pass.
225
368
  - Run `pracht verify --json` and confirm no failures.
226
369
 
227
370
  ## Rules
228
371
 
229
- 1. The middleware sets a request header; loaders read it. Do not stash the
372
+ 1. The middleware sets `context.locale`; loaders read it. Do not stash the
230
373
  locale in module-level state — concurrent requests will collide.
231
- 2. Always include the default locale as the fallback in `t()`.
232
- 3. For SSG, only prerender the URL combinations that exist; provide
233
- `getStaticPaths` returning the locale × dynamic-param product.
234
- 4. Recommend `Intl.DateTimeFormat` and `Intl.NumberFormat` for formatting
235
- no library needed.
236
- 5. Never bundle every translation into the client. If translations grow
237
- large, split per-locale and import lazily in loaders.
374
+ 2. Only registered locales may ever reach paths, cookies, or hreflang.
375
+ `defineI18n`/`localePath` enforce this never bypass them with string
376
+ concatenation on user input. `localePath` also resolves dot segments before
377
+ adding the locale prefix. Accept-Language wildcard fallbacks are resolved
378
+ through the registered locale list, respect explicit `q=0` exclusions, and
379
+ neither lookup truncation nor best-fit fallback can bypass those exclusions.
380
+ Directly matched longer variants win before same-language best fit, which
381
+ never crosses conflicting script subtags. If the defensive header-length
382
+ limit cuts an entry in half, discard that entry rather than parsing it with
383
+ an implied quality of 1.
384
+ 3. For SSG, only prerender URL combinations that exist; provide
385
+ `getStaticPaths` returning the locale × dynamic-param product when a
386
+ localized route has dynamic segments.
387
+ 4. Recommend `Intl.DateTimeFormat` and `Intl.NumberFormat` with
388
+ `data.locale` for formatting — no library needed.
389
+ 5. Never bundle every translation into the client: `createDictionaries`
390
+ loaders are per-locale lazy imports resolved in loaders; keep them that
391
+ way.
392
+ 6. Never move an existing app's URLs without asking. Locale prefixes are a
393
+ strategy, not a requirement — if the user says their URLs are fixed,
394
+ strategy B is the answer, not a redirect table.
238
395
 
239
396
  $ARGUMENTS