@uniflowed/router 0.0.0-alpha.28 → 0.0.0-alpha.30

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.
@@ -57,6 +57,7 @@ import {
57
57
  PAYLOAD_ROW_ATTRIBUTE,
58
58
  PayloadRowError,
59
59
  PayloadValueError,
60
+ payloadRowId,
60
61
  parseRowMessage,
61
62
  } from "./payload.js";
62
63
 
@@ -149,8 +150,8 @@ export function createPayloadReader(
149
150
  if (attribute == null) {
150
151
  return;
151
152
  }
152
- const id = Number.parseInt(attribute, 10);
153
- if (!Number.isSafeInteger(id)) {
153
+ const id = payloadRowId(attribute);
154
+ if (id == null) {
154
155
  return;
155
156
  }
156
157
  const slot = slots.get(id);
@@ -580,6 +580,19 @@ function decodeReference(value: string, path: string, resolve: RowResolver): mix
580
580
  return resolve(id);
581
581
  }
582
582
 
583
+ /**
584
+ * The row id carried by a streamed row element, or `null` when the attribute
585
+ * is not one.
586
+ *
587
+ * This is the same grammar as the `$P<n>` reference without the `$P` tag:
588
+ * digits only, in range. The browser reads row elements from a live document,
589
+ * so accepting `Number.parseInt`'s looser spellings would let `1x` satisfy the
590
+ * row the model named as `$P1`.
591
+ */
592
+ export function payloadRowId(value: string): number | null {
593
+ return parsePayloadRowId(value);
594
+ }
595
+
583
596
  /**
584
597
  * The row a reference names, or `null` when the string is not one.
585
598
  *
@@ -591,7 +604,10 @@ function rowId(value: string): number | null {
591
604
  if (!value.startsWith(REFERENCE_PREFIX + ROW_TAG)) {
592
605
  return null;
593
606
  }
594
- const digits = value.slice(2);
607
+ return parsePayloadRowId(value.slice(2));
608
+ }
609
+
610
+ function parsePayloadRowId(digits: string): number | null {
595
611
  if (digits.length === 0 || digits.length > 3) {
596
612
  return null;
597
613
  }
@@ -0,0 +1,426 @@
1
+ // @flow
2
+ //
3
+ // Internal to `@uniflowed/router`: the React-free route table primitives.
4
+ //
5
+ // Server Components need to be able to name routes, throw router control
6
+ // errors, and match generated tables without importing the client router,
7
+ // React components, or hooks. Keep this file to data, errors, and pure
8
+ // functions; rendering belongs in `runtime.js`.
9
+
10
+ /** One parameter a route path captures. */
11
+ export type RouteParamSpec = {| readonly name: string, readonly catchAll: boolean |};
12
+
13
+ /** The parameters captured from a URL. A catch-all captures the rest as a list. */
14
+ export type RouteParams = { readonly [string]: string | $ReadOnlyArray<string> };
15
+
16
+ /** The query string, as a read-only map. */
17
+ export type SearchParams = { readonly [string]: string };
18
+
19
+ /** A lazy route module entry from the generated route table. */
20
+ export type RouteModule<TModule = mixed> = () => Promise<TModule>;
21
+
22
+ /** One `$template.js`, as the route table carries it. */
23
+ export type TemplateRecord<TTemplate = mixed> = {|
24
+ readonly above: number,
25
+ readonly module: RouteModule<TTemplate>,
26
+ |};
27
+
28
+ /** One `$loading.js`, as the route table carries it. */
29
+ export type LoadingRecord<TLoading = mixed> = {|
30
+ readonly above: number,
31
+ readonly module: RouteModule<TLoading>,
32
+ |};
33
+
34
+ /** One parallel-route slot, as the route table carries it. */
35
+ export type SlotRecord<TPage = mixed, TLayout = mixed> = {|
36
+ readonly name: string,
37
+ readonly above: number,
38
+ readonly defaultPage: ?RouteModule<TPage>,
39
+ readonly defaultFile?: string,
40
+ readonly defaultMdx?: boolean,
41
+ readonly routes: $ReadOnlyArray<SlotRouteRecord<TPage, TLayout>>,
42
+ |};
43
+
44
+ /** One page inside a slot. */
45
+ export type SlotRouteRecord<TPage = mixed, TLayout = mixed> = {|
46
+ readonly path: string,
47
+ readonly params: $ReadOnlyArray<RouteParamSpec>,
48
+ readonly mdx: boolean,
49
+ readonly file: string,
50
+ readonly page: RouteModule<TPage>,
51
+ readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
52
+ readonly slots: $ReadOnlyArray<SlotRecord<TPage, TLayout>>,
53
+ |};
54
+
55
+ /** One entry of the generated route table. */
56
+ export type RouteRecord<TPage = mixed, TLayout = mixed, TTemplate = mixed, TLoading = mixed> = {|
57
+ readonly path: string,
58
+ readonly params: $ReadOnlyArray<RouteParamSpec>,
59
+ readonly mdx: boolean,
60
+ readonly file: string,
61
+ readonly page?: RouteModule<TPage>,
62
+ readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
63
+ readonly loading?: $ReadOnlyArray<LoadingRecord<TLoading>>,
64
+ readonly templates?: $ReadOnlyArray<TemplateRecord<TTemplate>>,
65
+ readonly slots?: $ReadOnlyArray<SlotRecord<TPage, TLayout>>,
66
+ |};
67
+
68
+ /** One not-found boundary: the page for a path under `path` that matched nothing. */
69
+ export type NotFoundBoundary<TPage = mixed, TLayout = mixed> = {|
70
+ readonly path: string,
71
+ readonly mdx: boolean,
72
+ readonly file: string,
73
+ readonly page: ?RouteModule<TPage>,
74
+ readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
75
+ |};
76
+
77
+ /** One error boundary: what renders in place of a subtree that threw. */
78
+ export type ErrorBoundary<TError = mixed, TLayout = mixed> = {|
79
+ readonly path: string,
80
+ readonly file: string,
81
+ readonly module: ?RouteModule<TError>,
82
+ readonly layouts: $ReadOnlyArray<RouteModule<TLayout>>,
83
+ |};
84
+
85
+ /** A route table plus the boundaries declared under it. */
86
+ export type RouteTable<
87
+ TPage = mixed,
88
+ TLayout = mixed,
89
+ TTemplate = mixed,
90
+ TLoading = mixed,
91
+ TError = mixed,
92
+ > = {|
93
+ readonly routes: $ReadOnlyArray<RouteRecord<TPage, TLayout, TTemplate, TLoading>>,
94
+ readonly notFound: $ReadOnlyArray<NotFoundBoundary<TPage, TLayout>>,
95
+ readonly errors: $ReadOnlyArray<ErrorBoundary<TError, TLayout>>,
96
+ |};
97
+
98
+ type UnknownRouteRecord = RouteRecord<mixed, mixed, mixed, mixed>;
99
+
100
+ /** A URL matched against a table. */
101
+ export type RouteMatch<TRoute: { +path: string, ... } = UnknownRouteRecord> = {|
102
+ readonly route: TRoute,
103
+ readonly params: RouteParams,
104
+ |};
105
+
106
+ /**
107
+ * Why the router is rendering an error boundary instead of a page.
108
+ *
109
+ * One union rather than one file convention per status. `forbidden()` and
110
+ * `unauthorized()` are not different *kinds* of file to write; they are
111
+ * different sentences an error page says.
112
+ */
113
+ export type RouteError =
114
+ | {| readonly kind: "thrown", readonly error: mixed |}
115
+ | {| readonly kind: "unauthorized" |}
116
+ | {| readonly kind: "forbidden" |};
117
+
118
+ /** The status a `RouteError` answers with. */
119
+ export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
120
+ return match (error) {
121
+ {kind: "unauthorized"} => 401,
122
+ {kind: "forbidden"} => 403,
123
+ {kind: "thrown"} => 500,
124
+ };
125
+ }
126
+
127
+ /** Thrown by `notFound()`; the renderer answers with the not-found page. */
128
+ export class NotFoundError extends Error {
129
+ constructor() {
130
+ super("not found");
131
+ this.name = "NotFoundError";
132
+ }
133
+ }
134
+
135
+ /** Thrown by `unauthorized()`; the renderer answers with the error boundary. */
136
+ export class UnauthorizedError extends Error {
137
+ constructor() {
138
+ super("unauthorized");
139
+ this.name = "UnauthorizedError";
140
+ }
141
+ }
142
+
143
+ /** Thrown by `forbidden()`; the renderer answers with the error boundary. */
144
+ export class ForbiddenError extends Error {
145
+ constructor() {
146
+ super("forbidden");
147
+ this.name = "ForbiddenError";
148
+ }
149
+ }
150
+
151
+ /** Thrown by `redirect()`; the renderer answers with a redirect. */
152
+ export class RedirectError extends Error {
153
+ to: string;
154
+ permanent: boolean;
155
+
156
+ constructor(to: string, permanent: boolean) {
157
+ super(`redirect to ${to}`);
158
+ this.name = "RedirectError";
159
+ this.to = to;
160
+ this.permanent = permanent;
161
+ }
162
+ }
163
+
164
+ type Segment =
165
+ | {| readonly kind: "static", readonly value: string |}
166
+ | {| readonly kind: "param", readonly name: string |}
167
+ | {| readonly kind: "catchAll", readonly name: string |};
168
+
169
+ function compile(routePath: string): $ReadOnlyArray<Segment> {
170
+ return routePath
171
+ .split("/")
172
+ .filter((segment) => segment !== "")
173
+ .map((segment): Segment => {
174
+ if (segment.startsWith(":") && segment.endsWith("*")) {
175
+ return { kind: "catchAll", name: segment.slice(1, -1) };
176
+ }
177
+ if (segment.startsWith(":")) {
178
+ return { kind: "param", name: segment.slice(1) };
179
+ }
180
+ return { kind: "static", value: segment };
181
+ });
182
+ }
183
+
184
+ /**
185
+ * How specific a route is, for ranking: a static segment outranks a parameter,
186
+ * which outranks a catch-all, and a longer path outranks a shorter one.
187
+ */
188
+ function specificity(segments: $ReadOnlyArray<Segment>): number {
189
+ let score = 0;
190
+ for (const segment of segments) {
191
+ score += match (segment) {
192
+ {kind: "static"} => 3,
193
+ {kind: "param"} => 2,
194
+ {kind: "catchAll"} => 1,
195
+ };
196
+ }
197
+ return score;
198
+ }
199
+
200
+ function matchSegments(
201
+ segments: $ReadOnlyArray<Segment>,
202
+ parts: $ReadOnlyArray<string>,
203
+ ): ?RouteParams {
204
+ const params: { [string]: string | $ReadOnlyArray<string> } = {};
205
+ let index = 0;
206
+ for (const segment of segments) {
207
+ match (segment) {
208
+ {kind: "static", value: const value} => {
209
+ if (parts[index] !== value) {
210
+ return null;
211
+ }
212
+ index += 1;
213
+ }
214
+ {kind: "param", name: const name} => {
215
+ if (index >= parts.length) {
216
+ return null;
217
+ }
218
+ params[name] = decodeSegment(parts[index]);
219
+ index += 1;
220
+ }
221
+ {kind: "catchAll", name: const name} => {
222
+ params[name] = parts.slice(index).map(decodeSegment);
223
+ index = parts.length;
224
+ }
225
+ }
226
+ }
227
+ return index === parts.length ? params : null;
228
+ }
229
+
230
+ /**
231
+ * The URL for a route pattern and the parameters it takes.
232
+ *
233
+ * The inverse of [`matchSegments`], and deliberately built out of the same
234
+ * [`compile`]: a builder that parsed patterns its own way would drift from the
235
+ * matcher, and the drift would show up as a link that 404s rather than as a
236
+ * failure anybody could see.
237
+ */
238
+ export function buildRoute(routePath: string, params?: RouteParams): string {
239
+ const values: RouteParams = params ?? {};
240
+ const parts: Array<string> = [];
241
+ for (const segment of compile(routePath)) {
242
+ match (segment) {
243
+ {kind: "static", value: const value} => {
244
+ parts.push(value);
245
+ }
246
+ {kind: "param", name: const name} => {
247
+ const value = values[name];
248
+ if (typeof value !== "string") {
249
+ throw new Error(
250
+ `route ${routePath} takes a string for :${name}, and got ${describeParam(value)}`,
251
+ );
252
+ }
253
+ parts.push(encodeURIComponent(value));
254
+ }
255
+ {kind: "catchAll", name: const name} => {
256
+ const value = values[name];
257
+ if (value == null || typeof value === "string") {
258
+ throw new Error(
259
+ `route ${routePath} takes an array of segments for :${name}*, and got ` +
260
+ describeParam(value),
261
+ );
262
+ }
263
+ for (const part of value) {
264
+ parts.push(encodeURIComponent(part));
265
+ }
266
+ }
267
+ }
268
+ }
269
+ return parts.length === 0 ? "/" : `/${parts.join("/")}`;
270
+ }
271
+
272
+ /** What a parameter was, for the message that says it was the wrong thing. */
273
+ function describeParam(value: string | $ReadOnlyArray<string> | void): string {
274
+ if (value === undefined) {
275
+ return "nothing";
276
+ }
277
+ return typeof value === "string" ? `the string ${JSON.stringify(value)}` : "an array";
278
+ }
279
+
280
+ function decodeSegment(segment: string): string {
281
+ try {
282
+ return decodeURIComponent(segment);
283
+ } catch {
284
+ return segment;
285
+ }
286
+ }
287
+
288
+ /** Whether this table can render the route in the browser. */
289
+ export function hasClientPage(route: { +page?: mixed, ... }): boolean {
290
+ return route.page != null;
291
+ }
292
+
293
+ /** Match a pathname against the table, preferring the most specific route. */
294
+ export function matchRoute<TRoute: { +path: string, ... }>(
295
+ routes: $ReadOnlyArray<TRoute>,
296
+ pathname: string,
297
+ ): ?RouteMatch<TRoute> {
298
+ return matchIn(routes, pathname);
299
+ }
300
+
301
+ /**
302
+ * The same match, over anything that has a route path.
303
+ *
304
+ * A slot is a second table matched against the same URL, and it has to be
305
+ * matched by this function rather than by one of its own.
306
+ */
307
+ export function matchIn<TRoute: { +path: string, ... }>(
308
+ routes: $ReadOnlyArray<TRoute>,
309
+ pathname: string,
310
+ ): ?RouteMatch<TRoute> {
311
+ const parts = pathname.split("/").filter((part) => part !== "");
312
+ let best: ?RouteMatch<TRoute> = null;
313
+ let bestScore = -1;
314
+ for (const route of routes) {
315
+ const segments = compile(route.path);
316
+ const params = matchSegments(segments, parts);
317
+ if (params == null) {
318
+ continue;
319
+ }
320
+ const score = specificity(segments);
321
+ if (score > bestScore) {
322
+ best = { route, params };
323
+ bestScore = score;
324
+ }
325
+ }
326
+ return best;
327
+ }
328
+
329
+ /**
330
+ * Whether a boundary declared at `segments` is at or above `parts`.
331
+ *
332
+ * The same segment kinds as [`matchSegments`], stopping when the boundary's
333
+ * own segments run out instead of requiring the path to.
334
+ */
335
+ function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>): boolean {
336
+ let index = 0;
337
+ for (const segment of segments) {
338
+ const next = match (segment) {
339
+ {kind: "static", value: const value} => parts[index] === value ? index + 1 : -1,
340
+ {kind: "param"} => index < parts.length ? index + 1 : -1,
341
+ {kind: "catchAll"} => parts.length,
342
+ };
343
+ if (next === -1) {
344
+ return false;
345
+ }
346
+ index = next;
347
+ }
348
+ return true;
349
+ }
350
+
351
+ /** The nearest boundary above `pathname`, or `null` when none covers it. */
352
+ export function nearestBoundary<TBoundary: { readonly path: string, ... }>(
353
+ boundaries: $ReadOnlyArray<TBoundary>,
354
+ pathname: string,
355
+ ): ?TBoundary {
356
+ const parts = pathname.split("/").filter((part) => part !== "");
357
+ let best: ?TBoundary = null;
358
+ let bestDepth = -1;
359
+ for (const boundary of boundaries) {
360
+ const segments = compile(boundary.path);
361
+ if (!covers(segments, parts)) {
362
+ continue;
363
+ }
364
+ if (segments.length > bestDepth) {
365
+ best = boundary;
366
+ bestDepth = segments.length;
367
+ }
368
+ }
369
+ return best;
370
+ }
371
+
372
+ /** Split a URL into its pathname and search string. */
373
+ export function splitUrl(url: string): {| readonly pathname: string, readonly search: string |} {
374
+ const hash = url.indexOf("#");
375
+ const withoutHash = hash === -1 ? url : url.slice(0, hash);
376
+ const question = withoutHash.indexOf("?");
377
+ if (question === -1) {
378
+ return { pathname: normalizePathname(withoutHash), search: "" };
379
+ }
380
+ return {
381
+ pathname: normalizePathname(withoutHash.slice(0, question)),
382
+ search: withoutHash.slice(question),
383
+ };
384
+ }
385
+
386
+ function normalizePathname(pathname: string): string {
387
+ if (pathname === "" || pathname === "/") {
388
+ return "/";
389
+ }
390
+ const trimmed = pathname.replace(/\/+$/, "");
391
+ return trimmed === "" ? "/" : trimmed;
392
+ }
393
+
394
+ /** Parse a search string into a flat map; a repeated key keeps its last value. */
395
+ export function parseSearch(search: string): SearchParams {
396
+ const params: { [string]: string } = {};
397
+ for (const [key, value] of new URLSearchParams(search)) {
398
+ params[key] = value;
399
+ }
400
+ return params;
401
+ }
402
+
403
+ /** Stop rendering the current page and show the not-found page instead. */
404
+ export function notFound(): empty {
405
+ throw new NotFoundError();
406
+ }
407
+
408
+ /** Stop rendering the current page and show the error boundary, as a 401. */
409
+ export function unauthorized(): empty {
410
+ throw new UnauthorizedError();
411
+ }
412
+
413
+ /** Stop rendering the current page and show the error boundary, as a 403. */
414
+ export function forbidden(): empty {
415
+ throw new ForbiddenError();
416
+ }
417
+
418
+ /** Stop rendering the current page and send the visitor elsewhere. */
419
+ export function redirect(to: string): empty {
420
+ throw new RedirectError(to, false);
421
+ }
422
+
423
+ /** `redirect`, with a permanent status. */
424
+ export function permanentRedirect(to: string): empty {
425
+ throw new RedirectError(to, true);
426
+ }
@@ -68,15 +68,53 @@ import {
68
68
  routeBoundaries,
69
69
  suspenseId,
70
70
  } from "./boundaries.js";
