@warlock.js/web 5.0.2 → 5.1.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 (46) hide show
  1. package/CHANGELOG.md +76 -0
  2. package/esm/build/contribution.d.mts +10 -0
  3. package/esm/build/contribution.mjs +36 -0
  4. package/esm/build/contribution.mjs.map +1 -1
  5. package/esm/build/discover-pages.mjs +226 -12
  6. package/esm/build/discover-pages.mjs.map +1 -1
  7. package/esm/build/generate-client-registry.mjs.map +1 -1
  8. package/esm/client/navigation/navigation-root.mjs +43 -6
  9. package/esm/client/navigation/navigation-root.mjs.map +1 -1
  10. package/esm/client/navigation/scroll-to-fragment.mjs +26 -0
  11. package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -0
  12. package/esm/metadata.d.mts +14 -0
  13. package/esm/metadata.mjs +45 -0
  14. package/esm/metadata.mjs.map +1 -0
  15. package/esm/routing/url-fragment.mjs +120 -0
  16. package/esm/routing/url-fragment.mjs.map +1 -0
  17. package/esm/server/create-page-route-handler.d.mts +27 -0
  18. package/esm/server/create-page-route-handler.mjs +12 -10
  19. package/esm/server/create-page-route-handler.mjs.map +1 -1
  20. package/esm/server/index.d.mts +2 -1
  21. package/esm/server/index.mjs +2 -1
  22. package/esm/server/install-page-routes-from-manifest.mjs +23 -1
  23. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
  24. package/esm/server/install-page-routes.d.mts +4 -2
  25. package/esm/server/install-page-routes.mjs +33 -5
  26. package/esm/server/install-page-routes.mjs.map +1 -1
  27. package/esm/server/install-production-page-routes.mjs +6 -1
  28. package/esm/server/install-production-page-routes.mjs.map +1 -1
  29. package/esm/server/not-found-page.d.mts +126 -0
  30. package/esm/server/not-found-page.mjs +157 -0
  31. package/esm/server/not-found-page.mjs.map +1 -0
  32. package/esm/server/web-connector-factory.mjs +2 -1
  33. package/esm/server/web-connector-factory.mjs.map +1 -1
  34. package/esm/server/web-connector.mjs +95 -0
  35. package/esm/server/web-connector.mjs.map +1 -1
  36. package/esm/vite/hydration-entries.mjs +8 -4
  37. package/esm/vite/hydration-entries.mjs.map +1 -1
  38. package/esm/vite/page-registry-plugin.mjs +211 -0
  39. package/esm/vite/page-registry-plugin.mjs.map +1 -1
  40. package/llms-full.txt +33 -5
  41. package/llms.txt +4 -2
  42. package/package.json +3 -3
  43. package/skills/create-a-page/SKILL.md +18 -2
  44. package/skills/navigate-on-the-client/SKILL.md +2 -0
  45. package/skills/use-layouts/SKILL.md +6 -0
  46. package/skills/write-the-root/SKILL.md +2 -0
package/CHANGELOG.md CHANGED
@@ -2,6 +2,82 @@
2
2
 
3
3
  All notable changes to `@warlock.js/web` are documented here.
4
4
 
5
+ ## 5.1.0
6
+
7
+ > **Upgrade if you installed 5.0.0, 5.0.1 or 5.0.2.** React did not execute at all in
8
+ > a published install of any of them — see the first entry under *Fixed*. Every
9
+ > interactive page shipped on those versions was inert in the browser.
10
+
11
+ ### Added
12
+
13
+ - **`404.page.tsx`** — an app-owned not-found page. It renders only when `text/html`
14
+ is explicitly present in the request's `Accept` header, so an unmatched `/api/...`
15
+ path still returns the JSON 404 an API client expects rather than a document. It
16
+ renders with no layouts: discovery reports an empty layout chain for this page only,
17
+ so the client hydration registry matches what the server has always rendered instead
18
+ of wrapping a failure page in chrome that can itself throw or need data. Ordinary
19
+ pages beside it keep their full layout chain, and nested-layout refusal on its path
20
+ is unchanged.
21
+ - **`export const metadata` is typed (`PageMetadata`) and checked at build time.** An
22
+ unannotated object literal with a misspelled key — `{ tittle: "x" }` — now fails the
23
+ build, naming the file, the line and the offending key. It previously typechecked as
24
+ a plain object and was silently ignored at runtime.
25
+ - **Fast Refresh in dev now applies only when an edit is confined to component
26
+ bodies.** Any module-level change — an import, a module-level declaration, or any
27
+ server export, `metadata` included — forces a full page reload instead of a stale hot
28
+ update; a JSX-only edit still hot-updates in place with component state intact.
29
+
30
+ ### Changed
31
+
32
+ - **`warlock dev` now refuses a `*.page.tsx` that exports no route**, throwing
33
+ `MissingRouteExportError` and naming the file. It previously 404'd silently, so a
34
+ missing `export const route` looked like a routing bug at request time. This matches
35
+ what `warlock build` already did — dev and build now reject the same file.
36
+
37
+ ### Removed
38
+
39
+ - **A false comment shipped in 5.0.0 through 5.0.2** claiming that a page's route is
40
+ derived from its file location. No such derivation has ever existed in this package;
41
+ the route comes from the page's `route` export and nothing else. The comment is gone
42
+ from the scaffold emitted by `warlock add web`, but **every app scaffolded on 5.0.0,
43
+ 5.0.1 or 5.0.2 still carries it in its own source** — delete it by hand.
44
+
45
+ ### Fixed
46
+
47
+ - **React did not run at all in published installs of 5.0.0 through 5.0.2.** The dev
48
+ Vite server served `react-dom/client` as raw CJS, so `hydrateRoot` did not exist and
49
+ the hydration module threw while being parsed. This one defect is the cause of all
50
+ four symptoms reported against those versions: `useState` never updated, Fast Refresh
51
+ never ran, metadata never refreshed, and `<Link>` fell back to a full page reload.
52
+ Fixed by declaring the React entries in the dev server's `optimizeDeps` so they are
53
+ pre-bundled to ESM before the browser asks for them. This is not a hydration
54
+ *improvement* — hydration did not happen.
55
+ - **The browser was loading two copies of every `@warlock.js/web` client module.**
56
+ Module-level state (context, the navigation runtime) existed twice, so a value written
57
+ through one copy was invisible to the component reading the other.
58
+
59
+ ## 5.0.2 - 2026-08-25
60
+
61
+ ### Fixed
62
+
63
+ - **`<Head/>` read an empty document context under SSR.** The connector now sets
64
+ `ssr.noExternal: ["@warlock.js/web"]` in `web-connector.ts`. Without it the server
65
+ loaded two instances of this package — one externalised, one bundled — so the context
66
+ the renderer wrote to was not the one `<Head/>` read from. A published 5.0.1 install
67
+ that returned 500 on a page request returns 200 after this fix.
68
+
69
+ ## 5.0.1 - 2026-08-25
70
+
71
+ ### Changed
72
+
73
+ - Narrowed the `vite` peer dependency to `">=7.3.5 <8"`, so a consumer resolving vite
74
+ for this package cannot land on a version outside the range it is built against.
75
+
76
+ ### Fixed
77
+
78
+ - Internal: a test in `gate-b-secrets.spec.ts` depended on the ambient `NODE_ENV` and
79
+ failed depending on how the suite was invoked. No runtime behaviour changed.
80
+
5
81
  ## 5.0.0 - 2026-08-25
6
82
 
7
83
  **First published release.**
