@warlock.js/web 5.0.2 → 5.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (219) hide show
  1. package/CHANGELOG.md +250 -0
  2. package/README.md +126 -12
  3. package/esm/build/contribution.d.mts +11 -4
  4. package/esm/build/contribution.mjs +46 -22
  5. package/esm/build/contribution.mjs.map +1 -1
  6. package/esm/build/discover-pages.mjs +318 -44
  7. package/esm/build/discover-pages.mjs.map +1 -1
  8. package/esm/build/generate-client-registry.mjs +47 -13
  9. package/esm/build/generate-client-registry.mjs.map +1 -1
  10. package/esm/build/generate-pages-barrel.mjs +55 -13
  11. package/esm/build/generate-pages-barrel.mjs.map +1 -1
  12. package/esm/build/page-default-export.mjs +48 -0
  13. package/esm/build/page-default-export.mjs.map +1 -0
  14. package/esm/build/page-routes-manifest.mjs +21 -0
  15. package/esm/build/page-routes-manifest.mjs.map +1 -0
  16. package/esm/build/public-files.mjs +45 -0
  17. package/esm/build/public-files.mjs.map +1 -0
  18. package/esm/build/read-route-exports.mjs +1 -1
  19. package/esm/build/read-route-exports.mjs.map +1 -1
  20. package/esm/client/build-hydrated-tree.mjs +40 -9
  21. package/esm/client/build-hydrated-tree.mjs.map +1 -1
  22. package/esm/client/hydrate-page.mjs +1 -1
  23. package/esm/client/hydrate-page.mjs.map +1 -1
  24. package/esm/client/navigation/current-route.d.mts +1 -1
  25. package/esm/client/navigation/current-route.mjs +1 -1
  26. package/esm/client/navigation/current-route.mjs.map +1 -1
  27. package/esm/client/navigation/fetch-page-data.mjs +1 -1
  28. package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
  29. package/esm/client/navigation/navigation-root.mjs +44 -7
  30. package/esm/client/navigation/navigation-root.mjs.map +1 -1
  31. package/esm/client/navigation/prefetch.mjs +1 -1
  32. package/esm/client/navigation/prefetch.mjs.map +1 -1
  33. package/esm/client/navigation/refresh.d.mts +1 -1
  34. package/esm/client/navigation/refresh.mjs +1 -1
  35. package/esm/client/navigation/refresh.mjs.map +1 -1
  36. package/esm/client/navigation/scroll-to-fragment.mjs +26 -0
  37. package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -0
  38. package/esm/client/navigation/verbs.d.mts +1 -1
  39. package/esm/client/navigation/verbs.mjs +1 -1
  40. package/esm/client/navigation/verbs.mjs.map +1 -1
  41. package/esm/client/runtime/index.d.mts +2 -1
  42. package/esm/client/runtime/index.mjs +2 -1
  43. package/esm/client/runtime/manifest.d.mts +1 -1
  44. package/esm/client/runtime/manifest.mjs +3 -2
  45. package/esm/client/runtime/manifest.mjs.map +1 -1
  46. package/esm/client/runtime/matcher.d.mts +13 -1
  47. package/esm/client/runtime/matcher.mjs +13 -1
  48. package/esm/client/runtime/matcher.mjs.map +1 -1
  49. package/esm/client/runtime/types.d.mts +15 -2
  50. package/esm/components/default-app.mjs +1 -1
  51. package/esm/components/default-app.mjs.map +1 -1
  52. package/esm/components/document-context.d.mts +40 -2
  53. package/esm/components/document-context.mjs +1 -1
  54. package/esm/components/document-context.mjs.map +1 -1
  55. package/esm/components/head.d.mts +1 -1
  56. package/esm/components/head.mjs +1 -1
  57. package/esm/components/head.mjs.map +1 -1
  58. package/esm/components/link.d.mts +1 -1
  59. package/esm/components/link.mjs +1 -1
  60. package/esm/components/link.mjs.map +1 -1
  61. package/esm/components/scripts.d.mts +1 -1
  62. package/esm/components/scripts.mjs +4 -2
  63. package/esm/components/scripts.mjs.map +1 -1
  64. package/esm/context.d.mts +6 -102
  65. package/esm/hydration/index.mjs +1 -1
  66. package/esm/hydration/index.mjs.map +1 -1
  67. package/esm/hydration-payload.d.mts +1 -1
  68. package/esm/hydration-payload.mjs +37 -8
  69. package/esm/hydration-payload.mjs.map +1 -1
  70. package/esm/index.d.mts +4 -3
  71. package/esm/loaders.d.mts +7 -36
  72. package/esm/metadata.d.mts +15 -1
  73. package/esm/metadata.mjs +45 -0
  74. package/esm/metadata.mjs.map +1 -0
  75. package/esm/props.d.mts +16 -8
  76. package/esm/route.d.mts +2 -17
  77. package/esm/routing/compose-route-path.d.mts +1 -1
  78. package/esm/routing/compose-route-path.mjs +1 -1
  79. package/esm/routing/compose-route-path.mjs.map +1 -1
  80. package/esm/routing/data-request.mjs +1 -1
  81. package/esm/routing/data-request.mjs.map +1 -1
  82. package/esm/routing/filesystem-route.mjs +45 -0
  83. package/esm/routing/filesystem-route.mjs.map +1 -0
  84. package/esm/routing/layout-policy.mjs +1 -1
  85. package/esm/routing/layout-policy.mjs.map +1 -1
  86. package/esm/routing/navigator.mjs +1 -1
  87. package/esm/routing/navigator.mjs.map +1 -1
  88. package/esm/routing/query-string.d.mts +1 -1
  89. package/esm/routing/query-string.mjs +1 -1
  90. package/esm/routing/query-string.mjs.map +1 -1
  91. package/esm/routing/route-identity.mjs +1 -1
  92. package/esm/routing/route-identity.mjs.map +1 -1
  93. package/esm/routing/route-table.d.mts +1 -1
  94. package/esm/routing/route-table.mjs +1 -1
  95. package/esm/routing/route-table.mjs.map +1 -1
  96. package/esm/routing/router-events.d.mts +1 -1
  97. package/esm/routing/router-events.mjs +1 -1
  98. package/esm/routing/router-events.mjs.map +1 -1
  99. package/esm/routing/url-fragment.mjs +120 -0
  100. package/esm/routing/url-fragment.mjs.map +1 -0
  101. package/esm/runtime/register-modules.d.mts +19 -0
  102. package/esm/runtime/register-modules.mjs +22 -0
  103. package/esm/runtime/register-modules.mjs.map +1 -0
  104. package/esm/server/build-hydration-payload.mjs +2 -1
  105. package/esm/server/build-hydration-payload.mjs.map +1 -1
  106. package/esm/server/client-asset-url-prefix.d.mts +1 -1
  107. package/esm/server/client-asset-url-prefix.mjs +1 -1
  108. package/esm/server/client-asset-url-prefix.mjs.map +1 -1
  109. package/esm/server/create-page-module-loader.d.mts +1 -1
  110. package/esm/server/create-page-module-loader.mjs +2 -1
  111. package/esm/server/create-page-module-loader.mjs.map +1 -1
  112. package/esm/server/create-page-route-handler.d.mts +58 -6
  113. package/esm/server/create-page-route-handler.mjs +127 -40
  114. package/esm/server/create-page-route-handler.mjs.map +1 -1
  115. package/esm/server/dev-server.mjs +2 -13
  116. package/esm/server/dev-server.mjs.map +1 -1
  117. package/esm/server/error-page.d.mts +14 -0
  118. package/esm/server/error-page.mjs +39 -0
  119. package/esm/server/error-page.mjs.map +1 -0
  120. package/esm/server/execute-page-request.d.mts +7 -4
  121. package/esm/server/execute-page-request.mjs +79 -80
  122. package/esm/server/execute-page-request.mjs.map +1 -1
  123. package/esm/server/execute-page-request.types.d.mts +33 -92
  124. package/esm/server/hydration-client-url.d.mts +1 -1
  125. package/esm/server/hydration-client-url.mjs +2 -2
  126. package/esm/server/hydration-client-url.mjs.map +1 -1
  127. package/esm/server/index.d.mts +6 -5
  128. package/esm/server/index.mjs +4 -4
  129. package/esm/server/install-page-routes-from-manifest.d.mts +17 -7
  130. package/esm/server/install-page-routes-from-manifest.mjs +91 -8
  131. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
  132. package/esm/server/install-page-routes.d.mts +32 -13
  133. package/esm/server/install-page-routes.mjs +112 -32
  134. package/esm/server/install-page-routes.mjs.map +1 -1
  135. package/esm/server/install-production-page-routes.mjs +3 -31
  136. package/esm/server/install-production-page-routes.mjs.map +1 -1
  137. package/esm/server/match-page-route.mjs +2 -2
  138. package/esm/server/match-page-route.mjs.map +1 -1
  139. package/esm/server/not-found-page.d.mts +126 -0
  140. package/esm/server/not-found-page.mjs +157 -0
  141. package/esm/server/not-found-page.mjs.map +1 -0
  142. package/esm/server/page-context.d.mts +3 -9
  143. package/esm/server/page-context.mjs +3 -16
  144. package/esm/server/page-context.mjs.map +1 -1
  145. package/esm/server/page-file-change.mjs +77 -0
  146. package/esm/server/page-file-change.mjs.map +1 -0
  147. package/esm/server/page-manifest.d.mts +25 -10
  148. package/esm/server/page-manifest.mjs +1 -1
  149. package/esm/server/page-manifest.mjs.map +1 -1
  150. package/esm/server/page-render-bundle.mjs +38 -0
  151. package/esm/server/page-render-bundle.mjs.map +1 -0
  152. package/esm/server/page-route-reload.mjs +54 -0
  153. package/esm/server/page-route-reload.mjs.map +1 -0
  154. package/esm/server/register-production-public-files.mjs +40 -0
  155. package/esm/server/register-production-public-files.mjs.map +1 -0
  156. package/esm/server/render-page.d.mts +11 -14
  157. package/esm/server/render-page.mjs +154 -28
  158. package/esm/server/render-page.mjs.map +1 -1
  159. package/esm/server/resolve-page-metadata.mjs +2 -2
  160. package/esm/server/resolve-page-metadata.mjs.map +1 -1
  161. package/esm/server/resolve-validation-data.mjs +1 -1
  162. package/esm/server/resolve-validation-data.mjs.map +1 -1
  163. package/esm/server/settle-page-response.d.mts +58 -1
  164. package/esm/server/settle-page-response.mjs +111 -46
  165. package/esm/server/settle-page-response.mjs.map +1 -1
  166. package/esm/server/stylesheet-urls.d.mts +41 -30
  167. package/esm/server/stylesheet-urls.mjs +137 -52
  168. package/esm/server/stylesheet-urls.mjs.map +1 -1
  169. package/esm/server/unregistered-pages.mjs +42 -0
  170. package/esm/server/unregistered-pages.mjs.map +1 -0
  171. package/esm/server/web-connector-factory.d.mts +2 -3
  172. package/esm/server/web-connector-factory.mjs +6 -4
  173. package/esm/server/web-connector-factory.mjs.map +1 -1
  174. package/esm/server/web-connector.d.mts +1 -1
  175. package/esm/server/web-connector.mjs +233 -18
  176. package/esm/server/web-connector.mjs.map +1 -1
  177. package/esm/shared.d.mts +18 -3
  178. package/esm/shared.mjs +3 -7
  179. package/esm/shared.mjs.map +1 -1
  180. package/esm/validation.d.mts +1 -1
  181. package/esm/vite/app-convention-aliases.mjs +1 -1
  182. package/esm/vite/app-convention-aliases.mjs.map +1 -1
  183. package/esm/vite/build-client.d.mts +1 -1
  184. package/esm/vite/build-client.mjs +2 -2
  185. package/esm/vite/build-client.mjs.map +1 -1
  186. package/esm/vite/gate-a-resolve.d.mts +1 -1
  187. package/esm/vite/gate-a-resolve.mjs +67 -13
  188. package/esm/vite/gate-a-resolve.mjs.map +1 -1
  189. package/esm/vite/gate-b-secrets.d.mts +1 -1
  190. package/esm/vite/gate-b-secrets.mjs +35 -17
  191. package/esm/vite/gate-b-secrets.mjs.map +1 -1
  192. package/esm/vite/gate-c-verify.d.mts +1 -1
  193. package/esm/vite/gate-c-verify.mjs +6 -6
  194. package/esm/vite/gate-c-verify.mjs.map +1 -1
  195. package/esm/vite/hydration-entries.d.mts +1 -1
  196. package/esm/vite/hydration-entries.mjs +10 -6
  197. package/esm/vite/hydration-entries.mjs.map +1 -1
  198. package/esm/vite/index.d.mts +9 -6
  199. package/esm/vite/index.mjs +124 -11
  200. package/esm/vite/index.mjs.map +1 -1
  201. package/esm/vite/page-registry-plugin.d.mts +22 -3
  202. package/esm/vite/page-registry-plugin.mjs +247 -2
  203. package/esm/vite/page-registry-plugin.mjs.map +1 -1
  204. package/esm/vite/projection.d.mts +2 -2
  205. package/esm/vite/projection.mjs +38 -15
  206. package/esm/vite/projection.mjs.map +1 -1
  207. package/llms-full.txt +241 -49
  208. package/llms.txt +6 -2
  209. package/package.json +3 -3
  210. package/skills/add-web-to-an-app/SKILL.md +3 -3
  211. package/skills/create-a-page/SKILL.md +130 -7
  212. package/skills/load-page-data/SKILL.md +92 -12
  213. package/skills/navigate-on-the-client/SKILL.md +3 -1
  214. package/skills/serve-styles/SKILL.md +13 -12
  215. package/skills/use-layouts/SKILL.md +20 -8
  216. package/skills/write-the-root/SKILL.md +5 -1
  217. package/esm/server/buffered-response.d.mts +0 -58
  218. package/esm/server/buffered-response.mjs +0 -115
  219. package/esm/server/buffered-response.mjs.map +0 -1
