@uniflowed/vite 0.0.0-alpha.4 → 0.0.0-alpha.40

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