@warlock.js/web 5.5.0 → 5.7.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.
Files changed (153) hide show
  1. package/CHANGELOG.md +53 -5
  2. package/esm/build/contribution.d.mts +2 -2
  3. package/esm/build/contribution.mjs +1 -1
  4. package/esm/build/contribution.mjs.map +1 -1
  5. package/esm/build/discover-pages.mjs +130 -208
  6. package/esm/build/discover-pages.mjs.map +1 -1
  7. package/esm/build/generate-pages-barrel.mjs +7 -5
  8. package/esm/build/generate-pages-barrel.mjs.map +1 -1
  9. package/esm/build/page-routes-manifest.mjs +1 -1
  10. package/esm/build/public-files.mjs +30 -2
  11. package/esm/build/public-files.mjs.map +1 -1
  12. package/esm/build/read-metadata-keys.mjs +225 -0
  13. package/esm/build/read-metadata-keys.mjs.map +1 -0
  14. package/esm/build/read-route-exports.mjs.map +1 -1
  15. package/esm/client/build-hydrated-tree.mjs +1 -1
  16. package/esm/client/build-hydrated-tree.mjs.map +1 -1
  17. package/esm/client/navigation/document-metadata.mjs +108 -0
  18. package/esm/client/navigation/document-metadata.mjs.map +1 -0
  19. package/esm/client/navigation/fetch-page-data.mjs +3 -10
  20. package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
  21. package/esm/client/navigation/navigation-root.mjs +24 -104
  22. package/esm/client/navigation/navigation-root.mjs.map +1 -1
  23. package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -1
  24. package/esm/client/navigation/use-query-string.d.mts +35 -0
  25. package/esm/client/navigation/use-query-string.mjs +68 -0
  26. package/esm/client/navigation/use-query-string.mjs.map +1 -0
  27. package/esm/client/runtime/index.d.mts +1 -1
  28. package/esm/client/runtime/index.mjs +1 -1
  29. package/esm/client/runtime/manifest.mjs.map +1 -1
  30. package/esm/client/runtime/matcher.mjs.map +1 -1
  31. package/esm/client/runtime/types.d.mts +1 -1
  32. package/esm/components/link.mjs.map +1 -1
  33. package/esm/core/src/router/normalize-route-path.mjs +33 -0
  34. package/esm/core/src/router/normalize-route-path.mjs.map +1 -0
  35. package/esm/{hydration → entry}/index.mjs +1 -1
  36. package/esm/entry/index.mjs.map +1 -0
  37. package/esm/hydration-payload.mjs +19 -10
  38. package/esm/hydration-payload.mjs.map +1 -1
  39. package/esm/index.d.mts +3 -2
  40. package/esm/index.mjs +3 -2
  41. package/esm/loaders.d.mts +13 -1
  42. package/esm/localization.mjs.map +1 -1
  43. package/esm/metadata.d.mts +4 -4
  44. package/esm/metadata.mjs.map +1 -1
  45. package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs +33 -0
  46. package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs.map +1 -0
  47. package/esm/{runtime/register-modules.d.mts → register-modules.d.mts} +1 -1
  48. package/esm/{runtime/register-modules.mjs → register-modules.mjs} +1 -1
  49. package/esm/register-modules.mjs.map +1 -0
  50. package/esm/route.d.mts +9 -0
  51. package/esm/routing/duplicate-route-path.mjs +22 -0
  52. package/esm/routing/duplicate-route-path.mjs.map +1 -0
  53. package/esm/routing/layout-level.mjs +50 -0
  54. package/esm/routing/layout-level.mjs.map +1 -0
  55. package/esm/routing/layout-policy.mjs.map +1 -1
  56. package/esm/routing/query-string.d.mts +23 -1
  57. package/esm/routing/query-string.mjs +23 -1
  58. package/esm/routing/query-string.mjs.map +1 -1
  59. package/esm/routing/route-identity.mjs +27 -2
  60. package/esm/routing/route-identity.mjs.map +1 -1
  61. package/esm/routing/route-table.mjs.map +1 -1
  62. package/esm/routing/router-events.d.mts +5 -4
  63. package/esm/routing/router-events.mjs.map +1 -1
  64. package/esm/server/create-page-route-handler.d.mts +2 -2
  65. package/esm/server/create-page-route-handler.mjs +18 -12
  66. package/esm/server/create-page-route-handler.mjs.map +1 -1
  67. package/esm/server/{dev-server.mjs → dev-error-transport.mjs} +2 -2
  68. package/esm/server/dev-error-transport.mjs.map +1 -0
  69. package/esm/server/execute-page-request.mjs +91 -35
  70. package/esm/server/execute-page-request.mjs.map +1 -1
  71. package/esm/server/execute-page-request.types.d.mts +21 -1
  72. package/esm/server/fold-layout-loaders.mjs +36 -0
  73. package/esm/server/fold-layout-loaders.mjs.map +1 -0
  74. package/esm/server/hydration-client-url.mjs +3 -3
  75. package/esm/server/hydration-client-url.mjs.map +1 -1
  76. package/esm/server/index.d.mts +4 -3
  77. package/esm/server/index.mjs +6 -6
  78. package/esm/server/install-page-routes-from-manifest.d.mts +10 -1
  79. package/esm/server/install-page-routes-from-manifest.mjs +61 -58
  80. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
  81. package/esm/server/install-page-routes.d.mts +24 -26
  82. package/esm/server/install-page-routes.mjs +149 -60
  83. package/esm/server/install-page-routes.mjs.map +1 -1
  84. package/esm/server/install-production-page-routes.mjs.map +1 -1
  85. package/esm/server/match-page-route.mjs +6 -10
  86. package/esm/server/match-page-route.mjs.map +1 -1
  87. package/esm/server/not-found-page.d.mts +1 -0
  88. package/esm/server/not-found-page.mjs +8 -1
  89. package/esm/server/not-found-page.mjs.map +1 -1
  90. package/esm/server/page-file-change.mjs +1 -1
  91. package/esm/server/page-file-change.mjs.map +1 -1
  92. package/esm/server/page-module-shapes.d.mts +24 -0
  93. package/esm/server/page-route-reload.mjs +4 -3
  94. package/esm/server/page-route-reload.mjs.map +1 -1
  95. package/esm/server/register-production-public-files.mjs +26 -2
  96. package/esm/server/register-production-public-files.mjs.map +1 -1
  97. package/esm/server/render-page.d.mts +13 -49
  98. package/esm/server/render-page.mjs +14 -55
  99. package/esm/server/render-page.mjs.map +1 -1
  100. package/esm/server/resolve-page-metadata.mjs.map +1 -1
  101. package/esm/server/resolve-route-validation-input.mjs +11 -0
  102. package/esm/server/resolve-route-validation-input.mjs.map +1 -0
  103. package/esm/server/resolve-validation-data.mjs +1 -1
  104. package/esm/server/resolve-validation-data.mjs.map +1 -1
  105. package/esm/server/settle-page-response.mjs +5 -3
  106. package/esm/server/settle-page-response.mjs.map +1 -1
  107. package/esm/server/stylesheet-urls.mjs +78 -19
  108. package/esm/server/stylesheet-urls.mjs.map +1 -1
  109. package/esm/server/unregistered-pages.mjs +2 -1
  110. package/esm/server/unregistered-pages.mjs.map +1 -1
  111. package/esm/server/web-connector-factory.mjs +1 -1
  112. package/esm/server/web-connector-factory.mjs.map +1 -1
  113. package/esm/server/web-connector.mjs +25 -322
  114. package/esm/server/web-connector.mjs.map +1 -1
  115. package/esm/shared/module-key.mjs +15 -0
  116. package/esm/shared/module-key.mjs.map +1 -0
  117. package/esm/shared/to-posix.mjs +13 -0
  118. package/esm/shared/to-posix.mjs.map +1 -0
  119. package/esm/shared.mjs +24 -0
  120. package/esm/shared.mjs.map +1 -1
  121. package/esm/validation.d.mts +12 -1
  122. package/esm/vite/build-client.mjs +1 -1
  123. package/esm/vite/build-client.mjs.map +1 -1
  124. package/esm/vite/dev-server-config.mjs +240 -0
  125. package/esm/vite/dev-server-config.mjs.map +1 -0
  126. package/esm/vite/gate-a-resolve.d.mts +5 -4
  127. package/esm/vite/gate-a-resolve.mjs +32 -71
  128. package/esm/vite/gate-a-resolve.mjs.map +1 -1
  129. package/esm/vite/gate-b-secrets.mjs.map +1 -1
  130. package/esm/vite/gate-c-verify.d.mts +1 -1
  131. package/esm/vite/gate-c-verify.mjs +1 -1
  132. package/esm/vite/gate-c-verify.mjs.map +1 -1
  133. package/esm/vite/hydration-entries.mjs +6 -8
  134. package/esm/vite/hydration-entries.mjs.map +1 -1
  135. package/esm/vite/index.d.mts +1 -1
  136. package/esm/vite/index.mjs +4 -110
  137. package/esm/vite/index.mjs.map +1 -1
  138. package/esm/vite/page-registry-plugin.mjs +9 -26
  139. package/esm/vite/page-registry-plugin.mjs.map +1 -1
  140. package/esm/vite/projection.mjs.map +1 -1
  141. package/esm/vite/react-refresh-preamble.mjs +90 -0
  142. package/esm/vite/react-refresh-preamble.mjs.map +1 -0
  143. package/esm/vite/ssr-client-view.mjs +135 -0
  144. package/esm/vite/ssr-client-view.mjs.map +1 -0
  145. package/llms-full.txt +564 -482
  146. package/llms.txt +2 -2
  147. package/package.json +6 -6
  148. package/skills/create-a-page/SKILL.md +354 -295
  149. package/skills/navigate-on-the-client/SKILL.md +212 -189
  150. package/esm/hydration/index.mjs.map +0 -1
  151. package/esm/runtime/register-modules.mjs.map +0 -1
  152. package/esm/server/dev-server.mjs.map +0 -1
  153. /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":"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"}
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,4 +1,4 @@
1
- import { registerModules } from "../runtime/register-modules.mjs";
1
+ import { registerModules } from "../register-modules.mjs";
2
2
  import { loadClientRouteComposition } from "./runtime/manifest.mjs";
