create-pracht 0.3.0 → 0.4.1

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.
@@ -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