create-pracht 0.5.0 → 0.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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"` |
@@ -424,6 +424,15 @@ the `@pracht/image/client` types once in a `.d.ts`, and keep
424
424
  `width`/`height`/`blurDataURL` exactly like Next's static imports. Pracht's
425
425
  blur is CSS-only (no fade animation, no inline event handlers).
426
426
 
427
+ For apps that relied on `next/image` producing files during a static export,
428
+ use `?pracht&pracht-static` instead. It emits cached responsive WebP variants
429
+ and bypasses the runtime loader while retaining plain, hydration-free `<img>`
430
+ markup. When Markdown content contains relative images, prefer
431
+ `defineMarkdownCollection()` from `@pracht/markdown`; it applies the same
432
+ static pipeline to normal `![alt](./photo.jpg)` syntax. Keep root-relative
433
+ `public/` and remote image URLs unchanged, and use an absolute Vite `base` for
434
+ static variants.
435
+
427
436
  #### `useRouter` → navigation
428
437
 
429
438
  ```tsx
@@ -452,13 +461,13 @@ async function createPost(formData: FormData) {
452
461
  }
453
462
 
454
463
  // Pracht — API route handler
455
- import type { ApiRouteArgs } from "@pracht/core";
464
+ import { withBase, type ApiRouteArgs } from "@pracht/core";
456
465
 
457
466
  export async function POST({ request }: ApiRouteArgs) {
458
467
  const form = await request.formData();
459
468
  await db.insert({ title: form.get("title") });
460
469
  // revalidatePath("/posts") equivalent: regenerate the ISG page on demand
461
- await fetch(new URL("/__pracht/revalidate", request.url), {
470
+ await fetch(new URL(withBase("/__pracht/revalidate"), request.url), {
462
471
  method: "POST",
463
472
  headers: {
464
473
  authorization: `Bearer ${process.env.PRACHT_REVALIDATE_TOKEN}`,
@@ -468,7 +477,7 @@ export async function POST({ request }: ApiRouteArgs) {
468
477
  });
469
478
  return new Response(null, {
470
479
  status: 303,
471
- headers: { location: "/posts" },
480
+ headers: { location: withBase("/posts") },
472
481
  });
473
482
  }
474
483
  ```