71
-
72
- /** One parameter a route path captures. */
73
- export type RouteParamSpec = {| readonly name: string, readonly catchAll: boolean |};
74
-
75
- /** The parameters captured from a URL. A catch-all captures the rest as a list. */
76
- export type RouteParams = { readonly [string]: string | $ReadOnlyArray<string> };
77
-
78
- /** The query string, as a read-only map. */
79
- export type SearchParams = { readonly [string]: string };
71
+ import {
72
+ ForbiddenError,
73
+ NotFoundError,
74
+ RedirectError,
75
+ UnauthorizedError,
76
+ hasClientPage,
77
+ matchIn,
78
+ matchRoute,
79
+ nearestBoundary,
80
+ parseSearch,
81
+ routeErrorStatus,
82
+ splitUrl,
83
+ } from "./routing.js";
84
+ import type {
85
+ ErrorBoundary as RoutingErrorBoundary,
86
+ LoadingRecord as RoutingLoadingRecord,
87
+ NotFoundBoundary as RoutingNotFoundBoundary,
88
+ RouteError,
89
+ RouteMatch as RoutingRouteMatch,
90
+ RouteParams,
91
+ RouteRecord as RoutingRouteRecord,
92
+ RouteTable as RoutingRouteTable,
93
+ SearchParams,
94
+ SlotRecord as RoutingSlotRecord,
95
+ SlotRouteRecord as RoutingSlotRouteRecord,
96
+ TemplateRecord as RoutingTemplateRecord,
97
+ } from "./routing.js";
98
+
99
+ export type { RouteError, RouteParamSpec, RouteParams, SearchParams } from "./routing.js";
100
+
101
+ export {
102
+ ForbiddenError,
103
+ NotFoundError,
104
+ RedirectError,
105
+ UnauthorizedError,
106
+ buildRoute,
107
+ forbidden,
108
+ hasClientPage,
109
+ matchRoute,
110
+ notFound,
111
+ parseSearch,
112
+ permanentRedirect,
113
+ redirect,
114
+ routeErrorStatus,
115
+ splitUrl,
116
+ unauthorized,
117
+ } from "./routing.js";
80
118
 
