@uniflowed/router 0.0.0-alpha.9 → 0.2.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/action.js +344 -0
- package/client.js +263 -7
- package/handler.js +113 -99
- package/http-client.js +104 -0
- package/index.js +49 -6
- package/instrumentation.js +92 -0
- package/internal/action-endpoint.js +646 -0
- package/internal/action-wire.js +608 -0
- package/internal/base-path.js +175 -0
- package/internal/boundaries.js +481 -0
- package/internal/boundary-data.js +88 -0
- package/internal/compose.js +490 -0
- package/internal/deployment.js +160 -0
- package/internal/devtools.js +131 -0
- package/internal/diagnostics.js +169 -0
- package/internal/error-view.js +193 -0
- package/internal/flight-browser.js +242 -0
- package/internal/flight-chunks.js +205 -0
- package/internal/flight-rows.js +135 -0
- package/internal/flight-ssr.js +91 -0
- package/internal/flight.js +192 -0
- package/internal/form-action.js +243 -0
- package/internal/head.js +219 -0
- package/internal/hydrate-options.js +38 -0
- package/internal/hydration.js +1085 -0
- package/internal/inspector.js +626 -0
- package/internal/native-links.js +67 -0
- package/internal/native-tree.js +89 -0
- package/internal/navigation-cache.js +181 -0
- package/internal/payload-rows.js +270 -0
- package/internal/payload.js +685 -0
- package/internal/prepare-document.js +54 -0
- package/internal/react-version.js +77 -0
- package/internal/resolve.js +1617 -0
- package/internal/resolved-summary.js +199 -0
- package/internal/routing.js +478 -0
- package/internal/runtime.js +1593 -1341
- package/internal/server-instrumentation.js +12 -0
- package/internal/server-route.js +58 -0
- package/internal/shell.js +132 -0
- package/internal/stream.js +766 -21
- package/middleware.js +274 -22
- package/native-navigation.js +217 -0
- package/native.js +416 -0
- package/package.json +48 -7
- package/routing.js +51 -0
- package/rsc-client.js +122 -0
- package/rsc-ssr.js +641 -0
- package/rsc.js +402 -0
- package/server-components.js +159 -0
- package/server.js +263 -106
|
@@ -0,0 +1,192 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: the names a Flight payload is spoken in.
|
|
4
|
+
//
|
|
5
|
+
// A route rendered by React Server Components travels as React's own Flight
|
|
6
|
+
// payload (ubugeeei-prod/uf#519). `react-server-dom-parcel` writes it on the
|
|
7
|
+
// server and reads it in the browser, and uf invents no format of its own —
|
|
8
|
+
// the one uf used to have for loader data, `./payload.js`'s numbered rows, was
|
|
9
|
+
// Flight-shaped precisely so that it could be replaced by this. What uf still
|
|
10
|
+
// decides is two things around the payload: what its root value *is*, and the
|
|
11
|
+
// URL a browser asks for one at. Every graph has to agree about both, so both
|
|
12
|
+
// are here.
|
|
13
|
+
//
|
|
14
|
+
// Pure data and string functions, and type imports only: the module graph
|
|
15
|
+
// that renders server components, the one that renders HTML and the browser's
|
|
16
|
+
// all import this file, and none of them may be handed anything the others
|
|
17
|
+
// could not evaluate.
|
|
18
|
+
|
|
19
|
+
import type { Node } from "react";
|
|
20
|
+
|
|
21
|
+
import type { RouteError, RouteParams, SearchParams } from "./routing.js";
|
|
22
|
+
import type { Metadata, ResolvedRoute } from "./resolve.js";
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* What a route resolved to, as the browser holds it.
|
|
26
|
+
*
|
|
27
|
+
* Everything a hook reads, and nothing that is a module. A resolved route
|
|
28
|
+
* carries the page, the layouts and the boundaries it loaded; none of those
|
|
29
|
+
* crosses, because what they rendered is already in the payload beside this
|
|
30
|
+
* value, and a component the browser does need crosses inside that tree as a
|
|
31
|
+
* client reference. So `useRoute()` and `useLoaderData()` read this, and
|
|
32
|
+
* `RouteView` renders the tree.
|
|
33
|
+
*
|
|
34
|
+
* `data` and `deferred` cross as Flight values. That is a narrower contract
|
|
35
|
+
* than the JSON the loader's answer used to be embedded as: Flight carries a
|
|
36
|
+
* `Date`, a `Map`, a `Set` and a promise, and it refuses a class instance by
|
|
37
|
+
* name rather than turning it into `{}` the way `JSON.stringify` did. A thrown
|
|
38
|
+
* error in `error` crosses the way React sends any error value — with its
|
|
39
|
+
* message in development and replaced by React's own sentence in a build.
|
|
40
|
+
*/
|
|
41
|
+
export type RouteState = {|
|
|
42
|
+
readonly pathname: string,
|
|
43
|
+
readonly search: string,
|
|
44
|
+
readonly path: string,
|
|
45
|
+
readonly params: RouteParams,
|
|
46
|
+
readonly searchParams: SearchParams,
|
|
47
|
+
/** What the loader returned, once it has. `undefined` while `deferred` is set. */
|
|
48
|
+
readonly data: mixed,
|
|
49
|
+
/** The loader still running, as a promise the browser can `use`. */
|
|
50
|
+
readonly deferred: ?Promise<mixed>,
|
|
51
|
+
readonly metadata: Metadata,
|
|
52
|
+
readonly viewTransition: ?string,
|
|
53
|
+
readonly status: 200 | 401 | 403 | 404 | 500,
|
|
54
|
+
readonly error: ?RouteError,
|
|
55
|
+
readonly interception?: ?FlightInterception,
|
|
56
|
+
|};
|
|
57
|
+
|
|
58
|
+
/** Row 0 of a route's payload: the route, and the tree it rendered. */
|
|
59
|
+
export type FlightRoot = {|
|
|
60
|
+
readonly route: RouteState,
|
|
61
|
+
readonly tree: Node,
|
|
62
|
+
/**
|
|
63
|
+
* The build that rendered it, when the build has an id.
|
|
64
|
+
*
|
|
65
|
+
* In the payload rather than only in a response header, because the payload
|
|
66
|
+
* of a prerendered route is a file and a host that serves files runs nothing
|
|
67
|
+
* that could set one. A page on another build loads the document instead of
|
|
68
|
+
* rendering this; see `./deployment.js`.
|
|
69
|
+
*/
|
|
70
|
+
readonly deployment?: string | null,
|
|
71
|
+
|};
|
|
72
|
+
|
|
73
|
+
/** The intercepted URL a Flight payload rendered, and the page it rendered over. */
|
|
74
|
+
export type FlightInterception = {|
|
|
75
|
+
readonly pathname: string,
|
|
76
|
+
readonly search: string,
|
|
77
|
+
readonly from: string,
|
|
78
|
+
|};
|
|
79
|
+
|
|
80
|
+
/** What the browser may send with a payload request. */
|
|
81
|
+
export type FlightFetchOptions = {|
|
|
82
|
+
readonly interceptedFrom?: string,
|
|
83
|
+
|};
|
|
84
|
+
|
|
85
|
+
/**
|
|
86
|
+
* What fetching a route's payload turned into.
|
|
87
|
+
*
|
|
88
|
+
* Declared here rather than beside the fetch in `./flight-browser.js`, because
|
|
89
|
+
* the router's navigation names this type and must not import React's Flight
|
|
90
|
+
* client: `./runtime.js` is in every application's bundle, and that client is
|
|
91
|
+
* only in the bundles of applications that render Server Components
|
|
92
|
+
* (ubugeeei-prod/uf#992).
|
|
93
|
+
*/
|
|
94
|
+
export type FetchedFlight =
|
|
95
|
+
| {|
|
|
96
|
+
readonly kind: "flight",
|
|
97
|
+
/** The route the server answered for: a redirect's target, when there was one. */
|
|
98
|
+
readonly url: string,
|
|
99
|
+
readonly root: Promise<FlightRoot>,
|
|
100
|
+
|}
|
|
101
|
+
| {|
|
|
102
|
+
/**
|
|
103
|
+
* The answer was not a payload: a redirect off this origin, or a host
|
|
104
|
+
* that had no payload for the URL. The browser should load `url` as a
|
|
105
|
+
* document.
|
|
106
|
+
*/
|
|
107
|
+
readonly kind: "document",
|
|
108
|
+
readonly url: string,
|
|
109
|
+
|};
|
|
110
|
+
|
|
111
|
+
/** The part of a resolved route that crosses to the browser. */
|
|
112
|
+
export function routeState(resolved: ResolvedRoute): RouteState {
|
|
113
|
+
const interception = resolved.interception;
|
|
114
|
+
return {
|
|
115
|
+
pathname: resolved.pathname,
|
|
116
|
+
search: resolved.search,
|
|
117
|
+
path: resolved.path,
|
|
118
|
+
params: resolved.params,
|
|
119
|
+
searchParams: resolved.searchParams,
|
|
120
|
+
data: resolved.data,
|
|
121
|
+
deferred: resolved.deferred,
|
|
122
|
+
metadata: resolved.metadata,
|
|
123
|
+
viewTransition: resolved.viewTransition,
|
|
124
|
+
status: resolved.status,
|
|
125
|
+
error: resolved.error,
|
|
126
|
+
interception:
|
|
127
|
+
interception == null
|
|
128
|
+
? null
|
|
129
|
+
: {
|
|
130
|
+
pathname: interception.pathname,
|
|
131
|
+
search: interception.search,
|
|
132
|
+
from: interception.base.pathname + interception.base.search,
|
|
133
|
+
},
|
|
134
|
+
};
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
/**
|
|
138
|
+
* The last path segment of the URL a route's payload is fetched from.
|
|
139
|
+
*
|
|
140
|
+
* A path rather than a header, for the two reasons a header would have been
|
|
141
|
+
* wrong. A static host answers files, and a file has one name per URL: `uf
|
|
142
|
+
* build` writes `dist/guide/__uf.flight` beside `dist/guide/index.html`, and a
|
|
143
|
+
* host that has never heard of uf serves both. And a shared cache that ignores
|
|
144
|
+
* `Vary` would hand a browser a payload where it asked for a document, which is
|
|
145
|
+
* the class of bug `docs/security.md` records for RSC caches — a different URL
|
|
146
|
+
* cannot be confused with the document's by anything that caches by URL.
|
|
147
|
+
*
|
|
148
|
+
* A segment rather than an extension, so that `/` and `/index` stay two URLs
|
|
149
|
+
* with two payloads, and so that a middleware guarding `/dashboard` guards the
|
|
150
|
+
* payload of `/dashboard` by the path rule it already has.
|
|
151
|
+
*/
|
|
152
|
+
export const FLIGHT_SEGMENT: string = "__uf.flight";
|
|
153
|
+
|
|
154
|
+
/** The content type a payload is answered with. */
|
|
155
|
+
export const FLIGHT_CONTENT_TYPE: string = "text/x-component";
|
|
156
|
+
|
|
157
|
+
/** The request header that carries the page an intercepted payload is rendered over. */
|
|
158
|
+
export const INTERCEPTED_FROM_HEADER: string = "uf-intercepted-from";
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The URL of `url`'s payload: its pathname with [`FLIGHT_SEGMENT`] appended,
|
|
162
|
+
* and its query string unchanged.
|
|
163
|
+
*
|
|
164
|
+
* `url` is a path and query, as the router holds one; a hash never reaches a
|
|
165
|
+
* server and is dropped.
|
|
166
|
+
*/
|
|
167
|
+
export function flightUrl(url: string): string {
|
|
168
|
+
const hash = url.indexOf("#");
|
|
169
|
+
const withoutHash = hash === -1 ? url : url.slice(0, hash);
|
|
170
|
+
const question = withoutHash.indexOf("?");
|
|
171
|
+
const pathname = question === -1 ? withoutHash : withoutHash.slice(0, question);
|
|
172
|
+
const search = question === -1 ? "" : withoutHash.slice(question);
|
|
173
|
+
const trimmed = pathname.replace(/\/+$/, "");
|
|
174
|
+
return `${trimmed}/${FLIGHT_SEGMENT}${search}`;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/**
|
|
178
|
+
* The document path a payload URL's pathname names, or `null` when the
|
|
179
|
+
* pathname is not a payload URL.
|
|
180
|
+
*
|
|
181
|
+
* `/__uf.flight` is the root's; `/guide/__uf.flight` is `/guide`'s. Anything
|
|
182
|
+
* else is `null`, and that includes a pathname that merely contains the segment
|
|
183
|
+
* somewhere in the middle — `/__uf.flight/x` is a URL some route may own.
|
|
184
|
+
*/
|
|
185
|
+
export function documentPathOf(pathname: string): string | null {
|
|
186
|
+
const suffix = `/${FLIGHT_SEGMENT}`;
|
|
187
|
+
if (!pathname.endsWith(suffix)) {
|
|
188
|
+
return null;
|
|
189
|
+
}
|
|
190
|
+
const document = pathname.slice(0, pathname.length - suffix.length);
|
|
191
|
+
return document === "" ? "/" : document;
|
|
192
|
+
}
|
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: a server action as a form the browser can
|
|
4
|
+
// submit before any JavaScript has run.
|
|
5
|
+
//
|
|
6
|
+
// React 19's contract for `<form action={serverFunction}>` is progressive
|
|
7
|
+
// enhancement. While rendering to HTML, React asks the function for
|
|
8
|
+
// `$$FORM_ACTION(prefix)` and writes what it answers into the markup — a
|
|
9
|
+
// `method`, an `encType`, a hidden field named `name`, and one hidden field per
|
|
10
|
+
// entry of `data` — so the form is a real form that posts to the page it is on.
|
|
11
|
+
// A function without the property gets `action="javascript:throw …"` instead,
|
|
12
|
+
// which is what every uf form got before ubugeeei-prod/uf#1358.
|
|
13
|
+
//
|
|
14
|
+
// Two functions carry the property: the reference the browser holds
|
|
15
|
+
// (`createServerReference` in `../action.js`) and the real function the server
|
|
16
|
+
// renders with (`registerServerAction`, which `@uniflowed/vite` calls on every
|
|
17
|
+
// callable export of a `"use server"` module in the server graph). Both answer
|
|
18
|
+
// with the same fields, from the code below, so the markup the server writes and
|
|
19
|
+
// what a hydrated page would have written agree.
|
|
20
|
+
//
|
|
21
|
+
// # The fields
|
|
22
|
+
//
|
|
23
|
+
// React hands `$$FORM_ACTION` a prefix unique to the form in its render, and
|
|
24
|
+
// the prefix is in every field name so that two forms — or a `<form>` and a
|
|
25
|
+
// `<button formAction>` inside it — never read each other's fields:
|
|
26
|
+
//
|
|
27
|
+
// | Field | Value |
|
|
28
|
+
// | --- | --- |
|
|
29
|
+
// | `$uf_ref_<prefix>` | empty. The field that says *which* action this submit is for; on a `<button formAction>` it is the button's own name, so only the button that was pressed sends it |
|
|
30
|
+
// | `$uf_id_<prefix>` | the action's id, the same 64 hexadecimal characters the JSON call carries in `uf-action` |
|
|
31
|
+
// | `$uf_bound_<prefix>` | the bound arguments, as the JSON envelope `encodeActionArguments` writes, when there are any |
|
|
32
|
+
//
|
|
33
|
+
// `useActionState` adds React's own `$ACTION_KEY`, which names the hook that
|
|
34
|
+
// submitted, so the answer can put the action's result back into the same hook.
|
|
35
|
+
//
|
|
36
|
+
// Every field is a string and every bound argument goes through the same
|
|
37
|
+
// grammar as a JSON call: nothing new is allowed to cross by arriving as a form.
|
|
38
|
+
// `./action-endpoint.js` is the other half, and its header says what a native
|
|
39
|
+
// post is refused for.
|
|
40
|
+
|
|
41
|
+
import { encodeActionArguments } from "./action-wire.js";
|
|
42
|
+
|
|
43
|
+
/** The name of the field that says which action a submit is for. */
|
|
44
|
+
export const FORM_REF_FIELD = "$uf_ref_";
|
|
45
|
+
/** The action id, beside it. */
|
|
46
|
+
export const FORM_ID_FIELD = "$uf_id_";
|
|
47
|
+
/** The bound arguments, beside it. */
|
|
48
|
+
export const FORM_BOUND_FIELD = "$uf_bound_";
|
|
49
|
+
/** React's own: which `useActionState` submitted. */
|
|
50
|
+
export const FORM_STATE_KEY_FIELD = "$ACTION_KEY";
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The only content type a native action post may have.
|
|
54
|
+
*
|
|
55
|
+
* `application/x-www-form-urlencoded` rather than `multipart/form-data`,
|
|
56
|
+
* because a server action refuses a file anyway and multipart parsing is a
|
|
57
|
+
* surface uf does not open for one (`docs/security.md`). `$$FORM_ACTION` says
|
|
58
|
+
* it explicitly, so React writes it on the form and on a submit button.
|
|
59
|
+
*/
|
|
60
|
+
export const FORM_ACTION_CONTENT_TYPE = "application/x-www-form-urlencoded";
|
|
61
|
+
|
|
62
|
+
/** What `$$FORM_ACTION` answers, in the shape React reads. */
|
|
63
|
+
export type FormActionFields = {|
|
|
64
|
+
readonly name: string,
|
|
65
|
+
readonly method: "POST",
|
|
66
|
+
readonly encType: string,
|
|
67
|
+
readonly data: FormData,
|
|
68
|
+
|};
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* What a `useActionState` postback leaves for the page it renders.
|
|
72
|
+
*
|
|
73
|
+
* React's `ReactFormState`: the action's result, the hook's key, the action's
|
|
74
|
+
* id, and how many bound arguments it had *besides* the state
|
|
75
|
+
* `useActionState` bound itself.
|
|
76
|
+
*/
|
|
77
|
+
export type FormState = [mixed, string, string, number];
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Make `fn` a server action a form can post to without JavaScript.
|
|
81
|
+
*
|
|
82
|
+
* Defines three non-enumerable properties on `fn` and returns it:
|
|
83
|
+
*
|
|
84
|
+
* - `$$FORM_ACTION(prefix)`, the fields above;
|
|
85
|
+
* - `$$IS_SIGNATURE_EQUAL(id, bound)`, which React asks while rendering a
|
|
86
|
+
* postback's answer to decide whether a `useActionState` is the one that
|
|
87
|
+
* submitted — the same action, bound the same number of times;
|
|
88
|
+
* - `bind`, which keeps both of those on the function it returns, with the
|
|
89
|
+
* bound arguments recorded so they travel in the form.
|
|
90
|
+
*
|
|
91
|
+
* `call` is what the bound function runs: `fn` itself on the server, the
|
|
92
|
+
* network call in the browser.
|
|
93
|
+
*/
|
|
94
|
+
export function withFormAction<T extends (...args: $ReadOnlyArray<empty>) => mixed>(
|
|
95
|
+
fn: T,
|
|
96
|
+
id: string,
|
|
97
|
+
bound: $ReadOnlyArray<mixed>,
|
|
98
|
+
): T {
|
|
99
|
+
const define = (name: string, value: mixed) => {
|
|
100
|
+
Object.defineProperty(fn, name, { value, configurable: true, writable: true });
|
|
101
|
+
};
|
|
102
|
+
define("$$FORM_ACTION", (prefix: string): FormActionFields => formFields(id, bound, prefix));
|
|
103
|
+
define(
|
|
104
|
+
"$$IS_SIGNATURE_EQUAL",
|
|
105
|
+
(referenceId: string, boundCount: number): boolean =>
|
|
106
|
+
referenceId === id && boundCount === bound.length,
|
|
107
|
+
);
|
|
108
|
+
// `Function.prototype.bind` itself, reached through a cast: Flow types
|
|
109
|
+
// `bind` per call site and has no type for the method taken off the
|
|
110
|
+
// prototype and applied to an arbitrary function.
|
|
111
|
+
const nativeBind: $FlowFixMe = Function.prototype.bind;
|
|
112
|
+
define("bind", function bindAction(thisArg: mixed, ...more: Array<mixed>) {
|
|
113
|
+
const next: T = nativeBind.call(fn, thisArg, ...more);
|
|
114
|
+
return withFormAction(next, id, [...bound, ...more]);
|
|
115
|
+
});
|
|
116
|
+
return fn;
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* The fields a form bound to this action is written with.
|
|
121
|
+
*
|
|
122
|
+
* Throws `ActionValueError` when a bound argument cannot cross, which React
|
|
123
|
+
* catches and reports as "Failed to serialize an action for progressive
|
|
124
|
+
* enhancement" before writing the form it would have written without this —
|
|
125
|
+
* so an unencodable state costs the pre-hydration submit and nothing else.
|
|
126
|
+
*/
|
|
127
|
+
export function formFields(
|
|
128
|
+
id: string,
|
|
129
|
+
bound: $ReadOnlyArray<mixed>,
|
|
130
|
+
prefix: string,
|
|
131
|
+
): FormActionFields {
|
|
132
|
+
const data = new FormData();
|
|
133
|
+
data.append(`${FORM_ID_FIELD}${prefix}`, id);
|
|
134
|
+
if (bound.length > 0) {
|
|
135
|
+
data.append(`${FORM_BOUND_FIELD}${prefix}`, encodeActionArguments(bound));
|
|
136
|
+
}
|
|
137
|
+
return {
|
|
138
|
+
name: `${FORM_REF_FIELD}${prefix}`,
|
|
139
|
+
method: "POST",
|
|
140
|
+
encType: FORM_ACTION_CONTENT_TYPE,
|
|
141
|
+
data,
|
|
142
|
+
};
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** What a native post names, read off its fields. */
|
|
146
|
+
export type FormPost = {|
|
|
147
|
+
/** The action id, as sent; checked by the caller. */
|
|
148
|
+
readonly id: string | null,
|
|
149
|
+
/** The bound-argument envelope, as sent, or `null` when there is none. */
|
|
150
|
+
readonly bound: string | null,
|
|
151
|
+
/** `$ACTION_KEY`, or `null` when no `useActionState` submitted. */
|
|
152
|
+
readonly stateKey: string | null,
|
|
153
|
+
/** Every other field, in order: what the action is handed as its form. */
|
|
154
|
+
readonly entries: Array<[string, string]>,
|
|
155
|
+
|};
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Read a native post's fields, or `null` when it names no action at all.
|
|
159
|
+
*
|
|
160
|
+
* `null` is the caller's signal to decline, exactly as a JSON request with no
|
|
161
|
+
* `uf-action` header is: an ordinary form posted to a route handler must reach
|
|
162
|
+
* that handler. The last `$uf_ref_` field wins, because the browser writes a
|
|
163
|
+
* pressed button's name after the fields of the form around it, and a button's
|
|
164
|
+
* `formAction` is meant to override the form's `action`.
|
|
165
|
+
*
|
|
166
|
+
* Every field whose name uf or React owns is kept out of `entries`, including
|
|
167
|
+
* another prefix's, so the action receives the form the person filled in and
|
|
168
|
+
* nothing that says how it was wired.
|
|
169
|
+
*/
|
|
170
|
+
export function readFormPost(fields: URLSearchParams): FormPost | null {
|
|
171
|
+
let prefix: string | null = null;
|
|
172
|
+
for (const [name] of fields) {
|
|
173
|
+
if (name.startsWith(FORM_REF_FIELD)) {
|
|
174
|
+
prefix = name.slice(FORM_REF_FIELD.length);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
if (prefix == null) {
|
|
178
|
+
return null;
|
|
179
|
+
}
|
|
180
|
+
const entries: Array<[string, string]> = [];
|
|
181
|
+
for (const [name, value] of fields) {
|
|
182
|
+
if (!isWiring(name)) {
|
|
183
|
+
entries.push([name, value]);
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
return {
|
|
187
|
+
id: fields.get(`${FORM_ID_FIELD}${prefix}`),
|
|
188
|
+
bound: fields.get(`${FORM_BOUND_FIELD}${prefix}`),
|
|
189
|
+
stateKey: fields.get(FORM_STATE_KEY_FIELD),
|
|
190
|
+
entries,
|
|
191
|
+
};
|
|
192
|
+
}
|
|
193
|
+
|
|
194
|
+
/** Whether a field is uf's or React's rather than the form's own. */
|
|
195
|
+
function isWiring(name: string): boolean {
|
|
196
|
+
return name.startsWith("$uf_") || name.startsWith("$ACTION_");
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** The element a postback's form state is written into the document as. */
|
|
200
|
+
export const FORM_STATE_ELEMENT_ID = "uf:form-state";
|
|
201
|
+
|
|
202
|
+
/**
|
|
203
|
+
* The `<script>` that carries a postback's form state to `hydrateRoot`.
|
|
204
|
+
*
|
|
205
|
+
* `application/json`, so it is data and never executed — no nonce, and nothing
|
|
206
|
+
* a Content Security Policy has to admit. The result was already held to the
|
|
207
|
+
* wire grammar before it got here, so it is plain JSON; `<` is escaped so
|
|
208
|
+
* that no string in it can close the element.
|
|
209
|
+
*/
|
|
210
|
+
export function formStateScript(state: FormState): string {
|
|
211
|
+
const json = JSON.stringify(state).replace(/</g, "\\u003c");
|
|
212
|
+
return `<script type="application/json" id="${FORM_STATE_ELEMENT_ID}">${json}</script>`;
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* The form state a document carries, or `undefined`.
|
|
217
|
+
*
|
|
218
|
+
* Read by both hydrating entries before `hydrateRoot`. Anything malformed is
|
|
219
|
+
* `undefined` rather than an exception: the page still hydrates, and the
|
|
220
|
+
* `useActionState` that submitted starts from its initial state, which is what
|
|
221
|
+
* it would have done with JavaScript from the start.
|
|
222
|
+
*/
|
|
223
|
+
export function readFormState(document: Document): FormState | void {
|
|
224
|
+
const element = document.getElementById(FORM_STATE_ELEMENT_ID);
|
|
225
|
+
if (element == null) {
|
|
226
|
+
return undefined;
|
|
227
|
+
}
|
|
228
|
+
try {
|
|
229
|
+
const parsed: mixed = JSON.parse(element.textContent);
|
|
230
|
+
if (
|
|
231
|
+
Array.isArray(parsed) &&
|
|
232
|
+
parsed.length === 4 &&
|
|
233
|
+
typeof parsed[1] === "string" &&
|
|
234
|
+
typeof parsed[2] === "string" &&
|
|
235
|
+
typeof parsed[3] === "number"
|
|
236
|
+
) {
|
|
237
|
+
return [parsed[0], parsed[1], parsed[2], parsed[3]];
|
|
238
|
+
}
|
|
239
|
+
} catch {
|
|
240
|
+
// Fall through: see above.
|
|
241
|
+
}
|
|
242
|
+
return undefined;
|
|
243
|
+
}
|
package/internal/head.js
ADDED
|
@@ -0,0 +1,219 @@
|
|
|
1
|
+
// @flow
|
|
2
|
+
//
|
|
3
|
+
// Internal to `@uniflowed/router`: a route's metadata, as the elements React
|
|
4
|
+
// hoists into `<head>`.
|
|
5
|
+
//
|
|
6
|
+
// Markup and nothing else — no hooks, no context — so the same component renders
|
|
7
|
+
// a route's head in the tree a server composes for React Server Components and
|
|
8
|
+
// in the tree the browser composes for a single-page application. `useSeo` in
|
|
9
|
+
// `./runtime.js` is the component-level way in, and it renders this too.
|
|
10
|
+
|
|
11
|
+
import * as React from "react";
|
|
12
|
+
|
|
13
|
+
import type { JsonLd, Metadata, Robots } from "./resolve.js";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* One URL from a route's metadata, made absolute if it can be.
|
|
17
|
+
*
|
|
18
|
+
* Open Graph, Twitter and `rel="canonical"` all want an absolute URL, and a
|
|
19
|
+
* route module cannot know the host it is served from — so `metadataBase` is
|
|
20
|
+
* how a site says it once, and this is where it is applied.
|
|
21
|
+
*
|
|
22
|
+
* Three things it deliberately does not do. It does not resolve against the
|
|
23
|
+
* *page's* URL: `Head` renders inside the route and does not know it, and a
|
|
24
|
+
* `metadataBase` is a site-wide fact rather than a per-page one. It does not
|
|
25
|
+
* invent a base: with none declared the value is emitted exactly as written,
|
|
26
|
+
* which is what every page that predates this field already gets. And it does
|
|
27
|
+
* not throw — a `metadataBase` that is not a URL is a mistake in one field,
|
|
28
|
+
* and turning it into a blank page would be a worse answer than an unresolved
|
|
29
|
+
* `og:image`.
|
|
30
|
+
*/
|
|
31
|
+
function absoluteUrl(value: string, base: void | string): string {
|
|
32
|
+
if (base == null) return value;
|
|
33
|
+
try {
|
|
34
|
+
return new URL(value, base).href;
|
|
35
|
+
} catch {
|
|
36
|
+
return value;
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* The `robots` directives, as one `content` string, or `null` for none.
|
|
42
|
+
*
|
|
43
|
+
* `null` rather than an empty string, so a page that declared nothing gets no
|
|
44
|
+
* tag at all: "index, follow" is what a document with no `robots` meta already
|
|
45
|
+
* means, and writing it out tells a crawler what it had already assumed.
|
|
46
|
+
*
|
|
47
|
+
* Each declared field contributes its directive and no field implies another.
|
|
48
|
+
* `index: true` therefore emits `index` rather than nothing — the value is
|
|
49
|
+
* there to overrule a section that said otherwise, and a directive that
|
|
50
|
+
* disappeared because it agreed with the default would be a page saying
|
|
51
|
+
* something and no evidence of it in the markup.
|
|
52
|
+
*/
|
|
53
|
+
function robotsContent(robots: void | Robots): ?string {
|
|
54
|
+
if (robots == null) {
|
|
55
|
+
return null;
|
|
56
|
+
}
|
|
57
|
+
const directives: Array<string> = [];
|
|
58
|
+
if (robots.index != null) {
|
|
59
|
+
directives.push(robots.index ? "index" : "noindex");
|
|
60
|
+
}
|
|
61
|
+
if (robots.follow != null) {
|
|
62
|
+
directives.push(robots.follow ? "follow" : "nofollow");
|
|
63
|
+
}
|
|
64
|
+
if (robots.maxSnippet != null) {
|
|
65
|
+
directives.push(`max-snippet:${robots.maxSnippet}`);
|
|
66
|
+
}
|
|
67
|
+
if (robots.maxImagePreview != null) {
|
|
68
|
+
directives.push(`max-image-preview:${robots.maxImagePreview}`);
|
|
69
|
+
}
|
|
70
|
+
return directives.length === 0 ? null : directives.join(", ");
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* One JSON-LD object as the text of a `<script>`.
|
|
75
|
+
*
|
|
76
|
+
* `<` is escaped so a string inside the data holding `</script>` cannot end
|
|
77
|
+
* the element early — the same escape `server.js` applies to the embedded
|
|
78
|
+
* loader data, and for the same reason: the text is the application's and the
|
|
79
|
+
* element it lands in is terminated by a character sequence rather than by a
|
|
80
|
+
* length. `dataScript` also escapes U+2028 and U+2029; those are about a
|
|
81
|
+
* string being parsed as JavaScript source, and this one never is.
|
|
82
|
+
*/
|
|
83
|
+
function jsonLdText(entry: JsonLd): string {
|
|
84
|
+
return JSON.stringify(entry).replace(/</g, "\\u003c");
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* One JSON-LD object, as the element that carries it.
|
|
89
|
+
*
|
|
90
|
+
* A function rather than an element written inline, because the suppression
|
|
91
|
+
* needs a line of its own; `docs/app/$layout.js` has the same shape for the
|
|
92
|
+
* same reason. `security/no-dangerously-set-inner-html` is about markup that
|
|
93
|
+
* came from somewhere and has to be sanitized before a browser parses it as
|
|
94
|
+
* HTML, and its escape hatch is a `@uniflowed/markdown` sanitizer — the right
|
|
95
|
+
* answer for markup and no answer at all for JSON. This string is
|
|
96
|
+
* `JSON.stringify`'s output with `<` escaped, so nothing in it can close the
|
|
97
|
+
* element, and it is never parsed as HTML. There is also no other spelling:
|
|
98
|
+
* React escapes a text child, so `{"@type":"Article"}` would reach the page as
|
|
99
|
+
* `"@type"`, which is not JSON-LD any more.
|
|
100
|
+
*/
|
|
101
|
+
function jsonLdScript(entry: JsonLd): React.Node {
|
|
102
|
+
const text = jsonLdText(entry);
|
|
103
|
+
const html = { __html: text };
|
|
104
|
+
// uf-lint-disable-next-line security/no-dangerously-set-inner-html
|
|
105
|
+
return <script key={text} type="application/ld+json" dangerouslySetInnerHTML={html} />;
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
export component Head(metadata: Metadata) {
|
|
109
|
+
const { title, description, metadataBase, canonical, robots } = metadata;
|
|
110
|
+
const { alternates, pagination, jsonLd, openGraph, twitter } = metadata;
|
|
111
|
+
const href = canonical != null ? absoluteUrl(canonical, metadataBase) : null;
|
|
112
|
+
const crawler = robotsContent(robots);
|
|
113
|
+
// Read out of `alternates` once rather than through it at every use: the map
|
|
114
|
+
// is read inside a callback, and a refinement of `alternates.languages` does
|
|
115
|
+
// not survive being carried into one.
|
|
116
|
+
const languages = alternates?.languages;
|
|
117
|
+
// A page that said what it is called has said what its card is called. Every
|
|
118
|
+
// site that had to write both wrote the same string twice, and the second
|
|
119
|
+
// one is the one that goes stale — the docs site shipped thirty pages whose
|
|
120
|
+
// share cards carried an image and no title at all.
|
|
121
|
+
//
|
|
122
|
+
// `??`, not `||`: an empty string is a decision, and a page that deliberately
|
|
123
|
+
// has no card title should get none rather than the document's.
|
|
124
|
+
const cardTitle = openGraph?.title ?? title;
|
|
125
|
+
const cardDescription = openGraph?.description ?? description;
|
|
126
|
+
// `og:type` is one of the four properties Open Graph requires. A default is
|
|
127
|
+
// the difference between a document with a card and a document without one,
|
|
128
|
+
// and `website` is right for everything that is not an article or a video.
|
|
129
|
+
const cardType = openGraph?.type ?? "website";
|
|
130
|
+
// Only when the card was asked for. A page with no `twitter.card` gets no
|
|
131
|
+
// Twitter tags at all, which is what a site that never wanted one meant.
|
|
132
|
+
const twitterTitle = twitter != null ? (twitter.title ?? cardTitle) : null;
|
|
133
|
+
const twitterDescription = twitter != null ? (twitter.description ?? cardDescription) : null;
|
|
134
|
+
const twitterImageAlt = twitter != null ? (twitter.imageAlt ?? openGraph?.imageAlt) : null;
|
|
135
|
+
return (
|
|
136
|
+
<>
|
|
137
|
+
{title != null ? <title>{title}</title> : null}
|
|
138
|
+
{description != null ? <meta name="description" content={description} /> : null}
|
|
139
|
+
{crawler != null ? <meta name="robots" content={crawler} /> : null}
|
|
140
|
+
{href != null ? <link rel="canonical" href={href} /> : null}
|
|
141
|
+
{/* The set is reciprocal and includes this page, so a `hreflang` list is
|
|
142
|
+
usually the same list on every page of it — which is why it belongs
|
|
143
|
+
on the layout they share rather than on each of them.
|
|
144
|
+
|
|
145
|
+
`hrefLang` is React's spelling and it reaches the markup unchanged,
|
|
146
|
+
which is worth knowing before grepping a document for `hreflang` and
|
|
147
|
+
concluding it is missing. HTML attribute names are case-insensitive,
|
|
148
|
+
so the parser every crawler runs reads it as the same attribute; the
|
|
149
|
+
lowercase spelling is the one React warns about. */}
|
|
150
|
+
{languages != null
|
|
151
|
+
? Object.keys(languages).map((language) => (
|
|
152
|
+
<link
|
|
153
|
+
key={language}
|
|
154
|
+
rel="alternate"
|
|
155
|
+
hrefLang={language}
|
|
156
|
+
href={absoluteUrl(languages[language], metadataBase)}
|
|
157
|
+
/>
|
|
158
|
+
))
|
|
159
|
+
: null}
|
|
160
|
+
{pagination?.prev != null ? (
|
|
161
|
+
<link rel="prev" href={absoluteUrl(pagination.prev, metadataBase)} />
|
|
162
|
+
) : null}
|
|
163
|
+
{pagination?.next != null ? (
|
|
164
|
+
<link rel="next" href={absoluteUrl(pagination.next, metadataBase)} />
|
|
165
|
+
) : null}
|
|
166
|
+
{/* `og:url` *is* the canonical URL of the page, in Open Graph's own
|
|
167
|
+
words, so one declaration answers both rather than asking a project
|
|
168
|
+
to write the same URL twice and keep them in step. */}
|
|
169
|
+
{href != null ? <meta property="og:url" content={href} /> : null}
|
|
170
|
+
{cardTitle != null ? <meta property="og:title" content={cardTitle} /> : null}
|
|
171
|
+
{cardDescription != null ? (
|
|
172
|
+
<meta property="og:description" content={cardDescription} />
|
|
173
|
+
) : null}
|
|
174
|
+
{/* Only alongside something else. A document with `og:type` and nothing
|
|
175
|
+
more is not a card; it is one meta tag saying the page is a page. */}
|
|
176
|
+
{cardTitle != null || cardDescription != null || openGraph?.images != null ? (
|
|
177
|
+
<meta property="og:type" content={cardType} />
|
|
178
|
+
) : null}
|
|
179
|
+
{openGraph?.siteName != null ? (
|
|
180
|
+
<meta property="og:site_name" content={openGraph.siteName} />
|
|
181
|
+
) : null}
|
|
182
|
+
{openGraph?.images != null
|
|
183
|
+
? openGraph.images.map((image) => (
|
|
184
|
+
<meta key={image} property="og:image" content={absoluteUrl(image, metadataBase)} />
|
|
185
|
+
))
|
|
186
|
+
: null}
|
|
187
|
+
{openGraph?.imageAlt != null && openGraph?.images != null ? (
|
|
188
|
+
<meta property="og:image:alt" content={openGraph.imageAlt} />
|
|
189
|
+
) : null}
|
|
190
|
+
{/* `name`, not `property`: Open Graph is RDFa and Twitter's cards are
|
|
191
|
+
not, and a `property="twitter:card"` is ignored by the crawler that
|
|
192
|
+
reads it. */}
|
|
193
|
+
{twitter?.card != null ? <meta name="twitter:card" content={twitter.card} /> : null}
|
|
194
|
+
{twitter?.site != null ? <meta name="twitter:site" content={twitter.site} /> : null}
|
|
195
|
+
{twitter?.creator != null ? <meta name="twitter:creator" content={twitter.creator} /> : null}
|
|
196
|
+
{/* X reads the `og:` tags when these are absent, so these are not
|
|
197
|
+
required — and every validator asks for them anyway, which is a good
|
|
198
|
+
enough reason when the value is one the page has already given. They
|
|
199
|
+
fall back through the card's title to the document's. */}
|
|
200
|
+
{twitterTitle != null ? <meta name="twitter:title" content={twitterTitle} /> : null}
|
|
201
|
+
{twitterDescription != null ? (
|
|
202
|
+
<meta name="twitter:description" content={twitterDescription} />
|
|
203
|
+
) : null}
|
|
204
|
+
{twitter?.images != null
|
|
205
|
+
? twitter.images.map((image) => (
|
|
206
|
+
<meta key={image} name="twitter:image" content={absoluteUrl(image, metadataBase)} />
|
|
207
|
+
))
|
|
208
|
+
: null}
|
|
209
|
+
{twitterImageAlt != null && twitter?.images != null ? (
|
|
210
|
+
<meta name="twitter:image:alt" content={twitterImageAlt} />
|
|
211
|
+
) : null}
|
|
212
|
+
{/* Last, and not hoisted into `<head>` with the rest: React hoists a
|
|
213
|
+
`<title>`, a `<meta>` and a `<link>`, and not a script whose body it
|
|
214
|
+
would have to carry. JSON-LD is read from anywhere in the document,
|
|
215
|
+
so these render where the route does. */}
|
|
216
|
+
{jsonLd != null ? jsonLd.map(jsonLdScript) : null}
|
|
217
|
+
</>
|
|
218
|
+
);
|
|
219
|
+
}
|