create-pracht 0.6.0 → 0.6.2

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.
Files changed (35) hide show
  1. package/package.json +1 -1
  2. package/skills/add-auth/SKILL.md +63 -143
  3. package/skills/add-capabilities/SKILL.md +409 -0
  4. package/skills/add-content/SKILL.md +242 -0
  5. package/skills/add-db/SKILL.md +93 -202
  6. package/skills/add-i18n/SKILL.md +178 -217
  7. package/skills/add-images/SKILL.md +203 -0
  8. package/skills/add-observability/SKILL.md +118 -15
  9. package/skills/add-openapi/SKILL.md +209 -0
  10. package/skills/audit-a11y/SKILL.md +8 -9
  11. package/skills/audit-agent-surface/SKILL.md +335 -0
  12. package/skills/audit-auth/SKILL.md +16 -11
  13. package/skills/audit-bundles/SKILL.md +56 -12
  14. package/skills/audit-csrf/SKILL.md +9 -10
  15. package/skills/audit-deps/SKILL.md +8 -8
  16. package/skills/audit-headers/SKILL.md +9 -10
  17. package/skills/audit-islands/SKILL.md +9 -10
  18. package/skills/audit-loaders/SKILL.md +23 -8
  19. package/skills/audit-redirects/SKILL.md +9 -10
  20. package/skills/audit-secrets/SKILL.md +6 -6
  21. package/skills/audit-seo/SKILL.md +8 -8
  22. package/skills/audit-shells/SKILL.md +8 -9
  23. package/skills/configure-isg/SKILL.md +9 -10
  24. package/skills/migrate-nextjs/SKILL.md +200 -415
  25. package/skills/pracht-debug/SKILL.md +165 -120
  26. package/skills/pracht-deploy/SKILL.md +248 -329
  27. package/skills/pracht-scaffold/SKILL.md +123 -146
  28. package/skills/pracht-test-api/SKILL.md +10 -10
  29. package/skills/pre-deploy/SKILL.md +166 -195
  30. package/skills/scaffold-e2e/SKILL.md +11 -12
  31. package/skills/scaffold-tests/SKILL.md +10 -12
  32. package/skills/tune-render-mode/SKILL.md +7 -8
  33. package/skills/typed-routes/SKILL.md +15 -11
  34. package/skills/upgrade-pracht/SKILL.md +12 -10
  35. package/src/index.js +43 -0
@@ -1,18 +1,13 @@
1
1
  ---
2
2
  name: add-i18n
3
- version: 2.1.0
3
+ version: 2.2.0
4
4
  description: |
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.
13
- Use when asked to "add i18n", "set up translations", "make my app
14
- multilingual", "add locale routing", "switch language without changing
15
- URLs", or "extract strings".
5
+ Wire `@pracht/i18n`: locale-detection middleware (URL prefix, cookie,
6
+ `Accept-Language`), lazy typed dictionaries, and either strategy
7
+ locale-prefixed route groups with hreflang, or one URL per page with a
8
+ cookie-backed switcher.
9
+ Use for "add i18n", "set up translations", "make my app multilingual", "add
10
+ locale routing", "switch language without changing URLs", "extract strings".
16
11
  allowed-tools:
17
12
  - Bash
18
13
  - Read
@@ -25,74 +20,60 @@ allowed-tools:
25
20
 
26
21
  # Pracht Add i18n
27
22
 
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.
23
+ `@pracht/i18n` ships locale-detection middleware, lazy dictionaries with keys
24
+ typed from the default locale, `t()`/`tPlural()` (plurals via
25
+ `Intl.PluralRules`), `localePath()`, and `hreflang()` for `head()`. Full guide:
26
+ `examples/docs/src/routes/docs/recipes-i18n.md` which also carries a
27
+ hand-rolled fallback recipe if the user refuses the dependency. Working setups
28
+ live in `examples/basic`, both locale-prefixed (`/welcome`, `/en/welcome`,
29
+ `/nl/welcome`) and prefix-free (`/greeting`, `src/api/locale.ts`).
36
30
 
