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

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,30 +35,95 @@ 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"];
60
80
 
81
+ /** Application targets the route scanner knows how to select files for. */
82
+ export const ROUTE_TARGETS = Object.freeze(["web", "native", "ios", "android"]);
83
+
84
+ const TARGET_VARIANTS = Object.freeze({
85
+ web: ["web", null],
86
+ native: ["native", null],
87
+ ios: ["ios", "native", null],
88
+ android: ["android", "native", null],
89
+ });
90
+
91
+ /**
92
+ * The route target a loaded `uf.config.js` and an optional CLI flag describe.
93
+ *
94
+ * `react-native` is accepted as the config-shaped spelling of the same target
95
+ * `uf build --target native` selects. The default follows the framework
96
+ * preset rather than the target list: uf's default list names both web and
97
+ * React Native, so the list is a promise the project should keep satisfying,
98
+ * not the one build to run when none was requested.
99
+ */
100
+ export function resolveRouteTarget(config = {}, requested = null) {
101
+ const app = config.app ?? {};
102
+ const named =
103
+ requested == null || requested === ""
104
+ ? app.framework === "react-native"
105
+ ? "native"
106
+ : "web"
107
+ : requested === "react-native"
108
+ ? "native"
109
+ : requested;
110
+ if (!ROUTE_TARGETS.includes(named)) {
111
+ throw new Error(
112
+ `uf: ${JSON.stringify(named)} is not an application target; choose web, native, ios or android`,
113
+ );
114
+ }
115
+ const declared = app.targets;
116
+ if (Array.isArray(declared)) {
117
+ const needs = named === "web" ? "web" : "react-native";
118
+ if (!declared.includes(needs)) {
119
+ throw new Error(
120
+ `uf: --target ${named} needs app.targets to include ${JSON.stringify(needs)}`,
121
+ );
122
+ }
123
+ }
124
+ return named;
125
+ }
126
+
61
127
  /** Deepest directory nesting the scan will follow. */
62
128
  const MAX_DEPTH = 32;
63
129
 
@@ -74,7 +140,62 @@ const MAX_DEPTH = 32;
74
140
  * `<Suspense>` boundaries in scope, root first; `above` is how many of
75
141
  * `layouts` are outside each one
76
142
  * @property {ReadonlyArray<{above: number, module: string}>} templates the
77
- * `_uf.template.js` wrappers in scope, root first, with the same `above`
143
+ * `$template.js` wrappers in scope, root first, with the same `above`
144
+ * @property {ReadonlyArray<Slot>} slots the parallel-route slots in scope,
145
+ * outermost first
146
+ * @property {boolean} mdx whether the page is MDX content
147
+ */
148
+
149
+ /**
150
+ * One parallel-route slot — a second thing a layout renders, beside its page.
151
+ *
152
+ * A directory named `@team` contributes no URL segment. It declares a slot on
153
+ * the segment that holds it, and that segment's own layout receives the
154
+ * rendered slot as a `team` prop beside `children`. The slot's pages are
155
+ * matched against the same URL the page is, so `app/dashboard/@team/members/
156
+ * $page.js` is what `/dashboard/members` puts in the slot — not a second
157
+ * page at that path.
158
+ *
159
+ * `above` is how many of the route's `layouts` are outside the slot, counted
160
+ * after the declaring segment's own layout is added — so `layouts[above - 1]`
161
+ * is the layout that receives it. It is the same number, spelled the same way,
162
+ * as a template's and a loading boundary's. The layout has to be the segment's
163
+ * *own*: a slot rendered into an inherited layout would be a prop that layout
164
+ * never declared, on every route below it, so {@link scanRoutes} refuses a slot
165
+ * whose segment has no layout of its own.
166
+ *
167
+ * `defaultPage` is the slot's `$default.js`: what it renders when the URL
168
+ * matches none of its routes. A slot with neither a match nor a default
169
+ * renders nothing, which is what an unaddressed slot on a soft navigation does
170
+ * in Next.js too.
171
+ *
172
+ * @typedef {object} Slot
173
+ * @property {string} name the slot's name, without the `@`
174
+ * @property {number} above how many of the route's layouts are outside it
175
+ * @property {?string} defaultPage absolute path of `$default.*`, or `null`
176
+ * @property {boolean} defaultMdx whether that default is MDX content
177
+ * @property {ReadonlyArray<SlotRoute>} routes what the slot may render, by URL
178
+ */
179
+
180
+ /**
181
+ * One page inside a slot.
182
+ *
183
+ * A `Route` without the parts a slot does not have: no `loading`, no
184
+ * `templates`, and no boundary of its own. Those are the segment's, and they
185
+ * already wrap the layout the slot renders into. Per-slot boundaries are the
186
+ * part of parallel routes uf has not built — see
187
+ * https://github.com/ubugeeei-prod/uf/issues/267 — and {@link scanRoutes}
188
+ * refuses the files rather than leaving them unopened.
189
+ *
190
+ * `layouts` are the layouts *inside* the slot, root first; the ones above it
191
+ * are already rendering, since the slot renders into one of them.
192
+ *
193
+ * @typedef {object} SlotRoute
194
+ * @property {string} path route path such as `/dashboard/members`
195
+ * @property {ReadonlyArray<{name: string, catchAll: boolean}>} params
196
+ * @property {string} page absolute path of the page module
197
+ * @property {ReadonlyArray<string>} layouts absolute paths, slot root first
198
+ * @property {ReadonlyArray<Slot>} slots slots declared inside this slot
78
199
  * @property {boolean} mdx whether the page is MDX content
79
200
  */