81
119
  /**
82
120
  * A component found in a route module.
@@ -420,241 +458,34 @@ export type MetadataArgs = {|
420
458
  readonly data: mixed,
421
459
  |};
422
460
 
423
- /** One entry of the generated route table. */
424
- export type RouteRecord = {|
425
- readonly path: string,
426
- readonly params: $ReadOnlyArray<RouteParamSpec>,
427
- readonly mdx: boolean,
428
- readonly file: string,
429
- /**
430
- * The page module — absent when this table cannot render the route.
431
- *
432
- * The server's table always has one: the server renders every route. The
433
- * browser's may not. `@uniflowed/vite` leaves the page out of the client
434
- * route table when uf's server-component analysis finds no `"use client"`
435
- * boundary reachable from the page, its layouts or its fallbacks, and with
436
- * the `import()` gone so is the whole subtree it reached — which is the
437
- * point of leaving it out.
438
- *
439
- * The route stays in the table because the router still has to *match* the
440
- * URL. Matching is what tells a `Link` that the destination is a document
441
- * the browser must fetch rather than a page this bundle can render; a route
442
- * missing from the table entirely would be a 404 instead. See
443
- * [`hasClientPage`], which is the question every caller asks.
444
- */
445
- readonly page?: () => Promise<PageModule>,
446
- readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
447
- /**
448
- * The `<Suspense>` boundaries this route renders inside, root first.
449
- *
450
- * Optional because a table written before `$loading.js` existed — a
451
- * hand-written one in a test, a server bundle built by an older `uf` —
452
- * is still a table this router can render, and a route with no boundary is
453
- * exactly what it had before.
454
- */
455
- readonly loading?: $ReadOnlyArray<LoadingRecord>,
456
- /**
457
- * The `$template.js` wrappers this route renders inside, root first.
458
- *
459
- * Optional for the reason `loading` is: a table written before templates
460
- * existed is still a table this router can render, and a route with no
461
- * template renders exactly the tree it did before.
462
- */
463
- readonly templates?: $ReadOnlyArray<TemplateRecord>,
464
- /**
465
- * The parallel-route slots in scope on this route, outermost first.
466
- *
467
- * Optional for the reason `templates` is. A route with no slot renders
468
- * exactly the tree it did before slots existed, which is most routes.
469
- */
470
- readonly slots?: $ReadOnlyArray<SlotRecord>,
471
- |};
461
+ export type RouteRecord = RoutingRouteRecord<
462
+ PageModule,
463
+ LayoutModule,
464
+ TemplateModule,
465
+ LoadingModule,
466
+ >;
472
467
 
