create-pracht 0.7.0 → 0.7.2

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.
@@ -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 |
@@ -36,6 +36,7 @@ Reach for these before deep manual inspection:
36
36
  | `pracht inspect routes\|api\|build --json` | The resolved graph — never reconstruct it from source |
37
37
  | `GET /_pracht` (JSON at `/_pracht.json`) | Same graph from a running dev server, no CLI needed |
38
38
  | `Server-Timing` on dev SSR responses | `mw` / `loader` / `render` durations in ms — which phase is slow |
39
+ | `pracht_*` WebMCP page tools in the open tab | Tab-scoped view from an agent-driven browser: `pracht_route`, `pracht_loader_data`, `pracht_islands`, `pracht_last_error`, `pracht_page_tools` |
39
40
 
40
41
  `pracht inspect` needs the pracht plugin in the vite config; `inspect build`
41
42
  needs a prior `pracht build`. Under a Vite deploy base, prefix `/_pracht` with
@@ -44,6 +45,18 @@ pracht MCP server is registered (docs/MCP.md), prefer the
44
45
  `inspect_routes`/`inspect_api`/`doctor`/`verify` MCP tools — same payloads,
45
46
  structured results.
46
47
 
48
+ When you are driving a browser against `pracht dev` with a WebMCP-compatible
49
+ test harness or host, ask the tab before reading logs: every dev document registers read-only
50
+ `pracht_*` page tools with `document.modelContext`. `pracht_route` gives the
51
+ matched route, files, render/hydration mode, and middleware; `pracht_loader_data`
52
+ the data the page holds now (`{ path: "a.0.b" }` narrows it; islands/none routes
53
+ answer with the route-state request to make instead); `pracht_islands` each
54
+ island's file, strategy, props, and hydration status; `pracht_last_error` the
55
+ structured server error behind an overlay or `ErrorBoundary` plus recent client
56
+ errors; `pracht_page_tools` the app's active WebMCP tools on the route. All
57
+ resolve to `{ ok, data }` / `{ ok: false, error }` envelopes and follow client
58
+ navigation.
59
+
47
60
  ## Checklist
48
61
 
49
62
  Work in order; stop at the root cause.
@@ -79,7 +92,9 @@ Work in order; stop at the root cause.
79
92
  ### 3. Loader / API errors
80
93
 
81
94
  - On slow pages, read `Server-Timing` before reading code.
82
- - Loaders must return serializable data — no functions, no circular refs.
95
+ - Loaders must return serializable data — no functions, no circular refs. A
96
+ `Date` arrives as a string unless `pracht({ client: { richData: true } })` is
97
+ set; with it, an unsupported value fails with an error naming its path.
83
98
  - API handlers must return `Response` objects, and a default export must branch
84
99
  on `request.method`.
85
100
  - Look for unhandled rejections or thrown errors.
