@paramour-js/next 0.4.0 → 0.5.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.
Files changed (74) hide show
  1. package/README.md +1 -1
  2. package/dist/app.d.ts +48 -49
  3. package/dist/app.js +11 -12
  4. package/dist/cli-args.d.ts +4 -4
  5. package/dist/cli-args.js +4 -4
  6. package/dist/cli-inputs.d.ts +6 -7
  7. package/dist/cli-inputs.js +6 -7
  8. package/dist/cli.js +1 -1
  9. package/dist/collisions.d.ts +8 -8
  10. package/dist/collisions.js +9 -9
  11. package/dist/commands/doctor.d.ts +12 -0
  12. package/dist/commands/doctor.js +12 -7
  13. package/dist/commands/generate.d.ts +55 -7
  14. package/dist/commands/generate.js +29 -22
  15. package/dist/commands/init.d.ts +40 -0
  16. package/dist/commands/init.js +111 -19
  17. package/dist/commands/list.d.ts +21 -0
  18. package/dist/commands/list.js +12 -7
  19. package/dist/commands/skills.d.ts +45 -0
  20. package/dist/commands/skills.js +222 -0
  21. package/dist/config.d.ts +17 -10
  22. package/dist/config.js +17 -4
  23. package/dist/devtools-seam.d.ts +19 -19
  24. package/dist/devtools-seam.js +3 -3
  25. package/dist/doctor/checks.js +7 -3
  26. package/dist/emit.d.ts +12 -12
  27. package/dist/emit.js +13 -13
  28. package/dist/generate.d.ts +14 -14
  29. package/dist/generate.js +11 -11
  30. package/dist/init/agents-md.d.ts +32 -0
  31. package/dist/init/agents-md.js +78 -0
  32. package/dist/init/scaffold.js +54 -0
  33. package/dist/list/discover-route-defs.d.ts +4 -4
  34. package/dist/list/discover-route-defs.js +4 -4
  35. package/dist/lock.d.ts +8 -9
  36. package/dist/lock.js +11 -12
  37. package/dist/navigation-adapter.d.ts +17 -18
  38. package/dist/navigation-adapter.js +4 -4
  39. package/dist/observe.d.ts +14 -14
  40. package/dist/observe.js +4 -4
  41. package/dist/pages.d.ts +30 -31
  42. package/dist/pages.js +10 -10
  43. package/dist/run-cli.d.ts +3 -1
  44. package/dist/run-cli.js +7 -3
  45. package/dist/scan-app.d.ts +10 -10
  46. package/dist/scan-app.js +30 -30
  47. package/dist/scan-pages.d.ts +6 -6
  48. package/dist/scan-pages.js +28 -26
  49. package/dist/scan.d.ts +13 -10
  50. package/dist/scan.js +7 -7
  51. package/dist/select.d.ts +40 -40
  52. package/dist/select.js +30 -30
  53. package/dist/skills/doctor.d.ts +11 -0
  54. package/dist/skills/doctor.js +71 -0
  55. package/dist/skills/manifest.d.ts +41 -0
  56. package/dist/skills/manifest.js +96 -0
  57. package/dist/skills/packaged.d.ts +21 -0
  58. package/dist/skills/packaged.js +32 -0
  59. package/dist/skills/sync.d.ts +84 -0
  60. package/dist/skills/sync.js +136 -0
  61. package/dist/skills/targets.d.ts +29 -0
  62. package/dist/skills/targets.js +73 -0
  63. package/dist/testing.d.ts +28 -32
  64. package/dist/testing.js +15 -15
  65. package/dist/watch.d.ts +12 -12
  66. package/dist/watch.js +13 -13
  67. package/dist/with-typed-routes.d.ts +11 -10
  68. package/dist/with-typed-routes.js +42 -39
  69. package/package.json +4 -3
  70. package/skills/paramour/SKILL.md +43 -0
  71. package/skills/paramour/references/authoring.md +113 -0
  72. package/skills/paramour/references/migration.md +141 -0
  73. package/skills/paramour/references/reference.md +96 -0
  74. package/skills/paramour/references/setup.md +139 -0