37
- If the pracht MCP server is registered (see docs/MCP.md), prefer its tools
38
- (`inspect_routes`, `inspect_api`, `inspect_build`, `doctor`, `verify`,
39
- `generate_*`) over shelling out. Prerequisite: `pracht inspect` needs a vite
40
- config with the pracht plugin registered.
31
+ MCP: when the pracht MCP server is registered (docs/MCP.md), prefer its
32
+ `inspect_routes`/`inspect_api`/`inspect_build`/`doctor`/`verify`/`generate_*`
33
+ tools over shelling out. `pracht inspect` needs the pracht plugin in the vite
34
+ config.
41
35
 
42
36
  ## Step 1: Pick locales and a URL strategy
43
37
 
44
- Use `AskUserQuestion` once for: supported locales (default: `en` plus one or
45
- two more), the default locale, and the **URL strategy**:
38
+ Use `AskUserQuestion` once for the supported locales (default `en` plus one or
39
+ two), the default locale, and the URL strategy:
46
40
 
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.
41
+ | | **A. Locale-prefixed** (`/en/about`) | **B. One URL per page** (`/about`) |
42
+ | --- | --- | --- |
43
+ | Use when | Public, indexable content | URLs cannot move, or the app is behind a login |
44
+ | Locale from | The path | The cookie switching needs no navigation |
45
+ | Render modes | Any, including `ssg`/`isg` | `ssr`/`spa` only (`Vary: Cookie, Accept-Language`) |
46
+ | hreflang | Works | Impossible one URL cannot carry alternates |
47
+ | Cost | Changes every URL | One indexed language |
56
48
 
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.
60
-
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.
65
-
66
- Install the package:
49
+ Never migrate a live app's URLs without saying so explicitly. Both strategies
50
+ can coexist in one app. Keep the default detection order
51
+ `["path", "cookie", "header"]` either way — the path source simply never
52
+ matches a prefix-free route — and change it only for an explicit cookie-only
53
+ or header-only request.
67
54
 
68
55
  ```bash
69
56
  npm install @pracht/i18n
70
57
  ```
71
58
 
72
- ## Step 2: The i18n instance and dictionaries
59
+ ## Step 2: Instance and dictionaries
73
60
 
74
61
  ```ts
75
62
  // src/i18n/index.ts
76
63
  import { createDictionaries, defineI18n } from "@pracht/i18n";
77
64
 
78
- export const i18n = defineI18n({
79
- locales: ["en", "fr"],
80
- defaultLocale: "en",
81
- });
65
+ export const i18n = defineI18n({ locales: ["en", "fr"], defaultLocale: "en" });
82
66
 
83
67
  export type AppLocale = (typeof i18n.locales)[number];
84
68
 
85
69
  export const dictionaries = createDictionaries(
86
- {
87
- en: () => import("./locales/en.ts"),
88
- fr: () => import("./locales/fr.ts"),
89
- },
70
+ { en: () => import("./locales/en.ts"), fr: () => import("./locales/fr.ts") },
90
71
  { defaultLocale: "en" },
91
72
  );
92
73
  ```
93
74
 
94
- One dictionary module per locale — flat string keys, default export,
95
- `as const` so key typing works:
75
+ One dictionary module per locale — flat string keys, default export, `as const`
76
+ so key typing works:
96
77
 
97
78
  ```ts
98
79
  // src/i18n/locales/en.ts
@@ -103,10 +84,10 @@ export default {
103
84
  } as const;
104
85
  ```
105
86
 
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.
87
+ Plural keys declare one entry per `Intl.PluralRules` category the locale needs
88
+ (`.one`, `.other`, plus `.few`/`.many` for e.g. Polish); `tPlural()` falls back
89
+ to `.other`. Non-default locales may omit keys — `load()` merges the default
90
+ locale underneath.
110
91
 
111
92
  ## Step 3: Detection middleware
112
93
 
@@ -117,18 +98,18 @@ import { i18n } from "../i18n/index.ts";
117
98
  export const middleware = i18n.middleware;