@@ -94,7 +109,10 @@ Work in order; stop at the root cause.
94
109
  component (via Preact's `options.__m` hook). Compare server HTML against
95
110
  client output. Usual causes: date/time differences, browser-only APIs during
96
111
  SSR (`window`, `document`, `localStorage`), conditional rendering on client
97
- state — or two copies of `@pracht/core` in the SSR module graph. The tell for
112
+ state — or, on Preact 10, a suspending boundary resolving to zero or multiple
113
+ DOM nodes. Preact 11 supports those boundary shapes, so Pracht skips that
114
+ legacy diagnostic there. Another cause is two copies of `@pracht/core` in
115
+ the SSR module graph. The tell for
98
116
  that last one: in the server-rendered HTML of *every* page, `useLocation()`
99
117
  returns `/`, `useParams()` returns `{}`, and `useRouteData()` returns
100
118
  `undefined`, while the hydrated client is correct — provider and hooks hold
@@ -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
 
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: pre-deploy
3
- version: 1.4.0
3
+ version: 1.5.0
4
4
  description: |
5
5
  Adapter-aware pre-deployment checklist (Node, Cloudflare Workers, Vercel, static)
6
6
  for the failures that only surface in production: missing env vars, Node-only
@@ -55,6 +55,20 @@ reports errors, stop here**; the remaining checks will be noisy false
55
55
  positives. Stale generated typed-route files block deployment; if the app does
56
56
  not use them yet, note that `typegen --check` is optional.
57
57
 
58
+ If `pracht inspect agents --json` reports any WebMCP exposure, run the live
59
+ browser boundary too. Point it at the already-started preview, or let it own the
60
+ server lifecycle:
61
+
62
+ ```bash
63
+ pracht verify webmcp --start "pracht preview" --json
64
+ ```
65
+
66
+ CI must install a pinned Chrome 150+ build and pass its executable with
67
+ `--browser`; the verifier never silently downloads one. Treat unsupported API,
68
+ startup/registration failure, and graph drift as deployment errors. Do not add
69
+ `--scenario` unless the repository already supplies an explicitly safe WebMCP
70
+ eval scenario.
71
+
58
72
  For a PR deploy, `pracht report --base origin/main` produces a markdown summary
59
73
  (graph diff + verify + budgets) worth attaching.
60
74
 
@@ -79,6 +93,14 @@ For a PR deploy, `pracht report --base origin/main` produces a markdown summary
79
93
  true })` is set; application code should still observe the public base in
80
94
  `request.url`, and the proxy must own the bare-base redirect (`/app` →
81
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.
82
104
 
83
105
  ### Cloudflare Workers (`@pracht/adapter-cloudflare`)
84
106
 
@@ -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.2.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,
@@ -130,12 +130,17 @@ pracht doctor --json # wiring still valid
130
130
  pracht typegen --check # generated route types up to date?
131
131
  pracht typegen # regenerate if --check failed or routes changed
132
132
  pracht verify --json # framework-aware checks
133
+ pnpm typecheck # both tsconfig programs, if the app has the script
133
134
  pracht build # full production build (budgets included)
134
135
  pnpm test # the app's own suite
135
136
  ```
136
137
 
137
138
  `pracht doctor`, `verify`, and `typegen --check` exit non-zero on failure, so
138
- they gate CI cleanly.
139
+ they gate CI cleanly. Act on doctor *warnings* too: `pnpm up` never touches
140
+ files `create-pracht` wrote, so a warning is how an older scaffold learns it
141
+ needs a config change — for example adding `"src/**/*.d.ts"` to the
142
+ `include` of `tsconfig.client.json`, without which typed route hooks are
143
+ `unknown` in client code and `<Link route>` accepts any string.
139
144
 
140
145
  ## Step 7: Rollback note
