@uniflowed/vite 0.0.0-alpha.8 → 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.
@@ -5,7 +5,7 @@
5
5
  // The file-system router, as the build sees it.
6
6
  //
7
7
  // This mirrors `uf_router` in Rust — the same reserved-name grammar
8
- // (`_uf.<role>[.<variant>].js`, plus `.mdx` for pages), the same route path
8
+ // (`$<role>[.<variant>].js`, plus `.mdx` for pages), the same route path
9
9
  // syntax (`[param]`, `[...rest]`, `(group)`) and the same sort order — and it
10
10
  // must keep mirroring it: `uf lint` and `router.js`'s generated types describe
11
11
  // the routes this module serves, so the two cannot be allowed to disagree.
@@ -14,24 +14,153 @@
14
14
  // route table imports every page and layout lazily, so a route is a chunk of
15
15
  // its own and the client only downloads what it navigates to.
16
16
 
17
+ import { clientInstrumentationSource } from "./instrumentation.js";
18
+
17
19
  import { readdirSync, statSync } from "node:fs";
18
20
  import path from "node:path";
19
21
 
20
22
  /** The file names the router reserves inside the router root. */
21
23
  export const RESERVED = Object.freeze({
22
- layout: "_uf.layout",
23
- page: "_uf.page",
24
- middleware: "_uf.middleware",
25
- notFound: "_uf.not-found",
26
- error: "_uf.error",
27
- loading: "_uf.loading",
28
- route: "_uf.route",
24
+ layout: "$layout",
25
+ template: "$template",
26
+ page: "$page",
27
+ default: "$default",
28
+ middleware: "$middleware",
29
+ notFound: "$not-found",
30
+ error: "$error",
31
+ loading: "$loading",
32
+ route: "$route",
33
+ instrumentation: "$instrumentation",
34
+ });
35
+
36
+ /**
37
+ * Directory names uf reserves inside the router root without serving them.
38
+ *
39
+ * One spelling each, and they are here so `crates/uf_router/tests/
40
+ * reserved_names.rs` can hold this router and `uf_router::RouteSegment` to the
41
+ * same list — the way it already holds the two to the same `$*` roles. A
42
+ * spelling one router refuses and the other serves as a URL is exactly the
43
+ * disagreement that made this necessary.
44
+ *
45
+ * Until #267 neither `@team` nor `(.)photo` meant anything to this scan, so
46
+ * both fell through to "a literal URL segment": `@team` became `/@team`,
47
+ * `(.)photo` became `/(.)photo` — the test for a `(group)` is that the segment
48
+ * *ends* in `)` — and the generated `RoutePath` union contained them. A
49
+ * convention served as nonsense is worse than one that is refused, because the
50
+ * project looks like it works.
51
+ *
52
+ * Both are routes this router serves now: a slot everywhere, and an
53
+ * interception inside a slot; see {@link INTERCEPTION_SEGMENTS}. What is left
54
+ * here is what is spelled like an interception and cannot be one — a marker
55
+ * that climbs nowhere, or a marker with no URL segment after it.
56
+ */
57
+ export const UNSUPPORTED_SEGMENTS = Object.freeze([
58
+ "(.)(.)photo",
59
+ "(.)(..)photo",
60
+ "(...)(..)photo",
61
+ "(....)photo",
62
+ "(.)(gallery)",
63
+ "(.)@photo",
64
+ ]);
65
+
66
+ /**
67
+ * Directory names this router serves inside a `@slot` and refuses outside one:
68
+ * intercepting routes.
69
+ *
70
+ * The same list as `uf_router::RouteSegment::SLOT_ONLY_EXAMPLES`, held to it by
71
+ * `crates/uf_router/tests/reserved_names.rs`. A second list rather than more
72
+ * entries on {@link UNSUPPORTED_SEGMENTS}, because the refusal is a different
73
+ * sentence: these are spelled correctly and are in the wrong place, and telling
74
+ * somebody to rename a correct directory is the worse of the two mistakes.
75
+ */
76
+ export const INTERCEPTION_SEGMENTS = Object.freeze([
77
+ "(.)photo",
78
+ "(..)photo",
79
+ "(...)photo",
80
+ "(..)(..)photo",
81
+ "(..)(..)(..)photo",
82
+ "(.)[id]",
83
+ ]);
84
+
85
+ /**
86
+ * The prop names a layout already receives, which a slot may therefore not
87
+ * take.
88
+ *
89
+ * A slot arrives as a prop named after its directory, so `@children` and
90
+ * `@params` are the two names that would land on top of something the layout
91
+ * already has. `@children` is the one somebody actually writes: `children` is
92
+ * what Next.js calls its implicit slot, so it is the first name a person
93
+ * migrating reaches for — and here the page the URL matched always is
94
+ * `children`.
95
+ *
96
+ * The same list as `uf_router::LAYOUT_PROP_NAMES`; see that file for why the
97
+ * collision is refused rather than resolved by precedence.
98
+ */
99
+ export const LAYOUT_PROP_NAMES = Object.freeze(["children", "params"]);
100
+
101
+ /** Template spellings that look conventional elsewhere but uf will not open. */
102
+ const UNSUPPORTED_TEMPLATE_FILES = Object.freeze(["template.js", "_uf.template.js"]);
103
+
104
+ /** Boundary spellings a slot might look for, but uf does not open. */
105
+ const UNSUPPORTED_SLOT_BOUNDARY_FILES = Object.freeze({
106
+ "error.js": "error",
107
+ "loading.js": "loading",
108
+ "not-found.js": "not-found",
109
+ "_uf.error.js": "error",
110
+ "_uf.loading.js": "loading",
111
+ "_uf.not-found.js": "not-found",
29
112
  });
30
113
 
31
114
  /** Extensions a page or layout may use; `.mdx` is a page written as content. */
32
115
  const PAGE_EXTENSIONS = [".js", ".jsx", ".mdx"];
33
116
  const MODULE_EXTENSIONS = [".js", ".jsx"];
34
117
 
118
+ /** Application targets the route scanner knows how to select files for. */
119
+ export const ROUTE_TARGETS = Object.freeze(["web", "native", "ios", "android"]);
120
+
121
+ const TARGET_VARIANTS = Object.freeze({
122
+ web: ["web", null],
123
+ native: ["native", null],
124
+ ios: ["ios", "native", null],
125
+ android: ["android", "native", null],
126
+ });
127
+
128
+ /**
129
+ * The route target a loaded `uf.config.js` and an optional CLI flag describe.
130
+ *
131
+ * `react-native` is accepted as the config-shaped spelling of the same target
132
+ * `uf build --target native` selects. The default follows the framework
133
+ * preset rather than the target list: uf's default list names both web and
134
+ * React Native, so the list is a promise the project should keep satisfying,
135
+ * not the one build to run when none was requested.
136
+ */
137
+ export function resolveRouteTarget(config = {}, requested = null) {
138
+ const app = config.app ?? {};
139
+ const named =
140
+ requested == null || requested === ""
141
+ ? app.framework === "react-native"
142
+ ? "native"
143
+ : "web"
144
+ : requested === "react-native"
145
+ ? "native"
146
+ : requested;
147
+ if (!ROUTE_TARGETS.includes(named)) {
148
+ throw new Error(
149
+ `uf: ${JSON.stringify(named)} is not an application target; choose web, native, ios or android`,
150
+ );
151
+ }
152
+ const declared = app.targets;
153
+ if (Array.isArray(declared)) {
154
+ const needs = named === "web" ? "web" : "react-native";
155
+ if (!declared.includes(needs)) {
156
+ throw new Error(
157
+ `uf: --target ${named} needs app.targets to include ${JSON.stringify(needs)}`,
158
+ );
159
+ }
160
+ }
161
+ return named;
162
+ }
163
+
35
164
  /** Deepest directory nesting the scan will follow. */
36
165
  const MAX_DEPTH = 32;
37
166
 
@@ -47,6 +176,80 @@ const MAX_DEPTH = 32;
47
176
  * @property {ReadonlyArray<{above: number, module: string}>} loading the
48
177
  * `<Suspense>` boundaries in scope, root first; `above` is how many of
49
178
  * `layouts` are outside each one
179
+ * @property {ReadonlyArray<{above: number, module: string}>} templates the
180
+ * `$template.js` wrappers in scope, root first, with the same `above`
181
+ * @property {ReadonlyArray<Slot>} slots the parallel-route slots in scope,
182
+ * outermost first
183
+ * @property {boolean} mdx whether the page is MDX content
184
+ */
185
+
186
+ /**
187
+ * One parallel-route slot — a second thing a layout renders, beside its page.
188
+ *
189
+ * A directory named `@team` contributes no URL segment. It declares a slot on
190
+ * the segment that holds it, and that segment's own layout receives the
191
+ * rendered slot as a `team` prop beside `children`. The slot's pages are
192
+ * matched against the same URL the page is, so `app/dashboard/@team/members/
193
+ * $page.js` is what `/dashboard/members` puts in the slot — not a second
194
+ * page at that path.
195
+ *
196
+ * `above` is how many of the route's `layouts` are outside the slot, counted
197
+ * after the declaring segment's own layout is added — so `layouts[above - 1]`
198
+ * is the layout that receives it. It is the same number, spelled the same way,
199
+ * as a template's and a loading boundary's. The layout has to be the segment's
200
+ * *own*: a slot rendered into an inherited layout would be a prop that layout
201
+ * never declared, on every route below it, so {@link scanRoutes} refuses a slot
202
+ * whose segment has no layout of its own.
203
+ *
204
+ * `defaultPage` is the slot's `$default.js`: what it renders when the URL
205
+ * matches none of its routes. A slot with neither a match nor a default
206
+ * renders nothing, which is what an unaddressed slot on a soft navigation does
207
+ * in Next.js too.
208
+ *
209
+ * `intercepts` is what the slot renders for a client navigation that starts on
210
+ * a page it is on and reaches the URL each entry names — the pages under an
211
+ * interception directory such as `@modal/(.)photo/[id]/`, each at the URL it
212
+ * stands in for. A list of its own, because nothing that matches `routes`
213
+ * may reach one: the server renders the ordinary page for that URL, always.
214
+ *
215
+ * @typedef {object} Slot
216
+ * @property {string} name the slot's name, without the `@`
217
+ * @property {number} above how many of the route's layouts are outside it
218
+ * @property {?string} defaultPage absolute path of `$default.*`, or `null`
219
+ * @property {boolean} defaultMdx whether that default is MDX content
220
+ * @property {?{above: number, module: string}} defaultErrorBoundary the
221
+ * `$error.js` boundary that catches the default page in the browser
222
+ * @property {ReadonlyArray<SlotRoute>} routes what the slot may render, by URL
223
+ * @property {ReadonlyArray<SlotRoute>} intercepts what the slot renders when a
224
+ * client navigation is intercepted, by the URL it stands in for
225
+ */
226
+
227
+ /**
228
+ * One page inside a slot.
229
+ *
230
+ * A `Route` without the request-level parts a slot does not have: no handler
231
+ * and no not-found boundary of its own. Loading boundaries, templates and
232
+ * browser render error boundaries are the pieces that compose like layouts, so
233
+ * they are carried below. Per-slot not-found boundaries are the part of
234
+ * parallel routes uf has not built — see
235
+ * https://github.com/ubugeeei-prod/uf/issues/267 — and {@link scanRoutes}
236
+ * refuses those files rather than leaving them unopened.
237
+ *
238
+ * `layouts` are the layouts *inside* the slot, root first; the ones above it
239
+ * are already rendering, since the slot renders into one of them.
240
+ *
241
+ * @typedef {object} SlotRoute
242
+ * @property {string} path route path such as `/dashboard/members`
243
+ * @property {ReadonlyArray<{name: string, catchAll: boolean}>} params
244
+ * @property {string} page absolute path of the page module
245
+ * @property {ReadonlyArray<string>} layouts absolute paths, slot root first
246
+ * @property {ReadonlyArray<{above: number, module: string}>} loading the
247
+ * `$loading.js` fallbacks inside the slot, root first
248
+ * @property {ReadonlyArray<{above: number, module: string}>} templates the
249
+ * `$template.js` wrappers inside the slot, root first
250
+ * @property {?{above: number, module: string}} errorBoundary the `$error.js`
251
+ * boundary inside the slot, if one is in scope
252
+ * @property {ReadonlyArray<Slot>} slots slots declared inside this slot
50
253
  * @property {boolean} mdx whether the page is MDX content
51
254
  */
