@uniflowed/vite 0.0.0-alpha.18 → 0.0.0-alpha.20

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,14 +19,15 @@ 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
- template: "_uf.template",
24
- page: "_uf.page",
25
- middleware: "_uf.middleware",
26
- notFound: "_uf.not-found",
27
- error: "_uf.error",
28
- loading: "_uf.loading",
29
- 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",
30
31
  });
31
32
 
32
33
  /**
@@ -34,26 +35,45 @@ export const RESERVED = Object.freeze({
34
35
  *
35
36
  * One spelling each, and they are here so `crates/uf_router/tests/
36
37
  * reserved_names.rs` can hold this router and `uf_router::RouteSegment` to the
37
- * same list — the way it already holds the two to the same `_uf.*` roles. A
38
+ * same list — the way it already holds the two to the same `$*` roles. A
38
39
  * spelling one router refuses and the other serves as a URL is exactly the
39
40
  * disagreement that made this necessary.
40
41
  *
41
- * `@team` is Next.js's parallel-route slot and `(.)photo` its intercepting
42
- * route. uf has neither, and until #267 both fell through to "a literal URL
43
- * segment": `@team` became `/@team`, `(.)photo` became `/(.)photo` — the test
44
- * for a `(group)` is that the segment *ends* in `)` — and the generated
42
+ * `(.)photo` is Next.js's intercepting route. `@team`, its parallel-route
43
+ * slot, was on this list too: until #267 both fell through to "a literal URL
44
+ * segment", so `@team` became `/@team`, `(.)photo` became `/(.)photo` — the
45
+ * test for a `(group)` is that the segment *ends* in `)` — and the generated
45
46
  * `RoutePath` union contained them, so `route("/@team", …)` type checked. A
46
47
  * convention served as nonsense is worse than one that is refused, because the
47
48
  * project looks like it works.
49
+ *
50
+ * A slot is a route this router serves now; see {@link scanRoutes}. An
51
+ * interception is not: it needs a navigation to carry where it came from,
52
+ * which is a change to what a navigation is rather than to this scan.
48
53
  */
49
54
  export const UNSUPPORTED_SEGMENTS = Object.freeze([
50
- "@team",
51
55
  "(.)photo",
52
56
  "(..)photo",
53
57
  "(...)photo",
54
58
  "(..)(..)photo",
55
59
  ]);
56
60
 
61
+ /**
62
+ * The prop names a layout already receives, which a slot may therefore not
63
+ * take.
64
+ *
65
+ * A slot arrives as a prop named after its directory, so `@children` and
66
+ * `@params` are the two names that would land on top of something the layout
67
+ * already has. `@children` is the one somebody actually writes: `children` is
68
+ * what Next.js calls its implicit slot, so it is the first name a person
69
+ * migrating reaches for — and here the page the URL matched always is
70
+ * `children`.
71
+ *
72
+ * The same list as `uf_router::LAYOUT_PROP_NAMES`; see that file for why the
73
+ * collision is refused rather than resolved by precedence.
74
+ */
75
+ export const LAYOUT_PROP_NAMES = Object.freeze(["children", "params"]);
76
+
57
77
  /** Extensions a page or layout may use; `.mdx` is a page written as content. */
58
78
  const PAGE_EXTENSIONS = [".js", ".jsx", ".mdx"];
59
79
  const MODULE_EXTENSIONS = [".js", ".jsx"];
@@ -74,7 +94,62 @@ const MAX_DEPTH = 32;
74
94
  * `<Suspense>` boundaries in scope, root first; `above` is how many of
75
95
  * `layouts` are outside each one
76
96
  * @property {ReadonlyArray<{above: number, module: string}>} templates the