package/dist/watch.d.ts CHANGED
@@ -9,31 +9,31 @@ export interface WatchRouteDirsOptions {
9
9
  debounceMs?: number;
10
10
  /**
11
11
  * Absolute paths whose events are ignored — the artifact file, so a
12
- * regeneration write can't re-trigger the watcher (TR5 feedback loop).
12
+ * regeneration write can't re-trigger the watcher in a feedback loop.
13
13
  */
14
14
  ignorePaths?: readonly string[];
15
15
  /**
16
16
  * Watcher startup/runtime failures and `onRescan` throws land here.
17
- * Surfaced, not logged: TR5's "log once, dev continues" behavior belongs
18
- * to the composition points (TR4/TR7), not this module.
17
+ * Surfaced, not logged: the "log once, dev continues" behavior belongs to
18
+ * the composition points, not this module.
19
19
  */
20
20
  onError?: (error: unknown) => void;
21
- /** The regenerate callback — full rescan → write-if-changed (TR5). */
21
+ /** The regenerate callback — full rescan → write-if-changed. */
22
22
  onRescan: () => void;
23
23
  }
24
- /** TR5: ~100 ms — long enough to coalesce an editor save storm. */
24
+ /** ~100 ms — long enough to coalesce an editor save storm. */
25
25
  export declare const DEFAULT_DEBOUNCE_MS = 100;
26
26
  /**
27
27
  * Debounced full-rescan watcher over the route dirs — both of them in a
28
- * hybrid project (PR8), sharing one debounce so an editor operation touching
29
- * both coalesces into a single rescan. Because a scan is milliseconds (TR2),
30
- * no event fidelity is needed: any event → debounce → `onRescan`. Native
28
+ * hybrid project, sharing one debounce so an editor operation touching both
29
+ * coalesces into a single rescan. Because a scan is milliseconds, no event
30
+ * fidelity is needed: any event → debounce → `onRescan`. Native
31
31
  * `fs.watch({ recursive: true })`, no chokidar; this start/close interface
32
32
  * is the seam chokidar would drop in behind if a platform hole appears.
33
33
  *
34
- * A missing dir is skipped — not watched, not an error (PR8): callers pass
35
- * the dirs discovery resolved, so absence here is a raced deletion, and dev
36
- * continuing in stale-types mode is exactly TR5's posture. Genuine watch
37
- * startup failures still surface through `onError`.
34
+ * A missing dir is skipped — not watched, not an error: callers pass the
35
+ * dirs discovery resolved, so absence here is a raced deletion, and dev
36
+ * continuing in stale-types mode is exactly the intended posture. Genuine
37
+ * watch startup failures still surface through `onError`.
38
38
  */
39
39
  export declare function watchRouteDirs(dirs: readonly string[], options: WatchRouteDirsOptions): RouteDirsWatcher;
package/dist/watch.js CHANGED
@@ -1,24 +1,24 @@
1
1
  import { statSync, watch } from "node:fs";
2
2
  import { resolve } from "node:path";
3
- /** TR5: ~100 ms — long enough to coalesce an editor save storm. */
3
+ /** ~100 ms — long enough to coalesce an editor save storm. */
4
4
  export const DEFAULT_DEBOUNCE_MS = 100;
5
5
  /**
6
6
  * Directory names whose subtrees are ignored if they ever fall under a
7
- * watched root (TR5).
7
+ * watched root.
8
8
  */
9
9
  const IGNORED_SEGMENTS = new Set([".next", "node_modules"]);
10
10
  /**
11
11
  * Debounced full-rescan watcher over the route dirs — both of them in a
12
- * hybrid project (PR8), sharing one debounce so an editor operation touching
13
- * both coalesces into a single rescan. Because a scan is milliseconds (TR2),
14
- * no event fidelity is needed: any event → debounce → `onRescan`. Native
12
+ * hybrid project, sharing one debounce so an editor operation touching both
13
+ * coalesces into a single rescan. Because a scan is milliseconds, no event
14
+ * fidelity is needed: any event → debounce → `onRescan`. Native
15
15
  * `fs.watch({ recursive: true })`, no chokidar; this start/close interface
16
16
  * is the seam chokidar would drop in behind if a platform hole appears.
17
17
  *
18
- * A missing dir is skipped — not watched, not an error (PR8): callers pass
19
- * the dirs discovery resolved, so absence here is a raced deletion, and dev
20
- * continuing in stale-types mode is exactly TR5's posture. Genuine watch
21
- * startup failures still surface through `onError`.
18
+ * A missing dir is skipped — not watched, not an error: callers pass the
19
+ * dirs discovery resolved, so absence here is a raced deletion, and dev
20
+ * continuing in stale-types mode is exactly the intended posture. Genuine
21
+ * watch startup failures still surface through `onError`.
22
22
  */