@@ -511,7 +520,8 @@ export async function loader({ request }: LoaderArgs) {
511
520
  | `next/image` | `@pracht/image` |
512
521
  | `react` | `preact` |
513
522
  | `react-dom` | `preact` |
514
- | `@next/font` | CSS `@font-face` or `fontsource` packages |
523
+ | `next/font/local` | `defineFont()` from `@pracht/core` — register via `head() { return { fonts: [font] } }`, use `font.className`/`font.style` in components |
524
+ | `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
525
  | `@next/mdx` | `@mdx-js/rollup` (Vite plugin) |
516
526
  | `next-auth` | Direct integration in middleware/loaders |
517
527
  | `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
 
@@ -72,6 +72,14 @@ Work through these in order, stopping when you find the root cause:
72
72
  - Date/time rendering differences
73
73
  - Browser-only APIs used during SSR (`window`, `document`, `localStorage`)
74
74
  - Conditional rendering based on client state
75
+ - Two copies of `@pracht/core` in the SSR module graph. The tell is that
76
+ `useLocation()` returns `/`, `useParams()` returns `{}`, and
77
+ `useRouteData()` returns `undefined` in the server-rendered HTML for
78
+ *every* page, while the hydrated client is correct — the provider and the
79
+ hooks hold different `createContext()` objects. The plugin prevents this by
80
+ keeping `@pracht/*` in `ssr.noExternal` (dev and build alike); listing a
81
+ `@pracht/*` package in `ssr.external` overrides that and brings the split
82
+ back.
75
83
  - **Missing shell**: Referencing an unregistered shell name throws at manifest resolution — `Unknown shell "..." for route "...". Did you mean "..."? Registered shells: ...` — and shows up in the dev error overlay as soon as the server loads the manifest. Verify the shell is registered in `defineApp({ shells: { ... } })` and assigned to the route/group.
76
84
  - **404 page**: Route not matched — check manifest wiring (step 1). In `pracht dev`, unmatched navigations render a dev-only 404 page listing every registered route with its render mode; compare the requested path against that table. The route table is also printed on dev-server startup and available via `pracht inspect routes`. Apps that declare `defineApp({ notFound })` render their own 404 page instead (in dev and production alike), so the route table is not shown — check `pracht inspect routes` directly. A 404 on a URL you *do* expect to work usually means the loader threw `notFound()`, not that matching failed.
77
85
 
@@ -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
 
@@ -116,7 +133,7 @@ pracht build
116
133
  npx wrangler deploy
117
134
  ```
118
135
 
119
- To smoke-test the built worker locally first, run `pracht preview` — it builds and then delegates to `wrangler dev`, which serves the wrangler config's `main` entry, `dist/server/worker.js`.
136
+ To smoke-test the built worker locally first, run `pracht preview` — it builds and then delegates to `wrangler dev`, which serves the wrangler config's `main` entry, `dist/server/worker.js`. Keep `no_bundle: true` and the JavaScript `ESModule` rule: Pracht's Vite output is already bundled and can contain lazy server chunks that Wrangler must upload separately.
120
137
 
121
138
  Wrangler owns the Worker's binding environment. Put local-only secrets such as
122
139
  `PRACHT_CONFIRMATION_SECRET` and `PRACHT_REVALIDATE_TOKEN` in a gitignored
@@ -134,8 +151,10 @@ pracht build
134
151
  npx wrangler dev --config wrangler.local.jsonc --port 3000
135
152
  ```
136
153
 
137
- The local config must keep `main: "dist/server/worker.js"` and omit the
138
- production route. `pracht preview` does not forward Wrangler's `--config` flag.
154
+ The local config must keep `main: "dist/server/worker.js"`, keep
155
+ `no_bundle: true`, include the JavaScript `ESModule` rule, and omit the
156
+ production route. `pracht preview` does not forward Wrangler's `--config`
157
+ flag.
139
158
 
140
159
  ### Wrangler Configuration
141
160
 
@@ -144,6 +163,8 @@ production route. `pracht preview` does not forward Wrangler's `--config` flag.
144
163
  {
145
164
  "name": "my-pracht-app",
146
165
  "main": "dist/server/worker.js",
166
+ "no_bundle": true,
167
+ "rules": [{ "type": "ESModule", "globs": ["**/*.js", "**/*.mjs"] }],
147
168
  "compatibility_date": "2026-04-06",
148
169
  "assets": {
149
170
  "binding": "ASSETS",
@@ -153,7 +174,7 @@ production route. `pracht preview` does not forward Wrangler's `--config` flag.
153
174
  }
154
175
  ```
155
176
 
156
- `"binding": "ASSETS"` and `"run_worker_first": true` are required. Without the binding, the worker's `env.ASSETS` resolves to nothing and the runtime silently falls back to `null` — headers and ISG manifests load empty, so SSG serving, ISG revalidation, and per-route headers all silently no-op. The canonical config lives at `examples/cloudflare/wrangler.jsonc`. If you rename the binding with `assetsBinding` (below), the wrangler `binding` value must match.
177
+ `"no_bundle": true`, the JavaScript `ESModule` rule, `"binding": "ASSETS"`, and `"run_worker_first": true` are required. Without the first two settings, Wrangler either re-bundles Pracht's Vite output and folds lazy server chunks into the entry or omits those chunks from the upload. Without the binding, the worker's `env.ASSETS` resolves to nothing and the runtime silently falls back to `null` — headers and ISG manifests load empty, so SSG serving, ISG revalidation, and per-route headers all silently no-op. The canonical config lives at `examples/cloudflare/wrangler.jsonc`. If you rename the binding with `assetsBinding` (below), the wrangler `binding` value must match.
157
178
 
158
179
  ### Bindings (KV, D1, R2)
159
180
 
@@ -249,10 +270,14 @@ npx netlify deploy --build --prod
249
270
 
250
271
  The build emits `netlify/functions/pracht.mjs`. Page requests go through that
251
272
  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
273
+ hashed assets bypass it and stay outside the function bundle at the origin
274
+ root. With a Vite deploy base, the function instead bundles and serves the
275
+ base-free asset and `/_pracht` trees so `/app/...` requests remain inside the
276
+ mount. Custom `excludedPath` entries still bypass their literal origin-root
277
+ URLs, but matching files remain bundled for base-prefixed requests. The
278
+ generated config enumerates only client files the function can serve and roots
279
+ applicable exclusions at the function file so Netlify's tracer cannot re-add
280
+ bypassed trees. Netlify durable caching
256
281
  implements time-based ISG and per-path cache tags implement authenticated
257
282
  webhook revalidation. A trailing-slash ISG document request permanently
258
283
  redirects to the canonical slashless URL before rendering, and webhook
@@ -305,6 +330,87 @@ functions.
305
330
 
306
331
  ---
307
332
 
333
+ ## Static Export Deployment
334
+
335
+ For apps where every route is `render: "ssg"` (or loaderless, full-hydration
336
+ `"spa"`), with no
337
+ request middleware, API routes, or HTTP/MCP/WebMCP-exposed capabilities. SSG
338
+ loaders run only at build time and must produce HTML plus valid JSON route
339
+ state; dynamic SSG routes must export `getStaticPaths()`. Anything else fails the build with an error naming the
340
+ offenders — that is the signal to pick a serverful adapter instead. Only
341
+ manifest-registered capabilities participate; every registered capability
342
+ module must load successfully so exposure validation can fail closed. The
343
+ `notFound` page must use full hydration (the default), because the shared
344
+ `404.html` needs the client router to adopt the visitor's actual URL. Sub-path
345
+ deploys (GitHub Pages *project* sites, S3 key prefixes) set Vite `base` to that
346
+ path; CDN and document-relative bases (`""` / `"./"`) are build errors,
347
+ because they split assets from the deploy root or resolve them beneath nested
348
+ page directories. Under a base,
349
+ internal navigation must go through `<Link route>` / `href()` — a hand-written
350
+ `<a href="/about">` still means the origin root.
351
+ Pracht's preview and first-party serverful adapters redirect the bare base
352
+ (`/app`) to its trailing-slash form (`/app/`) before serving the root document;
353
+ custom adapters receive the same behavior through `handlePrachtRequest()`.
354
+ Framework-owned browser URLs from the default image loader and OpenAPI
355
+ companion artifacts pick up the same base automatically.
356
+
357
+ ### Setup
358
+
359
+ 1. Ensure `@pracht/adapter-static` is installed.
360
+ 2. In `vite.config.ts`:
361
+ ```ts
362
+ import { pracht } from "@pracht/vite-plugin";
363
+ import { staticAdapter } from "@pracht/adapter-static";
364
+ export default { plugins: [pracht({ adapter: staticAdapter() })] };
365
+ // With dynamic SPA routes, add { fallback: "200.html" } and configure the
366
+ // host to rewrite unmatched URLs to it. If the route or shell exports
367
+ // head(), also set generic fallbackHead metadata shared by every rewrite.
368
+ ```
369
+
370
+ ### Build & Deploy
371
+
372
+ ```bash
373
+ pracht build # dist/client/ is the whole deployment
374
+ pracht preview # local static file server over dist/client/
375
+ ```
376
+
377
+ Upload `dist/client/` to any static host (GitHub Pages, S3, nginx, Netlify).
378
+ `dist/server/` is build tooling only — never deploy it. The host must serve
379
+ `<dir>/index.html` for clean URLs and should use `404.html` as its error
380
+ document. A static `notFound` page must use full hydration so that shared
381
+ document can adopt the visitor's real URL. Client navigation fetches collision-safe
382
+ bounded opaque `.json` files under `_pracht/state/` for full-hydration SSG
383
+ routes whose loader or route/shell `head()` metadata participates in navigation;
384
+ equivalent raw-Unicode and percent-encoded URL segment spellings resolve to the
385
+ same state file. Explicitly loaderless and headless routes fetch no Pracht
386
+ state; loaderless routes with head metadata fetch static state for font-head
387
+ fragments but still use browser-side requests to an external API for live
388
+ data. Files under `public/_pracht/state/` may not occupy a generated
389
+ route-state path; the build rejects the collision instead of overwriting the
390
+ public file. Files copied from `public/` or emitted by Vite also may not occupy
391
+ the generated `404.html` or configured fallback path, including a case- or
392
+ Unicode-normalization-equivalent spelling; the build rejects the portable
393
+ collision instead of overwriting existing output. Generic `fallbackHead` fonts
394
+ remain registered while the fallback commits a loaderless dynamic SPA route.
395
+ See docs/ADAPTERS.md § Static Adapter for host header
396
+ configuration and limitations (markdown negotiation, base paths). Pages are
397
+ written to the percent-decoded output path, matching how static hosts resolve
398
+ requests; `pracht preview` decodes request segments the same way. The SPA fallback only client-renders matched SPA routes; dynamic
399
+ SSG paths omitted by `getStaticPaths()` render the app's not-found page with
400
+ the build-time loader data or handled error state carried over from `404.html`.
401
+ The host rewrite that serves the fallback answers unknown URLs with status 200 (soft 404), and an app
402
+ with no `notFound` page and no unshadowed client-routable SPA catch-all renders them blank — the build
403
+ warns about that shape. A dynamic SPA route, its shell, or the not-found page
404
+ with `head()` requires an explicit `fallbackHead`, because the shared static
405
+ document cannot evaluate URL-specific server metadata. Prerendered pages must
406
+ map to distinct portable filesystem paths; duplicate/case-folded or
407
+ Unicode-normalization-equivalent outputs, Windows-invalid or overlong filename
408
+ components, and file/directory conflicts such as `/` with `/index.html` fail
409
+ before any page is written. Fallback names likewise reject Windows reserved
410
+ device names and the portable 255-byte/code-unit component limit.
411
+
412
+ ---
413
+
308
414
  ## Deployment Checklist
309
415
 
310
416
  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
 
@@ -92,6 +98,10 @@ a markdown summary (graph diff + verify + budgets) worth attaching to it.
92
98
  validates every named export of the deploy entry and rejects the build
93
99
  metadata (`buildTarget`, manifests, `resolvedApp`, ...) that `server.js`
94
100
  exports for the prerender pass.
101
+ - `no_bundle` is `true`, with an `ESModule` rule whose globs include
102
+ `"**/*.js"`. Pracht's Vite output is already bundled and may contain lazy
103
+ server chunks; these settings make Wrangler upload the chunks as separate
104
+ modules instead of folding them into the entry file.
95
105
  - `assets.directory` points to `dist/client`.
96
106
  - `compatibility_date` is set, and is a date the installed workerd supports.
97
107
  It must not be *newer* than the runtime: workerd refuses to start with
@@ -122,8 +132,10 @@ a markdown summary (graph diff + verify + budgets) worth attaching to it.
122
132
  gateway with a normalized `cf.cacheKey`; also check that markdown-capable
123
133
  routes normalize `Accept` at the gateway when variant fan-out matters.
124
134
  - Bundle size: measure what actually deploys — `dist/server/worker.js` plus
125
- its `dist/server/server.js` import (wrangler bundles the import graph of
126
- `main`; `worker.js` alone is a few lines). Workers limit is ~1 MB
135
+ its `dist/server/server.js` import and lazy chunks (`no_bundle: true` plus
136
+ the JavaScript `ESModule` rule uploads the pre-built module graph;
137
+ `worker.js` alone is a few lines).
138
+ Workers limit is ~1 MB
127
139
  compressed for free tier, ~10 MB on paid. Warn at 80% of the active limit.
128
140
 
129
141
  ### Vercel (`@pracht/adapter-vercel`)
@@ -156,12 +168,67 @@ a markdown summary (graph diff + verify + budgets) worth attaching to it.
156
168
  `passthroughLoader` instead.
157
169
  - Build Output API v3 sanity: `config.json` has `version: 3`.
158
170
 
171
+ ### Static export (`@pracht/adapter-static`)
172
+
173
+ `adapterTarget` is `"static"`. There is no server to get wrong, so the
174
+ checklist is about what the *host* must do and what the build cannot enforce.
175
+
176
+ - `dist/client/` exists and is the deploy root. `dist/server/` is build tooling
177
+ only — it must not be uploaded (it contains the prerender bundle).
178
+ - The build itself is the gate: it fails closed on `ssr`/`isg` routes, SPA
179
+ loaders, non-full SPA hydration, API routes, route/not-found middleware,
180
+ network-exposed capabilities, and any Vite `base` that is not `/` or a
181
+ root-absolute path (CDN and document-relative bases are rejected). If
182
+ `pracht build` succeeded, those contracts already hold — do not re-derive
183
+ them by hand. Report a failing build verbatim; the message names the routes.
184
+ - Host must serve `index.html` for directory URLs (clean URLs). Confirm the
185
+ host's setting: S3 website endpoints need an index document, nginx needs
186
+ `try_files $uri $uri/index.html`, GitHub Pages and Netlify do it by default.
187
+ - Host must map `404.html` as the error document, otherwise unknown URLs get
188
+ the host's generic error page instead of the app's `notFound` route. Verify
189
+ `dist/client/404.html` exists; if it does not, the app declares no `notFound`
190
+ page — flag it as a `warn`.
191
+ - **Security headers are not applied.** Every other adapter sets the four
192
+ default security headers at request time; a static host has no request
193
+ runtime. `dist/server/headers-manifest.json` records the headers each route
194
+ *would* have carried — mirror the ones you need in the host's own header
195
+ config (`_headers` on Netlify, CloudFront response header policies, nginx
196
+ `add_header`). This is an `error` for any app handling user input, and
197
+ `warn` otherwise. HSTS and CSP are host-side decisions either way.
198
+ - If `staticAdapter({ fallback })` is configured, the host needs a rewrite of
199
+ unmatched URLs to that file, and the rewrite must not shadow real files.
200
+ Note that it makes unknown URLs answer `200` (soft 404s). Without the
201
+ rewrite the fallback file is inert — deep links into dynamic `render: "spa"`
202
+ routes will 404.
203
+ - Smoke test the real output, not the dev server:
204
+ `pracht preview --skip-build` serves `dist/client/` the way a dumb host
205
+ would. Check `/`, one dynamic SSG path, one deep link into a SPA route, and
206
+ one unknown URL.
207
+ - Routes exporting `markdown` rely on server-side `Accept` negotiation, which
208
+ a static host cannot do — agents asking for `text/markdown` get HTML. The
209
+ build prints a note when this applies; publish `.md` files under `public/`
210
+ if a raw-markdown corpus matters.
211
+ - Deploying to a sub-path (GitHub Pages *project* site, S3 key prefix) needs
212
+ Vite `base` set to that path (`base: "/my-project/"`). Check it matches the
213
+ deploy path exactly — a mismatch 404s every asset. Then check the app has no
214
+ hand-written root-absolute internal links (`<a href="/about">`): those are
215
+ not base-prefixed and will leave the deploy. `grep -rn 'href="/' src/` and
216
+ confirm each hit is external, an asset under `public/`, or a `<Link route>`.
217
+ Framework-owned URLs from `@pracht/image`'s `defaultLoader` and the OpenAPI
218
+ companion UI/document already carry the base; do not flag their base-free
219
+ route declarations. Custom image loaders and OpenAPI provider asset URLs
220
+ still need to match the intended host.
221
+ CDN bases (`https://cdn…`) and document-relative bases (`""` / `"./"`) are
222
+ build errors, not sub-path deploys.
223
+
159
224
  ## Step 4: Cross-cutting checks
160
225
 
161
226
  - Run `audit-secrets` to confirm no `process.env.*` or `context.env.*` values
162
227
  flow into loader return values.
163
228
  - Run `audit-headers` to confirm `applyDefaultSecurityHeaders` is in use on
164
229
  user-facing responses (or that `headers()` exports cover the same ground).
230
+ On a static export this check moves entirely to the host's header config —
231
+ see the static section above.
165
232
  - Confirm `git status` is clean (deploying uncommitted work is a footgun).
166
233
 
167
234
  ## Step 5: Report
@@ -179,9 +246,13 @@ status. End with a one-line verdict: `READY` / `BLOCKED (N errors)` /
179
246
  3. For Cloudflare/Vercel-edge, the Node-only API check is non-negotiable; an
180
247
  API not covered by the active compatibility flags will crash the worker on
181
248
  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
249
+ 4. For a static export, never report `READY` without naming the host settings
250
+ the deploy depends on (clean URLs, `404.html`, security headers, and the
251
+ fallback rewrite if configured). The build cannot verify any of them, so an
252
+ unqualified `READY` is the one way this skill can mislead.
253
+ 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.
254
+ 6. Do not deploy on the user's behalf. End the skill at the verdict.
255
+ 7. If `pracht doctor` reports errors, do not run any other checks until those
185
256
  are resolved — they will produce noisy false positives.
186
257
 
187
258
  $ARGUMENTS
@@ -107,8 +107,12 @@ same-origin anchor. It also accepts navigation-behavior props:
107
107
  `prefetch="none" | "hover" | "intent" | "viewport" | "render"` (per-link
108
108
  prefetch strategy, default `"intent"`), `preserveScroll` (keep the scroll
109
109
  position), and `viewTransition` (animate the navigation with the View Transitions API
110
- where supported). There is also an imperative `prefetch()` export and a
111
- `useNavigation()` hook for pending navigation/submission state.
110
+ where supported). Set `speculate={false}` on links that browser speculation
111
+ rules must not prefetch or prerender. Because it is independent of the JS
112
+ `prefetch` strategy, use both `speculate={false}` and `prefetch="none"` for GET
113
+ links with side effects. There is also an imperative
114
+ `prefetch()` export and a `useNavigation()` hook for pending
115
+ navigation/submission state.
112
116
 
113
117
  ### Outside components
114
118
 
@@ -33,8 +33,9 @@ pnpm list --depth 1 --json | grep -A2 '@pracht/' # or read package.json + lock
33
33
 
34
34
  The family: `@pracht/core`, `@pracht/cli`, `@pracht/vite-plugin`,
35
35
  `@pracht/adapter-node`, `@pracht/adapter-cloudflare`, `@pracht/adapter-vercel`,
36
- `@pracht/preact-ssr-precompile`, `@pracht/image`. Get the latest published
37
- versions with `npm view <pkg> version`.
36
+ `@pracht/preact-ssr-precompile`, `@pracht/content`, `@pracht/markdown`,
37
+ `@pracht/image`. Get the latest published versions with
38
+ `npm view <pkg> version`.
38
39
 
39
40
  ## Step 2: Understand the versioning model
40
41
 
@@ -75,6 +76,8 @@ https://raw.githubusercontent.com/JoviDeCroock/pracht/main/packages/<dir>/CHANGE
75
76
  | `@pracht/vite-plugin` | `packages/vite-plugin` |
76
77
  | `@pracht/adapter-node` / `-cloudflare` / `-vercel` | `packages/adapter-*` |
77
78
  | `@pracht/preact-ssr-precompile` | `packages/preact-ssr-precompile` |
79
+ | `@pracht/content` | `packages/content` |
80
+ | `@pracht/markdown` | `packages/markdown` |
78
81
  | `@pracht/image` | `packages/image` |
79
82
 
80
83
  Changelogs are changesets-generated: `## X.Y.Z` sections containing