473
- /**
474
- * One parallel-route slot, as the route table carries it.
475
- *
476
- * A slot is a second thing a layout renders. `app/dashboard/@team/` gives
477
- * `app/dashboard/$layout.js` a `team` prop beside `children`, and the slot's
478
- * pages are matched against the same URL the page is: `/dashboard/members`
479
- * renders `app/dashboard/members/$page.js` as `children` and
480
- * `app/dashboard/@team/members/$page.js` as `team`, at once, each inside its
481
- * own layouts.
482
- *
483
- * A slot never adds a URL — the directory contributes no path segment — so
484
- * `routes` here is a second table matched against paths the main table already
485
- * defines. That is uf's answer to the question Next.js answers with a
486
- * `default.js` for `children`: there is no such thing, because `children` is
487
- * the page the URL matched and a URL that matches no page is a 404.
488
- *
489
- * `above` is how many of the route's `layouts` are outside the slot, so
490
- * `layouts[above - 1]` is the one that receives it — the same number, spelled
491
- * the same way, as [`TemplateRecord`]'s and [`LoadingRecord`]'s.
492
- */
493
- export type SlotRecord = {|
494
- readonly name: string,
495
- readonly above: number,
496
- /**
497
- * `$default.js`: what this slot renders when the URL matches none of its
498
- * routes.
499
- *
500
- * `null` for a slot that declares none, and then the slot renders nothing at
501
- * all. That is what an unaddressed slot does on a soft navigation in Next.js
502
- * too, and it is the honest answer for a slot that only some URLs have
503
- * something to put in — a modal, a detail pane.
504
- */
505
- readonly defaultPage: ?() => Promise<PageModule>,
506
- /** The default's source path, for diagnostics; absent when there is none. */
507
- readonly defaultFile?: string,
508
- /** Whether that default is MDX content. */
509
- readonly defaultMdx?: boolean,
510
- readonly routes: $ReadOnlyArray<SlotRouteRecord>,
511
- |};
468
+ export type SlotRecord = RoutingSlotRecord<PageModule, LayoutModule>;
512
469
 
