@lesto/router 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.
@@ -0,0 +1,703 @@
1
+ /**
2
+ * File-based routing — a convention that compiles a directory tree into the same
3
+ * `:param` patterns the rest of `@lesto/router` already matches.
4
+ *
5
+ * Every peer meta-framework lets you drop a file at a path and get a route; this
6
+ * is Lesto's version, and it is deliberately THIN. It owns one job: read the shape
7
+ * of a conventional directory (`app/` by default) and turn it into an ordered list
8
+ * of {@link FileRoute} descriptors — a URL pattern, the kind of file, and the
9
+ * layout chain that wraps it. It does NOT load modules, touch a `Lesto` builder,
10
+ * or render anything; the impure half (importing each module, calling `.page()` /
11
+ * `.layout()`) lives in `@lesto/web`'s `applyFileRoutes`, over THESE descriptors.
12
+ * Keeping the path math here, pure and over an injected reader, is what makes the
13
+ * whole convention unit-testable with no filesystem.
14
+ *
15
+ * THE CONVENTION (a strict subset of the Next/Remix/SvelteKit family):
16
+ *
17
+ * app/
18
+ * layout.tsx → a layout wrapping every route under app/
19
+ * page.tsx → the route "/"
20
+ * about/
21
+ * page.tsx → the route "/about"
22
+ * listings/
23
+ * layout.tsx → a layout wrapping every route under /listings
24
+ * page.tsx → the route "/listings"
25
+ * [id]/
26
+ * page.tsx → the route "/listings/:id" (typed param `id`)
27
+ * docs/
28
+ * [...slug]/
29
+ * page.tsx → the route "/docs/*slug" (catch-all, `slug: string[]`)
30
+ * (marketing)/ → a pathless GROUP — adds no URL segment
31
+ * layout.tsx → wraps the group's pages without nesting a URL
32
+ * about/
33
+ * page.tsx → the route "/about" (NOT "/(marketing)/about")
34
+ *
35
+ * A segment is a directory name. A `[name]` directory is a dynamic segment that
36
+ * compiles to `:name`; a `[...name]` is a CATCH-ALL that compiles to the greedy
37
+ * `*name` (one or more trailing segments, a typed `string[]`), and `[[...name]]`
38
+ * its OPTIONAL twin `*name?` (zero or more, so the parent path matches too). A
39
+ * `(name)` directory is a route GROUP: pathless, so it organizes files (and can
40
+ * hold a shared `layout`) without contributing a URL segment. So the typed-param
41
+ * machinery (`ParamKeys`/`PathParams`) the code-first router already has flows
42
+ * through unchanged: the URL the page registers at is an ordinary pattern string.
43
+ * A `page` file at a directory makes that directory's URL a page; a `layout` file
44
+ * makes one that wraps every page at or below it, outermost-first (the directory
45
+ * depth order layouts must nest in — a group's layout nests by its directory like
46
+ * any other). A `middleware` file is a directory-scoped GUARD that runs BEFORE the
47
+ * nearest page's loader — outermost-first down the SAME directory chain layouts
48
+ * nest in (every `middleware` above the page, not just the nearest) — so it can
49
+ * redirect before load or augment the loader's context; the impure applier
50
+ * (`@lesto/web`) runs it in the page's handler chain. A `loading`, `error`, or
51
+ * `not-found` file is a directory-scoped BOUNDARY: it supplies the nearest Suspense
52
+ * fallback, error boundary, or 404 boundary to the page at its directory and below,
53
+ * a deeper file of the same kind overriding the shallower for that subtree.
54
+ *
55
+ * Co-existence is the whole point: these descriptors become ordinary `.page()` /
56
+ * `.layout()` registrations on the SAME `Lesto` instance an app declares its
57
+ * programmatic routes on, so a file-route and a hand-written route live side by
58
+ * side with no second router.
59
+ *
60
+ * A CATCH-ALL is a greedy FALLBACK — mind its registration order. Among file
61
+ * routes it is auto-sorted LAST ({@link compileFileRoutes} sinks it below every
62
+ * non-catch-all), so a file-route tree resolves correctly on its own. But the app
63
+ * matcher is first-match-by-insertion-order (see `RouteTable`), and that sort does
64
+ * NOT reach across to hand-written routes, `.data()` sources, or the built-in
65
+ * `/__lesto/*` endpoints. So a ROOT catch-all (`/*slug`, `app/[[...slug]]`)
66
+ * registered BEFORE those — e.g. `applyFileRoutes(app, …)` then `app.get("/api/…")`
67
+ * — will shadow them (the request hits the catch-all page, not the API/data route).
68
+ * Register specific routes and data sources FIRST, the root catch-all LAST. A
69
+ * SCOPED catch-all (`/blog/*slug`) only covers its own subtree, so it is unaffected.
70
+ */
71
+
72
+ import { RouterError } from "./errors";
73
+
74
+ /**
75
+ * The recognized file kinds at a directory. A `page` makes the directory's URL a
76
+ * route; a `layout` wraps every route at or below it. A `middleware` runs before
77
+ * the nearest page's loader (redirect-before-load / context augmentation), composed
78
+ * down the directory chain like a layout. A `loading`, `error`, or `not-found` is a
79
+ * BOUNDARY: like a layout it is directory-scoped (it applies to the page at its
80
+ * directory and every page below, unless a deeper directory overrides it) and
81
+ * registers no route of its own — it only supplies the page's nearest Suspense
82
+ * fallback (`loading`), error boundary (`error`), or 404 boundary (`not-found`).
83
+ * Anything else under the convention dir is ignored, so a co-located helper
84
+ * (`listing-card.tsx`, a test, a stylesheet) is not mistaken for a route.
85
+ */
86
+ export type FileRouteKind = "page" | "layout" | "middleware" | "loading" | "error" | "not-found";
87
+
88
+ /**
89
+ * The DIRECTORY-SCOPED non-page kinds — every kind but `page`. Each registers no
90
+ * URL of its own: the applier keys it by directory and a page looks it up from its
91
+ * depths. They split by HOW a page resolves them: a `layout` and a `middleware`
92
+ * compose as the WHOLE chain above a page (every matching ancestor, outermost
93
+ * first — {@link layoutDepthsFor}), while a `loading`/`error`/`not-found` resolves
94
+ * to a SINGLE nearest file (the deepest matching directory — {@link boundariesFor}).
95
+ * Listed once so the compiler discovers, keys, and emits every one the same way.
96
+ */
97
+ export const BOUNDARY_KINDS = ["layout", "middleware", "loading", "error", "not-found"] as const;
98
+
99
+ /** One of the directory-scoped non-page kinds (everything but `page`). */
100
+ export type BoundaryKind = (typeof BOUNDARY_KINDS)[number];
101
+
102
+ /**
103
+ * The non-page kinds that resolve to a SINGLE nearest file above a page (a deeper
104
+ * file overriding a shallower for that subtree) — as opposed to `layout`/`middleware`,
105
+ * which compose the whole ancestor chain. Listed once so the compiler resolves each
106
+ * the SAME way (`boundariesFor`) rather than repeating the nearest walk per kind.
107
+ */
108
+ export const NEAREST_BOUNDARY_KINDS = ["loading", "error", "not-found"] as const;
109
+
110
+ /** One of the single-nearest boundary kinds (`loading`/`error`/`not-found`). */
111
+ export type NearestBoundaryKind = (typeof NEAREST_BOUNDARY_KINDS)[number];
112
+
113
+ /**
114
+ * The base names (without extension) that name each kind, in the order a reader
115
+ * surfaces matter not at all — the kind is decided by the name, never position.
116
+ * Exported so the impure scanner and its tests agree on exactly which files count.
117
+ */
118
+ export const ROUTE_FILE_NAMES: Readonly<Record<string, FileRouteKind>> = Object.freeze({
119
+ page: "page",
120
+ layout: "layout",
121
+ middleware: "middleware",
122
+ loading: "loading",
123
+ error: "error",
124
+ "not-found": "not-found",
125
+ });
126
+
127
+ /**
128
+ * One discovered module under the convention dir, as the injected reader yields
129
+ * it: the kind (page/layout) and the chain of URL SEGMENTS from the convention
130
+ * root to its directory.
131
+ *
132
+ * Segments are the raw directory names, NOT yet compiled to a pattern — `["app"]`
133
+ * is omitted (the reader yields paths relative to the convention root), so
134
+ * `app/listings/[id]/page.tsx` arrives as `{ kind: "page", segments: ["listings",
135
+ * "[id]"] }`. The root `app/page.tsx` arrives as `{ kind: "page", segments: [] }`.
136
+ * Keeping segments raw lets {@link compileFileRoutes} own the one place a `[id]`
137
+ * becomes `:id`, so the rule is tested once.
138
+ */
139
+ export interface DiscoveredFile {
140
+ kind: FileRouteKind;
141
+
142
+ /** The directory segments from the convention root to this file's directory. */
143
+ segments: ReadonlyArray<string>;
144
+ }
145
+
146
+ /**
147
+ * A compiled file route: the URL pattern it registers at, its kind, and — for a
148
+ * page — the layout depths that wrap it (outermost first).
149
+ *
150
+ * `pattern` is an ordinary `@lesto/router` pattern (`/listings/:id`), so it feeds
151
+ * `.page()` / `RouteTable.add` and inherits the same compilation, matching, and
152
+ * typed-param inference as a hand-written route. `layoutDepth` is present only on a
153
+ * `page` and lists the segment depths whose `layout` file wraps it, shallowest
154
+ * first — the order layouts must nest in (a root layout outside a section layout
155
+ * outside the page). The applier reads it to build each page's layout chain.
156
+ */
157
+ export interface FileRoute {
158
+ kind: FileRouteKind;
159
+
160
+ pattern: string;
161
+
162
+ /** The directory segments (raw, uncompiled) this file lives at — for the applier to key a module by. */
163
+ segments: ReadonlyArray<string>;
164
+
165
+ /**
166
+ * The depths (0 = root) of the `layout` files that wrap this route, shallowest
167
+ * first — the order they must nest in (root outside section outside page).
168
+ *
169
+ * Always present, so the applier reads it with no fallback: a page with no
170
+ * layouts above it gets `[]`, and a non-`page` descriptor gets `[]` too (the
171
+ * applier never wraps a boundary in layouts — only a `page` is registered).
172
+ * Making it total rather than page-only keeps the consumer branch-free.
173
+ */
174
+ layoutDepth: ReadonlyArray<number>;
175
+
176
+ /**
177
+ * The depths (0 = root) of the `middleware` files guarding this route, shallowest
178
+ * first — the order they must run in (the root's runs OUTERMOST, before a section's,
179
+ * before the page's own, before the page's loader).
180
+ *
181
+ * Like {@link layoutDepth} (and unlike the single-nearest {@link boundaries}), a
182
+ * `middleware` composes the WHOLE chain above a page: every directory on the path
183
+ * from the root to the page that holds a `middleware` file contributes one, so an
184
+ * auth guard at `app/routes/admin/` protects every admin page below without being
185
+ * restated. The applier reads each depth to look up the guard module and runs them
186
+ * outermost-first in the page's handler chain, where a guard may short-circuit
187
+ * (redirect before load) or augment the loader's context. Present on a `page`
188
+ * descriptor; a non-`page` descriptor carries `[]` (it is never the thing guarded).
189
+ */
190
+ middlewareDepth: ReadonlyArray<number>;
191
+
192
+ /**
193
+ * The NEAREST boundary of each kind above this route, as the depth of the
194
+ * directory holding it — or absent when no such file sits at or above the page.
195
+ *
196
+ * Unlike `layoutDepth` (the WHOLE chain, because layouts nest), a `loading`,
197
+ * `error`, or `not-found` resolves to a SINGLE boundary — the closest one, with a
198
+ * deeper file overriding a shallower for that subtree — so each is one depth, not
199
+ * a list. The applier reads the depth to look up the boundary's module and wrap
200
+ * the page in it (a Suspense fallback for `loading`, an error boundary for
201
+ * `error`). Present only on a `page` descriptor; a boundary/layout descriptor
202
+ * carries an empty record (it is never the thing wrapped).
203
+ */
204
+ boundaries: BoundaryDepths;
205
+ }
206
+
207
+ /**
208
+ * The nearest boundary depth of each non-layout boundary kind above a page, each
209
+ * absent when no such file sits at or above it. Layouts are NOT here — they nest
210
+ * as a whole chain ({@link FileRoute.layoutDepth}); these three resolve to one
211
+ * nearest file each.
212
+ */
213
+ export interface BoundaryDepths {
214
+ loading?: number;
215
+ error?: number;
216
+ "not-found"?: number;
217
+ }
218
+
219
+ // A dynamic segment is a directory named `[name]`; it compiles to `:name`. The
220
+ // name must be a valid param identifier, the same `[A-Za-z_][A-Za-z0-9_]*` the
221
+ // runtime pattern compiler captures (see `compile.ts`'s `PARAM_SEGMENT`), so the
222
+ // derived pattern is one the router will actually accept.
223
+ const DYNAMIC_SEGMENT = /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/;
224
+
225
+ // A catch-all segment `[...name]` compiles to the greedy `*name` (one or more
226
+ // trailing segments, captured as a typed `string[]`); its optional twin
227
+ // `[[...name]]` to `*name?` (zero or more, so the parent path matches too). The
228
+ // inner name is the same param identifier a `[name]` takes.
229
+ const CATCH_ALL_SEGMENT = /^\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]$/;
230
+ const OPTIONAL_CATCH_ALL_SEGMENT = /^\[\[\.\.\.([A-Za-z_][A-Za-z0-9_]*)\]\]$/;
231
+
232
+ // A route group `(name)` is a PATHLESS directory: it organizes files (and can hold
233
+ // a shared `layout`) without contributing a URL segment — `(marketing)/about` is
234
+ // the route `/about`. The name only labels the group, so the grammar is lenient.
235
+ const GROUP_SEGMENT = /^\(([A-Za-z0-9_-]+)\)$/;
236
+
237
+ // A static segment is one or more path-safe characters with no bracket, paren,
238
+ // slash, or the param colon — a literal directory name. Refusing anything else here
239
+ // turns a stray `[`/`]` (a malformed dynamic segment like `[id` or `[1bad]`) into a
240
+ // coded error at compile time, not a silently-wrong route.
241
+ const STATIC_SEGMENT = /^[A-Za-z0-9_.-]+$/;
242
+
243
+ /** A `(group)` directory contributes no URL segment — it is stripped before compiling. */
244
+ const isGroupSegment = (segment: string): boolean => GROUP_SEGMENT.test(segment);
245
+
246
+ /** A `[...rest]` or `[[...rest]]` directory — a catch-all (the greedy, trailing kind). */
247
+ const isCatchAllSegment = (segment: string): boolean =>
248
+ CATCH_ALL_SEGMENT.test(segment) || OPTIONAL_CATCH_ALL_SEGMENT.test(segment);
249
+
250
+ /**
251
+ * The param NAME a raw directory segment binds, or `undefined` for a static or
252
+ * group segment. A `[id]`, `[...id]`, and `[[...id]]` all bind `id` — so the
253
+ * duplicate-name guard catches a name repeated across ANY of these forms, not just
254
+ * across two `[id]`s.
255
+ */
256
+ function paramNameOf(segment: string): string | undefined {
257
+ const optional = OPTIONAL_CATCH_ALL_SEGMENT.exec(segment);
258
+
259
+ if (optional !== null) return optional[1] as string;
260
+
261
+ const catchAll = CATCH_ALL_SEGMENT.exec(segment);
262
+
263
+ if (catchAll !== null) return catchAll[1] as string;
264
+
265
+ const dynamic = DYNAMIC_SEGMENT.exec(segment);
266
+
267
+ if (dynamic !== null) return dynamic[1] as string;
268
+
269
+ return undefined;
270
+ }
271
+
272
+ /**
273
+ * Compile one raw directory segment into its pattern piece: `[id]` → `:id`,
274
+ * `[...rest]` → `*rest`, `[[...rest]]` → `*rest?`, a literal name → itself, anything
275
+ * malformed → a coded refusal. (A `(group)` segment never reaches here — it is
276
+ * stripped by {@link patternFor} before compilation.)
277
+ *
278
+ * The dynamic and catch-all cases reuse the router's own param grammar so the
279
+ * derived pattern is exactly what a hand-written route would take, typed params and
280
+ * all. A segment that is none of those well-formed forms (a bare `[`, an empty `[]`,
281
+ * a `[1bad]` starting with a digit, an empty `()` group) is a convention mistake the
282
+ * author must fix — surfaced by a stable `ROUTER_FILE_BAD_SEGMENT`, not compiled
283
+ * into a route that can never match.
284
+ */
285
+ function compileSegment(segment: string): string {
286
+ // Optional catch-all is checked before the required form (its brackets are a
287
+ // superset), and both before the single `[name]`, so each lands on its own arm.
288
+ const optional = OPTIONAL_CATCH_ALL_SEGMENT.exec(segment);
289
+
290
+ if (optional !== null) return `*${optional[1] as string}?`;
291
+
292
+ const catchAll = CATCH_ALL_SEGMENT.exec(segment);
293
+
294
+ if (catchAll !== null) return `*${catchAll[1] as string}`;
295
+
296
+ const dynamic = DYNAMIC_SEGMENT.exec(segment);
297
+
298
+ if (dynamic !== null) {
299
+ // `dynamic[1]` is the bracketed name; the regex guarantees it is present and
300
+ // a valid identifier, so it becomes the `:param` verbatim.
301
+ return `:${dynamic[1] as string}`;
302
+ }
303
+
304
+ if (STATIC_SEGMENT.test(segment)) {
305
+ return segment;
306
+ }
307
+
308
+ throw new RouterError(
309
+ "ROUTER_FILE_BAD_SEGMENT",
310
+ `File-route segment "${segment}" is neither a literal name, a "[param]", a "[...catchAll]", nor a "(group)" — rename the directory to a valid segment.`,
311
+ { segment },
312
+ );
313
+ }
314
+
315
+ /**
316
+ * Turn a chain of raw directory segments into a URL pattern, dropping `(group)`
317
+ * directories (which contribute no URL).
318
+ *
319
+ * The empty chain — or a chain of only groups (`(marketing)/page.tsx`) — is the
320
+ * site root `"/"`. Otherwise each surviving segment is compiled (`[id]` → `:id`,
321
+ * `[...rest]` → `*rest`) and joined under a leading slash, so `["listings", "[id]"]`
322
+ * becomes `/listings/:id` — the exact pattern string the code-first `.page()`
323
+ * would have taken.
324
+ */
325
+ function patternFor(segments: ReadonlyArray<string>): string {
326
+ const urlSegments = segments.filter((segment) => !isGroupSegment(segment));
327
+
328
+ if (urlSegments.length === 0) return "/";
329
+
330
+ return `/${urlSegments.map(compileSegment).join("/")}`;
331
+ }
332
+
333
+ /**
334
+ * The key a route is grouped by, so two files at the SAME directory (a `page` and
335
+ * its sibling `layout`) and two files at DIFFERENT directories never collide. The
336
+ * raw segments joined by `/` — `["listings", "[id]"]` → `"listings/[id]"`, the
337
+ * empty root → `""` — uniquely names a directory.
338
+ *
339
+ * Exported so the two halves of the convention — the pure compiler here and the
340
+ * impure applier in `@lesto/web` — key directories the SAME way; a drift between
341
+ * them would mis-pair a page with its layout. Kept in ONE place, tested once.
342
+ */
343
+ export const dirKey = (segments: ReadonlyArray<string>): string => segments.join("/");
344
+
345
+ /**
346
+ * The MATCH-SHAPE key of a compiled pattern: every `:param` segment normalized to a
347
+ * single `STAR` sentinel, static segments kept literal — `/:id` and `/:slug` both
348
+ * become `/STAR`, while `/files/:id` becomes `/files/STAR` and `/:category/new`
349
+ * becomes `/STAR/new` (the sentinel written here as `STAR` for the param wildcard).
350
+ *
351
+ * Two patterns share a shape iff they match exactly the same SET of URLs (same
352
+ * arity, same static segments at the same positions, a dynamic slot wherever
353
+ * either has one). Deduping on this — not the literal pattern string — is what
354
+ * catches two dynamic siblings with DIFFERENT param names (`[id]` vs `[slug]`):
355
+ * their patterns differ as strings but answer the same single-segment URL, so one
356
+ * would permanently shadow the other. The sentinel is not a legal pattern
357
+ * character (params are `:name`, statics are `STATIC_SEGMENT`), so it can never
358
+ * collide with a literal segment an author wrote.
359
+ *
360
+ * A catch-all segment (`*rest` / `*rest?`) normalizes to a SECOND sentinel `**`, so
361
+ * a required and an optional catch-all at the same path (`[...a]` vs `[[...b]]`, both
362
+ * answering `/x/…`) reduce to the same shape and are refused as duplicates — while a
363
+ * catch-all and a single dynamic (`/x/*rest` vs `/x/:id`) keep distinct shapes
364
+ * (`/x/**` vs `/x/*`), so they coexist and resolve by specificity.
365
+ */
366
+ function matchShape(pattern: string): string {
367
+ return pattern
368
+ .replace(/\*[A-Za-z_][A-Za-z0-9_]*\??/g, "**")
369
+ .replace(/:[A-Za-z_][A-Za-z0-9_]*/g, "*");
370
+ }
371
+
372
+ /**
373
+ * Refuse a page whose `[name]` directories repeat a param name across segments.
374
+ *
375
+ * `[id]/[id]/page.tsx` compiles to `/:id/:id`; at match time the deeper `:id`
376
+ * overwrites the shallower one in the params record, a silent collision a typed
377
+ * convention must not allow (it already rejects two params in one segment via
378
+ * `ROUTER_AMBIGUOUS_SEGMENT`). We scan the page's own segments and throw a coded
379
+ * `ROUTER_FILE_DUPLICATE_PARAM` on the first repeat, naming the offending param.
380
+ */
381
+ function assertNoDuplicateParam(segments: ReadonlyArray<string>, pattern: string): void {
382
+ const seen = new Set<string>();
383
+
384
+ for (const segment of segments) {
385
+ const name = paramNameOf(segment);
386
+
387
+ if (name === undefined) continue;
388
+
389
+ if (seen.has(name)) {
390
+ throw new RouterError(
391
+ "ROUTER_FILE_DUPLICATE_PARAM",
392
+ `File-route "${pattern}" uses the param ":${name}" twice — the deeper segment would silently shadow the shallower; rename one directory.`,
393
+ { pattern, param: name },
394
+ );
395
+ }
396
+
397
+ seen.add(name);
398
+ }
399
+ }
400
+
401
+ /**
402
+ * Refuse a page whose catch-all segment is not the LAST URL segment.
403
+ *
404
+ * A catch-all (`[...rest]` / `[[...rest]]`) compiles to a greedy capture that
405
+ * swallows the whole tail, so a segment after it (`[...rest]/edit`) could never
406
+ * match — the pattern compiler refuses the resulting `/*rest/edit` too, but we
407
+ * catch it HERE, at convention time, so `generateRouteManifest` fails on the
408
+ * directory shape rather than emitting a manifest that throws when applied. The
409
+ * caller passes the GROUP-STRIPPED segments, since a `(group)` adds no URL segment.
410
+ */
411
+ function assertCatchAllTerminal(urlSegments: ReadonlyArray<string>, pattern: string): void {
412
+ for (let i = 0; i < urlSegments.length - 1; i += 1) {
413
+ const segment = urlSegments[i] as string;
414
+
415
+ if (isCatchAllSegment(segment)) {
416
+ throw new RouterError(
417
+ "ROUTER_FILE_CATCHALL_POSITION",
418
+ `File-route "${pattern}" puts a catch-all "${segment}" before the end — a catch-all matches the whole remaining path, so it must be the last segment.`,
419
+ { pattern, segment },
420
+ );
421
+ }
422
+ }
423
+ }
424
+
425
+ /**
426
+ * Compile a flat list of {@link DiscoveredFile}s into ordered {@link FileRoute}s
427
+ * ready for the applier to register, oldest-convention rules enforced here once.
428
+ *
429
+ * What this owns, so the impure scanner does not:
430
+ *
431
+ * - **Pattern derivation.** Each file's segments become a URL pattern, with the
432
+ * one `[id]` → `:id` rule (and the malformed-segment refusal) living here.
433
+ *
434
+ * - **Layout nesting.** A page's `layoutDepth` lists the depths of every
435
+ * `layout` at or above its directory, shallowest first — the order layouts
436
+ * must wrap in. A `layout` at depth N wraps a page at depth ≥ N whose path
437
+ * passes through that directory; because the directory tree is a prefix tree,
438
+ * "wraps" is exactly "the page's segments start with the layout's segments."
439
+ *
440
+ * - **Boundary resolution.** A page's `boundaries` names the NEAREST `loading`,
441
+ * `error`, and `not-found` at or above it (the deepest matching directory, so
442
+ * a deeper file overrides a shallower for that subtree — Next's per-segment
443
+ * override). Unlike layouts these resolve to a single nearest file each, not a
444
+ * nesting chain; the applier wraps the page in that one Suspense/error/404
445
+ * boundary.
446
+ *
447
+ * - **Collision refusal.** Two `page` files sharing a MATCH SHAPE — the pattern
448
+ * with every `:param` normalized to `*`, so `/:id` and `/:slug` both reduce to
449
+ * `/*` — answer the same set of URLs and are a convention ambiguity the author
450
+ * must resolve; we refuse it with a coded `ROUTER_FILE_DUPLICATE_ROUTE` rather
451
+ * than let insertion order silently pick a winner (a string-equal check would
452
+ * miss two dynamic siblings with different param names). A literal `about/` and
453
+ * a `[slug]/` are NOT duplicates — `/about` and `/*` are distinct shapes
454
+ * resolved by precedence, not refused here. A page that repeats a param across
455
+ * segments (`[id]/[id]`) is refused too, by `ROUTER_FILE_DUPLICATE_PARAM`.
456
+ *
457
+ * - **Resolution order.** Pages are returned MOST-SPECIFIC FIRST: a deeper /
458
+ * more-static path before a shallower / more-dynamic one, so a literal route
459
+ * shadows a dynamic sibling whose first differing segment sits at the same
460
+ * position (`/listings/new` before `/listings/:id`, `/files/new` before
461
+ * `/:category/new`) and the first-match-wins `RouteTable` resolves the way an
462
+ * author expects without hand-ordering files.
463
+ */
464
+ export function compileFileRoutes(files: ReadonlyArray<DiscoveredFile>): ReadonlyArray<FileRoute> {
465
+ // Guard against two pages at one URL: key each page by its MATCH SHAPE (every
466
+ // `:param` normalized to `*`) and refuse a duplicate by code rather than let the
467
+ // later one silently shadow the earlier. Deduping on the shape — not the literal
468
+ // pattern — catches two dynamic siblings with different param names (`[id]` vs
469
+ // `[slug]`), which match the same URLs yet differ as strings, while leaving
470
+ // genuinely distinct routes (`files/[id]` vs `[category]/new`) untouched.
471
+ const seenShape = new Set<string>();
472
+
473
+ // The directories of every boundary file, grouped by kind and computed once: a
474
+ // page's layout/middleware chains (and each nearest boundary) are the subset that
475
+ // prefixes its path, so the lookups are built ahead of the loop rather than rebuilt
476
+ // per page. `layout` and `middleware` are here too, so the one walk feeds both
477
+ // chains and the single-nearest boundaries.
478
+ const boundaryKeys = boundaryKeysByKind(files);
479
+ const layoutKeys = boundaryKeys.layout;
480
+ const middlewareKeys = boundaryKeys.middleware;
481
+
482
+ const pages: FileRoute[] = [];
483
+
484
+ // Every non-page descriptor (layout + the three boundaries), in one bucket: each
485
+ // registers no route, so the applier keys it by directory and looks it up from a
486
+ // page's `layoutDepth`/`boundaries`. Kept ahead of the pages in the returned list.
487
+ const boundaries: FileRoute[] = [];
488
+
489
+ for (const file of files) {
490
+ const pattern = patternFor(file.segments);
491
+
492
+ if (file.kind !== "page") {
493
+ // A boundary/layout/middleware descriptor carries empty
494
+ // `layoutDepth`/`middlewareDepth`/`boundaries`: it is never the thing wrapped or
495
+ // guarded (only a page is registered), so it has none of its own — but the fields
496
+ // are total, so the applier reads every descriptor branch-free.
497
+ boundaries.push({
498
+ kind: file.kind,
499
+ pattern,
500
+ segments: file.segments,
501
+ layoutDepth: [],
502
+ middlewareDepth: [],
503
+ boundaries: {},
504
+ });
505
+
506
+ continue;
507
+ }
508
+
509
+ // The URL-bearing segments (a `(group)` adds none) — what the catch-all-position
510
+ // and duplicate-param guards reason over, since neither concerns a pathless group.
511
+ const urlSegments = file.segments.filter((segment) => !isGroupSegment(segment));
512
+
513
+ // A catch-all greedily matches the tail, so it must be the final URL segment.
514
+ assertCatchAllTerminal(urlSegments, pattern);
515
+
516
+ const shape = matchShape(pattern);
517
+
518
+ if (seenShape.has(shape)) {
519
+ throw new RouterError(
520
+ "ROUTER_FILE_DUPLICATE_ROUTE",
521
+ `Two file-routes share the match-shape "${shape}" (this one is "${pattern}") — they answer the same URLs, so two pages cannot disambiguate; rename one directory.`,
522
+ { pattern, shape },
523
+ );
524
+ }
525
+
526
+ // A `[id]` in two different segments (`[id]/[id]/page.tsx`) compiles to
527
+ // `/:id/:id`, where the deeper capture silently clobbers the shallower at match
528
+ // time — and likewise a `[id]` and a `[...id]` sharing a name. A typed-param
529
+ // convention must not mint that collision — refuse by code, mirroring the
530
+ // single-segment ambiguity `compile` already rejects.
531
+ assertNoDuplicateParam(urlSegments, pattern);
532
+
533
+ seenShape.add(shape);
534
+
535
+ pages.push({
536
+ kind: "page",
537
+ pattern,
538
+ segments: file.segments,
539
+ layoutDepth: layoutDepthsFor(file.segments, layoutKeys),
540
+ // A `middleware` composes the WHOLE ancestor chain (every guard above the page,
541
+ // outermost first) exactly like a layout — so the same prefix walk, over the
542
+ // middleware directories, yields its shallowest-first depths.
543
+ middlewareDepth: layoutDepthsFor(file.segments, middlewareKeys),
544
+ boundaries: boundariesFor(file.segments, boundaryKeys),
545
+ });
546
+ }
547
+
548
+ // Most-specific first: a deeper path before a shallower one, and at equal depth
549
+ // the path whose first differing segment is static (not `:param`) before the
550
+ // dynamic one. The first-match-wins table then resolves a literal route ahead of
551
+ // a dynamic sibling without the author hand-ordering anything.
552
+ pages.sort(comparePageSpecificity);
553
+
554
+ // The non-page descriptors (layout + middleware + loading/error/not-found) trail
555
+ // the pages; the applier registers nothing for them directly — it keys them by
556
+ // directory and looks each up from a page's depths — but we keep them grouped and
557
+ // depth-ordered so a shallowest-first scan is available to any consumer.
558
+ boundaries.sort((a, b) => a.segments.length - b.segments.length);
559
+
560
+ return [...boundaries, ...pages];
561
+ }
562
+
563
+ /**
564
+ * The directories of every non-page file, grouped by kind — a set per
565
+ * {@link BOUNDARY_KINDS} of the `dirKey` of each `layout`/`middleware`/`loading`/
566
+ * `error`/`not-found` file. Built once so the per-page chain/nearest walks are set
567
+ * lookups, not a re-filter of the file list. Seeded from `BOUNDARY_KINDS` so adding
568
+ * a kind needs no change here — every recognized non-page kind gets its set.
569
+ */
570
+ function boundaryKeysByKind(
571
+ files: ReadonlyArray<DiscoveredFile>,
572
+ ): Record<BoundaryKind, Set<string>> {
573
+ const byKind = Object.fromEntries(
574
+ BOUNDARY_KINDS.map((kind) => [kind, new Set<string>()]),
575
+ ) as Record<BoundaryKind, Set<string>>;
576
+
577
+ for (const file of files) {
578
+ if (file.kind !== "page") byKind[file.kind].add(dirKey(file.segments));
579
+ }
580
+
581
+ return byKind;
582
+ }
583
+
584
+ /**
585
+ * The nearest boundary of each non-layout kind above a page — its directory depth,
586
+ * or absent when no such file sits at or above the page.
587
+ *
588
+ * "Nearest" is the DEEPEST directory on the path from the root to the page that
589
+ * holds the kind, so a deeper `loading`/`error`/`not-found` overrides a shallower
590
+ * for that subtree (Next's per-segment override). We reuse {@link layoutDepthsFor}
591
+ * (which records every matching depth shallowest-first) and take its last entry —
592
+ * the deepest — per kind. Layouts are excluded: they nest as the whole chain, not
593
+ * a single nearest file.
594
+ */
595
+ function boundariesFor(
596
+ pageSegments: ReadonlyArray<string>,
597
+ keysByKind: Record<BoundaryKind, Set<string>>,
598
+ ): BoundaryDepths {
599
+ const result: BoundaryDepths = {};
600
+
601
+ for (const kind of NEAREST_BOUNDARY_KINDS) {
602
+ const depths = layoutDepthsFor(pageSegments, keysByKind[kind]);
603
+
604
+ // The nearest is the deepest matching directory — the last entry, since
605
+ // `layoutDepthsFor` records them shallowest-first. Absent when none matched.
606
+ const nearest = depths.at(-1);
607
+
608
+ if (nearest !== undefined) result[kind] = nearest;
609
+ }
610
+
611
+ return result;
612
+ }
613
+
614
+ /**
615
+ * The depths of every `layout` at or above a page's directory, shallowest first.
616
+ *
617
+ * A layout wraps a page when the page's directory is the layout's directory or a
618
+ * descendant of it — which, on a prefix tree of segments, is exactly "the page's
619
+ * segments start with the layout's segments." We walk the page's own segment
620
+ * prefixes (root, then one segment, then two, …) and record the depth of any that
621
+ * has a `layout` file, so the result is naturally shallowest-first — the order the
622
+ * layouts must nest in (root outside section outside page).
623
+ */
624
+ function layoutDepthsFor(
625
+ pageSegments: ReadonlyArray<string>,
626
+ layoutKeys: ReadonlySet<string>,
627
+ ): ReadonlyArray<number> {
628
+ const depths: number[] = [];
629
+
630
+ // Each prefix length 0..pageSegments.length names a directory on the path from
631
+ // the root to the page; a layout there wraps the page.
632
+ for (let depth = 0; depth <= pageSegments.length; depth += 1) {
633
+ const key = dirKey(pageSegments.slice(0, depth));
634
+
635
+ if (layoutKeys.has(key)) {
636
+ depths.push(depth);
637
+ }
638
+ }
639
+
640
+ return depths;
641
+ }
642
+
643
+ /** The URL segments of a compiled pattern — `[]` for the root, else the `/`-split. */
644
+ const urlPatternSegments = (pattern: string): ReadonlyArray<string> =>
645
+ pattern === "/" ? [] : pattern.slice(1).split("/");
646
+
647
+ /** A pattern is a catch-all iff it carries a `*rest` token (single `:` params have none). */
648
+ const isCatchAllPattern = (pattern: string): boolean => pattern.includes("*");
649
+
650
+ /**
651
+ * A pattern segment's specificity rank, LOWER = more specific: a literal (`0`)
652
+ * before a single dynamic `:param` (`1`) before a catch-all `*rest` (`2`).
653
+ */
654
+ function segmentRank(segment: string): number {
655
+ if (segment.startsWith("*")) return 2;
656
+ if (segment.startsWith(":")) return 1;
657
+
658
+ return 0;
659
+ }
660
+
661
+ /**
662
+ * Order two pages most-specific first — over the compiled URL pattern, so a
663
+ * `(group)` (which adds no URL segment) never skews depth.
664
+ *
665
+ * A catch-all route is the BROADEST kind, so it sinks below every non-catch-all
666
+ * route regardless of depth: an explicit page — even the catch-all's own parent
667
+ * (`/shop` under a `/shop/[[...slug]]`) — always wins, and the catch-all answers
668
+ * only what nothing else claimed. Among same-kind routes, deeper paths win (more
669
+ * segments = more specific); at equal depth the comparison is POSITION-AWARE: at
670
+ * the first slot where the rank differs, the more specific (literal before `:param`
671
+ * before `*rest`) sorts earlier — so `/files/new` precedes `/:category/new`, and
672
+ * `/listings/new` precedes `/listings/:id`, the literal route shadowing its dynamic
673
+ * sibling AT THAT POSITION under first-match resolution. An otherwise-identical pair
674
+ * falls back to the pattern string so the sort is stable across reader orderings.
675
+ *
676
+ * The two pages compared here always have DISTINCT patterns — two pages that share
677
+ * a match-shape are refused upstream with `ROUTER_FILE_DUPLICATE_ROUTE` before the
678
+ * sort runs — so the final string comparison need only choose a side, never report
679
+ * "equal"; `< ? -1 : 1` is total over the distinct-pattern inputs the sort sees.
680
+ */
681
+ function comparePageSpecificity(a: FileRoute, b: FileRoute): number {
682
+ const aCatchAll = isCatchAllPattern(a.pattern);
683
+ const bCatchAll = isCatchAllPattern(b.pattern);
684
+
685
+ if (aCatchAll !== bCatchAll) return aCatchAll ? 1 : -1;
686
+
687
+ const aSegments = urlPatternSegments(a.pattern);
688
+ const bSegments = urlPatternSegments(b.pattern);
689
+
690
+ if (aSegments.length !== bSegments.length) {
691
+ return bSegments.length - aSegments.length;
692
+ }
693
+
694
+ // Equal depth and kind: at the first slot where specificity differs, the more
695
+ // specific (lower-rank) segment sorts earlier.
696
+ for (let i = 0; i < aSegments.length; i += 1) {
697
+ const rank = segmentRank(aSegments[i] as string) - segmentRank(bSegments[i] as string);
698
+
699
+ if (rank !== 0) return rank;
700
+ }
701
+
702
+ return a.pattern < b.pattern ? -1 : 1;
703
+ }