@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.
- package/README.md +1 -1
- package/dist/app.d.ts +48 -49
- package/dist/app.js +11 -12
- package/dist/cli-args.d.ts +4 -4
- package/dist/cli-args.js +4 -4
- package/dist/cli-inputs.d.ts +6 -7
- package/dist/cli-inputs.js +6 -7
- package/dist/cli.js +1 -1
- package/dist/collisions.d.ts +8 -8
- package/dist/collisions.js +9 -9
- package/dist/commands/doctor.d.ts +12 -0
- package/dist/commands/doctor.js +12 -7
- package/dist/commands/generate.d.ts +55 -7
- package/dist/commands/generate.js +29 -22
- package/dist/commands/init.d.ts +40 -0
- package/dist/commands/init.js +111 -19
- package/dist/commands/list.d.ts +21 -0
- package/dist/commands/list.js +12 -7
- package/dist/commands/skills.d.ts +45 -0
- package/dist/commands/skills.js +222 -0
- package/dist/config.d.ts +17 -10
- package/dist/config.js +17 -4
- package/dist/devtools-seam.d.ts +19 -19
- package/dist/devtools-seam.js +3 -3
- package/dist/doctor/checks.js +7 -3
- package/dist/emit.d.ts +12 -12
- package/dist/emit.js +13 -13
- package/dist/generate.d.ts +14 -14
- package/dist/generate.js +11 -11
- package/dist/init/agents-md.d.ts +32 -0
- package/dist/init/agents-md.js +78 -0
- package/dist/init/scaffold.js +54 -0
- package/dist/list/discover-route-defs.d.ts +4 -4
- package/dist/list/discover-route-defs.js +4 -4
- package/dist/lock.d.ts +8 -9
- package/dist/lock.js +11 -12
- package/dist/navigation-adapter.d.ts +17 -18
- package/dist/navigation-adapter.js +4 -4
- package/dist/observe.d.ts +14 -14
- package/dist/observe.js +4 -4
- package/dist/pages.d.ts +30 -31
- package/dist/pages.js +10 -10
- package/dist/run-cli.d.ts +3 -1
- package/dist/run-cli.js +7 -3
- package/dist/scan-app.d.ts +10 -10
- package/dist/scan-app.js +30 -30
- package/dist/scan-pages.d.ts +6 -6
- package/dist/scan-pages.js +28 -26
- package/dist/scan.d.ts +13 -10
- package/dist/scan.js +7 -7
- package/dist/select.d.ts +40 -40
- package/dist/select.js +30 -30
- package/dist/skills/doctor.d.ts +11 -0
- package/dist/skills/doctor.js +71 -0
- package/dist/skills/manifest.d.ts +41 -0
- package/dist/skills/manifest.js +96 -0
- package/dist/skills/packaged.d.ts +21 -0
- package/dist/skills/packaged.js +32 -0
- package/dist/skills/sync.d.ts +84 -0
- package/dist/skills/sync.js +136 -0
- package/dist/skills/targets.d.ts +29 -0
- package/dist/skills/targets.js +73 -0
- package/dist/testing.d.ts +28 -32
- package/dist/testing.js +15 -15
- package/dist/watch.d.ts +12 -12
- package/dist/watch.js +13 -13
- package/dist/with-typed-routes.d.ts +11 -10
- package/dist/with-typed-routes.js +42 -39
- package/package.json +4 -3
- package/skills/paramour/SKILL.md +43 -0
- package/skills/paramour/references/authoring.md +113 -0
- package/skills/paramour/references/migration.md +141 -0
- package/skills/paramour/references/reference.md +96 -0
- 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
|
|
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:
|
|
18
|
-
*
|
|
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
|
|
21
|
+
/** The regenerate callback — full rescan → write-if-changed. */
|
|
22
22
|
onRescan: () => void;
|
|
23
23
|
}
|
|
24
|
-
/**
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
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
|
|
35
|
-
*
|
|
36
|
-
* continuing in stale-types mode is exactly
|
|
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
|
-
/**
|
|
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
|
|
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
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
|
19
|
-
*
|
|
20
|
-
* continuing in stale-types mode is exactly
|
|
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
|
|
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
|
|
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
|
-
//
|
|
67
|
-
//
|
|
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}
|
|
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
|
|
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
|
|
11
|
-
*
|
|
12
|
-
*
|
|
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
|
|
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
|
-
*
|
|
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
|
|
40
|
-
* populated-ignored-dir config error (
|
|
41
|
-
*
|
|
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
|
|
11
|
-
*
|
|
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
|
-
*
|
|
17
|
-
* path. Load-bearing even for a single `next dev`:
|
|
18
|
-
*
|
|
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"
|
|
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
|
|
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
|
-
*
|
|
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
|
|
52
|
-
* populated-ignored-dir config error (
|
|
53
|
-
*
|
|
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
|
|
62
|
-
//
|
|
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
|
|
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
|
-
//
|
|
70
|
-
//
|
|
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
|
|
85
|
-
*
|
|
86
|
-
*
|
|
87
|
-
*
|
|
88
|
-
* CI-degrades-to-world-A scenario the committed file exists to prevent
|
|
89
|
-
*
|
|
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
|
-
//
|
|
98
|
-
//
|
|
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
|
-
//
|
|
103
|
-
//
|
|
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
|
|
123
|
-
* route collision, which throws from the config
|
|
124
|
-
* in the build phase
|
|
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
|
|
138
|
-
* dev running in stale-types mode — never fatal
|
|
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
|
-
//
|
|
151
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
175
|
-
// mid-move — log loudly every time (not once: it stays
|
|
176
|
-
// until fixed), keep the last good artifact, keep running
|
|
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
|
|
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
|
-
/**
|
|
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.
|
|
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.
|
|
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.
|