513
- /**
514
- * One page inside a slot.
515
- *
516
- * A [`RouteRecord`] without the parts a slot does not have. No `loading` and no
517
- * `templates`: those belong to the segment, and they already wrap the layout
518
- * the slot renders into. Per-slot boundaries are the part of parallel routes uf
519
- * has not built, and `@uniflowed/vite`'s scan refuses the files rather than
520
- * leaving them unopened — see ubugeeei-prod/uf#267.
521
- *
522
- * `page` is required, unlike a `RouteRecord`'s: a slot route that ships no
523
- * client page has no URL of its own to hand the browser, so there would be
524
- * nothing to do with the entry. The client table drops the whole route when its
525
- * page is dropped, slots and all.
526
- *
527
- * `layouts` are the layouts *inside* the slot, and `slots` are the slots a
528
- * layout inside this one declares — the recursion is the feature rather than a
529
- * special case.
530
- */
531
- export type SlotRouteRecord = {|
532
- readonly path: string,
533
- readonly params: $ReadOnlyArray<RouteParamSpec>,
534
- readonly mdx: boolean,
535
- readonly file: string,
536
- readonly page: () => Promise<PageModule>,
537
- readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
538
- readonly slots: $ReadOnlyArray<SlotRecord>,
539
- |};
470
+ export type SlotRouteRecord = RoutingSlotRouteRecord<PageModule, LayoutModule>;
540
471
 
541
- /**
542
- * One `$template.js`, as the route table carries it.
543
- *
544
- * The same shape as [`LoadingRecord`] and the same `above`, because it answers
545
- * the same question — where in the stack of layouts this thing sits — and
546
- * there is no second vocabulary for it.
547
- */
548
- export type TemplateRecord = {|
549
- readonly above: number,
550
- readonly module: () => Promise<TemplateModule>,
551
- |};
552
-
553
- /**
554
- * One `$loading.js`, as the route table carries it.
555
- *
556
- * `above` is how many of the route's `layouts` are outside the boundary, which
557
- * is the same number `ResolvedRoute["errorBoundary"].above` means and is
558
- * spelled the same way on purpose: both answer "where in the stack of layouts
559
- * does this thing sit", and there is no second vocabulary for it.
560
- */
561
- export type LoadingRecord = {|
562
- readonly above: number,
563
- readonly module: () => Promise<LoadingModule>,
564
- |};
565
-
566
- /**
567
- * One not-found boundary: the page for a path under `path` that matched
568
- * nothing.
569
- *
570
- * `$not-found.js` is a segment file, so `path` is the route path of the
571
- * directory that declares it and `layouts` are the layouts in scope *there* —
572
- * which is what the boundary renders inside. A project with one at the router
573
- * root has one of these; a project whose manual answers its own 404 has two.
574
- */
575
- export type NotFoundBoundary = {|
576
- readonly path: string,
577
- readonly mdx: boolean,
578
- readonly file: string,
579
- /**
580
- * The page this boundary renders — `null` for the one the build synthesises
581
- * at the router root when a project declares no `$not-found.js` there.
582
- *
583
- * A project that had declared none used to get `layouts: []` along with the
584
- * framework's page: not the nearest-ancestor rule failing, but the fallback
585
- * having no record to take layouts from. So the root's layouts are a record
586
- * like any other, with the framework's component in place of a module to
587
- * import — which is a nullable field rather than a second kind of answer, and
588
- * is why an unmatched URL still arrives inside the site's own masthead. See
589
- * ubugeeei-prod/uf#351.
590
- */
591
- readonly page: ?() => Promise<PageModule>,
592
- readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
593
- |};
472
+ export type TemplateRecord = RoutingTemplateRecord<TemplateModule>;
594
473
 
595
- /**
596
- * One error boundary: what renders in place of the subtree under `path` when
597
- * something in it throws.
598
- *
599
- * The same nearest-ancestor shape as [`NotFoundBoundary`], and `layouts` means
600
- * the same thing — the layouts in scope where the file is, which stay mounted
601
- * around the error and are why the rest of the document is still there.
602
- */
603
- export type ErrorBoundary = {|
604
- readonly path: string,
605
- readonly file: string,
606
- /** `null` for the synthesised root record; see [`NotFoundBoundary`]`.page`. */
607
- readonly module: ?() => Promise<ErrorModule>,
608
- readonly layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
609
- |};
474
+ export type LoadingRecord = RoutingLoadingRecord<LoadingModule>;
610
475
 
611
- /**
612
- * A route table plus the boundaries declared under it.
613
- *
614
- * `errors` is the error boundaries a project declared, not failures that
615
- * happened.
616
- */
617
- export type RouteTable = {|
618
- readonly routes: $ReadOnlyArray<RouteRecord>,
619
- readonly notFound: $ReadOnlyArray<NotFoundBoundary>,
620
- readonly errors: $ReadOnlyArray<ErrorBoundary>,
621
- |};
476
+ export type NotFoundBoundary = RoutingNotFoundBoundary<PageModule, LayoutModule>;
622
477
 
623
- /** A URL matched against the table. */
624
- export type RouteMatch = {|
625
- readonly route: RouteRecord,
626
- readonly params: RouteParams,
627
- |};
478
+ export type ErrorBoundary = RoutingErrorBoundary<ErrorModule, LayoutModule>;
628
479
 
629
- /**
630
- * Why the router is rendering an error boundary instead of a page.
631
- *
632
- * One union rather than one file convention per status. `forbidden()` and
633
- * `unauthorized()` are not different *kinds* of file to write; they are
634
- * different sentences an error page says, and `match` over this is where a
635
- * page says all three and the checker confirms it covered them. Deciding it
636
- * the other way — `$forbidden.js` and `$unauthorized.js` beside
637
- * `$error.js`, which is what Next.js does — is three files per segment to
638
- * express one thing, and nothing would check that any of them handled the
639
- * case it was named for.
640
- *
641
- * The thrown value is carried but deliberately not rendered by the default
642
- * boundary: a server exception's message is written for the person who
643
- * deployed the application, not for whoever asks for the page.
644
- */
645
- export type RouteError =
646
- | {| readonly kind: "thrown", readonly error: mixed |}
647
- | {| readonly kind: "unauthorized" |}
648
- | {| readonly kind: "forbidden" |};
480
+ export type RouteTable = RoutingRouteTable<
481
+ PageModule,
482
+ LayoutModule,
483
+ TemplateModule,
484
+ LoadingModule,
485
+ ErrorModule,
486
+ >;
649
487
 
