@warlock.js/web 5.2.3 → 5.2.4
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/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-client-registry.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs.map +1 -1
- package/esm/build/page-default-export.mjs.map +1 -1
- package/esm/build/page-routes-manifest.mjs.map +1 -1
- package/esm/build/public-files.mjs.map +1 -1
- package/esm/build/read-route-exports.mjs.map +1 -1
- package/esm/client/build-hydrated-tree.mjs.map +1 -1
- package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
- package/esm/client/navigation/prefetch.mjs.map +1 -1
- package/esm/client/runtime/manifest.mjs.map +1 -1
- package/esm/client/runtime/matcher.mjs.map +1 -1
- package/esm/components/document-context.mjs.map +1 -1
- package/esm/components/link.mjs.map +1 -1
- package/esm/routing/filesystem-route.mjs.map +1 -1
- package/esm/routing/layout-policy.mjs.map +1 -1
- package/esm/routing/query-string.mjs.map +1 -1
- package/esm/routing/route-table.mjs.map +1 -1
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/match-page-route.mjs.map +1 -1
- package/esm/server/not-found-page.mjs.map +1 -1
- package/esm/server/page-file-change.mjs.map +1 -1
- package/esm/server/page-route-reload.mjs.map +1 -1
- package/esm/server/render-page.mjs.map +1 -1
- package/esm/server/settle-page-response.mjs.map +1 -1
- package/esm/server/stylesheet-urls.mjs.map +1 -1
- package/esm/server/unregistered-pages.mjs.map +1 -1
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/shared.mjs.map +1 -1
- package/esm/vite/build-client.mjs.map +1 -1
- package/esm/vite/gate-a-resolve.mjs.map +1 -1
- package/esm/vite/gate-b-secrets.mjs.map +1 -1
- package/esm/vite/gate-c-verify.mjs.map +1 -1
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/index.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/esm/vite/projection.mjs.map +1 -1
- package/package.json +3 -3
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"read-route-exports.mjs","names":[],"sources":["../../../../../../../web/src/build/read-route-exports.ts"],"sourcesContent":["/**\r\n * Reads a page's `route` export and a layout's `prefix` export STATICALLY —\r\n * by parsing the source, never by loading the module.\r\n *\r\n * The build has to know a page's declared route before anything is built, and\r\n * the only other way to learn it is to run the page: import the module, let its\r\n * top-level code execute, and read the binding. That is a different program\r\n * from the one being built, with the application's own side effects in it. So\r\n * this module parses instead, and the price of parsing is that the declaration\r\n * has to be readable without evaluation — a literal. What cannot be read is\r\n * REFUSED rather than guessed: a wrong route path that builds is worse than a\r\n * build that stops and says which file to change.\r\n *\r\n * `route` and `prefix` are names the page contract reserves, so this reads them\r\n * out of whichever file it is given and refuses a computed one wherever it\r\n * appears — a page that exports `prefix`, or a layout that exports `route`, is\r\n * using a name the framework already owns.\r\n *\r\n * Single responsibility, deliberately: this returns values or a typed\r\n * rejection and decides nothing. What a rejection costs, and when a page is\r\n * routed at all, belongs to the caller.\r\n */\r\nimport fs from \"node:fs\";\r\nimport { parse } from \"@babel/parser\";\r\n\r\n/**\r\n * The AST types are derived from `parse`'s own return type rather than imported\r\n * from `@babel/types`: the parser resolves its own copy of that package, and a\r\n * node from one copy is not assignable to the identically-shaped type from the\r\n * other. Reading the types off the function that produced the nodes cannot\r\n * disagree with it.\r\n */\r\ntype Statement = ReturnType<typeof parse>[\"program\"][\"body\"][number];\r\ntype Expression = Extract<Statement, { type: \"ExpressionStatement\" }>[\"expression\"];\r\ntype ObjectExpression = Extract<Expression, { type: \"ObjectExpression\" }>;\r\ntype ObjectProperty = Extract<ObjectExpression[\"properties\"][number], { type: \"ObjectProperty\" }>;\r\n\r\n/**\r\n * Anything that can appear where a value is expected — an expression, or one of\r\n * the destructuring patterns that are legal in an object literal's value slot\r\n * and are never a literal string.\r\n */\r\ntype ValueNode = ObjectProperty[\"value\"];\r\n\r\n/**\r\n * A declared route, normalised. The bare-string form (`route = \"/list\"`) and\r\n * the object form (`route = { path: \"/list\" }`) reach the caller identically,\r\n * because the server resolves them identically — `name` is absent exactly when\r\n * the source omitted it, which is the caller's signal to derive one.\r\n */\r\nexport type DeclaredRoute = { path: string; name?: string };\r\n\r\n/** Which export could not be read, from which file, and what was found instead. */\r\nexport type RouteExportsRejection = {\r\n sourceFile: string;\r\n exportName: \"route\" | \"prefix\";\r\n /** A sentence fragment naming the form that was found, e.g. \"its value is a function call\". */\r\n detail: string;\r\n};\r\n\r\nexport type RouteExportsReadResult =\r\n | { ok: true; route?: DeclaredRoute; prefix?: string }\r\n | { ok: false; rejection: RouteExportsRejection };\r\n\r\nconst EXAMPLES: Record<\"route\" | \"prefix\", string> = {\r\n route: 'export const route = \"/list\"; (or export const route = { path: \"/list\", name: \"shop.list\" };)',\r\n prefix: 'export const prefix = \"/shop\";',\r\n};\r\n\r\n/**\r\n * The one thing an app developer is told when a declaration cannot be read.\r\n *\r\n * It names the file, says what was found, says why a literal is required, and\r\n * shows one — because the reader of this message is someone who wrote perfectly\r\n * valid TypeScript and needs to know why the build will not take it.\r\n */\r\nexport class NonLiteralRouteExportError extends Error {\r\n public constructor(public readonly rejection: RouteExportsRejection) {\r\n const { sourceFile, exportName, detail } = rejection;\r\n\r\n super(\r\n `Cannot read the \\`${exportName}\\` export of \"${sourceFile}\": ${detail}. The build reads ` +\r\n \"route declarations without running your application code, so this value has to be \" +\r\n `written out as a literal. For example: ${EXAMPLES[exportName]}`,\r\n );\r\n\r\n this.name = \"NonLiteralRouteExportError\";\r\n }\r\n}\r\n\r\n/**\r\n * `as const`, `satisfies`, a non-null assertion and parentheses all wrap a value\r\n * without changing it, so reading through them costs nothing and refusing them\r\n * would reject declarations that are literal in every sense that matters here.\r\n */\r\nfunction unwrap(node: ValueNode): ValueNode {\r\n switch (node.type) {\r\n case \"TSAsExpression\":\r\n case \"TSSatisfiesExpression\":\r\n case \"TSNonNullExpression\":\r\n case \"TypeCastExpression\":\r\n case \"ParenthesizedExpression\":\r\n return unwrap(node.expression);\r\n default:\r\n return node;\r\n }\r\n}\r\n\r\n/** The string a node denotes, or `undefined` when that needs evaluation to know. */\r\nfunction stringLiteralOf(node: ValueNode): string | undefined {\r\n const value = unwrap(node);\r\n\r\n if (value.type === \"StringLiteral\") return value.value;\r\n\r\n // A template with no substitutions is a string spelled with backticks.\r\n if (value.type === \"TemplateLiteral\" && value.expressions.length === 0) {\r\n return value.quasis[0]?.value.cooked ?? value.quasis[0]?.value.raw;\r\n }\r\n\r\n return undefined;\r\n}\r\n\r\n/** A sentence fragment naming what was found, for the developer-facing message. */\r\nfunction describe(node: ValueNode): string {\r\n const value = unwrap(node);\r\n\r\n switch (value.type) {\r\n case \"CallExpression\":\r\n case \"OptionalCallExpression\":\r\n case \"NewExpression\":\r\n return \"its value is a function call\";\r\n case \"Identifier\":\r\n return `its value is the variable \\`${value.name}\\``;\r\n case \"MemberExpression\":\r\n case \"OptionalMemberExpression\":\r\n return \"its value is read off another object\";\r\n case \"TemplateLiteral\":\r\n return \"its value is a template literal with an expression in it\";\r\n case \"ConditionalExpression\":\r\n return \"its value depends on a condition\";\r\n case \"BinaryExpression\":\r\n case \"LogicalExpression\":\r\n return \"its value is built by an expression\";\r\n default:\r\n return \"its value is computed rather than written out\";\r\n }\r\n}\r\n\r\ntype ObjectRead = { ok: true; route: DeclaredRoute } | { ok: false; detail: string };\r\n\r\n/**\r\n * The object form. Unknown keys are IGNORED rather than refused, matching the\r\n * server, which reads `path` and `name` and lets a page carry whatever else it\r\n * wants alongside them. A spread is not an unknown key: it can contribute\r\n * `path` itself, so an object that spreads is an object whose route this cannot\r\n * claim to have read.\r\n */\r\nfunction readRouteObject(node: ObjectExpression): ObjectRead {\r\n let routePath: string | undefined;\r\n let routeName: string | undefined;\r\n\r\n for (const property of node.properties) {\r\n if (property.type === \"SpreadElement\") {\r\n return { ok: false, detail: \"the object spreads another value into itself\" };\r\n }\r\n\r\n if (property.computed) {\r\n return { ok: false, detail: \"one of the object's keys is computed\" };\r\n }\r\n\r\n const { key } = property;\r\n const keyName =\r\n key.type === \"Identifier\" ? key.name : key.type === \"StringLiteral\" ? key.value : undefined;\r\n\r\n if (keyName !== \"path\" && keyName !== \"name\") continue;\r\n\r\n if (property.type !== \"ObjectProperty\") {\r\n return { ok: false, detail: `\\`${keyName}\\` is declared as a method` };\r\n }\r\n\r\n const value = stringLiteralOf(property.value);\r\n\r\n if (value === undefined) {\r\n return { ok: false, detail: `its \\`${keyName}\\` is not written as a string literal` };\r\n }\r\n\r\n if (keyName === \"path\") routePath = value;\r\n else routeName = value;\r\n }\r\n\r\n if (routePath === undefined) {\r\n return { ok: false, detail: \"the object does not declare a `path`\" };\r\n }\r\n\r\n return {\r\n ok: true,\r\n route: routeName === undefined ? { path: routePath } : { path: routePath, name: routeName },\r\n };\r\n}\r\n\r\n/**\r\n * Parses the source, or THROWS when it cannot be parsed at all.\r\n *\r\n * A syntax error is not a rejection, deliberately: nothing about the route\r\n * declaration is known yet, so telling the developer to write a literal would\r\n * answer a question they did not ask.\r\n */\r\nfunction parseSource(sourceFile: string, source: string) {\r\n try {\r\n return parse(source, {\r\n sourceType: \"module\",\r\n // Every file this reads is a page or a layout, i.e. `.tsx`.\r\n plugins: [\"typescript\", \"jsx\"],\r\n errorRecovery: false,\r\n });\r\n } catch (error) {\r\n throw new Error(\r\n `Cannot read the route declarations of \"${sourceFile}\": the file could not be parsed ` +\r\n `(${(error as Error).message}). Fix the syntax error and the build will continue.`,\r\n );\r\n }\r\n}\r\n\r\n/**\r\n * Returns the literal `route` and `prefix` this file declares.\r\n *\r\n * Absent is not a rejection: a file that declares neither is read successfully\r\n * with both fields unset, and what THAT means — a page with no public URL, a\r\n * layout that adds no prefix — is the caller's call to make.\r\n *\r\n * `source` is an override for callers that already hold the text; by default\r\n * the file is read from disk.\r\n */\r\nexport function readRouteExports(sourceFile: string, source?: string): RouteExportsReadResult {\r\n const text = source ?? fs.readFileSync(sourceFile, \"utf-8\");\r\n const ast = parseSource(sourceFile, text);\r\n\r\n const reject = (exportName: \"route\" | \"prefix\", detail: string): RouteExportsReadResult => ({\r\n ok: false,\r\n rejection: { sourceFile, exportName, detail },\r\n });\r\n\r\n let route: DeclaredRoute | undefined;\r\n let prefix: string | undefined;\r\n\r\n for (const statement of ast.program.body) {\r\n if (statement.type !== \"ExportNamedDeclaration\" || statement.exportKind === \"type\") continue;\r\n\r\n // `export { route }` hides the value behind a binding this cannot follow\r\n // without resolving scope — and following it across modules is exactly the\r\n // evaluation this reader exists to avoid.\r\n for (const specifier of statement.specifiers) {\r\n if (specifier.type !== \"ExportSpecifier\" || specifier.exportKind === \"type\") continue;\r\n\r\n const exported =\r\n specifier.exported.type === \"Identifier\"\r\n ? specifier.exported.name\r\n : specifier.exported.value;\r\n\r\n if (exported === \"route\" || exported === \"prefix\") {\r\n return reject(\r\n exported,\r\n \"it is exported through an export list rather than declared with `export const`\",\r\n );\r\n }\r\n }\r\n\r\n const { declaration } = statement;\r\n\r\n if (declaration?.type !== \"VariableDeclaration\") continue;\r\n\r\n for (const declarator of declaration.declarations) {\r\n if (declarator.id.type !== \"Identifier\") continue;\r\n\r\n const declared = declarator.id.name;\r\n\r\n if (declared !== \"route\" && declared !== \"prefix\") continue;\r\n\r\n if (declarator.init === null || declarator.init === undefined) {\r\n return reject(declared, \"it is declared without a value\");\r\n }\r\n\r\n if (declared === \"prefix\") {\r\n const value = stringLiteralOf(declarator.init);\r\n\r\n if (value === undefined) return reject(\"prefix\", describe(declarator.init));\r\n\r\n prefix = value;\r\n continue;\r\n }\r\n\r\n const value = stringLiteralOf(declarator.init);\r\n\r\n if (value !== undefined) {\r\n route = { path: value };\r\n continue;\r\n }\r\n\r\n const object = unwrap(declarator.init);\r\n\r\n if (object.type !== \"ObjectExpression\") return reject(\"route\", describe(declarator.init));\r\n\r\n const read = readRouteObject(object);\r\n\r\n if (!read.ok) return reject(\"route\", read.detail);\r\n\r\n route = read.route;\r\n }\r\n }\r\n\r\n return {\r\n ok: true,\r\n ...(route === undefined ? {} : { route }),\r\n ...(prefix === undefined ? {} : { prefix }),\r\n };\r\n}\r\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"read-route-exports.mjs","names":[],"sources":["../../../../../../../web/src/build/read-route-exports.ts"],"sourcesContent":["/**\r\n * Reads a page's `route` export and a layout's `prefix` export STATICALLY —\r\n * by parsing the source, never by loading the module.\r\n *\r\n * The build has to know a page's declared route before anything is built, and\r\n * the only other way to learn it is to run the page: import the module, let its\r\n * top-level code execute, and read the binding. That is a different program\r\n * from the one being built, with the application's own side effects in it. So\r\n * this module parses instead, and the price of parsing is that the declaration\r\n * has to be readable without evaluation — a literal. What cannot be read is\r\n * REFUSED rather than guessed: a wrong route path that builds is worse than a\r\n * build that stops and says which file to change.\r\n *\r\n * `route` and `prefix` are names the page contract reserves, so this reads them\r\n * out of whichever file it is given and refuses a computed one wherever it\r\n * appears — a page that exports `prefix`, or a layout that exports `route`, is\r\n * using a name the framework already owns.\r\n *\r\n * Single responsibility, deliberately: this returns values or a typed\r\n * rejection and decides nothing. What a rejection costs, and when a page is\r\n * routed at all, belongs to the caller.\r\n */\r\nimport fs from \"node:fs\";\r\nimport { parse } from \"@babel/parser\";\r\n\r\n/**\r\n * The AST types are derived from `parse`'s own return type rather than imported\r\n * from `@babel/types`: the parser resolves its own copy of that package, and a\r\n * node from one copy is not assignable to the identically-shaped type from the\r\n * other. Reading the types off the function that produced the nodes cannot\r\n * disagree with it.\r\n */\r\ntype Statement = ReturnType<typeof parse>[\"program\"][\"body\"][number];\r\ntype Expression = Extract<Statement, { type: \"ExpressionStatement\" }>[\"expression\"];\r\ntype ObjectExpression = Extract<Expression, { type: \"ObjectExpression\" }>;\r\ntype ObjectProperty = Extract<ObjectExpression[\"properties\"][number], { type: \"ObjectProperty\" }>;\r\n\r\n/**\r\n * Anything that can appear where a value is expected — an expression, or one of\r\n * the destructuring patterns that are legal in an object literal's value slot\r\n * and are never a literal string.\r\n */\r\ntype ValueNode = ObjectProperty[\"value\"];\r\n\r\n/**\r\n * A declared route, normalised. The bare-string form (`route = \"/list\"`) and\r\n * the object form (`route = { path: \"/list\" }`) reach the caller identically,\r\n * because the server resolves them identically — `name` is absent exactly when\r\n * the source omitted it, which is the caller's signal to derive one.\r\n */\r\nexport type DeclaredRoute = { path: string; name?: string };\r\n\r\n/** Which export could not be read, from which file, and what was found instead. */\r\nexport type RouteExportsRejection = {\r\n sourceFile: string;\r\n exportName: \"route\" | \"prefix\";\r\n /** A sentence fragment naming the form that was found, e.g. \"its value is a function call\". */\r\n detail: string;\r\n};\r\n\r\nexport type RouteExportsReadResult =\r\n | { ok: true; route?: DeclaredRoute; prefix?: string }\r\n | { ok: false; rejection: RouteExportsRejection };\r\n\r\nconst EXAMPLES: Record<\"route\" | \"prefix\", string> = {\r\n route: 'export const route = \"/list\"; (or export const route = { path: \"/list\", name: \"shop.list\" };)',\r\n prefix: 'export const prefix = \"/shop\";',\r\n};\r\n\r\n/**\r\n * The one thing an app developer is told when a declaration cannot be read.\r\n *\r\n * It names the file, says what was found, says why a literal is required, and\r\n * shows one — because the reader of this message is someone who wrote perfectly\r\n * valid TypeScript and needs to know why the build will not take it.\r\n */\r\nexport class NonLiteralRouteExportError extends Error {\r\n public constructor(public readonly rejection: RouteExportsRejection) {\r\n const { sourceFile, exportName, detail } = rejection;\r\n\r\n super(\r\n `Cannot read the \\`${exportName}\\` export of \"${sourceFile}\": ${detail}. The build reads ` +\r\n \"route declarations without running your application code, so this value has to be \" +\r\n `written out as a literal. For example: ${EXAMPLES[exportName]}`,\r\n );\r\n\r\n this.name = \"NonLiteralRouteExportError\";\r\n }\r\n}\r\n\r\n/**\r\n * `as const`, `satisfies`, a non-null assertion and parentheses all wrap a value\r\n * without changing it, so reading through them costs nothing and refusing them\r\n * would reject declarations that are literal in every sense that matters here.\r\n */\r\nfunction unwrap(node: ValueNode): ValueNode {\r\n switch (node.type) {\r\n case \"TSAsExpression\":\r\n case \"TSSatisfiesExpression\":\r\n case \"TSNonNullExpression\":\r\n case \"TypeCastExpression\":\r\n case \"ParenthesizedExpression\":\r\n return unwrap(node.expression);\r\n default:\r\n return node;\r\n }\r\n}\r\n\r\n/** The string a node denotes, or `undefined` when that needs evaluation to know. */\r\nfunction stringLiteralOf(node: ValueNode): string | undefined {\r\n const value = unwrap(node);\r\n\r\n if (value.type === \"StringLiteral\") return value.value;\r\n\r\n // A template with no substitutions is a string spelled with backticks.\r\n if (value.type === \"TemplateLiteral\" && value.expressions.length === 0) {\r\n return value.quasis[0]?.value.cooked ?? value.quasis[0]?.value.raw;\r\n }\r\n\r\n return undefined;\r\n}\r\n\r\n/** A sentence fragment naming what was found, for the developer-facing message. */\r\nfunction describe(node: ValueNode): string {\r\n const value = unwrap(node);\r\n\r\n switch (value.type) {\r\n case \"CallExpression\":\r\n case \"OptionalCallExpression\":\r\n case \"NewExpression\":\r\n return \"its value is a function call\";\r\n case \"Identifier\":\r\n return `its value is the variable \\`${value.name}\\``;\r\n case \"MemberExpression\":\r\n case \"OptionalMemberExpression\":\r\n return \"its value is read off another object\";\r\n case \"TemplateLiteral\":\r\n return \"its value is a template literal with an expression in it\";\r\n case \"ConditionalExpression\":\r\n return \"its value depends on a condition\";\r\n case \"BinaryExpression\":\r\n case \"LogicalExpression\":\r\n return \"its value is built by an expression\";\r\n default:\r\n return \"its value is computed rather than written out\";\r\n }\r\n}\r\n\r\ntype ObjectRead = { ok: true; route: DeclaredRoute } | { ok: false; detail: string };\r\n\r\n/**\r\n * The object form. Unknown keys are IGNORED rather than refused, matching the\r\n * server, which reads `path` and `name` and lets a page carry whatever else it\r\n * wants alongside them. A spread is not an unknown key: it can contribute\r\n * `path` itself, so an object that spreads is an object whose route this cannot\r\n * claim to have read.\r\n */\r\nfunction readRouteObject(node: ObjectExpression): ObjectRead {\r\n let routePath: string | undefined;\r\n let routeName: string | undefined;\r\n\r\n for (const property of node.properties) {\r\n if (property.type === \"SpreadElement\") {\r\n return { ok: false, detail: \"the object spreads another value into itself\" };\r\n }\r\n\r\n if (property.computed) {\r\n return { ok: false, detail: \"one of the object's keys is computed\" };\r\n }\r\n\r\n const { key } = property;\r\n const keyName =\r\n key.type === \"Identifier\" ? key.name : key.type === \"StringLiteral\" ? key.value : undefined;\r\n\r\n if (keyName !== \"path\" && keyName !== \"name\") continue;\r\n\r\n if (property.type !== \"ObjectProperty\") {\r\n return { ok: false, detail: `\\`${keyName}\\` is declared as a method` };\r\n }\r\n\r\n const value = stringLiteralOf(property.value);\r\n\r\n if (value === undefined) {\r\n return { ok: false, detail: `its \\`${keyName}\\` is not written as a string literal` };\r\n }\r\n\r\n if (keyName === \"path\") routePath = value;\r\n else routeName = value;\r\n }\r\n\r\n if (routePath === undefined) {\r\n return { ok: false, detail: \"the object does not declare a `path`\" };\r\n }\r\n\r\n return {\r\n ok: true,\r\n route: routeName === undefined ? { path: routePath } : { path: routePath, name: routeName },\r\n };\r\n}\r\n\r\n/**\r\n * Parses the source, or THROWS when it cannot be parsed at all.\r\n *\r\n * A syntax error is not a rejection, deliberately: nothing about the route\r\n * declaration is known yet, so telling the developer to write a literal would\r\n * answer a question they did not ask.\r\n */\r\nfunction parseSource(sourceFile: string, source: string) {\r\n try {\r\n return parse(source, {\r\n sourceType: \"module\",\r\n // Every file this reads is a page or a layout, i.e. `.tsx`.\r\n plugins: [\"typescript\", \"jsx\"],\r\n errorRecovery: false,\r\n });\r\n } catch (error) {\r\n throw new Error(\r\n `Cannot read the route declarations of \"${sourceFile}\": the file could not be parsed ` +\r\n `(${(error as Error).message}). Fix the syntax error and the build will continue.`,\r\n );\r\n }\r\n}\r\n\r\n/**\r\n * Returns the literal `route` and `prefix` this file declares.\r\n *\r\n * Absent is not a rejection: a file that declares neither is read successfully\r\n * with both fields unset, and what THAT means — a page with no public URL, a\r\n * layout that adds no prefix — is the caller's call to make.\r\n *\r\n * `source` is an override for callers that already hold the text; by default\r\n * the file is read from disk.\r\n */\r\nexport function readRouteExports(sourceFile: string, source?: string): RouteExportsReadResult {\r\n const text = source ?? fs.readFileSync(sourceFile, \"utf-8\");\r\n const ast = parseSource(sourceFile, text);\r\n\r\n const reject = (exportName: \"route\" | \"prefix\", detail: string): RouteExportsReadResult => ({\r\n ok: false,\r\n rejection: { sourceFile, exportName, detail },\r\n });\r\n\r\n let route: DeclaredRoute | undefined;\r\n let prefix: string | undefined;\r\n\r\n for (const statement of ast.program.body) {\r\n if (statement.type !== \"ExportNamedDeclaration\" || statement.exportKind === \"type\") continue;\r\n\r\n // `export { route }` hides the value behind a binding this cannot follow\r\n // without resolving scope — and following it across modules is exactly the\r\n // evaluation this reader exists to avoid.\r\n for (const specifier of statement.specifiers) {\r\n if (specifier.type !== \"ExportSpecifier\" || specifier.exportKind === \"type\") continue;\r\n\r\n const exported =\r\n specifier.exported.type === \"Identifier\"\r\n ? specifier.exported.name\r\n : specifier.exported.value;\r\n\r\n if (exported === \"route\" || exported === \"prefix\") {\r\n return reject(\r\n exported,\r\n \"it is exported through an export list rather than declared with `export const`\",\r\n );\r\n }\r\n }\r\n\r\n const { declaration } = statement;\r\n\r\n if (declaration?.type !== \"VariableDeclaration\") continue;\r\n\r\n for (const declarator of declaration.declarations) {\r\n if (declarator.id.type !== \"Identifier\") continue;\r\n\r\n const declared = declarator.id.name;\r\n\r\n if (declared !== \"route\" && declared !== \"prefix\") continue;\r\n\r\n if (declarator.init === null || declarator.init === undefined) {\r\n return reject(declared, \"it is declared without a value\");\r\n }\r\n\r\n if (declared === \"prefix\") {\r\n const value = stringLiteralOf(declarator.init);\r\n\r\n if (value === undefined) return reject(\"prefix\", describe(declarator.init));\r\n\r\n prefix = value;\r\n continue;\r\n }\r\n\r\n const value = stringLiteralOf(declarator.init);\r\n\r\n if (value !== undefined) {\r\n route = { path: value };\r\n continue;\r\n }\r\n\r\n const object = unwrap(declarator.init);\r\n\r\n if (object.type !== \"ObjectExpression\") return reject(\"route\", describe(declarator.init));\r\n\r\n const read = readRouteObject(object);\r\n\r\n if (!read.ok) return reject(\"route\", read.detail);\r\n\r\n route = read.route;\r\n }\r\n }\r\n\r\n return {\r\n ok: true,\r\n ...(route === undefined ? {} : { route }),\r\n ...(prefix === undefined ? {} : { prefix }),\r\n };\r\n}\r\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"build-hydrated-tree.mjs","names":[],"sources":["../../../../../../../web/src/client/build-hydrated-tree.ts"],"sourcesContent":["/**\n * The hydration COMPOSER: payload + page registry -> the ReactNode to mount.\n *\n * It takes the registry as an ARGUMENT and touches no browser global, which is\n * the whole point of it living apart from `index.ts`: every rule below is\n * testable with a hand-built registry, no bundler, no virtual module, no DOM.\n *\n * LOOKUP BY NAME, NEVER BY MATCH. `payload.name` is the identity of the entry\n * the SERVER matched for this exact request (document-context.ts's `name`\n * field). Re-deriving it from `location.pathname` with `matchClientRoute`\n * would be a second implementation of route semantics running against the one\n * request it is hydrating, free to disagree with the server that produced the\n * markup. `matchClientRoute` is for client-side NAVIGATION, where no server\n * answer exists yet.\n */\nimport { createElement, type ComponentType, type ReactNode } from \"react\";\nimport type {\n HydrationDocumentPayloadSource,\n SerializedErrorPageProps,\n} from \"../hydration-payload\";\nimport { registerModules } from \"../runtime/register-modules\";\nimport { loadClientRouteComposition } from \"./runtime\";\nimport type { ClientPageEntry, ClientProjectedModule } from \"./runtime/types\";\n\n/** What every composed level receives — the shape `render-page.ts` uses server-side. */\ntype HydratedLevelProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly children?: ReactNode;\n};\n\n/** The ordinary page leaf alone receives params from the server's match. */\ntype HydratedPageProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly params: Readonly<Record<string, string>>;\n};\n\nfunction describeKnownNames(knownPageNames: readonly string[]): string {\n if (knownPageNames.length === 0) return \"The client page registry is empty.\";\n\n return `The registry knows: ${knownPageNames.map((name) => JSON.stringify(name)).join(\", \")}.`;\n}\n\n/**\n * The THIRD hydration failure case, beside an absent and a malformed payload.\n *\n * It fails CLOSED — no default entry, no nearest-path fallback, no silent\n * no-op. A registry that quietly substitutes a page produces a browser showing\n * one page's markup running another page's code, which is precisely the defect\n * this entry point was rewritten to remove; a fallback would reintroduce it\n * wearing a recovery costume. Throwing leaves the server-rendered markup on\n * screen and un-hydrated, which is degraded but honest.\n */\nexport class UnknownHydrationPageNameError extends Error {\n public constructor(\n public readonly pageName: string,\n public readonly knownPageNames: readonly string[],\n ) {\n super(\n `Warlock hydration aborted: the payload names page ${JSON.stringify(pageName)}, which is ` +\n `not in the client page registry. ${describeKnownNames(knownPageNames)} The server ` +\n \"rendered a page this browser bundle does not carry, so the server and client were \" +\n \"built from different page graphs. To fix: rebuild the client bundle, or check that \" +\n \"the page's file still exports a `route` discovery can see.\",\n );\n this.name = \"UnknownHydrationPageNameError\";\n }\n}\n\n/**\n * The server selected an app error page, but this browser graph cannot load it.\n * Substituting the ordinary page would execute the component that already\n * failed and hydrate markup the server did not render, so this path fails\n * closed just like an unknown route name.\n */\nexport class MissingHydrationErrorPageError extends Error {\n public constructor(public readonly pageName: string) {\n super(\n `Warlock hydration aborted: the server selected error.page.tsx for route ` +\n `${JSON.stringify(pageName)}, but that route's client composition has no ErrorPage ` +\n \"module. Rebuild the client page registry so it projects the discovered error page.\",\n );\n this.name = \"MissingHydrationErrorPageError\";\n }\n}\n\nfunction findEntryByName(\n pages: readonly ClientPageEntry[],\n name: string,\n): ClientPageEntry {\n const entry = pages.find((candidate) => candidate.name === name);\n\n if (entry === undefined) {\n throw new UnknownHydrationPageNameError(\n name,\n pages.map((candidate) => candidate.name),\n );\n }\n\n return entry;\n}\n\n/**\n * A level's component, or undefined when the module exports no default.\n *\n * Undefined is NOT an error: `render-page.ts:258` and `:279` treat a missing\n * default as a passthrough server-side, and the client tree has to match the\n * markup React is hydrating against — introducing a level here that the server\n * did not render is a hydration mismatch, not a repair.\n */\nfunction componentOf<Props extends object>(\n module: ClientProjectedModule,\n): ComponentType<Props> | undefined {\n const component = module.default;\n\n return typeof component === \"function\"\n ? (component as ComponentType<Props>)\n : undefined;\n}\n\nfunction wrap(\n module: ClientProjectedModule,\n data: unknown,\n shared: unknown,\n children: ReactNode,\n): ReactNode {\n const Component = componentOf<HydratedLevelProps>(module);\n\n if (Component === undefined) return children;\n\n return createElement(Component, { data, shared, children });\n}\n\n/**\n * Compose the tree the server rendered inside `#root`: ordered layouts wrapping\n * the selected Page or ErrorPage leaf, layouts OUTERMOST FIRST as\n * `ClientRouteComposition` declares them. Ordinary levels receive\n * `{ data, shared }`; the error leaf receives the serialized `{ error, status\n * }` payload shape.\n *\n * ── THE APP LEVEL IS DELIBERATELY ABSENT, AND MUST STAY ABSENT ──────────────\n * `ClientRouteComposition.App` and `payload.appData` still exist and are still\n * carried; they are contracts owned elsewhere. They are simply not part of THIS\n * tree, because App is not part of the markup this tree hydrates against:\n *\n * - Server-side, `render-page.ts`'s `wrapRootward` wraps the page leaf in\n * `[\"layout\", \"app\"]` (`render-page.ts:274`), so the document React renders\n * is `App( Layout( Page ) )`.\n * - The app root is the level that owns `<html>`/`<body>` and renders\n * `<div id=\"root\">{children}</div>` inside the body. So App CONTAINS the\n * mount point — the markup actually inside `#root` is `Layout( Page )`.\n * - `hydrate-page.tsx` mounts at `#root` and nowhere else.\n *\n * Composing App here would therefore hydrate a whole `<html>` document inside a\n * `<div>` the server filled with a layout: a guaranteed hydration mismatch. If\n * you arrived here from the optional `App?` on the composition type and are\n * about to \"complete\" the tree with it — that would be the defect, not the\n * omission.\n *\n * `load()` is awaited exactly ONCE per hydration and its result reused for all\n * levels — the composition arrives whole, so calling it per layout would be\n * one network waterfall per level for no new information.\n */\nexport async function buildHydratedTree(\n pages: readonly ClientPageEntry[],\n payload: HydrationDocumentPayloadSource,\n): Promise<ReactNode> {\n const entry = findEntryByName(pages, payload.name);\n const composition = await loadClientRouteComposition(entry);\n const errorPageProps = payload.errorPage;\n const selectedPageModule =\n errorPageProps === undefined ? composition.Page : composition.ErrorPage;\n\n if (selectedPageModule === undefined) {\n throw new MissingHydrationErrorPageError(payload.name);\n }\n\n // Registration is the first lifecycle action after the real namespaces have\n // loaded. Keep server order: root/App, layouts outermost-to-innermost, page.\n // On the error path the selected error module replaces the ordinary Page in\n // that order; registering Page as well would run code the server did not run.\n // Component extraction and React element creation intentionally happen only\n // after every registration hook has completed synchronously.\n registerModules([\n ...(composition.App === undefined ? [] : [composition.App]),\n ...composition.layouts,\n selectedPageModule,\n ]);\n\n const { shared } = payload;\n let element: ReactNode;\n\n if (errorPageProps === undefined) {\n const Page = componentOf<HydratedPageProps>(selectedPageModule);\n element =\n Page === undefined\n ? null\n : createElement(Page, {\n data: payload.pageData,\n shared,\n params: payload.params ?? {},\n });\n } else {\n const ErrorPage = componentOf<SerializedErrorPageProps>(selectedPageModule);\n element =\n ErrorPage === undefined ? null : createElement(ErrorPage, errorPageProps);\n }\n\n // Innermost layout wraps the page, so walk the outermost-first list backwards.\n for (let index = composition.layouts.length - 1; index >= 0; index -= 1) {\n element = wrap(\n composition.layouts[index]!,\n payload.layoutData,\n shared,\n element,\n );\n }\n\n return element;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAsCA,SAAS,mBAAmB,gBAA2C;CACrE,IAAI,eAAe,WAAW,GAAG,OAAO;CAExC,OAAO,uBAAuB,eAAe,KAAK,SAAS,KAAK,UAAU,IAAI,CAAC,EAAE,KAAK,IAAI,EAAE;AAC9F;;;;;;;;;;;AAYA,IAAa,gCAAb,cAAmD,MAAM;CAErC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,gBAChB;EACA,MACE,qDAAqD,KAAK,UAAU,QAAQ,EAAE,8CACxC,mBAAmB,cAAc,EAAE,8OAI3E;EATgB;EACA;EAShB,KAAK,OAAO;CACd;AACF;;;;;;;AAQA,IAAa,iCAAb,cAAoD,MAAM;CACrB;CAAnC,AAAO,YAAY,AAAgB,UAAkB;EACnD,MACE,2EACK,KAAK,UAAU,QAAQ,EAAE,0IAEhC;EALiC;EAMjC,KAAK,OAAO;CACd;AACF;AAEA,SAAS,gBACP,OACA,MACiB;CACjB,MAAM,QAAQ,MAAM,MAAM,cAAc,UAAU,SAAS,IAAI;CAE/D,IAAI,UAAU,QACZ,MAAM,IAAI,8BACR,MACA,MAAM,KAAK,cAAc,UAAU,IAAI,CACzC;CAGF,OAAO;AACT;;;;;;;;;AAUA,SAAS,YACP,QACkC;CAClC,MAAM,YAAY,OAAO;CAEzB,OAAO,OAAO,cAAc,aACvB,YACD;AACN;AAEA,SAAS,KACP,QACA,MACA,QACA,UACW;CACX,MAAM,YAAY,YAAgC,MAAM;CAExD,IAAI,cAAc,QAAW,OAAO;CAEpC,OAAO,cAAc,WAAW;EAAE;EAAM;EAAQ;CAAS,CAAC;AAC5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,eAAsB,kBACpB,OACA,SACoB;CAEpB,MAAM,cAAc,MAAM,2BADZ,gBAAgB,OAAO,QAAQ,IACY,CAAC;CAC1D,MAAM,iBAAiB,QAAQ;CAC/B,MAAM,qBACJ,mBAAmB,SAAY,YAAY,OAAO,YAAY;CAEhE,IAAI,uBAAuB,QACzB,MAAM,IAAI,+BAA+B,QAAQ,IAAI;CASvD,gBAAgB;EACd,GAAI,YAAY,QAAQ,SAAY,CAAC,IAAI,CAAC,YAAY,GAAG;EACzD,GAAG,YAAY;EACf;CACF,CAAC;CAED,MAAM,EAAE,WAAW;CACnB,IAAI;CAEJ,IAAI,mBAAmB,QAAW;EAChC,MAAM,OAAO,YAA+B,kBAAkB;EAC9D,UACE,SAAS,SACL,OACA,cAAc,MAAM;GAClB,MAAM,QAAQ;GACd;GACA,QAAQ,QAAQ,UAAU,CAAC;EAC7B,CAAC;CACT,OAAO;EACL,MAAM,YAAY,YAAsC,kBAAkB;EAC1E,UACE,cAAc,SAAY,OAAO,cAAc,WAAW,cAAc;CAC5E;CAGA,KAAK,IAAI,QAAQ,YAAY,QAAQ,SAAS,GAAG,SAAS,GAAG,SAAS,GACpE,UAAU,KACR,YAAY,QAAQ,QACpB,QAAQ,YACR,QACA,OACF;CAGF,OAAO;AACT"}
|
|
1
|
+
{"version":3,"file":"build-hydrated-tree.mjs","names":[],"sources":["../../../../../../../web/src/client/build-hydrated-tree.ts"],"sourcesContent":["/**\n * The hydration COMPOSER: payload + page registry -> the ReactNode to mount.\n *\n * It takes the registry as an ARGUMENT and touches no browser global, which is\n * the whole point of it living apart from `index.ts`: every rule below is\n * testable with a hand-built registry, no bundler, no virtual module, no DOM.\n *\n * LOOKUP BY NAME, NEVER BY MATCH. `payload.name` is the identity of the entry\n * the SERVER matched for this exact request (document-context.ts's `name`\n * field). Re-deriving it from `location.pathname` with `matchClientRoute`\n * would be a second implementation of route semantics running against the one\n * request it is hydrating, free to disagree with the server that produced the\n * markup. `matchClientRoute` is for client-side NAVIGATION, where no server\n * answer exists yet.\n */\nimport { createElement, type ComponentType, type ReactNode } from \"react\";\nimport type {\n HydrationDocumentPayloadSource,\n SerializedErrorPageProps,\n} from \"../hydration-payload\";\nimport { registerModules } from \"../runtime/register-modules\";\nimport { loadClientRouteComposition } from \"./runtime\";\nimport type { ClientPageEntry, ClientProjectedModule } from \"./runtime/types\";\n\n/** What every composed level receives — the shape `render-page.ts` uses server-side. */\ntype HydratedLevelProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly children?: ReactNode;\n};\n\n/** The ordinary page leaf alone receives params from the server's match. */\ntype HydratedPageProps = {\n readonly data: unknown;\n readonly shared: unknown;\n readonly params: Readonly<Record<string, string>>;\n};\n\nfunction describeKnownNames(knownPageNames: readonly string[]): string {\n if (knownPageNames.length === 0) return \"The client page registry is empty.\";\n\n return `The registry knows: ${knownPageNames.map((name) => JSON.stringify(name)).join(\", \")}.`;\n}\n\n/**\n * The THIRD hydration failure case, beside an absent and a malformed payload.\n *\n * It fails CLOSED — no default entry, no nearest-path fallback, no silent\n * no-op. A registry that quietly substitutes a page produces a browser showing\n * one page's markup running another page's code, which is precisely the defect\n * this entry point was rewritten to remove; a fallback would reintroduce it\n * wearing a recovery costume. Throwing leaves the server-rendered markup on\n * screen and un-hydrated, which is degraded but honest.\n */\nexport class UnknownHydrationPageNameError extends Error {\n public constructor(\n public readonly pageName: string,\n public readonly knownPageNames: readonly string[],\n ) {\n super(\n `Warlock hydration aborted: the payload names page ${JSON.stringify(pageName)}, which is ` +\n `not in the client page registry. ${describeKnownNames(knownPageNames)} The server ` +\n \"rendered a page this browser bundle does not carry, so the server and client were \" +\n \"built from different page graphs. To fix: rebuild the client bundle, or check that \" +\n \"the page's file still exports a `route` discovery can see.\",\n );\n this.name = \"UnknownHydrationPageNameError\";\n }\n}\n\n/**\n * The server selected an app error page, but this browser graph cannot load it.\n * Substituting the ordinary page would execute the component that already\n * failed and hydrate markup the server did not render, so this path fails\n * closed just like an unknown route name.\n */\nexport class MissingHydrationErrorPageError extends Error {\n public constructor(public readonly pageName: string) {\n super(\n `Warlock hydration aborted: the server selected error.page.tsx for route ` +\n `${JSON.stringify(pageName)}, but that route's client composition has no ErrorPage ` +\n \"module. Rebuild the client page registry so it projects the discovered error page.\",\n );\n this.name = \"MissingHydrationErrorPageError\";\n }\n}\n\nfunction findEntryByName(\n pages: readonly ClientPageEntry[],\n name: string,\n): ClientPageEntry {\n const entry = pages.find((candidate) => candidate.name === name);\n\n if (entry === undefined) {\n throw new UnknownHydrationPageNameError(\n name,\n pages.map((candidate) => candidate.name),\n );\n }\n\n return entry;\n}\n\n/**\n * A level's component, or undefined when the module exports no default.\n *\n * Undefined is NOT an error: `render-page.ts:258` and `:279` treat a missing\n * default as a passthrough server-side, and the client tree has to match the\n * markup React is hydrating against — introducing a level here that the server\n * did not render is a hydration mismatch, not a repair.\n */\nfunction componentOf<Props extends object>(\n module: ClientProjectedModule,\n): ComponentType<Props> | undefined {\n const component = module.default;\n\n return typeof component === \"function\"\n ? (component as ComponentType<Props>)\n : undefined;\n}\n\nfunction wrap(\n module: ClientProjectedModule,\n data: unknown,\n shared: unknown,\n children: ReactNode,\n): ReactNode {\n const Component = componentOf<HydratedLevelProps>(module);\n\n if (Component === undefined) return children;\n\n return createElement(Component, { data, shared, children });\n}\n\n/**\n * Compose the tree the server rendered inside `#root`: ordered layouts wrapping\n * the selected Page or ErrorPage leaf, layouts OUTERMOST FIRST as\n * `ClientRouteComposition` declares them. Ordinary levels receive\n * `{ data, shared }`; the error leaf receives the serialized `{ error, status\n * }` payload shape.\n *\n * ── THE APP LEVEL IS DELIBERATELY ABSENT, AND MUST STAY ABSENT ──────────────\n * `ClientRouteComposition.App` and `payload.appData` still exist and are still\n * carried; they are contracts owned elsewhere. They are simply not part of THIS\n * tree, because App is not part of the markup this tree hydrates against:\n *\n * - Server-side, `render-page.ts`'s `wrapRootward` wraps the page leaf in\n * `[\"layout\", \"app\"]` (`render-page.ts:274`), so the document React renders\n * is `App( Layout( Page ) )`.\n * - The app root is the level that owns `<html>`/`<body>` and renders\n * `<div id=\"root\">{children}</div>` inside the body. So App CONTAINS the\n * mount point — the markup actually inside `#root` is `Layout( Page )`.\n * - `hydrate-page.tsx` mounts at `#root` and nowhere else.\n *\n * Composing App here would therefore hydrate a whole `<html>` document inside a\n * `<div>` the server filled with a layout: a guaranteed hydration mismatch. If\n * you arrived here from the optional `App?` on the composition type and are\n * about to \"complete\" the tree with it — that would be the defect, not the\n * omission.\n *\n * `load()` is awaited exactly ONCE per hydration and its result reused for all\n * levels — the composition arrives whole, so calling it per layout would be\n * one network waterfall per level for no new information.\n */\nexport async function buildHydratedTree(\n pages: readonly ClientPageEntry[],\n payload: HydrationDocumentPayloadSource,\n): Promise<ReactNode> {\n const entry = findEntryByName(pages, payload.name);\n const composition = await loadClientRouteComposition(entry);\n const errorPageProps = payload.errorPage;\n const selectedPageModule =\n errorPageProps === undefined ? composition.Page : composition.ErrorPage;\n\n if (selectedPageModule === undefined) {\n throw new MissingHydrationErrorPageError(payload.name);\n }\n\n // Registration is the first lifecycle action after the real namespaces have\n // loaded. Keep server order: root/App, layouts outermost-to-innermost, page.\n // On the error path the selected error module replaces the ordinary Page in\n // that order; registering Page as well would run code the server did not run.\n // Component extraction and React element creation intentionally happen only\n // after every registration hook has completed synchronously.\n registerModules([\n ...(composition.App === undefined ? [] : [composition.App]),\n ...composition.layouts,\n selectedPageModule,\n ]);\n\n const { shared } = payload;\n let element: ReactNode;\n\n if (errorPageProps === undefined) {\n const Page = componentOf<HydratedPageProps>(selectedPageModule);\n element =\n Page === undefined\n ? null\n : createElement(Page, {\n data: payload.pageData,\n shared,\n params: payload.params ?? {},\n });\n } else {\n const ErrorPage = componentOf<SerializedErrorPageProps>(selectedPageModule);\n element =\n ErrorPage === undefined ? null : createElement(ErrorPage, errorPageProps);\n }\n\n // Innermost layout wraps the page, so walk the outermost-first list backwards.\n for (let index = composition.layouts.length - 1; index >= 0; index -= 1) {\n element = wrap(\n composition.layouts[index]!,\n payload.layoutData,\n shared,\n element,\n );\n }\n\n return element;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AAsCA,SAAS,mBAAmB,gBAA2C;CACrE,IAAI,eAAe,WAAW,GAAG,OAAO;CAExC,OAAO,uBAAuB,eAAe,KAAK,SAAS,KAAK,UAAU,IAAI,CAAC,CAAC,CAAC,KAAK,IAAI,EAAE;AAC9F;;;;;;;;;;;AAYA,IAAa,gCAAb,cAAmD,MAAM;CAErC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,gBAChB;EACA,MACE,qDAAqD,KAAK,UAAU,QAAQ,EAAE,8CACxC,mBAAmB,cAAc,EAAE,8OAI3E;EATgB;EACA;EAShB,KAAK,OAAO;CACd;AACF;;;;;;;AAQA,IAAa,iCAAb,cAAoD,MAAM;CACrB;CAAnC,AAAO,YAAY,AAAgB,UAAkB;EACnD,MACE,2EACK,KAAK,UAAU,QAAQ,EAAE,0IAEhC;EALiC;EAMjC,KAAK,OAAO;CACd;AACF;AAEA,SAAS,gBACP,OACA,MACiB;CACjB,MAAM,QAAQ,MAAM,MAAM,cAAc,UAAU,SAAS,IAAI;CAE/D,IAAI,UAAU,QACZ,MAAM,IAAI,8BACR,MACA,MAAM,KAAK,cAAc,UAAU,IAAI,CACzC;CAGF,OAAO;AACT;;;;;;;;;AAUA,SAAS,YACP,QACkC;CAClC,MAAM,YAAY,OAAO;CAEzB,OAAO,OAAO,cAAc,aACvB,YACD;AACN;AAEA,SAAS,KACP,QACA,MACA,QACA,UACW;CACX,MAAM,YAAY,YAAgC,MAAM;CAExD,IAAI,cAAc,QAAW,OAAO;CAEpC,OAAO,cAAc,WAAW;EAAE;EAAM;EAAQ;CAAS,CAAC;AAC5D;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAgCA,eAAsB,kBACpB,OACA,SACoB;CAEpB,MAAM,cAAc,MAAM,2BADZ,gBAAgB,OAAO,QAAQ,IACY,CAAC;CAC1D,MAAM,iBAAiB,QAAQ;CAC/B,MAAM,qBACJ,mBAAmB,SAAY,YAAY,OAAO,YAAY;CAEhE,IAAI,uBAAuB,QACzB,MAAM,IAAI,+BAA+B,QAAQ,IAAI;CASvD,gBAAgB;EACd,GAAI,YAAY,QAAQ,SAAY,CAAC,IAAI,CAAC,YAAY,GAAG;EACzD,GAAG,YAAY;EACf;CACF,CAAC;CAED,MAAM,EAAE,WAAW;CACnB,IAAI;CAEJ,IAAI,mBAAmB,QAAW;EAChC,MAAM,OAAO,YAA+B,kBAAkB;EAC9D,UACE,SAAS,SACL,OACA,cAAc,MAAM;GAClB,MAAM,QAAQ;GACd;GACA,QAAQ,QAAQ,UAAU,CAAC;EAC7B,CAAC;CACT,OAAO;EACL,MAAM,YAAY,YAAsC,kBAAkB;EAC1E,UACE,cAAc,SAAY,OAAO,cAAc,WAAW,cAAc;CAC5E;CAGA,KAAK,IAAI,QAAQ,YAAY,QAAQ,SAAS,GAAG,SAAS,GAAG,SAAS,GACpE,UAAU,KACR,YAAY,QAAQ,QACpB,QAAQ,YACR,QACA,OACF;CAGF,OAAO;AACT"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"fetch-page-data.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/fetch-page-data.ts"],"sourcesContent":["/**\n * Ask the server for a URL's page data instead of its document.\n *\n * This is the browser half of the representation split: the same route the user\n * would have loaded, asked for as JSON via the `x-warlock-data` marker. What\n * comes back is exactly the payload a full page load embeds, so the caller can\n * rebuild the tree from it with no second code path.\n *\n * ## Every failure degrades to a REAL navigation, never to an error screen\n *\n * A client navigation is an OPTIMISATION over what the browser already does\n * perfectly well. So nothing here reports a failure to the user — it reports\n * `hard-navigate`, and the caller hands the URL back to the browser. The user\n * gets the page; they just get it the slow way.\n *\n * That is what makes the whole feature safe to add: the worst case of a bug in\n * this file is the behaviour we had before the file existed. Rendering our own\n * \"navigation failed\" state would be strictly worse than the fallback we\n * already have, and would turn every unhandled edge — an auth redirect to an\n * external IdP, a maintenance page, a proxy that strips the header, a deploy\n * that changed the payload shape mid-session — into a dead end.\n */\nimport {\n DATA_RESPONSE_CONTENT_TYPE,\n WARLOCK_DATA_REQUEST_HEADER,\n WARLOCK_DATA_REQUEST_VALUE,\n} from \"../../routing/data-request\";\nimport type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\n\nexport type PageDataResult =\n | {\n type: \"payload\";\n /**\n * The payload to rebuild the tree from.\n */\n payload: HydrationDocumentPayloadSource;\n /**\n * The URL the response actually came from — NOT the one requested. A\n * redirect is followed by `fetch` transparently, so a login-required page\n * answers from `/login`, and pushing the requested URL into history would\n * leave the address bar lying about what is on screen.\n */\n url: string;\n }\n | {\n type: \"hard-navigate\";\n url: string;\n /** Why, for a console warning — never shown to the user. */\n reason: string;\n };\n\n/**\n * Whether the body is the payload we asked for.\n *\n * Checked rather than assumed because a 200 does not mean \"this came from the\n * page pipeline\": a captive portal, an SSO interstitial or a proxy error page\n * all answer 200 with HTML. Parsing that as JSON would throw; treating a\n * successful parse of *something else* as a payload would render garbage.\n */\nfunction isPayloadResponse(response: Response): boolean {\n return (response.headers.get(\"content-type\") ?? \"\").includes(DATA_RESPONSE_CONTENT_TYPE);\n}\n\n/**\n * The shape check, kept deliberately narrow: `name` is the only field the tree\n * builder cannot proceed without — it selects the page. The data fields are\n * page-defined and may legitimately be anything, including `null`.\n */\nfunction isPayloadShape(value: unknown): value is HydrationDocumentPayloadSource {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as { name?: unknown }).name === \"string\"\n );\n}\n\nexport async function fetchPageData(url: string): Promise<PageDataResult> {\n let response: Response;\n\n try {\n response = await fetch(url, {\n headers: {\n [WARLOCK_DATA_REQUEST_HEADER]: WARLOCK_DATA_REQUEST_VALUE,\n accept: DATA_RESPONSE_CONTENT_TYPE,\n },\n // Same-origin credentials so a navigation carries the session exactly as\n // a document request would. Without this a client navigation could be\n // logged out while a full load of the same URL is not.\n credentials: \"same-origin\",\n // Redirects are FOLLOWED, not intercepted: the marker header is re-sent,\n // so the destination answers with a payload too, and `response.url` tells\n // us where we ended up. Handling redirects ourselves would mean\n // re-implementing the rules the browser already has.\n redirect: \"follow\",\n });\n } catch (error) {\n // Offline, DNS, CORS, an aborted connection. The browser can render its own\n // network error far better than we can fake one.\n return { type: \"hard-navigate\", url, reason: `request failed: ${String(error)}` };\n }\n\n if (!response.ok) {\n // 404, 500, 403 — all of these have a real page the server renders. Letting\n // the browser load it gets the correct status AND the correct document,\n // rather than us inventing a client-side error state that the server's own\n // error page already covers.\n return { type: \"hard-navigate\", url, reason: `status ${response.status}` };\n }\n\n if (!isPayloadResponse(response)) {\n return {\n type: \"hard-navigate\",\n url,\n reason: `unexpected content-type \"${response.headers.get(\"content-type\") ?? \"none\"}\"`,\n };\n }\n\n let parsed: unknown;\n\n try {\n parsed = await response.json();\n } catch (error) {\n return { type: \"hard-navigate\", url, reason: `malformed JSON: ${String(error)}` };\n }\n\n if (!isPayloadShape(parsed)) {\n return { type: \"hard-navigate\", url, reason: \"payload has no route name\" };\n }\n\n // `response.url` is absolute and reflects any redirect that was followed.\n // Falling back to the requested URL keeps this working under test doubles\n // that do not set it.\n return { type: \"payload\", payload: parsed, url: response.url || url };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2DA,SAAS,kBAAkB,UAA6B;CACtD,QAAQ,SAAS,QAAQ,IAAI,cAAc,KAAK,
|
|
1
|
+
{"version":3,"file":"fetch-page-data.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/fetch-page-data.ts"],"sourcesContent":["/**\n * Ask the server for a URL's page data instead of its document.\n *\n * This is the browser half of the representation split: the same route the user\n * would have loaded, asked for as JSON via the `x-warlock-data` marker. What\n * comes back is exactly the payload a full page load embeds, so the caller can\n * rebuild the tree from it with no second code path.\n *\n * ## Every failure degrades to a REAL navigation, never to an error screen\n *\n * A client navigation is an OPTIMISATION over what the browser already does\n * perfectly well. So nothing here reports a failure to the user — it reports\n * `hard-navigate`, and the caller hands the URL back to the browser. The user\n * gets the page; they just get it the slow way.\n *\n * That is what makes the whole feature safe to add: the worst case of a bug in\n * this file is the behaviour we had before the file existed. Rendering our own\n * \"navigation failed\" state would be strictly worse than the fallback we\n * already have, and would turn every unhandled edge — an auth redirect to an\n * external IdP, a maintenance page, a proxy that strips the header, a deploy\n * that changed the payload shape mid-session — into a dead end.\n */\nimport {\n DATA_RESPONSE_CONTENT_TYPE,\n WARLOCK_DATA_REQUEST_HEADER,\n WARLOCK_DATA_REQUEST_VALUE,\n} from \"../../routing/data-request\";\nimport type { HydrationDocumentPayloadSource } from \"../../hydration-payload\";\n\nexport type PageDataResult =\n | {\n type: \"payload\";\n /**\n * The payload to rebuild the tree from.\n */\n payload: HydrationDocumentPayloadSource;\n /**\n * The URL the response actually came from — NOT the one requested. A\n * redirect is followed by `fetch` transparently, so a login-required page\n * answers from `/login`, and pushing the requested URL into history would\n * leave the address bar lying about what is on screen.\n */\n url: string;\n }\n | {\n type: \"hard-navigate\";\n url: string;\n /** Why, for a console warning — never shown to the user. */\n reason: string;\n };\n\n/**\n * Whether the body is the payload we asked for.\n *\n * Checked rather than assumed because a 200 does not mean \"this came from the\n * page pipeline\": a captive portal, an SSO interstitial or a proxy error page\n * all answer 200 with HTML. Parsing that as JSON would throw; treating a\n * successful parse of *something else* as a payload would render garbage.\n */\nfunction isPayloadResponse(response: Response): boolean {\n return (response.headers.get(\"content-type\") ?? \"\").includes(DATA_RESPONSE_CONTENT_TYPE);\n}\n\n/**\n * The shape check, kept deliberately narrow: `name` is the only field the tree\n * builder cannot proceed without — it selects the page. The data fields are\n * page-defined and may legitimately be anything, including `null`.\n */\nfunction isPayloadShape(value: unknown): value is HydrationDocumentPayloadSource {\n return (\n typeof value === \"object\" &&\n value !== null &&\n typeof (value as { name?: unknown }).name === \"string\"\n );\n}\n\nexport async function fetchPageData(url: string): Promise<PageDataResult> {\n let response: Response;\n\n try {\n response = await fetch(url, {\n headers: {\n [WARLOCK_DATA_REQUEST_HEADER]: WARLOCK_DATA_REQUEST_VALUE,\n accept: DATA_RESPONSE_CONTENT_TYPE,\n },\n // Same-origin credentials so a navigation carries the session exactly as\n // a document request would. Without this a client navigation could be\n // logged out while a full load of the same URL is not.\n credentials: \"same-origin\",\n // Redirects are FOLLOWED, not intercepted: the marker header is re-sent,\n // so the destination answers with a payload too, and `response.url` tells\n // us where we ended up. Handling redirects ourselves would mean\n // re-implementing the rules the browser already has.\n redirect: \"follow\",\n });\n } catch (error) {\n // Offline, DNS, CORS, an aborted connection. The browser can render its own\n // network error far better than we can fake one.\n return { type: \"hard-navigate\", url, reason: `request failed: ${String(error)}` };\n }\n\n if (!response.ok) {\n // 404, 500, 403 — all of these have a real page the server renders. Letting\n // the browser load it gets the correct status AND the correct document,\n // rather than us inventing a client-side error state that the server's own\n // error page already covers.\n return { type: \"hard-navigate\", url, reason: `status ${response.status}` };\n }\n\n if (!isPayloadResponse(response)) {\n return {\n type: \"hard-navigate\",\n url,\n reason: `unexpected content-type \"${response.headers.get(\"content-type\") ?? \"none\"}\"`,\n };\n }\n\n let parsed: unknown;\n\n try {\n parsed = await response.json();\n } catch (error) {\n return { type: \"hard-navigate\", url, reason: `malformed JSON: ${String(error)}` };\n }\n\n if (!isPayloadShape(parsed)) {\n return { type: \"hard-navigate\", url, reason: \"payload has no route name\" };\n }\n\n // `response.url` is absolute and reflects any redirect that was followed.\n // Falling back to the requested URL keeps this working under test doubles\n // that do not set it.\n return { type: \"payload\", payload: parsed, url: response.url || url };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA2DA,SAAS,kBAAkB,UAA6B;CACtD,QAAQ,SAAS,QAAQ,IAAI,cAAc,KAAK,GAAE,CAAE,SAAS,0BAA0B;AACzF;;;;;;AAOA,SAAS,eAAe,OAAyD;CAC/E,OACE,OAAO,UAAU,YACjB,UAAU,QACV,OAAQ,MAA6B,SAAS;AAElD;AAEA,eAAsB,cAAc,KAAsC;CACxE,IAAI;CAEJ,IAAI;EACF,WAAW,MAAM,MAAM,KAAK;GAC1B,SAAS;KACN;IACD,QAAQ;GACV;GAIA,aAAa;GAKb,UAAU;EACZ,CAAC;CACH,SAAS,OAAO;EAGd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,SAAS,IAKZ,OAAO;EAAE,MAAM;EAAiB;EAAK,QAAQ,UAAU,SAAS;CAAS;CAG3E,IAAI,CAAC,kBAAkB,QAAQ,GAC7B,OAAO;EACL,MAAM;EACN;EACA,QAAQ,4BAA4B,SAAS,QAAQ,IAAI,cAAc,KAAK,OAAO;CACrF;CAGF,IAAI;CAEJ,IAAI;EACF,SAAS,MAAM,SAAS,KAAK;CAC/B,SAAS,OAAO;EACd,OAAO;GAAE,MAAM;GAAiB;GAAK,QAAQ,mBAAmB,OAAO,KAAK;EAAI;CAClF;CAEA,IAAI,CAAC,eAAe,MAAM,GACxB,OAAO;EAAE,MAAM;EAAiB;EAAK,QAAQ;CAA4B;CAM3E,OAAO;EAAE,MAAM;EAAW,SAAS;EAAQ,KAAK,SAAS,OAAO;CAAI;AACtE"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"prefetch.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/prefetch.ts"],"sourcesContent":["/**\n * Speculative page data, fetched on hover and spent on the click that follows.\n *\n * A prefetch is a GUESS. The user pointed at a link; they may never click it.\n * Everything about this module follows from that one fact:\n *\n * - it never reports a failure — nobody asked for this request, so nobody may\n * be told it failed. A failed prefetch leaves the cache empty and the click\n * fetches for real, which is the behaviour we had before this file existed.\n * - it never delays a click. `prefetchPageData` is fire-and-forget; the\n * navigation path reads the cache synchronously and does not wait on it.\n * - it is BOUNDED and it EXPIRES. A cached payload is a copy of a page that\n * may already have changed, so it may only be served while it is very\n * probably still true.\n *\n * ## The read side is deliberately separate from the write side\n *\n * `<Link>` writes (on hover); the navigation runtime reads (on click). Neither\n * knows about the other — they share a URL, and this module is the only thing\n * between them. That is what lets prefetch be added without touching the\n * navigation state machine.\n */\nimport type { PageDataResult } from \"./fetch-page-data\";\nimport { fetchPageData } from \"./fetch-page-data\";\n\n/** A prefetch result worth keeping — only ever a successful payload. */\nexport type PrefetchedPageData = Extract<PageDataResult, { type: \"payload\" }>;\n\n/**\n * How many pages may be held at once.\n *\n * Ten, because the working set this serves is \"links the pointer has crossed in\n * the last few seconds\", which is small by construction — a nav bar, a card\n * grid the user is scanning. A page payload is loader data, not markup, but it\n * is still measured in tens of kilobytes, so an unbounded map on a long-lived\n * SPA session is a slow leak with no upper edge. Ten covers scanning a menu and\n * costs at most a few hundred kilobytes in the worst case.\n *\n * Eviction is by INSERTION ORDER, not by recency: an entry here is written once\n * and read at most once, so there is no recency to track — the oldest guess is\n * always the one least likely to be spent.\n */\nexport const PREFETCH_CACHE_LIMIT = 10;\n\n/**\n * How long a cached payload may be served, in milliseconds.\n *\n * Thirty seconds. This is a SAFETY bound and not a performance knob: the\n * interval this feature exists to cover is hover-to-click, which is well under\n * a second, and every millisecond beyond that is pure staleness risk. A payload\n * served after the user has changed the data behind it renders a wrong page —\n * silently, and with no way for them to tell. Thirty seconds is long enough\n * that a hesitant click still hits, and short enough that no realistic\n * \"navigate away, mutate something, come back\" flow can complete inside it.\n */\nexport const PREFETCH_TTL_MS = 30_000;\n\ntype CacheEntry = {\n result: PrefetchedPageData;\n /** `Date.now()` at which this entry stops being servable. */\n expiresAt: number;\n};\n\n/**\n * Insertion-ordered by `Map` contract, which is what makes eviction a `keys()\n * .next()` and not a bookkeeping structure.\n */\nconst cache = new Map<string, CacheEntry>();\n\n/**\n * URLs with a request already in the air. Repeated `mouseenter` events on the\n * same anchor are the norm, not the exception — a pointer crossing a link fires\n * as the user's hand settles — and without this each one would be its own\n * request for the same page.\n */\nconst inFlight = new Set<string>();\n\n/**\n * Whether there is a browser to prefetch from.\n *\n * Called rather than assumed because `<Link>` is universal: the same module\n * graph is evaluated during a server render, where a speculative request would\n * be a request the server makes to itself for a page nobody is looking at.\n */\nfunction isBrowser(): boolean {\n return typeof window !== \"undefined\";\n}\n\nfunction evictOldest(): void {\n const oldest = cache.keys().next();\n\n if (oldest.done !== true) cache.delete(oldest.value);\n}\n\n/**\n * Fetch a URL's page data ahead of the click, and cache it.\n *\n * NEVER REJECTS and never reports. The returned promise exists so a test can\n * await the speculative work; callers in the component tree discard it\n * (`void prefetchPageData(url)`) and must not await it — a click that waited on\n * a guess would be slower than one that never made it.\n *\n * The caller decides WHETHER a URL may be prefetched. This function does not\n * re-derive that: it has a URL and no way to tell an in-app path from a\n * cross-origin one it must never touch. `<Link>` gates on `isInApp`.\n */\nexport async function prefetchPageData(url: string): Promise<void> {\n if (!isBrowser()) return;\n\n if (inFlight.has(url)) return;\n\n // A fresh entry means the answer is already here; an expired one is dropped\n // now so the fetch below can replace it.\n const cached = cache.get(url);\n\n if (cached !== undefined) {\n if (cached.expiresAt > Date.now()) return;\n\n cache.delete(url);\n }\n\n inFlight.add(url);\n\n try {\n const result = await fetchPageData(url);\n\n /*\n `hard-navigate` is NOT cached. It is the signal that this URL needs a full\n page load — a 404, a redirect to an interstitial, a proxy that stripped\n the marker header. Caching it would mean the click either replays a\n failure or, worse, consults an entry that cannot be rendered. The click\n re-asks and gets the same answer, correctly, through the navigation path\n that already knows how to degrade.\n */\n if (result.type !== \"payload\") return;\n\n if (cache.size >= PREFETCH_CACHE_LIMIT) evictOldest();\n\n cache.set(url, { result, expiresAt: Date.now() + PREFETCH_TTL_MS });\n } catch {\n /*\n `fetchPageData` already converts every network failure into a\n `hard-navigate`, so reaching here means something unforeseen. It is\n swallowed anyway, deliberately and without a log: this request was\n speculative, the user never asked for it, and a console full of warnings\n about requests nobody made is how a helpful optimisation becomes noise\n that hides real errors.\n */\n } finally {\n // In `finally` so a failed attempt does not poison the URL for the rest of\n // the session — the next hover is allowed to try again.\n inFlight.delete(url);\n }\n}\n\n/**\n * Take the prefetched payload for a URL, if there is a live one.\n *\n * CONSUMING: the entry is removed whether or not the caller ends up using it.\n * One hover buys one saved round trip. Serving the same payload to a second\n * navigation would double the window in which it can be wrong, in exchange for\n * a saving the user did not notice the first time.\n *\n * @returns the cached result, or `undefined` — in which case the caller fetches\n * exactly as it did before this module existed.\n */\nexport function takePrefetchedPageData(url: string): PrefetchedPageData | undefined {\n const entry = cache.get(url);\n\n if (entry === undefined) return undefined;\n\n cache.delete(url);\n\n // Expiry is checked on READ, not on a timer: a timer would keep a\n // long-lived page waking up to clean a cache that is already bounded, and an\n // entry nobody reads costs nothing but the slot it is evicted from anyway.\n return entry.expiresAt > Date.now() ? entry.result : undefined;\n}\n\n/**\n * Drop everything. For tests, and for any caller that knows the cached pages\n * are now wrong — the module-level cache would otherwise outlive a suite and\n * leak into the next one.\n */\nexport function resetPrefetchCache(): void {\n cache.clear();\n inFlight.clear();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AA0CA,MAAa,uBAAuB;;;;;;;;;;;;AAapC,MAAa,kBAAkB;;;;;AAY/B,MAAM,wBAAQ,IAAI,IAAwB;;;;;;;AAQ1C,MAAM,2BAAW,IAAI,IAAY;;;;;;;;AASjC,SAAS,YAAqB;CAC5B,OAAO,OAAO,WAAW;AAC3B;AAEA,SAAS,cAAoB;CAC3B,MAAM,SAAS,MAAM,KAAK,
|
|
1
|
+
{"version":3,"file":"prefetch.mjs","names":[],"sources":["../../../../../../../../web/src/client/navigation/prefetch.ts"],"sourcesContent":["/**\n * Speculative page data, fetched on hover and spent on the click that follows.\n *\n * A prefetch is a GUESS. The user pointed at a link; they may never click it.\n * Everything about this module follows from that one fact:\n *\n * - it never reports a failure — nobody asked for this request, so nobody may\n * be told it failed. A failed prefetch leaves the cache empty and the click\n * fetches for real, which is the behaviour we had before this file existed.\n * - it never delays a click. `prefetchPageData` is fire-and-forget; the\n * navigation path reads the cache synchronously and does not wait on it.\n * - it is BOUNDED and it EXPIRES. A cached payload is a copy of a page that\n * may already have changed, so it may only be served while it is very\n * probably still true.\n *\n * ## The read side is deliberately separate from the write side\n *\n * `<Link>` writes (on hover); the navigation runtime reads (on click). Neither\n * knows about the other — they share a URL, and this module is the only thing\n * between them. That is what lets prefetch be added without touching the\n * navigation state machine.\n */\nimport type { PageDataResult } from \"./fetch-page-data\";\nimport { fetchPageData } from \"./fetch-page-data\";\n\n/** A prefetch result worth keeping — only ever a successful payload. */\nexport type PrefetchedPageData = Extract<PageDataResult, { type: \"payload\" }>;\n\n/**\n * How many pages may be held at once.\n *\n * Ten, because the working set this serves is \"links the pointer has crossed in\n * the last few seconds\", which is small by construction — a nav bar, a card\n * grid the user is scanning. A page payload is loader data, not markup, but it\n * is still measured in tens of kilobytes, so an unbounded map on a long-lived\n * SPA session is a slow leak with no upper edge. Ten covers scanning a menu and\n * costs at most a few hundred kilobytes in the worst case.\n *\n * Eviction is by INSERTION ORDER, not by recency: an entry here is written once\n * and read at most once, so there is no recency to track — the oldest guess is\n * always the one least likely to be spent.\n */\nexport const PREFETCH_CACHE_LIMIT = 10;\n\n/**\n * How long a cached payload may be served, in milliseconds.\n *\n * Thirty seconds. This is a SAFETY bound and not a performance knob: the\n * interval this feature exists to cover is hover-to-click, which is well under\n * a second, and every millisecond beyond that is pure staleness risk. A payload\n * served after the user has changed the data behind it renders a wrong page —\n * silently, and with no way for them to tell. Thirty seconds is long enough\n * that a hesitant click still hits, and short enough that no realistic\n * \"navigate away, mutate something, come back\" flow can complete inside it.\n */\nexport const PREFETCH_TTL_MS = 30_000;\n\ntype CacheEntry = {\n result: PrefetchedPageData;\n /** `Date.now()` at which this entry stops being servable. */\n expiresAt: number;\n};\n\n/**\n * Insertion-ordered by `Map` contract, which is what makes eviction a `keys()\n * .next()` and not a bookkeeping structure.\n */\nconst cache = new Map<string, CacheEntry>();\n\n/**\n * URLs with a request already in the air. Repeated `mouseenter` events on the\n * same anchor are the norm, not the exception — a pointer crossing a link fires\n * as the user's hand settles — and without this each one would be its own\n * request for the same page.\n */\nconst inFlight = new Set<string>();\n\n/**\n * Whether there is a browser to prefetch from.\n *\n * Called rather than assumed because `<Link>` is universal: the same module\n * graph is evaluated during a server render, where a speculative request would\n * be a request the server makes to itself for a page nobody is looking at.\n */\nfunction isBrowser(): boolean {\n return typeof window !== \"undefined\";\n}\n\nfunction evictOldest(): void {\n const oldest = cache.keys().next();\n\n if (oldest.done !== true) cache.delete(oldest.value);\n}\n\n/**\n * Fetch a URL's page data ahead of the click, and cache it.\n *\n * NEVER REJECTS and never reports. The returned promise exists so a test can\n * await the speculative work; callers in the component tree discard it\n * (`void prefetchPageData(url)`) and must not await it — a click that waited on\n * a guess would be slower than one that never made it.\n *\n * The caller decides WHETHER a URL may be prefetched. This function does not\n * re-derive that: it has a URL and no way to tell an in-app path from a\n * cross-origin one it must never touch. `<Link>` gates on `isInApp`.\n */\nexport async function prefetchPageData(url: string): Promise<void> {\n if (!isBrowser()) return;\n\n if (inFlight.has(url)) return;\n\n // A fresh entry means the answer is already here; an expired one is dropped\n // now so the fetch below can replace it.\n const cached = cache.get(url);\n\n if (cached !== undefined) {\n if (cached.expiresAt > Date.now()) return;\n\n cache.delete(url);\n }\n\n inFlight.add(url);\n\n try {\n const result = await fetchPageData(url);\n\n /*\n `hard-navigate` is NOT cached. It is the signal that this URL needs a full\n page load — a 404, a redirect to an interstitial, a proxy that stripped\n the marker header. Caching it would mean the click either replays a\n failure or, worse, consults an entry that cannot be rendered. The click\n re-asks and gets the same answer, correctly, through the navigation path\n that already knows how to degrade.\n */\n if (result.type !== \"payload\") return;\n\n if (cache.size >= PREFETCH_CACHE_LIMIT) evictOldest();\n\n cache.set(url, { result, expiresAt: Date.now() + PREFETCH_TTL_MS });\n } catch {\n /*\n `fetchPageData` already converts every network failure into a\n `hard-navigate`, so reaching here means something unforeseen. It is\n swallowed anyway, deliberately and without a log: this request was\n speculative, the user never asked for it, and a console full of warnings\n about requests nobody made is how a helpful optimisation becomes noise\n that hides real errors.\n */\n } finally {\n // In `finally` so a failed attempt does not poison the URL for the rest of\n // the session — the next hover is allowed to try again.\n inFlight.delete(url);\n }\n}\n\n/**\n * Take the prefetched payload for a URL, if there is a live one.\n *\n * CONSUMING: the entry is removed whether or not the caller ends up using it.\n * One hover buys one saved round trip. Serving the same payload to a second\n * navigation would double the window in which it can be wrong, in exchange for\n * a saving the user did not notice the first time.\n *\n * @returns the cached result, or `undefined` — in which case the caller fetches\n * exactly as it did before this module existed.\n */\nexport function takePrefetchedPageData(url: string): PrefetchedPageData | undefined {\n const entry = cache.get(url);\n\n if (entry === undefined) return undefined;\n\n cache.delete(url);\n\n // Expiry is checked on READ, not on a timer: a timer would keep a\n // long-lived page waking up to clean a cache that is already bounded, and an\n // entry nobody reads costs nothing but the slot it is evicted from anyway.\n return entry.expiresAt > Date.now() ? entry.result : undefined;\n}\n\n/**\n * Drop everything. For tests, and for any caller that knows the cached pages\n * are now wrong — the module-level cache would otherwise outlive a suite and\n * leak into the next one.\n */\nexport function resetPrefetchCache(): void {\n cache.clear();\n inFlight.clear();\n}\n"],"mappings":";;;;;;;;;;;;;;;;;AA0CA,MAAa,uBAAuB;;;;;;;;;;;;AAapC,MAAa,kBAAkB;;;;;AAY/B,MAAM,wBAAQ,IAAI,IAAwB;;;;;;;AAQ1C,MAAM,2BAAW,IAAI,IAAY;;;;;;;;AASjC,SAAS,YAAqB;CAC5B,OAAO,OAAO,WAAW;AAC3B;AAEA,SAAS,cAAoB;CAC3B,MAAM,SAAS,MAAM,KAAK,CAAC,CAAC,KAAK;CAEjC,IAAI,OAAO,SAAS,MAAM,MAAM,OAAO,OAAO,KAAK;AACrD;;;;;;;;;;;;;AAcA,eAAsB,iBAAiB,KAA4B;CACjE,IAAI,CAAC,UAAU,GAAG;CAElB,IAAI,SAAS,IAAI,GAAG,GAAG;CAIvB,MAAM,SAAS,MAAM,IAAI,GAAG;CAE5B,IAAI,WAAW,QAAW;EACxB,IAAI,OAAO,YAAY,KAAK,IAAI,GAAG;EAEnC,MAAM,OAAO,GAAG;CAClB;CAEA,SAAS,IAAI,GAAG;CAEhB,IAAI;EACF,MAAM,SAAS,MAAM,cAAc,GAAG;EAUtC,IAAI,OAAO,SAAS,WAAW;EAE/B,IAAI,MAAM,YAA8B,YAAY;EAEpD,MAAM,IAAI,KAAK;GAAE;GAAQ,WAAW,KAAK,IAAI,IAAI;EAAgB,CAAC;CACpE,QAAQ,CASR,UAAU;EAGR,SAAS,OAAO,GAAG;CACrB;AACF;;;;;;;;;;;;AAaA,SAAgB,uBAAuB,KAA6C;CAClF,MAAM,QAAQ,MAAM,IAAI,GAAG;CAE3B,IAAI,UAAU,QAAW,OAAO;CAEhC,MAAM,OAAO,GAAG;CAKhB,OAAO,MAAM,YAAY,KAAK,IAAI,IAAI,MAAM,SAAS;AACvD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"manifest.mjs","names":[],"sources":["../../../../../../../../web/src/client/runtime/manifest.ts"],"sourcesContent":["import type {\n ClientPageEntry,\n ClientProjectedModule,\n ClientRouteComposition,\n} from \"./types\";\n\nconst ENTRY_KEYS = [\"type\", \"name\", \"path\", \"load\"] as const;\nconst COMPOSITION_REQUIRED_KEYS = [\"Page\", \"layouts\"] as const;\nconst COMPOSITION_OPTIONAL_KEYS = [\"App\", \"ErrorPage\"] as const;\n\ntype DataRecord = Record<PropertyKey, unknown>;\n\nfunction isNonArrayObject(value: unknown): value is DataRecord {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction printableValue(value: unknown): string {\n if (typeof value === \"string\") return JSON.stringify(value);\n\n try {\n return String(value);\n } catch {\n return \"<unprintable>\";\n }\n}\n\nfunction assertExactDataKeys(\n value: DataRecord,\n requiredKeys: readonly string[],\n optionalKeys: readonly string[],\n label: string,\n): void {\n const allowedKeys = new Set([...requiredKeys, ...optionalKeys]);\n const descriptors = Object.getOwnPropertyDescriptors(value);\n const ownKeys = Reflect.ownKeys(value);\n\n for (const key of ownKeys) {\n if (typeof key !== \"string\" || !allowedKeys.has(key)) {\n throw new TypeError(`${label} has unexpected own key ${JSON.stringify(String(key))}.`);\n }\n }\n\n for (const key of requiredKeys) {\n if (!Object.prototype.hasOwnProperty.call(descriptors, key)) {\n throw new TypeError(`${label} is missing own key ${JSON.stringify(key)}.`);\n }\n }\n\n for (const key of ownKeys) {\n const descriptor = Object.getOwnPropertyDescriptor(value, key);\n if (descriptor && !(\"value\" in descriptor)) {\n throw new TypeError(\n `${label} key ${JSON.stringify(String(key))} must be an own data property.`,\n );\n }\n }\n}\n\nfunction validateEntry(input: unknown, index: number): ClientPageEntry {\n const label = `Client route manifest entry at index ${index}`;\n\n if (!isNonArrayObject(input)) {\n throw new TypeError(`${label} must be a non-array object.`);\n }\n\n assertExactDataKeys(input, ENTRY_KEYS, [], label);\n\n if (input.type !== \"page\") {\n throw new TypeError(\n `${label} has unknown type ${printableValue(input.type)}; expected \"page\".`,\n );\n }\n\n if (typeof input.name !== \"string\" || input.name.trim().length === 0) {\n throw new TypeError(`${label} name must be a non-empty string.`);\n }\n\n if (typeof input.path !== \"string\" || input.path.trim().length === 0) {\n throw new TypeError(`${label} path must be a non-empty string.`);\n }\n\n if (typeof input.load !== \"function\") {\n throw new TypeError(`${label} load must be callable.`);\n }\n\n return input as ClientPageEntry;\n}\n\nfunction validateProjectedModule(input: unknown, label: string): ClientProjectedModule {\n if (!isNonArrayObject(input)) {\n throw new TypeError(`${label} must be a non-array module object.`);\n }\n\n return input;\n}\n\nfunction validateComposition(input: unknown): ClientRouteComposition {\n const label = \"Loaded client route composition\";\n\n if (!isNonArrayObject(input)) {\n throw new TypeError(`${label} must be a non-array object.`);\n }\n\n assertExactDataKeys(\n input,\n COMPOSITION_REQUIRED_KEYS,\n COMPOSITION_OPTIONAL_KEYS,\n label,\n );\n validateProjectedModule(input.Page, `${label} Page`);\n\n if (!Array.isArray(input.layouts)) {\n throw new TypeError(`${label} layouts must be an array.`);\n }\n\n input.layouts.forEach((layout, index) => {\n validateProjectedModule(layout, `${label} layout at index ${index}`);\n });\n\n if (Object.prototype.hasOwnProperty.call(input, \"App\")) {\n validateProjectedModule(input.App, `${label} App`);\n }\n\n if (Object.prototype.hasOwnProperty.call(input, \"ErrorPage\")) {\n validateProjectedModule(input.ErrorPage, `${label} ErrorPage`);\n }\n\n return input as ClientRouteComposition;\n}\n\nexport function validateClientRouteManifest(input: unknown): readonly ClientPageEntry[] {\n if (!Array.isArray(input)) {\n throw new TypeError(\"Client route manifest must be an array.\");\n }\n\n const names = new Set<string>();\n const paths = new Set<string>();\n\n return input.map((candidate, index) => {\n const entry = validateEntry(candidate, index);\n\n if (names.has(entry.name)) {\n throw new TypeError(\n `Client route manifest has duplicate name ${JSON.stringify(entry.name)}.`,\n );\n }\n\n if (paths.has(entry.path)) {\n throw new TypeError(\n `Client route manifest has duplicate path ${JSON.stringify(entry.path)}.`,\n );\n }\n\n names.add(entry.name);\n paths.add(entry.path);\n return entry;\n });\n}\n\nexport async function loadClientRouteComposition(\n entry: ClientPageEntry,\n): Promise<ClientRouteComposition> {\n const loaded = await entry.load();\n return validateComposition(loaded);\n}\n"],"mappings":";AAMA,MAAM,aAAa;CAAC;CAAQ;CAAQ;CAAQ;AAAM;AAClD,MAAM,4BAA4B,CAAC,QAAQ,SAAS;AACpD,MAAM,4BAA4B,CAAC,OAAO,WAAW;AAIrD,SAAS,iBAAiB,OAAqC;CAC7D,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAEA,SAAS,eAAe,OAAwB;CAC9C,IAAI,OAAO,UAAU,UAAU,OAAO,KAAK,UAAU,KAAK;CAE1D,IAAI;EACF,OAAO,OAAO,KAAK;CACrB,QAAQ;EACN,OAAO;CACT;AACF;AAEA,SAAS,oBACP,OACA,cACA,cACA,OACM;CACN,MAAM,cAAc,IAAI,IAAI,CAAC,GAAG,cAAc,GAAG,YAAY,CAAC;CAC9D,MAAM,cAAc,OAAO,0BAA0B,KAAK;CAC1D,MAAM,UAAU,QAAQ,QAAQ,KAAK;CAErC,KAAK,MAAM,OAAO,SAChB,IAAI,OAAO,QAAQ,YAAY,CAAC,YAAY,IAAI,GAAG,GACjD,MAAM,IAAI,UAAU,GAAG,MAAM,0BAA0B,KAAK,UAAU,OAAO,GAAG,CAAC,EAAE,EAAE;CAIzF,KAAK,MAAM,OAAO,cAChB,IAAI,CAAC,OAAO,UAAU,eAAe,KAAK,aAAa,GAAG,GACxD,MAAM,IAAI,UAAU,GAAG,MAAM,sBAAsB,KAAK,UAAU,GAAG,EAAE,EAAE;CAI7E,KAAK,MAAM,OAAO,SAAS;EACzB,MAAM,aAAa,OAAO,yBAAyB,OAAO,GAAG;EAC7D,IAAI,cAAc,EAAE,WAAW,aAC7B,MAAM,IAAI,UACR,GAAG,MAAM,OAAO,KAAK,UAAU,OAAO,GAAG,CAAC,EAAE,+BAC9C;CAEJ;AACF;AAEA,SAAS,cAAc,OAAgB,OAAgC;CACrE,MAAM,QAAQ,wCAAwC;CAEtD,IAAI,CAAC,iBAAiB,KAAK,GACzB,MAAM,IAAI,UAAU,GAAG,MAAM,6BAA6B;CAG5D,oBAAoB,OAAO,YAAY,CAAC,GAAG,KAAK;CAEhD,IAAI,MAAM,SAAS,QACjB,MAAM,IAAI,UACR,GAAG,MAAM,oBAAoB,eAAe,MAAM,IAAI,EAAE,mBAC1D;CAGF,IAAI,OAAO,MAAM,SAAS,YAAY,MAAM,KAAK,KAAK,
|
|
1
|
+
{"version":3,"file":"manifest.mjs","names":[],"sources":["../../../../../../../../web/src/client/runtime/manifest.ts"],"sourcesContent":["import type {\n ClientPageEntry,\n ClientProjectedModule,\n ClientRouteComposition,\n} from \"./types\";\n\nconst ENTRY_KEYS = [\"type\", \"name\", \"path\", \"load\"] as const;\nconst COMPOSITION_REQUIRED_KEYS = [\"Page\", \"layouts\"] as const;\nconst COMPOSITION_OPTIONAL_KEYS = [\"App\", \"ErrorPage\"] as const;\n\ntype DataRecord = Record<PropertyKey, unknown>;\n\nfunction isNonArrayObject(value: unknown): value is DataRecord {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction printableValue(value: unknown): string {\n if (typeof value === \"string\") return JSON.stringify(value);\n\n try {\n return String(value);\n } catch {\n return \"<unprintable>\";\n }\n}\n\nfunction assertExactDataKeys(\n value: DataRecord,\n requiredKeys: readonly string[],\n optionalKeys: readonly string[],\n label: string,\n): void {\n const allowedKeys = new Set([...requiredKeys, ...optionalKeys]);\n const descriptors = Object.getOwnPropertyDescriptors(value);\n const ownKeys = Reflect.ownKeys(value);\n\n for (const key of ownKeys) {\n if (typeof key !== \"string\" || !allowedKeys.has(key)) {\n throw new TypeError(`${label} has unexpected own key ${JSON.stringify(String(key))}.`);\n }\n }\n\n for (const key of requiredKeys) {\n if (!Object.prototype.hasOwnProperty.call(descriptors, key)) {\n throw new TypeError(`${label} is missing own key ${JSON.stringify(key)}.`);\n }\n }\n\n for (const key of ownKeys) {\n const descriptor = Object.getOwnPropertyDescriptor(value, key);\n if (descriptor && !(\"value\" in descriptor)) {\n throw new TypeError(\n `${label} key ${JSON.stringify(String(key))} must be an own data property.`,\n );\n }\n }\n}\n\nfunction validateEntry(input: unknown, index: number): ClientPageEntry {\n const label = `Client route manifest entry at index ${index}`;\n\n if (!isNonArrayObject(input)) {\n throw new TypeError(`${label} must be a non-array object.`);\n }\n\n assertExactDataKeys(input, ENTRY_KEYS, [], label);\n\n if (input.type !== \"page\") {\n throw new TypeError(\n `${label} has unknown type ${printableValue(input.type)}; expected \"page\".`,\n );\n }\n\n if (typeof input.name !== \"string\" || input.name.trim().length === 0) {\n throw new TypeError(`${label} name must be a non-empty string.`);\n }\n\n if (typeof input.path !== \"string\" || input.path.trim().length === 0) {\n throw new TypeError(`${label} path must be a non-empty string.`);\n }\n\n if (typeof input.load !== \"function\") {\n throw new TypeError(`${label} load must be callable.`);\n }\n\n return input as ClientPageEntry;\n}\n\nfunction validateProjectedModule(input: unknown, label: string): ClientProjectedModule {\n if (!isNonArrayObject(input)) {\n throw new TypeError(`${label} must be a non-array module object.`);\n }\n\n return input;\n}\n\nfunction validateComposition(input: unknown): ClientRouteComposition {\n const label = \"Loaded client route composition\";\n\n if (!isNonArrayObject(input)) {\n throw new TypeError(`${label} must be a non-array object.`);\n }\n\n assertExactDataKeys(\n input,\n COMPOSITION_REQUIRED_KEYS,\n COMPOSITION_OPTIONAL_KEYS,\n label,\n );\n validateProjectedModule(input.Page, `${label} Page`);\n\n if (!Array.isArray(input.layouts)) {\n throw new TypeError(`${label} layouts must be an array.`);\n }\n\n input.layouts.forEach((layout, index) => {\n validateProjectedModule(layout, `${label} layout at index ${index}`);\n });\n\n if (Object.prototype.hasOwnProperty.call(input, \"App\")) {\n validateProjectedModule(input.App, `${label} App`);\n }\n\n if (Object.prototype.hasOwnProperty.call(input, \"ErrorPage\")) {\n validateProjectedModule(input.ErrorPage, `${label} ErrorPage`);\n }\n\n return input as ClientRouteComposition;\n}\n\nexport function validateClientRouteManifest(input: unknown): readonly ClientPageEntry[] {\n if (!Array.isArray(input)) {\n throw new TypeError(\"Client route manifest must be an array.\");\n }\n\n const names = new Set<string>();\n const paths = new Set<string>();\n\n return input.map((candidate, index) => {\n const entry = validateEntry(candidate, index);\n\n if (names.has(entry.name)) {\n throw new TypeError(\n `Client route manifest has duplicate name ${JSON.stringify(entry.name)}.`,\n );\n }\n\n if (paths.has(entry.path)) {\n throw new TypeError(\n `Client route manifest has duplicate path ${JSON.stringify(entry.path)}.`,\n );\n }\n\n names.add(entry.name);\n paths.add(entry.path);\n return entry;\n });\n}\n\nexport async function loadClientRouteComposition(\n entry: ClientPageEntry,\n): Promise<ClientRouteComposition> {\n const loaded = await entry.load();\n return validateComposition(loaded);\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"matcher.mjs","names":[],"sources":["../../../../../../../../web/src/client/runtime/matcher.ts"],"sourcesContent":["import type { ClientPageEntry, ClientRouteMatch } from \"./types\";\n\ntype RouteToken =\n | { readonly type: \"static\"; readonly value: string }\n | { readonly type: \"parameter\"; readonly name: string }\n | { readonly type: \"catch-all\" };\n\ntype CompiledRoute = {\n readonly entry: ClientPageEntry;\n readonly tokens: readonly RouteToken[];\n readonly parameterNames: readonly string[];\n readonly expression: RegExp;\n readonly collisionKey: string;\n};\n\ntype SanitizedPath = {\n readonly path: string;\n readonly shouldDecodeParameters: boolean;\n};\n\nconst PARAMETER_NAME = /^[A-Za-z0-9_]+$/;\nconst REGEXP_SPECIAL = /[.*+?^${}()|[\\]\\\\]/g;\n\nfunction escapeRegExp(value: string): string {\n return value.replace(REGEXP_SPECIAL, \"\\\\$&\");\n}\n\nfunction decodeReservedCharacter(high: string, low: string): string | null {\n const pair = `${high}${low}`.toUpperCase();\n const reserved: Readonly<Record<string, string>> = {\n \"23\": \"#\",\n \"24\": \"$\",\n \"25\": \"%\",\n \"26\": \"&\",\n \"2B\": \"+\",\n \"2C\": \",\",\n \"2F\": \"/\",\n \"3A\": \":\",\n \"3B\": \";\",\n \"3D\": \"=\",\n \"3F\": \"?\",\n \"40\": \"@\",\n };\n\n return reserved[pair] ?? null;\n}\n\nfunction sanitizePathname(pathname: string): SanitizedPath | null {\n if (typeof pathname !== \"string\" || !pathname.startsWith(\"/\")) {\n throw new Error(\"Client route pathname must start with '/'\");\n }\n\n let path = pathname;\n let shouldDecode = false;\n let shouldDecodeParameters = false;\n\n for (let index = 1; index < path.length; index++) {\n if (path[index] !== \"%\") continue;\n\n const high = path[index + 1] ?? \"\";\n const low = path[index + 2] ?? \"\";\n const reserved = decodeReservedCharacter(high, low);\n\n if (reserved === null) {\n shouldDecode = true;\n continue;\n }\n\n shouldDecodeParameters = true;\n if (reserved === \"%\") {\n path = `${path.slice(0, index + 1)}25${path.slice(index + 1)}`;\n shouldDecode = true;\n index += 2;\n }\n index += 2;\n }\n\n try {\n if (shouldDecode) path = decodeURI(path);\n } catch {\n return null;\n }\n\n if (path.length > 1 && path.endsWith(\"/\")) path = path.slice(0, -1);\n\n return { path, shouldDecodeParameters };\n}\n\nfunction decodeParameter(value: string): string {\n let decoded = \"\";\n\n for (let index = 0; index < value.length; index++) {\n if (value[index] !== \"%\") {\n decoded += value[index];\n continue;\n }\n\n const reserved = decodeReservedCharacter(value[index + 1] ?? \"\", value[index + 2] ?? \"\");\n if (reserved === null) return value;\n\n decoded += reserved;\n index += 2;\n }\n\n return decoded;\n}\n\nfunction parsePattern(entry: ClientPageEntry): CompiledRoute {\n const original = entry.path;\n const isExactRootCatchAll = original === \"*\";\n if (!isExactRootCatchAll && !original.startsWith(\"/\")) {\n throw new Error(`Client route pattern '${original}' must start with '/'`);\n }\n\n const pattern = original.length > 1 && original.endsWith(\"/\")\n ? original.slice(0, -1)\n : original;\n const segments = isExactRootCatchAll\n ? [\"*\"]\n : pattern === \"/\"\n ? []\n : pattern.slice(1).split(\"/\");\n const tokens: RouteToken[] = [];\n const parameterNames: string[] = [];\n\n for (let index = 0; index < segments.length; index++) {\n const segment = segments[index];\n if (!segment) {\n throw new Error(`Client route pattern '${original}' contains an empty segment`);\n }\n\n if (segment === \"*\") {\n if (index !== segments.length - 1) {\n throw new Error(`Client route pattern '${original}' has a non-terminal catch-all`);\n }\n tokens.push({ type: \"catch-all\" });\n parameterNames.push(\"*\");\n continue;\n }\n\n if (segment.startsWith(\":\")) {\n const name = segment.slice(1);\n if (!PARAMETER_NAME.test(name)) {\n throw new Error(`Client route pattern '${original}' has an unsupported parameter segment`);\n }\n if (parameterNames.includes(name)) {\n throw new Error(`Client route pattern '${original}' repeats parameter '${name}'`);\n }\n tokens.push({ type: \"parameter\", name });\n parameterNames.push(name);\n continue;\n }\n\n if (segment.includes(\":\") || segment.includes(\"*\") || segment.includes(\"?\") || segment.includes(\"%\")) {\n throw new Error(`Client route pattern '${original}' contains unsupported syntax`);\n }\n tokens.push({ type: \"static\", value: segment });\n }\n\n let source = \"^\";\n for (let index = 0; index < tokens.length; index++) {\n const token = tokens[index];\n if (token.type === \"static\") source += `/${escapeRegExp(token.value)}`;\n if (token.type === \"parameter\") source += \"/([^/]{1,100})\";\n if (token.type === \"catch-all\") {\n source += isExactRootCatchAll ? \"(.*)\" : index === 0 ? \"/(.*)\" : \"/(.+)\";\n }\n }\n if (tokens.length === 0) source += \"/\";\n source += \"$\";\n\n const collisionKey = tokens\n .map((token) => {\n if (token.type === \"static\") return `s:${token.value.toLowerCase()}`;\n if (token.type === \"parameter\") return \"p\";\n return \"w\";\n })\n .join(\"/\");\n\n return {\n entry,\n tokens,\n parameterNames,\n expression: new RegExp(source, \"i\"),\n collisionKey,\n };\n}\n\nfunction compareSpecificity(left: CompiledRoute, right: CompiledRoute): number {\n const rank = (token: RouteToken | undefined): number => {\n if (!token || token.type === \"static\") return 3;\n if (token.type === \"parameter\") return 2;\n return 1;\n };\n\n const length = Math.max(left.tokens.length, right.tokens.length);\n for (let index = 0; index < length; index++) {\n const difference = rank(right.tokens[index]) - rank(left.tokens[index]);\n if (difference !== 0) return difference;\n }\n return 0;\n}\n\nfunction compileRoutes(entries: readonly ClientPageEntry[]): readonly CompiledRoute[] {\n const collisions = new Map<string, ClientPageEntry>();\n const routes = entries.map((entry) => {\n const route = parsePattern(entry);\n const existing = collisions.get(route.collisionKey);\n if (existing) {\n throw new Error(\n `Client route patterns '${existing.path}' and '${entry.path}' collide under server matching`,\n );\n }\n collisions.set(route.collisionKey, entry);\n return route;\n });\n\n return routes.sort(compareSpecificity);\n}\n\n/**\n * @deprecated Do not adopt for new code. This client-side matcher duplicates the\n * route grammar the server already evaluates, and divergence between the two is\n * silent (wrong page, not an error). It is superseded by navigation consuming the\n * server-returned page composition/page swap: the client requests loader data and\n * the matched page's identity rides back on that same response.\n *\n * Delete only after the server-answered page swap is proven working in production\n * use — not before. Deleting earlier leaves neither implementation in place.\n * Removing this export (and the `@warlock.js/web/client/runtime` re-export) is a\n * breaking change to a published subpath and must be announced as one.\n */\nexport function matchClientRoute(\n entries: readonly ClientPageEntry[],\n pathname: string,\n): ClientRouteMatch | null {\n const routes = compileRoutes(entries);\n const sanitized = sanitizePathname(pathname);\n if (!sanitized) return null;\n\n for (const route of routes) {\n const match = route.expression.exec(sanitized.path);\n if (!match) continue;\n\n const params: Record<string, string> = {};\n for (let index = 0; index < route.parameterNames.length; index++) {\n const value = match[index + 1];\n params[route.parameterNames[index]] = sanitized.shouldDecodeParameters\n ? decodeParameter(value)\n : value;\n }\n return { entry: route.entry, params };\n }\n\n return null;\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"matcher.mjs","names":[],"sources":["../../../../../../../../web/src/client/runtime/matcher.ts"],"sourcesContent":["import type { ClientPageEntry, ClientRouteMatch } from \"./types\";\n\ntype RouteToken =\n | { readonly type: \"static\"; readonly value: string }\n | { readonly type: \"parameter\"; readonly name: string }\n | { readonly type: \"catch-all\" };\n\ntype CompiledRoute = {\n readonly entry: ClientPageEntry;\n readonly tokens: readonly RouteToken[];\n readonly parameterNames: readonly string[];\n readonly expression: RegExp;\n readonly collisionKey: string;\n};\n\ntype SanitizedPath = {\n readonly path: string;\n readonly shouldDecodeParameters: boolean;\n};\n\nconst PARAMETER_NAME = /^[A-Za-z0-9_]+$/;\nconst REGEXP_SPECIAL = /[.*+?^${}()|[\\]\\\\]/g;\n\nfunction escapeRegExp(value: string): string {\n return value.replace(REGEXP_SPECIAL, \"\\\\$&\");\n}\n\nfunction decodeReservedCharacter(high: string, low: string): string | null {\n const pair = `${high}${low}`.toUpperCase();\n const reserved: Readonly<Record<string, string>> = {\n \"23\": \"#\",\n \"24\": \"$\",\n \"25\": \"%\",\n \"26\": \"&\",\n \"2B\": \"+\",\n \"2C\": \",\",\n \"2F\": \"/\",\n \"3A\": \":\",\n \"3B\": \";\",\n \"3D\": \"=\",\n \"3F\": \"?\",\n \"40\": \"@\",\n };\n\n return reserved[pair] ?? null;\n}\n\nfunction sanitizePathname(pathname: string): SanitizedPath | null {\n if (typeof pathname !== \"string\" || !pathname.startsWith(\"/\")) {\n throw new Error(\"Client route pathname must start with '/'\");\n }\n\n let path = pathname;\n let shouldDecode = false;\n let shouldDecodeParameters = false;\n\n for (let index = 1; index < path.length; index++) {\n if (path[index] !== \"%\") continue;\n\n const high = path[index + 1] ?? \"\";\n const low = path[index + 2] ?? \"\";\n const reserved = decodeReservedCharacter(high, low);\n\n if (reserved === null) {\n shouldDecode = true;\n continue;\n }\n\n shouldDecodeParameters = true;\n if (reserved === \"%\") {\n path = `${path.slice(0, index + 1)}25${path.slice(index + 1)}`;\n shouldDecode = true;\n index += 2;\n }\n index += 2;\n }\n\n try {\n if (shouldDecode) path = decodeURI(path);\n } catch {\n return null;\n }\n\n if (path.length > 1 && path.endsWith(\"/\")) path = path.slice(0, -1);\n\n return { path, shouldDecodeParameters };\n}\n\nfunction decodeParameter(value: string): string {\n let decoded = \"\";\n\n for (let index = 0; index < value.length; index++) {\n if (value[index] !== \"%\") {\n decoded += value[index];\n continue;\n }\n\n const reserved = decodeReservedCharacter(value[index + 1] ?? \"\", value[index + 2] ?? \"\");\n if (reserved === null) return value;\n\n decoded += reserved;\n index += 2;\n }\n\n return decoded;\n}\n\nfunction parsePattern(entry: ClientPageEntry): CompiledRoute {\n const original = entry.path;\n const isExactRootCatchAll = original === \"*\";\n if (!isExactRootCatchAll && !original.startsWith(\"/\")) {\n throw new Error(`Client route pattern '${original}' must start with '/'`);\n }\n\n const pattern = original.length > 1 && original.endsWith(\"/\")\n ? original.slice(0, -1)\n : original;\n const segments = isExactRootCatchAll\n ? [\"*\"]\n : pattern === \"/\"\n ? []\n : pattern.slice(1).split(\"/\");\n const tokens: RouteToken[] = [];\n const parameterNames: string[] = [];\n\n for (let index = 0; index < segments.length; index++) {\n const segment = segments[index];\n if (!segment) {\n throw new Error(`Client route pattern '${original}' contains an empty segment`);\n }\n\n if (segment === \"*\") {\n if (index !== segments.length - 1) {\n throw new Error(`Client route pattern '${original}' has a non-terminal catch-all`);\n }\n tokens.push({ type: \"catch-all\" });\n parameterNames.push(\"*\");\n continue;\n }\n\n if (segment.startsWith(\":\")) {\n const name = segment.slice(1);\n if (!PARAMETER_NAME.test(name)) {\n throw new Error(`Client route pattern '${original}' has an unsupported parameter segment`);\n }\n if (parameterNames.includes(name)) {\n throw new Error(`Client route pattern '${original}' repeats parameter '${name}'`);\n }\n tokens.push({ type: \"parameter\", name });\n parameterNames.push(name);\n continue;\n }\n\n if (segment.includes(\":\") || segment.includes(\"*\") || segment.includes(\"?\") || segment.includes(\"%\")) {\n throw new Error(`Client route pattern '${original}' contains unsupported syntax`);\n }\n tokens.push({ type: \"static\", value: segment });\n }\n\n let source = \"^\";\n for (let index = 0; index < tokens.length; index++) {\n const token = tokens[index];\n if (token.type === \"static\") source += `/${escapeRegExp(token.value)}`;\n if (token.type === \"parameter\") source += \"/([^/]{1,100})\";\n if (token.type === \"catch-all\") {\n source += isExactRootCatchAll ? \"(.*)\" : index === 0 ? \"/(.*)\" : \"/(.+)\";\n }\n }\n if (tokens.length === 0) source += \"/\";\n source += \"$\";\n\n const collisionKey = tokens\n .map((token) => {\n if (token.type === \"static\") return `s:${token.value.toLowerCase()}`;\n if (token.type === \"parameter\") return \"p\";\n return \"w\";\n })\n .join(\"/\");\n\n return {\n entry,\n tokens,\n parameterNames,\n expression: new RegExp(source, \"i\"),\n collisionKey,\n };\n}\n\nfunction compareSpecificity(left: CompiledRoute, right: CompiledRoute): number {\n const rank = (token: RouteToken | undefined): number => {\n if (!token || token.type === \"static\") return 3;\n if (token.type === \"parameter\") return 2;\n return 1;\n };\n\n const length = Math.max(left.tokens.length, right.tokens.length);\n for (let index = 0; index < length; index++) {\n const difference = rank(right.tokens[index]) - rank(left.tokens[index]);\n if (difference !== 0) return difference;\n }\n return 0;\n}\n\nfunction compileRoutes(entries: readonly ClientPageEntry[]): readonly CompiledRoute[] {\n const collisions = new Map<string, ClientPageEntry>();\n const routes = entries.map((entry) => {\n const route = parsePattern(entry);\n const existing = collisions.get(route.collisionKey);\n if (existing) {\n throw new Error(\n `Client route patterns '${existing.path}' and '${entry.path}' collide under server matching`,\n );\n }\n collisions.set(route.collisionKey, entry);\n return route;\n });\n\n return routes.sort(compareSpecificity);\n}\n\n/**\n * @deprecated Do not adopt for new code. This client-side matcher duplicates the\n * route grammar the server already evaluates, and divergence between the two is\n * silent (wrong page, not an error). It is superseded by navigation consuming the\n * server-returned page composition/page swap: the client requests loader data and\n * the matched page's identity rides back on that same response.\n *\n * Delete only after the server-answered page swap is proven working in production\n * use — not before. Deleting earlier leaves neither implementation in place.\n * Removing this export (and the `@warlock.js/web/client/runtime` re-export) is a\n * breaking change to a published subpath and must be announced as one.\n */\nexport function matchClientRoute(\n entries: readonly ClientPageEntry[],\n pathname: string,\n): ClientRouteMatch | null {\n const routes = compileRoutes(entries);\n const sanitized = sanitizePathname(pathname);\n if (!sanitized) return null;\n\n for (const route of routes) {\n const match = route.expression.exec(sanitized.path);\n if (!match) continue;\n\n const params: Record<string, string> = {};\n for (let index = 0; index < route.parameterNames.length; index++) {\n const value = match[index + 1];\n params[route.parameterNames[index]] = sanitized.shouldDecodeParameters\n ? decodeParameter(value)\n : value;\n }\n return { entry: route.entry, params };\n }\n\n return null;\n}\n"],"mappings":";AAoBA,MAAM,iBAAiB;AACvB,MAAM,iBAAiB;AAEvB,SAAS,aAAa,OAAuB;CAC3C,OAAO,MAAM,QAAQ,gBAAgB,MAAM;AAC7C;AAEA,SAAS,wBAAwB,MAAc,KAA4B;CAiBzE,OAAO;EAdL,MAAM;EACN,MAAM;EACN,MAAM;EACN,MAAM;EACN,MAAM;EACN,MAAM;EACN,MAAM;EACN,MAAM;EACN,MAAM;EACN,MAAM;EACN,MAAM;EACN,MAAM;CAGM,EAhBD,GAAG,OAAO,MAAM,YAgBV,MAAM;AAC3B;AAEA,SAAS,iBAAiB,UAAwC;CAChE,IAAI,OAAO,aAAa,YAAY,CAAC,SAAS,WAAW,GAAG,GAC1D,MAAM,IAAI,MAAM,2CAA2C;CAG7D,IAAI,OAAO;CACX,IAAI,eAAe;CACnB,IAAI,yBAAyB;CAE7B,KAAK,IAAI,QAAQ,GAAG,QAAQ,KAAK,QAAQ,SAAS;EAChD,IAAI,KAAK,WAAW,KAAK;EAIzB,MAAM,WAAW,wBAFJ,KAAK,QAAQ,MAAM,IACpB,KAAK,QAAQ,MAAM,EACmB;EAElD,IAAI,aAAa,MAAM;GACrB,eAAe;GACf;EACF;EAEA,yBAAyB;EACzB,IAAI,aAAa,KAAK;GACpB,OAAO,GAAG,KAAK,MAAM,GAAG,QAAQ,CAAC,EAAE,IAAI,KAAK,MAAM,QAAQ,CAAC;GAC3D,eAAe;GACf,SAAS;EACX;EACA,SAAS;CACX;CAEA,IAAI;EACF,IAAI,cAAc,OAAO,UAAU,IAAI;CACzC,QAAQ;EACN,OAAO;CACT;CAEA,IAAI,KAAK,SAAS,KAAK,KAAK,SAAS,GAAG,GAAG,OAAO,KAAK,MAAM,GAAG,EAAE;CAElE,OAAO;EAAE;EAAM;CAAuB;AACxC;AAEA,SAAS,gBAAgB,OAAuB;CAC9C,IAAI,UAAU;CAEd,KAAK,IAAI,QAAQ,GAAG,QAAQ,MAAM,QAAQ,SAAS;EACjD,IAAI,MAAM,WAAW,KAAK;GACxB,WAAW,MAAM;GACjB;EACF;EAEA,MAAM,WAAW,wBAAwB,MAAM,QAAQ,MAAM,IAAI,MAAM,QAAQ,MAAM,EAAE;EACvF,IAAI,aAAa,MAAM,OAAO;EAE9B,WAAW;EACX,SAAS;CACX;CAEA,OAAO;AACT;AAEA,SAAS,aAAa,OAAuC;CAC3D,MAAM,WAAW,MAAM;CACvB,MAAM,sBAAsB,aAAa;CACzC,IAAI,CAAC,uBAAuB,CAAC,SAAS,WAAW,GAAG,GAClD,MAAM,IAAI,MAAM,yBAAyB,SAAS,sBAAsB;CAG1E,MAAM,UAAU,SAAS,SAAS,KAAK,SAAS,SAAS,GAAG,IACxD,SAAS,MAAM,GAAG,EAAE,IACpB;CACJ,MAAM,WAAW,sBACb,CAAC,GAAG,IACJ,YAAY,MACV,CAAC,IACD,QAAQ,MAAM,CAAC,CAAC,CAAC,MAAM,GAAG;CAChC,MAAM,SAAuB,CAAC;CAC9B,MAAM,iBAA2B,CAAC;CAElC,KAAK,IAAI,QAAQ,GAAG,QAAQ,SAAS,QAAQ,SAAS;EACpD,MAAM,UAAU,SAAS;EACzB,IAAI,CAAC,SACH,MAAM,IAAI,MAAM,yBAAyB,SAAS,4BAA4B;EAGhF,IAAI,YAAY,KAAK;GACnB,IAAI,UAAU,SAAS,SAAS,GAC9B,MAAM,IAAI,MAAM,yBAAyB,SAAS,+BAA+B;GAEnF,OAAO,KAAK,EAAE,MAAM,YAAY,CAAC;GACjC,eAAe,KAAK,GAAG;GACvB;EACF;EAEA,IAAI,QAAQ,WAAW,GAAG,GAAG;GAC3B,MAAM,OAAO,QAAQ,MAAM,CAAC;GAC5B,IAAI,CAAC,eAAe,KAAK,IAAI,GAC3B,MAAM,IAAI,MAAM,yBAAyB,SAAS,uCAAuC;GAE3F,IAAI,eAAe,SAAS,IAAI,GAC9B,MAAM,IAAI,MAAM,yBAAyB,SAAS,uBAAuB,KAAK,EAAE;GAElF,OAAO,KAAK;IAAE,MAAM;IAAa;GAAK,CAAC;GACvC,eAAe,KAAK,IAAI;GACxB;EACF;EAEA,IAAI,QAAQ,SAAS,GAAG,KAAK,QAAQ,SAAS,GAAG,KAAK,QAAQ,SAAS,GAAG,KAAK,QAAQ,SAAS,GAAG,GACjG,MAAM,IAAI,MAAM,yBAAyB,SAAS,8BAA8B;EAElF,OAAO,KAAK;GAAE,MAAM;GAAU,OAAO;EAAQ,CAAC;CAChD;CAEA,IAAI,SAAS;CACb,KAAK,IAAI,QAAQ,GAAG,QAAQ,OAAO,QAAQ,SAAS;EAClD,MAAM,QAAQ,OAAO;EACrB,IAAI,MAAM,SAAS,UAAU,UAAU,IAAI,aAAa,MAAM,KAAK;EACnE,IAAI,MAAM,SAAS,aAAa,UAAU;EAC1C,IAAI,MAAM,SAAS,aACjB,UAAU,sBAAsB,SAAS,UAAU,IAAI,UAAU;CAErE;CACA,IAAI,OAAO,WAAW,GAAG,UAAU;CACnC,UAAU;CAEV,MAAM,eAAe,OAClB,KAAK,UAAU;EACd,IAAI,MAAM,SAAS,UAAU,OAAO,KAAK,MAAM,MAAM,YAAY;EACjE,IAAI,MAAM,SAAS,aAAa,OAAO;EACvC,OAAO;CACT,CAAC,CAAC,CACD,KAAK,GAAG;CAEX,OAAO;EACL;EACA;EACA;EACA,YAAY,IAAI,OAAO,QAAQ,GAAG;EAClC;CACF;AACF;AAEA,SAAS,mBAAmB,MAAqB,OAA8B;CAC7E,MAAM,QAAQ,UAA0C;EACtD,IAAI,CAAC,SAAS,MAAM,SAAS,UAAU,OAAO;EAC9C,IAAI,MAAM,SAAS,aAAa,OAAO;EACvC,OAAO;CACT;CAEA,MAAM,SAAS,KAAK,IAAI,KAAK,OAAO,QAAQ,MAAM,OAAO,MAAM;CAC/D,KAAK,IAAI,QAAQ,GAAG,QAAQ,QAAQ,SAAS;EAC3C,MAAM,aAAa,KAAK,MAAM,OAAO,MAAM,IAAI,KAAK,KAAK,OAAO,MAAM;EACtE,IAAI,eAAe,GAAG,OAAO;CAC/B;CACA,OAAO;AACT;AAEA,SAAS,cAAc,SAA+D;CACpF,MAAM,6BAAa,IAAI,IAA6B;CAapD,OAZe,QAAQ,KAAK,UAAU;EACpC,MAAM,QAAQ,aAAa,KAAK;EAChC,MAAM,WAAW,WAAW,IAAI,MAAM,YAAY;EAClD,IAAI,UACF,MAAM,IAAI,MACR,0BAA0B,SAAS,KAAK,SAAS,MAAM,KAAK,gCAC9D;EAEF,WAAW,IAAI,MAAM,cAAc,KAAK;EACxC,OAAO;CACT,CAEY,CAAC,CAAC,KAAK,kBAAkB;AACvC;;;;;;;;;;;;;AAcA,SAAgB,iBACd,SACA,UACyB;CACzB,MAAM,SAAS,cAAc,OAAO;CACpC,MAAM,YAAY,iBAAiB,QAAQ;CAC3C,IAAI,CAAC,WAAW,OAAO;CAEvB,KAAK,MAAM,SAAS,QAAQ;EAC1B,MAAM,QAAQ,MAAM,WAAW,KAAK,UAAU,IAAI;EAClD,IAAI,CAAC,OAAO;EAEZ,MAAM,SAAiC,CAAC;EACxC,KAAK,IAAI,QAAQ,GAAG,QAAQ,MAAM,eAAe,QAAQ,SAAS;GAChE,MAAM,QAAQ,MAAM,QAAQ;GAC5B,OAAO,MAAM,eAAe,UAAU,UAAU,yBAC5C,gBAAgB,KAAK,IACrB;EACN;EACA,OAAO;GAAE,OAAO,MAAM;GAAO;EAAO;CACtC;CAEA,OAAO;AACT"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"document-context.mjs","names":[],"sources":["../../../../../../../web/src/components/document-context.ts"],"sourcesContent":["import { createContext, useContext } from \"react\";\nimport type { MetadataOutput } from \"../metadata\";\n\n/**\n * The JSON-safe error shape carried from the server document to browser\n * hydration.\n *\n * This is deliberately NOT the original thrown object. Error prototypes,\n * identity, non-enumerable fields and arbitrary custom values do not survive a\n * JSON boundary reliably. The server renders `ErrorPageProps` with the\n * original value, then normalizes it to this lossy representation only for the\n * hydration payload. Normalization also owns disclosure: `stack` is optional\n * and must be omitted or redacted when server internals are not safe to expose\n * to the browser.\n */\nexport type SerializedPageError = {\n readonly name: string;\n readonly message: string;\n readonly stack?: string;\n};\n\n/**\n * Props received by the application-owned `error.page.tsx` during SSR.\n *\n * Deliberately preserve the thrown value here. An application can use its own\n * error classes, symbols, or structured values while rendering on the server;\n * this public component contract is not a JSON boundary.\n */\nexport type ErrorPageProps = {\n readonly error: unknown;\n readonly status: number;\n};\n\n/**\n * The JSON-safe counterpart of {@link ErrorPageProps}, used only after the\n * document crosses from SSR into browser hydration. Keeping this distinct\n * prevents a serialized approximation from being mistaken for the original\n * thrown value available to the server render.\n */\nexport type SerializedErrorPageProps = {\n readonly error: SerializedPageError;\n readonly status: number;\n};\n\nexport type HydrationDocumentPayloadSource = {\n readonly appData: unknown;\n readonly layoutData: unknown;\n readonly pageData: unknown;\n readonly shared: unknown;\n /**\n * The matched page manifest entry's stable `name` — the same field the\n * manifest entry contract `{ type, name, path, load }` declares. It is on\n * the wire so the browser can look up WHICH page the server rendered\n * instead of re-matching `location.pathname` itself: re-matching is a\n * second implementation of route semantics, and it can disagree with the\n * server on the very request it is hydrating.\n */\n readonly name: string;\n /**\n * The params the SERVER matched for this request — `bundle.route.params`\n * (`server/execute-page-request.ts:288`), carried untransformed. Same reason\n * `name` is here: the browser must not re-derive them from\n * `location.pathname`, because deriving them IS a second matcher.\n *\n * OPTIONAL, and ungated on purpose — see {@link metadata} below for the rule\n * both new keys share. The server always emits it (`{}` for a route with no\n * dynamic segments), so absence means the payload came from a producer that\n * predates this key; `currentRoute()` then reports `{}` rather than failing a\n * page over an accessor.\n */\n readonly params?: Readonly<Record<string, string>>;\n /**\n * The page metadata the server resolved at stage 8, carried WHOLE — the same\n * `MetadataOutput` `<Head/>` rendered into the document on the first request.\n *\n * Why it has to be on the wire at all: `<Head/>` renders inside the App\n * level, and the App level is not part of the hydrated tree (the client\n * mounts at `#root`, which App contains). So on a client navigation there is\n * no React render that can reach `<head>` — without this key the browser\n * never learns the new page's title and the tab keeps the old one.\n *\n * OPTIONAL, deliberately: `bundle.metadata` is itself optional\n * (`server/execute-page-request.ts:296`) — a page that exports no `metadata`\n * produces none, and a loader short-circuit skips stage 8 entirely. Gating a\n * key the server is right not to produce would make `readHydrationPayload`\n * throw on a valid page. Present-but-not-an-object is still MALFORMED and\n * still throws; only ABSENT is accepted.\n */\n readonly metadata?: MetadataOutput;\n /**\n * Present only when the server selected the application-owned error page for\n * this response. Atomic rather than two independently optional top-level\n * fields: a status without an error (or the reverse) cannot describe a tree\n * the browser can hydrate.\n *\n * `name` above intentionally remains the ORIGINAL matched route. This field\n * selects the `ErrorPage` module projected into that route's client\n * composition; it does not turn the error page into a second browsable route.\n */\n readonly errorPage?: SerializedErrorPageProps;\n};\n\nexport const PAYLOAD_SCRIPT_ID = \"__WARLOCK_DATA__\";\n\nconst LINE_SEPARATOR = String.fromCharCode(0x2028);\nconst PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);\n\n/** Escape JSON text for raw insertion into an application/json script. */\nexport function escapePayload(json: string): string {\n return json\n .split(\"<\")\n .join(\"\\\\u003c\")\n .split(\">\")\n .join(\"\\\\u003e\")\n .split(LINE_SEPARATOR)\n .join(\"\\\\u2028\")\n .split(PARAGRAPH_SEPARATOR)\n .join(\"\\\\u2029\");\n}\n\n/**\n * What `<Head/>`/`<Scripts/>` need to render real elements instead of the\n * framework injecting them by string surgery post-render (Suki, room seq\n * 1205): the resolved page metadata and the exact payload the hydration\n * script will read back. Provided once, around the root element, before\n * `renderToString` runs (`render-page.ts`'s stage 9 — the bundle is already\n * complete by then). Universal: no server-only imports, so the client's\n * hydration entry (a later slice) can provide the same shape from the parsed\n * payload script.\n */\nexport type DocumentContextValue = {\n metadata: MetadataOutput | undefined;\n payload: HydrationDocumentPayloadSource;\n /**\n * The nonce/lang/dir SLOTS: fed by the render provider from CORE request\n * fields — request nonce, request locale — never from app-owned `shared`\n * keys, which an app can overwrite. The provider-side\n * wiring is a separate slice, so these are absent at runtime until it\n * lands; every reader must treat them as optional.\n */\n nonce?: string;\n lang?: string;\n dir?: string;\n};\n\nexport const DocumentContext = createContext<DocumentContextValue | undefined>(undefined);\n\n/**\n * Require the page pipeline's universal document state. The payload id and\n * escaping helpers remain exported above for the existing server seam.\n */\nexport function useDocumentContext(componentName: string): DocumentContextValue {\n const value = useContext(DocumentContext);\n\n if (!value) {\n throw new Error(\n `<${componentName}/> was rendered outside the page pipeline's document context ` +\n \"(web/src/components/document-context.ts). Fix: only render it inside \" +\n \"an App/Layout/Page component tree the pipeline itself renders.\",\n );\n }\n\n return value;\n}\n"],"mappings":";;;AAsGA,MAAa,oBAAoB;AAEjC,MAAM,iBAAiB,OAAO,aAAa,IAAM;AACjD,MAAM,sBAAsB,OAAO,aAAa,IAAM;;AAGtD,SAAgB,cAAc,MAAsB;CAClD,OAAO,KACJ,MAAM,GAAG,
|
|
1
|
+
{"version":3,"file":"document-context.mjs","names":[],"sources":["../../../../../../../web/src/components/document-context.ts"],"sourcesContent":["import { createContext, useContext } from \"react\";\nimport type { MetadataOutput } from \"../metadata\";\n\n/**\n * The JSON-safe error shape carried from the server document to browser\n * hydration.\n *\n * This is deliberately NOT the original thrown object. Error prototypes,\n * identity, non-enumerable fields and arbitrary custom values do not survive a\n * JSON boundary reliably. The server renders `ErrorPageProps` with the\n * original value, then normalizes it to this lossy representation only for the\n * hydration payload. Normalization also owns disclosure: `stack` is optional\n * and must be omitted or redacted when server internals are not safe to expose\n * to the browser.\n */\nexport type SerializedPageError = {\n readonly name: string;\n readonly message: string;\n readonly stack?: string;\n};\n\n/**\n * Props received by the application-owned `error.page.tsx` during SSR.\n *\n * Deliberately preserve the thrown value here. An application can use its own\n * error classes, symbols, or structured values while rendering on the server;\n * this public component contract is not a JSON boundary.\n */\nexport type ErrorPageProps = {\n readonly error: unknown;\n readonly status: number;\n};\n\n/**\n * The JSON-safe counterpart of {@link ErrorPageProps}, used only after the\n * document crosses from SSR into browser hydration. Keeping this distinct\n * prevents a serialized approximation from being mistaken for the original\n * thrown value available to the server render.\n */\nexport type SerializedErrorPageProps = {\n readonly error: SerializedPageError;\n readonly status: number;\n};\n\nexport type HydrationDocumentPayloadSource = {\n readonly appData: unknown;\n readonly layoutData: unknown;\n readonly pageData: unknown;\n readonly shared: unknown;\n /**\n * The matched page manifest entry's stable `name` — the same field the\n * manifest entry contract `{ type, name, path, load }` declares. It is on\n * the wire so the browser can look up WHICH page the server rendered\n * instead of re-matching `location.pathname` itself: re-matching is a\n * second implementation of route semantics, and it can disagree with the\n * server on the very request it is hydrating.\n */\n readonly name: string;\n /**\n * The params the SERVER matched for this request — `bundle.route.params`\n * (`server/execute-page-request.ts:288`), carried untransformed. Same reason\n * `name` is here: the browser must not re-derive them from\n * `location.pathname`, because deriving them IS a second matcher.\n *\n * OPTIONAL, and ungated on purpose — see {@link metadata} below for the rule\n * both new keys share. The server always emits it (`{}` for a route with no\n * dynamic segments), so absence means the payload came from a producer that\n * predates this key; `currentRoute()` then reports `{}` rather than failing a\n * page over an accessor.\n */\n readonly params?: Readonly<Record<string, string>>;\n /**\n * The page metadata the server resolved at stage 8, carried WHOLE — the same\n * `MetadataOutput` `<Head/>` rendered into the document on the first request.\n *\n * Why it has to be on the wire at all: `<Head/>` renders inside the App\n * level, and the App level is not part of the hydrated tree (the client\n * mounts at `#root`, which App contains). So on a client navigation there is\n * no React render that can reach `<head>` — without this key the browser\n * never learns the new page's title and the tab keeps the old one.\n *\n * OPTIONAL, deliberately: `bundle.metadata` is itself optional\n * (`server/execute-page-request.ts:296`) — a page that exports no `metadata`\n * produces none, and a loader short-circuit skips stage 8 entirely. Gating a\n * key the server is right not to produce would make `readHydrationPayload`\n * throw on a valid page. Present-but-not-an-object is still MALFORMED and\n * still throws; only ABSENT is accepted.\n */\n readonly metadata?: MetadataOutput;\n /**\n * Present only when the server selected the application-owned error page for\n * this response. Atomic rather than two independently optional top-level\n * fields: a status without an error (or the reverse) cannot describe a tree\n * the browser can hydrate.\n *\n * `name` above intentionally remains the ORIGINAL matched route. This field\n * selects the `ErrorPage` module projected into that route's client\n * composition; it does not turn the error page into a second browsable route.\n */\n readonly errorPage?: SerializedErrorPageProps;\n};\n\nexport const PAYLOAD_SCRIPT_ID = \"__WARLOCK_DATA__\";\n\nconst LINE_SEPARATOR = String.fromCharCode(0x2028);\nconst PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);\n\n/** Escape JSON text for raw insertion into an application/json script. */\nexport function escapePayload(json: string): string {\n return json\n .split(\"<\")\n .join(\"\\\\u003c\")\n .split(\">\")\n .join(\"\\\\u003e\")\n .split(LINE_SEPARATOR)\n .join(\"\\\\u2028\")\n .split(PARAGRAPH_SEPARATOR)\n .join(\"\\\\u2029\");\n}\n\n/**\n * What `<Head/>`/`<Scripts/>` need to render real elements instead of the\n * framework injecting them by string surgery post-render (Suki, room seq\n * 1205): the resolved page metadata and the exact payload the hydration\n * script will read back. Provided once, around the root element, before\n * `renderToString` runs (`render-page.ts`'s stage 9 — the bundle is already\n * complete by then). Universal: no server-only imports, so the client's\n * hydration entry (a later slice) can provide the same shape from the parsed\n * payload script.\n */\nexport type DocumentContextValue = {\n metadata: MetadataOutput | undefined;\n payload: HydrationDocumentPayloadSource;\n /**\n * The nonce/lang/dir SLOTS: fed by the render provider from CORE request\n * fields — request nonce, request locale — never from app-owned `shared`\n * keys, which an app can overwrite. The provider-side\n * wiring is a separate slice, so these are absent at runtime until it\n * lands; every reader must treat them as optional.\n */\n nonce?: string;\n lang?: string;\n dir?: string;\n};\n\nexport const DocumentContext = createContext<DocumentContextValue | undefined>(undefined);\n\n/**\n * Require the page pipeline's universal document state. The payload id and\n * escaping helpers remain exported above for the existing server seam.\n */\nexport function useDocumentContext(componentName: string): DocumentContextValue {\n const value = useContext(DocumentContext);\n\n if (!value) {\n throw new Error(\n `<${componentName}/> was rendered outside the page pipeline's document context ` +\n \"(web/src/components/document-context.ts). Fix: only render it inside \" +\n \"an App/Layout/Page component tree the pipeline itself renders.\",\n );\n }\n\n return value;\n}\n"],"mappings":";;;AAsGA,MAAa,oBAAoB;AAEjC,MAAM,iBAAiB,OAAO,aAAa,IAAM;AACjD,MAAM,sBAAsB,OAAO,aAAa,IAAM;;AAGtD,SAAgB,cAAc,MAAsB;CAClD,OAAO,KACJ,MAAM,GAAG,CAAC,CACV,KAAK,SAAS,CAAC,CACf,MAAM,GAAG,CAAC,CACV,KAAK,SAAS,CAAC,CACf,MAAM,cAAc,CAAC,CACrB,KAAK,SAAS,CAAC,CACf,MAAM,mBAAmB,CAAC,CAC1B,KAAK,SAAS;AACnB;AA2BA,MAAa,kBAAkB,cAAgD,MAAS;;;;;AAMxF,SAAgB,mBAAmB,eAA6C;CAC9E,MAAM,QAAQ,WAAW,eAAe;CAExC,IAAI,CAAC,OACH,MAAM,IAAI,MACR,IAAI,cAAc,iMAGpB;CAGF,OAAO;AACT"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"link.mjs","names":[],"sources":["../../../../../../../web/src/components/link.ts"],"sourcesContent":["import { createElement } from \"react\";\nimport type {\n AnchorHTMLAttributes,\n ComponentType,\n FocusEvent,\n MouseEvent,\n ReactElement,\n} from \"react\";\nimport { prefetchPageData } from \"../client/navigation/prefetch\";\nimport { currentNavigator } from \"../routing/navigator\";\nimport { href, knownRouteNames } from \"../routing/route-table\";\n\n/**\n * `<Link>` is SUGAR over `href()`, and deliberately thin.\n *\n * `href(name, params, query)` is the durable primitive — it serves emails,\n * redirects, `Location` headers and every non-React caller, none of which can\n * render a component. This file adds one thing to it: an anchor element.\n *\n * It renders a real `<a href>`. Client-side interception is a later slice of\n * the navigation runtime and lands here without changing this API, which is the\n * point of routing everything through `href` first: navigation becomes a\n * BEHAVIOUR change, not an API change.\n *\n * ── Parity with `@mongez/react-router` ───────────────────────────────────────\n * `href`, `newTab`, `email`, `tel`, `component` and `prefetch` are spelled\n * exactly as MRR spells them, so a component moved across keeps compiling.\n * `params` and `query` are ours and have no MRR equivalent: they pair with the\n * typed `href()` helper, which is what makes a route NAME — rather than a URL —\n * the thing a call site names.\n *\n * ── The semantic divergence this file bridges ────────────────────────────────\n * MRR's `to` is a PATH. Ours was a route NAME, and only a name — which meant a\n * component moved across from MRR compiled and then threw at render, because\n * `\"/products\"` is not the name of anything. The two packages disagreed about\n * what the most-used prop in either of them MEANS.\n *\n * Since 2026-08-24 (owner ruling) `to`/`href` accept BOTH, discriminated by\n * SHAPE — see {@link isLiteralUrl}. That is what makes MRR code portable, and\n * it costs nothing at a Warlock call site, because the two grammars cannot\n * collide: a route name never begins with `/` and never carries a `scheme:`.\n * The ruling RESTS on that, so this file asserts it rather than trusting it\n * ({@link RouteNameShapeCollisionError}).\n */\n\ntype AnchorProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"href\">;\n\n/**\n * Where the link goes. Every field is documented here once; which COMBINATIONS\n * are legal is decided by {@link LinkDestination}.\n */\ntype LinkDestinationProps = {\n /**\n * A route NAME, or a literal URL — told apart by SHAPE.\n *\n * `\"products.details\"` is a NAME and is resolved through the route table. A\n * page that moves changes its URL and keeps its name, so every call site\n * survives the move; a dead name throws at render naming the routes that do\n * exist, rather than rendering an anchor that 404s. This is the form to\n * prefer, and the only one `params` and `query` apply to.\n *\n * `\"/pricing\"`, `\"https://stripe.com\"`, `\"mailto:sales@example.com\"` and any\n * other `scheme:` are LITERAL — passed through to the element untouched, with\n * no route lookup at all. An app links out, and a route name is not a thing\n * you can have for a page that is not yours.\n */\n to?: string;\n /**\n * An alias of {@link to}, for parity with `@mongez/react-router`. Identical\n * in every respect, including which shapes it accepts.\n */\n href?: string;\n /** Renders a `mailto:` link. Not an in-app navigation. */\n email?: string;\n /** Renders a `tel:` link. Not an in-app navigation. */\n tel?: string;\n /**\n * Values for the route's `:param` segments, e.g. `{ id }` for\n * `\"/products/:id\"`. Only meaningful with a route NAME.\n */\n params?: Record<string, unknown>;\n /**\n * Query string values; an `undefined` value is omitted. Only meaningful with\n * a route NAME.\n */\n query?: Record<string, unknown>;\n};\n\n/**\n * EXACTLY ONE destination, enforced by the type.\n *\n * The alternative — a documented precedence such as \"`to` wins over `href`\" —\n * is silent by construction: the losing prop goes on compiling and goes on\n * reading like it works at the call site, and the anchor points at the wrong\n * page. Refusing the pair costs a call site one edit and can never be\n * misread. The runtime refuses it as well, because a JavaScript caller and a\n * cast both get past this.\n */\ntype LinkDestination =\n | { to: string; href?: never; email?: never; tel?: never }\n | { href: string; to?: never; email?: never; tel?: never }\n | { email: string; to?: never; href?: never; tel?: never }\n | { tel: string; to?: never; href?: never; email?: never };\n\nexport type LinkProps = AnchorProps &\n LinkDestinationProps &\n LinkDestination & {\n /**\n * Open in a new browsing context: `target=\"_blank\"` plus the `rel` that\n * stops the opened page from reaching back through `window.opener`.\n *\n * A caller's own `target`/`rel` win — this only fills in what was not said.\n */\n newTab?: boolean;\n /**\n * Render as something other than `<a>` — a tag name or a component.\n *\n * It receives the resolved `href`, the click handler and every remaining\n * prop, so a design-system anchor keeps client-side navigation as long as\n * it spreads what it is given onto the element it renders.\n *\n * `ComponentType<any>` is MRR's signature, kept verbatim: the component is\n * the caller's and its props are unknowable from here.\n */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n component?: ComponentType<any> | string;\n /**\n * Fetch this page's data when the pointer or the keyboard reaches the link,\n * so the click that follows swaps without a round trip.\n *\n * A GUESS, and treated as one everywhere: it is never awaited, a failure is\n * silent, and the click behaves exactly as it would without it. Opt-in per\n * link rather than on by default, because every prefetch is a request the\n * user did not ask for and someone pays for the bandwidth.\n *\n * IGNORED for anything that is not an in-app navigation — an external URL,\n * `mailto:`, `tel:`, `newTab`, any explicit `target`. Prefetching those\n * would mean issuing a cross-origin request to a third party on hover,\n * which is not a thing a link component may decide to do.\n */\n prefetch?: boolean;\n };\n\nconst DESTINATION_PROPS = [\"to\", \"href\", \"email\", \"tel\"] as const;\n\nexport class AmbiguousLinkDestinationError extends Error {\n public constructor(public readonly providedProps: readonly string[]) {\n super(\n `Warlock <Link> was given ${providedProps\n .map(name => JSON.stringify(name))\n .join(\" and \")}, but a link goes to exactly one place. There is no ` +\n \"precedence between them on purpose: one of the two would silently win, and the \" +\n \"call site would go on naming a destination that never renders. Delete the one \" +\n \"you did not mean.\",\n );\n this.name = \"AmbiguousLinkDestinationError\";\n }\n}\n\nexport class MissingLinkDestinationError extends Error {\n public constructor() {\n super(\n `Warlock <Link> was given no destination. Pass exactly one of ${DESTINATION_PROPS.map(\n name => JSON.stringify(name),\n ).join(\", \")}. It is not defaulted to the current page: an anchor with an empty ` +\n \"`href` renders as a working link and reloads the page when clicked, which is a \" +\n \"harder fault to see than this message.\",\n );\n this.name = \"MissingLinkDestinationError\";\n }\n}\n\nexport class RouteArgumentsOnLiteralUrlError extends Error {\n public constructor(\n public readonly url: string,\n public readonly providedProps: readonly string[],\n ) {\n super(\n `Warlock <Link> was given ${providedProps\n .map(name => JSON.stringify(name))\n .join(\" and \")} alongside the literal URL \"${url}\". Those apply to a route NAME, ` +\n \"which is resolved through the route table; a literal URL is passed through exactly \" +\n \"as written, so they would have been dropped and the link would have pointed at an \" +\n \"unfiltered page that still looked right at the call site. Put the values in the URL, \" +\n \"or name the route.\",\n );\n this.name = \"RouteArgumentsOnLiteralUrlError\";\n }\n}\n\n/**\n * The ruling's one assumption, broken. See the module doc comment: telling a\n * literal URL from a route NAME by shape is only safe while no route is NAMED\n * like a URL, and nothing in the route pipeline validates a hand-declared\n * `route.name`. So the collision is checked at the one place it could do harm,\n * where it is a loud refusal instead of an anchor that silently points\n * somewhere else.\n */\nexport class RouteNameShapeCollisionError extends Error {\n public constructor(public readonly routeName: string) {\n super(\n `Warlock route table: a route is NAMED ${JSON.stringify(routeName)}, which is shaped ` +\n \"like a URL. <Link> tells a literal URL from a route name by shape — a destination \" +\n \"starting with `/` or carrying a `scheme:` is passed through untouched — so this name \" +\n \"can never be resolved, and every link to it would silently point at that path \" +\n \"instead. Rename the route (`route = { path, name }`) to a dotted name such as \" +\n `${JSON.stringify(routeName.replace(/^\\/+/, \"\").replace(/\\//g, \".\") || \"index\")}.`,\n );\n this.name = \"RouteNameShapeCollisionError\";\n }\n}\n\ntype Destination = {\n /** What lands on the element's `href`. */\n url: string;\n /**\n * Whether this URL is a page in THIS app — the only kind the client\n * navigation runtime may be asked about, and the only kind that may be\n * prefetched. `mailto:`, `tel:` and an external URL hand off to another\n * application or another origin entirely, so intercepting any of them would\n * break it, and speculatively fetching one would be a cross-origin request\n * the developer never asked for.\n */\n isInApp: boolean;\n};\n\n/**\n * Any RFC 3986 scheme — `https:`, `mailto:`, `tel:`, `whatsapp:`, an app's own\n * custom one. Matched generically rather than as a list of known schemes: a\n * list would silently resolve `bitcoin:...` through the route table, which is\n * the exact failure this ruling exists to remove, and it would have to grow\n * forever.\n */\nconst SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:/i;\n\n/**\n * Whether this destination is a URL to be used as written, rather than a route\n * name to resolve.\n *\n * The whole discriminator, and deliberately the whole of it: two cheap shape\n * tests, no parsing, no matching. Anything more would be a SECOND route matcher\n * living beside the server's, which this codebase refuses everywhere it comes\n * up — a matcher that disagreed with the real one would produce links to pages\n * that do not exist.\n */\nfunction isLiteralUrl(destination: string): boolean {\n return destination.startsWith(\"/\") || SCHEME_PATTERN.test(destination);\n}\n\n/**\n * Whether a literal URL addresses THIS app.\n *\n * A path is ours. A `scheme:` is not — including `https:` to our own origin,\n * which would need `window.location` to recognise and would make the answer\n * depend on where the code is running. And `//host/path` is PROTOCOL-RELATIVE:\n * it starts with a slash and is nonetheless another origin, which is precisely\n * the case a \"starts with `/`\" test alone would hand to the navigator, where it\n * becomes a `pushState` to a foreign origin — a SecurityError — or a\n * speculative fetch of a third-party host.\n */\nfunction addressesThisApp(url: string): boolean {\n return url.startsWith(\"/\") && !url.startsWith(\"//\");\n}\n\n/**\n * Refuses the one table that would make {@link isLiteralUrl} wrong.\n *\n * Reached only for a destination already judged literal, so the cost is a scan\n * of the published names for links that were never going to hit the table\n * anyway — and zero for the route-name form, which is the common one. The right\n * permanent home for this is `publishRouteTable`, at boot, once (see the report\n * on this card).\n */\nfunction assertNotARouteName(url: string): void {\n if (knownRouteNames().includes(url)) throw new RouteNameShapeCollisionError(url);\n}\n\nconst ROUTE_ARGUMENT_PROPS = [\"params\", \"query\"] as const;\n\nfunction resolveDestination(props: LinkDestinationProps): Destination {\n const provided = DESTINATION_PROPS.filter(name => props[name] !== undefined);\n\n if (provided.length > 1) throw new AmbiguousLinkDestinationError(provided);\n\n if (provided.length === 0) throw new MissingLinkDestinationError();\n\n if (props.email !== undefined) return { url: `mailto:${props.email}`, isInApp: false };\n\n if (props.tel !== undefined) return { url: `tel:${props.tel}`, isInApp: false };\n\n const destination = (props.to ?? props.href) as string;\n\n /*\n LITERAL: `/pricing`, `https://stripe.com`, `mailto:…`, `whatsapp://…`. It\n goes to the element exactly as written and the route table is never\n consulted — there is nothing to look up, and looking anyway is what used to\n throw `UnknownRouteNameError` on every link out of the application.\n */\n if (isLiteralUrl(destination)) {\n const routeArguments = ROUTE_ARGUMENT_PROPS.filter(name => props[name] !== undefined);\n\n if (routeArguments.length > 0) {\n throw new RouteArgumentsOnLiteralUrlError(destination, routeArguments);\n }\n\n assertNotARouteName(destination);\n\n return { url: destination, isInApp: addressesThisApp(destination) };\n }\n\n /*\n A NAME, resolved against the route table published at boot from the SAME\n discovery result the server registered its routes from. The previous version\n of this file restated six URLs in a literal map, so linking to any seventh\n page in the application threw — the map was the limit on what could be\n linked, and nothing said so at the call site.\n */\n return { url: href(destination, props.params, props.query), isInApp: true };\n}\n\n/**\n * Whether this click should be left entirely to the browser.\n *\n * Every case here is a click that MEANS something other than \"go there in this\n * tab\", and intercepting any of them would take away behaviour the user\n * explicitly asked for:\n *\n * - a modifier or middle button: open in a new tab/window, or download\n * - `download`: save the resource, do not render it\n * - already prevented: something upstream in the tree handled this click\n *\n * Left button with no modifiers is the only click that means plain navigation.\n * The `target` case is decided before this, from the RESOLVED target, because\n * `newTab` sets it after the caller's props are read.\n */\nfunction isPlainLeftClick(event: MouseEvent<HTMLAnchorElement>): boolean {\n return (\n event.button === 0 &&\n !event.metaKey &&\n !event.ctrlKey &&\n !event.shiftKey &&\n !event.altKey &&\n !event.defaultPrevented\n );\n}\n\n/**\n * A target other than `_self` names ANOTHER browsing context — `_blank`, but\n * also `_parent`, `_top` and any named frame. Client navigation rewrites the\n * history of THIS one, so none of them are ours to intercept.\n */\nfunction opensAnotherContext(target: string | undefined): boolean {\n return target !== undefined && target !== \"_self\";\n}\n\nexport function Link({\n to,\n href: hrefAlias,\n email,\n tel,\n params,\n query,\n newTab,\n prefetch,\n component: Component = \"a\",\n children,\n onClick,\n ...elementProps\n}: LinkProps): ReactElement {\n const { url, isInApp } = resolveDestination({\n to,\n href: hrefAlias,\n email,\n tel,\n params,\n query,\n });\n\n const target = elementProps.target ?? (newTab === true ? \"_blank\" : undefined);\n\n // Only a DEFAULT: a caller that wrote its own `rel` (`\"me noopener\"`,\n // `\"external\"`) meant it, and overwriting it would delete a value the page\n // depends on to say something this component knows nothing about.\n const rel =\n elementProps.rel ?? (target === \"_blank\" ? \"noopener noreferrer\" : undefined);\n\n const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {\n // The caller's handler runs FIRST and unconditionally — it may be doing\n // analytics, closing a menu, or calling `preventDefault()` to veto the\n // navigation outright. Deciding before it ran would let this component\n // navigate away from a click the application had already cancelled.\n onClick?.(event);\n\n // `mailto:`, `tel:` and anything aimed at another browsing context leave\n // this page standing. The runtime is not consulted at all — asking it would\n // spend a page-data fetch on a click that was never going to navigate here.\n if (!isInApp || opensAnotherContext(target)) return;\n\n if (!isPlainLeftClick(event)) return;\n\n /*\n Asked for per click, never captured at render: the runtime registers\n itself when the hydration bundle mounts, which is AFTER the first render\n of every anchor on the page. A value read at render time would be\n `undefined` forever for exactly the links present at hydration — that is,\n all of them.\n\n Absent (server render, or before hydration) the anchor is left alone and\n does what an anchor does. That is the whole progressive-enhancement story:\n links work before this code runs, and work better after.\n */\n if (currentNavigator()?.(url) !== true) return;\n\n event.preventDefault();\n };\n\n /*\n The SAME gate the click uses, asked before any speculative request exists:\n only a destination this app would have navigated to itself may be fetched\n ahead of time. `mailto:`, `tel:`, an external URL and anything aimed at\n another browsing context are all clicks that leave this page, and none of\n them has page data to fetch.\n */\n const prefetchesOnInteraction =\n prefetch === true && isInApp && !opensAnotherContext(target);\n\n /*\n Attached ONLY when prefetching — a link without the prop keeps whatever\n handlers the caller passed, on the element, unwrapped.\n\n Hover AND focus, because a keyboard user never generates the first one and\n would otherwise be the only visitor who never gets the optimisation.\n\n Fire-and-forget by construction: `prefetchPageData` never rejects and is\n never awaited, so nothing here can delay the event or surface a failure. It\n is also safe to reach on the server — it no-ops without a browser — which is\n why this file can import it directly rather than through a `connect*` seam\n like the navigator's. The navigator needs a seam because the runtime behind\n it drags React state and the page registry into the server bundle; the\n prefetch cache is a `Map` and a `fetch` call, inert until an event fires.\n */\n const prefetchHandlers = prefetchesOnInteraction\n ? {\n onMouseEnter: (event: MouseEvent<HTMLAnchorElement>): void => {\n elementProps.onMouseEnter?.(event);\n void prefetchPageData(url);\n },\n onFocus: (event: FocusEvent<HTMLAnchorElement>): void => {\n elementProps.onFocus?.(event);\n void prefetchPageData(url);\n },\n }\n : undefined;\n\n return createElement(\n Component,\n { ...elementProps, ...prefetchHandlers, target, rel, href: url, onClick: handleClick },\n children,\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"link.mjs","names":[],"sources":["../../../../../../../web/src/components/link.ts"],"sourcesContent":["import { createElement } from \"react\";\nimport type {\n AnchorHTMLAttributes,\n ComponentType,\n FocusEvent,\n MouseEvent,\n ReactElement,\n} from \"react\";\nimport { prefetchPageData } from \"../client/navigation/prefetch\";\nimport { currentNavigator } from \"../routing/navigator\";\nimport { href, knownRouteNames } from \"../routing/route-table\";\n\n/**\n * `<Link>` is SUGAR over `href()`, and deliberately thin.\n *\n * `href(name, params, query)` is the durable primitive — it serves emails,\n * redirects, `Location` headers and every non-React caller, none of which can\n * render a component. This file adds one thing to it: an anchor element.\n *\n * It renders a real `<a href>`. Client-side interception is a later slice of\n * the navigation runtime and lands here without changing this API, which is the\n * point of routing everything through `href` first: navigation becomes a\n * BEHAVIOUR change, not an API change.\n *\n * ── Parity with `@mongez/react-router` ───────────────────────────────────────\n * `href`, `newTab`, `email`, `tel`, `component` and `prefetch` are spelled\n * exactly as MRR spells them, so a component moved across keeps compiling.\n * `params` and `query` are ours and have no MRR equivalent: they pair with the\n * typed `href()` helper, which is what makes a route NAME — rather than a URL —\n * the thing a call site names.\n *\n * ── The semantic divergence this file bridges ────────────────────────────────\n * MRR's `to` is a PATH. Ours was a route NAME, and only a name — which meant a\n * component moved across from MRR compiled and then threw at render, because\n * `\"/products\"` is not the name of anything. The two packages disagreed about\n * what the most-used prop in either of them MEANS.\n *\n * Since 2026-08-24 (owner ruling) `to`/`href` accept BOTH, discriminated by\n * SHAPE — see {@link isLiteralUrl}. That is what makes MRR code portable, and\n * it costs nothing at a Warlock call site, because the two grammars cannot\n * collide: a route name never begins with `/` and never carries a `scheme:`.\n * The ruling RESTS on that, so this file asserts it rather than trusting it\n * ({@link RouteNameShapeCollisionError}).\n */\n\ntype AnchorProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"href\">;\n\n/**\n * Where the link goes. Every field is documented here once; which COMBINATIONS\n * are legal is decided by {@link LinkDestination}.\n */\ntype LinkDestinationProps = {\n /**\n * A route NAME, or a literal URL — told apart by SHAPE.\n *\n * `\"products.details\"` is a NAME and is resolved through the route table. A\n * page that moves changes its URL and keeps its name, so every call site\n * survives the move; a dead name throws at render naming the routes that do\n * exist, rather than rendering an anchor that 404s. This is the form to\n * prefer, and the only one `params` and `query` apply to.\n *\n * `\"/pricing\"`, `\"https://stripe.com\"`, `\"mailto:sales@example.com\"` and any\n * other `scheme:` are LITERAL — passed through to the element untouched, with\n * no route lookup at all. An app links out, and a route name is not a thing\n * you can have for a page that is not yours.\n */\n to?: string;\n /**\n * An alias of {@link to}, for parity with `@mongez/react-router`. Identical\n * in every respect, including which shapes it accepts.\n */\n href?: string;\n /** Renders a `mailto:` link. Not an in-app navigation. */\n email?: string;\n /** Renders a `tel:` link. Not an in-app navigation. */\n tel?: string;\n /**\n * Values for the route's `:param` segments, e.g. `{ id }` for\n * `\"/products/:id\"`. Only meaningful with a route NAME.\n */\n params?: Record<string, unknown>;\n /**\n * Query string values; an `undefined` value is omitted. Only meaningful with\n * a route NAME.\n */\n query?: Record<string, unknown>;\n};\n\n/**\n * EXACTLY ONE destination, enforced by the type.\n *\n * The alternative — a documented precedence such as \"`to` wins over `href`\" —\n * is silent by construction: the losing prop goes on compiling and goes on\n * reading like it works at the call site, and the anchor points at the wrong\n * page. Refusing the pair costs a call site one edit and can never be\n * misread. The runtime refuses it as well, because a JavaScript caller and a\n * cast both get past this.\n */\ntype LinkDestination =\n | { to: string; href?: never; email?: never; tel?: never }\n | { href: string; to?: never; email?: never; tel?: never }\n | { email: string; to?: never; href?: never; tel?: never }\n | { tel: string; to?: never; href?: never; email?: never };\n\nexport type LinkProps = AnchorProps &\n LinkDestinationProps &\n LinkDestination & {\n /**\n * Open in a new browsing context: `target=\"_blank\"` plus the `rel` that\n * stops the opened page from reaching back through `window.opener`.\n *\n * A caller's own `target`/`rel` win — this only fills in what was not said.\n */\n newTab?: boolean;\n /**\n * Render as something other than `<a>` — a tag name or a component.\n *\n * It receives the resolved `href`, the click handler and every remaining\n * prop, so a design-system anchor keeps client-side navigation as long as\n * it spreads what it is given onto the element it renders.\n *\n * `ComponentType<any>` is MRR's signature, kept verbatim: the component is\n * the caller's and its props are unknowable from here.\n */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n component?: ComponentType<any> | string;\n /**\n * Fetch this page's data when the pointer or the keyboard reaches the link,\n * so the click that follows swaps without a round trip.\n *\n * A GUESS, and treated as one everywhere: it is never awaited, a failure is\n * silent, and the click behaves exactly as it would without it. Opt-in per\n * link rather than on by default, because every prefetch is a request the\n * user did not ask for and someone pays for the bandwidth.\n *\n * IGNORED for anything that is not an in-app navigation — an external URL,\n * `mailto:`, `tel:`, `newTab`, any explicit `target`. Prefetching those\n * would mean issuing a cross-origin request to a third party on hover,\n * which is not a thing a link component may decide to do.\n */\n prefetch?: boolean;\n };\n\nconst DESTINATION_PROPS = [\"to\", \"href\", \"email\", \"tel\"] as const;\n\nexport class AmbiguousLinkDestinationError extends Error {\n public constructor(public readonly providedProps: readonly string[]) {\n super(\n `Warlock <Link> was given ${providedProps\n .map(name => JSON.stringify(name))\n .join(\" and \")}, but a link goes to exactly one place. There is no ` +\n \"precedence between them on purpose: one of the two would silently win, and the \" +\n \"call site would go on naming a destination that never renders. Delete the one \" +\n \"you did not mean.\",\n );\n this.name = \"AmbiguousLinkDestinationError\";\n }\n}\n\nexport class MissingLinkDestinationError extends Error {\n public constructor() {\n super(\n `Warlock <Link> was given no destination. Pass exactly one of ${DESTINATION_PROPS.map(\n name => JSON.stringify(name),\n ).join(\", \")}. It is not defaulted to the current page: an anchor with an empty ` +\n \"`href` renders as a working link and reloads the page when clicked, which is a \" +\n \"harder fault to see than this message.\",\n );\n this.name = \"MissingLinkDestinationError\";\n }\n}\n\nexport class RouteArgumentsOnLiteralUrlError extends Error {\n public constructor(\n public readonly url: string,\n public readonly providedProps: readonly string[],\n ) {\n super(\n `Warlock <Link> was given ${providedProps\n .map(name => JSON.stringify(name))\n .join(\" and \")} alongside the literal URL \"${url}\". Those apply to a route NAME, ` +\n \"which is resolved through the route table; a literal URL is passed through exactly \" +\n \"as written, so they would have been dropped and the link would have pointed at an \" +\n \"unfiltered page that still looked right at the call site. Put the values in the URL, \" +\n \"or name the route.\",\n );\n this.name = \"RouteArgumentsOnLiteralUrlError\";\n }\n}\n\n/**\n * The ruling's one assumption, broken. See the module doc comment: telling a\n * literal URL from a route NAME by shape is only safe while no route is NAMED\n * like a URL, and nothing in the route pipeline validates a hand-declared\n * `route.name`. So the collision is checked at the one place it could do harm,\n * where it is a loud refusal instead of an anchor that silently points\n * somewhere else.\n */\nexport class RouteNameShapeCollisionError extends Error {\n public constructor(public readonly routeName: string) {\n super(\n `Warlock route table: a route is NAMED ${JSON.stringify(routeName)}, which is shaped ` +\n \"like a URL. <Link> tells a literal URL from a route name by shape — a destination \" +\n \"starting with `/` or carrying a `scheme:` is passed through untouched — so this name \" +\n \"can never be resolved, and every link to it would silently point at that path \" +\n \"instead. Rename the route (`route = { path, name }`) to a dotted name such as \" +\n `${JSON.stringify(routeName.replace(/^\\/+/, \"\").replace(/\\//g, \".\") || \"index\")}.`,\n );\n this.name = \"RouteNameShapeCollisionError\";\n }\n}\n\ntype Destination = {\n /** What lands on the element's `href`. */\n url: string;\n /**\n * Whether this URL is a page in THIS app — the only kind the client\n * navigation runtime may be asked about, and the only kind that may be\n * prefetched. `mailto:`, `tel:` and an external URL hand off to another\n * application or another origin entirely, so intercepting any of them would\n * break it, and speculatively fetching one would be a cross-origin request\n * the developer never asked for.\n */\n isInApp: boolean;\n};\n\n/**\n * Any RFC 3986 scheme — `https:`, `mailto:`, `tel:`, `whatsapp:`, an app's own\n * custom one. Matched generically rather than as a list of known schemes: a\n * list would silently resolve `bitcoin:...` through the route table, which is\n * the exact failure this ruling exists to remove, and it would have to grow\n * forever.\n */\nconst SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:/i;\n\n/**\n * Whether this destination is a URL to be used as written, rather than a route\n * name to resolve.\n *\n * The whole discriminator, and deliberately the whole of it: two cheap shape\n * tests, no parsing, no matching. Anything more would be a SECOND route matcher\n * living beside the server's, which this codebase refuses everywhere it comes\n * up — a matcher that disagreed with the real one would produce links to pages\n * that do not exist.\n */\nfunction isLiteralUrl(destination: string): boolean {\n return destination.startsWith(\"/\") || SCHEME_PATTERN.test(destination);\n}\n\n/**\n * Whether a literal URL addresses THIS app.\n *\n * A path is ours. A `scheme:` is not — including `https:` to our own origin,\n * which would need `window.location` to recognise and would make the answer\n * depend on where the code is running. And `//host/path` is PROTOCOL-RELATIVE:\n * it starts with a slash and is nonetheless another origin, which is precisely\n * the case a \"starts with `/`\" test alone would hand to the navigator, where it\n * becomes a `pushState` to a foreign origin — a SecurityError — or a\n * speculative fetch of a third-party host.\n */\nfunction addressesThisApp(url: string): boolean {\n return url.startsWith(\"/\") && !url.startsWith(\"//\");\n}\n\n/**\n * Refuses the one table that would make {@link isLiteralUrl} wrong.\n *\n * Reached only for a destination already judged literal, so the cost is a scan\n * of the published names for links that were never going to hit the table\n * anyway — and zero for the route-name form, which is the common one. The right\n * permanent home for this is `publishRouteTable`, at boot, once (see the report\n * on this card).\n */\nfunction assertNotARouteName(url: string): void {\n if (knownRouteNames().includes(url)) throw new RouteNameShapeCollisionError(url);\n}\n\nconst ROUTE_ARGUMENT_PROPS = [\"params\", \"query\"] as const;\n\nfunction resolveDestination(props: LinkDestinationProps): Destination {\n const provided = DESTINATION_PROPS.filter(name => props[name] !== undefined);\n\n if (provided.length > 1) throw new AmbiguousLinkDestinationError(provided);\n\n if (provided.length === 0) throw new MissingLinkDestinationError();\n\n if (props.email !== undefined) return { url: `mailto:${props.email}`, isInApp: false };\n\n if (props.tel !== undefined) return { url: `tel:${props.tel}`, isInApp: false };\n\n const destination = (props.to ?? props.href) as string;\n\n /*\n LITERAL: `/pricing`, `https://stripe.com`, `mailto:…`, `whatsapp://…`. It\n goes to the element exactly as written and the route table is never\n consulted — there is nothing to look up, and looking anyway is what used to\n throw `UnknownRouteNameError` on every link out of the application.\n */\n if (isLiteralUrl(destination)) {\n const routeArguments = ROUTE_ARGUMENT_PROPS.filter(name => props[name] !== undefined);\n\n if (routeArguments.length > 0) {\n throw new RouteArgumentsOnLiteralUrlError(destination, routeArguments);\n }\n\n assertNotARouteName(destination);\n\n return { url: destination, isInApp: addressesThisApp(destination) };\n }\n\n /*\n A NAME, resolved against the route table published at boot from the SAME\n discovery result the server registered its routes from. The previous version\n of this file restated six URLs in a literal map, so linking to any seventh\n page in the application threw — the map was the limit on what could be\n linked, and nothing said so at the call site.\n */\n return { url: href(destination, props.params, props.query), isInApp: true };\n}\n\n/**\n * Whether this click should be left entirely to the browser.\n *\n * Every case here is a click that MEANS something other than \"go there in this\n * tab\", and intercepting any of them would take away behaviour the user\n * explicitly asked for:\n *\n * - a modifier or middle button: open in a new tab/window, or download\n * - `download`: save the resource, do not render it\n * - already prevented: something upstream in the tree handled this click\n *\n * Left button with no modifiers is the only click that means plain navigation.\n * The `target` case is decided before this, from the RESOLVED target, because\n * `newTab` sets it after the caller's props are read.\n */\nfunction isPlainLeftClick(event: MouseEvent<HTMLAnchorElement>): boolean {\n return (\n event.button === 0 &&\n !event.metaKey &&\n !event.ctrlKey &&\n !event.shiftKey &&\n !event.altKey &&\n !event.defaultPrevented\n );\n}\n\n/**\n * A target other than `_self` names ANOTHER browsing context — `_blank`, but\n * also `_parent`, `_top` and any named frame. Client navigation rewrites the\n * history of THIS one, so none of them are ours to intercept.\n */\nfunction opensAnotherContext(target: string | undefined): boolean {\n return target !== undefined && target !== \"_self\";\n}\n\nexport function Link({\n to,\n href: hrefAlias,\n email,\n tel,\n params,\n query,\n newTab,\n prefetch,\n component: Component = \"a\",\n children,\n onClick,\n ...elementProps\n}: LinkProps): ReactElement {\n const { url, isInApp } = resolveDestination({\n to,\n href: hrefAlias,\n email,\n tel,\n params,\n query,\n });\n\n const target = elementProps.target ?? (newTab === true ? \"_blank\" : undefined);\n\n // Only a DEFAULT: a caller that wrote its own `rel` (`\"me noopener\"`,\n // `\"external\"`) meant it, and overwriting it would delete a value the page\n // depends on to say something this component knows nothing about.\n const rel =\n elementProps.rel ?? (target === \"_blank\" ? \"noopener noreferrer\" : undefined);\n\n const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {\n // The caller's handler runs FIRST and unconditionally — it may be doing\n // analytics, closing a menu, or calling `preventDefault()` to veto the\n // navigation outright. Deciding before it ran would let this component\n // navigate away from a click the application had already cancelled.\n onClick?.(event);\n\n // `mailto:`, `tel:` and anything aimed at another browsing context leave\n // this page standing. The runtime is not consulted at all — asking it would\n // spend a page-data fetch on a click that was never going to navigate here.\n if (!isInApp || opensAnotherContext(target)) return;\n\n if (!isPlainLeftClick(event)) return;\n\n /*\n Asked for per click, never captured at render: the runtime registers\n itself when the hydration bundle mounts, which is AFTER the first render\n of every anchor on the page. A value read at render time would be\n `undefined` forever for exactly the links present at hydration — that is,\n all of them.\n\n Absent (server render, or before hydration) the anchor is left alone and\n does what an anchor does. That is the whole progressive-enhancement story:\n links work before this code runs, and work better after.\n */\n if (currentNavigator()?.(url) !== true) return;\n\n event.preventDefault();\n };\n\n /*\n The SAME gate the click uses, asked before any speculative request exists:\n only a destination this app would have navigated to itself may be fetched\n ahead of time. `mailto:`, `tel:`, an external URL and anything aimed at\n another browsing context are all clicks that leave this page, and none of\n them has page data to fetch.\n */\n const prefetchesOnInteraction =\n prefetch === true && isInApp && !opensAnotherContext(target);\n\n /*\n Attached ONLY when prefetching — a link without the prop keeps whatever\n handlers the caller passed, on the element, unwrapped.\n\n Hover AND focus, because a keyboard user never generates the first one and\n would otherwise be the only visitor who never gets the optimisation.\n\n Fire-and-forget by construction: `prefetchPageData` never rejects and is\n never awaited, so nothing here can delay the event or surface a failure. It\n is also safe to reach on the server — it no-ops without a browser — which is\n why this file can import it directly rather than through a `connect*` seam\n like the navigator's. The navigator needs a seam because the runtime behind\n it drags React state and the page registry into the server bundle; the\n prefetch cache is a `Map` and a `fetch` call, inert until an event fires.\n */\n const prefetchHandlers = prefetchesOnInteraction\n ? {\n onMouseEnter: (event: MouseEvent<HTMLAnchorElement>): void => {\n elementProps.onMouseEnter?.(event);\n void prefetchPageData(url);\n },\n onFocus: (event: FocusEvent<HTMLAnchorElement>): void => {\n elementProps.onFocus?.(event);\n void prefetchPageData(url);\n },\n }\n : undefined;\n\n return createElement(\n Component,\n { ...elementProps, ...prefetchHandlers, target, rel, href: url, onClick: handleClick },\n children,\n );\n}\n"],"mappings":"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"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"filesystem-route.mjs","names":[],"sources":["../../../../../../../web/src/routing/filesystem-route.ts"],"sourcesContent":["export type FilesystemRouteInput = {\n /** POSIX path relative to `src/web`, ending in `.page.tsx`. */\n pageFile: string;\n /** Layout prefixes keyed by their POSIX directory relative to `src/web`; root uses `\"\"`. */\n layoutPrefixes?: Readonly<Record<string, string>>;\n};\n\nfunction isGroup(segment: string): boolean {\n return /^\\([^/]+\\)$/.test(segment);\n}\n\nfunction routeSegment(segment: string): string {\n const dynamic = /^\\[([A-Za-z_][A-Za-z0-9_]*)\\]$/.exec(segment);\n\n return dynamic ? `:${dynamic[1]}` : segment;\n}\n\nfunction prefixSegments(prefix: string): string[] {\n return prefix.split(\"/\").filter(Boolean);\n}\n\nfunction pageParts(pageFile: string): { directories: string[]; basename: string } {\n if (pageFile.includes(\"\\\\\")) {\n throw new Error(`filesystem-route: pageFile must use POSIX separators: \"${pageFile}\"`);\n }\n\n if (!pageFile.endsWith(\".page.tsx\")) {\n throw new Error(`filesystem-route: pageFile must end in .page.tsx: \"${pageFile}\"`);\n }\n\n const parts = pageFile.split(\"/\");\n const filename = parts.pop() as string;\n\n return {\n directories: parts,\n basename: filename.slice(0, -\".page.tsx\".length),\n };\n}\n\n/** Derive the effective URL for a page with no explicit `route` export. */\nexport function deriveFilesystemRoutePath(input: FilesystemRouteInput): string {\n const { directories, basename } = pageParts(input.pageFile);\n const prefixes = input.layoutPrefixes ?? {};\n const segments = [...prefixSegments(prefixes[\"\"] ?? \"\")];\n\n for (let index = 0; index < directories.length; index++) {\n const directory = directories[index];\n const directoryPath = directories.slice(0, index + 1).join(\"/\");\n const prefix = prefixes[directoryPath];\n\n if (prefix !== undefined) {\n segments.push(...prefixSegments(prefix));\n } else if (!isGroup(directory)) {\n segments.push(routeSegment(directory));\n }\n }\n\n if (basename !== \"index\") {\n segments.push(routeSegment(basename));\n }\n\n return segments.length === 0 ? \"/\" : `/${segments.join(\"/\")}`;\n}\n\n/** Derive the stable dotted route name from a page's filesystem identity. */\nexport function deriveFilesystemRouteName(pageFile: string): string {\n const { directories, basename } = pageParts(pageFile);\n const segments = directories.filter((segment) => !isGroup(segment)).map(routeSegment);\n\n if (basename !== \"index\") {\n segments.push(routeSegment(basename));\n }\n\n return segments.map((segment) => segment.replace(/^:/, \"\")).join(\".\") || \"index\";\n}\n"],"mappings":";AAOA,SAAS,QAAQ,SAA0B;CACzC,OAAO,cAAc,KAAK,OAAO;AACnC;AAEA,SAAS,aAAa,SAAyB;CAC7C,MAAM,UAAU,iCAAiC,KAAK,OAAO;CAE7D,OAAO,UAAU,IAAI,QAAQ,OAAO;AACtC;AAEA,SAAS,eAAe,QAA0B;CAChD,OAAO,OAAO,MAAM,GAAG,
|
|
1
|
+
{"version":3,"file":"filesystem-route.mjs","names":[],"sources":["../../../../../../../web/src/routing/filesystem-route.ts"],"sourcesContent":["export type FilesystemRouteInput = {\n /** POSIX path relative to `src/web`, ending in `.page.tsx`. */\n pageFile: string;\n /** Layout prefixes keyed by their POSIX directory relative to `src/web`; root uses `\"\"`. */\n layoutPrefixes?: Readonly<Record<string, string>>;\n};\n\nfunction isGroup(segment: string): boolean {\n return /^\\([^/]+\\)$/.test(segment);\n}\n\nfunction routeSegment(segment: string): string {\n const dynamic = /^\\[([A-Za-z_][A-Za-z0-9_]*)\\]$/.exec(segment);\n\n return dynamic ? `:${dynamic[1]}` : segment;\n}\n\nfunction prefixSegments(prefix: string): string[] {\n return prefix.split(\"/\").filter(Boolean);\n}\n\nfunction pageParts(pageFile: string): { directories: string[]; basename: string } {\n if (pageFile.includes(\"\\\\\")) {\n throw new Error(`filesystem-route: pageFile must use POSIX separators: \"${pageFile}\"`);\n }\n\n if (!pageFile.endsWith(\".page.tsx\")) {\n throw new Error(`filesystem-route: pageFile must end in .page.tsx: \"${pageFile}\"`);\n }\n\n const parts = pageFile.split(\"/\");\n const filename = parts.pop() as string;\n\n return {\n directories: parts,\n basename: filename.slice(0, -\".page.tsx\".length),\n };\n}\n\n/** Derive the effective URL for a page with no explicit `route` export. */\nexport function deriveFilesystemRoutePath(input: FilesystemRouteInput): string {\n const { directories, basename } = pageParts(input.pageFile);\n const prefixes = input.layoutPrefixes ?? {};\n const segments = [...prefixSegments(prefixes[\"\"] ?? \"\")];\n\n for (let index = 0; index < directories.length; index++) {\n const directory = directories[index];\n const directoryPath = directories.slice(0, index + 1).join(\"/\");\n const prefix = prefixes[directoryPath];\n\n if (prefix !== undefined) {\n segments.push(...prefixSegments(prefix));\n } else if (!isGroup(directory)) {\n segments.push(routeSegment(directory));\n }\n }\n\n if (basename !== \"index\") {\n segments.push(routeSegment(basename));\n }\n\n return segments.length === 0 ? \"/\" : `/${segments.join(\"/\")}`;\n}\n\n/** Derive the stable dotted route name from a page's filesystem identity. */\nexport function deriveFilesystemRouteName(pageFile: string): string {\n const { directories, basename } = pageParts(pageFile);\n const segments = directories.filter((segment) => !isGroup(segment)).map(routeSegment);\n\n if (basename !== \"index\") {\n segments.push(routeSegment(basename));\n }\n\n return segments.map((segment) => segment.replace(/^:/, \"\")).join(\".\") || \"index\";\n}\n"],"mappings":";AAOA,SAAS,QAAQ,SAA0B;CACzC,OAAO,cAAc,KAAK,OAAO;AACnC;AAEA,SAAS,aAAa,SAAyB;CAC7C,MAAM,UAAU,iCAAiC,KAAK,OAAO;CAE7D,OAAO,UAAU,IAAI,QAAQ,OAAO;AACtC;AAEA,SAAS,eAAe,QAA0B;CAChD,OAAO,OAAO,MAAM,GAAG,CAAC,CAAC,OAAO,OAAO;AACzC;AAEA,SAAS,UAAU,UAA+D;CAChF,IAAI,SAAS,SAAS,IAAI,GACxB,MAAM,IAAI,MAAM,0DAA0D,SAAS,EAAE;CAGvF,IAAI,CAAC,SAAS,SAAS,WAAW,GAChC,MAAM,IAAI,MAAM,sDAAsD,SAAS,EAAE;CAGnF,MAAM,QAAQ,SAAS,MAAM,GAAG;CAGhC,OAAO;EACL,aAAa;EACb,UAJe,MAAM,IAIJ,CAAC,CAAC,MAAM,GAAG,EAAmB;CACjD;AACF;;AAGA,SAAgB,0BAA0B,OAAqC;CAC7E,MAAM,EAAE,aAAa,aAAa,UAAU,MAAM,QAAQ;CAC1D,MAAM,WAAW,MAAM,kBAAkB,CAAC;CAC1C,MAAM,WAAW,CAAC,GAAG,eAAe,SAAS,OAAO,EAAE,CAAC;CAEvD,KAAK,IAAI,QAAQ,GAAG,QAAQ,YAAY,QAAQ,SAAS;EACvD,MAAM,YAAY,YAAY;EAE9B,MAAM,SAAS,SADO,YAAY,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC,KAAK,GACvB;EAEpC,IAAI,WAAW,QACb,SAAS,KAAK,GAAG,eAAe,MAAM,CAAC;OAClC,IAAI,CAAC,QAAQ,SAAS,GAC3B,SAAS,KAAK,aAAa,SAAS,CAAC;CAEzC;CAEA,IAAI,aAAa,SACf,SAAS,KAAK,aAAa,QAAQ,CAAC;CAGtC,OAAO,SAAS,WAAW,IAAI,MAAM,IAAI,SAAS,KAAK,GAAG;AAC5D;;AAGA,SAAgB,0BAA0B,UAA0B;CAClE,MAAM,EAAE,aAAa,aAAa,UAAU,QAAQ;CACpD,MAAM,WAAW,YAAY,QAAQ,YAAY,CAAC,QAAQ,OAAO,CAAC,CAAC,CAAC,IAAI,YAAY;CAEpF,IAAI,aAAa,SACf,SAAS,KAAK,aAAa,QAAQ,CAAC;CAGtC,OAAO,SAAS,KAAK,YAAY,QAAQ,QAAQ,MAAM,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,KAAK;AAC3E"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"layout-policy.mjs","names":[],"sources":["../../../../../../../web/src/routing/layout-policy.ts"],"sourcesContent":["/**\n * Layout policy — the single, pure rule for turning a page's ENUMERATED\n * layout chain into a selection decision.\n *\n * Enumeration and selection are deliberately separate concerns. Discovery\n * ({@link \"../build/discover-pages.ts\"}'s `layoutChainFor`) walks a page's\n * directory ancestry and reports every `layout.tsx` it finds, outermost\n * first, honestly and unfiltered — it does not decide whether that chain is\n * usable. This module is the one place that decision is made: given a chain,\n * how many layouts does composition get to use, and which one(s)?\n *\n * THE RULE COUNTS RENDERING LAYOUTS, NOT FILES. A layout that contributes no\n * element to the document — one with no default export, carrying only\n * `prefix`, `middleware` or other named exports — is not a second wrapper and\n * never was. Counting files instead of wrappers made an authorization boundary\n * indistinguishable from a nested layout, and the resulting refusal told app\n * authors to delete the boundary to make the build pass. Nested RENDERING\n * layouts remain unsupported; everything else composes freely.\n *\n * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing\n * here may import `node:fs`, `node:path`, `vite`, or `fastify`. This module\n * receives a canonical chain and trusts nothing about it beyond the input\n * contract asserted below — it asserts rather than trusts, but it never\n * repairs. It therefore never DECIDES what renders either: that answer needs\n * the filesystem, so the caller — which has it — classifies each entry and\n * passes the classification in. Discovery owns the fact; this module owns the\n * rule. The `layout` identifiers are opaque (paths, in practice) and this\n * module never inspects their shape; it only counts and selects.\n *\n * REJECTION IS DATA, NOT A THROW: a chain with two or more rendering layouts\n * does not make {@link selectPageLayout} raise — it returns the rejected\n * rendering layouts, in order. What a rejection MEANS to the user is\n * nonetheless fixed here: {@link NestedLayoutsNotSupportedError} is the single\n * error contract for it — one class, one message shape, built from the\n * rejection data plus caller-supplied page identity. Callers decide only WHEN\n * to raise it and supply that context; none of them wraps the rejection in a\n * category or wording of its own. A shared policy whose failure semantics fork\n * per caller is shared in the happy path and forked in the sad one — and the\n * sad path is the one users meet.\n */\n\n/**\n * One layout in a chain, with the caller's answer to the only question this\n * module needs about it: does it render?\n *\n * `renders` is true when the layout's module has a default export — the thing\n * that puts an element in the document. Everything else it exports (`prefix`,\n * `middleware`, helpers) is invisible to this rule.\n */\nexport type LayoutChainEntry = {\n /** Opaque identifier for the layout — an app-root-relative POSIX path, in practice. */\n layout: string;\n /** Whether this layout contributes an element to the document. */\n renders: boolean;\n};\n\n/**\n * A chain as {@link selectPageLayout} accepts it: classified entries, or bare\n * identifiers for a caller that has not classified its chain yet.\n *\n * TRANSITIONAL: a bare string is read as a RENDERING layout, which is the\n * conservative reading (it can only make the rule stricter, never looser) and\n * reproduces this module's pre-classification behaviour exactly. It exists so\n * the boot-time manifest installer — which holds loaded modules rather than\n * source files — keeps working unchanged until it classifies too; remove the\n * string arm once every caller passes {@link LayoutChainEntry}s.\n */\nexport type LayoutChainInput = readonly (string | LayoutChainEntry)[];\n\n/**\n * The policy's decision for one page's layout chain:\n *\n * - `\"none\"` — no layout on the chain renders; the page composes against no\n * layout. A chain of three middleware-only layouts lands here exactly as an\n * empty chain does, because neither has a wrapper in it.\n * - `\"selected\"` — exactly one layout renders; `layout` is that element, which,\n * being the only rendering one, is simultaneously the outermost and the\n * nearest rendering layout — there is no distinction to draw between the two\n * when there is only one.\n * - `\"rejected\"` — more than one layout renders; `layouts` carries the\n * RENDERING layouts only, outermost-first, in chain order, so a consumer can\n * name every layout actually at fault without naming the guards between them.\n */\nexport type LayoutPolicyResult =\n | { type: \"none\" }\n | { type: \"selected\"; layout: string }\n | { type: \"rejected\"; layouts: readonly string[] };\n\n/**\n * Raised when a `chain` passed to {@link selectPageLayout} contains an empty\n * layout identifier. An empty string is not a layout identifier a caller could\n * have meant; this module refuses it rather than silently treating it as\n * absent or as a real selection.\n */\nexport class EmptyLayoutChainEntryError extends Error {\n public constructor(public readonly chain: readonly string[]) {\n super(\n `layout-policy: chain [${chain.map((entry) => `\"${entry}\"`).join(\", \")}] contains an empty ` +\n \"string. Every element of a layout chain passed to selectPageLayout must be a non-empty \" +\n \"layout identifier — omit the entry entirely rather than passing an empty string for it.\",\n );\n this.name = \"EmptyLayoutChainEntryError\";\n }\n}\n\n/**\n * The single error contract for a `rejected` selection — the one class and\n * one message every caller of {@link selectPageLayout} raises when it refuses\n * a page whose path holds more than one RENDERING layout. `pageFile` and\n * `layoutFiles` are the caller's context (its audience-appropriate identifiers\n * for the page and the rejected rendering layouts — app-root-relative POSIX\n * paths, in practice); the category and wording are this module's.\n *\n * The wording names the rendering layouts and ONLY the rendering layouts, and\n * it does not offer removal as a remedy. Its predecessor said \"remove or\n * consolidate the extra layout\", which, on a chain whose second element was a\n * `middleware`-only authorization boundary, instructed the reader to delete\n * their security guard to make the build pass.\n */\nexport class NestedLayoutsNotSupportedError extends Error {\n public constructor(\n public readonly pageFile: string,\n public readonly layoutFiles: readonly string[],\n ) {\n super(\n `\"${pageFile}\" has more than one layout on its path that renders: ` +\n `${layoutFiles.map((file) => `\"${file}\"`).join(\", \")}. Pages currently support at most one ` +\n \"RENDERING layout — a layout with a default export — and nesting more than one is not yet \" +\n \"supported. Layouts that render nothing, such as a `prefix`- or `middleware`-only layout, \" +\n \"do not count against this and may nest freely. To fix: consolidate the rendering layouts \" +\n \"named above into one — and do not remove a middleware-only layout to satisfy this, since \" +\n \"none of them is what this refuses.\",\n );\n this.name = \"NestedLayoutsNotSupportedError\";\n }\n}\n\n/** The classified form of an entry, whichever way the caller spelled it. */\nfunction toEntry(entry: string | LayoutChainEntry): LayoutChainEntry {\n return typeof entry === \"string\" ? { layout: entry, renders: true } : entry;\n}\n\n/**\n * Selects which layout, if any, a page RENDERS inside, given its layout chain\n * as enumerated outermost-first.\n *\n * Only entries with `renders: true` are counted: no rendering layout yields\n * `{ type: \"none\" }`; exactly one yields `{ type: \"selected\"; layout }`; more\n * than one yields `{ type: \"rejected\"; layouts }` carrying just those rendering\n * layouts — this function throws nothing for that case; see the module doc for\n * why rejection is data, not a throw.\n *\n * Throws {@link EmptyLayoutChainEntryError} when any entry's identifier is an\n * empty string — the one input-contract violation this module refuses rather\n * than passes through as a selection.\n */\nexport function selectPageLayout(chain: LayoutChainInput): LayoutPolicyResult {\n const entries = chain.map(toEntry);\n\n if (entries.some((entry) => entry.layout === \"\")) {\n throw new EmptyLayoutChainEntryError(entries.map((entry) => entry.layout));\n }\n\n const rendering = entries.filter((entry) => entry.renders).map((entry) => entry.layout);\n\n if (rendering.length === 0) {\n return { type: \"none\" };\n }\n\n if (rendering.length === 1) {\n return { type: \"selected\", layout: rendering[0] };\n }\n\n return { type: \"rejected\", layouts: rendering };\n}\n"],"mappings":";;;;;;;AA8FA,IAAa,6BAAb,cAAgD,MAAM;CACjB;CAAnC,AAAO,YAAY,AAAgB,OAA0B;EAC3D,MACE,yBAAyB,MAAM,KAAK,UAAU,IAAI,MAAM,EAAE,
|
|
1
|
+
{"version":3,"file":"layout-policy.mjs","names":[],"sources":["../../../../../../../web/src/routing/layout-policy.ts"],"sourcesContent":["/**\n * Layout policy — the single, pure rule for turning a page's ENUMERATED\n * layout chain into a selection decision.\n *\n * Enumeration and selection are deliberately separate concerns. Discovery\n * ({@link \"../build/discover-pages.ts\"}'s `layoutChainFor`) walks a page's\n * directory ancestry and reports every `layout.tsx` it finds, outermost\n * first, honestly and unfiltered — it does not decide whether that chain is\n * usable. This module is the one place that decision is made: given a chain,\n * how many layouts does composition get to use, and which one(s)?\n *\n * THE RULE COUNTS RENDERING LAYOUTS, NOT FILES. A layout that contributes no\n * element to the document — one with no default export, carrying only\n * `prefix`, `middleware` or other named exports — is not a second wrapper and\n * never was. Counting files instead of wrappers made an authorization boundary\n * indistinguishable from a nested layout, and the resulting refusal told app\n * authors to delete the boundary to make the build pass. Nested RENDERING\n * layouts remain unsupported; everything else composes freely.\n *\n * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing\n * here may import `node:fs`, `node:path`, `vite`, or `fastify`. This module\n * receives a canonical chain and trusts nothing about it beyond the input\n * contract asserted below — it asserts rather than trusts, but it never\n * repairs. It therefore never DECIDES what renders either: that answer needs\n * the filesystem, so the caller — which has it — classifies each entry and\n * passes the classification in. Discovery owns the fact; this module owns the\n * rule. The `layout` identifiers are opaque (paths, in practice) and this\n * module never inspects their shape; it only counts and selects.\n *\n * REJECTION IS DATA, NOT A THROW: a chain with two or more rendering layouts\n * does not make {@link selectPageLayout} raise — it returns the rejected\n * rendering layouts, in order. What a rejection MEANS to the user is\n * nonetheless fixed here: {@link NestedLayoutsNotSupportedError} is the single\n * error contract for it — one class, one message shape, built from the\n * rejection data plus caller-supplied page identity. Callers decide only WHEN\n * to raise it and supply that context; none of them wraps the rejection in a\n * category or wording of its own. A shared policy whose failure semantics fork\n * per caller is shared in the happy path and forked in the sad one — and the\n * sad path is the one users meet.\n */\n\n/**\n * One layout in a chain, with the caller's answer to the only question this\n * module needs about it: does it render?\n *\n * `renders` is true when the layout's module has a default export — the thing\n * that puts an element in the document. Everything else it exports (`prefix`,\n * `middleware`, helpers) is invisible to this rule.\n */\nexport type LayoutChainEntry = {\n /** Opaque identifier for the layout — an app-root-relative POSIX path, in practice. */\n layout: string;\n /** Whether this layout contributes an element to the document. */\n renders: boolean;\n};\n\n/**\n * A chain as {@link selectPageLayout} accepts it: classified entries, or bare\n * identifiers for a caller that has not classified its chain yet.\n *\n * TRANSITIONAL: a bare string is read as a RENDERING layout, which is the\n * conservative reading (it can only make the rule stricter, never looser) and\n * reproduces this module's pre-classification behaviour exactly. It exists so\n * the boot-time manifest installer — which holds loaded modules rather than\n * source files — keeps working unchanged until it classifies too; remove the\n * string arm once every caller passes {@link LayoutChainEntry}s.\n */\nexport type LayoutChainInput = readonly (string | LayoutChainEntry)[];\n\n/**\n * The policy's decision for one page's layout chain:\n *\n * - `\"none\"` — no layout on the chain renders; the page composes against no\n * layout. A chain of three middleware-only layouts lands here exactly as an\n * empty chain does, because neither has a wrapper in it.\n * - `\"selected\"` — exactly one layout renders; `layout` is that element, which,\n * being the only rendering one, is simultaneously the outermost and the\n * nearest rendering layout — there is no distinction to draw between the two\n * when there is only one.\n * - `\"rejected\"` — more than one layout renders; `layouts` carries the\n * RENDERING layouts only, outermost-first, in chain order, so a consumer can\n * name every layout actually at fault without naming the guards between them.\n */\nexport type LayoutPolicyResult =\n | { type: \"none\" }\n | { type: \"selected\"; layout: string }\n | { type: \"rejected\"; layouts: readonly string[] };\n\n/**\n * Raised when a `chain` passed to {@link selectPageLayout} contains an empty\n * layout identifier. An empty string is not a layout identifier a caller could\n * have meant; this module refuses it rather than silently treating it as\n * absent or as a real selection.\n */\nexport class EmptyLayoutChainEntryError extends Error {\n public constructor(public readonly chain: readonly string[]) {\n super(\n `layout-policy: chain [${chain.map((entry) => `\"${entry}\"`).join(\", \")}] contains an empty ` +\n \"string. Every element of a layout chain passed to selectPageLayout must be a non-empty \" +\n \"layout identifier — omit the entry entirely rather than passing an empty string for it.\",\n );\n this.name = \"EmptyLayoutChainEntryError\";\n }\n}\n\n/**\n * The single error contract for a `rejected` selection — the one class and\n * one message every caller of {@link selectPageLayout} raises when it refuses\n * a page whose path holds more than one RENDERING layout. `pageFile` and\n * `layoutFiles` are the caller's context (its audience-appropriate identifiers\n * for the page and the rejected rendering layouts — app-root-relative POSIX\n * paths, in practice); the category and wording are this module's.\n *\n * The wording names the rendering layouts and ONLY the rendering layouts, and\n * it does not offer removal as a remedy. Its predecessor said \"remove or\n * consolidate the extra layout\", which, on a chain whose second element was a\n * `middleware`-only authorization boundary, instructed the reader to delete\n * their security guard to make the build pass.\n */\nexport class NestedLayoutsNotSupportedError extends Error {\n public constructor(\n public readonly pageFile: string,\n public readonly layoutFiles: readonly string[],\n ) {\n super(\n `\"${pageFile}\" has more than one layout on its path that renders: ` +\n `${layoutFiles.map((file) => `\"${file}\"`).join(\", \")}. Pages currently support at most one ` +\n \"RENDERING layout — a layout with a default export — and nesting more than one is not yet \" +\n \"supported. Layouts that render nothing, such as a `prefix`- or `middleware`-only layout, \" +\n \"do not count against this and may nest freely. To fix: consolidate the rendering layouts \" +\n \"named above into one — and do not remove a middleware-only layout to satisfy this, since \" +\n \"none of them is what this refuses.\",\n );\n this.name = \"NestedLayoutsNotSupportedError\";\n }\n}\n\n/** The classified form of an entry, whichever way the caller spelled it. */\nfunction toEntry(entry: string | LayoutChainEntry): LayoutChainEntry {\n return typeof entry === \"string\" ? { layout: entry, renders: true } : entry;\n}\n\n/**\n * Selects which layout, if any, a page RENDERS inside, given its layout chain\n * as enumerated outermost-first.\n *\n * Only entries with `renders: true` are counted: no rendering layout yields\n * `{ type: \"none\" }`; exactly one yields `{ type: \"selected\"; layout }`; more\n * than one yields `{ type: \"rejected\"; layouts }` carrying just those rendering\n * layouts — this function throws nothing for that case; see the module doc for\n * why rejection is data, not a throw.\n *\n * Throws {@link EmptyLayoutChainEntryError} when any entry's identifier is an\n * empty string — the one input-contract violation this module refuses rather\n * than passes through as a selection.\n */\nexport function selectPageLayout(chain: LayoutChainInput): LayoutPolicyResult {\n const entries = chain.map(toEntry);\n\n if (entries.some((entry) => entry.layout === \"\")) {\n throw new EmptyLayoutChainEntryError(entries.map((entry) => entry.layout));\n }\n\n const rendering = entries.filter((entry) => entry.renders).map((entry) => entry.layout);\n\n if (rendering.length === 0) {\n return { type: \"none\" };\n }\n\n if (rendering.length === 1) {\n return { type: \"selected\", layout: rendering[0] };\n }\n\n return { type: \"rejected\", layouts: rendering };\n}\n"],"mappings":";;;;;;;AA8FA,IAAa,6BAAb,cAAgD,MAAM;CACjB;CAAnC,AAAO,YAAY,AAAgB,OAA0B;EAC3D,MACE,yBAAyB,MAAM,KAAK,UAAU,IAAI,MAAM,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,mMAGzE;EALiC;EAMjC,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;AAgBA,IAAa,iCAAb,cAAoD,MAAM;CAEtC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,aAChB;EACA,MACE,IAAI,SAAS,uDACR,YAAY,KAAK,SAAS,IAAI,KAAK,EAAE,CAAC,CAAC,KAAK,IAAI,EAAE,ibAMzD;EAXgB;EACA;EAWhB,KAAK,OAAO;CACd;AACF;;AAGA,SAAS,QAAQ,OAAoD;CACnE,OAAO,OAAO,UAAU,WAAW;EAAE,QAAQ;EAAO,SAAS;CAAK,IAAI;AACxE;;;;;;;;;;;;;;;AAgBA,SAAgB,iBAAiB,OAA6C;CAC5E,MAAM,UAAU,MAAM,IAAI,OAAO;CAEjC,IAAI,QAAQ,MAAM,UAAU,MAAM,WAAW,EAAE,GAC7C,MAAM,IAAI,2BAA2B,QAAQ,KAAK,UAAU,MAAM,MAAM,CAAC;CAG3E,MAAM,YAAY,QAAQ,QAAQ,UAAU,MAAM,OAAO,CAAC,CAAC,KAAK,UAAU,MAAM,MAAM;CAEtF,IAAI,UAAU,WAAW,GACvB,OAAO,EAAE,MAAM,OAAO;CAGxB,IAAI,UAAU,WAAW,GACvB,OAAO;EAAE,MAAM;EAAY,QAAQ,UAAU;CAAG;CAGlD,OAAO;EAAE,MAAM;EAAY,SAAS;CAAU;AAChD"}
|