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