650
- /** The status a `RouteError` answers with. */
651
- export function routeErrorStatus(error: RouteError): 401 | 403 | 500 {
652
- return match (error) {
653
- {kind: "unauthorized"} => 401,
654
- {kind: "forbidden"} => 403,
655
- {kind: "thrown"} => 500,
656
- };
657
- }
488
+ export type RouteMatch = RoutingRouteMatch<RouteRecord>;
658
489
 
659
490
  /**
660
491
  * A match whose modules are loaded and whose loader has run or is running — or,
@@ -772,319 +603,6 @@ export type ResolvedSlot = {|
772
603
  readonly slots: $ReadOnlyArray<ResolvedSlot>,
773
604
  |};
774
605
 
775
- /** Thrown by `notFound()`; the renderer answers with the not-found page. */
776
- export class NotFoundError extends Error {
777
- constructor() {
778
- super("not found");
779
- this.name = "NotFoundError";
780
- }
781
- }
782
-
783
- /** Thrown by `unauthorized()`; the renderer answers with the error boundary. */
784
- export class UnauthorizedError extends Error {
785
- constructor() {
786
- super("unauthorized");
787
- this.name = "UnauthorizedError";
788
- }
789
- }
790
-
791
- /** Thrown by `forbidden()`; the renderer answers with the error boundary. */
792
- export class ForbiddenError extends Error {
793
- constructor() {
794
- super("forbidden");
795
- this.name = "ForbiddenError";
796
- }
797
- }
798
-
799
- /** Thrown by `redirect()`; the renderer answers with a redirect. */
800
- export class RedirectError extends Error {
801
- to: string;
802
- permanent: boolean;
803
-
804
- constructor(to: string, permanent: boolean) {
805
- super(`redirect to ${to}`);
806
- this.name = "RedirectError";
807
- this.to = to;
808
- this.permanent = permanent;
809
- }
810
- }
811
-
812
- // ---------------------------------------------------------------------------
813
- // Matching
814
- // ---------------------------------------------------------------------------
815
-
816
- type Segment =
817
- | {| readonly kind: "static", readonly value: string |}
818
- | {| readonly kind: "param", readonly name: string |}
819
- | {| readonly kind: "catchAll", readonly name: string |};
820
-
821
- function compile(routePath: string): $ReadOnlyArray<Segment> {
822
- return routePath
823
- .split("/")
824
- .filter((segment) => segment !== "")
825
- .map((segment): Segment => {
826
- if (segment.startsWith(":") && segment.endsWith("*")) {
827
- return { kind: "catchAll", name: segment.slice(1, -1) };
828
- }
829
- if (segment.startsWith(":")) {
830
- return { kind: "param", name: segment.slice(1) };
831
- }
832
- return { kind: "static", value: segment };
833
- });
834
- }
835
-
836
- /**
837
- * How specific a route is, for ranking: a static segment outranks a parameter,
838
- * which outranks a catch-all, and a longer path outranks a shorter one.
839
- */
840
- function specificity(segments: $ReadOnlyArray<Segment>): number {
841
- let score = 0;
842
- for (const segment of segments) {
843
- score += match (segment) {
844
- {kind: "static"} => 3,
845
- {kind: "param"} => 2,
846
- {kind: "catchAll"} => 1,
847
- };
848
- }
849
- return score;
850
- }
851
-
852
- function matchSegments(
853
- segments: $ReadOnlyArray<Segment>,
854
- parts: $ReadOnlyArray<string>,
855
- ): ?RouteParams {
856
- const params: { [string]: string | $ReadOnlyArray<string> } = {};
857
- let index = 0;
858
- for (const segment of segments) {
859
- match (segment) {
860
- {kind: "static", value: const value} => {
861
- if (parts[index] !== value) {
862
- return null;
863
- }
864
- index += 1;
865
- }
866
- {kind: "param", name: const name} => {
867
- if (index >= parts.length) {
868
- return null;
869
- }
870
- params[name] = decodeSegment(parts[index]);
871
- index += 1;
872
- }
873
- {kind: "catchAll", name: const name} => {
874
- params[name] = parts.slice(index).map(decodeSegment);
875
- index = parts.length;
876
- }
877
- }
878
- }
879
- return index === parts.length ? params : null;
880
- }
881
-
882
- /**
883
- * The URL for a route pattern and the parameters it takes.
884
- *
885
- * The inverse of [`matchSegments`], and deliberately built out of the same
886
- * [`compile`]: a builder that parsed patterns its own way would drift from the
887
- * matcher, and the drift would show up as a link that 404s rather than as a
888
- * failure anybody could see.
889
- *
890
- * The generated `router.js` is what a project calls — `route("/posts/:slug",
891
- * { slug })` — and it is typed there, so the parameters are checked before this
892
- * runs. This still refuses a bad call rather than building a wrong URL,
893
- * because the types are only in front of the callers that have them: a value
894
- * that arrived from JSON, or from a module that opted out of Flow, reaches
895
- * here unchecked. A link to `/posts/undefined` is the failure this exists to
896
- * turn into an error with a name on it.
897
- *
898
- * Each segment is `encodeURIComponent`d, which is what [`decodeSegment`]
899
- * undoes on the way back — so a slug with a slash in it round-trips as one
900
- * segment rather than becoming two.
901
- */
902
- export function buildRoute(routePath: string, params?: RouteParams): string {
903
- const values: RouteParams = params ?? {};
904
- const parts: Array<string> = [];
905
- for (const segment of compile(routePath)) {
906
- match (segment) {
907
- {kind: "static", value: const value} => {
908
- parts.push(value);
909
- }
910
- {kind: "param", name: const name} => {
911
- const value = values[name];
912
- if (typeof value !== "string") {
913
- throw new Error(
914
- `route ${routePath} takes a string for :${name}, and got ${describeParam(value)}`,
915
- );
916
- }
917
- parts.push(encodeURIComponent(value));
918
- }
919
- {kind: "catchAll", name: const name} => {
920
- const value = values[name];
921
- if (value == null || typeof value === "string") {
922
- throw new Error(
923
- `route ${routePath} takes an array of segments for :${name}*, and got ` +
924
- describeParam(value),
925
- );
926
- }
927
- for (const part of value) {
928
- parts.push(encodeURIComponent(part));
929
- }
930
- }
931
- }
932
- }
933
- return parts.length === 0 ? "/" : `/${parts.join("/")}`;
934
- }
935
-
936
- /** What a parameter was, for the message that says it was the wrong thing. */
937
- function describeParam(value: string | $ReadOnlyArray<string> | void): string {
938
- if (value === undefined) {
939
- return "nothing";
940
- }
941
- return typeof value === "string" ? `the string ${JSON.stringify(value)}` : "an array";
942
- }
943
-
944
- function decodeSegment(segment: string): string {
945
- try {
946
- return decodeURIComponent(segment);
947
- } catch {
948
- return segment;
949
- }
950
- }
951
-
952
- /**
953
- * Whether this table can render the route in the browser.
954
- *
955
- * False only in the client bundle, and only for a route uf decided ships no
956
- * JavaScript. Every caller that would load a page asks this first, and the two
957
- * answers are different actions rather than a success and a failure: render
958
- * it, or let the browser fetch the document.
959
- */
960
- export function hasClientPage(route: RouteRecord): boolean {
961
- return route.page != null;
962
- }
963
-
964
- /**
965
- * Match a pathname against the table, preferring the most specific route.
966
- */
967
- export function matchRoute(routes: $ReadOnlyArray<RouteRecord>, pathname: string): ?RouteMatch {
968
- return matchIn(routes, pathname);
969
- }
970
-
971
- /**
972
- * The same match, over anything that has a route path.
973
- *
974
- * A slot is a second table matched against the same URL — see [`SlotRecord`] —
975
- * and it has to be matched by *this* function rather than by one of its own:
976
- * two matchers would be two answers to "which of these paths does this URL
977
- * name", and the one that disagreed would show up as a slot holding somebody
978
- * else's page. The generic is only about the record type; the ranking, the
979
- * parameters and the tie-break are the route table's.
980
- */
981
- function matchIn<TRecord: { +path: string, ... }>(
982
- routes: $ReadOnlyArray<TRecord>,
983
- pathname: string,
984
- ): ?{| readonly route: TRecord, readonly params: RouteParams |} {
985
- const parts = pathname.split("/").filter((part) => part !== "");
986
- let best: ?{| readonly route: TRecord, readonly params: RouteParams |} = null;
987
- let bestScore = -1;
988
- for (const route of routes) {
989
- const segments = compile(route.path);
990
- const params = matchSegments(segments, parts);
991
- if (params == null) {
992
- continue;
993
- }
994
- const score = specificity(segments);
995
- if (score > bestScore) {
996
- best = { route, params };
997
- bestScore = score;
998
- }
999
- }
1000
- return best;
1001
- }
1002
-
1003
- /**
1004
- * Whether a boundary declared at `segments` is at or above `parts`.
1005
- *
1006
- * The same segment kinds as [`matchSegments`], stopping when the boundary's
1007
- * own segments run out instead of requiring the path to: `/guide` covers
1008
- * `/guide/nope`, and `/guide` covers `/guide` itself.
1009
- */
1010
- function covers(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>): boolean {
1011
- let index = 0;
1012
- for (const segment of segments) {
1013
- const next = match (segment) {
1014
- {kind: "static", value: const value} => parts[index] === value ? index + 1 : -1,
1015
- {kind: "param"} => index < parts.length ? index + 1 : -1,
1016
- {kind: "catchAll"} => parts.length,
1017
- };
1018
- if (next === -1) {
1019
- return false;
1020
- }
1021
- index = next;
1022
- }
1023
- return true;
1024
- }
1025
-
1026
- /**
1027
- * The nearest boundary above `pathname`, or `null` when none covers it.
1028
- *
1029
- * The one rule both `$not-found.js` and `$error.js` are resolved by, and
1030
- * the same one layouts already follow: nearest means the longest path that
1031
- * covers the URL. It is decided here rather than by the table's order — the
1032
- * table is sorted by path so the generated module is stable, and a resolver
1033
- * that read "nearest" as "first" would silently depend on that sort. Two
1034
- * boundaries can share a path (a route group's directory does not appear in
1035
- * the URL), and then the first in the table wins.
1036
- */
1037
- function nearestBoundary<TBoundary: { readonly path: string, ... }>(
1038
- boundaries: $ReadOnlyArray<TBoundary>,
1039
- pathname: string,
1040
- ): ?TBoundary {
1041
- const parts = pathname.split("/").filter((part) => part !== "");
1042
- let best: ?TBoundary = null;
1043
- let bestDepth = -1;
1044
- for (const boundary of boundaries) {
1045
- const segments = compile(boundary.path);
1046
- if (!covers(segments, parts)) {
1047
- continue;
1048
- }
1049
- if (segments.length > bestDepth) {
1050
- best = boundary;
1051
- bestDepth = segments.length;
1052
- }
1053
- }
1054
- return best;
1055
- }
1056
-
1057
- /** Split a URL into its pathname and search string. */
1058
- export function splitUrl(url: string): {| readonly pathname: string, readonly search: string |} {
1059
- const hash = url.indexOf("#");
1060
- const withoutHash = hash === -1 ? url : url.slice(0, hash);
1061
- const question = withoutHash.indexOf("?");
1062
- if (question === -1) {
1063
- return { pathname: normalizePathname(withoutHash), search: "" };
1064
- }
1065
- return {
1066
- pathname: normalizePathname(withoutHash.slice(0, question)),
1067
- search: withoutHash.slice(question),
1068
- };
1069
- }
1070
-
1071
- function normalizePathname(pathname: string): string {
1072
- if (pathname === "" || pathname === "/") {
1073
- return "/";
1074
- }
1075
- const trimmed = pathname.replace(/\/+$/, "");
1076
- return trimmed === "" ? "/" : trimmed;
1077
- }
1078
-
1079
- /** Parse a search string into a flat map; a repeated key keeps its last value. */
1080
- export function parseSearch(search: string): SearchParams {
1081
- const params: { [string]: string } = {};
1082
- for (const [key, value] of new URLSearchParams(search)) {
1083
- params[key] = value;
1084
- }
1085
- return params;
1086
- }
1087
-
1088
606
  // ---------------------------------------------------------------------------