3
3
  import { createElement } from "react";
4
4
 
@@ -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"}
@@ -1,4 +1,5 @@
1
1
  import { DATA_RESPONSE_CONTENT_TYPE, WARLOCK_DATA_REQUEST_HEADER } from "../../routing/data-request.mjs";
2
+ import { isHydrationPayload } from "../../hydration-payload.mjs";
2
3
 
3
4
  //#region ../web/src/client/navigation/fetch-page-data.ts
4
5
  /**
@@ -34,14 +35,6 @@ import { DATA_RESPONSE_CONTENT_TYPE, WARLOCK_DATA_REQUEST_HEADER } from "../../r
34
35
  function isPayloadResponse(response) {
35
36
  return (response.headers.get("content-type") ?? "").includes(DATA_RESPONSE_CONTENT_TYPE);
36
37
  }
37
- /**
38
- * The shape check, kept deliberately narrow: `name` is the only field the tree
39
- * builder cannot proceed without — it selects the page. The data fields are
40
- * page-defined and may legitimately be anything, including `null`.
41
- */
42
- function isPayloadShape(value) {
43
- return typeof value === "object" && value !== null && typeof value.name === "string" && typeof value.locale === "string" && value.locale.length > 0;
44
- }
45
38
  async function fetchPageData(url) {
46
39
  let response;
47
40
  try {
@@ -80,10 +73,10 @@ async function fetchPageData(url) {
80
73
  reason: `malformed JSON: ${String(error)}`
81
74
  };
82
75
  }
83
- if (!isPayloadShape(parsed)) return {
76
+ if (!isHydrationPayload(parsed)) return {
84
77
  type: "hard-navigate",
85
78
  url,
86
- reason: "payload is missing required navigation identity"
79
+ reason: "payload is malformed"
87
80
  };
88
81
  return {
89
82
  type: "payload",
@@ -1 +1 @@
1
- {"version":3,"file":"fetch-page-data.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/fetch-page-data.ts"],"sourcesContent":["/**\n * Ask the server for a URL's page data instead of its document.\n *\n * This is the browser half of the representation split: the same route the user\n * would have loaded, asked for as JSON via the `x-warlock-data` marker. What\n * comes back is exactly the payload a full page load embeds, so the caller can\n * rebuild the tree from it with no second code path.\n *\n * ## Every failure degrades to a REAL navigation, never to an error screen\n *\n * A client navigation is an OPTIMISATION over what the browser already does\n * perfectly well. So nothing here reports a failure to the user — it reports\n * `hard-navigate`, and the caller hands the URL back to the browser. The user\n * gets the page; they just get it the slow way.\n *\n * That is what makes the whole feature safe to add: the worst case of a bug in\n * this file is the behaviour we had before the file existed. Rendering our own\n * \"navigation failed\" state would be strictly worse than the fallback we\n * already have, and would turn every unhandled edge — an auth redirect to an\n * external IdP, a maintenance page, a proxy that strips the header, a deploy\n * that changed the payload shape mid-session — into a dead end.\n */\nimport {\n DATA_RESPONSE_CONTENT_TYPE,\n WARLOCK_DATA_REQUEST_HEADER,\n WARLOCK_DATA_REQUEST_VALUE,\n} from \"../../routing/data-request\";\nimport type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\n\nexport type PageDataResult =\n | {\n type: \"payload\";\n /**\n * The payload to rebuild the tree from.\n */\n payload: HydrationDocumentPayloadSource;\n /**\n * The URL the response actually came from — NOT the one requested. A\n * redirect is followed by `fetch` transparently, so a login-required page\n * answers from `/login`, and pushing the requested URL into history would\n * leave the address bar lying about what is on screen.\n */\n url: string;\n }\n | {\n type: \"hard-navigate\";\n url: string;\n /** Why, for a console warning — never shown to the user. */\n reason: string;\n };\n\n/**\n * Whether the body is the payload we asked for.\n *\n * Checked rather than assumed because a 200 does not mean \"this came from the\n * page pipeline\": a captive portal, an SSO interstitial or a proxy error page\n * all answer 200 with HTML. Parsing that as JSON would throw; treating a\n * successful parse of *something else* as a payload would render garbage.\n */\nfunction isPayloadResponse(response: Response): boolean {\n return (response.headers.get(\"content-type\") ?? \"\").includes(DATA_RESPONSE_CONTENT_TYPE);\n}\n\n/**\n * The shape check, kept deliberately narrow: `name` is the only field the tree\n * builder cannot proceed without — it selects the page. The data fields are\n * page-defined and may legitimately be anything, including `null`.\n */\nfunction isPayloadShape(value: unknown): value is HydrationDocumentPayloadSource {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as { name?: unknown }).name === \"string\" &&\n typeof (value as { locale?: unknown }).locale === \"string\" &&\n (value as { locale: string }).locale.length > 0\n );\n}\n\nexport async function fetchPageData(url: string): Promise<PageDataResult> {\n let response: Response;\n\n try {\n response = await fetch(url, {\n headers: {\n [WARLOCK_DATA_REQUEST_HEADER]: WARLOCK_DATA_REQUEST_VALUE,\n accept: DATA_RESPONSE_CONTENT_TYPE,\n },\n // Same-origin credentials so a navigation carries the session exactly as\n // a document request would. Without this a client navigation could be\n // logged out while a full load of the same URL is not.\n credentials: \"same-origin\",\n // Redirects are FOLLOWED, not intercepted: the marker header is re-sent,\n // so the destination answers with a payload too, and `response.url` tells\n // us where we ended up. Handling redirects ourselves would mean\n // re-implementing the rules the browser already has.\n redirect: \"follow\",\n });\n } catch (error) {\n // Offline, DNS, CORS, an aborted connection. The browser can render its own\n // network error far better than we can fake one.\n return { type: \"hard-navigate\", url, reason: `request failed: ${String(error)}` };\n }\n\n if (!response.ok) {\n // 404, 500, 403 — all of these have a real page the server renders. Letting\n // the browser load it gets the correct status AND the correct document,\n // rather than us inventing a client-side error state that the server's own\n // error page already covers.\n return { type: \"hard-navigate\", url, reason: `status ${response.status}` };\n }\n\n if (!isPayloadResponse(response)) {\n return {\n type: \"hard-navigate\",\n url,\n reason: `unexpected content-type \"${response.headers.get(\"content-type\") ?? \"none\"}\"`,\n };\n }\n\n let parsed: unknown;\n\n try {\n parsed = await response.json();\n } catch (error) {\n return { type: \"hard-navigate\", url, reason: `malformed JSON: ${String(error)}` };\n }\n\n if (!isPayloadShape(parsed)) {\n // Deliberately recover through a full document load: malformed navigation\n // data must not crash the client when the server can still render the URL.\n return {\n type: \"hard-navigate\",\n url,\n reason: \"payload is missing required navigation identity\",\n };\n }\n\n // `response.url` is absolute and reflects any redirect that was followed.\n // Falling back to the requested URL keeps this working under test doubles\n // that do not set it.\n return { type: \"payload\", payload: parsed, url: response.url || url };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2DA,SAAS,kBAAkB,UAA6B;CACtD,QAAQ,SAAS,QAAQ,IAAI,cAAc,KAAK,GAAE,CAAE,SAAS,0BAA0B;AACzF;;;;;;AAOA,SAAS,eAAe,OAAyD;CAC/E,OACE,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAA6B,SAAS,YAC9C,OAAQ,MAA+B,WAAW,YACjD,MAA6B,OAAO,SAAS;AAElD;AAEA,eAAsB,cAAc,KAAsC;CACxE,IAAI;CAEJ,IAAI;EACF,WAAW,MAAM,MAAM,KAAK;GAC1B,SAAS;KACN;IACD,QAAQ;GACV;GAIA,aAAa;GAKb,UAAU;EACZ,CAAC;CACH,SAAS,OAAO;EAGd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,SAAS,IAKZ,OAAO;EAAE,MAAM;EAAiB;EAAK,QAAQ,UAAU,SAAS;CAAS;CAG3E,IAAI,CAAC,kBAAkB,QAAQ,GAC7B,OAAO;EACL,MAAM;EACN;EACA,QAAQ,4BAA4B,SAAS,QAAQ,IAAI,cAAc,KAAK,OAAO;CACrF;CAGF,IAAI;CAEJ,IAAI;EACF,SAAS,MAAM,SAAS,KAAK;CAC/B,SAAS,OAAO;EACd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,eAAe,MAAM,GAGxB,OAAO;EACL,MAAM;EACN;EACA,QAAQ;CACV;CAMF,OAAO;EAAE,MAAM;EAAW,SAAS;EAAQ,KAAK,SAAS,OAAO;CAAI;AACtE"}
1
+ {"version":3,"file":"fetch-page-data.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/fetch-page-data.ts"],"sourcesContent":["/**\n * Ask the server for a URL's page data instead of its document.\n *\n * This is the browser half of the representation split: the same route the user\n * would have loaded, asked for as JSON via the `x-warlock-data` marker. What\n * comes back is exactly the payload a full page load embeds, so the caller can\n * rebuild the tree from it with no second code path.\n *\n * ## Every failure degrades to a REAL navigation, never to an error screen\n *\n * A client navigation is an OPTIMISATION over what the browser already does\n * perfectly well. So nothing here reports a failure to the user — it reports\n * `hard-navigate`, and the caller hands the URL back to the browser. The user\n * gets the page; they just get it the slow way.\n *\n * That is what makes the whole feature safe to add: the worst case of a bug in\n * this file is the behaviour we had before the file existed. Rendering our own\n * \"navigation failed\" state would be strictly worse than the fallback we\n * already have, and would turn every unhandled edge — an auth redirect to an\n * external IdP, a maintenance page, a proxy that strips the header, a deploy\n * that changed the payload shape mid-session — into a dead end.\n */\nimport {\n DATA_RESPONSE_CONTENT_TYPE,\n WARLOCK_DATA_REQUEST_HEADER,\n WARLOCK_DATA_REQUEST_VALUE,\n} from \"../../routing/data-request\";\nimport { isHydrationPayload, type HydrationDocumentPayloadSource } from \"../../hydration-payload\";\n\nexport type PageDataResult =\n | {\n type: \"payload\";\n /**\n * The payload to rebuild the tree from.\n */\n payload: HydrationDocumentPayloadSource;\n /**\n * The URL the response actually came from — NOT the one requested. A\n * redirect is followed by `fetch` transparently, so a login-required page\n * answers from `/login`, and pushing the requested URL into history would\n * leave the address bar lying about what is on screen.\n */\n url: string;\n }\n | {\n type: \"hard-navigate\";\n url: string;\n /** Why, for a console warning — never shown to the user. */\n reason: string;\n };\n\n/**\n * Whether the body is the payload we asked for.\n *\n * Checked rather than assumed because a 200 does not mean \"this came from the\n * page pipeline\": a captive portal, an SSO interstitial or a proxy error page\n * all answer 200 with HTML. Parsing that as JSON would throw; treating a\n * successful parse of *something else* as a payload would render garbage.\n */\nfunction isPayloadResponse(response: Response): boolean {\n return (response.headers.get(\"content-type\") ?? \"\").includes(DATA_RESPONSE_CONTENT_TYPE);\n}\n\nexport async function fetchPageData(url: string): Promise<PageDataResult> {\n let response: Response;\n\n try {\n response = await fetch(url, {\n headers: {\n [WARLOCK_DATA_REQUEST_HEADER]: WARLOCK_DATA_REQUEST_VALUE,\n accept: DATA_RESPONSE_CONTENT_TYPE,\n },\n // Same-origin credentials so a navigation carries the session exactly as\n // a document request would. Without this a client navigation could be\n // logged out while a full load of the same URL is not.\n credentials: \"same-origin\",\n // Redirects are FOLLOWED, not intercepted: the marker header is re-sent,\n // so the destination answers with a payload too, and `response.url` tells\n // us where we ended up. Handling redirects ourselves would mean\n // re-implementing the rules the browser already has.\n redirect: \"follow\",\n });\n } catch (error) {\n // Offline, DNS, CORS, an aborted connection. The browser can render its own\n // network error far better than we can fake one.\n return { type: \"hard-navigate\", url, reason: `request failed: ${String(error)}` };\n }\n\n if (!response.ok) {\n // 404, 500, 403 — all of these have a real page the server renders. Letting\n // the browser load it gets the correct status AND the correct document,\n // rather than us inventing a client-side error state that the server's own\n // error page already covers.\n return { type: \"hard-navigate\", url, reason: `status ${response.status}` };\n }\n\n if (!isPayloadResponse(response)) {\n return {\n type: \"hard-navigate\",\n url,\n reason: `unexpected content-type \"${response.headers.get(\"content-type\") ?? \"none\"}\"`,\n };\n }\n\n let parsed: unknown;\n\n try {\n parsed = await response.json();\n } catch (error) {\n return { type: \"hard-navigate\", url, reason: `malformed JSON: ${String(error)}` };\n }\n\n if (!isHydrationPayload(parsed)) {\n // Deliberately recover through a full document load: malformed navigation\n // data must not crash the client when the server can still render the URL.\n return {\n type: \"hard-navigate\",\n url,\n reason: \"payload is malformed\",\n };\n }\n\n // `response.url` is absolute and reflects any redirect that was followed.\n // Falling back to the requested URL keeps this working under test doubles\n // that do not set it.\n return { type: \"payload\", payload: parsed, url: response.url || url };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2DA,SAAS,kBAAkB,UAA6B;CACtD,QAAQ,SAAS,QAAQ,IAAI,cAAc,KAAK,GAAE,CAAE,SAAS,0BAA0B;AACzF;AAEA,eAAsB,cAAc,KAAsC;CACxE,IAAI;CAEJ,IAAI;EACF,WAAW,MAAM,MAAM,KAAK;GAC1B,SAAS;KACN;IACD,QAAQ;GACV;GAIA,aAAa;GAKb,UAAU;EACZ,CAAC;CACH,SAAS,OAAO;EAGd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,SAAS,IAKZ,OAAO;EAAE,MAAM;EAAiB;EAAK,QAAQ,UAAU,SAAS;CAAS;CAG3E,IAAI,CAAC,kBAAkB,QAAQ,GAC7B,OAAO;EACL,MAAM;EACN;EACA,QAAQ,4BAA4B,SAAS,QAAQ,IAAI,cAAc,KAAK,OAAO;CACrF;CAGF,IAAI;CAEJ,IAAI;EACF,SAAS,MAAM,SAAS,KAAK;CAC/B,SAAS,OAAO;EACd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,mBAAmB,MAAM,GAG5B,OAAO;EACL,MAAM;EACN;EACA,QAAQ;CACV;CAMF,OAAO;EAAE,MAAM;EAAW,SAAS;EAAQ,KAAK,SAAS,OAAO;CAAI;AACtE"}