@paramour-js/next 0.1.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 (59) hide show
  1. package/LICENSE +21 -0
  2. package/bin/paramour.js +19 -0
  3. package/dist/app.d.ts +76 -0
  4. package/dist/app.js +32 -0
  5. package/dist/cli-args.d.ts +28 -0
  6. package/dist/cli-args.js +35 -0
  7. package/dist/cli-inputs.d.ts +32 -0
  8. package/dist/cli-inputs.js +80 -0
  9. package/dist/cli-io.d.ts +15 -0
  10. package/dist/cli-io.js +16 -0
  11. package/dist/cli.d.ts +2 -0
  12. package/dist/cli.js +5 -0
  13. package/dist/collisions.d.ts +37 -0
  14. package/dist/collisions.js +80 -0
  15. package/dist/commands/doctor.d.ts +7 -0
  16. package/dist/commands/doctor.js +56 -0
  17. package/dist/commands/generate.d.ts +11 -0
  18. package/dist/commands/generate.js +222 -0
  19. package/dist/commands/init.d.ts +9 -0
  20. package/dist/commands/init.js +205 -0
  21. package/dist/commands/list.d.ts +9 -0
  22. package/dist/commands/list.js +94 -0
  23. package/dist/config.d.ts +35 -0
  24. package/dist/config.js +99 -0
  25. package/dist/doctor/checks.d.ts +16 -0
  26. package/dist/doctor/checks.js +231 -0
  27. package/dist/emit.d.ts +35 -0
  28. package/dist/emit.js +74 -0
  29. package/dist/generate.d.ts +70 -0
  30. package/dist/generate.js +106 -0
  31. package/dist/index.d.ts +9 -0
  32. package/dist/index.js +9 -0
  33. package/dist/init/scaffold.d.ts +39 -0
  34. package/dist/init/scaffold.js +244 -0
  35. package/dist/init/wrap-next-config.d.ts +41 -0
  36. package/dist/init/wrap-next-config.js +99 -0
  37. package/dist/list/discover-route-defs.d.ts +52 -0
  38. package/dist/list/discover-route-defs.js +121 -0
  39. package/dist/list/render.d.ts +35 -0
  40. package/dist/list/render.js +132 -0
  41. package/dist/lock.d.ts +29 -0
  42. package/dist/lock.js +88 -0
  43. package/dist/pages.d.ts +49 -0
  44. package/dist/pages.js +62 -0
  45. package/dist/run-cli.d.ts +11 -0
  46. package/dist/run-cli.js +53 -0
  47. package/dist/scan-app.d.ts +30 -0
  48. package/dist/scan-app.js +149 -0
  49. package/dist/scan-pages.d.ts +10 -0
  50. package/dist/scan-pages.js +102 -0
  51. package/dist/scan.d.ts +32 -0
  52. package/dist/scan.js +77 -0
  53. package/dist/select.d.ts +94 -0
  54. package/dist/select.js +195 -0
  55. package/dist/watch.d.ts +39 -0
  56. package/dist/watch.js +87 -0
  57. package/dist/with-typed-routes.d.ts +44 -0
  58. package/dist/with-typed-routes.js +200 -0
  59. package/package.json +67 -0