1089
607
  // Loading
1090
608
  // ---------------------------------------------------------------------------
@@ -2691,6 +2209,9 @@ export component RouteView() {
2691
2209
  );
2692
2210
  }
2693
2211
  }
2212
+ if (needsRootStreamFrame(resolved)) {
2213
+ element = <RootStreamFrame>{element}</RootStreamFrame>;
2214
+ }
2694
2215
  return (
2695
2216
  <>
2696
2217
  <Head metadata={resolved.metadata} />
@@ -2706,6 +2227,28 @@ export component RouteView() {
2706
2227
  );
2707
2228
  }
2708
2229
 
2230
+ /**
2231
+ * Whether the outermost route fallback needs one host element above it.
2232
+ *
2233
+ * React can flush a shell whose suspended boundary is inside any host element,
2234
+ * but not one whose boundary is a direct child of the render root. A route with
2235
+ * no layout and a root `$loading.js` is exactly that second tree: every
2236
+ * framework component above it renders no element, so the fallback waits for
2237
+ * the page it was meant to stand in for. A root layout is already the element
2238
+ * that can carry it, and deeper fallbacks sit inside a layout by construction.
2239
+ */
2240
+ function needsRootStreamFrame(resolved: ResolvedRoute): boolean {
2241
+ return resolved.layouts.length === 0 && resolved.loading.some((boundary) => boundary.above === 0);
2242
+ }
2243
+
2244
+ component RootStreamFrame(children: React.Node) {
2245
+ return (
2246
+ <div data-uf-stream-root="" style={{ display: "contents" }}>
2247
+ {children}
2248
+ </div>
2249
+ );
2250
+ }
2251
+
2709
2252
  /**
2710
2253
  * The page, with the loader's answer and the copy of it the browser hydrates
2711
2254
  * from.
@@ -2814,10 +2357,8 @@ component AwaitedPage(loader: Promise<mixed>) {
2814
2357
  * rows rather than one each — the boundaries inside it still resolve
2815
2358
  * independently, since each is its own.
2816
2359
  *
2817
- * The same rule catches a route whose `$loading.js` sits above no layout:
2818
- * `RouteView` puts that boundary in the same position, and it does not stream
2819
- * either. That is a bug this file did not introduce and does not fix; it is
2820
- * written down in ubugeeei-prod/uf#519 rather than left to be rediscovered.
2360
+ * The same rule catches a route whose `$loading.js` sits above no layout, so
2361
+ * `RouteView` wraps that specific root shape in `RootStreamFrame`.
2821
2362
  */