77
- * `_uf.template.js` wrappers in scope, root first, with the same `above`
97
+ * `$template.js` wrappers in scope, root first, with the same `above`
98
+ * @property {ReadonlyArray<Slot>} slots the parallel-route slots in scope,
99
+ * outermost first
100
+ * @property {boolean} mdx whether the page is MDX content
101
+ */
102
+
103
+ /**
104
+ * One parallel-route slot — a second thing a layout renders, beside its page.
105
+ *
106
+ * A directory named `@team` contributes no URL segment. It declares a slot on
107
+ * the segment that holds it, and that segment's own layout receives the
108
+ * rendered slot as a `team` prop beside `children`. The slot's pages are
109
+ * matched against the same URL the page is, so `app/dashboard/@team/members/
110
+ * $page.js` is what `/dashboard/members` puts in the slot — not a second
111
+ * page at that path.
112
+ *
113
+ * `above` is how many of the route's `layouts` are outside the slot, counted
114
+ * after the declaring segment's own layout is added — so `layouts[above - 1]`
115
+ * is the layout that receives it. It is the same number, spelled the same way,
116
+ * as a template's and a loading boundary's. The layout has to be the segment's
117
+ * *own*: a slot rendered into an inherited layout would be a prop that layout
118
+ * never declared, on every route below it, so {@link scanRoutes} refuses a slot
119
+ * whose segment has no layout of its own.
120
+ *
121
+ * `defaultPage` is the slot's `$default.js`: what it renders when the URL
122
+ * matches none of its routes. A slot with neither a match nor a default
123
+ * renders nothing, which is what an unaddressed slot on a soft navigation does
124
+ * in Next.js too.
125
+ *
126
+ * @typedef {object} Slot
127
+ * @property {string} name the slot's name, without the `@`
128
+ * @property {number} above how many of the route's layouts are outside it
129
+ * @property {?string} defaultPage absolute path of `$default.*`, or `null`
130
+ * @property {boolean} defaultMdx whether that default is MDX content
131
+ * @property {ReadonlyArray<SlotRoute>} routes what the slot may render, by URL
132
+ */
133
+
134
+ /**
135
+ * One page inside a slot.
136
+ *
137
+ * A `Route` without the parts a slot does not have: no `loading`, no
138
+ * `templates`, and no boundary of its own. Those are the segment's, and they
139
+ * already wrap the layout the slot renders into. Per-slot boundaries are the
140
+ * part of parallel routes uf has not built — see
141
+ * https://github.com/ubugeeei-prod/uf/issues/267 — and {@link scanRoutes}
142
+ * refuses the files rather than leaving them unopened.
143
+ *
144
+ * `layouts` are the layouts *inside* the slot, root first; the ones above it
145
+ * are already rendering, since the slot renders into one of them.
146
+ *
147
+ * @typedef {object} SlotRoute
148
+ * @property {string} path route path such as `/dashboard/members`
149
+ * @property {ReadonlyArray<{name: string, catchAll: boolean}>} params
150
+ * @property {string} page absolute path of the page module
151
+ * @property {ReadonlyArray<string>} layouts absolute paths, slot root first
152
+ * @property {ReadonlyArray<Slot>} slots slots declared inside this slot
78
153
  * @property {boolean} mdx whether the page is MDX content
79
154
  */
80
155
 
@@ -107,7 +182,7 @@ const MAX_DEPTH = 32;
107
182
  * One not-found boundary — the page a path under `path` gets when nothing
108
183
  * there matched.
109
184
  *
110
- * A `_uf.not-found.js` is a segment file like `_uf.layout.js`, so a directory
185
+ * A `$not-found.js` is a segment file like `$layout.js`, so a directory
111
186
  * declares the 404 for everything beneath it and the resolver takes the
112
187
  * nearest one above the path. `layouts` are the layouts in scope *at that
113
188
  * directory*, which is what wraps the boundary when it renders.
@@ -128,7 +203,7 @@ const MAX_DEPTH = 32;
128
203
  * The same nearest-ancestor shape as a not-found boundary, and deliberately
129
204
  * not the same extensions: an error module is handed an error and a `reset`,
130
205
  * which is a component's contract. `.mdx` compiles to a component that takes
131
- * no such thing, so a `_uf.error.mdx` would be a file the router loads and can
206
+ * no such thing, so a `$error.mdx` would be a file the router loads and can
132
207
  * never hand its arguments to.
133
208
  *
134
209
  * @typedef {object} ErrorBoundary
@@ -144,8 +219,8 @@ const MAX_DEPTH = 32;
144
219
  * Not the nearest-ancestor shape the other two boundaries have, and the
145
220
  * difference is the whole of what a fallback is. A not-found or an error
146
221
  * boundary is *chosen*: one of them renders, and the resolver picks the
147
- * nearest above the path. Loading boundaries *nest*: `app/_uf.loading.js` and
148
- * `app/docs/_uf.loading.js` are two `<Suspense>` elements on one route, one
222
+ * nearest above the path. Loading boundaries *nest*: `app/$loading.js` and
223
+ * `app/docs/$loading.js` are two `<Suspense>` elements on one route, one
149
224
  * inside the other, and both are in the tree at once. So they accumulate down
150
225
  * the walk the way layouts do rather than being matched afterwards, and each
151
226
  * route carries the list that applies to it.
@@ -167,9 +242,16 @@ const MAX_DEPTH = 32;
167
242
  * Directories that do not exist yield an empty table rather than an error: a
168
243
  * library project has no router root, and that is not a mistake.
169
244
  *
170
- * Throws for a directory named the way a parallel route or an intercepting
171
- * route is spelled: uf has neither, and both used to become literal URL
172
- * segments. See {@link UNSUPPORTED_SEGMENTS}.
245
+ * Throws for a directory named the way an intercepting route is spelled: uf
246
+ * does not have interception, and the spelling used to become a literal URL
247
+ * segment. See {@link UNSUPPORTED_SEGMENTS}.
248
+ *
249
+ * A `@slot` directory is a parallel route and is scanned; see {@link Slot}. It
250
+ * throws for the three ways one can be written without being renderable: a
251
+ * slot on a segment with no layout of its own, a `$default.js` that is not
252
+ * directly inside a slot, and a boundary or a handler inside a slot. Each is a
253
+ * file the router would otherwise never open, which is the failure #267 is
254
+ * about.
173
255
  *
