create-pracht 0.5.0 → 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 CHANGED
@@ -15,9 +15,9 @@ 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 Vercel adapters.
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, not-found page, sample API route, runnable project README, TypeScript typecheck script, and (with agent tooling enabled) 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
@@ -36,13 +36,16 @@ node ./packages/start/bin/create-pracht.js
36
36
  node ./packages/start/bin/create-pracht.js my-app --adapter=node --skip-install
37
37
  node ./packages/start/bin/create-pracht.js my-app --adapter=vercel --skip-install
38
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
39
40
  node ./packages/start/bin/create-pracht.js my-app --template=tailwind --yes
40
41
  node ./packages/start/bin/create-pracht.js my-app --adapter=node --no-tailwind --no-git --yes
41
42
  ```
42
43
 
43
44
  ## Options
44
45
 
45
- - `--adapter=node|cf|netlify|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.
46
49
  - `--router=manifest|pages` — choose the routing system (default: manifest).
47
50
  - `--template=minimal|tailwind` — non-interactive template selection; `minimal` is the default output, `tailwind` is minimal plus Tailwind CSS wiring.
48
51
  - `--tailwind` / `--no-tailwind` — enable or disable Tailwind CSS without going through the prompt.
@@ -61,7 +64,7 @@ node ./packages/start/bin/create-pracht.js my-app --adapter=node --no-tailwind -
61
64
  - `src/routes/home.tsx`
62
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)
63
66
  - `src/shells/public.tsx`
64
- - `src/api/health.ts`
67
+ - `src/api/health.ts` — serverful adapters only
65
68
  - `.gitignore`
66
69
  - `.claude/skills/<name>/SKILL.md` — the pracht agent skills (unless `--no-agent-tools`)
67
70
  - `.mcp.json` — registers the `pracht mcp` server for MCP clients (unless `--no-agent-tools`)
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pracht",
3
- "version": "0.5.0",
3
+ "version": "0.6.0",
4
4
  "description": "Interactive and scriptable starter CLI for creating full-stack Preact apps with Pracht.",
5
5
  "keywords": [
6
6
  "pracht",
@@ -1,14 +1,18 @@
1
1
  ---
2
2
  name: add-i18n
3
- version: 1.1.0
3
+ version: 2.1.0
4
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.
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", or "extract strings".
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 no i18n library the framework gives you primitives. The
25
- recommended recipe lives at
26
- `examples/docs/src/routes/docs/recipes-i18n.md`.
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 the locale-detection strategy
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
- | 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 |
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
- Default to **URL-prefix** unless the user explicitly chooses otherwise.
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
- ## Step 2: Pick the supported locales
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
- Ask once. Default suggestion: `en` plus one to two more. Confirm a default
48
- locale (used as fallback in `t()`).
66
+ Install the package:
49
67
 
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;
68
+ ```bash
69
+ npm install @pracht/i18n
61
70
  ```
62
71
 
63
- `src/i18n/index.ts`:
72
+ ## Step 2: The i18n instance and dictionaries
64
73
 
65
74
  ```ts
66
- import en from "./en";
67
- import fr from "./fr";
75
+ // src/i18n/index.ts
76
+ import { createDictionaries, defineI18n } from "@pracht/i18n";
68
77
 
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>;
78
+ export const i18n = defineI18n({
79
+ locales: ["en", "fr"],
80
+ defaultLocale: "en",
81
+ });
72
82
 
73
- export type Locale = keyof typeof translations;
74
- export type TranslationKey = keyof typeof en;
83
+ export type AppLocale = (typeof i18n.locales)[number];
75
84
 
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
- }
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
- ## Step 4: Locale-detection middleware
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/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
- };
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
- 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
+ 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
- ```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
111
+ ## Step 3: Detection middleware
117
112
 
118
113
  ```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
114
+ // src/middleware/i18n.ts
115
+ import { i18n } from "../i18n/index.ts";
133
116
 
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
- };
117
+ export const middleware = i18n.middleware;
145
118
  ```
146
119
 
147
- ## Step 5: Use in a loader
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
- import type { LoaderArgs } from "@pracht/core";
151
- import { t } from "../i18n";
134
+ // src/env.d.ts
135
+ import type { I18nRequestContext } from "@pracht/i18n";
152
136
 
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
- };
137
+ declare module "@pracht/core" {
138
+ interface Register {
139
+ context: I18nRequestContext<"en" | "fr">;
140
+ }
160
141
  }
161
142
  ```
162
143
 
163
- ## Step 6: Wire the manifest
144
+ Intersect with the existing registered context type if the app already has
145
+ one.
164
146
 
165
- For the URL-prefix strategy, the routes need to live under per-locale
166
- groups. Update `src/routes.ts`:
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
- route("/", "./routes/home.tsx", { id: "home-default" }),
176
- route("/:locale/", "./routes/home.tsx", { id: "home-localized" }),
177
- route("/:locale/about", "./routes/about.tsx"),
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
- For cookie / Accept-Language strategies, just add the middleware to the root
184
- group; no path changes.
175
+ Notes:
185
176
 
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.
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
- ## Step 7: SEO touch-ups
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
- - 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.
190
+ export async function loader({ context, request }: LoaderArgs) {
191
+ return redirect(i18n.localePath("/", context.locale), { request });
192
+ }
197
193
 
198
- ## Step 8: String extraction (optional)
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
- First create `scripts/i18n-extract.mjs`, a script that:
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
- 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.
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
- Then run it:
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
- ```bash
210
- node scripts/i18n-extract.mjs
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
- The output is a TODO list per locale, not auto-translation.
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 9: Verify
344
+ ## Step 7: Verify
216
345
 
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.
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
- - 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.
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 a request header; loaders read it. Do not stash the
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. 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.
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
@@ -49,8 +49,11 @@ The audit surface is therefore:
49
49
  - **(c)** HSTS and CSP, which genuinely need user action.
50
50
 
51
51
  Prerequisites: `pracht inspect` requires a vite config that registers the