@@ -27,6 +27,16 @@ type WebBuildOptions = {
27
27
  clientOutDir?: string; /** Client-build resolve aliases: specifier -> absolute path. */
28
28
  aliases?: Record<string, string>; /** Extra package names to keep external to the client bundle. */
29
29
  external?: string[];
30
+ /**
31
+ * NOT AN APP-FACING OPTION. Set by `webConnector()` from the length of its own
32
+ * `plugins` array; a COUNT rather than the array itself, because this options
33
+ * object is JSON-serializable-values-only (constraint B) and a plugin instance
34
+ * here would drag Vite into every config load.
35
+ *
36
+ * Its only consumer is the refusal in `generate` — see
37
+ * {@link ConnectorPluginsNotSupportedError}.
38
+ */
39
+ connectorPluginCount?: number;
30
40
  };
31
41
  /**
32
42
  * The resolved `@warlock.js/web` root is not that package.
@@ -37,6 +37,40 @@ var ClientOutDirNotSupportedError = class extends Error {
37
37
  this.name = "ClientOutDirNotSupportedError";
38
38
  }
39
39
  };
40
+ /**
41
+ * `webConnector({ plugins })` was given plugins, and `warlock build` cannot
42
+ * apply them.
43
+ *
44
+ * THE SILENT FAILURE THIS REPLACES. `plugins` reaches exactly one place: the
45
+ * dev server's `createServer({ plugins: [...] })`. The production client bundle
46
+ * is built by `buildWarlockHydrationClient`, which composes its own pipeline
47
+ * (`web/src/vite/index.ts` — projection and the boundary gates) and is not
48
+ * handed the connector's array by anyone. So a plugin worked in `warlock dev`
49
+ * and was absent from `warlock build`, with no warning and a green build log:
50
+ * the site shipped unstyled, or unprocessed in whatever way the plugin
51
+ * mattered, and the build claimed success. That is worse than any loud failure.
52
+ *
53
+ * WHY REFUSE RATHER THAN WIRE IT THROUGH. Plugin ORDER is part of a pipeline's
54
+ * behavior, and a plugin authored against a dev server can misbehave inside a
55
+ * production build; threading the array in is a change to build behavior that
56
+ * has to be verified, not assumed. Refusing removes the silent failure now and
57
+ * is strictly additive to reverse — wiring the plugins through later breaks no
58
+ * app that this error currently stops, while shipping a half-verified plugin
59
+ * pipeline could break every one of them.
60
+ *
61
+ * The message names the option and says what to do instead, because an error
62
+ * that only says "no" costs the reader the same hour it took to find this.
63
+ */
64
+ var ConnectorPluginsNotSupportedError = class extends Error {
65
+ constructor(pluginCount) {
66
+ super(`"plugins" on the web connector is not supported by \`warlock build\`: webConnector({ plugins }) was given ${pluginCount} plugin${pluginCount === 1 ? "" : "s"}, and they reach the dev server ONLY. The production client bundle is built with the framework's own pipeline and would silently ship without them, so this build refuses rather than emit an artifact that differs from what you saw in \`warlock dev\`.
67
+
68
+ What to do instead: express the transform somewhere BOTH dev and build already read.
69
+ - CSS/PostCSS (Tailwind, autoprefixer, ...): put a \`postcss.config.mjs\` at your app root. Vite loads it automatically in dev and in build — this is how Tailwind is supported today.
70
+ - Otherwise: remove \`plugins\` from webConnector() and open an issue describing what the plugin does, so the production pipeline can support it deliberately.`);
71
+ this.name = "ConnectorPluginsNotSupportedError";
72
+ }
73
+ };
40
74
  /** The package name every candidate web root must declare to be one. */
41
75
  const WEB_PACKAGE_NAME = "@warlock.js/web";
