@uniflowed/router 0.0.0-alpha.1
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/client.js +38 -0
- package/index.js +64 -0
- package/internal/runtime.js +792 -0
- package/package.json +29 -0
- package/server.js +138 -0
package/client.js
ADDED
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Hydrating the document in the browser.
|
|
4
|
+
//
|
|
5
|
+
// `virtual:uf/client` calls `hydrate` with the app root and the route table.
|
|
6
|
+
// The current route's chunks are loaded and its embedded loader data read
|
|
7
|
+
// *before* `hydrateRoot`, so the first client render is synchronous and
|
|
8
|
+
// matches the server's markup exactly.
|
|
9
|
+
|
|
10
|
+
import * as React from "react";
|
|
11
|
+
import { startTransition } from "react";
|
|
12
|
+
import { hydrateRoot } from "react-dom/client";
|
|
13
|
+
|
|
14
|
+
import { type AppProps, type RouteTable, installRoutes, resolveMatch } from "./internal/runtime.js";
|
|
15
|
+
import { DATA_ID, ROOT_ID } from "./server.js";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* Hydrate the current document.
|
|
19
|
+
*/
|
|
20
|
+
export async function hydrate(options: {|
|
|
21
|
+
+App: React.ComponentType<AppProps>,
|
|
22
|
+
+routes: RouteTable["routes"],
|
|
23
|
+
+notFound: RouteTable["notFound"],
|
|
24
|
+
|}): Promise<void> {
|
|
25
|
+
const table: RouteTable = { routes: options.routes, notFound: options.notFound };
|
|
26
|
+
installRoutes(table);
|
|
27
|
+
|
|
28
|
+
const url = window.location.pathname + window.location.search;
|
|
29
|
+
const embedded = document.getElementById(DATA_ID);
|
|
30
|
+
const data = embedded != null ? JSON.parse(embedded.textContent ?? "null") : undefined;
|
|
31
|
+
const resolved = await resolveMatch(table, url, { data, skipLoader: embedded != null });
|
|
32
|
+
|
|
33
|
+
const { App } = options;
|
|
34
|
+
const container = document.getElementById(ROOT_ID) ?? document;
|
|
35
|
+
startTransition(() => {
|
|
36
|
+
hydrateRoot(container, <App url={url} initial={resolved} />);
|
|
37
|
+
});
|
|
38
|
+
}
|
package/index.js
ADDED
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// `@uniflowed/router`: the file-system router.
|
|
4
|
+
//
|
|
5
|
+
// Pages live in `app/` as `_uf.page.js` (or `.mdx`), layouts as
|
|
6
|
+
// `_uf.layout.js`, and `app.js` exports `routerView("./app")`. The route table
|
|
7
|
+
// is generated from the directory at build time; this module is the runtime
|
|
8
|
+
// that matches, loads, navigates and renders it.
|
|
9
|
+
|
|
10
|
+
export type {
|
|
11
|
+
AppProps,
|
|
12
|
+
LayoutModule,
|
|
13
|
+
LinkPrefetch,
|
|
14
|
+
LoaderArgs,
|
|
15
|
+
Metadata,
|
|
16
|
+
MetadataArgs,
|
|
17
|
+
NavigateOptions,
|
|
18
|
+
PageModule,
|
|
19
|
+
ResolvedRoute,
|
|
20
|
+
RouteInfo,
|
|
21
|
+
RouteMatch,
|
|
22
|
+
RouteParamSpec,
|
|
23
|
+
RouteParams,
|
|
24
|
+
RouteRecord,
|
|
25
|
+
RouteTable,
|
|
26
|
+
Router,
|
|
27
|
+
SearchParams,
|
|
28
|
+
} from "./internal/runtime.js";
|
|
29
|
+
|
|
30
|
+
export {
|
|
31
|
+
Link,
|
|
32
|
+
NotFoundError,
|
|
33
|
+
RedirectError,
|
|
34
|
+
RouteView,
|
|
35
|
+
RouterProvider,
|
|
36
|
+
matchRoute,
|
|
37
|
+
notFound,
|
|
38
|
+
parseSearch,
|
|
39
|
+
permanentRedirect,
|
|
40
|
+
redirect,
|
|
41
|
+
resolveMatch,
|
|
42
|
+
routerView,
|
|
43
|
+
splitUrl,
|
|
44
|
+
useIsServer,
|
|
45
|
+
useLoaderData,
|
|
46
|
+
useRoute,
|
|
47
|
+
useRouter,
|
|
48
|
+
} from "./internal/runtime.js";
|
|
49
|
+
|
|
50
|
+
/** Props a page receives. */
|
|
51
|
+
export type PageProps<
|
|
52
|
+
TParams: { +[string]: string | $ReadOnlyArray<string> } = {},
|
|
53
|
+
TData = void,
|
|
54
|
+
> = {|
|
|
55
|
+
+params: TParams,
|
|
56
|
+
+searchParams: { +[string]: string },
|
|
57
|
+
+data: TData,
|
|
58
|
+
|};
|
|
59
|
+
|
|
60
|
+
/** Props a layout receives. */
|
|
61
|
+
export type LayoutProps<TParams: { +[string]: string | $ReadOnlyArray<string> } = {}> = {|
|
|
62
|
+
+params: TParams,
|
|
63
|
+
+children: React$Node,
|
|
64
|
+
|};
|
|
@@ -0,0 +1,792 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// The router runtime: matching, loading, navigation, and the React binding.
|
|
4
|
+
//
|
|
5
|
+
// A route table is data — the virtual module `virtual:uf/routes` that
|
|
6
|
+
// `@uniflowed/vite` generates from the `app/` directory — and this module is
|
|
7
|
+
// everything that turns it into a running application. The same code runs on
|
|
8
|
+
// the server (`./server.js` renders one URL) and in the browser (`./client.js`
|
|
9
|
+
// hydrates it and then navigates), so a page's loader, layouts and metadata
|
|
10
|
+
// resolve identically in both places.
|
|
11
|
+
|
|
12
|
+
import * as React from "react";
|
|
13
|
+
import {
|
|
14
|
+
createContext,
|
|
15
|
+
startTransition,
|
|
16
|
+
useCallback,
|
|
17
|
+
useContext,
|
|
18
|
+
useEffect,
|
|
19
|
+
useMemo,
|
|
20
|
+
useState,
|
|
21
|
+
useSyncExternalStore,
|
|
22
|
+
} from "react";
|
|
23
|
+
|
|
24
|
+
/** One parameter a route path captures. */
|
|
25
|
+
export type RouteParamSpec = {| +name: string, +catchAll: boolean |};
|
|
26
|
+
|
|
27
|
+
/** The parameters captured from a URL. A catch-all captures the rest as a list. */
|
|
28
|
+
export type RouteParams = { +[string]: string | $ReadOnlyArray<string> };
|
|
29
|
+
|
|
30
|
+
/** The query string, as a read-only map. */
|
|
31
|
+
export type SearchParams = { +[string]: string };
|
|
32
|
+
|
|
33
|
+
/** What a page module may export. The component is `default` or `Page`. */
|
|
34
|
+
export type PageModule = {
|
|
35
|
+
+default?: React.ComponentType<any>,
|
|
36
|
+
+Page?: React.ComponentType<any>,
|
|
37
|
+
+loader?: (args: LoaderArgs) => mixed | Promise<mixed>,
|
|
38
|
+
+metadata?: Metadata,
|
|
39
|
+
+generateMetadata?: (args: MetadataArgs) => Metadata | Promise<Metadata>,
|
|
40
|
+
+generateStaticParams?: () => $ReadOnlyArray<RouteParams> | Promise<$ReadOnlyArray<RouteParams>>,
|
|
41
|
+
+frontmatter?: { +title?: string, +description?: string, ... },
|
|
42
|
+
...
|
|
43
|
+
};
|
|
44
|
+
|
|
45
|
+
/** What a layout module may export. The component is `default` or `Layout`. */
|
|
46
|
+
export type LayoutModule = {
|
|
47
|
+
+default?: React.ComponentType<any>,
|
|
48
|
+
+Layout?: React.ComponentType<any>,
|
|
49
|
+
+metadata?: Metadata,
|
|
50
|
+
...
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
/** Document metadata a page or layout declares. */
|
|
54
|
+
export type Metadata = {
|
|
55
|
+
+title?: string,
|
|
56
|
+
+description?: string,
|
|
57
|
+
+openGraph?: {
|
|
58
|
+
+title?: string,
|
|
59
|
+
+description?: string,
|
|
60
|
+
+images?: $ReadOnlyArray<string>,
|
|
61
|
+
},
|
|
62
|
+
};
|
|
63
|
+
|
|
64
|
+
/** Arguments a loader receives. */
|
|
65
|
+
export type LoaderArgs = {|
|
|
66
|
+
+params: RouteParams,
|
|
67
|
+
+searchParams: SearchParams,
|
|
68
|
+
+pathname: string,
|
|
69
|
+
|};
|
|
70
|
+
|
|
71
|
+
/** Arguments `generateMetadata` receives. */
|
|
72
|
+
export type MetadataArgs = {|
|
|
73
|
+
+params: RouteParams,
|
|
74
|
+
+searchParams: SearchParams,
|
|
75
|
+
+data: mixed,
|
|
76
|
+
|};
|
|
77
|
+
|
|
78
|
+
/** One entry of the generated route table. */
|
|
79
|
+
export type RouteRecord = {|
|
|
80
|
+
+path: string,
|
|
81
|
+
+params: $ReadOnlyArray<RouteParamSpec>,
|
|
82
|
+
+mdx: boolean,
|
|
83
|
+
+file: string,
|
|
84
|
+
+page: () => Promise<PageModule>,
|
|
85
|
+
+layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
86
|
+
|};
|
|
87
|
+
|
|
88
|
+
/** The not-found page, when the app declares one. */
|
|
89
|
+
export type NotFoundRecord = {|
|
|
90
|
+
+mdx: boolean,
|
|
91
|
+
+file: string,
|
|
92
|
+
+page: () => Promise<PageModule>,
|
|
93
|
+
+layouts: $ReadOnlyArray<() => Promise<LayoutModule>>,
|
|
94
|
+
|};
|
|
95
|
+
|
|
96
|
+
/** A route table plus the not-found page. */
|
|
97
|
+
export type RouteTable = {|
|
|
98
|
+
+routes: $ReadOnlyArray<RouteRecord>,
|
|
99
|
+
+notFound: ?NotFoundRecord,
|
|
100
|
+
|};
|
|
101
|
+
|
|
102
|
+
/** A URL matched against the table. */
|
|
103
|
+
export type RouteMatch = {|
|
|
104
|
+
+route: RouteRecord,
|
|
105
|
+
+params: RouteParams,
|
|
106
|
+
|};
|
|
107
|
+
|
|
108
|
+
/** A match whose modules are loaded and whose loader has run. */
|
|
109
|
+
export type ResolvedRoute = {|
|
|
110
|
+
+pathname: string,
|
|
111
|
+
+search: string,
|
|
112
|
+
+path: string,
|
|
113
|
+
+params: RouteParams,
|
|
114
|
+
+searchParams: SearchParams,
|
|
115
|
+
+page: PageModule,
|
|
116
|
+
+layouts: $ReadOnlyArray<LayoutModule>,
|
|
117
|
+
+data: mixed,
|
|
118
|
+
+metadata: Metadata,
|
|
119
|
+
+status: 200 | 404,
|
|
120
|
+
|};
|
|
121
|
+
|
|
122
|
+
/** Thrown by `notFound()`; the renderer answers with the not-found page. */
|
|
123
|
+
export class NotFoundError extends Error {
|
|
124
|
+
constructor() {
|
|
125
|
+
super("not found");
|
|
126
|
+
this.name = "NotFoundError";
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/** Thrown by `redirect()`; the renderer answers with a redirect. */
|
|
131
|
+
export class RedirectError extends Error {
|
|
132
|
+
to: string;
|
|
133
|
+
permanent: boolean;
|
|
134
|
+
|
|
135
|
+
constructor(to: string, permanent: boolean) {
|
|
136
|
+
super(`redirect to ${to}`);
|
|
137
|
+
this.name = "RedirectError";
|
|
138
|
+
this.to = to;
|
|
139
|
+
this.permanent = permanent;
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// ---------------------------------------------------------------------------
|
|
144
|
+
// Matching
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
|
|
147
|
+
type Segment =
|
|
148
|
+
| {| +kind: "static", +value: string |}
|
|
149
|
+
| {| +kind: "param", +name: string |}
|
|
150
|
+
| {| +kind: "catchAll", +name: string |};
|
|
151
|
+
|
|
152
|
+
function compile(routePath: string): $ReadOnlyArray<Segment> {
|
|
153
|
+
return routePath
|
|
154
|
+
.split("/")
|
|
155
|
+
.filter((segment) => segment !== "")
|
|
156
|
+
.map((segment): Segment => {
|
|
157
|
+
if (segment.startsWith(":") && segment.endsWith("*")) {
|
|
158
|
+
return { kind: "catchAll", name: segment.slice(1, -1) };
|
|
159
|
+
}
|
|
160
|
+
if (segment.startsWith(":")) {
|
|
161
|
+
return { kind: "param", name: segment.slice(1) };
|
|
162
|
+
}
|
|
163
|
+
return { kind: "static", value: segment };
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* How specific a route is, for ranking: a static segment outranks a parameter,
|
|
169
|
+
* which outranks a catch-all, and a longer path outranks a shorter one.
|
|
170
|
+
*/
|
|
171
|
+
function specificity(segments: $ReadOnlyArray<Segment>): number {
|
|
172
|
+
let score = 0;
|
|
173
|
+
for (const segment of segments) {
|
|
174
|
+
score +=
|
|
175
|
+
match (segment) {
|
|
176
|
+
{ kind: "static" } => 3,
|
|
177
|
+
{ kind: "param" } => 2,
|
|
178
|
+
{ kind: "catchAll" } => 1,
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
return score;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
function matchSegments(segments: $ReadOnlyArray<Segment>, parts: $ReadOnlyArray<string>): ?RouteParams {
|
|
185
|
+
const params: { [string]: string | $ReadOnlyArray<string> } = {};
|
|
186
|
+
let index = 0;
|
|
187
|
+
for (const segment of segments) {
|
|
188
|
+
match (segment) {
|
|
189
|
+
{ kind: "static", value: const value } => {
|
|
190
|
+
if (parts[index] !== value) {
|
|
191
|
+
return null;
|
|
192
|
+
}
|
|
193
|
+
index += 1;
|
|
194
|
+
}
|
|
195
|
+
{ kind: "param", name: const name } => {
|
|
196
|
+
if (index >= parts.length) {
|
|
197
|
+
return null;
|
|
198
|
+
}
|
|
199
|
+
params[name] = decodeSegment(parts[index]);
|
|
200
|
+
index += 1;
|
|
201
|
+
}
|
|
202
|
+
{ kind: "catchAll", name: const name } => {
|
|
203
|
+
params[name] = parts.slice(index).map(decodeSegment);
|
|
204
|
+
index = parts.length;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
return index === parts.length ? params : null;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function decodeSegment(segment: string): string {
|
|
212
|
+
try {
|
|
213
|
+
return decodeURIComponent(segment);
|
|
214
|
+
} catch {
|
|
215
|
+
return segment;
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Match a pathname against the table, preferring the most specific route.
|
|
221
|
+
*/
|
|
222
|
+
export function matchRoute(routes: $ReadOnlyArray<RouteRecord>, pathname: string): ?RouteMatch {
|
|
223
|
+
const parts = pathname.split("/").filter((part) => part !== "");
|
|
224
|
+
let best: ?RouteMatch = null;
|
|
225
|
+
let bestScore = -1;
|
|
226
|
+
for (const route of routes) {
|
|
227
|
+
const segments = compile(route.path);
|
|
228
|
+
const params = matchSegments(segments, parts);
|
|
229
|
+
if (params == null) {
|
|
230
|
+
continue;
|
|
231
|
+
}
|
|
232
|
+
const score = specificity(segments);
|
|
233
|
+
if (score > bestScore) {
|
|
234
|
+
best = { route, params };
|
|
235
|
+
bestScore = score;
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
return best;
|
|
239
|
+
}
|
|
240
|
+
|
|
241
|
+
/** Split a URL into its pathname and search string. */
|
|
242
|
+
export function splitUrl(url: string): {| +pathname: string, +search: string |} {
|
|
243
|
+
const hash = url.indexOf("#");
|
|
244
|
+
const withoutHash = hash === -1 ? url : url.slice(0, hash);
|
|
245
|
+
const question = withoutHash.indexOf("?");
|
|
246
|
+
if (question === -1) {
|
|
247
|
+
return { pathname: normalizePathname(withoutHash), search: "" };
|
|
248
|
+
}
|
|
249
|
+
return {
|
|
250
|
+
pathname: normalizePathname(withoutHash.slice(0, question)),
|
|
251
|
+
search: withoutHash.slice(question),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
function normalizePathname(pathname: string): string {
|
|
256
|
+
if (pathname === "" || pathname === "/") {
|
|
257
|
+
return "/";
|
|
258
|
+
}
|
|
259
|
+
const trimmed = pathname.replace(/\/+$/, "");
|
|
260
|
+
return trimmed === "" ? "/" : trimmed;
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
/** Parse a search string into a flat map; a repeated key keeps its last value. */
|
|
264
|
+
export function parseSearch(search: string): SearchParams {
|
|
265
|
+
const params: { [string]: string } = {};
|
|
266
|
+
for (const [key, value] of new URLSearchParams(search)) {
|
|
267
|
+
params[key] = value;
|
|
268
|
+
}
|
|
269
|
+
return params;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
// ---------------------------------------------------------------------------
|
|
273
|
+
// Loading
|
|
274
|
+
// ---------------------------------------------------------------------------
|
|
275
|
+
|
|
276
|
+
const moduleCache: Map<() => Promise<mixed>, Promise<mixed>> = new Map();
|
|
277
|
+
|
|
278
|
+
function loadOnce<T>(load: () => Promise<T>): Promise<T> {
|
|
279
|
+
let pending = moduleCache.get(load);
|
|
280
|
+
if (pending == null) {
|
|
281
|
+
pending = load();
|
|
282
|
+
moduleCache.set(load, pending);
|
|
283
|
+
}
|
|
284
|
+
// $FlowFixMe[incompatible-return] the cache is keyed by the loader, whose result type it stores.
|
|
285
|
+
return pending;
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
/**
|
|
289
|
+
* Load a match's modules and run its loader.
|
|
290
|
+
*
|
|
291
|
+
* `data` is what the loader returned; on the client after hydration it is the
|
|
292
|
+
* value the server embedded, so the loader does not run twice for the first
|
|
293
|
+
* page.
|
|
294
|
+
*/
|
|
295
|
+
export async function resolveMatch(
|
|
296
|
+
table: RouteTable,
|
|
297
|
+
url: string,
|
|
298
|
+
options?: {| +data?: mixed, +skipLoader?: boolean |},
|
|
299
|
+
): Promise<ResolvedRoute> {
|
|
300
|
+
const { pathname, search } = splitUrl(url);
|
|
301
|
+
const searchParams = parseSearch(search);
|
|
302
|
+
const matched = matchRoute(table.routes, pathname);
|
|
303
|
+
|
|
304
|
+
if (matched == null) {
|
|
305
|
+
return resolveNotFound(table, pathname, search, searchParams);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const [page, ...layouts] = await Promise.all([
|
|
309
|
+
loadOnce(matched.route.page),
|
|
310
|
+
...matched.route.layouts.map((layout) => loadOnce(layout)),
|
|
311
|
+
]);
|
|
312
|
+
|
|
313
|
+
let data: mixed = options?.data;
|
|
314
|
+
if (options?.skipLoader !== true && typeof page.loader === "function") {
|
|
315
|
+
try {
|
|
316
|
+
data = await page.loader({ params: matched.params, searchParams, pathname });
|
|
317
|
+
} catch (error) {
|
|
318
|
+
if (error instanceof NotFoundError) {
|
|
319
|
+
return resolveNotFound(table, pathname, search, searchParams);
|
|
320
|
+
}
|
|
321
|
+
throw error;
|
|
322
|
+
}
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
const metadata = await resolveMetadata(page, layouts, { params: matched.params, searchParams, data });
|
|
326
|
+
return {
|
|
327
|
+
pathname,
|
|
328
|
+
search,
|
|
329
|
+
path: matched.route.path,
|
|
330
|
+
params: matched.params,
|
|
331
|
+
searchParams,
|
|
332
|
+
page,
|
|
333
|
+
layouts,
|
|
334
|
+
data,
|
|
335
|
+
metadata,
|
|
336
|
+
status: 200,
|
|
337
|
+
};
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
async function resolveNotFound(
|
|
341
|
+
table: RouteTable,
|
|
342
|
+
pathname: string,
|
|
343
|
+
search: string,
|
|
344
|
+
searchParams: SearchParams,
|
|
345
|
+
): Promise<ResolvedRoute> {
|
|
346
|
+
const record = table.notFound;
|
|
347
|
+
if (record == null) {
|
|
348
|
+
return {
|
|
349
|
+
pathname,
|
|
350
|
+
search,
|
|
351
|
+
path: "*",
|
|
352
|
+
params: {},
|
|
353
|
+
searchParams,
|
|
354
|
+
page: { default: DefaultNotFound },
|
|
355
|
+
layouts: [],
|
|
356
|
+
data: undefined,
|
|
357
|
+
metadata: { title: "Not found" },
|
|
358
|
+
status: 404,
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
const [page, ...layouts] = await Promise.all([
|
|
362
|
+
loadOnce(record.page),
|
|
363
|
+
...record.layouts.map((layout) => loadOnce(layout)),
|
|
364
|
+
]);
|
|
365
|
+
const metadata = await resolveMetadata(page, layouts, { params: {}, searchParams, data: undefined });
|
|
366
|
+
return {
|
|
367
|
+
pathname,
|
|
368
|
+
search,
|
|
369
|
+
path: "*",
|
|
370
|
+
params: {},
|
|
371
|
+
searchParams,
|
|
372
|
+
page,
|
|
373
|
+
layouts,
|
|
374
|
+
data: undefined,
|
|
375
|
+
metadata,
|
|
376
|
+
status: 404,
|
|
377
|
+
};
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
async function resolveMetadata(
|
|
381
|
+
page: PageModule,
|
|
382
|
+
layouts: $ReadOnlyArray<LayoutModule>,
|
|
383
|
+
args: MetadataArgs,
|
|
384
|
+
): Promise<Metadata> {
|
|
385
|
+
let merged: Metadata = {};
|
|
386
|
+
for (const layout of layouts) {
|
|
387
|
+
if (layout.metadata != null) {
|
|
388
|
+
merged = { ...merged, ...layout.metadata };
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
if (page.frontmatter != null) {
|
|
392
|
+
const { title, description } = page.frontmatter;
|
|
393
|
+
merged = { ...merged, ...(title != null ? { title } : {}), ...(description != null ? { description } : {}) };
|
|
394
|
+
}
|
|
395
|
+
if (page.metadata != null) {
|
|
396
|
+
merged = { ...merged, ...page.metadata };
|
|
397
|
+
}
|
|
398
|
+
if (typeof page.generateMetadata === "function") {
|
|
399
|
+
merged = { ...merged, ...(await page.generateMetadata(args)) };
|
|
400
|
+
}
|
|
401
|
+
return merged;
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
component DefaultNotFound() {
|
|
405
|
+
return (
|
|
406
|
+
<main>
|
|
407
|
+
<title>Not found</title>
|
|
408
|
+
<h1>404</h1>
|
|
409
|
+
<p>This page does not exist.</p>
|
|
410
|
+
</main>
|
|
411
|
+
);
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// ---------------------------------------------------------------------------
|
|
415
|
+
// The React binding
|
|
416
|
+
// ---------------------------------------------------------------------------
|
|
417
|
+
|
|
418
|
+
/** How a navigation is performed. */
|
|
419
|
+
export type NavigateOptions = {| +replace?: boolean, +scroll?: boolean |};
|
|
420
|
+
|
|
421
|
+
/** What `useRouter()` returns. */
|
|
422
|
+
export type Router = {|
|
|
423
|
+
+push: (to: string, options?: NavigateOptions) => Promise<void>,
|
|
424
|
+
+replace: (to: string) => Promise<void>,
|
|
425
|
+
+prefetch: (to: string) => Promise<void>,
|
|
426
|
+
+refresh: () => Promise<void>,
|
|
427
|
+
+back: () => void,
|
|
428
|
+
+forward: () => void,
|
|
429
|
+
|};
|
|
430
|
+
|
|
431
|
+
/** What `useRoute()` returns. */
|
|
432
|
+
export type RouteInfo = {|
|
|
433
|
+
+path: string,
|
|
434
|
+
+pathname: string,
|
|
435
|
+
+params: RouteParams,
|
|
436
|
+
+searchParams: SearchParams,
|
|
437
|
+
+data: mixed,
|
|
438
|
+
+pending: boolean,
|
|
439
|
+
|};
|
|
440
|
+
|
|
441
|
+
type RouterState = {|
|
|
442
|
+
+resolved: ResolvedRoute,
|
|
443
|
+
+router: Router,
|
|
444
|
+
+pending: boolean,
|
|
445
|
+
|};
|
|
446
|
+
|
|
447
|
+
const RouterContext: React.Context<?RouterState> = createContext(null);
|
|
448
|
+
|
|
449
|
+
/** The route table the application was started with. */
|
|
450
|
+
let installedTable: ?RouteTable = null;
|
|
451
|
+
|
|
452
|
+
/** Register the generated route table. Called once by the client and server entries. */
|
|
453
|
+
export function installRoutes(table: RouteTable): void {
|
|
454
|
+
installedTable = table;
|
|
455
|
+
}
|
|
456
|
+
|
|
457
|
+
/** The registered table, or a clear error when the entry forgot to install it. */
|
|
458
|
+
export function routeTable(): RouteTable {
|
|
459
|
+
if (installedTable == null) {
|
|
460
|
+
throw new Error("@uniflowed/router: no route table is installed; start the app through `uf dev` or `uf build`");
|
|
461
|
+
}
|
|
462
|
+
return installedTable;
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
/** Props the app root receives from the client and server entries. */
|
|
466
|
+
export type AppProps = {|
|
|
467
|
+
+url: string,
|
|
468
|
+
+initial: ResolvedRoute,
|
|
469
|
+
|};
|
|
470
|
+
|
|
471
|
+
const isBrowser = typeof window !== "undefined" && typeof document !== "undefined";
|
|
472
|
+
|
|
473
|
+
/**
|
|
474
|
+
* Provides the current route to the tree and performs navigation.
|
|
475
|
+
*
|
|
476
|
+
* On the server the route is fixed for the request. In the browser the
|
|
477
|
+
* provider listens to history and to `Link` clicks; a navigation resolves the
|
|
478
|
+
* next route (loading its chunks and running its loader) *before* committing,
|
|
479
|
+
* inside a transition, so the previous page stays interactive meanwhile.
|
|
480
|
+
*/
|
|
481
|
+
export component RouterProvider(url: string, initial: ResolvedRoute, children: React.Node) {
|
|
482
|
+
const [resolved, setResolved] = useState<ResolvedRoute>(initial);
|
|
483
|
+
const [pending, setPending] = useState<boolean>(false);
|
|
484
|
+
|
|
485
|
+
const navigate = useCallback(async (to: string, options?: NavigateOptions): Promise<void> => {
|
|
486
|
+
if (!isBrowser) {
|
|
487
|
+
return;
|
|
488
|
+
}
|
|
489
|
+
const target = new URL(to, window.location.href);
|
|
490
|
+
const next = target.pathname + target.search;
|
|
491
|
+
setPending(true);
|
|
492
|
+
try {
|
|
493
|
+
const nextResolved = await resolveMatch(routeTable(), next);
|
|
494
|
+
if (options?.replace === true) {
|
|
495
|
+
window.history.replaceState(null, "", next + target.hash);
|
|
496
|
+
} else {
|
|
497
|
+
window.history.pushState(null, "", next + target.hash);
|
|
498
|
+
}
|
|
499
|
+
startTransition(() => {
|
|
500
|
+
setResolved(nextResolved);
|
|
501
|
+
setPending(false);
|
|
502
|
+
});
|
|
503
|
+
if (options?.scroll !== false) {
|
|
504
|
+
if (target.hash !== "") {
|
|
505
|
+
const element = document.getElementById(target.hash.slice(1));
|
|
506
|
+
if (element != null) {
|
|
507
|
+
element.scrollIntoView();
|
|
508
|
+
return;
|
|
509
|
+
}
|
|
510
|
+
}
|
|
511
|
+
window.scrollTo(0, 0);
|
|
512
|
+
}
|
|
513
|
+
} catch (error) {
|
|
514
|
+
setPending(false);
|
|
515
|
+
throw error;
|
|
516
|
+
}
|
|
517
|
+
}, []);
|
|
518
|
+
|
|
519
|
+
useEffect(() => {
|
|
520
|
+
if (!isBrowser) {
|
|
521
|
+
return undefined;
|
|
522
|
+
}
|
|
523
|
+
const onPopState = () => {
|
|
524
|
+
const next = window.location.pathname + window.location.search;
|
|
525
|
+
resolveMatch(routeTable(), next).then((nextResolved) => {
|
|
526
|
+
startTransition(() => {
|
|
527
|
+
setResolved(nextResolved);
|
|
528
|
+
});
|
|
529
|
+
});
|
|
530
|
+
};
|
|
531
|
+
window.addEventListener("popstate", onPopState);
|
|
532
|
+
return () => {
|
|
533
|
+
window.removeEventListener("popstate", onPopState);
|
|
534
|
+
};
|
|
535
|
+
}, []);
|
|
536
|
+
|
|
537
|
+
const router = useMemo<Router>(
|
|
538
|
+
() => ({
|
|
539
|
+
push: (to, options) => navigate(to, options),
|
|
540
|
+
replace: (to) => navigate(to, { replace: true }),
|
|
541
|
+
prefetch: async (to) => {
|
|
542
|
+
if (!isBrowser) {
|
|
543
|
+
return;
|
|
544
|
+
}
|
|
545
|
+
const target = new URL(to, window.location.href);
|
|
546
|
+
const matched = matchRoute(routeTable().routes, target.pathname);
|
|
547
|
+
if (matched == null) {
|
|
548
|
+
return;
|
|
549
|
+
}
|
|
550
|
+
await Promise.all([loadOnce(matched.route.page), ...matched.route.layouts.map((layout) => loadOnce(layout))]);
|
|
551
|
+
},
|
|
552
|
+
refresh: async () => {
|
|
553
|
+
if (!isBrowser) {
|
|
554
|
+
return;
|
|
555
|
+
}
|
|
556
|
+
const nextResolved = await resolveMatch(routeTable(), window.location.pathname + window.location.search);
|
|
557
|
+
startTransition(() => {
|
|
558
|
+
setResolved(nextResolved);
|
|
559
|
+
});
|
|
560
|
+
},
|
|
561
|
+
back: () => {
|
|
562
|
+
if (isBrowser) {
|
|
563
|
+
window.history.back();
|
|
564
|
+
}
|
|
565
|
+
},
|
|
566
|
+
forward: () => {
|
|
567
|
+
if (isBrowser) {
|
|
568
|
+
window.history.forward();
|
|
569
|
+
}
|
|
570
|
+
},
|
|
571
|
+
}),
|
|
572
|
+
[navigate],
|
|
573
|
+
);
|
|
574
|
+
|
|
575
|
+
const value = useMemo<RouterState>(() => ({ resolved, router, pending }), [resolved, router, pending]);
|
|
576
|
+
return <RouterContext.Provider value={value}>{children}</RouterContext.Provider>;
|
|
577
|
+
}
|
|
578
|
+
|
|
579
|
+
hook useRouterState(): RouterState {
|
|
580
|
+
const state = useContext(RouterContext);
|
|
581
|
+
if (state == null) {
|
|
582
|
+
throw new Error("@uniflowed/router: this hook must be used inside the app started by `routerView`");
|
|
583
|
+
}
|
|
584
|
+
return state;
|
|
585
|
+
}
|
|
586
|
+
|
|
587
|
+
/** The current route. */
|
|
588
|
+
export hook useRoute(): RouteInfo {
|
|
589
|
+
const { resolved, pending } = useRouterState();
|
|
590
|
+
return {
|
|
591
|
+
path: resolved.path,
|
|
592
|
+
pathname: resolved.pathname,
|
|
593
|
+
params: resolved.params,
|
|
594
|
+
searchParams: resolved.searchParams,
|
|
595
|
+
data: resolved.data,
|
|
596
|
+
pending,
|
|
597
|
+
};
|
|
598
|
+
}
|
|
599
|
+
|
|
600
|
+
/** Navigation. */
|
|
601
|
+
export hook useRouter(): Router {
|
|
602
|
+
return useRouterState().router;
|
|
603
|
+
}
|
|
604
|
+
|
|
605
|
+
/** The current page's loader data. */
|
|
606
|
+
export hook useLoaderData<T>(): T {
|
|
607
|
+
// $FlowFixMe[unclear-type] loader data is typed by the page that declares the loader.
|
|
608
|
+
return (useRouterState().resolved.data: any);
|
|
609
|
+
}
|
|
610
|
+
|
|
611
|
+
/**
|
|
612
|
+
* Renders the matched page inside its layouts, innermost last, with the
|
|
613
|
+
* document metadata as hoistable head elements.
|
|
614
|
+
*/
|
|
615
|
+
export component RouteView() {
|
|
616
|
+
const { resolved } = useRouterState();
|
|
617
|
+
const Page = pageComponent(resolved.page);
|
|
618
|
+
let element: React.Node = (
|
|
619
|
+
<Page params={resolved.params} searchParams={resolved.searchParams} data={resolved.data} />
|
|
620
|
+
);
|
|
621
|
+
for (let index = resolved.layouts.length - 1; index >= 0; index -= 1) {
|
|
622
|
+
const Layout = layoutComponent(resolved.layouts[index]);
|
|
623
|
+
element = <Layout params={resolved.params}>{element}</Layout>;
|
|
624
|
+
}
|
|
625
|
+
return (
|
|
626
|
+
<>
|
|
627
|
+
<Head metadata={resolved.metadata} />
|
|
628
|
+
{element}
|
|
629
|
+
</>
|
|
630
|
+
);
|
|
631
|
+
}
|
|
632
|
+
|
|
633
|
+
/**
|
|
634
|
+
* The component a page module renders: its default export, or the named
|
|
635
|
+
* `Page` that `uf create` scaffolds. An MDX page always has a default export.
|
|
636
|
+
*/
|
|
637
|
+
function pageComponent(module: PageModule): React.ComponentType<any> {
|
|
638
|
+
const component = module.default ?? module.Page;
|
|
639
|
+
if (component == null) {
|
|
640
|
+
throw new Error("@uniflowed/router: a page module must export a component as `default` or `Page`");
|
|
641
|
+
}
|
|
642
|
+
return component;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
/** The component a layout module renders: `default`, or the named `Layout`. */
|
|
646
|
+
function layoutComponent(module: LayoutModule): React.ComponentType<any> {
|
|
647
|
+
const component = module.default ?? module.Layout;
|
|
648
|
+
if (component == null) {
|
|
649
|
+
throw new Error("@uniflowed/router: a layout module must export a component as `default` or `Layout`");
|
|
650
|
+
}
|
|
651
|
+
return component;
|
|
652
|
+
}
|
|
653
|
+
|
|
654
|
+
component Head(metadata: Metadata) {
|
|
655
|
+
const { title, description, openGraph } = metadata;
|
|
656
|
+
return (
|
|
657
|
+
<>
|
|
658
|
+
{title != null ? <title>{title}</title> : null}
|
|
659
|
+
{description != null ? <meta name="description" content={description} /> : null}
|
|
660
|
+
{openGraph?.title != null ? <meta property="og:title" content={openGraph.title} /> : null}
|
|
661
|
+
{openGraph?.description != null ? <meta property="og:description" content={openGraph.description} /> : null}
|
|
662
|
+
{openGraph?.images != null
|
|
663
|
+
? openGraph.images.map((image) => <meta key={image} property="og:image" content={image} />)
|
|
664
|
+
: null}
|
|
665
|
+
</>
|
|
666
|
+
);
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
/** When a `Link` loads the route it points at. */
|
|
670
|
+
export type LinkPrefetch = "off" | "intent" | "render";
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* A client-side navigation.
|
|
674
|
+
*
|
|
675
|
+
* Renders a real anchor, so the link works before hydration and for a right
|
|
676
|
+
* click, and takes over only a plain left click. `prefetch="intent"` (the
|
|
677
|
+
* default) loads the destination's chunks on hover or focus.
|
|
678
|
+
*/
|
|
679
|
+
export component Link(
|
|
680
|
+
to: string,
|
|
681
|
+
prefetch?: LinkPrefetch = "intent",
|
|
682
|
+
replace?: boolean = false,
|
|
683
|
+
children?: React.Node,
|
|
684
|
+
className?: string,
|
|
685
|
+
onClick?: (event: SyntheticMouseEvent<HTMLAnchorElement>) => mixed,
|
|
686
|
+
...rest: { +[string]: mixed }
|
|
687
|
+
) {
|
|
688
|
+
const router = useRouter();
|
|
689
|
+
const prefetched = React.useRef(false);
|
|
690
|
+
|
|
691
|
+
const doPrefetch = () => {
|
|
692
|
+
if (prefetch === "off" || prefetched.current || isExternal(to)) {
|
|
693
|
+
return;
|
|
694
|
+
}
|
|
695
|
+
prefetched.current = true;
|
|
696
|
+
router.prefetch(to).catch(() => {});
|
|
697
|
+
};
|
|
698
|
+
|
|
699
|
+
useEffect(() => {
|
|
700
|
+
if (prefetch === "render") {
|
|
701
|
+
doPrefetch();
|
|
702
|
+
}
|
|
703
|
+
});
|
|
704
|
+
|
|
705
|
+
const handleClick = (event: SyntheticMouseEvent<HTMLAnchorElement>) => {
|
|
706
|
+
if (onClick != null) {
|
|
707
|
+
onClick(event);
|
|
708
|
+
}
|
|
709
|
+
if (
|
|
710
|
+
event.defaultPrevented ||
|
|
711
|
+
event.button !== 0 ||
|
|
712
|
+
event.metaKey ||
|
|
713
|
+
event.ctrlKey ||
|
|
714
|
+
event.shiftKey ||
|
|
715
|
+
event.altKey ||
|
|
716
|
+
isExternal(to)
|
|
717
|
+
) {
|
|
718
|
+
return;
|
|
719
|
+
}
|
|
720
|
+
event.preventDefault();
|
|
721
|
+
router.push(to, { replace }).catch((error) => {
|
|
722
|
+
// A failed navigation falls back to the browser doing it.
|
|
723
|
+
console.error(error);
|
|
724
|
+
window.location.assign(to);
|
|
725
|
+
});
|
|
726
|
+
};
|
|
727
|
+
|
|
728
|
+
return (
|
|
729
|
+
<a
|
|
730
|
+
{...rest}
|
|
731
|
+
href={to}
|
|
732
|
+
className={className}
|
|
733
|
+
onClick={handleClick}
|
|
734
|
+
onMouseEnter={prefetch === "intent" ? doPrefetch : undefined}
|
|
735
|
+
onFocus={prefetch === "intent" ? doPrefetch : undefined}
|
|
736
|
+
>
|
|
737
|
+
{children}
|
|
738
|
+
</a>
|
|
739
|
+
);
|
|
740
|
+
}
|
|
741
|
+
|
|
742
|
+
function isExternal(to: string): boolean {
|
|
743
|
+
return /^[a-z][a-z0-9+.-]*:/i.test(to) || to.startsWith("//");
|
|
744
|
+
}
|
|
745
|
+
|
|
746
|
+
/**
|
|
747
|
+
* The application root `app.js` exports: `export default routerView("./app")`.
|
|
748
|
+
*
|
|
749
|
+
* The argument documents where the routes live; the table itself is generated
|
|
750
|
+
* from that directory at build time and installed by the entry that starts
|
|
751
|
+
* the app, so the component only has to render it.
|
|
752
|
+
*/
|
|
753
|
+
export function routerView(root: string): React.ComponentType<AppProps> {
|
|
754
|
+
void root;
|
|
755
|
+
component App(url: string, initial: ResolvedRoute) {
|
|
756
|
+
return (
|
|
757
|
+
<RouterProvider url={url} initial={initial}>
|
|
758
|
+
<RouteView />
|
|
759
|
+
</RouterProvider>
|
|
760
|
+
);
|
|
761
|
+
}
|
|
762
|
+
return App;
|
|
763
|
+
}
|
|
764
|
+
|
|
765
|
+
/** Stop rendering the current page and show the not-found page instead. */
|
|
766
|
+
export function notFound(): empty {
|
|
767
|
+
throw new NotFoundError();
|
|
768
|
+
}
|
|
769
|
+
|
|
770
|
+
/** Stop rendering the current page and send the visitor elsewhere. */
|
|
771
|
+
export function redirect(to: string): empty {
|
|
772
|
+
throw new RedirectError(to, false);
|
|
773
|
+
}
|
|
774
|
+
|
|
775
|
+
/** `redirect`, with a permanent status. */
|
|
776
|
+
export function permanentRedirect(to: string): empty {
|
|
777
|
+
throw new RedirectError(to, true);
|
|
778
|
+
}
|
|
779
|
+
|
|
780
|
+
/**
|
|
781
|
+
* Whether the app is being rendered on the server.
|
|
782
|
+
*
|
|
783
|
+
* Read through `useSyncExternalStore` so a component that branches on it
|
|
784
|
+
* hydrates consistently: the server snapshot is `true`, the client one `false`.
|
|
785
|
+
*/
|
|
786
|
+
export hook useIsServer(): boolean {
|
|
787
|
+
return useSyncExternalStore(
|
|
788
|
+
() => () => {},
|
|
789
|
+
() => false,
|
|
790
|
+
() => true,
|
|
791
|
+
);
|
|
792
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@uniflowed/router",
|
|
3
|
+
"version": "0.0.0-alpha.1",
|
|
4
|
+
"description": "The file-system router for Flow React applications: matching, layouts, loaders, navigation, server rendering and hydration.",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"license": "MIT",
|
|
7
|
+
"sideEffects": false,
|
|
8
|
+
"repository": {
|
|
9
|
+
"type": "git",
|
|
10
|
+
"url": "git+https://github.com/ubugeeei-prod/uf.git",
|
|
11
|
+
"directory": "packages/router"
|
|
12
|
+
},
|
|
13
|
+
"exports": {
|
|
14
|
+
".": "./index.js",
|
|
15
|
+
"./client": "./client.js",
|
|
16
|
+
"./server": "./server.js",
|
|
17
|
+
"./package.json": "./package.json"
|
|
18
|
+
},
|
|
19
|
+
"files": [
|
|
20
|
+
"index.js",
|
|
21
|
+
"client.js",
|
|
22
|
+
"server.js",
|
|
23
|
+
"internal"
|
|
24
|
+
],
|
|
25
|
+
"peerDependencies": {
|
|
26
|
+
"react": ">=19",
|
|
27
|
+
"react-dom": ">=19"
|
|
28
|
+
}
|
|
29
|
+
}
|
package/server.js
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Rendering one URL to an HTML document.
|
|
4
|
+
//
|
|
5
|
+
// `virtual:uf/server` calls `createRenderer` with the app root and the route
|
|
6
|
+
// table, and `uf dev` renders every document request through the result while
|
|
7
|
+
// `uf build` renders every static route through it once. Both produce the
|
|
8
|
+
// same markup from the same code, which is the point.
|
|
9
|
+
|
|
10
|
+
import * as React from "react";
|
|
11
|
+
import { renderToString } from "react-dom/server";
|
|
12
|
+
|
|
13
|
+
import {
|
|
14
|
+
type AppProps,
|
|
15
|
+
type ResolvedRoute,
|
|
16
|
+
type RouteTable,
|
|
17
|
+
RedirectError,
|
|
18
|
+
installRoutes,
|
|
19
|
+
resolveMatch,
|
|
20
|
+
} from "./internal/runtime.js";
|
|
21
|
+
|
|
22
|
+
/** Asset URLs to reference from the document. */
|
|
23
|
+
export type RenderAssets = {|
|
|
24
|
+
+scripts: $ReadOnlyArray<string>,
|
|
25
|
+
+styles: $ReadOnlyArray<string>,
|
|
26
|
+
+preloads: $ReadOnlyArray<string>,
|
|
27
|
+
|};
|
|
28
|
+
|
|
29
|
+
/** A rendered document. */
|
|
30
|
+
export type RenderResult = {|
|
|
31
|
+
+status: number,
|
|
32
|
+
+html: string,
|
|
33
|
+
+headers?: { +[string]: string },
|
|
34
|
+
|};
|
|
35
|
+
|
|
36
|
+
/** The id of the element the client hydrates when the app does not render `<html>`. */
|
|
37
|
+
export const ROOT_ID = "uf-root";
|
|
38
|
+
|
|
39
|
+
/** The id of the script carrying the loader data to the client. */
|
|
40
|
+
export const DATA_ID = "__uf_data";
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* Build a `render(url, assets)` for one app.
|
|
44
|
+
*/
|
|
45
|
+
export function createRenderer(options: {|
|
|
46
|
+
+App: React.ComponentType<AppProps>,
|
|
47
|
+
+routes: RouteTable["routes"],
|
|
48
|
+
+notFound: RouteTable["notFound"],
|
|
49
|
+
|}): (url: string, assets: RenderAssets) => Promise<RenderResult> {
|
|
50
|
+
const table: RouteTable = { routes: options.routes, notFound: options.notFound };
|
|
51
|
+
installRoutes(table);
|
|
52
|
+
const { App } = options;
|
|
53
|
+
|
|
54
|
+
return async function render(url: string, assets: RenderAssets): Promise<RenderResult> {
|
|
55
|
+
let resolved: ResolvedRoute;
|
|
56
|
+
try {
|
|
57
|
+
resolved = await resolveMatch(table, url);
|
|
58
|
+
} catch (error) {
|
|
59
|
+
if (error instanceof RedirectError) {
|
|
60
|
+
return redirectDocument(error);
|
|
61
|
+
}
|
|
62
|
+
throw error;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const markup = renderToString(<App url={url} initial={resolved} />);
|
|
66
|
+
const html = assemble(markup, resolved, assets);
|
|
67
|
+
return { status: resolved.status, html };
|
|
68
|
+
};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function redirectDocument(error: RedirectError): RenderResult {
|
|
72
|
+
const target = escapeAttribute(error.to);
|
|
73
|
+
return {
|
|
74
|
+
status: error.permanent ? 308 : 307,
|
|
75
|
+
headers: { Location: error.to },
|
|
76
|
+
html: `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="refresh" content="0; url=${target}"><title>Redirecting</title></head><body><a href="${target}">Redirecting…</a></body></html>\n`,
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Turn the app's markup into a complete document.
|
|
82
|
+
*
|
|
83
|
+
* An app whose root layout renders `<html>` owns the whole document, and the
|
|
84
|
+
* client hydrates `document`; the scripts and stylesheets are inserted before
|
|
85
|
+
* `</head>`. An app that renders only content is wrapped in a minimal shell
|
|
86
|
+
* around `<div id="uf-root">`, which is what the client hydrates instead.
|
|
87
|
+
*/
|
|
88
|
+
function assemble(markup: string, resolved: ResolvedRoute, assets: RenderAssets): string {
|
|
89
|
+
const head = headTags(assets) + dataScript(resolved.data);
|
|
90
|
+
if (/^\s*<html[\s>]/i.test(markup)) {
|
|
91
|
+
const document = markup.includes("</head>")
|
|
92
|
+
? markup.replace("</head>", `${head}</head>`)
|
|
93
|
+
: markup.replace(/<html([^>]*)>/i, `<html$1><head>${head}</head>`);
|
|
94
|
+
return `<!doctype html>\n${document}\n`;
|
|
95
|
+
}
|
|
96
|
+
const title = resolved.metadata.title != null ? `<title>${escapeText(resolved.metadata.title)}</title>` : "";
|
|
97
|
+
return `<!doctype html>\n<html lang="en"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1">${title}${head}</head><body><div id="${ROOT_ID}">${markup}</div></body></html>\n`;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function headTags(assets: RenderAssets): string {
|
|
101
|
+
let tags = "";
|
|
102
|
+
for (const href of assets.styles) {
|
|
103
|
+
tags += `<link rel="stylesheet" href="${escapeAttribute(href)}">`;
|
|
104
|
+
}
|
|
105
|
+
for (const href of assets.preloads) {
|
|
106
|
+
tags += `<link rel="modulepreload" href="${escapeAttribute(href)}">`;
|
|
107
|
+
}
|
|
108
|
+
for (const src of assets.scripts) {
|
|
109
|
+
tags += `<script type="module" src="${escapeAttribute(src)}"></script>`;
|
|
110
|
+
}
|
|
111
|
+
return tags;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* The loader data, embedded for hydration.
|
|
116
|
+
*
|
|
117
|
+
* `<` is escaped inside the JSON so a string holding `</script>` cannot end
|
|
118
|
+
* the element early, and the script's type keeps the browser from executing
|
|
119
|
+
* it.
|
|
120
|
+
*/
|
|
121
|
+
function dataScript(data: mixed): string {
|
|
122
|
+
if (data === undefined) {
|
|
123
|
+
return "";
|
|
124
|
+
}
|
|
125
|
+
const json = JSON.stringify(data)
|
|
126
|
+
.replace(/</g, "\\u003c")
|
|
127
|
+
.replace(/\u2028/g, "\\u2028")
|
|
128
|
+
.replace(/\u2029/g, "\\u2029");
|
|
129
|
+
return `<script id="${DATA_ID}" type="application/json">${json}</script>`;
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function escapeAttribute(value: string): string {
|
|
133
|
+
return value.replace(/&/g, "&").replace(/"/g, """).replace(/</g, "<");
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function escapeText(value: string): string {
|
|
137
|
+
return value.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
|
|
138
|
+
}
|