80
201
 
@@ -107,7 +228,7 @@ const MAX_DEPTH = 32;
107
228
  * One not-found boundary — the page a path under `path` gets when nothing
108
229
  * there matched.
109
230
  *
110
- * A `_uf.not-found.js` is a segment file like `_uf.layout.js`, so a directory
231
+ * A `$not-found.js` is a segment file like `$layout.js`, so a directory
111
232
  * declares the 404 for everything beneath it and the resolver takes the
112
233
  * nearest one above the path. `layouts` are the layouts in scope *at that
113
234
  * directory*, which is what wraps the boundary when it renders.
@@ -128,7 +249,7 @@ const MAX_DEPTH = 32;
128
249
  * The same nearest-ancestor shape as a not-found boundary, and deliberately
129
250
  * not the same extensions: an error module is handed an error and a `reset`,
130
251
  * 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
252
+ * no such thing, so a `$error.mdx` would be a file the router loads and can
132
253
  * never hand its arguments to.
133
254
  *
134
255
  * @typedef {object} ErrorBoundary
@@ -144,8 +265,8 @@ const MAX_DEPTH = 32;
144
265
  * Not the nearest-ancestor shape the other two boundaries have, and the
145
266
  * difference is the whole of what a fallback is. A not-found or an error
146
267
  * 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
268
+ * nearest above the path. Loading boundaries *nest*: `app/$loading.js` and
269
+ * `app/docs/$loading.js` are two `<Suspense>` elements on one route, one
149
270
  * inside the other, and both are in the tree at once. So they accumulate down
150
271
  * the walk the way layouts do rather than being matched afterwards, and each
151
272
  * route carries the list that applies to it.
@@ -167,11 +288,19 @@ const MAX_DEPTH = 32;
167
288
  * Directories that do not exist yield an empty table rather than an error: a
168
289
  * library project has no router root, and that is not a mistake.
169
290
  *
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}.
291
+ * Throws for a directory named the way an intercepting route is spelled: uf
292
+ * does not have interception, and the spelling used to become a literal URL
293
+ * segment. See {@link UNSUPPORTED_SEGMENTS}.
294
+ *
295
+ * A `@slot` directory is a parallel route and is scanned; see {@link Slot}. It
296
+ * throws for the three ways one can be written without being renderable: a
297
+ * slot on a segment with no layout of its own, a `$default.js` that is not
298
+ * directly inside a slot, and a boundary or a handler inside a slot. Each is a
299
+ * file the router would otherwise never open, which is the failure #267 is
300
+ * about.
173
301
  *
174
302
  * @param {string} appRoot absolute path of the router root (`app/`)
303
+ * @param {{target?: "web" | "native" | "ios" | "android"}} [options]
175
304
  * @returns {{
176
305
  * routes: Route[],
177
306
  * handlers: Handler[],
@@ -180,7 +309,8 @@ const MAX_DEPTH = 32;
180
309
  * errors: ErrorBoundary[],
181
310
  * }}
182
311
  */
