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.
- package/README.md +2 -2
- package/fallback-versions.json +20 -0
- package/package.json +3 -2
- package/skills/add-auth/SKILL.md +25 -7
- package/skills/add-capabilities/SKILL.md +11 -8
- package/skills/add-db/SKILL.md +5 -3
- package/skills/add-images/SKILL.md +17 -3
- package/skills/add-observability/SKILL.md +13 -4
- package/skills/audit-agent-surface/SKILL.md +2 -1
- package/skills/audit-auth/SKILL.md +29 -1
- package/skills/audit-bundles/SKILL.md +25 -0
- package/skills/audit-headers/SKILL.md +4 -0
- package/skills/audit-islands/SKILL.md +41 -5
- package/skills/audit-loaders/SKILL.md +20 -3
- package/skills/audit-redirects/SKILL.md +22 -8
- package/skills/audit-secrets/SKILL.md +15 -4
- package/skills/audit-shells/SKILL.md +17 -1
- package/skills/migrate-nextjs/SKILL.md +5 -5
- package/skills/pracht-debug/SKILL.md +20 -2
- package/skills/pracht-deploy/SKILL.md +13 -2
- package/skills/pracht-scaffold/SKILL.md +6 -4
- package/skills/pre-deploy/SKILL.md +23 -1
- package/skills/tune-render-mode/SKILL.md +20 -2
- package/skills/typed-routes/SKILL.md +50 -3
- package/skills/upgrade-pracht/SKILL.md +7 -2
- package/src/index.js +99 -25
- package/src/versions.js +43 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: migrate-nextjs
|
|
3
|
-
version: 1.
|
|
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
|
|
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,
|
|
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()`
|
|
341
|
-
| `next/font/google` |
|
|
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
|
|
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
|
|
284
|
-
`404.html`
|
|
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.
|
|
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
|
|
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.
|
|
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.
|
|
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)
|
|
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.
|
|
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`
|
|
186
|
-
|
|
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.
|
|
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
|
-
|
|
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, {
|
|
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
|
-
|
|
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(
|