create-pracht 0.7.1 → 0.7.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,20 @@
1
+ {
2
+ "@pracht/adapter-cloudflare": "^0.8.1",
3
+ "@pracht/adapter-netlify": "^0.3.1",
4
+ "@pracht/adapter-node": "^0.5.1",
5
+ "@pracht/adapter-static": "^0.2.1",
6
+ "@pracht/adapter-vercel": "^0.4.1",
7
+ "@pracht/capabilities": "^0.6.1",
8
+ "@pracht/cli": "^1.16.0",
9
+ "@pracht/content": "^0.2.0",
10
+ "@pracht/core": "^0.20.0",
11
+ "@pracht/i18n": "^0.1.6",
12
+ "@pracht/image": "^0.5.0",
13
+ "@pracht/markdown": "^0.1.5",
14
+ "@pracht/openapi": "^0.2.7",
15
+ "@pracht/preact-ssr-precompile": "^0.1.4",
16
+ "@pracht/query": "^0.1.1",
17
+ "@pracht/session": "^0.1.3",
18
+ "@pracht/test": "^0.3.1",
19
+ "@pracht/vite-plugin": "^0.15.0"
20
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "create-pracht",
3
- "version": "0.7.1",
3
+ "version": "0.7.3",
4
4
  "description": "Interactive and scriptable starter CLI for creating full-stack Preact apps with Pracht.",
5
5
  "keywords": [
6
6
  "pracht",
@@ -30,6 +30,7 @@
30
30
  "bin",
31
31
  "src",
32
32
  "skills",
33
+ "fallback-versions.json",
33
34
  "README.md"
34
35
  ],
35
36
  "type": "module",
@@ -40,6 +41,6 @@
40
41
  "provenance": true
41
42
  },
42
43
  "scripts": {
43
- "build": "node ./scripts/sync-skills.js"
44
+ "build": "node ./scripts/sync-skills.js && node ./scripts/sync-versions.js"
44
45
  }
45
46
  }
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: add-auth
3
- version: 2.1.0
3
+ version: 2.1.1
4
4
  description: |
5
5
  Wire session-based auth into a pracht app with `@pracht/session`: encrypted
6
6
  cookie sessions, gate middleware, login/logout/signup API routes, `<Form>`
@@ -170,23 +170,40 @@ Workers, measure a login against the plan's CPU limit.
170
170
 
171
171
  ## Step 5: Auth API routes
172
172
 
173
+ `src/server/redirects.ts` — the one gate for every request-derived redirect
174
+ target. Never use a `startsWith("/") && !startsWith("//")` prefix check: URL
175
+ parsing turns `\` into `/`, so `/\evil.com` passes it and lands off-origin.
176
+
177
+ ```ts
178
+ export function safeRedirectPath(value: unknown, base: URL, fallback: string): string {
179
+ if (typeof value !== "string" || !value.startsWith("/")) return fallback;
180
+ try {
181
+ const target = new URL(value, base);
182
+ return target.origin === base.origin
183
+ ? `${target.pathname}${target.search}${target.hash}`
184
+ : fallback;
185
+ } catch {
186
+ return fallback;
187
+ }
188
+ }
189
+ ```
190
+
173
191
  `src/api/auth/login.ts`:
174
192
 
175
193
  ```ts
176
194
  import { redirect, type ApiRouteArgs } from "@pracht/core";
177
195
 
196
+ import { safeRedirectPath } from "../../server/redirects.ts";
178
197
  import { sessions } from "../../server/session.ts";
179
198
  import { verifyCredentials } from "../../server/users.ts";
180
199
 