183
- export function scanRoutes(appRoot) {
312
+ export function scanRoutes(appRoot, options = {}) {
313
+ const target = resolveRouteTarget({}, options.target ?? "web");
184
314
  const routes = [];
185
315
  const handlers = [];
186
316
  const middleware = [];
@@ -192,13 +322,26 @@ export function scanRoutes(appRoot) {
192
322
  // records below are made of them. See the note beside them.
193
323
  let rootLayouts = [];
194
324
 
195
- const walk = (directory, segments, layouts, loading, templates, depth) => {
325
+ const walk = (directory, segments, layouts, loading, templates, slots, depth) => {
196
326
  if (depth > MAX_DEPTH) return;
197
327
  const entries = readdirSync(directory, { withFileTypes: true }).sort((a, b) =>
198
328
  a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
199
329
  );
200
330
 
201
- const ownLayout = findModule(directory, RESERVED.layout, MODULE_EXTENSIONS);
331
+ // A `$default.js` answers one question — what a slot renders when the
332
+ // URL says nothing about it — and this walk is everywhere a slot is not,
333
+ // so one found here is a file nothing would ever open.
334
+ const strayDefault = findModule(directory, RESERVED.default, PAGE_EXTENSIONS, target);
335
+ if (strayDefault != null) {
336
+ throw new Error(
337
+ `${strayDefault}: \`$default.js\` is what a \`@slot\` renders when the URL says ` +
338
+ "nothing about it, and it belongs directly inside the slot directory — one per slot, " +
339
+ "beside that slot's own pages. Nothing would ever render this one. uf has no " +
340
+ "`default` for `children`: a URL that matches no page is a 404.",
341
+ );
342
+ }
343
+
344
+ const ownLayout = findModule(directory, RESERVED.layout, MODULE_EXTENSIONS, target);
202
345
  const nextLayouts = ownLayout ? [...layouts, ownLayout] : layouts;
203
346
  if (depth === 0) {
204
347
  rootLayouts = nextLayouts;
@@ -210,7 +353,7 @@ export function scanRoutes(appRoot) {
210
353
  // `nextLayouts.length` is therefore the count taken after the own layout is
211
354
  // added, not before. A segment with a loading file and no layout of its own
212
355
  // still gets a boundary — it just shares its parent's frame.
213
- const ownLoading = findModule(directory, RESERVED.loading, MODULE_EXTENSIONS);
356
+ const ownLoading = findModule(directory, RESERVED.loading, MODULE_EXTENSIONS, target);
214
357
  const nextLoading = ownLoading
215
358
  ? [...loading, { above: nextLayouts.length, module: ownLoading }]
216
359
  : loading;
@@ -221,20 +364,44 @@ export function scanRoutes(appRoot) {
221
364
  // Every template above a route is on that route, one inside the next, for
222
365
  // the reason every layout is — the difference between the two is a `key`,
223
366
  // not a shape.
224
- const ownTemplate = findModule(directory, RESERVED.template, MODULE_EXTENSIONS);
367
+ const ownTemplate = findModule(directory, RESERVED.template, MODULE_EXTENSIONS, target);
225
368
  const nextTemplates = ownTemplate
226
369
  ? [...templates, { above: nextLayouts.length, module: ownTemplate }]
227
370
  : templates;
228
371
 
372
+ // Slots before this directory's own page, because the page renders inside
373
+ // the layout that holds them: a slot declared here belongs to every route
374
+ // at or below this segment, the way a template does.
375
+ let nextSlots = slots;
376
+ for (const entry of entries) {
377
+ if (!entry.isDirectory()) continue;
378
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
379
+ const classified = classifyRouteSegment(entry.name);
380
+ if (classified.kind !== "slot") continue;
381
+ nextSlots = [
382
+ ...nextSlots,
383
+ scanSlot(
384
+ directory,
385
+ entry.name,
386
+ classified.name,
387
+ segments,
388
+ ownLayout,
389
+ nextLayouts.length,
390
+ target,
391
+ depth,
392
+ ),
393
+ ];
394
+ }
395
+
229
396
  // 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`.
232
- const ownMiddleware = findModule(directory, RESERVED.middleware, MODULE_EXTENSIONS);
397
+ // not this directory is itself a route: `app/dashboard/$middleware.js`
398
+ // with no `$page.js` beside it still guards `/dashboard/settings`.
399
+ const ownMiddleware = findModule(directory, RESERVED.middleware, MODULE_EXTENSIONS, target);
233
400
  if (ownMiddleware) {
234
401
  middleware.push({ path: routeFromSegments(segments).path, module: ownMiddleware });
235
402
  }
236
403
 
237
- const page = findModule(directory, RESERVED.page, PAGE_EXTENSIONS);
404
+ const page = findModule(directory, RESERVED.page, PAGE_EXTENSIONS, target);
238
405
  if (page) {
239
406
  const { path: routePath, pattern, params } = routeFromSegments(segments);
240
407
  routes.push({
@@ -245,23 +412,24 @@ export function scanRoutes(appRoot) {
245
412
  layouts: nextLayouts,
246
413
  loading: nextLoading,
247
414
  templates: nextTemplates,
415
+ slots: nextSlots,
248
416
  mdx: page.endsWith(".mdx"),
249
417
  });
250
418
  }
251
419
  // A handler answers the request itself, so it takes no layouts and is not
252
420
  // MDX. It may sit beside a page: `/feed` can render for a browser and
253
421
  // `/feed.xml` answer for a reader, and both are the same directory tree.
254
- const handler = findModule(directory, RESERVED.route, MODULE_EXTENSIONS);
422
+ const handler = findModule(directory, RESERVED.route, MODULE_EXTENSIONS, target);
255
423
  if (handler) {
256
424
  const { path: routePath, pattern, params } = routeFromSegments(segments);
257
425
  handlers.push({ path: routePath, pattern, params, module: handler });
258
426
  }
259
427
 
260
428
  // 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
429
+ // `app/guide/$not-found.js` was never looked for and a reader who
262
430
  // followed a stale link into the manual was answered by the site's root
263
431
  // 404, outside the manual's own layout. See ubugeeei-prod/uf#263.
264
- const ownNotFound = findModule(directory, RESERVED.notFound, PAGE_EXTENSIONS);
432
+ const ownNotFound = findModule(directory, RESERVED.notFound, PAGE_EXTENSIONS, target);
265
433
  if (ownNotFound) {
266
434
  notFound.push({
267
435
  path: routeFromSegments(segments).path,
@@ -272,8 +440,8 @@ export function scanRoutes(appRoot) {
272
440
  }
273
441
 
274
442
  // `errors` is the boundaries a project declares, not failures that
275
- // happened: one entry per directory holding an `_uf.error.js`.
276
- const ownError = findModule(directory, RESERVED.error, MODULE_EXTENSIONS);
443
+ // happened: one entry per directory holding an `$error.js`.
444
+ const ownError = findModule(directory, RESERVED.error, MODULE_EXTENSIONS, target);
277
445
  if (ownError) {
278
446
  errors.push({
279
447
  path: routeFromSegments(segments).path,
@@ -287,9 +455,11 @@ export function scanRoutes(appRoot) {
287
455
  // A leading dot or underscore is private to the author: `_components/`
288
456
  // beside a page is a place to put things, not a route.
289
457
  if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
458
+ // Already walked, above, into a table of its own.
459
+ if (classifyRouteSegment(entry.name).kind === "slot") continue;
290
460
  // 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.
461
+ // the same reason `uf_router` prunes them: `app/_drafts/(.)photo/` is not
462
+ // a route uf would have served, so it is not one to refuse.
293
463
  const refused = unsupportedSegmentReason(entry.name);
294
464
  if (refused != null) {
295
465
  throw new Error(`${path.join(directory, entry.name)}: ${refused}`);
@@ -300,12 +470,13 @@ export function scanRoutes(appRoot) {
300
470
  nextLayouts,
301
471
  nextLoading,
302
472
  nextTemplates,
473
+ nextSlots,
303
474
  depth + 1,
304
475
  );
305
476
  }
306
477
  };
307
478
 
308
- walk(appRoot, [], [], [], [], 0);
479
+ walk(appRoot, [], [], [], [], [], 0);
309
480
 
310
481
  // A boundary at the router root for a project that declared none, carrying
311
482
  // the root's layouts and no module of its own.
@@ -319,7 +490,7 @@ export function scanRoutes(appRoot) {
319
490
  // new project is in until it writes one. See ubugeeei-prod/uf#351.
320
491
  //
321
492
  // 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
493
+ // segment, so `app/(marketing)/$not-found.js` is a boundary at `/` too and
323
494
  // adding a second one there would put a second answer at a path the URL
324
495
  // cannot choose between.
325
496
  const atRoot = (boundaries) => boundaries.some((boundary) => boundary.path === "/");
@@ -342,7 +513,7 @@ export function scanRoutes(appRoot) {
342
513
  // by nearness would hide that.
343
514
  //
344
515
  // 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
516
+ // segment — `app/$not-found.js` and `app/(marketing)/$not-found.js` are
346
517
  // both at `/`, and the URL cannot say which tree it is in. The sort is stable
347
518
  // and `walk` records a directory's own boundary before descending, so the
348
519
  // shallower file wins, which is the one that is the site's own 404 rather
@@ -353,6 +524,174 @@ export function scanRoutes(appRoot) {
353
524
  return { routes, handlers, middleware, notFound, errors };
354
525
  }
355
526
 
527
+ /**
528
+ * One `@slot` directory, scanned into a {@link Slot}.
529
+ *
530
+ * Separate from `walk` rather than a mode of it, because the two build
531
+ * different things out of the same tree. `walk` builds URLs and the boundaries
532
+ * around them; this builds what one named place may hold, matched against URLs
533
+ * somebody else's directories define. Folding them together would mean a
534
+ * `loading` accumulator that is dead in half the calls and a route table that
535
+ * is dead in the other half.
536
+ *
537
+ * Nested slots are ordinary: a slot's own layout may declare slots of its own,
538
+ * and they are collected here the same way, so the recursion is the shape of
539
+ * the feature rather than a special case.
540
+ *
541
+ * @param {string} parent the directory that declares the slot
542
+ * @param {string} directoryName the slot directory, `@team` as written
543
+ * @param {string} name the slot's name, `team`
544
+ * @param {ReadonlyArray<string>} segments the declaring segments, for the URL
545
+ * @param {?string} ownLayout the declaring segment's own layout, or `null`
546
+ * @param {number} above how many layouts are outside the slot
547
+ * @param {"web" | "native" | "ios" | "android"} target application target
548
+ * @param {number} depth nesting depth, against `MAX_DEPTH`
549
+ * @returns {Slot}
550
+ */
551
+ function scanSlot(parent, directoryName, name, segments, ownLayout, above, target, depth) {
552
+ const directory = path.join(parent, directoryName);
553
+ if (LAYOUT_PROP_NAMES.includes(name)) {
554
+ throw new Error(
555
+ `${directory}: a slot arrives as a prop named after its directory, and \`${name}\` is a ` +
556
+ "prop every layout already receives, so one of the two would silently go missing. " +
557
+ "Rename the slot. The page a URL matches is always `children` — uf has no `@children` " +
558
+ "slot, which is the name Next.js gives that page.",
559
+ );
560
+ }
561
+ // The declaring segment's *own* layout, not the layouts in scope there. A
562
+ // slot is a prop that layout receives beside `children`, so a slot on a
563
+ // segment with no layout has nothing to render into — and rendering it into
564
+ // an inherited one would hand a prop to a layout that never declared it, on
565
+ // every route below.
566
+ if (ownLayout == null) {
567
+ const routePath = routeFromSegments(segments).path;
568
+ throw new Error(
569
+ `${directory}: \`${directoryName}\` is a parallel-route slot and \`${routePath}\` declares ` +
570
+ "no layout of its own, so there is nothing to render the slot into — a slot is a prop " +
571
+ "the segment's own layout receives beside `children`. Add " +
572
+ `\`${path.join(parent, `${RESERVED.layout}.js`)}\`, or move the slot to a segment that ` +
573
+ "has one.",
574
+ );
575
+ }
576
+
577
+ const routes = [];
578
+ const defaultPage = findModule(directory, RESERVED.default, PAGE_EXTENSIONS, target);
579
+
580
+ const walkSlot = (current, currentSegments, layouts, atSlotRoot, currentDepth) => {
581
+ if (currentDepth > MAX_DEPTH) return;
582
+ // What a slot does not have, said where somebody writing the file will
583
+ // read it rather than by never opening it. This is also the list of what
584
+ // is left of parallel routes; see the issue.
585
+ for (const role of [RESERVED.notFound, RESERVED.error, RESERVED.loading, RESERVED.template]) {
586
+ const found =
587
+ findModule(current, role, MODULE_EXTENSIONS, target) ??
588
+ findModule(current, role, PAGE_EXTENSIONS, target);
589
+ if (found != null) {
590
+ throw new Error(
591
+ `${found}: a \`@slot\` renders a page and the layouts inside the slot, and has no ` +
592
+ `\`${role.slice("$".length)}\` of its own — uf's parallel routes do not carry ` +
593
+ "per-slot boundaries yet, so this file would never be opened. Put it outside " +
594
+ `\`${directoryName}\`, where it covers the whole segment. ` +
595
+ "https://github.com/ubugeeei-prod/uf/issues/267",
596
+ );
597
+ }
598
+ }
599
+ for (const role of [RESERVED.route, RESERVED.middleware]) {
600
+ const found = findModule(current, role, MODULE_EXTENSIONS, target);
601
+ if (found != null) {
602
+ throw new Error(
603
+ `${found}: a \`@slot\` renders inside the page at a URL and answers no request of its ` +
604
+ `own, so \`${role}.js\` here would never run. A slot directory contributes no URL ` +
605
+ `segment, so this would claim \`${routeFromSegments(currentSegments).path}\` — which ` +
606
+ `belongs to the segment that declares the slot. Move it out of \`${directoryName}\`.`,
607
+ );
608
+ }
609
+ }
610
+ // One default per slot, at the slot. A deeper one would be a second answer
611
+ // to a question that is asked once — the URL either addressed this slot or
612
+ // it did not.
613
+ const nestedDefault = findModule(current, RESERVED.default, PAGE_EXTENSIONS, target);
614
+ if (!atSlotRoot && nestedDefault != null) {
615
+ throw new Error(
616
+ `${nestedDefault}: a \`@slot\` has one ` +
617
+ `\`$default.js\`, directly inside \`${directoryName}\`, and this one is deeper, so ` +
618
+ "nothing would ever render it.",
619
+ );
620
+ }
621
+
622
+ const layoutHere = findModule(current, RESERVED.layout, MODULE_EXTENSIONS, target);
623
+ const nextLayouts = layoutHere ? [...layouts, layoutHere] : layouts;
624
+
625
+ const entries = readdirSync(current, { withFileTypes: true }).sort((a, b) =>
626
+ a.name < b.name ? -1 : a.name > b.name ? 1 : 0,
627
+ );
628
+
629
+ let nestedSlots = [];
630
+ for (const entry of entries) {
631
+ if (!entry.isDirectory()) continue;
632
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
633
+ const classified = classifyRouteSegment(entry.name);
634
+ if (classified.kind !== "slot") continue;
635
+ nestedSlots = [
636
+ ...nestedSlots,
637
+ scanSlot(
638
+ current,
639
+ entry.name,
640
+ classified.name,
641
+ currentSegments,
642
+ layoutHere,
643
+ nextLayouts.length,
644
+ target,
645
+ currentDepth,
646
+ ),
647
+ ];
648
+ }
649
+
650
+ const page = findModule(current, RESERVED.page, PAGE_EXTENSIONS, target);
651
+ if (page) {
652
+ const { path: routePath, params } = routeFromSegments(currentSegments);
653
+ routes.push({
654
+ path: routePath,
655
+ params,
656
+ page,
657
+ layouts: nextLayouts,
658
+ slots: nestedSlots,
659
+ mdx: page.endsWith(".mdx"),
660
+ });
661
+ }
662
+
663
+ for (const entry of entries) {
664
+ if (!entry.isDirectory()) continue;
665
+ if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
666
+ const classified = classifyRouteSegment(entry.name);
667
+ if (classified.kind === "slot") continue;
668
+ const refused = unsupportedSegmentReason(entry.name);
669
+ if (refused != null) {
670
+ throw new Error(`${path.join(current, entry.name)}: ${refused}`);
671
+ }
672
+ walkSlot(
673
+ path.join(current, entry.name),
674
+ [...currentSegments, entry.name],
675
+ nextLayouts,
676
+ false,
677
+ currentDepth + 1,
678
+ );
679
+ }
680
+ };
681
+
682
+ walkSlot(directory, segments, [], true, depth + 1);
683
+
684
+ const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
685
+ routes.sort(byPath);
686
+ return {
687
+ name,
688
+ above,
689
+ defaultPage,
690
+ defaultMdx: defaultPage != null && defaultPage.endsWith(".mdx"),
691
+ routes,
692
+ };
693
+ }
694
+
356
695
  function isDirectory(candidate) {
357
696
  try {
358
697
  return statSync(candidate).isDirectory();
@@ -361,13 +700,16 @@ function isDirectory(candidate) {
361
700
  }
362
701
  }
363
702
 
364
- function findModule(directory, stem, extensions) {
365
- for (const extension of extensions) {
366
- const candidate = path.join(directory, stem + extension);
367
- try {
368
- if (statSync(candidate).isFile()) return candidate;
369
- } catch {
370
- // keep looking
703
+ function findModule(directory, stem, extensions, target = "web") {
704
+ for (const variant of TARGET_VARIANTS[target] ?? TARGET_VARIANTS.web) {
705
+ for (const extension of extensions) {
706
+ const fileName = variant == null ? `${stem}${extension}` : `${stem}.${variant}${extension}`;
707
+ const candidate = path.join(directory, fileName);
708
+ try {
709
+ if (statSync(candidate).isFile()) return candidate;
710
+ } catch {
711
+ // keep looking
712
+ }
371
713
  }
372
714
  }
373
715
  return null;
@@ -436,15 +778,6 @@ function interceptionMarker(segment) {
436
778
  */
437
779
  export function unsupportedSegmentReason(segment) {
438
780
  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
781
  if (classified.kind === "interception") {
449
782
  return (
450
783
  `\`${segment}\` is an intercepting route, and uf does not have interception — a navigation ` +
@@ -461,16 +794,19 @@ export function unsupportedSegmentReason(segment) {
461
794
  * Turn directory segments into a route path and its parameters.
462
795
  *
463
796
  * `(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.
797
+ * captures one segment, and `[...name]` captures the rest of the path. A
798
+ * `@slot` contributes nothing either — it is a named place a route renders
799
+ * into, matched against the URL of the segment that declares it — so a slot's
800
+ * pages are matched against ordinary paths and add none of their own. An
801
+ * interception never reaches here: {@link scanRoutes} refuses the directory
802
+ * before it walks into it.
467
803
  */
468
804
  export function routeFromSegments(segments) {
469
805
  const params = [];
470
806
  const out = [];
471
807
  for (const segment of segments) {
472
808
  const classified = classifyRouteSegment(segment);
473
- if (classified.kind === "group") continue;
809
+ if (classified.kind === "group" || classified.kind === "slot") continue;
474
810
  if (classified.kind === "catchAll") {
475
811
  params.push({ name: classified.name, catchAll: true });
476
812
  out.push(`:${classified.name}*`);
@@ -557,7 +893,7 @@ export const VIRTUAL = Object.freeze({
557
893
  *
558
894
  * The server's table can hold an absolute path; it is read on the machine that
559
895
  * 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`
896
+ * uf's own manual shipped `/home/<user>/…/docs/app/guide/cache/$page.mdx`
561
897
  * for each of thirty-four routes to every visitor, which publishes the build
562
898
  * machine's layout and its user's name for nothing — the browser has no
563
899
  * filesystem to resolve them against and reads them only in a message.
@@ -609,7 +945,7 @@ export function routesModuleSource(table, options = {}) {
609
945
  };
610
946
 
611
947
  // 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
948
+ // layouts are: one `app/$loading.js` is the fallback of every route under
613
949
  // it, and fifty copies of the same `import()` would be fifty chunks of the
614
950
  // same file.
615
951
  //
@@ -632,7 +968,7 @@ export function routesModuleSource(table, options = {}) {
632
968
  };
633
969
 
634
970
  // Templates are deduplicated for the reason layouts are — one
635
- // `app/_uf.template.js` wraps every route under it — and are lazy for the
971
+ // `app/$template.js` wraps every route under it — and are lazy for the
636
972
  // reason layouts are too: a template is part of the route's own tree rather
637
973
  // than a fallback React has to have in hand at the moment something goes
638
974
  // wrong, so it is awaited with the layouts before the first render.
@@ -648,6 +984,59 @@ export function routesModuleSource(table, options = {}) {
648
984
  return id;
649
985
  };
650
986
 
987
+ // Slots are hoisted like layouts and deduplicated by identity rather than by
988
+ // file: one `scanRoutes` slot record is shared by every route at or below the
989
+ // segment that declares it, so the object is the key. A slot holds a whole
990
+ // route table of its own, and emitting it once per route below it would be
991
+ // that table copied into the bundle once per route.
992
+ //
993
+ // Nested slots are emitted before the slot that holds them, because a `const`
994
+ // cannot read one declared after it.
995
+ const slotIds = new Map();
996
+ const slotDefinitions = [];
997
+ const slotFiles = new Set();
998
+ const slotId = (slot) => {
999
+ let id = slotIds.get(slot);
1000
+ if (id !== undefined) {
1001
+ return id;
1002
+ }
1003
+ const routes = slot.routes.map((route) => {
1004
+ slotFiles.add(route.page);
1005
+ for (const file of route.layouts) slotFiles.add(file);
1006
+ const nested = route.slots.map(slotId);
1007
+ return ` {
1008
+ path: ${JSON.stringify(route.path)},
1009
+ params: ${JSON.stringify(route.params)},
1010
+ mdx: ${route.mdx},
1011
+ file: ${JSON.stringify(displayFile(route.page))},
1012
+ page: () => import(${JSON.stringify(route.page)}),
1013
+ layouts: [${route.layouts.map(layoutId).join(", ")}],
1014
+ slots: [${nested.join(", ")}],
1015
+ }`;
1016
+ });
1017
+ // After the routes, so a nested slot's `const` is already emitted.
1018
+ id = `slot${slotIds.size}`;
1019
+ slotIds.set(slot, id);
1020
+ if (slot.defaultPage != null) {
1021
+ slotFiles.add(slot.defaultPage);
1022
+ }
1023
+ const fallback =
1024
+ slot.defaultPage == null
1025
+ ? " defaultPage: null,"
1026
+ : ` defaultPage: () => import(${JSON.stringify(slot.defaultPage)}),
1027
+ defaultFile: ${JSON.stringify(displayFile(slot.defaultPage))},`;
1028
+ slotDefinitions.push(`const ${id} = {
1029
+ name: ${JSON.stringify(slot.name)},
1030
+ above: ${slot.above},
1031
+ ${fallback}
1032
+ defaultMdx: ${slot.defaultMdx},
1033
+ routes: [
1034
+ ${routes.join(",\n")}
1035
+ ],
1036
+ };`);
1037
+ return id;
1038
+ };
1039
+
651
1040
  const entries = table.routes.map((route) => {
652
1041
  if (!shipsPage(route)) {
653
1042
  return ` {
@@ -658,6 +1047,7 @@ export function routesModuleSource(table, options = {}) {
658
1047
  layouts: [],
659
1048
  loading: [],
660
1049
  templates: [],
1050
+ slots: [],
661
1051
  }`;
662
1052
  }
663
1053
  const layouts = route.layouts.map(layoutId);
@@ -667,6 +1057,7 @@ export function routesModuleSource(table, options = {}) {
667
1057
  const templates = (route.templates ?? []).map(
668
1058
  (entry) => `{ above: ${entry.above}, module: ${templateId(entry.module)} }`,
669
1059
  );
1060
+ const slots = (route.slots ?? []).map(slotId);
670
1061
  return ` {
671
1062
  path: ${JSON.stringify(route.path)},
672
1063
  params: ${JSON.stringify(route.params)},
@@ -676,6 +1067,7 @@ export function routesModuleSource(table, options = {}) {
676
1067
  layouts: [${layouts.join(", ")}],
677
1068
  loading: [${loading.join(", ")}],
678
1069
  templates: [${templates.join(", ")}],
1070
+ slots: [${slots.join(", ")}],
679
1071
  }`;
680
1072
  });
681
1073
 
@@ -743,7 +1135,12 @@ export function routesModuleSource(table, options = {}) {
743
1135
  // Last, because it is defined by what everything above did *not* import: a
744
1136
  // layout a kept route also uses is already in the graph as a lazy chunk, and
745
1137
  // importing it here as well would pull it into the entry chunk instead.
746
- const carried = new Set([...layoutIds.keys(), ...loadingIds.keys(), ...templateIds.keys()]);
1138
+ const carried = new Set([
1139
+ ...layoutIds.keys(),
1140
+ ...loadingIds.keys(),
1141
+ ...templateIds.keys(),
1142
+ ...slotFiles,
1143
+ ]);
747
1144
  const styleOnlyImports = [];
748
1145
  for (const route of table.routes) {
749
1146
  if (shipsPage(route)) {
@@ -754,6 +1151,7 @@ export function routesModuleSource(table, options = {}) {
754
1151
  ...route.layouts,
755
1152
  ...(route.loading ?? []).map((it) => it.module),
756
1153
  ...(route.templates ?? []).map((it) => it.module),
1154
+ ...slotModuleFiles(route.slots ?? []),
757
1155
  ];
758
1156
  for (const file of files) {
759
1157
  if (carried.has(file)) {
@@ -764,7 +1162,7 @@ export function routesModuleSource(table, options = {}) {
764
1162
  }
765
1163
  }
766
1164
 
767
- return `${[...styleOnlyImports, ...layoutImports, ...loadingImports, ...templateImports].join("\n")}
1165
+ return `${[...styleOnlyImports, ...layoutImports, ...loadingImports, ...templateImports, ...slotDefinitions].join("\n")}
768
1166
  export const routes = [
769
1167
  ${entries.join(",\n")}
770
1168
  ];
@@ -784,6 +1182,27 @@ export default routes;
784
1182
  `;
785
1183
  }
786
1184
 
1185
+ /**
1186
+ * Every module a slot tree holds, flattened.
1187
+ *
1188
+ * For the side-effect imports a dropped route needs: a slot's pages, its
1189
+ * layouts and its default are as much a part of that route's stylesheets as
1190
+ * its own page is, and a slot nested inside one is too.
1191
+ *
1192
+ * @param {ReadonlyArray<Slot>} slots
1193
+ * @returns {Array<string>}
1194
+ */
1195
+ function slotModuleFiles(slots) {
1196
+ const files = [];
1197
+ for (const slot of slots) {
1198
+ if (slot.defaultPage != null) files.push(slot.defaultPage);
1199
+ for (const route of slot.routes) {
1200
+ files.push(route.page, ...route.layouts, ...slotModuleFiles(route.slots));
1201
+ }
1202
+ }
1203
+ return files;
1204
+ }
1205
+
787
1206
  /**
788
1207
  * The source of `virtual:uf/client`: hydrate the document with the app.
789
1208
  *
@@ -803,15 +1222,48 @@ export default routes;
803
1222
  * hydrates the way its deployment does, which is the whole of the escape
804
1223
  * hatch.
805
1224
  *
1225
+ * # Navigation is a generated constant for a different reason
1226
+ *
1227
+ * `app.rendering.navigation` decides whether the client router takes a link
1228
+ * over, and it is written in here for the reason Strict Mode is not: it must
1229
+ * be the *same* in development and in the build. A `uf dev` whose links
1230
+ * resolve in the page and a deployment whose links fetch a document are two
1231
+ * applications, and the one a person is looking at is the one that is not
1232
+ * deployed. So this is generated from the config with no `isProduction` beside
1233
+ * it, and `uf dev`, `uf build` and `uf preview` all get what the project asked
1234
+ * for.
1235
+ *
1236
+ * `"client"` is emitted as an absent argument rather than as
1237
+ * `navigation: "client"`, so the module a default project gets is byte for
1238
+ * byte the one it got before this option existed.
1239
+ *
1240
+ * # And whether it hydrates at all
1241
+ *
1242
+ * `mount` is the third generated constant and the one that changes which
1243
+ * function is imported. A `["csr"]` build wrote one shell with an empty root,
1244
+ * so there is no markup to attach to and `render` is what starts the
1245
+ * application; every other build has markup, and `hydrate` attaches to it.
1246
+ *
1247
+ * One import or the other, rather than one import and a branch, because they
1248
+ * are two different React entry points: a bundle that mounts by hydrating has
1249
+ * no reason to carry `createRoot`, and a bundle that renders has none to carry
1250
+ * `hydrateRoot`. Which one is in the module decides which one is in the build.
1251
+ *
806
1252
  * @param {string} appEntry the project's `app.js`, as an import specifier
807
- * @param {{ strictMode?: boolean }} [options]
1253
+ * @param {{
1254
+ * strictMode?: boolean,
1255
+ * navigation?: "client" | "document",
1256
+ * mount?: "hydrate" | "render",
1257
+ * }} [options]
808
1258
  */
809
1259
  export function clientModuleSource(appEntry, options = {}) {
810
1260
  const strictMode = options.strictMode === true ? ", strictMode: true" : "";
811
- return `import { hydrate } from "@uniflowed/router/client";
1261
+ const navigation = options.navigation === "document" ? ', navigation: "document"' : "";
1262
+ const mount = options.mount === "render" ? "render" : "hydrate";
1263
+ return `import { ${mount} } from "@uniflowed/router/client";
812
1264
  import { routes, notFound, errors } from ${JSON.stringify(VIRTUAL.routes)};
813
1265
  import App from ${JSON.stringify(appEntry)};
814
- hydrate({ App, routes, notFound, errors${strictMode} });
1266
+ ${mount}({ App, routes, notFound, errors${strictMode}${navigation} });
815
1267
  `;
816
1268
  }
817
1269
 
@@ -846,6 +1298,12 @@ hydrate({ App, routes, notFound, errors${strictMode} });
846
1298
  * a host is one or the other: a server streams, a build writes files. See the
847
1299
  * header of `packages/router/server.js` for why React needs both told apart.
848
1300
  *
1301
+ * `shellDocument` is the third and is neither: it renders no route, because a
1302
+ * `["csr"]` build has none to render at build time. It is re-exported straight
1303
+ * from the router rather than closed over the table, which says the true thing
1304
+ * about it — the shell is a function of the assets alone, and the route table
1305
+ * has nothing to do with a document that is no route's.
1306
+ *
849
1307
  * `beginRequest` is the fourth, and it is re-exported rather than imported by
850
1308
  * the host for a reason that is easy to get wrong: `@uniflowed/server` keeps
851
1309
  * the request in an `AsyncLocalStorage` held by *its module*, and a bundled
@@ -877,6 +1335,7 @@ export { beginRequest } from "@uniflowed/router/server";
877
1335
  const renderer = createRenderer({ App, routes, notFound, errors });
878
1336
  export const render = renderer.render;
879
1337
  export const prerender = renderer.prerender;
1338
+ export { shellDocument } from "@uniflowed/router/server";
880
1339
  export const dispatch = createDispatcher({ handlers });
881
1340
  export const callAction = createActionDispatcher({ actions });
882
1341
  export const runMiddleware = createMiddlewareRunner({ middleware });