@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,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
+ }
@@ -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>;
@@ -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[];
@@ -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[];