181
- export async function POST({ request }: ApiRouteArgs) {
200
+ export async function POST({ request, url }: ApiRouteArgs) {
182
201
  const form = await request.formData();
183
202
  const email = String(form.get("email") ?? "");
184
203
  const password = String(form.get("password") ?? "");
185
204
 
186
205
  // The redirect field is user input — gate it, or this is an open redirect.
187
- const requested = String(form.get("redirect") ?? "/dashboard");
188
- const target =
189
- requested.startsWith("/") && !requested.startsWith("//") ? requested : "/dashboard";
206
+ const target = safeRedirectPath(form.get("redirect"), url, "/dashboard");
190
207
 
191
208
  const user = await verifyCredentials(email, password);
192
209
  if (!user) {
@@ -226,11 +243,12 @@ export async function POST({ request }: ApiRouteArgs) {
226
243
  ```tsx
227
244
  import { Form, type LoaderArgs, type RouteComponentProps } from "@pracht/core";
228
245
 
246
+ import { safeRedirectPath } from "../server/redirects.ts";
247
+
229
248
  export async function loader({ url }: LoaderArgs) {
230
- const requested = url.searchParams.get("redirect") ?? "/dashboard";
231
249
  return {
232
250
  error: url.searchParams.get("error") === "1",
233
- redirect: requested.startsWith("/") && !requested.startsWith("//") ? requested : "/dashboard",
251
+ redirect: safeRedirectPath(url.searchParams.get("redirect"), url, "/dashboard"),
234
252
  };
235
253
  }
236
254
 
@@ -180,6 +180,15 @@ the *authoring guide* for coding agents; `pracht llms --write` drops it in the
180
180
  app root, which is exactly the confusing filename collision to avoid in an app
181
181
  that publishes its own index.
182
182
 
183
+ For agents, link entries to Markdown and split the full text by section:
184
+ `` link: (document) => `${document.path}.md` `` (only when those URLs exist,
185
+ e.g. via `rawContentArtifacts()`), and `fullPath: "/llms-guides.txt"` on a
186
+ section to emit a file with just its pages (with `origin` set, each page gets
187
+ a `Source:` line with its page URL; `sort` orders a section's entries). Section files and
188
+ `/llms-full.txt` are listed under a trailing `## Optional` heading; a section
189
+ that matches nothing emits no file. Duplicate or file/directory-colliding
190
+ artifact paths throw when the helper is defined.
191
+
183
192
  Localization trap: a string `section.match` is compared against the
184
193
  **locale-neutral** route, so `match: "/docs"` indexes only the default locale
185
194
  while `rawContentArtifacts()` publishes every translation. Pass a `match`
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: add-db
3
- version: 1.2.0
3
+ version: 1.2.1
4
4
  description: |
5
5
  Wire Drizzle ORM into a pracht app: pick the target (D1, PlanetScale, Neon,
6
6
  Supabase, Turso, Postgres, MySQL, SQLite), then generate driver setup, schema,
@@ -93,11 +93,13 @@ export const db = drizzle(pool, { schema });
93
93
  ```
94
94
 
95
95
  For Cloudflare D1, register the Cloudflare context type once via the `Register`
96
- augmentation (the pattern in
97
- `examples/docs/src/routes/docs/recipes-fullstack-cloudflare.md`):
96
+ augmentation. Keep the `import`: without it the file is a global script and the
97
+ declaration replaces `@pracht/core`'s types instead of extending them.
98
98
 
99
99
  ```ts
100
100
  // src/env.d.ts
101
+ import "@pracht/core";
102
+
101
103
  declare module "@pracht/core" {
102
104
  interface Register {
103
105
  context: {
@@ -1,9 +1,9 @@
1
1
  ---
2
2
  name: add-images
3
- version: 1.0.1
3
+ version: 1.1.0
4
4
  description: |
5
5
  Wire `@pracht/image`: the CLS-safe zero-runtime `<Image>`, the loader for your
6
- target (sharp endpoint, Cloudflare, Vercel, passthrough), `?pracht` build-time
6
+ target (sharp endpoint, Cloudflare, Vercel, Netlify, passthrough), `?pracht` build-time
7
7
  imports with blur placeholders, prebuilt WebP variants, and the optimization
8
8
  endpoint's security settings.
9
9
  Use for "add images", "optimize images", "responsive images", "next/image
@@ -39,6 +39,7 @@ pracht inspect build --json # adapterTarget (requires a prior `pracht build`)
39
39
  | Node | `defaultLoader` + the built-in endpoint | needs `sharp`; put a CDN in front |
40
40
  | Cloudflare | `cloudflareLoader` | Image Resizing must be enabled on the zone; sharp does not run on Workers |
41
41
  | Vercel | `vercelLoader` | needs an `images` section in the project config; Vercel only serves widths listed in `images.sizes` |
42
+ | Netlify | `netlifyLoader` | Image CDN; remote sources need `images.remote_images` in `netlify.toml`; no sharp endpoint |
42
43
  | Static host | `passthroughLoader` | no image service — srcset is omitted |
43
44
  | Any target, no runtime service | `?pracht&pracht-static` imports | prebuilt WebP variants, no loader involved |
44
45
 
@@ -76,9 +77,22 @@ configureImage({
76
77
 
77
78
  `createDefaultLoader("/my/endpoint")` builds a default-style loader for a custom
78
79
  endpoint path. Root-absolute endpoints pick up Vite's deploy `base`
79
- automatically; provider loaders (`cloudflareLoader`, `vercelLoader`)
80
+ automatically; provider loaders (`cloudflareLoader`, `vercelLoader`, `netlifyLoader`)
80
81
  deliberately stay at the origin root.
81
82
 
83
+ For Netlify, use `configureImage({ loader: import.meta.env.DEV ?
84
+ passthroughLoader : netlifyLoader })` so `pracht dev` renders original images.
85
+ Import both loaders from `@pracht/image`. For remote sources, merge the
86
+ allowed URL patterns into the existing `netlify.toml`:
87
+
88
+ ```toml
89
+ [images]
90
+ remote_images = ['https://images\.example\.com/.*']
91
+ ```
92
+
93
+ Local source files need no image-service configuration; Netlify negotiates
94
+ the output format and caches resized images. Skip the Node endpoint step.
95
+
82
96
  ## Step 4: Render images
83
97
 
84
98
  ```tsx
@@ -48,6 +48,12 @@ but trivial.
48
48
 
49
49
  ## Step 2: Server error tracking
50
50
 
51
+ Pracht already prints one line per 5xx to the server console — phase, route id,
52
+ source file, request path, message, and the stack — so the app is not silent
53
+ before any of this is wired. What that default does not give you is a request
54
+ id, a duration, the successful requests, or delivery to a sink off the box.
55
+ Say so when the user only wants "errors in my logs": they may already have it.
56
+
51
57
  ### Sentry path (Node adapter)
52
58
 
53
59
  The pattern below uses `@sentry/node` and works on the **Node adapter only**
@@ -274,10 +280,13 @@ Key properties to state when scaffolding this:
274
280
  - Delivery snapshots the registered sinks before callbacks run. Adding or
275
281
  replacing a sink from inside a callback takes effect on the next dispatch,
276
282
  so the current event is never delivered twice to one name.
277
- - On Cloudflare Workers, a batching exporter must flush within the request or
278
- be handed the execution context by app code
279
- (`context.executionContext.waitUntil(exporter.flush())`). Pracht does not
280
- call `ctx.waitUntil()` for a sink.
283
+ - A sink's returned promise is handed to the request's `waitUntil()`, so an
284
+ `async` sink finishes after the response on every adapter (Cloudflare
285
+ `ctx.waitUntil`, Netlify/Vercel `context.waitUntil`, Node's shutdown drain)
286
+ and its rejection is reported. A batching exporter that flushes on its own
287
+ schedule should be flushed from middleware with
288
+ `args.waitUntil(exporter.flush())` — never reach for
289
+ `context.executionContext.waitUntil`.
281
290
  - Audit events cover *dispatch* only. A cross-origin 403, an unknown-capability
282
291
  404, and an unknown MCP tool name all return before dispatch and emit
283
292
  nothing, so do not build a reconnaissance alert on these events — use the
@@ -238,17 +238,18 @@ With `agents.mcp.auth`, check:
238
238
  ## Step 5: The discovery surface
239
239
 
240
240
  - `llmsTxt` in the vite config: every listed path is a URL the app *invites* an
241
- agent to fetch. Cross-check the `exclude` list against routes behind auth
242
- middleware, internal tooling, and deliberate error routes — nothing about a
243
- middleware tells the framework whether it gates or merely logs, so an
244
- auth-gated route missing from `exclude` is a `warn` (see `docs/LLMS_TXT.md`).
241
+ agent to fetch. An auth-gated route missing from `exclude` is a `warn`: the
242
+ framework cannot tell a gating middleware from a logging one.
245
243
  Capabilities appear there with their effect class; destructive ones are
246
244
  annotated `requires confirmation`.
247
245
  - A collection-driven `llmsTxtArtifacts()` (see `/add-content`) is a second
248
- generator with its own coverage — compare what each publishes.
246
+ generator; compare what each publishes.
249
247
  - Routes exporting `markdown` or declaring `markdown: true` serve a Markdown
250
248
  representation to agents. Confirm the Markdown variant is not more permissive
251
249
  than the HTML page (it carries `Vary: Accept`, so it is separately cached).
250
+ SSG routes with a `markdown` export and no middleware also publish it as
251
+ `dist/client/<path>.md` (off with `markdownFiles: false`); confirm each
252
+ file is meant for every visitor.
252
253
  - An enabled OpenAPI document (`/openapi.json`, `dist/client/openapi.json`) is
253
254
  public unless the host protects it — check descriptions and examples for
254
255
  internal hostnames or credentials, and that "Try it out" mutation endpoints
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: audit-auth
3
- version: 1.4.0
3
+ version: 1.5.0
4
4
  description: |
5
5
  Find pracht routes that look protected but aren't: missing auth middleware,
6
6
  middleware that augments context but never gates, client-only checks, and
@@ -87,6 +87,13 @@ auth is enforced because `request.headers.get('x-user-id')` returns a value
87
87
  when present, but unauthenticated requests just get `null` and the loader has
88
88
  to handle it. Flag every loader downstream of an Augmenter that doesn't.
89
89
 
90
+ A shell `loader` that redirects anonymous users is **not** a gate either. A
91
+ client that already holds the shell's data sends `x-pracht-shell-data: <shell>`
92
+ on route-state requests and the server skips the shell loader, so the route's
93
+ data comes back without it running. Flag any shell whose loader is the only
94
+ thing between an anonymous request and the routes under it; the gate belongs
95
+ in middleware.
96
+
90
97
  ## Step 2: Identify protected routes
91
98
 
92
99
  A route is "expected protected" if any of:
@@ -165,6 +172,27 @@ target. From `pracht inspect api --json`:
165
172
  - Common bug: dashboard route is protected by middleware, but
166
173
  `POST /api/items` is not — attacker bypasses the UI entirely.
167
174
 
175
+ ### Server islands
176
+
177
+ Every module in `src/server-islands/` (or `pracht({ serverIslandsDir })`) is
178
+ reachable at `GET /__pracht/server-island` with the visitor's cookies, for any
179
+ page whose route or shell lists it in `export const serverIslands = [...]` —
180
+ with that route's middleware
181
+ (see <https://pracht.resynapse.dev/docs/server-islands#security>). Map each
182
+ server island to the routes that list it, directly or through their shell.
183
+
184
+ - Flag a server island listed by both gated and ungated routes whose loader
185
+ returns user-specific data without checking `context` itself — `error`: the
186
+ ungated route serves it to anyone.
187
+ - Flag pages that gate a server island only at render time
188
+ (`{isAdmin && <AdminStats />}` behind a loader check, not middleware) —
189
+ `error`: the endpoint runs it for anyone who passes that route's middleware.
190
+ - Flag server island loaders that pick *whose* data to load from `props`
191
+ (`userId`, `orderId`, `accountId`) — the caller controls the query string
192
+ (`error`, IDOR). Identity must come from `context`.
193
+ - Flag server island loaders with side effects (writes, sends) — the endpoint is
194
+ a `GET` (`warn`).
195
+
168
196
  ## Step 5: Client/server enforcement parity
169
197
 
170
198
  Grep client components for patterns like `if (!user) return <Login />`. For
@@ -56,6 +56,14 @@ reference with `dist/client/.vite/manifest.json` for chunk metadata
56
56
  (file, imports, dynamicImports, css, isEntry) — needed for vendor fan-in
57
57
  analysis (Step 4) and CSS sizing, which the analyze report does not cover.
58
58
 
59
+ Size CSS from the `cssManifest` this command reports, never from
60
+ `dist/client/.vite/manifest.json`. A `hydration: "none"` or `"islands"` route is
61
+ absent from the client manifest by construction, so reading it directly reports
62
+ no CSS for a page whose document links a stylesheet — under-reporting exactly
63
+ the routes pracht recommends. `inspect` merges `dist/server/css-manifest.json`,
64
+ the build's own record, over the client manifest; an older build without that
65
+ file is client-only, and the CSS column should say so rather than read as zero.
66
+
59
67
  ## Step 3: Interpret per-route payload
60
68
 
61
69
  From the Step 1 JSON, report:
@@ -191,6 +199,13 @@ rel="stylesheet">` tags on static pages, and the build warns.
191
199
  `build.cssCodeSplit: false` is rejected outright — there is no `index.html` to
192
200
  link the single stylesheet it produces, so every page would ship without one.
193
201
 
202
+ When a route's CSS is small and the site is entered cold — a static export, a
203
+ content site arriving from search — the stylesheet link is often the only
204
+ render-blocking request left on a page that ships no JavaScript. Report
205
+ `pracht({ inlineCss: true })` as a suggestion there, with the cost: the same
206
+ bytes in every document and no shared stylesheet cache. An app whose visitors
207
+ move between pages should keep the link.
208
+
194
209
  ## Step 7: Report
195
210
 
196
211
  Three sections:
@@ -121,6 +121,10 @@ into `speculation` (they emit an inline
121
121
  `<script id="pracht-state" type="application/json">` — it does not need
122
122
  `'unsafe-inline'`. Do not add `'unsafe-inline'` unless the app truly emits
123
123
  executable inline scripts and the tradeoff is documented.
124
+ 5. Routes with `streaming: true` emit executable inline scripts for deferred
125
+ data and boundary swaps. Under a nonce policy, return the request nonce as
126
+ `scriptNonce` from the shell `head()` and allow it in `script-src`; it also
127
+ covers the speculation rules script.
124
128
 
125
129
  Output a draft CSP referencing `docs/CSP.md` and explain what each origin is
126
130
  for. Do not hand the user a CSP that breaks their site — present as draft.
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: audit-islands
3
- version: 1.0.1
3
+ version: 1.1.0
4
4
  description: |
5
5
  Audit pracht hydration: over-hydrated routes that should be `hydration:
6
6
  "islands"` or `"none"`, dead interactivity outside the islands directory,
@@ -89,9 +89,22 @@ there is no wrapper to mark a component elsewhere as an island.
89
89
  Island props are serialized to JSON in the HTML. At each island call site on
90
90
  an islands route, flag props that are functions, symbols, bigints, class
91
91
  instances (`Date`, `Map`, ...), JSX elements, or circular — rendering throws a
92
- descriptive error naming the offending prop path. Passing children into an
93
- island from a server component also throws (unsupported in v1): move the
94
- content inside the island or pass a serializable prop.
92
+ descriptive error naming the offending prop path. Children passed into an
93
+ island are fine: markup renders once on the server as static HTML, and plain
94
+ text arrives as a string value. Flag:
95
+
96
+ - a render function passed as children (it throws);
97
+ - children the island expects to re-render or pass props into, and islands
98
+ among the children that read context the outer island provides (the browser
99
+ gives them the default) — those belong inside the island or need a prop;
100
+ - an island that splits markup children with `toChildArray`, `Children.map`, or
101
+ `Children.count` (tabs, carousels): it receives one opaque node, so labels
102
+ belong in props and panels are toggled through a ref (`hidden`);
103
+ - an island that renders `children` inside `<p>`, `<a>`, or `<button>` while
104
+ call sites pass block elements, links, or buttons (the dev server throws
105
+ when it sees this), or raw HTML in children that may be malformed: the
106
+ browser moves those nodes out of the slot and the island is left unhydrated
107
+ with a console error.
95
108
 
96
109
  ### 3d. Hydration strategy tuning (`info`)
97
110
 
@@ -115,7 +128,28 @@ navigation to, from, and between them is full-document (MPA-style), and
115
128
  route-state prefetching is skipped for them. Flag apps that rely on
116
129
  client-side state surviving navigation across such routes (in-memory stores,
117
130
  module-level caches shared between pages) — every navigation is a fresh
118
- document.
131
+ document, unless `pracht({ client: { islandsNavigation: true } })` is set. With
132
+ it, links between islands pages (and from them to `none` pages) swap the page
133
+ into the same document and an island rendered with identical props on both
134
+ pages keeps its state; module state survives too. Still flag state that must
135
+ survive a link to a full-hydration route, a reload, or a browser without the
136
+ Navigation API, since those remain full loads. Page-change animation does not need the client router: with
137
+ `defineApp({ viewTransitions: true })` these documents carry a
138
+ `@view-transition` rule and animate as cross-document view transitions, so
139
+ flag only apps that load extra JavaScript to animate such navigations.
140
+
141
+ ### 3g. Personal data in islands on cached pages (`warn`)
142
+
143
+ Island props on an `ssg`/`isg` route are fixed at build or regeneration time
144
+ and shared by every visitor. Flag islands that exist only to fetch per-visitor
145
+ content after load (a hand-rolled `fetch` of the session, cart, or "signed in
146
+ as" data in `useEffect`) and islands whose props look personal. Suggest a
147
+ server island instead (`src/server-islands/`, see
148
+ <https://pracht.resynapse.dev/docs/server-islands>): its
149
+ loader runs per request with the route's middleware context, and it may
150
+ contain islands, which hydrate once the server island is swapped in. On
151
+ full-hydration pages islands inside a server island stay static HTML — flag those
152
+ (`warn`) if they are expected to be interactive.
119
153
 
120
154
  ## Step 4: Report
121
155
 
@@ -139,5 +173,7 @@ finding.
139
173
  routes.
140
174
  4. Verify hydration in a running app via `html[data-pracht-islands-hydrated="true"]`
141
175
  (set after all `load` islands hydrate) and per-island `data-hydrated="true"`.
176
+ Server islands report `html[data-pracht-server-islands-ready="true"]` once every server
177
+ island fetch the page started has settled, on every hydration mode.
142
178
 
143
179
  $ARGUMENTS
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: audit-loaders
3
- version: 1.1.1
3
+ version: 1.2.0
4
4
  description: |
5
5
  Audit pracht route loaders for serializability, leaked secrets, unsafe
6
6
  `loaderCache`, browser-only API use, and missing `AbortSignal` plumbing.
@@ -38,13 +38,22 @@ manifest (`RouteConfig.loader`); the inspect JSON surfaces that as
38
38
  `loaderFile` (null when the loader lives in the route module itself). Reading
39
39
  only `file` misses every externalized loader.
40
40
 
41
+ Shells can export a `loader` too. Read each distinct `shellFile` from the same
42
+ JSON and audit its `loader` like a route's: its return value reaches the
43
+ browser as `shellData` on every page under that shell.
44
+
41
45
  ## Step 2: Run the five checks
42
46
 
43
47
  For each `loader` (and `getStaticPaths` when present):
44
48
 
45
49
  ### 2a. Serializability
46
50
 
47
- Flag returns that contain any of:
51
+ Skip this check for `hydration: "islands"` and `"none"` routes: they ship no
52
+ loader data. Otherwise, check `vite.config.*` for
53
+ `pracht({ client: { richData: true } })` first.
54
+
55
+ Without `richData` (the default), loader data is `JSON.stringify`'d while the
56
+ component's types still promise the original. Flag returns that contain any of:
48
57
 
49
58
  | Construct | Why it breaks |
50
59
  | ---------------------------- | ----------------------------------- |
@@ -57,6 +66,13 @@ Flag returns that contain any of:
57
66
  | `bigint` | `JSON.stringify` throws |
58
67
  | `undefined` in arrays/object | Drops keys; arrays become `null` |
59
68
 
69
+ With `richData`, `Date`, `Map`, `Set`, `URL`, `RegExp`, `bigint`, `undefined`,
70
+ and shared or circular references arrive intact: do not flag them. Flag
71
+ functions, symbols, typed arrays, and class instances without `toJSON()`
72
+ (`Error` included) instead: each fails the request with a 500 naming the path,
73
+ in production too. A class instance with `toJSON()` arrives as its output, so
74
+ its type lies.
75
+
60
76
  A bare promise in loader data is always a bug — it serializes to `{}`. The fix
61
77
  is `defer(promise)`, which marks the field as deferred and is read in the
62
78
  component with `use()` inside a `<Suspense>` boundary. Flag a bare promise as an
@@ -73,7 +89,8 @@ Two `defer()` rules worth checking while you are in the loader:
73
89
  behind a getter. An unresolved marker throws during serialization.
74
90
 
75
91
  Recommend converting to `string` (ISO for dates), plain arrays, or plain objects
76
- before return.
92
+ before return, or turning on `client.richData` when an app returns many dates
93
+ or maps.
77
94
 
78
95
  ### 2b. Secret leaks
79
96
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: audit-redirects
3
- version: 1.1.1
3
+ version: 1.1.2
4
4
  description: |
5
5
  Find open redirects in pracht loaders, middleware, and navigation. The framework
6
6
  rejects unsafe schemes and CRLF at both ends, but a hand-rolled 3xx Response
@@ -86,16 +86,18 @@ For each request-derived target, look for one of:
86
86
  | Gate | Safe? |
87
87
  | -------------------------------------- | ----- |
88
88
  | Hardcoded allowlist of paths/origins | Yes |
89
- | `target.startsWith('/')` AND `!target.startsWith('//')` | Yes — same-origin path only |
90
89
  | `new URL(target, base).origin === url.origin` | Yes — origin comparison |
90
+ | `target.startsWith('/')` AND `!target.startsWith('//')` | **Bypassable** (`/\evil.example`) |
91
91
  | `new URL(target).hostname === expected` | Yes if `expected` is trusted |
92
92
  | No check | **Open redirect** |
93
93
  | `target.includes(domain)` (substring) | **Bypassable** (`evil.com#yourdomain.com`) |
94
94
  | Regex without anchors | **Likely bypassable** |
95
95
 
96
- `startsWith('/')` alone is **not** sufficient — `//evil.example/path` parses
97
- as a protocol-relative URL and most browsers treat it as cross-origin. Require
98
- both `startsWith('/')` AND `!startsWith('//')`, or use `URL` parsing.
96
+ Prefix checks are **not** sufficient. `//evil.example/path` is
97
+ protocol-relative, and URL parsing turns `\` into `/` and strips tabs and
98
+ newlines, so `/\evil.example` and `/\t/evil.example` pass
99
+ `startsWith('/') && !startsWith('//')` yet land on another origin. Only an
100
+ origin comparison after `new URL(target, url)` is a real gate.
99
101
 
100
102
  Remember: `redirect()`'s built-in validation covers scheme and CRLF only — it
101
103
  happily redirects to any well-formed `http(s)` origin, so Class A sites still
@@ -118,9 +120,21 @@ Severity is the primary scale; the verdict is a secondary domain label:
118
120
  For each `open`/`risky` finding, propose a fix snippet, e.g.:
119
121
 
120
122
  ```ts
121
- const raw = url.searchParams.get("redirect") ?? "/dashboard";
122
- const safe = raw.startsWith("/") && !raw.startsWith("//") ? raw : "/dashboard";
123
- return redirect(safe, { request });
123
+ function safeRedirectPath(value: unknown, base: URL, fallback: string): string {
124
+ if (typeof value !== "string" || !value.startsWith("/")) return fallback;
125
+ try {
126
+ const target = new URL(value, base);
127
+ return target.origin === base.origin
128
+ ? `${target.pathname}${target.search}${target.hash}`
129
+ : fallback;
130
+ } catch {
131
+ return fallback;
132
+ }
133
+ }
134
+
135
+ return redirect(safeRedirectPath(url.searchParams.get("redirect"), url, "/dashboard"), {
136
+ request,
137
+ });
124
138
  ```
125
139
 
126
140
  ## Step 5: Cross-check with the framework guards
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: audit-secrets
3
- version: 1.1.1
3
+ version: 1.2.0
4
4
  description: |
5
5
  Detect environment variables and secrets reaching the client bundle via loader
6
6
  return values, hydration state, or server-only modules imported from client code
@@ -74,8 +74,9 @@ risk in the report.
74
74
 
75
75
  ## Step 3: Module import boundaries
76
76
 
77
- Grep client-rendered files (route components, shells, anything imported from
78
- them) for imports of:
77
+ Grep client-rendered files (route components, shells, the app root registered
78
+ as `defineApp({ root })` or `pages/_root.tsx`, anything imported from them) for
79
+ imports of:
79
80
 
80
81
  - `node:*` builtins
81
82
  - `@pracht/adapter-*`
@@ -87,12 +88,22 @@ The Vite plugin strips the server-only exports `loader`, `head`, `headers`,
87
88
  query (`middleware` is not a route-file export — middleware lives in the
88
89
  manifest); see the client module transform notes in `docs/ARCHITECTURE.md`
89
90
  and `docs/ENV.md`. But a component that imports `../server/db` will still
90
- pull `db` into the client bundle. Flag those imports.
91
+ pull `db` into the client bundle. Flag those imports. The app root's browser
92
+ copy loses only `dehydrate` and the imports nothing else uses; `setup()`,
93
+ `Root`, `hydrate()`, and everything they import ship. A `dehydrate` bound in
94
+ the same destructuring as those (`export const { setup, Root, dehydrate,
95
+ hydrate } = createRoot(…)`) or reached through `export *` ships with them, so
96
+ flag a server-only value passed into that call. A root whose other code calls
97
+ or re-exports `dehydrate` ships whole; flag its server-only imports too.
91
98
 
92
99
  ## Step 4: Hidden surfaces
93
100
 
94
101
  Check for accidental exposure outside loaders:
95
102
 
103
+ - The app root's `dehydrate()` snapshot: it lands in `pracht-state` as `root`
104
+ and in every route-state response, like loader data. For `@pracht/query`
105
+ that is every successful query cached during the request, so flag a
106
+ `queryFn` or `setQueryData` in a loader that caches a secret-bearing value.
96
107
  - `head()` returns: rare, but a `meta` value containing a token leaks into HTML.
97
108
  - `headers()` returns: flag values that look like secrets. For SSG/ISG pages,
98
109
  document headers enter `dist/server/headers-manifest.json`; every serverful
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: audit-shells
3
- version: 1.3.0
3
+ version: 1.4.0
4
4
  description: |
5
5
  Audit pracht shells: missing `Loading()` on SPA routes, `<html>`/`<head>`/
6
6
  `<body>` rendered inside a shell, shells that never render `children`, unused
@@ -92,6 +92,22 @@ route `headers()`). Optional, but if present:
92
92
  - Flag shells whose `headers()` returns `undefined` unconditionally — delete
93
93
  the export.
94
94
 
95
+ ### 2g. `loader` export
96
+
97
+ Shells may export `loader(args)` for layout-level data, read with
98
+ `useShellData()` in the shell and its routes. If present:
99
+ - Run the audit-loaders checks on it: its return value is serialized into
100
+ every page under the shell (`shellData` in the hydration state and the
101
+ route-state JSON).
102
+ - `useShellData()` returns `undefined` while the shell renders without its
103
+ data (SPA loading state, an error boundary after the shell loader failed).
104
+ Flag a shell that dereferences it unguarded.
105
+ - Flag a shell loader used as an auth gate (redirecting anonymous users) with
106
+ no gating middleware on its routes: navigations inside the shell ask the
107
+ server to skip it. Gate in middleware.
108
+ - Flag several routes under one shell whose loaders each fetch the same
109
+ layout-level value (the current user): that belongs in the shell loader.
110
+
95
111
  ## Step 3: Coverage and waste
96
112
 
97
113
  Shell registration is mode-aware — check the `mode` field from the Step 1
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: migrate-nextjs
3
- version: 1.7.0
3
+ version: 1.8.1
4
4
  description: |
5
5
  Migrate a Next.js app to pracht: App or Pages Router pages, layouts, middleware,
6
6
  API routes, data fetching, and metadata — plus React→Preact, `className`→`class`,
@@ -74,7 +74,7 @@ automatic**:
74
74
  | ------------------------------- | --------------------------------------------------------------- | --------------------------------------------------------------------- |
75
75
  | `pages/` directory | `pagesDir` plugin option | Auto-discovers routes from the file system |
76
76
  | `app/page.tsx` | `src/routes/*.tsx` + `route()` in manifest | File is a module; wiring is explicit |
77
- | `app/layout.tsx` | `src/shells/*.tsx` + `shells` in `defineApp` (pages: `_app.tsx` per directory) | Named shells; a directory `_app` replaces its parent instead of nesting |
77
+ | `app/layout.tsx` | `src/shells/*.tsx` + `shells` in `defineApp` (pages: `_app.tsx` per directory) | Named shells; a directory `_app` replaces its parent. Layout data: shell `loader` + `useShellData()` |
78
78
  | `app/loading.tsx` | `Loading` export on the shell | SSR placeholder for SPA routes until the client router takes over |
79
79
  | `app/error.tsx` | `ErrorBoundary` export in route module | Same concept, different wiring |
80
80
  | `app/not-found.tsx` | `notFound:` in `defineApp` (or `pages/404.tsx` in pagesDir mode) | Not a route — never matches a URL, so it cannot shadow static assets |
@@ -87,7 +87,7 @@ automatic**:
87
87
  | `"use client"` (few, mostly-server app) | `hydration: "islands"` + `src/islands/` | Only islands ship JS; see Phase 4 |
88
88
  | `revalidatePath` / `res.revalidate()` | `webhookRevalidate()` + `POST /__pracht/revalidate` | On-demand ISG; combinable with `timeRevalidate(seconds)` |
89
89
  | `useRouter()` (next/navigation) | `useNavigate()` | Takes a path, or `{ route: "id" }` after `pracht typegen` |
90
- | `useSearchParams()` | `useSearchParams()` | Reactive read-only params; SSG gets the browser query after hydration, loaders use `url.searchParams` |
90
+ | `useSearchParams()` | `useSearchParams()` | Reactive read-only params; SSG gets the browser query after hydration. For validated, typed values export a `search` schema and read `args.search` / `useSearch()` |
91
91
  | `useParams()` | `useParams()` | Direct equivalent; also `params` in loader args |
92
92
  | `next/link` `<Link>` | `<Link route="..." params={{…}}>` or plain `<a>` | Prefer typed `<Link>` after `pracht typegen`; the router intercepts same-origin anchors |
93
93
  | `next/link` `prefetch={false}` | `<Link prefetch="none">` | Default `"intent"` (hover/focus); also `"viewport"`, `"render"` |
@@ -337,8 +337,8 @@ and fix errors iteratively.
337
337
  | `next` | `@pracht/core` + `@pracht/cli` + `@pracht/vite-plugin` + a target adapter |
338
338
  | `next/image` | `@pracht/image` |
339
339
  | `react`, `react-dom` | `preact` |
340
- | `next/font/local` | `defineFont()` from `@pracht/core` — register via `head() { return { fonts: [font] } }`, use `font.className`/`font.style` |
341
- | `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 |
340
+ | `next/font/local` | Import WOFF2 with `?url&no-inline` into [`defineFont()`](https://pracht.resynapse.dev/docs/fonts); register `head().fonts`, apply `font.className`/`font.style` |
341
+ | `next/font/google` | Self-host WOFF2 in `src/fonts/`, then follow `next/font/local`; pracht never fetches fonts at build time |
342
342
  | `@next/mdx` | `@mdx-js/rollup` (Vite plugin) |
343
343
  | `next-auth` | Direct integration in middleware/loaders |
344
344
  | `next/og` | `@vercel/og` or a custom solution |
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pracht-debug
3
- version: 1.4.0
3
+ version: 1.5.0
4
4
  description: |
5
5
  Framework-aware debugging for pracht: route matching, loader and API errors,
6
6
  rendering and hydration, middleware, HMR, and build failures — root cause first.
@@ -33,6 +33,7 @@ Reach for these before deep manual inspection:
33
33
  | ---- | --- |
34
34
  | `pracht verify` (`--changed` to scope to git-changed files) | Fast confidence check |
35
35
  | `pracht doctor` | Broader broken wiring or missing files |
36
+ | `pracht docs <topic>` / `pracht docs --search <query>` | Docs bundled with the installed CLI — check documented behavior before calling it a bug. Older CLIs lack the command; read https://pracht.resynapse.dev/llms.txt instead |
36
37
  | `pracht inspect routes\|api\|build --json` | The resolved graph — never reconstruct it from source |
37
38
  | `GET /_pracht` (JSON at `/_pracht.json`) | Same graph from a running dev server, no CLI needed |
38
39
  | `Server-Timing` on dev SSR responses | `mw` / `loader` / `render` durations in ms — which phase is slow |
@@ -92,7 +93,9 @@ Work in order; stop at the root cause.
92
93
  ### 3. Loader / API errors
93
94
 
94
95
  - On slow pages, read `Server-Timing` before reading code.
95
- - Loaders must return serializable data — no functions, no circular refs.
96
+ - Loaders must return serializable data — no functions, no circular refs. A
97
+ `Date` arrives as a string unless `pracht({ client: { richData: true } })` is
98
+ set; with it, an unsupported value fails with an error naming its path.
96
99
  - API handlers must return `Response` objects, and a default export must branch
97
100
  on `request.method`.
98
101
  - Look for unhandled rejections or thrown errors.
@@ -75,6 +75,14 @@ step (`--port <n>`, `--skip-build` to reuse a build).
75
75
  `Accept-Encoding`, streaming for dynamic bodies, an in-memory LRU for static
76
76
  assets). Behind a proxy or CDN that already compresses, set
77
77
  `nodeAdapter({ compression: false })` to avoid doing it twice.
78
+ - The generated server drains on `SIGTERM`/`SIGINT`: it stops accepting
79
+ connections and waits for in-flight requests and `args.waitUntil()` work up
80
+ to `nodeAdapter({ shutdownTimeoutMs })` (default 10s). Keep it below the
81
+ process manager's kill grace period. A custom `createNodeRequestHandler()`
82
+ server calls `await handler.drain(ms)` after `server.close()` instead.
83
+ - Background work after the response is `args.waitUntil(promise)` on every
84
+ adapter — do not wire `executionContext.waitUntil` (Cloudflare) or the
85
+ platform context yourself.
78
86
 
79
87
  Docker:
80
88
 
@@ -280,8 +288,11 @@ SSG loaders run only at build time and must produce HTML plus valid JSON route
280
288
  state; dynamic SSG routes must export `getStaticPaths()`. Only
281
289
  manifest-registered capabilities participate, and every registered capability
282
290
  module must load successfully so exposure validation fails closed. The
283
- `notFound` page must use full hydration (the default) because the shared
284
- `404.html` needs the client router to adopt the visitor's actual URL.
291
+ `notFound` page may set `hydration: "islands"` or `"none"` to keep the client
292
+ router out of `404.html` — the router is there only to replace the build URL
293
+ with the visitor's, so a 404 that does not name the requested URL pays nothing
294
+ for it. `staticAdapter({ fallback })` still requires full hydration there,
295
+ because that document is built from the not-found page's route state.
285
296
 
286
297
  **Deploy base.** Sub-path deploys (GitHub Pages *project* sites, S3 key
287
298
  prefixes) set Vite `base` to that path. CDN and document-relative bases (`""`,
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pracht-scaffold
3
- version: 1.4.0
3
+ version: 1.5.0
4
4
  description: |
5
5
  Scaffold pracht code with the native generators (`pracht generate
6
6
  route|shell|middleware|api`), falling back to manual edits only when the CLI
@@ -49,7 +49,9 @@ If the CLI can express the request, do not reimplement the scaffold by hand.
49
49
  | `--revalidate` | ISG window in seconds (`isg` only, default 3600) |
50
50
  | `--json` | Machine-readable output |
51
51
 
52
- `generate shell` and `generate middleware` take `--name`; `generate api` takes
52
+ `generate shell` and `generate middleware` take `--name`; `generate shell
53
+ --loader` adds a shell `loader` export read in the shell with `useShellData()`
54
+ (layout-level data such as the signed-in user). `generate api` takes
53
55
  `--path` and `--methods`. All subcommands accept `--json` — use it when another
54
56
  agent or tool consumes the output. When the pracht MCP server is registered
55
57
  (docs/MCP.md), call the `generate_route`/`generate_shell`/`generate_middleware`/
@@ -82,8 +84,8 @@ agent or tool consumes the output. When the pracht MCP server is registered
82
84
 
83
85
  | Kind | Directory | Key exports |
84
86
  | ---------- | ----------------- | -------------------------------------------------------------------------------- |
85
- | Route | `src/routes/` | `loader`, `head`, `Component`, `ErrorBoundary`, `getStaticPaths` |
86
- | Shell | `src/shells/` | `Shell`, `head` |
87
+ | Route | `src/routes/` | `loader`, `head`, `Component`, `ErrorBoundary`, `getStaticPaths`, `search` |
88
+ | Shell | `src/shells/` | `Shell`, `loader` (read with `useShellData()`), `head`, `headers`, `Loading`, `ErrorBoundary` |
87
89
  | Middleware | `src/middleware/` | `middleware` |
88
90
  | API route | `src/api/` | Named method handlers (`GET`, `POST`, `PUT`, `PATCH`, `DELETE`) or one default dispatcher |
89
91
 
@@ -93,6 +93,14 @@ For a PR deploy, `pracht report --base origin/main` produces a markdown summary
93
93
  true })` is set; application code should still observe the public base in
94
94
  `request.url`, and the proxy must own the bare-base redirect (`/app` →
95
95
  `/app/`).
96
+ - If the app registers background work with `args.waitUntil()`, the process
97
+ manager sends `SIGTERM` (not `SIGKILL`) on stop and its grace period exceeds
98
+ `nodeAdapter({ shutdownTimeoutMs })` (default 10s); otherwise that work is
99
+ cut off on every deploy. A custom server entry must call `handler.drain()`
100
+ itself.
101
+ - Background work still reaching for `context.executionContext.waitUntil` or a
102
+ platform context should move to the portable `args.waitUntil()` — the
103
+ platform-specific call is undefined on every other adapter.
96
104
 
97
105
  ### Cloudflare Workers (`@pracht/adapter-cloudflare`)
98
106
 
@@ -197,10 +205,11 @@ must do and what the build cannot enforce.
197
205
  - Smoke test the real output, not the dev server: `pracht preview --skip-build`
198
206
  serves `dist/client/` the way a dumb host would. Check `/`, one dynamic SSG
199
207
  path, one deep link into a SPA route, and one unknown URL.
200
- - Routes exporting `markdown` rely on server-side `Accept` negotiation, which a
201
- static host cannot do — agents asking for `text/markdown` get HTML. The build
202
- prints a note; publish `.md` files under `public/` if a raw-markdown corpus
203
- matters.
208
+ - A static host cannot negotiate `Accept`, so agents read an SSG page's
209
+ Markdown at `/<path>.md` (written by the build for routes with a `markdown`
210
+ export and no middleware). Check the host serves `.md` as
211
+ `text/markdown; charset=utf-8` — nginx's stock `mime.types` lacks it — and
212
+ read the build note naming Markdown-capable pages that got no file.
204
213
  - Sub-path deploys (GitHub Pages *project* site, S3 key prefix) need Vite
205
214
  `base` set to that path (`base: "/my-project/"`), matching the deploy path
206
215
  exactly — a mismatch 404s every asset. Then confirm no hand-written
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: tune-render-mode
3
- version: 1.1.1
3
+ version: 1.2.0
4
4
  description: |
5
5
  Recommend the right render mode (ssg, isg, ssr, spa) per route from what each
6
6
  loader actually does, then apply the change after confirmation.
@@ -53,6 +53,18 @@ For each route:
53
53
  anything personalized** → **`ssr`**
54
54
  - Auth dashboards, anything user-specific, anything that varies by user
55
55
  identity at request time.
56
+ - **Unless only a small part of the page is personal** (a cart count,
57
+ "signed in as …", a recommendation strip) and the rest is shared: then
58
+ recommend **`ssg`/`isg`** for the route plus a **server island** for
59
+ the personal part (see branch 5b).
60
+
61
+ 5b. **Personal data is confined to a small, self-contained part of the page**
62
+ → keep the route **`ssg`/`isg`** and move that part into `src/server-islands/`:
63
+ a default-export component plus a `loader` that reads `context` instead of
64
+ the page loader, used as JSX with a `fallback`. See
65
+ <https://pracht.resynapse.dev/docs/server-islands>. Not available on the static
66
+ adapter. Server island props are untrusted input — the server island loader must
67
+ authorize from `context` itself.
56
68
 
57
69
  6. **Heavy client interactivity, no SEO need, auth-gated** → **`spa`**
58
70
  - Internal admin tools, post-login dashboards where the first paint can be a
@@ -108,6 +120,9 @@ Examples of recommendations:
108
120
  request — cannot be prerendered." (`error`)
109
121
  - `spa` → `ssr` when route has SEO-relevant `head()` and unauthenticated
110
122
  visitors should see content. (`warn`)
123
+ - `ssr` → `isg(600)` + server island when the loader reads the session only for a
124
+ header greeting or cart badge: "move the personal part into
125
+ `src/server-islands/`; the page itself is shared." (`info`)
111
126
 
112
127
  ## Step 3b: Consider the hydration mode too
113
128
 
@@ -124,7 +139,8 @@ field (absent from the JSON), grep the manifest for `hydration:` (pages apps:
124
139
  box, newsletter form) → `hydration: "islands"` with the widgets moved to
125
140
  `src/islands/`.
126
141
  - Caveats: islands routes use MPA-style full-document navigation (no client
127
- router), island props must be JSON-serializable, and `render: "spa"` cannot
142
+ router) unless `client.islandsNavigation` swaps islands pages in place,
143
+ island props must be JSON-serializable, and `render: "spa"` cannot
128
144
  combine with `"islands"`/`"none"`.
129
145
 
130
146
  ## Step 3c: Consider streaming for slow SSR routes
@@ -190,6 +206,8 @@ Apply the edits only after the user confirms.
190
206
  1. Never silently change render modes. Always present the recommendation and
191
207
  the exact diff first; apply only after explicit user approval.
192
208
  2. If a route uses `auth` middleware, default to `ssr` — auth implies cookies.
209
+ The exception is middleware that only *reads* the session for a small
210
+ personal part of an otherwise public page: that is the server island case (5b).
193
211
  3. All three adapters support ISG — the mechanisms differ. Confirm which
194
212
  adapter is in play, then use this capability table:
195
213
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: typed-routes
3
- version: 1.1.1
3
+ version: 1.3.0
4
4
  description: |
5
5
  Add or maintain pracht typed routes: run typegen, adopt route-id based links and
6
6
  navigation, and replace hard-coded hrefs with generated helpers.
@@ -79,6 +79,10 @@ when `src/pracht.d.ts` does not exist. In CI, prefer:
79
79
  pracht typegen --check
80
80
  ```
81
81
 
82
+ The check compares declarations, not bytes, so generated files reformatted by
83
+ the project's formatter still read as up to date and regenerating leaves them
84
+ formatted. Do not add them to a formatter ignore list on that account.
85
+
82
86
  ## Step 3: Replace string navigation where it matters
83
87
 
84
88
  ### Components
@@ -149,6 +153,46 @@ loader type their data as `undefined`. The id must be the active route: a
149
153
  mismatch throws (with a descriptive message in dev), so a shell or island that
150
154
  needs another route's data must receive it as props instead.
151
155
 
156
+ Shell loaders register the same way: `useShellData("app")` types the result
157
+ from the `app` shell's `loader` for every shell a route renders under. It is
158
+ typed `| undefined` because the shell can render without its data (the SPA
159
+ loading state), and naming a shell the active route does not use throws.
160
+
161
+ ### Search params
162
+
163
+ A route that reads its query string should export a `search` schema (any
164
+ Standard Schema validator) instead of parsing `url.searchParams` by hand.
165
+ Typegen then types `search` on `<Link>`, `navigate()`, and `href()` from the
166
+ schema's input, and `useSearch(routeId)` returns its output:
167
+
168
+ ```tsx
169
+ import { Link, useSearch, type LoaderArgs, type SearchArgs } from "@pracht/core";
170
+ import * as z from "zod";
171
+
172
+ export const search = z.object({
173
+ page: z.coerce.number().int().min(1).default(1),
174
+ q: z.string().optional(),
175
+ });
176
+
177
+ export async function loader(args: LoaderArgs & SearchArgs<typeof search>) {
178
+ return listProducts(args.search); // { page: number; q?: string }
179
+ }
180
+
181
+ export function Component() {
182
+ const { page, q } = useSearch("products");
183
+ return <Link route="products" search={{ page: page + 1, q }}>Next</Link>;
184
+ }
185
+ ```
186
+
187
+ - Values arrive as strings (arrays for repeated keys): coerce with
188
+ `z.coerce.*`, and give optional params defaults so the bare URL is valid. A
189
+ key that may repeat is a plain string when it appears once — accept both.
190
+ - A rejected query answers 400 and renders the route's `ErrorBoundary` with
191
+ `error.issues`; client navigation does the same.
192
+ - SSG/ISG routes are prerendered with an empty query, so the schema must
193
+ accept one; loaders there only ever see the defaults.
194
+ - Keep `useSearchParams()` for raw access; it is unchanged.
195
+
152
196
  ### API routes
153
197
 
154
198
  After typegen, `apiFetch()` type-checks API calls end to end — paths,
@@ -182,8 +226,11 @@ Generated param types accept `RouteParamInput = string | number | boolean`
182
226
  - `:path*` requires `params: { path: RouteParamInput }`.
183
227
  - Routes with no dynamic segments should omit `params`.
184
228
  - Missing and extra params should fail at typecheck time.
185
- - `search` currently accepts `string`, `URLSearchParams`, or an object of
186
- primitive values/arrays; route-specific search schemas can be added later.
229
+ - `search` is typed by the route module's `search` schema input when it
230
+ exports one: unknown keys and wrong value types fail, keys whose input
231
+ cannot come from a string (`z.number()`) fail, and a required key makes
232
+ `search` itself required. Routes without a schema accept `string`,
233
+ `URLSearchParams`, or an object of primitive values/arrays.
187
234
 
188
235
  ## Step 5: Verify
189
236
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: upgrade-pracht
3
- version: 1.1.0
3
+ version: 1.3.0
4
4
  description: |
5
5
  Upgrade the `@pracht/*` packages safely: inventory installed versions, read the
6
6
  changelogs between installed and target, map breaking changes to real usage,
@@ -120,6 +120,9 @@ pnpm up '@pracht/core@<v>' '@pracht/cli@<v>' '@pracht/vite-plugin@<v>' <adapters
120
120
 
121
121
  Upgrade every installed `@pracht/*` package in the same command. Apply the
122
122
  agreed code migrations with minimal diffs, one changelog entry at a time.
123
+ Then read the page behind each changed API with `pracht docs <topic>`, which
124
+ serves the docs bundled with the newly installed CLI. A CLI without that
125
+ command predates it; use https://pracht.resynapse.dev/llms.txt instead.
123
126
 
124
127
  ## Step 6: Verification ladder
125
128
 
@@ -130,12 +133,17 @@ pracht doctor --json # wiring still valid
130
133
  pracht typegen --check # generated route types up to date?
131
134
  pracht typegen # regenerate if --check failed or routes changed
132
135
  pracht verify --json # framework-aware checks
136
+ pnpm typecheck # both tsconfig programs, if the app has the script
133
137
  pracht build # full production build (budgets included)
134
138
  pnpm test # the app's own suite
135
139
  ```
136
140
 
137
141
  `pracht doctor`, `verify`, and `typegen --check` exit non-zero on failure, so
138
- they gate CI cleanly.
142
+ they gate CI cleanly. Act on doctor *warnings* too: `pnpm up` never touches
143
+ files `create-pracht` wrote, so a warning is how an older scaffold learns it
144
+ needs a config change — for example adding `"src/**/*.d.ts"` to the
145
+ `include` of `tsconfig.client.json`, without which typed route hooks are
146
+ `unknown` in client code and `<Link route>` accepts any string.
139
147
 
140
148
  ## Step 7: Rollback note
141
149
 
package/src/index.js CHANGED
@@ -5,6 +5,8 @@ import { basename, dirname, resolve } from "node:path";
5
5
  import { createInterface } from "node:readline/promises";
6
6
  import { fileURLToPath } from "node:url";
7
7
 
8
+ import { loadFallbackVersionRanges } from "./versions.js";
9
+
8
10
  export class ValidationError extends Error {
9
11
  constructor(message) {
10
12
  super(message);
@@ -12,22 +14,6 @@ export class ValidationError extends Error {
12
14
  }
13
15
  }
14
16
 
15
- const FALLBACK_VERSION_RANGES = {
16
- "@pracht/adapter-cloudflare": "^0.5.8",
17
- "@pracht/adapter-netlify": "^0.1.0",
18
- "@pracht/adapter-node": "^0.3.8",
19
- "@pracht/adapter-static": "^0.1.0",
20
- "@pracht/adapter-vercel": "^0.2.8",
21
- "@pracht/cli": "^1.11.0",
22
- "@pracht/core": "^0.14.0",
23
- "@pracht/vite-plugin": "^0.9.0",
24
- "@tailwindcss/vite": "^4.1.0",
25
- "netlify-cli": "^21.6.0",
26
- tailwindcss: "^4.1.0",
27
- typescript: "^6.0.0",
28
- vercel: "^56.5.0",
29
- };
30
-
31
17
  /**
32
18
  * Cloudflare `compatibility_date` for scaffolded apps.
33
19
  *
@@ -105,6 +91,24 @@ function readFileSyncSafe(path) {
105
91
 
106
92
  const PACKAGE_ROOT = fileURLToPath(new URL("..", import.meta.url));
107
93
 
94
+ // Used only when the registry lookup fails. The `@pracht/*` ranges are the
95
+ // versions this create-pracht was built alongside (see src/versions.js).
96
+ const FALLBACK_VERSION_RANGES = {
97
+ "@tailwindcss/vite": "^4.1.0",
98
+ "netlify-cli": "^21.6.0",
99
+ tailwindcss: "^4.1.0",
100
+ typescript: "^6.0.0",
101
+ vercel: "^56.5.0",
102
+ ...loadFallbackVersionRanges(PACKAGE_ROOT),
103
+ };
104
+
105
+ // The Node floor every pracht package declares, read from this package's own
106
+ // engines field so a bump lands in scaffolded apps without a second edit.
107
+ const NODE_MINIMUM = (
108
+ JSON.parse(readFileSyncSafe(resolve(PACKAGE_ROOT, "package.json"))).engines?.node ?? ">=22.18"
109
+ ).replace(/^\D*/, "");
110
+ const NODE_MAJOR = NODE_MINIMUM.split(".")[0];
111
+
108
112
  // The published package bundles a copy of the repo skills (see
109
113
  // scripts/sync-skills.js); inside the monorepo we fall back to the source.
110
114
  const SKILL_DIRS = [resolve(PACKAGE_ROOT, "skills"), resolve(PACKAGE_ROOT, "../../skills")];
@@ -225,7 +229,7 @@ export async function run(argv = process.argv.slice(2)) {
225
229
  return;
226
230
  }
227
231
 
228
- const { pnpmWorkspaceNotice } = await scaffoldProject({
232
+ const { pnpmWorkspaceNotice, versionFallbacks } = await scaffoldProject({
229
233
  adapter: ADAPTERS[resolvedAdapter],
230
234
  agentSkills,
231
235
  agentTools: resolvedAgentTools,
@@ -289,6 +293,7 @@ export async function run(argv = process.argv.slice(2)) {
289
293
  pnpmWorkspaceNotice,
290
294
  router: resolvedRouter,
291
295
  tailwind: resolvedTailwind,
296
+ versionFallbacks,
292
297
  }),
293
298
  );
294
299
  } else {
@@ -303,6 +308,7 @@ export async function run(argv = process.argv.slice(2)) {
303
308
  router: resolvedRouter,
304
309
  skipInstall: options.skipInstall,
305
310
  tailwind: resolvedTailwind,
311
+ versionFallbacks,
306
312
  });
307
313
  }
308
314
  }
@@ -319,7 +325,7 @@ export async function scaffoldProject({
319
325
  targetDir,
320
326
  }) {
321
327
  const packageName = toPackageName(basename(targetDir));
322
- const { files, pnpmWorkspaceNotice } = await buildProjectFiles({
328
+ const { files, pnpmWorkspaceNotice, versionFallbacks } = await buildProjectFiles({
323
329
  adapter,
324
330
  agentSkills,
325
331
  agentTools,
@@ -347,7 +353,7 @@ export async function scaffoldProject({
347
353
  // AGENTS.md (and the CLAUDE.md alias pointing at it) are agent tooling too —
348
354
  // `--no-agent-tools` means a project with none of it, not "all of it except
349
355
  // the instruction files". README.md carries the same commands for humans.
350
- if (!agentTools) return { pnpmWorkspaceNotice };
356
+ if (!agentTools) return { pnpmWorkspaceNotice, versionFallbacks };
351
357
 
352
358
  try {
353
359
  await symlink("AGENTS.md", resolve(targetDir, "CLAUDE.md"));
@@ -359,7 +365,7 @@ export async function scaffoldProject({
359
365
  }
360
366
  }
361
367
 
362
- return { pnpmWorkspaceNotice };
368
+ return { pnpmWorkspaceNotice, versionFallbacks };
363
369
  }
364
370
 
365
371
  export function getPackageManager(userAgent = process.env.npm_config_user_agent ?? "") {
@@ -676,7 +682,13 @@ function normalizeAdapter(value) {
676
682
  return null;
677
683
  }
678
684
 
685
+ /**
686
+ * Latest published ranges for `packageNames`. `fallbacks` lists the packages
687
+ * whose registry lookup failed and got the bundled range instead; a scaffold
688
+ * that asked for no lookup (`remote: false`) reports none.
689
+ */
679
690
  async function resolveVersions(packageNames, { remote = true } = {}) {
691
+ const fallbacks = [];
680
692
  const entries = await Promise.all(
681
693
  packageNames.map(async (name) => {
682
694
  const fallback = FALLBACK_VERSION_RANGES[name] ?? "latest";
@@ -684,11 +696,13 @@ async function resolveVersions(packageNames, { remote = true } = {}) {
684
696
  try {
685
697
  return [name, `^${await fetchLatestVersion(name)}`];
686
698
  } catch {
699
+ fallbacks.push(name);
687
700
  return [name, fallback];
688
701
  }
689
702
  }),
690
703
  );
691
- return Object.fromEntries(entries);
704
+ const versions = Object.fromEntries(entries);
705
+ return { fallbacks: fallbacks.sort().map((name) => `${name}@${versions[name]}`), versions };
692
706
  }
693
707
 
694
708
  async function buildProjectFiles({
@@ -720,7 +734,9 @@ async function buildProjectFiles({
720
734
  packagesToResolve.push("tailwindcss", "@tailwindcss/vite");
721
735
  }
722
736
 
723
- const versions = await resolveVersions(packagesToResolve, { remote: resolveRemoteVersions });
737
+ const { fallbacks: versionFallbacks, versions } = await resolveVersions(packagesToResolve, {
738
+ remote: resolveRemoteVersions,
739
+ });
724
740
  const policyMajor = pnpmMajor ?? 11;
725
741
  const ancestorWorkspace =
726
742
  targetDir && packageManager === "pnpm" ? findAncestorPnpmWorkspace(targetDir) : null;
@@ -733,6 +749,11 @@ async function buildProjectFiles({
733
749
  : null;
734
750
 
735
751
  const files = {
752
+ // Build images (Cloudflare Pages, Netlify, most CI) pick a Node version
753
+ // from this file; without it they choose their own default, which is
754
+ // routinely older than pracht's `engines.node` and fails the build on a
755
+ // missing modern built-in rather than on a version mismatch.
756
+ ".nvmrc": `${NODE_MAJOR}\n`,
736
757
  ".gitignore":
737
758
  "dist\nnode_modules\n.netlify\n.wrangler\n.vercel\n.env*\n!.env.example\n.dev.vars\n# Keep .pracht/app-graph.json committed — it is the `pracht plan` snapshot.\n",
738
759
  "README.md": createReadme({
@@ -821,7 +842,7 @@ async function buildProjectFiles({
821
842
  files["pnpm-workspace.yaml"] = createPnpmWorkspaceConfig(adapter, tailwind, policyMajor);
822
843
  }
823
844
 
824
- return { files, pnpmWorkspaceNotice };
845
+ return { files, pnpmWorkspaceNotice, versionFallbacks };
825
846
  }
826
847
 
827
848
  function createMcpConfig() {
@@ -888,7 +909,8 @@ function createPackageJson({ adapter, projectName, tailwind, versions }) {
888
909
  "@pracht/cli": versions["@pracht/cli"],
889
910
  "@pracht/vite-plugin": versions["@pracht/vite-plugin"],
890
911
  preact: "^10.26.9",
891
- "preact-render-to-string": "^6.5.13",
912
+ // At least @pracht/core's peer floor, or the first install warns.
913
+ "preact-render-to-string": "^6.7.0",
892
914
  typescript: versions["typescript"],
893
915
  vite: "^8.0.0",
894
916
  };
@@ -923,6 +945,10 @@ function createPackageJson({ adapter, projectName, tailwind, versions }) {
923
945
  version: "0.0.0",
924
946
  private: true,
925
947
  type: "module",
948
+ // Matches every pracht package. Vercel reads this to choose a Node
949
+ // version; the `.nvmrc` next to it covers the hosts that read that
950
+ // instead.
951
+ engines: { node: `>=${NODE_MINIMUM}` },
926
952
  scripts,
927
953
  dependencies: {
928
954
  [adapter.packageName]: versions[adapter.packageName],
@@ -1021,6 +1047,7 @@ function createShellFile(projectName, tailwind = false) {
1021
1047
  "",
1022
1048
  "export function head() {",
1023
1049
  " return {",
1050
+ ' lang: "en",',
1024
1051
  ' meta: [{ content: "width=device-width, initial-scale=1", name: "viewport" }],',
1025
1052
  ` title: ${JSON.stringify(projectName)},`,
1026
1053
  " };",
@@ -1171,10 +1198,13 @@ function createClientTSConfig(router) {
1171
1198
  compilerOptions: {
1172
1199
  customConditions: ["browser"],
1173
1200
  },
1201
+ // Declaration files carry the generated route/capability types and the
1202
+ // app's `Register` augmentations; without them the client program sees
1203
+ // `useRouteData()` as `unknown` and accepts any `<Link route>`.
1174
1204
  include:
1175
1205
  router === "pages"
1176
- ? ["src/pages/**/*", "src/islands/**/*"]
1177
- : ["src/routes/**/*", "src/shells/**/*", "src/islands/**/*"],
1206
+ ? ["src/pages/**/*", "src/islands/**/*", "src/**/*.d.ts"]
1207
+ : ["src/routes/**/*", "src/shells/**/*", "src/islands/**/*", "src/**/*.d.ts"],
1178
1208
  };
1179
1209
 
1180
1210
  // Pages route modules are shared with the browser, but these two convention
@@ -1541,6 +1571,10 @@ function createAgentInstructions({
1541
1571
  const lines = [
1542
1572
  "# Pracht App",
1543
1573
  "",
1574
+ "Docs for your installed pracht version: `pracht docs <topic>` (or read",
1575
+ "`node_modules/@pracht/cli/docs/`); `pracht docs --search <query>` finds a section.",
1576
+ "pracht is newer than most training data, so check them before relying on memory.",
1577
+ "",
1544
1578
  "## Commands",
1545
1579
  "",
1546
1580
  `- \`${runCmd} dev\` — start the dev server`,
@@ -1984,6 +2018,7 @@ function printNextSteps({
1984
2018
  router,
1985
2019
  skipInstall,
1986
2020
  tailwind,
2021
+ versionFallbacks = [],
1987
2022
  }) {
1988
2023
  const installCommand = packageManager === "npm" ? "npm install" : `${packageManager} install`;
1989
2024
  const devCommand = packageManager === "npm" ? "npm run dev" : `${packageManager} dev`;
@@ -2027,6 +2062,18 @@ function printNextSteps({
2027
2062
  console.log("Dependency installation did not complete. The project files were still created.");
2028
2063
  }
2029
2064
 
2065
+ if (versionFallbacks.length > 0) {
2066
+ console.log("");
2067
+ console.log(
2068
+ "Could not look up the latest versions on the npm registry, so package.json uses\n" +
2069
+ "the ranges this create-pracht was released with. Check for newer versions once\n" +
2070
+ "you are online:",
2071
+ );
2072
+ for (const entry of versionFallbacks) {
2073
+ console.log(` ${entry}`);
2074
+ }
2075
+ }
2076
+
2030
2077
  if (pnpmWorkspaceNotice) {
2031
2078
  console.log("");
2032
2079
  console.log(
@@ -0,0 +1,43 @@
1
+ import { existsSync, readFileSync, readdirSync } from "node:fs";
2
+ import { resolve } from "node:path";
3
+
4
+ /** Written next to `package.json` by `scripts/sync-versions.js` at build time. */
5
+ export const FALLBACK_VERSIONS_FILE = "fallback-versions.json";
6
+
7
+ /**
8
+ * `^<version>` for every published `@pracht/*` package in a packages
9
+ * directory, keyed by package name.
10
+ */
11
+ export function collectWorkspaceVersionRanges(packagesDir) {
12
+ const ranges = {};
13
+ for (const entry of readdirSync(packagesDir, { withFileTypes: true })) {
14
+ if (!entry.isDirectory()) continue;
15
+ const manifestPath = resolve(packagesDir, entry.name, "package.json");
16
+ if (!existsSync(manifestPath)) continue;
17
+ const manifest = JSON.parse(readFileSync(manifestPath, "utf-8"));
18
+ if (manifest.private || !manifest.name?.startsWith("@pracht/") || !manifest.version) continue;
19
+ ranges[manifest.name] = `^${manifest.version}`;
20
+ }
21
+ return Object.fromEntries(Object.entries(ranges).sort(([a], [b]) => a.localeCompare(b)));
22
+ }
23
+
24
+ /**
25
+ * The `@pracht/*` ranges a scaffold falls back to when the registry lookup
26
+ * fails. Inside the monorepo they come straight from the sibling packages, so
27
+ * a checkout never goes stale; a published copy reads the file its build
28
+ * wrote from those same siblings.
29
+ */
30
+ export function loadFallbackVersionRanges(packageRoot) {
31
+ const packagesDir = resolve(packageRoot, "..");
32
+ try {
33
+ const core = JSON.parse(readFileSync(resolve(packagesDir, "framework/package.json"), "utf-8"));
34
+ if (core.name === "@pracht/core") return collectWorkspaceVersionRanges(packagesDir);
35
+ } catch {
36
+ // Not a checkout of the monorepo.
37
+ }
38
+ try {
39
+ return JSON.parse(readFileSync(resolve(packageRoot, FALLBACK_VERSIONS_FILE), "utf-8"));
40
+ } catch {
41
+ return {};
42
+ }
43
+ }