52
- pracht plugin; `pracht inspect build` and `dist/client/_pracht/headers.json`
53
- require a prior `pracht build`.
52
+ pracht plugin; `pracht inspect build` and
53
+ `dist/server/headers-manifest.json` require a prior `pracht build`. Every
54
+ serverful target currently publishes a client copy at
55
+ `dist/client/_pracht/headers.json`; only Cloudflare reads it there. Pure static
56
+ exports deliberately do not publish that copy.
54
57
 
55
58
  ## Step 1: Inventory header sources
56
59
 
@@ -134,9 +137,15 @@ the `headers()` sources statically to catch them **before** a build failure,
134
137
  and report each as `error` with the prerender failure it would cause.
135
138
 
136
139
  The real target is the `warn` class the framework cannot catch: innocuously
137
- named headers carrying user-specific values, which get copied into
138
- `dist/client/_pracht/headers.json` (public client output) and replayed across
139
- users on static responses.
140
+ named headers carrying user-specific values in
141
+ `dist/server/headers-manifest.json`, which serverful adapters may replay across
142
+ users on static responses. Every serverful build currently also copies that
143
+ manifest into public client output; Cloudflare reads it through the assets
144
+ binding, while the other adapters retain the public copy for compatibility. A
145
+ pure static export omits the client copy and has no runtime to replay the server
146
+ manifest; instead, verify that the deployment's host-header configuration
147
+ mirrors only safe, non-user-specific entries. Missing host configuration means
148
+ route `headers()` values are not applied at all.
140
149
 
141
150
  Secret VALUES in headers are owned by `audit-secrets`; this skill owns policy
142
151
  headers. Cross-reference `audit-secrets` for value-level findings.
@@ -95,10 +95,13 @@ Check for accidental exposure outside loaders:
95
95
 
96
96
  - `head()` returns: rare, but a `meta` value containing a token leaks into HTML.
97
97
  - `headers()` returns: flag values that look like secrets. For SSG/ISG pages,
98
- document headers can be copied into `dist/client/_pracht/headers.json`, which
99
- is public client output and may be replayed on static responses. This skill
100
- owns secret VALUES in headers; header policy (CSP, HSTS, weakened defaults)
101
- is owned by `audit-headers` cross-reference it.
98
+ document headers enter `dist/server/headers-manifest.json`; every serverful
99
+ build currently also copies that manifest to public
100
+ `dist/client/_pracht/headers.json` (only Cloudflare reads it there). A pure
101
+ static export omits the client copy but may mirror the server manifest into
102
+ host configuration. This skill owns secret VALUES in headers; header policy
103
+ (CSP, HSTS, weakened defaults) is owned by `audit-headers` — cross-reference
104
+ it.
102
105
  - `<Form>` `action` URLs containing tokens in the query string.
103
106
  - `prefetchRouteState(url)` calls with sensitive query params.
104
107
  - Inline `<script>` content emitted from custom shells.
@@ -81,16 +81,20 @@ webhooks naming them are `skipped` on Node/Cloudflare (nothing to refresh).
81
81
 
82
82
  ## Step 3: The revalidation webhook
83
83
 
84
- All adapters expose `POST /__pracht/revalidate` (`PRACHT_REVALIDATE_ENDPOINT`
85
- from `@pracht/core`):
84
+ All adapters expose `POST <base>/__pracht/revalidate`
85
+ (`PRACHT_REVALIDATE_ENDPOINT` from `@pracht/core`). For example, an app with
86
+ `base: "/app/"` uses:
86
87
 
87
88
  ```sh
88
- curl -X POST https://example.com/__pracht/revalidate \
89
+ curl -X POST https://example.com/app/__pracht/revalidate \
89
90
  -H "Authorization: Bearer $PRACHT_REVALIDATE_TOKEN" \
90
91
  -H "Content-Type: application/json" \
91
92
  -d '{"paths":["/pricing"]}'
92
93
  ```
93
94
 
95
+ At the default `base: "/"`, omit `/app`. Keep request-body paths base-free;
96
+ they identify manifest routes rather than public deployment URLs.
97
+
94
98
  - Auth: `PRACHT_REVALIDATE_TOKEN` env var; fails closed with `401` when unset
95
99
  or wrong. Providers that can't send bearer auth may use the
96
100
  `x-pracht-revalidate-token` header instead.