@@ -0,0 +1,102 @@
1
+ import { readdirSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { assertNoStructuralCollisions, RouteCollisionError, } from "./collisions.js";
4
+ import { DEFAULT_PAGE_EXTENSIONS, resolvesToFile } from "./scan-app.js";
5
+ /**
6
+ * Pages Router scanner (PR4). Deliberately a separate walker from
7
+ * `scan-app.ts` — the rule sets barely overlap (routes live on FILES here,
8
+ * and none of TR2's skip rules apply), and a shared walker would have to
9
+ * take its rule set as a parameter to be worth having (PR8).
10
+ */
11
+ /**
12
+ * Names special to Next at the TOP level of the pages dir only (PR4):
13
+ * `_app`/`_document`/`_error` are framework files, `404`/`500` are error
14
+ * pages, not navigation targets — `href("/404")` should not type-check.
15
+ * Nested twins (`pages/blog/404.tsx`) are ordinary pages and route.
16
+ * Every other `_`-prefixed file routes too (spike 1: co-location under
17
+ * `pages/` was requested, vercel/next.js#8454, and never implemented).
18
+ */
19
+ const TOP_LEVEL_EXCLUDED = new Set([
20
+ "404",
21
+ "500",
22
+ "_app",
23
+ "_document",
24
+ "_error",
25
+ ]);
26
+ /**
27
+ * Walk a pages dir and return the sorted union of URL-shaped route paths —
28
+ * exactly the strings `definePagesRoute` accepts (PR4). A route is any file
29
+ * whose extension is in `pageExtensions`, mapped by its path relative to the
30
+ * dir; `index.<ext>` maps to its directory. Two files resolving to one URL
31
+ * path — folder/file spelling (`blog.tsx` + `blog/index.tsx`) or extension
32
+ * twins (`about.tsx` + `about.jsx`) — throw a {@link RouteCollisionError},
33
+ * never dedupe: both are Next's own build errors (PR9).
34
+ */
35
+ export function scanPagesRoutes(pagesDir, pageExtensions = DEFAULT_PAGE_EXTENSIONS) {
36
+ // Path → the fs path (relative to pagesDir) that produced it, so a
37
+ // collision can name both files.
38
+ const out = new Map();
39
+ walk(pagesDir, [], pageExtensions, out, true);
40
+ // Code-unit sort, never localeCompare — locale independence feeds TR3's
41
+ // byte-identical-on-every-OS guarantee.
42
+ const paths = [...out.keys()].sort();
43
+ // PR9 structural collisions (different slug names, optional-catch-all
44
+ // specificity) — non-equal strings the Map above cannot catch.
45
+ assertNoStructuralCollisions(paths.map((path) => ({ path, router: "pages" })));
46
+ return paths;
47
+ }
48
+ /**
49
+ * The extension a file name matches, or `undefined` — first match in the
50
+ * caller's order, mirroring how Next builds its page matcher from
51
+ * `pageExtensions`. Requires a non-empty base name so a bare `.tsx` file is
52
+ * not a route to `/`.
53
+ */
54
+ function matchExtension(name, pageExtensions) {
55
+ return pageExtensions.find((ext) => name.length > ext.length + 1 && name.endsWith(`.${ext}`));
56
+ }
57
+ function walk(dir, urlSegments, pageExtensions, out, isTopLevel) {
58
+ // Sorted traversal: readdir order is platform-dependent, and which of two
59
+ // colliding files gets named first in the error must not be.
60
+ const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
61
+ for (const entry of entries) {
62
+ const name = entry.name;
63
+ // A real file, or a symlink whose target is a file: Next resolves and
64
+ // serves symlinked page files, so a file symlink routes exactly like a
65
+ // real file (Bug 4, TR2 shared posture). Directory symlinks fall through
66
+ // to the directory guard below and stay not-followed.
67
+ if (resolvesToFile(entry, dir)) {
68
+ // Declaration files match `.ts` but are never pages (PR11 §1).
69
+ if (name.endsWith(".d.ts"))
70
+ continue;
71
+ const ext = matchExtension(name, pageExtensions);
72
+ if (ext === undefined)
73
+ continue;
74
+ const base = name.slice(0, -(ext.length + 1));
75
+ if (isTopLevel && TOP_LEVEL_EXCLUDED.has(base))
76
+ continue;
77
+ // `index.<ext>` maps to its directory; everything else — dynamic
78
+ // segments included — is a path segment of its own (PR4).
79
+ const segments = base === "index" ? urlSegments : [...urlSegments, base];
80
+ const path = segments.length === 0 ? "/" : `/${segments.join("/")}`;
81
+ const file = [...urlSegments, name].join("/");
82
+ const existing = out.get(path);
83
+ if (existing !== undefined) {
84
+ throw new RouteCollisionError(`pages route collision at "${path}": ${existing} and ${file} resolve to the same path (PR9)`);
85
+ }
86
+ out.set(path, file);
87
+ continue;
88
+ }
89
+ // Symlinked directories are deliberately not followed (TR2 v1 stance,
90
+ // shared posture): `resolvesToFile` returned false and `isDirectory()` is
91
+ // false for the link Dirent, so the subtree is skipped here.
92
+ if (!entry.isDirectory())
93
+ continue;
94
+ // `pages/api/**` is excluded — top level only, so `pages/foo/api/bar.tsx`
95
+ // routes (API-route typing is deferred to v1.x, PR4/§14). NO app-style
96
+ // skip rules beyond this: `(group)`, `@slot`, `(.)x`, and `_`-prefixed
97
+ // dirs are ordinary literal segments in the Pages Router (PR4).
98
+ if (isTopLevel && name === "api")
99
+ continue;
100
+ walk(join(dir, name), [...urlSegments, name], pageExtensions, out, false);
101
+ }
102
+ }
package/dist/scan.d.ts ADDED
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The thin orchestrator over the two scanners (PR8): joint directory
3
+ * discovery, delegation, and the cross-router collision checks (PR9).
4
+ */
5
+ /** The two route dirs of a project; either may be absent (PR1 hybrid). */
6
+ export interface RouteDirs {
7
+ appDir?: string | undefined;
8
+ pagesDir?: string | undefined;
9
+ }
10
+ /** Result of {@link scanRoutes} — the input shape of the PR9 artifact. */
11
+ export interface ScanRoutesResult {
12
+ appRoutes: string[];
13
+ pagesRoutes: string[];
14
+ }
15
+ /**
16
+ * Joint route-dir discovery (spike-2 ruling). Next's documented rule is one
17
+ * decision, not two probes: `src/app` AND `src/pages` are both ignored
18
+ * whenever `app/` OR `pages/` exists at the project root. An ignored src dir
19
+ * that contains page files is a hard config error — Next silently serves
20
+ * none of those pages (and has shipped bugs in the mixed case,
21
+ * vercel/next.js#58728), so there is no valid state to warn about.
22
+ */
23
+ export declare function resolveRouteDirs(projectRoot: string, pageExtensions?: readonly string[]): RouteDirs;
24
+ /**
25
+ * Scan whichever route dirs exist and return both route unions (PR1). After
26
+ * each scanner's own intra-router checks, two cross-router passes run (PR9):
27
+ * a path in BOTH unions is Next's "Conflicting app and page file" build
28
+ * error, and the structural pass re-runs over the merged, labeled union so
29
+ * shared-prefix slug conflicts and cross-router optional-catch-all
30
+ * specificity are caught too.
31
+ */
32
+ export declare function scanRoutes(dirs: RouteDirs, pageExtensions?: readonly string[]): ScanRoutesResult;
package/dist/scan.js ADDED
@@ -0,0 +1,77 @@
1
+ import { statSync } from "node:fs";
2
+ import { join } from "node:path";
3
+ import { assertNoStructuralCollisions, RouteCollisionError, } from "./collisions.js";
4
+ import { DEFAULT_PAGE_EXTENSIONS, scanAppRoutes } from "./scan-app.js";
5
+ import { scanPagesRoutes } from "./scan-pages.js";
6
+ /**
7
+ * Joint route-dir discovery (spike-2 ruling). Next's documented rule is one
8
+ * decision, not two probes: `src/app` AND `src/pages` are both ignored
9
+ * whenever `app/` OR `pages/` exists at the project root. An ignored src dir
10
+ * that contains page files is a hard config error — Next silently serves
11
+ * none of those pages (and has shipped bugs in the mixed case,
12
+ * vercel/next.js#58728), so there is no valid state to warn about.
13
+ */
14
+ export function resolveRouteDirs(projectRoot, pageExtensions = DEFAULT_PAGE_EXTENSIONS) {
15
+ const rootApp = dirIfExists(join(projectRoot, "app"));
16
+ const rootPages = dirIfExists(join(projectRoot, "pages"));
17
+ const srcApp = dirIfExists(join(projectRoot, "src", "app"));
18
+ const srcPages = dirIfExists(join(projectRoot, "src", "pages"));
19
+ if (rootApp === undefined && rootPages === undefined) {
20
+ return { appDir: srcApp, pagesDir: srcPages };
21
+ }
22
+ const winner = rootApp === undefined ? "pages/" : "app/";
23
+ const probes = [
24
+ [srcApp, (dir) => scanAppRoutes(dir, pageExtensions), "src/app"],
25
+ [srcPages, (dir) => scanPagesRoutes(dir, pageExtensions), "src/pages"],
26
+ ];
27
+ for (const [dir, scan, label] of probes) {
28
+ if (dir === undefined)
29
+ continue;
30
+ let populated;
31
+ try {
32
+ populated = scan(dir).length > 0;
33
+ }
34
+ catch (error) {
35
+ // Only a RouteCollisionError proves the ignored dir has page files —
36
+ // which is the fact the probe needs. Any other throw (EACCES/EPERM,
37
+ // a dir removed mid-scan, …) is a real I/O failure that must not be
38
+ // masked by the "silently unreachable" config diagnosis; rethrow it.
39
+ if (!(error instanceof RouteCollisionError))
40
+ throw error;
41
+ populated = true;
42
+ }
43
+ if (populated) {
44
+ throw new Error(`${label} contains page files, but Next ignores src/ route directories whenever app/ or pages/ exists at the project root (here: ${winner}) — those pages are silently unreachable. Move ${label} to the project root, or the root ${winner} directory under src/; explicit --app-dir/--pages-dir flags bypass this discovery.`);
45
+ }
46
+ }
47
+ return { appDir: rootApp, pagesDir: rootPages };
48
+ }
49
+ /**
50
+ * Scan whichever route dirs exist and return both route unions (PR1). After
51
+ * each scanner's own intra-router checks, two cross-router passes run (PR9):
52
+ * a path in BOTH unions is Next's "Conflicting app and page file" build
53
+ * error, and the structural pass re-runs over the merged, labeled union so
54
+ * shared-prefix slug conflicts and cross-router optional-catch-all
55
+ * specificity are caught too.
56
+ */
57
+ export function scanRoutes(dirs, pageExtensions = DEFAULT_PAGE_EXTENSIONS) {
58
+ const appRoutes = dirs.appDir === undefined ? [] : scanAppRoutes(dirs.appDir, pageExtensions);
59
+ const pagesRoutes = dirs.pagesDir === undefined
60
+ ? []
61
+ : scanPagesRoutes(dirs.pagesDir, pageExtensions);
62
+ const appSet = new Set(appRoutes);
63
+ const shared = pagesRoutes.filter((path) => appSet.has(path));
64
+ if (shared.length > 0) {
65
+ throw new RouteCollisionError(`route collision between app/ and pages/: ${shared.map((path) => `"${path}"`).join(", ")} — Next fails the build on conflicting app and page files (PR9)`);
66
+ }
67
+ assertNoStructuralCollisions([
68
+ ...appRoutes.map((path) => ({ path, router: "app" })),
69
+ ...pagesRoutes.map((path) => ({ path, router: "pages" })),
70
+ ]);
71
+ return { appRoutes, pagesRoutes };
72
+ }
73
+ function dirIfExists(path) {
74
+ return statSync(path, { throwIfNoEntry: false })?.isDirectory()
75
+ ? path
76
+ : undefined;
77
+ }
@@ -0,0 +1,94 @@
1
+ import type { AnyRoute, ParamsSource, SafeResult } from "paramour";
2
+ /**
3
+ * Shared internals of the read hooks' selector surface (design-07): the
4
+ * raw-slice stabilization layer (SEL4) and the selector layer (SEL2/SEL3).
5
+ * Deliberately NO `"use client"` directive: app.ts (which carries one) and
6
+ * pages.ts (which must not carry one, design-06 PR2) both import from here,
7
+ * and the directive belongs on the entry modules, not a shared leaf.
8
+ *
9
+ * Both layers are `useRef` caches mutated during render (SEL8) — the
10
+ * Redux/TanStack selector pattern, and the one sanctioned departure from the
11
+ * hooks' pure-`useMemo` discipline: result equality needs memory across
12
+ * renders, which no pure memo can provide. Every cache is cleared BEFORE a
13
+ * compute that can throw, so a throwing decode or selector never strands a
14
+ * stale entry.
15
+ */
16
+ /**
17
+ * Options bag accepted by every read hook (design-07 SEL1).
18
+ */
19
+ export interface SelectOptions<T, U> {
20
+ /**
21
+ * Result-equality mode for the selected value (SEL3): `Object.is` by
22
+ * default — free and correct for primitive selections — with one-level
23
+ * `"shallow"` as the opt-in for tuple/object selections.
24
+ */
25
+ readonly equality?: "shallow";
26
+ /**
27
+ * Pure projection of the decoded value; runs only on the success arm
28
+ * (SEL2). Identity is never compared, so inline arrows are fine — and when
29
+ * the underlying result is reference-stable the selector is NOT re-run
30
+ * (SEL6), so it must not read changing outside state. A throw propagates to
31
+ * the nearest error boundary (SEL5): a selector bug is a code bug, never
32
+ * the `SafeResult` error arm, which is reserved for URL data problems.
33
+ */
34
+ readonly select: (value: T) => U;
35
+ }
36
+ /**
37
+ * Fingerprint of the pages hooks' pre-`isReady` state (design-06 PR5). Every
38
+ * real fingerprint is a `JSON.stringify`'d array (starts with `[`), so this
39
+ * can never collide with one.
40
+ */
41
+ export declare const PENDING_FINGERPRINT = "pending";
42
+ /**
43
+ * Raw slice of a params source (SEL4): the route's dynamic segment names'
44
+ * raw values, from the define-time `~segments` token cache. Unknown keys —
45
+ * e.g. a parallel route's params in the same `useParams()` bag — never bust
46
+ * the fingerprint, because the decode never reads them.
47
+ */
48
+ export declare function paramsFingerprint(route: AnyRoute, source: ParamsSource): string;
49
+ /**
50
+ * Raw slice of a pages `router.query` bag for the search half (SEL4). A
51
+ * codec-map route reads exactly its declared keys (query junk and the
52
+ * route's own path params are invisible to the decode, PR9 disjointness); a
53
+ * `rawSearch` route has no enumerable declared-key set — the schema sees
54
+ * every key except the route's path params (design-06 PR5 subtraction), so
55
+ * exactly that slice is fingerprinted, in sorted-key order for record-order
56
+ * independence.
57
+ */
58
+ export declare function queryFingerprint(route: AnyRoute, query: ParamsSource): string;
59
+ /**
60
+ * Raw slice of an app `useSearchParams()` source (SEL4): the declared keys'
61
+ * `[key, value]` pairs in wire order — order is load-bearing for repeated
62
+ * keys (array codecs decode in wire order, P5/S5), and iterating the live
63
+ * pairs preserves the relative order of declared entries while `?utm_*`
64
+ * churn between them stays invisible. A `rawSearch` route's schema sees
65
+ * every key (P8 does not apply there), so its slice is all pairs.
66
+ */
67
+ export declare function searchParamsFingerprint(route: AnyRoute, source: URLSearchParams): string;
68
+ /**
69
+ * The selector layer for the safe hooks (SEL2): projects the success arm and
70
+ * reference-stabilizes the projected WRAPPER by result equality (SEL3) — a
71
+ * stable `data` inside a fresh wrapper would still churn every consumer.
72
+ * Error and pending arms pass through untouched; they are already
73
+ * reference-stabilized by {@link useStableResult}'s raw-slice layer.
74
+ */
75
+ export declare function useSelectedResult<T, U>(result: SafeResult<T>, options: SelectOptions<T, U> | undefined): SafeResult<U>;
76
+ export declare function useSelectedResult<T, U>(result: SafeResult<T> | {
77
+ status: "pending";
78
+ }, options: SelectOptions<T, U> | undefined): SafeResult<U> | {
79
+ status: "pending";
80
+ };
81
+ /**
82
+ * {@link useSelectedResult}'s twin for the `*OrThrow` hooks (SEL2): same
83
+ * layering, no wrapper — the hook's return IS the (selected) value.
84
+ */
85
+ export declare function useSelectedValue<T, U>(value: T, options: SelectOptions<T, U> | undefined): T | U;
86
+ /**
87
+ * The raw-slice stabilization layer (SEL4): while `route` and `fingerprint`
88
+ * are unchanged from the previous render, the previous result — success OR
89
+ * error arm — is returned without recomputing, so a fresh `useSearchParams()`
90
+ * / `query` object whose DECLARED slice is unchanged (`?utm_source=` churn)
91
+ * costs neither a decode nor anyone's referential equality. This replaces
92
+ * the pre-design-07 "memo keyed on Next's object reference" behavior.
93
+ */
94
+ export declare function useStableResult<T>(route: AnyRoute, fingerprint: string, compute: () => T): T;
package/dist/select.js ADDED
@@ -0,0 +1,195 @@
1
+ import { useRef } from "react";
2
+ /**
3
+ * Fingerprint of the pages hooks' pre-`isReady` state (design-06 PR5). Every
4
+ * real fingerprint is a `JSON.stringify`'d array (starts with `[`), so this
5
+ * can never collide with one.
6
+ */
7
+ export const PENDING_FINGERPRINT = "pending";
8
+ /**
9
+ * Raw slice of a params source (SEL4): the route's dynamic segment names'
10
+ * raw values, from the define-time `~segments` token cache. Unknown keys —
11
+ * e.g. a parallel route's params in the same `useParams()` bag — never bust
12
+ * the fingerprint, because the decode never reads them.
13
+ */
14
+ export function paramsFingerprint(route, source) {
15
+ return recordFingerprint(dynamicSegmentNames(route), source);
16
+ }
17
+ /**
18
+ * Raw slice of a pages `router.query` bag for the search half (SEL4). A
19
+ * codec-map route reads exactly its declared keys (query junk and the
20
+ * route's own path params are invisible to the decode, PR9 disjointness); a
21
+ * `rawSearch` route has no enumerable declared-key set — the schema sees
22
+ * every key except the route's path params (design-06 PR5 subtraction), so
23
+ * exactly that slice is fingerprinted, in sorted-key order for record-order
24
+ * independence.
25
+ */
26
+ export function queryFingerprint(route, query) {
27
+ const declared = declaredSearchKeys(route);
28
+ if (declared !== null)
29
+ return recordFingerprint(declared, query);
30
+ const paramNames = new Set(dynamicSegmentNames(route));
31
+ const keys = Object.keys(query)
32
+ .filter((key) => !paramNames.has(key))
33
+ .sort();
34
+ return recordFingerprint(keys, query);
35
+ }
36
+ /**
37
+ * Raw slice of an app `useSearchParams()` source (SEL4): the declared keys'
38
+ * `[key, value]` pairs in wire order — order is load-bearing for repeated
39
+ * keys (array codecs decode in wire order, P5/S5), and iterating the live
40
+ * pairs preserves the relative order of declared entries while `?utm_*`
41
+ * churn between them stays invisible. A `rawSearch` route's schema sees
42
+ * every key (P8 does not apply there), so its slice is all pairs.
43
+ */
44
+ export function searchParamsFingerprint(route, source) {
45
+ const declared = declaredSearchKeys(route);
46
+ const declaredSet = declared === null ? null : new Set(declared);
47
+ const pairs = [];
48
+ for (const [key, value] of source) {
49
+ if (declaredSet === null || declaredSet.has(key))
50
+ pairs.push([key, value]);
51
+ }
52
+ return JSON.stringify(pairs);
53
+ }
54
+ export function useSelectedResult(result, options) {
55
+ const cache = useRef(null);
56
+ if (options === undefined) {
57
+ // No selector: the raw-slice layer already stabilized `result`, and the
58
+ // public overloads pin U = T for this arity.
59
+ return result;
60
+ }
61
+ if (result.status !== "success")
62
+ return result;
63
+ const previous = cache.current;
64
+ if (previous !== null && Object.is(previous.input, result.data)) {
65
+ // Reference-stable input ⇒ equal output by selector purity (SEL6); the
66
+ // selector is deliberately not re-run.
67
+ return previous.wrapped;
68
+ }
69
+ const selected = options.select(result.data); // a throw propagates (SEL5)
70
+ if (previous !== null &&
71
+ selectedEquals(options.equality, previous.wrapped.data, selected)) {
72
+ // Same selection out of a new decode: keep the previous wrapper (SEL2)
73
+ // and re-key the cache so the next render takes the reference fast path.
74
+ previous.input = result.data;
75
+ return previous.wrapped;
76
+ }
77
+ const wrapped = {
78
+ data: selected,
79
+ status: "success",
80
+ };
81
+ cache.current = { input: result.data, wrapped };
82
+ return wrapped;
83
+ }
84
+ /**
85
+ * {@link useSelectedResult}'s twin for the `*OrThrow` hooks (SEL2): same
86
+ * layering, no wrapper — the hook's return IS the (selected) value.
87
+ */
88
+ export function useSelectedValue(value, options) {
89
+ const cache = useRef(null);
90
+ if (options === undefined)
91
+ return value;
92
+ const previous = cache.current;
93
+ if (previous !== null && Object.is(previous.input, value)) {
94
+ return previous.selected; // SEL6: selector purity, not re-run
95
+ }
96
+ const selected = options.select(value); // a throw propagates (SEL5)
97
+ if (previous !== null &&
98
+ selectedEquals(options.equality, previous.selected, selected)) {
99
+ previous.input = value;
100
+ return previous.selected;
101
+ }
102
+ cache.current = { input: value, selected };
103
+ return selected;
104
+ }
105
+ /**
106
+ * The raw-slice stabilization layer (SEL4): while `route` and `fingerprint`
107
+ * are unchanged from the previous render, the previous result — success OR
108
+ * error arm — is returned without recomputing, so a fresh `useSearchParams()`
109
+ * / `query` object whose DECLARED slice is unchanged (`?utm_source=` churn)
110
+ * costs neither a decode nor anyone's referential equality. This replaces
111
+ * the pre-design-07 "memo keyed on Next's object reference" behavior.
112
+ */
113
+ export function useStableResult(route, fingerprint, compute) {
114
+ const cache = useRef(null);
115
+ const cached = cache.current;
116
+ if (cached !== null &&
117
+ cached.route === route &&
118
+ cached.fingerprint === fingerprint) {
119
+ return cached.value;
120
+ }
121
+ // Cleared BEFORE computing (SEL8): a throwing decode must not strand the
122
+ // previous entry, or the rerender after an error boundary reset would
123
+ // serve a stale value under the new fingerprint.
124
+ cache.current = null;
125
+ const value = compute();
126
+ cache.current = { fingerprint, route, value };
127
+ return value;
128
+ }
129
+ /**
130
+ * Declared search keys of a route's `~search` slot, or `null` for a
131
+ * `rawSearch` route (whose schema owns every key, so no declared subset
132
+ * exists). The `~kind` marker is unambiguous against a codec map, which
133
+ * never carries a top-level `~`-prefixed key (design-04 SS2).
134
+ */
135
+ function declaredSearchKeys(route) {
136
+ const config = route["~search"];
137
+ if (typeof config !== "object" || config === null)
138
+ return [];
139
+ if (config["~kind"] === "raw-search")
140
+ return null;
141
+ return Object.keys(config);
142
+ }
143
+ /** Dynamic segment names from the define-time `~segments` token cache. */
144
+ function dynamicSegmentNames(route) {
145
+ const names = [];
146
+ for (const segment of route["~segments"]) {
147
+ if (segment.kind !== "static")
148
+ names.push(segment.name);
149
+ }
150
+ return names;
151
+ }
152
+ /**
153
+ * `JSON.stringify`'d `[key, value]` slice of a record source — unambiguous
154
+ * against concatenation collisions; absent and explicit-`undefined` keys
155
+ * both fingerprint as `null`, matching the decode's absence semantics.
156
+ * `Object.hasOwn` mirrors the core readers: an inherited member (a declared
157
+ * key named `"constructor"`) is absence to the decode, so it must be absence
158
+ * to the fingerprint too.
159
+ */
160
+ function recordFingerprint(keys, source) {
161
+ return JSON.stringify(keys.map((key) => [
162
+ key,
163
+ Object.hasOwn(source, key) ? (source[key] ?? null) : null,
164
+ ]));
165
+ }
166
+ /** SEL3: `Object.is`, widened one level by the `"shallow"` opt-in. */
167
+ function selectedEquals(equality, a, b) {
168
+ if (Object.is(a, b))
169
+ return true;
170
+ return equality === "shallow" && shallowEqual(a, b);
171
+ }
172
+ /**
173
+ * One-level equality for the `"shallow"` opt-in (SEL3): arrays element-wise,
174
+ * plain objects by own enumerable keys — `Object.is` at each leaf, nothing
175
+ * recursive (deep comparison in a render path is a non-goal, design-07).
176
+ */
177
+ function shallowEqual(a, b) {
178
+ if (typeof a !== "object" ||
179
+ a === null ||
180
+ typeof b !== "object" ||
181
+ b === null) {
182
+ return false;
183
+ }
184
+ if (Array.isArray(a) || Array.isArray(b)) {
185
+ return (Array.isArray(a) &&
186
+ Array.isArray(b) &&
187
+ a.length === b.length &&
188
+ a.every((element, index) => Object.is(element, b[index])));
189
+ }
190
+ const aRecord = a;
191
+ const bRecord = b;
192
+ const aKeys = Object.keys(aRecord);
193
+ return (aKeys.length === Object.keys(bRecord).length &&
194
+ aKeys.every((key) => Object.hasOwn(bRecord, key) && Object.is(aRecord[key], bRecord[key])));
195
+ }
@@ -0,0 +1,39 @@
1
+ /** Handle returned by {@link watchRouteDirs}. */
2
+ export interface RouteDirsWatcher {
3
+ /** Stop watching and drop any pending debounced rescan. Idempotent. */
4
+ close(): void;
5
+ }
6
+ /** Options for {@link watchRouteDirs}. */
7
+ export interface WatchRouteDirsOptions {
8
+ /** Debounce window in milliseconds; defaults to {@link DEFAULT_DEBOUNCE_MS}. */
9
+ debounceMs?: number;
10
+ /**
11
+ * Absolute paths whose events are ignored — the artifact file, so a
12
+ * regeneration write can't re-trigger the watcher (TR5 feedback loop).
13
+ */
14
+ ignorePaths?: readonly string[];
15
+ /**
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.
19
+ */
20
+ onError?: (error: unknown) => void;
21
+ /** The regenerate callback — full rescan → write-if-changed (TR5). */
22
+ onRescan: () => void;
23
+ }
24
+ /** TR5: ~100 ms — long enough to coalesce an editor save storm. */
25
+ export declare const DEFAULT_DEBOUNCE_MS = 100;
26
+ /**
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
31
+ * `fs.watch({ recursive: true })`, no chokidar; this start/close interface
32
+ * is the seam chokidar would drop in behind if a platform hole appears.
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`.
38
+ */
39
+ export declare function watchRouteDirs(dirs: readonly string[], options: WatchRouteDirsOptions): RouteDirsWatcher;
package/dist/watch.js ADDED
@@ -0,0 +1,87 @@
1
+ import { statSync, watch } from "node:fs";
2
+ import { resolve } from "node:path";
3
+ /** TR5: ~100 ms — long enough to coalesce an editor save storm. */
4
+ export const DEFAULT_DEBOUNCE_MS = 100;
5
+ /**
6
+ * Directory names whose subtrees are ignored if they ever fall under a
7
+ * watched root (TR5).
8
+ */
9
+ const IGNORED_SEGMENTS = new Set([".next", "node_modules"]);
10
+ /**
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
15
+ * `fs.watch({ recursive: true })`, no chokidar; this start/close interface
16
+ * is the seam chokidar would drop in behind if a platform hole appears.
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`.
22
+ */
23
+ export function watchRouteDirs(dirs, options) {
24
+ const { debounceMs = DEFAULT_DEBOUNCE_MS, ignorePaths = [], onError, onRescan, } = options;
25
+ const ignored = new Set(ignorePaths.map((path) => resolve(path)));
26
+ let timer;
27
+ const schedule = () => {
28
+ clearTimeout(timer);
29
+ timer = setTimeout(() => {
30
+ try {
31
+ onRescan();
32
+ }
33
+ catch (error) {
34
+ // A throwing regeneration must not kill the watcher (TR5 non-fatal).
35
+ onError?.(error);
36
+ }
37
+ }, debounceMs);
38
+ };
39
+ const watchers = [];
40
+ for (const dir of dirs) {
41
+ let watcher;
42
+ try {
43
+ // Linux's userland recursive watcher (Node <= 24.18.0,
44
+ // internal/fs/recursive_watch.js) swallows ENOENT under the default
45
+ // throwIfNoEntry — a missing dir silently never watches, with no throw
46
+ // and no 'error' event. Stat first so the missing-dir skip is
47
+ // synchronous and identical on every platform.
48
+ if (statSync(dir, { throwIfNoEntry: false }) === undefined)
49
+ continue;
50
+ watcher = watch(dir, { recursive: true }, (_eventType, filename) => {
51
+ // `filename` can be null (platform-dependent); with nothing to
52
+ // filter on, err toward rescanning — a spurious pass is a no-op
53
+ // write (TR3).
54
+ if (filename !== null) {
55
+ if (ignored.has(resolve(dir, filename)))
56
+ return;
57
+ // Windows reports backslash-joined relative paths; split on both.
58
+ const segments = filename.split(/[/\\]/);
59
+ if (segments.some((segment) => IGNORED_SEGMENTS.has(segment)))
60
+ return;
61
+ }
62
+ schedule();
63
+ });
64
+ }
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.
68
+ onError?.(error);
69
+ continue;
70
+ }
71
+ watcher.on("error", (error) => {
72
+ onError?.(error);
73
+ });
74
+ watchers.push(watcher);
75
+ }
76
+ let closed = false;
77
+ return {
78
+ close() {
79
+ if (closed)
80
+ return;
81
+ closed = true;
82
+ clearTimeout(timer);
83
+ for (const watcher of watchers)
84
+ watcher.close();
85
+ },
86
+ };
87
+ }
@@ -0,0 +1,44 @@
1
+ /** Options for {@link withTypedRoutes} (TR4). */
2
+ export interface WithTypedRoutesOptions {
3
+ /**
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
6
+ * project root. Default: `paramour-env.d.ts` at the project root.
7
+ */
8
+ outFile?: string;
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.
13
+ */
14
+ strict?: boolean;
15
+ }
16
+ /** Next config-function form: `(phase, ctx) => config`, possibly async. */
17
+ type ConfigFunction<C> = (phase: string, ctx: unknown) => C | Promise<C>;
18
+ /** @internal Test seam: the number of live dev-watcher singletons. */
19
+ export declare function devWatcherCountForTests(): number;
20
+ /**
21
+ * @internal Test seam: close every dev watcher, release the pidfile locks,
22
+ * and clear the log-once state. Also required between tests on Windows —
23
+ * an open watch handle blocks temp-dir removal.
24
+ */
25
+ export declare function resetDevWatchersForTests(): void;
26
+ /**
27
+ * Wrap a Next config with route-registry generation (TR4). Returns the
28
+ * config-function form; Next's phase argument is the mode discriminator:
29
+ *
30
+ * - production build → one generation pass before the config is returned
31
+ * (the build type-checks against fresh routes); drift warns loudly, or
32
+ * fails the build under `strict: true`.
33
+ * - dev server → one immediate generation pass, then the debounced watcher
34
+ * (TR5) behind both single-writer guards (TR6).
35
+ * - every other phase → pass-through, no generation.
36
+ *
37
+ * Two states throw during config evaluation instead of degrading to
38
+ * 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).
42
+ */
43
+ export declare function withTypedRoutes<C extends object>(config: C | ConfigFunction<C>, options?: WithTypedRoutesOptions): ConfigFunction<C>;
44
+ export {};