@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,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `--json` payload. Keys are alphabetical; a route with no definition
|
|
3
|
+
* carries `definition: null` rather than an absent member — friendlier to
|
|
4
|
+
* both `jq` and exactOptionalPropertyTypes consumers.
|
|
5
|
+
*/
|
|
6
|
+
export function buildListJson(report) {
|
|
7
|
+
return {
|
|
8
|
+
appRoutes: report.appRoutes.map(routeJson),
|
|
9
|
+
duplicates: report.duplicates,
|
|
10
|
+
loadFailures: report.loadFailures,
|
|
11
|
+
orphanDefinitions: report.orphans.map(definitionJson),
|
|
12
|
+
pagesRoutes: report.pagesRoutes.map(routeJson),
|
|
13
|
+
};
|
|
14
|
+
}
|
|
15
|
+
/**
|
|
16
|
+
* `integer`, `enum(a, b)`, `string[]`, with annotations in fixed order:
|
|
17
|
+
* presence, default, catch.
|
|
18
|
+
*/
|
|
19
|
+
export function formatCodec(description) {
|
|
20
|
+
let base = description.enumMembers === undefined
|
|
21
|
+
? description.kind
|
|
22
|
+
: `enum(${description.enumMembers.join(", ")})`;
|
|
23
|
+
if (description.arity === "many")
|
|
24
|
+
base += "[]";
|
|
25
|
+
const notes = [];
|
|
26
|
+
if (description.presence === "optional")
|
|
27
|
+
notes.push("(optional)");
|
|
28
|
+
if (description.defaultValue !== undefined) {
|
|
29
|
+
notes.push(description.defaultValue.kind === "value"
|
|
30
|
+
? `(default: ${description.defaultValue.wire})`
|
|
31
|
+
: "(default: factory)");
|
|
32
|
+
}
|
|
33
|
+
if (description.caught)
|
|
34
|
+
notes.push("(catch)");
|
|
35
|
+
return [base, ...notes].join(" ");
|
|
36
|
+
}
|
|
37
|
+
/** Human report; one string per output line. */
|
|
38
|
+
export function renderListReport(report) {
|
|
39
|
+
const lines = [
|
|
40
|
+
...renderGroup("app", report.appRoutes),
|
|
41
|
+
...renderGroup("pages", report.pagesRoutes),
|
|
42
|
+
];
|
|
43
|
+
if (lines.length === 0)
|
|
44
|
+
lines.push("no routes found");
|
|
45
|
+
if (report.orphans.length > 0) {
|
|
46
|
+
lines.push("", "definitions with no filesystem route:");
|
|
47
|
+
for (const orphan of report.orphans) {
|
|
48
|
+
lines.push(` ⚠ ${orphan.description.path} (${orphan.description.router}) ${orphan.file}`);
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
if (report.duplicates.length > 0) {
|
|
52
|
+
lines.push("", "duplicate definitions (first wins):");
|
|
53
|
+
for (const duplicate of report.duplicates) {
|
|
54
|
+
lines.push(` ⚠ ${duplicate.path} (${duplicate.router}) ${duplicate.file} (already defined in ${duplicate.firstFile})`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
if (report.loadFailures.length > 0) {
|
|
58
|
+
lines.push("", `${String(report.loadFailures.length)} module${report.loadFailures.length === 1 ? "" : "s"} failed to load (definitions in them are not shown):`);
|
|
59
|
+
for (const failure of report.loadFailures) {
|
|
60
|
+
lines.push(` ⚠ ${failure.file}: ${failure.message}`);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return lines;
|
|
64
|
+
}
|
|
65
|
+
function definitionJson(definition) {
|
|
66
|
+
return {
|
|
67
|
+
exportName: definition.exportName,
|
|
68
|
+
file: definition.file,
|
|
69
|
+
params: definition.description.params,
|
|
70
|
+
path: definition.description.path,
|
|
71
|
+
router: definition.description.router,
|
|
72
|
+
search: definition.description.search,
|
|
73
|
+
};
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* A param's codec is per-element (D5/D6) — the array-ness comes from the
|
|
77
|
+
* segment kind, so catch-all params render with the `[]` suffix plus a
|
|
78
|
+
* segment note.
|
|
79
|
+
*/
|
|
80
|
+
function formatParam(param) {
|
|
81
|
+
if (param.segmentKind === "single")
|
|
82
|
+
return formatCodec(param);
|
|
83
|
+
const note = param.segmentKind === "catchall" ? "(catch-all)" : "(optional catch-all)";
|
|
84
|
+
return `${formatCodec({ ...param, arity: "many" })} ${note}`;
|
|
85
|
+
}
|
|
86
|
+
function renderGroup(router, routes) {
|
|
87
|
+
if (routes.length === 0)
|
|
88
|
+
return [];
|
|
89
|
+
const lines = [`${router} routes (${String(routes.length)}):`];
|
|
90
|
+
const width = Math.max(...routes.map((route) => route.path.length));
|
|
91
|
+
for (const route of routes) {
|
|
92
|
+
const annotation = route.definition === null
|
|
93
|
+
? "⚠ filesystem only (no route definition found)"
|
|
94
|
+
: route.definition.file;
|
|
95
|
+
lines.push(` ${route.path.padEnd(width)} ${annotation}`);
|
|
96
|
+
if (route.definition !== null) {
|
|
97
|
+
lines.push(...renderShape(route.definition.description));
|
|
98
|
+
}
|
|
99
|
+
}
|
|
100
|
+
lines.push("");
|
|
101
|
+
return lines;
|
|
102
|
+
}
|
|
103
|
+
function renderKeys(keys, indent) {
|
|
104
|
+
const names = Object.keys(keys);
|
|
105
|
+
const width = Math.max(...names.map((name) => name.length)) + 1;
|
|
106
|
+
return names.map((name) => `${indent}${`${name}:`.padEnd(width)} ${keys[name] ?? ""}`);
|
|
107
|
+
}
|
|
108
|
+
function renderShape(description) {
|
|
109
|
+
const lines = [];
|
|
110
|
+
const params = Object.entries(description.params);
|
|
111
|
+
if (params.length > 0) {
|
|
112
|
+
lines.push(" params:");
|
|
113
|
+
lines.push(...renderKeys(Object.fromEntries(params.map(([name, param]) => [name, formatParam(param)])), " "));
|
|
114
|
+
}
|
|
115
|
+
if (description.search.kind === "raw") {
|
|
116
|
+
lines.push(" search: (rawSearch schema)");
|
|
117
|
+
}
|
|
118
|
+
else if (description.search.kind === "codecs") {
|
|
119
|
+
lines.push(" search:");
|
|
120
|
+
lines.push(...renderKeys(Object.fromEntries(Object.entries(description.search.keys).map(([name, codec]) => [
|
|
121
|
+
name,
|
|
122
|
+
formatCodec(codec),
|
|
123
|
+
])), " "));
|
|
124
|
+
}
|
|
125
|
+
return lines;
|
|
126
|
+
}
|
|
127
|
+
function routeJson(route) {
|
|
128
|
+
return {
|
|
129
|
+
definition: route.definition === null ? null : definitionJson(route.definition),
|
|
130
|
+
path: route.path,
|
|
131
|
+
};
|
|
132
|
+
}
|
package/dist/lock.d.ts
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
/** Result of {@link acquireWatcherLock}. */
|
|
2
|
+
export interface AcquireLockResult {
|
|
3
|
+
/** `false` when a live owner holds the lock — do not start a watcher. */
|
|
4
|
+
acquired: boolean;
|
|
5
|
+
/** The live owner's PID, set only when declined — for the caller's log. */
|
|
6
|
+
ownerPid?: number;
|
|
7
|
+
/**
|
|
8
|
+
* Set only when acquired: remove the lock and deregister the process
|
|
9
|
+
* cleanup handlers. Idempotent; safe to call from the caller's own signal
|
|
10
|
+
* handling.
|
|
11
|
+
*/
|
|
12
|
+
release?: () => void;
|
|
13
|
+
}
|
|
14
|
+
/**
|
|
15
|
+
* Cross-process single-writer guard (TR6): a best-effort pidfile lock. On
|
|
16
|
+
* startup: read lock → liveness-probe the owner → decline if alive,
|
|
17
|
+
* (over)write and acquire if dead or absent. Deliberately best-effort, not
|
|
18
|
+
* correct — TR3's deterministic write-if-changed output means two live
|
|
19
|
+
* watchers produce identical bytes; imperfect locking costs a log line, not
|
|
20
|
+
* corruption. Hence no flock semantics, atomic-rename dances, or PID-reuse
|
|
21
|
+
* paranoia. The in-process singleton (TR6 guard 1) lives at the composition
|
|
22
|
+
* points, not here.
|
|
23
|
+
*/
|
|
24
|
+
export declare function acquireWatcherLock(lockPath: string): AcquireLockResult;
|
|
25
|
+
/**
|
|
26
|
+
* The one canonical pidfile location (TR6): CLI-vs-wrapper dedupe only works
|
|
27
|
+
* because both paths compute the lock from the same project root.
|
|
28
|
+
*/
|
|
29
|
+
export declare function watcherLockPath(projectRoot: string): string;
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import { mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
2
|
+
import { dirname, join } from "node:path";
|
|
3
|
+
/** Strict anchored PID parse — anything else is a stale/corrupt lock. */
|
|
4
|
+
const PID_RE = /^\d+$/;
|
|
5
|
+
/**
|
|
6
|
+
* Cross-process single-writer guard (TR6): a best-effort pidfile lock. On
|
|
7
|
+
* startup: read lock → liveness-probe the owner → decline if alive,
|
|
8
|
+
* (over)write and acquire if dead or absent. Deliberately best-effort, not
|
|
9
|
+
* correct — TR3's deterministic write-if-changed output means two live
|
|
10
|
+
* watchers produce identical bytes; imperfect locking costs a log line, not
|
|
11
|
+
* corruption. Hence no flock semantics, atomic-rename dances, or PID-reuse
|
|
12
|
+
* paranoia. The in-process singleton (TR6 guard 1) lives at the composition
|
|
13
|
+
* points, not here.
|
|
14
|
+
*/
|
|
15
|
+
export function acquireWatcherLock(lockPath) {
|
|
16
|
+
const ownerPid = readOwnerPid(lockPath);
|
|
17
|
+
if (ownerPid !== undefined && ownerPid !== process.pid && isAlive(ownerPid)) {
|
|
18
|
+
return { acquired: false, ownerPid };
|
|
19
|
+
}
|
|
20
|
+
mkdirSync(dirname(lockPath), { recursive: true });
|
|
21
|
+
writeFileSync(lockPath, String(process.pid));
|
|
22
|
+
let released = false;
|
|
23
|
+
const release = () => {
|
|
24
|
+
if (released)
|
|
25
|
+
return;
|
|
26
|
+
released = true;
|
|
27
|
+
process.removeListener("exit", release);
|
|
28
|
+
process.removeListener("SIGINT", onSigint);
|
|
29
|
+
process.removeListener("SIGTERM", onSigterm);
|
|
30
|
+
try {
|
|
31
|
+
// Only remove a lock that is still ours — a successor may have taken
|
|
32
|
+
// over after our probe found this process dead (it wasn't).
|
|
33
|
+
if (readOwnerPid(lockPath) === process.pid) {
|
|
34
|
+
rmSync(lockPath, { force: true });
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
// Best-effort (TR6): a leftover lock self-heals via the liveness
|
|
39
|
+
// probe on the next startup.
|
|
40
|
+
}
|
|
41
|
+
};
|
|
42
|
+
const reraise = (signal) => {
|
|
43
|
+
release();
|
|
44
|
+
// Re-raise so the default termination — or Next's own handlers — still
|
|
45
|
+
// apply; `once` already removed this listener.
|
|
46
|
+
process.kill(process.pid, signal);
|
|
47
|
+
};
|
|
48
|
+
const onSigint = () => {
|
|
49
|
+
reraise("SIGINT");
|
|
50
|
+
};
|
|
51
|
+
const onSigterm = () => {
|
|
52
|
+
reraise("SIGTERM");
|
|
53
|
+
};
|
|
54
|
+
process.once("exit", release);
|
|
55
|
+
process.once("SIGINT", onSigint);
|
|
56
|
+
process.once("SIGTERM", onSigterm);
|
|
57
|
+
return { acquired: true, release };
|
|
58
|
+
}
|
|
59
|
+
/**
|
|
60
|
+
* The one canonical pidfile location (TR6): CLI-vs-wrapper dedupe only works
|
|
61
|
+
* because both paths compute the lock from the same project root.
|
|
62
|
+
*/
|
|
63
|
+
export function watcherLockPath(projectRoot) {
|
|
64
|
+
return join(projectRoot, "node_modules", ".cache", "paramour", "watcher.lock");
|
|
65
|
+
}
|
|
66
|
+
/** `true` when `pid` is a live process (TR6 liveness probe). */
|
|
67
|
+
function isAlive(pid) {
|
|
68
|
+
try {
|
|
69
|
+
process.kill(pid, 0);
|
|
70
|
+
return true;
|
|
71
|
+
}
|
|
72
|
+
catch (error) {
|
|
73
|
+
// EPERM: the process exists but isn't ours to signal — alive.
|
|
74
|
+
return error.code === "EPERM";
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
/** The PID in the lock file, or `undefined` when absent/unparseable. */
|
|
78
|
+
function readOwnerPid(lockPath) {
|
|
79
|
+
let content;
|
|
80
|
+
try {
|
|
81
|
+
content = readFileSync(lockPath, "utf8");
|
|
82
|
+
}
|
|
83
|
+
catch {
|
|
84
|
+
return undefined;
|
|
85
|
+
}
|
|
86
|
+
const trimmed = content.trim();
|
|
87
|
+
return PID_RE.test(trimmed) ? Number(trimmed) : undefined;
|
|
88
|
+
}
|
package/dist/pages.d.ts
ADDED
|
@@ -0,0 +1,49 @@
|
|
|
1
|
+
import { type AnyPagesRoute, type InferRouteParams, type SafeResult, type SearchOutputOf } from "paramour";
|
|
2
|
+
import { type SelectOptions } from "./select.js";
|
|
3
|
+
export type { SelectOptions } from "./select.js";
|
|
4
|
+
/**
|
|
5
|
+
* Pages Router hooks (design-06 PR5/PR6, design-07). Deliberately NO
|
|
6
|
+
* `"use client"` directive on this module: the directive is an App Router
|
|
7
|
+
* (RSC graph) concept, meaningless in a `pages/` bundle (PR2).
|
|
8
|
+
*
|
|
9
|
+
* `useRouter().query` is one merged bag (route params + search), and on a
|
|
10
|
+
* statically-optimized page it is `{}` until `router.isReady` flips after
|
|
11
|
+
* hydration — a platform fact the result type admits as a third state
|
|
12
|
+
* instead of papering over (PR5). On `getServerSideProps` pages the FIRST
|
|
13
|
+
* render is already `isReady: true` with a populated query (design-06
|
|
14
|
+
* spike 3), so the `pending` arm never surfaces there.
|
|
15
|
+
*
|
|
16
|
+
* Deliberately NO `OrThrow` variants (PR6): throwing on `pending` would
|
|
17
|
+
* flash the error boundary on every statically-optimized page's first
|
|
18
|
+
* render, and returning `T | undefined` would make the name a lie. The
|
|
19
|
+
* three-state union forcing the check IS the feature — and users who know
|
|
20
|
+
* their page has `getServerSideProps` should be reading typed props from
|
|
21
|
+
* `route.parseContext(ctx)` (PR10) rather than reaching for a client hook.
|
|
22
|
+
*
|
|
23
|
+
* Both hooks are gated to `AnyPagesRoute` (PR3) and share the /app hooks'
|
|
24
|
+
* design-07 layering: raw-slice stabilization keyed on the declared slice of
|
|
25
|
+
* `query` (+ `isReady`), then an optional `{ select }` projection with
|
|
26
|
+
* result-equality checking — the `pending` arm passes through the selector
|
|
27
|
+
* untouched (SEL2), and `PENDING` itself is one referentially stable object.
|
|
28
|
+
*/
|
|
29
|
+
/**
|
|
30
|
+
* Three-state result for the pages hooks (PR5): core's `SafeResult` plus a
|
|
31
|
+
* `pending` member for the pre-`isReady` render of a statically-optimized
|
|
32
|
+
* page. Literally `SafeResult<T> | { status: "pending" }` (PR12), so both
|
|
33
|
+
* routers' results destructure identically.
|
|
34
|
+
*/
|
|
35
|
+
export type RouterResult<T> = SafeResult<T> | {
|
|
36
|
+
status: "pending";
|
|
37
|
+
};
|
|
38
|
+
/**
|
|
39
|
+
* Decoded route params as a {@link RouterResult} (PR5), optionally projected
|
|
40
|
+
* through `options.select` (design-07 SEL1/SEL2).
|
|
41
|
+
*/
|
|
42
|
+
export declare function useRouteParams<R extends AnyPagesRoute>(route: R): RouterResult<InferRouteParams<R>>;
|
|
43
|
+
export declare function useRouteParams<R extends AnyPagesRoute, U>(route: R, options: SelectOptions<InferRouteParams<R>, U>): RouterResult<U>;
|
|
44
|
+
/**
|
|
45
|
+
* Decoded search params as a {@link RouterResult} (PR5), optionally projected
|
|
46
|
+
* through `options.select` (design-07 SEL1/SEL2).
|
|
47
|
+
*/
|
|
48
|
+
export declare function useSearch<R extends AnyPagesRoute>(route: R): RouterResult<SearchOutputOf<R["~search"]>>;
|
|
49
|
+
export declare function useSearch<R extends AnyPagesRoute, U>(route: R, options: SelectOptions<SearchOutputOf<R["~search"]>, U>): RouterResult<U>;
|
package/dist/pages.js
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import { useRouter } from "next/router";
|
|
2
|
+
import { ParamourError, safeDecodeParams, safeDecodeSearch, } from "paramour";
|
|
3
|
+
import { paramsFingerprint, PENDING_FINGERPRINT, queryFingerprint, useSelectedResult, useStableResult, } from "./select.js";
|
|
4
|
+
/** Referentially stable across every pending render. */
|
|
5
|
+
const PENDING = { status: "pending" };
|
|
6
|
+
export function useRouteParams(route, options) {
|
|
7
|
+
const { isReady, query } = usePagesRouter();
|
|
8
|
+
const result = useStableResult(route, isReady ? paramsFingerprint(route, query) : PENDING_FINGERPRINT, () => {
|
|
9
|
+
if (!isReady)
|
|
10
|
+
return PENDING;
|
|
11
|
+
// The merged query is a legal params source as-is: decodeParams reads
|
|
12
|
+
// only the route's own segment names, never unknown keys. R5: next/router
|
|
13
|
+
// has already percent-decoded `query`, so skip core's decode to avoid a
|
|
14
|
+
// double-decode (`/product/a%2520b` → `"a%20b"` must survive as-is).
|
|
15
|
+
return safeDecodeParams(route, query, { percentDecode: false });
|
|
16
|
+
});
|
|
17
|
+
return useSelectedResult(result, options);
|
|
18
|
+
}
|
|
19
|
+
export function useSearch(route, options) {
|
|
20
|
+
const { isReady, query } = usePagesRouter();
|
|
21
|
+
const result = useStableResult(route, isReady ? queryFingerprint(route, query) : PENDING_FINGERPRINT, () => {
|
|
22
|
+
if (!isReady)
|
|
23
|
+
return PENDING;
|
|
24
|
+
return safeDecodeSearch(route, omitPathParams(query, route));
|
|
25
|
+
});
|
|
26
|
+
return useSelectedResult(result, options);
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* `query` minus the route's own path-param names (PR5) — the client twin of
|
|
30
|
+
* `parseContext`'s server-side subtraction (core route.ts, PR10). Entries →
|
|
31
|
+
* fromEntries so a hostile `?__proto__=` key stays an ordinary own property
|
|
32
|
+
* (decodeParams's ethos). Names come from the define-time `~segments` token
|
|
33
|
+
* cache, so nothing re-tokenizes per render.
|
|
34
|
+
*/
|
|
35
|
+
function omitPathParams(query, route) {
|
|
36
|
+
const names = new Set();
|
|
37
|
+
for (const segment of route["~segments"]) {
|
|
38
|
+
if (segment.kind !== "static")
|
|
39
|
+
names.add(segment.name);
|
|
40
|
+
}
|
|
41
|
+
return Object.fromEntries(Object.entries(query).filter(([key]) => !names.has(key)));
|
|
42
|
+
}
|
|
43
|
+
/**
|
|
44
|
+
* `useRouter` with the one failure the brand cannot catch translated (PR5):
|
|
45
|
+
* in a hybrid project a component rendered under `app/` can legally hold a
|
|
46
|
+
* pages-branded route, but `next/router` has no mount there and throws
|
|
47
|
+
* "NextRouter was not mounted" — a message pointing at the wrong fix
|
|
48
|
+
* (component placement is invisible to the type system). Rethrow a
|
|
49
|
+
* `ParamourError` naming the actual mistake; everything else propagates.
|
|
50
|
+
*/
|
|
51
|
+
function usePagesRouter() {
|
|
52
|
+
try {
|
|
53
|
+
return useRouter();
|
|
54
|
+
}
|
|
55
|
+
catch (error) {
|
|
56
|
+
if (error instanceof Error &&
|
|
57
|
+
error.message.includes("NextRouter was not mounted")) {
|
|
58
|
+
throw new ParamourError('pages hooks were rendered under the App Router, where next/router is never mounted — import this component\'s hooks from "@paramour-js/next/app" and pass it an app route instead (PR5)', { cause: error });
|
|
59
|
+
}
|
|
60
|
+
throw error;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { type CliIo } from "./cli-io.js";
|
|
2
|
+
export { type CliIo } from "./cli-io.js";
|
|
3
|
+
/**
|
|
4
|
+
* @internal The CLI dispatcher (TR7), in-process testable: returns the exit
|
|
5
|
+
* code instead of exiting. The exit-code contract holds across every
|
|
6
|
+
* command: 0 success, 1 "the thing you asked me to verify is not true"
|
|
7
|
+
* (`check`/`generate --check` drift, `doctor` failures) ONLY, 2
|
|
8
|
+
* usage/config/operational errors. Each command owns its flags parse and
|
|
9
|
+
* usage text; this layer only routes the first positional.
|
|
10
|
+
*/
|
|
11
|
+
export declare function runCli(argv: readonly string[], io?: CliIo): Promise<number>;
|
package/dist/run-cli.js
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import { resolveIo } from "./cli-io.js";
|
|
2
|
+
import { runDoctor } from "./commands/doctor.js";
|
|
3
|
+
import { runGenerate } from "./commands/generate.js";
|
|
4
|
+
import { runInit } from "./commands/init.js";
|
|
5
|
+
import { runList } from "./commands/list.js";
|
|
6
|
+
export {} from "./cli-io.js";
|
|
7
|
+
// Alphabetical; the unknown-command message derives from these keys.
|
|
8
|
+
const COMMANDS = {
|
|
9
|
+
check: (argv, io) => runGenerate(argv, io, "check"),
|
|
10
|
+
doctor: runDoctor,
|
|
11
|
+
generate: (argv, io) => runGenerate(argv, io, "generate"),
|
|
12
|
+
init: runInit,
|
|
13
|
+
list: runList,
|
|
14
|
+
};
|
|
15
|
+
const USAGE = [
|
|
16
|
+
"Usage: paramour <command> [options]",
|
|
17
|
+
"",
|
|
18
|
+
"Commands:",
|
|
19
|
+
" check verify the artifact is current; exit 1 on drift, never writes",
|
|
20
|
+
" doctor diagnose the project's paramour setup",
|
|
21
|
+
" generate generate paramour-env.d.ts from the app and pages directories",
|
|
22
|
+
" init set up paramour in this project",
|
|
23
|
+
" list print every route with its params/search shape",
|
|
24
|
+
"",
|
|
25
|
+
"Run `paramour <command> --help` for that command's options.",
|
|
26
|
+
].join("\n");
|
|
27
|
+
/**
|
|
28
|
+
* @internal The CLI dispatcher (TR7), in-process testable: returns the exit
|
|
29
|
+
* code instead of exiting. The exit-code contract holds across every
|
|
30
|
+
* command: 0 success, 1 "the thing you asked me to verify is not true"
|
|
31
|
+
* (`check`/`generate --check` drift, `doctor` failures) ONLY, 2
|
|
32
|
+
* usage/config/operational errors. Each command owns its flags parse and
|
|
33
|
+
* usage text; this layer only routes the first positional.
|
|
34
|
+
*/
|
|
35
|
+
export async function runCli(argv, io = {}) {
|
|
36
|
+
const { stderr, stdout } = resolveIo(io);
|
|
37
|
+
const [command, ...rest] = argv;
|
|
38
|
+
if (command === undefined) {
|
|
39
|
+
stderr(USAGE);
|
|
40
|
+
return 2;
|
|
41
|
+
}
|
|
42
|
+
if (command === "--help" || command === "-h" || command === "help") {
|
|
43
|
+
stdout(USAGE);
|
|
44
|
+
return 0;
|
|
45
|
+
}
|
|
46
|
+
const run = COMMANDS[command];
|
|
47
|
+
if (run === undefined) {
|
|
48
|
+
stderr(`paramour: unknown command "${command}" (expected one of: ${Object.keys(COMMANDS).join(", ")})`);
|
|
49
|
+
stderr(USAGE);
|
|
50
|
+
return 2;
|
|
51
|
+
}
|
|
52
|
+
return run(rest, io);
|
|
53
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import type { Dirent } from "node:fs";
|
|
2
|
+
/** Next's default `pageExtensions` — extensions only, no leading dot (TR2). */
|
|
3
|
+
export declare const DEFAULT_PAGE_EXTENSIONS: readonly ["tsx", "ts", "jsx", "js"];
|
|
4
|
+
/**
|
|
5
|
+
* Whether a directory entry should be treated as a FILE for routing: a real
|
|
6
|
+
* file, or a symlink whose target is a regular file. `Dirent.isFile()` is
|
|
7
|
+
* false for a symlink even when it points at a file, yet Next resolves and
|
|
8
|
+
* serves symlinked `page`/`route` files (common in pnpm-linked monorepos), so
|
|
9
|
+
* dropping them would omit routes that Next actually serves (Bug 4, TR2). A
|
|
10
|
+
* symlink to a DIRECTORY returns false — directory symlinks stay not-followed,
|
|
11
|
+
* the existing v1 stance — and a broken link (statSync throws ENOENT) also
|
|
12
|
+
* returns false, i.e. is skipped silently, matching Next's own tolerance of
|
|
13
|
+
* broken links.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolvesToFile(entry: Dirent, dir: string): boolean;
|
|
16
|
+
/**
|
|
17
|
+
* Walk an app dir and return the sorted union of URL-shaped route paths —
|
|
18
|
+
* exactly the strings `defineAppRoute` accepts (TR2, RL2). Pure `fs.readdir`
|
|
19
|
+
* recursion; no dependency on Next internals. Two page files resolving to
|
|
20
|
+
* one URL path — `(a)/x` + `(b)/x` group twins, or `page.tsx` + `page.jsx`
|
|
21
|
+
* extension twins — throw a {@link RouteCollisionError} instead of being
|
|
22
|
+
* deduped (PR4/PR9 alignment ruling): that state is Next's own build error,
|
|
23
|
+
* and deduping would emit an artifact for a project that cannot build.
|
|
24
|
+
*
|
|
25
|
+
* `route.<ext>` handlers are scanned but never emitted (handler typing is
|
|
26
|
+
* deferred, §14). They exist only to catch the states Next refuses to build:
|
|
27
|
+
* a page and a route handler at the same URL path ("conflicting route and
|
|
28
|
+
* page"), and two route handlers at the same path — both throw (PR9).
|
|
29
|
+
*/
|
|
30
|
+
export declare function scanAppRoutes(appDir: string, pageExtensions?: readonly string[]): string[];
|
package/dist/scan-app.js
ADDED
|
@@ -0,0 +1,149 @@
|
|
|
1
|
+
import { readdirSync, statSync } from "node:fs";
|
|
2
|
+
import { join } from "node:path";
|
|
3
|
+
import { assertNoStructuralCollisions, RouteCollisionError, } from "./collisions.js";
|
|
4
|
+
/** Next's default `pageExtensions` — extensions only, no leading dot (TR2). */
|
|
5
|
+
export const DEFAULT_PAGE_EXTENSIONS = ["tsx", "ts", "jsx", "js"];
|
|
6
|
+
/**
|
|
7
|
+
* Interception markers `(.)`/`(..)`/`(...)` (TR2, RL2 / §15.5). A prefix
|
|
8
|
+
* match, so chained forms like `(..)(..)segment` are caught too; tested
|
|
9
|
+
* BEFORE the route-group test so `(.)foo` is never misread as a group.
|
|
10
|
+
*/
|
|
11
|
+
const INTERCEPTION_PREFIX = /^\(\.{1,3}\)/;
|
|
12
|
+
/**
|
|
13
|
+
* Next's documented `%5F` escape (Next "Project Organization" → Private
|
|
14
|
+
* Folders): a folder whose name begins with a percent-encoded underscore
|
|
15
|
+
* serves a URL segment beginning with a literal `_`, opting that segment out
|
|
16
|
+
* of the private-folder convention — `app/%5Fsettings/page.tsx` serves
|
|
17
|
+
* `/_settings`. The escape is defined for the LEADING position only. Because
|
|
18
|
+
* RFC 3986 percent-encoding is case-insensitive on its hex digits (and this
|
|
19
|
+
* could not be pinned against Next's source from here), both `%5F` and `%5f`
|
|
20
|
+
* are decoded defensively (Bug 8, TR2). The fs name stays raw for error
|
|
21
|
+
* messages; only the emitted URL segment is decoded.
|
|
22
|
+
*/
|
|
23
|
+
const LEADING_ESCAPED_UNDERSCORE = /^%5[Ff]/;
|
|
24
|
+
/** Route groups `(group)` — stripped from the emitted path (TR2, RL2). */
|
|
25
|
+
const ROUTE_GROUP = /^\(.*\)$/;
|
|
26
|
+
/**
|
|
27
|
+
* Whether a directory entry should be treated as a FILE for routing: a real
|
|
28
|
+
* file, or a symlink whose target is a regular file. `Dirent.isFile()` is
|
|
29
|
+
* false for a symlink even when it points at a file, yet Next resolves and
|
|
30
|
+
* serves symlinked `page`/`route` files (common in pnpm-linked monorepos), so
|
|
31
|
+
* dropping them would omit routes that Next actually serves (Bug 4, TR2). A
|
|
32
|
+
* symlink to a DIRECTORY returns false — directory symlinks stay not-followed,
|
|
33
|
+
* the existing v1 stance — and a broken link (statSync throws ENOENT) also
|
|
34
|
+
* returns false, i.e. is skipped silently, matching Next's own tolerance of
|
|
35
|
+
* broken links.
|
|
36
|
+
*/
|
|
37
|
+
export function resolvesToFile(entry, dir) {
|
|
38
|
+
if (entry.isFile())
|
|
39
|
+
return true;
|
|
40
|
+
if (!entry.isSymbolicLink())
|
|
41
|
+
return false;
|
|
42
|
+
try {
|
|
43
|
+
// statSync follows the link (unlike the withFileTypes Dirent, which
|
|
44
|
+
// reflects the link itself).
|
|
45
|
+
return statSync(join(dir, entry.name)).isFile();
|
|
46
|
+
}
|
|
47
|
+
catch {
|
|
48
|
+
return false;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
/**
|
|
52
|
+
* Walk an app dir and return the sorted union of URL-shaped route paths —
|
|
53
|
+
* exactly the strings `defineAppRoute` accepts (TR2, RL2). Pure `fs.readdir`
|
|
54
|
+
* recursion; no dependency on Next internals. Two page files resolving to
|
|
55
|
+
* one URL path — `(a)/x` + `(b)/x` group twins, or `page.tsx` + `page.jsx`
|
|
56
|
+
* extension twins — throw a {@link RouteCollisionError} instead of being
|
|
57
|
+
* deduped (PR4/PR9 alignment ruling): that state is Next's own build error,
|
|
58
|
+
* and deduping would emit an artifact for a project that cannot build.
|
|
59
|
+
*
|
|
60
|
+
* `route.<ext>` handlers are scanned but never emitted (handler typing is
|
|
61
|
+
* deferred, §14). They exist only to catch the states Next refuses to build:
|
|
62
|
+
* a page and a route handler at the same URL path ("conflicting route and
|
|
63
|
+
* page"), and two route handlers at the same path — both throw (PR9).
|
|
64
|
+
*/
|
|
65
|
+
export function scanAppRoutes(appDir, pageExtensions = DEFAULT_PAGE_EXTENSIONS) {
|
|
66
|
+
// Path → the fs path (relative to appDir) that produced it, so a collision
|
|
67
|
+
// can name both files. `out` holds page routes (emitted); `routeOut` holds
|
|
68
|
+
// route-handler paths (never emitted — collision detection only).
|
|
69
|
+
const out = new Map();
|
|
70
|
+
const routeOut = new Map();
|
|
71
|
+
const pageFileNames = new Set(pageExtensions.map((ext) => `page.${ext}`));
|
|
72
|
+
const routeFileNames = new Set(pageExtensions.map((ext) => `route.${ext}`));
|
|
73
|
+
walk(appDir, [], [], pageFileNames, routeFileNames, out, routeOut);
|
|
74
|
+
// PR9: a page and a route handler resolving to one URL path is Next's
|
|
75
|
+
// "conflicting route and page" build error — no valid artifact exists, so
|
|
76
|
+
// throw rather than emit the page and silently drop the handler. Sorted so
|
|
77
|
+
// the reported pair is deterministic across platforms.
|
|
78
|
+
for (const [path, routeFile] of [...routeOut].sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0)) {
|
|
79
|
+
const pageFile = out.get(path);
|
|
80
|
+
if (pageFile !== undefined) {
|
|
81
|
+
throw new RouteCollisionError(`app route collision at "${path}": page ${pageFile} and route handler ${routeFile} resolve to the same path, which Next refuses to build (conflicting route and page) (PR9)`);
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
// Code-unit sort, never localeCompare — locale independence feeds TR3's
|
|
85
|
+
// byte-identical-on-every-OS guarantee.
|
|
86
|
+
const paths = [...out.keys()].sort();
|
|
87
|
+
// PR9 structural collisions (different slug names, optional-catch-all
|
|
88
|
+
// specificity) — non-equal strings the Map above cannot catch.
|
|
89
|
+
assertNoStructuralCollisions(paths.map((path) => ({ path, router: "app" })));
|
|
90
|
+
return paths;
|
|
91
|
+
}
|
|
92
|
+
function walk(dir, urlSegments, fsSegments, pageFileNames, routeFileNames, out, routeOut) {
|
|
93
|
+
// Sorted traversal: readdir order is platform-dependent, and which of two
|
|
94
|
+
// colliding files gets named first in the error must not be.
|
|
95
|
+
const entries = readdirSync(dir, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : a.name > b.name ? 1 : 0);
|
|
96
|
+
for (const entry of entries) {
|
|
97
|
+
const name = entry.name;
|
|
98
|
+
// A real file, or a symlink whose target is a file (Bug 4). Directory
|
|
99
|
+
// symlinks fall through to the directory guard below, which is false for
|
|
100
|
+
// a symlink Dirent, so their subtree is skipped — not followed (TR2).
|
|
101
|
+
if (resolvesToFile(entry, dir)) {
|
|
102
|
+
// Exact, case-sensitive `page.<ext>` / `route.<ext>` match (TR2). Pages
|
|
103
|
+
// are emitted; route handlers are tracked separately (never emitted —
|
|
104
|
+
// handler typing is §14) purely to detect the build errors above (PR9).
|
|
105
|
+
const isPage = pageFileNames.has(name);
|
|
106
|
+
const isRoute = !isPage && routeFileNames.has(name);
|
|
107
|
+
if (isPage || isRoute) {
|
|
108
|
+
const path = urlSegments.length === 0 ? "/" : `/${urlSegments.join("/")}`;
|
|
109
|
+
const file = [...fsSegments, name].join("/");
|
|
110
|
+
const target = isPage ? out : routeOut;
|
|
111
|
+
const existing = target.get(path);
|
|
112
|
+
if (existing !== undefined) {
|
|
113
|
+
throw new RouteCollisionError(isPage
|
|
114
|
+
? `app route collision at "${path}": ${existing} and ${file} resolve to the same path (PR9)`
|
|
115
|
+
: `app route collision at "${path}": ${existing} and ${file} both declare a route handler at the same path, which Next refuses to build (PR9)`);
|
|
116
|
+
}
|
|
117
|
+
target.set(path, file);
|
|
118
|
+
}
|
|
119
|
+
continue;
|
|
120
|
+
}
|
|
121
|
+
// Symlinked directories are deliberately not followed (TR2 v1 stance):
|
|
122
|
+
// `resolvesToFile` returned false and `isDirectory()` is false for the
|
|
123
|
+
// link Dirent, so the subtree is skipped here.
|
|
124
|
+
if (!entry.isDirectory())
|
|
125
|
+
continue;
|
|
126
|
+
// TR2 skip rules: private folders, parallel slots, interception routes —
|
|
127
|
+
// each skips the entire subtree, pages at any depth included. The `_`
|
|
128
|
+
// test reads the raw fs name, so `%5F`-escaped folders (which do NOT
|
|
129
|
+
// start with `_`) are correctly NOT skipped (Bug 8).
|
|
130
|
+
if (name.startsWith("_"))
|
|
131
|
+
continue;
|
|
132
|
+
if (name.startsWith("@"))
|
|
133
|
+
continue;
|
|
134
|
+
if (INTERCEPTION_PREFIX.test(name))
|
|
135
|
+
continue;
|
|
136
|
+
if (ROUTE_GROUP.test(name)) {
|
|
137
|
+
// Group stripped: recurse with the SAME url segments (TR2).
|
|
138
|
+
walk(join(dir, name), urlSegments, [...fsSegments, name], pageFileNames, routeFileNames, out, routeOut);
|
|
139
|
+
continue;
|
|
140
|
+
}
|
|
141
|
+
// Dynamic segments `[id]` / `[...slug]` / `[[...slug]]` pass through
|
|
142
|
+
// verbatim (TR2, RL2 URL-shaped literals). A leading `%5F` decodes to `_`
|
|
143
|
+
// for the emitted URL segment so it string-matches the served URL; the fs
|
|
144
|
+
// name stays raw for error messages, and the decoded form participates in
|
|
145
|
+
// collision detection via the `out` Map key (Bug 8).
|
|
146
|
+
const urlSegment = name.replace(LEADING_ESCAPED_UNDERSCORE, "_");
|
|
147
|
+
walk(join(dir, name), [...urlSegments, urlSegment], [...fsSegments, name], pageFileNames, routeFileNames, out, routeOut);
|
|
148
|
+
}
|
|
149
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Walk a pages dir and return the sorted union of URL-shaped route paths —
|
|
3
|
+
* exactly the strings `definePagesRoute` accepts (PR4). A route is any file
|
|
4
|
+
* whose extension is in `pageExtensions`, mapped by its path relative to the
|
|
5
|
+
* dir; `index.<ext>` maps to its directory. Two files resolving to one URL
|
|
6
|
+
* path — folder/file spelling (`blog.tsx` + `blog/index.tsx`) or extension
|
|
7
|
+
* twins (`about.tsx` + `about.jsx`) — throw a {@link RouteCollisionError},
|
|
8
|
+
* never dedupe: both are Next's own build errors (PR9).
|
|
9
|
+
*/
|
|
10
|
+
export declare function scanPagesRoutes(pagesDir: string, pageExtensions?: readonly string[]): string[];
|