141
146
 
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({
@@ -754,6 +775,7 @@ async function buildProjectFiles({
754
775
  }),
755
776
  "vite.config.ts": createViteConfig(adapter, router, tailwind),
756
777
  "tsconfig.json": createBaseTSConfig(adapter),
778
+ "tsconfig.client.json": createClientTSConfig(router),
757
779
  };
758
780
 
759
781
  // A static export has no server, so an API route would be a hard build
@@ -820,7 +842,7 @@ async function buildProjectFiles({
820
842
  files["pnpm-workspace.yaml"] = createPnpmWorkspaceConfig(adapter, tailwind, policyMajor);
821
843
  }
822
844
 
823
- return { files, pnpmWorkspaceNotice };
845
+ return { files, pnpmWorkspaceNotice, versionFallbacks };
824
846
  }
825
847
 
826
848
  function createMcpConfig() {
@@ -871,7 +893,7 @@ function createPackageJson({ adapter, projectName, tailwind, versions }) {
871
893
  const scripts = {
872
894
  build: "pracht build",
873
895
  dev: "pracht dev",
874
- typecheck: "tsc --noEmit",
896
+ typecheck: "tsc --noEmit && tsc --noEmit --project tsconfig.client.json",
875
897
  };
876
898
 
877
899
  if (adapter.id === "node") {
@@ -887,7 +909,8 @@ function createPackageJson({ adapter, projectName, tailwind, versions }) {
887
909
  "@pracht/cli": versions["@pracht/cli"],
888
910
  "@pracht/vite-plugin": versions["@pracht/vite-plugin"],
889
911
  preact: "^10.26.9",
890
- "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",
891
914
  typescript: versions["typescript"],
892
915
  vite: "^8.0.0",
893
916
  };
@@ -922,6 +945,10 @@ function createPackageJson({ adapter, projectName, tailwind, versions }) {
922
945
  version: "0.0.0",
923
946
  private: true,
924
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}` },
925
952
  scripts,
926
953
  dependencies: {
927
954
  [adapter.packageName]: versions[adapter.packageName],
@@ -1020,6 +1047,7 @@ function createShellFile(projectName, tailwind = false) {
1020
1047
  "",
1021
1048
  "export function head() {",
1022
1049
  " return {",
1050
+ ' lang: "en",',
1023
1051
  ' meta: [{ content: "width=device-width, initial-scale=1", name: "viewport" }],',
1024
1052
  ` title: ${JSON.stringify(projectName)},`,
1025
1053
  " };",
@@ -1164,6 +1192,30 @@ function createBaseTSConfig(_adapter) {
1164
1192
  return `${JSON.stringify(config, null, 2)}\n`;
1165
1193
  }
1166
1194
 
1195
+ function createClientTSConfig(router) {
1196
+ const config = {
1197
+ extends: "./tsconfig.json",
1198
+ compilerOptions: {
1199
+ customConditions: ["browser"],
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>`.
1204
+ include:
1205
+ router === "pages"
1206
+ ? ["src/pages/**/*", "src/islands/**/*", "src/**/*.d.ts"]
1207
+ : ["src/routes/**/*", "src/shells/**/*", "src/islands/**/*", "src/**/*.d.ts"],
1208
+ };
1209
+
1210
+ // Pages route modules are shared with the browser, but these two convention
1211
+ // files are server-only. The base program still checks the whole project.
1212
+ if (router === "pages") {
1213
+ config.exclude = ["src/pages/**/_app.config.*", "src/pages/**/_middleware.*"];
1214
+ }
1215
+
1216
+ return `${JSON.stringify(config, null, 2)}\n`;
1217
+ }
1218
+
1167
1219
  function createHealthRoute(adapter) {
1168
1220
  return [
1169
1221
  "export function GET() {",
@@ -1606,6 +1658,10 @@ function createAgentInstructions({
1606
1658
  lines.push("- `src/api/` — API route handlers");
1607
1659
  }
1608
1660
  lines.push(`- \`vite.config.ts\` — Vite config with the ${adapter.label} adapter`);
1661
+ lines.push("- `tsconfig.json` — server-capable whole-project TypeScript checks");
1662
+ lines.push(
1663
+ "- `tsconfig.client.json` — browser-conditioned checks for routes, shells, islands, and their imports",
1664
+ );
1609
1665
 
1610
1666
  if (tailwind) {
1611
1667
  lines.push("- `src/styles/global.css` — Tailwind CSS entry stylesheet, imported by the shell");
@@ -1781,6 +1837,11 @@ function createReadme({
1781
1837
  lines.push("- `src/api/health.ts` is a sample API route.");
1782
1838
  }
1783
1839
 
1840
+ lines.push("- `tsconfig.json` — server-capable whole-project TypeScript checks.");
1841
+ lines.push(
1842
+ "- `tsconfig.client.json` — browser-conditioned checks for routes, shells, islands, and their imports.",
1843
+ );
1844
+
1784
1845
  if (tailwind) {
1785
1846
  lines.push("- `src/styles/global.css` is the Tailwind CSS entry, imported by the shell.");
1786
1847
  }
@@ -1953,6 +2014,7 @@ function printNextSteps({
1953
2014
  router,
1954
2015
  skipInstall,
1955
2016
  tailwind,
2017
+ versionFallbacks = [],
1956
2018
  }) {
1957
2019
  const installCommand = packageManager === "npm" ? "npm install" : `${packageManager} install`;
1958
2020
  const devCommand = packageManager === "npm" ? "npm run dev" : `${packageManager} dev`;
@@ -1996,6 +2058,18 @@ function printNextSteps({
1996
2058
  console.log("Dependency installation did not complete. The project files were still created.");
1997
2059
  }
1998
2060
 
2061
+ if (versionFallbacks.length > 0) {
2062
+ console.log("");
2063
+ console.log(
2064
+ "Could not look up the latest versions on the npm registry, so package.json uses\n" +
2065
+ "the ranges this create-pracht was released with. Check for newer versions once\n" +
2066
+ "you are online:",
2067
+ );
2068
+ for (const entry of versionFallbacks) {
2069
+ console.log(` ${entry}`);
2070
+ }
2071
+ }
2072
+
1999
2073
  if (pnpmWorkspaceNotice) {
2000
2074
  console.log("");
2001
2075
  console.log(