@warlock.js/web 5.5.0 → 5.6.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/CHANGELOG.md +27 -5
- package/esm/build/contribution.d.mts +2 -2
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +127 -205
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs +5 -3
- package/esm/build/generate-pages-barrel.mjs.map +1 -1
- package/esm/build/public-files.mjs +29 -1
- package/esm/build/public-files.mjs.map +1 -1
- package/esm/build/read-metadata-keys.mjs +225 -0
- package/esm/build/read-metadata-keys.mjs.map +1 -0
- package/esm/build/read-route-exports.mjs.map +1 -1
- package/esm/client/build-hydrated-tree.mjs +1 -1
- package/esm/client/build-hydrated-tree.mjs.map +1 -1
- package/esm/client/navigation/document-metadata.mjs +108 -0
- package/esm/client/navigation/document-metadata.mjs.map +1 -0
- package/esm/client/navigation/navigation-root.mjs +23 -103
- package/esm/client/navigation/navigation-root.mjs.map +1 -1
- package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -1
- package/esm/client/navigation/use-query-string.d.mts +35 -0
- package/esm/client/navigation/use-query-string.mjs +68 -0
- package/esm/client/navigation/use-query-string.mjs.map +1 -0
- package/esm/client/runtime/index.d.mts +1 -1
- package/esm/client/runtime/index.mjs +1 -1
- package/esm/client/runtime/manifest.mjs.map +1 -1
- package/esm/client/runtime/matcher.mjs.map +1 -1
- package/esm/client/runtime/types.d.mts +1 -1
- package/esm/components/link.mjs.map +1 -1
- package/esm/core/src/router/normalize-route-path.mjs +33 -0
- package/esm/core/src/router/normalize-route-path.mjs.map +1 -0
- package/esm/{hydration → entry}/index.mjs +1 -1
- package/esm/entry/index.mjs.map +1 -0
- package/esm/hydration-payload.mjs.map +1 -1
- package/esm/index.d.mts +3 -2
- package/esm/index.mjs +3 -2
- package/esm/loaders.d.mts +10 -3
- package/esm/localization.mjs.map +1 -1
- package/esm/metadata.d.mts +4 -4
- package/esm/metadata.mjs.map +1 -1
- package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs +33 -0
- package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs.map +1 -0
- package/esm/{runtime/register-modules.d.mts → register-modules.d.mts} +1 -1
- package/esm/{runtime/register-modules.mjs → register-modules.mjs} +1 -1
- package/esm/register-modules.mjs.map +1 -0
- package/esm/route.d.mts +35 -2
- package/esm/routing/duplicate-route-path.mjs +22 -0
- package/esm/routing/duplicate-route-path.mjs.map +1 -0
- package/esm/routing/layout-level.mjs +50 -0
- package/esm/routing/layout-level.mjs.map +1 -0
- package/esm/routing/layout-policy.mjs.map +1 -1
- package/esm/routing/query-string.d.mts +23 -1
- package/esm/routing/query-string.mjs +23 -1
- package/esm/routing/query-string.mjs.map +1 -1
- package/esm/routing/route-identity.mjs +27 -2
- package/esm/routing/route-identity.mjs.map +1 -1
- package/esm/routing/route-table.mjs.map +1 -1
- package/esm/routing/router-events.d.mts +5 -4
- package/esm/routing/router-events.mjs.map +1 -1
- package/esm/server/create-page-route-handler.d.mts +2 -2
- package/esm/server/create-page-route-handler.mjs +6 -6
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/{dev-server.mjs → dev-error-transport.mjs} +2 -2
- package/esm/server/dev-error-transport.mjs.map +1 -0
- package/esm/server/execute-page-request.mjs +87 -23
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/execute-page-request.types.d.mts +10 -1
- package/esm/server/fold-layout-loaders.mjs +36 -0
- package/esm/server/fold-layout-loaders.mjs.map +1 -0
- package/esm/server/hydration-client-url.mjs +2 -2
- package/esm/server/hydration-client-url.mjs.map +1 -1
- package/esm/server/index.d.mts +2 -2
- package/esm/server/index.mjs +2 -2
- package/esm/server/install-page-routes-from-manifest.d.mts +1 -1
- package/esm/server/install-page-routes-from-manifest.mjs +43 -40
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +22 -2
- package/esm/server/install-page-routes.mjs +108 -42
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/install-production-page-routes.mjs.map +1 -1
- package/esm/server/match-page-route.mjs.map +1 -1
- package/esm/server/page-file-change.mjs.map +1 -1
- package/esm/server/page-route-reload.mjs +4 -3
- package/esm/server/page-route-reload.mjs.map +1 -1
- package/esm/server/register-production-public-files.mjs +25 -1
- package/esm/server/register-production-public-files.mjs.map +1 -1
- package/esm/server/render-page.d.mts +11 -48
- package/esm/server/render-page.mjs +13 -55
- package/esm/server/render-page.mjs.map +1 -1
- package/esm/server/resolve-page-metadata.mjs.map +1 -1
- package/esm/server/resolve-route-validation-input.mjs +11 -0
- package/esm/server/resolve-route-validation-input.mjs.map +1 -0
- package/esm/server/route-validation-error.mjs +32 -0
- package/esm/server/route-validation-error.mjs.map +1 -0
- package/esm/server/settle-page-response.mjs +5 -3
- package/esm/server/settle-page-response.mjs.map +1 -1
- package/esm/server/unregistered-pages.mjs +2 -1
- package/esm/server/unregistered-pages.mjs.map +1 -1
- package/esm/server/web-connector-factory.mjs +1 -1
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.mjs +24 -321
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/shared/module-key.mjs +15 -0
- package/esm/shared/module-key.mjs.map +1 -0
- package/esm/shared/to-posix.mjs +13 -0
- package/esm/shared/to-posix.mjs.map +1 -0
- package/esm/shared.mjs +24 -0
- package/esm/shared.mjs.map +1 -1
- package/esm/vite/build-client.mjs.map +1 -1
- package/esm/vite/dev-server-config.mjs +240 -0
- package/esm/vite/dev-server-config.mjs.map +1 -0
- package/esm/vite/gate-a-resolve.d.mts +5 -4
- package/esm/vite/gate-a-resolve.mjs +31 -70
- package/esm/vite/gate-a-resolve.mjs.map +1 -1
- package/esm/vite/gate-b-secrets.mjs.map +1 -1
- package/esm/vite/gate-c-verify.d.mts +1 -1
- package/esm/vite/gate-c-verify.mjs +1 -1
- package/esm/vite/gate-c-verify.mjs.map +1 -1
- package/esm/vite/hydration-entries.mjs +5 -7
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/index.d.mts +1 -1
- package/esm/vite/index.mjs +4 -110
- package/esm/vite/index.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.mjs +9 -26
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/esm/vite/projection.mjs.map +1 -1
- package/esm/vite/react-refresh-preamble.mjs +90 -0
- package/esm/vite/react-refresh-preamble.mjs.map +1 -0
- package/esm/vite/ssr-client-view.mjs +135 -0
- package/esm/vite/ssr-client-view.mjs.map +1 -0
- package/llms-full.txt +558 -482
- package/llms.txt +2 -2
- package/package.json +6 -6
- package/skills/create-a-page/SKILL.md +348 -295
- package/skills/navigate-on-the-client/SKILL.md +212 -189
- package/esm/hydration/index.mjs.map +0 -1
- package/esm/runtime/register-modules.mjs.map +0 -1
- package/esm/server/dev-server.mjs.map +0 -1
- /package/esm/{hydration → entry}/index.d.mts +0 -0
|
@@ -0,0 +1,225 @@
|
|
|
1
|
+
import { METADATA_KEYS, OPEN_GRAPH_KEYS, TWITTER_KEYS } from "../metadata.mjs";
|
|
2
|
+
import { parse } from "@babel/parser";
|
|
3
|
+
|
|
4
|
+
//#region ../web/src/build/read-metadata-keys.ts
|
|
5
|
+
/**
|
|
6
|
+
* Checks a page's `metadata` export for a key nothing reads — by parsing the
|
|
7
|
+
* source, never by loading the module.
|
|
8
|
+
*
|
|
9
|
+
* THE POINT OF THIS ERROR IS THE UNANNOTATED CASE. A page that writes
|
|
10
|
+
* `export const metadata: PageMetadata = { tittle: "x" }` is already refused by
|
|
11
|
+
* TypeScript, and if that were the whole story this module would not need to
|
|
12
|
+
* exist. But the annotation is optional, nobody writes it, and
|
|
13
|
+
* `export const metadata = { tittle: "x" }` is a perfectly well-typed program:
|
|
14
|
+
* the compiler infers `{ tittle: string }`, has nothing to check it against,
|
|
15
|
+
* and says nothing. The page is then served with no `<title>` — not a wrong
|
|
16
|
+
* title, a missing one — and no error is raised anywhere, at build or at
|
|
17
|
+
* runtime, ever.
|
|
18
|
+
*
|
|
19
|
+
* So the check lives here instead, at the gate every page already passes
|
|
20
|
+
* through: a page that silently omits requested metadata is worse than a
|
|
21
|
+
* build that stops and says which line to fix.
|
|
22
|
+
*
|
|
23
|
+
* Single responsibility, deliberately: this returns the unknown keys it finds
|
|
24
|
+
* and decides nothing. What that costs the build belongs to the caller.
|
|
25
|
+
*/
|
|
26
|
+
/**
|
|
27
|
+
* Raised when a page's `metadata` export declares a key nothing reads.
|
|
28
|
+
*
|
|
29
|
+
* THE POINT OF THIS ERROR IS THE UNANNOTATED CASE. A page that writes
|
|
30
|
+
* `export const metadata: PageMetadata = { tittle: "x" }` is already refused by
|
|
31
|
+
* TypeScript, and if that were the whole story this class would not need to
|
|
32
|
+
* exist. But the annotation is optional, nobody writes it, and
|
|
33
|
+
* `export const metadata = { tittle: "x" }` is a perfectly well-typed program:
|
|
34
|
+
* the compiler infers `{ tittle: string }`, has nothing to check it against, and
|
|
35
|
+
* says nothing. The page is then served with no `<title>` — not a wrong title, a
|
|
36
|
+
* missing one — and no error is raised anywhere, at build or at runtime, ever.
|
|
37
|
+
*
|
|
38
|
+
* So the check lives HERE instead, at the gate every page already passes
|
|
39
|
+
* through: a page that silently omits requested metadata is worse than a build
|
|
40
|
+
* that stops and says which line to fix.
|
|
41
|
+
*
|
|
42
|
+
* The alternative considered and rejected was a `defineMetadata({...})` wrapper,
|
|
43
|
+
* which would infer the type for free. It also puts framework ceremony in every
|
|
44
|
+
* page, and a page is meant to be two lines of framework surface (canon
|
|
45
|
+
* `6ea0662f`). The gate gets the same safety without spending that.
|
|
46
|
+
*/
|
|
47
|
+
var UnknownMetadataKeyError = class extends Error {
|
|
48
|
+
pageFile;
|
|
49
|
+
unknownKeys;
|
|
50
|
+
constructor(pageFile, unknownKeys) {
|
|
51
|
+
const findings = unknownKeys.map(({ container, key, line, suggestion }) => {
|
|
52
|
+
const where = `line ${line}: \`${container}.${key}\` — no such key.`;
|
|
53
|
+
return suggestion === void 0 ? where : `${where} Did you mean \`${suggestion}\`?`;
|
|
54
|
+
}).join("\n ");
|
|
55
|
+
super(`The \`metadata\` export of "${pageFile}" declares a key nothing reads:\n ${findings}\nNothing writes an unknown key to \`<head>\`, so the tag it was meant to produce would simply be absent from every response, with no error at build time or at runtime. The build refuses it here instead.
|
|
56
|
+
Known keys: ${METADATA_KEYS.join(", ")}.\n Inside \`openGraph\`: ${OPEN_GRAPH_KEYS.join(", ")}.\n Inside \`twitter\`: ${TWITTER_KEYS.join(", ")}.\nAnnotating the export — \`export const metadata: PageMetadata = { … }\` — gets you the same list as autocomplete in the editor, before the build runs.`);
|
|
57
|
+
this.pageFile = pageFile;
|
|
58
|
+
this.unknownKeys = unknownKeys;
|
|
59
|
+
this.name = "UnknownMetadataKeyError";
|
|
60
|
+
}
|
|
61
|
+
};
|
|
62
|
+
/** `as const`, `satisfies`, `!` and parentheses wrap a value without changing it. */
|
|
63
|
+
function unwrapValue(node) {
|
|
64
|
+
switch (node.type) {
|
|
65
|
+
case "TSAsExpression":
|
|
66
|
+
case "TSSatisfiesExpression":
|
|
67
|
+
case "TSNonNullExpression":
|
|
68
|
+
case "TypeCastExpression":
|
|
69
|
+
case "ParenthesizedExpression": return unwrapValue(node.expression);
|
|
70
|
+
default: return node;
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
/** Levenshtein distance — small strings, so the plain two-row table is the whole cost. */
|
|
74
|
+
function editDistance(left, right) {
|
|
75
|
+
let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
|
|
76
|
+
for (let row = 1; row <= left.length; row++) {
|
|
77
|
+
const current = [row];
|
|
78
|
+
for (let column = 1; column <= right.length; column++) {
|
|
79
|
+
const substitution = previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1);
|
|
80
|
+
current[column] = Math.min(substitution, previous[column] + 1, current[column - 1] + 1);
|
|
81
|
+
}
|
|
82
|
+
previous = current;
|
|
83
|
+
}
|
|
84
|
+
return previous[right.length];
|
|
85
|
+
}
|
|
86
|
+
/**
|
|
87
|
+
* The known key the written one was probably meant to be.
|
|
88
|
+
*
|
|
89
|
+
* Two edits, because that covers the typos this exists for — `tittle`,
|
|
90
|
+
* `descriptoin`, `keywrods` — without reaching so far that `image` gets
|
|
91
|
+
* suggested for `alt`. Case is ignored first, so `Title` resolves exactly.
|
|
92
|
+
*/
|
|
93
|
+
function suggestKey(written, known) {
|
|
94
|
+
const lowered = written.toLowerCase();
|
|
95
|
+
const sameLetters = known.find((candidate) => candidate.toLowerCase() === lowered);
|
|
96
|
+
if (sameLetters !== void 0) return sameLetters;
|
|
97
|
+
let best;
|
|
98
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
99
|
+
for (const candidate of known) {
|
|
100
|
+
const distance = editDistance(lowered, candidate.toLowerCase());
|
|
101
|
+
if (distance < bestDistance) {
|
|
102
|
+
best = candidate;
|
|
103
|
+
bestDistance = distance;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
return bestDistance <= 2 ? best : void 0;
|
|
107
|
+
}
|
|
108
|
+
/** The name an object key denotes, or `undefined` when knowing it needs evaluation. */
|
|
109
|
+
function propertyKeyName(property) {
|
|
110
|
+
if (property.computed) return void 0;
|
|
111
|
+
const { key } = property;
|
|
112
|
+
if (key.type === "Identifier") return key.name;
|
|
113
|
+
if (key.type === "StringLiteral") return key.value;
|
|
114
|
+
}
|
|
115
|
+
/** The nested objects that carry a key set of their own. */
|
|
116
|
+
const NESTED_METADATA_KEYS = {
|
|
117
|
+
openGraph: OPEN_GRAPH_KEYS,
|
|
118
|
+
twitter: TWITTER_KEYS
|
|
119
|
+
};
|
|
120
|
+
/**
|
|
121
|
+
* Every unknown key in one metadata object literal, and in the `openGraph` /
|
|
122
|
+
* `twitter` literals inside it.
|
|
123
|
+
*
|
|
124
|
+
* A SPREAD does not suppress the check, unlike the route reader's rule: a
|
|
125
|
+
* spread can only ADD keys, and no value it contributes can make a key written
|
|
126
|
+
* out beside it correct. A COMPUTED key is skipped — its name is not knowable
|
|
127
|
+
* without running the page, and refusing what cannot be read would fail builds
|
|
128
|
+
* that are fine. Both are silence in the narrow places where the parse genuinely
|
|
129
|
+
* does not know, and the annotation is the second net there.
|
|
130
|
+
*/
|
|
131
|
+
function collectUnknownKeys(object, allowed, container, into) {
|
|
132
|
+
for (const property of object.properties) {
|
|
133
|
+
if (property.type === "SpreadElement") continue;
|
|
134
|
+
const key = propertyKeyName(property);
|
|
135
|
+
if (key === void 0) continue;
|
|
136
|
+
if (!allowed.includes(key)) {
|
|
137
|
+
const suggestion = suggestKey(key, allowed);
|
|
138
|
+
into.push({
|
|
139
|
+
container,
|
|
140
|
+
key,
|
|
141
|
+
line: property.loc?.start.line ?? 0,
|
|
142
|
+
...suggestion === void 0 ? {} : { suggestion }
|
|
143
|
+
});
|
|
144
|
+
continue;
|
|
145
|
+
}
|
|
146
|
+
const nested = container === "metadata" ? NESTED_METADATA_KEYS[key] : void 0;
|
|
147
|
+
if (nested === void 0 || property.type !== "ObjectProperty") continue;
|
|
148
|
+
const value = unwrapValue(property.value);
|
|
149
|
+
if (value.type === "ObjectExpression") collectUnknownKeys(value, nested, `${container}.${key}`, into);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
/**
|
|
153
|
+
* Every object literal a function form RETURNS, without descending into
|
|
154
|
+
* functions nested inside it — a callback's return value is not the metadata.
|
|
155
|
+
*
|
|
156
|
+
* A generic walk rather than a statement-by-statement one because a `return` is
|
|
157
|
+
* legal anywhere a statement is: inside an `if`, a `switch`, a `try`. Enumerating
|
|
158
|
+
* the statement types that may contain one is a list that is wrong the moment
|
|
159
|
+
* the language grows.
|
|
160
|
+
*/
|
|
161
|
+
function collectReturnedObjects(node, into) {
|
|
162
|
+
if (node === null || typeof node !== "object") return;
|
|
163
|
+
if (Array.isArray(node)) {
|
|
164
|
+
for (const item of node) collectReturnedObjects(item, into);
|
|
165
|
+
return;
|
|
166
|
+
}
|
|
167
|
+
const candidate = node;
|
|
168
|
+
if (candidate.type === "FunctionDeclaration" || candidate.type === "FunctionExpression" || candidate.type === "ArrowFunctionExpression" || candidate.type === "ObjectMethod" || candidate.type === "ClassMethod") return;
|
|
169
|
+
if (candidate.type === "ReturnStatement") {
|
|
170
|
+
if (candidate.argument === null || candidate.argument === void 0) return;
|
|
171
|
+
const returned = unwrapValue(candidate.argument);
|
|
172
|
+
if (returned.type === "ObjectExpression") into.push(returned);
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
for (const value of Object.values(node)) collectReturnedObjects(value, into);
|
|
176
|
+
}
|
|
177
|
+
/** The metadata object literals one `metadata` export declares, if any can be seen at all. */
|
|
178
|
+
function metadataObjectsOf(init) {
|
|
179
|
+
const value = unwrapValue(init);
|
|
180
|
+
if (value.type === "ObjectExpression") return [value];
|
|
181
|
+
if (value.type === "ArrowFunctionExpression" || value.type === "FunctionExpression") {
|
|
182
|
+
const body = unwrapValue(value.body);
|
|
183
|
+
if (body.type === "ObjectExpression") return [body];
|
|
184
|
+
const returned = [];
|
|
185
|
+
collectReturnedObjects(value.body, returned);
|
|
186
|
+
return returned;
|
|
187
|
+
}
|
|
188
|
+
return [];
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* The unknown keys a page's `metadata` export declares, read by PARSING — the
|
|
192
|
+
* same rule the rest of this module lives by, and the reason this check can run
|
|
193
|
+
* before anything is built.
|
|
194
|
+
*
|
|
195
|
+
* Empty for a page with no `metadata` export, for one whose metadata is a value
|
|
196
|
+
* this file cannot see into, and for a correct one.
|
|
197
|
+
*/
|
|
198
|
+
function readMetadataKeys(pageFile, source) {
|
|
199
|
+
let program;
|
|
200
|
+
try {
|
|
201
|
+
program = parse(source, {
|
|
202
|
+
sourceType: "module",
|
|
203
|
+
plugins: ["typescript", "jsx"],
|
|
204
|
+
errorRecovery: false
|
|
205
|
+
}).program;
|
|
206
|
+
} catch (error) {
|
|
207
|
+
throw new Error(`Cannot read the \`metadata\` export of "${pageFile}": the file could not be parsed (${error.message}). Fix the syntax error and the build will continue.`);
|
|
208
|
+
}
|
|
209
|
+
const unknownKeys = [];
|
|
210
|
+
for (const statement of program.body) {
|
|
211
|
+
if (statement.type !== "ExportNamedDeclaration" || statement.exportKind === "type") continue;
|
|
212
|
+
const { declaration } = statement;
|
|
213
|
+
if (declaration?.type !== "VariableDeclaration") continue;
|
|
214
|
+
for (const declarator of declaration.declarations) {
|
|
215
|
+
if (declarator.id.type !== "Identifier" || declarator.id.name !== "metadata") continue;
|
|
216
|
+
if (declarator.init === null || declarator.init === void 0) continue;
|
|
217
|
+
for (const object of metadataObjectsOf(declarator.init)) collectUnknownKeys(object, METADATA_KEYS, "metadata", unknownKeys);
|
|
218
|
+
}
|
|
219
|
+
}
|
|
220
|
+
return unknownKeys;
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
//#endregion
|
|
224
|
+
export { UnknownMetadataKeyError, readMetadataKeys };
|
|
225
|
+
//# sourceMappingURL=read-metadata-keys.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"read-metadata-keys.mjs","names":[],"sources":["../../../../../../../web/src/build/read-metadata-keys.ts"],"sourcesContent":["/**\n * Checks a page's `metadata` export for a key nothing reads — by parsing the\n * source, never by loading the module.\n *\n * THE POINT OF THIS ERROR IS THE UNANNOTATED CASE. A page that writes\n * `export const metadata: PageMetadata = { tittle: \"x\" }` is already refused by\n * TypeScript, and if that were the whole story this module would not need to\n * exist. But the annotation is optional, nobody writes it, and\n * `export const metadata = { tittle: \"x\" }` is a perfectly well-typed program:\n * the compiler infers `{ tittle: string }`, has nothing to check it against,\n * and says nothing. The page is then served with no `<title>` — not a wrong\n * title, a missing one — and no error is raised anywhere, at build or at\n * runtime, ever.\n *\n * So the check lives here instead, at the gate every page already passes\n * through: a page that silently omits requested metadata is worse than a\n * build that stops and says which line to fix.\n *\n * Single responsibility, deliberately: this returns the unknown keys it finds\n * and decides nothing. What that costs the build belongs to the caller.\n */\nimport { parse } from \"@babel/parser\";\nimport { METADATA_KEYS, OPEN_GRAPH_KEYS, TWITTER_KEYS } from \"../metadata\";\n\n/** One key a page's `metadata` declares that nothing reads, and where it is written. */\nexport type UnknownMetadataKey = {\n /** The object it was declared in: `metadata`, `metadata.openGraph`, `metadata.twitter`. */\n container: string;\n /** The key exactly as the page wrote it. */\n key: string;\n /** 1-based line in the page file, so the message points at the character that is wrong. */\n line: number;\n /** The known key it is within two edits of, when there is one. Usually the whole answer. */\n suggestion?: string;\n};\n\n/**\n * Raised when a page's `metadata` export declares a key nothing reads.\n *\n * THE POINT OF THIS ERROR IS THE UNANNOTATED CASE. A page that writes\n * `export const metadata: PageMetadata = { tittle: \"x\" }` is already refused by\n * TypeScript, and if that were the whole story this class would not need to\n * exist. But the annotation is optional, nobody writes it, and\n * `export const metadata = { tittle: \"x\" }` is a perfectly well-typed program:\n * the compiler infers `{ tittle: string }`, has nothing to check it against, and\n * says nothing. The page is then served with no `<title>` — not a wrong title, a\n * missing one — and no error is raised anywhere, at build or at runtime, ever.\n *\n * So the check lives HERE instead, at the gate every page already passes\n * through: a page that silently omits requested metadata is worse than a build\n * that stops and says which line to fix.\n *\n * The alternative considered and rejected was a `defineMetadata({...})` wrapper,\n * which would infer the type for free. It also puts framework ceremony in every\n * page, and a page is meant to be two lines of framework surface (canon\n * `6ea0662f`). The gate gets the same safety without spending that.\n */\nexport class UnknownMetadataKeyError extends Error {\n public constructor(\n public readonly pageFile: string,\n public readonly unknownKeys: readonly UnknownMetadataKey[],\n ) {\n const findings = unknownKeys\n .map(({ container, key, line, suggestion }) => {\n const where = `line ${line}: \\`${container}.${key}\\` — no such key.`;\n\n return suggestion === undefined ? where : `${where} Did you mean \\`${suggestion}\\`?`;\n })\n .join(\"\\n \");\n\n super(\n `The \\`metadata\\` export of \"${pageFile}\" declares a key nothing reads:\\n ${findings}\\n` +\n \"Nothing writes an unknown key to `<head>`, so the tag it was meant to produce would \" +\n \"simply be absent from every response, with no error at build time or at runtime. The \" +\n \"build refuses it here instead.\\n\" +\n ` Known keys: ${METADATA_KEYS.join(\", \")}.\\n` +\n ` Inside \\`openGraph\\`: ${OPEN_GRAPH_KEYS.join(\", \")}.\\n` +\n ` Inside \\`twitter\\`: ${TWITTER_KEYS.join(\", \")}.\\n` +\n \"Annotating the export — `export const metadata: PageMetadata = { … }` — gets you the \" +\n \"same list as autocomplete in the editor, before the build runs.\",\n );\n this.name = \"UnknownMetadataKeyError\";\n }\n}\n\n/**\n * The AST types, derived from `parse`'s own return type rather than imported\n * from `@babel/types`, for the reason `read-route-exports.ts` gives: the parser\n * resolves its own copy of that package and nodes from one copy are not\n * assignable to the identical types from the other.\n */\ntype PageStatement = ReturnType<typeof parse>[\"program\"][\"body\"][number];\ntype PageExpression = Extract<PageStatement, { type: \"ExpressionStatement\" }>[\"expression\"];\ntype PageObjectExpression = Extract<PageExpression, { type: \"ObjectExpression\" }>;\ntype PageObjectProperty = Extract<\n PageObjectExpression[\"properties\"][number],\n { type: \"ObjectProperty\" }\n>;\ntype PageValueNode = PageObjectProperty[\"value\"];\n\n/** `as const`, `satisfies`, `!` and parentheses wrap a value without changing it. */\nfunction unwrapValue(node: PageValueNode): PageValueNode {\n switch (node.type) {\n case \"TSAsExpression\":\n case \"TSSatisfiesExpression\":\n case \"TSNonNullExpression\":\n case \"TypeCastExpression\":\n case \"ParenthesizedExpression\":\n return unwrapValue(node.expression);\n default:\n return node;\n }\n}\n\n/** Levenshtein distance — small strings, so the plain two-row table is the whole cost. */\nfunction editDistance(left: string, right: string): number {\n let previous = Array.from({ length: right.length + 1 }, (_, index) => index);\n\n for (let row = 1; row <= left.length; row++) {\n const current = [row];\n\n for (let column = 1; column <= right.length; column++) {\n const substitution = previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1);\n current[column] = Math.min(substitution, previous[column] + 1, current[column - 1] + 1);\n }\n\n previous = current;\n }\n\n return previous[right.length];\n}\n\n/**\n * The known key the written one was probably meant to be.\n *\n * Two edits, because that covers the typos this exists for — `tittle`,\n * `descriptoin`, `keywrods` — without reaching so far that `image` gets\n * suggested for `alt`. Case is ignored first, so `Title` resolves exactly.\n */\nfunction suggestKey(written: string, known: readonly string[]): string | undefined {\n const lowered = written.toLowerCase();\n const sameLetters = known.find((candidate) => candidate.toLowerCase() === lowered);\n\n if (sameLetters !== undefined) return sameLetters;\n\n let best: string | undefined;\n let bestDistance = Number.POSITIVE_INFINITY;\n\n for (const candidate of known) {\n const distance = editDistance(lowered, candidate.toLowerCase());\n\n if (distance < bestDistance) {\n best = candidate;\n bestDistance = distance;\n }\n }\n\n return bestDistance <= 2 ? best : undefined;\n}\n\n/** The name an object key denotes, or `undefined` when knowing it needs evaluation. */\nfunction propertyKeyName(property: PageObjectProperty): string | undefined {\n if (property.computed) return undefined;\n\n const { key } = property;\n\n if (key.type === \"Identifier\") return key.name;\n if (key.type === \"StringLiteral\") return key.value;\n\n return undefined;\n}\n\n/** The nested objects that carry a key set of their own. */\nconst NESTED_METADATA_KEYS: Record<string, readonly string[]> = {\n openGraph: OPEN_GRAPH_KEYS,\n twitter: TWITTER_KEYS,\n};\n\n/**\n * Every unknown key in one metadata object literal, and in the `openGraph` /\n * `twitter` literals inside it.\n *\n * A SPREAD does not suppress the check, unlike the route reader's rule: a\n * spread can only ADD keys, and no value it contributes can make a key written\n * out beside it correct. A COMPUTED key is skipped — its name is not knowable\n * without running the page, and refusing what cannot be read would fail builds\n * that are fine. Both are silence in the narrow places where the parse genuinely\n * does not know, and the annotation is the second net there.\n */\nfunction collectUnknownKeys(\n object: PageObjectExpression,\n allowed: readonly string[],\n container: string,\n into: UnknownMetadataKey[],\n): void {\n for (const property of object.properties) {\n if (property.type === \"SpreadElement\") continue;\n\n const key = propertyKeyName(property as PageObjectProperty);\n\n if (key === undefined) continue;\n\n if (!allowed.includes(key)) {\n const suggestion = suggestKey(key, allowed);\n\n into.push({\n container,\n key,\n line: property.loc?.start.line ?? 0,\n ...(suggestion === undefined ? {} : { suggestion }),\n });\n\n continue;\n }\n\n const nested = container === \"metadata\" ? NESTED_METADATA_KEYS[key] : undefined;\n\n if (nested === undefined || property.type !== \"ObjectProperty\") continue;\n\n const value = unwrapValue(property.value);\n\n if (value.type === \"ObjectExpression\") {\n collectUnknownKeys(value, nested, `${container}.${key}`, into);\n }\n }\n}\n\n/**\n * Every object literal a function form RETURNS, without descending into\n * functions nested inside it — a callback's return value is not the metadata.\n *\n * A generic walk rather than a statement-by-statement one because a `return` is\n * legal anywhere a statement is: inside an `if`, a `switch`, a `try`. Enumerating\n * the statement types that may contain one is a list that is wrong the moment\n * the language grows.\n */\nfunction collectReturnedObjects(node: unknown, into: PageObjectExpression[]): void {\n if (node === null || typeof node !== \"object\") return;\n\n if (Array.isArray(node)) {\n for (const item of node) collectReturnedObjects(item, into);\n\n return;\n }\n\n const candidate = node as { type?: string; argument?: unknown };\n\n if (\n candidate.type === \"FunctionDeclaration\" ||\n candidate.type === \"FunctionExpression\" ||\n candidate.type === \"ArrowFunctionExpression\" ||\n candidate.type === \"ObjectMethod\" ||\n candidate.type === \"ClassMethod\"\n ) {\n return;\n }\n\n if (candidate.type === \"ReturnStatement\") {\n if (candidate.argument === null || candidate.argument === undefined) return;\n\n const returned = unwrapValue(candidate.argument as PageValueNode);\n\n if (returned.type === \"ObjectExpression\") into.push(returned);\n\n // Not descending into the returned value: a `return` inside it belongs to a\n // function this walk is deliberately not entering.\n return;\n }\n\n for (const value of Object.values(node as Record<string, unknown>)) {\n collectReturnedObjects(value, into);\n }\n}\n\n/** The metadata object literals one `metadata` export declares, if any can be seen at all. */\nfunction metadataObjectsOf(init: PageValueNode): PageObjectExpression[] {\n const value = unwrapValue(init);\n\n if (value.type === \"ObjectExpression\") return [value];\n\n if (value.type === \"ArrowFunctionExpression\" || value.type === \"FunctionExpression\") {\n const body = unwrapValue(value.body as PageValueNode);\n\n // The concise arrow body — `({ data }) => ({ title: data.name })`, which is\n // how every function-form metadata export in the reference app is written.\n if (body.type === \"ObjectExpression\") return [body];\n\n const returned: PageObjectExpression[] = [];\n\n collectReturnedObjects(value.body, returned);\n\n return returned;\n }\n\n // `export const metadata = buildMetadata()`, or a bare identifier: the keys\n // are not in this file. Silent by design — see `collectUnknownKeys`.\n return [];\n}\n\n/**\n * The unknown keys a page's `metadata` export declares, read by PARSING — the\n * same rule the rest of this module lives by, and the reason this check can run\n * before anything is built.\n *\n * Empty for a page with no `metadata` export, for one whose metadata is a value\n * this file cannot see into, and for a correct one.\n */\nexport function readMetadataKeys(pageFile: string, source: string): UnknownMetadataKey[] {\n let program: ReturnType<typeof parse>[\"program\"];\n\n try {\n program = parse(source, {\n sourceType: \"module\",\n plugins: [\"typescript\", \"jsx\"],\n errorRecovery: false,\n }).program;\n } catch (error) {\n throw new Error(\n `Cannot read the \\`metadata\\` export of \"${pageFile}\": the file could not be parsed ` +\n `(${(error as Error).message}). Fix the syntax error and the build will continue.`,\n );\n }\n\n const unknownKeys: UnknownMetadataKey[] = [];\n\n for (const statement of program.body) {\n if (statement.type !== \"ExportNamedDeclaration\" || statement.exportKind === \"type\") continue;\n\n const { declaration } = statement;\n\n if (declaration?.type !== \"VariableDeclaration\") continue;\n\n for (const declarator of declaration.declarations) {\n if (declarator.id.type !== \"Identifier\" || declarator.id.name !== \"metadata\") continue;\n if (declarator.init === null || declarator.init === undefined) continue;\n\n for (const object of metadataObjectsOf(declarator.init)) {\n collectUnknownKeys(object, METADATA_KEYS, \"metadata\", unknownKeys);\n }\n }\n }\n\n return unknownKeys;\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"read-route-exports.mjs","names":[],"sources":["../../../../../../../web/src/build/read-route-exports.ts"],"sourcesContent":["/**\r\n * Reads a page's `route` export and a layout's `prefix` export STATICALLY —\r\n * by parsing the source, never by loading the module.\r\n *\r\n * The build has to know a page's declared route before anything is built, and\r\n * the only other way to learn it is to run the page: import the module, let its\r\n * top-level code execute, and read the binding. That is a different program\r\n * from the one being built, with the application's own side effects in it. So\r\n * this module parses instead, and the price of parsing is that the declaration\r\n * has to be readable without evaluation — a literal. What cannot be read is\r\n * REFUSED rather than guessed: a wrong route path that builds is worse than a\r\n * build that stops and says which file to change.\r\n *\r\n * `route` and `prefix` are names the page contract reserves, so this reads them\r\n * out of whichever file it is given and refuses a computed one wherever it\r\n * appears — a page that exports `prefix`, or a layout that exports `route`, is\r\n * using a name the framework already owns.\r\n *\r\n * Single responsibility, deliberately: this returns values or a typed\r\n * rejection and decides nothing. What a rejection costs, and when a page is\r\n * routed at all, belongs to the caller.\r\n */\r\nimport fs from \"node:fs\";\r\nimport { parse } from \"@babel/parser\";\r\n\r\n/**\r\n * The AST types are derived from `parse`'s own return type rather than imported\r\n * from `@babel/types`: the parser resolves its own copy of that package, and a\r\n * node from one copy is not assignable to the identically-shaped type from the\r\n * other. Reading the types off the function that produced the nodes cannot\r\n * disagree with it.\r\n */\r\ntype Statement = ReturnType<typeof parse>[\"program\"][\"body\"][number];\r\ntype Expression = Extract<Statement, { type: \"ExpressionStatement\" }>[\"expression\"];\r\ntype ObjectExpression = Extract<Expression, { type: \"ObjectExpression\" }>;\r\ntype ObjectProperty = Extract<ObjectExpression[\"properties\"][number], { type: \"ObjectProperty\" }>;\r\n\r\n/**\r\n * Anything that can appear where a value is expected — an expression, or one of\r\n * the destructuring patterns that are legal in an object literal's value slot\r\n * and are never a literal string.\r\n */\r\ntype ValueNode = ObjectProperty[\"value\"];\r\n\r\n/**\r\n * A declared route, normalised. The bare-string form (`route = \"/list\"`) and\r\n * the object form (`route = { path: \"/list\" }`) reach the caller identically,\r\n * because the server resolves them identically — `name` is absent exactly when\r\n * the source omitted it, which is the caller's signal to derive one.\r\n */\r\nexport type DeclaredRoute = { path: string; name?: string };\r\n\r\n/** Which export could not be read, from which file, and what was found instead. */\r\nexport type RouteExportsRejection = {\r\n sourceFile: string;\r\n exportName: \"route\" | \"prefix\";\r\n /** A sentence fragment naming the form that was found, e.g. \"its value is a function call\". */\r\n detail: string;\r\n};\r\n\r\nexport type RouteExportsReadResult =\r\n | { ok: true; route?: DeclaredRoute; prefix?: string }\r\n | { ok: false; rejection: RouteExportsRejection };\r\n\r\nconst EXAMPLES: Record<\"route\" | \"prefix\", string> = {\r\n route: 'export const route = \"/list\"; (or export const route = { path: \"/list\", name: \"shop.list\" };)',\r\n prefix: 'export const prefix = \"/shop\";',\r\n};\r\n\r\n/**\r\n * The one thing an app developer is told when a declaration cannot be read.\r\n *\r\n * It names the file, says what was found, says why a literal is required, and\r\n * shows one — because the reader of this message is someone who wrote perfectly\r\n * valid TypeScript and needs to know why the build will not take it.\r\n */\r\nexport class NonLiteralRouteExportError extends Error {\r\n public constructor(public readonly rejection: RouteExportsRejection) {\r\n const { sourceFile, exportName, detail } = rejection;\r\n\r\n super(\r\n `Cannot read the \\`${exportName}\\` export of \"${sourceFile}\": ${detail}. The build reads ` +\r\n \"route declarations without running your application code, so this value has to be \" +\r\n `written out as a literal. For example: ${EXAMPLES[exportName]}`,\r\n );\r\n\r\n this.name = \"NonLiteralRouteExportError\";\r\n }\r\n}\r\n\r\n/**\r\n * `as const`, `satisfies`, a non-null assertion and parentheses all wrap a value\r\n * without changing it, so reading through them costs nothing and refusing them\r\n * would reject declarations that are literal in every sense that matters here.\r\n */\r\nfunction unwrap(node: ValueNode): ValueNode {\r\n switch (node.type) {\r\n case \"TSAsExpression\":\r\n case \"TSSatisfiesExpression\":\r\n case \"TSNonNullExpression\":\r\n case \"TypeCastExpression\":\r\n case \"ParenthesizedExpression\":\r\n return unwrap(node.expression);\r\n default:\r\n return node;\r\n }\r\n}\r\n\r\n/** The string a node denotes, or `undefined` when that needs evaluation to know. */\r\nfunction stringLiteralOf(node: ValueNode): string | undefined {\r\n const value = unwrap(node);\r\n\r\n if (value.type === \"StringLiteral\") return value.value;\r\n\r\n // A template with no substitutions is a string spelled with backticks.\r\n if (value.type === \"TemplateLiteral\" && value.expressions.length === 0) {\r\n return value.quasis[0]?.value.cooked ?? value.quasis[0]?.value.raw;\r\n }\r\n\r\n return undefined;\r\n}\r\n\r\n/** A sentence fragment naming what was found, for the developer-facing message. */\r\nfunction describe(node: ValueNode): string {\r\n const value = unwrap(node);\r\n\r\n switch (value.type) {\r\n case \"CallExpression\":\r\n case \"OptionalCallExpression\":\r\n case \"NewExpression\":\r\n return \"its value is a function call\";\r\n case \"Identifier\":\r\n return `its value is the variable \\`${value.name}\\``;\r\n case \"MemberExpression\":\r\n case \"OptionalMemberExpression\":\r\n return \"its value is read off another object\";\r\n case \"TemplateLiteral\":\r\n return \"its value is a template literal with an expression in it\";\r\n case \"ConditionalExpression\":\r\n return \"its value depends on a condition\";\r\n case \"BinaryExpression\":\r\n case \"LogicalExpression\":\r\n return \"its value is built by an expression\";\r\n default:\r\n return \"its value is computed rather than written out\";\r\n }\r\n}\r\n\r\ntype ObjectRead = { ok: true; route: DeclaredRoute } | { ok: false; detail: string };\r\n\r\n/**\r\n * The object form. Unknown keys are IGNORED rather than refused, matching the\r\n * server, which reads `path` and `name` and lets a page carry whatever else it\r\n * wants alongside them. A spread is not an unknown key: it can contribute\r\n * `path` itself, so an object that spreads is an object whose route this cannot\r\n * claim to have read.\r\n */\r\nfunction readRouteObject(node: ObjectExpression): ObjectRead {\r\n let routePath: string | undefined;\r\n let routeName: string | undefined;\r\n\r\n for (const property of node.properties) {\r\n if (property.type === \"SpreadElement\") {\r\n return { ok: false, detail: \"the object spreads another value into itself\" };\r\n }\r\n\r\n if (property.computed) {\r\n return { ok: false, detail: \"one of the object's keys is computed\" };\r\n }\r\n\r\n const { key } = property;\r\n const keyName =\r\n key.type === \"Identifier\" ? key.name : key.type === \"StringLiteral\" ? key.value : undefined;\r\n\r\n if (keyName !== \"path\" && keyName !== \"name\") continue;\r\n\r\n if (property.type !== \"ObjectProperty\") {\r\n return { ok: false, detail: `\\`${keyName}\\` is declared as a method` };\r\n }\r\n\r\n const value = stringLiteralOf(property.value);\r\n\r\n if (value === undefined) {\r\n return { ok: false, detail: `its \\`${keyName}\\` is not written as a string literal` };\r\n }\r\n\r\n if (keyName === \"path\") routePath = value;\r\n else routeName = value;\r\n }\r\n\r\n if (routePath === undefined) {\r\n return { ok: false, detail: \"the object does not declare a `path`\" };\r\n }\r\n\r\n return {\r\n ok: true,\r\n route: routeName === undefined ? { path: routePath } : { path: routePath, name: routeName },\r\n };\r\n}\r\n\r\n/**\r\n * Parses the source, or THROWS when it cannot be parsed at all.\r\n *\r\n * A syntax error is not a rejection, deliberately: nothing about the route\r\n * declaration is known yet, so telling the developer to write a literal would\r\n * answer a question they did not ask.\r\n */\r\nfunction parseSource(sourceFile: string, source: string) {\r\n try {\r\n return parse(source, {\r\n sourceType: \"module\",\r\n // Every file this reads is a page or a layout, i.e. `.tsx`.\r\n plugins: [\"typescript\", \"jsx\"],\r\n errorRecovery: false,\r\n });\r\n } catch (error) {\r\n throw new Error(\r\n `Cannot read the route declarations of \"${sourceFile}\": the file could not be parsed ` +\r\n `(${(error as Error).message}). Fix the syntax error and the build will continue.`,\r\n );\r\n }\r\n}\r\n\r\n/**\r\n * Returns the literal `route` and `prefix` this file declares.\r\n *\r\n * Absent is not a rejection: a file that declares neither is read successfully\r\n * with both fields unset, and what THAT means — a page with no public URL, a\r\n * layout that adds no prefix — is the caller's call to make.\r\n *\r\n * `source` is an override for callers that already hold the text; by default\r\n * the file is read from disk.\r\n */\r\nexport function readRouteExports(sourceFile: string, source?: string): RouteExportsReadResult {\r\n const text = source ?? fs.readFileSync(sourceFile, \"utf-8\");\r\n const ast = parseSource(sourceFile, text);\r\n\r\n const reject = (exportName: \"route\" | \"prefix\", detail: string): RouteExportsReadResult => ({\r\n ok: false,\r\n rejection: { sourceFile, exportName, detail },\r\n });\r\n\r\n let route: DeclaredRoute | undefined;\r\n let prefix: string | undefined;\r\n\r\n for (const statement of ast.program.body) {\r\n if (statement.type !== \"ExportNamedDeclaration\" || statement.exportKind === \"type\") continue;\r\n\r\n // `export { route }` hides the value behind a binding this cannot follow\r\n // without resolving scope — and following it across modules is exactly the\r\n // evaluation this reader exists to avoid.\r\n for (const specifier of statement.specifiers) {\r\n if (specifier.type !== \"ExportSpecifier\" || specifier.exportKind === \"type\") continue;\r\n\r\n const exported =\r\n specifier.exported.type === \"Identifier\"\r\n ? specifier.exported.name\r\n : specifier.exported.value;\r\n\r\n if (exported === \"route\" || exported === \"prefix\") {\r\n return reject(\r\n exported,\r\n \"it is exported through an export list rather than declared with `export const`\",\r\n );\r\n }\r\n }\r\n\r\n const { declaration } = statement;\r\n\r\n if (declaration?.type !== \"VariableDeclaration\") continue;\r\n\r\n for (const declarator of declaration.declarations) {\r\n if (declarator.id.type !== \"Identifier\") continue;\r\n\r\n const declared = declarator.id.name;\r\n\r\n if (declared !== \"route\" && declared !== \"prefix\") continue;\r\n\r\n if (declarator.init === null || declarator.init === undefined) {\r\n return reject(declared, \"it is declared without a value\");\r\n }\r\n\r\n if (declared === \"prefix\") {\r\n const value = stringLiteralOf(declarator.init);\r\n\r\n if (value === undefined) return reject(\"prefix\", describe(declarator.init));\r\n\r\n prefix = value;\r\n continue;\r\n }\r\n\r\n const value = stringLiteralOf(declarator.init);\r\n\r\n if (value !== undefined) {\r\n route = { path: value };\r\n continue;\r\n }\r\n\r\n const object = unwrap(declarator.init);\r\n\r\n if (object.type !== \"ObjectExpression\") return reject(\"route\", describe(declarator.init));\r\n\r\n const read = readRouteObject(object);\r\n\r\n if (!read.ok) return reject(\"route\", read.detail);\r\n\r\n route = read.route;\r\n }\r\n }\r\n\r\n return {\r\n ok: true,\r\n ...(route === undefined ? {} : { route }),\r\n ...(prefix === undefined ? {} : { prefix }),\r\n };\r\n}\r\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAgEA,MAAM,WAA+C;CACnD,OAAO;CACP,QAAQ;AACV;;;;;;;;AASA,IAAa,6BAAb,cAAgD,MAAM;CACjB;CAAnC,AAAO,YAAY,AAAgB,WAAkC;EACnE,MAAM,EAAE,YAAY,YAAY,WAAW;EAE3C,MACE,qBAAqB,WAAW,gBAAgB,WAAW,KAAK,OAAO,6IAE3B,SAAS,aACvD;EAPiC;EASjC,KAAK,OAAO;CACd;AACF;;;;;;AAOA,SAAS,OAAO,MAA4B;CAC1C,QAAQ,KAAK,MAAb;EACE,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK;EACL,KAAK,2BACH,OAAO,OAAO,KAAK,UAAU;EAC/B,SACE,OAAO;CACX;AACF;;AAGA,SAAS,gBAAgB,MAAqC;CAC5D,MAAM,QAAQ,OAAO,IAAI;CAEzB,IAAI,MAAM,SAAS,iBAAiB,OAAO,MAAM;CAGjD,IAAI,MAAM,SAAS,qBAAqB,MAAM,YAAY,WAAW,GACnE,OAAO,MAAM,OAAO,EAAE,EAAE,MAAM,UAAU,MAAM,OAAO,EAAE,EAAE,MAAM;AAInE;;AAGA,SAAS,SAAS,MAAyB;CACzC,MAAM,QAAQ,OAAO,IAAI;CAEzB,QAAQ,MAAM,MAAd;EACE,KAAK;EACL,KAAK;EACL,KAAK,iBACH,OAAO;EACT,KAAK,cACH,OAAO,+BAA+B,MAAM,KAAK;EACnD,KAAK;EACL,KAAK,4BACH,OAAO;EACT,KAAK,mBACH,OAAO;EACT,KAAK,yBACH,OAAO;EACT,KAAK;EACL,KAAK,qBACH,OAAO;EACT,SACE,OAAO;CACX;AACF;;;;;;;;AAWA,SAAS,gBAAgB,MAAoC;CAC3D,IAAI;CACJ,IAAI;CAEJ,KAAK,MAAM,YAAY,KAAK,YAAY;EACtC,IAAI,SAAS,SAAS,iBACpB,OAAO;GAAE,IAAI;GAAO,QAAQ;EAA+C;EAG7E,IAAI,SAAS,UACX,OAAO;GAAE,IAAI;GAAO,QAAQ;EAAuC;EAGrE,MAAM,EAAE,QAAQ;EAChB,MAAM,UACJ,IAAI,SAAS,eAAe,IAAI,OAAO,IAAI,SAAS,kBAAkB,IAAI,QAAQ;EAEpF,IAAI,YAAY,UAAU,YAAY,QAAQ;EAE9C,IAAI,SAAS,SAAS,kBACpB,OAAO;GAAE,IAAI;GAAO,QAAQ,KAAK,QAAQ;EAA4B;EAGvE,MAAM,QAAQ,gBAAgB,SAAS,KAAK;EAE5C,IAAI,UAAU,QACZ,OAAO;GAAE,IAAI;GAAO,QAAQ,SAAS,QAAQ;EAAuC;EAGtF,IAAI,YAAY,QAAQ,YAAY;OAC/B,YAAY;CACnB;CAEA,IAAI,cAAc,QAChB,OAAO;EAAE,IAAI;EAAO,QAAQ;CAAuC;CAGrE,OAAO;EACL,IAAI;EACJ,OAAO,cAAc,SAAY,EAAE,MAAM,UAAU,IAAI;GAAE,MAAM;GAAW,MAAM;EAAU;CAC5F;AACF;;;;;;;;AASA,SAAS,YAAY,YAAoB,QAAgB;CACvD,IAAI;EACF,OAAO,MAAM,QAAQ;GACnB,YAAY;GAEZ,SAAS,CAAC,cAAc,KAAK;GAC7B,eAAe;EACjB,CAAC;CACH,SAAS,OAAO;EACd,MAAM,IAAI,MACR,0CAA0C,WAAW,mCAC9C,MAAgB,QAAQ,qDACjC;CACF;AACF;;;;;;;;;;;AAYA,SAAgB,iBAAiB,YAAoB,QAAyC;CAE5F,MAAM,MAAM,YAAY,YADX,UAAU,GAAG,aAAa,YAAY,OAAO,CAClB;CAExC,MAAM,UAAU,YAAgC,YAA4C;EAC1F,IAAI;EACJ,WAAW;GAAE;GAAY;GAAY;EAAO;CAC9C;CAEA,IAAI;CACJ,IAAI;CAEJ,KAAK,MAAM,aAAa,IAAI,QAAQ,MAAM;EACxC,IAAI,UAAU,SAAS,4BAA4B,UAAU,eAAe,QAAQ;EAKpF,KAAK,MAAM,aAAa,UAAU,YAAY;GAC5C,IAAI,UAAU,SAAS,qBAAqB,UAAU,eAAe,QAAQ;GAE7E,MAAM,WACJ,UAAU,SAAS,SAAS,eACxB,UAAU,SAAS,OACnB,UAAU,SAAS;GAEzB,IAAI,aAAa,WAAW,aAAa,UACvC,OAAO,OACL,UACA,gFACF;EAEJ;EAEA,MAAM,EAAE,gBAAgB;EAExB,IAAI,aAAa,SAAS,uBAAuB;EAEjD,KAAK,MAAM,cAAc,YAAY,cAAc;GACjD,IAAI,WAAW,GAAG,SAAS,cAAc;GAEzC,MAAM,WAAW,WAAW,GAAG;GAE/B,IAAI,aAAa,WAAW,aAAa,UAAU;GAEnD,IAAI,WAAW,SAAS,QAAQ,WAAW,SAAS,QAClD,OAAO,OAAO,UAAU,gCAAgC;GAG1D,IAAI,aAAa,UAAU;IACzB,MAAM,QAAQ,gBAAgB,WAAW,IAAI;IAE7C,IAAI,UAAU,QAAW,OAAO,OAAO,UAAU,SAAS,WAAW,IAAI,CAAC;IAE1E,SAAS;IACT;GACF;GAEA,MAAM,QAAQ,gBAAgB,WAAW,IAAI;GAE7C,IAAI,UAAU,QAAW;IACvB,QAAQ,EAAE,MAAM,MAAM;IACtB;GACF;GAEA,MAAM,SAAS,OAAO,WAAW,IAAI;GAErC,IAAI,OAAO,SAAS,oBAAoB,OAAO,OAAO,SAAS,SAAS,WAAW,IAAI,CAAC;GAExF,MAAM,OAAO,gBAAgB,MAAM;GAEnC,IAAI,CAAC,KAAK,IAAI,OAAO,OAAO,SAAS,KAAK,MAAM;GAEhD,QAAQ,KAAK;EACf;CACF;CAEA,OAAO;EACL,IAAI;EACJ,GAAI,UAAU,SAAY,CAAC,IAAI,EAAE,MAAM;EACvC,GAAI,WAAW,SAAY,CAAC,IAAI,EAAE,OAAO;CAC3C;AACF"}
|
|
1
|
+
{"version":3,"file":"read-route-exports.mjs","names":[],"sources":["../../../../../../../web/src/build/read-route-exports.ts"],"sourcesContent":["/**\r\n * Reads a page's `route` export and a layout's `prefix` export STATICALLY —\r\n * by parsing the source, never by loading the module.\r\n *\r\n * The build has to know a page's declared route before anything is built, and\r\n * the only other way to learn it is to run the page: import the module, let its\r\n * top-level code execute, and read the binding. That is a different program\r\n * from the one being built, with the application's own side effects in it. So\r\n * this module parses instead, and the price of parsing is that the declaration\r\n * has to be readable without evaluation — a literal. What cannot be read is\r\n * REFUSED rather than guessed: a wrong route path that builds is worse than a\r\n * build that stops and says which file to change.\r\n *\r\n * `route` and `prefix` are names the page contract reserves, so this reads them\r\n * out of whichever file it is given and refuses a computed one wherever it\r\n * appears — a page that exports `prefix`, or a layout that exports `route`, is\r\n * using a name the framework already owns.\r\n *\r\n * Single responsibility, deliberately: this returns values or a typed\r\n * rejection and decides nothing. What a rejection costs, and when a page is\r\n * routed at all, belongs to the caller.\r\n */\r\nimport fs from \"node:fs\";\r\nimport { parse } from \"@babel/parser\";\r\n\r\n/**\r\n * The AST types are derived from `parse`'s own return type rather than imported\r\n * from `@babel/types`: the parser resolves its own copy of that package, and a\r\n * node from one copy is not assignable to the identically-shaped type from the\r\n * other. Reading the types off the function that produced the nodes cannot\r\n * disagree with it.\r\n */\r\ntype Statement = ReturnType<typeof parse>[\"program\"][\"body\"][number];\r\ntype Expression = Extract<Statement, { type: \"ExpressionStatement\" }>[\"expression\"];\r\ntype ObjectExpression = Extract<Expression, { type: \"ObjectExpression\" }>;\r\ntype ObjectProperty = Extract<ObjectExpression[\"properties\"][number], { type: \"ObjectProperty\" }>;\r\n\r\n/**\r\n * Anything that can appear where a value is expected — an expression, or one of\r\n * the destructuring patterns that are legal in an object literal's value slot\r\n * and are never a literal string.\r\n */\r\ntype ValueNode = ObjectProperty[\"value\"];\r\n\r\n/**\r\n * A declared route, normalised. The bare-string form (`route = \"/list\"`) and\r\n * the object form (`route = { path: \"/list\" }`) reach the caller identically,\r\n * because the server resolves them identically — `name` is absent exactly when\r\n * the source omitted it, which is the caller's signal to derive one.\r\n */\r\nexport type DeclaredRoute = { path: string; name?: string };\r\n\r\n/** Which export could not be read, from which file, and what was found instead. */\r\nexport type RouteExportsRejection = {\r\n sourceFile: string;\r\n exportName: \"route\" | \"prefix\";\r\n /** A sentence fragment naming the form that was found, e.g. \"its value is a function call\". */\r\n detail: string;\r\n};\r\n\r\nexport type RouteExportsReadResult =\r\n | { ok: true; route?: DeclaredRoute; prefix?: string }\r\n | { ok: false; rejection: RouteExportsRejection };\r\n\r\nconst EXAMPLES: Record<\"route\" | \"prefix\", string> = {\r\n route:\r\n 'export const route = \"/list\"; (or export const route = { path: \"/list\", name: \"shop.list\" };)',\r\n prefix: 'export const prefix = \"/shop\";',\r\n};\r\n\r\n/**\r\n * The one thing an app developer is told when a declaration cannot be read.\r\n *\r\n * It names the file, says what was found, says why a literal is required, and\r\n * shows one — because the reader of this message is someone who wrote perfectly\r\n * valid TypeScript and needs to know why the build will not take it.\r\n */\r\nexport class NonLiteralRouteExportError extends Error {\r\n public constructor(public readonly rejection: RouteExportsRejection) {\r\n const { sourceFile, exportName, detail } = rejection;\r\n\r\n super(\r\n `Cannot read the \\`${exportName}\\` export of \"${sourceFile}\": ${detail}. The build reads ` +\r\n \"route declarations without running your application code, so this value has to be \" +\r\n `written out as a literal. For example: ${EXAMPLES[exportName]}`,\r\n );\r\n\r\n this.name = \"NonLiteralRouteExportError\";\r\n }\r\n}\r\n\r\n/**\r\n * `as const`, `satisfies`, a non-null assertion and parentheses all wrap a value\r\n * without changing it, so reading through them costs nothing and refusing them\r\n * would reject declarations that are literal in every sense that matters here.\r\n */\r\nfunction unwrap(node: ValueNode): ValueNode {\r\n switch (node.type) {\r\n case \"TSAsExpression\":\r\n case \"TSSatisfiesExpression\":\r\n case \"TSNonNullExpression\":\r\n case \"TypeCastExpression\":\r\n case \"ParenthesizedExpression\":\r\n return unwrap(node.expression);\r\n default:\r\n return node;\r\n }\r\n}\r\n\r\n/** The string a node denotes, or `undefined` when that needs evaluation to know. */\r\nfunction stringLiteralOf(node: ValueNode): string | undefined {\r\n const value = unwrap(node);\r\n\r\n if (value.type === \"StringLiteral\") return value.value;\r\n\r\n // A template with no substitutions is a string spelled with backticks.\r\n if (value.type === \"TemplateLiteral\" && value.expressions.length === 0) {\r\n return value.quasis[0]?.value.cooked ?? value.quasis[0]?.value.raw;\r\n }\r\n\r\n return undefined;\r\n}\r\n\r\n/** A sentence fragment naming what was found, for the developer-facing message. */\r\nfunction describe(node: ValueNode): string {\r\n const value = unwrap(node);\r\n\r\n switch (value.type) {\r\n case \"CallExpression\":\r\n case \"OptionalCallExpression\":\r\n case \"NewExpression\":\r\n return \"its value is a function call\";\r\n case \"Identifier\":\r\n return `its value is the variable \\`${value.name}\\``;\r\n case \"MemberExpression\":\r\n case \"OptionalMemberExpression\":\r\n return \"its value is read off another object\";\r\n case \"TemplateLiteral\":\r\n return \"its value is a template literal with an expression in it\";\r\n case \"ConditionalExpression\":\r\n return \"its value depends on a condition\";\r\n case \"BinaryExpression\":\r\n case \"LogicalExpression\":\r\n return \"its value is built by an expression\";\r\n default:\r\n return \"its value is computed rather than written out\";\r\n }\r\n}\r\n\r\ntype ObjectRead = { ok: true; route: DeclaredRoute } | { ok: false; detail: string };\r\n\r\n/**\r\n * The object form. Unknown keys are IGNORED rather than refused, matching the\r\n * server, which reads `path` and `name` and lets a page carry whatever else it\r\n * wants alongside them. A spread is not an unknown key: it can contribute\r\n * `path` itself, so an object that spreads is an object whose route this cannot\r\n * claim to have read.\r\n */\r\nfunction readRouteObject(node: ObjectExpression): ObjectRead {\r\n let routePath: string | undefined;\r\n let routeName: string | undefined;\r\n\r\n for (const property of node.properties) {\r\n if (property.type === \"SpreadElement\") {\r\n return { ok: false, detail: \"the object spreads another value into itself\" };\r\n }\r\n\r\n if (property.computed) {\r\n return { ok: false, detail: \"one of the object's keys is computed\" };\r\n }\r\n\r\n const { key } = property;\r\n const keyName =\r\n key.type === \"Identifier\" ? key.name : key.type === \"StringLiteral\" ? key.value : undefined;\r\n\r\n if (keyName !== \"path\" && keyName !== \"name\") continue;\r\n\r\n if (property.type !== \"ObjectProperty\") {\r\n return { ok: false, detail: `\\`${keyName}\\` is declared as a method` };\r\n }\r\n\r\n const value = stringLiteralOf(property.value);\r\n\r\n if (value === undefined) {\r\n return { ok: false, detail: `its \\`${keyName}\\` is not written as a string literal` };\r\n }\r\n\r\n if (keyName === \"path\") routePath = value;\r\n else routeName = value;\r\n }\r\n\r\n if (routePath === undefined) {\r\n return { ok: false, detail: \"the object does not declare a `path`\" };\r\n }\r\n\r\n return {\r\n ok: true,\r\n route: routeName === undefined ? { path: routePath } : { path: routePath, name: routeName },\r\n };\r\n}\r\n\r\n/**\r\n * Parses the source, or THROWS when it cannot be parsed at all.\r\n *\r\n * A syntax error is not a rejection, deliberately: nothing about the route\r\n * declaration is known yet, so telling the developer to write a literal would\r\n * answer a question they did not ask.\r\n */\r\nfunction parseSource(sourceFile: string, source: string) {\r\n try {\r\n return parse(source, {\r\n sourceType: \"module\",\r\n // Every file this reads is a page or a layout, i.e. `.tsx`.\r\n plugins: [\"typescript\", \"jsx\"],\r\n errorRecovery: false,\r\n });\r\n } catch (error) {\r\n throw new Error(\r\n `Cannot read the route declarations of \"${sourceFile}\": the file could not be parsed ` +\r\n `(${(error as Error).message}). Fix the syntax error and the build will continue.`,\r\n );\r\n }\r\n}\r\n\r\n/**\r\n * Returns the literal `route` and `prefix` this file declares.\r\n *\r\n * Absent is not a rejection: a file that declares neither is read successfully\r\n * with both fields unset, and what THAT means — a page with no public URL, a\r\n * layout that adds no prefix — is the caller's call to make.\r\n *\r\n * `source` is an override for callers that already hold the text; by default\r\n * the file is read from disk.\r\n */\r\nexport function readRouteExports(sourceFile: string, source?: string): RouteExportsReadResult {\r\n const text = source ?? fs.readFileSync(sourceFile, \"utf-8\");\r\n const ast = parseSource(sourceFile, text);\r\n\r\n const reject = (exportName: \"route\" | \"prefix\", detail: string): RouteExportsReadResult => ({\r\n ok: false,\r\n rejection: { sourceFile, exportName, detail },\r\n });\r\n\r\n let route: DeclaredRoute | undefined;\r\n let prefix: string | undefined;\r\n\r\n for (const statement of ast.program.body) {\r\n if (statement.type !== \"ExportNamedDeclaration\" || statement.exportKind === \"type\") continue;\r\n\r\n // `export { route }` hides the value behind a binding this cannot follow\r\n // without resolving scope — and following it across modules is exactly the\r\n // evaluation this reader exists to avoid.\r\n for (const specifier of statement.specifiers) {\r\n if (specifier.type !== \"ExportSpecifier\" || specifier.exportKind === \"type\") continue;\r\n\r\n const exported =\r\n specifier.exported.type === \"Identifier\"\r\n ? specifier.exported.name\r\n : specifier.exported.value;\r\n\r\n if (exported === \"route\" || exported === \"prefix\") {\r\n return reject(\r\n exported,\r\n \"it is exported through an export list rather than declared with `export const`\",\r\n );\r\n }\r\n }\r\n\r\n const { declaration } = statement;\r\n\r\n if (declaration?.type !== \"VariableDeclaration\") continue;\r\n\r\n for (const declarator of declaration.declarations) {\r\n if (declarator.id.type !== \"Identifier\") continue;\r\n\r\n const declared = declarator.id.name;\r\n\r\n if (declared !== \"route\" && declared !== \"prefix\") continue;\r\n\r\n if (declarator.init === null || declarator.init === undefined) {\r\n return reject(declared, \"it is declared without a value\");\r\n }\r\n\r\n if (declared === \"prefix\") {\r\n const value = stringLiteralOf(declarator.init);\r\n\r\n if (value === undefined) return reject(\"prefix\", describe(declarator.init));\r\n\r\n prefix = value;\r\n continue;\r\n }\r\n\r\n const value = stringLiteralOf(declarator.init);\r\n\r\n if (value !== undefined) {\r\n route = { path: value };\r\n continue;\r\n }\r\n\r\n const object = unwrap(declarator.init);\r\n\r\n if (object.type !== \"ObjectExpression\") return reject(\"route\", describe(declarator.init));\r\n\r\n const read = readRouteObject(object);\r\n\r\n if (!read.ok) return reject(\"route\", read.detail);\r\n\r\n route = read.route;\r\n }\r\n }\r\n\r\n return {\r\n ok: true,\r\n ...(route === undefined ? {} : { route }),\r\n ...(prefix === undefined ? {} : { prefix }),\r\n };\r\n}\r\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-hydrated-tree.mjs","names":[],"sources":["../../../../../../../web/src/client/build-hydrated-tree.ts"],"sourcesContent":["/**\n * The hydration COMPOSER: payload + page registry -> the ReactNode to mount.\n *\n * It takes the registry as an ARGUMENT and touches no browser global, which is\n * the whole point of it living apart from `index.ts`: every rule below is\n * testable with a hand-built registry, no bundler, no virtual module, no DOM.\n *\n * LOOKUP BY NAME, NEVER BY MATCH. `payload.name` is the identity of the entry\n * the SERVER matched for this exact request (document-context.ts's `name`\n * field). Re-deriving it from `location.pathname` with `matchClientRoute`\n * would be a second implementation of route semantics running against the one\n * request it is hydrating, free to disagree with the server that produced the\n * markup. `matchClientRoute` is for client-side NAVIGATION, where no server\n * answer exists yet.\n */\nimport { createElement, type ComponentType, type ReactNode } from \"react\";\nimport type {\n HydrationDocumentPayloadSource,\n SerializedErrorPageProps,\n} from \"../hydration-payload\";\nimport { registerModules } from \"../runtime/register-modules\";\nimport { loadClientRouteComposition } from \"./runtime\";\nimport type { ClientPageEntry, ClientProjectedModule } from \"./runtime/types\";\n\n/** What every composed level receives — the shape `render-page.ts` uses server-side. */\ntype HydratedLevelProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly children?: ReactNode;\n};\n\n/** The ordinary page leaf alone receives params from the server's match. */\ntype HydratedPageProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly params: Readonly<Record<string, string>>;\n};\n\nfunction describeKnownNames(knownPageNames: readonly string[]): string {\n if (knownPageNames.length === 0) return \"The client page registry is empty.\";\n\n return `The registry knows: ${knownPageNames.map((name) => JSON.stringify(name)).join(\", \")}.`;\n}\n\n/**\n * The THIRD hydration failure case, beside an absent and a malformed payload.\n *\n * It fails CLOSED — no default entry, no nearest-path fallback, no silent\n * no-op. A registry that quietly substitutes a page produces a browser showing\n * one page's markup running another page's code, which is precisely the defect\n * this entry point was rewritten to remove; a fallback would reintroduce it\n * wearing a recovery costume. Throwing leaves the server-rendered markup on\n * screen and un-hydrated, which is degraded but honest.\n */\nexport class UnknownHydrationPageNameError extends Error {\n public constructor(\n public readonly pageName: string,\n public readonly knownPageNames: readonly string[],\n ) {\n super(\n `Warlock hydration aborted: the payload names page ${JSON.stringify(pageName)}, which is ` +\n `not in the client page registry. ${describeKnownNames(knownPageNames)} The server ` +\n \"rendered a page this browser bundle does not carry, so the server and client were \" +\n \"built from different page graphs. To fix: rebuild the client bundle, or check that \" +\n \"the page's file still exports a `route` discovery can see.\",\n );\n this.name = \"UnknownHydrationPageNameError\";\n }\n}\n\n/**\n * The server selected an app error page, but this browser graph cannot load it.\n * Substituting the ordinary page would execute the component that already\n * failed and hydrate markup the server did not render, so this path fails\n * closed just like an unknown route name.\n */\nexport class MissingHydrationErrorPageError extends Error {\n public constructor(public readonly pageName: string) {\n super(\n `Warlock hydration aborted: the server selected error.page.tsx for route ` +\n `${JSON.stringify(pageName)}, but that route's client composition has no ErrorPage ` +\n \"module. Rebuild the client page registry so it projects the discovered error page.\",\n );\n this.name = \"MissingHydrationErrorPageError\";\n }\n}\n\nfunction findEntryByName(\n pages: readonly ClientPageEntry[],\n name: string,\n): ClientPageEntry {\n const entry = pages.find((candidate) => candidate.name === name);\n\n if (entry === undefined) {\n throw new UnknownHydrationPageNameError(\n name,\n pages.map((candidate) => candidate.name),\n );\n }\n\n return entry;\n}\n\n/**\n * A level's component, or undefined when the module exports no default.\n *\n * Undefined is NOT an error: `render-page.ts:258` and `:279` treat a missing\n * default as a passthrough server-side, and the client tree has to match the\n * markup React is hydrating against — introducing a level here that the server\n * did not render is a hydration mismatch, not a repair.\n */\nfunction componentOf<Props extends object>(\n module: ClientProjectedModule,\n): ComponentType<Props> | undefined {\n const component = module.default;\n\n return typeof component === \"function\"\n ? (component as ComponentType<Props>)\n : undefined;\n}\n\nfunction wrap(\n module: ClientProjectedModule,\n data: unknown,\n shared: unknown,\n children: ReactNode,\n): ReactNode {\n const Component = componentOf<HydratedLevelProps>(module);\n\n if (Component === undefined) return children;\n\n return createElement(Component, { data, shared, children });\n}\n\n/**\n * Compose the tree the server rendered inside `#root`: ordered layouts wrapping\n * the selected Page or ErrorPage leaf, layouts OUTERMOST FIRST as\n * `ClientRouteComposition` declares them. Ordinary levels receive\n * `{ data, shared }`; the error leaf receives the serialized `{ error, status\n * }` payload shape.\n *\n * ── THE APP LEVEL IS DELIBERATELY ABSENT, AND MUST STAY ABSENT ──────────────\n * `ClientRouteComposition.App` and `payload.appData` still exist and are still\n * carried; they are contracts owned elsewhere. They are simply not part of THIS\n * tree, because App is not part of the markup this tree hydrates against:\n *\n * - Server-side, `render-page.ts`'s `wrapRootward` wraps the page leaf in\n * `[\"layout\", \"app\"]` (`render-page.ts:274`), so the document React renders\n * is `App( Layout( Page ) )`.\n * - The app root is the level that owns `<html>`/`<body>` and renders\n * `<div id=\"root\">{children}</div>` inside the body. So App CONTAINS the\n * mount point — the markup actually inside `#root` is `Layout( Page )`.\n * - `hydrate-page.tsx` mounts at `#root` and nowhere else.\n *\n * Composing App here would therefore hydrate a whole `<html>` document inside a\n * `<div>` the server filled with a layout: a guaranteed hydration mismatch. If\n * you arrived here from the optional `App?` on the composition type and are\n * about to \"complete\" the tree with it — that would be the defect, not the\n * omission.\n *\n * `load()` is awaited exactly ONCE per hydration and its result reused for all\n * levels — the composition arrives whole, so calling it per layout would be\n * one network waterfall per level for no new information.\n */\nexport async function buildHydratedTree(\n pages: readonly ClientPageEntry[],\n payload: HydrationDocumentPayloadSource,\n): Promise<ReactNode> {\n const entry = findEntryByName(pages, payload.name);\n const composition = await loadClientRouteComposition(entry);\n const errorPageProps = payload.errorPage;\n const selectedPageModule =\n errorPageProps === undefined ? composition.Page : composition.ErrorPage;\n\n if (selectedPageModule === undefined) {\n throw new MissingHydrationErrorPageError(payload.name);\n }\n\n // Registration is the first lifecycle action after the real namespaces have\n // loaded. Keep server order: root/App, layouts outermost-to-innermost, page.\n // On the error path the selected error module replaces the ordinary Page in\n // that order; registering Page as well would run code the server did not run.\n // Component extraction and React element creation intentionally happen only\n // after every registration hook has completed synchronously.\n registerModules([\n ...(composition.App === undefined ? [] : [composition.App]),\n ...composition.layouts,\n selectedPageModule,\n ]);\n\n const { shared } = payload;\n let element: ReactNode;\n\n if (errorPageProps === undefined) {\n const Page = componentOf<HydratedPageProps>(selectedPageModule);\n element =\n Page === undefined\n ? null\n : createElement(Page, {\n data: payload.pageData,\n shared,\n params: payload.params ?? {},\n });\n } else {\n const ErrorPage = componentOf<SerializedErrorPageProps>(selectedPageModule);\n element =\n ErrorPage === undefined ? null : createElement(ErrorPage, errorPageProps);\n }\n\n // Innermost layout wraps the page, so walk the outermost-first list backwards.\n for (let index = composition.layouts.length - 1; index >= 0; index -= 1) {\n element = wrap(\n composition.layouts[index]!,\n payload.layoutData,\n shared,\n element,\n );\n }\n\n return element;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAsCA,SAAS,mBAAmB,gBAA2C;CACrE,IAAI,eAAe,WAAW,GAAG,OAAO;CAExC,OAAO,uBAAuB,eAAe,KAAK,SAAS,KAAK,UAAU,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE;AAC9F;;;;;;;;;;;AAYA,IAAa,gCAAb,cAAmD,MAAM;CAErC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,gBAChB;EACA,MACE,qDAAqD,KAAK,UAAU,QAAQ,EAAE,8CACxC,mBAAmB,cAAc,EAAE,8OAI3E;EATgB;EACA;EAShB,KAAK,OAAO;CACd;AACF;;;;;;;AAQA,IAAa,iCAAb,cAAoD,MAAM;CACrB;CAAnC,AAAO,YAAY,AAAgB,UAAkB;EACnD,MACE,2EACK,KAAK,UAAU,QAAQ,EAAE,0IAEhC;EALiC;EAMjC,KAAK,OAAO;CACd;AACF;AAEA,SAAS,gBACP,OACA,MACiB;CACjB,MAAM,QAAQ,MAAM,MAAM,cAAc,UAAU,SAAS,IAAI;CAE/D,IAAI,UAAU,QACZ,MAAM,IAAI,8BACR,MACA,MAAM,KAAK,cAAc,UAAU,IAAI,CACzC;CAGF,OAAO;AACT;;;;;;;;;AAUA,SAAS,YACP,QACkC;CAClC,MAAM,YAAY,OAAO;CAEzB,OAAO,OAAO,cAAc,aACvB,YACD;AACN;AAEA,SAAS,KACP,QACA,MACA,QACA,UACW;CACX,MAAM,YAAY,YAAgC,MAAM;CAExD,IAAI,cAAc,QAAW,OAAO;CAEpC,OAAO,cAAc,WAAW;EAAE;EAAM;EAAQ;CAAS,CAAC;AAC5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,eAAsB,kBACpB,OACA,SACoB;CAEpB,MAAM,cAAc,MAAM,2BADZ,gBAAgB,OAAO,QAAQ,IACY,CAAC;CAC1D,MAAM,iBAAiB,QAAQ;CAC/B,MAAM,qBACJ,mBAAmB,SAAY,YAAY,OAAO,YAAY;CAEhE,IAAI,uBAAuB,QACzB,MAAM,IAAI,+BAA+B,QAAQ,IAAI;CASvD,gBAAgB;EACd,GAAI,YAAY,QAAQ,SAAY,CAAC,IAAI,CAAC,YAAY,GAAG;EACzD,GAAG,YAAY;EACf;CACF,CAAC;CAED,MAAM,EAAE,WAAW;CACnB,IAAI;CAEJ,IAAI,mBAAmB,QAAW;EAChC,MAAM,OAAO,YAA+B,kBAAkB;EAC9D,UACE,SAAS,SACL,OACA,cAAc,MAAM;GAClB,MAAM,QAAQ;GACd;GACA,QAAQ,QAAQ,UAAU,CAAC;EAC7B,CAAC;CACT,OAAO;EACL,MAAM,YAAY,YAAsC,kBAAkB;EAC1E,UACE,cAAc,SAAY,OAAO,cAAc,WAAW,cAAc;CAC5E;CAGA,KAAK,IAAI,QAAQ,YAAY,QAAQ,SAAS,GAAG,SAAS,GAAG,SAAS,GACpE,UAAU,KACR,YAAY,QAAQ,QACpB,QAAQ,YACR,QACA,OACF;CAGF,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"build-hydrated-tree.mjs","names":[],"sources":["../../../../../../../web/src/client/build-hydrated-tree.ts"],"sourcesContent":["/**\n * The hydration COMPOSER: payload + page registry -> the ReactNode to mount.\n *\n * It takes the registry as an ARGUMENT and touches no browser global, which is\n * the whole point of it living apart from `index.ts`: every rule below is\n * testable with a hand-built registry, no bundler, no virtual module, no DOM.\n *\n * LOOKUP BY NAME, NEVER BY MATCH. `payload.name` is the identity of the entry\n * the SERVER matched for this exact request (document-context.ts's `name`\n * field). Re-deriving it from `location.pathname` with `matchClientRoute`\n * would be a second implementation of route semantics running against the one\n * request it is hydrating, free to disagree with the server that produced the\n * markup. `matchClientRoute` is for client-side NAVIGATION, where no server\n * answer exists yet.\n */\nimport { createElement, type ComponentType, type ReactNode } from \"react\";\nimport type {\n HydrationDocumentPayloadSource,\n SerializedErrorPageProps,\n} from \"../hydration-payload\";\nimport { registerModules } from \"../register-modules\";\nimport { loadClientRouteComposition } from \"./runtime\";\nimport type { ClientPageEntry, ClientProjectedModule } from \"./runtime/types\";\n\n/** What every composed level receives — the shape `render-page.ts` uses server-side. */\ntype HydratedLevelProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly children?: ReactNode;\n};\n\n/** The ordinary page leaf alone receives params from the server's match. */\ntype HydratedPageProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly params: Readonly<Record<string, string>>;\n};\n\nfunction describeKnownNames(knownPageNames: readonly string[]): string {\n if (knownPageNames.length === 0) return \"The client page registry is empty.\";\n\n return `The registry knows: ${knownPageNames.map((name) => JSON.stringify(name)).join(\", \")}.`;\n}\n\n/**\n * The THIRD hydration failure case, beside an absent and a malformed payload.\n *\n * It fails CLOSED — no default entry, no nearest-path fallback, no silent\n * no-op. A registry that quietly substitutes a page produces a browser showing\n * one page's markup running another page's code, which is precisely the defect\n * this entry point was rewritten to remove; a fallback would reintroduce it\n * wearing a recovery costume. Throwing leaves the server-rendered markup on\n * screen and un-hydrated, which is degraded but honest.\n */\nexport class UnknownHydrationPageNameError extends Error {\n public constructor(\n public readonly pageName: string,\n public readonly knownPageNames: readonly string[],\n ) {\n super(\n `Warlock hydration aborted: the payload names page ${JSON.stringify(pageName)}, which is ` +\n `not in the client page registry. ${describeKnownNames(knownPageNames)} The server ` +\n \"rendered a page this browser bundle does not carry, so the server and client were \" +\n \"built from different page graphs. To fix: rebuild the client bundle, or check that \" +\n \"the page's file still exports a `route` discovery can see.\",\n );\n this.name = \"UnknownHydrationPageNameError\";\n }\n}\n\n/**\n * The server selected an app error page, but this browser graph cannot load it.\n * Substituting the ordinary page would execute the component that already\n * failed and hydrate markup the server did not render, so this path fails\n * closed just like an unknown route name.\n */\nexport class MissingHydrationErrorPageError extends Error {\n public constructor(public readonly pageName: string) {\n super(\n `Warlock hydration aborted: the server selected error.page.tsx for route ` +\n `${JSON.stringify(pageName)}, but that route's client composition has no ErrorPage ` +\n \"module. Rebuild the client page registry so it projects the discovered error page.\",\n );\n this.name = \"MissingHydrationErrorPageError\";\n }\n}\n\nfunction findEntryByName(pages: readonly ClientPageEntry[], name: string): ClientPageEntry {\n const entry = pages.find((candidate) => candidate.name === name);\n\n if (entry === undefined) {\n throw new UnknownHydrationPageNameError(\n name,\n pages.map((candidate) => candidate.name),\n );\n }\n\n return entry;\n}\n\n/**\n * A level's component, or undefined when the module exports no default.\n *\n * Undefined is NOT an error: `render-page.ts:258` and `:279` treat a missing\n * default as a passthrough server-side, and the client tree has to match the\n * markup React is hydrating against — introducing a level here that the server\n * did not render is a hydration mismatch, not a repair.\n */\nfunction componentOf<Props extends object>(\n module: ClientProjectedModule,\n): ComponentType<Props> | undefined {\n const component = module.default;\n\n return typeof component === \"function\" ? (component as ComponentType<Props>) : undefined;\n}\n\nfunction wrap(\n module: ClientProjectedModule,\n data: unknown,\n shared: unknown,\n children: ReactNode,\n): ReactNode {\n const Component = componentOf<HydratedLevelProps>(module);\n\n if (Component === undefined) return children;\n\n return createElement(Component, { data, shared, children });\n}\n\n/**\n * Compose the tree the server rendered inside `#root`: ordered layouts wrapping\n * the selected Page or ErrorPage leaf, layouts OUTERMOST FIRST as\n * `ClientRouteComposition` declares them. Ordinary levels receive\n * `{ data, shared }`; the error leaf receives the serialized `{ error, status\n * }` payload shape.\n *\n * ── THE APP LEVEL IS DELIBERATELY ABSENT, AND MUST STAY ABSENT ──────────────\n * `ClientRouteComposition.App` and `payload.appData` still exist and are still\n * carried; they are contracts owned elsewhere. They are simply not part of THIS\n * tree, because App is not part of the markup this tree hydrates against:\n *\n * - Server-side, `render-page.ts`'s `wrapRootward` wraps the page leaf in\n * `[\"layout\", \"app\"]` (`render-page.ts:274`), so the document React renders\n * is `App( Layout( Page ) )`.\n * - The app root is the level that owns `<html>`/`<body>` and renders\n * `<div id=\"root\">{children}</div>` inside the body. So App CONTAINS the\n * mount point — the markup actually inside `#root` is `Layout( Page )`.\n * - `hydrate-page.tsx` mounts at `#root` and nowhere else.\n *\n * Composing App here would therefore hydrate a whole `<html>` document inside a\n * `<div>` the server filled with a layout: a guaranteed hydration mismatch. If\n * you arrived here from the optional `App?` on the composition type and are\n * about to \"complete\" the tree with it — that would be the defect, not the\n * omission.\n *\n * `load()` is awaited exactly ONCE per hydration and its result reused for all\n * levels — the composition arrives whole, so calling it per layout would be\n * one network waterfall per level for no new information.\n */\nexport async function buildHydratedTree(\n pages: readonly ClientPageEntry[],\n payload: HydrationDocumentPayloadSource,\n): Promise<ReactNode> {\n const entry = findEntryByName(pages, payload.name);\n const composition = await loadClientRouteComposition(entry);\n const errorPageProps = payload.errorPage;\n const selectedPageModule =\n errorPageProps === undefined ? composition.Page : composition.ErrorPage;\n\n if (selectedPageModule === undefined) {\n throw new MissingHydrationErrorPageError(payload.name);\n }\n\n // Registration is the first lifecycle action after the real namespaces have\n // loaded. Keep server order: root/App, layouts outermost-to-innermost, page.\n // On the error path the selected error module replaces the ordinary Page in\n // that order; registering Page as well would run code the server did not run.\n // Component extraction and React element creation intentionally happen only\n // after every registration hook has completed synchronously.\n registerModules([\n ...(composition.App === undefined ? [] : [composition.App]),\n ...composition.layouts,\n selectedPageModule,\n ]);\n\n const { shared } = payload;\n let element: ReactNode;\n\n if (errorPageProps === undefined) {\n const Page = componentOf<HydratedPageProps>(selectedPageModule);\n element =\n Page === undefined\n ? null\n : createElement(Page, {\n data: payload.pageData,\n shared,\n params: payload.params ?? {},\n });\n } else {\n const ErrorPage = componentOf<SerializedErrorPageProps>(selectedPageModule);\n element = ErrorPage === undefined ? null : createElement(ErrorPage, errorPageProps);\n }\n\n // Innermost layout wraps the page, so walk the outermost-first list backwards.\n for (let index = composition.layouts.length - 1; index >= 0; index -= 1) {\n element = wrap(composition.layouts[index]!, payload.layoutData, shared, element);\n }\n\n return element;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAsCA,SAAS,mBAAmB,gBAA2C;CACrE,IAAI,eAAe,WAAW,GAAG,OAAO;CAExC,OAAO,uBAAuB,eAAe,KAAK,SAAS,KAAK,UAAU,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE;AAC9F;;;;;;;;;;;AAYA,IAAa,gCAAb,cAAmD,MAAM;CAErC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,gBAChB;EACA,MACE,qDAAqD,KAAK,UAAU,QAAQ,EAAE,8CACxC,mBAAmB,cAAc,EAAE,8OAI3E;EATgB;EACA;EAShB,KAAK,OAAO;CACd;AACF;;;;;;;AAQA,IAAa,iCAAb,cAAoD,MAAM;CACrB;CAAnC,AAAO,YAAY,AAAgB,UAAkB;EACnD,MACE,2EACK,KAAK,UAAU,QAAQ,EAAE,0IAEhC;EALiC;EAMjC,KAAK,OAAO;CACd;AACF;AAEA,SAAS,gBAAgB,OAAmC,MAA+B;CACzF,MAAM,QAAQ,MAAM,MAAM,cAAc,UAAU,SAAS,IAAI;CAE/D,IAAI,UAAU,QACZ,MAAM,IAAI,8BACR,MACA,MAAM,KAAK,cAAc,UAAU,IAAI,CACzC;CAGF,OAAO;AACT;;;;;;;;;AAUA,SAAS,YACP,QACkC;CAClC,MAAM,YAAY,OAAO;CAEzB,OAAO,OAAO,cAAc,aAAc,YAAqC;AACjF;AAEA,SAAS,KACP,QACA,MACA,QACA,UACW;CACX,MAAM,YAAY,YAAgC,MAAM;CAExD,IAAI,cAAc,QAAW,OAAO;CAEpC,OAAO,cAAc,WAAW;EAAE;EAAM;EAAQ;CAAS,CAAC;AAC5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,eAAsB,kBACpB,OACA,SACoB;CAEpB,MAAM,cAAc,MAAM,2BADZ,gBAAgB,OAAO,QAAQ,IACY,CAAC;CAC1D,MAAM,iBAAiB,QAAQ;CAC/B,MAAM,qBACJ,mBAAmB,SAAY,YAAY,OAAO,YAAY;CAEhE,IAAI,uBAAuB,QACzB,MAAM,IAAI,+BAA+B,QAAQ,IAAI;CASvD,gBAAgB;EACd,GAAI,YAAY,QAAQ,SAAY,CAAC,IAAI,CAAC,YAAY,GAAG;EACzD,GAAG,YAAY;EACf;CACF,CAAC;CAED,MAAM,EAAE,WAAW;CACnB,IAAI;CAEJ,IAAI,mBAAmB,QAAW;EAChC,MAAM,OAAO,YAA+B,kBAAkB;EAC9D,UACE,SAAS,SACL,OACA,cAAc,MAAM;GAClB,MAAM,QAAQ;GACd;GACA,QAAQ,QAAQ,UAAU,CAAC;EAC7B,CAAC;CACT,OAAO;EACL,MAAM,YAAY,YAAsC,kBAAkB;EAC1E,UAAU,cAAc,SAAY,OAAO,cAAc,WAAW,cAAc;CACpF;CAGA,KAAK,IAAI,QAAQ,YAAY,QAAQ,SAAS,GAAG,SAAS,GAAG,SAAS,GACpE,UAAU,KAAK,YAAY,QAAQ,QAAS,QAAQ,YAAY,QAAQ,OAAO;CAGjF,OAAO;AACT"}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
//#region ../web/src/client/navigation/document-metadata.ts
|
|
2
|
+
function metaTag(attribute, key) {
|
|
3
|
+
return {
|
|
4
|
+
selector: `meta[${attribute}="${key}"]`,
|
|
5
|
+
create: (documentNode) => {
|
|
6
|
+
const element = documentNode.createElement("meta");
|
|
7
|
+
element.setAttribute(attribute, key);
|
|
8
|
+
return element;
|
|
9
|
+
},
|
|
10
|
+
write: (element, value) => element.setAttribute("content", value)
|
|
11
|
+
};
|
|
12
|
+
}
|
|
13
|
+
const TITLE_TAG = {
|
|
14
|
+
selector: "title",
|
|
15
|
+
create: (documentNode) => documentNode.createElement("title"),
|
|
16
|
+
write: (element, value) => {
|
|
17
|
+
element.textContent = value;
|
|
18
|
+
}
|
|
19
|
+
};
|
|
20
|
+
const CANONICAL_TAG = {
|
|
21
|
+
selector: "link[rel=\"canonical\"]",
|
|
22
|
+
create: (documentNode) => {
|
|
23
|
+
const element = documentNode.createElement("link");
|
|
24
|
+
element.setAttribute("rel", "canonical");
|
|
25
|
+
return element;
|
|
26
|
+
},
|
|
27
|
+
write: (element, value) => element.setAttribute("href", value)
|
|
28
|
+
};
|
|
29
|
+
/**
|
|
30
|
+
* The metadata, resolved into (tag, value) pairs in `<Head/>`'s ORDER and by
|
|
31
|
+
* `<Head/>`'s RULES — including the og fallbacks and the fact that they apply
|
|
32
|
+
* only when `openGraph` is present (`components/head.ts:21-24,43-48`).
|
|
33
|
+
*
|
|
34
|
+
* The duplication is deliberate and it is the known cost here. `<Head/>` is a
|
|
35
|
+
* React component that renders elements into a tree; this writes elements into
|
|
36
|
+
* a live `<head>` that no client tree owns. They cannot be one function today,
|
|
37
|
+
* but they MUST agree: the head after navigating to a URL has to equal the head
|
|
38
|
+
* after landing on it, or a share preview depends on how the visitor arrived.
|
|
39
|
+
* The fix is a shared descriptor list both consume — see the report's followup.
|
|
40
|
+
*/
|
|
41
|
+
function resolveManagedTags(metadata) {
|
|
42
|
+
const keywords = metadata?.keywords === void 0 ? void 0 : Array.isArray(metadata.keywords) ? metadata.keywords.join(", ") : metadata.keywords;
|
|
43
|
+
const openGraph = metadata?.openGraph;
|
|
44
|
+
const twitter = metadata?.twitter;
|
|
45
|
+
return [
|
|
46
|
+
[TITLE_TAG, metadata?.title],
|
|
47
|
+
[metaTag("name", "description"), metadata?.description],
|
|
48
|
+
[metaTag("name", "keywords"), keywords],
|
|
49
|
+
[CANONICAL_TAG, metadata?.canonical],
|
|
50
|
+
[metaTag("name", "robots"), metadata?.robots],
|
|
51
|
+
[metaTag("property", "og:title"), openGraph && (openGraph.title ?? metadata?.title)],
|
|
52
|
+
[metaTag("property", "og:description"), openGraph && (openGraph.description ?? metadata?.description)],
|
|
53
|
+
[metaTag("property", "og:image"), openGraph?.image],
|
|
54
|
+
[metaTag("property", "og:url"), openGraph?.url],
|
|
55
|
+
[metaTag("property", "og:type"), openGraph?.type],
|
|
56
|
+
[metaTag("name", "twitter:card"), twitter?.card],
|
|
57
|
+
[metaTag("name", "twitter:title"), twitter?.title],
|
|
58
|
+
[metaTag("name", "twitter:description"), twitter?.description],
|
|
59
|
+
[metaTag("name", "twitter:image"), twitter?.image]
|
|
60
|
+
];
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Make `<head>` describe the page now on screen.
|
|
64
|
+
*
|
|
65
|
+
* ## Why this is imperative, and why that is not a shortcut
|
|
66
|
+
*
|
|
67
|
+
* `<Head/>` renders inside the App level, and the App level is deliberately NOT
|
|
68
|
+
* in the hydrated tree — the client mounts at `#root`, which App contains
|
|
69
|
+
* (`client/build-hydrated-tree.ts`'s header). So no client render can reach
|
|
70
|
+
* `<head>`, and a swap either writes it directly or leaves the previous page's
|
|
71
|
+
* title in the tab. It leaves it today; that is the bug.
|
|
72
|
+
*
|
|
73
|
+
* ## ABSENT MEANS REMOVED
|
|
74
|
+
*
|
|
75
|
+
* Every managed tag the new metadata does not set is REMOVED, not left alone.
|
|
76
|
+
* `/` sets a description and `/contact-us` does not: keeping it would describe
|
|
77
|
+
* the contact page with the home page's words to every crawler, share preview
|
|
78
|
+
* and assistive reader that looks — a wrong answer, where an absent one is
|
|
79
|
+
* merely absent. A title the new page does not set goes too, and the tab falls
|
|
80
|
+
* back to the URL, which is the honest rendering of "this page did not name
|
|
81
|
+
* itself".
|
|
82
|
+
*
|
|
83
|
+
* Only the tags `<Head/>` renders FROM METADATA are touched. The charset meta
|
|
84
|
+
* is rendered unconditionally and belongs to the document, so it is left alone.
|
|
85
|
+
*
|
|
86
|
+
* Takes the document as an argument rather than reaching for the global, which
|
|
87
|
+
* is what makes it provable in a suite with no DOM.
|
|
88
|
+
*/
|
|
89
|
+
function applyDocumentMetadata(documentNode, metadata) {
|
|
90
|
+
for (const [tag, value] of resolveManagedTags(metadata)) {
|
|
91
|
+
const existing = documentNode.querySelector(tag.selector);
|
|
92
|
+
if (value === void 0) {
|
|
93
|
+
existing?.remove();
|
|
94
|
+
continue;
|
|
95
|
+
}
|
|
96
|
+
if (existing !== null) {
|
|
97
|
+
tag.write(existing, value);
|
|
98
|
+
continue;
|
|
99
|
+
}
|
|
100
|
+
const created = tag.create(documentNode);
|
|
101
|
+
tag.write(created, value);
|
|
102
|
+
documentNode.head.appendChild(created);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
//#endregion
|
|
107
|
+
export { applyDocumentMetadata };
|
|
108
|
+
//# sourceMappingURL=document-metadata.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"document-metadata.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/document-metadata.ts"],"sourcesContent":["import type { MetadataOutput } from \"../../metadata\";\n\n/**\n * ONE tag `<head>` may hold at most one of, addressed the way the browser\n * already addresses it. No marker attribute: the tags this replaces were\n * rendered by `<Head/>` on the server and carry none, and a marker would make\n * the applier ignore exactly the tags it exists to correct — the first\n * navigation's.\n */\ntype ManagedTag = {\n /** Finds the existing tag, server-rendered or applied by a previous swap. */\n selector: string;\n create: (documentNode: Document) => Element;\n write: (element: Element, value: string) => void;\n};\n\nfunction metaTag(attribute: \"name\" | \"property\", key: string): ManagedTag {\n return {\n selector: `meta[${attribute}=\"${key}\"]`,\n create: (documentNode) => {\n const element = documentNode.createElement(\"meta\");\n\n element.setAttribute(attribute, key);\n\n return element;\n },\n write: (element, value) => element.setAttribute(\"content\", value),\n };\n}\n\nconst TITLE_TAG: ManagedTag = {\n selector: \"title\",\n create: (documentNode) => documentNode.createElement(\"title\"),\n write: (element, value) => {\n element.textContent = value;\n },\n};\n\nconst CANONICAL_TAG: ManagedTag = {\n selector: 'link[rel=\"canonical\"]',\n create: (documentNode) => {\n const element = documentNode.createElement(\"link\");\n\n element.setAttribute(\"rel\", \"canonical\");\n\n return element;\n },\n write: (element, value) => element.setAttribute(\"href\", value),\n};\n\n/**\n * The metadata, resolved into (tag, value) pairs in `<Head/>`'s ORDER and by\n * `<Head/>`'s RULES — including the og fallbacks and the fact that they apply\n * only when `openGraph` is present (`components/head.ts:21-24,43-48`).\n *\n * The duplication is deliberate and it is the known cost here. `<Head/>` is a\n * React component that renders elements into a tree; this writes elements into\n * a live `<head>` that no client tree owns. They cannot be one function today,\n * but they MUST agree: the head after navigating to a URL has to equal the head\n * after landing on it, or a share preview depends on how the visitor arrived.\n * The fix is a shared descriptor list both consume — see the report's followup.\n */\nfunction resolveManagedTags(\n metadata: MetadataOutput | undefined,\n): readonly (readonly [ManagedTag, string | undefined])[] {\n const keywords =\n metadata?.keywords === undefined\n ? undefined\n : Array.isArray(metadata.keywords)\n ? metadata.keywords.join(\", \")\n : (metadata.keywords as string);\n\n const openGraph = metadata?.openGraph;\n const twitter = metadata?.twitter;\n\n return [\n [TITLE_TAG, metadata?.title],\n [metaTag(\"name\", \"description\"), metadata?.description],\n [metaTag(\"name\", \"keywords\"), keywords],\n [CANONICAL_TAG, metadata?.canonical],\n [metaTag(\"name\", \"robots\"), metadata?.robots],\n [metaTag(\"property\", \"og:title\"), openGraph && (openGraph.title ?? metadata?.title)],\n [\n metaTag(\"property\", \"og:description\"),\n openGraph && (openGraph.description ?? metadata?.description),\n ],\n [metaTag(\"property\", \"og:image\"), openGraph?.image],\n [metaTag(\"property\", \"og:url\"), openGraph?.url],\n [metaTag(\"property\", \"og:type\"), openGraph?.type],\n [metaTag(\"name\", \"twitter:card\"), twitter?.card],\n [metaTag(\"name\", \"twitter:title\"), twitter?.title],\n [metaTag(\"name\", \"twitter:description\"), twitter?.description],\n [metaTag(\"name\", \"twitter:image\"), twitter?.image],\n ];\n}\n\n/**\n * Make `<head>` describe the page now on screen.\n *\n * ## Why this is imperative, and why that is not a shortcut\n *\n * `<Head/>` renders inside the App level, and the App level is deliberately NOT\n * in the hydrated tree — the client mounts at `#root`, which App contains\n * (`client/build-hydrated-tree.ts`'s header). So no client render can reach\n * `<head>`, and a swap either writes it directly or leaves the previous page's\n * title in the tab. It leaves it today; that is the bug.\n *\n * ## ABSENT MEANS REMOVED\n *\n * Every managed tag the new metadata does not set is REMOVED, not left alone.\n * `/` sets a description and `/contact-us` does not: keeping it would describe\n * the contact page with the home page's words to every crawler, share preview\n * and assistive reader that looks — a wrong answer, where an absent one is\n * merely absent. A title the new page does not set goes too, and the tab falls\n * back to the URL, which is the honest rendering of \"this page did not name\n * itself\".\n *\n * Only the tags `<Head/>` renders FROM METADATA are touched. The charset meta\n * is rendered unconditionally and belongs to the document, so it is left alone.\n *\n * Takes the document as an argument rather than reaching for the global, which\n * is what makes it provable in a suite with no DOM.\n */\nexport function applyDocumentMetadata(\n documentNode: Document,\n metadata: MetadataOutput | undefined,\n): void {\n for (const [tag, value] of resolveManagedTags(metadata)) {\n const existing = documentNode.querySelector(tag.selector);\n\n if (value === undefined) {\n existing?.remove();\n continue;\n }\n\n if (existing !== null) {\n tag.write(existing, value);\n continue;\n }\n\n const created = tag.create(documentNode);\n\n tag.write(created, value);\n documentNode.head.appendChild(created);\n }\n}\n"],"mappings":";AAgBA,SAAS,QAAQ,WAAgC,KAAyB;CACxE,OAAO;EACL,UAAU,QAAQ,UAAU,IAAI,IAAI;EACpC,SAAS,iBAAiB;GACxB,MAAM,UAAU,aAAa,cAAc,MAAM;GAEjD,QAAQ,aAAa,WAAW,GAAG;GAEnC,OAAO;EACT;EACA,QAAQ,SAAS,UAAU,QAAQ,aAAa,WAAW,KAAK;CAClE;AACF;AAEA,MAAM,YAAwB;CAC5B,UAAU;CACV,SAAS,iBAAiB,aAAa,cAAc,OAAO;CAC5D,QAAQ,SAAS,UAAU;EACzB,QAAQ,cAAc;CACxB;AACF;AAEA,MAAM,gBAA4B;CAChC,UAAU;CACV,SAAS,iBAAiB;EACxB,MAAM,UAAU,aAAa,cAAc,MAAM;EAEjD,QAAQ,aAAa,OAAO,WAAW;EAEvC,OAAO;CACT;CACA,QAAQ,SAAS,UAAU,QAAQ,aAAa,QAAQ,KAAK;AAC/D;;;;;;;;;;;;;AAcA,SAAS,mBACP,UACwD;CACxD,MAAM,WACJ,UAAU,aAAa,SACnB,SACA,MAAM,QAAQ,SAAS,QAAQ,IAC7B,SAAS,SAAS,KAAK,IAAI,IAC1B,SAAS;CAElB,MAAM,YAAY,UAAU;CAC5B,MAAM,UAAU,UAAU;CAE1B,OAAO;EACL,CAAC,WAAW,UAAU,KAAK;EAC3B,CAAC,QAAQ,QAAQ,aAAa,GAAG,UAAU,WAAW;EACtD,CAAC,QAAQ,QAAQ,UAAU,GAAG,QAAQ;EACtC,CAAC,eAAe,UAAU,SAAS;EACnC,CAAC,QAAQ,QAAQ,QAAQ,GAAG,UAAU,MAAM;EAC5C,CAAC,QAAQ,YAAY,UAAU,GAAG,cAAc,UAAU,SAAS,UAAU,MAAM;EACnF,CACE,QAAQ,YAAY,gBAAgB,GACpC,cAAc,UAAU,eAAe,UAAU,YACnD;EACA,CAAC,QAAQ,YAAY,UAAU,GAAG,WAAW,KAAK;EAClD,CAAC,QAAQ,YAAY,QAAQ,GAAG,WAAW,GAAG;EAC9C,CAAC,QAAQ,YAAY,SAAS,GAAG,WAAW,IAAI;EAChD,CAAC,QAAQ,QAAQ,cAAc,GAAG,SAAS,IAAI;EAC/C,CAAC,QAAQ,QAAQ,eAAe,GAAG,SAAS,KAAK;EACjD,CAAC,QAAQ,QAAQ,qBAAqB,GAAG,SAAS,WAAW;EAC7D,CAAC,QAAQ,QAAQ,eAAe,GAAG,SAAS,KAAK;CACnD;AACF;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,SAAgB,sBACd,cACA,UACM;CACN,KAAK,MAAM,CAAC,KAAK,UAAU,mBAAmB,QAAQ,GAAG;EACvD,MAAM,WAAW,aAAa,cAAc,IAAI,QAAQ;EAExD,IAAI,UAAU,QAAW;GACvB,UAAU,OAAO;GACjB;EACF;EAEA,IAAI,aAAa,MAAM;GACrB,IAAI,MAAM,UAAU,KAAK;GACzB;EACF;EAEA,MAAM,UAAU,IAAI,OAAO,YAAY;EAEvC,IAAI,MAAM,SAAS,KAAK;EACxB,aAAa,KAAK,YAAY,OAAO;CACvC;AACF"}
|