52
255
 
@@ -79,14 +282,16 @@ const MAX_DEPTH = 32;
79
282
  * One not-found boundary — the page a path under `path` gets when nothing
80
283
  * there matched.
81
284
  *
82
- * A `_uf.not-found.js` is a segment file like `_uf.layout.js`, so a directory
285
+ * A `$not-found.js` is a segment file like `$layout.js`, so a directory
83
286
  * declares the 404 for everything beneath it and the resolver takes the
84
287
  * nearest one above the path. `layouts` are the layouts in scope *at that
85
288
  * directory*, which is what wraps the boundary when it renders.
86
289
  *
87
290
  * @typedef {object} NotFoundBoundary
88
291
  * @property {string} path route path of the directory that declares it
89
- * @property {string} page absolute path of the page module
292
+ * @property {?string} page absolute path of the page module, or `null` for the
293
+ * record the scan synthesises at the router root when a project declares
294
+ * none — see `scanRoutes`
90
295
  * @property {ReadonlyArray<string>} layouts absolute paths, root first
91
296
  * @property {boolean} mdx whether the page is MDX content
92
297
  */
@@ -98,12 +303,13 @@ const MAX_DEPTH = 32;
98
303
  * The same nearest-ancestor shape as a not-found boundary, and deliberately
99
304
  * not the same extensions: an error module is handed an error and a `reset`,
100
305
  * which is a component's contract. `.mdx` compiles to a component that takes
101
- * no such thing, so a `_uf.error.mdx` would be a file the router loads and can
306
+ * no such thing, so a `$error.mdx` would be a file the router loads and can
102
307
  * never hand its arguments to.
103
308
  *
104
309
  * @typedef {object} ErrorBoundary
105
310
  * @property {string} path route path of the directory that declares it
106
- * @property {string} module absolute path of the error module
311
+ * @property {?string} module absolute path of the error module, or `null` for
312
+ * the synthesised root record
107
313
  * @property {ReadonlyArray<string>} layouts absolute paths, root first
108
314
  */
109
315
 
@@ -113,8 +319,8 @@ const MAX_DEPTH = 32;
113
319
  * Not the nearest-ancestor shape the other two boundaries have, and the
114
320
  * difference is the whole of what a fallback is. A not-found or an error
115
321
  * boundary is *chosen*: one of them renders, and the resolver picks the
116
- * nearest above the path. Loading boundaries *nest*: `app/_uf.loading.js` and
117
- * `app/docs/_uf.loading.js` are two `<Suspense>` elements on one route, one
322
+ * nearest above the path. Loading boundaries *nest*: `app/$loading.js` and
323
+ * `app/docs/$loading.js` are two `<Suspense>` elements on one route, one
118
324
  * inside the other, and both are in the tree at once. So they accumulate down
119
325
  * the walk the way layouts do rather than being matched afterwards, and each
120
326
  * route carries the list that applies to it.
@@ -136,7 +342,20 @@ const MAX_DEPTH = 32;
136
342
  * Directories that do not exist yield an empty table rather than an error: a
137
343
  * library project has no router root, and that is not a mistake.
138
344
  *
345
+ * A `@slot` directory is a parallel route and is scanned; see {@link Slot}. It
346
+ * throws for the ways one can be written without being renderable: a slot on a
347
+ * segment with no layout of its own, a `$default.js` that is not directly
348
+ * inside a slot, a not-found boundary or handler inside a slot, and
349
+ * boundary-like files with names uf does not open. Each is a file the router
350
+ * would otherwise never open, which is the failure #267 is about.
351
+ *
352
+ * An interception directory — `(.)photo` — is scanned inside a slot, into that
353
+ * slot's `intercepts`, and throws everywhere it cannot be one: outside a slot,
354
+ * spelled so nothing reads it ({@link UNSUPPORTED_SEGMENTS}), climbing past the
355
+ * router root, or standing in for a URL no page serves.
356
+ *
139
357
  * @param {string} appRoot absolute path of the router root (`app/`)
358
+ * @param {{target?: "web" | "native" | "ios" | "android"}} [options]
140
359
  * @returns {{
141
360
  * routes: Route[],
142
361
  * handlers: Handler[],
@@ -145,7 +364,8 @@ const MAX_DEPTH = 32;
145
364
  * errors: ErrorBoundary[],
146
365
  * }}
147
366
  */
