@paramour-js/next 0.4.1 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,113 @@
1
+ # Authoring: codecs, routes, links, hooks
2
+
3
+ ## `p.*` codec builders (import `{ p }` from `"paramour"`)
4
+
5
+ Wire grammars are strict and anchored — no `Number()` coercion, no whitespace, no hex, no `1e3` for integers. Every parse failure is a `ParseError` (recoverable per-key with `.catch()`); every serialize failure is a `SerializeError` at link-build time.
6
+
7
+ <!--
8
+ The Builder column below is drift-checked against Object.keys(p) by
9
+ packages/next/test/skill-drift.test.ts — keep each row's first cell a
10
+ single `p.name(...)` backtick token.
11
+ -->
12
+
13
+ | Builder | Decoded type | Wire behavior | Options |
14
+ | -------------------- | ---------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------- |
15
+ | `p.string(schema?)` | `string` | Verbatim text. Empty string is a real value (`key=`), not absence. | Optional Standard Schema `<string, string>` refinement (runs on parse AND serialize) |
16
+ | `p.integer(schema?)` | `number` | `/^-?\d+$/`, safe-integer range. | Optional Standard Schema `<number, number>` |
17
+ | `p.number(schema?)` | `number` | Decimal/scientific notation, finite only. | Optional Standard Schema `<number, number>` |
18
+ | `p.boolean()` | `boolean` | Exactly `"true"` / `"false"`. | — |
19
+ | `p.enum(members)` | union of members | Exact member match. `p.enum(["asc", "desc"])` decodes to `"asc" \| "desc"`. | Non-empty readonly string tuple |
20
+ | `p.isoDate()` | `Date` | `YYYY-MM-DD`, real calendar dates only (rejects `2026-02-30`); serializes UTC date part. | — |
21
+ | `p.timestamp()` | `Date` | ISO 8601 UTC only (`...T..:..:..[.mmm]Z`, offsets rejected); serializes `Date#toISOString()`. | — |
22
+ | `p.json(schema)` | schema output | `JSON.parse` then schema; serialize re-validates then `JSON.stringify`. | Standard Schema (required) |
23
+ | `p.index(schema?)` | `number` | 1-based on the wire, 0-based in memory: `?page=1` ↔ `0`. Wire `< 1` is a parse failure; negative in-memory index is a `SerializeError`. | Optional Standard Schema `<number, number>` (validates the 0-based value) |
24
+ | `p.csv(element?)` | `E[]` | ONE wire value, comma-joined (`?tags=a,b`). Empty wire string is `[]`; `a,,b` / trailing comma are parse failures; serializing an element that is empty or contains a comma is a `SerializeError`. Arity "single" — full modifier set applies. | Optional element codec (default `p.string()`); element must be an unmodified scalar, no nested csv |
25
+ | `p.array(element?)` | `E[]` | Repeated keys (`?tags=a&tags=b`). Absent ≡ `[]` — so NO `.optional()`/`.default()`; `.catch()` recovers the whole list. | Optional element codec (default `p.string()`); element must be an unmodified scalar, not arity-many |
26
+ | `p.custom({ ... })` | `Out` | Your bidirectional transform. Thrown foreign errors are rebranded `ParseError`/`SerializeError`. | `{ parse: (raw: string) => Out; serialize: (value: Out) => string; label?: string }` |
27
+
28
+ ## Modifier chains
29
+
30
+ | Modifier | Effect on decode | Effect on href input | Effect on URL |
31
+ | ------------------------------- | -------------------------------------------------------------------- | -------------------- | -------------------------------------------------------------------------------------------------- |
32
+ | (none — required) | Absent key is a decode issue | Key required | Always emitted when building |
33
+ | `.optional()` | Absent → `undefined`; field type `T \| undefined` | Key omittable | Omitted key emits nothing |
34
+ | `.default(value)` | Absent → default; field type stays `T` | Key omittable | Value equal to the default ELIDES (compared by serialized wire form) — one canonical URL per state |
35
+ | `.default(() => value)` | Absent → factory result; field type stays `T` | Key omittable | NEVER elides (a time-varying factory would swallow explicit values) |
36
+ | `.catch(v)` / `.catch(() => v)` | A PRESENT value that fails parsing → fallback. Never covers absence. | No change | No change |
37
+
38
+ Legality (compile-time type-state — illegal calls type as `never`; runtime throws for JS):
39
+
40
+ - Legal: `p.integer()`, `.optional()`, `.default(1)`, `.catch(0)`, `.optional().catch(0)`, `.catch(0).optional()`, `.default(1).catch(0)`, `.catch(0).default(1)`, `p.csv().default([])`, `p.array().catch([])`.
41
+ - Illegal: `.optional().default(...)`, `.default(...).optional()`, `.optional().optional()`, `.default(...).default(...)`, `.catch(...).catch(...)`, `p.array().optional()`, `p.array().default(...)`, any modifier on a csv/array ELEMENT (`p.csv(p.string().optional())`), `p.csv(p.csv())`, `p.array(p.array())`, and `.default(value)` where the value's type includes a function member (use the factory form).
42
+ - `params:` codecs additionally forbid `.optional()`/`.default()` (path optionality comes from `[[...slug]]`); `.catch()` is fine.
43
+
44
+ Value vs factory `.default()`: value defaults are serialized eagerly at definition time (an invalid default fails immediately) and participate in URL elision; factory defaults are invoked per decode (fresh reference per call — use for mutable objects) and never elide. Array value defaults are handed out as fresh shallow copies per decode.
45
+
46
+ ## Defining routes
47
+
48
+ ```ts
49
+ import { defineAppRoute, definePagesRoute, p } from "paramour";
50
+
51
+ export const productRoute = defineAppRoute("/product/[id]", {
52
+ params: { id: p.integer() },
53
+ search: { page: p.index().default(0), tags: p.csv() },
54
+ });
55
+
56
+ export const docsRoute = defineAppRoute("/docs/[[...slug]]", {
57
+ params: { slug: p.string() }, // codec describes ONE segment element
58
+ });
59
+
60
+ export const aboutRoute = defineAppRoute("/about", {}); // static: params rejected
61
+
62
+ export const blogRoute = definePagesRoute("/blog/[slug]", {
63
+ params: { slug: p.string() },
64
+ search: { preview: p.boolean().optional() },
65
+ });
66
+ ```
67
+
68
+ Rules:
69
+
70
+ - Path literal is checked against the generated registry (`paramour-env.d.ts`); pre-generation any literal compiles. Exactly one codec per dynamic segment name; extra or misspelled `params:` keys fail to compile.
71
+ - `[...slug]` / `[[...slug]]` decode to `Out[]` (absent optional catch-all → `[]`); the codec is per-element, `.catch()` recovery is element-wise.
72
+ - Pages routes forbid a search key shadowing a path param name (Next merges `query`, path params win).
73
+ - Whole-object escape hatch: `search: rawSearch(zodSchema)` hands the entire search object to one Standard Schema — schema sees every key on decode; encode is a raw pass-through of wire strings (no round-trip, no elision). Prefer codec maps.
74
+
75
+ ## Server-side reads
76
+
77
+ App Router (async, props-based): `route.parse(props)`, `route.parseParams(props)`, `route.parseSearch(props)` — throw `ParamsDecodeError`/`SearchDecodeError` on malformed URLs (params decode first) — plus `safeParse`/`safeParseParams`/`safeParseSearch` returning `SafeResult` (`status: "success" | "error"`). Annotate page props as `RouteProps` (or `ParamsProps`/`SearchProps` for layouts/halves) from `paramour`; `generateMetadata` takes the same props.
78
+
79
+ Pages Router (sync, context-based): `route.parseContext(ctx)` / `route.safeParseContext(ctx)` with a `getServerSideProps` or `getInitialProps` context (`{ params?, query }`). NOT `getStaticProps` (no query string) — decode `ctx.params` with `safeDecodeParams(route, ctx.params ?? {})` there.
80
+
81
+ Standalone sync decoders (middleware, route handlers, anywhere holding a source): `decodeParams`/`decodeSearch` (throwing) and `safeDecodeParams`/`safeDecodeSearch` (SafeResult), all from `paramour`.
82
+
83
+ ## Typed links
84
+
85
+ ```ts
86
+ import { href } from "paramour";
87
+
88
+ href(productRoute, { params: { id: 42 }, search: { tags: ["a", "b"] } });
89
+ // "/product/42?tags=a,b"
90
+ href(aboutRoute); // "/about" — options omittable when nothing is required
91
+ href(productRoute, { params: { id: 1 }, hash: "reviews" }); // "#reviews" appended verbatim
92
+ href("/about", { hash: "team" }); // string form: registered STATIC paths only
93
+ ```
94
+
95
+ `href` returns `Href` — a string subtype accepted by `next/link`, `router.push`, `redirect` unchanged. Required params/search make the options argument required; defaulted/optional/array keys are omittable. Serialization failures (bad value, empty segment, required catch-all given `[]`) throw `SerializeError` at link-build time. Lower-level pieces: `buildPath(route, params)`, `searchToString(config, input)`, `encodeStaticParams(route, params)` for `generateStaticParams`/`getStaticPaths`.
96
+
97
+ ## Client hooks
98
+
99
+ App Router — `import { useRouteParams, useRouteParamsOrThrow, useSearch, useSearchOrThrow } from "@paramour-js/next/app"` (client components only; app-branded routes only, compile-enforced):
100
+
101
+ - `useRouteParams(route)` / `useSearch(route)` → `SafeResult`: branch on `result.status === "error"` before touching `result.data`. No loading state; SSR-consistent.
102
+ - `useRouteParamsOrThrow(route)` / `useSearchOrThrow(route)` → decoded value, throwing the decode error to the nearest error boundary.
103
+ - All four accept optional `{ select: (value) => U, equality?: "shallow" }` — a projection with result-equality stabilization so unrelated URL churn (e.g. `utm_*`) does not produce new references.
104
+
105
+ Pages Router — `import { useRouteParams, useSearch } from "@paramour-js/next/pages"` (pages-branded routes only):
106
+
107
+ - Return `RouterResult` = `SafeResult` plus a `{ status: "pending" }` arm for the pre-`isReady` first render of a statically optimized page. Handle all three statuses. No `OrThrow` variants exist, by design. Same `{ select }` option.
108
+
109
+ Testing — `import { ParamourTestingProvider, withParamourTesting } from "@paramour-js/next/testing"`: overrides the hooks' framework reads without mocking `next/*` modules. `withParamourTesting({ pathname: "/product/42", params: { id: "42" }, search: "?q=hi" })` is testing-library's `wrapper`; options also cover `isReady`, `mounted`, `onReplace`, `params: null`.
110
+
111
+ ## Errors
112
+
113
+ All library throws are `ParamourError` subclasses (brand-hardened `instanceof`): `ParseError` (one wire value failed its grammar/schema; `.catch()`-recoverable), `SerializeError` (link-build time), `ParamsDecodeError`/`SearchDecodeError` (aggregate, `.issues: Issue[]` with `key`/`message`/`reason`/`expected`/`wire`), `SearchSourceError` (malformed source under a declared key). `safeParse*`/`safeDecode*`/hooks convert only the decode errors into the error arm — contract violations stay thrown.
@@ -0,0 +1,141 @@
1
+ # Migration: converting raw params/searchParams to paramour
2
+
3
+ ## THE RULE
4
+
5
+ Migrate INCREMENTALLY: one route per pass. For each pass — define the route object, convert that route's files, run `paramour check` plus the project type check, commit, then move to the next route. NEVER rewrite all routes at once. Paramour routes and raw `params`/`searchParams` access coexist without conflict, so a partially migrated app is a normal, stable state. If asked to "migrate the app", plan a route-by-route sequence and execute it as separate verified passes.
6
+
7
+ Prerequisite: paramour is installed and `withTypedRoutes` is wired (otherwise do `references/setup.md` first — `npx paramour init` is safe to run on an existing project).
8
+
9
+ ## Worked example: one App Router route
10
+
11
+ ### Before
12
+
13
+ ```tsx
14
+ // app/products/[id]/page.tsx
15
+ export default async function ProductPage({
16
+ params,
17
+ searchParams,
18
+ }: {
19
+ params: Promise<{ id: string }>;
20
+ searchParams: Promise<{ [key: string]: string | string[] | undefined }>;
21
+ }) {
22
+ const { id } = await params;
23
+ const sp = await searchParams;
24
+ const page = Number(sp.page ?? "1");
25
+ const sort = sp.sort === "desc" ? "desc" : "asc";
26
+ const q = typeof sp.q === "string" ? sp.q : undefined;
27
+ // ...
28
+ }
29
+ ```
30
+
31
+ ### After
32
+
33
+ 1. Define the route object, colocated with the page:
34
+
35
+ ```ts
36
+ // app/products/[id]/route.def.ts
37
+ import { defineAppRoute, p } from "paramour";
38
+
39
+ export const productRoute = defineAppRoute("/products/[id]", {
40
+ params: { id: p.integer() },
41
+ search: {
42
+ page: p.integer().default(1),
43
+ q: p.string().optional(),
44
+ sort: p.enum(["asc", "desc"]).default("asc"),
45
+ },
46
+ });
47
+ ```
48
+
49
+ 2. Convert the page (and `generateMetadata`, which takes the same props):
50
+
51
+ ```tsx
52
+ // app/products/[id]/page.tsx
53
+ import type { RouteProps } from "paramour";
54
+ import { productRoute } from "./route.def";
55
+
56
+ export default async function ProductPage(props: RouteProps) {
57
+ const { params, search } = await productRoute.parse(props);
58
+ // params.id: number — search.page: number — search.sort: "asc" | "desc"
59
+ // search.q: string | undefined
60
+ // ...
61
+ }
62
+ ```
63
+
64
+ 3. Verify: `paramour check` (the path already existed, so usually no artifact change), then `tsc --noEmit`. Commit.
65
+
66
+ ## Mapping decisions
67
+
68
+ ### Dynamic segments → `params:` codecs
69
+
70
+ - `[id]` numeric in practice → `p.integer()`; otherwise keep `p.string()` (behavior-preserving — when unsure, `p.string()` first, tighten later).
71
+ - `[...slug]` / `[[...slug]]` → the codec describes ONE segment element (usually `p.string()`); the array comes from the path shape. Decoded type is `Out[]`; an absent optional catch-all decodes to `[]`.
72
+ - Presence modifiers are illegal in `params:` — optionality lives in the path grammar (`[[...slug]]`), not the codec.
73
+
74
+ ### searchParams reads → `search:` codecs
75
+
76
+ - `sp.x ?? "default"` / `Number(sp.x ?? "1")` → codec with `.default(value)`. The decoded field is non-optional and the default value elides from built URLs.
77
+ - "May be absent, no fallback" (`typeof sp.x === "string" ? sp.x : undefined`) → `.optional()`. Decoded as `T | undefined`.
78
+ - Silent-coercion tolerance (old code shrugged off garbage, e.g. `Number(...)` producing `NaN` handled downstream) → add `.catch(fallback)` so a malformed PRESENT value falls back instead of failing the decode. `.catch()` never covers absence — combine with `.default()`/`.optional()` for that.
79
+ - Multi-value keys (`sp.tags` handled as `string | string[]`) → `p.array()` for repeated keys (`?tags=a&tags=b`) or `p.csv()` for one comma-joined key (`?tags=a,b`). Match whichever wire form the app already emits.
80
+ - Enumerated strings → `p.enum(["a", "b"])`; numbers → `p.number()`; booleans (`sp.flag === "true"`) → `p.boolean()`; dates → `p.isoDate()` (YYYY-MM-DD) or `p.timestamp()` (ISO UTC).
81
+
82
+ ### Behavior change to decide explicitly
83
+
84
+ Raw code silently tolerated malformed URLs; `route.parse` THROWS `ParamsDecodeError`/`SearchDecodeError` on them. Pick one per route:
85
+
86
+ - `parse` + a colocated `error.tsx` boundary (malformed URL renders the boundary), or
87
+ - `safeParse` and branch on the result: `if (result.status === "error") notFound();` (or render a fallback), or
88
+ - keep `parse` but add `.catch()` on the keys that used to be silently coerced.
89
+
90
+ ### Client components in the route
91
+
92
+ Replace `useParams()` / `useSearchParams()` reads with the typed hooks:
93
+
94
+ ```tsx
95
+ "use client";
96
+ import { useRouteParams, useSearch } from "@paramour-js/next/app";
97
+ import { productRoute } from "./route.def";
98
+
99
+ export function Panel() {
100
+ const params = useRouteParams(productRoute); // SafeResult
101
+ const search = useSearch(productRoute);
102
+ if (params.status === "error" || search.status === "error") return null;
103
+ return <span>{search.data.q ?? ""}</span>;
104
+ }
105
+ ```
106
+
107
+ `useRouteParamsOrThrow`/`useSearchOrThrow` throw to the nearest error boundary instead of returning the error arm. Pages Router components use `@paramour-js/next/pages` (`useRouteParams`/`useSearch`, which add a `pending` status arm; server side, `route.parseContext(ctx)` in `getServerSideProps`).
108
+
109
+ ### Links into the migrated route
110
+
111
+ Replace hand-built strings with `href` wherever the route is linked:
112
+
113
+ ```tsx
114
+ // before
115
+ <Link href={`/products/${id}?page=2`}>
116
+ // after
117
+ <Link href={href(productRoute, { params: { id }, search: { page: 2 } })}>
118
+ ```
119
+
120
+ `href(...)` returns a string subtype — `next/link`, `router.push`, and `redirect` accept it unchanged. Convert the links you find; ones you miss keep working (they are just untyped strings) and can be converted in later passes.
121
+
122
+ ### Static generation
123
+
124
+ `generateStaticParams` → build entries with `encodeStaticParams(route, { id: 42 })` from `paramour`, which returns the per-param wire-string record Next expects.
125
+
126
+ ## What NOT to touch in a pass
127
+
128
+ - Other routes' raw `params`/`searchParams` access — leave it alone until that route's own pass.
129
+ - Shared layouts/components serving unmigrated routes.
130
+ - Unrelated search keys the page never read — do not "complete" the search config speculatively; declare only what the route actually uses (undeclared keys are ignored by decode and never emitted by encode).
131
+ - Wire formats: do not change a param's URL spelling (e.g. csv ↔ repeated keys) while migrating; preserve existing URLs, change formats in a separate deliberate commit.
132
+
133
+ ## Per-route checklist
134
+
135
+ 1. Read every file of the route (page, layout if it reads params, generateMetadata, client components, links into it) and inventory each `params`/`searchParams` key and its fallback semantics.
136
+ 2. Create `route.def.ts` with `defineAppRoute` (or `definePagesRoute`): `params:` codec per dynamic segment, `search:` codec per read key with `.default()`/`.optional()`/`.catch()` matching the old semantics.
137
+ 3. Convert server access: props typed `RouteProps`; `route.parse(props)` / `parseParams` / `parseSearch` (or the `safeParse*` forms). Decide the malformed-URL story (error boundary vs safeParse vs `.catch()`).
138
+ 4. Convert the route's client components to `@paramour-js/next/app` (or `/pages`) hooks.
139
+ 5. Convert links into the route to `href(route, ...)`.
140
+ 6. Run `paramour check` (regenerate + commit the artifact if routes were added/renamed), then the type check and tests.
141
+ 7. Commit. Next route.
@@ -0,0 +1,96 @@
1
+ # Reference: exports, CLI, config, wire format
2
+
3
+ <!--
4
+ The tables and the "Key types" list in this file are drift-checked against
5
+ the package source by packages/next/test/skill-drift.test.ts. Keep each
6
+ cited export a single backtick token (`name` or `name(...)`) in the listed
7
+ column, flags spelled `--flag` in the Flags column, and wire-rule IDs in
8
+ their published S/P/D/CV/R/PP spelling.
9
+ -->
10
+
11
+ ## `paramour` barrel exports
12
+
13
+ Runtime values:
14
+
15
+ | Export | Purpose |
16
+ | ---------------------------------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- |
17
+ | `p` | Codec builders: `string, integer, number, boolean, enum, isoDate, timestamp, json, csv, array, index, custom` |
18
+ | `defineAppRoute(path, config)` | Define an App Router route object (`config: { params?, search? }`) |
19
+ | `definePagesRoute(path, config)` | Define a Pages Router route object |
20
+ | `href(routeOrStaticPath, options?)` | Build a typed link (`{ params?, search?, hash? }`); returns `Href` (string subtype) |
21
+ | `buildPath(route, params)` | Path portion only (`/a/b`), no query |
22
+ | `encodeParams(route, params)` | Encoded path segments as `string[]` |
23
+ | `encodeStaticParams(route, params)` | Per-param wire-string record for `generateStaticParams` / `getStaticPaths` |
24
+ | `decodeParams(route, source, opts?)` | Sync params decode; throws `ParamsDecodeError`; `opts: { percentDecode?: boolean }` (default true — App Router) |
25
+ | `decodeSearch(config, source, routePath?)` | Sync search decode; throws `SearchDecodeError`; unknown keys ignored |
26
+ | `safeDecodeParams` / `safeDecodeSearch` | `SafeResult`-returning twins of the two decoders |
27
+ | `encodeSearch(config, input)` | Decoded values → ordered wire pairs `[string, string][]` (default elision applied) |
28
+ | `buildSearchString(pairs)` | Pairs → `?…` string (`%20`, never `+`) |
29
+ | `searchToString(config, input)` | `encodeSearch` + `buildSearchString` |
30
+ | `serializeValue(codec, label, value)` | One value through a codec's serializer, string contract enforced |
31
+ | `rawSearch(schema)` / `isRawSearch(config)` | Whole-object search escape hatch and its discriminant |
32
+ | `standardSearchSchema(route)` | Export a route's search config as a Standard Schema (tRPC input, TanStack `validateSearch`) |
33
+ | `describeCodec(codec)` / `describeRoute(route)` / `formatCodecDescription(desc, style)` | Reflection over codec/route metadata (powers `paramour list`) |
34
+ | `ParamourError, ParseError, SerializeError, ParamsDecodeError, SearchDecodeError, SearchSourceError` | Error classes (brand-hardened `instanceof`) |
35
+
36
+ Key types: `Codec`, `AnyCodec`, `OutputOf`, `ParamCodec`, `Presence`, `PresenceOf`, `Arity`; `AppRoute`, `PagesRoute`, `Route`, `AnyRoute`, `AnyAppRoute`, `AnyPagesRoute`, `RouterKind`, `PagesContext`; `RouteProps`, `ParamsProps`, `SearchProps` (+ `*Input` sync-accepting forms) — annotate page/layout props with these; `InferRouteParams`, `SearchOutputOf`, `InferSearchInput`, `InferSearchOutput`, `InferStaticParams`, `InferHrefInput`, `HrefArgs`, `Href`, `StaticHrefOptions`; `SafeResult`, `RouteDecodeError`, `Issue`, `IssueReason`; `ParamsConfig`, `SearchConfig`, `ParamsSource`, `SearchSource`, `RawSearch`, `StandardSearchSchema`; `ParamourRegister` + `Registered*RoutePaths` (codegen augmentation targets); `CodecDescription`, `RouteDescription`, `ParamDescription`, `SearchDescription`, `CodecDefaultDescription`, `CodecFormatStyle`; `DecodeParamsOptions`.
37
+
38
+ ## `@paramour-js/next` exports
39
+
40
+ | Entry point | Exports |
41
+ | --------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
42
+ | `@paramour-js/next` | `withTypedRoutes(config, options?)` (`options: { outFile?, strict? }`), `RouteCollisionError`, types `WithTypedRoutesOptions`, `ParamourConfig` |
43
+ | `@paramour-js/next/app` | `useRouteParams`, `useRouteParamsOrThrow`, `useSearch`, `useSearchOrThrow` (all `(route, options?)` with `options: { select, equality?: "shallow" }`), type `SelectOptions` |
44
+ | `@paramour-js/next/pages` | `useRouteParams`, `useSearch` (return `RouterResult` = `SafeResult` + `{ status: "pending" }`), types `RouterResult`, `SelectOptions` |
45
+ | `@paramour-js/next/testing` | `ParamourTestingProvider`, `withParamourTesting(options?)`, type `ParamourTestingOptions` (`isReady, mounted, onReplace, params, pathname, search`) |
46
+ | `@paramour-js/next/devtools-seam` | Types-only seam contract consumed by `@paramour-js/devtools-panel`; not needed in app code |
47
+
48
+ ## CLI (`paramour <command>`, bin shipped by `@paramour-js/next`)
49
+
50
+ Exit-code contract for every command: `0` success; `1` ONLY a failed verification (`check` drift, `doctor` fail, `skills --check` missing/stale); `2` usage/config/operational errors.
51
+
52
+ | Command | Purpose | Flags |
53
+ | ---------- | ---------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
54
+ | `generate` | Write `paramour-env.d.ts` from route dirs | `--app-dir <dir>`, `--pages-dir <dir>`, `--out-file <file>`, `--page-extensions <list>`, `--check`, `--watch` |
55
+ | `check` | Verify the artifact is current; exit 1 on drift or missing; never writes | `--app-dir`, `--pages-dir`, `--out-file`, `--page-extensions` |
56
+ | `init` | Set up paramour: scaffold config, wrap next.config, add script, first generate, agent skills + AGENTS.md snippet | `--dry-run`, `--force`, `--no-config`, `--no-generate`, `--no-script`, `--no-wrap`, `--no-skills`, `--no-agents-md` |
57
+ | `list` | Print every filesystem route with its params/search shape (evaluates route-definition modules) | `--json`, `--app-dir`, `--pages-dir`, `--page-extensions` |
58
+ | `doctor` | Diagnose setup: config validity, artifact freshness, next.config wrapping, versions, tsconfig | `--json`; exit 1 on any failing check, warnings exit 0 |
59
+ | `skills` | Install/sync this agent skill into detected tool dirs (`.claude/`, `.cursor/`, …) | `--check` (verify only; exit 1 on missing/stale), `--dry-run`, `--force` (overwrite user-edited files), `--json`, `--tool <t>` (agents, claude, codex, cursor; repeatable or comma-separated, overrides detection) |
60
+
61
+ All commands accept `--help`/`-h` and run against `process.cwd()` as the project root.
62
+
63
+ ## `paramour.config.{ts,mjs,json}` (project root; first match wins; all fields optional)
64
+
65
+ Precedence: CLI flags → config file → discovery. Unknown keys are rejected (exit 2).
66
+
67
+ | Field | Default | Meaning |
68
+ | ---------------- | --------------------------- | ------------------------------------------------------------------------------------------------------ |
69
+ | `appDir` | discovered `app/`/`src/app` | App directory, relative to project root |
70
+ | `pagesDir` | discovered `pages/`… | Pages directory |
71
+ | `outFile` | `paramour-env.d.ts` | Artifact path (monorepo escape hatch); also settable on `withTypedRoutes` |
72
+ | `pageExtensions` | `["tsx","ts","jsx","js"]` | No leading dots |
73
+ | `routeFiles` | automatic content scan | Globs of modules exporting route definitions — used by `list`/`doctor` only; generation never reads it |
74
+
75
+ `.ts`/`.mjs` files default-export the object (`export default {...} satisfies ParamourConfig`).
76
+
77
+ ## Wire-format summary (numbered spec: docs/reference/wire-format on paramour.dev)
78
+
79
+ | Family | Scope |
80
+ | ------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
81
+ | S | Byte layer: `%20` never `+` (S2); absence = omitted key, `""` = `key=` (S3); never bare `?flag` (S4); deterministic declaration order (S5); hash only from `href`'s explicit option, verbatim (S10) |
82
+ | P | Parse layer: duplicate values on a scalar codec are an error, catchable (P5); absent array key → `[]` (P6); unknown keys ignored and never validated (P8) |
83
+ | SS | `rawSearch`: explicit wrapper only; schema sees every key on decode; encode is a raw pass-through, schema never runs; no elision/round-trip |
84
+ | D | Codec grammar: `.catch()` recovers parse failures never absence (D2); presence governs absence and every declared key appears in decode output (D4); params take no presence modifiers (D5); a catch-all codec describes one element (D6); value defaults elide (D8) |
85
+ | CV | `p.csv`: one comma-joined wire value; `""` ↔ `[]`; empty elements are parse failures; serialize rejects elements that are empty or contain commas |
86
+ | R | Route segments: one segment per `[param]` (R1); catch-all elements encode independently, inner `/` → `%2F` (R2); optional catch-all elides, required `[]` is a `SerializeError` (R3); empty segment value is a `SerializeError` (R4); App Router param props arrive percent-ENCODED and core decodes them — Pages surfaces opt out via `percentDecode: false` (R5); no trailing slashes (R6) |
87
+ | PP | `p.array` typed elements by composition (PP1); `p.index` is 1-based on the wire, 0-based in memory (PP5) |
88
+
89
+ Facts agents trip on:
90
+
91
+ - Booleans serialize as exactly `true`/`false`; anything else fails to parse.
92
+ - Dates: `p.isoDate` is `YYYY-MM-DD`; `p.timestamp` is full ISO UTC (`Z` only, offsets rejected); both reject impossible calendar dates.
93
+ - Integers reject `1e3`, hex, whitespace, and unsafe-range values.
94
+ - Arrays: `p.array` repeats the key (`?t=a&t=b`); `p.csv` packs one key (`?t=a,b`). Same in-memory `string[]`, two deliberate wire spellings — do not swap them casually.
95
+ - Value-form `.default()` elides: building a URL with the default value emits nothing for that key; decoding the bare URL restores the default. Factory defaults never elide.
96
+ - An href with no emitted pairs has no `?` at all; a fully elided optional catch-all leaves the bare base path (`/docs`, never `/docs/`).
@@ -0,0 +1,139 @@
1
+ # Setup: adding paramour to a project (greenfield)
2
+
3
+ ## 1. Install both packages
4
+
5
+ ```sh
6
+ npm install paramour @paramour-js/next
7
+ # or
8
+ pnpm add paramour @paramour-js/next
9
+ # or
10
+ yarn add paramour @paramour-js/next
11
+ ```
12
+
13
+ Requirements: `next >= 15`, `react >= 18.2`, Node `>= 22.13` for the CLI. ESM-only packages.
14
+
15
+ ## 2. Run `npx paramour init`
16
+
17
+ `init` is non-interactive and idempotent: it runs straight through with defaults and prints one status line per step. Steps:
18
+
19
+ 1. Scaffold `paramour.config.ts` at the project root (all fields commented-out defaults — deleting the file changes nothing). Skipped if any `paramour.config.{ts,mjs,json}` already exists; `--force` overwrites/replaces it.
20
+ 2. Wrap `next.config.*` with `withTypedRoutes` via an AST transform. If no next.config exists or the transform cannot apply safely, it prints a manual snippet to apply yourself — that is still a SUCCESS outcome (exit 0), not a failure.
21
+ 3. Add `"paramour": "paramour generate"` to `package.json` scripts.
22
+ 4. Run the first generate (skipped with a warning if no `app/` or `pages/` directory exists yet).
23
+ 5. Install the paramour agent skill files into detected agent tool directories (`.claude/`, `.cursor/`, etc.); skip with `--no-skills`.
24
+ 6. Append a marker-delimited (`<!-- paramour:start -->` … `<!-- paramour:end -->`) paramour section to an existing `AGENTS.md` (or, failing that, `CLAUDE.md`) pointing agents at the installed skill and the verify loop. Never creates the file; re-runs refresh the section in place; skip with `--no-agents-md`.
25
+
26
+ Then a `setup:` detect-and-verify summary (route dirs found, both packages declared, tsconfig `include` covers the artifact) — these are warn-level and never change the exit code — and a final reminder to commit the artifact.
27
+
28
+ Output marks to recognize:
29
+
30
+ - `✔ created paramour.config.ts` / `✔ wrapped next.config.ts with withTypedRoutes` / `✔ added "paramour" script to package.json` / `✔ wrote paramour-env.d.ts (N routes)` — step performed.
31
+ - `• ... already exists — skipped` / `• ... already wraps withTypedRoutes — skipped` / `• ... already up to date — skipped` — idempotent skip; fine.
32
+ - `→ could not transform ... — apply this yourself:` followed by an indented snippet — apply the printed snippet manually, then continue; exit is still 0.
33
+ - `⚠ no route directory yet — skipped generate` — create `app/` or `pages/`, then run `paramour generate`.
34
+
35
+ Flags: `--dry-run` (report every step, write nothing), `--force` (overwrite an existing paramour.config), `--no-config`, `--no-generate`, `--no-script`, `--no-wrap`, `--no-skills`, `--no-agents-md`, `--help`/`-h`.
36
+
37
+ ## 3. Manual equivalent (when init cannot be used)
38
+
39
+ Scaffold `paramour.config.ts` (optional — only needed to override discovery; every value shown is the default):
40
+
41
+ ```ts
42
+ import type { ParamourConfig } from "@paramour-js/next";
43
+
44
+ export default {
45
+ // appDir: "app",
46
+ // outFile: "paramour-env.d.ts",
47
+ // pageExtensions: ["tsx", "ts", "jsx", "js"],
48
+ // pagesDir: "pages",
49
+ // routeFiles: ["src/routes/**/*.ts"], // pin `paramour list`'s definition scan
50
+ } satisfies ParamourConfig;
51
+ ```
52
+
53
+ Wrap next.config:
54
+
55
+ ```ts
56
+ import { withTypedRoutes } from "@paramour-js/next";
57
+ import type { NextConfig } from "next";
58
+
59
+ const nextConfig: NextConfig = {};
60
+
61
+ export default withTypedRoutes(nextConfig);
62
+ ```
63
+
64
+ `withTypedRoutes(config, options?)` regenerates the artifact once per production build (drift warns; `{ strict: true }` fails the build on drift instead) and runs a debounced regeneration watcher during `next dev`. `{ outFile: "..." }` relocates the artifact (monorepo escape hatch). Generation is never load-bearing: a missing route dir or an incidental failure warns and continues with stale types — the two exceptions that throw are an app↔pages route collision and a populated-but-ignored route dir.
65
+
66
+ Add the script to `package.json`:
67
+
68
+ ```json
69
+ { "scripts": { "paramour": "paramour generate" } }
70
+ ```
71
+
72
+ ## 4. Define a first route
73
+
74
+ Colocate a route definition next to its page (any module works; `route.def.ts` beside `page.tsx` is the common pattern):
75
+
76
+ ```ts
77
+ // app/product/[id]/route.def.ts
78
+ import { defineAppRoute, p } from "paramour";
79
+
80
+ export const productRoute = defineAppRoute("/product/[id]", {
81
+ params: { id: p.integer() },
82
+ search: {
83
+ page: p.integer().default(1),
84
+ q: p.string().optional(),
85
+ },
86
+ });
87
+ ```
88
+
89
+ Use it in the page. Annotate props with paramour's `RouteProps` (Next's promised `params`/`searchParams` are structurally assignable to it):
90
+
91
+ ```tsx
92
+ // app/product/[id]/page.tsx
93
+ import type { RouteProps } from "paramour";
94
+ import { productRoute } from "./route.def";
95
+
96
+ export default async function ProductPage(props: RouteProps) {
97
+ // Throws ParamsDecodeError/SearchDecodeError on a malformed URL — pair
98
+ // with an error.tsx boundary, or use safeParse for a status-discriminated
99
+ // result instead of a throw.
100
+ const { params, search } = await productRoute.parse(props);
101
+ return (
102
+ <h1>
103
+ Product #{params.id} (page {search.page})
104
+ </h1>
105
+ );
106
+ }
107
+ ```
108
+
109
+ Build links to it from anywhere:
110
+
111
+ ```ts
112
+ import { href } from "paramour";
113
+ import { productRoute } from "@/app/product/[id]/route.def";
114
+
115
+ const link = href(productRoute, { params: { id: 42 }, search: { q: "hi" } });
116
+ // "/product/42?q=hi" — page=1 elides because it equals the default
117
+ ```
118
+
119
+ ## 5. Generate, verify, commit
120
+
121
+ ```sh
122
+ npx paramour generate # writes paramour-env.d.ts
123
+ npx paramour check # exit 0 = artifact current
124
+ ```
125
+
126
+ Commit `paramour-env.d.ts` with the route change. The artifact is a pure `.d.ts` module augmentation (`declare module "paramour" { interface ParamourRegister { ... } }`) that turns `defineAppRoute`/`definePagesRoute` path literals and `href("/static/path")` strings into filesystem-verified unions. Ensure the project tsconfig `include` covers it (Next's default `**/*.ts` include does).
127
+
128
+ ## Exit codes (all CLI commands)
129
+
130
+ - `0` — success, including init's printed manual-fallback snippets and idempotent skips.
131
+ - `1` — ONLY "the thing you asked me to verify is not true": `check` (or `generate --check`) drift or missing artifact, `doctor` with a failing check, `skills --check` with missing/stale skill files.
132
+ - `2` — usage/config/operational errors: unknown command or flag, invalid `paramour.config` (unknown key, dotted `pageExtensions` entry), no `package.json` for init, no route directory for generate, app↔pages route collisions.
133
+
134
+ ## Common failure modes
135
+
136
+ - `paramour check` exit 1 saying `out of date` or `is missing`, with a route diff: run `paramour generate` and commit. If it says `content differs from generator output` with no route diff, someone hand-edited the artifact — regenerate, never edit.
137
+ - Route collision (same path served by both `app/` and `pages/`): exit 2 from the CLI and a thrown error from `withTypedRoutes` — remove one of the duplicates; Next itself cannot build that state either.
138
+ - `no route directory` from `paramour generate`: run from the project root (the directory holding `app/`/`pages/` or `src/app/`/`src/pages/`), or set `appDir`/`pagesDir` in `paramour.config.ts`.
139
+ - Registered-path types not narrowing (any string accepted in `defineAppRoute`): the artifact is missing or not covered by tsconfig `include` — run `paramour doctor`.