@warlock.js/web 5.0.1 → 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.
- package/CHANGELOG.md +76 -0
- package/esm/build/contribution.d.mts +10 -0
- package/esm/build/contribution.mjs +36 -0
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +226 -12
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-client-registry.mjs.map +1 -1
- package/esm/client/navigation/navigation-root.mjs +43 -6
- package/esm/client/navigation/navigation-root.mjs.map +1 -1
- package/esm/client/navigation/scroll-to-fragment.mjs +26 -0
- package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -0
- package/esm/metadata.d.mts +14 -0
- package/esm/metadata.mjs +45 -0
- package/esm/metadata.mjs.map +1 -0
- package/esm/routing/url-fragment.mjs +120 -0
- package/esm/routing/url-fragment.mjs.map +1 -0
- package/esm/server/create-page-route-handler.d.mts +27 -0
- package/esm/server/create-page-route-handler.mjs +12 -10
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/index.d.mts +2 -1
- package/esm/server/index.mjs +2 -1
- package/esm/server/install-page-routes-from-manifest.mjs +23 -1
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +4 -2
- package/esm/server/install-page-routes.mjs +33 -5
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/install-production-page-routes.mjs +6 -1
- package/esm/server/install-production-page-routes.mjs.map +1 -1
- package/esm/server/not-found-page.d.mts +126 -0
- package/esm/server/not-found-page.mjs +157 -0
- package/esm/server/not-found-page.mjs.map +1 -0
- package/esm/server/web-connector-factory.mjs +2 -1
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.mjs +123 -5
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/vite/hydration-entries.mjs +8 -4
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.mjs +211 -0
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/llms-full.txt +33 -5
- package/llms.txt +4 -2
- package/package.json +3 -3
- package/skills/create-a-page/SKILL.md +18 -2
- package/skills/navigate-on-the-client/SKILL.md +2 -0
- package/skills/use-layouts/SKILL.md +6 -0
- package/skills/write-the-root/SKILL.md +2 -0
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"discover-pages.mjs","names":[],"sources":["../../../../../../../web/src/build/discover-pages.ts"],"sourcesContent":["/**\n * Static discovery of the application's page graph — the ONE scan every\n * provider shares.\n *\n * This module has a single responsibility: look at the filesystem and produce\n * the recipe. It writes nothing, knows nothing about the generated barrel, and\n * imports neither Vite nor a single application module. Everything that turns\n * the recipe into an artefact (the production barrel, the dev plugin, the\n * generated client entry) lives with that artefact and consumes\n * {@link discoverPages}.\n *\n * The reason it is one module rather than one function per consumer: two\n * scanners that agree today drift tomorrow, and a page that exists for the\n * server but not the client is the silent failure that costs a day to find.\n * Sharing the scan makes that disagreement impossible by construction instead\n * of catchable by test.\n *\n * \"Static\" means WITHOUT RUNNING THE APPLICATION — this module globs\n * `*.page.tsx`, `layout.tsx` and `root.tsx`, and reads each page's declared\n * `route` and each layout's declared `prefix` by PARSING the source\n * ({@link readRouteExports}). It still imports no application module.\n *\n * The route a page is served under is the one the page DECLARES, not the one\n * its directory suggests, so that is the route discovery reports. The\n * composition — EVERY layout `prefix` on the page's path, outermost first, plus\n * the page's own `route` path — and the fallback used when a route omits its\n * name are mirrored from the server's installer (`installPageRoutes`),\n * deliberately and in one direction: build and boot agree because one of them\n * copies the other, not because two conventions were written to match.\n *\n * Discovery also CLASSIFIES each layout — does its module have a default\n * export, does it export `middleware` — because the layout policy\n * ({@link \"../routing/layout-policy.ts\"}) owns the rule but may not touch a\n * filesystem to learn the facts the rule needs. That classification is another\n * parse, never an import: a layout is read exactly the way a page's `route` is.\n */\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { parse } from \"@babel/parser\";\nimport { composeRoutePath } from \"../routing/compose-route-path\";\nimport { NestedLayoutsNotSupportedError, selectPageLayout } from \"../routing/layout-policy\";\nimport { deriveFallbackRouteName } from \"../routing/route-identity\";\nimport type { RouteExportsReadResult } from \"./read-route-exports\";\nimport { NonLiteralRouteExportError, readRouteExports } from \"./read-route-exports\";\n\nexport type DiscoverPagesOptions = {\n /** Absolute path to the application root (where `package.json` lives). */\n appRoot: string;\n /** Source directory name under `appRoot`; defaults to `\"src\"`. */\n srcDir?: string;\n};\n\nexport type DiscoveredPage = {\n /**\n * The page's route name: the `name` its `route` export declares, or the\n * server's own fallback derivation — `<module>.<declared path with dots>` for\n * a page in a module's web tree, the dotted path alone for one in the global\n * tree, and `index` when neither has anything to say.\n *\n * Unique across the whole graph — {@link discoverPages} refuses to return a\n * result where it is not.\n */\n routeName: string;\n /**\n * The page's EFFECTIVE route path: the declared `prefix` of EVERY layout on\n * its path — outermost first, wherever in the ancestry each lives — composed\n * in order with the page's own declared `route` path, which is the path the\n * server registers it under. A layout that declares no `prefix` contributes\n * nothing. Always `/`-prefixed.\n *\n * Every layout, not just the rendering one: a `prefix`-only layout is a real\n * segment of the URL its subtree lives under, and skipping it would serve the\n * subtree from a path nobody wrote down.\n */\n routePath: string;\n /** Absolute path to the `*.page.tsx` file. */\n pageFile: string;\n /** The web root this page was found under — the root its layout chain climbs to. */\n webRoot: string;\n /** Absolute paths of every `layout.tsx` from the web root down to the page's own directory, OUTERMOST FIRST. */\n layouts: string[];\n /**\n * The page's middleware chain: the subset of {@link layouts} whose modules\n * export `middleware`, OUTERMOST FIRST — the order they must run in.\n *\n * Source files rather than the middleware values themselves, because\n * discovery never runs application code: this says WHICH layouts contribute a\n * guard, and the consumer that already loads those modules reads the values\n * off them.\n *\n * Empty when no layout on the path declares middleware, which is the common\n * case; a page with no layouts always has an empty chain.\n */\n middlewareLayouts: string[];\n /** Absolute path to the global `root.tsx` every page renders inside, when it exists. */\n appFile?: string;\n};\n\n/** Raised when two pages claim one route name. */\nexport class DuplicatePageRouteNameError extends Error {\n public constructor(\n public readonly routeName: string,\n public readonly firstFile: string,\n public readonly secondFile: string,\n ) {\n super(\n `Two pages resolve to the same route name \"${routeName}\": \"${firstFile}\" and ` +\n `\"${secondFile}\". A route name identifies exactly one page, so the second ` +\n \"page would be unreachable. To fix: rename one of the files, or move it so \" +\n \"its directory gives it a different route name.\",\n );\n this.name = \"DuplicatePageRouteNameError\";\n }\n}\n\nexport function toPosix(value: string): string {\n return value.replace(/\\\\/g, \"/\");\n}\n\nfunction isDirectory(candidate: string): boolean {\n try {\n return fs.statSync(candidate).isDirectory();\n } catch {\n return false;\n }\n}\n\nexport function isFile(candidate: string): boolean {\n try {\n return fs.statSync(candidate).isFile();\n } catch {\n return false;\n }\n}\n\n/**\n * The two page roots: the global `src/web/**` tree and each\n * module's `src/app/<module>/web/**` tree. Both are optional; a project with\n * neither has zero pages, which is a legal empty state.\n */\nexport function discoverWebRoots(srcRoot: string): string[] {\n const roots: string[] = [];\n const globalRoot = path.join(srcRoot, \"web\");\n\n if (isDirectory(globalRoot)) {\n roots.push(globalRoot);\n }\n\n const appDir = path.join(srcRoot, \"app\");\n\n if (isDirectory(appDir)) {\n for (const entry of fs.readdirSync(appDir, { withFileTypes: true }).sort(byName)) {\n if (!entry.isDirectory()) continue;\n\n const moduleWebRoot = path.join(appDir, entry.name, \"web\");\n\n if (isDirectory(moduleWebRoot)) {\n roots.push(moduleWebRoot);\n }\n }\n }\n\n return roots;\n}\n\nfunction byName(left: { name: string }, right: { name: string }): number {\n return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;\n}\n\nexport type DiscoveredPageFile = {\n /** Absolute path to the `*.page.tsx` file. */\n pageFile: string;\n /** The web root ({@link discoverWebRoots}) this page was found under. */\n webRoot: string;\n};\n\n/**\n * The subject list: every `*.page.tsx` under BOTH web roots, one call for the\n * whole graph.\n *\n * Unlike {@link discoverPages}, this reads no `route` or `prefix` export and\n * throws on nothing — it answers only \"which page files exist\", so a provider\n * that still resolves a page's route by its own means (dev's\n * `ssrLoadModule`-driven installer, chiefly) can share the walk without\n * inheriting the static-parsing refusals that answering \"what route is this\"\n * requires.\n */\nexport function discoverPageFiles(srcRoot: string): DiscoveredPageFile[] {\n const found: DiscoveredPageFile[] = [];\n\n for (const webRoot of discoverWebRoots(srcRoot)) {\n for (const pageFile of walkFiles(webRoot, (fileName) => fileName.endsWith(\".page.tsx\"))) {\n found.push({ pageFile, webRoot });\n }\n }\n\n return found;\n}\n\n/** Every file under `dir` (recursive) whose name matches `predicate`. */\nexport function walkFiles(dir: string, predicate: (fileName: string) => boolean): string[] {\n const found: string[] = [];\n\n for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort(byName)) {\n const full = path.join(dir, entry.name);\n\n if (entry.isDirectory()) {\n found.push(...walkFiles(full, predicate));\n } else if (entry.isFile() && predicate(entry.name)) {\n found.push(full);\n }\n }\n\n return found;\n}\n\n/**\n * A page's layout chain: every `layout.tsx` in the directories from its web\n * root down to its own directory, OUTERMOST FIRST.\n *\n * Dev's installer reads only the nearest layout today, because its page module\n * triple holds a single layout; the recipe carries the whole chain so the\n * runtime side can compose\n * nested layouts without a second discovery pass. The nearest layout is always\n * the LAST element, so a consumer that still wants dev's one-layout behaviour\n * reads `layouts.at(-1)`.\n */\nexport function layoutChainFor(pageFile: string, webRoot: string): string[] {\n const chain: string[] = [];\n const relativeDir = path.relative(webRoot, path.dirname(pageFile));\n const segments = relativeDir === \"\" ? [] : relativeDir.split(path.sep);\n\n let current = webRoot;\n\n for (let index = 0; index <= segments.length; index++) {\n if (index > 0) {\n current = path.join(current, segments[index - 1]);\n }\n\n const candidate = path.join(current, \"layout.tsx\");\n\n if (isFile(candidate)) {\n chain.push(candidate);\n }\n }\n\n return chain;\n}\n\nfunction compareStrings(left: string, right: string): number {\n return left < right ? -1 : left > right ? 1 : 0;\n}\n\n/**\n * The total order every consumer sees, and the reason discovery returns an\n * array rather than a set: the page's SOURCE FILE PATH, lexicographic, POSIX.\n *\n * Serialization order only; matching precedence is a property of the route\n * grammar, not of this array.\n *\n * The file path rather than the route path, now that the route is the declared\n * one: a page's route can be rewritten by editing one line, which would reorder\n * an artefact that has not otherwise changed, while the file it lives in is the\n * stable identity the artefact is built from. It also carries no suggestion of\n * precedence — nobody reads \"sorted by file name\" as \"most specific first\",\n * which is the misreading a route-path order invites.\n *\n * Lexicographic because it is byte-comparable output across every provider,\n * which keeps diffs stable and keeps filesystem enumeration order out of the\n * artefact: two machines that list a directory differently must still produce\n * byte-identical output, or a build is only reproducible by luck.\n */\nfunction comparePages(left: DiscoveredPage, right: DiscoveredPage): number {\n return compareStrings(toPosix(left.pageFile), toPosix(right.pageFile));\n}\n\nfunction assertUniqueRouteNames(pages: readonly DiscoveredPage[], appRoot: string): void {\n const fileByRouteName = new Map<string, string>();\n\n for (const page of pages) {\n const existing = fileByRouteName.get(page.routeName);\n const relative = toPosix(path.relative(appRoot, page.pageFile));\n\n if (existing !== undefined) {\n throw new DuplicatePageRouteNameError(page.routeName, existing, relative);\n }\n\n fileByRouteName.set(page.routeName, relative);\n }\n}\n\n/** Raised when a `*.page.tsx` declares no `route` export. */\nexport class MissingRouteExportError extends Error {\n public constructor(public readonly pageFile: string) {\n super(\n `\"${pageFile}\" is a page file but declares no \\`route\\` export. A page with no route is a ` +\n \"page the dev server would still serve and production would 404 on, so the build refuses \" +\n `it instead. For example: export const route = \"/list\";`,\n );\n this.name = \"MissingRouteExportError\";\n }\n}\n\n/**\n * The declared exports of one file, or a thrown\n * {@link NonLiteralRouteExportError} when they cannot be read without running\n * the application. Layouts are read once per run and remembered: a layout is\n * the nearest one for every page beside it, and parsing it once per page would\n * be the same answer bought repeatedly.\n */\nfunction readDeclarations(sourceFile: string, cache: Map<string, RouteExportsReadResult>) {\n let result = cache.get(sourceFile);\n\n if (result === undefined) {\n result = readRouteExports(sourceFile);\n cache.set(sourceFile, result);\n }\n\n if (!result.ok) {\n throw new NonLiteralRouteExportError(result.rejection);\n }\n\n return result;\n}\n\n/**\n * What a layout DOES, read by parsing it — the facts the layout policy's rule\n * needs and, being a pure module, cannot go and find for itself.\n */\ntype LayoutShape = {\n /**\n * Whether the module has a default export — the export that puts an element\n * in the document, and therefore the one thing that makes a layout count\n * against the single-rendering-layout rule.\n */\n renders: boolean;\n /** Whether the module exports `middleware`, or might via a re-export this cannot see through. */\n hasMiddleware: boolean;\n};\n\n/**\n * Parses one layout and reports its shape. Remembered per run for the same\n * reason declarations are: a layout is on the path of every page beneath it.\n */\nfunction readLayoutShape(layoutFile: string, cache: Map<string, LayoutShape>): LayoutShape {\n const cached = cache.get(layoutFile);\n\n if (cached !== undefined) return cached;\n\n const source = fs.readFileSync(layoutFile, \"utf-8\");\n let program: ReturnType<typeof parse>[\"program\"];\n\n try {\n program = parse(source, {\n sourceType: \"module\",\n // Every file this reads is a layout, i.e. `.tsx`.\n plugins: [\"typescript\", \"jsx\"],\n errorRecovery: false,\n }).program;\n } catch (error) {\n throw new Error(\n `Cannot read the exports of \"${layoutFile}\": the file could not be parsed ` +\n `(${(error as Error).message}). Fix the syntax error and the build will continue.`,\n );\n }\n\n const shape: LayoutShape = { renders: false, hasMiddleware: false };\n\n for (const statement of program.body) {\n if (statement.type === \"ExportDefaultDeclaration\") {\n shape.renders = true;\n continue;\n }\n\n // `export * from \"./guard\"` cannot re-export a default — the language\n // excludes it — but it CAN contribute `middleware`, and no parse can see\n // through it without resolving and reading another module. Reading it as\n // \"no middleware here\" is exactly the silent unguarding this slice exists\n // to prevent, so it is read as \"possibly\" and fails loudly downstream.\n if (statement.type === \"ExportAllDeclaration\") {\n shape.hasMiddleware = true;\n continue;\n }\n\n if (statement.type !== \"ExportNamedDeclaration\" || statement.exportKind === \"type\") continue;\n\n for (const specifier of statement.specifiers) {\n if (specifier.type !== \"ExportSpecifier\" || specifier.exportKind === \"type\") continue;\n\n const exported =\n specifier.exported.type === \"Identifier\"\n ? specifier.exported.name\n : specifier.exported.value;\n\n if (exported === \"default\") shape.renders = true;\n if (exported === \"middleware\") shape.hasMiddleware = true;\n }\n\n const { declaration } = statement;\n\n if (declaration === null || declaration === undefined) continue;\n\n if (declaration.type === \"VariableDeclaration\") {\n for (const declarator of declaration.declarations) {\n if (declarator.id.type === \"Identifier\" && declarator.id.name === \"middleware\") {\n shape.hasMiddleware = true;\n }\n }\n\n continue;\n }\n\n if (\n (declaration.type === \"FunctionDeclaration\" || declaration.type === \"ClassDeclaration\") &&\n declaration.id?.name === \"middleware\"\n ) {\n shape.hasMiddleware = true;\n }\n }\n\n cache.set(layoutFile, shape);\n\n return shape;\n}\n\n/**\n * Scans both web roots and returns the pages in a defined total order.\n *\n * Zero pages is a legal result, not an error: a project may be configured\n * with web and have nothing to serve yet. What is an error is a `*.page.tsx`\n * with no `route` export, which is refused rather than silently omitted — an\n * artefact that leaves a page out is a page the dev server still serves and\n * production 404s on; two pages claiming one route name, which this refuses\n * to return at all — the alternative is an artefact in which one of them is\n * silently unreachable; a `route` or `prefix` that cannot be read without\n * running the application, which is refused before any page is reported at\n * all; and a page whose layout chain holds more than one RENDERING layout, which\n * the production installer would refuse anyway — discovery refuses it first so\n * that artefact is never produced.\n */\nexport function discoverPages(options: DiscoverPagesOptions): DiscoveredPage[] {\n const { appRoot } = options;\n const srcRoot = path.join(appRoot, options.srcDir ?? \"src\");\n const webRoots = discoverWebRoots(srcRoot);\n const appFile = path.join(srcRoot, \"web\", \"root.tsx\");\n const hasAppFile = isFile(appFile);\n const declarations = new Map<string, RouteExportsReadResult>();\n const layoutShapes = new Map<string, LayoutShape>();\n const relativeToApp = (file: string) => toPosix(path.relative(appRoot, file));\n\n const pages: DiscoveredPage[] = [];\n\n for (const webRoot of webRoots) {\n for (const pageFile of walkFiles(webRoot, (fileName) => fileName.endsWith(\".page.tsx\"))) {\n const { route } = readDeclarations(pageFile, declarations);\n\n if (route === undefined) {\n throw new MissingRouteExportError(relativeToApp(pageFile));\n }\n\n // The policy decides which layout the page RENDERS INSIDE, from the FULL\n // enumerated chain: a layout anywhere on the ancestry path counts, not\n // just one in the page's own directory. Discovery supplies the one fact\n // the rule needs and the pure policy cannot learn — whether each layout\n // renders anything at all.\n const layouts = layoutChainFor(pageFile, webRoot);\n const shapes = layouts.map((layoutFile) => readLayoutShape(layoutFile, layoutShapes));\n const selection = selectPageLayout(\n layouts.map((layout, index) => ({ layout, renders: shapes[index].renders })),\n );\n\n if (selection.type === \"rejected\") {\n throw new NestedLayoutsNotSupportedError(\n relativeToApp(pageFile),\n selection.layouts.map(relativeToApp),\n );\n }\n\n // Every layout that declares a guard, outermost first. Both installers\n // now CONCATENATE the whole chain into the pipeline's single layout slot\n // (`../server/install-page-routes.ts`, `../server/install-page-routes-from-manifest.ts`),\n // so a guard anywhere on the path runs, in this order — which is why the\n // temporary refusal that used to stand here is gone rather than relaxed.\n const middlewareLayouts = layouts.filter((_, index) => shapes[index].hasMiddleware);\n\n // EVERY prefix on the path, outermost first: a `prefix`-only layout is\n // still a segment of the URL, and composing only the rendering layout's\n // would serve the subtree from a path nobody declared.\n const layoutPrefix = layouts.reduce(\n (composed, layoutFile) =>\n composeRoutePath(composed, readDeclarations(layoutFile, declarations).prefix ?? \"/\"),\n \"/\",\n );\n\n pages.push({\n routeName:\n route.name ??\n deriveFallbackRouteName({\n routePath: route.path,\n sourceFile: relativeToApp(pageFile),\n }),\n routePath: composeRoutePath(layoutPrefix, route.path),\n pageFile,\n webRoot,\n layouts,\n middlewareLayouts,\n ...(hasAppFile ? { appFile } : {}),\n });\n }\n }\n\n pages.sort(comparePages);\n\n assertUniqueRouteNames(pages, appRoot);\n\n return pages;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"discover-pages.mjs","names":[],"sources":["../../../../../../../web/src/build/discover-pages.ts"],"sourcesContent":["/**\n * Static discovery of the application's page graph — the ONE scan every\n * provider shares.\n *\n * This module has a single responsibility: look at the filesystem and produce\n * the recipe. It writes nothing, knows nothing about the generated barrel, and\n * imports neither Vite nor a single application module. Everything that turns\n * the recipe into an artefact (the production barrel, the dev plugin, the\n * generated client entry) lives with that artefact and consumes\n * {@link discoverPages}.\n *\n * The reason it is one module rather than one function per consumer: two\n * scanners that agree today drift tomorrow, and a page that exists for the\n * server but not the client is the silent failure that costs a day to find.\n * Sharing the scan makes that disagreement impossible by construction instead\n * of catchable by test.\n *\n * \"Static\" means WITHOUT RUNNING THE APPLICATION — this module globs\n * `*.page.tsx`, `layout.tsx` and `root.tsx`, and reads each page's declared\n * `route` and each layout's declared `prefix` by PARSING the source\n * ({@link readRouteExports}). It still imports no application module.\n *\n * The route a page is served under is the one the page DECLARES, not the one\n * its directory suggests, so that is the route discovery reports. The\n * composition — EVERY layout `prefix` on the page's path, outermost first, plus\n * the page's own `route` path — and the fallback used when a route omits its\n * name are mirrored from the server's installer (`installPageRoutes`),\n * deliberately and in one direction: build and boot agree because one of them\n * copies the other, not because two conventions were written to match.\n *\n * Discovery also CLASSIFIES each layout — does its module have a default\n * export, does it export `middleware` — because the layout policy\n * ({@link \"../routing/layout-policy.ts\"}) owns the rule but may not touch a\n * filesystem to learn the facts the rule needs. That classification is another\n * parse, never an import: a layout is read exactly the way a page's `route` is.\n *\n * And it CHECKS the page's `metadata` keys ({@link UnknownMetadataKeyError}) —\n * see that error and {@link readMetadataKeys} for why a type alone does not\n * close that hole.\n */\nimport fs from \"node:fs\";\nimport path from \"node:path\";\nimport { parse } from \"@babel/parser\";\nimport { composeRoutePath } from \"../routing/compose-route-path\";\n// `../server/not-found-page` is imported for its CONSTANTS and its filename\n// predicate only — that module has no runtime imports of its own, precisely so\n// this edge cannot drag the render pipeline into the build. What a not-found\n// page is, and what identity it carries, is decided in one place for discovery\n// and both installers.\nimport {\n isNotFoundPageFile,\n NotFoundPageDeclaresRouteError,\n NOT_FOUND_ROUTE_NAME,\n NOT_FOUND_ROUTE_PATH,\n} from \"../server/not-found-page\";\n// The metadata key set, as VALUES. `../metadata` is a types-plus-constants\n// module with no runtime imports of its own (both its imports are `import\n// type`), so this costs the build nothing and buys the one thing a parse cannot\n// get from a type: the list of keys a page is allowed to write.\nimport { METADATA_KEYS, OPEN_GRAPH_KEYS, TWITTER_KEYS } from \"../metadata\";\nimport { NestedLayoutsNotSupportedError, selectPageLayout } from \"../routing/layout-policy\";\nimport { deriveFallbackRouteName } from \"../routing/route-identity\";\nimport type { RouteExportsReadResult } from \"./read-route-exports\";\nimport { NonLiteralRouteExportError, readRouteExports } from \"./read-route-exports\";\n\nexport type DiscoverPagesOptions = {\n /** Absolute path to the application root (where `package.json` lives). */\n appRoot: string;\n /** Source directory name under `appRoot`; defaults to `\"src\"`. */\n srcDir?: string;\n};\n\nexport type DiscoveredPage = {\n /**\n * The page's route name: the `name` its `route` export declares, or the\n * server's own fallback derivation — `<module>.<declared path with dots>` for\n * a page in a module's web tree, the dotted path alone for one in the global\n * tree, and `index` when neither has anything to say.\n *\n * Unique across the whole graph — {@link discoverPages} refuses to return a\n * result where it is not.\n */\n routeName: string;\n /**\n * The page's EFFECTIVE route path: the declared `prefix` of EVERY layout on\n * its path — outermost first, wherever in the ancestry each lives — composed\n * in order with the page's own declared `route` path, which is the path the\n * server registers it under. A layout that declares no `prefix` contributes\n * nothing. Always `/`-prefixed.\n *\n * Every layout, not just the rendering one: a `prefix`-only layout is a real\n * segment of the URL its subtree lives under, and skipping it would serve the\n * subtree from a path nobody wrote down.\n */\n routePath: string;\n /** Absolute path to the `*.page.tsx` file. */\n pageFile: string;\n /** The web root this page was found under — the root its layout chain climbs to. */\n webRoot: string;\n /** Absolute paths of every `layout.tsx` from the web root down to the page's own directory, OUTERMOST FIRST. */\n layouts: string[];\n /**\n * The page's middleware chain: the subset of {@link layouts} whose modules\n * export `middleware`, OUTERMOST FIRST — the order they must run in.\n *\n * Source files rather than the middleware values themselves, because\n * discovery never runs application code: this says WHICH layouts contribute a\n * guard, and the consumer that already loads those modules reads the values\n * off them.\n *\n * Empty when no layout on the path declares middleware, which is the common\n * case; a page with no layouts always has an empty chain.\n */\n middlewareLayouts: string[];\n /** Absolute path to the global `root.tsx` every page renders inside, when it exists. */\n appFile?: string;\n};\n\n/** Raised when two pages claim one route name. */\nexport class DuplicatePageRouteNameError extends Error {\n public constructor(\n public readonly routeName: string,\n public readonly firstFile: string,\n public readonly secondFile: string,\n ) {\n super(\n `Two pages resolve to the same route name \"${routeName}\": \"${firstFile}\" and ` +\n `\"${secondFile}\". A route name identifies exactly one page, so the second ` +\n \"page would be unreachable. To fix: rename one of the files, or move it so \" +\n \"its directory gives it a different route name.\",\n );\n this.name = \"DuplicatePageRouteNameError\";\n }\n}\n\nexport function toPosix(value: string): string {\n return value.replace(/\\\\/g, \"/\");\n}\n\nfunction isDirectory(candidate: string): boolean {\n try {\n return fs.statSync(candidate).isDirectory();\n } catch {\n return false;\n }\n}\n\nexport function isFile(candidate: string): boolean {\n try {\n return fs.statSync(candidate).isFile();\n } catch {\n return false;\n }\n}\n\n/**\n * The two page roots: the global `src/web/**` tree and each\n * module's `src/app/<module>/web/**` tree. Both are optional; a project with\n * neither has zero pages, which is a legal empty state.\n */\nexport function discoverWebRoots(srcRoot: string): string[] {\n const roots: string[] = [];\n const globalRoot = path.join(srcRoot, \"web\");\n\n if (isDirectory(globalRoot)) {\n roots.push(globalRoot);\n }\n\n const appDir = path.join(srcRoot, \"app\");\n\n if (isDirectory(appDir)) {\n for (const entry of fs.readdirSync(appDir, { withFileTypes: true }).sort(byName)) {\n if (!entry.isDirectory()) continue;\n\n const moduleWebRoot = path.join(appDir, entry.name, \"web\");\n\n if (isDirectory(moduleWebRoot)) {\n roots.push(moduleWebRoot);\n }\n }\n }\n\n return roots;\n}\n\nfunction byName(left: { name: string }, right: { name: string }): number {\n return left.name < right.name ? -1 : left.name > right.name ? 1 : 0;\n}\n\nexport type DiscoveredPageFile = {\n /** Absolute path to the `*.page.tsx` file. */\n pageFile: string;\n /** The web root ({@link discoverWebRoots}) this page was found under. */\n webRoot: string;\n};\n\n/**\n * The subject list: every `*.page.tsx` under BOTH web roots, one call for the\n * whole graph.\n *\n * Unlike {@link discoverPages}, this reads no `route` or `prefix` export and\n * throws on nothing — it answers only \"which page files exist\", so a provider\n * that still resolves a page's route by its own means (dev's\n * `ssrLoadModule`-driven installer, chiefly) can share the walk without\n * inheriting the static-parsing refusals that answering \"what route is this\"\n * requires.\n */\nexport function discoverPageFiles(srcRoot: string): DiscoveredPageFile[] {\n const found: DiscoveredPageFile[] = [];\n\n for (const webRoot of discoverWebRoots(srcRoot)) {\n for (const pageFile of walkFiles(webRoot, (fileName) => fileName.endsWith(\".page.tsx\"))) {\n found.push({ pageFile, webRoot });\n }\n }\n\n return found;\n}\n\n/** Every file under `dir` (recursive) whose name matches `predicate`. */\nexport function walkFiles(dir: string, predicate: (fileName: string) => boolean): string[] {\n const found: string[] = [];\n\n for (const entry of fs.readdirSync(dir, { withFileTypes: true }).sort(byName)) {\n const full = path.join(dir, entry.name);\n\n if (entry.isDirectory()) {\n found.push(...walkFiles(full, predicate));\n } else if (entry.isFile() && predicate(entry.name)) {\n found.push(full);\n }\n }\n\n return found;\n}\n\n/**\n * A page's layout chain: every `layout.tsx` in the directories from its web\n * root down to its own directory, OUTERMOST FIRST.\n *\n * Dev's installer reads only the nearest layout today, because its page module\n * triple holds a single layout; the recipe carries the whole chain so the\n * runtime side can compose\n * nested layouts without a second discovery pass. The nearest layout is always\n * the LAST element, so a consumer that still wants dev's one-layout behaviour\n * reads `layouts.at(-1)`.\n */\nexport function layoutChainFor(pageFile: string, webRoot: string): string[] {\n const chain: string[] = [];\n const relativeDir = path.relative(webRoot, path.dirname(pageFile));\n const segments = relativeDir === \"\" ? [] : relativeDir.split(path.sep);\n\n let current = webRoot;\n\n for (let index = 0; index <= segments.length; index++) {\n if (index > 0) {\n current = path.join(current, segments[index - 1]);\n }\n\n const candidate = path.join(current, \"layout.tsx\");\n\n if (isFile(candidate)) {\n chain.push(candidate);\n }\n }\n\n return chain;\n}\n\nfunction compareStrings(left: string, right: string): number {\n return left < right ? -1 : left > right ? 1 : 0;\n}\n\n/**\n * The total order every consumer sees, and the reason discovery returns an\n * array rather than a set: the page's SOURCE FILE PATH, lexicographic, POSIX.\n *\n * Serialization order only; matching precedence is a property of the route\n * grammar, not of this array.\n *\n * The file path rather than the route path, now that the route is the declared\n * one: a page's route can be rewritten by editing one line, which would reorder\n * an artefact that has not otherwise changed, while the file it lives in is the\n * stable identity the artefact is built from. It also carries no suggestion of\n * precedence — nobody reads \"sorted by file name\" as \"most specific first\",\n * which is the misreading a route-path order invites.\n *\n * Lexicographic because it is byte-comparable output across every provider,\n * which keeps diffs stable and keeps filesystem enumeration order out of the\n * artefact: two machines that list a directory differently must still produce\n * byte-identical output, or a build is only reproducible by luck.\n */\nfunction comparePages(left: DiscoveredPage, right: DiscoveredPage): number {\n return compareStrings(toPosix(left.pageFile), toPosix(right.pageFile));\n}\n\nfunction assertUniqueRouteNames(pages: readonly DiscoveredPage[], appRoot: string): void {\n const fileByRouteName = new Map<string, string>();\n\n for (const page of pages) {\n const existing = fileByRouteName.get(page.routeName);\n const relative = toPosix(path.relative(appRoot, page.pageFile));\n\n if (existing !== undefined) {\n throw new DuplicatePageRouteNameError(page.routeName, existing, relative);\n }\n\n fileByRouteName.set(page.routeName, relative);\n }\n}\n\n/** Raised when a `*.page.tsx` declares no `route` export. */\nexport class MissingRouteExportError extends Error {\n public constructor(public readonly pageFile: string) {\n super(\n `\"${pageFile}\" is a page file but declares no \\`route\\` export. A page with no route is a ` +\n \"page nothing can ever reach, so both the dev server and the build refuse it instead of \" +\n `serving a silent 404. For example: export const route = \"/list\";`,\n );\n this.name = \"MissingRouteExportError\";\n }\n}\n\n/** One key a page's `metadata` declares that nothing reads, and where it is written. */\nexport type UnknownMetadataKey = {\n /** The object it was declared in: `metadata`, `metadata.openGraph`, `metadata.twitter`. */\n container: string;\n /** The key exactly as the page wrote it. */\n key: string;\n /** 1-based line in the page file, so the message points at the character that is wrong. */\n line: number;\n /** The known key it is within two edits of, when there is one. Usually the whole answer. */\n suggestion?: string;\n};\n\n/**\n * Raised when a page's `metadata` export declares a key nothing reads.\n *\n * THE POINT OF THIS ERROR IS THE UNANNOTATED CASE. A page that writes\n * `export const metadata: PageMetadata = { tittle: \"x\" }` is already refused by\n * TypeScript, and if that were the whole story this class would not need to\n * exist. But the annotation is optional, nobody writes it, and\n * `export const metadata = { tittle: \"x\" }` is a perfectly well-typed program:\n * the compiler infers `{ tittle: string }`, has nothing to check it against, and\n * says nothing. The page is then served with no `<title>` — not a wrong title, a\n * missing one — and no error is raised anywhere, at build or at runtime, ever.\n *\n * So the check lives HERE instead, at the gate every page already passes\n * through, where a `route`-less page is refused by name for the same reason: a\n * page that silently does not work is worse than a build that stops and says\n * which line to fix.\n *\n * The alternative considered and rejected was a `defineMetadata({...})` wrapper,\n * which would infer the type for free. It also puts framework ceremony in every\n * page, and a page is meant to be two lines of framework surface (canon\n * `6ea0662f`). The gate gets the same safety without spending that.\n */\nexport class UnknownMetadataKeyError extends Error {\n public constructor(\n public readonly pageFile: string,\n public readonly unknownKeys: readonly UnknownMetadataKey[],\n ) {\n const findings = unknownKeys\n .map(({ container, key, line, suggestion }) => {\n const where = `line ${line}: \\`${container}.${key}\\` — no such key.`;\n\n return suggestion === undefined ? where : `${where} Did you mean \\`${suggestion}\\`?`;\n })\n .join(\"\\n \");\n\n super(\n `The \\`metadata\\` export of \"${pageFile}\" declares a key nothing reads:\\n ${findings}\\n` +\n \"Nothing writes an unknown key to `<head>`, so the tag it was meant to produce would \" +\n \"simply be absent from every response, with no error at build time or at runtime. The \" +\n \"build refuses it here instead.\\n\" +\n ` Known keys: ${METADATA_KEYS.join(\", \")}.\\n` +\n ` Inside \\`openGraph\\`: ${OPEN_GRAPH_KEYS.join(\", \")}.\\n` +\n ` Inside \\`twitter\\`: ${TWITTER_KEYS.join(\", \")}.\\n` +\n \"Annotating the export — `export const metadata: PageMetadata = { … }` — gets you the \" +\n \"same list as autocomplete in the editor, before the build runs.\",\n );\n this.name = \"UnknownMetadataKeyError\";\n }\n}\n\n/**\n * The AST types, derived from `parse`'s own return type rather than imported\n * from `@babel/types`, for the reason `read-route-exports.ts` gives: the parser\n * resolves its own copy of that package and nodes from one copy are not\n * assignable to the identical types from the other.\n */\ntype PageStatement = ReturnType<typeof parse>[\"program\"][\"body\"][number];\ntype PageExpression = Extract<PageStatement, { type: \"ExpressionStatement\" }>[\"expression\"];\ntype PageObjectExpression = Extract<PageExpression, { type: \"ObjectExpression\" }>;\ntype PageObjectProperty = Extract<\n PageObjectExpression[\"properties\"][number],\n { type: \"ObjectProperty\" }\n>;\ntype PageValueNode = PageObjectProperty[\"value\"];\n\n/** `as const`, `satisfies`, `!` and parentheses wrap a value without changing it. */\nfunction unwrapValue(node: PageValueNode): PageValueNode {\n switch (node.type) {\n case \"TSAsExpression\":\n case \"TSSatisfiesExpression\":\n case \"TSNonNullExpression\":\n case \"TypeCastExpression\":\n case \"ParenthesizedExpression\":\n return unwrapValue(node.expression);\n default:\n return node;\n }\n}\n\n/** Levenshtein distance — small strings, so the plain two-row table is the whole cost. */\nfunction editDistance(left: string, right: string): number {\n let previous = Array.from({ length: right.length + 1 }, (_, index) => index);\n\n for (let row = 1; row <= left.length; row++) {\n const current = [row];\n\n for (let column = 1; column <= right.length; column++) {\n const substitution = previous[column - 1] + (left[row - 1] === right[column - 1] ? 0 : 1);\n current[column] = Math.min(substitution, previous[column] + 1, current[column - 1] + 1);\n }\n\n previous = current;\n }\n\n return previous[right.length];\n}\n\n/**\n * The known key the written one was probably meant to be.\n *\n * Two edits, because that covers the typos this exists for — `tittle`,\n * `descriptoin`, `keywrods` — without reaching so far that `image` gets\n * suggested for `alt`. Case is ignored first, so `Title` resolves exactly.\n */\nfunction suggestKey(written: string, known: readonly string[]): string | undefined {\n const lowered = written.toLowerCase();\n const sameLetters = known.find((candidate) => candidate.toLowerCase() === lowered);\n\n if (sameLetters !== undefined) return sameLetters;\n\n let best: string | undefined;\n let bestDistance = Number.POSITIVE_INFINITY;\n\n for (const candidate of known) {\n const distance = editDistance(lowered, candidate.toLowerCase());\n\n if (distance < bestDistance) {\n best = candidate;\n bestDistance = distance;\n }\n }\n\n return bestDistance <= 2 ? best : undefined;\n}\n\n/** The name an object key denotes, or `undefined` when knowing it needs evaluation. */\nfunction propertyKeyName(property: PageObjectProperty): string | undefined {\n if (property.computed) return undefined;\n\n const { key } = property;\n\n if (key.type === \"Identifier\") return key.name;\n if (key.type === \"StringLiteral\") return key.value;\n\n return undefined;\n}\n\n/** The nested objects that carry a key set of their own. */\nconst NESTED_METADATA_KEYS: Record<string, readonly string[]> = {\n openGraph: OPEN_GRAPH_KEYS,\n twitter: TWITTER_KEYS,\n};\n\n/**\n * Every unknown key in one metadata object literal, and in the `openGraph` /\n * `twitter` literals inside it.\n *\n * A SPREAD does not suppress the check, unlike the route reader's rule: a\n * spread can only ADD keys, and no value it contributes can make a key written\n * out beside it correct. A COMPUTED key is skipped — its name is not knowable\n * without running the page, and refusing what cannot be read would fail builds\n * that are fine. Both are silence in the narrow places where the parse genuinely\n * does not know, and the annotation is the second net there.\n */\nfunction collectUnknownKeys(\n object: PageObjectExpression,\n allowed: readonly string[],\n container: string,\n into: UnknownMetadataKey[],\n): void {\n for (const property of object.properties) {\n if (property.type === \"SpreadElement\") continue;\n\n const key = propertyKeyName(property as PageObjectProperty);\n\n if (key === undefined) continue;\n\n if (!allowed.includes(key)) {\n const suggestion = suggestKey(key, allowed);\n\n into.push({\n container,\n key,\n line: property.loc?.start.line ?? 0,\n ...(suggestion === undefined ? {} : { suggestion }),\n });\n\n continue;\n }\n\n const nested = container === \"metadata\" ? NESTED_METADATA_KEYS[key] : undefined;\n\n if (nested === undefined || property.type !== \"ObjectProperty\") continue;\n\n const value = unwrapValue(property.value);\n\n if (value.type === \"ObjectExpression\") {\n collectUnknownKeys(value, nested, `${container}.${key}`, into);\n }\n }\n}\n\n/**\n * Every object literal a function form RETURNS, without descending into\n * functions nested inside it — a callback's return value is not the metadata.\n *\n * A generic walk rather than a statement-by-statement one because a `return` is\n * legal anywhere a statement is: inside an `if`, a `switch`, a `try`. Enumerating\n * the statement types that may contain one is a list that is wrong the moment\n * the language grows.\n */\nfunction collectReturnedObjects(node: unknown, into: PageObjectExpression[]): void {\n if (node === null || typeof node !== \"object\") return;\n\n if (Array.isArray(node)) {\n for (const item of node) collectReturnedObjects(item, into);\n\n return;\n }\n\n const candidate = node as { type?: string; argument?: unknown };\n\n if (\n candidate.type === \"FunctionDeclaration\" ||\n candidate.type === \"FunctionExpression\" ||\n candidate.type === \"ArrowFunctionExpression\" ||\n candidate.type === \"ObjectMethod\" ||\n candidate.type === \"ClassMethod\"\n ) {\n return;\n }\n\n if (candidate.type === \"ReturnStatement\") {\n if (candidate.argument === null || candidate.argument === undefined) return;\n\n const returned = unwrapValue(candidate.argument as PageValueNode);\n\n if (returned.type === \"ObjectExpression\") into.push(returned);\n\n // Not descending into the returned value: a `return` inside it belongs to a\n // function this walk is deliberately not entering.\n return;\n }\n\n for (const value of Object.values(node as Record<string, unknown>)) {\n collectReturnedObjects(value, into);\n }\n}\n\n/** The metadata object literals one `metadata` export declares, if any can be seen at all. */\nfunction metadataObjectsOf(init: PageValueNode): PageObjectExpression[] {\n const value = unwrapValue(init);\n\n if (value.type === \"ObjectExpression\") return [value];\n\n if (value.type === \"ArrowFunctionExpression\" || value.type === \"FunctionExpression\") {\n const body = unwrapValue(value.body as PageValueNode);\n\n // The concise arrow body — `({ data }) => ({ title: data.name })`, which is\n // how every function-form metadata export in the reference app is written.\n if (body.type === \"ObjectExpression\") return [body];\n\n const returned: PageObjectExpression[] = [];\n\n collectReturnedObjects(value.body, returned);\n\n return returned;\n }\n\n // `export const metadata = buildMetadata()`, or a bare identifier: the keys\n // are not in this file. Silent by design — see `collectUnknownKeys`.\n return [];\n}\n\n/**\n * The unknown keys a page's `metadata` export declares, read by PARSING — the\n * same rule the rest of this module lives by, and the reason this check can run\n * before anything is built.\n *\n * Empty for a page with no `metadata` export, for one whose metadata is a value\n * this file cannot see into, and for a correct one.\n */\nexport function readMetadataKeys(pageFile: string, source: string): UnknownMetadataKey[] {\n let program: ReturnType<typeof parse>[\"program\"];\n\n try {\n program = parse(source, {\n sourceType: \"module\",\n plugins: [\"typescript\", \"jsx\"],\n errorRecovery: false,\n }).program;\n } catch (error) {\n throw new Error(\n `Cannot read the \\`metadata\\` export of \"${pageFile}\": the file could not be parsed ` +\n `(${(error as Error).message}). Fix the syntax error and the build will continue.`,\n );\n }\n\n const unknownKeys: UnknownMetadataKey[] = [];\n\n for (const statement of program.body) {\n if (statement.type !== \"ExportNamedDeclaration\" || statement.exportKind === \"type\") continue;\n\n const { declaration } = statement;\n\n if (declaration?.type !== \"VariableDeclaration\") continue;\n\n for (const declarator of declaration.declarations) {\n if (declarator.id.type !== \"Identifier\" || declarator.id.name !== \"metadata\") continue;\n if (declarator.init === null || declarator.init === undefined) continue;\n\n for (const object of metadataObjectsOf(declarator.init)) {\n collectUnknownKeys(object, METADATA_KEYS, \"metadata\", unknownKeys);\n }\n }\n }\n\n return unknownKeys;\n}\n\n/**\n * The declared exports of one file, or a thrown\n * {@link NonLiteralRouteExportError} when they cannot be read without running\n * the application. Layouts are read once per run and remembered: a layout is\n * the nearest one for every page beside it, and parsing it once per page would\n * be the same answer bought repeatedly.\n *\n * `source` is the file's text when the caller already holds it — the page loop\n * reads each page once and spends that read on both the route declarations and\n * the metadata check, rather than opening the same file twice.\n */\nfunction readDeclarations(\n sourceFile: string,\n cache: Map<string, RouteExportsReadResult>,\n source?: string,\n) {\n let result = cache.get(sourceFile);\n\n if (result === undefined) {\n result = readRouteExports(sourceFile, source);\n cache.set(sourceFile, result);\n }\n\n if (!result.ok) {\n throw new NonLiteralRouteExportError(result.rejection);\n }\n\n return result;\n}\n\n/**\n * What a layout DOES, read by parsing it — the facts the layout policy's rule\n * needs and, being a pure module, cannot go and find for itself.\n */\ntype LayoutShape = {\n /**\n * Whether the module has a default export — the export that puts an element\n * in the document, and therefore the one thing that makes a layout count\n * against the single-rendering-layout rule.\n */\n renders: boolean;\n /** Whether the module exports `middleware`, or might via a re-export this cannot see through. */\n hasMiddleware: boolean;\n};\n\n/**\n * Parses one layout and reports its shape. Remembered per run for the same\n * reason declarations are: a layout is on the path of every page beneath it.\n */\nfunction readLayoutShape(layoutFile: string, cache: Map<string, LayoutShape>): LayoutShape {\n const cached = cache.get(layoutFile);\n\n if (cached !== undefined) return cached;\n\n const source = fs.readFileSync(layoutFile, \"utf-8\");\n let program: ReturnType<typeof parse>[\"program\"];\n\n try {\n program = parse(source, {\n sourceType: \"module\",\n // Every file this reads is a layout, i.e. `.tsx`.\n plugins: [\"typescript\", \"jsx\"],\n errorRecovery: false,\n }).program;\n } catch (error) {\n throw new Error(\n `Cannot read the exports of \"${layoutFile}\": the file could not be parsed ` +\n `(${(error as Error).message}). Fix the syntax error and the build will continue.`,\n );\n }\n\n const shape: LayoutShape = { renders: false, hasMiddleware: false };\n\n for (const statement of program.body) {\n if (statement.type === \"ExportDefaultDeclaration\") {\n shape.renders = true;\n continue;\n }\n\n // `export * from \"./guard\"` cannot re-export a default — the language\n // excludes it — but it CAN contribute `middleware`, and no parse can see\n // through it without resolving and reading another module. Reading it as\n // \"no middleware here\" is exactly the silent unguarding this slice exists\n // to prevent, so it is read as \"possibly\" and fails loudly downstream.\n if (statement.type === \"ExportAllDeclaration\") {\n shape.hasMiddleware = true;\n continue;\n }\n\n if (statement.type !== \"ExportNamedDeclaration\" || statement.exportKind === \"type\") continue;\n\n for (const specifier of statement.specifiers) {\n if (specifier.type !== \"ExportSpecifier\" || specifier.exportKind === \"type\") continue;\n\n const exported =\n specifier.exported.type === \"Identifier\"\n ? specifier.exported.name\n : specifier.exported.value;\n\n if (exported === \"default\") shape.renders = true;\n if (exported === \"middleware\") shape.hasMiddleware = true;\n }\n\n const { declaration } = statement;\n\n if (declaration === null || declaration === undefined) continue;\n\n if (declaration.type === \"VariableDeclaration\") {\n for (const declarator of declaration.declarations) {\n if (declarator.id.type === \"Identifier\" && declarator.id.name === \"middleware\") {\n shape.hasMiddleware = true;\n }\n }\n\n continue;\n }\n\n if (\n (declaration.type === \"FunctionDeclaration\" || declaration.type === \"ClassDeclaration\") &&\n declaration.id?.name === \"middleware\"\n ) {\n shape.hasMiddleware = true;\n }\n }\n\n cache.set(layoutFile, shape);\n\n return shape;\n}\n\n/**\n * Scans both web roots and returns the pages in a defined total order.\n *\n * Zero pages is a legal result, not an error: a project may be configured\n * with web and have nothing to serve yet. What is an error is a `*.page.tsx`\n * with no `route` export, which is refused rather than silently omitted — an\n * artefact that leaves a page out is a page the dev server still serves and\n * production 404s on; two pages claiming one route name, which this refuses\n * to return at all — the alternative is an artefact in which one of them is\n * silently unreachable; a `route` or `prefix` that cannot be read without\n * running the application, which is refused before any page is reported at\n * all; a `metadata` export declaring a key nothing reads, which no compiler\n * catches unless the page opted into the type and which otherwise serves a page\n * with a silently missing tag; and a page whose layout chain holds more than one RENDERING layout, which\n * the production installer would refuse anyway — discovery refuses it first so\n * that artefact is never produced.\n */\nexport function discoverPages(options: DiscoverPagesOptions): DiscoveredPage[] {\n const { appRoot } = options;\n const srcRoot = path.join(appRoot, options.srcDir ?? \"src\");\n const webRoots = discoverWebRoots(srcRoot);\n const appFile = path.join(srcRoot, \"web\", \"root.tsx\");\n const hasAppFile = isFile(appFile);\n const declarations = new Map<string, RouteExportsReadResult>();\n const layoutShapes = new Map<string, LayoutShape>();\n const relativeToApp = (file: string) => toPosix(path.relative(appRoot, file));\n\n const pages: DiscoveredPage[] = [];\n\n for (const webRoot of webRoots) {\n for (const pageFile of walkFiles(webRoot, (fileName) => fileName.endsWith(\".page.tsx\"))) {\n const pageSource = fs.readFileSync(pageFile, \"utf-8\");\n const { route } = readDeclarations(pageFile, declarations, pageSource);\n const isNotFoundPage = isNotFoundPageFile(pageFile);\n\n // THE NOT-FOUND PAGE IS THE ONE PAGE WITH NO URL, in both directions.\n //\n // A missing `route` is refused for every other page because a page\n // nothing can reach is a page that was written by mistake. `404.page.tsx`\n // is reached by NOT matching, so the premise does not hold for it — and\n // the opposite is the error: a `route` export here reads as a promise\n // that some path is browsable, which the installers never keep.\n //\n // Discovery still reports it, with the SAME reserved identity both\n // installers register it under, so the emitted artefacts agree with the\n // server about which entry is the not-found page — and so the client\n // registry carries the module the SSR'd document has to hydrate against.\n if (isNotFoundPage && route !== undefined) {\n throw new NotFoundPageDeclaresRouteError(relativeToApp(pageFile));\n }\n\n if (route === undefined && !isNotFoundPage) {\n throw new MissingRouteExportError(relativeToApp(pageFile));\n }\n\n // The page contract is not only `route`. `metadata` is the other export\n // every page may declare, and it is the one with no compiler behind it\n // unless the author opted in to a type annotation — so it is checked\n // here, by name, exactly like the route above.\n //\n // AFTER the route check on purpose: a page nothing can reach is a bigger\n // problem than a page reached with the wrong `<head>`, and reporting the\n // smaller one first would send the developer to the wrong line.\n const unknownMetadataKeys = readMetadataKeys(relativeToApp(pageFile), pageSource);\n\n if (unknownMetadataKeys.length > 0) {\n throw new UnknownMetadataKeyError(relativeToApp(pageFile), unknownMetadataKeys);\n }\n\n // The policy decides which layout the page RENDERS INSIDE, from the FULL\n // enumerated chain: a layout anywhere on the ancestry path counts, not\n // just one in the page's own directory. Discovery supplies the one fact\n // the rule needs and the pure policy cannot learn — whether each layout\n // renders anything at all.\n const layouts = layoutChainFor(pageFile, webRoot);\n const shapes = layouts.map((layoutFile) => readLayoutShape(layoutFile, layoutShapes));\n const selection = selectPageLayout(\n layouts.map((layout, index) => ({ layout, renders: shapes[index].renders })),\n );\n\n if (selection.type === \"rejected\") {\n throw new NestedLayoutsNotSupportedError(\n relativeToApp(pageFile),\n selection.layouts.map(relativeToApp),\n );\n }\n\n // Every layout that declares a guard, outermost first. Both installers\n // now CONCATENATE the whole chain into the pipeline's single layout slot\n // (`../server/install-page-routes.ts`, `../server/install-page-routes-from-manifest.ts`),\n // so a guard anywhere on the path runs, in this order — which is why the\n // temporary refusal that used to stand here is gone rather than relaxed.\n const middlewareLayouts = layouts.filter((_, index) => shapes[index].hasMiddleware);\n\n // EVERY prefix on the path, outermost first: a `prefix`-only layout is\n // still a segment of the URL, and composing only the rendering layout's\n // would serve the subtree from a path nobody declared.\n const layoutPrefix = layouts.reduce(\n (composed, layoutFile) =>\n composeRoutePath(composed, readDeclarations(layoutFile, declarations).prefix ?? \"/\"),\n \"/\",\n );\n\n pages.push({\n routeName: isNotFoundPage\n ? NOT_FOUND_ROUTE_NAME\n : (route?.name ??\n deriveFallbackRouteName({\n routePath: route?.path ?? \"/\",\n sourceFile: relativeToApp(pageFile),\n })),\n // The catch-all, which the client route matcher already understands as\n // a terminal `catch-all` token sorted LAST by specificity\n // (`../client/runtime/matcher.ts`) — so the browser resolves the\n // not-found page for a URL that matched nothing, exactly as the server\n // did, and never in preference to a real page.\n routePath: isNotFoundPage\n ? NOT_FOUND_ROUTE_PATH\n : composeRoutePath(layoutPrefix, route?.path ?? \"/\"),\n pageFile,\n webRoot,\n // THE NOT-FOUND PAGE RENDERS INSIDE THE APPLICATION ROOT AND NOTHING\n // ELSE — an EMPTY chain, not the one enumerated above.\n //\n // Both installers render it with `layoutFile: undefined`, deliberately\n // and independently (`../server/install-page-routes.ts`,\n // `../server/install-page-routes-from-manifest.ts`), for the same\n // reason the 404 page takes no loader: a path whose entire job is to\n // handle failure must not depend on chrome that can itself throw,\n // redirect, or need data. Reporting layouts here anyway put them in the\n // client registry and therefore in HYDRATION, so the server rendered\n // `App(Page)` while the browser rebuilt `App(Layout(Page))` — a\n // guaranteed mismatch on the one route nobody is watching, invisible to\n // any application that happens to have no layouts.\n //\n // Aligned by REMOVING them from the client, never by giving them to the\n // server: layouts on the not-found route would make it the most fragile\n // route in the application.\n //\n // The chain above is still enumerated and still validated, so a nested\n // layout on this page's path is refused at build time exactly as it is\n // everywhere else — what changes is only what the page renders inside.\n layouts: isNotFoundPage ? [] : layouts,\n middlewareLayouts: isNotFoundPage ? [] : middlewareLayouts,\n ...(hasAppFile ? { appFile } : {}),\n });\n }\n }\n\n pages.sort(comparePages);\n\n assertUniqueRouteNames(pages, appRoot);\n\n return pages;\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"generate-client-registry.mjs","names":[],"sources":["../../../../../../../web/src/build/generate-client-registry.ts"],"sourcesContent":["/**\n * The CLIENT half of the build→runtime handoff: the source of a generated\n * module that carries the page graph into the BROWSER.\n *\n * `generate-pages-barrel.ts` is the server half. The two are deliberately not\n * one function, because they want opposite things from the same recipe: the\n * server barrel emits STATIC `import * as` so every page lands in the one\n * server bundle and its route can be read off the module at boot, while the\n * browser must never download page B to render page A — so every module\n * reference here is a dynamic `import()`, and `name`/`path` are baked in as\n * DATA so the client runtime can match a route before loading anything.\n *\n * This function is PURE. It reads no filesystem, writes no file, and does not\n * call {@link discoverPages} — the caller passes the already-discovered graph\n * in. That is what keeps the \"one discovery run, one graph\" invariant: names\n * and paths agree with the server's because there is only ever one scan, not\n * because two scanners were written to match.\n *\n * Module RESOLUTION is the one thing that legitimately differs between the dev\n * provider and the production provider, so it is injected as\n * {@link GenerateClientRegistryOptions.toImportSpecifier} rather than computed\n * here. No provider-specific data — no Vite ids, no `/@fs/` prefixes, no\n * hashed chunk urls, no HMR metadata — reaches the emitted entry, and every\n * other property stays testable with a trivial mapper.\n */\nimport type { DiscoveredPage } from \"./discover-pages\";\n\nexport type GenerateClientRegistryOptions = {\n /**\n * The discovered page graph, IN THE ORDER DISCOVERY GAVE IT.\n *\n * `discoverPages` already sorts by POSIX source-file path\n * (`discover-pages.ts:368`, comparator at `discover-pages.ts:248-250`), so\n * this emitter preserves that order rather than imposing a second one: two\n * orderings of one graph is exactly the drift the shared scan exists to\n * prevent. Serialization stability only — nothing emitted here implies\n * matching precedence, which is a property of the route grammar.\n */\n pages: readonly DiscoveredPage[];\n /** Maps an absolute source file path to the specifier the emitted module will import. */\n toImportSpecifier: (absoluteFilePath: string) => string;\n};\n\n/** The name of the array the generated module exports. */\nexport const CLIENT_REGISTRY_EXPORT_NAME = \"pages\";\n\n/** The specifier the generated module imports its entry type from. */\nconst CLIENT_RUNTIME_SPECIFIER = \"@warlock.js/web/client/runtime\";\n\n/**\n * Raised when two pages claim one route name.\n *\n * `discoverPages` promises uniqueness already (`discover-pages.ts:370`). This\n * asserts it anyway: the alternative is trusting an upstream promise and\n * emitting a registry in which one page is silently unreachable, which costs\n * far more to diagnose than the check costs to run.\n */\nexport class DuplicateClientPageNameError extends Error {\n public constructor(\n public readonly routeName: string,\n public readonly firstPageFile: string,\n public readonly secondPageFile: string,\n ) {\n super(\n `Two pages claim the client route name \"${routeName}\": \"${firstPageFile}\" and ` +\n `\"${secondPageFile}\". A route name identifies exactly one page, so the second ` +\n \"page would be unreachable in the browser. To fix: rename one page's `route` \" +\n \"export, or move the file so its directory gives it a different route name.\",\n );\n this.name = \"DuplicateClientPageNameError\";\n }\n}\n\n/**\n * Every literal the generator emits goes through here, without exception.\n * Windows separators, quotes and backslashes are all in scope, and a\n * hand-quoted literal that gets one of them wrong fails at bundle time with a\n * parse error no user can act on.\n */\nfunction quote(value: string): string {\n return JSON.stringify(value);\n}\n\nconst HEADER = [\n \"// AUTO-GENERATED by @warlock.js/web — do not edit.\",\n \"// The page graph, compiled into the CLIENT bundle.\",\n \"//\",\n \"// `name` and `path` are baked in as data so the client runtime can match a\",\n \"// route before loading anything; every module reference is a dynamic\",\n \"// `import()` so a page's code is downloaded only when that page is rendered.\",\n `import type { ClientPageEntry } from ${quote(CLIENT_RUNTIME_SPECIFIER)};`,\n];\n\nfunction assertUniqueNames(pages: readonly DiscoveredPage[]): void {\n const pageFileByName = new Map<string, string>();\n\n for (const page of pages) {\n const existing = pageFileByName.get(page.routeName);\n\n if (existing !== undefined) {\n throw new DuplicateClientPageNameError(page.routeName, existing, page.pageFile);\n }\n\n pageFileByName.set(page.routeName, page.pageFile);\n }\n}\n\n/**\n * One entry's `load`.\n *\n * It memoises NOTHING and holds no one-shot state: the runtime calls it again\n * on re-navigation to the same route, and a loader that works once and returns\n * undefined the second time is a silent blank page. Dynamic `import()` is\n * already module-cached by the bundler, so the correct implementation is to\n * let the cache do the caching.\n */\nfunction loadSource(page: DiscoveredPage, toImportSpecifier: (file: string) => string): string[] {\n const bindings = [\"Page\"];\n const specifiers = [toImportSpecifier(page.pageFile)];\n\n // Outermost first, exactly as discovery enumerated the chain — the order IS\n // the nesting, so reordering it would silently reparent the page.\n const layoutBindings = page.layouts.map((layoutFile, index) => {\n bindings.push(`layout${index}`);\n specifiers.push(toImportSpecifier(layoutFile));\n\n return `layout${index}`;\n });\n\n if (page.appFile !== undefined) {\n bindings.push(\"App\");\n specifiers.push(toImportSpecifier(page.appFile));\n }\n\n // `App` is OMITTED, never emitted as `App: undefined`: the runtime validator\n // reads an own `App` key as a promise that a module namespace is behind it.\n const app = page.appFile === undefined ? \"\" : \", App\";\n\n return [\n \" load: async () => {\",\n ` const [${bindings.join(\", \")}] = await Promise.all([`,\n ...specifiers.map((specifier) => ` import(${quote(specifier)}),`),\n \" ]);\",\n \"\",\n ` return { Page, layouts: [${layoutBindings.join(\", \")}]${app} };`,\n \" },\",\n ];\n}\n\nfunction entrySource(\n page: DiscoveredPage,\n toImportSpecifier: (file: string) => string,\n): string[] {\n return [\n \" {\",\n ' type: \"page\",',\n // Verbatim from discovery, both of them. `routePath` in particular is NOT\n // re-derived or re-normalised here: discovery already composed the layout\n // prefix with the declared route, and a second composition is a second\n // convention waiting to disagree with the server's.\n ` name: ${quote(page.routeName)},`,\n ` path: ${quote(page.routePath)},`,\n ...loadSource(page, toImportSpecifier),\n \" },\",\n ];\n}\n\n/**\n * Returns the SOURCE of the client page registry module. Writes nothing.\n *\n * Zero pages is a legal result, not an error, and it is emitted as an explicit\n * empty registry: \"built with web, no pages\" must reach the runtime as a fact\n * rather than as a missing module.\n */\nexport function generateClientRegistry(options: GenerateClientRegistryOptions): string {\n const { pages, toImportSpecifier } = options;\n\n assertUniqueNames(pages);\n\n const declaration = `export const ${CLIENT_REGISTRY_EXPORT_NAME}: readonly ClientPageEntry[] =`;\n\n if (pages.length === 0) {\n return [...HEADER, \"\", `${declaration} [];`, \"\"].join(\"\\n\");\n }\n\n return [\n ...HEADER,\n \"\",\n `${declaration} [`,\n ...pages.flatMap((page) => entrySource(page, toImportSpecifier)),\n \"];\",\n \"\",\n ].join(\"\\n\");\n}\n"],"mappings":";;AA4CA,MAAa,8BAA8B;;AAG3C,MAAM,2BAA2B;;;;;;;;;AAUjC,IAAa,+BAAb,cAAkD,MAAM;CAEpC;CACA;CACA;CAHlB,AAAO,YACL,AAAgB,WAChB,AAAgB,eAChB,AAAgB,gBAChB;EACA,MACE,0CAA0C,UAAU,MAAM,cAAc,SAClE,eAAe,oNAGvB;EATgB;EACA;EACA;EAQhB,KAAK,OAAO;CACd;AACF;;;;;;;AAQA,SAAS,MAAM,OAAuB;CACpC,OAAO,KAAK,UAAU,KAAK;AAC7B;AAEA,MAAM,SAAS;CACb;CACA;CACA;CACA;CACA;CACA;CACA,wCAAwC,MAAM,wBAAwB,EAAE;AAC1E;AAEA,SAAS,kBAAkB,OAAwC;CACjE,MAAM,iCAAiB,IAAI,IAAoB;CAE/C,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,WAAW,eAAe,IAAI,KAAK,SAAS;EAElD,IAAI,aAAa,QACf,MAAM,IAAI,6BAA6B,KAAK,WAAW,UAAU,KAAK,QAAQ;EAGhF,eAAe,IAAI,KAAK,WAAW,KAAK,QAAQ;CAClD;AACF;;;;;;;;;;AAWA,SAAS,WAAW,MAAsB,mBAAuD;CAC/F,MAAM,WAAW,CAAC,MAAM;CACxB,MAAM,aAAa,CAAC,kBAAkB,KAAK,QAAQ,CAAC;CAIpD,MAAM,iBAAiB,KAAK,QAAQ,KAAK,YAAY,UAAU;EAC7D,SAAS,KAAK,SAAS,OAAO;EAC9B,WAAW,KAAK,kBAAkB,UAAU,CAAC;EAE7C,OAAO,SAAS;CAClB,CAAC;CAED,IAAI,KAAK,YAAY,QAAW;EAC9B,SAAS,KAAK,KAAK;EACnB,WAAW,KAAK,kBAAkB,KAAK,OAAO,CAAC;CACjD;CAIA,MAAM,MAAM,KAAK,YAAY,SAAY,KAAK;CAE9C,OAAO;EACL;EACA,gBAAgB,SAAS,KAAK,IAAI,EAAE;EACpC,GAAG,WAAW,KAAK,cAAc,kBAAkB,MAAM,SAAS,EAAE,GAAG;EACvE;EACA;EACA,kCAAkC,eAAe,KAAK,IAAI,EAAE,GAAG,IAAI;EACnE;CACF;AACF;AAEA,SAAS,YACP,MACA,mBACU;CACV,OAAO;EACL;EACA;EAKA,aAAa,MAAM,KAAK,SAAS,EAAE;EACnC,aAAa,MAAM,KAAK,SAAS,EAAE;EACnC,GAAG,WAAW,MAAM,iBAAiB;EACrC;CACF;AACF;;;;;;;;AASA,SAAgB,uBAAuB,SAAgD;CACrF,MAAM,EAAE,OAAO,sBAAsB;CAErC,kBAAkB,KAAK;CAEvB,MAAM,cAAc,gBAAgB,4BAA4B;CAEhE,IAAI,MAAM,WAAW,GACnB,OAAO;EAAC,GAAG;EAAQ;EAAI,GAAG,YAAY;EAAO;CAAE,CAAC,CAAC,KAAK,IAAI;CAG5D,OAAO;EACL,GAAG;EACH;EACA,GAAG,YAAY;EACf,GAAG,MAAM,SAAS,SAAS,YAAY,MAAM,iBAAiB,CAAC;EAC/D;EACA;CACF,CAAC,CAAC,KAAK,IAAI;AACb"}
|
|
1
|
+
{"version":3,"file":"generate-client-registry.mjs","names":[],"sources":["../../../../../../../web/src/build/generate-client-registry.ts"],"sourcesContent":["/**\n * The CLIENT half of the build→runtime handoff: the source of a generated\n * module that carries the page graph into the BROWSER.\n *\n * `generate-pages-barrel.ts` is the server half. The two are deliberately not\n * one function, because they want opposite things from the same recipe: the\n * server barrel emits STATIC `import * as` so every page lands in the one\n * server bundle and its route can be read off the module at boot, while the\n * browser must never download page B to render page A — so every module\n * reference here is a dynamic `import()`, and `name`/`path` are baked in as\n * DATA so the client runtime can match a route before loading anything.\n *\n * This function is PURE. It reads no filesystem, writes no file, and does not\n * call {@link discoverPages} — the caller passes the already-discovered graph\n * in. That is what keeps the \"one discovery run, one graph\" invariant: names\n * and paths agree with the server's because there is only ever one scan, not\n * because two scanners were written to match.\n *\n * Module RESOLUTION is the one thing that legitimately differs between the dev\n * provider and the production provider, so it is injected as\n * {@link GenerateClientRegistryOptions.toImportSpecifier} rather than computed\n * here. No provider-specific data — no Vite ids, no `/@fs/` prefixes, no\n * hashed chunk urls, no HMR metadata — reaches the emitted entry, and every\n * other property stays testable with a trivial mapper.\n */\nimport type { DiscoveredPage } from \"./discover-pages\";\n\nexport type GenerateClientRegistryOptions = {\n /**\n * The discovered page graph, IN THE ORDER DISCOVERY GAVE IT.\n *\n * `discoverPages` already sorts by POSIX source-file path\n * (`discover-pages.ts:368`, comparator at `discover-pages.ts:248-250`), so\n * this emitter preserves that order rather than imposing a second one: two\n * orderings of one graph is exactly the drift the shared scan exists to\n * prevent. Serialization stability only — nothing emitted here implies\n * matching precedence, which is a property of the route grammar.\n */\n pages: readonly DiscoveredPage[];\n /** Maps an absolute source file path to the specifier the emitted module will import. */\n toImportSpecifier: (absoluteFilePath: string) => string;\n};\n\n/** The name of the array the generated module exports. */\nexport const CLIENT_REGISTRY_EXPORT_NAME = \"pages\";\n\n/** The specifier the generated module imports its entry type from. */\nconst CLIENT_RUNTIME_SPECIFIER = \"@warlock.js/web/client/runtime\";\n\n/**\n * Raised when two pages claim one route name.\n *\n * `discoverPages` promises uniqueness already (`discover-pages.ts:370`). This\n * asserts it anyway: the alternative is trusting an upstream promise and\n * emitting a registry in which one page is silently unreachable, which costs\n * far more to diagnose than the check costs to run.\n */\nexport class DuplicateClientPageNameError extends Error {\n public constructor(\n public readonly routeName: string,\n public readonly firstPageFile: string,\n public readonly secondPageFile: string,\n ) {\n super(\n `Two pages claim the client route name \"${routeName}\": \"${firstPageFile}\" and ` +\n `\"${secondPageFile}\". A route name identifies exactly one page, so the second ` +\n \"page would be unreachable in the browser. To fix: rename one page's `route` \" +\n \"export, or move the file so its directory gives it a different route name.\",\n );\n this.name = \"DuplicateClientPageNameError\";\n }\n}\n\n/**\n * Every literal the generator emits goes through here, without exception.\n * Windows separators, quotes and backslashes are all in scope, and a\n * hand-quoted literal that gets one of them wrong fails at bundle time with a\n * parse error no user can act on.\n */\nfunction quote(value: string): string {\n return JSON.stringify(value);\n}\n\nconst HEADER = [\n \"// AUTO-GENERATED by @warlock.js/web — do not edit.\",\n \"// The page graph, compiled into the CLIENT bundle.\",\n \"//\",\n \"// `name` and `path` are baked in as data so the client runtime can match a\",\n \"// route before loading anything; every module reference is a dynamic\",\n \"// `import()` so a page's code is downloaded only when that page is rendered.\",\n `import type { ClientPageEntry } from ${quote(CLIENT_RUNTIME_SPECIFIER)};`,\n];\n\nfunction assertUniqueNames(pages: readonly DiscoveredPage[]): void {\n const pageFileByName = new Map<string, string>();\n\n for (const page of pages) {\n const existing = pageFileByName.get(page.routeName);\n\n if (existing !== undefined) {\n throw new DuplicateClientPageNameError(page.routeName, existing, page.pageFile);\n }\n\n pageFileByName.set(page.routeName, page.pageFile);\n }\n}\n\n/**\n * One entry's `load`.\n *\n * It memoises NOTHING and holds no one-shot state: the runtime calls it again\n * on re-navigation to the same route, and a loader that works once and returns\n * undefined the second time is a silent blank page. Dynamic `import()` is\n * already module-cached by the bundler, so the correct implementation is to\n * let the cache do the caching.\n */\nfunction loadSource(page: DiscoveredPage, toImportSpecifier: (file: string) => string): string[] {\n const bindings = [\"Page\"];\n const specifiers = [toImportSpecifier(page.pageFile)];\n\n // Outermost first, exactly as discovery enumerated the chain — the order IS\n // the nesting, so reordering it would silently reparent the page.\n //\n // Emitted VERBATIM, including the empty chain discovery reports for the\n // not-found page (`./discover-pages.ts`), which is what keeps hydration\n // rendering the same `App(Page)` the installers SSR'd. The not-found case is\n // NOT re-detected here: a second rule about which page gets layouts is a\n // second convention waiting to disagree with the server's, which is the same\n // reason `routePath` below is not re-composed either.\n const layoutBindings = page.layouts.map((layoutFile, index) => {\n bindings.push(`layout${index}`);\n specifiers.push(toImportSpecifier(layoutFile));\n\n return `layout${index}`;\n });\n\n if (page.appFile !== undefined) {\n bindings.push(\"App\");\n specifiers.push(toImportSpecifier(page.appFile));\n }\n\n // `App` is OMITTED, never emitted as `App: undefined`: the runtime validator\n // reads an own `App` key as a promise that a module namespace is behind it.\n const app = page.appFile === undefined ? \"\" : \", App\";\n\n return [\n \" load: async () => {\",\n ` const [${bindings.join(\", \")}] = await Promise.all([`,\n ...specifiers.map((specifier) => ` import(${quote(specifier)}),`),\n \" ]);\",\n \"\",\n ` return { Page, layouts: [${layoutBindings.join(\", \")}]${app} };`,\n \" },\",\n ];\n}\n\nfunction entrySource(\n page: DiscoveredPage,\n toImportSpecifier: (file: string) => string,\n): string[] {\n return [\n \" {\",\n ' type: \"page\",',\n // Verbatim from discovery, both of them. `routePath` in particular is NOT\n // re-derived or re-normalised here: discovery already composed the layout\n // prefix with the declared route, and a second composition is a second\n // convention waiting to disagree with the server's.\n ` name: ${quote(page.routeName)},`,\n ` path: ${quote(page.routePath)},`,\n ...loadSource(page, toImportSpecifier),\n \" },\",\n ];\n}\n\n/**\n * Returns the SOURCE of the client page registry module. Writes nothing.\n *\n * Zero pages is a legal result, not an error, and it is emitted as an explicit\n * empty registry: \"built with web, no pages\" must reach the runtime as a fact\n * rather than as a missing module.\n */\nexport function generateClientRegistry(options: GenerateClientRegistryOptions): string {\n const { pages, toImportSpecifier } = options;\n\n assertUniqueNames(pages);\n\n const declaration = `export const ${CLIENT_REGISTRY_EXPORT_NAME}: readonly ClientPageEntry[] =`;\n\n if (pages.length === 0) {\n return [...HEADER, \"\", `${declaration} [];`, \"\"].join(\"\\n\");\n }\n\n return [\n ...HEADER,\n \"\",\n `${declaration} [`,\n ...pages.flatMap((page) => entrySource(page, toImportSpecifier)),\n \"];\",\n \"\",\n ].join(\"\\n\");\n}\n"],"mappings":";;AA4CA,MAAa,8BAA8B;;AAG3C,MAAM,2BAA2B;;;;;;;;;AAUjC,IAAa,+BAAb,cAAkD,MAAM;CAEpC;CACA;CACA;CAHlB,AAAO,YACL,AAAgB,WAChB,AAAgB,eAChB,AAAgB,gBAChB;EACA,MACE,0CAA0C,UAAU,MAAM,cAAc,SAClE,eAAe,oNAGvB;EATgB;EACA;EACA;EAQhB,KAAK,OAAO;CACd;AACF;;;;;;;AAQA,SAAS,MAAM,OAAuB;CACpC,OAAO,KAAK,UAAU,KAAK;AAC7B;AAEA,MAAM,SAAS;CACb;CACA;CACA;CACA;CACA;CACA;CACA,wCAAwC,MAAM,wBAAwB,EAAE;AAC1E;AAEA,SAAS,kBAAkB,OAAwC;CACjE,MAAM,iCAAiB,IAAI,IAAoB;CAE/C,KAAK,MAAM,QAAQ,OAAO;EACxB,MAAM,WAAW,eAAe,IAAI,KAAK,SAAS;EAElD,IAAI,aAAa,QACf,MAAM,IAAI,6BAA6B,KAAK,WAAW,UAAU,KAAK,QAAQ;EAGhF,eAAe,IAAI,KAAK,WAAW,KAAK,QAAQ;CAClD;AACF;;;;;;;;;;AAWA,SAAS,WAAW,MAAsB,mBAAuD;CAC/F,MAAM,WAAW,CAAC,MAAM;CACxB,MAAM,aAAa,CAAC,kBAAkB,KAAK,QAAQ,CAAC;CAWpD,MAAM,iBAAiB,KAAK,QAAQ,KAAK,YAAY,UAAU;EAC7D,SAAS,KAAK,SAAS,OAAO;EAC9B,WAAW,KAAK,kBAAkB,UAAU,CAAC;EAE7C,OAAO,SAAS;CAClB,CAAC;CAED,IAAI,KAAK,YAAY,QAAW;EAC9B,SAAS,KAAK,KAAK;EACnB,WAAW,KAAK,kBAAkB,KAAK,OAAO,CAAC;CACjD;CAIA,MAAM,MAAM,KAAK,YAAY,SAAY,KAAK;CAE9C,OAAO;EACL;EACA,gBAAgB,SAAS,KAAK,IAAI,EAAE;EACpC,GAAG,WAAW,KAAK,cAAc,kBAAkB,MAAM,SAAS,EAAE,GAAG;EACvE;EACA;EACA,kCAAkC,eAAe,KAAK,IAAI,EAAE,GAAG,IAAI;EACnE;CACF;AACF;AAEA,SAAS,YACP,MACA,mBACU;CACV,OAAO;EACL;EACA;EAKA,aAAa,MAAM,KAAK,SAAS,EAAE;EACnC,aAAa,MAAM,KAAK,SAAS,EAAE;EACnC,GAAG,WAAW,MAAM,iBAAiB;EACrC;CACF;AACF;;;;;;;;AASA,SAAgB,uBAAuB,SAAgD;CACrF,MAAM,EAAE,OAAO,sBAAsB;CAErC,kBAAkB,KAAK;CAEvB,MAAM,cAAc,gBAAgB,4BAA4B;CAEhE,IAAI,MAAM,WAAW,GACnB,OAAO;EAAC,GAAG;EAAQ;EAAI,GAAG,YAAY;EAAO;CAAE,CAAC,CAAC,KAAK,IAAI;CAG5D,OAAO;EACL,GAAG;EACH;EACA,GAAG,YAAY;EACf,GAAG,MAAM,SAAS,SAAS,YAAY,MAAM,iBAAiB,CAAC;EAC/D;EACA;CACF,CAAC,CAAC,KAAK,IAAI;AACb"}
|
|
@@ -5,7 +5,9 @@ import { connectNavigator } from "../../routing/navigator.mjs";
|
|
|
5
5
|
import { recordCurrentRoute } from "./current-route.mjs";
|
|
6
6
|
import { connectRefresher, createRefresher } from "./refresh.mjs";
|
|
7
7
|
import { DocumentContext } from "../../components/document-context.mjs";
|
|
8
|
-
import {
|
|
8
|
+
import { fragmentOf, samePageFragment, withFragmentFrom, withoutFragment } from "../../routing/url-fragment.mjs";
|
|
9
|
+
import { scrollToFragment } from "./scroll-to-fragment.mjs";
|
|
10
|
+
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|
9
11
|
import { jsx } from "react/jsx-runtime";
|
|
10
12
|
|
|
11
13
|
//#region ../web/src/client/navigation/navigation-root.tsx
|
|
@@ -120,10 +122,23 @@ function NavigationRoot({ pages, initialPayload, initialTree, buildTree }) {
|
|
|
120
122
|
});
|
|
121
123
|
const currentRef = useRef(current);
|
|
122
124
|
currentRef.current = current;
|
|
125
|
+
const pendingFragment = useRef(void 0);
|
|
123
126
|
useEffect(() => {
|
|
124
127
|
let token = 0;
|
|
125
128
|
let disposed = false;
|
|
126
|
-
|
|
129
|
+
let committedUrl = window.location.href;
|
|
130
|
+
/**
|
|
131
|
+
* @param honourFragment whether the URL's fragment should be SCROLLED to
|
|
132
|
+
* once the new page is on screen. True for a navigation the app asked for
|
|
133
|
+
* — a `<Link>` click, `navigateTo` — and false for Back/Forward, where the
|
|
134
|
+
* browser has already restored the scroll position of the entry being
|
|
135
|
+
* returned to and moving the page again would overwrite the user's own
|
|
136
|
+
* position with the anchor they had scrolled away from. (Restoration is
|
|
137
|
+
* the browser's, deliberately: canon `0342c0d4`.)
|
|
138
|
+
*
|
|
139
|
+
* The fragment is still PRESERVED in the URL in both cases — see below.
|
|
140
|
+
*/
|
|
141
|
+
const apply = async (url, replace, honourFragment) => {
|
|
127
142
|
const ticket = ++token;
|
|
128
143
|
const result = takePrefetchedPageData(url) ?? await fetchPageData(url);
|
|
129
144
|
if (disposed || ticket !== token) return;
|
|
@@ -142,8 +157,11 @@ function NavigationRoot({ pages, initialPayload, initialTree, buildTree }) {
|
|
|
142
157
|
}
|
|
143
158
|
if (disposed || ticket !== token) return;
|
|
144
159
|
hydrateShared(result.payload.shared);
|
|
145
|
-
|
|
146
|
-
|
|
160
|
+
const finalUrl = withFragmentFrom(result.url, url);
|
|
161
|
+
if (replace) window.history.replaceState(null, "", finalUrl);
|
|
162
|
+
else window.history.pushState(null, "", finalUrl);
|
|
163
|
+
committedUrl = finalUrl;
|
|
164
|
+
pendingFragment.current = honourFragment ? fragmentOf(finalUrl) : void 0;
|
|
147
165
|
setCurrent({
|
|
148
166
|
payload: result.payload,
|
|
149
167
|
tree,
|
|
@@ -161,11 +179,24 @@ function NavigationRoot({ pages, initialPayload, initialTree, buildTree }) {
|
|
|
161
179
|
claimTicket
|
|
162
180
|
}));
|
|
163
181
|
const previousNavigator = connectNavigator((url, options) => {
|
|
164
|
-
|
|
182
|
+
const replace = options?.replace === true;
|
|
183
|
+
const fragment = samePageFragment(url, window.location.href);
|
|
184
|
+
if (fragment !== void 0) {
|
|
185
|
+
if (replace) window.history.replaceState(null, "", url);
|
|
186
|
+
else window.history.pushState(null, "", url);
|
|
187
|
+
committedUrl = window.location.href;
|
|
188
|
+
scrollToFragment(document, fragment);
|
|
189
|
+
return true;
|
|
190
|
+
}
|
|
191
|
+
apply(url, replace, true);
|
|
165
192
|
return true;
|
|
166
193
|
});
|
|
167
194
|
const onPopState = () => {
|
|
168
|
-
|
|
195
|
+
const target = window.location.href;
|
|
196
|
+
const hashOnlyMove = withoutFragment(target) === withoutFragment(committedUrl);
|
|
197
|
+
committedUrl = target;
|
|
198
|
+
if (hashOnlyMove) return;
|
|
199
|
+
apply(target, true, false);
|
|
169
200
|
};
|
|
170
201
|
window.addEventListener("popstate", onPopState);
|
|
171
202
|
return () => {
|
|
@@ -175,6 +206,12 @@ function NavigationRoot({ pages, initialPayload, initialTree, buildTree }) {
|
|
|
175
206
|
connectRefresher(previousRefresher);
|
|
176
207
|
};
|
|
177
208
|
}, [pages, buildTree]);
|
|
209
|
+
useLayoutEffect(() => {
|
|
210
|
+
const fragment = pendingFragment.current;
|
|
211
|
+
if (fragment === void 0) return;
|
|
212
|
+
pendingFragment.current = void 0;
|
|
213
|
+
scrollToFragment(document, fragment);
|
|
214
|
+
}, [current]);
|
|
178
215
|
const appliedMetadataSource = useRef(current.payload);
|
|
179
216
|
useEffect(() => {
|
|
180
217
|
if (appliedMetadataSource.current === current.payload) return;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"navigation-root.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/navigation-root.tsx"],"sourcesContent":["import { useEffect, useRef, useState, type ReactNode } from \"react\";\nimport { DocumentContext } from \"../../components/document-context\";\nimport type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\nimport type { MetadataOutput } from \"../../metadata\";\nimport { connectNavigator } from \"../../routing/navigator\";\nimport { hydrateShared } from \"../../shared\";\nimport type { ClientPageEntry } from \"../runtime\";\nimport { recordCurrentRoute } from \"./current-route\";\nimport { fetchPageData } from \"./fetch-page-data\";\nimport { takePrefetchedPageData } from \"./prefetch\";\nimport { connectRefresher, createRefresher, type RefreshablePage } from \"./refresh\";\n\n/**\n * The component that makes a page REPLACEABLE.\n *\n * Hydration mounts a fixed tree — correct, because the first render must match\n * the server's markup byte for byte. Client navigation needs that same position\n * in the tree to be able to hold a *different* page later, which means state,\n * which means a component. This is that component and nothing more.\n *\n * ## Why the layout stays mounted\n *\n * The new tree is built by the same `buildHydratedTree` the server's payload\n * went through, so a navigation within one layout produces an element whose\n * layout components are the same types in the same positions. React reconciles\n * them rather than remounting, so layout state — an open menu, a scroll\n * container, a video — survives. That is the entire user-visible benefit of\n * client navigation, and it is a consequence of reusing the builder rather than\n * anything this file does.\n */\n\n/**\n * ONE tag `<head>` may hold at most one of, addressed the way the browser\n * already addresses it. No marker attribute: the tags this replaces were\n * rendered by `<Head/>` on the server and carry none, and a marker would make\n * the applier ignore exactly the tags it exists to correct — the first\n * navigation's.\n */\ntype ManagedTag = {\n /** Finds the existing tag, server-rendered or applied by a previous swap. */\n selector: string;\n create: (documentNode: Document) => Element;\n write: (element: Element, value: string) => void;\n};\n\nfunction metaTag(attribute: \"name\" | \"property\", key: string): ManagedTag {\n return {\n selector: `meta[${attribute}=\"${key}\"]`,\n create: documentNode => {\n const element = documentNode.createElement(\"meta\");\n\n element.setAttribute(attribute, key);\n\n return element;\n },\n write: (element, value) => element.setAttribute(\"content\", value),\n };\n}\n\nconst TITLE_TAG: ManagedTag = {\n selector: \"title\",\n create: documentNode => documentNode.createElement(\"title\"),\n write: (element, value) => {\n element.textContent = value;\n },\n};\n\nconst CANONICAL_TAG: ManagedTag = {\n selector: 'link[rel=\"canonical\"]',\n create: documentNode => {\n const element = documentNode.createElement(\"link\");\n\n element.setAttribute(\"rel\", \"canonical\");\n\n return element;\n },\n write: (element, value) => element.setAttribute(\"href\", value),\n};\n\n/**\n * The metadata, resolved into (tag, value) pairs in `<Head/>`'s ORDER and by\n * `<Head/>`'s RULES — including the og fallbacks and the fact that they apply\n * only when `openGraph` is present (`components/head.ts:21-24,43-48`).\n *\n * The duplication is deliberate and it is the known cost here. `<Head/>` is a\n * React component that renders elements into a tree; this writes elements into\n * a live `<head>` that no client tree owns. They cannot be one function today,\n * but they MUST agree: the head after navigating to a URL has to equal the head\n * after landing on it, or a share preview depends on how the visitor arrived.\n * The fix is a shared descriptor list both consume — see the report's followup.\n */\nfunction resolveManagedTags(\n metadata: MetadataOutput | undefined,\n): readonly (readonly [ManagedTag, string | undefined])[] {\n const keywords =\n metadata?.keywords === undefined\n ? undefined\n : Array.isArray(metadata.keywords)\n ? metadata.keywords.join(\", \")\n : (metadata.keywords as string);\n\n const openGraph = metadata?.openGraph;\n const twitter = metadata?.twitter;\n\n return [\n [TITLE_TAG, metadata?.title],\n [metaTag(\"name\", \"description\"), metadata?.description],\n [metaTag(\"name\", \"keywords\"), keywords],\n [CANONICAL_TAG, metadata?.canonical],\n [metaTag(\"name\", \"robots\"), metadata?.robots],\n [metaTag(\"property\", \"og:title\"), openGraph && (openGraph.title ?? metadata?.title)],\n [\n metaTag(\"property\", \"og:description\"),\n openGraph && (openGraph.description ?? metadata?.description),\n ],\n [metaTag(\"property\", \"og:image\"), openGraph?.image],\n [metaTag(\"property\", \"og:url\"), openGraph?.url],\n [metaTag(\"property\", \"og:type\"), openGraph?.type],\n [metaTag(\"name\", \"twitter:card\"), twitter?.card],\n [metaTag(\"name\", \"twitter:title\"), twitter?.title],\n [metaTag(\"name\", \"twitter:description\"), twitter?.description],\n [metaTag(\"name\", \"twitter:image\"), twitter?.image],\n ];\n}\n\n/**\n * Make `<head>` describe the page now on screen.\n *\n * ## Why this is imperative, and why that is not a shortcut\n *\n * `<Head/>` renders inside the App level, and the App level is deliberately NOT\n * in the hydrated tree — the client mounts at `#root`, which App contains\n * (`client/build-hydrated-tree.ts`'s header). So no client render can reach\n * `<head>`, and a swap either writes it directly or leaves the previous page's\n * title in the tab. It leaves it today; that is the bug.\n *\n * ## ABSENT MEANS REMOVED\n *\n * Every managed tag the new metadata does not set is REMOVED, not left alone.\n * `/` sets a description and `/contact-us` does not: keeping it would describe\n * the contact page with the home page's words to every crawler, share preview\n * and assistive reader that looks — a wrong answer, where an absent one is\n * merely absent. A title the new page does not set goes too, and the tab falls\n * back to the URL, which is the honest rendering of \"this page did not name\n * itself\".\n *\n * Only the tags `<Head/>` renders FROM METADATA are touched. The charset meta\n * is rendered unconditionally and belongs to the document, so it is left alone.\n *\n * Takes the document as an argument rather than reaching for the global, which\n * is what makes it provable in a suite with no DOM.\n */\nexport function applyDocumentMetadata(\n documentNode: Document,\n metadata: MetadataOutput | undefined,\n): void {\n for (const [tag, value] of resolveManagedTags(metadata)) {\n const existing = documentNode.querySelector(tag.selector);\n\n if (value === undefined) {\n existing?.remove();\n continue;\n }\n\n if (existing !== null) {\n tag.write(existing, value);\n continue;\n }\n\n const created = tag.create(documentNode);\n\n tag.write(created, value);\n documentNode.head.appendChild(created);\n }\n}\n\nexport type NavigationRootProps = {\n pages: readonly ClientPageEntry[];\n /** The payload the document was rendered from — the hydration one. */\n initialPayload: HydrationDocumentPayloadSource;\n /** The tree already built from {@link initialPayload}, rendered as-is first. */\n initialTree: ReactNode;\n /**\n * How a payload becomes a tree. Injected rather than imported so this\n * component is testable without the page registry or a bundler — the same\n * reason `hydratePage` takes its builder as an argument.\n */\n buildTree: (\n pages: readonly ClientPageEntry[],\n payload: HydrationDocumentPayloadSource,\n ) => Promise<ReactNode>;\n};\n\n/**\n * The page on screen. Defined in `refresh.ts` because the third field is that\n * file's decision: `routeSource` is the payload object `current-route.ts`\n * identifies the route by, which is `payload` after a navigation but the\n * PREVIOUS page's object after a refresh — a refresh must not shift\n * `previousRoute()` onto the page the user is already looking at.\n */\ntype Current = RefreshablePage;\n\nexport function NavigationRoot({\n pages,\n initialPayload,\n initialTree,\n buildTree,\n}: NavigationRootProps) {\n const [current, setCurrent] = useState<Current>({\n payload: initialPayload,\n tree: initialTree,\n routeSource: initialPayload,\n });\n\n /*\n The latest state, readable from the effect below — which closes over the\n render that created it and would otherwise see the page the user was on\n when the runtime connected. A ref rather than an effect dependency because\n re-running the effect on every swap would disconnect and reconnect the\n navigator mid-navigation.\n */\n const currentRef = useRef(current);\n\n currentRef.current = current;\n\n useEffect(() => {\n /*\n THE RACE THIS COUNTER EXISTS FOR. Two clicks in quick succession start two\n fetches; the second can easily answer first (a cached page beating an\n uncached one is the common case, not the exotic one). Without a token the\n slower FIRST response lands last and the user ends up on the page they\n navigated away from, with the address bar showing the other one.\n\n Every navigation takes a token; a result is applied only if its token is\n still the newest. Superseded responses are dropped silently — they are not\n errors, they are answers to a question the user stopped asking.\n */\n let token = 0;\n let disposed = false;\n\n const apply = async (url: string, replace: boolean): Promise<void> => {\n const ticket = ++token;\n /*\n A prefetched response is CONSUMED, never merely read — `take` removes it,\n so the same speculative fetch can satisfy exactly one navigation and a\n second click on the same link goes to the network. That matters because\n the HTTP cache cannot stand in for this: dev responses are `no-store`\n (`server/dev-server.ts:254`) and production is `private` with no\n `max-age` (`server/render-page.ts:432`), so the browser will not reliably\n replay the speculative response on the real click.\n\n The race guard below still holds on a cache hit: `??` short-circuits the\n await, and the synchronous path reaches the same `ticket !== token` check.\n */\n const result = takePrefetchedPageData(url) ?? (await fetchPageData(url));\n\n if (disposed || ticket !== token) return;\n\n if (result.type === \"hard-navigate\") {\n // The documented degradation: hand the URL back to the browser. The\n // user still gets the page — see `fetch-page-data.ts`.\n console.warn(`Warlock navigation fell back to a full load (${result.reason}):`, url);\n window.location.assign(url);\n\n return;\n }\n\n let tree: ReactNode;\n\n try {\n tree = await buildTree(pages, result.payload);\n } catch (error) {\n // The payload was fine but its page chunk would not load or compose —\n // a stale bundle after a deploy is the realistic cause. A full load\n // fetches the current bundle, which is also the fix.\n console.warn(\"Warlock navigation could not build the page tree:\", error);\n window.location.assign(url);\n\n return;\n }\n\n if (disposed || ticket !== token) return;\n\n /*\n Shared state BEFORE the render that consumes it. `hydrateShared`\n installs the snapshot `useShared()` reads; swapping the tree first would\n render one frame of the new page against the previous page's shared\n state — locale, permissions, the current user.\n */\n hydrateShared(result.payload.shared);\n\n // History AFTER the fetch succeeded, never before. Pushing optimistically\n // would leave the address bar pointing at a page that then failed to\n // load, and a Back press would return to a URL the user never saw.\n if (replace) {\n window.history.replaceState(null, \"\", result.url);\n } else {\n window.history.pushState(null, \"\", result.url);\n }\n\n // A navigation IS the route moving, so the fetched payload is both the\n // page and the route's identity.\n setCurrent({ payload: result.payload, tree, routeSource: result.payload });\n };\n\n /*\n The same counter `apply` above takes its tickets from, handed to\n `refresh()` as a predicate. ONE mechanism, not two: a refresh and a\n navigation can overtake each other in either direction, and separate\n counters would leave each blind to the other.\n */\n const claimTicket = (): (() => boolean) => {\n const ticket = ++token;\n\n return () => !disposed && ticket === token;\n };\n\n const previousRefresher = connectRefresher(\n createRefresher({\n readCurrent: () => currentRef.current,\n writeCurrent: setCurrent,\n buildTree: payload => buildTree(pages, payload),\n claimTicket,\n }),\n );\n\n const previousNavigator = connectNavigator((url, options) => {\n void apply(url, options?.replace === true);\n\n // Accepted: the caller suppresses the browser's default. Returning `true`\n // before the fetch resolves is deliberate — the decision to handle a link\n // cannot wait on the network without the browser having already followed\n // it.\n return true;\n });\n\n /*\n Back/Forward. The entry is already in history and the URL has already\n changed by the time this fires, so the page is fetched and swapped with\n `replace` — pushing here would append a duplicate entry and make Back\n require two presses.\n */\n const onPopState = (): void => {\n void apply(window.location.href, true);\n };\n\n window.addEventListener(\"popstate\", onPopState);\n\n return () => {\n disposed = true;\n window.removeEventListener(\"popstate\", onPopState);\n connectNavigator(previousNavigator);\n connectRefresher(previousRefresher);\n };\n }, [pages, buildTree]);\n\n /*\n The payload whose metadata `<head>` currently reflects. Seeded with the\n HYDRATION payload, which is the point: on the initial mount the head is the\n server's own render of this very metadata, so there is nothing to correct —\n and re-applying would be a chance to get it wrong, since a payload from a\n build with no `metadata` key would wipe a head the server filled in\n correctly. The head is applied on SWAPS only.\n */\n const appliedMetadataSource = useRef(current.payload);\n\n useEffect(() => {\n if (appliedMetadataSource.current === current.payload) return;\n\n appliedMetadataSource.current = current.payload;\n\n /*\n Keyed on the payload's identity, so this covers a refresh as well as a\n navigation: `refresh()` produces a NEW payload for the page already on\n screen, and a page whose metadata is a function of its loader data can\n legitimately re-title itself when that data changes. One applier at the\n one place the page changes, rather than a call in each pathway that could\n be forgotten in the next one.\n */\n applyDocumentMetadata(document, current.payload.metadata);\n }, [current.payload]);\n\n /*\n DURING RENDER, not in an effect, and that placement is the requirement\n rather than a shortcut. A page component calling `currentRoute()` does it\n while IT is rendering, and child effects run before a parent's, so anything\n recorded from an effect here would be recorded too late to answer the first\n render of the page it describes — which on the initial mount is the only\n render there has been, and the server's match is all there is.\n\n Recording is keyed on the payload's identity (`current-route.ts`), so the\n extra passes render gives us for free — StrictMode's double invoke, a parent\n re-render — are recognised as the same page rather than counted as\n navigations.\n\n `routeSource`, NOT `payload`: they are the same object for every navigation,\n and differ only after a refresh, which produces a new payload for the page\n already on screen and must not be counted as a move (see `refresh.ts`).\n */\n recordCurrentRoute(current.routeSource);\n\n /*\n An INNER DocumentContext provider, nested inside the one `hydratePage`\n mounted. That outer provider is created once with the hydration payload and\n never changes, so anything reading the document context after a navigation\n would see the payload of the page the user has left. The nearest provider\n wins, so this one keeps it current.\n */\n /*\n `metadata` from the PAYLOAD, not `undefined`. The document context is the\n universal shape — the server provides the resolved metadata around the same\n tree — so handing the client's readers `undefined` was a lie the payload can\n now correct. It also removes a latent mismatch: a `<Head/>` rendered inside\n a Layout (not the App level) renders its tags into `#root` server-side, and\n hydrating that markup against `metadata: undefined` produced a tree the\n server never rendered.\n */\n return (\n <DocumentContext.Provider\n value={{ metadata: current.payload.metadata, payload: current.payload }}\n >\n {current.tree}\n </DocumentContext.Provider>\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"navigation-root.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/navigation-root.tsx"],"sourcesContent":["import { useEffect, useLayoutEffect, useRef, useState, type ReactNode } from \"react\";\r\nimport { DocumentContext } from \"../../components/document-context\";\r\nimport type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\r\nimport type { MetadataOutput } from \"../../metadata\";\r\nimport { connectNavigator } from \"../../routing/navigator\";\r\nimport {\r\n fragmentOf,\r\n samePageFragment,\r\n withFragmentFrom,\r\n withoutFragment,\r\n} from \"../../routing/url-fragment\";\r\nimport { hydrateShared } from \"../../shared\";\r\nimport type { ClientPageEntry } from \"../runtime\";\r\nimport { recordCurrentRoute } from \"./current-route\";\r\nimport { fetchPageData } from \"./fetch-page-data\";\r\nimport { takePrefetchedPageData } from \"./prefetch\";\r\nimport { connectRefresher, createRefresher, type RefreshablePage } from \"./refresh\";\r\nimport { scrollToFragment } from \"./scroll-to-fragment\";\r\n\r\n/**\r\n * The component that makes a page REPLACEABLE.\r\n *\r\n * Hydration mounts a fixed tree — correct, because the first render must match\r\n * the server's markup byte for byte. Client navigation needs that same position\r\n * in the tree to be able to hold a *different* page later, which means state,\r\n * which means a component. This is that component and nothing more.\r\n *\r\n * ## Why the layout stays mounted\r\n *\r\n * The new tree is built by the same `buildHydratedTree` the server's payload\r\n * went through, so a navigation within one layout produces an element whose\r\n * layout components are the same types in the same positions. React reconciles\r\n * them rather than remounting, so layout state — an open menu, a scroll\r\n * container, a video — survives. That is the entire user-visible benefit of\r\n * client navigation, and it is a consequence of reusing the builder rather than\r\n * anything this file does.\r\n */\r\n\r\n/**\r\n * ONE tag `<head>` may hold at most one of, addressed the way the browser\r\n * already addresses it. No marker attribute: the tags this replaces were\r\n * rendered by `<Head/>` on the server and carry none, and a marker would make\r\n * the applier ignore exactly the tags it exists to correct — the first\r\n * navigation's.\r\n */\r\ntype ManagedTag = {\r\n /** Finds the existing tag, server-rendered or applied by a previous swap. */\r\n selector: string;\r\n create: (documentNode: Document) => Element;\r\n write: (element: Element, value: string) => void;\r\n};\r\n\r\nfunction metaTag(attribute: \"name\" | \"property\", key: string): ManagedTag {\r\n return {\r\n selector: `meta[${attribute}=\"${key}\"]`,\r\n create: documentNode => {\r\n const element = documentNode.createElement(\"meta\");\r\n\r\n element.setAttribute(attribute, key);\r\n\r\n return element;\r\n },\r\n write: (element, value) => element.setAttribute(\"content\", value),\r\n };\r\n}\r\n\r\nconst TITLE_TAG: ManagedTag = {\r\n selector: \"title\",\r\n create: documentNode => documentNode.createElement(\"title\"),\r\n write: (element, value) => {\r\n element.textContent = value;\r\n },\r\n};\r\n\r\nconst CANONICAL_TAG: ManagedTag = {\r\n selector: 'link[rel=\"canonical\"]',\r\n create: documentNode => {\r\n const element = documentNode.createElement(\"link\");\r\n\r\n element.setAttribute(\"rel\", \"canonical\");\r\n\r\n return element;\r\n },\r\n write: (element, value) => element.setAttribute(\"href\", value),\r\n};\r\n\r\n/**\r\n * The metadata, resolved into (tag, value) pairs in `<Head/>`'s ORDER and by\r\n * `<Head/>`'s RULES — including the og fallbacks and the fact that they apply\r\n * only when `openGraph` is present (`components/head.ts:21-24,43-48`).\r\n *\r\n * The duplication is deliberate and it is the known cost here. `<Head/>` is a\r\n * React component that renders elements into a tree; this writes elements into\r\n * a live `<head>` that no client tree owns. They cannot be one function today,\r\n * but they MUST agree: the head after navigating to a URL has to equal the head\r\n * after landing on it, or a share preview depends on how the visitor arrived.\r\n * The fix is a shared descriptor list both consume — see the report's followup.\r\n */\r\nfunction resolveManagedTags(\r\n metadata: MetadataOutput | undefined,\r\n): readonly (readonly [ManagedTag, string | undefined])[] {\r\n const keywords =\r\n metadata?.keywords === undefined\r\n ? undefined\r\n : Array.isArray(metadata.keywords)\r\n ? metadata.keywords.join(\", \")\r\n : (metadata.keywords as string);\r\n\r\n const openGraph = metadata?.openGraph;\r\n const twitter = metadata?.twitter;\r\n\r\n return [\r\n [TITLE_TAG, metadata?.title],\r\n [metaTag(\"name\", \"description\"), metadata?.description],\r\n [metaTag(\"name\", \"keywords\"), keywords],\r\n [CANONICAL_TAG, metadata?.canonical],\r\n [metaTag(\"name\", \"robots\"), metadata?.robots],\r\n [metaTag(\"property\", \"og:title\"), openGraph && (openGraph.title ?? metadata?.title)],\r\n [\r\n metaTag(\"property\", \"og:description\"),\r\n openGraph && (openGraph.description ?? metadata?.description),\r\n ],\r\n [metaTag(\"property\", \"og:image\"), openGraph?.image],\r\n [metaTag(\"property\", \"og:url\"), openGraph?.url],\r\n [metaTag(\"property\", \"og:type\"), openGraph?.type],\r\n [metaTag(\"name\", \"twitter:card\"), twitter?.card],\r\n [metaTag(\"name\", \"twitter:title\"), twitter?.title],\r\n [metaTag(\"name\", \"twitter:description\"), twitter?.description],\r\n [metaTag(\"name\", \"twitter:image\"), twitter?.image],\r\n ];\r\n}\r\n\r\n/**\r\n * Make `<head>` describe the page now on screen.\r\n *\r\n * ## Why this is imperative, and why that is not a shortcut\r\n *\r\n * `<Head/>` renders inside the App level, and the App level is deliberately NOT\r\n * in the hydrated tree — the client mounts at `#root`, which App contains\r\n * (`client/build-hydrated-tree.ts`'s header). So no client render can reach\r\n * `<head>`, and a swap either writes it directly or leaves the previous page's\r\n * title in the tab. It leaves it today; that is the bug.\r\n *\r\n * ## ABSENT MEANS REMOVED\r\n *\r\n * Every managed tag the new metadata does not set is REMOVED, not left alone.\r\n * `/` sets a description and `/contact-us` does not: keeping it would describe\r\n * the contact page with the home page's words to every crawler, share preview\r\n * and assistive reader that looks — a wrong answer, where an absent one is\r\n * merely absent. A title the new page does not set goes too, and the tab falls\r\n * back to the URL, which is the honest rendering of \"this page did not name\r\n * itself\".\r\n *\r\n * Only the tags `<Head/>` renders FROM METADATA are touched. The charset meta\r\n * is rendered unconditionally and belongs to the document, so it is left alone.\r\n *\r\n * Takes the document as an argument rather than reaching for the global, which\r\n * is what makes it provable in a suite with no DOM.\r\n */\r\nexport function applyDocumentMetadata(\r\n documentNode: Document,\r\n metadata: MetadataOutput | undefined,\r\n): void {\r\n for (const [tag, value] of resolveManagedTags(metadata)) {\r\n const existing = documentNode.querySelector(tag.selector);\r\n\r\n if (value === undefined) {\r\n existing?.remove();\r\n continue;\r\n }\r\n\r\n if (existing !== null) {\r\n tag.write(existing, value);\r\n continue;\r\n }\r\n\r\n const created = tag.create(documentNode);\r\n\r\n tag.write(created, value);\r\n documentNode.head.appendChild(created);\r\n }\r\n}\r\n\r\nexport type NavigationRootProps = {\r\n pages: readonly ClientPageEntry[];\r\n /** The payload the document was rendered from — the hydration one. */\r\n initialPayload: HydrationDocumentPayloadSource;\r\n /** The tree already built from {@link initialPayload}, rendered as-is first. */\r\n initialTree: ReactNode;\r\n /**\r\n * How a payload becomes a tree. Injected rather than imported so this\r\n * component is testable without the page registry or a bundler — the same\r\n * reason `hydratePage` takes its builder as an argument.\r\n */\r\n buildTree: (\r\n pages: readonly ClientPageEntry[],\r\n payload: HydrationDocumentPayloadSource,\r\n ) => Promise<ReactNode>;\r\n};\r\n\r\n/**\r\n * The page on screen. Defined in `refresh.ts` because the third field is that\r\n * file's decision: `routeSource` is the payload object `current-route.ts`\r\n * identifies the route by, which is `payload` after a navigation but the\r\n * PREVIOUS page's object after a refresh — a refresh must not shift\r\n * `previousRoute()` onto the page the user is already looking at.\r\n */\r\ntype Current = RefreshablePage;\r\n\r\nexport function NavigationRoot({\r\n pages,\r\n initialPayload,\r\n initialTree,\r\n buildTree,\r\n}: NavigationRootProps) {\r\n const [current, setCurrent] = useState<Current>({\r\n payload: initialPayload,\r\n tree: initialTree,\r\n routeSource: initialPayload,\r\n });\r\n\r\n /*\r\n The latest state, readable from the effect below — which closes over the\r\n render that created it and would otherwise see the page the user was on\r\n when the runtime connected. A ref rather than an effect dependency because\r\n re-running the effect on every swap would disconnect and reconnect the\r\n navigator mid-navigation.\r\n */\r\n const currentRef = useRef(current);\r\n\r\n currentRef.current = current;\r\n\r\n /*\r\n THE ORDERING PROBLEM, and this ref is half of the answer to it.\r\n\r\n The element a fragment names lives in the tree that has not been built yet:\r\n at the moment `apply` finishes fetching, the DOM still holds the page the\r\n user is LEAVING. Scrolling there finds nothing, and finding nothing is\r\n silent — indistinguishable from the fragment bug itself.\r\n\r\n So the fragment is not scrolled to; it is HANDED OVER. `apply` parks it here\r\n immediately before the `setCurrent` that swaps the tree, and the layout\r\n effect below — which React runs after it has committed that tree to the DOM\r\n and before the browser paints — spends it. Read the two together; neither\r\n half means anything alone.\r\n */\r\n const pendingFragment = useRef<string | undefined>(undefined);\r\n\r\n useEffect(() => {\r\n /*\r\n THE RACE THIS COUNTER EXISTS FOR. Two clicks in quick succession start two\r\n fetches; the second can easily answer first (a cached page beating an\r\n uncached one is the common case, not the exotic one). Without a token the\r\n slower FIRST response lands last and the user ends up on the page they\r\n navigated away from, with the address bar showing the other one.\r\n\r\n Every navigation takes a token; a result is applied only if its token is\r\n still the newest. Superseded responses are dropped silently — they are not\r\n errors, they are answers to a question the user stopped asking.\r\n */\r\n let token = 0;\r\n let disposed = false;\r\n /*\r\n The URL this runtime last put in the address bar, so `popstate` can tell a\r\n move BETWEEN pages from a move between two fragments of one page. Seeded\r\n with the URL the document was loaded at, which is the entry the first Back\r\n would come from.\r\n */\r\n let committedUrl = window.location.href;\r\n\r\n /**\r\n * @param honourFragment whether the URL's fragment should be SCROLLED to\r\n * once the new page is on screen. True for a navigation the app asked for\r\n * — a `<Link>` click, `navigateTo` — and false for Back/Forward, where the\r\n * browser has already restored the scroll position of the entry being\r\n * returned to and moving the page again would overwrite the user's own\r\n * position with the anchor they had scrolled away from. (Restoration is\r\n * the browser's, deliberately: canon `0342c0d4`.)\r\n *\r\n * The fragment is still PRESERVED in the URL in both cases — see below.\r\n */\r\n const apply = async (\r\n url: string,\r\n replace: boolean,\r\n honourFragment: boolean,\r\n ): Promise<void> => {\r\n const ticket = ++token;\r\n /*\r\n A prefetched response is CONSUMED, never merely read — `take` removes it,\r\n so the same speculative fetch can satisfy exactly one navigation and a\r\n second click on the same link goes to the network. That matters because\r\n the HTTP cache cannot stand in for this: dev responses are `no-store`\r\n (`server/dev-server.ts:254`) and production is `private` with no\r\n `max-age` (`server/render-page.ts:432`), so the browser will not reliably\r\n replay the speculative response on the real click.\r\n\r\n The race guard below still holds on a cache hit: `??` short-circuits the\r\n await, and the synchronous path reaches the same `ticket !== token` check.\r\n */\r\n const result = takePrefetchedPageData(url) ?? (await fetchPageData(url));\r\n\r\n if (disposed || ticket !== token) return;\r\n\r\n if (result.type === \"hard-navigate\") {\r\n // The documented degradation: hand the URL back to the browser. The\r\n // user still gets the page — see `fetch-page-data.ts`.\r\n console.warn(`Warlock navigation fell back to a full load (${result.reason}):`, url);\r\n window.location.assign(url);\r\n\r\n return;\r\n }\r\n\r\n let tree: ReactNode;\r\n\r\n try {\r\n tree = await buildTree(pages, result.payload);\r\n } catch (error) {\r\n // The payload was fine but its page chunk would not load or compose —\r\n // a stale bundle after a deploy is the realistic cause. A full load\r\n // fetches the current bundle, which is also the fix.\r\n console.warn(\"Warlock navigation could not build the page tree:\", error);\r\n window.location.assign(url);\r\n\r\n return;\r\n }\r\n\r\n if (disposed || ticket !== token) return;\r\n\r\n /*\r\n Shared state BEFORE the render that consumes it. `hydrateShared`\r\n installs the snapshot `useShared()` reads; swapping the tree first would\r\n render one frame of the new page against the previous page's shared\r\n state — locale, permissions, the current user.\r\n */\r\n hydrateShared(result.payload.shared);\r\n\r\n /*\r\n The fragment PUT BACK. `result.url` comes from `response.url`, and a\r\n fragment is never sent to a server, so the URL a navigation would\r\n otherwise be written to history from has had it stripped — which is how\r\n `<Link href=\"/docs#install\">` used to land on `/docs` with the author's\r\n fragment gone from the address bar for good.\r\n\r\n Applied on EVERY path, Back included: a popstate re-fetch that wrote\r\n `result.url` back would delete the fragment from an entry the user is\r\n merely returning to.\r\n */\r\n const finalUrl = withFragmentFrom(result.url, url);\r\n\r\n // History AFTER the fetch succeeded, never before. Pushing optimistically\r\n // would leave the address bar pointing at a page that then failed to\r\n // load, and a Back press would return to a URL the user never saw.\r\n if (replace) {\r\n window.history.replaceState(null, \"\", finalUrl);\r\n } else {\r\n window.history.pushState(null, \"\", finalUrl);\r\n }\r\n\r\n committedUrl = finalUrl;\r\n\r\n // Handed to the layout effect, which runs once React has committed the\r\n // tree below to the DOM — the first moment the target can exist. Set\r\n // unconditionally so a navigation with no fragment CLEARS a fragment left\r\n // pending by one that was superseded.\r\n pendingFragment.current = honourFragment ? fragmentOf(finalUrl) : undefined;\r\n\r\n // A navigation IS the route moving, so the fetched payload is both the\r\n // page and the route's identity.\r\n setCurrent({ payload: result.payload, tree, routeSource: result.payload });\r\n };\r\n\r\n /*\r\n The same counter `apply` above takes its tickets from, handed to\r\n `refresh()` as a predicate. ONE mechanism, not two: a refresh and a\r\n navigation can overtake each other in either direction, and separate\r\n counters would leave each blind to the other.\r\n */\r\n const claimTicket = (): (() => boolean) => {\r\n const ticket = ++token;\r\n\r\n return () => !disposed && ticket === token;\r\n };\r\n\r\n const previousRefresher = connectRefresher(\r\n createRefresher({\r\n readCurrent: () => currentRef.current,\r\n writeCurrent: setCurrent,\r\n buildTree: payload => buildTree(pages, payload),\r\n claimTicket,\r\n }),\r\n );\r\n\r\n const previousNavigator = connectNavigator((url, options) => {\r\n const replace = options?.replace === true;\r\n\r\n /*\r\n THIS page with a fragment on it — `#reviews`, or the current path spelled\r\n out with one appended. No fetch, no tree swap: the page is already here,\r\n and re-fetching it would discard its DOM and everything live in it to\r\n arrive back where we started, one round trip later. Address bar first,\r\n then the jump, which is the order the browser uses for a plain anchor.\r\n */\r\n const fragment = samePageFragment(url, window.location.href);\r\n\r\n if (fragment !== undefined) {\r\n if (replace) {\r\n window.history.replaceState(null, \"\", url);\r\n } else {\r\n window.history.pushState(null, \"\", url);\r\n }\r\n\r\n committedUrl = window.location.href;\r\n\r\n // The target is in the DOM already, so there is nothing to wait for —\r\n // and nothing to hand to the layout effect, which no swap would fire.\r\n scrollToFragment(document, fragment);\r\n\r\n return true;\r\n }\r\n\r\n void apply(url, replace, true);\r\n\r\n // Accepted: the caller suppresses the browser's default. Returning `true`\r\n // before the fetch resolves is deliberate — the decision to handle a link\r\n // cannot wait on the network without the browser having already followed\r\n // it.\r\n return true;\r\n });\r\n\r\n /*\r\n Back/Forward. The entry is already in history and the URL has already\r\n changed by the time this fires, so the page is fetched and swapped with\r\n `replace` — pushing here would append a duplicate entry and make Back\r\n require two presses.\r\n */\r\n const onPopState = (): void => {\r\n const target = window.location.href;\r\n /*\r\n A hash-only move within one page — Back off a `#section` click, or\r\n Forward onto one. The document is the same document and the tree on\r\n screen is already the right tree, so there is nothing to fetch: the\r\n browser has changed the URL and restored the position for that entry\r\n itself, and re-fetching would throw away a live page to rebuild the one\r\n already showing. Scroll restoration stays the browser's (canon\r\n `0342c0d4`), which is exactly what leaving this alone means.\r\n */\r\n const hashOnlyMove = withoutFragment(target) === withoutFragment(committedUrl);\r\n\r\n committedUrl = target;\r\n\r\n if (hashOnlyMove) return;\r\n\r\n void apply(target, true, false);\r\n };\r\n\r\n window.addEventListener(\"popstate\", onPopState);\r\n\r\n return () => {\r\n disposed = true;\r\n window.removeEventListener(\"popstate\", onPopState);\r\n connectNavigator(previousNavigator);\r\n connectRefresher(previousRefresher);\r\n };\r\n }, [pages, buildTree]);\r\n\r\n /*\r\n THE OTHER HALF OF THE ORDERING PROBLEM (see `pendingFragment` above).\r\n\r\n `useLayoutEffect`, not `useEffect`, and the difference is the whole point:\r\n React runs a layout effect after it has COMMITTED this render to the DOM and\r\n BEFORE the browser paints. That is the earliest instant the new page's\r\n elements exist — a scroll any sooner finds nothing — and the last instant\r\n before the user sees anything, so the page is never painted at the top and\r\n then jumped. `useEffect` would satisfy the first requirement and not the\r\n second: it runs after paint, which is a visible flash of the wrong position.\r\n\r\n Keyed on `current` rather than reaching for a fresh render: the effect fires\r\n on the swap that put the target in the DOM, so no polling, no rAF, no\r\n timeout. What it CANNOT wait for is content that arrives later still — an\r\n image without dimensions above the target, a component that suspends — which\r\n moves the target after we have scrolled to where it was. That is the known\r\n limit of this mechanism and it is the same one a browser has.\r\n\r\n Consumed once: the fragment is cleared as it is read, so a later re-render\r\n (a refresh, a parent's state change) does not yank the page back to an\r\n anchor the user has since scrolled away from.\r\n */\r\n useLayoutEffect(() => {\r\n const fragment = pendingFragment.current;\r\n\r\n if (fragment === undefined) return;\r\n\r\n pendingFragment.current = undefined;\r\n\r\n scrollToFragment(document, fragment);\r\n }, [current]);\r\n\r\n /*\r\n The payload whose metadata `<head>` currently reflects. Seeded with the\r\n HYDRATION payload, which is the point: on the initial mount the head is the\r\n server's own render of this very metadata, so there is nothing to correct —\r\n and re-applying would be a chance to get it wrong, since a payload from a\r\n build with no `metadata` key would wipe a head the server filled in\r\n correctly. The head is applied on SWAPS only.\r\n */\r\n const appliedMetadataSource = useRef(current.payload);\r\n\r\n useEffect(() => {\r\n if (appliedMetadataSource.current === current.payload) return;\r\n\r\n appliedMetadataSource.current = current.payload;\r\n\r\n /*\r\n Keyed on the payload's identity, so this covers a refresh as well as a\r\n navigation: `refresh()` produces a NEW payload for the page already on\r\n screen, and a page whose metadata is a function of its loader data can\r\n legitimately re-title itself when that data changes. One applier at the\r\n one place the page changes, rather than a call in each pathway that could\r\n be forgotten in the next one.\r\n */\r\n applyDocumentMetadata(document, current.payload.metadata);\r\n }, [current.payload]);\r\n\r\n /*\r\n DURING RENDER, not in an effect, and that placement is the requirement\r\n rather than a shortcut. A page component calling `currentRoute()` does it\r\n while IT is rendering, and child effects run before a parent's, so anything\r\n recorded from an effect here would be recorded too late to answer the first\r\n render of the page it describes — which on the initial mount is the only\r\n render there has been, and the server's match is all there is.\r\n\r\n Recording is keyed on the payload's identity (`current-route.ts`), so the\r\n extra passes render gives us for free — StrictMode's double invoke, a parent\r\n re-render — are recognised as the same page rather than counted as\r\n navigations.\r\n\r\n `routeSource`, NOT `payload`: they are the same object for every navigation,\r\n and differ only after a refresh, which produces a new payload for the page\r\n already on screen and must not be counted as a move (see `refresh.ts`).\r\n */\r\n recordCurrentRoute(current.routeSource);\r\n\r\n /*\r\n An INNER DocumentContext provider, nested inside the one `hydratePage`\r\n mounted. That outer provider is created once with the hydration payload and\r\n never changes, so anything reading the document context after a navigation\r\n would see the payload of the page the user has left. The nearest provider\r\n wins, so this one keeps it current.\r\n */\r\n /*\r\n `metadata` from the PAYLOAD, not `undefined`. The document context is the\r\n universal shape — the server provides the resolved metadata around the same\r\n tree — so handing the client's readers `undefined` was a lie the payload can\r\n now correct. It also removes a latent mismatch: a `<Head/>` rendered inside\r\n a Layout (not the App level) renders its tags into `#root` server-side, and\r\n hydrating that markup against `metadata: undefined` produced a tree the\r\n server never rendered.\r\n */\r\n return (\r\n <DocumentContext.Provider\r\n value={{ metadata: current.payload.metadata, payload: current.payload }}\r\n >\r\n {current.tree}\r\n </DocumentContext.Provider>\r\n );\r\n}\r\n"],"mappings":"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"}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import { fragmentTargetId } from "../../routing/url-fragment.mjs";
|
|
2
|
+
|
|
3
|
+
//#region ../web/src/client/navigation/scroll-to-fragment.ts
|
|
4
|
+
/**
|
|
5
|
+
* @param fragment the fragment WITHOUT its leading `#`, as
|
|
6
|
+
* `fragmentOf`/`samePageFragment` return it. Still percent-encoded — decoding
|
|
7
|
+
* is this function's job, via `fragmentTargetId`.
|
|
8
|
+
*
|
|
9
|
+
* @returns whether a target was found and scrolled to.
|
|
10
|
+
*/
|
|
11
|
+
function scrollToFragment(documentNode, fragment) {
|
|
12
|
+
if (fragment === "") return false;
|
|
13
|
+
const id = fragmentTargetId(fragment);
|
|
14
|
+
const target = documentNode.getElementById(id) ?? namedAnchor(documentNode, id);
|
|
15
|
+
if (target === void 0 || target === null) return false;
|
|
16
|
+
target.scrollIntoView();
|
|
17
|
+
return true;
|
|
18
|
+
}
|
|
19
|
+
function namedAnchor(documentNode, name) {
|
|
20
|
+
const matches = documentNode.getElementsByName?.(name);
|
|
21
|
+
return matches === void 0 || matches.length === 0 ? void 0 : matches[0];
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
//#endregion
|
|
25
|
+
export { scrollToFragment };
|
|
26
|
+
//# sourceMappingURL=scroll-to-fragment.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scroll-to-fragment.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/scroll-to-fragment.ts"],"sourcesContent":["import { fragmentTargetId } from \"../../routing/url-fragment\";\n\n/**\n * Put the element a fragment names on screen — the browser's job, done by hand\n * because a client navigation never let the browser see the fragment.\n *\n * ## A fragment that matches nothing is NOT an error\n *\n * It does not throw, and — decided with it — it does not cause the fragment to\n * be dropped from the URL either. A browser loading `/docs#gone` shows\n * `#gone` in the address bar and leaves the page where it is; an id that has\n * not been added yet, or a page whose content moved, is an ordinary state of\n * the web and not something to report. So the return value says whether\n * anything was found, for a caller that wants to know, and every caller today\n * is free to ignore it.\n *\n * ## Why the document is an argument\n *\n * The suite runs with no DOM (`web/vitest.config.ts` — `environment: \"node\"`),\n * so a module that reached for the global `document` could only be proved in a\n * browser. The structural type below is satisfied by a real `Document` and by\n * three lines of test double, which is what makes the lookup rules provable at\n * all.\n */\n\n/** The two lookups, and nothing else this module needs from a `Document`. */\nexport type FragmentScrollDocument = {\n getElementById(id: string): { scrollIntoView(): void } | null;\n /**\n * The legacy anchor form, `<a name=\"install\">`, which browsers still honour\n * as a fragment target and older documentation pages are still full of.\n * Optional so a test double may leave it out.\n */\n getElementsByName?(name: string): ArrayLike<{ scrollIntoView(): void }>;\n};\n\n/**\n * @param fragment the fragment WITHOUT its leading `#`, as\n * `fragmentOf`/`samePageFragment` return it. Still percent-encoded — decoding\n * is this function's job, via `fragmentTargetId`.\n *\n * @returns whether a target was found and scrolled to.\n */\nexport function scrollToFragment(\n documentNode: FragmentScrollDocument,\n fragment: string,\n): boolean {\n // `/docs#` names no target. Nothing to look up, and nothing to move.\n if (fragment === \"\") return false;\n\n const id = fragmentTargetId(fragment);\n\n /*\n `getElementById`, never `querySelector(\"#\" + id)`. An id is allowed to\n contain characters that are not valid in a CSS selector — `#1`, `#a.b`,\n `#a b` — and `querySelector` THROWS on those rather than missing them, which\n would turn a link to a legal id into an exception in the middle of a\n navigation.\n */\n const target = documentNode.getElementById(id) ?? namedAnchor(documentNode, id);\n\n if (target === undefined || target === null) return false;\n\n target.scrollIntoView();\n\n return true;\n}\n\nfunction namedAnchor(\n documentNode: FragmentScrollDocument,\n name: string,\n): { scrollIntoView(): void } | undefined {\n const matches = documentNode.getElementsByName?.(name);\n\n return matches === undefined || matches.length === 0 ? undefined : matches[0];\n}\n"],"mappings":";;;;;;;;;;AA2CA,SAAgB,iBACd,cACA,UACS;CAET,IAAI,aAAa,IAAI,OAAO;CAE5B,MAAM,KAAK,iBAAiB,QAAQ;CASpC,MAAM,SAAS,aAAa,eAAe,EAAE,KAAK,YAAY,cAAc,EAAE;CAE9E,IAAI,WAAW,UAAa,WAAW,MAAM,OAAO;CAEpD,OAAO,eAAe;CAEtB,OAAO;AACT;AAEA,SAAS,YACP,cACA,MACwC;CACxC,MAAM,UAAU,aAAa,oBAAoB,IAAI;CAErD,OAAO,YAAY,UAAa,QAAQ,WAAW,IAAI,SAAY,QAAQ;AAC7E"}
|
package/esm/metadata.d.mts
CHANGED
|
@@ -5,6 +5,20 @@ import { SharedContext } from "./index.mjs";
|
|
|
5
5
|
/**
|
|
6
6
|
* What the `metadata` export may produce. The pipeline injects it into
|
|
7
7
|
* `<head>` before the first byte; `<Head />` only decides placement.
|
|
8
|
+
*
|
|
9
|
+
* **Every member here is a member something READS.** The two renderers are
|
|
10
|
+
* `components/head.ts:28-70` (the SSR'd document) and
|
|
11
|
+
* `client/navigation/navigation-root.tsx:105-123` (the same head, rewritten
|
|
12
|
+
* after a client navigation), and they agree key for key. This type is the list
|
|
13
|
+
* of those keys and deliberately not one entry longer: a field the type promises
|
|
14
|
+
* and no renderer consumes is the same silence as an unknown key — the page is
|
|
15
|
+
* served without it and nothing says so.
|
|
16
|
+
*
|
|
17
|
+
* Adding a member is therefore a two-file change by construction. Add it here
|
|
18
|
+
* and {@link METADATA_KEYS} stops matching, which is a compile error
|
|
19
|
+
* ({@link MetadataKeysAreExact}); make it match and the build gate in
|
|
20
|
+
* `build/discover-pages.ts` accepts the key — but until a renderer emits a tag
|
|
21
|
+
* for it, the key still does nothing. Write the renderer.
|
|
8
22
|
*/
|
|
9
23
|
type MetadataOutput = {
|
|
10
24
|
title?: string;
|
package/esm/metadata.mjs
ADDED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region ../web/src/metadata.ts
|
|
2
|
+
/**
|
|
3
|
+
* The SAME key set as {@link MetadataOutput}, as a value.
|
|
4
|
+
*
|
|
5
|
+
* It exists because the defect this guards is one a type cannot reach.
|
|
6
|
+
* `export const metadata = { tittle: "x" }` — no annotation — is a well-typed
|
|
7
|
+
* program: TypeScript infers `{ tittle: string }` and has nothing to compare it
|
|
8
|
+
* against, so the page is served with no title and the build says nothing. The
|
|
9
|
+
* only place left to catch it is where pages are DISCOVERED, and discovery
|
|
10
|
+
* refuses to run application code — it parses. A parser cannot ask a type what
|
|
11
|
+
* its keys are, so the keys have to exist at runtime too.
|
|
12
|
+
*
|
|
13
|
+
* Two lists of one thing is exactly the drift this codebase refuses elsewhere,
|
|
14
|
+
* so they are not two lists: {@link MetadataKeysAreExact} makes any
|
|
15
|
+
* disagreement a compile error, in either direction. Add a key to the type
|
|
16
|
+
* alone and this file stops compiling; add it here alone, likewise.
|
|
17
|
+
*/
|
|
18
|
+
const METADATA_KEYS = [
|
|
19
|
+
"title",
|
|
20
|
+
"description",
|
|
21
|
+
"keywords",
|
|
22
|
+
"canonical",
|
|
23
|
+
"robots",
|
|
24
|
+
"openGraph",
|
|
25
|
+
"twitter"
|
|
26
|
+
];
|
|
27
|
+
/** The members of `openGraph`, on the same terms as {@link METADATA_KEYS}. */
|
|
28
|
+
const OPEN_GRAPH_KEYS = [
|
|
29
|
+
"title",
|
|
30
|
+
"description",
|
|
31
|
+
"image",
|
|
32
|
+
"url",
|
|
33
|
+
"type"
|
|
34
|
+
];
|
|
35
|
+
/** The members of `twitter`, on the same terms as {@link METADATA_KEYS}. */
|
|
36
|
+
const TWITTER_KEYS = [
|
|
37
|
+
"card",
|
|
38
|
+
"title",
|
|
39
|
+
"description",
|
|
40
|
+
"image"
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
//#endregion
|
|
44
|
+
export { METADATA_KEYS, OPEN_GRAPH_KEYS, TWITTER_KEYS };
|
|
45
|
+
//# sourceMappingURL=metadata.mjs.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"metadata.mjs","names":[],"sources":["../../../../../../web/src/metadata.ts"],"sourcesContent":["import type { SharedContext } from \"./index\";\r\nimport type { LoaderData, LoaderFunction } from \"./props\";\r\n\r\n/**\r\n * What the `metadata` export may produce. The pipeline injects it into\r\n * `<head>` before the first byte; `<Head />` only decides placement.\r\n *\r\n * **Every member here is a member something READS.** The two renderers are\r\n * `components/head.ts:28-70` (the SSR'd document) and\r\n * `client/navigation/navigation-root.tsx:105-123` (the same head, rewritten\r\n * after a client navigation), and they agree key for key. This type is the list\r\n * of those keys and deliberately not one entry longer: a field the type promises\r\n * and no renderer consumes is the same silence as an unknown key — the page is\r\n * served without it and nothing says so.\r\n *\r\n * Adding a member is therefore a two-file change by construction. Add it here\r\n * and {@link METADATA_KEYS} stops matching, which is a compile error\r\n * ({@link MetadataKeysAreExact}); make it match and the build gate in\r\n * `build/discover-pages.ts` accepts the key — but until a renderer emits a tag\r\n * for it, the key still does nothing. Write the renderer.\r\n */\r\nexport type MetadataOutput = {\r\n title?: string;\r\n description?: string;\r\n keywords?: string | readonly string[];\r\n canonical?: string;\r\n robots?: string;\r\n /**\r\n * `og:title`/`og:description` fall back to the top-level `title`/\r\n * `description` when `openGraph` is present but the member is absent —\r\n * no other member has a fallback.\r\n */\r\n openGraph?: {\r\n title?: string;\r\n description?: string;\r\n image?: string;\r\n url?: string;\r\n type?: string;\r\n };\r\n twitter?: {\r\n card?: string;\r\n title?: string;\r\n description?: string;\r\n image?: string;\r\n };\r\n};\r\n\r\n/**\r\n * The SAME key set as {@link MetadataOutput}, as a value.\r\n *\r\n * It exists because the defect this guards is one a type cannot reach.\r\n * `export const metadata = { tittle: \"x\" }` — no annotation — is a well-typed\r\n * program: TypeScript infers `{ tittle: string }` and has nothing to compare it\r\n * against, so the page is served with no title and the build says nothing. The\r\n * only place left to catch it is where pages are DISCOVERED, and discovery\r\n * refuses to run application code — it parses. A parser cannot ask a type what\r\n * its keys are, so the keys have to exist at runtime too.\r\n *\r\n * Two lists of one thing is exactly the drift this codebase refuses elsewhere,\r\n * so they are not two lists: {@link MetadataKeysAreExact} makes any\r\n * disagreement a compile error, in either direction. Add a key to the type\r\n * alone and this file stops compiling; add it here alone, likewise.\r\n */\r\nexport const METADATA_KEYS = [\r\n \"title\",\r\n \"description\",\r\n \"keywords\",\r\n \"canonical\",\r\n \"robots\",\r\n \"openGraph\",\r\n \"twitter\",\r\n] as const;\r\n\r\n/** The members of `openGraph`, on the same terms as {@link METADATA_KEYS}. */\r\nexport const OPEN_GRAPH_KEYS = [\"title\", \"description\", \"image\", \"url\", \"type\"] as const;\r\n\r\n/** The members of `twitter`, on the same terms as {@link METADATA_KEYS}. */\r\nexport const TWITTER_KEYS = [\"card\", \"title\", \"description\", \"image\"] as const;\r\n\r\n/**\r\n * Instantiates only when `Difference` is empty. When it is not, the compiler\r\n * names the offending key in the error — \"Type '\\\"tittle\\\"' does not satisfy\r\n * the constraint 'never'\" — which is the whole message a drift needs.\r\n *\r\n * A mutual `extends` (`Exactly<A extends B, B extends A>`) says the same thing\r\n * more directly and TypeScript rejects it as a circular constraint (TS2313), so\r\n * the sets are compared by difference instead, once in each direction. BOTH\r\n * directions matter: a list that has fallen behind the type makes the build gate\r\n * reject a key it should accept, and a list that has run ahead makes it accept\r\n * one nothing renders.\r\n */\r\ntype NoDifference<Difference extends never> = Difference;\r\n\r\n/**\r\n * The drift guards, written out per key set rather than through one generic\r\n * helper: a `SameKeys<List, Keys>` alias would apply `NoDifference` to an\r\n * UNRESOLVED `Exclude<List, Keys>`, which the compiler cannot show is empty and\r\n * so rejects at the declaration (TS2344) whatever the real key sets are. Passed\r\n * concrete types, it resolves and checks the thing it is meant to check.\r\n *\r\n * Exported so they are not \"unused\", and named so a failure reads as what it\r\n * is: the key list and the type have diverged.\r\n */\r\nexport type MetadataKeysAreExact = [\r\n NoDifference<Exclude<(typeof METADATA_KEYS)[number], keyof MetadataOutput>>,\r\n NoDifference<Exclude<keyof MetadataOutput, (typeof METADATA_KEYS)[number]>>,\r\n];\r\n\r\ntype OpenGraphKey = keyof NonNullable<MetadataOutput[\"openGraph\"]>;\r\n\r\nexport type OpenGraphKeysAreExact = [\r\n NoDifference<Exclude<(typeof OPEN_GRAPH_KEYS)[number], OpenGraphKey>>,\r\n NoDifference<Exclude<OpenGraphKey, (typeof OPEN_GRAPH_KEYS)[number]>>,\r\n];\r\n\r\ntype TwitterKey = keyof NonNullable<MetadataOutput[\"twitter\"]>;\r\n\r\nexport type TwitterKeysAreExact = [\r\n NoDifference<Exclude<(typeof TWITTER_KEYS)[number], TwitterKey>>,\r\n NoDifference<Exclude<TwitterKey, (typeof TWITTER_KEYS)[number]>>,\r\n];\r\n\r\n/**\r\n * Static object OR a function of the loader's data (products.page.tsx:81-84,\r\n * product-details.page.tsx:71-74). The function form runs server-side, after\r\n * the loader, with the same `data` the component will receive — which is why\r\n * it can describe the page instead of guessing at it.\r\n *\r\n * **`data` is always present, and that is now true rather than merely\r\n * declared.** The function form runs only when the loader resolved; when it\r\n * rejected, the framework emits `ERROR_PAGE_METADATA` and this never runs\r\n * (`server/resolve-page-metadata.ts`, which explains why at length). An earlier\r\n * revision passed `{ data: undefined, error }` on the boundary path while\r\n * declaring `data` non-optional — every page that read `data` unguarded then\r\n * threw a `TypeError` that replaced the loader's real error.\r\n */\r\nexport type PageMetadata<TLoader extends LoaderFunction | undefined = undefined> =\r\n | MetadataOutput\r\n | ((context: {\r\n data: LoaderData<TLoader>;\r\n shared: Readonly<SharedContext>;\r\n }) => MetadataOutput);\r\n"],"mappings":";;;;;;;;;;;;;;;;;AA+DA,MAAa,gBAAgB;CAC3B;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;AAGA,MAAa,kBAAkB;CAAC;CAAS;CAAe;CAAS;CAAO;AAAM;;AAG9E,MAAa,eAAe;CAAC;CAAQ;CAAS;CAAe;AAAO"}
|