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 +24 -6
- package/package.json +1 -1
- package/skills/add-i18n/SKILL.md +308 -151
- package/skills/audit-auth/SKILL.md +32 -2
- package/skills/audit-headers/SKILL.md +14 -5
- package/skills/audit-secrets/SKILL.md +7 -4
- package/skills/configure-isg/SKILL.md +37 -18
- package/skills/migrate-nextjs/SKILL.md +17 -7
- package/skills/pracht-debug/SKILL.md +1 -1
- package/skills/pracht-deploy/SKILL.md +227 -8
- package/skills/pracht-scaffold/SKILL.md +11 -1
- package/skills/pracht-test-api/SKILL.md +13 -19
- package/skills/pre-deploy/SKILL.md +92 -15
- package/skills/scaffold-tests/SKILL.md +34 -50
- package/skills/tune-render-mode/SKILL.md +4 -1
- package/src/index.js +583 -42
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
|
|
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
|
-
-
|
|
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
|
|
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
package/skills/add-i18n/SKILL.md
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: add-i18n
|
|
3
|
-
version:
|
|
3
|
+
version: 2.1.0
|
|
4
4
|
description: |
|
|
5
|
-
Wire internationalization into a pracht app
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
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",
|
|
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
|
|
25
|
-
|
|
26
|
-
`
|
|
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
|
|
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
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
locale (used as fallback in `t()`).
|
|
66
|
+
Install the package:
|
|
49
67
|
|
|
50
|
-
|
|
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
|
-
|
|
72
|
+
## Step 2: The i18n instance and dictionaries
|
|
64
73
|
|
|
65
74
|
```ts
|
|
66
|
-
|
|
67
|
-
import
|
|
75
|
+
// src/i18n/index.ts
|
|
76
|
+
import { createDictionaries, defineI18n } from "@pracht/i18n";
|
|
68
77
|
|
|
69
|
-
export const
|
|
70
|
-
|
|
71
|
-
|
|
78
|
+
export const i18n = defineI18n({
|
|
79
|
+
locales: ["en", "fr"],
|
|
80
|
+
defaultLocale: "en",
|
|
81
|
+
});
|
|
72
82
|
|
|
73
|
-
export type
|
|
74
|
-
export type TranslationKey = keyof typeof en;
|
|
83
|
+
export type AppLocale = (typeof i18n.locales)[number];
|
|
75
84
|
|
|
76
|
-
export
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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/
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
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
|
-
|
|
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
|
-
|
|
120
|
-
import {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
151
|
-
import {
|
|
134
|
+
// src/env.d.ts
|
|
135
|
+
import type { I18nRequestContext } from "@pracht/i18n";
|
|
152
136
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
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
|
-
|
|
144
|
+
Intersect with the existing registered context type if the app already has
|
|
145
|
+
one.
|
|
164
146
|
|
|
165
|
-
|
|
166
|
-
|
|
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
|
-
|
|
176
|
-
|
|
177
|
-
route("
|
|
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
|
-
|
|
184
|
-
group; no path changes.
|
|
175
|
+
Notes:
|
|
185
176
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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
|
-
|
|
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
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
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
|
-
|
|
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
|
-
|
|
210
|
-
|
|
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
|
-
|
|
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
|
|
344
|
+
## Step 7: Verify
|
|
216
345
|
|
|
217
|
-
-
|
|
218
|
-
route types/`href()` helper. Add
|
|
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
|
-
-
|
|
222
|
-
|
|
223
|
-
|
|
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
|
|
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.
|
|
232
|
-
|
|
233
|
-
`
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
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
|