42
76
  /**
@@ -96,6 +130,8 @@ function createWebBuildContribution(options = {}) {
96
130
  let pageCount = 0;
97
131
  return {
98
132
  async generate(context) {
133
+ const connectorPluginCount = options.connectorPluginCount ?? 0;
134
+ if (connectorPluginCount > 0) throw new ConnectorPluginsNotSupportedError(connectorPluginCount);
99
135
  const { generatePagesBarrel, WEB_ENTRY_IMPORT, WEB_ESBUILD_PATCH } = await import("./generate-pages-barrel.mjs");
100
136
  pageCount = (await generatePagesBarrel({
101
137
  appRoot: context.appRoot,
@@ -1 +1 @@
1
- {"version":3,"file":"contribution.mjs","names":[],"sources":["../../../../../../../web/src/build/contribution.ts"],"sourcesContent":["/**\n * Web's build-time half — the object a `WebConnector` hands to\n * `warlock build` as its `build` contribution.\n *\n * KEPT DELIBERATELY LIGHT: everything this\n * module imports statically is a type or `node:path`. The heavy halves — the\n * filesystem discovery/barrel generator, and the Vite client build — are\n * `await import(...)`ed INSIDE the hooks, so a config file that merely\n * constructs the connector never drags Vite, React or the page graph into its\n * static import graph.\n *\n * {@link WebBuildOptions} carries JSON-SERIALIZABLE VALUES ONLY (constraint\n * B): no plugin instances, no functions, no class instances. Anything heavy is\n * constructed inside a hook after that hook's dynamic import.\n */\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport type {\n ConnectorBuildContext,\n ConnectorBuildContribution,\n ConnectorBuildGenerateResult,\n} from \"@warlock.js/core\";\n\nexport type WebBuildOptions = {\n /** Source directory name under the app root. Default: `\"src\"`. */\n srcDir?: string;\n /**\n * Absolute path to the `@warlock.js/web` package root, which holds the\n * hydration entry (packaged as `esm/hydration/index.mjs`, with a source\n * fallback at `src/hydration/index.ts` for this checkout).\n *\n * Defaults to this module's own package root, derived from `import.meta.url`\n * at emit time. Set it explicitly when the build process loads this module\n * through a transform that rewrites `import.meta` (e.g. a CJS config\n * pipeline) — the derivation is the only thing here that depends on it.\n *\n * Derived or configured, the root is VERIFIED at emit time against\n * `<root>/package.json`'s `name`; a root that is not `@warlock.js/web`\n * throws {@link WebPackageRootResolutionError} rather than being guessed at.\n */\n webRoot?: string;\n /**\n * NOT SUPPORTED. The client bundle always lands at `<build.outdir>/client`,\n * the layout the runtime half reads (`resolveHydrationClientUrl`) — the\n * runtime does not yet consult a relocated path. Setting this option throws\n * {@link ClientOutDirNotSupportedError}.\n */\n clientOutDir?: string;\n /** Client-build resolve aliases: specifier -> absolute path. */\n aliases?: Record<string, string>;\n /** Extra package names to keep external to the client bundle. */\n external?: string[];\n};\n\nfunction resolveClientOutDir(context: ConnectorBuildContext): string {\n return path.resolve(context.appRoot, context.options.outdir, \"client\");\n}\n\n/**\n * `build.clientOutDir` was set.\n *\n * The production runtime hardcodes where it reads the client bundle from\n * (`<outdir>/client`, via `resolveHydrationClientUrl`) — it does not yet\n * consult the build config for a relocated path. A build that honored a\n * custom `clientOutDir` would therefore produce an artifact the runtime\n * cannot find, silently. Rejected at build start, before any work happens,\n * rather than left to surface later as a missing-bundle failure in\n * production.\n */\nexport class ClientOutDirNotSupportedError extends Error {\n public constructor() {\n super(\n '\"build.clientOutDir\" is not supported: the production server reads the client bundle ' +\n \"from its default location and does not consult this option, so a build honoring it \" +\n \"would produce an artifact production cannot serve correctly. Remove \" +\n '\"build.clientOutDir\" from the build config.',\n );\n this.name = \"ClientOutDirNotSupportedError\";\n }\n}\n\n/** The package name every candidate web root must declare to be one. */\nconst WEB_PACKAGE_NAME = \"@warlock.js/web\";\n\n/**\n * The resolved `@warlock.js/web` root is not that package.\n *\n * Same posture as the manifest resolver's three named errors: a root that\n * cannot be proven is REJECTED, never quietly\n * repaired by walking upwards or falling back to `process.cwd()`. A wrong root\n * would otherwise surface much later as an unintelligible Vite entry failure.\n */\nexport class WebPackageRootResolutionError extends Error {\n public constructor(webRoot: string, reason: string, cause?: unknown) {\n super(\n `Cannot resolve the \"${WEB_PACKAGE_NAME}\" package root: \"${webRoot}\" ${reason}. ` +\n \"Pass `webRoot` explicitly in the web build options to point at the package root \" +\n `whose package.json declares \"name\": \"${WEB_PACKAGE_NAME}\".`,\n { cause },\n );\n this.name = \"WebPackageRootResolutionError\";\n }\n}\n\n/**\n * Proves that `webRoot` really is the `@warlock.js/web` package root by reading\n * `<webRoot>/package.json` and matching its `name`. Returns the path unchanged.\n */\nexport function assertWebPackageRoot(webRoot: string): string {\n const manifestPath = path.join(webRoot, \"package.json\");\n\n let raw: string;\n\n try {\n raw = readFileSync(manifestPath, \"utf-8\");\n } catch (error) {\n throw new WebPackageRootResolutionError(webRoot, \"has no readable package.json\", error);\n }\n\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(raw);\n } catch (error) {\n throw new WebPackageRootResolutionError(\n webRoot,\n \"has a package.json that is not valid JSON\",\n error,\n );\n }\n\n if (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) {\n throw new WebPackageRootResolutionError(\n webRoot,\n \"has a package.json whose root is not a JSON object\",\n );\n }\n\n const name = (parsed as { name?: unknown }).name;\n\n if (name !== WEB_PACKAGE_NAME) {\n throw new WebPackageRootResolutionError(\n webRoot,\n `declares package name ${JSON.stringify(name)}, not \"${WEB_PACKAGE_NAME}\"`,\n );\n }\n\n return webRoot;\n}\n\nexport async function resolveWebPackageRoot(configured: string | undefined): Promise<string> {\n if (configured !== undefined) {\n return assertWebPackageRoot(path.resolve(configured));\n }\n\n const { fileURLToPath } = await import(\"node:url\");\n\n // `web/src/build/contribution.ts` published as `web/esm/build/contribution.js`\n // — two levels up is the package root under both layouts.\n return assertWebPackageRoot(\n path.resolve(path.dirname(fileURLToPath(import.meta.url)), \"..\", \"..\"),\n );\n}\n\n/**\n * Builds web's `ConnectorBuildContribution`.\n *\n * `generate` writes the page barrel into `ctx.productionDir` and patches\n * esbuild; `emit` produces the client bundle esbuild cannot. The two share one\n * closure fact — how many pages exist — because a build with zero pages must\n * skip the client bundle rather than emit an orphan one. `generate` itself does\n * NOT skip: the barrel and its entry import are emitted either way, so the\n * runtime can tell a page-free web build from a build without web.\n */\nexport function createWebBuildContribution(\n options: WebBuildOptions = {},\n): ConnectorBuildContribution {\n if (options.clientOutDir !== undefined) {\n throw new ClientOutDirNotSupportedError();\n }\n\n let pageCount = 0;\n\n return {\n async generate(context: ConnectorBuildContext): Promise<ConnectorBuildGenerateResult | void> {\n const { generatePagesBarrel, WEB_ENTRY_IMPORT, WEB_ESBUILD_PATCH } = await import(\n \"./generate-pages-barrel\"\n );\n\n const result = await generatePagesBarrel({\n appRoot: context.appRoot,\n productionDir: context.productionDir,\n srcDir: options.srcDir,\n // Derived from `resolveClientOutDir` — the SAME function `emit` passes\n // to the Vite build below — so the path baked into the manifest and\n // the path the bundle is written to are one expression, not two that\n // happen to agree today.\n // POSIX-normalised inline rather than via the generator's `toPosix`:\n // this module's static graph is `node:path` and types only, and the\n // generator is reached by `await import` precisely to keep it that way.\n clientDir: path\n .relative(context.appRoot, resolveClientOutDir(context))\n .split(path.sep)\n .join(\"/\"),\n });\n\n pageCount = result.pageCount;\n\n // Contributed unconditionally, zero pages included: the barrel is always\n // written, and the entry has to IMPORT it for the empty table to reach\n // the runtime. Withholding this line on zero pages would leave the\n // manifest absent, which is the runtime's signal for \"never built with\n // web\" — the exact confusion the always-written barrel removes.\n return { entryImports: [WEB_ENTRY_IMPORT], esbuild: WEB_ESBUILD_PATCH };\n },\n\n async emit(context: ConnectorBuildContext): Promise<void> {\n if (pageCount === 0) {\n return;\n }\n\n const { buildWarlockHydrationClient } = await import(\"../vite\");\n const { appConventionAliases } = await import(\"../vite/app-convention-aliases\");\n\n // The app-tree convention (`web/*`, `app/*`) the dev server installs must\n // also reach the production build, from the SAME definition — see\n // `app-convention-aliases.ts`. Caller-supplied aliases come first so a\n // caller can win a conflict, matching the dev server's ordering.\n const callerAliases = Object.entries(options.aliases ?? {}).map(([find, replacement]) => ({\n find,\n replacement,\n }));\n\n await buildWarlockHydrationClient({\n appRoot: context.appRoot,\n webRoot: await resolveWebPackageRoot(options.webRoot),\n outDir: resolveClientOutDir(context),\n resolveAliases: [\n ...callerAliases,\n ...appConventionAliases(path.join(context.appRoot, options.srcDir ?? \"src\")),\n ],\n external: options.external,\n });\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAsDA,SAAS,oBAAoB,SAAwC;CACnE,OAAO,KAAK,QAAQ,QAAQ,SAAS,QAAQ,QAAQ,QAAQ,QAAQ;AACvE;;;;;;;;;;;;AAaA,IAAa,gCAAb,cAAmD,MAAM;CACvD,AAAO,cAAc;EACnB,MACE,6RAIF;EACA,KAAK,OAAO;CACd;AACF;;AAGA,MAAM,mBAAmB;;;;;;;;;AAUzB,IAAa,gCAAb,cAAmD,MAAM;CACvD,AAAO,YAAY,SAAiB,QAAgB,OAAiB;EACnE,MACE,uBAAuB,iBAAiB,mBAAmB,QAAQ,IAAI,OAAO,2HAEpC,iBAAiB,KAC3D,EAAE,MAAM,CACV;EACA,KAAK,OAAO;CACd;AACF;;;;;AAMA,SAAgB,qBAAqB,SAAyB;CAC5D,MAAM,eAAe,KAAK,KAAK,SAAS,cAAc;CAEtD,IAAI;CAEJ,IAAI;EACF,MAAM,aAAa,cAAc,OAAO;CAC1C,SAAS,OAAO;EACd,MAAM,IAAI,8BAA8B,SAAS,gCAAgC,KAAK;CACxF;CAEA,IAAI;CAEJ,IAAI;EACF,SAAS,KAAK,MAAM,GAAG;CACzB,SAAS,OAAO;EACd,MAAM,IAAI,8BACR,SACA,6CACA,KACF;CACF;CAEA,IAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,MAAM,GACvE,MAAM,IAAI,8BACR,SACA,oDACF;CAGF,MAAM,OAAQ,OAA8B;CAE5C,IAAI,SAAS,kBACX,MAAM,IAAI,8BACR,SACA,yBAAyB,KAAK,UAAU,IAAI,EAAE,SAAS,iBAAiB,EAC1E;CAGF,OAAO;AACT;AAEA,eAAsB,sBAAsB,YAAiD;CAC3F,IAAI,eAAe,QACjB,OAAO,qBAAqB,KAAK,QAAQ,UAAU,CAAC;CAGtD,MAAM,EAAE,kBAAkB,MAAM,OAAO;CAIvC,OAAO,qBACL,KAAK,QAAQ,KAAK,QAAQ,cAAc,OAAO,KAAK,GAAG,CAAC,GAAG,MAAM,IAAI,CACvE;AACF;;;;;;;;;;;AAYA,SAAgB,2BACd,UAA2B,CAAC,GACA;CAC5B,IAAI,QAAQ,iBAAiB,QAC3B,MAAM,IAAI,8BAA8B;CAG1C,IAAI,YAAY;CAEhB,OAAO;EACL,MAAM,SAAS,SAA8E;GAC3F,MAAM,EAAE,qBAAqB,kBAAkB,sBAAsB,MAAM,OACzE;GAoBF,aAAY,MAjBS,oBAAoB;IACvC,SAAS,QAAQ;IACjB,eAAe,QAAQ;IACvB,QAAQ,QAAQ;IAQhB,WAAW,KACR,SAAS,QAAQ,SAAS,oBAAoB,OAAO,CAAC,CAAC,CACvD,MAAM,KAAK,GAAG,CAAC,CACf,KAAK,GAAG;GACb,CAAC,EAEiB,CAAC;GAOnB,OAAO;IAAE,cAAc,CAAC,gBAAgB;IAAG,SAAS;GAAkB;EACxE;EAEA,MAAM,KAAK,SAA+C;GACxD,IAAI,cAAc,GAChB;GAGF,MAAM,EAAE,gCAAgC,MAAM,OAAO;GACrD,MAAM,EAAE,yBAAyB,MAAM,OAAO;GAM9C,MAAM,gBAAgB,OAAO,QAAQ,QAAQ,WAAW,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,kBAAkB;IACxF;IACA;GACF,EAAE;GAEF,MAAM,4BAA4B;IAChC,SAAS,QAAQ;IACjB,SAAS,MAAM,sBAAsB,QAAQ,OAAO;IACpD,QAAQ,oBAAoB,OAAO;IACnC,gBAAgB,CACd,GAAG,eACH,GAAG,qBAAqB,KAAK,KAAK,QAAQ,SAAS,QAAQ,UAAU,KAAK,CAAC,CAC7E;IACA,UAAU,QAAQ;GACpB,CAAC;EACH;CACF;AACF"}
1
+ {"version":3,"file":"contribution.mjs","names":[],"sources":["../../../../../../../web/src/build/contribution.ts"],"sourcesContent":["/**\n * Web's build-time half — the object a `WebConnector` hands to\n * `warlock build` as its `build` contribution.\n *\n * KEPT DELIBERATELY LIGHT: everything this\n * module imports statically is a type or `node:path`. The heavy halves — the\n * filesystem discovery/barrel generator, and the Vite client build — are\n * `await import(...)`ed INSIDE the hooks, so a config file that merely\n * constructs the connector never drags Vite, React or the page graph into its\n * static import graph.\n *\n * {@link WebBuildOptions} carries JSON-SERIALIZABLE VALUES ONLY (constraint\n * B): no plugin instances, no functions, no class instances. Anything heavy is\n * constructed inside a hook after that hook's dynamic import.\n */\nimport { readFileSync } from \"node:fs\";\nimport path from \"node:path\";\nimport type {\n ConnectorBuildContext,\n ConnectorBuildContribution,\n ConnectorBuildGenerateResult,\n} from \"@warlock.js/core\";\n\nexport type WebBuildOptions = {\n /** Source directory name under the app root. Default: `\"src\"`. */\n srcDir?: string;\n /**\n * Absolute path to the `@warlock.js/web` package root, which holds the\n * hydration entry (packaged as `esm/hydration/index.mjs`, with a source\n * fallback at `src/hydration/index.ts` for this checkout).\n *\n * Defaults to this module's own package root, derived from `import.meta.url`\n * at emit time. Set it explicitly when the build process loads this module\n * through a transform that rewrites `import.meta` (e.g. a CJS config\n * pipeline) — the derivation is the only thing here that depends on it.\n *\n * Derived or configured, the root is VERIFIED at emit time against\n * `<root>/package.json`'s `name`; a root that is not `@warlock.js/web`\n * throws {@link WebPackageRootResolutionError} rather than being guessed at.\n */\n webRoot?: string;\n /**\n * NOT SUPPORTED. The client bundle always lands at `<build.outdir>/client`,\n * the layout the runtime half reads (`resolveHydrationClientUrl`) — the\n * runtime does not yet consult a relocated path. Setting this option throws\n * {@link ClientOutDirNotSupportedError}.\n */\n clientOutDir?: string;\n /** Client-build resolve aliases: specifier -> absolute path. */\n aliases?: Record<string, string>;\n /** Extra package names to keep external to the client bundle. */\n external?: string[];\n /**\n * NOT AN APP-FACING OPTION. Set by `webConnector()` from the length of its own\n * `plugins` array; a COUNT rather than the array itself, because this options\n * object is JSON-serializable-values-only (constraint B) and a plugin instance\n * here would drag Vite into every config load.\n *\n * Its only consumer is the refusal in `generate` — see\n * {@link ConnectorPluginsNotSupportedError}.\n */\n connectorPluginCount?: number;\n};\n\nfunction resolveClientOutDir(context: ConnectorBuildContext): string {\n return path.resolve(context.appRoot, context.options.outdir, \"client\");\n}\n\n/**\n * `build.clientOutDir` was set.\n *\n * The production runtime hardcodes where it reads the client bundle from\n * (`<outdir>/client`, via `resolveHydrationClientUrl`) — it does not yet\n * consult the build config for a relocated path. A build that honored a\n * custom `clientOutDir` would therefore produce an artifact the runtime\n * cannot find, silently. Rejected at build start, before any work happens,\n * rather than left to surface later as a missing-bundle failure in\n * production.\n */\nexport class ClientOutDirNotSupportedError extends Error {\n public constructor() {\n super(\n '\"build.clientOutDir\" is not supported: the production server reads the client bundle ' +\n \"from its default location and does not consult this option, so a build honoring it \" +\n \"would produce an artifact production cannot serve correctly. Remove \" +\n '\"build.clientOutDir\" from the build config.',\n );\n this.name = \"ClientOutDirNotSupportedError\";\n }\n}\n\n/**\n * `webConnector({ plugins })` was given plugins, and `warlock build` cannot\n * apply them.\n *\n * THE SILENT FAILURE THIS REPLACES. `plugins` reaches exactly one place: the\n * dev server's `createServer({ plugins: [...] })`. The production client bundle\n * is built by `buildWarlockHydrationClient`, which composes its own pipeline\n * (`web/src/vite/index.ts` — projection and the boundary gates) and is not\n * handed the connector's array by anyone. So a plugin worked in `warlock dev`\n * and was absent from `warlock build`, with no warning and a green build log:\n * the site shipped unstyled, or unprocessed in whatever way the plugin\n * mattered, and the build claimed success. That is worse than any loud failure.\n *\n * WHY REFUSE RATHER THAN WIRE IT THROUGH. Plugin ORDER is part of a pipeline's\n * behavior, and a plugin authored against a dev server can misbehave inside a\n * production build; threading the array in is a change to build behavior that\n * has to be verified, not assumed. Refusing removes the silent failure now and\n * is strictly additive to reverse — wiring the plugins through later breaks no\n * app that this error currently stops, while shipping a half-verified plugin\n * pipeline could break every one of them.\n *\n * The message names the option and says what to do instead, because an error\n * that only says \"no\" costs the reader the same hour it took to find this.\n */\nexport class ConnectorPluginsNotSupportedError extends Error {\n public constructor(pluginCount: number) {\n super(\n `\"plugins\" on the web connector is not supported by \\`warlock build\\`: ` +\n `webConnector({ plugins }) was given ${pluginCount} plugin${pluginCount === 1 ? \"\" : \"s\"}, ` +\n \"and they reach the dev server ONLY. The production client bundle is built with the \" +\n \"framework's own pipeline and would silently ship without them, so this build refuses \" +\n \"rather than emit an artifact that differs from what you saw in `warlock dev`.\\n\\n\" +\n \"What to do instead: express the transform somewhere BOTH dev and build already read.\\n\" +\n \" - CSS/PostCSS (Tailwind, autoprefixer, ...): put a `postcss.config.mjs` at your app \" +\n \"root. Vite loads it automatically in dev and in build — this is how Tailwind is \" +\n \"supported today.\\n\" +\n \" - Otherwise: remove `plugins` from webConnector() and open an issue describing what \" +\n \"the plugin does, so the production pipeline can support it deliberately.\",\n );\n this.name = \"ConnectorPluginsNotSupportedError\";\n }\n}\n\n/** The package name every candidate web root must declare to be one. */\nconst WEB_PACKAGE_NAME = \"@warlock.js/web\";\n\n/**\n * The resolved `@warlock.js/web` root is not that package.\n *\n * Same posture as the manifest resolver's three named errors: a root that\n * cannot be proven is REJECTED, never quietly\n * repaired by walking upwards or falling back to `process.cwd()`. A wrong root\n * would otherwise surface much later as an unintelligible Vite entry failure.\n */\nexport class WebPackageRootResolutionError extends Error {\n public constructor(webRoot: string, reason: string, cause?: unknown) {\n super(\n `Cannot resolve the \"${WEB_PACKAGE_NAME}\" package root: \"${webRoot}\" ${reason}. ` +\n \"Pass `webRoot` explicitly in the web build options to point at the package root \" +\n `whose package.json declares \"name\": \"${WEB_PACKAGE_NAME}\".`,\n { cause },\n );\n this.name = \"WebPackageRootResolutionError\";\n }\n}\n\n/**\n * Proves that `webRoot` really is the `@warlock.js/web` package root by reading\n * `<webRoot>/package.json` and matching its `name`. Returns the path unchanged.\n */\nexport function assertWebPackageRoot(webRoot: string): string {\n const manifestPath = path.join(webRoot, \"package.json\");\n\n let raw: string;\n\n try {\n raw = readFileSync(manifestPath, \"utf-8\");\n } catch (error) {\n throw new WebPackageRootResolutionError(webRoot, \"has no readable package.json\", error);\n }\n\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(raw);\n } catch (error) {\n throw new WebPackageRootResolutionError(\n webRoot,\n \"has a package.json that is not valid JSON\",\n error,\n );\n }\n\n if (typeof parsed !== \"object\" || parsed === null || Array.isArray(parsed)) {\n throw new WebPackageRootResolutionError(\n webRoot,\n \"has a package.json whose root is not a JSON object\",\n );\n }\n\n const name = (parsed as { name?: unknown }).name;\n\n if (name !== WEB_PACKAGE_NAME) {\n throw new WebPackageRootResolutionError(\n webRoot,\n `declares package name ${JSON.stringify(name)}, not \"${WEB_PACKAGE_NAME}\"`,\n );\n }\n\n return webRoot;\n}\n\nexport async function resolveWebPackageRoot(configured: string | undefined): Promise<string> {\n if (configured !== undefined) {\n return assertWebPackageRoot(path.resolve(configured));\n }\n\n const { fileURLToPath } = await import(\"node:url\");\n\n // `web/src/build/contribution.ts` published as `web/esm/build/contribution.js`\n // — two levels up is the package root under both layouts.\n return assertWebPackageRoot(\n path.resolve(path.dirname(fileURLToPath(import.meta.url)), \"..\", \"..\"),\n );\n}\n\n/**\n * Builds web's `ConnectorBuildContribution`.\n *\n * `generate` writes the page barrel into `ctx.productionDir` and patches\n * esbuild; `emit` produces the client bundle esbuild cannot. The two share one\n * closure fact — how many pages exist — because a build with zero pages must\n * skip the client bundle rather than emit an orphan one. `generate` itself does\n * NOT skip: the barrel and its entry import are emitted either way, so the\n * runtime can tell a page-free web build from a build without web.\n */\nexport function createWebBuildContribution(\n options: WebBuildOptions = {},\n): ConnectorBuildContribution {\n if (options.clientOutDir !== undefined) {\n throw new ClientOutDirNotSupportedError();\n }\n\n let pageCount = 0;\n\n return {\n async generate(context: ConnectorBuildContext): Promise<ConnectorBuildGenerateResult | void> {\n // REFUSED HERE, NOT IN THE CONSTRUCTOR ABOVE, and the difference is the\n // whole of `warlock dev` still working.\n //\n // `webConnector()` — and therefore `createWebBuildContribution()` — runs\n // on every config load, dev included. Throwing at construction the way\n // `clientOutDir` does would make a connector with dev-only plugins fail\n // to boot the dev server those plugins exist for. `generate` is the first\n // hook `warlock build` calls, and nothing but a build calls it: refusing\n // here fails the build before any barrel is written or any bundle emitted,\n // and is unreachable from dev.\n //\n // Unconditional on page count. `emit` skips the client bundle when the app\n // has zero pages, but \"your plugins will not be applied\" is true either\n // way, and a refusal that depends on how many pages you happen to have is\n // a worse contract than one that does not.\n const connectorPluginCount = options.connectorPluginCount ?? 0;\n\n if (connectorPluginCount > 0) {\n throw new ConnectorPluginsNotSupportedError(connectorPluginCount);\n }\n\n const { generatePagesBarrel, WEB_ENTRY_IMPORT, WEB_ESBUILD_PATCH } = await import(\n \"./generate-pages-barrel\"\n );\n\n const result = await generatePagesBarrel({\n appRoot: context.appRoot,\n productionDir: context.productionDir,\n srcDir: options.srcDir,\n // Derived from `resolveClientOutDir` — the SAME function `emit` passes\n // to the Vite build below — so the path baked into the manifest and\n // the path the bundle is written to are one expression, not two that\n // happen to agree today.\n // POSIX-normalised inline rather than via the generator's `toPosix`:\n // this module's static graph is `node:path` and types only, and the\n // generator is reached by `await import` precisely to keep it that way.\n clientDir: path\n .relative(context.appRoot, resolveClientOutDir(context))\n .split(path.sep)\n .join(\"/\"),\n });\n\n pageCount = result.pageCount;\n\n // Contributed unconditionally, zero pages included: the barrel is always\n // written, and the entry has to IMPORT it for the empty table to reach\n // the runtime. Withholding this line on zero pages would leave the\n // manifest absent, which is the runtime's signal for \"never built with\n // web\" — the exact confusion the always-written barrel removes.\n return { entryImports: [WEB_ENTRY_IMPORT], esbuild: WEB_ESBUILD_PATCH };\n },\n\n async emit(context: ConnectorBuildContext): Promise<void> {\n if (pageCount === 0) {\n return;\n }\n\n const { buildWarlockHydrationClient } = await import(\"../vite\");\n const { appConventionAliases } = await import(\"../vite/app-convention-aliases\");\n\n // The app-tree convention (`web/*`, `app/*`) the dev server installs must\n // also reach the production build, from the SAME definition — see\n // `app-convention-aliases.ts`. Caller-supplied aliases come first so a\n // caller can win a conflict, matching the dev server's ordering.\n const callerAliases = Object.entries(options.aliases ?? {}).map(([find, replacement]) => ({\n find,\n replacement,\n }));\n\n await buildWarlockHydrationClient({\n appRoot: context.appRoot,\n webRoot: await resolveWebPackageRoot(options.webRoot),\n outDir: resolveClientOutDir(context),\n resolveAliases: [\n ...callerAliases,\n ...appConventionAliases(path.join(context.appRoot, options.srcDir ?? \"src\")),\n ],\n external: options.external,\n });\n },\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;AAgEA,SAAS,oBAAoB,SAAwC;CACnE,OAAO,KAAK,QAAQ,QAAQ,SAAS,QAAQ,QAAQ,QAAQ,QAAQ;AACvE;;;;;;;;;;;;AAaA,IAAa,gCAAb,cAAmD,MAAM;CACvD,AAAO,cAAc;EACnB,MACE,6RAIF;EACA,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;;;;;;;;;;;AA0BA,IAAa,oCAAb,cAAuD,MAAM;CAC3D,AAAO,YAAY,aAAqB;EACtC,MACE,6GACyC,YAAY,SAAS,gBAAgB,IAAI,KAAK,IAAI;;;;iKAU7F;EACA,KAAK,OAAO;CACd;AACF;;AAGA,MAAM,mBAAmB;;;;;;;;;AAUzB,IAAa,gCAAb,cAAmD,MAAM;CACvD,AAAO,YAAY,SAAiB,QAAgB,OAAiB;EACnE,MACE,uBAAuB,iBAAiB,mBAAmB,QAAQ,IAAI,OAAO,2HAEpC,iBAAiB,KAC3D,EAAE,MAAM,CACV;EACA,KAAK,OAAO;CACd;AACF;;;;;AAMA,SAAgB,qBAAqB,SAAyB;CAC5D,MAAM,eAAe,KAAK,KAAK,SAAS,cAAc;CAEtD,IAAI;CAEJ,IAAI;EACF,MAAM,aAAa,cAAc,OAAO;CAC1C,SAAS,OAAO;EACd,MAAM,IAAI,8BAA8B,SAAS,gCAAgC,KAAK;CACxF;CAEA,IAAI;CAEJ,IAAI;EACF,SAAS,KAAK,MAAM,GAAG;CACzB,SAAS,OAAO;EACd,MAAM,IAAI,8BACR,SACA,6CACA,KACF;CACF;CAEA,IAAI,OAAO,WAAW,YAAY,WAAW,QAAQ,MAAM,QAAQ,MAAM,GACvE,MAAM,IAAI,8BACR,SACA,oDACF;CAGF,MAAM,OAAQ,OAA8B;CAE5C,IAAI,SAAS,kBACX,MAAM,IAAI,8BACR,SACA,yBAAyB,KAAK,UAAU,IAAI,EAAE,SAAS,iBAAiB,EAC1E;CAGF,OAAO;AACT;AAEA,eAAsB,sBAAsB,YAAiD;CAC3F,IAAI,eAAe,QACjB,OAAO,qBAAqB,KAAK,QAAQ,UAAU,CAAC;CAGtD,MAAM,EAAE,kBAAkB,MAAM,OAAO;CAIvC,OAAO,qBACL,KAAK,QAAQ,KAAK,QAAQ,cAAc,OAAO,KAAK,GAAG,CAAC,GAAG,MAAM,IAAI,CACvE;AACF;;;;;;;;;;;AAYA,SAAgB,2BACd,UAA2B,CAAC,GACA;CAC5B,IAAI,QAAQ,iBAAiB,QAC3B,MAAM,IAAI,8BAA8B;CAG1C,IAAI,YAAY;CAEhB,OAAO;EACL,MAAM,SAAS,SAA8E;GAgB3F,MAAM,uBAAuB,QAAQ,wBAAwB;GAE7D,IAAI,uBAAuB,GACzB,MAAM,IAAI,kCAAkC,oBAAoB;GAGlE,MAAM,EAAE,qBAAqB,kBAAkB,sBAAsB,MAAM,OACzE;GAoBF,aAAY,MAjBS,oBAAoB;IACvC,SAAS,QAAQ;IACjB,eAAe,QAAQ;IACvB,QAAQ,QAAQ;IAQhB,WAAW,KACR,SAAS,QAAQ,SAAS,oBAAoB,OAAO,CAAC,CAAC,CACvD,MAAM,KAAK,GAAG,CAAC,CACf,KAAK,GAAG;GACb,CAAC,EAEiB,CAAC;GAOnB,OAAO;IAAE,cAAc,CAAC,gBAAgB;IAAG,SAAS;GAAkB;EACxE;EAEA,MAAM,KAAK,SAA+C;GACxD,IAAI,cAAc,GAChB;GAGF,MAAM,EAAE,gCAAgC,MAAM,OAAO;GACrD,MAAM,EAAE,yBAAyB,MAAM,OAAO;GAM9C,MAAM,gBAAgB,OAAO,QAAQ,QAAQ,WAAW,CAAC,CAAC,CAAC,CAAC,KAAK,CAAC,MAAM,kBAAkB;IACxF;IACA;GACF,EAAE;GAEF,MAAM,4BAA4B;IAChC,SAAS,QAAQ;IACjB,SAAS,MAAM,sBAAsB,QAAQ,OAAO;IACpD,QAAQ,oBAAoB,OAAO;IACnC,gBAAgB,CACd,GAAG,eACH,GAAG,qBAAqB,KAAK,KAAK,QAAQ,SAAS,QAAQ,UAAU,KAAK,CAAC,CAC7E;IACA,UAAU,QAAQ;GACpB,CAAC;EACH;CACF;AACF"}
@@ -1,6 +1,8 @@
1
1
  import { composeRoutePath } from "../routing/compose-route-path.mjs";
2
2
  import { NestedLayoutsNotSupportedError, selectPageLayout } from "../routing/layout-policy.mjs";
3
3
  import { deriveFallbackRouteName } from "../routing/route-identity.mjs";
4
+ import { NOT_FOUND_ROUTE_NAME, NOT_FOUND_ROUTE_PATH, NotFoundPageDeclaresRouteError, isNotFoundPageFile } from "../server/not-found-page.mjs";
5
+ import { METADATA_KEYS, OPEN_GRAPH_KEYS, TWITTER_KEYS } from "../metadata.mjs";
4
6
  import { NonLiteralRouteExportError, readRouteExports } from "./read-route-exports.mjs";
5
7
  import path from "node:path";
6
8
  import fs from "node:fs";
@@ -42,6 +44,10 @@ import { parse } from "@babel/parser";
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
  */
46
52
  /** Raised when two pages claim one route name. */
47
53
  var DuplicatePageRouteNameError = class extends Error {
@@ -183,22 +189,223 @@ function assertUniqueRouteNames(pages, appRoot) {
183
189
  var MissingRouteExportError = class extends Error {
184
190
  pageFile;
185
191
  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";`);
192
+ super(`"${pageFile}" is a page file but declares no \`route\` export. A page with no route is a page nothing can ever reach, so both the dev server and the build refuse it instead of serving a silent 404. For example: export const route = "/list";`);
187
193
  this.pageFile = pageFile;
188
194
  this.name = "MissingRouteExportError";
189
195
  }
190
196
  };
191
197
  /**
198
+ * Raised when a page's `metadata` export declares a key nothing reads.
199
+ *
200
+ * THE POINT OF THIS ERROR IS THE UNANNOTATED CASE. A page that writes
201
+ * `export const metadata: PageMetadata = { tittle: "x" }` is already refused by
202
+ * TypeScript, and if that were the whole story this class would not need to
203
+ * exist. But the annotation is optional, nobody writes it, and
204
+ * `export const metadata = { tittle: "x" }` is a perfectly well-typed program:
205
+ * the compiler infers `{ tittle: string }`, has nothing to check it against, and
206
+ * says nothing. The page is then served with no `<title>` — not a wrong title, a
207
+ * missing one — and no error is raised anywhere, at build or at runtime, ever.
208
+ *
209
+ * So the check lives HERE instead, at the gate every page already passes
210
+ * through, where a `route`-less page is refused by name for the same reason: a
211
+ * page that silently does not work is worse than a build that stops and says
212
+ * which line to fix.
213
+ *
214
+ * The alternative considered and rejected was a `defineMetadata({...})` wrapper,
215
+ * which would infer the type for free. It also puts framework ceremony in every
216
+ * page, and a page is meant to be two lines of framework surface (canon
217
+ * `6ea0662f`). The gate gets the same safety without spending that.
218
+ */
219
+ var UnknownMetadataKeyError = class extends Error {
220
+ pageFile;
221
+ unknownKeys;
222
+ constructor(pageFile, unknownKeys) {
223
+ const findings = unknownKeys.map(({ container, key, line, suggestion }) => {
224
+ const where = `line ${line}: \`${container}.${key}\` — no such key.`;
225
+ return suggestion === void 0 ? where : `${where} Did you mean \`${suggestion}\`?`;
226
+ }).join("\n ");
227
+ 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.
228
+ 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.`);
229
+ this.pageFile = pageFile;
230
+ this.unknownKeys = unknownKeys;
231
+ this.name = "UnknownMetadataKeyError";
232
+ }
233
+ };
234
+ /** `as const`, `satisfies`, `!` and parentheses wrap a value without changing it. */
235
+ function unwrapValue(node) {
236
+ switch (node.type) {
237
+ case "TSAsExpression":
238
+ case "TSSatisfiesExpression":
239
+ case "TSNonNullExpression":
240
+ case "TypeCastExpression":
241
+ case "ParenthesizedExpression": return unwrapValue(node.expression);
242
+ default: return node;
243
+ }
244
+ }
245
+ /** Levenshtein distance — small strings, so the plain two-row table is the whole cost. */
246
+ function editDistance(left, right) {
247
+ let previous = Array.from({ length: right.length + 1 }, (_, index) => index);
248
+ for (let row = 1; row <= left.length; row++) {
249
+ const current = [row];
250
+ for (let column = 1; column <= right.length; column++) {
251
+ const substitution = previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1);
252
+ current[column] = Math.min(substitution, previous[column] + 1, current[column - 1] + 1);
253
+ }
254
+ previous = current;
255
+ }
256
+ return previous[right.length];
257
+ }
258
+ /**
259
+ * The known key the written one was probably meant to be.
260
+ *
261
+ * Two edits, because that covers the typos this exists for — `tittle`,
262
+ * `descriptoin`, `keywrods` — without reaching so far that `image` gets
263
+ * suggested for `alt`. Case is ignored first, so `Title` resolves exactly.
264
+ */
265
+ function suggestKey(written, known) {
266
+ const lowered = written.toLowerCase();
267
+ const sameLetters = known.find((candidate) => candidate.toLowerCase() === lowered);
268
+ if (sameLetters !== void 0) return sameLetters;
269
+ let best;
270
+ let bestDistance = Number.POSITIVE_INFINITY;
271
+ for (const candidate of known) {
272
+ const distance = editDistance(lowered, candidate.toLowerCase());
273
+ if (distance < bestDistance) {
274
+ best = candidate;
275
+ bestDistance = distance;
276
+ }
277
+ }
278
+ return bestDistance <= 2 ? best : void 0;
279
+ }
280
+ /** The name an object key denotes, or `undefined` when knowing it needs evaluation. */
281
+ function propertyKeyName(property) {
282
+ if (property.computed) return void 0;
283
+ const { key } = property;
284
+ if (key.type === "Identifier") return key.name;
285
+ if (key.type === "StringLiteral") return key.value;
286
+ }
287
+ /** The nested objects that carry a key set of their own. */
288
+ const NESTED_METADATA_KEYS = {
289
+ openGraph: OPEN_GRAPH_KEYS,
290
+ twitter: TWITTER_KEYS
291
+ };
292
+ /**
293
+ * Every unknown key in one metadata object literal, and in the `openGraph` /
294
+ * `twitter` literals inside it.
295
+ *
296
+ * A SPREAD does not suppress the check, unlike the route reader's rule: a
297
+ * spread can only ADD keys, and no value it contributes can make a key written
298
+ * out beside it correct. A COMPUTED key is skipped — its name is not knowable
299
+ * without running the page, and refusing what cannot be read would fail builds
300
+ * that are fine. Both are silence in the narrow places where the parse genuinely
301
+ * does not know, and the annotation is the second net there.
302
+ */
303
+ function collectUnknownKeys(object, allowed, container, into) {
304
+ for (const property of object.properties) {
305
+ if (property.type === "SpreadElement") continue;
306
+ const key = propertyKeyName(property);
307
+ if (key === void 0) continue;
308
+ if (!allowed.includes(key)) {
309
+ const suggestion = suggestKey(key, allowed);
310
+ into.push({
311
+ container,
312
+ key,
313
+ line: property.loc?.start.line ?? 0,
314
+ ...suggestion === void 0 ? {} : { suggestion }
315
+ });
316
+ continue;
317
+ }
318
+ const nested = container === "metadata" ? NESTED_METADATA_KEYS[key] : void 0;
319
+ if (nested === void 0 || property.type !== "ObjectProperty") continue;
320
+ const value = unwrapValue(property.value);
321
+ if (value.type === "ObjectExpression") collectUnknownKeys(value, nested, `${container}.${key}`, into);
322
+ }
323
+ }
324
+ /**
325
+ * Every object literal a function form RETURNS, without descending into
326
+ * functions nested inside it — a callback's return value is not the metadata.
327
+ *
328
+ * A generic walk rather than a statement-by-statement one because a `return` is
329
+ * legal anywhere a statement is: inside an `if`, a `switch`, a `try`. Enumerating
330
+ * the statement types that may contain one is a list that is wrong the moment
331
+ * the language grows.
332
+ */
333
+ function collectReturnedObjects(node, into) {
334
+ if (node === null || typeof node !== "object") return;
335
+ if (Array.isArray(node)) {
336
+ for (const item of node) collectReturnedObjects(item, into);
337
+ return;
338
+ }
339
+ const candidate = node;
340
+ if (candidate.type === "FunctionDeclaration" || candidate.type === "FunctionExpression" || candidate.type === "ArrowFunctionExpression" || candidate.type === "ObjectMethod" || candidate.type === "ClassMethod") return;
341
+ if (candidate.type === "ReturnStatement") {
342
+ if (candidate.argument === null || candidate.argument === void 0) return;
343
+ const returned = unwrapValue(candidate.argument);
344
+ if (returned.type === "ObjectExpression") into.push(returned);
345
+ return;
346
+ }
347
+ for (const value of Object.values(node)) collectReturnedObjects(value, into);
348
+ }
349
+ /** The metadata object literals one `metadata` export declares, if any can be seen at all. */
350
+ function metadataObjectsOf(init) {
351
+ const value = unwrapValue(init);
352
+ if (value.type === "ObjectExpression") return [value];
353
+ if (value.type === "ArrowFunctionExpression" || value.type === "FunctionExpression") {
354
+ const body = unwrapValue(value.body);
355
+ if (body.type === "ObjectExpression") return [body];
356
+ const returned = [];
357
+ collectReturnedObjects(value.body, returned);
358
+ return returned;
359
+ }
360
+ return [];
361
+ }
362
+ /**
363
+ * The unknown keys a page's `metadata` export declares, read by PARSING — the
364
+ * same rule the rest of this module lives by, and the reason this check can run
365
+ * before anything is built.
366
+ *
367
+ * Empty for a page with no `metadata` export, for one whose metadata is a value
368
+ * this file cannot see into, and for a correct one.
369
+ */
370
+ function readMetadataKeys(pageFile, source) {
371
+ let program;
372
+ try {
373
+ program = parse(source, {
374
+ sourceType: "module",
375
+ plugins: ["typescript", "jsx"],
376
+ errorRecovery: false
377
+ }).program;
378
+ } catch (error) {
379
+ 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.`);
380
+ }
381
+ const unknownKeys = [];
382
+ for (const statement of program.body) {
383
+ if (statement.type !== "ExportNamedDeclaration" || statement.exportKind === "type") continue;
384
+ const { declaration } = statement;
385
+ if (declaration?.type !== "VariableDeclaration") continue;
386
+ for (const declarator of declaration.declarations) {
387
+ if (declarator.id.type !== "Identifier" || declarator.id.name !== "metadata") continue;
388
+ if (declarator.init === null || declarator.init === void 0) continue;
389
+ for (const object of metadataObjectsOf(declarator.init)) collectUnknownKeys(object, METADATA_KEYS, "metadata", unknownKeys);
390
+ }
391
+ }
392
+ return unknownKeys;
393
+ }
394
+ /**
192
395
  * The declared exports of one file, or a thrown
193
396
  * {@link NonLiteralRouteExportError} when they cannot be read without running
194
397
  * the application. Layouts are read once per run and remembered: a layout is
195
398
  * the nearest one for every page beside it, and parsing it once per page would
196
399
  * be the same answer bought repeatedly.
400
+ *
401
+ * `source` is the file's text when the caller already holds it — the page loop
402
+ * reads each page once and spends that read on both the route declarations and
403
+ * the metadata check, rather than opening the same file twice.
197
404
  */
198
- function readDeclarations(sourceFile, cache) {
405
+ function readDeclarations(sourceFile, cache, source) {
199
406
  let result = cache.get(sourceFile);
200
407
  if (result === void 0) {
201
- result = readRouteExports(sourceFile);
408
+ result = readRouteExports(sourceFile, source);
202
409
  cache.set(sourceFile, result);
203
410
  }
204
411
  if (!result.ok) throw new NonLiteralRouteExportError(result.rejection);
@@ -264,7 +471,9 @@ function readLayoutShape(layoutFile, cache) {
264
471
  * to return at all — the alternative is an artefact in which one of them is
265
472
  * silently unreachable; a `route` or `prefix` that cannot be read without
266
473
  * 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
474
+ * all; a `metadata` export declaring a key nothing reads, which no compiler
475
+ * catches unless the page opted into the type and which otherwise serves a page
476
+ * with a silently missing tag; and a page whose layout chain holds more than one RENDERING layout, which
268
477
  * the production installer would refuse anyway — discovery refuses it first so
269
478
  * that artefact is never produced.
270
479
  */
@@ -279,8 +488,13 @@ function discoverPages(options) {
279
488
  const relativeToApp = (file) => toPosix(path.relative(appRoot, file));
280
489
  const pages = [];
281
490
  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));
491
+ const pageSource = fs.readFileSync(pageFile, "utf-8");
492
+ const { route } = readDeclarations(pageFile, declarations, pageSource);
493
+ const isNotFoundPage = isNotFoundPageFile(pageFile);
494
+ if (isNotFoundPage && route !== void 0) throw new NotFoundPageDeclaresRouteError(relativeToApp(pageFile));
495
+ if (route === void 0 && !isNotFoundPage) throw new MissingRouteExportError(relativeToApp(pageFile));
496
+ const unknownMetadataKeys = readMetadataKeys(relativeToApp(pageFile), pageSource);
497
+ if (unknownMetadataKeys.length > 0) throw new UnknownMetadataKeyError(relativeToApp(pageFile), unknownMetadataKeys);
284
498
  const layouts = layoutChainFor(pageFile, webRoot);
285
499
  const shapes = layouts.map((layoutFile) => readLayoutShape(layoutFile, layoutShapes));
286
500
  const selection = selectPageLayout(layouts.map((layout, index) => ({
@@ -291,15 +505,15 @@ function discoverPages(options) {
291
505
  const middlewareLayouts = layouts.filter((_, index) => shapes[index].hasMiddleware);
292
506
  const layoutPrefix = layouts.reduce((composed, layoutFile) => composeRoutePath(composed, readDeclarations(layoutFile, declarations).prefix ?? "/"), "/");
293
507
  pages.push({
294
- routeName: route.name ?? deriveFallbackRouteName({
295
- routePath: route.path,
508
+ routeName: isNotFoundPage ? NOT_FOUND_ROUTE_NAME : route?.name ?? deriveFallbackRouteName({
509
+ routePath: route?.path ?? "/",
296
510
  sourceFile: relativeToApp(pageFile)
297
511
  }),
298
- routePath: composeRoutePath(layoutPrefix, route.path),
512
+ routePath: isNotFoundPage ? "*" : composeRoutePath(layoutPrefix, route?.path ?? "/"),
299
513
  pageFile,
300
514
  webRoot,
301
- layouts,
302
- middlewareLayouts,
515
+ layouts: isNotFoundPage ? [] : layouts,
516
+ middlewareLayouts: isNotFoundPage ? [] : middlewareLayouts,
303
517
  ...hasAppFile ? { appFile } : {}
304
518
  });
305
519
  }
@@ -309,5 +523,5 @@ function discoverPages(options) {
309
523
  }
310
524
 
311
525
  //#endregion
312
- export { DuplicatePageRouteNameError, discoverPageFiles, discoverPages, discoverWebRoots, isFile, layoutChainFor, toPosix, walkFiles };
526
+ export { DuplicatePageRouteNameError, MissingRouteExportError, discoverPageFiles, discoverPages, discoverWebRoots, isFile, layoutChainFor, toPosix, walkFiles };
313
527
  //# sourceMappingURL=discover-pages.mjs.map