174
256
  * @param {string} appRoot absolute path of the router root (`app/`)
175
257
  * @returns {{
@@ -192,12 +274,25 @@ export function scanRoutes(appRoot) {
192
274
  // records below are made of them. See the note beside them.
193
275
  let rootLayouts = [];
194
276
 
195
- const walk = (directory, segments, layouts, loading, templates, depth) => {
277
+ const walk = (directory, segments, layouts, loading, templates, slots, depth) => {
196
278
  if (depth > MAX_DEPTH) return;
197
279
  const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
198
280
  a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
199
281
  );
200
282
 
283
+ // A `$default.js` answers one question — what a slot renders when the
284
+ // URL says nothing about it — and this walk is everywhere a slot is not,
285
+ // so one found here is a file nothing would ever open.
286
+ const strayDefault = findModule(directory, RESERVED.default, PAGE_EXTENSIONS);
287
+ if (strayDefault != null) {
288
+ throw new Error(
289
+ `${strayDefault}: \`$default.js\` is what a \`@slot\` renders when the URL says ` +
290
+ "nothing about it, and it belongs directly inside the slot directory — one per slot, " +
291
+ "beside that slot's own pages. Nothing would ever render this one. uf has no " +
292
+ "`default` for `children`: a URL that matches no page is a 404.",
293
+ );
294
+ }
295
+
201
296
  const ownLayout = findModule(directory, RESERVED.layout, MODULE_EXTENSIONS);
202
297
  const nextLayouts = ownLayout ? [...layouts, ownLayout] : layouts;
203
298
  if (depth === 0) {
@@ -226,9 +321,32 @@ export function scanRoutes(appRoot) {
226
321
  ? [...templates, { above: nextLayouts.length, module: ownTemplate }]
227
322
  : templates;
228
323
 
324
+ // Slots before this directory's own page, because the page renders inside
325
+ // the layout that holds them: a slot declared here belongs to every route
326
+ // at or below this segment, the way a template does.
327
+ let nextSlots = slots;
328
+ for (const entry of entries) {
329
+ if (!entry.isDirectory()) continue;
330
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
331
+ const classified = classifyRouteSegment(entry.name);
332
+ if (classified.kind !== "slot") continue;
333
+ nextSlots = [
334
+ ...nextSlots,
335
+ scanSlot(
336
+ directory,
337
+ entry.name,
338
+ classified.name,
339
+ segments,
340
+ ownLayout,
341
+ nextLayouts.length,
342
+ depth,
343
+ ),
344
+ ];
345
+ }
346
+
229
347
  // A middleware guards this directory and everything below it, whether or
230
- // not this directory is itself a route: `app/dashboard/_uf.middleware.js`
231
- // with no `_uf.page.js` beside it still guards `/dashboard/settings`.
348
+ // not this directory is itself a route: `app/dashboard/$middleware.js`
349
+ // with no `$page.js` beside it still guards `/dashboard/settings`.
232
350
  const ownMiddleware = findModule(directory, RESERVED.middleware, MODULE_EXTENSIONS);
233
351
  if (ownMiddleware) {
234
352
  middleware.push({ path: routeFromSegments(segments).path, module: ownMiddleware });
@@ -245,6 +363,7 @@ export function scanRoutes(appRoot) {
245
363
  layouts: nextLayouts,
246
364
  loading: nextLoading,
247
365
  templates: nextTemplates,
366
+ slots: nextSlots,
248
367
  mdx: page.endsWith(".mdx"),
249
368
  });
250
369
  }
@@ -258,7 +377,7 @@ export function scanRoutes(appRoot) {
258
377
  }
259
378
 
260
379
  // At every depth, not only the root. This read `if (depth === 0)`, so
261
- // `app/guide/_uf.not-found.js` was never looked for and a reader who
380
+ // `app/guide/$not-found.js` was never looked for and a reader who
262
381
  // followed a stale link into the manual was answered by the site's root
263
382
  // 404, outside the manual's own layout. See ubugeeei-prod/uf#263.
264
383
  const ownNotFound = findModule(directory, RESERVED.notFound, PAGE_EXTENSIONS);
@@ -272,7 +391,7 @@ export function scanRoutes(appRoot) {
272
391
  }
273
392
 
274
393
  // `errors` is the boundaries a project declares, not failures that
275
- // happened: one entry per directory holding an `_uf.error.js`.
394
+ // happened: one entry per directory holding an `$error.js`.
276
395
  const ownError = findModule(directory, RESERVED.error, MODULE_EXTENSIONS);
277
396
  if (ownError) {
278
397
  errors.push({
@@ -287,9 +406,11 @@ export function scanRoutes(appRoot) {
287
406
  // A leading dot or underscore is private to the author: `_components/`
288
407
  // beside a page is a place to put things, not a route.
289
408
  if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
409
+ // Already walked, above, into a table of its own.
410
+ if (classifyRouteSegment(entry.name).kind === "slot") continue;
290
411
  // Checked before descending, and after the private-directory test for
291
- // the same reason `uf_router` prunes them: `app/_drafts/@team/` is not a
292
- // route uf would have served, so it is not one to refuse.
412
+ // the same reason `uf_router` prunes them: `app/_drafts/(.)photo/` is not
413
+ // a route uf would have served, so it is not one to refuse.
293
414
  const refused = unsupportedSegmentReason(entry.name);
294
415
  if (refused != null) {
295
416
  throw new Error(`${path.join(directory, entry.name)}: ${refused}`);
@@ -300,12 +421,13 @@ export function scanRoutes(appRoot) {
300
421
  nextLayouts,
301
422
  nextLoading,
302
423
  nextTemplates,
424
+ nextSlots,
303
425
  depth + 1,
304
426
  );
305
427
  }
306
428
  };
307
429
 
308
- walk(appRoot, [], [], [], [], 0);
430
+ walk(appRoot, [], [], [], [], [], 0);
309
431
 
310
432
  // A boundary at the router root for a project that declared none, carrying
311
433
  // the root's layouts and no module of its own.
@@ -319,7 +441,7 @@ export function scanRoutes(appRoot) {
319
441
  // new project is in until it writes one. See ubugeeei-prod/uf#351.
320
442
  //
321
443
  // Only when nothing is at `/` already. A `(group)` directory is not a URL
322
- // segment, so `app/(marketing)/_uf.not-found.js` is a boundary at `/` too and
444
+ // segment, so `app/(marketing)/$not-found.js` is a boundary at `/` too and
323
445
  // adding a second one there would put a second answer at a path the URL
324
446
  // cannot choose between.
325
447
  const atRoot = (boundaries) => boundaries.some((boundary) => boundary.path === "/");
@@ -342,7 +464,7 @@ export function scanRoutes(appRoot) {
342
464
  // by nearness would hide that.
343
465
  //
344
466
  // Two boundaries can share a path, because a `(group)` directory is not a URL
345
- // segment — `app/_uf.not-found.js` and `app/(marketing)/_uf.not-found.js` are
467
+ // segment — `app/$not-found.js` and `app/(marketing)/$not-found.js` are
346
468
  // both at `/`, and the URL cannot say which tree it is in. The sort is stable
347
469
  // and `walk` records a directory's own boundary before descending, so the
348
470
  // shallower file wins, which is the one that is the site's own 404 rather
@@ -353,6 +475,170 @@ export function scanRoutes(appRoot) {
353
475
  return { routes, handlers, middleware, notFound, errors };
354
476
  }
355
477
 
478
+ /**
479
+ * One `@slot` directory, scanned into a {@link Slot}.
480
+ *
481
+ * Separate from `walk` rather than a mode of it, because the two build
482
+ * different things out of the same tree. `walk` builds URLs and the boundaries
483
+ * around them; this builds what one named place may hold, matched against URLs
484
+ * somebody else's directories define. Folding them together would mean a
485
+ * `loading` accumulator that is dead in half the calls and a route table that
486
+ * is dead in the other half.
487
+ *
488
+ * Nested slots are ordinary: a slot's own layout may declare slots of its own,
489
+ * and they are collected here the same way, so the recursion is the shape of
490
+ * the feature rather than a special case.
491
+ *
492
+ * @param {string} parent the directory that declares the slot
493
+ * @param {string} directoryName the slot directory, `@team` as written
494
+ * @param {string} name the slot's name, `team`
495
+ * @param {ReadonlyArray<string>} segments the declaring segments, for the URL
496
+ * @param {?string} ownLayout the declaring segment's own layout, or `null`
497
+ * @param {number} above how many layouts are outside the slot
498
+ * @param {number} depth nesting depth, against `MAX_DEPTH`
499
+ * @returns {Slot}
500
+ */
501
+ function scanSlot(parent, directoryName, name, segments, ownLayout, above, depth) {
502
+ const directory = path.join(parent, directoryName);
503
+ if (LAYOUT_PROP_NAMES.includes(name)) {
504
+ throw new Error(
505
+ `${directory}: a slot arrives as a prop named after its directory, and \`${name}\` is a ` +
506
+ "prop every layout already receives, so one of the two would silently go missing. " +
507
+ "Rename the slot. The page a URL matches is always `children` — uf has no `@children` " +
508
+ "slot, which is the name Next.js gives that page.",
509
+ );
510
+ }
511
+ // The declaring segment's *own* layout, not the layouts in scope there. A
512
+ // slot is a prop that layout receives beside `children`, so a slot on a
513
+ // segment with no layout has nothing to render into — and rendering it into
514
+ // an inherited one would hand a prop to a layout that never declared it, on
515
+ // every route below.
516
+ if (ownLayout == null) {
517
+ const routePath = routeFromSegments(segments).path;
518
+ throw new Error(
519
+ `${directory}: \`${directoryName}\` is a parallel-route slot and \`${routePath}\` declares ` +
520
+ "no layout of its own, so there is nothing to render the slot into — a slot is a prop " +
521
+ "the segment's own layout receives beside `children`. Add " +
522
+ `\`${path.join(parent, `${RESERVED.layout}.js`)}\`, or move the slot to a segment that ` +
523
+ "has one.",
524
+ );
525
+ }
526
+
527
+ const routes = [];
528
+ const defaultPage = findModule(directory, RESERVED.default, PAGE_EXTENSIONS);
529
+
530
+ const walkSlot = (current, currentSegments, layouts, atSlotRoot, currentDepth) => {
531
+ if (currentDepth > MAX_DEPTH) return;
532
+ // What a slot does not have, said where somebody writing the file will
533
+ // read it rather than by never opening it. This is also the list of what
534
+ // is left of parallel routes; see the issue.
535
+ for (const role of [RESERVED.notFound, RESERVED.error, RESERVED.loading, RESERVED.template]) {
536
+ const found =
537
+ findModule(current, role, MODULE_EXTENSIONS) ?? findModule(current, role, PAGE_EXTENSIONS);
538
+ if (found != null) {
539
+ throw new Error(
540
+ `${found}: a \`@slot\` renders a page and the layouts inside the slot, and has no ` +
541
+ `\`${role.slice("$".length)}\` of its own — uf's parallel routes do not carry ` +
542
+ "per-slot boundaries yet, so this file would never be opened. Put it outside " +
543
+ `\`${directoryName}\`, where it covers the whole segment. ` +
544
+ "https://github.com/ubugeeei-prod/uf/issues/267",
545
+ );
546
+ }
547
+ }
548
+ for (const role of [RESERVED.route, RESERVED.middleware]) {
549
+ const found = findModule(current, role, MODULE_EXTENSIONS);
550
+ if (found != null) {
551
+ throw new Error(
552
+ `${found}: a \`@slot\` renders inside the page at a URL and answers no request of its ` +
553
+ `own, so \`${role}.js\` here would never run. A slot directory contributes no URL ` +
554
+ `segment, so this would claim \`${routeFromSegments(currentSegments).path}\` — which ` +
555
+ `belongs to the segment that declares the slot. Move it out of \`${directoryName}\`.`,
556
+ );
557
+ }
558
+ }
559
+ // One default per slot, at the slot. A deeper one would be a second answer
560
+ // to a question that is asked once — the URL either addressed this slot or
561
+ // it did not.
562
+ if (!atSlotRoot && findModule(current, RESERVED.default, PAGE_EXTENSIONS) != null) {
563
+ throw new Error(
564
+ `${findModule(current, RESERVED.default, PAGE_EXTENSIONS)}: a \`@slot\` has one ` +
565
+ `\`$default.js\`, directly inside \`${directoryName}\`, and this one is deeper, so ` +
566
+ "nothing would ever render it.",
567
+ );
568
+ }
569
+
570
+ const layoutHere = findModule(current, RESERVED.layout, MODULE_EXTENSIONS);
571
+ const nextLayouts = layoutHere ? [...layouts, layoutHere] : layouts;
572
+
573
+ const entries = readdirSync(current, { withFileTypes: true }).sort((a, b) =>
574
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
575
+ );
576
+
577
+ let nestedSlots = [];
578
+ for (const entry of entries) {
579
+ if (!entry.isDirectory()) continue;
580
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
581
+ const classified = classifyRouteSegment(entry.name);
582
+ if (classified.kind !== "slot") continue;
583
+ nestedSlots = [
584
+ ...nestedSlots,
585
+ scanSlot(
586
+ current,
587
+ entry.name,
588
+ classified.name,
589
+ currentSegments,
590
+ layoutHere,
591
+ nextLayouts.length,
592
+ currentDepth,
593
+ ),
594
+ ];
595
+ }
596
+
597
+ const page = findModule(current, RESERVED.page, PAGE_EXTENSIONS);
598
+ if (page) {
599
+ const { path: routePath, params } = routeFromSegments(currentSegments);
600
+ routes.push({
601
+ path: routePath,
602
+ params,
603
+ page,
604
+ layouts: nextLayouts,
605
+ slots: nestedSlots,
606
+ mdx: page.endsWith(".mdx"),
607
+ });
608
+ }
609
+
610
+ for (const entry of entries) {
611
+ if (!entry.isDirectory()) continue;
612
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
613
+ const classified = classifyRouteSegment(entry.name);
614
+ if (classified.kind === "slot") continue;
615
+ const refused = unsupportedSegmentReason(entry.name);
616
+ if (refused != null) {
617
+ throw new Error(`${path.join(current, entry.name)}: ${refused}`);
618
+ }
619
+ walkSlot(
620
+ path.join(current, entry.name),
621
+ [...currentSegments, entry.name],
622
+ nextLayouts,
623
+ false,
624
+ currentDepth + 1,
625
+ );
626
+ }
627
+ };
628
+
629
+ walkSlot(directory, segments, [], true, depth + 1);
630
+
631
+ const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
632
+ routes.sort(byPath);
633
+ return {
634
+ name,
635
+ above,
636
+ defaultPage,
637
+ defaultMdx: defaultPage != null && defaultPage.endsWith(".mdx"),
638
+ routes,
639
+ };
640
+ }
641
+
356
642
  function isDirectory(candidate) {
357
643
  try {
358
644
  return statSync(candidate).isDirectory();
@@ -436,15 +722,6 @@ function interceptionMarker(segment) {
436
722
  */
437
723
  export function unsupportedSegmentReason(segment) {
438
724
  const classified = classifyRouteSegment(segment);
439
- if (classified.kind === "slot") {
440
- return (
441
- `\`${segment}\` is a parallel-route slot, and uf does not have parallel routes — a route ` +
442
- "here renders in one place, so there is nothing for a slot to render into. It is refused " +
443
- `rather than served as the URL segment \`/${segment}\`, which is what it used to become. ` +
444
- "Rename the directory; a URL segment that really starts with `@` has no spelling in this " +
445
- "grammar, so capture it with a `[param]`. https://github.com/ubugeeei-prod/uf/issues/267"
446
- );
447
- }
448
725
  if (classified.kind === "interception") {
449
726
  return (
450
727
  `\`${segment}\` is an intercepting route, and uf does not have interception — a navigation ` +
@@ -461,16 +738,19 @@ export function unsupportedSegmentReason(segment) {
461
738
  * Turn directory segments into a route path and its parameters.
462
739
  *
463
740
  * `(group)` segments organise files without appearing in the URL, `[name]`
464
- * captures one segment, and `[...name]` captures the rest of the path. A slot
465
- * or an interception never reaches here: {@link scanRoutes} refuses the
466
- * directory before it walks into it.
741
+ * captures one segment, and `[...name]` captures the rest of the path. A
742
+ * `@slot` contributes nothing either — it is a named place a route renders
743
+ * into, matched against the URL of the segment that declares it — so a slot's
744
+ * pages are matched against ordinary paths and add none of their own. An
745
+ * interception never reaches here: {@link scanRoutes} refuses the directory
746
+ * before it walks into it.
467
747
  */
468
748
  export function routeFromSegments(segments) {
469
749
  const params = [];
470
750
  const out = [];
471
751
  for (const segment of segments) {
472
752
  const classified = classifyRouteSegment(segment);
473
- if (classified.kind === "group") continue;
753
+ if (classified.kind === "group" || classified.kind === "slot") continue;
474
754
  if (classified.kind === "catchAll") {
475
755
  params.push({ name: classified.name, catchAll: true });
476
756
  out.push(`:${classified.name}*`);
@@ -557,7 +837,7 @@ export const VIRTUAL = Object.freeze({
557
837
  *
558
838
  * The server's table can hold an absolute path; it is read on the machine that
559
839
  * has those files. The browser's cannot, because that table is downloaded:
560
- * uf's own manual shipped `/home/<user>/…/docs/app/guide/cache/_uf.page.mdx`
840
+ * uf's own manual shipped `/home/<user>/…/docs/app/guide/cache/$page.mdx`
561
841
  * for each of thirty-four routes to every visitor, which publishes the build
562
842
  * machine's layout and its user's name for nothing — the browser has no
563
843
  * filesystem to resolve them against and reads them only in a message.
@@ -609,7 +889,7 @@ export function routesModuleSource(table, options = {}) {
609
889
  };
610
890
 
611
891
  // Loading modules are deduplicated into a table of their own, for the reason
612
- // layouts are: one `app/_uf.loading.js` is the fallback of every route under
892
+ // layouts are: one `app/$loading.js` is the fallback of every route under
613
893
  // it, and fifty copies of the same `import()` would be fifty chunks of the
614
894
  // same file.
615
895
  //
@@ -632,7 +912,7 @@ export function routesModuleSource(table, options = {}) {
632
912
  };
633
913
 
634
914
  // Templates are deduplicated for the reason layouts are — one
635
- // `app/_uf.template.js` wraps every route under it — and are lazy for the
915
+ // `app/$template.js` wraps every route under it — and are lazy for the
636
916
  // reason layouts are too: a template is part of the route's own tree rather
637
917
  // than a fallback React has to have in hand at the moment something goes
638
918
  // wrong, so it is awaited with the layouts before the first render.
@@ -648,6 +928,59 @@ export function routesModuleSource(table, options = {}) {
648
928
  return id;
649
929
  };
650
930
 
931
+ // Slots are hoisted like layouts and deduplicated by identity rather than by
932
+ // file: one `scanRoutes` slot record is shared by every route at or below the
933
+ // segment that declares it, so the object is the key. A slot holds a whole
934
+ // route table of its own, and emitting it once per route below it would be
935
+ // that table copied into the bundle once per route.
936
+ //
937
+ // Nested slots are emitted before the slot that holds them, because a `const`
938
+ // cannot read one declared after it.
939
+ const slotIds = new Map();
940
+ const slotDefinitions = [];
941
+ const slotFiles = new Set();
942
+ const slotId = (slot) => {
943
+ let id = slotIds.get(slot);
944
+ if (id !== undefined) {
945
+ return id;
946
+ }
947
+ const routes = slot.routes.map((route) => {
948
+ slotFiles.add(route.page);
949
+ for (const file of route.layouts) slotFiles.add(file);
950
+ const nested = route.slots.map(slotId);
951
+ return ` {
952
+ path: ${JSON.stringify(route.path)},
953
+ params: ${JSON.stringify(route.params)},
954
+ mdx: ${route.mdx},
955
+ file: ${JSON.stringify(displayFile(route.page))},
956
+ page: () => import(${JSON.stringify(route.page)}),
957
+ layouts: [${route.layouts.map(layoutId).join(", ")}],
958
+ slots: [${nested.join(", ")}],
959
+ }`;
960
+ });
961
+ // After the routes, so a nested slot's `const` is already emitted.
962
+ id = `slot${slotIds.size}`;
963
+ slotIds.set(slot, id);
964
+ if (slot.defaultPage != null) {
965
+ slotFiles.add(slot.defaultPage);
966
+ }
967
+ const fallback =
968
+ slot.defaultPage == null
969
+ ? " defaultPage: null,"
970
+ : ` defaultPage: () => import(${JSON.stringify(slot.defaultPage)}),
971
+ defaultFile: ${JSON.stringify(displayFile(slot.defaultPage))},`;
972
+ slotDefinitions.push(`const ${id} = {
973
+ name: ${JSON.stringify(slot.name)},
974
+ above: ${slot.above},
975
+ ${fallback}
976
+ defaultMdx: ${slot.defaultMdx},
977
+ routes: [
978
+ ${routes.join(",\n")}
979
+ ],
980
+ };`);
981
+ return id;
982
+ };
983
+
651
984
  const entries = table.routes.map((route) => {
652
985
  if (!shipsPage(route)) {
653
986
  return ` {
@@ -658,6 +991,7 @@ export function routesModuleSource(table, options = {}) {
658
991
  layouts: [],
659
992
  loading: [],
660
993
  templates: [],
994
+ slots: [],
661
995
  }`;
662
996
  }
663
997
  const layouts = route.layouts.map(layoutId);
@@ -667,6 +1001,7 @@ export function routesModuleSource(table, options = {}) {
667
1001
  const templates = (route.templates ?? []).map(
668
1002
  (entry) => `{ above: ${entry.above}, module: ${templateId(entry.module)} }`,
669
1003
  );
1004
+ const slots = (route.slots ?? []).map(slotId);
670
1005
  return ` {
671
1006
  path: ${JSON.stringify(route.path)},
672
1007
  params: ${JSON.stringify(route.params)},
@@ -676,6 +1011,7 @@ export function routesModuleSource(table, options = {}) {
676
1011
  layouts: [${layouts.join(", ")}],
677
1012
  loading: [${loading.join(", ")}],
678
1013
  templates: [${templates.join(", ")}],
1014
+ slots: [${slots.join(", ")}],
679
1015
  }`;
680
1016
  });
681
1017
 
@@ -743,7 +1079,12 @@ export function routesModuleSource(table, options = {}) {
743
1079
  // Last, because it is defined by what everything above did *not* import: a
744
1080
  // layout a kept route also uses is already in the graph as a lazy chunk, and
745
1081
  // importing it here as well would pull it into the entry chunk instead.
746
- const carried = new Set([...layoutIds.keys(), ...loadingIds.keys(), ...templateIds.keys()]);
1082
+ const carried = new Set([
1083
+ ...layoutIds.keys(),
1084
+ ...loadingIds.keys(),
1085
+ ...templateIds.keys(),
1086
+ ...slotFiles,
1087
+ ]);
747
1088
  const styleOnlyImports = [];
748
1089
  for (const route of table.routes) {
749
1090
  if (shipsPage(route)) {
@@ -754,6 +1095,7 @@ export function routesModuleSource(table, options = {}) {
754
1095
  ...route.layouts,
755
1096
  ...(route.loading ?? []).map((it) => it.module),
756
1097
  ...(route.templates ?? []).map((it) => it.module),
1098
+ ...slotModuleFiles(route.slots ?? []),
757
1099
  ];
758
1100
  for (const file of files) {
759
1101
  if (carried.has(file)) {
@@ -764,7 +1106,7 @@ export function routesModuleSource(table, options = {}) {
764
1106
  }
765
1107
  }
766
1108
 
767
- return `${[...styleOnlyImports, ...layoutImports, ...loadingImports, ...templateImports].join("\n")}
1109
+ return `${[...styleOnlyImports, ...layoutImports, ...loadingImports, ...templateImports, ...slotDefinitions].join("\n")}
768
1110
  export const routes = [
769
1111
  ${entries.join(",\n")}
770
1112
  ];
@@ -784,6 +1126,27 @@ export default routes;
784
1126
  `;
785
1127
  }
786
1128
 
1129
+ /**
1130
+ * Every module a slot tree holds, flattened.
1131
+ *
1132
+ * For the side-effect imports a dropped route needs: a slot's pages, its
1133
+ * layouts and its default are as much a part of that route's stylesheets as
1134
+ * its own page is, and a slot nested inside one is too.
1135
+ *
1136
+ * @param {ReadonlyArray<Slot>} slots
1137
+ * @returns {Array<string>}
1138
+ */
1139
+ function slotModuleFiles(slots) {
1140
+ const files = [];
1141
+ for (const slot of slots) {
1142
+ if (slot.defaultPage != null) files.push(slot.defaultPage);
1143
+ for (const route of slot.routes) {
1144
+ files.push(route.page, ...route.layouts, ...slotModuleFiles(route.slots));
1145
+ }
1146
+ }
1147
+ return files;
1148
+ }
1149
+
787
1150
  /**
788
1151
  * The source of `virtual:uf/client`: hydrate the document with the app.
789
1152
  *
@@ -803,15 +1166,48 @@ export default routes;
803
1166
  * hydrates the way its deployment does, which is the whole of the escape
804
1167
  * hatch.
805
1168
  *
1169
+ * # Navigation is a generated constant for a different reason
1170
+ *
1171
+ * `app.rendering.navigation` decides whether the client router takes a link
1172
+ * over, and it is written in here for the reason Strict Mode is not: it must
1173
+ * be the *same* in development and in the build. A `uf dev` whose links
1174
+ * resolve in the page and a deployment whose links fetch a document are two
1175
+ * applications, and the one a person is looking at is the one that is not
1176
+ * deployed. So this is generated from the config with no `isProduction` beside
1177
+ * it, and `uf dev`, `uf build` and `uf preview` all get what the project asked
1178
+ * for.
1179
+ *
1180
+ * `"client"` is emitted as an absent argument rather than as
1181
+ * `navigation: "client"`, so the module a default project gets is byte for
1182
+ * byte the one it got before this option existed.
1183
+ *
1184
+ * # And whether it hydrates at all
1185
+ *
1186
+ * `mount` is the third generated constant and the one that changes which
1187
+ * function is imported. A `["csr"]` build wrote one shell with an empty root,
1188
+ * so there is no markup to attach to and `render` is what starts the
1189
+ * application; every other build has markup, and `hydrate` attaches to it.
1190
+ *
1191
+ * One import or the other, rather than one import and a branch, because they
1192
+ * are two different React entry points: a bundle that mounts by hydrating has
1193
+ * no reason to carry `createRoot`, and a bundle that renders has none to carry
1194
+ * `hydrateRoot`. Which one is in the module decides which one is in the build.
1195
+ *
806
1196
  * @param {string} appEntry the project's `app.js`, as an import specifier
807
- * @param {{ strictMode?: boolean }} [options]
1197
+ * @param {{
1198
+ * strictMode?: boolean,
1199
+ * navigation?: "client" | "document",
1200
+ * mount?: "hydrate" | "render",
1201
+ * }} [options]
808
1202
  */
809
1203
  export function clientModuleSource(appEntry, options = {}) {
810
1204
  const strictMode = options.strictMode === true ? ", strictMode: true" : "";
811
- return `import { hydrate } from "@uniflowed/router/client";
1205
+ const navigation = options.navigation === "document" ? ', navigation: "document"' : "";
1206
+ const mount = options.mount === "render" ? "render" : "hydrate";
1207
+ return `import { ${mount} } from "@uniflowed/router/client";
812
1208
  import { routes, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
813
1209
  import App from ${JSON.stringify(appEntry)};
814
- hydrate({ App, routes, notFound, errors${strictMode} });
1210
+ ${mount}({ App, routes, notFound, errors${strictMode}${navigation} });
815
1211
  `;
816
1212
  }
817
1213
 
@@ -846,6 +1242,12 @@ hydrate({ App, routes, notFound, errors${strictMode} });
846
1242
  * a host is one or the other: a server streams, a build writes files. See the
847
1243
  * header of `packages/router/server.js` for why React needs both told apart.
848
1244
  *
1245
+ * `shellDocument` is the third and is neither: it renders no route, because a
1246
+ * `["csr"]` build has none to render at build time. It is re-exported straight
1247
+ * from the router rather than closed over the table, which says the true thing
1248
+ * about it — the shell is a function of the assets alone, and the route table
1249
+ * has nothing to do with a document that is no route's.
1250
+ *
849
1251
  * `beginRequest` is the fourth, and it is re-exported rather than imported by
850
1252
  * the host for a reason that is easy to get wrong: `@uniflowed/server` keeps
851
1253
  * the request in an `AsyncLocalStorage` held by *its module*, and a bundled
@@ -877,6 +1279,7 @@ export { beginRequest } from "@uniflowed/router/server";
877
1279
  const renderer = createRenderer({ App, routes, notFound, errors });
878
1280
  export const render = renderer.render;
879
1281
  export const prerender = renderer.prerender;
1282
+ export { shellDocument } from "@uniflowed/router/server";
880
1283
  export const dispatch = createDispatcher({ handlers });
881
1284
  export const callAction = createActionDispatcher({ actions });
882
1285
  export const runMiddleware = createMiddlewareRunner({ middleware });