@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.
- package/LICENSE +21 -0
- package/bin/paramour.js +19 -0
- package/dist/app.d.ts +76 -0
- package/dist/app.js +32 -0
- package/dist/cli-args.d.ts +28 -0
- package/dist/cli-args.js +35 -0
- package/dist/cli-inputs.d.ts +32 -0
- package/dist/cli-inputs.js +80 -0
- package/dist/cli-io.d.ts +15 -0
- package/dist/cli-io.js +16 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.js +5 -0
- package/dist/collisions.d.ts +37 -0
- package/dist/collisions.js +80 -0
- package/dist/commands/doctor.d.ts +7 -0
- package/dist/commands/doctor.js +56 -0
- package/dist/commands/generate.d.ts +11 -0
- package/dist/commands/generate.js +222 -0
- package/dist/commands/init.d.ts +9 -0
- package/dist/commands/init.js +205 -0
- package/dist/commands/list.d.ts +9 -0
- package/dist/commands/list.js +94 -0
- package/dist/config.d.ts +35 -0
- package/dist/config.js +99 -0
- package/dist/doctor/checks.d.ts +16 -0
- package/dist/doctor/checks.js +231 -0
- package/dist/emit.d.ts +35 -0
- package/dist/emit.js +74 -0
- package/dist/generate.d.ts +70 -0
- package/dist/generate.js +106 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +9 -0
- package/dist/init/scaffold.d.ts +39 -0
- package/dist/init/scaffold.js +244 -0
- package/dist/init/wrap-next-config.d.ts +41 -0
- package/dist/init/wrap-next-config.js +99 -0
- package/dist/list/discover-route-defs.d.ts +52 -0
- package/dist/list/discover-route-defs.js +121 -0
- package/dist/list/render.d.ts +35 -0
- package/dist/list/render.js +132 -0
- package/dist/lock.d.ts +29 -0
- package/dist/lock.js +88 -0
- package/dist/pages.d.ts +49 -0
- package/dist/pages.js +62 -0
- package/dist/run-cli.d.ts +11 -0
- package/dist/run-cli.js +53 -0
- package/dist/scan-app.d.ts +30 -0
- package/dist/scan-app.js +149 -0
- package/dist/scan-pages.d.ts +10 -0
- package/dist/scan-pages.js +102 -0
- package/dist/scan.d.ts +32 -0
- package/dist/scan.js +77 -0
- package/dist/select.d.ts +94 -0
- package/dist/select.js +195 -0
- package/dist/watch.d.ts +39 -0
- package/dist/watch.js +87 -0
- package/dist/with-typed-routes.d.ts +44 -0
- package/dist/with-typed-routes.js +200 -0
- 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
|
+
}
|
package/dist/select.d.ts
ADDED
|
@@ -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
|
+
}
|
package/dist/watch.d.ts
ADDED
|
@@ -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 {};
|