@@ -78,7 +78,7 @@ For pages router projects, you can **skip manual manifest wiring entirely** (Pha
78
78
  | `"use client"` (few, in a mostly-server app) | `hydration: "islands"` + `src/islands/` | Only islands ship JS; see the islands note in Phase 4 |
79
79
  | `revalidatePath` / `res.revalidate()` | `webhookRevalidate()` + `POST /__pracht/revalidate` | On-demand ISG regeneration; combinable with `timeRevalidate(seconds)` |
80
80
  | `useRouter()` (next/navigation) | `useNavigate()` from pracht | Accepts paths or typed route targets after `pracht typegen` |
81
- | `useSearchParams()` | `useLocation()` from pracht | Returns `{ pathname, search }`; loaders also receive `url` with searchParams |
81
+ | `useSearchParams()` | `useSearchParams()` from pracht | Returns reactive read-only params; SSG receives the browser query after hydration, while loaders use `url.searchParams` |
82
82
  | `useParams()` | `useParams()` from pracht | Direct equivalent; also available as `params` in loader args |
83
83
  | `next/link` `<Link>` | `<Link route="...">` or plain `<a>` | Prefer typed `<Link>` for known app routes after `pracht typegen`; plain anchors still work |
84
84
  | `next/link` `prefetch={false}` | `<Link prefetch="none">` | Pracht prefetches on hover/focus by default; also `"viewport"`, `"render"` |
@@ -452,13 +452,13 @@ async function createPost(formData: FormData) {
452
452
  }
453
453
 
454
454
  // Pracht — API route handler
455
- import type { ApiRouteArgs } from "@pracht/core";
455
+ import { withBase, type ApiRouteArgs } from "@pracht/core";
456
456
 
457
457
  export async function POST({ request }: ApiRouteArgs) {
458
458
  const form = await request.formData();
459
459
  await db.insert({ title: form.get("title") });
460
460
  // revalidatePath("/posts") equivalent: regenerate the ISG page on demand
461
- await fetch(new URL("/__pracht/revalidate", request.url), {
461
+ await fetch(new URL(withBase("/__pracht/revalidate"), request.url), {
462
462
  method: "POST",
463
463
  headers: {
464
464
  authorization: `Bearer ${process.env.PRACHT_REVALIDATE_TOKEN}`,
@@ -468,7 +468,7 @@ export async function POST({ request }: ApiRouteArgs) {
468
468
  });
469
469
  return new Response(null, {
470
470
  status: 303,
471
- headers: { location: "/posts" },
471
+ headers: { location: withBase("/posts") },
472
472
  });
473
473
  }
474
474
  ```
@@ -511,7 +511,8 @@ export async function loader({ request }: LoaderArgs) {
511
511
  | `next/image` | `@pracht/image` |
512
512
  | `react` | `preact` |
513
513
  | `react-dom` | `preact` |
514
- | `@next/font` | CSS `@font-face` or `fontsource` packages |
514
+ | `next/font/local` | `defineFont()` from `@pracht/core` register via `head() { return { fonts: [font] } }`, use `font.className`/`font.style` in components |
515
+ | `next/font/google` | Download the woff2 files into `public/fonts/` (e.g. via google-webfonts-helper), then `defineFont()` — pracht never fetches fonts at build time |
515
516
  | `@next/mdx` | `@mdx-js/rollup` (Vite plugin) |
516
517
  | `next-auth` | Direct integration in middleware/loaders |
517
518
  | `next/og` | `@vercel/og` or custom solution |
@@ -28,7 +28,7 @@ The user will describe a symptom (error, unexpected behavior, blank page, etc.).
28
28
  Before deep manual inspection, prefer running `pracht verify` (add `--changed` to scope the checks to git-changed files) for a fast agent loop or `pracht doctor` when the problem could be caused by broader broken app wiring or missing files.
29
29
  When another agent/tool needs the framework's resolved graph, prefer `pracht inspect routes --json`, `pracht inspect api --json`, or `pracht inspect build --json` over reconstructing it from source files. Prerequisites: `pracht inspect` needs the pracht plugin registered in the project's vite config, and `pracht inspect build` needs a prior `pracht build`.
30
30
  If the pracht MCP server is registered (docs/MCP.md), prefer the `inspect_routes`/`inspect_api`/`doctor`/`verify` MCP tools over shelling out — same payloads, structured results.
31
- While the dev server is running, `GET /_pracht` serves a devtools page with the same resolved route/API graph (raw JSON at `/_pracht.json`) — useful when you have a browser or `curl` handy but no CLI access. Dev SSR responses also carry a `Server-Timing` header (`mw`, `loader`, `render` durations in ms) — check it in the browser Network panel or with `curl -sI` to see which phase makes a route slow.
31
+ While the dev server is running, `GET /_pracht` serves a devtools page with the same resolved route/API graph (raw JSON at `/_pracht.json`) — useful when you have a browser or `curl` handy but no CLI access. Under a Vite deploy base, prefix both paths with that base; links from the devtools and dev-404 pages already do so. Dev SSR responses also carry a `Server-Timing` header (`mw`, `loader`, `render` durations in ms) — check it in the browser Network panel or with `curl -sI` to see which phase makes a route slow.
32
32
 
33
33
  ## Iron Law
34
34
 
@@ -1,12 +1,13 @@
1
1
  ---
2
2
  name: pracht-deploy
3
- version: 1.1.0
3
+ version: 1.2.0
4
4
  description: |
5
5
  Pracht deployment guide. Walks through adapter configuration, building, and
6
- deploying to Node.js, Cloudflare Workers, Netlify, or Vercel. Handles platform
7
- config, Docker and production checklist.
6
+ deploying to Node.js, Cloudflare Workers, Netlify, Vercel, or a pure static
7
+ host. Handles platform config, Docker and production checklist.
8
8
  Use when asked to "deploy", "set up deployment", "configure adapter",
9
- "deploy to cloudflare", "deploy to netlify", "deploy to vercel", or
9
+ "deploy to cloudflare", "deploy to netlify", "deploy to vercel", "static
10
+ export", or
10
11
  "production build".
11
12
  allowed-tools:
12
13
  - Bash
@@ -37,6 +38,7 @@ If the pracht MCP server is registered (docs/MCP.md), prefer the `inspect_build`
37
38
  | Cloudflare Workers | `@pracht/adapter-cloudflare` | Stable |
38
39
  | Netlify | `@pracht/adapter-netlify` | Stable |
39
40
  | Vercel | `@pracht/adapter-vercel` | Stable |
41
+ | Static export | `@pracht/adapter-static` | Stable |
40
42
 
41
43
  ---
42
44
 
@@ -62,6 +64,21 @@ Pin `canonicalOrigin` in production so `request.url` does not depend on the
62
64
  incoming `Host` header. `maxBodySize` is also available on `nodeAdapter()`.
63
65
  Only custom entries behind a trusted proxy that overwrites forwarded headers
64
66
  should use `createNodeRequestHandler({ trustProxy: true })`.
67
+ If that proxy strips Vite's deploy base from the forwarded path, set
68
+ `nodeAdapter({ basePathStripped: true })` (or the same option on a custom
69
+ `createNodeRequestHandler`). Do not infer this from the first path segment: a
70
+ route may legitimately begin with the same segment as the deploy base. The
71
+ adapter restores the public base before `createContext()`, loaders, and API
72
+ handlers receive the request.
73
+ The proxy must also own the public bare-base redirect (`/app` to `/app/`) in
74
+ this mode because the stripped origin cannot distinguish it from a legitimate
75
+ base-free `/app` route.
76
+
77
+ The Node adapter compresses responses by default (brotli/gzip negotiated via
78
+ `Accept-Encoding`, streaming for dynamic bodies, an in-memory LRU for static
79
+ assets). When the deployment sits behind a reverse proxy or CDN that already
80
+ compresses responses, set `nodeAdapter({ compression: false })` so bodies are
81
+ not compressed twice.
65
82
 
66
83
  ### Build
67
84
 
@@ -249,10 +266,14 @@ npx netlify deploy --build --prod
249
266
 
250
267
  The build emits `netlify/functions/pracht.mjs`. Page requests go through that
251
268
  function so Markdown negotiation and route-state requests remain correct;
252
- hashed assets bypass it and stay outside the function bundle. The generated
253
- config enumerates only client files the function can serve and roots matching
254
- exclusions at the function file so Netlify's tracer cannot re-add bypassed
255
- trees. Netlify durable caching
269
+ hashed assets bypass it and stay outside the function bundle at the origin
270
+ root. With a Vite deploy base, the function instead bundles and serves the
271
+ base-free asset and `/_pracht` trees so `/app/...` requests remain inside the
272
+ mount. Custom `excludedPath` entries still bypass their literal origin-root
273
+ URLs, but matching files remain bundled for base-prefixed requests. The
274
+ generated config enumerates only client files the function can serve and roots
275
+ applicable exclusions at the function file so Netlify's tracer cannot re-add
276
+ bypassed trees. Netlify durable caching
256
277
  implements time-based ISG and per-path cache tags implement authenticated
257
278
  webhook revalidation. A trailing-slash ISG document request permanently
258
279
  redirects to the canonical slashless URL before rendering, and webhook
@@ -305,6 +326,87 @@ functions.
305
326
 
306
327
  ---
307
328
 
329
+ ## Static Export Deployment
330
+
331
+ For apps where every route is `render: "ssg"` (or loaderless, full-hydration
332
+ `"spa"`), with no
333
+ request middleware, API routes, or HTTP/MCP/WebMCP-exposed capabilities. SSG
334
+ loaders run only at build time and must produce HTML plus valid JSON route
335
+ state; dynamic SSG routes must export `getStaticPaths()`. Anything else fails the build with an error naming the
336
+ offenders — that is the signal to pick a serverful adapter instead. Only
337
+ manifest-registered capabilities participate; every registered capability
338
+ module must load successfully so exposure validation can fail closed. The
339
+ `notFound` page must use full hydration (the default), because the shared
340
+ `404.html` needs the client router to adopt the visitor's actual URL. Sub-path
341
+ deploys (GitHub Pages *project* sites, S3 key prefixes) set Vite `base` to that
342
+ path; CDN and document-relative bases (`""` / `"./"`) are build errors,
343
+ because they split assets from the deploy root or resolve them beneath nested
344
+ page directories. Under a base,
345
+ internal navigation must go through `<Link route>` / `href()` — a hand-written
346
+ `<a href="/about">` still means the origin root.
347
+ Pracht's preview and first-party serverful adapters redirect the bare base
348
+ (`/app`) to its trailing-slash form (`/app/`) before serving the root document;
349
+ custom adapters receive the same behavior through `handlePrachtRequest()`.
350
+ Framework-owned browser URLs from the default image loader and OpenAPI
351
+ companion artifacts pick up the same base automatically.
352
+
353
+ ### Setup
354
+
355
+ 1. Ensure `@pracht/adapter-static` is installed.
356
+ 2. In `vite.config.ts`:
357
+ ```ts
358
+ import { pracht } from "@pracht/vite-plugin";
359
+ import { staticAdapter } from "@pracht/adapter-static";
360
+ export default { plugins: [pracht({ adapter: staticAdapter() })] };
361
+ // With dynamic SPA routes, add { fallback: "200.html" } and configure the
362
+ // host to rewrite unmatched URLs to it. If the route or shell exports
363
+ // head(), also set generic fallbackHead metadata shared by every rewrite.
364
+ ```
365
+
366
+ ### Build & Deploy
367
+
368
+ ```bash
369
+ pracht build # dist/client/ is the whole deployment
370
+ pracht preview # local static file server over dist/client/
371
+ ```
372
+
373
+ Upload `dist/client/` to any static host (GitHub Pages, S3, nginx, Netlify).
374
+ `dist/server/` is build tooling only — never deploy it. The host must serve
375
+ `<dir>/index.html` for clean URLs and should use `404.html` as its error
376
+ document. A static `notFound` page must use full hydration so that shared
377
+ document can adopt the visitor's real URL. Client navigation fetches collision-safe
378
+ bounded opaque `.json` files under `_pracht/state/` for full-hydration SSG
379
+ routes whose loader or route/shell `head()` metadata participates in navigation;
380
+ equivalent raw-Unicode and percent-encoded URL segment spellings resolve to the
381
+ same state file. Explicitly loaderless and headless routes fetch no Pracht
382
+ state; loaderless routes with head metadata fetch static state for font-head
383
+ fragments but still use browser-side requests to an external API for live
384
+ data. Files under `public/_pracht/state/` may not occupy a generated
385
+ route-state path; the build rejects the collision instead of overwriting the
386
+ public file. Files copied from `public/` or emitted by Vite also may not occupy
387
+ the generated `404.html` or configured fallback path, including a case- or
388
+ Unicode-normalization-equivalent spelling; the build rejects the portable
389
+ collision instead of overwriting existing output. Generic `fallbackHead` fonts
390
+ remain registered while the fallback commits a loaderless dynamic SPA route.
391
+ See docs/ADAPTERS.md § Static Adapter for host header
392
+ configuration and limitations (markdown negotiation, base paths). Pages are
393
+ written to the percent-decoded output path, matching how static hosts resolve
394
+ requests; `pracht preview` decodes request segments the same way. The SPA fallback only client-renders matched SPA routes; dynamic
395
+ SSG paths omitted by `getStaticPaths()` render the app's not-found page with
396
+ the build-time loader data or handled error state carried over from `404.html`.
397
+ The host rewrite that serves the fallback answers unknown URLs with status 200 (soft 404), and an app
398
+ with no `notFound` page and no unshadowed client-routable SPA catch-all renders them blank — the build
399
+ warns about that shape. A dynamic SPA route, its shell, or the not-found page
400
+ with `head()` requires an explicit `fallbackHead`, because the shared static
401
+ document cannot evaluate URL-specific server metadata. Prerendered pages must
402
+ map to distinct portable filesystem paths; duplicate/case-folded or
403
+ Unicode-normalization-equivalent outputs, Windows-invalid or overlong filename
404
+ components, and file/directory conflicts such as `/` with `/index.html` fail
405
+ before any page is written. Fallback names likewise reject Windows reserved
406
+ device names and the portable 255-byte/code-unit component limit.
407
+
408
+ ---
409
+
308
410
  ## Deployment Checklist
309
411
 
310
412
  1. **Build**: Run `pracht build` and verify `dist/` output.
@@ -53,7 +53,7 @@ pracht generate api --path /health --methods GET,POST
53
53
  - `--shell`/`--middleware` names must already be registered in the app manifest — the CLI errors otherwise. Generate the shell/middleware first, then the route that references it.
54
54
  - If the pracht MCP server is registered (docs/MCP.md), call the `generate_route`/`generate_shell`/`generate_middleware`/`generate_api` MCP tools instead of Bash — same behavior, structured results.
55
55
  - Add `--json` when another agent/tool needs machine-readable output.
56
- - `generate route` also emits a Playwright smoke test in `e2e/` when the app has a Playwright setup (`playwright.config.*` or an `e2e/` directory). Pass `--no-test` to skip it, `--test` to force it. Keep the generated test — it is the output-level proof the route works.
56
+ - `generate route` also emits a Playwright smoke test in `e2e/` when the app has a Playwright setup (`playwright.config.*` or an `e2e/` directory). Pass `--no-test` to skip it, `--test` to force it. The test imports `@playwright/test`; if that dependency is absent, follow the generator's install note before typechecking. Keep the generated test — it is the output-level proof the route works.
57
57
  - Use `pracht inspect routes --json` or `pracht inspect api --json` to confirm current wiring before manual edits when the existing graph matters. `pracht inspect` requires the pracht plugin registered in the project's vite config.
58
58
  - If the app has typed routes (`src/pracht-routes.ts` / `.d.ts`) or the user asks for typed links, run `pracht typegen` after adding or renaming routes.
59
59
  - If the app commits `.pracht/app-graph.json`, run `pracht plan --write` after changing routes and include the refreshed snapshot — `pracht verify` fails when it is stale.
@@ -161,6 +161,16 @@ export function GET({ params, url }: ApiRouteArgs) {
161
161
  - Use `request.json()`, `request.formData()`, etc. for body parsing.
162
162
  - Always return `Response` objects (typically `Response.json()`).
163
163
  - Dynamic segments use bracket syntax in filenames: `[id].ts`, `[...slug].ts`.
164
+ - For live server→client updates, use Server-Sent Events:
165
+ `createEventStream(request, { keepAlive: 15 })` from `@pracht/core/server`
166
+ returns `{ response, send, close }` — return `response`, push with
167
+ `send({ data, event?, id? })`, and stop producing when `send()` returns
168
+ `false` (client disconnected). Consume in components with
169
+ `useEventSource(url, { json: true })` from `@pracht/core`. Works on all
170
+ adapters. For WebSockets use `isUpgradeRequest(request)` plus the
171
+ per-adapter recipes in `docs/ADAPTERS.md` (Cloudflare: API route + Durable
172
+ Object; Node: `nodeAdapter({ configureServerFrom })`; Vercel: unsupported —
173
+ use SSE).
164
174
 
165
175
  ## Wiring Into the Manifest (manual fallback only)
166
176
 
@@ -1,11 +1,13 @@
1
1
  ---
2
2
  name: pre-deploy
3
- version: 1.2.0
3
+ version: 1.3.0
4
4
  description: |
5
5
  Adapter-aware pre-deployment checklist for pracht apps targeting Node,
6
- Cloudflare Workers, or Vercel. Catches the issues that only surface in the
7
- production runtime: missing env vars, Node-only APIs in edge bundles,
8
- ISG manifest absence, oversized edge bundles, missing wrangler/vercel config.
6
+ Cloudflare Workers, Vercel, or a pure static export. Catches the issues that
7
+ only surface in the production runtime: missing env vars, Node-only APIs in
8
+ edge bundles, ISG manifest absence, oversized edge bundles, missing
9
+ wrangler/vercel config, and static hosts missing clean-URL, 404, or security
10
+ header configuration.
9
11
  Use when asked to "pre-deploy check", "ready to ship?", "deployment
10
12
  checklist", "is my build production-safe", or before running `wrangler
11
13
  deploy` / `vercel deploy`.
@@ -27,8 +29,8 @@ If the pracht MCP server is registered (see docs/MCP.md), prefer its tools
27
29
  (`inspect_routes`, `inspect_api`, `inspect_build`, `doctor`, `verify`) over
28
30
  shelling out.
29
31
 
30
- Read `vite.config.ts` and look for `nodeAdapter()`, `cloudflareAdapter()`, or
31
- `vercelAdapter()`. Confirm with:
32
+ Read `vite.config.ts` and look for `nodeAdapter()`, `cloudflareAdapter()`,
33
+ `vercelAdapter()`, or `staticAdapter()`. Confirm with:
32
34
 
33
35
  ```bash
34
36
  pracht inspect build --json
@@ -82,6 +84,10 @@ a markdown summary (graph diff + verify + budgets) worth attaching to it.
82
84
  are intentionally not trusted.
83
85
  - Reverse-proxy / TLS termination configured (out of scope for this skill —
84
86
  flag for confirmation).
87
+ - If the proxy strips Vite's deploy base, confirm
88
+ `nodeAdapter({ basePathStripped: true })`; application code should still
89
+ observe the public base in `request.url`, and the proxy must own the public
90
+ bare-base redirect (`/app` to `/app/`).
85
91
 
86
92
  ### Cloudflare Workers (`@pracht/adapter-cloudflare`)
87
93
 
@@ -156,12 +162,67 @@ a markdown summary (graph diff + verify + budgets) worth attaching to it.
156
162
  `passthroughLoader` instead.
157
163
  - Build Output API v3 sanity: `config.json` has `version: 3`.
158
164
 
165
+ ### Static export (`@pracht/adapter-static`)
166
+
167
+ `adapterTarget` is `"static"`. There is no server to get wrong, so the
168
+ checklist is about what the *host* must do and what the build cannot enforce.
169
+
170
+ - `dist/client/` exists and is the deploy root. `dist/server/` is build tooling
171
+ only — it must not be uploaded (it contains the prerender bundle).
172
+ - The build itself is the gate: it fails closed on `ssr`/`isg` routes, SPA
173
+ loaders, non-full SPA hydration, API routes, route/not-found middleware,
174
+ network-exposed capabilities, and any Vite `base` that is not `/` or a
175
+ root-absolute path (CDN and document-relative bases are rejected). If
176
+ `pracht build` succeeded, those contracts already hold — do not re-derive
177
+ them by hand. Report a failing build verbatim; the message names the routes.
178
+ - Host must serve `index.html` for directory URLs (clean URLs). Confirm the
179
+ host's setting: S3 website endpoints need an index document, nginx needs
180
+ `try_files $uri $uri/index.html`, GitHub Pages and Netlify do it by default.
181
+ - Host must map `404.html` as the error document, otherwise unknown URLs get
182
+ the host's generic error page instead of the app's `notFound` route. Verify
183
+ `dist/client/404.html` exists; if it does not, the app declares no `notFound`
184
+ page — flag it as a `warn`.
185
+ - **Security headers are not applied.** Every other adapter sets the four
186
+ default security headers at request time; a static host has no request
187
+ runtime. `dist/server/headers-manifest.json` records the headers each route
188
+ *would* have carried — mirror the ones you need in the host's own header
189
+ config (`_headers` on Netlify, CloudFront response header policies, nginx
190
+ `add_header`). This is an `error` for any app handling user input, and
191
+ `warn` otherwise. HSTS and CSP are host-side decisions either way.
192
+ - If `staticAdapter({ fallback })` is configured, the host needs a rewrite of
193
+ unmatched URLs to that file, and the rewrite must not shadow real files.
194
+ Note that it makes unknown URLs answer `200` (soft 404s). Without the
195
+ rewrite the fallback file is inert — deep links into dynamic `render: "spa"`
196
+ routes will 404.
197
+ - Smoke test the real output, not the dev server:
198
+ `pracht preview --skip-build` serves `dist/client/` the way a dumb host
199
+ would. Check `/`, one dynamic SSG path, one deep link into a SPA route, and
200
+ one unknown URL.
201
+ - Routes exporting `markdown` rely on server-side `Accept` negotiation, which
202
+ a static host cannot do — agents asking for `text/markdown` get HTML. The
203
+ build prints a note when this applies; publish `.md` files under `public/`
204
+ if a raw-markdown corpus matters.
205
+ - Deploying to a sub-path (GitHub Pages *project* site, S3 key prefix) needs
206
+ Vite `base` set to that path (`base: "/my-project/"`). Check it matches the
207
+ deploy path exactly — a mismatch 404s every asset. Then check the app has no
208
+ hand-written root-absolute internal links (`<a href="/about">`): those are
209
+ not base-prefixed and will leave the deploy. `grep -rn 'href="/' src/` and
210
+ confirm each hit is external, an asset under `public/`, or a `<Link route>`.
211
+ Framework-owned URLs from `@pracht/image`'s `defaultLoader` and the OpenAPI
212
+ companion UI/document already carry the base; do not flag their base-free
213
+ route declarations. Custom image loaders and OpenAPI provider asset URLs
214
+ still need to match the intended host.
215
+ CDN bases (`https://cdn…`) and document-relative bases (`""` / `"./"`) are
216
+ build errors, not sub-path deploys.
217
+
159
218
  ## Step 4: Cross-cutting checks
160
219
 
161
220
  - Run `audit-secrets` to confirm no `process.env.*` or `context.env.*` values
162
221
  flow into loader return values.
163
222
  - Run `audit-headers` to confirm `applyDefaultSecurityHeaders` is in use on
164
223
  user-facing responses (or that `headers()` exports cover the same ground).
224
+ On a static export this check moves entirely to the host's header config —
225
+ see the static section above.
165
226
  - Confirm `git status` is clean (deploying uncommitted work is a footgun).
166
227
 
167
228
  ## Step 5: Report
@@ -179,9 +240,13 @@ status. End with a one-line verdict: `READY` / `BLOCKED (N errors)` /
179
240
  3. For Cloudflare/Vercel-edge, the Node-only API check is non-negotiable; an
180
241
  API not covered by the active compatibility flags will crash the worker on
181
242
  a code path that may never hit in dev.
182
- 4. If the app does not use generated typed route files yet, note that `pracht typegen --check` is optional; if it does, stale generated files block deployment.
183
- 5. Do not deploy on the user's behalf. End the skill at the verdict.
184
- 6. If `pracht doctor` reports errors, do not run any other checks until those
243
+ 4. For a static export, never report `READY` without naming the host settings
244
+ the deploy depends on (clean URLs, `404.html`, security headers, and the
245
+ fallback rewrite if configured). The build cannot verify any of them, so an
246
+ unqualified `READY` is the one way this skill can mislead.
247
+ 5. If the app does not use generated typed route files yet, note that `pracht typegen --check` is optional; if it does, stale generated files block deployment.
248
+ 6. Do not deploy on the user's behalf. End the skill at the verdict.
249
+ 7. If `pracht doctor` reports errors, do not run any other checks until those
185
250
  are resolved — they will produce noisy false positives.
186
251
 
187
252
  $ARGUMENTS
package/src/index.js CHANGED
@@ -16,10 +16,11 @@ const FALLBACK_VERSION_RANGES = {
16
16
  "@pracht/adapter-cloudflare": "^0.5.8",
17
17
  "@pracht/adapter-netlify": "^0.1.0",
18
18
  "@pracht/adapter-node": "^0.3.8",
19
+ "@pracht/adapter-static": "^0.1.0",
19
20
  "@pracht/adapter-vercel": "^0.2.8",
20
- "@pracht/cli": "^1.9.0",
21
- "@pracht/core": "^0.12.0",
22
- "@pracht/vite-plugin": "^0.7.6",
21
+ "@pracht/cli": "^1.11.0",
22
+ "@pracht/core": "^0.14.0",
23
+ "@pracht/vite-plugin": "^0.9.0",
23
24
  "@tailwindcss/vite": "^4.1.0",
24
25
  "netlify-cli": "^21.6.0",
25
26
  tailwindcss: "^4.1.0",
@@ -87,6 +88,13 @@ const ADAPTERS = {
87
88
  packageName: "@pracht/adapter-vercel",
88
89
  short: "vercel",
89
90
  },
91
+ static: {
92
+ description: "Pure static export — deploy dist/client to any static host",
93
+ id: "static",
94
+ label: "Static export",
95
+ packageName: "@pracht/adapter-static",
96
+ short: "static",
97
+ },
90
98
  };
91
99
 
92
100
  const DEFAULT_DIRECTORY = "pracht-app";
@@ -407,7 +415,7 @@ export function parseArgs(argv) {
407
415
  const value = normalizeAdapter(arg.slice("--adapter=".length));
408
416
  if (!value) {
409
417
  throw new ValidationError(
410
- `Invalid adapter: ${arg.slice("--adapter=".length)}. Use node, cf, netlify, or vercel.`,
418
+ `Invalid adapter: ${arg.slice("--adapter=".length)}. Use node, cf, netlify, vercel, or static.`,
411
419
  );
412
420
  }
413
421
  options.adapter = value;
@@ -462,6 +470,7 @@ async function promptForAdapter(readline) {
462
470
  console.log(" 2. Cloudflare Workers");
463
471
  console.log(" 3. Vercel");
464
472
  console.log(" 4. Netlify");
473
+ console.log(" 5. Static export (no server)");
465
474
 
466
475
  while (true) {
467
476
  const answer = await readline.question("Adapter (1): ");
@@ -471,7 +480,7 @@ async function promptForAdapter(readline) {
471
480
  return normalized;
472
481
  }
473
482
 
474
- console.log("Choose 1/2/3/4 or node/cf/vercel/netlify.");
483
+ console.log("Choose 1/2/3/4/5 or node/cf/vercel/netlify/static.");
475
484
  }
476
485
  }
477
486
 
@@ -614,6 +623,10 @@ function normalizeAdapter(value) {
614
623
  return "netlify";
615
624
  }
616
625
 
626
+ if (normalized === "5" || normalized === "static" || normalized === "export") {
627
+ return "static";
628
+ }
629
+
617
630
  return null;
618
631
  }
619
632
 
@@ -690,11 +703,16 @@ async function buildProjectFiles({
690
703
  tailwind,
691
704
  versions,
692
705
  }),
693
- "src/api/health.ts": createHealthRoute(adapter),
694
706
  "vite.config.ts": createViteConfig(adapter, router, tailwind),
695
707
  "tsconfig.json": createBaseTSConfig(adapter),
696
708
  };
697
709
 
710
+ // A static export has no server, so an API route would be a hard build
711
+ // error — the starter must not scaffold one it cannot build.
712
+ if (adapter.id !== "static") {
713
+ files["src/api/health.ts"] = createHealthRoute(adapter);
714
+ }
715
+
698
716
  if (agentTools) {
699
717
  files["AGENTS.md"] = createAgentInstructions({
700
718
  adapter,
@@ -803,6 +821,10 @@ function createPackageJson({ adapter, projectName, tailwind, versions }) {
803
821
  scripts.start = "node dist/server/server.js";
804
822
  }
805
823
 
824
+ if (adapter.id === "static") {
825
+ scripts.preview = "pracht preview";
826
+ }
827
+
806
828
  const devDependencies = {
807
829
  "@pracht/cli": versions["@pracht/cli"],
808
830
  "@pracht/vite-plugin": versions["@pracht/vite-plugin"],
@@ -858,6 +880,7 @@ function createViteConfig(adapter, router, tailwind) {
858
880
  cloudflare: { fn: "cloudflareAdapter", pkg: "@pracht/adapter-cloudflare" },
859
881
  netlify: { fn: "netlifyAdapter", pkg: "@pracht/adapter-netlify" },
860
882
  vercel: { fn: "vercelAdapter", pkg: "@pracht/adapter-vercel" },
883
+ static: { fn: "staticAdapter", pkg: "@pracht/adapter-static" },
861
884
  };
862
885
 
863
886
  const info = ADAPTER_IMPORTS[adapter.id] ?? ADAPTER_IMPORTS.node;
@@ -955,7 +978,9 @@ function createHomeRoute(adapter) {
955
978
  " steps: [",
956
979
  ' "Edit src/routes/home.tsx to change this page.",',
957
980
  ' "Add more routes in src/routes.ts.",',
958
- ' "Add API handlers in src/api/*.ts.",',
981
+ adapter.id === "static"
982
+ ? ' "Fetch live data from the browser — a static export runs no server.",'
983
+ : ' "Add API handlers in src/api/*.ts.",',
959
984
  " ],",
960
985
  " };",
961
986
  "}",
@@ -974,7 +999,9 @@ function createHomeRoute(adapter) {
974
999
  " ))}",
975
1000
  " </ul>",
976
1001
  ' <p style={{ marginTop: "24px" }}>',
977
- " Check <code>/api/health</code> for a simple API route.",
1002
+ adapter.id === "static"
1003
+ ? " Run <code>pracht build</code>, then deploy <code>dist/client</code> anywhere."
1004
+ : " Check <code>/api/health</code> for a simple API route.",
978
1005
  " </p>",
979
1006
  " </section>",
980
1007
  " );",
@@ -1022,7 +1049,9 @@ function createPagesHomeRoute(adapter) {
1022
1049
  " steps: [",
1023
1050
  ' "Edit src/pages/index.tsx to change this page.",',
1024
1051
  ' "Add more pages in src/pages/.",',
1025
- ' "Add API handlers in src/api/*.ts.",',
1052
+ adapter.id === "static"
1053
+ ? ' "Fetch live data from the browser — a static export runs no server.",'
1054
+ : ' "Add API handlers in src/api/*.ts.",',
1026
1055
  " ],",
1027
1056
  " };",
1028
1057
  "}",
@@ -1041,7 +1070,9 @@ function createPagesHomeRoute(adapter) {
1041
1070
  " ))}",
1042
1071
  " </ul>",
1043
1072
  ' <p style={{ marginTop: "24px" }}>',
1044
- " Check <code>/api/health</code> for a simple API route.",
1073
+ adapter.id === "static"
1074
+ ? " Run <code>pracht build</code>, then deploy <code>dist/client</code> anywhere."
1075
+ : " Check <code>/api/health</code> for a simple API route.",
1045
1076
  " </p>",
1046
1077
  " </section>",
1047
1078
  " );",
@@ -1410,7 +1441,12 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
1410
1441
  `- \`${runCmd} build\` — production build`,
1411
1442
  ];
1412
1443
 
1413
- if (adapter.id === "node" || adapter.id === "cloudflare" || adapter.id === "netlify") {
1444
+ if (
1445
+ adapter.id === "node" ||
1446
+ adapter.id === "cloudflare" ||
1447
+ adapter.id === "netlify" ||
1448
+ adapter.id === "static"
1449
+ ) {
1414
1450
  lines.push(`- \`${runCmd} preview\` — build and serve the production build locally`);
1415
1451
  }
1416
1452
 
@@ -1430,10 +1466,14 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
1430
1466
  lines.push("- `pracht generate route --path /about` — add a route");
1431
1467
  if (router !== "pages") {
1432
1468
  lines.push("- `pracht generate shell --name app` — add a shell");
1433
- lines.push("- `pracht generate middleware --name auth` — add middleware");
1469
+ if (adapter.id !== "static") {
1470
+ lines.push("- `pracht generate middleware --name auth` — add middleware");
1471
+ }
1434
1472
  }
1435
- lines.push("- `pracht generate api --path /health --methods GET` — add an API route");
1436
- if (router !== "pages") {
1473
+ if (adapter.id !== "static") {
1474
+ lines.push("- `pracht generate api --path /health --methods GET` — add an API route");
1475
+ }
1476
+ if (router !== "pages" && adapter.id !== "static") {
1437
1477
  lines.push(
1438
1478
  "- `pracht generate capability --name notes.search --effect read --expose http` — add a capability (agent-callable operation)",
1439
1479
  );
@@ -1473,7 +1513,9 @@ function createAgentInstructions({ adapter, agentTools, packageManager, router,
1473
1513
  lines.push("- `src/shells/` — shell components (layouts)");
1474
1514
  }
1475
1515
 
1476
- lines.push("- `src/api/` — API route handlers");
1516
+ if (adapter.id !== "static") {
1517
+ lines.push("- `src/api/` — API route handlers");
1518
+ }
1477
1519
  lines.push(`- \`vite.config.ts\` — Vite config with the ${adapter.label} adapter`);
1478
1520
 
1479
1521
  if (tailwind) {
@@ -1576,6 +1618,21 @@ function createReadme({
1576
1618
  lines.push("Run the deploy command after linking or logging into your Vercel account.");
1577
1619
  }
1578
1620
 
1621
+ if (adapter.id === "static") {
1622
+ lines.push(`- \`${previewCommand}\``);
1623
+ lines.push("");
1624
+ lines.push(
1625
+ "`pracht build` writes the whole site to `dist/client`. Upload that directory to any " +
1626
+ "static host — there is no server to run. Configure the host to serve `index.html` " +
1627
+ "for directory URLs and to use `404.html` as its error document.",
1628
+ );
1629
+ lines.push("");
1630
+ lines.push(
1631
+ "A static export runs no server, so API routes, middleware, and `ssr`/`isg` routes are " +
1632
+ "build errors. Fetch live data from the browser instead, or switch to a serverful adapter.",
1633
+ );
1634
+ }
1635
+
1579
1636
  lines.push("");
1580
1637
  lines.push("## Files");
1581
1638
  lines.push("");
@@ -1597,7 +1654,9 @@ function createReadme({
1597
1654
  lines.push("- `src/routes/not-found.tsx` is the not-found page, wired via `notFound`.");
1598
1655
  }
1599
1656
 
1600
- lines.push("- `src/api/health.ts` is a sample API route.");
1657
+ if (adapter.id !== "static") {
1658
+ lines.push("- `src/api/health.ts` is a sample API route.");
1659
+ }
1601
1660
 
1602
1661
  if (packageManager === "pnpm") {
1603
1662
  lines.push(
@@ -1791,7 +1850,7 @@ Usage:
1791
1850
  create-pracht [directory] [options]
1792
1851
 
1793
1852
  Options:
1794
- --adapter=node|cf|netlify|vercel
1853
+ --adapter=node|cf|netlify|vercel|static
1795
1854
  Choose hosting adapter (default: node)
1796
1855
  --router=manifest|pages Choose routing system (default: manifest)
1797
1856
  --template=minimal|tailwind Choose starter template (minimal, or minimal + Tailwind CSS)