@uniflowed/vite 0.0.0-alpha.33 → 0.0.0-alpha.34
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 +3 -3
- package/internal/routes.js +343 -76
- package/internal/rsc.js +7 -1
- package/internal/serve.js +14 -14
- package/package.json +5 -5
package/driver.js
CHANGED
|
@@ -1356,9 +1356,9 @@ async function deploy() {
|
|
|
1356
1356
|
* out — `server.js` below does exactly that through
|
|
1357
1357
|
* `@uniflowed/server/node`, and a worker hands `settle` to `ctx.waitUntil`.
|
|
1358
1358
|
* It comes from the bundle rather than from the host's own
|
|
1359
|
-
* `@uniflowed/server`, because the request
|
|
1360
|
-
*
|
|
1361
|
-
*
|
|
1359
|
+
* `@uniflowed/server`, because the request store is shared only by copies of
|
|
1360
|
+
* one release of that package and the release the application reads is the one
|
|
1361
|
+
* inlined here. See ubugeeei-prod/uf#389.
|
|
1362
1362
|
*
|
|
1363
1363
|
* The document's script and stylesheet URLs are baked in here because they
|
|
1364
1364
|
* come from the client manifest, which exists at this moment and not in the
|
package/internal/routes.js
CHANGED
|
@@ -39,24 +39,44 @@ export const RESERVED = Object.freeze({
|
|
|
39
39
|
* spelling one router refuses and the other serves as a URL is exactly the
|
|
40
40
|
* disagreement that made this necessary.
|
|
41
41
|
*
|
|
42
|
-
* `(.)photo`
|
|
43
|
-
*
|
|
44
|
-
*
|
|
45
|
-
*
|
|
46
|
-
* `RoutePath` union contained them, so `route("/@team", …)` type checked. A
|
|
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
|
|
47
46
|
* convention served as nonsense is worse than one that is refused, because the
|
|
48
47
|
* project looks like it works.
|
|
49
48
|
*
|
|
50
|
-
*
|
|
51
|
-
* interception
|
|
52
|
-
*
|
|
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
53
|
*/
|
|
54
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([
|
|
55
74
|
"(.)photo",
|
|
56
75
|
"(..)photo",
|
|
57
76
|
"(...)photo",
|
|
58
77
|
"(..)(..)photo",
|
|
59
78
|
"(..)(..)(..)photo",
|
|
79
|
+
"(.)[id]",
|
|
60
80
|
]);
|
|
61
81
|
|
|
62
82
|
/**
|
|
@@ -183,6 +203,12 @@ const MAX_DEPTH = 32;
|
|
|
183
203
|
* renders nothing, which is what an unaddressed slot on a soft navigation does
|
|
184
204
|
* in Next.js too.
|
|
185
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
|
+
*
|
|
186
212
|
* @typedef {object} Slot
|
|
187
213
|
* @property {string} name the slot's name, without the `@`
|
|
188
214
|
* @property {number} above how many of the route's layouts are outside it
|
|
@@ -191,6 +217,8 @@ const MAX_DEPTH = 32;
|
|
|
191
217
|
* @property {?{above: number, module: string}} defaultErrorBoundary the
|
|
192
218
|
* `$error.js` boundary that catches the default page in the browser
|
|
193
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
|
|
194
222
|
*/
|
|
195
223
|
|
|
196
224
|
/**
|
|
@@ -311,17 +339,18 @@ const MAX_DEPTH = 32;
|
|
|
311
339
|
* Directories that do not exist yield an empty table rather than an error: a
|
|
312
340
|
* library project has no router root, and that is not a mistake.
|
|
313
341
|
*
|
|
314
|
-
* Throws for a directory named the way an intercepting route is spelled: uf
|
|
315
|
-
* does not have interception, and the spelling used to become a literal URL
|
|
316
|
-
* segment. See {@link UNSUPPORTED_SEGMENTS}.
|
|
317
|
-
*
|
|
318
342
|
* A `@slot` directory is a parallel route and is scanned; see {@link Slot}. It
|
|
319
|
-
* throws for the
|
|
320
|
-
*
|
|
321
|
-
*
|
|
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
|
|
322
346
|
* boundary-like files with names uf does not open. Each is a file the router
|
|
323
347
|
* would otherwise never open, which is the failure #267 is about.
|
|
324
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
|
+
*
|
|
325
354
|
* @param {string} appRoot absolute path of the router root (`app/`)
|
|
326
355
|
* @param {{target?: "web" | "native" | "ios" | "android"}} [options]
|
|
327
356
|
* @returns {{
|
|
@@ -484,8 +513,10 @@ export function scanRoutes(appRoot, options = {}) {
|
|
|
484
513
|
if (classifyRouteSegment(entry.name).kind === "slot") continue;
|
|
485
514
|
// Checked before descending, and after the private-directory test for
|
|
486
515
|
// the same reason `uf_router` prunes them: `app/_drafts/(.)photo/` is not
|
|
487
|
-
// a route uf would have served, so it is not one to refuse.
|
|
488
|
-
|
|
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);
|
|
489
520
|
if (refused != null) {
|
|
490
521
|
throw new Error(`${path.join(directory, entry.name)}: ${refused}`);
|
|
491
522
|
}
|
|
@@ -546,9 +577,88 @@ export function scanRoutes(appRoot, options = {}) {
|
|
|
546
577
|
// parallel-route trees uf does not have yet; see ubugeeei-prod/uf#267.
|
|
547
578
|
notFound.sort(byPath);
|
|
548
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);
|
|
549
584
|
return { routes, handlers, middleware, notFound, errors };
|
|
550
585
|
}
|
|
551
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
|
+
|
|
552
662
|
/**
|
|
553
663
|
* One `@slot` directory, scanned into a {@link Slot}.
|
|
554
664
|
*
|
|
@@ -600,6 +710,12 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
|
|
|
600
710
|
}
|
|
601
711
|
|
|
602
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 = [];
|
|
603
719
|
const defaultPage = findModule(directory, RESERVED.default, PAGE_EXTENSIONS, target);
|
|
604
720
|
const defaultError = findModule(directory, RESERVED.error, MODULE_EXTENSIONS, target);
|
|
605
721
|
|
|
@@ -611,6 +727,7 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
|
|
|
611
727
|
templates,
|
|
612
728
|
errorBoundary,
|
|
613
729
|
atSlotRoot,
|
|
730
|
+
intercepting,
|
|
614
731
|
currentDepth,
|
|
615
732
|
) => {
|
|
616
733
|
if (currentDepth > MAX_DEPTH) return;
|
|
@@ -710,8 +827,11 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
|
|
|
710
827
|
|
|
711
828
|
const page = findModule(current, RESERVED.page, PAGE_EXTENSIONS, target);
|
|
712
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.
|
|
713
833
|
const { path: routePath, params } = routeFromSegments(currentSegments);
|
|
714
|
-
routes.push({
|
|
834
|
+
(intercepting ? intercepts : routes).push({
|
|
715
835
|
path: routePath,
|
|
716
836
|
params,
|
|
717
837
|
page,
|
|
@@ -729,9 +849,18 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
|
|
|
729
849
|
if (entry.name.startsWith(".") || entry.name.startsWith("_")) continue;
|
|
730
850
|
const classified = classifyRouteSegment(entry.name);
|
|
731
851
|
if (classified.kind === "slot") continue;
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
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
|
+
}
|
|
735
864
|
}
|
|
736
865
|
walkSlot(
|
|
737
866
|
path.join(current, entry.name),
|
|
@@ -741,24 +870,30 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
|
|
|
741
870
|
nextTemplates,
|
|
742
871
|
nextErrorBoundary,
|
|
743
872
|
false,
|
|
873
|
+
intercepting || classified.kind === "interception",
|
|
744
874
|
currentDepth + 1,
|
|
745
875
|
);
|
|
746
876
|
}
|
|
747
877
|
};
|
|
748
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.
|
|
749
882
|
walkSlot(
|
|
750
883
|
directory,
|
|
751
|
-
segments,
|
|
884
|
+
[...segments, directoryName],
|
|
752
885
|
[],
|
|
753
886
|
[],
|
|
754
887
|
[],
|
|
755
888
|
defaultError == null ? null : { above: 0, module: defaultError },
|
|
756
889
|
true,
|
|
890
|
+
false,
|
|
757
891
|
depth + 1,
|
|
758
892
|
);
|
|
759
893
|
|
|
760
894
|
const byPath = (a, b) => (a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
|
|
761
895
|
routes.sort(byPath);
|
|
896
|
+
intercepts.sort(byPath);
|
|
762
897
|
return {
|
|
763
898
|
name,
|
|
764
899
|
above,
|
|
@@ -766,6 +901,7 @@ function scanSlot(parent, directoryName, name, segments, ownLayout, above, targe
|
|
|
766
901
|
defaultMdx: defaultPage != null && defaultPage.endsWith(".mdx"),
|
|
767
902
|
defaultErrorBoundary: defaultError == null ? null : { above: 0, module: defaultError },
|
|
768
903
|
routes,
|
|
904
|
+
intercepts,
|
|
769
905
|
};
|
|
770
906
|
}
|
|
771
907
|
|
|
@@ -848,10 +984,12 @@ export function classifyRouteSegment(segment) {
|
|
|
848
984
|
/**
|
|
849
985
|
* The `(.)`-style prefix of `segment` and the route after it, or `null`.
|
|
850
986
|
*
|
|
851
|
-
* One or more
|
|
852
|
-
*
|
|
853
|
-
*
|
|
854
|
-
*
|
|
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.
|
|
855
993
|
*/
|
|
856
994
|
function interceptionMarker(segment) {
|
|
857
995
|
let consumed = 0;
|
|
@@ -859,7 +997,7 @@ function interceptionMarker(segment) {
|
|
|
859
997
|
const close = segment.indexOf(")", consumed);
|
|
860
998
|
if (close === -1) break;
|
|
861
999
|
const inner = segment.slice(consumed + 1, close);
|
|
862
|
-
if (inner.length === 0 ||
|
|
1000
|
+
if (inner.length === 0 || /[^.]/.test(inner)) break;
|
|
863
1001
|
consumed = close + 1;
|
|
864
1002
|
}
|
|
865
1003
|
if (consumed === 0 || consumed === segment.length) return null;
|
|
@@ -867,28 +1005,124 @@ function interceptionMarker(segment) {
|
|
|
867
1005
|
}
|
|
868
1006
|
|
|
869
1007
|
/**
|
|
870
|
-
*
|
|
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.
|
|
871
1046
|
*
|
|
872
1047
|
* The message is this router's own rather than `uf_router`'s, because the two
|
|
873
1048
|
* are reached differently: the Rust one fails `uf build` and `uf dev` through
|
|
874
1049
|
* the route manifest, and this one fails a project driving Vite itself. Both
|
|
875
|
-
* say the same
|
|
876
|
-
*
|
|
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`.
|
|
877
1055
|
*/
|
|
878
1056
|
export function unsupportedSegmentReason(segment) {
|
|
879
1057
|
const classified = classifyRouteSegment(segment);
|
|
880
|
-
if (classified.kind
|
|
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) {
|
|
881
1071
|
return (
|
|
882
|
-
`\`${segment}\` is an intercepting route, and
|
|
883
|
-
"
|
|
884
|
-
|
|
885
|
-
|
|
886
|
-
"
|
|
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"
|
|
887
1078
|
);
|
|
888
1079
|
}
|
|
889
1080
|
return null;
|
|
890
1081
|
}
|
|
891
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
|
+
|
|
892
1126
|
/**
|
|
893
1127
|
* Turn directory segments into a route path and its parameters.
|
|
894
1128
|
*
|
|
@@ -896,32 +1130,48 @@ export function unsupportedSegmentReason(segment) {
|
|
|
896
1130
|
* captures one segment, and `[...name]` captures the rest of the path. A
|
|
897
1131
|
* `@slot` contributes nothing either — it is a named place a route renders
|
|
898
1132
|
* into, matched against the URL of the segment that declares it — so a slot's
|
|
899
|
-
* pages are matched against ordinary paths and add none of their own.
|
|
900
|
-
*
|
|
901
|
-
*
|
|
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.
|
|
902
1142
|
*/
|
|
903
1143
|
export function routeFromSegments(segments) {
|
|
904
|
-
|
|
905
|
-
|
|
1144
|
+
let out = [];
|
|
1145
|
+
let insideSlot = false;
|
|
906
1146
|
for (const segment of segments) {
|
|
907
1147
|
const classified = classifyRouteSegment(segment);
|
|
908
|
-
if (classified.kind === "group"
|
|
909
|
-
if (classified.kind === "
|
|
910
|
-
|
|
911
|
-
out.push(`:${classified.name}*`);
|
|
912
|
-
continue;
|
|
913
|
-
}
|
|
914
|
-
if (classified.kind === "param") {
|
|
915
|
-
params.push({ name: classified.name, catchAll: false });
|
|
916
|
-
out.push(`:${classified.name}`);
|
|
1148
|
+
if (classified.kind === "group") continue;
|
|
1149
|
+
if (classified.kind === "slot") {
|
|
1150
|
+
insideSlot = true;
|
|
917
1151
|
continue;
|
|
918
1152
|
}
|
|
1153
|
+
let named = classified;
|
|
919
1154
|
if (classified.kind === "interception") {
|
|
920
|
-
|
|
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 });
|
|
921
1171
|
}
|
|
922
|
-
out.push(segment);
|
|
923
1172
|
}
|
|
924
|
-
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]));
|
|
925
1175
|
return { path: routePath, pattern: routePath.replace(/:(\w+)\*/g, "*$1"), params };
|
|
926
1176
|
}
|
|
927
1177
|
|
|
@@ -1101,18 +1351,16 @@ export function routesModuleSource(table, options = {}) {
|
|
|
1101
1351
|
boundary == null
|
|
1102
1352
|
? "null"
|
|
1103
1353
|
: `{ above: ${boundary.above}, module: () => import(${JSON.stringify(boundary.module)}) }`;
|
|
1104
|
-
|
|
1105
|
-
|
|
1106
|
-
|
|
1107
|
-
|
|
1108
|
-
|
|
1109
|
-
const
|
|
1110
|
-
|
|
1111
|
-
|
|
1112
|
-
|
|
1113
|
-
|
|
1114
|
-
if (route.errorBoundary != null) slotFiles.add(route.errorBoundary.module);
|
|
1115
|
-
return ` {
|
|
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 ` {
|
|
1116
1364
|
path: ${JSON.stringify(route.path)},
|
|
1117
1365
|
params: ${JSON.stringify(route.params)},
|
|
1118
1366
|
mdx: ${route.mdx},
|
|
@@ -1128,7 +1376,14 @@ export function routesModuleSource(table, options = {}) {
|
|
|
1128
1376
|
errorBoundary: ${slotErrorBoundary(route.errorBoundary ?? null)},
|
|
1129
1377
|
slots: [${nested.join(", ")}],
|
|
1130
1378
|
}`;
|
|
1131
|
-
|
|
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);
|
|
1132
1387
|
// After the routes, so a nested slot's `const` is already emitted.
|
|
1133
1388
|
id = `slot${slotIds.size}`;
|
|
1134
1389
|
slotIds.set(slot, id);
|
|
@@ -1143,6 +1398,15 @@ export function routesModuleSource(table, options = {}) {
|
|
|
1143
1398
|
? " defaultPage: null,"
|
|
1144
1399
|
: ` defaultPage: () => import(${JSON.stringify(slot.defaultPage)}),
|
|
1145
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
|
+
],`;
|
|
1146
1410
|
slotDefinitions.push(`const ${id} = {
|
|
1147
1411
|
name: ${JSON.stringify(slot.name)},
|
|
1148
1412
|
above: ${slot.above},
|
|
@@ -1151,7 +1415,7 @@ ${fallback}
|
|
|
1151
1415
|
defaultErrorBoundary: ${slotErrorBoundary(slot.defaultErrorBoundary ?? null)},
|
|
1152
1416
|
routes: [
|
|
1153
1417
|
${routes.join(",\n")}
|
|
1154
|
-
]
|
|
1418
|
+
],${intercepting}
|
|
1155
1419
|
};`);
|
|
1156
1420
|
return id;
|
|
1157
1421
|
};
|
|
@@ -1316,7 +1580,9 @@ function slotModuleFiles(slots) {
|
|
|
1316
1580
|
for (const slot of slots) {
|
|
1317
1581
|
if (slot.defaultPage != null) files.push(slot.defaultPage);
|
|
1318
1582
|
if (slot.defaultErrorBoundary != null) files.push(slot.defaultErrorBoundary.module);
|
|
1319
|
-
|
|
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 ?? [])]) {
|
|
1320
1586
|
files.push(
|
|
1321
1587
|
route.page,
|
|
1322
1588
|
...route.layouts,
|
|
@@ -1432,14 +1698,15 @@ ${mount}({ App, routes, notFound, errors${strictMode}${navigation} });
|
|
|
1432
1698
|
* has nothing to do with a document that is no route's.
|
|
1433
1699
|
*
|
|
1434
1700
|
* `beginRequest` is the fourth, and it is re-exported rather than imported by
|
|
1435
|
-
* the host for a reason that is easy to get wrong: `@uniflowed/server`
|
|
1436
|
-
*
|
|
1437
|
-
* application has its own copy
|
|
1438
|
-
*
|
|
1439
|
-
*
|
|
1440
|
-
*
|
|
1441
|
-
* the
|
|
1442
|
-
* take it from here; see
|
|
1701
|
+
* the host for a reason that is easy to get wrong: `@uniflowed/server` shares
|
|
1702
|
+
* its request store between copies of one *release* of itself, and a bundled
|
|
1703
|
+
* application has its own copy inlined. A host that imported `beginRequest`
|
|
1704
|
+
* from its own `node_modules` could be holding another release, would
|
|
1705
|
+
* establish a request in a store the application never reads, and every
|
|
1706
|
+
* `cookies()` in the application would still be outside one. So the bundle
|
|
1707
|
+
* hands the host the entry point that belongs to the bundle. `uf preview`,
|
|
1708
|
+
* `uf start`, `uf dev` and the compiled binary all take it from here; see
|
|
1709
|
+
* ubugeeei-prod/uf#389.
|
|
1443
1710
|
*
|
|
1444
1711
|
* Through `@uniflowed/router/server` rather than `@uniflowed/server/host`,
|
|
1445
1712
|
* because this source is resolved from the *project's* directory and a project
|
package/internal/rsc.js
CHANGED
|
@@ -202,7 +202,13 @@ function slotPredicate(needed) {
|
|
|
202
202
|
const needsSlot = (slot) => {
|
|
203
203
|
const cached = cache.get(slot);
|
|
204
204
|
if (cached !== undefined) return cached;
|
|
205
|
-
|
|
205
|
+
// A slot that intercepts anything needs the client router on every page it
|
|
206
|
+
// is rendered on, whatever its modules are. An intercepting route renders
|
|
207
|
+
// only for a client navigation that starts on such a page, and a page
|
|
208
|
+
// served as a document with no router hydrated on it is a page no
|
|
209
|
+
// navigation starts from — so dropping it would leave the interception a
|
|
210
|
+
// file nothing ever renders, which is the silence #267 is about.
|
|
211
|
+
let answer = (slot.intercepts ?? []).length > 0;
|
|
206
212
|
if (slot.defaultPage != null && needed(slot.defaultPage)) {
|
|
207
213
|
answer = true;
|
|
208
214
|
}
|
package/internal/serve.js
CHANGED
|
@@ -64,12 +64,12 @@
|
|
|
64
64
|
// settles it on the line after the last byte.
|
|
65
65
|
//
|
|
66
66
|
// It has to be the *entry's* `beginRequest` rather than one imported here: the
|
|
67
|
-
// request
|
|
68
|
-
//
|
|
69
|
-
//
|
|
70
|
-
// application cannot see, and nothing would fail loudly — the
|
|
71
|
-
// the page would render, and every `cookies()` in it would
|
|
72
|
-
// host had run at all. See ubugeeei-prod/uf#389.
|
|
67
|
+
// request store is shared by every copy of one release of `@uniflowed/server`,
|
|
68
|
+
// and the release that matters is the one inside the application bundle. A
|
|
69
|
+
// host that resolved its own could be holding another release, would begin a
|
|
70
|
+
// request the application cannot see, and nothing would fail loudly — the
|
|
71
|
+
// guard would run, the page would render, and every `cookies()` in it would
|
|
72
|
+
// throw as though no host had run at all. See ubugeeei-prod/uf#389.
|
|
73
73
|
|
|
74
74
|
import { readFile, stat } from "node:fs/promises";
|
|
75
75
|
import path from "node:path";
|
|
@@ -348,10 +348,10 @@ export function assetsFromManifest(manifest) {
|
|
|
348
348
|
* its callback whether the render threw or not, and `drainDeferred` already
|
|
349
349
|
* reports a failing task rather than propagating it.
|
|
350
350
|
*
|
|
351
|
-
* `entry.beginRequest` and not an import: the request
|
|
352
|
-
*
|
|
353
|
-
*
|
|
354
|
-
*
|
|
351
|
+
* `entry.beginRequest` and not an import: the request store is shared by every
|
|
352
|
+
* copy of one release of `@uniflowed/server`, and the release that matters is
|
|
353
|
+
* the one inside the application bundle. See `serverModuleSource` in
|
|
354
|
+
* `./routes.js`.
|
|
355
355
|
*
|
|
356
356
|
* The one case this cannot be exact about is a request uf hands back rather
|
|
357
357
|
* than answers: a caller whose `catch` is `next(error)` gives the response to
|
|
@@ -385,10 +385,10 @@ export async function withRequest(entry, request, body) {
|
|
|
385
385
|
* `finally` here. A caller that always answers should use [`withRequest`] and
|
|
386
386
|
* not think about it.
|
|
387
387
|
*
|
|
388
|
-
* `entry.beginRequest` and not an import: the request
|
|
389
|
-
*
|
|
390
|
-
*
|
|
391
|
-
*
|
|
388
|
+
* `entry.beginRequest` and not an import: the request store is shared by every
|
|
389
|
+
* copy of one release of `@uniflowed/server`, and the release that matters is
|
|
390
|
+
* the one inside the application bundle. See `serverModuleSource` in
|
|
391
|
+
* `./routes.js`.
|
|
392
392
|
*
|
|
393
393
|
* What this host can do is put on the request the way `createFetchHandler`
|
|
394
394
|
* puts it on the one it owns. `uf dev` and `uf build --compile` reach a route
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@uniflowed/vite",
|
|
3
|
-
"version": "0.0.0-alpha.
|
|
3
|
+
"version": "0.0.0-alpha.34",
|
|
4
4
|
"description": "Vite, driven by uf.config.js: every Flow module through `uf transform`, MDX, the file-system router and static rendering as Vite plugins.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"license": "MIT",
|
|
@@ -34,10 +34,10 @@
|
|
|
34
34
|
"dependencies": {
|
|
35
35
|
"@mdx-js/rollup": "^3.1.1",
|
|
36
36
|
"@shikijs/rehype": "^3.23.0",
|
|
37
|
-
"@uniflowed/host": "0.0.0-alpha.
|
|
38
|
-
"@uniflowed/router": "0.0.0-alpha.
|
|
39
|
-
"@uniflowed/server": "0.0.0-alpha.
|
|
40
|
-
"@uniflowed/validator": "0.0.0-alpha.
|
|
37
|
+
"@uniflowed/host": "0.0.0-alpha.34",
|
|
38
|
+
"@uniflowed/router": "0.0.0-alpha.34",
|
|
39
|
+
"@uniflowed/server": "0.0.0-alpha.34",
|
|
40
|
+
"@uniflowed/validator": "0.0.0-alpha.34",
|
|
41
41
|
"rehype-slug": "^6.0.0",
|
|
42
42
|
"remark-frontmatter": "^5.0.0",
|
|
43
43
|
"remark-gfm": "^4.0.1",
|