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 +7 -4
- package/package.json +1 -1
- package/skills/add-i18n/SKILL.md +308 -151
- package/skills/audit-headers/SKILL.md +14 -5
- package/skills/audit-secrets/SKILL.md +7 -4
- package/skills/configure-isg/SKILL.md +7 -3
- package/skills/migrate-nextjs/SKILL.md +6 -5
- package/skills/pracht-debug/SKILL.md +1 -1
- package/skills/pracht-deploy/SKILL.md +110 -8
- package/skills/pracht-scaffold/SKILL.md +11 -1
- package/skills/pre-deploy/SKILL.md +74 -9
- package/src/index.js +76 -17
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
|
|
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
package/skills/add-i18n/SKILL.md
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: add-i18n
|
|
3
|
-
version:
|
|
3
|
+
version: 2.1.0
|
|
4
4
|
description: |
|
|
5
|
-
Wire internationalization into a pracht app
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
5
|
+
Wire internationalization into a pracht app with the first-party
|
|
6
|
+
`@pracht/i18n` package, following the framework's recommended pattern
|
|
7
|
+
(middleware detects locale, loaders return translations, components
|
|
8
|
+
consume via route data). Sets up the i18n instance, lazy locale
|
|
9
|
+
dictionaries with typed keys, the detection middleware (URL-prefix,
|
|
10
|
+
cookie, and `Accept-Language`), and either strategy: locale-prefixed
|
|
11
|
+
route groups with hreflang metadata, or one URL per page with a
|
|
12
|
+
cookie-backed switcher that changes no URLs.
|
|
10
13
|
Use when asked to "add i18n", "set up translations", "make my app
|
|
11
|
-
multilingual", "add locale routing",
|
|
14
|
+
multilingual", "add locale routing", "switch language without changing
|
|
15
|
+
URLs", or "extract strings".
|
|
12
16
|
allowed-tools:
|
|
13
17
|
- Bash
|
|
14
18
|
- Read
|
|
@@ -21,219 +25,372 @@ allowed-tools:
|
|
|
21
25
|
|
|
22
26
|
# Pracht Add i18n
|
|
23
27
|
|
|
24
|
-
Pracht ships
|
|
25
|
-
|
|
26
|
-
`
|
|
28
|
+
Pracht ships its i18n primitives as `@pracht/i18n`: locale-detection
|
|
29
|
+
middleware, lazy dictionaries with keys typed from the default locale,
|
|
30
|
+
`t()`/`tPlural()` (plurals via `Intl.PluralRules`), `localePath()`, and an
|
|
31
|
+
`hreflang()` helper for `head()`. The full guide lives at
|
|
32
|
+
`examples/docs/src/routes/docs/recipes-i18n.md`; working setups are in
|
|
33
|
+
`examples/basic` — locale-prefixed (`/welcome`, `/en/welcome`,
|
|
34
|
+
`/nl/welcome`) and prefix-free (`/greeting`, `src/api/locale.ts`). That page
|
|
35
|
+
also keeps a hand-rolled fallback recipe if the user refuses the dependency.
|
|
27
36
|
|
|
28
37
|
If the pracht MCP server is registered (see docs/MCP.md), prefer its tools
|
|
29
38
|
(`inspect_routes`, `inspect_api`, `inspect_build`, `doctor`, `verify`,
|
|
30
39
|
`generate_*`) over shelling out. Prerequisite: `pracht inspect` needs a vite
|
|
31
40
|
config with the pracht plugin registered.
|
|
32
41
|
|
|
33
|
-
## Step 1: Pick
|
|
42
|
+
## Step 1: Pick locales and a URL strategy
|
|
34
43
|
|
|
35
|
-
Use `AskUserQuestion
|
|
44
|
+
Use `AskUserQuestion` once for: supported locales (default: `en` plus one or
|
|
45
|
+
two more), the default locale, and the **URL strategy**:
|
|
36
46
|
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
47
|
+
- **A. Locale-prefixed URLs** (`/en/about`) — the default for public,
|
|
48
|
+
indexable content: each language is its own URL, `hreflang()` works, and
|
|
49
|
+
routes can stay `ssg`/`isg`. Adopting it changes every URL.
|
|
50
|
+
- **B. One URL per page** (`/about`) — for an existing site whose URLs
|
|
51
|
+
cannot move, or an app behind a login where indexing does not matter. The
|
|
52
|
+
cookie decides the locale, switching needs no navigation, and no URL
|
|
53
|
+
changes. Cost: `Vary: Cookie, Accept-Language` makes those routes
|
|
54
|
+
per-request (`ssr`/`spa`, never `ssg`/`isg`) and a single URL cannot carry
|
|
55
|
+
hreflang alternates.
|
|
42
56
|
|
|
43
|
-
|
|
57
|
+
Ask explicitly if the user already has a live site — never migrate an
|
|
58
|
+
existing app's URLs without saying so. Both strategies can coexist in one
|
|
59
|
+
app on one instance.
|
|
44
60
|
|
|
45
|
-
|
|
61
|
+
Keep the default detection order `["path", "cookie", "header"]` in both
|
|
62
|
+
cases (the path source simply never matches a prefix-free route); only
|
|
63
|
+
change it when the user explicitly wants cookie-only or header-only
|
|
64
|
+
detection.
|
|
46
65
|
|
|
47
|
-
|
|
48
|
-
locale (used as fallback in `t()`).
|
|
66
|
+
Install the package:
|
|
49
67
|
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
`src/i18n/<locale>.ts` per locale:
|
|
53
|
-
|
|
54
|
-
```ts
|
|
55
|
-
export default {
|
|
56
|
-
"home.title": "Welcome",
|
|
57
|
-
"home.subtitle": "Built with pracht",
|
|
58
|
-
"nav.home": "Home",
|
|
59
|
-
"nav.about": "About",
|
|
60
|
-
} as const;
|
|
68
|
+
```bash
|
|
69
|
+
npm install @pracht/i18n
|
|
61
70
|
```
|
|
62
71
|
|
|
63
|
-
|
|
72
|
+
## Step 2: The i18n instance and dictionaries
|
|
64
73
|
|
|
65
74
|
```ts
|
|
66
|
-
|
|
67
|
-
import
|
|
75
|
+
// src/i18n/index.ts
|
|
76
|
+
import { createDictionaries, defineI18n } from "@pracht/i18n";
|
|
68
77
|
|
|
69
|
-
export const
|
|
70
|
-
|
|
71
|
-
|
|
78
|
+
export const i18n = defineI18n({
|
|
79
|
+
locales: ["en", "fr"],
|
|
80
|
+
defaultLocale: "en",
|
|
81
|
+
});
|
|
72
82
|
|
|
73
|
-
export type
|
|
74
|
-
export type TranslationKey = keyof typeof en;
|
|
83
|
+
export type AppLocale = (typeof i18n.locales)[number];
|
|
75
84
|
|
|
76
|
-
export
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
}
|
|
85
|
+
export const dictionaries = createDictionaries(
|
|
86
|
+
{
|
|
87
|
+
en: () => import("./locales/en.ts"),
|
|
88
|
+
fr: () => import("./locales/fr.ts"),
|
|
89
|
+
},
|
|
90
|
+
{ defaultLocale: "en" },
|
|
91
|
+
);
|
|
81
92
|
```
|
|
82
93
|
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
### URL-prefix variant
|
|
94
|
+
One dictionary module per locale — flat string keys, default export,
|
|
95
|
+
`as const` so key typing works:
|
|
86
96
|
|
|
87
97
|
```ts
|
|
88
|
-
// src/
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
const maybe = segments[0] ?? "";
|
|
95
|
-
const locale = (supportedLocales as readonly string[]).includes(maybe) ? maybe : defaultLocale;
|
|
96
|
-
request.headers.set("x-locale", locale);
|
|
97
|
-
return next();
|
|
98
|
-
};
|
|
98
|
+
// src/i18n/locales/en.ts
|
|
99
|
+
export default {
|
|
100
|
+
"home.title": "Welcome, {name}",
|
|
101
|
+
"cart.items.one": "{count} item",
|
|
102
|
+
"cart.items.other": "{count} items",
|
|
103
|
+
} as const;
|
|
99
104
|
```
|
|
100
105
|
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
segment looks like a locale but isn't supported:
|
|
106
|
+
Plural keys declare one entry per `Intl.PluralRules` category the locale
|
|
107
|
+
needs (`.one`, `.other`, plus `.few`/`.many` for e.g. Polish); `tPlural()`
|
|
108
|
+
falls back to `.other`. Non-default locales may omit keys — `load()` merges
|
|
109
|
+
the default locale underneath.
|
|
106
110
|
|
|
107
|
-
|
|
108
|
-
// Add before `return next()` in the URL-prefix middleware:
|
|
109
|
-
if (maybe.length === 2 && !(supportedLocales as readonly string[]).includes(maybe)) {
|
|
110
|
-
return new Response("Not Found", { status: 404 });
|
|
111
|
-
// or redirect to the default-locale URL (import `redirect` from "@pracht/core"):
|
|
112
|
-
// return redirect(`/${url.pathname.split("/").slice(2).join("/")}`, { request });
|
|
113
|
-
}
|
|
114
|
-
```
|
|
115
|
-
|
|
116
|
-
### Cookie variant
|
|
111
|
+
## Step 3: Detection middleware
|
|
117
112
|
|
|
118
113
|
```ts
|
|
119
|
-
|
|
120
|
-
import {
|
|
121
|
-
|
|
122
|
-
export const middleware: MiddlewareFn = ({ request }, next) => {
|
|
123
|
-
const cookie = request.headers.get("cookie") ?? "";
|
|
124
|
-
const m = cookie.match(/locale=([^;]+)/);
|
|
125
|
-
const requested = m?.[1] ?? defaultLocale;
|
|
126
|
-
const locale = (supportedLocales as readonly string[]).includes(requested) ? requested : defaultLocale;
|
|
127
|
-
request.headers.set("x-locale", locale);
|
|
128
|
-
return next();
|
|
129
|
-
};
|
|
130
|
-
```
|
|
131
|
-
|
|
132
|
-
### Accept-Language variant
|
|
114
|
+
// src/middleware/i18n.ts
|
|
115
|
+
import { i18n } from "../i18n/index.ts";
|
|
133
116
|
|
|
134
|
-
|
|
135
|
-
import type { MiddlewareFn } from "@pracht/core";
|
|
136
|
-
import { defaultLocale, supportedLocales } from "../i18n";
|
|
137
|
-
|
|
138
|
-
export const middleware: MiddlewareFn = ({ request }, next) => {
|
|
139
|
-
const header = request.headers.get("accept-language") ?? "";
|
|
140
|
-
const preferred = header.split(",").map(p => p.split(";")[0]?.trim().toLowerCase().slice(0, 2));
|
|
141
|
-
const match = preferred.find(p => (supportedLocales as readonly string[]).includes(p));
|
|
142
|
-
request.headers.set("x-locale", match ?? defaultLocale);
|
|
143
|
-
return next();
|
|
144
|
-
};
|
|
117
|
+
export const middleware = i18n.middleware;
|
|
145
118
|
```
|
|
146
119
|
|
|
147
|
-
|
|
120
|
+
The middleware sets `context.locale` and persists URL-prefix choices in a
|
|
121
|
+
`SameSite=Lax` cookie — but only on per-request (SSR/SPA) routes: SSG/ISG
|
|
122
|
+
output is stored and replayed to every visitor, so the middleware never
|
|
123
|
+
attaches `Set-Cookie` there (a baked-in cookie would fail the prerender
|
|
124
|
+
build and block ISG revalidation). It also appends `Vary: Cookie` /
|
|
125
|
+
`Accept-Language` when those sources were consulted. Path-resolved SSR/SPA
|
|
126
|
+
responses vary on `Cookie` too, because the presence of their persistence
|
|
127
|
+
`Set-Cookie` depends on the incoming cookie; path-only SSG/ISG output stays
|
|
128
|
+
keyed solely by URL. Type the context once via the Register pattern:
|
|
129
|
+
|
|
130
|
+
Cookie configuration stays browser-valid: `SameSite=None` always forces
|
|
131
|
+
`Secure`, even if an explicit option attempts to disable it.
|
|
148
132
|
|
|
149
133
|
```ts
|
|
150
|
-
|
|
151
|
-
import {
|
|
134
|
+
// src/env.d.ts
|
|
135
|
+
import type { I18nRequestContext } from "@pracht/i18n";
|
|
152
136
|
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
title: t(locale, "home.title"),
|
|
158
|
-
subtitle: t(locale, "home.subtitle"),
|
|
159
|
-
};
|
|
137
|
+
declare module "@pracht/core" {
|
|
138
|
+
interface Register {
|
|
139
|
+
context: I18nRequestContext<"en" | "fr">;
|
|
140
|
+
}
|
|
160
141
|
}
|
|
161
142
|
```
|
|
162
143
|
|
|
163
|
-
|
|
144
|
+
Intersect with the existing registered context type if the app already has
|
|
145
|
+
one.
|
|
164
146
|
|
|
165
|
-
|
|
166
|
-
|
|
147
|
+
## Step 4: Wire the manifest
|
|
148
|
+
|
|
149
|
+
### Strategy A — locale-prefixed URLs
|
|
150
|
+
|
|
151
|
+
One `pathPrefix` group per locale — only registered locales produce URLs, so
|
|
152
|
+
`/zz/about` 404s instead of serving duplicate default-locale content (never
|
|
153
|
+
use a `/:locale` param route for this; it matches any first segment):
|
|
167
154
|
|
|
168
155
|
```ts
|
|
169
156
|
import { defineApp, group, route } from "@pracht/core";
|
|
170
157
|
|
|
158
|
+
const localizedRoutes = [
|
|
159
|
+
route("/", "./routes/home.tsx", { render: "ssr" }),
|
|
160
|
+
route("/about", "./routes/about.tsx", { render: "ssr" }),
|
|
161
|
+
];
|
|
162
|
+
|
|
171
163
|
export const app = defineApp({
|
|
172
164
|
middleware: { i18n: "./middleware/i18n.ts" },
|
|
173
165
|
routes: [
|
|
174
166
|
group({ middleware: ["i18n"] }, [
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
route("
|
|
167
|
+
group({ pathPrefix: "/en" }, localizedRoutes),
|
|
168
|
+
group({ pathPrefix: "/fr" }, localizedRoutes),
|
|
169
|
+
route("/", "./routes/locale-redirect.tsx", { render: "ssr" }),
|
|
178
170
|
]),
|
|
179
171
|
],
|
|
180
172
|
});
|
|
181
173
|
```
|
|
182
174
|
|
|
183
|
-
|
|
184
|
-
group; no path changes.
|
|
175
|
+
Notes:
|
|
185
176
|
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
177
|
+
- Reusing one `localizedRoutes` array is fine with auto-generated ids; if
|
|
178
|
+
the app sets explicit `id`s, each locale's copy needs unique ids.
|
|
179
|
+
- The unprefixed detector redirects using what the middleware resolved.
|
|
180
|
+
`return` the redirect — a *thrown* Response short-circuits past the
|
|
181
|
+
middleware chain, so the i18n middleware could not stamp
|
|
182
|
+
`Vary: Cookie, Accept-Language` on it (a shared cache could then replay
|
|
183
|
+
one visitor's locale redirect to everyone):
|
|
189
184
|
|
|
190
|
-
|
|
185
|
+
```ts
|
|
186
|
+
// src/routes/locale-redirect.tsx
|
|
187
|
+
import { redirect, type LoaderArgs } from "@pracht/core";
|
|
188
|
+
import { i18n } from "../i18n/index.ts";
|
|
191
189
|
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
- Update sitemap (cross-reference with `audit-seo`) to include all
|
|
196
|
-
per-locale URLs.
|
|
190
|
+
export async function loader({ context, request }: LoaderArgs) {
|
|
191
|
+
return redirect(i18n.localePath("/", context.locale), { request });
|
|
192
|
+
}
|
|
197
193
|
|
|
198
|
-
|
|
194
|
+
export function Component() {
|
|
195
|
+
return null;
|
|
196
|
+
}
|
|
197
|
+
```
|
|
198
|
+
|
|
199
|
+
- Route matching is exact: locale prefixes are lowercase URLs; build links
|
|
200
|
+
with `i18n.localePath()` so they always come out canonical. It resolves
|
|
201
|
+
literal and encoded dot segments before prefixing, so even a path assembled
|
|
202
|
+
from user input cannot escape the locale namespace during browser URL
|
|
203
|
+
normalization.
|
|
204
|
+
- If localized routes are SSG/ISG, their stored response cannot safely carry
|
|
205
|
+
a visitor-specific `Set-Cookie`. In a hydrated component shared by those
|
|
206
|
+
routes, persist the explicit prefix with
|
|
207
|
+
`useEffect(() => { i18n.setLocaleCookie(data.locale); }, [data.locale])` so the
|
|
208
|
+
SSR detector remembers it later. This is harmless on SSR pages. Without
|
|
209
|
+
JavaScript, remembering a prerendered visit requires SSR or platform edge
|
|
210
|
+
middleware before static asset serving.
|
|
211
|
+
|
|
212
|
+
### Strategy B — one URL per page
|
|
213
|
+
|
|
214
|
+
Nothing about the routes changes: add the middleware to the group and skip
|
|
215
|
+
the prefix groups and the detector route entirely. Detection falls to the
|
|
216
|
+
cookie, then `Accept-Language`; the middleware adds
|
|
217
|
+
`Vary: Cookie, Accept-Language`, so keep those routes `ssr`/`spa`.
|
|
218
|
+
|
|
219
|
+
Because no URL prefix ever signals an explicit choice, the *switcher* writes
|
|
220
|
+
the cookie. Generate an API route (works with JavaScript disabled):
|
|
199
221
|
|
|
200
|
-
|
|
222
|
+
```ts
|
|
223
|
+
// src/api/locale.ts
|
|
224
|
+
import { redirect, type BaseRouteArgs } from "@pracht/core";
|
|
225
|
+
import { i18n } from "../i18n/index.ts";
|
|
226
|
+
|
|
227
|
+
function sameOriginPath(value: FormDataEntryValue | null, base: URL, fallback: string): string {
|
|
228
|
+
if (typeof value !== "string" || !value.startsWith("/")) return fallback;
|
|
229
|
+
try {
|
|
230
|
+
const target = new URL(value, base);
|
|
231
|
+
return target.origin === base.origin
|
|
232
|
+
? `${target.pathname}${target.search}${target.hash}`
|
|
233
|
+
: fallback;
|
|
234
|
+
} catch {
|
|
235
|
+
return fallback;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
201
238
|
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
239
|
+
export async function POST({ request, url }: BaseRouteArgs) {
|
|
240
|
+
const form = await request.formData();
|
|
241
|
+
const locale = form.get("locale");
|
|
242
|
+
if (!i18n.isLocale(locale)) return new Response("Unknown locale", { status: 400 });
|
|
206
243
|
|
|
207
|
-
|
|
244
|
+
// Parse `next` before trusting it: URL normalization can expose an origin.
|
|
245
|
+
const next = form.get("next");
|
|
246
|
+
const target = sameOriginPath(next, url, "/");
|
|
208
247
|
|
|
209
|
-
|
|
210
|
-
|
|
248
|
+
const response = redirect(target, { request, status: 303 });
|
|
249
|
+
response.headers.append("set-cookie", i18n.localeCookie(locale, { url }));
|
|
250
|
+
return response;
|
|
251
|
+
}
|
|
211
252
|
```
|
|
212
253
|
|
|
213
|
-
|
|
254
|
+
…and a `<Form method="post" action="/api/locale">` switcher with one
|
|
255
|
+
`<button name="locale" value={locale}>` per locale plus a hidden `next`
|
|
256
|
+
field carrying `useLocation().pathname + useLocation().search` so switching
|
|
257
|
+
does not drop the current query. Hydrated, `<Form>` uses the framework's
|
|
258
|
+
redirect handshake and re-runs the loader; without JavaScript the browser
|
|
259
|
+
follows the 303 normally.
|
|
260
|
+
|
|
261
|
+
For an instant switch with no request at all, `i18n.setLocaleCookie(locale)`
|
|
262
|
+
writes the same cookie from the browser and
|
|
263
|
+
`await dictionaries.load(locale)` swaps the dictionary in place — hold the
|
|
264
|
+
result in state, reset it when loader data changes, and set both
|
|
265
|
+
`document.documentElement.lang` and a localized `document.title` by hand
|
|
266
|
+
(`head()` already ran server-side).
|
|
267
|
+
Load the dictionary before writing the cookie, and guard concurrent lazy
|
|
268
|
+
loads with a monotonically increasing request id so only the latest successful
|
|
269
|
+
selection commits both the cookie and component state. Catch import failures
|
|
270
|
+
instead of leaving an unhandled event-handler rejection or a partially applied
|
|
271
|
+
locale choice. Invalidate that request id from a `useLayoutEffect` cleanup
|
|
272
|
+
keyed by loader messages so a loader-data change or unmount wins during commit;
|
|
273
|
+
a passive `useEffect` cleanup leaves time for a stale import to write its cookie.
|
|
274
|
+
Also increment the shared request id synchronously in the server switcher's
|
|
275
|
+
`<Form onSubmit>` and before any other navigation that can replace loader data.
|
|
276
|
+
Cleanup at commit cannot undo a stale cookie written while that transition was
|
|
277
|
+
still in flight.
|
|
278
|
+
`i18n.detectClient()` is the browser-side `detect()` if a client-only
|
|
279
|
+
surface needs to resolve the locale itself.
|
|
280
|
+
|
|
281
|
+
## Step 5: Use in loaders and components
|
|
282
|
+
|
|
283
|
+
```tsx
|
|
284
|
+
import type { HeadArgs, LoaderArgs, RouteComponentProps } from "@pracht/core";
|
|
285
|
+
import { t, tPlural } from "@pracht/i18n";
|
|
286
|
+
import { dictionaries, i18n } from "../i18n/index.ts";
|
|
287
|
+
import { useEffect } from "preact/hooks";
|
|
288
|
+
|
|
289
|
+
export async function loader({ context }: LoaderArgs) {
|
|
290
|
+
const messages = await dictionaries.load(context.locale);
|
|
291
|
+
return { locale: context.locale, messages };
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
export function head({ data, url }: HeadArgs<typeof loader>) {
|
|
295
|
+
return {
|
|
296
|
+
lang: data.locale,
|
|
297
|
+
title: t(data.messages, "home.title"),
|
|
298
|
+
link: i18n.hreflang(url.pathname, { origin: "https://example.com" }),
|
|
299
|
+
};
|
|
300
|
+
}
|
|
301
|
+
|
|
302
|
+
export function Component({ data }: RouteComponentProps<typeof loader>) {
|
|
303
|
+
// Required for SSG/ISG locale routes; harmless when SSR middleware already
|
|
304
|
+
// persisted the matching path locale.
|
|
305
|
+
useEffect(() => {
|
|
306
|
+
i18n.setLocaleCookie(data.locale);
|
|
307
|
+
}, [data.locale]);
|
|
308
|
+
return <h1>{t(data.messages, "home.title", { name: "Jovi" })}</h1>;
|
|
309
|
+
}
|
|
310
|
+
```
|
|
311
|
+
|
|
312
|
+
`messages` is a plain serializable object, so the same `t()` calls work
|
|
313
|
+
after hydration and on client navigations. `hreflang()` emits one alternate
|
|
314
|
+
link per locale plus `x-default` pointing at the unprefixed detector; pass
|
|
315
|
+
the app's canonical origin. Relative previews remain current-origin because
|
|
316
|
+
`splitLocale()` always keeps the stripped pathname root-relative, and every
|
|
317
|
+
alternate preserves an input query/hash suffix. Under strategy B, omit the
|
|
318
|
+
`link` entry: there is no alternate URL to point at, so emitting hreflang
|
|
319
|
+
would be a lie.
|
|
320
|
+
|
|
321
|
+
## Step 6: SEO touch-ups
|
|
322
|
+
|
|
323
|
+
- Set `lang` from the resolved locale in `head()` (as above).
|
|
324
|
+
- Keep the detector route SSR; locale-prefixed routes may be `ssg` or
|
|
325
|
+
`isg` — every prefixed URL is a real route, so each locale prerenders,
|
|
326
|
+
and the middleware skips cookie persistence on those routes so no
|
|
327
|
+
`Set-Cookie` lands in stored output. Persist the resolved path locale after
|
|
328
|
+
hydration when the SSR detector should remember it; without JavaScript,
|
|
329
|
+
use SSR or platform edge middleware. Keep `"path"` first in the detect order
|
|
330
|
+
for prerendered routes: cookie/header detection cannot run against a stored
|
|
331
|
+
document (prerender/ISG requests carry no cookies or `Accept-Language`), and
|
|
332
|
+
a route that *depends* on those sources gets `Vary: Cookie` and is refused by
|
|
333
|
+
the ISG cache.
|
|
334
|
+
- Prerendered `head()` runs against a placeholder request origin — pass the
|
|
335
|
+
app's canonical origin to `hreflang()` on SSG/ISG routes instead of
|
|
336
|
+
`url.origin`, or the alternates bake in `http://localhost`.
|
|
337
|
+
- Update the sitemap (cross-reference with `audit-seo`) to include all
|
|
338
|
+
per-locale URLs.
|
|
339
|
+
- Strategy B only: one URL means one indexed language (whatever the
|
|
340
|
+
crawler's `Accept-Language` resolves to). Say this out loud to the user;
|
|
341
|
+
if it matters, that is the argument for strategy A. Still set `lang`, and
|
|
342
|
+
leave sitemap entries as the single canonical URLs they already are.
|
|
214
343
|
|
|
215
|
-
## Step
|
|
344
|
+
## Step 7: Verify
|
|
216
345
|
|
|
217
|
-
-
|
|
218
|
-
route types/`href()` helper. Add
|
|
219
|
-
types fail the build.
|
|
346
|
+
- If step 4 changed route paths (strategy A) or added the API route, run
|
|
347
|
+
`pracht typegen` to refresh the generated route types/`href()` helper. Add
|
|
348
|
+
`pracht typegen --check` to CI so stale types fail the build.
|
|
220
349
|
- Boot dev: `pracht dev`.
|
|
221
|
-
-
|
|
222
|
-
|
|
223
|
-
|
|
350
|
+
- Strategy A: `curl -i` the unprefixed detector with `Accept-Language: fr`
|
|
351
|
+
(expect a 302 to `/fr/...`), with a `pracht_locale` cookie (cookie beats
|
|
352
|
+
header), and with garbage (`;q=`, unknown tags — expect the default
|
|
353
|
+
locale). Visit a locale-prefixed page; confirm translated content and the
|
|
354
|
+
hreflang links in the head. On SSR, confirm `Set-Cookie` on first visit and
|
|
355
|
+
`Vary: Cookie` whether or not the request cookie already matches. On SSG/ISG,
|
|
356
|
+
confirm the stored response has neither `Set-Cookie` nor a path-only `Vary`, hydration writes
|
|
357
|
+
the locale cookie, and then the unprefixed detector returns to that locale.
|
|
358
|
+
Visit an unsupported prefix (e.g. `/zz/about`); confirm it 404s.
|
|
359
|
+
- Strategy B: `curl -i` the page with `Accept-Language: fr` (expect French
|
|
360
|
+
content, `Vary: Cookie, Accept-Language`, no `Set-Cookie`) and with
|
|
361
|
+
`Cookie: pracht_locale=fr` while sending `Accept-Language: en` (cookie
|
|
362
|
+
wins). `curl -i -X POST` the switcher with `-d locale=fr` and an
|
|
363
|
+
`Origin` header matching the host (mutation API routes are
|
|
364
|
+
same-origin-checked): expect a 303 plus `Set-Cookie`. Post an unregistered
|
|
365
|
+
locale and an off-origin `next`; expect a 400 and a same-origin redirect.
|
|
366
|
+
In the browser, switch and confirm the URL never changes.
|
|
224
367
|
- `pnpm test` and `pnpm e2e` still pass.
|
|
225
368
|
- Run `pracht verify --json` and confirm no failures.
|
|
226
369
|
|
|
227
370
|
## Rules
|
|
228
371
|
|
|
229
|
-
1. The middleware sets
|
|
372
|
+
1. The middleware sets `context.locale`; loaders read it. Do not stash the
|
|
230
373
|
locale in module-level state — concurrent requests will collide.
|
|
231
|
-
2.
|
|
232
|
-
|
|
233
|
-
`
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
374
|
+
2. Only registered locales may ever reach paths, cookies, or hreflang.
|
|
375
|
+
`defineI18n`/`localePath` enforce this — never bypass them with string
|
|
376
|
+
concatenation on user input. `localePath` also resolves dot segments before
|
|
377
|
+
adding the locale prefix. Accept-Language wildcard fallbacks are resolved
|
|
378
|
+
through the registered locale list, respect explicit `q=0` exclusions, and
|
|
379
|
+
neither lookup truncation nor best-fit fallback can bypass those exclusions.
|
|
380
|
+
Directly matched longer variants win before same-language best fit, which
|
|
381
|
+
never crosses conflicting script subtags. If the defensive header-length
|
|
382
|
+
limit cuts an entry in half, discard that entry rather than parsing it with
|
|
383
|
+
an implied quality of 1.
|
|
384
|
+
3. For SSG, only prerender URL combinations that exist; provide
|
|
385
|
+
`getStaticPaths` returning the locale × dynamic-param product when a
|
|
386
|
+
localized route has dynamic segments.
|
|
387
|
+
4. Recommend `Intl.DateTimeFormat` and `Intl.NumberFormat` with
|
|
388
|
+
`data.locale` for formatting — no library needed.
|
|
389
|
+
5. Never bundle every translation into the client: `createDictionaries`
|
|
390
|
+
loaders are per-locale lazy imports resolved in loaders; keep them that
|
|
391
|
+
way.
|
|
392
|
+
6. Never move an existing app's URLs without asking. Locale prefixes are a
|
|
393
|
+
strategy, not a requirement — if the user says their URLs are fixed,
|
|
394
|
+
strategy B is the answer, not a redirect table.
|
|
238
395
|
|
|
239
396
|
$ARGUMENTS
|
|
@@ -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
|
|
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
|
|
138
|
-
`dist/
|
|
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
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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()` | `
|
|
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
|
|
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
|
-
|
|
|
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.
|
|
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
|
|
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",
|
|
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
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
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.
|
|
3
|
+
version: 1.3.0
|
|
4
4
|
description: |
|
|
5
5
|
Adapter-aware pre-deployment checklist for pracht apps targeting Node,
|
|
6
|
-
Cloudflare Workers, or
|
|
7
|
-
production runtime: missing env vars, Node-only APIs in
|
|
8
|
-
ISG manifest absence, oversized edge bundles, missing
|
|
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()`,
|
|
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.
|
|
183
|
-
|
|
184
|
-
|
|
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.
|
|
21
|
-
"@pracht/core": "^0.
|
|
22
|
-
"@pracht/vite-plugin": "^0.
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 (
|
|
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
|
-
|
|
1469
|
+
if (adapter.id !== "static") {
|
|
1470
|
+
lines.push("- `pracht generate middleware --name auth` — add middleware");
|
|
1471
|
+
}
|
|
1434
1472
|
}
|
|
1435
|
-
|
|
1436
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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)
|