118
99
  ```
119
100
 
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
101
+ It sets `context.locale`, persists URL-prefix choices in a `SameSite=Lax`
102
+ cookie, and appends `Vary: Cookie` / `Accept-Language` for whichever sources it
103
+ consulted. Persistence happens only on per-request (SSR/SPA) routes: SSG/ISG
104
+ output is stored and replayed to every visitor, so a baked-in `Set-Cookie`
105
+ would fail the prerender build and block ISG revalidation. Path-resolved
106
+ SSR/SPA responses still vary on `Cookie`, because whether they carry that
127
107
  `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:
108
+ keyed solely by URL. Cookie config stays browser-valid `SameSite=None` always
109
+ forces `Secure`, even if an option tries to disable it.
129
110
 
130
- Cookie configuration stays browser-valid: `SameSite=None` always forces
131
- `Secure`, even if an explicit option attempts to disable it.
111
+ Type the context once via the Register pattern, intersecting with the app's
112
+ existing registered context type if it has one:
132
113
 
133
114
  ```ts
134
115
  // src/env.d.ts
@@ -141,16 +122,11 @@ declare module "@pracht/core" {
141
122
  }
142
123
  ```
143
124
 
144
- Intersect with the existing registered context type if the app already has
145
- one.
125
+ ## Step 4A: Manifest locale-prefixed URLs
146
126
 
147
- ## Step 4: Wire the manifest
148
-
149
- ### Strategy Alocale-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):
127
+ One `pathPrefix` group per locale, so only registered locales produce URLs and
128
+ `/zz/about` 404s instead of serving duplicate default-locale content. Never use
129
+ a `/:locale` param route for this it matches any first segment.
154
130
 
155
131
  ```ts
156
132
  import { defineApp, group, route } from "@pracht/core";
@@ -172,15 +148,10 @@ export const app = defineApp({
172
148
  });
173
149
  ```
174
150
 
175
- Notes:
176
-
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):
151
+ The unprefixed detector redirects using what the middleware resolved. **`return`
152
+ the redirect** — a *thrown* Response short-circuits past the middleware chain,
153
+ so the i18n middleware could not stamp `Vary: Cookie, Accept-Language` on it,
154
+ and a shared cache could replay one visitor's locale redirect to everyone:
184
155
 
185
156
  ```ts
186
157
  // src/routes/locale-redirect.tsx
@@ -196,28 +167,28 @@ export function Component() {
196
167
  }
197
168
  ```
198
169
 
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):
170
+ - Reusing one `localizedRoutes` array is fine with auto-generated ids; with
171
+ explicit `id`s, each locale's copy needs unique ones.
172
+ - Route matching is exact and locale prefixes are lowercase build links with
173
+ `i18n.localePath()` so they come out canonical. It resolves literal and
174
+ encoded dot segments before prefixing, so a path assembled from user input
175
+ cannot escape the locale namespace during browser URL normalization.
176
+ - SSG/ISG localized routes cannot carry a visitor-specific `Set-Cookie`. In a
177
+ hydrated component shared by those routes, persist the prefix with
178
+ `useEffect(() => { i18n.setLocaleCookie(data.locale); }, [data.locale])` so
179
+ the SSR detector remembers it later (harmless on SSR pages). Without
180
+ JavaScript, remembering a prerendered visit needs SSR or platform edge
181
+ middleware ahead of static asset serving.
182
+
183
+ ## Step 4B: Manifest — one URL per page
184
+
185
+ Nothing about the routes changes: add the middleware to the group, skip the
186
+ prefix groups and the detector route. Detection falls to the cookie, then
187
+ `Accept-Language`, and the `Vary: Cookie, Accept-Language` header keeps those
188
+ routes `ssr`/`spa`.
189
+
190
+ Because no URL prefix ever signals an explicit choice, the switcher writes the
191
+ cookie. Generate an API route so it works with JavaScript disabled:
221
192
 