148
- export function scanRoutes(appRoot) {
367
+ export function scanRoutes(appRoot, options = {}) {
368
+ const target = resolveRouteTarget({}, options.target ?? "web");
149
369
  const routes = [];
150
370
  const handlers = [];
151
371
  const middleware = [];
@@ -153,14 +373,45 @@ export function scanRoutes(appRoot) {
153
373
  const errors = [];
154
374
  if (!isDirectory(appRoot)) return { routes, handlers, middleware, notFound, errors };
155
375
 
156
- const walk = (directory, segments, layouts, loading, depth) => {
376
+ // The layouts in scope at the router root, kept because the two synthesised
377
+ // records below are made of them. See the note beside them.
378
+ let rootLayouts = [];
379
+
380
+ const walk = (directory, segments, layouts, loading, templates, slots, depth) => {
157
381
  if (depth > MAX_DEPTH) return;
158
382
  const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
159
383
  a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
160
384
  );
161
385
 
162
- const ownLayout = findModule(directory, RESERVED.layout, MODULE_EXTENSIONS);
386
+ refuseUnsupportedTemplateFiles(directory, entries);
387
+ for (const entry of entries) {
388
+ if (entry.name.startsWith("$instrumentation") && !/\.test\.jsx?$/.test(entry.name)) {
389
+ if (depth !== 0 || !/^\$instrumentation(?:\.client)?\.jsx?$/.test(entry.name)) {
390
+ throw new Error(
391
+ `uf: ${path.join(directory, entry.name)} must be $instrumentation.js or $instrumentation.client.js at the router root`,
392
+ );
393
+ }
394
+ }
395
+ }
396
+
397
+ // A `$default.js` answers one question — what a slot renders when the
398
+ // URL says nothing about it — and this walk is everywhere a slot is not,
399
+ // so one found here is a file nothing would ever open.
400
+ const strayDefault = findModule(directory, RESERVED.default, PAGE_EXTENSIONS, target);
401
+ if (strayDefault != null) {
402
+ throw new Error(
403
+ `${strayDefault}: \`$default.js\` is what a \`@slot\` renders when the URL says ` +
404
+ "nothing about it, and it belongs directly inside the slot directory — one per slot, " +
405
+ "beside that slot's own pages. Nothing would ever render this one. uf has no " +
406
+ "`default` for `children`: a URL that matches no page is a 404.",
407
+ );
408
+ }
409
+
410
+ const ownLayout = findModule(directory, RESERVED.layout, MODULE_EXTENSIONS, target);
163
411
  const nextLayouts = ownLayout ? [...layouts, ownLayout] : layouts;
412
+ if (depth === 0) {
413
+ rootLayouts = nextLayouts;
414
+ }
164
415
 
165
416
  // Inside this directory's own layout, which is where Next.js puts it and
166
417
  // the only placement that makes sense: the fallback is what shows *within*
@@ -168,20 +419,55 @@ export function scanRoutes(appRoot) {
168
419
  // `nextLayouts.length` is therefore the count taken after the own layout is
169
420
  // added, not before. A segment with a loading file and no layout of its own
170
421
  // still gets a boundary — it just shares its parent's frame.
171
- const ownLoading = findModule(directory, RESERVED.loading, MODULE_EXTENSIONS);
422
+ const ownLoading = findModule(directory, RESERVED.loading, MODULE_EXTENSIONS, target);
172
423
  const nextLoading = ownLoading
173
424
  ? [...loading, { above: nextLayouts.length, module: ownLoading }]
174
425
  : loading;
175
426
 
427
+ // A template accumulates the way a layout does, and is placed the way a
428
+ // loading file is: inside its own segment's layout and outside everything
429
+ // below, so `nextLayouts.length` is taken after the own layout is added.
430
+ // Every template above a route is on that route, one inside the next, for
431
+ // the reason every layout is — the difference between the two is a `key`,
432
+ // not a shape.
433
+ const ownTemplate = findModule(directory, RESERVED.template, MODULE_EXTENSIONS, target);
434
+ const nextTemplates = ownTemplate
435
+ ? [...templates, { above: nextLayouts.length, module: ownTemplate }]
436
+ : templates;
437
+
438
+ // Slots before this directory's own page, because the page renders inside
439
+ // the layout that holds them: a slot declared here belongs to every route
440
+ // at or below this segment, the way a template does.
441
+ let nextSlots = slots;
442
+ for (const entry of entries) {
443
+ if (!entry.isDirectory()) continue;
444
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
445
+ const classified = classifyRouteSegment(entry.name);
446
+ if (classified.kind !== "slot") continue;
447
+ nextSlots = [
448
+ ...nextSlots,
449
+ scanSlot(
450
+ directory,
451
+ entry.name,
452
+ classified.name,
453
+ segments,
454
+ ownLayout,
455
+ nextLayouts.length,
456
+ target,
457
+ depth,
458
+ ),
459
+ ];
460
+ }
461
+
176
462
  // A middleware guards this directory and everything below it, whether or
177
- // not this directory is itself a route: `app/dashboard/_uf.middleware.js`
178
- // with no `_uf.page.js` beside it still guards `/dashboard/settings`.
179
- const ownMiddleware = findModule(directory, RESERVED.middleware, MODULE_EXTENSIONS);
463
+ // not this directory is itself a route: `app/dashboard/$middleware.js`
464
+ // with no `$page.js` beside it still guards `/dashboard/settings`.
465
+ const ownMiddleware = findModule(directory, RESERVED.middleware, MODULE_EXTENSIONS, target);
180
466
  if (ownMiddleware) {
181
467
  middleware.push({ path: routeFromSegments(segments).path, module: ownMiddleware });
182
468
  }
183
469
 
184
- const page = findModule(directory, RESERVED.page, PAGE_EXTENSIONS);
470
+ const page = findModule(directory, RESERVED.page, PAGE_EXTENSIONS, target);
185
471
  if (page) {
186
472
  const { path: routePath, pattern, params } = routeFromSegments(segments);
187
473
  routes.push({
@@ -191,23 +477,25 @@ export function scanRoutes(appRoot) {
191
477
  page,
192
478
  layouts: nextLayouts,
193
479
  loading: nextLoading,
480
+ templates: nextTemplates,
481
+ slots: nextSlots,
194
482
  mdx: page.endsWith(".mdx"),
195
483
  });
196
484
  }
197
485
  // A handler answers the request itself, so it takes no layouts and is not
198
486
  // MDX. It may sit beside a page: `/feed` can render for a browser and
199
487
  // `/feed.xml` answer for a reader, and both are the same directory tree.
200
- const handler = findModule(directory, RESERVED.route, MODULE_EXTENSIONS);
488
+ const handler = findModule(directory, RESERVED.route, MODULE_EXTENSIONS, target);
201
489
  if (handler) {
202
490
  const { path: routePath, pattern, params } = routeFromSegments(segments);
203
491
  handlers.push({ path: routePath, pattern, params, module: handler });
204
492
  }
205
493
 
206
494
  // At every depth, not only the root. This read `if (depth === 0)`, so
207
- // `app/guide/_uf.not-found.js` was never looked for and a reader who
495
+ // `app/guide/$not-found.js` was never looked for and a reader who
208
496
  // followed a stale link into the manual was answered by the site's root
209
497
  // 404, outside the manual's own layout. See ubugeeei-prod/uf#263.
210
- const ownNotFound = findModule(directory, RESERVED.notFound, PAGE_EXTENSIONS);
498
+ const ownNotFound = findModule(directory, RESERVED.notFound, PAGE_EXTENSIONS, target);
211
499
  if (ownNotFound) {
212
500
  notFound.push({
213
501
  path: routeFromSegments(segments).path,
@@ -218,8 +506,8 @@ export function scanRoutes(appRoot) {
218
506
  }
219
507
 
220
508
  // `errors` is the boundaries a project declares, not failures that
221
- // happened: one entry per directory holding an `_uf.error.js`.
222
- const ownError = findModule(directory, RESERVED.error, MODULE_EXTENSIONS);
509
+ // happened: one entry per directory holding an `$error.js`.
510
+ const ownError = findModule(directory, RESERVED.error, MODULE_EXTENSIONS, target);
223
511
  if (ownError) {
224
512
  errors.push({
225
513
  path: routeFromSegments(segments).path,
@@ -233,17 +521,54 @@ export function scanRoutes(appRoot) {
233
521
  // A leading dot or underscore is private to the author: `_components/`
234
522
  // beside a page is a place to put things, not a route.
235
523
  if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
524
+ // Already walked, above, into a table of its own.
525
+ if (classifyRouteSegment(entry.name).kind === "slot") continue;
526
+ // Checked before descending, and after the private-directory test for
527
+ // the same reason `uf_router` prunes them: `app/_drafts/(.)photo/` is not
528
+ // a route uf would have served, so it is not one to refuse. This walk is
529
+ // everywhere a slot is not, so a correctly spelled interception is
530
+ // refused here too — for where it is rather than how it is written.
531
+ const refused = unsupportedSegmentReason(entry.name) ?? outsideSlotReason(entry.name);
532
+ if (refused != null) {
533
+ throw new Error(`${path.join(directory, entry.name)}: ${refused}`);
534
+ }
236
535
  walk(
237
536
  path.join(directory, entry.name),
238
537
  [...segments, entry.name],
239
538
  nextLayouts,
240
539
  nextLoading,
540
+ nextTemplates,
541
+ nextSlots,
241
542
  depth + 1,
242
543
  );
243
544
  }
244
545
  };
245
546
 
246
- walk(appRoot, [], [], [], 0);
547
+ walk(appRoot, [], [], [], [], [], 0);
548
+
549
+ // A boundary at the router root for a project that declared none, carrying
550
+ // the root's layouts and no module of its own.
551
+ //
552
+ // Without it the router had no record to answer an unmatched URL with, so it
553
+ // answered with the framework's page and `layouts: []` — and a site whose
554
+ // root layout owns the masthead, the stylesheet and often `<html>` itself
555
+ // replied to a stale link with a white page saying 404, with no way to leave
556
+ // it. That was never the nearest-ancestor rule failing: the rule had nothing
557
+ // to find. `uf create` scaffolds neither boundary, so this is the state every
558
+ // new project is in until it writes one. See ubugeeei-prod/uf#351.
559
+ //
560
+ // Only when nothing is at `/` already. A `(group)` directory is not a URL
561
+ // segment, so `app/(marketing)/$not-found.js` is a boundary at `/` too and
562
+ // adding a second one there would put a second answer at a path the URL
563
+ // cannot choose between.
564
+ const atRoot = (boundaries) => boundaries.some((boundary) => boundary.path === "/");
565
+ if (!atRoot(notFound)) {
566
+ notFound.push({ path: "/", page: null, layouts: rootLayouts, mdx: false });
567
+ }
568
+ if (!atRoot(errors)) {
569
+ errors.push({ path: "/", module: null, layouts: rootLayouts });
570
+ }
571
+
247
572
  const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
248
573
  routes.sort(byPath);
249
574
  handlers.sort(byPath);
@@ -256,7 +581,7 @@ export function scanRoutes(appRoot) {
256
581
  // by nearness would hide that.
257
582
  //
258
583
  // Two boundaries can share a path, because a `(group)` directory is not a URL
259
- // segment — `app/_uf.not-found.js` and `app/(marketing)/_uf.not-found.js` are
584
+ // segment — `app/$not-found.js` and `app/(marketing)/$not-found.js` are
260
585
  // both at `/`, and the URL cannot say which tree it is in. The sort is stable
261
586
  // and `walk` records a directory's own boundary before descending, so the
262
587
  // shallower file wins, which is the one that is the site's own 404 rather
@@ -264,9 +589,334 @@ export function scanRoutes(appRoot) {
264
589
  // parallel-route trees uf does not have yet; see ubugeeei-prod/uf#267.
265
590
  notFound.sort(byPath);
266
591
  errors.sort(byPath);
592
+ // Last, because it is about the table rather than a directory: an
593
+ // intercepting page is only as good as the ordinary page that serves its URL
594
+ // to everybody the interception does not.
595
+ refuseInterceptionsWithoutPages(appRoot, routes);
267
596
  return { routes, handlers, middleware, notFound, errors };
268
597
  }
269
598
 
599
+ /**
600
+ * Refuse an intercepting route whose URL no page serves.
601
+ *
602
+ * An interception renders in its slot only for a client navigation that starts
603
+ * on a page the slot is on. Everybody else who arrives at the URL — a reload, a
604
+ * shared link, a crawler, the prerender — is given the page the URL names, and
605
+ * with none the photo a reader opened in a modal is a 404 the moment they reload
606
+ * it or send it to somebody. Mirrors `uf_router`'s `check_interceptions`.
607
+ *
608
+ * Each slot record is visited once, because a record is shared by every route
609
+ * under the segment that declares it.
610
+ */
611
+ function refuseInterceptionsWithoutPages(appRoot, routes) {
612
+ const seen = new Set();
613
+ const visit = (slots) => {
614
+ for (const slot of slots) {
615
+ if (seen.has(slot)) continue;
616
+ seen.add(slot);
617
+ for (const intercepting of slot.intercepts ?? []) {
618
+ if (!routes.some((route) => servesEveryUrlOf(route.path, intercepting.path))) {
619
+ const directories = intercepting.path
620
+ .split("/")
621
+ .filter((part) => part !== "")
622
+ .map((part) =>
623
+ part.startsWith(":") && part.endsWith("*")
624
+ ? `[...${part.slice(1, -1)}]`
625
+ : part.startsWith(":")
626
+ ? `[${part.slice(1)}]`
627
+ : part,
628
+ );
629
+ const ordinary = path.join(appRoot, ...directories, `${RESERVED.page}.js`);
630
+ throw new Error(
631
+ `${intercepting.page}: this intercepting route stands in for \`${intercepting.path}\` ` +
632
+ "when a client navigation reaches it, and no page serves " +
633
+ `\`${intercepting.path}\`, so a reload of that URL, a link to it and the prerender ` +
634
+ `would all be a 404. Add \`${ordinary}\`, the page everybody who does not arrive by ` +
635
+ "that navigation gets, or remove the interception.",
636
+ );
637
+ }
638
+ visit(intercepting.slots);
639
+ }
640
+ for (const route of slot.routes) {
641
+ visit(route.slots);
642
+ }
643
+ }
644
+ };
645
+ for (const route of routes) {
646
+ visit(route.slots ?? []);
647
+ }
648
+ }
649
+
650
+ /**
651
+ * Whether every URL the route path `intercepted` matches is one `ordinary`
652
+ * serves: segment by segment, the way the runtime's matcher reads both. A
653
+ * static segment serves only itself, a parameter any one segment but not a
654
+ * catch-all's many, and a catch-all whatever is left as long as something is.
655
+ * Mirrors `uf_router`'s `serves_every_url_of`.
656
+ */
657
+ function servesEveryUrlOf(ordinary, intercepted) {
658
+ const theirs = ordinary.split("/").filter((part) => part !== "");
659
+ const ours = intercepted.split("/").filter((part) => part !== "");
660
+ for (let index = 0; index < theirs.length; index += 1) {
661
+ const segment = theirs[index];
662
+ if (segment.startsWith(":") && segment.endsWith("*")) return ours.length > index;
663
+ const other = ours[index];
664
+ if (other === undefined) return false;
665
+ const otherIsCatchAll = other.startsWith(":") && other.endsWith("*");
666
+ const serves = segment.startsWith(":")
667
+ ? !otherIsCatchAll
668
+ : !other.startsWith(":") && other === segment;
669
+ if (!serves) return false;
670
+ }
671
+ return ours.length === theirs.length;
672
+ }
673
+
674
+ /**
675
+ * One `@slot` directory, scanned into a {@link Slot}.
676
+ *
677
+ * Separate from `walk` rather than a mode of it, because the two build
678
+ * different things out of the same tree. `walk` builds URLs and the boundaries
679
+ * around them; this builds what one named place may hold, matched against URLs
680
+ * somebody else's directories define. Folding them together would mean a
681
+ * `loading` accumulator that is dead in half the calls and a route table that
682
+ * is dead in the other half.
683
+ *
684
+ * Nested slots are ordinary: a slot's own layout may declare slots of its own,
685
+ * and they are collected here the same way, so the recursion is the shape of
686
+ * the feature rather than a special case.
687
+ *
688
+ * @param {string} parent the directory that declares the slot
689
+ * @param {string} directoryName the slot directory, `@team` as written
690
+ * @param {string} name the slot's name, `team`
691
+ * @param {ReadonlyArray<string>} segments the declaring segments, for the URL
692
+ * @param {?string} ownLayout the declaring segment's own layout, or `null`
693
+ * @param {number} above how many layouts are outside the slot
694
+ * @param {"web" | "native" | "ios" | "android"} target application target
695
+ * @param {number} depth nesting depth, against `MAX_DEPTH`
696
+ * @returns {Slot}
697
+ */
698
+ function scanSlot(parent, directoryName, name, segments, ownLayout, above, target, depth) {
699
+ const directory = path.join(parent, directoryName);
700
+ if (LAYOUT_PROP_NAMES.includes(name)) {
701
+ throw new Error(
702
+ `${directory}: a slot arrives as a prop named after its directory, and \`${name}\` is a ` +
703
+ "prop every layout already receives, so one of the two would silently go missing. " +
704
+ "Rename the slot. The page a URL matches is always `children` — uf has no `@children` " +
705
+ "slot, which is the name Next.js gives that page.",
706
+ );
707
+ }
708
+ // The declaring segment's *own* layout, not the layouts in scope there. A
709
+ // slot is a prop that layout receives beside `children`, so a slot on a
710
+ // segment with no layout has nothing to render into — and rendering it into
711
+ // an inherited one would hand a prop to a layout that never declared it, on
712
+ // every route below.
713
+ if (ownLayout == null) {
714
+ const routePath = routeFromSegments(segments).path;
715
+ throw new Error(
716
+ `${directory}: \`${directoryName}\` is a parallel-route slot and \`${routePath}\` declares ` +
717
+ "no layout of its own, so there is nothing to render the slot into — a slot is a prop " +
718
+ "the segment's own layout receives beside `children`. Add " +
719
+ `\`${path.join(parent, `${RESERVED.layout}.js`)}\`, or move the slot to a segment that ` +
720
+ "has one.",
721
+ );
722
+ }
723
+
724
+ const routes = [];
725
+ // What the slot renders *instead of* the page a client navigation reaches:
726
+ // the pages under an interception directory. A list of its own rather than
727
+ // more `routes`, because `routes` is matched against every URL the segment
728
+ // renders — by the server as much as the browser — and nothing but a
729
+ // navigation that starts on a page this slot is on may render one of these.
730
+ const intercepts = [];
731
+ const defaultPage = findModule(directory, RESERVED.default, PAGE_EXTENSIONS, target);
732
+ const defaultError = findModule(directory, RESERVED.error, MODULE_EXTENSIONS, target);
733
+
734
+ const walkSlot = (
735
+ current,
736
+ currentSegments,
737
+ layouts,
738
+ loading,
739
+ templates,
740
+ errorBoundary,
741
+ atSlotRoot,
742
+ intercepting,
743
+ currentDepth,
744
+ ) => {
745
+ if (currentDepth > MAX_DEPTH) return;
746
+ const entries = readdirSync(current, { withFileTypes: true }).sort((a, b) =>
747
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
748
+ );
749
+
750
+ // What a slot does not have, said where somebody writing the file will
751
+ // read it rather than by never opening it. Loading and error boundaries
752
+ // compose like layouts, so they are carried below instead of refused here.
753
+ for (const role of [RESERVED.notFound]) {
754
+ const found =
755
+ findModule(current, role, MODULE_EXTENSIONS, target) ??
756
+ findModule(current, role, PAGE_EXTENSIONS, target);
757
+ if (found != null) {
758
+ throw new Error(
759
+ `${found}: a \`@slot\` renders a page and the layouts inside the slot, and has no ` +
760
+ `\`${role.slice("$".length)}\` of its own — uf's parallel routes do not carry ` +
761
+ "per-slot not-found boundaries yet, so this file would never be opened. Put it outside " +
762
+ `\`${directoryName}\`, where it covers the whole segment. ` +
763
+ "https://github.com/ubugeeei-prod/uf/issues/267",
764
+ );
765
+ }
766
+ }
767
+ for (const entry of entries) {
768
+ if (!entry.isFile()) continue;
769
+ const role = unsupportedSlotBoundaryRole(entry.name);
770
+ if (role == null) continue;
771
+ const file = path.join(current, entry.name);
772
+ throw new Error(
773
+ `${file}: \`${entry.name}\` looks like a \`${role}\` boundary for a \`@slot\`, but it ` +
774
+ "is not a uf route file there. Use `$loading.js` for slot loading and `$error.js` " +
775
+ "for slot errors; per-slot not-found boundaries are still not implemented. " +
776
+ "https://github.com/ubugeeei-prod/uf/issues/267",
777
+ );
778
+ }
779
+ for (const role of [RESERVED.route, RESERVED.middleware]) {
780
+ const found = findModule(current, role, MODULE_EXTENSIONS, target);
781
+ if (found != null) {
782
+ throw new Error(
783
+ `${found}: a \`@slot\` renders inside the page at a URL and answers no request of its ` +
784
+ `own, so \`${role}.js\` here would never run. A slot directory contributes no URL ` +
785
+ `segment, so this would claim \`${routeFromSegments(currentSegments).path}\` — which ` +
786
+ `belongs to the segment that declares the slot. Move it out of \`${directoryName}\`.`,
787
+ );
788
+ }
789
+ }
790
+ // One default per slot, at the slot. A deeper one would be a second answer
791
+ // to a question that is asked once — the URL either addressed this slot or
792
+ // it did not.
793
+ const nestedDefault = findModule(current, RESERVED.default, PAGE_EXTENSIONS, target);
794
+ if (!atSlotRoot && nestedDefault != null) {
795
+ throw new Error(
796
+ `${nestedDefault}: a \`@slot\` has one ` +
797
+ `\`$default.js\`, directly inside \`${directoryName}\`, and this one is deeper, so ` +
798
+ "nothing would ever render it.",
799
+ );
800
+ }
801
+
802
+ const layoutHere = findModule(current, RESERVED.layout, MODULE_EXTENSIONS, target);
803
+ const nextLayouts = layoutHere ? [...layouts, layoutHere] : layouts;
804
+ const loadingHere = findModule(current, RESERVED.loading, MODULE_EXTENSIONS, target);
805
+ const nextLoading = loadingHere
806
+ ? [...loading, { above: nextLayouts.length, module: loadingHere }]
807
+ : loading;
808
+ const templateHere = findModule(current, RESERVED.template, MODULE_EXTENSIONS, target);
809
+ const nextTemplates = templateHere
810
+ ? [...templates, { above: nextLayouts.length, module: templateHere }]
811
+ : templates;
812
+ const errorHere = findModule(current, RESERVED.error, MODULE_EXTENSIONS, target);
813
+ const nextErrorBoundary = errorHere
814
+ ? { above: nextLayouts.length, module: errorHere }
815
+ : errorBoundary;
816
+
817
+ refuseUnsupportedTemplateFiles(current, entries);
818
+
819
+ let nestedSlots = [];
820
+ for (const entry of entries) {
821
+ if (!entry.isDirectory()) continue;
822
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
823
+ const classified = classifyRouteSegment(entry.name);
824
+ if (classified.kind !== "slot") continue;
825
+ nestedSlots = [
826
+ ...nestedSlots,
827
+ scanSlot(
828
+ current,
829
+ entry.name,
830
+ classified.name,
831
+ currentSegments,
832
+ layoutHere,
833
+ nextLayouts.length,
834
+ target,
835
+ currentDepth,
836
+ ),
837
+ ];
838
+ }
839
+
840
+ const page = findModule(current, RESERVED.page, PAGE_EXTENSIONS, target);
841
+ if (page) {
842
+ // Under an interception directory the path is the URL the page stands in
843
+ // for — `routeFromSegments` applies the climb — and the page goes in the
844
+ // slot's other list.
845
+ const { path: routePath, params } = routeFromSegments(currentSegments);
846
+ (intercepting ? intercepts : routes).push({
847
+ path: routePath,
848
+ params,
849
+ page,
850
+ layouts: nextLayouts,
851
+ loading: nextLoading,
852
+ templates: nextTemplates,
853
+ errorBoundary: nextErrorBoundary,
854
+ slots: nestedSlots,
855
+ mdx: page.endsWith(".mdx"),
856
+ });
857
+ }
858
+
859
+ for (const entry of entries) {
860
+ if (!entry.isDirectory()) continue;
861
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
862
+ const classified = classifyRouteSegment(entry.name);
863
+ if (classified.kind === "slot") continue;
864
+ if (classified.kind === "interception") {
865
+ // Inside a slot, so the place is right. What is left to refuse is a
866
+ // spelling nothing reads and a climb past the router root, and the
867
+ // depth is what the directories above it really contribute, climbs
868
+ // applied.
869
+ const depthHere = routeFromSegments(currentSegments)
870
+ .path.split("/")
871
+ .filter((part) => part !== "").length;
872
+ const refused = unsupportedSegmentReason(entry.name) ?? climbReason(entry.name, depthHere);
873
+ if (refused != null) {
874
+ throw new Error(`${path.join(current, entry.name)}: ${refused}`);
875
+ }
876
+ }
877
+ walkSlot(
878
+ path.join(current, entry.name),
879
+ [...currentSegments, entry.name],
880
+ nextLayouts,
881
+ nextLoading,
882
+ nextTemplates,
883
+ nextErrorBoundary,
884
+ false,
885
+ intercepting || classified.kind === "interception",
886
+ currentDepth + 1,
887
+ );
888
+ }
889
+ };
890
+
891
+ // The slot's own directory is in the segments from here down. It adds nothing
892
+ // to a path, and it is how `routeFromSegments` knows that an interception
893
+ // below it is inside a slot.
894
+ walkSlot(
895
+ directory,
896
+ [...segments, directoryName],
897
+ [],
898
+ [],
899
+ [],
900
+ defaultError == null ? null : { above: 0, module: defaultError },
901
+ true,
902
+ false,
903
+ depth + 1,
904
+ );
905
+
906
+ const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
907
+ routes.sort(byPath);
908
+ intercepts.sort(byPath);
909
+ return {
910
+ name,
911
+ above,
912
+ defaultPage,
913
+ defaultMdx: defaultPage != null && defaultPage.endsWith(".mdx"),
914
+ defaultErrorBoundary: defaultError == null ? null : { above: 0, module: defaultError },
915
+ routes,
916
+ intercepts,
917
+ };
918
+ }
919
+
270
920
  function isDirectory(candidate) {
271
921
  try {
272
922
  return statSync(candidate).isDirectory();
@@ -275,52 +925,283 @@ function isDirectory(candidate) {
275
925
  }
276
926
  }
277
927
 
278
- function findModule(directory, stem, extensions) {
279
- for (const extension of extensions) {
280
- const candidate = path.join(directory, stem + extension);
281
- try {
282
- if (statSync(candidate).isFile()) return candidate;
283
- } catch {
284
- // keep looking
928
+ function findModule(directory, stem, extensions, target = "web") {
929
+ for (const variant of TARGET_VARIANTS[target] ?? TARGET_VARIANTS.web) {
930
+ for (const extension of extensions) {
931
+ const fileName = variant == null ? `${stem}${extension}` : `${stem}.${variant}${extension}`;
932
+ const candidate = path.join(directory, fileName);
933
+ try {
934
+ if (statSync(candidate).isFile()) return candidate;
935
+ } catch {
936
+ // keep looking
937
+ }
285
938
  }
286
939
  }
287
940
  return null;
288
941
  }
289
942
 
943
+ function refuseUnsupportedTemplateFiles(directory, entries) {
944
+ for (const entry of entries) {
945
+ if (!entry.isFile() || !UNSUPPORTED_TEMPLATE_FILES.includes(entry.name)) {
946
+ continue;
947
+ }
948
+ const file = path.join(directory, entry.name);
949
+ throw new Error(`${file}: ${unsupportedTemplateFileReason(entry.name)}`);
950
+ }
951
+ }
952
+
953
+ function unsupportedTemplateFileReason(fileName) {
954
+ return (
955
+ `\`${fileName}\` looks like a route template, but uf's route template file is ` +
956
+ "`$template.js`. This file would be ignored rather than remounting the route, so it is " +
957
+ "refused; rename it to `$template.js`. https://github.com/ubugeeei-prod/uf/issues/267"
958
+ );
959
+ }
960
+
961
+ function unsupportedSlotBoundaryRole(fileName) {
962
+ return UNSUPPORTED_SLOT_BOUNDARY_FILES[fileName] ?? null;
963
+ }
964
+
965
+ /**
966
+ * What one directory name means to the route path.
967
+ *
968
+ * Mirrors `uf_router::classify_route_segment`, which is the same six answers
969
+ * in the same order. The order is load-bearing in one place: an interception
970
+ * marker is a `(…)` *prefix* with a route after it, and a `(group)` is a
971
+ * segment that ends in `)`, so the interception test has to come first or
972
+ * every group would be read as one.
973
+ *
974
+ * @param {string} segment one directory name
975
+ * @returns {{kind: "group"}
976
+ * | {kind: "param", name: string}
977
+ * | {kind: "catchAll", name: string}
978
+ * | {kind: "literal", name: string}
979
+ * | {kind: "slot", name: string}
980
+ * | {kind: "interception", marker: string, route: string}}
981
+ */
982
+ export function classifyRouteSegment(segment) {
983
+ if (segment.startsWith("@")) return { kind: "slot", name: segment.slice(1) };
984
+ const intercepted = interceptionMarker(segment);
985
+ if (intercepted != null) return { kind: "interception", ...intercepted };
986
+ if (segment.startsWith("(") && segment.endsWith(")")) return { kind: "group" };
987
+ if (segment.startsWith("[...") && segment.endsWith("]")) {
988
+ return { kind: "catchAll", name: segment.slice(4, -1) };
989
+ }
990
+ if (segment.startsWith("[") && segment.endsWith("]")) {
991
+ return { kind: "param", name: segment.slice(1, -1) };
992
+ }
993
+ return { kind: "literal", name: segment };
994
+ }
995
+
996
+ /**
997
+ * The `(.)`-style prefix of `segment` and the route after it, or `null`.
998
+ *
999
+ * One or more parenthesised runs of dots, followed by something for them to
1000
+ * intercept. Which runs *mean* anything is {@link interceptionClimb}'s question
1001
+ * and deliberately not this one: `(....)photo` is the shape of an interception
1002
+ * written by somebody who miscounted, and reading it as a literal URL segment is
1003
+ * how the miscount becomes a page at `/(....)photo`. A marker with nothing after
1004
+ * it names no route and is the `(group)` it has always been.
1005
+ */
1006
+ function interceptionMarker(segment) {
1007
+ let consumed = 0;
1008
+ while (segment[consumed] === "(") {
1009
+ const close = segment.indexOf(")", consumed);
1010
+ if (close === -1) break;
1011
+ const inner = segment.slice(consumed + 1, close);
1012
+ if (inner.length === 0 || /[^.]/.test(inner)) break;
1013
+ consumed = close + 1;
1014
+ }
1015
+ if (consumed === 0 || consumed === segment.length) return null;
1016
+ return { marker: segment.slice(0, consumed), route: segment.slice(consumed) };
1017
+ }
1018
+
1019
+ /**
1020
+ * How far a marker climbs, in URL segments: a number for `(.)` and `(..)`
1021
+ * repeated, `"root"` for `(...)`, and `null` for a marker uf does not read.
1022
+ *
1023
+ * Mirrors `uf_router::interception_climb`. `(.)` and `(...)` only as the whole
1024
+ * marker, because each already says where the climb ends; `(..)` as many times
1025
+ * as there are levels to climb.
1026
+ *
1027
+ * @param {string} marker
1028
+ * @returns {number | "root" | null}
1029
+ */
1030
+ export function interceptionClimb(marker) {
1031
+ const runs = marker.match(/\(\.+\)/g) ?? [];
1032
+ if (runs.length === 0 || runs.join("") !== marker) return null;
1033
+ if (runs.length === 1 && runs[0] === "(.)") return 0;
1034
+ if (runs.length === 1 && runs[0] === "(...)") return "root";
1035
+ return runs.every((run) => run === "(..)") ? runs.length : null;
1036
+ }
1037
+
1038
+ /**
1039
+ * The interception a classified directory name is, when it is one uf reads: a
1040
+ * marker that climbs, with a URL segment after it to stand in for.
1041
+ *
1042
+ * @returns {{climb: number | "root", route: {kind: string, name: string}} | null}
1043
+ */
1044
+ function readInterception(classified) {
1045
+ if (classified.kind !== "interception") return null;
1046
+ const climb = interceptionClimb(classified.marker);
1047
+ const route = classifyRouteSegment(classified.route);
1048
+ if (climb == null) return null;
1049
+ if (route.kind !== "literal" && route.kind !== "param" && route.kind !== "catchAll") {
1050
+ return null;
1051
+ }
1052
+ return { climb, route };
1053
+ }
1054
+
1055
+ /**
1056
+ * Why uf refuses a directory named `segment` wherever it is, or `null` when it
1057
+ * serves it somewhere.
1058
+ *
1059
+ * The message is this router's own rather than `uf_router`'s, because the two
1060
+ * are reached differently: the Rust one fails `uf build` and `uf dev` through
1061
+ * the route manifest, and this one fails a project driving Vite itself. Both
1062
+ * say the same things — what is wrong with the spelling, and that it is refused
1063
+ * rather than served as a URL.
1064
+ *
1065
+ * A correctly spelled interception has two more refusals, about where it is
1066
+ * rather than how it is written: {@link outsideSlotReason} and `climbReason`.
1067
+ */
1068
+ export function unsupportedSegmentReason(segment) {
1069
+ const classified = classifyRouteSegment(segment);
1070
+ if (classified.kind !== "interception") return null;
1071
+ const { marker, route } = classified;
1072
+ if (interceptionClimb(marker) == null) {
1073
+ return (
1074
+ `\`${segment}\` is spelled like an intercepting route and \`${marker}\` is not a marker uf ` +
1075
+ "reads. The markers are `(.)` for the level the directory is at, `(..)` for one above it — " +
1076
+ "repeated for each further level — and `(...)` for the router root. It is refused rather " +
1077
+ `than served as the URL segment \`/${segment}\`, which is what it used to become. Spell the ` +
1078
+ "marker as one of those and put the directory inside a `@slot`, or rename it to the literal " +
1079
+ `segment \`${route}\`. https://github.com/ubugeeei-prod/uf/issues/267`
1080
+ );
1081
+ }
1082
+ if (readInterception(classified) == null) {
1083
+ return (
1084
+ `\`${segment}\` is spelled like an intercepting route, and \`${route}\` after the marker is ` +
1085
+ "not a URL segment, so there is no path for it to intercept: an interception names the " +
1086
+ `segment it stands in for, the way \`${marker}photo\` and \`${marker}[id]\` do. It is ` +
1087
+ `refused rather than served as the URL segment \`/${segment}\`, which is what it used to ` +
1088
+ "become. Put a segment name after the marker, or rename the directory. " +
1089
+ "https://github.com/ubugeeei-prod/uf/issues/267"
1090
+ );
1091
+ }
1092
+ return null;
1093
+ }
1094
+
1095
+ /**
1096
+ * Why uf refuses the intercepting route `segment` outside a `@slot`, where it
1097
+ * would serve it inside one; `null` for any other directory name.
1098
+ *
1099
+ * A sentence of its own rather than another case of
1100
+ * {@link unsupportedSegmentReason}, because this one is spelled correctly and
1101
+ * placed wrongly, and telling its author to rename it would be wrong.
1102
+ */
1103
+ export function outsideSlotReason(segment) {
1104
+ const classified = classifyRouteSegment(segment);
1105
+ if (readInterception(classified) == null) return null;
1106
+ return (
1107
+ `\`${segment}\` is an intercepting route, and an intercepting route renders into a \`@slot\`: ` +
1108
+ "it is what a client navigation shows in a named place instead of the page its URL names, " +
1109
+ "and outside a slot there is no named place for it to show in. It is refused rather than " +
1110
+ `served as the URL segment \`/${segment}\`, which is what it used to become. Move it inside a ` +
1111
+ "slot directory beside the layout that renders the slot, or rename the directory to the " +
1112
+ `literal segment \`${classified.route}\`. https://github.com/ubugeeei-prod/uf/issues/267`
1113
+ );
1114
+ }
1115
+
1116
+ /**
1117
+ * Why an interception `depth` URL segments below the router root climbs past
1118
+ * it, or `null` when it does not. Mirrors `RouteSegment::climb_reason`.
1119
+ */
1120
+ function climbReason(segment, depth) {
1121
+ const read = readInterception(classifyRouteSegment(segment));
1122
+ if (read == null || read.climb === "root" || read.climb <= depth) return null;
1123
+ const climbs = read.climb === 1 ? "one level" : `${read.climb} levels`;
1124
+ const sits =
1125
+ depth === 0
1126
+ ? "at the router root"
1127
+ : depth === 1
1128
+ ? "one level below it"
1129
+ : `${depth} levels below it`;
1130
+ return (
1131
+ `\`${segment}\` climbs ${climbs} from the directory it is in, which is ${sits}, so the URL it ` +
1132
+ "intercepts would be above the router root, and there is no such URL. It is refused rather " +
1133
+ "than read as a climb to the root. Remove a `(..)`, or write `(...)` to intercept from the " +
1134
+ "router root. https://github.com/ubugeeei-prod/uf/issues/267"
1135
+ );
1136
+ }
1137
+
290
1138
  /**
291
1139
  * Turn directory segments into a route path and its parameters.
292
1140
  *
293
1141
  * `(group)` segments organise files without appearing in the URL, `[name]`
294
- * captures one segment, and `[...name]` captures the rest of the path.
1142
+ * captures one segment, and `[...name]` captures the rest of the path. A
1143
+ * `@slot` contributes nothing either — it is a named place a route renders
1144
+ * into, matched against the URL of the segment that declares it — so a slot's
1145
+ * pages are matched against ordinary paths and add none of their own.
1146
+ *
1147
+ * An intercepting route is where "one directory, one segment" stops holding.
1148
+ * Inside a slot, `(..)photo` takes a segment *away* before it adds its own, so
1149
+ * `["feed", "@modal", "(..)photo", "[id]"]` is `/photo/:id`: the URL the
1150
+ * interception stands in for. That is `uf_router`'s `path_segments`, spelled
1151
+ * again. Wherever an interception cannot be — outside a slot, climbing past the
1152
+ * root, written so nothing reads it — this throws the refusal
1153
+ * {@link scanRoutes} would give the directory rather than build a URL from it.
295
1154
  */
296
1155
  export function routeFromSegments(segments) {
297
- const params = [];
298
- const out = [];
1156
+ let out = [];
1157
+ let insideSlot = false;
299
1158
  for (const segment of segments) {
300
- if (segment.startsWith("(") && segment.endsWith(")")) continue;
301
- if (segment.startsWith("[...") && segment.endsWith("]")) {
302
- const name = segment.slice(4, -1);
303
- params.push({ name, catchAll: true });
304
- out.push(`:${name}*`);
1159
+ const classified = classifyRouteSegment(segment);
1160
+ if (classified.kind === "group") continue;
1161
+ if (classified.kind === "slot") {
1162
+ insideSlot = true;
305
1163
  continue;
306
1164
  }
307
- if (segment.startsWith("[") && segment.endsWith("]")) {
308
- const name = segment.slice(1, -1);
309
- params.push({ name, catchAll: false });
310
- out.push(`:${name}`);
311
- continue;
1165
+ let named = classified;
1166
+ if (classified.kind === "interception") {
1167
+ const refused =
1168
+ unsupportedSegmentReason(segment) ??
1169
+ (insideSlot ? climbReason(segment, out.length) : outsideSlotReason(segment));
1170
+ if (refused != null) {
1171
+ throw new Error(refused);
1172
+ }
1173
+ const read = readInterception(classified);
1174
+ out = read.climb === "root" ? [] : out.slice(0, out.length - read.climb);
1175
+ named = read.route;
1176
+ }
1177
+ if (named.kind === "catchAll") {
1178
+ out.push({ spelling: `:${named.name}*`, param: { name: named.name, catchAll: true } });
1179
+ } else if (named.kind === "param") {
1180
+ out.push({ spelling: `:${named.name}`, param: { name: named.name, catchAll: false } });
1181
+ } else {
1182
+ out.push({ spelling: named.name, param: null });
312
1183
  }
313
- out.push(segment);
314
1184
  }
315
- const routePath = out.length === 0 ? "/" : `/${out.join("/")}`;
1185
+ const routePath = out.length === 0 ? "/" : `/${out.map((entry) => entry.spelling).join("/")}`;
1186
+ const params = out.flatMap((entry) => (entry.param == null ? [] : [entry.param]));
316
1187
  return { path: routePath, pattern: routePath.replace(/:(\w+)\*/g, "*$1"), params };
317
1188
  }
318
1189
 
319
- /** Virtual module ids the router plugin serves. */
1190
+ /**
1191
+ * Virtual module ids the router plugin serves.
1192
+ *
1193
+ * `actions` is the one that does not come from this file's directory scan: it
1194
+ * is generated from the RSC manifest by `internal/rsc.js`, because which
1195
+ * `"use server"` exports are callable endpoints is an answer about the module
1196
+ * graph and not about the filesystem. It is here because it is a virtual
1197
+ * module id and this is where they are named, and because
1198
+ * `serverModuleSource` below is the only thing that imports it.
1199
+ */
320
1200
  export const VIRTUAL = Object.freeze({
321
1201
  routes: "virtual:uf/routes",
322
1202
  client: "virtual:uf/client",
323
1203
  server: "virtual:uf/server",
1204
+ actions: "virtual:uf/actions",
324
1205
  });
325
1206
 
326
1207
  /**
@@ -366,6 +1247,25 @@ export const VIRTUAL = Object.freeze({
366
1247
  * `virtual:uf/server` is generated with the default and always will be: the
367
1248
  * server renders every route, so its table is the complete one.
368
1249
  *
1250
+ * # `relativeTo`, and the one string in this table a browser can read
1251
+ *
1252
+ * Every `import()` here is a specifier Vite resolves and rewrites to a chunk
1253
+ * URL, so no absolute path survives the build — except `file`, which is a
1254
+ * string. It is the route's source path, kept for diagnostics: the middleware
1255
+ * table's is what names a module in an error, and `router.js`'s generated
1256
+ * types are about the same files.
1257
+ *
1258
+ * The server's table can hold an absolute path; it is read on the machine that
1259
+ * has those files. The browser's cannot, because that table is downloaded:
1260
+ * uf's own manual shipped `/home/<user>/…/docs/app/guide/cache/$page.mdx`
1261
+ * for each of thirty-four routes to every visitor, which publishes the build
1262
+ * machine's layout and its user's name for nothing — the browser has no
1263
+ * filesystem to resolve them against and reads them only in a message.
1264
+ *
1265
+ * So the client call passes the project root and every `file` here is emitted
1266
+ * relative to it. Diagnostics keep a path a person can act on — a shorter one
1267
+ * — and a deploy stops describing the machine it was built on.
1268
+ *
369
1269
  * @param {{
370
1270
  * routes: Route[],
371
1271
  * handlers?: Handler[],
@@ -373,10 +1273,29 @@ export const VIRTUAL = Object.freeze({
373
1273
  * notFound?: NotFoundBoundary[],
374
1274
  * errors?: ErrorBoundary[],
375
1275
  * }} table
376
- * @param {{shipsPage?: (route: Route) => boolean}} [options]
1276
+ * @param {{
1277
+ * shipsPage?: (route: Route) => boolean,
1278
+ * relativeTo?: string,
1279
+ * }} [options]
377
1280
  */
378
1281
  export function routesModuleSource(table, options = {}) {
379
1282
  const shipsPage = options.shipsPage ?? (() => true);
1283
+ const relativeTo = options.relativeTo ?? null;
1284
+ /**
1285
+ * A `file` as this table should state it.
1286
+ *
1287
+ * Relative even when that means leading `..` segments — a module outside the
1288
+ * project root is rare and a `../` path still says where it is without
1289
+ * saying where the machine is, which is the whole property. Separators are
1290
+ * POSIX because this string is read wherever the bundle is opened rather
1291
+ * than where it was written.
1292
+ */
1293
+ const displayFile = (file) => {
1294
+ if (relativeTo == null || file == null) {
1295
+ return file;
1296
+ }
1297
+ return path.relative(relativeTo, file).split(path.sep).join("/");
1298
+ };
380
1299
  const layoutIds = new Map();
381
1300
  const layoutImports = [];
382
1301
  const layoutId = (file) => {
@@ -390,7 +1309,7 @@ export function routesModuleSource(table, options = {}) {
390
1309
  };
391
1310
 
392
1311
  // Loading modules are deduplicated into a table of their own, for the reason
393
- // layouts are: one `app/_uf.loading.js` is the fallback of every route under
1312
+ // layouts are: one `app/$loading.js` is the fallback of every route under
394
1313
  // it, and fifty copies of the same `import()` would be fifty chunks of the
395
1314
  // same file.
396
1315
  //
@@ -412,32 +1331,150 @@ export function routesModuleSource(table, options = {}) {
412
1331
  return id;
413
1332
  };
414
1333
 
1334
+ // Templates are deduplicated for the reason layouts are — one
1335
+ // `app/$template.js` wraps every route under it — and are lazy for the
1336
+ // reason layouts are too: a template is part of the route's own tree rather
1337
+ // than a fallback React has to have in hand at the moment something goes
1338
+ // wrong, so it is awaited with the layouts before the first render.
1339
+ const templateIds = new Map();
1340
+ const templateImports = [];
1341
+ const templateId = (file) => {
1342
+ let id = templateIds.get(file);
1343
+ if (id === undefined) {
1344
+ id = `template${templateIds.size}`;
1345
+ templateIds.set(file, id);
1346
+ templateImports.push(`const ${id} = () => import(${JSON.stringify(file)});`);
1347
+ }
1348
+ return id;
1349
+ };
1350
+
1351
+ // Slots are hoisted like layouts and deduplicated by identity rather than by
1352
+ // file: one `scanRoutes` slot record is shared by every route at or below the
1353
+ // segment that declares it, so the object is the key. A slot holds a whole
1354
+ // route table of its own, and emitting it once per route below it would be
1355
+ // that table copied into the bundle once per route.
1356
+ //
1357
+ // Nested slots are emitted before the slot that holds them, because a `const`
1358
+ // cannot read one declared after it.
1359
+ const slotIds = new Map();
1360
+ const slotDefinitions = [];
1361
+ const slotFiles = new Set();
1362
+ const slotErrorBoundary = (boundary) =>
1363
+ boundary == null
1364
+ ? "null"
1365
+ : `{ above: ${boundary.above}, module: () => import(${JSON.stringify(boundary.module)}) }`;
1366
+ // One route a slot may render, as source. The same shape for a slot's own
1367
+ // routes and for its interceptions, because an intercepting page is composed
1368
+ // exactly the way every other page in the slot is.
1369
+ const slotRoute = (route) => {
1370
+ slotFiles.add(route.page);
1371
+ for (const file of route.layouts) slotFiles.add(file);
1372
+ for (const entry of route.loading ?? []) slotFiles.add(entry.module);
1373
+ const nested = route.slots.map(slotId);
1374
+ if (route.errorBoundary != null) slotFiles.add(route.errorBoundary.module);
1375
+ return ` {
1376
+ path: ${JSON.stringify(route.path)},
1377
+ params: ${JSON.stringify(route.params)},
1378
+ mdx: ${route.mdx},
1379
+ file: ${JSON.stringify(displayFile(route.page))},
1380
+ page: () => import(${JSON.stringify(route.page)}),
1381
+ layouts: [${route.layouts.map(layoutId).join(", ")}],
1382
+ loading: [${(route.loading ?? [])
1383
+ .map((boundary) => `{ above: ${boundary.above}, module: ${loadingId(boundary.module)} }`)
1384
+ .join(", ")}],
1385
+ templates: [${(route.templates ?? [])
1386
+ .map((entry) => `{ above: ${entry.above}, module: ${templateId(entry.module)} }`)
1387
+ .join(", ")}],
1388
+ errorBoundary: ${slotErrorBoundary(route.errorBoundary ?? null)},
1389
+ slots: [${nested.join(", ")}],
1390
+ }`;
1391
+ };
1392
+ const slotId = (slot) => {
1393
+ let id = slotIds.get(slot);
1394
+ if (id !== undefined) {
1395
+ return id;
1396
+ }
1397
+ const routes = slot.routes.map(slotRoute);
1398
+ const intercepts = (slot.intercepts ?? []).map(slotRoute);
1399
+ // After the routes, so a nested slot's `const` is already emitted.
1400
+ id = `slot${slotIds.size}`;
1401
+ slotIds.set(slot, id);
1402
+ if (slot.defaultPage != null) {
1403
+ slotFiles.add(slot.defaultPage);
1404
+ }
1405
+ if (slot.defaultErrorBoundary != null) {
1406
+ slotFiles.add(slot.defaultErrorBoundary.module);
1407
+ }
1408
+ const fallback =
1409
+ slot.defaultPage == null
1410
+ ? " defaultPage: null,"
1411
+ : ` defaultPage: () => import(${JSON.stringify(slot.defaultPage)}),
1412
+ defaultFile: ${JSON.stringify(displayFile(slot.defaultPage))},`;
1413
+ // Only when there is one, so a slot that intercepts nothing is emitted byte
1414
+ // for byte the way it was before interception existed.
1415
+ const intercepting =
1416
+ intercepts.length === 0
1417
+ ? ""
1418
+ : `
1419
+ intercepts: [
1420
+ ${intercepts.join(",\n")}
1421
+ ],`;
1422
+ slotDefinitions.push(`const ${id} = {
1423
+ name: ${JSON.stringify(slot.name)},
1424
+ above: ${slot.above},
1425
+ ${fallback}
1426
+ defaultMdx: ${slot.defaultMdx},
1427
+ defaultErrorBoundary: ${slotErrorBoundary(slot.defaultErrorBoundary ?? null)},
1428
+ routes: [
1429
+ ${routes.join(",\n")}
1430
+ ],${intercepting}
1431
+ };`);
1432
+ return id;
1433
+ };
1434
+
415
1435
  const entries = table.routes.map((route) => {
416
1436
  if (!shipsPage(route)) {
417
1437
  return ` {
418
1438
  path: ${JSON.stringify(route.path)},
419
1439
  params: ${JSON.stringify(route.params)},
420
1440
  mdx: ${route.mdx},
421
- file: ${JSON.stringify(route.page)},
1441
+ file: ${JSON.stringify(displayFile(route.page))},
422
1442
  layouts: [],
423
1443
  loading: [],
1444
+ templates: [],
1445
+ slots: [],
424
1446
  }`;
425
1447
  }
426
1448
  const layouts = route.layouts.map(layoutId);
427
1449
  const loading = (route.loading ?? []).map(
428
1450
  (boundary) => `{ above: ${boundary.above}, module: ${loadingId(boundary.module)} }`,
429
1451
  );
1452
+ const templates = (route.templates ?? []).map(
1453
+ (entry) => `{ above: ${entry.above}, module: ${templateId(entry.module)} }`,
1454
+ );
1455
+ const slots = (route.slots ?? []).map(slotId);
430
1456
  return ` {
431
1457
  path: ${JSON.stringify(route.path)},
432
1458
  params: ${JSON.stringify(route.params)},
433
1459
  mdx: ${route.mdx},
434
- file: ${JSON.stringify(route.page)},
1460
+ file: ${JSON.stringify(displayFile(route.page))},
435
1461
  page: () => import(${JSON.stringify(route.page)}),
436
1462
  layouts: [${layouts.join(", ")}],
437
1463
  loading: [${loading.join(", ")}],
1464
+ templates: [${templates.join(", ")}],
1465
+ slots: [${slots.join(", ")}],
438
1466
  }`;
439
1467
  });
440
1468
 
1469
+ // A boundary the scan synthesised has no module to import — the framework's
1470
+ // own page renders in its place — so it emits `null` where a declared one
1471
+ // emits a loader, and a name for `file` rather than a path nothing wrote.
1472
+ // See the note in `scanRoutes` and ubugeeei-prod/uf#351.
1473
+ const SYNTHESISED = JSON.stringify("@uniflowed/router");
1474
+ const boundaryModule = (file) =>
1475
+ file == null ? "null" : `() => import(${JSON.stringify(file)})`;
1476
+ const boundaryFile = (file) => (file == null ? SYNTHESISED : JSON.stringify(displayFile(file)));
1477
+
441
1478
  // A list, because a not-found is a segment file: every directory may declare
442
1479
  // one and the router takes the nearest above the path. `layoutId` is the
443
1480
  // same table the routes use, so a boundary that shares a layout with a page
@@ -446,8 +1483,8 @@ export function routesModuleSource(table, options = {}) {
446
1483
  (boundary) => ` {
447
1484
  path: ${JSON.stringify(boundary.path)},
448
1485
  mdx: ${boundary.mdx},
449
- file: ${JSON.stringify(boundary.page)},
450
- page: () => import(${JSON.stringify(boundary.page)}),
1486
+ file: ${boundaryFile(boundary.page)},
1487
+ page: ${boundaryModule(boundary.page)},
451
1488
  layouts: [${boundary.layouts.map(layoutId).join(", ")}],
452
1489
  }`,
453
1490
  );
@@ -459,8 +1496,8 @@ export function routesModuleSource(table, options = {}) {
459
1496
  const errorEntries = (table.errors ?? []).map(
460
1497
  (boundary) => ` {
461
1498
  path: ${JSON.stringify(boundary.path)},
462
- file: ${JSON.stringify(boundary.module)},
463
- module: () => import(${JSON.stringify(boundary.module)}),
1499
+ file: ${boundaryFile(boundary.module)},
1500
+ module: ${boundaryModule(boundary.module)},
464
1501
  layouts: [${boundary.layouts.map(layoutId).join(", ")}],
465
1502
  }`,
466
1503
  );
@@ -472,7 +1509,7 @@ export function routesModuleSource(table, options = {}) {
472
1509
  (handler) => ` {
473
1510
  path: ${JSON.stringify(handler.path)},
474
1511
  params: ${JSON.stringify(handler.params)},
475
- file: ${JSON.stringify(handler.module)},
1512
+ file: ${JSON.stringify(displayFile(handler.module))},
476
1513
  load: () => import(${JSON.stringify(handler.module)}),
477
1514
  }`,
478
1515
  );
@@ -485,7 +1522,7 @@ export function routesModuleSource(table, options = {}) {
485
1522
  const middlewareEntries = (table.middleware ?? []).map(
486
1523
  (entry) => ` {
487
1524
  path: ${JSON.stringify(entry.path)},
488
- file: ${JSON.stringify(entry.module)},
1525
+ file: ${JSON.stringify(displayFile(entry.module))},
489
1526
  load: () => import(${JSON.stringify(entry.module)}),
490
1527
  }`,
491
1528
  );
@@ -493,13 +1530,24 @@ export function routesModuleSource(table, options = {}) {
493
1530
  // Last, because it is defined by what everything above did *not* import: a
494
1531
  // layout a kept route also uses is already in the graph as a lazy chunk, and
495
1532
  // importing it here as well would pull it into the entry chunk instead.
496
- const carried = new Set([...layoutIds.keys(), ...loadingIds.keys()]);
1533
+ const carried = new Set([
1534
+ ...layoutIds.keys(),
1535
+ ...loadingIds.keys(),
1536
+ ...templateIds.keys(),
1537
+ ...slotFiles,
1538
+ ]);
497
1539
  const styleOnlyImports = [];
498
1540
  for (const route of table.routes) {
499
1541
  if (shipsPage(route)) {
500
1542
  continue;
501
1543
  }
502
- const files = [route.page, ...route.layouts, ...(route.loading ?? []).map((it) => it.module)];
1544
+ const files = [
1545
+ route.page,
1546
+ ...route.layouts,
1547
+ ...(route.loading ?? []).map((it) => it.module),
1548
+ ...(route.templates ?? []).map((it) => it.module),
1549
+ ...slotModuleFiles(route.slots ?? []),
1550
+ ];
503
1551
  for (const file of files) {
504
1552
  if (carried.has(file)) {
505
1553
  continue;
@@ -509,7 +1557,7 @@ export function routesModuleSource(table, options = {}) {
509
1557
  }
510
1558
  }
511
1559
 
512
- return `${[...styleOnlyImports, ...layoutImports, ...loadingImports].join("\n")}
1560
+ return `${[...styleOnlyImports, ...layoutImports, ...loadingImports, ...templateImports, ...slotDefinitions].join("\n")}
513
1561
  export const routes = [
514
1562
  ${entries.join(",\n")}
515
1563
  ];
@@ -529,18 +1577,102 @@ export default routes;
529
1577
  `;
530
1578
  }
531
1579
 
1580
+ /**
1581
+ * Every module a slot tree holds, flattened.
1582
+ *
1583
+ * For the side-effect imports a dropped route needs: a slot's pages, its
1584
+ * layouts and its default are as much a part of that route's stylesheets as
1585
+ * its own page is, and a slot nested inside one is too.
1586
+ *
1587
+ * @param {ReadonlyArray<Slot>} slots
1588
+ * @returns {Array<string>}
1589
+ */
1590
+ function slotModuleFiles(slots) {
1591
+ const files = [];
1592
+ for (const slot of slots) {
1593
+ if (slot.defaultPage != null) files.push(slot.defaultPage);
1594
+ if (slot.defaultErrorBoundary != null) files.push(slot.defaultErrorBoundary.module);
1595
+ // An interception's modules with the slot's own: its stylesheet is part of
1596
+ // what the page looks like when a navigation opens it over this route.
1597
+ for (const route of [...slot.routes, ...(slot.intercepts ?? [])]) {
1598
+ files.push(
1599
+ route.page,
1600
+ ...route.layouts,
1601
+ ...(route.loading ?? []).map((it) => it.module),
1602
+ ...(route.templates ?? []).map((it) => it.module),
1603
+ ...(route.errorBoundary == null ? [] : [route.errorBoundary.module]),
1604
+ ...slotModuleFiles(route.slots),
1605
+ );
1606
+ }
1607
+ }
1608
+ return files;
1609
+ }
1610
+
532
1611
  /**
533
1612
  * The source of `virtual:uf/client`: hydrate the document with the app.
534
1613
  *
535
1614
  * The current route's modules are loaded *before* hydration so the first
536
1615
  * render is synchronous and matches the server's HTML; a lazy import during
537
1616
  * hydration would suspend and React would fall back to a client render.
1617
+ *
1618
+ * # Strict Mode is a generated constant, not a runtime check
1619
+ *
1620
+ * `strictMode` is written into this module as a literal, so a production build
1621
+ * gets `hydrate({ … })` with the argument absent and Rollup has nothing to
1622
+ * decide. It would have been shorter to have `hydrate` read `import.meta.hot`
1623
+ * — the way `client.js` gates the hydration reporter — and that would have been
1624
+ * one signal answering two questions: `uf.config.js` can turn Strict Mode off
1625
+ * (ubugeeei-prod/uf#516) and `import.meta.hot` cannot be told about it. A
1626
+ * project that sets `app.react.strictMode: false` gets a dev server that
1627
+ * hydrates the way its deployment does, which is the whole of the escape
1628
+ * hatch.
1629
+ *
1630
+ * # Navigation is a generated constant for a different reason
1631
+ *
1632
+ * `app.rendering.navigation` decides whether the client router takes a link
1633
+ * over, and it is written in here for the reason Strict Mode is not: it must
1634
+ * be the *same* in development and in the build. A `uf dev` whose links
1635
+ * resolve in the page and a deployment whose links fetch a document are two
1636
+ * applications, and the one a person is looking at is the one that is not
1637
+ * deployed. So this is generated from the config with no `isProduction` beside
1638
+ * it, and `uf dev`, `uf build` and `uf preview` all get what the project asked
1639
+ * for.
1640
+ *
1641
+ * `"client"` is emitted as an absent argument rather than as
1642
+ * `navigation: "client"`, so the module a default project gets is byte for
1643
+ * byte the one it got before this option existed.
1644
+ *
1645
+ * # And whether it hydrates at all
1646
+ *
1647
+ * `mount` is the third generated constant and the one that changes which
1648
+ * function is imported. A `["csr"]` build wrote one shell with an empty root,
1649
+ * so there is no markup to attach to and `render` is what starts the
1650
+ * application; every other build has markup, and `hydrate` attaches to it.
1651
+ *
1652
+ * One import or the other, rather than one import and a branch, because they
1653
+ * are two different React entry points: a bundle that mounts by hydrating has
1654
+ * no reason to carry `createRoot`, and a bundle that renders has none to carry
1655
+ * `hydrateRoot`. Which one is in the module decides which one is in the build.
1656
+ *
1657
+ * @param {string} appEntry the project's `app.js`, as an import specifier
1658
+ * @param {{
1659
+ * strictMode?: boolean,
1660
+ * navigation?: "client" | "document",
1661
+ * mount?: "hydrate" | "render",
1662
+ * }} [options]
538
1663
  */
539
- export function clientModuleSource(appEntry) {
540
- return `import { hydrate } from "@uniflowed/router/client";
1664
+ export function clientModuleSource(appEntry, options = {}) {
1665
+ const strictMode = options.strictMode === true ? ", strictMode: true" : "";
1666
+ const navigation = options.navigation === "document" ? ', navigation: "document"' : "";
1667
+ const mount = options.mount === "render" ? "render" : "hydrate";
1668
+ const routing = routingArgumentSource(options.routing);
1669
+ // `app.rendering.staleTime`, in seconds, and nothing for the default `0`.
1670
+ const staleTime =
1671
+ options.staleTime > 0 ? `, staleTime: ${JSON.stringify(options.staleTime)}` : "";
1672
+ return `import { ${mount} } from "@uniflowed/router/client";
541
1673
  import { routes, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
542
1674
  import App from ${JSON.stringify(appEntry)};
543
- hydrate({ App, routes, notFound, errors });
1675
+ ${clientInstrumentationSource(options.instrumentation)}${mount}({ App, routes, notFound, errors${strictMode}${navigation}${staleTime}${routing} });
544
1676
  `;
545
1677
  }
546
1678
 
@@ -558,6 +1690,16 @@ hydrate({ App, routes, notFound, errors });
558
1690
  * request — one decides whether the router is reached at all, the others
559
1691
  * decide what the router renders when it is.
560
1692
  *
1693
+ * `callAction` goes between the two, and its position is the same argument
1694
+ * made twice. Below `runMiddleware`, because an action call is a request to a
1695
+ * path and the guard on that path is owed the same say over it as over the
1696
+ * page — which is why the call is a `POST` to the page's own URL rather than
1697
+ * to a reserved one. Above `dispatch`, because a request that names an action
1698
+ * has named it: letting it fall through to a route handler that happens to sit
1699
+ * at the same path would answer somebody's action with somebody else's
1700
+ * function. It declines every request that carries no action id, so a project
1701
+ * with no actions pays one `headers.get` per request and nothing else.
1702
+ *
561
1703
  * `internal/serve.js` and `driver.js` call them in that order, and
562
1704
  * `packages/vite/index.js` does the same for a project driving Vite itself.
563
1705
  *
@@ -565,15 +1707,22 @@ hydrate({ App, routes, notFound, errors });
565
1707
  * a host is one or the other: a server streams, a build writes files. See the
566
1708
  * header of `packages/router/server.js` for why React needs both told apart.
567
1709
  *
1710
+ * `shellDocument` is the third and is neither: it renders no route, because a
1711
+ * `["csr"]` build has none to render at build time. It is re-exported straight
1712
+ * from the router rather than closed over the table, which says the true thing
1713
+ * about it — the shell is a function of the assets alone, and the route table
1714
+ * has nothing to do with a document that is no route's.
1715
+ *
568
1716
  * `beginRequest` is the fourth, and it is re-exported rather than imported by
569
- * the host for a reason that is easy to get wrong: `@uniflowed/server` keeps
570
- * the request in an `AsyncLocalStorage` held by *its module*, and a bundled
571
- * application has its own copy of that module inlined. A host that imported
572
- * `beginRequest` from its own `node_modules` would establish a request in a
573
- * second storage, and every `cookies()` in the application would still be
574
- * outside one. So the bundle hands the host the entry point that belongs to
575
- * the bundle. `uf preview`, `uf start`, `uf dev` and the compiled binary all
576
- * take it from here; see ubugeeei-prod/uf#389.
1717
+ * the host for a reason that is easy to get wrong: `@uniflowed/server` shares
1718
+ * its request store between copies of one *release* of itself, and a bundled
1719
+ * application has its own copy inlined. A host that imported `beginRequest`
1720
+ * from its own `node_modules` could be holding another release, would
1721
+ * establish a request in a store the application never reads, and every
1722
+ * `cookies()` in the application would still be outside one. So the bundle
1723
+ * hands the host the entry point that belongs to the bundle. `uf preview`,
1724
+ * `uf start`, `uf dev` and the compiled binary all take it from here; see
1725
+ * ubugeeei-prod/uf#389.
577
1726
  *
578
1727
  * Through `@uniflowed/router/server` rather than `@uniflowed/server/host`,
579
1728
  * because this source is resolved from the *project's* directory and a project
@@ -581,20 +1730,84 @@ hydrate({ App, routes, notFound, errors });
581
1730
  * shorter proof of the paragraph above: the copy the router dispatches and
582
1731
  * renders with is by construction the copy the host is handed.
583
1732
  */
584
- export function serverModuleSource(appEntry) {
1733
+ export function serverModuleSource(appEntry, routing = routingRulesOf({}), instrumentation = null) {
585
1734
  return `import {
1735
+ createActionDispatcher,
1736
+ createInstrumentation,
1737
+ instrumentRender,
1738
+ traceRequestPhase,
586
1739
  createDispatcher,
587
1740
  createMiddlewareRunner,
588
1741
  createRenderer,
1742
+ installRouting,
589
1743
  } from "@uniflowed/router/server";
590
1744
  import { routes, handlers, middleware, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
1745
+ import { actions } from ${JSON.stringify(VIRTUAL.actions)};
591
1746
  import App from ${JSON.stringify(appEntry)};
1747
+ ${routingExportSource(routing)}installRouting(routing);
592
1748
  export { routes, handlers, middleware, notFound, errors };
593
- export { beginRequest } from "@uniflowed/router/server";
1749
+ ${instrumentation == null ? "" : `import * as hooks from ${JSON.stringify(instrumentation)};`}
1750
+ export const { beginRequest } = createInstrumentation(${instrumentation == null ? "" : "hooks"});
594
1751
  const renderer = createRenderer({ App, routes, notFound, errors });
595
- export const render = renderer.render;
1752
+ export const render = (url, assets, options = {}) => instrumentRender(
1753
+ (onError) => renderer.render(url, assets, { ...options, onError }), options.onError,
1754
+ );
596
1755
  export const prerender = renderer.prerender;
597
- export const dispatch = createDispatcher({ handlers });
598
- export const runMiddleware = createMiddlewareRunner({ middleware });
1756
+ export { shellDocument } from "@uniflowed/router/server";
1757
+ const dispatchRoute = createDispatcher({ handlers });
1758
+ export const dispatch = (request) => traceRequestPhase("route", () => dispatchRoute(request));
1759
+ export const callAction = createActionDispatcher({ actions });
1760
+ const guard = createMiddlewareRunner({ middleware });
1761
+ export const runMiddleware = (request) => traceRequestPhase("middleware", () => guard(request));
599
1762
  `;
600
1763
  }
1764
+
1765
+ /**
1766
+ * `app.router.redirects`, `rewrites` and `headers`, as the bundle carries them.
1767
+ *
1768
+ * Three lists and nothing else, each present, so a host reads `routing` the one
1769
+ * way whatever the project wrote. Validation is not here: `uf_config` refuses a
1770
+ * rule it cannot read when the file is loaded, with a sentence per spelling,
1771
+ * and `@uniflowed/server`'s `internal/routing.js` is what interprets one.
1772
+ *
1773
+ * @param {{redirects?: unknown[], rewrites?: unknown[], headers?: unknown[]} | undefined} router
1774
+ */
1775
+ export function routingRulesOf(router) {
1776
+ const policy = router?.trailingSlash;
1777
+ return {
1778
+ redirects: Array.isArray(router?.redirects) ? router.redirects : [],
1779
+ rewrites: Array.isArray(router?.rewrites) ? router.rewrites : [],
1780
+ headers: Array.isArray(router?.headers) ? router.headers : [],
1781
+ basePath: typeof router?.basePath === "string" ? router.basePath.replace(/\/+$/, "") : "",
1782
+ trailingSlash: policy === "never" || policy === "always" ? policy : "ignore",
1783
+ };
1784
+ }
1785
+
1786
+ /**
1787
+ * `basePath` and `trailingSlash` as arguments to a client entry's call, or
1788
+ * nothing for a project at the root with the default policy — so a default
1789
+ * project's entry is the module it has always been.
1790
+ *
1791
+ * @param {{basePath?: string, trailingSlash?: string} | undefined} routing
1792
+ */
1793
+ export function routingArgumentSource(routing) {
1794
+ const basePath = routing?.basePath ?? "";
1795
+ const trailingSlash = routing?.trailingSlash ?? "ignore";
1796
+ let source = "";
1797
+ if (basePath !== "") source += `, basePath: ${JSON.stringify(basePath)}`;
1798
+ if (trailingSlash !== "ignore") source += `, trailingSlash: ${JSON.stringify(trailingSlash)}`;
1799
+ return source;
1800
+ }
1801
+
1802
+ /**
1803
+ * The `routing` export of `virtual:uf/server`.
1804
+ *
1805
+ * On the bundle rather than read from `uf.config.js` where a host starts, so a
1806
+ * served build answers with the rules it was built with — `uf start` of last
1807
+ * week's build, and every `--adapter` artefact, carry their own.
1808
+ *
1809
+ * @param {ReturnType<typeof routingRulesOf>} routing
1810
+ */
1811
+ export function routingExportSource(routing) {
1812
+ return `export const routing = ${JSON.stringify(routing)};\n`;
1813
+ }