2822
2363
  function payloadElements(data: mixed): React.Node {
2823
2364
  if (data === undefined) {
@@ -3506,31 +3047,6 @@ export function routerView(root: string): React.ComponentType<AppProps> {
3506
3047
  return App;
3507
3048
  }
3508
3049
 
3509
- /** Stop rendering the current page and show the not-found page instead. */
3510
- export function notFound(): empty {
3511
- throw new NotFoundError();
3512
- }
3513
-
3514
- /** Stop rendering the current page and show the error boundary, as a 401. */
3515
- export function unauthorized(): empty {
3516
- throw new UnauthorizedError();
3517
- }
3518
-
3519
- /** Stop rendering the current page and show the error boundary, as a 403. */
3520
- export function forbidden(): empty {
3521
- throw new ForbiddenError();
3522
- }
3523
-
3524
- /** Stop rendering the current page and send the visitor elsewhere. */
3525
- export function redirect(to: string): empty {
3526
- throw new RedirectError(to, false);
3527
- }
3528
-
3529
- /** `redirect`, with a permanent status. */
3530
- export function permanentRedirect(to: string): empty {
3531
- throw new RedirectError(to, true);
3532
- }
3533
-
3534
3050
  /**
3535
3051
  * Whether the app is being rendered on the server.
3536
3052
  *
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@uniflowed/router",
3
- "version": "0.0.0-alpha.28",
3
+ "version": "0.0.0-alpha.30",
4
4
  "description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
5
5
  "type": "module",
6
6
  "license": "MIT",
@@ -15,6 +15,7 @@
15
15
  "./action": "./action.js",
16
16
  "./client": "./client.js",
17
17
  "./server": "./server.js",
18
+ "./routing": "./routing.js",
18
19
  "./package.json": "./package.json",
19
20
  "./handler": "./handler.js",
20
21
  "./middleware": "./middleware.js"
@@ -26,6 +27,7 @@
26
27
  "index.js",
27
28
  "internal",
28
29
  "middleware.js",
30
+ "routing.js",
29
31
  "server.js",
30
32
  "!*.test.js"
31
33
  ],
@@ -34,7 +36,7 @@
34
36
  "react-dom": ">=19"
35
37
  },
36
38
  "dependencies": {
37
- "@uniflowed/hooks": "0.0.0-alpha.28",
38
- "@uniflowed/server": "0.0.0-alpha.28"
39
+ "@uniflowed/hooks": "0.0.0-alpha.30",
40
+ "@uniflowed/server": "0.0.0-alpha.30"
39
41
  }
40
42
  }
package/routing.js ADDED
@@ -0,0 +1,42 @@
1
+ // @flow
2
+ //
3
+ // `@uniflowed/router/routing`: React-free route table helpers.
4
+ //
5
+ // Import this from server-oriented modules that need matching, URL building,
6
+ // or router control errors without importing the client router, React
7
+ // components, or hooks.
8
+
9
+ export type {
10
+ ErrorBoundary,
11
+ LoadingRecord,
12
+ NotFoundBoundary,
13
+ RouteError,
14
+ RouteMatch,
15
+ RouteModule,
16
+ RouteParamSpec,
17
+ RouteParams,
18
+ RouteRecord,
19
+ RouteTable,
20
+ SearchParams,
21
+ SlotRecord,
22
+ SlotRouteRecord,
23
+ TemplateRecord,
24
+ } from "./internal/routing.js";
25
+
26
+ export {
27
+ ForbiddenError,
28
+ NotFoundError,
29
+ RedirectError,
30
+ UnauthorizedError,
31
+ buildRoute,
32
+ forbidden,
33
+ hasClientPage,
34
+ matchRoute,
35
+ notFound,
36
+ parseSearch,
37
+ permanentRedirect,
38
+ redirect,
39
+ routeErrorStatus,
40
+ splitUrl,
41
+ unauthorized,
42
+ } from "./internal/routing.js";