222
193
  ```ts
223
194
  // src/api/locale.ts
@@ -242,8 +213,7 @@ export async function POST({ request, url }: BaseRouteArgs) {
242
213
  if (!i18n.isLocale(locale)) return new Response("Unknown locale", { status: 400 });
243
214
 
244
215
  // Parse `next` before trusting it: URL normalization can expose an origin.
245
- const next = form.get("next");
246
- const target = sameOriginPath(next, url, "/");
216
+ const target = sameOriginPath(form.get("next"), url, "/");
247
217
 
248
218
  const response = redirect(target, { request, status: 303 });
249
219
  response.headers.append("set-cookie", i18n.localeCookie(locale, { url }));
@@ -251,38 +221,40 @@ export async function POST({ request, url }: BaseRouteArgs) {
251
221
  }
252
222
  ```
253
223
 
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
224
+ Pair it with a `<Form method="post" action="/api/locale">` switcher: one
225
+ `<button name="locale" value={locale}>` per locale plus a hidden `next` field
226
+ carrying `useLocation().pathname + useLocation().search`, so switching does not
227
+ drop the current query. Hydrated, `<Form>` uses the framework's redirect
228
+ handshake and re-runs the loader; without JavaScript the browser follows the
229
+ 303.
230
+
231
+ For an instant switch with no request, `i18n.setLocaleCookie(locale)` writes the
232
+ same cookie from the browser and `await dictionaries.load(locale)` swaps the
233
+ dictionary in place. That path is easy to get wrong — hold the dictionary in
234
+ state, reset it when loader data changes, and set `document.documentElement.lang`
235
+ and a localized `document.title` by hand (`head()` already ran server-side).
236
+ Then:
237
+
238
+ - Load the dictionary *before* writing the cookie.
239
+ - Guard concurrent lazy loads with a monotonically increasing request id so
240
+ only the latest successful selection commits both cookie and state.
241
+ - Catch import failures rather than leaving an unhandled event-handler
242
+ rejection or a half-applied locale.
243
+ - Invalidate that request id from a `useLayoutEffect` cleanup keyed by loader
244
+ messages, so a loader-data change or unmount wins during commit; a passive
245
+ `useEffect` cleanup leaves time for a stale import to write its cookie.
246
+ - Increment the shared request id synchronously in the server switcher's
247
+ `<Form onSubmit>` and before any other navigation that can replace loader
248
+ data cleanup at commit cannot undo a stale cookie written mid-transition.
249
+
250
+ `i18n.detectClient()` is the browser-side `detect()` when a client-only surface
251
+ must resolve the locale itself.
252
+
253
+ ## Step 5: Loaders and components
282
254
 
283
255
  ```tsx
284
256
  import type { HeadArgs, LoaderArgs, RouteComponentProps } from "@pracht/core";
285
- import { t, tPlural } from "@pracht/i18n";
257
+ import { t } from "@pracht/i18n";
286
258
  import { dictionaries, i18n } from "../i18n/index.ts";
287
259
  import { useEffect } from "preact/hooks";
288
260
 
@@ -309,88 +281,77 @@ export function Component({ data }: RouteComponentProps<typeof loader>) {
309
281
  }
310
282
  ```
311
283
 
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.
284
+ `messages` is a plain serializable object, so the same `t()` calls work after
285
+ hydration and on client navigations. `hreflang()` emits one alternate per
286
+ locale plus `x-default` pointing at the unprefixed detector pass the app's
287
+ canonical origin. Relative previews stay current-origin because `splitLocale()`
288
+ keeps the stripped pathname root-relative, and every alternate preserves an
289
+ input query/hash suffix. Under strategy B, omit the `link` entry entirely:
290
+ there is no alternate URL, so emitting hreflang would be a lie.
291
+
292
+ ## Step 6: SEO
293
+
294
+ - Set `lang` from the resolved locale in `head()`.
295
+ - Keep the detector route SSR. Locale-prefixed routes may be `ssg`/`isg`
296
+ every prefixed URL is a real route, and the middleware skips cookie
297
+ persistence there so no `Set-Cookie` lands in stored output. Keep `"path"`
298
+ first in the detect order for prerendered routes: cookie/header detection
299
+ cannot run against a stored document (prerender/ISG requests carry no cookies
300
+ or `Accept-Language`), and a route that *depends* on those sources gets
301
+ `Vary: Cookie` and is refused by the ISG cache.
334
302
  - 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.
