@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.
- package/driver.js +307 -34
- package/index.js +86 -10
- package/internal/a11y.js +6 -4
- package/internal/devtools.js +2 -2
- package/internal/diagnostics.js +2 -2
- package/internal/flow-keywords.js +1 -1
- package/internal/routes.js +457 -54
- package/internal/rsc.js +38 -4
- package/internal/serve.js +158 -28
- package/package.json +5 -4
package/internal/routes.js
CHANGED
|
@@ -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
|
-
// (
|
|
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: "
|
|
23
|
-
template: "
|
|
24
|
-
page: "
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
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
|
-
*
|
|
42
|
-
*
|
|
43
|
-
* segment"
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
148
|
-
* `app/docs
|
|
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
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
|
231
|
-
// with no
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
465
|
-
*
|
|
466
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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([
|
|
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 {{
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|