23
23
  export function watchRouteDirs(dirs, options) {
24
24
  const { debounceMs = DEFAULT_DEBOUNCE_MS, ignorePaths = [], onError, onRescan, } = options;
@@ -31,7 +31,7 @@ export function watchRouteDirs(dirs, options) {
31
31
  onRescan();
32
32
  }
33
33
  catch (error) {
34
- // A throwing regeneration must not kill the watcher (TR5 non-fatal).
34
+ // A throwing regeneration must not kill the watcher — non-fatal.
35
35
  onError?.(error);
36
36
  }
37
37
  }, debounceMs);
@@ -50,7 +50,7 @@ export function watchRouteDirs(dirs, options) {
50
50
  watcher = watch(dir, { recursive: true }, (_eventType, filename) => {
51
51
  // `filename` can be null (platform-dependent); with nothing to
52
52
  // filter on, err toward rescanning — a spurious pass is a no-op
53
- // write (TR3).
53
+ // write.
54
54
  if (filename !== null) {
55
55
  if (ignored.has(resolve(dir, filename)))
56
56
  return;
@@ -63,8 +63,8 @@ export function watchRouteDirs(dirs, options) {
63
63
  });
64
64
  }
65
65
  catch (error) {
66
- // TR5: watcher failure is non-fatal — dev continues in stale-types
67
- // mode, and the other dir's watcher (if any) keeps running.
66
+ // Watcher failure is non-fatal — dev continues in stale-types mode,
67
+ // and the other dir's watcher (if any) keeps running.
68
68
  onError?.(error);
69
69
  continue;
70
70
  }
@@ -1,15 +1,15 @@
1
- /** Options for {@link withTypedRoutes} (TR4). */
1
+ /** Options for {@link withTypedRoutes}. */
2
2
  export interface WithTypedRoutesOptions {
3
3
  /**
4
4
  * Artifact location, for monorepos where the Next app root isn't where the
5
- * file should live (TR3 escape hatch). Relative paths resolve against the
5
+ * file should live — the escape hatch. Relative paths resolve against the
6
6
  * project root. Default: `paramour-env.d.ts` at the project root.
7
7
  */
8
8
  outFile?: string;
9
9
  /**
10
- * Upgrade build-phase drift from a loud warning to a build failure (TR4)
11
- * — for teams that want the committed artifact to be the law. Default
12
- * `false`, friendly to gitignored-file workflows and CI images.
10
+ * Upgrade build-phase drift from a loud warning to a build failure — for
11
+ * teams that want the committed artifact to be the law. Default `false`,
12
+ * friendly to gitignored-file workflows and CI images.
13
13
  */
14
14
  strict?: boolean;
15
15
  }
@@ -24,21 +24,22 @@ export declare function devWatcherCountForTests(): number;
24
24
  */
25
25
  export declare function resetDevWatchersForTests(): void;
26
26
  /**
27
- * Wrap a Next config with route-registry generation (TR4). Returns the
27
+ * Wrap a Next config with route-registry generation. Returns the
28
28
  * config-function form; Next's phase argument is the mode discriminator:
29
29
  *
30
30
  * - production build → one generation pass before the config is returned
31
31
  * (the build type-checks against fresh routes); drift warns loudly, or
32
32
  * fails the build under `strict: true`.
33
33
  * - dev server → one immediate generation pass, then the debounced watcher
34
- * (TR5) behind both single-writer guards (TR6).
34
+ * behind both single-writer guards (the in-process singleton and the
35
+ * cross-process pidfile lock).
35
36
  * - every other phase → pass-through, no generation.
36
37
  *
37
38
  * Two states throw during config evaluation instead of degrading to
38
39
  * stale-types mode, both phases alike, because Next itself has no valid
39
- * build for them: an app↔pages route collision (PR9), and discovery's
40
- * populated-ignored-dir config error (spike-2 ruling — Next is silently
41
- * serving none of those pages).
40
+ * build for them: an app↔pages route collision, and discovery's
41
+ * populated-ignored-dir config error (Next is silently serving none of those
42
+ * pages).
42
43
  */
43
44
  export declare function withTypedRoutes<C extends object>(config: C | ConfigFunction<C>, options?: WithTypedRoutesOptions): ConfigFunction<C>;
44
45
  export {};
@@ -7,18 +7,18 @@ import { resolveRouteDirs } from "./scan.js";
7
7
  import { watchRouteDirs } from "./watch.js";
8
8
  /**
9
9
  * Phase constants from `next/constants`, hardcoded so the package stays
10
- * hermetic (TR4 hermeticity ruling): the values are stable, documented
11
- * public API, and importing them would make `next` a runtime dependency.
10
+ * hermetic: the values are stable, documented public API, and importing them
11
+ * would make `next` a runtime dependency.
12
12
  */
13
13
  const PHASE_DEVELOPMENT_SERVER = "phase-development-server";
14
14
  const PHASE_PRODUCTION_BUILD = "phase-production-build";
15
15
  /**
16
- * TR6 guard 1 — the in-process singleton, keyed by route dirs + artifact
17
- * path. Load-bearing even for a single `next dev`: the spike-#1 census found
18
- * Turbopack dev invokes the config function twice in the same process.
16
+ * The in-process single-writer guard — a singleton keyed by route dirs +
17
+ * artifact path. Load-bearing even for a single `next dev`: Turbopack dev
18
+ * invokes the config function twice in the same process.
19
19
  */
20
20
  const devWatcherTeardowns = new Map();
21
- /** Messages already logged — "log once" (TR5) across repeat evaluations. */
21
+ /** Messages already logged — "log once" across repeat evaluations. */
22
22
  const warnedOnce = new Set();
23
23
  /** @internal Test seam: the number of live dev-watcher singletons. */
24
24
  export function devWatcherCountForTests() {
@@ -36,21 +36,22 @@ export function resetDevWatchersForTests() {
36
36
  warnedOnce.clear();
37
37
  }
38
38
  /**
39
- * Wrap a Next config with route-registry generation (TR4). Returns the
39
+ * Wrap a Next config with route-registry generation. Returns the
40
40
  * config-function form; Next's phase argument is the mode discriminator:
41
41
  *
42
42
  * - production build → one generation pass before the config is returned
43
43
  * (the build type-checks against fresh routes); drift warns loudly, or
44
44
  * fails the build under `strict: true`.
45
45
  * - dev server → one immediate generation pass, then the debounced watcher
46
- * (TR5) behind both single-writer guards (TR6).
46
+ * behind both single-writer guards (the in-process singleton and the
47
+ * cross-process pidfile lock).
47
48
  * - every other phase → pass-through, no generation.
48
49
  *
49
50
  * Two states throw during config evaluation instead of degrading to
50
51
  * stale-types mode, both phases alike, because Next itself has no valid
51
- * build for them: an app↔pages route collision (PR9), and discovery's
52
- * populated-ignored-dir config error (spike-2 ruling — Next is silently
53
- * serving none of those pages).
52
+ * build for them: an app↔pages route collision, and discovery's
53
+ * populated-ignored-dir config error (Next is silently serving none of those
54
+ * pages).
54
55
  */
55
56
  export function withTypedRoutes(config, options = {}) {
56
57
  return async (phase, ctx) => {
@@ -58,16 +59,17 @@ export function withTypedRoutes(config, options = {}) {
58
59
  if (phase !== PHASE_DEVELOPMENT_SERVER && phase !== PHASE_PRODUCTION_BUILD)
59
60
  return resolved;
60
61
  // The dev server and every build worker evaluate the config with the
61
- // project root as cwd (spike-#1 census); TR7's CLI flags are the home
62
- // for anything more configurable than this.
62
+ // project root as cwd; the CLI flags are the home for anything more
63
+ // configurable than this.
63
64
  const projectRoot = process.cwd();
64
65
  const artifactPath = resolve(projectRoot, options.outFile ?? "paramour-env.d.ts");
65
66
  const pageExtensions = resolved.pageExtensions ?? DEFAULT_PAGE_EXTENSIONS;
66
- // May throw the spike-2 config error — deliberately not caught (above).
67
+ // May throw the populated-ignored-dir config error — deliberately not
68
+ // caught (see above).
67
69
  const dirs = resolveRouteDirs(projectRoot, pageExtensions);
68
70
  if (dirs.appDir === undefined && dirs.pagesDir === undefined) {
69
- // §7.3: codegen is never load-bearing — a config wrapper must not
70
- // take down `next dev`/`next build` over a missing route dir.
71
+ // Codegen is never load-bearing — a config wrapper must not take down
72
+ // `next dev`/`next build` over a missing route dir.
71
73
  warnOnce(`paramour: no route directory (app/, pages/, src/app/, or src/pages/) under ${projectRoot}; route generation skipped`);
72
74
  return resolved;
73
75
  }
@@ -81,12 +83,12 @@ export function withTypedRoutes(config, options = {}) {
81
83
  };
82
84
  }
83
85
  /**
84
- * Build-phase pass (TR4): regenerate, then warn loudly on drift — naming the
85
- * paths that appeared/disappeared and the router they moved in — but
86
- * continue; `strict` upgrades drift to a thrown error *after* the file is
87
- * already corrected. A missing artifact counts as drift: that is exactly the
88
- * CI-degrades-to-world-A scenario the committed file exists to prevent
89
- * (TR3). A route collision is NOT incidental failure and rethrows (PR9).
86
+ * Build-phase pass: regenerate, then warn loudly on drift — naming the paths
87
+ * that appeared/disappeared and the router they moved in — but continue;
88
+ * `strict` upgrades drift to a thrown error *after* the file is already
89
+ * corrected. A missing artifact counts as drift: that is exactly the
90
+ * CI-degrades-to-world-A scenario the committed file exists to prevent. A
91
+ * route collision is NOT incidental failure and rethrows.
90
92
  */
91
93
  function generateForBuild(dirs, pageExtensions, artifactPath, strict) {
92
94
  let result;
@@ -94,13 +96,13 @@ function generateForBuild(dirs, pageExtensions, artifactPath, strict) {
94
96
  result = generate({ ...dirs, artifactPath, pageExtensions });
95
97
  }
96
98
  catch (error) {
97
- // PR9: Next fails this build anyway; surfacing the collision from the
98
- // config evaluation names the actual problem instead of leaving a stale
99
+ // Next fails this build anyway; surfacing the collision from the config
100
+ // evaluation names the actual problem instead of leaving a stale
99
101
  // artifact to confuse the type errors that follow.
100
102
  if (error instanceof RouteCollisionError)
101
103
  throw error;
102
- // §7.3 again: incidental generation failure is stale types, not a
103
- // broken build. Only *drift* is allowed to fail a strict build.
104
+ // Again: incidental generation failure is stale types, not a broken
105
+ // build. Only *drift* is allowed to fail a strict build.
104
106
  console.warn("paramour: route generation failed; building with stale route types", error);
105
107
  return;
106
108
  }
@@ -119,9 +121,10 @@ function generateForBuild(dirs, pageExtensions, artifactPath, strict) {
119
121
  console.warn(message);
120
122
  }
121
123
  /**
122
- * Dev-phase generation (TR4): failure warns and continues (§7.3) — except a
123
- * route collision, which throws from the config evaluation here exactly as
124
- * in the build phase (PR9; only the running WATCHER treats it non-fatally).
124
+ * Dev-phase generation: failure warns and continues (codegen is never
125
+ * load-bearing) — except a route collision, which throws from the config
126
+ * evaluation here exactly as in the build phase; only the running WATCHER
127
+ * treats it non-fatally.
125
128
  */
126
129
  function generateSafely(dirs, pageExtensions, artifactPath) {
127
130
  try {
@@ -134,8 +137,8 @@ function generateSafely(dirs, pageExtensions, artifactPath) {
134
137
  }
135
138
  }
136
139
  /**
137
- * Start the dev watcher behind both TR6 guards. Failure at any layer leaves
138
- * dev running in stale-types mode — never fatal (TR5).
140
+ * Start the dev watcher behind both single-writer guards. Failure at any
141
+ * layer leaves dev running in stale-types mode — never fatal.
139
142
  */
140
143
  function startDevWatcher(projectRoot, dirs, pageExtensions, artifactPath) {
141
144
  const watchedDirs = [dirs.appDir, dirs.pagesDir].filter((dir) => dir !== undefined);
@@ -147,14 +150,14 @@ function startDevWatcher(projectRoot, dirs, pageExtensions, artifactPath) {
147
150
  lock = acquireWatcherLock(watcherLockPath(projectRoot));
148
151
  }
149
152
  catch (error) {
150
- // §7.3: a corrupt lock location (e.g. a directory at the pidfile path)
151
- // must not take down `next dev` — stale-types mode, like every other
153
+ // A corrupt lock location (e.g. a directory at the pidfile path) must
154
+ // not take down `next dev` — stale-types mode, like every other
152
155
  // watcher-layer failure.
153
156
  warnOnce("paramour: dev watcher failed; dev continues with stale route types", error);
154
157
  return;
155
158
  }
156
159
  if (!lock.acquired) {
157
- // TR6: another live process owns the watcher (e.g. `paramour generate
160
+ // Another live process owns the watcher (e.g. `paramour generate
158
161
  // --watch` beside `next dev`). Initial generation above already ran, so
159
162
  // dev is still correct from second zero.
160
163
  console.warn(`paramour: watcher already running (pid ${String(lock.ownerPid)})`);
@@ -171,13 +174,13 @@ function startDevWatcher(projectRoot, dirs, pageExtensions, artifactPath) {
171
174
  }
172
175
  catch (error) {
173
176
  if (error instanceof RouteCollisionError) {
174
- // PR9's watch exception: a mid-watch collision is usually a file
175
- // mid-move — log loudly every time (not once: it stays broken
176
- // until fixed), keep the last good artifact, keep running (TR5).
177
+ // The collision watch exception: a mid-watch collision is usually
178
+ // a file mid-move — log loudly every time (not once: it stays
179
+ // broken until fixed), keep the last good artifact, keep running.
177
180
  console.warn(`paramour: ${error.message}; dev continues with the last good artifact`);
178
181
  return;
179
182
  }
180
- throw error; // routed to onError by the watcher (TR5 non-fatal)
183
+ throw error; // routed to onError by the watcher — non-fatal
181
184
  }
182
185
  },
183
186
  });
@@ -188,7 +191,7 @@ function startDevWatcher(projectRoot, dirs, pageExtensions, artifactPath) {
188
191
  lock.release?.();
189
192
  });
190
193
  }
191
- /** TR5 "log once": repeat evaluations/events don't spam the dev console. */
194
+ /** "Log once": repeat evaluations/events don't spam the dev console. */
192
195
  function warnOnce(message, detail) {
193
196
  if (warnedOnce.has(message))
194
197
  return;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@paramour-js/next",
3
- "version": "0.4.0",
3
+ "version": "0.5.0",
4
4
  "type": "module",
5
5
  "exports": {
6
6
  ".": {
@@ -34,7 +34,7 @@
34
34
  "jiti": "^2.7.0",
35
35
  "magicast": "^0.3.5",
36
36
  "tinyglobby": "^0.2.15",
37
- "paramour": "0.5.1"
37
+ "paramour": "0.6.0"
38
38
  },
39
39
  "peerDependencies": {
40
40
  "next": ">=15",
@@ -76,7 +76,8 @@
76
76
  },
77
77
  "files": [
78
78
  "bin",
79
- "dist"
79
+ "dist",
80
+ "skills"
80
81
  ],
81
82
  "publishConfig": {
82
83
  "access": "public",
@@ -0,0 +1,43 @@
1
+ ---
2
+ name: paramour
3
+ description: Type-safe routing for Next.js using the paramour and @paramour-js/next packages. Covers defining routes as route objects (defineAppRoute, definePagesRoute), typing params and searchParams with p.* codecs and the .optional()/.default()/.catch() modifiers, building typed links with href(), reading route state with typed hooks (useSearch, useRouteParams) and server parse surfaces (route.parse), wrapping next.config with withTypedRoutes, and using the paramour CLI (generate, check, init, list, doctor, skills). Load when working in a Next.js project that depends on paramour, when setting paramour up, or when migrating raw params/searchParams usage in an App Router or Pages Router project to validated, typed route objects.
4
+ ---
5
+
6
+ # paramour
7
+
8
+ Paramour is a type-safe routing companion for Next.js: each route is defined once as an importable route object whose params and search params are described by bidirectional wire codecs (parse AND serialize — Standard Schema validators plug in for validation, serialization is library-owned). Two packages: `paramour` (validation-agnostic core: `p.*` codec builders, `defineAppRoute`/`definePagesRoute`, `href`, decode/encode helpers) and `@paramour-js/next` (Next integration: `withTypedRoutes` config wrapper, App/Pages Router hooks, the `paramour` CLI).
9
+
10
+ ## Core rules (always apply)
11
+
12
+ 1. Import only from package barrels: `paramour`, `@paramour-js/next`, `@paramour-js/next/app`, `@paramour-js/next/pages`, `@paramour-js/next/testing`. Never import `dist/` or deep source paths.
13
+ 2. Codec modifier legality is type-state: illegal chains do not compile (the method's type becomes `never`) and throw at runtime for JS callers. The rules:
14
+ - `.optional()` and `.default()` apply only to a bare, unmodified single-value codec — at most ONE of the two, at most once. `.optional().default()`, `.default().optional()`, and any repeat are illegal.
15
+ - `.catch()` applies at most once and combines with either presence modifier in either order. It recovers parse failures of PRESENT wire values only — never absence.
16
+ - `p.array(...)` codecs take no `.optional()`/`.default()` (an absent key and `[]` are the same wire state). `.catch()` is allowed.
17
+ - Codecs in a `params:` config take no presence modifiers at all (`.optional()`/`.default()` are illegal there); `.catch()` is allowed.
18
+ - `p.csv(element)`/`p.array(element)` elements must be bare unmodified scalars: no modifiers, no csv inside csv, no array-arity element.
19
+ - `.default(value)` rejects values whose type includes a function; a function argument is always a factory (`.default(() => value)`).
20
+ 3. `paramour-env.d.ts` is a generated artifact. NEVER hand-edit it. Regenerate with `paramour generate` (dev server and builds wrapped by `withTypedRoutes` also regenerate it) and commit the result.
21
+ 4. After adding, renaming, moving, or deleting any route file or `defineAppRoute`/`definePagesRoute` call, run `paramour check`. Exit 1 means drift — run `paramour generate`, then commit the artifact with the route change.
22
+ 5. When converting an existing project, migrate ONE route per pass — define, wire, verify, commit — never a big-bang rewrite. See `references/migration.md`.
23
+ 6. Route objects are the currency: export them from a module and import them where needed. Never build a central string-keyed route registry.
24
+
25
+ ## Verification loop
26
+
27
+ - `paramour list` — print every filesystem route with its params/search shape as the library sees it (`--json` for machine output). Warnings (route without a definition, definition without a route) exit 0.
28
+ - `paramour generate` — (re)write `paramour-env.d.ts` from `app/` / `pages/`.
29
+ - `paramour check` — verify the artifact is current. Exit 1 on drift or a missing artifact; never writes.
30
+ - `paramour doctor` — diagnose setup (config validity, artifact freshness, next.config wrapping, version alignment, installed agent skills, tsconfig coverage). Exit 1 on any failing check; warnings exit 0.
31
+
32
+ Also run the project's type check (`tsc --noEmit` or the build) after route changes — paramour's guarantees are compiler-enforced, so a wrong codec chain or a misspelled param surfaces there.
33
+
34
+ ## Task router
35
+
36
+ Read exactly the reference file matching the task; each is self-contained.
37
+
38
+ | Task | Read |
39
+ | ------------------------------------------------------------------------------------- | ------------------------- |
40
+ | Install paramour into a project that does not have it yet (init, config, first route) | `references/setup.md` |
41
+ | Convert existing raw `params`/`searchParams` code to paramour routes | `references/migration.md` |
42
+ | Day-to-day work: write codecs, define routes, build links, use hooks | `references/authoring.md` |
43
+ | Look up an export, hook, CLI flag, config option, or wire-format rule | `references/reference.md` |
@@ -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.