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