create-pracht 0.2.6 → 0.4.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 +38 -0
- package/package.json +3 -2
- package/skills/add-auth/SKILL.md +346 -0
- package/skills/add-db/SKILL.md +316 -0
- package/skills/add-i18n/SKILL.md +239 -0
- package/skills/add-observability/SKILL.md +270 -0
- package/skills/audit-a11y/SKILL.md +170 -0
- package/skills/audit-auth/SKILL.md +144 -0
- package/skills/audit-bundles/SKILL.md +167 -0
- package/skills/audit-csrf/SKILL.md +179 -0
- package/skills/audit-deps/SKILL.md +138 -0
- package/skills/audit-headers/SKILL.md +186 -0
- package/skills/audit-islands/SKILL.md +144 -0
- package/skills/audit-loaders/SKILL.md +135 -0
- package/skills/audit-redirects/SKILL.md +146 -0
- package/skills/audit-secrets/SKILL.md +150 -0
- package/skills/audit-seo/SKILL.md +164 -0
- package/skills/audit-shells/SKILL.md +142 -0
- package/skills/configure-isg/SKILL.md +172 -0
- package/skills/migrate-nextjs/SKILL.md +519 -0
- package/skills/pracht-debug/SKILL.md +146 -0
- package/skills/pracht-deploy/SKILL.md +208 -0
- package/skills/pracht-scaffold/SKILL.md +191 -0
- package/skills/pracht-test-api/SKILL.md +165 -0
- package/skills/pre-deploy/SKILL.md +164 -0
- package/skills/scaffold-e2e/SKILL.md +189 -0
- package/skills/scaffold-tests/SKILL.md +283 -0
- package/skills/tune-render-mode/SKILL.md +168 -0
- package/skills/typed-routes/SKILL.md +203 -0
- package/skills/upgrade-pracht/SKILL.md +151 -0
- package/src/index.js +472 -31
|
@@ -0,0 +1,316 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: add-db
|
|
3
|
+
version: 1.1.0
|
|
4
|
+
description: |
|
|
5
|
+
Wire Drizzle ORM into a pracht app. Asks the user which database to target
|
|
6
|
+
(Cloudflare D1, PlanetScale, Neon, Supabase, Turso, Postgres, MySQL, SQLite,
|
|
7
|
+
...) and generates the matching driver setup, schema scaffold, migration
|
|
8
|
+
workflow, and a typed client accessible from loaders, middleware, and API
|
|
9
|
+
routes.
|
|
10
|
+
Use when asked to "add database", "set up Drizzle", "wire D1",
|
|
11
|
+
"add Postgres", "set up an ORM", or "I need a DB".
|
|
12
|
+
allowed-tools:
|
|
13
|
+
- Bash
|
|
14
|
+
- Read
|
|
15
|
+
- Write
|
|
16
|
+
- Edit
|
|
17
|
+
- Grep
|
|
18
|
+
- Glob
|
|
19
|
+
- AskUserQuestion
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# Pracht Add Database (Drizzle)
|
|
23
|
+
|
|
24
|
+
Drizzle works well in pracht because it is small, type-safe, and runs in
|
|
25
|
+
both Node and edge runtimes (Cloudflare Workers, Vercel Edge). This skill
|
|
26
|
+
sets up the driver, schema directory, migration tooling, and a client
|
|
27
|
+
factory wired to the project's adapter.
|
|
28
|
+
|
|
29
|
+
## Step 1: Pick the target
|
|
30
|
+
|
|
31
|
+
Use `AskUserQuestion`:
|
|
32
|
+
|
|
33
|
+
| Provider | Driver | Adapter notes |
|
|
34
|
+
| ------------------ | ---------------------------------------- | ---------------------------- |
|
|
35
|
+
| Cloudflare D1 | `drizzle-orm/d1` | Workers binding |
|
|
36
|
+
| Cloudflare Hyperdrive (Postgres) | `drizzle-orm/postgres-js` or `node-postgres` | Workers binding |
|
|
37
|
+
| PlanetScale | `drizzle-orm/planetscale-serverless` | Works on Node + edge |
|
|
38
|
+
| Neon (Postgres) | `drizzle-orm/neon-serverless` or `neon-http` | Works on Node + edge |
|
|
39
|
+
| Supabase Postgres | `drizzle-orm/postgres-js` | Node + edge (HTTP variant) |
|
|
40
|
+
| Turso (libSQL) | `drizzle-orm/libsql` | Node + edge |
|
|
41
|
+
| Vanilla Postgres | `drizzle-orm/node-postgres` | Node only |
|
|
42
|
+
| Vanilla MySQL | `drizzle-orm/mysql2` | Node only |
|
|
43
|
+
| SQLite (better-sqlite3) | `drizzle-orm/better-sqlite3` | Node only |
|
|
44
|
+
|
|
45
|
+
If the pracht MCP server is registered (see docs/MCP.md), prefer its tools
|
|
46
|
+
(`inspect_routes`, `inspect_api`, `inspect_build`, `doctor`, `verify`,
|
|
47
|
+
`generate_*`) over shelling out. Prerequisites: `pracht inspect` needs a vite
|
|
48
|
+
config with the pracht plugin; `pracht inspect build` reads artifacts from a
|
|
49
|
+
prior `pracht build`.
|
|
50
|
+
|
|
51
|
+
Cross-check with the project's pracht adapter (`pracht inspect build --json`):
|
|
52
|
+
flag mismatches (e.g., `node-postgres` on Cloudflare Workers — won't work).
|
|
53
|
+
|
|
54
|
+
## Step 2: Install
|
|
55
|
+
|
|
56
|
+
```bash
|
|
57
|
+
pnpm add drizzle-orm <driver>
|
|
58
|
+
pnpm add -D drizzle-kit
|
|
59
|
+
```
|
|
60
|
+
|
|
61
|
+
Specific drivers:
|
|
62
|
+
|
|
63
|
+
- D1: no additional package; uses the Workers binding.
|
|
64
|
+
- PlanetScale: `pnpm add @planetscale/database`.
|
|
65
|
+
- Neon: `pnpm add @neondatabase/serverless`.
|
|
66
|
+
- Postgres / Supabase: `pnpm add postgres` (postgres-js).
|
|
67
|
+
- Turso: `pnpm add @libsql/client`.
|
|
68
|
+
- node-postgres: `pnpm add pg && pnpm add -D @types/pg`.
|
|
69
|
+
- mysql2: `pnpm add mysql2`.
|
|
70
|
+
- better-sqlite3: `pnpm add better-sqlite3 && pnpm add -D @types/better-sqlite3`.
|
|
71
|
+
|
|
72
|
+
## Step 3: Schema directory
|
|
73
|
+
|
|
74
|
+
`src/db/schema.ts`:
|
|
75
|
+
|
|
76
|
+
```ts
|
|
77
|
+
// Postgres example — substitute sqliteTable / mysqlTable for other dialects.
|
|
78
|
+
import { pgTable, serial, text, timestamp } from "drizzle-orm/pg-core";
|
|
79
|
+
|
|
80
|
+
export const users = pgTable("users", {
|
|
81
|
+
id: serial("id").primaryKey(),
|
|
82
|
+
email: text("email").notNull().unique(),
|
|
83
|
+
createdAt: timestamp("created_at").defaultNow().notNull(),
|
|
84
|
+
});
|
|
85
|
+
```
|
|
86
|
+
|
|
87
|
+
For D1/SQLite, use `sqliteTable` from `drizzle-orm/sqlite-core`. For MySQL,
|
|
88
|
+
use `mysqlTable` from `drizzle-orm/mysql-core`.
|
|
89
|
+
|
|
90
|
+
## Step 4: Client factory
|
|
91
|
+
|
|
92
|
+
`src/db/client.ts`:
|
|
93
|
+
|
|
94
|
+
```ts
|
|
95
|
+
// Example for Postgres on Node:
|
|
96
|
+
import { serverEnv } from "@pracht/core/env/server";
|
|
97
|
+
import { drizzle } from "drizzle-orm/node-postgres";
|
|
98
|
+
import { Pool } from "pg";
|
|
99
|
+
import * as schema from "./schema";
|
|
100
|
+
|
|
101
|
+
const pool = new Pool({ connectionString: serverEnv.DATABASE_URL });
|
|
102
|
+
export const db = drizzle(pool, { schema });
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
Read the connection string via `serverEnv` (from `@pracht/core/env/server`),
|
|
106
|
+
never `process.env` — it keeps the secret out of the client bundle and
|
|
107
|
+
resolves per adapter (see docs/ENV.md). The module-level singleton above is
|
|
108
|
+
fine on the Node adapter, where `serverEnv` works at module top level; on
|
|
109
|
+
Cloudflare/Vercel Edge, read `serverEnv` inside a factory function instead —
|
|
110
|
+
Workers env bindings only exist per request.
|
|
111
|
+
|
|
112
|
+
For Cloudflare D1, first register the Cloudflare context type once via the
|
|
113
|
+
`Register` augmentation (the pattern the docs recommend — see
|
|
114
|
+
`examples/docs/src/routes/docs/recipes-fullstack-cloudflare.md`):
|
|
115
|
+
|
|
116
|
+
```ts
|
|
117
|
+
// src/env.d.ts
|
|
118
|
+
declare module "@pracht/core" {
|
|
119
|
+
interface Register {
|
|
120
|
+
context: {
|
|
121
|
+
env: Env; // wrangler-generated bindings type, includes DB: D1Database
|
|
122
|
+
executionContext: ExecutionContext;
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
```
|
|
127
|
+
|
|
128
|
+
Then the factory needs no per-file generics:
|
|
129
|
+
|
|
130
|
+
```ts
|
|
131
|
+
import { drizzle } from "drizzle-orm/d1";
|
|
132
|
+
import * as schema from "./schema";
|
|
133
|
+
import type { LoaderArgs } from "@pracht/core";
|
|
134
|
+
|
|
135
|
+
export function getDb({ context }: Pick<LoaderArgs, "context">) {
|
|
136
|
+
return drizzle(context.env.DB, { schema });
|
|
137
|
+
}
|
|
138
|
+
```
|
|
139
|
+
|
|
140
|
+
(Without the `Register` augmentation, the inline generic must describe the
|
|
141
|
+
full Cloudflare context shape —
|
|
142
|
+
`LoaderArgs<{ env: { DB: D1Database }; executionContext: ExecutionContext }>` —
|
|
143
|
+
the context is `{ env, executionContext }`, not the bindings object itself.)
|
|
144
|
+
|
|
145
|
+
For PlanetScale / Neon / Turso, follow the matching driver pattern. The
|
|
146
|
+
pattern is:
|
|
147
|
+
|
|
148
|
+
- **Node + persistent process**: module-level singleton.
|
|
149
|
+
- **Edge + per-request context (Cloudflare/Vercel Edge)**: factory called
|
|
150
|
+
with `context` inside the loader.
|
|
151
|
+
|
|
152
|
+
## Step 5: `drizzle.config.ts`
|
|
153
|
+
|
|
154
|
+
If `drizzle.config.ts` already exists, diff and merge — never overwrite.
|
|
155
|
+
(`process.env` is fine here: this file runs under the drizzle-kit CLI on
|
|
156
|
+
Node, never inside the worker.)
|
|
157
|
+
|
|
158
|
+
### Non-D1 providers (Postgres, MySQL, Turso, PlanetScale, Neon, local SQLite)
|
|
159
|
+
|
|
160
|
+
```ts
|
|
161
|
+
import { defineConfig } from "drizzle-kit";
|
|
162
|
+
|
|
163
|
+
export default defineConfig({
|
|
164
|
+
schema: "./src/db/schema.ts",
|
|
165
|
+
out: "./drizzle/migrations",
|
|
166
|
+
dialect: "postgresql", // or "sqlite" / "mysql"
|
|
167
|
+
dbCredentials: {
|
|
168
|
+
url: process.env.DATABASE_URL!,
|
|
169
|
+
},
|
|
170
|
+
});
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### Cloudflare D1
|
|
174
|
+
|
|
175
|
+
D1 has no TCP endpoint, so drizzle-kit can only *generate* migrations. It
|
|
176
|
+
cannot apply them — applying goes through `wrangler` (Step 6):
|
|
177
|
+
|
|
178
|
+
```ts
|
|
179
|
+
import { defineConfig } from "drizzle-kit";
|
|
180
|
+
|
|
181
|
+
export default defineConfig({
|
|
182
|
+
schema: "./src/db/schema.ts",
|
|
183
|
+
out: "./drizzle/migrations",
|
|
184
|
+
dialect: "sqlite",
|
|
185
|
+
});
|
|
186
|
+
```
|
|
187
|
+
|
|
188
|
+
If you want `drizzle-kit studio` against D1, add a `driver: "d1-http"` block
|
|
189
|
+
with Cloudflare account/database/API-token credentials (see Drizzle's D1
|
|
190
|
+
docs). Otherwise omit `dbCredentials` entirely — `drizzle-kit generate`
|
|
191
|
+
doesn't need them.
|
|
192
|
+
|
|
193
|
+
## Step 6: Scripts
|
|
194
|
+
|
|
195
|
+
Merge these into the existing `package.json` `scripts` block — never
|
|
196
|
+
overwrite scripts that already exist; diff and ask if one collides.
|
|
197
|
+
|
|
198
|
+
### Non-D1 providers
|
|
199
|
+
|
|
200
|
+
```json
|
|
201
|
+
{
|
|
202
|
+
"scripts": {
|
|
203
|
+
"db:generate": "drizzle-kit generate",
|
|
204
|
+
"db:migrate": "drizzle-kit migrate",
|
|
205
|
+
"db:push": "drizzle-kit push",
|
|
206
|
+
"db:studio": "drizzle-kit studio"
|
|
207
|
+
}
|
|
208
|
+
}
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### Cloudflare D1
|
|
212
|
+
|
|
213
|
+
`drizzle-kit migrate` does not work against D1 (no TCP). Apply migrations
|
|
214
|
+
via `wrangler d1 migrations apply <db-name>`, split into local vs remote so
|
|
215
|
+
you can iterate safely against the miniflare D1 before touching production:
|
|
216
|
+
|
|
217
|
+
```json
|
|
218
|
+
{
|
|
219
|
+
"scripts": {
|
|
220
|
+
"db:generate": "drizzle-kit generate",
|
|
221
|
+
"db:migrate:local": "wrangler d1 migrations apply <db-name> --local",
|
|
222
|
+
"db:migrate:remote": "wrangler d1 migrations apply <db-name> --remote",
|
|
223
|
+
"db:studio": "drizzle-kit studio"
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
```
|
|
227
|
+
|
|
228
|
+
Replace `<db-name>` with the `database_name` from `wrangler.toml`/`.jsonc`.
|
|
229
|
+
Omit `db:push` for D1 — the migrations-apply flow is the only supported
|
|
230
|
+
path.
|
|
231
|
+
|
|
232
|
+
## Step 7: Use in a loader
|
|
233
|
+
|
|
234
|
+
Demonstrate the wired-up usage:
|
|
235
|
+
|
|
236
|
+
```ts
|
|
237
|
+
import type { LoaderArgs } from "@pracht/core";
|
|
238
|
+
import { db } from "../db/client"; // or getDb(args) on edge runtimes
|
|
239
|
+
import { users } from "../db/schema";
|
|
240
|
+
|
|
241
|
+
export async function loader(_args: LoaderArgs) {
|
|
242
|
+
const rows = await db.select().from(users).limit(20);
|
|
243
|
+
return { users: rows.map(u => ({ id: u.id, email: u.email })) };
|
|
244
|
+
}
|
|
245
|
+
```
|
|
246
|
+
|
|
247
|
+
Note: explicit projection — never spread DB rows into loader return values
|
|
248
|
+
(see `audit-secrets`).
|
|
249
|
+
|
|
250
|
+
## Step 8: Bindings & env vars
|
|
251
|
+
|
|
252
|
+
- For Cloudflare adapters with D1: add the binding to `wrangler.toml` (or
|
|
253
|
+
`wrangler.jsonc`). If the file already exists, diff and merge the binding
|
|
254
|
+
in — never overwrite the existing config. `migrations_dir` must match the
|
|
255
|
+
`out` in `drizzle.config.ts` so wrangler finds the SQL drizzle-kit emits:
|
|
256
|
+
```toml
|
|
257
|
+
[[d1_databases]]
|
|
258
|
+
binding = "DB"
|
|
259
|
+
database_name = "my-app"
|
|
260
|
+
database_id = "<id>"
|
|
261
|
+
migrations_dir = "drizzle/migrations"
|
|
262
|
+
```
|
|
263
|
+
- For Node/Vercel: document `DATABASE_URL` in `.env.example`. Add `.env*` to
|
|
264
|
+
`.gitignore` if missing.
|
|
265
|
+
|
|
266
|
+
## Step 9: Verify
|
|
267
|
+
|
|
268
|
+
Non-D1:
|
|
269
|
+
|
|
270
|
+
```bash
|
|
271
|
+
pnpm db:generate
|
|
272
|
+
pnpm db:push # or db:migrate after creating one
|
|
273
|
+
```
|
|
274
|
+
|
|
275
|
+
D1:
|
|
276
|
+
|
|
277
|
+
```bash
|
|
278
|
+
pnpm db:generate
|
|
279
|
+
pnpm db:migrate:local # apply to miniflare D1
|
|
280
|
+
# when happy:
|
|
281
|
+
pnpm db:migrate:remote # apply to production D1
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
Then:
|
|
285
|
+
|
|
286
|
+
```bash
|
|
287
|
+
pracht verify --json
|
|
288
|
+
pnpm test
|
|
289
|
+
```
|
|
290
|
+
|
|
291
|
+
Note: on a fresh project `pnpm test` is a no-op (no tests exist yet) — it
|
|
292
|
+
proves nothing about the DB wiring. Suggest a loader smoke test that calls
|
|
293
|
+
the Step 7 loader with a real (local) DB and asserts on the returned shape,
|
|
294
|
+
or run `scaffold-tests` to set that up.
|
|
295
|
+
|
|
296
|
+
## Rules
|
|
297
|
+
|
|
298
|
+
1. Always confirm the adapter ↔ driver compatibility before installing.
|
|
299
|
+
2. Never spread DB rows into loader return values — project explicitly.
|
|
300
|
+
3. For edge runtimes, do not module-cache a connection — use a factory keyed
|
|
301
|
+
by `context.env`.
|
|
302
|
+
4. In app code, read connection strings via `serverEnv` from
|
|
303
|
+
`@pracht/core/env/server`, not `process.env`; on Cloudflare, read it
|
|
304
|
+
inside functions only. (Exception: `drizzle.config.ts` runs under the
|
|
305
|
+
drizzle-kit CLI on Node, where `process.env` is fine.)
|
|
306
|
+
5. Add `.env*` to `.gitignore` if a connection string is involved.
|
|
307
|
+
6. Recommend a migration workflow (`db:migrate`) over `db:push` for
|
|
308
|
+
anything beyond local dev.
|
|
309
|
+
7. For D1, apply migrations with `wrangler d1 migrations apply`, not
|
|
310
|
+
`drizzle-kit migrate` — D1 exposes no TCP endpoint and drizzle-kit will
|
|
311
|
+
silently fail to connect. Split into `db:migrate:local` and
|
|
312
|
+
`db:migrate:remote` so the local miniflare DB can be iterated without
|
|
313
|
+
touching production. Ensure `migrations_dir` in `wrangler.toml` matches
|
|
314
|
+
`out` in `drizzle.config.ts`.
|
|
315
|
+
|
|
316
|
+
$ARGUMENTS
|
|
@@ -0,0 +1,239 @@
|
|
|
1
|
+
---
|
|
2
|
+
name: add-i18n
|
|
3
|
+
version: 1.1.0
|
|
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.
|
|
10
|
+
Use when asked to "add i18n", "set up translations", "make my app
|
|
11
|
+
multilingual", "add locale routing", or "extract strings".
|
|
12
|
+
allowed-tools:
|
|
13
|
+
- Bash
|
|
14
|
+
- Read
|
|
15
|
+
- Write
|
|
16
|
+
- Edit
|
|
17
|
+
- Grep
|
|
18
|
+
- Glob
|
|
19
|
+
- AskUserQuestion
|
|
20
|
+
---
|
|
21
|
+
|
|
22
|
+
# Pracht Add i18n
|
|
23
|
+
|
|
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`.
|
|
27
|
+
|
|
28
|
+
If the pracht MCP server is registered (see docs/MCP.md), prefer its tools
|
|
29
|
+
(`inspect_routes`, `inspect_api`, `inspect_build`, `doctor`, `verify`,
|
|
30
|
+
`generate_*`) over shelling out. Prerequisite: `pracht inspect` needs a vite
|
|
31
|
+
config with the pracht plugin registered.
|
|
32
|
+
|
|
33
|
+
## Step 1: Pick the locale-detection strategy
|
|
34
|
+
|
|
35
|
+
Use `AskUserQuestion`:
|
|
36
|
+
|
|
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 |
|
|
42
|
+
|
|
43
|
+
Default to **URL-prefix** unless the user explicitly chooses otherwise.
|
|
44
|
+
|
|
45
|
+
## Step 2: Pick the supported locales
|
|
46
|
+
|
|
47
|
+
Ask once. Default suggestion: `en` plus one to two more. Confirm a default
|
|
48
|
+
locale (used as fallback in `t()`).
|
|
49
|
+
|
|
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;
|
|
61
|
+
```
|
|
62
|
+
|
|
63
|
+
`src/i18n/index.ts`:
|
|
64
|
+
|
|
65
|
+
```ts
|
|
66
|
+
import en from "./en";
|
|
67
|
+
import fr from "./fr";
|
|
68
|
+
|
|
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>;
|
|
72
|
+
|
|
73
|
+
export type Locale = keyof typeof translations;
|
|
74
|
+
export type TranslationKey = keyof typeof en;
|
|
75
|
+
|
|
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
|
+
}
|
|
81
|
+
```
|
|
82
|
+
|
|
83
|
+
## Step 4: Locale-detection middleware
|
|
84
|
+
|
|
85
|
+
### URL-prefix variant
|
|
86
|
+
|
|
87
|
+
```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
|
+
};
|
|
99
|
+
```
|
|
100
|
+
|
|
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
|
+
|
|
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
|
|
117
|
+
|
|
118
|
+
```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
|
|
133
|
+
|
|
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
|
+
};
|
|
145
|
+
```
|
|
146
|
+
|
|
147
|
+
## Step 5: Use in a loader
|
|
148
|
+
|
|
149
|
+
```ts
|
|
150
|
+
import type { LoaderArgs } from "@pracht/core";
|
|
151
|
+
import { t } from "../i18n";
|
|
152
|
+
|
|
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
|
+
};
|
|
160
|
+
}
|
|
161
|
+
```
|
|
162
|
+
|
|
163
|
+
## Step 6: Wire the manifest
|
|
164
|
+
|
|
165
|
+
For the URL-prefix strategy, the routes need to live under per-locale
|
|
166
|
+
groups. Update `src/routes.ts`:
|
|
167
|
+
|
|
168
|
+
```ts
|
|
169
|
+
import { defineApp, group, route } from "@pracht/core";
|
|
170
|
+
|
|
171
|
+
export const app = defineApp({
|
|
172
|
+
middleware: { i18n: "./middleware/i18n.ts" },
|
|
173
|
+
routes: [
|
|
174
|
+
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"),
|
|
178
|
+
]),
|
|
179
|
+
],
|
|
180
|
+
});
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
For cookie / Accept-Language strategies, just add the middleware to the root
|
|
184
|
+
group; no path changes.
|
|
185
|
+
|
|
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.
|
|
189
|
+
|
|
190
|
+
## Step 7: SEO touch-ups
|
|
191
|
+
|
|
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.
|
|
197
|
+
|
|
198
|
+
## Step 8: String extraction (optional)
|
|
199
|
+
|
|
200
|
+
First create `scripts/i18n-extract.mjs`, a script that:
|
|
201
|
+
|
|
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.
|
|
206
|
+
|
|
207
|
+
Then run it:
|
|
208
|
+
|
|
209
|
+
```bash
|
|
210
|
+
node scripts/i18n-extract.mjs
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
The output is a TODO list per locale, not auto-translation.
|
|
214
|
+
|
|
215
|
+
## Step 9: Verify
|
|
216
|
+
|
|
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.
|
|
220
|
+
- 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.
|
|
224
|
+
- `pnpm test` and `pnpm e2e` still pass.
|
|
225
|
+
- Run `pracht verify --json` and confirm no failures.
|
|
226
|
+
|
|
227
|
+
## Rules
|
|
228
|
+
|
|
229
|
+
1. The middleware sets a request header; loaders read it. Do not stash the
|
|
230
|
+
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.
|
|
238
|
+
|
|
239
|
+
$ARGUMENTS
|