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.
- package/package.json +1 -1
- package/skills/add-auth/SKILL.md +63 -143
- package/skills/add-capabilities/SKILL.md +409 -0
- package/skills/add-content/SKILL.md +242 -0
- package/skills/add-db/SKILL.md +93 -202
- package/skills/add-i18n/SKILL.md +178 -217
- package/skills/add-images/SKILL.md +203 -0
- package/skills/add-observability/SKILL.md +118 -15
- package/skills/add-openapi/SKILL.md +209 -0
- package/skills/audit-a11y/SKILL.md +8 -9
- package/skills/audit-agent-surface/SKILL.md +335 -0
- package/skills/audit-auth/SKILL.md +16 -11
- package/skills/audit-bundles/SKILL.md +56 -12
- package/skills/audit-csrf/SKILL.md +9 -10
- package/skills/audit-deps/SKILL.md +8 -8
- package/skills/audit-headers/SKILL.md +9 -10
- package/skills/audit-islands/SKILL.md +9 -10
- package/skills/audit-loaders/SKILL.md +23 -8
- package/skills/audit-redirects/SKILL.md +9 -10
- package/skills/audit-secrets/SKILL.md +6 -6
- package/skills/audit-seo/SKILL.md +8 -8
- package/skills/audit-shells/SKILL.md +8 -9
- package/skills/configure-isg/SKILL.md +9 -10
- package/skills/migrate-nextjs/SKILL.md +200 -415
- package/skills/pracht-debug/SKILL.md +165 -120
- package/skills/pracht-deploy/SKILL.md +248 -329
- package/skills/pracht-scaffold/SKILL.md +123 -146
- package/skills/pracht-test-api/SKILL.md +10 -10
- package/skills/pre-deploy/SKILL.md +166 -195
- package/skills/scaffold-e2e/SKILL.md +11 -12
- package/skills/scaffold-tests/SKILL.md +10 -12
- package/skills/tune-render-mode/SKILL.md +7 -8
- package/skills/typed-routes/SKILL.md +15 -11
- package/skills/upgrade-pracht/SKILL.md +12 -10
- package/src/index.js +43 -0
package/skills/add-i18n/SKILL.md
CHANGED
|
@@ -1,18 +1,13 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: add-i18n
|
|
3
|
-
version: 2.
|
|
3
|
+
version: 2.2.0
|
|
4
4
|
description: |
|
|
5
|
-
Wire
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
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
|
-
|
|
29
|
-
|
|
30
|
-
`
|
|
31
|
-
`
|
|
32
|
-
|
|
33
|
-
`examples/basic
|
|
34
|
-
`/nl/welcome`) and prefix-free (`/greeting`, `src/api/locale.ts`).
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
config
|
|
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
|
|
45
|
-
two
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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:
|
|
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
|
-
|
|
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
|
-
|
|
108
|
-
|
|
109
|
-
|
|
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
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
build and block ISG revalidation
|
|
125
|
-
|
|
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.
|
|
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
|
-
|
|
131
|
-
|
|
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
|
-
|
|
145
|
-
one.
|
|
125
|
+
## Step 4A: Manifest — locale-prefixed URLs
|
|
146
126
|
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
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
|
-
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
routes, persist the
|
|
207
|
-
`useEffect(() => { i18n.setLocaleCookie(data.locale); }, [data.locale])` so
|
|
208
|
-
SSR detector remembers it later
|
|
209
|
-
JavaScript, remembering a prerendered visit
|
|
210
|
-
middleware
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
Nothing about the routes changes: add the middleware to the group
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
Because no URL prefix ever signals an explicit choice, the
|
|
220
|
-
|
|
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
|
|
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
|
-
|
|
255
|
-
`<button name="locale" value={locale}>` per locale plus a hidden `next`
|
|
256
|
-
|
|
257
|
-
|
|
258
|
-
|
|
259
|
-
|
|
260
|
-
|
|
261
|
-
For an instant switch with no request
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
|
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
|
-
|
|
314
|
-
|
|
315
|
-
|
|
316
|
-
|
|
317
|
-
|
|
318
|
-
|
|
319
|
-
|
|
320
|
-
|
|
321
|
-
|
|
322
|
-
|
|
323
|
-
-
|
|
324
|
-
|
|
325
|
-
|
|
326
|
-
|
|
327
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
336
|
-
|
|
337
|
-
- Update the sitemap
|
|
338
|
-
|
|
339
|
-
-
|
|
340
|
-
|
|
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
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
-
|
|
350
|
-
|
|
351
|
-
(
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
`
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
`
|
|
362
|
-
|
|
363
|
-
`
|
|
364
|
-
same-origin
|
|
365
|
-
|
|
366
|
-
|
|
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.
|
|
373
|
-
locale in module-level state — concurrent requests
|
|
374
|
-
2. Only registered locales may
|
|
375
|
-
`defineI18n`/`localePath` enforce
|
|
376
|
-
concatenation on user input.
|
|
377
|
-
|
|
378
|
-
through the registered locale list,
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
never crosses conflicting script subtags
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
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
|