303
+ canonical origin to `hreflang()` on SSG/ISG routes instead of `url.origin`,
304
+ or the alternates bake in `http://localhost`.
305
+ - Update the sitemap to include every per-locale URL (see `/audit-seo`).
306
+ - Strategy B only: one URL means one indexed language, whatever the crawler's
307
+ `Accept-Language` resolves to. Say that out loud if it matters, that is the
308
+ argument for strategy A. Still set `lang`; sitemap entries stay as-is.
343
309
 
344
310
  ## Step 7: Verify
345
311
 
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.
349
- - Boot dev: `pracht dev`.
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.
367
- - `pnpm test` and `pnpm e2e` still pass.
368
- - Run `pracht verify --json` and confirm no failures.
312
+ Run `pracht typegen` if step 4 changed route paths or added the API route, and
313
+ add `pracht typegen --check` to CI. Boot `pracht dev`, then:
314
+
315
+ - **Strategy A.** `curl -i` the unprefixed detector with `Accept-Language: fr`
316
+ (302 to `/fr/...`), with a `pracht_locale` cookie (cookie beats header), and
317
+ with garbage (`;q=`, unknown tags default locale). On a locale-prefixed
318
+ page, confirm translated content and hreflang links. On SSR, confirm
319
+ `Set-Cookie` on first visit and `Vary: Cookie` whether or not the request
320
+ cookie already matches. On SSG/ISG, confirm the stored response has neither
321
+ `Set-Cookie` nor a path-only `Vary`, that hydration writes the locale cookie,
322
+ and that the unprefixed detector then returns to that locale. `/zz/about`
323
+ must 404.
324
+ - **Strategy B.** `curl -i` the page with `Accept-Language: fr` (French
325
+ content, `Vary: Cookie, Accept-Language`, no `Set-Cookie`), and with
326
+ `Cookie: pracht_locale=fr` plus `Accept-Language: en` (cookie wins).
327
+ `curl -i -X POST` the switcher with `-d locale=fr` and an `Origin` header
328
+ matching the host (mutation API routes are same-origin-checked): expect 303 +
329
+ `Set-Cookie`. Post an unregistered locale and an off-origin `next`: expect
330
+ 400 and a same-origin redirect. In the browser, switch and confirm the URL
331
+ never changes.
332
+ - `pnpm test`, `pnpm e2e`, and `pracht verify --json` all pass.
369
333
 
370
334
  ## Rules
371
335
 
372
- 1. The middleware sets `context.locale`; loaders read it. Do not stash the
373
- locale in module-level state — concurrent requests will collide.
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.
336
+ 1. The middleware sets `context.locale`; loaders read it. Never stash the
337
+ locale in module-level state — concurrent requests collide.
338
+ 2. Only registered locales may reach paths, cookies, or hreflang.
339
+ `defineI18n`/`localePath` enforce that; never bypass them with string
340
+ concatenation on user input.
341
+ 3. `Accept-Language` handling is already conservative and must stay that way:
342
+ wildcard fallbacks resolve through the registered locale list, explicit
343
+ `q=0` exclusions hold against both lookup truncation and best-fit fallback,
344
+ directly matched longer variants win before same-language best fit (which
345
+ never crosses conflicting script subtags), and an entry cut in half by the
346
+ defensive header-length limit is discarded rather than parsed with an
347
+ implied quality of 1.
348
+ 4. For SSG with dynamic segments, `getStaticPaths` must return the locale ×
349
+ dynamic-param product prerender only URL combinations that exist.
350
+ 5. Format dates and numbers with `Intl.DateTimeFormat`/`Intl.NumberFormat` and
351
+ `data.locale`; no library needed.
352
+ 6. Never bundle every translation into the client. `createDictionaries` loaders
353
+ are per-locale lazy imports resolved in loaders keep them that way.
354
+ 7. Never move an existing app's URLs without asking. If the user says their
355
+ URLs are fixed, strategy B is the answer, not a redirect table.
395
356
 
396
357
  $ARGUMENTS