@@ -1,12 +1,16 @@
1
1
  import { composeRoutePath } from "../routing/compose-route-path.mjs";
2
+ import { deriveFilesystemRouteName, deriveFilesystemRoutePath } from "../routing/filesystem-route.mjs";
2
3
  import { NestedLayoutsNotSupportedError, selectPageLayout } from "../routing/layout-policy.mjs";
3
4
  import { deriveFallbackRouteName } from "../routing/route-identity.mjs";
5
+ import { NOT_FOUND_ROUTE_NAME, NOT_FOUND_ROUTE_PATH, NotFoundPageDeclaresRouteError, isNotFoundPageFile } from "../server/not-found-page.mjs";
6
+ import { METADATA_KEYS, OPEN_GRAPH_KEYS, TWITTER_KEYS } from "../metadata.mjs";
7
+ import { assertPageHasDefaultExport } from "./page-default-export.mjs";
4
8
  import { NonLiteralRouteExportError, readRouteExports } from "./read-route-exports.mjs";
5
- import path from "node:path";
6
9
  import fs from "node:fs";
10
+ import path from "node:path";
7
11
  import { parse } from "@babel/parser";
8
12
 
9
- //#region ../web/src/build/discover-pages.ts
13
+ //#region web/src/build/discover-pages.ts
10
14
  /**
11
15
  * Static discovery of the application's page graph — the ONE scan every
12
16
  * provider shares.
@@ -29,20 +33,40 @@ import { parse } from "@babel/parser";
29
33
  * `route` and each layout's declared `prefix` by PARSING the source
30
34
  * ({@link readRouteExports}). It still imports no application module.
31
35
  *
32
- * The route a page is served under is the one the page DECLARES, not the one
33
- * its directory suggests, so that is the route discovery reports. The
34
- * composition EVERY layout `prefix` on the page's path, outermost first, plus
35
- * the page's own `route` path and the fallback used when a route omits its
36
- * name are mirrored from the server's installer (`installPageRoutes`),
37
- * deliberately and in one direction: build and boot agree because one of them
38
- * copies the other, not because two conventions were written to match.
36
+ * An explicit `route` export wins. Otherwise the URL is derived from the page's
37
+ * path beneath `src/web`: directories contribute segments, `(groups)` do not,
38
+ * `index.page.tsx` claims its directory, and `[id]` becomes `:id`. A layout
39
+ * `prefix` replaces its own directory segment. The same recipe is emitted to
40
+ * every provider so build and boot cannot drift.
39
41
  *
40
42
  * Discovery also CLASSIFIES each layout — does its module have a default
41
43
  * export, does it export `middleware` — because the layout policy
42
44
  * ({@link "../routing/layout-policy.ts"}) owns the rule but may not touch a
43
45
  * filesystem to learn the facts the rule needs. That classification is another
44
46
  * parse, never an import: a layout is read exactly the way a page's `route` is.
47
+ *
48
+ * And it CHECKS the page's `metadata` keys ({@link UnknownMetadataKeyError}) —
49
+ * see that error and {@link readMetadataKeys} for why a type alone does not
50
+ * close that hole.
45
51
  */
