@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.
- package/driver.js +380 -46
- package/index.js +101 -15
- package/internal/a11y.js +6 -4
- package/internal/config.js +30 -3
- package/internal/devtools.js +2 -2
- package/internal/diagnostics.js +2 -2
- package/internal/flow-keywords.js +1 -1
- package/internal/openapi.js +218 -0
- package/internal/routes.js +529 -70
- package/internal/rsc.js +43 -10
- package/internal/serve.js +158 -28
- package/package.json +7 -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,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
|
|
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"];
|
|
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
|
-
*
|
|
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
|
|
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
|
|
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
|
|
148
|
-
* `app/docs
|
|
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
|
|
171
|
-
*
|
|
172
|
-
*
|
|
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
|
-
|
|
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
|
|
231
|
-
// with no
|
|
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
|
|
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
|
|
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
|
|
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)
|
|
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
|
|
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
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
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
|
|
465
|
-
*
|
|
466
|
-
*
|
|
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
|
|
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
|
|
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
|
|
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([
|
|
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 {{
|
|
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
|
-
|
|
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
|
-
|
|
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 });
|