52
+ function isDiscoveredRoutablePage(page) {
53
+ return page.type === "page";
54
+ }
55
+ function isErrorPageFile(file) {
56
+ return path.basename(file) === "error.page.tsx";
57
+ }
58
+ var DuplicateErrorPageError = class extends Error {
59
+ constructor(firstFile, secondFile) {
60
+ super(`Two error pages were found: "${firstFile}" and "${secondFile}". An application may own exactly one \`error.page.tsx\` anywhere beneath src/web.`);
61
+ this.name = "DuplicateErrorPageError";
62
+ }
63
+ };
64
+ var ErrorPageDeclaresRouteError = class extends Error {
65
+ constructor(pageFile) {
66
+ super(`The error page "${pageFile}" exports \`route\`. error.page.tsx is an error boundary, not a browsable page; remove the route export.`);
67
+ this.name = "ErrorPageDeclaresRouteError";
68
+ }
69
+ };
46
70
  /** Raised when two pages claim one route name. */
47
71
  var DuplicatePageRouteNameError = class extends Error {
48
72
  routeName;
@@ -56,6 +80,19 @@ var DuplicatePageRouteNameError = class extends Error {
56
80
  this.name = "DuplicatePageRouteNameError";
57
81
  }
58
82
  };
83
+ /** Raised when two different page files resolve to one effective URL. */
84
+ var DuplicatePageRoutePathError = class extends Error {
85
+ routePath;
86
+ firstFile;
87
+ secondFile;
88
+ constructor(routePath, firstFile, secondFile) {
89
+ super(`Two pages resolve to the same route path "${routePath}": "${firstFile}" and "${secondFile}". Each URL may identify exactly one page. Rename or move one file, or give one page an explicit route with a different path.`);
90
+ this.routePath = routePath;
91
+ this.firstFile = firstFile;
92
+ this.secondFile = secondFile;
93
+ this.name = "DuplicatePageRoutePathError";
94
+ }
95
+ };
59
96
  function toPosix(value) {
60
97
  return value.replace(/\\/g, "/");
61
98
  }
@@ -74,27 +111,18 @@ function isFile(candidate) {
74
111
  }
75
112
  }
76
113
  /**
77
- * The two page roots: the global `src/web/**` tree and each
78
- * module's `src/app/<module>/web/**` tree. Both are optional; a project with
79
- * neither has zero pages, which is a legal empty state.
114
+ * The page root. It is optional; a project without it has zero pages, which is
115
+ * a legal empty state.
80
116
  */
81
117
  function discoverWebRoots(srcRoot) {
82
- const roots = [];
83
- const globalRoot = path.join(srcRoot, "web");
84
- if (isDirectory(globalRoot)) roots.push(globalRoot);
85
- const appDir = path.join(srcRoot, "app");
86
- if (isDirectory(appDir)) for (const entry of fs.readdirSync(appDir, { withFileTypes: true }).sort(byName)) {
87
- if (!entry.isDirectory()) continue;
88
- const moduleWebRoot = path.join(appDir, entry.name, "web");
89
- if (isDirectory(moduleWebRoot)) roots.push(moduleWebRoot);
90
- }
91
- return roots;
118
+ const webRoot = path.join(srcRoot, "web");
119
+ return isDirectory(webRoot) ? [webRoot] : [];
92
120
  }
93
121
  function byName(left, right) {
94
122
  return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;
95
123
  }
96
124
  /**
97
- * The subject list: every `*.page.tsx` under BOTH web roots, one call for the
125
+ * The subject list: every `*.page.tsx` under the page root, one call for the
98
126
  * whole graph.
99
127
  *
100
128
  * Unlike {@link discoverPages}, this reads no `route` or `prefix` export and
@@ -179,26 +207,238 @@ function assertUniqueRouteNames(pages, appRoot) {
179
207
  fileByRouteName.set(page.routeName, relative);
180
208
  }
181
209
  }
182
- /** Raised when a `*.page.tsx` declares no `route` export. */
183
- var MissingRouteExportError = class extends Error {
210
+ /**
211
+ * A page's declared `route.path` is a choice its author made deliberately, so
212
+ * two pages that both declare the same path are the author's call, not an
213
+ * accident — {@link assertUniqueRouteNames} is what keeps each of them
214
+ * reachable by name. What this refuses is a COLLISION NOBODY CHOSE: at least
215
+ * one side's path is filesystem-derived, which is exactly the case a rename
216
+ * or a new file can produce without anyone noticing two pages now answer the
217
+ * same URL. The reserved not-found path (`"*"`) is never a candidate here
218
+ * either way — it identifies no browsable URL, and {@link assertUniqueRouteNames}
219
+ * already refuses a second `404.page.tsx` by its own reserved name.
220
+ */
221
+ function assertUniqueRoutePaths(pages, appRoot, explicitFiles) {
222
+ const fileByRoutePath = /* @__PURE__ */ new Map();
223
+ for (const page of pages) {
224
+ if (page.routePath === "*") continue;
225
+ const existing = fileByRoutePath.get(page.routePath);
226
+ const relative = toPosix(path.relative(appRoot, page.pageFile));
227
+ if (existing !== void 0 && !(explicitFiles.has(existing) && explicitFiles.has(relative))) throw new DuplicatePageRoutePathError(page.routePath, existing, relative);
228
+ if (existing === void 0) fileByRoutePath.set(page.routePath, relative);
229
+ }
230
+ }
231
+ /**
232
+ * Raised when a page's `metadata` export declares a key nothing reads.
233
+ *
234
+ * THE POINT OF THIS ERROR IS THE UNANNOTATED CASE. A page that writes
235
+ * `export const metadata: PageMetadata = { tittle: "x" }` is already refused by
236
+ * TypeScript, and if that were the whole story this class would not need to
237
+ * exist. But the annotation is optional, nobody writes it, and
238
+ * `export const metadata = { tittle: "x" }` is a perfectly well-typed program:
239
+ * the compiler infers `{ tittle: string }`, has nothing to check it against, and
240
+ * says nothing. The page is then served with no `<title>` — not a wrong title, a
241
+ * missing one — and no error is raised anywhere, at build or at runtime, ever.
242
+ *
243
+ * So the check lives HERE instead, at the gate every page already passes
244
+ * through: a page that silently omits requested metadata is worse than a build
245
+ * that stops and says which line to fix.
246
+ *
247
+ * The alternative considered and rejected was a `defineMetadata({...})` wrapper,
248
+ * which would infer the type for free. It also puts framework ceremony in every
249
+ * page, and a page is meant to be two lines of framework surface (canon
250
+ * `6ea0662f`). The gate gets the same safety without spending that.
251
+ */
252
+ var UnknownMetadataKeyError = class extends Error {
184
253
  pageFile;
185
- constructor(pageFile) {
186
- super(`"${pageFile}" is a page file but declares no \`route\` export. A page with no route is a page the dev server would still serve and production would 404 on, so the build refuses it instead. For example: export const route = "/list";`);
254
+ unknownKeys;
255
+ constructor(pageFile, unknownKeys) {
256
+ const findings = unknownKeys.map(({ container, key, line, suggestion }) => {
257
+ const where = `line ${line}: \`${container}.${key}\` — no such key.`;
258
+ return suggestion === void 0 ? where : `${where} Did you mean \`${suggestion}\`?`;
259
+ }).join("\n ");
260
+ 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.
261
+ 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.`);
187
262
  this.pageFile = pageFile;
188
- this.name = "MissingRouteExportError";
263
+ this.unknownKeys = unknownKeys;
264
+ this.name = "UnknownMetadataKeyError";
189
265
  }
190
266
  };
267
+ /** `as const`, `satisfies`, `!` and parentheses wrap a value without changing it. */
268
+ function unwrapValue(node) {
269
+ switch (node.type) {
270
+ case "TSAsExpression":
271
+ case "TSSatisfiesExpression":
272
+ case "TSNonNullExpression":
273
+ case "TypeCastExpression":
274
+ case "ParenthesizedExpression": return unwrapValue(node.expression);
275
+ default: return node;
276
+ }
277
+ }
278
+ /** Levenshtein distance — small strings, so the plain two-row table is the whole cost. */
279
+ function editDistance(left, right) {
280
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
281
+ for (let row = 1; row <= left.length; row++) {
282
+ const current = [row];
283
+ for (let column = 1; column <= right.length; column++) {
284
+ const substitution = previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1);
285
+ current[column] = Math.min(substitution, previous[column] + 1, current[column - 1] + 1);
286
+ }
287
+ previous = current;
288
+ }
289
+ return previous[right.length];
290
+ }
291
+ /**
292
+ * The known key the written one was probably meant to be.
293
+ *
294
+ * Two edits, because that covers the typos this exists for — `tittle`,
295
+ * `descriptoin`, `keywrods` — without reaching so far that `image` gets
296
+ * suggested for `alt`. Case is ignored first, so `Title` resolves exactly.
297
+ */
298
+ function suggestKey(written, known) {
299
+ const lowered = written.toLowerCase();
300
+ const sameLetters = known.find((candidate) => candidate.toLowerCase() === lowered);
301
+ if (sameLetters !== void 0) return sameLetters;
302
+ let best;
303
+ let bestDistance = Number.POSITIVE_INFINITY;
304
+ for (const candidate of known) {
305
+ const distance = editDistance(lowered, candidate.toLowerCase());
306
+ if (distance < bestDistance) {
307
+ best = candidate;
308
+ bestDistance = distance;
309
+ }
310
+ }
311
+ return bestDistance <= 2 ? best : void 0;
312
+ }
313
+ /** The name an object key denotes, or `undefined` when knowing it needs evaluation. */
314
+ function propertyKeyName(property) {
315
+ if (property.computed) return void 0;
316
+ const { key } = property;
317
+ if (key.type === "Identifier") return key.name;
318
+ if (key.type === "StringLiteral") return key.value;
319
+ }
320
+ /** The nested objects that carry a key set of their own. */
321
+ const NESTED_METADATA_KEYS = {
322
+ openGraph: OPEN_GRAPH_KEYS,
323
+ twitter: TWITTER_KEYS
324
+ };
325
+ /**
326
+ * Every unknown key in one metadata object literal, and in the `openGraph` /
327
+ * `twitter` literals inside it.
328
+ *
329
+ * A SPREAD does not suppress the check, unlike the route reader's rule: a
330
+ * spread can only ADD keys, and no value it contributes can make a key written
331
+ * out beside it correct. A COMPUTED key is skipped — its name is not knowable
332
+ * without running the page, and refusing what cannot be read would fail builds
333
+ * that are fine. Both are silence in the narrow places where the parse genuinely
334
+ * does not know, and the annotation is the second net there.
335
+ */
336
+ function collectUnknownKeys(object, allowed, container, into) {
337
+ for (const property of object.properties) {
338
+ if (property.type === "SpreadElement") continue;
339
+ const key = propertyKeyName(property);
340
+ if (key === void 0) continue;
341
+ if (!allowed.includes(key)) {
342
+ const suggestion = suggestKey(key, allowed);
343
+ into.push({
344
+ container,
345
+ key,
346
+ line: property.loc?.start.line ?? 0,
347
+ ...suggestion === void 0 ? {} : { suggestion }
348
+ });
349
+ continue;
350
+ }
351
+ const nested = container === "metadata" ? NESTED_METADATA_KEYS[key] : void 0;
352
+ if (nested === void 0 || property.type !== "ObjectProperty") continue;
353
+ const value = unwrapValue(property.value);
354
+ if (value.type === "ObjectExpression") collectUnknownKeys(value, nested, `${container}.${key}`, into);
355
+ }
356
+ }
357
+ /**
358
+ * Every object literal a function form RETURNS, without descending into
359
+ * functions nested inside it — a callback's return value is not the metadata.
360
+ *
361
+ * A generic walk rather than a statement-by-statement one because a `return` is
362
+ * legal anywhere a statement is: inside an `if`, a `switch`, a `try`. Enumerating
363
+ * the statement types that may contain one is a list that is wrong the moment
364
+ * the language grows.
365
+ */
366
+ function collectReturnedObjects(node, into) {
367
+ if (node === null || typeof node !== "object") return;
368
+ if (Array.isArray(node)) {
369
+ for (const item of node) collectReturnedObjects(item, into);
370
+ return;
371
+ }
372
+ const candidate = node;
373
+ if (candidate.type === "FunctionDeclaration" || candidate.type === "FunctionExpression" || candidate.type === "ArrowFunctionExpression" || candidate.type === "ObjectMethod" || candidate.type === "ClassMethod") return;
374
+ if (candidate.type === "ReturnStatement") {
375
+ if (candidate.argument === null || candidate.argument === void 0) return;
376
+ const returned = unwrapValue(candidate.argument);
377
+ if (returned.type === "ObjectExpression") into.push(returned);
378
+ return;
379
+ }
380
+ for (const value of Object.values(node)) collectReturnedObjects(value, into);
381
+ }
382
+ /** The metadata object literals one `metadata` export declares, if any can be seen at all. */
383
+ function metadataObjectsOf(init) {
384
+ const value = unwrapValue(init);
385
+ if (value.type === "ObjectExpression") return [value];
386
+ if (value.type === "ArrowFunctionExpression" || value.type === "FunctionExpression") {
387
+ const body = unwrapValue(value.body);
388
+ if (body.type === "ObjectExpression") return [body];
389
+ const returned = [];
390
+ collectReturnedObjects(value.body, returned);
391
+ return returned;
392
+ }
393
+ return [];
394
+ }
395
+ /**
396
+ * The unknown keys a page's `metadata` export declares, read by PARSING — the
397
+ * same rule the rest of this module lives by, and the reason this check can run
398
+ * before anything is built.
399
+ *
400
+ * Empty for a page with no `metadata` export, for one whose metadata is a value
401
+ * this file cannot see into, and for a correct one.
402
+ */
403
+ function readMetadataKeys(pageFile, source) {
404
+ let program;
405
+ try {
406
+ program = parse(source, {
407
+ sourceType: "module",
408
+ plugins: ["typescript", "jsx"],
409
+ errorRecovery: false
410
+ }).program;
411
+ } catch (error) {
412
+ 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.`);
413
+ }
414
+ const unknownKeys = [];
415
+ for (const statement of program.body) {
416
+ if (statement.type !== "ExportNamedDeclaration" || statement.exportKind === "type") continue;
417
+ const { declaration } = statement;
418
+ if (declaration?.type !== "VariableDeclaration") continue;
419
+ for (const declarator of declaration.declarations) {
420
+ if (declarator.id.type !== "Identifier" || declarator.id.name !== "metadata") continue;
421
+ if (declarator.init === null || declarator.init === void 0) continue;
422
+ for (const object of metadataObjectsOf(declarator.init)) collectUnknownKeys(object, METADATA_KEYS, "metadata", unknownKeys);
423
+ }
424
+ }
425
+ return unknownKeys;
426
+ }
191
427
  /**
192
428
  * The declared exports of one file, or a thrown
193
429
  * {@link NonLiteralRouteExportError} when they cannot be read without running
194
430
  * the application. Layouts are read once per run and remembered: a layout is
195
431
  * the nearest one for every page beside it, and parsing it once per page would
196
432
  * be the same answer bought repeatedly.
433
+ *
434
+ * `source` is the file's text when the caller already holds it — the page loop
435
+ * reads each page once and spends that read on both the route declarations and
436
+ * the metadata check, rather than opening the same file twice.
197
437
  */
198
- function readDeclarations(sourceFile, cache) {
438
+ function readDeclarations(sourceFile, cache, source) {
199
439
  let result = cache.get(sourceFile);
200
440
  if (result === void 0) {
201
- result = readRouteExports(sourceFile);
441
+ result = readRouteExports(sourceFile, source);
202
442
  cache.set(sourceFile, result);
203
443
  }
204
444
  if (!result.ok) throw new NonLiteralRouteExportError(result.rejection);
@@ -254,17 +494,18 @@ function readLayoutShape(layoutFile, cache) {
254
494
  return shape;
255
495
  }
256
496
  /**
257
- * Scans both web roots and returns the pages in a defined total order.
497
+ * Scans the page root and returns the pages in a defined total order.
258
498
  *
259
499
  * Zero pages is a legal result, not an error: a project may be configured
260
500
  * with web and have nothing to serve yet. What is an error is a `*.page.tsx`
261
- * with no `route` export, which is refused rather than silently omitted an
262
- * artefact that leaves a page out is a page the dev server still serves and
263
- * production 404s on; two pages claiming one route name, which this refuses
501
+ * with a route or metadata export that cannot be read statically; two pages
502
+ * claiming one effective path or route name, which this refuses
264
503
  * to return at all — the alternative is an artefact in which one of them is
265
504
  * silently unreachable; a `route` or `prefix` that cannot be read without
266
505
  * running the application, which is refused before any page is reported at
267
- * all; and a page whose layout chain holds more than one RENDERING layout, which
506
+ * all; a `metadata` export declaring a key nothing reads, which no compiler
507
+ * catches unless the page opted into the type and which otherwise serves a page
508
+ * with a silently missing tag; and a page whose layout chain holds more than one RENDERING layout, which
268
509
  * the production installer would refuse anyway — discovery refuses it first so
269
510
  * that artefact is never produced.
270
511
  */
@@ -278,9 +519,27 @@ function discoverPages(options) {
278
519
  const layoutShapes = /* @__PURE__ */ new Map();
279
520
  const relativeToApp = (file) => toPosix(path.relative(appRoot, file));
280
521
  const pages = [];
522
+ const explicitRouteFiles = /* @__PURE__ */ new Set();
523
+ let errorPage;
281
524
  for (const webRoot of webRoots) for (const pageFile of walkFiles(webRoot, (fileName) => fileName.endsWith(".page.tsx"))) {
282
- const { route } = readDeclarations(pageFile, declarations);
283
- if (route === void 0) throw new MissingRouteExportError(relativeToApp(pageFile));
525
+ const pageSource = fs.readFileSync(pageFile, "utf-8");
526
+ assertPageHasDefaultExport(relativeToApp(pageFile), pageSource);
527
+ const { route } = readDeclarations(pageFile, declarations, pageSource);
528
+ if (isErrorPageFile(pageFile)) {
529
+ if (route !== void 0) throw new ErrorPageDeclaresRouteError(relativeToApp(pageFile));
530
+ if (errorPage !== void 0) throw new DuplicateErrorPageError(relativeToApp(errorPage.pageFile), relativeToApp(pageFile));
531
+ errorPage = {
532
+ type: "error",
533
+ pageFile,
534
+ webRoot,
535
+ ...hasAppFile ? { appFile } : {}
536
+ };
537
+ continue;
538
+ }
539
+ const isNotFoundPage = isNotFoundPageFile(pageFile);
540
+ if (isNotFoundPage && route !== void 0) throw new NotFoundPageDeclaresRouteError(relativeToApp(pageFile));
541
+ const unknownMetadataKeys = readMetadataKeys(relativeToApp(pageFile), pageSource);
542
+ if (unknownMetadataKeys.length > 0) throw new UnknownMetadataKeyError(relativeToApp(pageFile), unknownMetadataKeys);
284
543
  const layouts = layoutChainFor(pageFile, webRoot);
285
544
  const shapes = layouts.map((layoutFile) => readLayoutShape(layoutFile, layoutShapes));
286
545
  const selection = selectPageLayout(layouts.map((layout, index) => ({
@@ -290,24 +549,39 @@ function discoverPages(options) {
290
549
  if (selection.type === "rejected") throw new NestedLayoutsNotSupportedError(relativeToApp(pageFile), selection.layouts.map(relativeToApp));
291
550
  const middlewareLayouts = layouts.filter((_, index) => shapes[index].hasMiddleware);
292
551
  const layoutPrefix = layouts.reduce((composed, layoutFile) => composeRoutePath(composed, readDeclarations(layoutFile, declarations).prefix ?? "/"), "/");
552
+ const relativePageFile = toPosix(path.relative(webRoot, pageFile));
553
+ const layoutPrefixes = Object.fromEntries(layouts.flatMap((layoutFile) => {
554
+ const prefix = readDeclarations(layoutFile, declarations).prefix;
555
+ if (prefix === void 0) return [];
556
+ return [[toPosix(path.relative(webRoot, path.dirname(layoutFile))), prefix]];
557
+ }));
558
+ const effectiveRoutePath = isNotFoundPage ? "*" : route ? composeRoutePath(layoutPrefix, route.path) : deriveFilesystemRoutePath({
559
+ pageFile: relativePageFile,
560
+ layoutPrefixes
561
+ });
562
+ if (!isNotFoundPage && route !== void 0) explicitRouteFiles.add(relativeToApp(pageFile));
293
563
  pages.push({
294
- routeName: route.name ?? deriveFallbackRouteName({
564
+ type: "page",
565
+ routeName: isNotFoundPage ? NOT_FOUND_ROUTE_NAME : route?.name ?? (route ? deriveFallbackRouteName({
295
566
  routePath: route.path,
296
567
  sourceFile: relativeToApp(pageFile)
297
- }),
298
- routePath: composeRoutePath(layoutPrefix, route.path),
568
+ }) : deriveFilesystemRouteName(relativePageFile)),
569
+ routePath: effectiveRoutePath,
299
570
  pageFile,
300
571
  webRoot,
301
- layouts,
302
- middlewareLayouts,
572
+ layouts: isNotFoundPage ? [] : layouts,
573
+ middlewareLayouts: isNotFoundPage ? [] : middlewareLayouts,
303
574
  ...hasAppFile ? { appFile } : {}
304
575
  });
305
576
  }
577
+ if (errorPage !== void 0) pages.push(errorPage);
306
578
  pages.sort(comparePages);
307
- assertUniqueRouteNames(pages, appRoot);
579
+ const routablePages = pages.filter(isDiscoveredRoutablePage);
580
+ assertUniqueRoutePaths(routablePages, appRoot, explicitRouteFiles);
581
+ assertUniqueRouteNames(routablePages, appRoot);
308
582
  return pages;
309
583
  }
310
584
 
311
585
  //#endregion
312
- export { DuplicatePageRouteNameError, discoverPageFiles, discoverPages, discoverWebRoots, isFile, layoutChainFor, toPosix, walkFiles };
586
+ export { DuplicatePageRouteNameError, ErrorPageDeclaresRouteError, discoverPageFiles, discoverPages, discoverWebRoots, isDiscoveredRoutablePage, isErrorPageFile, isFile, layoutChainFor, toPosix, walkFiles };
313
587
  //# sourceMappingURL=discover-pages.mjs.map