@warlock.js/web 5.5.0 → 5.6.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +27 -5
  2. package/esm/build/contribution.d.mts +2 -2
  3. package/esm/build/contribution.mjs.map +1 -1
  4. package/esm/build/discover-pages.mjs +127 -205
  5. package/esm/build/discover-pages.mjs.map +1 -1
  6. package/esm/build/generate-pages-barrel.mjs +5 -3
  7. package/esm/build/generate-pages-barrel.mjs.map +1 -1
  8. package/esm/build/public-files.mjs +29 -1
  9. package/esm/build/public-files.mjs.map +1 -1
  10. package/esm/build/read-metadata-keys.mjs +225 -0
  11. package/esm/build/read-metadata-keys.mjs.map +1 -0
  12. package/esm/build/read-route-exports.mjs.map +1 -1
  13. package/esm/client/build-hydrated-tree.mjs +1 -1
  14. package/esm/client/build-hydrated-tree.mjs.map +1 -1
  15. package/esm/client/navigation/document-metadata.mjs +108 -0
  16. package/esm/client/navigation/document-metadata.mjs.map +1 -0
  17. package/esm/client/navigation/navigation-root.mjs +23 -103
  18. package/esm/client/navigation/navigation-root.mjs.map +1 -1
  19. package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -1
  20. package/esm/client/navigation/use-query-string.d.mts +35 -0
  21. package/esm/client/navigation/use-query-string.mjs +68 -0
  22. package/esm/client/navigation/use-query-string.mjs.map +1 -0
  23. package/esm/client/runtime/index.d.mts +1 -1
  24. package/esm/client/runtime/index.mjs +1 -1
  25. package/esm/client/runtime/manifest.mjs.map +1 -1
  26. package/esm/client/runtime/matcher.mjs.map +1 -1
  27. package/esm/client/runtime/types.d.mts +1 -1
  28. package/esm/components/link.mjs.map +1 -1
  29. package/esm/core/src/router/normalize-route-path.mjs +33 -0
  30. package/esm/core/src/router/normalize-route-path.mjs.map +1 -0
  31. package/esm/{hydration → entry}/index.mjs +1 -1
  32. package/esm/entry/index.mjs.map +1 -0
  33. package/esm/hydration-payload.mjs.map +1 -1
  34. package/esm/index.d.mts +3 -2
  35. package/esm/index.mjs +3 -2
  36. package/esm/loaders.d.mts +10 -3
  37. package/esm/localization.mjs.map +1 -1
  38. package/esm/metadata.d.mts +4 -4
  39. package/esm/metadata.mjs.map +1 -1
  40. package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs +33 -0
  41. package/esm/node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs.map +1 -0
  42. package/esm/{runtime/register-modules.d.mts → register-modules.d.mts} +1 -1
  43. package/esm/{runtime/register-modules.mjs → register-modules.mjs} +1 -1
  44. package/esm/register-modules.mjs.map +1 -0
  45. package/esm/route.d.mts +35 -2
  46. package/esm/routing/duplicate-route-path.mjs +22 -0
  47. package/esm/routing/duplicate-route-path.mjs.map +1 -0
  48. package/esm/routing/layout-level.mjs +50 -0
  49. package/esm/routing/layout-level.mjs.map +1 -0
  50. package/esm/routing/layout-policy.mjs.map +1 -1
  51. package/esm/routing/query-string.d.mts +23 -1
  52. package/esm/routing/query-string.mjs +23 -1
  53. package/esm/routing/query-string.mjs.map +1 -1
  54. package/esm/routing/route-identity.mjs +27 -2
  55. package/esm/routing/route-identity.mjs.map +1 -1
  56. package/esm/routing/route-table.mjs.map +1 -1
  57. package/esm/routing/router-events.d.mts +5 -4
  58. package/esm/routing/router-events.mjs.map +1 -1
  59. package/esm/server/create-page-route-handler.d.mts +2 -2
  60. package/esm/server/create-page-route-handler.mjs +6 -6
  61. package/esm/server/create-page-route-handler.mjs.map +1 -1
  62. package/esm/server/{dev-server.mjs → dev-error-transport.mjs} +2 -2
  63. package/esm/server/dev-error-transport.mjs.map +1 -0
  64. package/esm/server/execute-page-request.mjs +87 -23
  65. package/esm/server/execute-page-request.mjs.map +1 -1
  66. package/esm/server/execute-page-request.types.d.mts +10 -1
  67. package/esm/server/fold-layout-loaders.mjs +36 -0
  68. package/esm/server/fold-layout-loaders.mjs.map +1 -0
  69. package/esm/server/hydration-client-url.mjs +2 -2
  70. package/esm/server/hydration-client-url.mjs.map +1 -1
  71. package/esm/server/index.d.mts +2 -2
  72. package/esm/server/index.mjs +2 -2
  73. package/esm/server/install-page-routes-from-manifest.d.mts +1 -1
  74. package/esm/server/install-page-routes-from-manifest.mjs +43 -40
  75. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
  76. package/esm/server/install-page-routes.d.mts +22 -2
  77. package/esm/server/install-page-routes.mjs +108 -42
  78. package/esm/server/install-page-routes.mjs.map +1 -1
  79. package/esm/server/install-production-page-routes.mjs.map +1 -1
  80. package/esm/server/match-page-route.mjs.map +1 -1
  81. package/esm/server/page-file-change.mjs.map +1 -1
  82. package/esm/server/page-route-reload.mjs +4 -3
  83. package/esm/server/page-route-reload.mjs.map +1 -1
  84. package/esm/server/register-production-public-files.mjs +25 -1
  85. package/esm/server/register-production-public-files.mjs.map +1 -1
  86. package/esm/server/render-page.d.mts +11 -48
  87. package/esm/server/render-page.mjs +13 -55
  88. package/esm/server/render-page.mjs.map +1 -1
  89. package/esm/server/resolve-page-metadata.mjs.map +1 -1
  90. package/esm/server/resolve-route-validation-input.mjs +11 -0
  91. package/esm/server/resolve-route-validation-input.mjs.map +1 -0
  92. package/esm/server/route-validation-error.mjs +32 -0
  93. package/esm/server/route-validation-error.mjs.map +1 -0
  94. package/esm/server/settle-page-response.mjs +5 -3
  95. package/esm/server/settle-page-response.mjs.map +1 -1
  96. package/esm/server/unregistered-pages.mjs +2 -1
  97. package/esm/server/unregistered-pages.mjs.map +1 -1
  98. package/esm/server/web-connector-factory.mjs +1 -1
  99. package/esm/server/web-connector-factory.mjs.map +1 -1
  100. package/esm/server/web-connector.mjs +24 -321
  101. package/esm/server/web-connector.mjs.map +1 -1
  102. package/esm/shared/module-key.mjs +15 -0
  103. package/esm/shared/module-key.mjs.map +1 -0
  104. package/esm/shared/to-posix.mjs +13 -0
  105. package/esm/shared/to-posix.mjs.map +1 -0
  106. package/esm/shared.mjs +24 -0
  107. package/esm/shared.mjs.map +1 -1
  108. package/esm/vite/build-client.mjs.map +1 -1
  109. package/esm/vite/dev-server-config.mjs +240 -0
  110. package/esm/vite/dev-server-config.mjs.map +1 -0
  111. package/esm/vite/gate-a-resolve.d.mts +5 -4
  112. package/esm/vite/gate-a-resolve.mjs +31 -70
  113. package/esm/vite/gate-a-resolve.mjs.map +1 -1
  114. package/esm/vite/gate-b-secrets.mjs.map +1 -1
  115. package/esm/vite/gate-c-verify.d.mts +1 -1
  116. package/esm/vite/gate-c-verify.mjs +1 -1
  117. package/esm/vite/gate-c-verify.mjs.map +1 -1
  118. package/esm/vite/hydration-entries.mjs +5 -7
  119. package/esm/vite/hydration-entries.mjs.map +1 -1
  120. package/esm/vite/index.d.mts +1 -1
  121. package/esm/vite/index.mjs +4 -110
  122. package/esm/vite/index.mjs.map +1 -1
  123. package/esm/vite/page-registry-plugin.mjs +9 -26
  124. package/esm/vite/page-registry-plugin.mjs.map +1 -1
  125. package/esm/vite/projection.mjs.map +1 -1
  126. package/esm/vite/react-refresh-preamble.mjs +90 -0
  127. package/esm/vite/react-refresh-preamble.mjs.map +1 -0
  128. package/esm/vite/ssr-client-view.mjs +135 -0
  129. package/esm/vite/ssr-client-view.mjs.map +1 -0
  130. package/llms-full.txt +558 -482
  131. package/llms.txt +2 -2
  132. package/package.json +6 -6
  133. package/skills/create-a-page/SKILL.md +348 -295
  134. package/skills/navigate-on-the-client/SKILL.md +212 -189
  135. package/esm/hydration/index.mjs.map +0 -1
  136. package/esm/runtime/register-modules.mjs.map +0 -1
  137. package/esm/server/dev-server.mjs.map +0 -1
  138. /package/esm/{hydration → entry}/index.d.mts +0 -0
@@ -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 = 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 = 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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"}
@@ -0,0 +1,33 @@
1
+ import { concatRoute } from "../../../node_modules/.pnpm/@mongez_concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs";
2
+
3
+ //#region ../core/src/router/normalize-route-path.ts
4
+ /**
5
+ * Normalize route path segments into the canonical path the router serves.
6
+ *
7
+ * This is the ONE definition of a route path's canonical form. `Router.add`
8
+ * calls it to join a group prefix onto a route path, and that call is the only
9
+ * reason a bare `"*"` (the not-found catch-all) is registered — and therefore
10
+ * matched, listed and diffed — as `"/*"`.
11
+ *
12
+ * Anything that PREDICTS or RECORDS a route path outside registration has to
13
+ * call this same function. The build-time page-route manifest is the case that
14
+ * forced it into existence: it is derived from a filesystem scan that never
15
+ * registers a route, so before it normalized here it persisted `"*"` while the
16
+ * running router held `"/*"`, and `warlock routes:diff` reported a route as
17
+ * changed immediately after a successful build.
18
+ *
19
+ * Keeping one implementation is what lets the diff stay a STRICT string
20
+ * comparison instead of teaching every consumer which spellings are secretly
21
+ * equivalent.
22
+ *
23
+ * @example normalizeRoutePath("*") // "/*"
24
+ * @example normalizeRoutePath("/users/", "/:id") // "/users/:id"
25
+ * @example normalizeRoutePath("") // "/"
26
+ */
27
+ function normalizeRoutePath(...segments) {
28
+ return concatRoute(...segments);
29
+ }
30
+
31
+ //#endregion
32
+ export { normalizeRoutePath };
33
+ //# sourceMappingURL=normalize-route-path.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"normalize-route-path.mjs","names":[],"sources":["../../../../../../../../../core/src/router/normalize-route-path.ts"],"sourcesContent":["import concatRoute from \"@mongez/concat-route\";\n\n/**\n * Normalize route path segments into the canonical path the router serves.\n *\n * This is the ONE definition of a route path's canonical form. `Router.add`\n * calls it to join a group prefix onto a route path, and that call is the only\n * reason a bare `\"*\"` (the not-found catch-all) is registered — and therefore\n * matched, listed and diffed — as `\"/*\"`.\n *\n * Anything that PREDICTS or RECORDS a route path outside registration has to\n * call this same function. The build-time page-route manifest is the case that\n * forced it into existence: it is derived from a filesystem scan that never\n * registers a route, so before it normalized here it persisted `\"*\"` while the\n * running router held `\"/*\"`, and `warlock routes:diff` reported a route as\n * changed immediately after a successful build.\n *\n * Keeping one implementation is what lets the diff stay a STRICT string\n * comparison instead of teaching every consumer which spellings are secretly\n * equivalent.\n *\n * @example normalizeRoutePath(\"*\") // \"/*\"\n * @example normalizeRoutePath(\"/users/\", \"/:id\") // \"/users/:id\"\n * @example normalizeRoutePath(\"\") // \"/\"\n */\nexport function normalizeRoutePath(...segments: string[]): string {\n return concatRoute(...segments);\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;AAyBA,SAAgB,mBAAmB,GAAG,UAA4B;CAChE,OAAO,YAAY,GAAG,QAAQ;AAChC"}
@@ -5,7 +5,7 @@ import { NavigationRoot } from "../client/navigation/navigation-root.mjs";
5
5
  import { createElement } from "react";
6
6
  import { pages } from "virtual:warlock/pages";
7
7
 
8
- //#region ../web/src/hydration/index.ts
8
+ //#region ../web/src/entry/index.ts
9
9
  /**
10
10
  * The client entry: registry in, hydration out. No page names, no app paths.
11
11
  *
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../../../../../../web/src/entry/index.ts"],"sourcesContent":["/**\n * The client entry: registry in, hydration out. No page names, no app paths.\n *\n * The page graph arrives from the Vite virtual module the build plugin serves,\n * so this file works for ANY page in ANY app. The previous version imported one\n * page and one layout by relative path into the monorepo's reference app, which\n * both hydrated every URL as the home page and made `@warlock.js/web`\n * uninstallable anywhere outside this checkout. Neither a page name nor an app\n * path appears here now, and nothing about the composition lives here either -\n * that is `buildHydratedTree`, which takes the registry as an argument and is\n * therefore testable without a bundler.\n */\nimport { createElement } from \"react\";\nimport { pages } from \"virtual:warlock/pages\";\nimport { buildHydratedTree } from \"../client/build-hydrated-tree\";\nimport { hydratePage } from \"../client/hydrate-page\";\nimport { NavigationRoot } from \"../client/navigation/navigation-root\";\nimport { publishRouteTable } from \"../routing/route-table\";\n\n/*\n BEFORE the mount, not after: `<Link>` resolves its URL through the route table\n during render, and the first render is the hydration render. Publishing\n afterwards would make every anchor in the initial tree throw.\n\n The registry entries already carry `name` and `path` - the same pair the\n server registered its routes from, out of the same discovery result - so the\n browser's table cannot drift from the server's without the two being built\n from different page graphs, which hydration already refuses.\n*/\npublishRouteTable(pages, \"hydration client entry\");\n\n/*\n The hydrated tree is wrapped in `NavigationRoot` so the page can be REPLACED\n later without a document load. The first render is still exactly the tree the\n server produced - `NavigationRoot` renders `initialTree` verbatim and adds no\n markup of its own - so hydration still matches the server byte for byte, and\n the wrapper only starts to matter on the first navigation.\n\n NO MRR HISTORY BRIDGE IS INSTALLED HERE YET, and that is a pending decision\n rather than an oversight. This runtime drives `window.history` itself and is\n complete without MRR. Handing history to `@mongez/react-router` as well means\n `@warlock.js/web` importing it, which puts MRR in the bundle of EVERY app that\n uses this package - a packaging choice (dependency vs. peer vs. app-level\n opt-in) with consequences for apps that never navigate through MRR. The bridge\n itself is written and documented in\n `conversations/2026-08-24-production-ssr-session.md`; it lands the moment that\n choice is made.\n*/\nhydratePage(async (payload) => {\n const tree = await buildHydratedTree(pages, payload);\n\n return createElement(NavigationRoot, {\n pages,\n initialPayload: payload,\n initialTree: tree,\n buildTree: buildHydratedTree,\n });\n});\n"],"mappings":";;;;;;;;;;;;;;;;;;;;AA6BA,kBAAkB,OAAO,wBAAwB;AAmBjD,YAAY,OAAO,YAAY;CAG7B,OAAO,cAAc,gBAAgB;EACnC;EACA,gBAAgB;EAChB,aAAa,MALI,kBAAkB,OAAO,OAAO;EAMjD,WAAW;CACb,CAAC;AACH,CAAC"}
@@ -1 +1 @@
1
- {"version":3,"file":"hydration-payload.mjs","names":[],"sources":["../../../../../../web/src/hydration-payload.ts"],"sourcesContent":["import {\n PAYLOAD_SCRIPT_ID,\n type HydrationDocumentPayloadSource,\n} from \"./components/document-context\";\n\nexport type { HydrationDocumentPayloadSource } from \"./components/document-context\";\nexport type {\n ErrorPageProps,\n SerializedErrorPageProps,\n SerializedPageError,\n} from \"./components/document-context\";\n\n/**\n * Exported so a payload-shape assertion can be written against the contract\n * itself. A spec that hardcodes its own copy of this list silently becomes a\n * claim about a PAST revision — that is exactly how the rev. 3 keys landed with\n * two specs still asserting the rev. 2 shape.\n */\nexport const REQUIRED_PAYLOAD_KEYS = [\n \"appData\",\n \"layoutData\",\n \"pageData\",\n \"shared\",\n \"name\",\n \"locale\",\n] as const;\n\nconst ABSENT_PAYLOAD_MESSAGE =\n `Warlock hydration payload is absent: #${PAYLOAD_SCRIPT_ID}, owned by ` +\n \"web/src/components/document-context.ts, was not found.\";\nconst MALFORMED_PAYLOAD_MESSAGE =\n `Warlock hydration payload was found at #${PAYLOAD_SCRIPT_ID} but could not be read.`;\n\nfunction malformedPayload(): never {\n throw new Error(MALFORMED_PAYLOAD_MESSAGE);\n}\n\n/**\n * The keys that are allowed to be ABSENT but not allowed to be wrong.\n *\n * `metadata`, `params` and `errorPage` are optional because the server is right\n * always produce them — a page with no `metadata` export resolves none, and a\n * older payload carries none of these additions. Failing a whole page over an\n * absent accessor would turn a compatible payload into a blank screen, so\n * absence is accepted.\n *\n * Present-but-not-an-object is a different claim entirely: it means something\n * produced a payload with these names meaning something else, and every reader\n * downstream would then be indexing a string. That is MALFORMED under the same\n * rule the required keys live by, so it throws. Arrays included — `typeof []`\n * is `\"object\"`, and an array of params is not params.\n */\nexport const OPTIONAL_OBJECT_PAYLOAD_KEYS = [\"metadata\", \"params\", \"errorPage\"] as const;\n\nfunction isPlainObject(value: unknown): boolean {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction hasExactStringKeys(\n value: Record<PropertyKey, unknown>,\n required: readonly string[],\n optional: readonly string[] = [],\n): boolean {\n const allowed = new Set([...required, ...optional]);\n const keys = Reflect.ownKeys(value);\n\n return (\n required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&\n keys.every((key) => typeof key === \"string\" && allowed.has(key))\n );\n}\n\n/**\n * Validate the explicit serialization boundary, not an `Error` instance.\n * `JSON.stringify(new Error(\"boom\"))` is normally `{}` because its useful\n * fields are non-enumerable; accepting that would hydrate an error page with a\n * different contract from the one the server rendered.\n */\nfunction requireErrorPagePayload(value: unknown): void {\n if (!isPlainObject(value)) malformedPayload();\n\n const errorPage = value as Record<PropertyKey, unknown>;\n\n if (!hasExactStringKeys(errorPage, [\"error\", \"status\"])) malformedPayload();\n if (!isPlainObject(errorPage.error)) malformedPayload();\n\n const error = errorPage.error as Record<PropertyKey, unknown>;\n\n if (!hasExactStringKeys(error, [\"name\", \"message\"], [\"stack\"])) malformedPayload();\n if (typeof error.name !== \"string\" || typeof error.message !== \"string\") {\n malformedPayload();\n }\n if (error.stack !== undefined && typeof error.stack !== \"string\") malformedPayload();\n\n if (\n typeof errorPage.status !== \"number\" ||\n !Number.isInteger(errorPage.status) ||\n errorPage.status < 500 ||\n errorPage.status > 599\n ) {\n malformedPayload();\n }\n}\n\nfunction requireHydrationPayload(value: unknown): HydrationDocumentPayloadSource {\n if (!isPlainObject(value)) malformedPayload();\n\n for (const key of REQUIRED_PAYLOAD_KEYS) {\n if (!Object.prototype.hasOwnProperty.call(value, key)) malformedPayload();\n }\n\n if (typeof (value as Record<string, unknown>).name !== \"string\") malformedPayload();\n const locale = (value as Record<string, unknown>).locale;\n if (typeof locale !== \"string\" || locale.length === 0) malformedPayload();\n\n for (const key of OPTIONAL_OBJECT_PAYLOAD_KEYS) {\n const optional = (value as Record<string, unknown>)[key];\n\n if (optional !== undefined && !isPlainObject(optional)) malformedPayload();\n }\n\n const errorPage = (value as Record<string, unknown>).errorPage;\n if (errorPage !== undefined) requireErrorPagePayload(errorPage);\n\n return value as HydrationDocumentPayloadSource;\n}\n\n/**\n * Read the fixed payload script without changing the server-rendered root.\n *\n * Extra fields are ignored. The gate owns the SIX required keys — absent or\n * malformed, both throw — plus a shape check on the three optional ones\n * ({@link OPTIONAL_OBJECT_PAYLOAD_KEYS}); it deliberately does not require\n * those to be present. `errorPage`, when present, is additionally validated as\n * one atomic `{ error, status }` selection with a serialized error and a 5xx.\n */\nexport function readHydrationPayload(documentNode: Document): HydrationDocumentPayloadSource {\n const element = documentNode.getElementById(PAYLOAD_SCRIPT_ID);\n\n if (element === null) throw new Error(ABSENT_PAYLOAD_MESSAGE);\n\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(element.textContent ?? \"\");\n } catch {\n malformedPayload();\n }\n\n return requireHydrationPayload(parsed);\n}\n"],"mappings":";;;;;;;;;AAkBA,MAAa,wBAAwB;CACnC;CACA;CACA;CACA;CACA;CACA;AACF;AAEA,MAAM,yBACJ,yCAAyC,kBAAkB;AAE7D,MAAM,4BACJ,2CAA2C,kBAAkB;AAE/D,SAAS,mBAA0B;CACjC,MAAM,IAAI,MAAM,yBAAyB;AAC3C;;;;;;;;;;;;;;;;AAiBA,MAAa,+BAA+B;CAAC;CAAY;CAAU;AAAW;AAE9E,SAAS,cAAc,OAAyB;CAC9C,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAEA,SAAS,mBACP,OACA,UACA,WAA8B,CAAC,GACtB;CACT,MAAM,UAAU,IAAI,IAAI,CAAC,GAAG,UAAU,GAAG,QAAQ,CAAC;CAClD,MAAM,OAAO,QAAQ,QAAQ,KAAK;CAElC,OACE,SAAS,OAAO,QAAQ,OAAO,UAAU,eAAe,KAAK,OAAO,GAAG,CAAC,KACxE,KAAK,OAAO,QAAQ,OAAO,QAAQ,YAAY,QAAQ,IAAI,GAAG,CAAC;AAEnE;;;;;;;AAQA,SAAS,wBAAwB,OAAsB;CACrD,IAAI,CAAC,cAAc,KAAK,GAAG,iBAAiB;CAE5C,MAAM,YAAY;CAElB,IAAI,CAAC,mBAAmB,WAAW,CAAC,SAAS,QAAQ,CAAC,GAAG,iBAAiB;CAC1E,IAAI,CAAC,cAAc,UAAU,KAAK,GAAG,iBAAiB;CAEtD,MAAM,QAAQ,UAAU;CAExB,IAAI,CAAC,mBAAmB,OAAO,CAAC,QAAQ,SAAS,GAAG,CAAC,OAAO,CAAC,GAAG,iBAAiB;CACjF,IAAI,OAAO,MAAM,SAAS,YAAY,OAAO,MAAM,YAAY,UAC7D,iBAAiB;CAEnB,IAAI,MAAM,UAAU,UAAa,OAAO,MAAM,UAAU,UAAU,iBAAiB;CAEnF,IACE,OAAO,UAAU,WAAW,YAC5B,CAAC,OAAO,UAAU,UAAU,MAAM,KAClC,UAAU,SAAS,OACnB,UAAU,SAAS,KAEnB,iBAAiB;AAErB;AAEA,SAAS,wBAAwB,OAAgD;CAC/E,IAAI,CAAC,cAAc,KAAK,GAAG,iBAAiB;CAE5C,KAAK,MAAM,OAAO,uBAChB,IAAI,CAAC,OAAO,UAAU,eAAe,KAAK,OAAO,GAAG,GAAG,iBAAiB;CAG1E,IAAI,OAAQ,MAAkC,SAAS,UAAU,iBAAiB;CAClF,MAAM,SAAU,MAAkC;CAClD,IAAI,OAAO,WAAW,YAAY,OAAO,WAAW,GAAG,iBAAiB;CAExE,KAAK,MAAM,OAAO,8BAA8B;EAC9C,MAAM,WAAY,MAAkC;EAEpD,IAAI,aAAa,UAAa,CAAC,cAAc,QAAQ,GAAG,iBAAiB;CAC3E;CAEA,MAAM,YAAa,MAAkC;CACrD,IAAI,cAAc,QAAW,wBAAwB,SAAS;CAE9D,OAAO;AACT;;;;;;;;;;AAWA,SAAgB,qBAAqB,cAAwD;CAC3F,MAAM,UAAU,aAAa,eAAe,iBAAiB;CAE7D,IAAI,YAAY,MAAM,MAAM,IAAI,MAAM,sBAAsB;CAE5D,IAAI;CAEJ,IAAI;EACF,SAAS,KAAK,MAAM,QAAQ,eAAe,EAAE;CAC/C,QAAQ;EACN,iBAAiB;CACnB;CAEA,OAAO,wBAAwB,MAAM;AACvC"}
1
+ {"version":3,"file":"hydration-payload.mjs","names":[],"sources":["../../../../../../web/src/hydration-payload.ts"],"sourcesContent":["import {\n PAYLOAD_SCRIPT_ID,\n type HydrationDocumentPayloadSource,\n} from \"./components/document-context\";\n\nexport type { HydrationDocumentPayloadSource } from \"./components/document-context\";\nexport type {\n ErrorPageProps,\n SerializedErrorPageProps,\n SerializedPageError,\n} from \"./components/document-context\";\n\n/**\n * Exported so a payload-shape assertion can be written against the contract\n * itself. A spec that hardcodes its own copy of this list silently becomes a\n * claim about a PAST revision — that is exactly how the rev. 3 keys landed with\n * two specs still asserting the rev. 2 shape.\n */\nexport const REQUIRED_PAYLOAD_KEYS = [\n \"appData\",\n \"layoutData\",\n \"pageData\",\n \"shared\",\n \"name\",\n \"locale\",\n] as const;\n\nconst ABSENT_PAYLOAD_MESSAGE =\n `Warlock hydration payload is absent: #${PAYLOAD_SCRIPT_ID}, owned by ` +\n \"web/src/components/document-context.ts, was not found.\";\nconst MALFORMED_PAYLOAD_MESSAGE = `Warlock hydration payload was found at #${PAYLOAD_SCRIPT_ID} but could not be read.`;\n\nfunction malformedPayload(): never {\n throw new Error(MALFORMED_PAYLOAD_MESSAGE);\n}\n\n/**\n * The keys that are allowed to be ABSENT but not allowed to be wrong.\n *\n * `metadata`, `params` and `errorPage` are optional because the server is right\n * always produce them — a page with no `metadata` export resolves none, and a\n * older payload carries none of these additions. Failing a whole page over an\n * absent accessor would turn a compatible payload into a blank screen, so\n * absence is accepted.\n *\n * Present-but-not-an-object is a different claim entirely: it means something\n * produced a payload with these names meaning something else, and every reader\n * downstream would then be indexing a string. That is MALFORMED under the same\n * rule the required keys live by, so it throws. Arrays included — `typeof []`\n * is `\"object\"`, and an array of params is not params.\n */\nexport const OPTIONAL_OBJECT_PAYLOAD_KEYS = [\"metadata\", \"params\", \"errorPage\"] as const;\n\nfunction isPlainObject(value: unknown): boolean {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction hasExactStringKeys(\n value: Record<PropertyKey, unknown>,\n required: readonly string[],\n optional: readonly string[] = [],\n): boolean {\n const allowed = new Set([...required, ...optional]);\n const keys = Reflect.ownKeys(value);\n\n return (\n required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&\n keys.every((key) => typeof key === \"string\" && allowed.has(key))\n );\n}\n\n/**\n * Validate the explicit serialization boundary, not an `Error` instance.\n * `JSON.stringify(new Error(\"boom\"))` is normally `{}` because its useful\n * fields are non-enumerable; accepting that would hydrate an error page with a\n * different contract from the one the server rendered.\n */\nfunction requireErrorPagePayload(value: unknown): void {\n if (!isPlainObject(value)) malformedPayload();\n\n const errorPage = value as Record<PropertyKey, unknown>;\n\n if (!hasExactStringKeys(errorPage, [\"error\", \"status\"])) malformedPayload();\n if (!isPlainObject(errorPage.error)) malformedPayload();\n\n const error = errorPage.error as Record<PropertyKey, unknown>;\n\n if (!hasExactStringKeys(error, [\"name\", \"message\"], [\"stack\"])) malformedPayload();\n if (typeof error.name !== \"string\" || typeof error.message !== \"string\") {\n malformedPayload();\n }\n if (error.stack !== undefined && typeof error.stack !== \"string\") malformedPayload();\n\n if (\n typeof errorPage.status !== \"number\" ||\n !Number.isInteger(errorPage.status) ||\n errorPage.status < 500 ||\n errorPage.status > 599\n ) {\n malformedPayload();\n }\n}\n\nfunction requireHydrationPayload(value: unknown): HydrationDocumentPayloadSource {\n if (!isPlainObject(value)) malformedPayload();\n\n for (const key of REQUIRED_PAYLOAD_KEYS) {\n if (!Object.prototype.hasOwnProperty.call(value, key)) malformedPayload();\n }\n\n if (typeof (value as Record<string, unknown>).name !== \"string\") malformedPayload();\n const locale = (value as Record<string, unknown>).locale;\n if (typeof locale !== \"string\" || locale.length === 0) malformedPayload();\n\n for (const key of OPTIONAL_OBJECT_PAYLOAD_KEYS) {\n const optional = (value as Record<string, unknown>)[key];\n\n if (optional !== undefined && !isPlainObject(optional)) malformedPayload();\n }\n\n const errorPage = (value as Record<string, unknown>).errorPage;\n if (errorPage !== undefined) requireErrorPagePayload(errorPage);\n\n return value as HydrationDocumentPayloadSource;\n}\n\n/**\n * Read the fixed payload script without changing the server-rendered root.\n *\n * Extra fields are ignored. The gate owns the SIX required keys — absent or\n * malformed, both throw — plus a shape check on the three optional ones\n * ({@link OPTIONAL_OBJECT_PAYLOAD_KEYS}); it deliberately does not require\n * those to be present. `errorPage`, when present, is additionally validated as\n * one atomic `{ error, status }` selection with a serialized error and a 5xx.\n */\nexport function readHydrationPayload(documentNode: Document): HydrationDocumentPayloadSource {\n const element = documentNode.getElementById(PAYLOAD_SCRIPT_ID);\n\n if (element === null) throw new Error(ABSENT_PAYLOAD_MESSAGE);\n\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(element.textContent ?? \"\");\n } catch {\n malformedPayload();\n }\n\n return requireHydrationPayload(parsed);\n}\n"],"mappings":";;;;;;;;;AAkBA,MAAa,wBAAwB;CACnC;CACA;CACA;CACA;CACA;CACA;AACF;AAEA,MAAM,yBACJ,yCAAyC,kBAAkB;AAE7D,MAAM,4BAA4B,2CAA2C,kBAAkB;AAE/F,SAAS,mBAA0B;CACjC,MAAM,IAAI,MAAM,yBAAyB;AAC3C;;;;;;;;;;;;;;;;AAiBA,MAAa,+BAA+B;CAAC;CAAY;CAAU;AAAW;AAE9E,SAAS,cAAc,OAAyB;CAC9C,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAEA,SAAS,mBACP,OACA,UACA,WAA8B,CAAC,GACtB;CACT,MAAM,UAAU,IAAI,IAAI,CAAC,GAAG,UAAU,GAAG,QAAQ,CAAC;CAClD,MAAM,OAAO,QAAQ,QAAQ,KAAK;CAElC,OACE,SAAS,OAAO,QAAQ,OAAO,UAAU,eAAe,KAAK,OAAO,GAAG,CAAC,KACxE,KAAK,OAAO,QAAQ,OAAO,QAAQ,YAAY,QAAQ,IAAI,GAAG,CAAC;AAEnE;;;;;;;AAQA,SAAS,wBAAwB,OAAsB;CACrD,IAAI,CAAC,cAAc,KAAK,GAAG,iBAAiB;CAE5C,MAAM,YAAY;CAElB,IAAI,CAAC,mBAAmB,WAAW,CAAC,SAAS,QAAQ,CAAC,GAAG,iBAAiB;CAC1E,IAAI,CAAC,cAAc,UAAU,KAAK,GAAG,iBAAiB;CAEtD,MAAM,QAAQ,UAAU;CAExB,IAAI,CAAC,mBAAmB,OAAO,CAAC,QAAQ,SAAS,GAAG,CAAC,OAAO,CAAC,GAAG,iBAAiB;CACjF,IAAI,OAAO,MAAM,SAAS,YAAY,OAAO,MAAM,YAAY,UAC7D,iBAAiB;CAEnB,IAAI,MAAM,UAAU,UAAa,OAAO,MAAM,UAAU,UAAU,iBAAiB;CAEnF,IACE,OAAO,UAAU,WAAW,YAC5B,CAAC,OAAO,UAAU,UAAU,MAAM,KAClC,UAAU,SAAS,OACnB,UAAU,SAAS,KAEnB,iBAAiB;AAErB;AAEA,SAAS,wBAAwB,OAAgD;CAC/E,IAAI,CAAC,cAAc,KAAK,GAAG,iBAAiB;CAE5C,KAAK,MAAM,OAAO,uBAChB,IAAI,CAAC,OAAO,UAAU,eAAe,KAAK,OAAO,GAAG,GAAG,iBAAiB;CAG1E,IAAI,OAAQ,MAAkC,SAAS,UAAU,iBAAiB;CAClF,MAAM,SAAU,MAAkC;CAClD,IAAI,OAAO,WAAW,YAAY,OAAO,WAAW,GAAG,iBAAiB;CAExE,KAAK,MAAM,OAAO,8BAA8B;EAC9C,MAAM,WAAY,MAAkC;EAEpD,IAAI,aAAa,UAAa,CAAC,cAAc,QAAQ,GAAG,iBAAiB;CAC3E;CAEA,MAAM,YAAa,MAAkC;CACrD,IAAI,cAAc,QAAW,wBAAwB,SAAS;CAE9D,OAAO;AACT;;;;;;;;;;AAWA,SAAgB,qBAAqB,cAAwD;CAC3F,MAAM,UAAU,aAAa,eAAe,iBAAiB;CAE7D,IAAI,YAAY,MAAM,MAAM,IAAI,MAAM,sBAAsB;CAE5D,IAAI;CAEJ,IAAI;EACF,SAAS,KAAK,MAAM,QAAQ,eAAe,EAAE;CAC/C,QAAQ;EACN,iBAAiB;CACnB;CAEA,OAAO,wBAAwB,MAAM;AACvC"}
package/esm/index.d.mts CHANGED
@@ -5,13 +5,14 @@ import { PageMetadata } from "./metadata.mjs";
5
5
  import { ErrorPageProps, SerializedErrorPageProps, SerializedPageError } from "./components/document-context.mjs";
6
6
  import { shared, useShared } from "./shared.mjs";
7
7
  import { LocaleProvider, LocaleProviderProps, Translate, useLocale, useTrans } from "./localization.mjs";
8
- import { QueryStringInput, QueryStringLeaf, QueryStringNested, QueryStringObject, QueryStringOptions, QueryStringValue, RepeatedKeyStrategy, UnserializableQueryValueError, queryString, queryStringOf, resetQueryStringOptions, setQueryStringOptions } from "./routing/query-string.mjs";
8
+ import { QueryStringInput, QueryStringLeaf, QueryStringNested, QueryStringObject, QueryStringOptions, QueryStringValue, RepeatedKeyStrategy, RequestSearchResolver, UnserializableQueryValueError, connectRequestSearch, queryString, queryStringOf, resetQueryStringOptions, setQueryStringOptions } from "./routing/query-string.mjs";
9
9
  import { RouteParameters, RouteQuery, href } from "./routing/route-table.mjs";
10
10
  import { Link } from "./components/link.mjs";
11
11
  import { getHash, navigateBack, navigateTo } from "./client/navigation/verbs.mjs";
12
12
  import { NavigationEndPayload, NavigationErrorPayload, NavigationStartPayload, createRouterEvents, routerEvents } from "./routing/router-events.mjs";
13
13
  import { MatchedRoute, currentRoute, previousRoute } from "./client/navigation/current-route.mjs";
14
14
  import { refresh } from "./client/navigation/refresh.mjs";
15
+ import { useQueryString } from "./client/navigation/use-query-string.mjs";
15
16
  import { Head } from "./components/head.mjs";
16
17
  import { Scripts } from "./components/scripts.mjs";
17
18
 
@@ -33,6 +34,6 @@ import { Scripts } from "./components/scripts.mjs";
33
34
  */
34
35
  interface SharedContext {}
35
36
  //#endregion
36
- export { type AppLoader, type AppProps, type ErrorPageProps, Head, type HttpContext, type LayoutLoader, type LayoutProps, Link, LocaleProvider, type LocaleProviderProps, type MatchedRoute, type NavigationEndPayload, type NavigationErrorPayload, type NavigationStartPayload, type PageContext, type PageLoader, type PageMetadata, type PageProps, type QueryStringInput, type QueryStringLeaf, type QueryStringNested, type QueryStringObject, type QueryStringOptions, type QueryStringValue, type RepeatedKeyStrategy, type RouteParameters, type RouteQuery, Scripts, type SerializedErrorPageProps, type SerializedPageError, SharedContext, type Translate, UnserializableQueryValueError, createRouterEvents, currentRoute, getHash, href, navigateBack, navigateTo, previousRoute, queryString, queryStringOf, refresh, resetQueryStringOptions, routerEvents, setQueryStringOptions, shared, useLocale, useShared, useTrans };
37
+ export { type AppLoader, type AppProps, type ErrorPageProps, Head, type HttpContext, type LayoutLoader, type LayoutProps, Link, LocaleProvider, type LocaleProviderProps, type MatchedRoute, type NavigationEndPayload, type NavigationErrorPayload, type NavigationStartPayload, type PageContext, type PageLoader, type PageMetadata, type PageProps, type QueryStringInput, type QueryStringLeaf, type QueryStringNested, type QueryStringObject, type QueryStringOptions, type QueryStringValue, type RepeatedKeyStrategy, type RequestSearchResolver, type RouteParameters, type RouteQuery, Scripts, type SerializedErrorPageProps, type SerializedPageError, SharedContext, type Translate, UnserializableQueryValueError, connectRequestSearch, createRouterEvents, currentRoute, getHash, href, navigateBack, navigateTo, previousRoute, queryString, queryStringOf, refresh, resetQueryStringOptions, routerEvents, setQueryStringOptions, shared, useLocale, useQueryString, useShared, useTrans };
37
38
  //# sourceMappingURL=index.d.mts.map
38
39
  import "./server/create-page-route-handler.mjs";
package/esm/index.mjs CHANGED
@@ -1,13 +1,14 @@
1
1
  import { shared, useShared } from "./shared.mjs";
2
2
  import { LocaleProvider, useLocale, useTrans } from "./localization.mjs";
3
- import { UnserializableQueryValueError, queryString, queryStringOf, resetQueryStringOptions, setQueryStringOptions } from "./routing/query-string.mjs";
3
+ import { UnserializableQueryValueError, connectRequestSearch, queryString, queryStringOf, resetQueryStringOptions, setQueryStringOptions } from "./routing/query-string.mjs";
4
4
  import { href } from "./routing/route-table.mjs";
5
5
  import { Link } from "./components/link.mjs";
6
6
  import { getHash, navigateBack, navigateTo } from "./client/navigation/verbs.mjs";
7
7
  import { createRouterEvents, routerEvents } from "./routing/router-events.mjs";
8
8
  import { currentRoute, previousRoute } from "./client/navigation/current-route.mjs";
9
9
  import { refresh } from "./client/navigation/refresh.mjs";
10
+ import { useQueryString } from "./client/navigation/use-query-string.mjs";
10
11
  import { Head } from "./components/head.mjs";
11
12
  import { Scripts } from "./components/scripts.mjs";
12
13
 
13
- export { Head, Link, LocaleProvider, Scripts, UnserializableQueryValueError, createRouterEvents, currentRoute, getHash, href, navigateBack, navigateTo, previousRoute, queryString, queryStringOf, refresh, resetQueryStringOptions, routerEvents, setQueryStringOptions, shared, useLocale, useShared, useTrans };
14
+ export { Head, Link, LocaleProvider, Scripts, UnserializableQueryValueError, connectRequestSearch, createRouterEvents, currentRoute, getHash, href, navigateBack, navigateTo, previousRoute, queryString, queryStringOf, refresh, resetQueryStringOptions, routerEvents, setQueryStringOptions, shared, useLocale, useQueryString, useShared, useTrans };
package/esm/loaders.d.mts CHANGED
@@ -1,12 +1,19 @@
1
1
  import { PageContext } from "./context.mjs";
2
- import { RouteDeclaration } from "./route.mjs";
2
+ import { RouteDeclaration, RouteValidatedOutput } from "./route.mjs";
3
3
  import { PageValidation, ValidatedOutput } from "./validation.mjs";
4
4
  import { SharedContext } from "./index.mjs";
5
5
  import { Request, Response } from "@warlock.js/core";
6
6
 
7
7
  //#region ../web/src/loaders.d.ts
8
- type PageLoaderContext<TValidation extends PageValidation | undefined, _TRoute extends RouteDeclaration | undefined> = {
9
- request: Request<ValidatedOutput<TValidation>>;
8
+ type PageLoaderContext<TValidation extends PageValidation | undefined, TRoute extends RouteDeclaration | undefined> = {
9
+ /**
10
+ * `validated()` carries BOTH validation surfaces a page may declare: the
11
+ * pre-existing top-level `validation` export's flat shape, and
12
+ * `route.validate`'s `{ params, query }` — merged by intersection so a page
13
+ * using either (or, at the type level, both) sees every field typed rather
14
+ * than validating again inside the loader (canon `1ca1e8ae`).
15
+ */
16
+ request: Request<ValidatedOutput<TValidation> & RouteValidatedOutput<TRoute>>;
10
17
  response: Response;
11
18
  shared: SharedContext;
12
19
  };
@@ -1 +1 @@
1
- {"version":3,"file":"localization.mjs","names":[],"sources":["../../../../../../web/src/localization.tsx"],"sourcesContent":["import {\n transFrom,\n type Converter,\n type Translatable,\n} from \"@mongez/localization\";\nimport { createContext, useCallback, useContext, type ReactNode } from \"react\";\n\nexport type LocaleProviderProps = {\n readonly locale: string;\n readonly children: ReactNode;\n};\n\nexport type Translate = (\n keyword: Translatable,\n placeholders?: unknown,\n converter?: Converter,\n) => ReturnType<typeof transFrom>;\n\nconst LocaleContext = createContext<string | undefined>(undefined);\n\n/** Bind translations to the request locale carried by the hydration payload. */\nexport function LocaleProvider({ locale, children }: LocaleProviderProps) {\n return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;\n}\n\n/** Read the locale selected for the current server render or client page. */\nexport function useLocale(): string {\n const locale = useContext(LocaleContext);\n\n if (locale === undefined) {\n throw new Error(\n \"useLocale() was called outside Warlock's LocaleProvider. Render the component \" +\n \"through the @warlock.js/web page pipeline.\",\n );\n }\n\n return locale;\n}\n\n/** Translate without consulting @mongez/localization's process-global locale. */\nexport function useTrans(): Translate {\n const locale = useLocale();\n\n return useCallback(\n (keyword, placeholders, converter) =>\n transFrom(locale, keyword, placeholders, converter),\n [locale],\n );\n}\n"],"mappings":";;;;;AAkBA,MAAM,gBAAgB,cAAkC,MAAS;;AAGjE,SAAgB,eAAe,EAAE,QAAQ,YAAiC;CACxE,OAAO,oBAAC,cAAc,UAAf;EAAwB,OAAO;EAAS;CAAiC;AAClF;;AAGA,SAAgB,YAAoB;CAClC,MAAM,SAAS,WAAW,aAAa;CAEvC,IAAI,WAAW,QACb,MAAM,IAAI,MACR,0HAEF;CAGF,OAAO;AACT;;AAGA,SAAgB,WAAsB;CACpC,MAAM,SAAS,UAAU;CAEzB,OAAO,aACJ,SAAS,cAAc,cACtB,UAAU,QAAQ,SAAS,cAAc,SAAS,GACpD,CAAC,MAAM,CACT;AACF"}
1
+ {"version":3,"file":"localization.mjs","names":[],"sources":["../../../../../../web/src/localization.tsx"],"sourcesContent":["import { transFrom, type Converter, type Translatable } from \"@mongez/localization\";\nimport { createContext, useCallback, useContext, type ReactNode } from \"react\";\n\nexport type LocaleProviderProps = {\n readonly locale: string;\n readonly children: ReactNode;\n};\n\nexport type Translate = (\n keyword: Translatable,\n placeholders?: unknown,\n converter?: Converter,\n) => ReturnType<typeof transFrom>;\n\nconst LocaleContext = createContext<string | undefined>(undefined);\n\n/** Bind translations to the request locale carried by the hydration payload. */\nexport function LocaleProvider({ locale, children }: LocaleProviderProps) {\n return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;\n}\n\n/** Read the locale selected for the current server render or client page. */\nexport function useLocale(): string {\n const locale = useContext(LocaleContext);\n\n if (locale === undefined) {\n throw new Error(\n \"useLocale() was called outside Warlock's LocaleProvider. Render the component \" +\n \"through the @warlock.js/web page pipeline.\",\n );\n }\n\n return locale;\n}\n\n/** Translate without consulting @mongez/localization's process-global locale. */\nexport function useTrans(): Translate {\n const locale = useLocale();\n\n return useCallback(\n (keyword, placeholders, converter) => transFrom(locale, keyword, placeholders, converter),\n [locale],\n );\n}\n"],"mappings":";;;;;AAcA,MAAM,gBAAgB,cAAkC,MAAS;;AAGjE,SAAgB,eAAe,EAAE,QAAQ,YAAiC;CACxE,OAAO,oBAAC,cAAc,UAAf;EAAwB,OAAO;EAAS;CAAiC;AAClF;;AAGA,SAAgB,YAAoB;CAClC,MAAM,SAAS,WAAW,aAAa;CAEvC,IAAI,WAAW,QACb,MAAM,IAAI,MACR,0HAEF;CAGF,OAAO;AACT;;AAGA,SAAgB,WAAsB;CACpC,MAAM,SAAS,UAAU;CAEzB,OAAO,aACJ,SAAS,cAAc,cAAc,UAAU,QAAQ,SAAS,cAAc,SAAS,GACxF,CAAC,MAAM,CACT;AACF"}
@@ -7,9 +7,9 @@ import { SharedContext } from "./index.mjs";
7
7
  * `<head>` before the first byte; `<Head />` only decides placement.
8
8
  *
9
9
  * **Every member here is a member something READS.** The two renderers are
10
- * `components/head.ts:28-70` (the SSR'd document) and
11
- * `client/navigation/navigation-root.tsx:105-123` (the same head, rewritten
12
- * after a client navigation), and they agree key for key. This type is the list
10
+ * `components/head.ts` (the SSR'd document) and
11
+ * `client/navigation/document-metadata.ts` (the same head, rewritten after a
12
+ * client navigation), and they agree key for key. This type is the list
13
13
  * of those keys and deliberately not one entry longer: a field the type promises
14
14
  * and no renderer consumes is the same silence as an unknown key — the page is
15
15
  * served without it and nothing says so.
@@ -17,7 +17,7 @@ import { SharedContext } from "./index.mjs";
17
17
  * Adding a member is therefore a two-file change by construction. Add it here
18
18
  * and {@link METADATA_KEYS} stops matching, which is a compile error
19
19
  * ({@link MetadataKeysAreExact}); make it match and the build gate in
20
- * `build/discover-pages.ts` accepts the key — but until a renderer emits a tag
20
+ * `build/read-metadata-keys.ts` accepts the key — but until a renderer emits a tag
21
21
  * for it, the key still does nothing. Write the renderer.
22
22
  */
23
23
  type MetadataOutput = {
@@ -1 +1 @@
1
- {"version":3,"file":"metadata.mjs","names":[],"sources":["../../../../../../web/src/metadata.ts"],"sourcesContent":["import type { SharedContext } from \"./index\";\r\nimport type { LoaderData, LoaderFunction } from \"./props\";\r\n\r\n/**\r\n * What the `metadata` export may produce. The pipeline injects it into\r\n * `<head>` before the first byte; `<Head />` only decides placement.\r\n *\r\n * **Every member here is a member something READS.** The two renderers are\r\n * `components/head.ts:28-70` (the SSR'd document) and\r\n * `client/navigation/navigation-root.tsx:105-123` (the same head, rewritten\r\n * after a client navigation), and they agree key for key. This type is the list\r\n * of those keys and deliberately not one entry longer: a field the type promises\r\n * and no renderer consumes is the same silence as an unknown key — the page is\r\n * served without it and nothing says so.\r\n *\r\n * Adding a member is therefore a two-file change by construction. Add it here\r\n * and {@link METADATA_KEYS} stops matching, which is a compile error\r\n * ({@link MetadataKeysAreExact}); make it match and the build gate in\r\n * `build/discover-pages.ts` accepts the key — but until a renderer emits a tag\r\n * for it, the key still does nothing. Write the renderer.\r\n */\r\nexport type MetadataOutput = {\r\n title?: string;\r\n description?: string;\r\n keywords?: string | readonly string[];\r\n canonical?: string;\r\n robots?: string;\r\n /**\r\n * `og:title`/`og:description` fall back to the top-level `title`/\r\n * `description` when `openGraph` is present but the member is absent —\r\n * no other member has a fallback.\r\n */\r\n openGraph?: {\r\n title?: string;\r\n description?: string;\r\n image?: string;\r\n url?: string;\r\n type?: string;\r\n };\r\n twitter?: {\r\n card?: string;\r\n title?: string;\r\n description?: string;\r\n image?: string;\r\n };\r\n};\r\n\r\n/**\r\n * The SAME key set as {@link MetadataOutput}, as a value.\r\n *\r\n * It exists because the defect this guards is one a type cannot reach.\r\n * `export const metadata = { tittle: \"x\" }` — no annotation — is a well-typed\r\n * program: TypeScript infers `{ tittle: string }` and has nothing to compare it\r\n * against, so the page is served with no title and the build says nothing. The\r\n * only place left to catch it is where pages are DISCOVERED, and discovery\r\n * refuses to run application code — it parses. A parser cannot ask a type what\r\n * its keys are, so the keys have to exist at runtime too.\r\n *\r\n * Two lists of one thing is exactly the drift this codebase refuses elsewhere,\r\n * so they are not two lists: {@link MetadataKeysAreExact} makes any\r\n * disagreement a compile error, in either direction. Add a key to the type\r\n * alone and this file stops compiling; add it here alone, likewise.\r\n */\r\nexport const METADATA_KEYS = [\r\n \"title\",\r\n \"description\",\r\n \"keywords\",\r\n \"canonical\",\r\n \"robots\",\r\n \"openGraph\",\r\n \"twitter\",\r\n] as const;\r\n\r\n/** The members of `openGraph`, on the same terms as {@link METADATA_KEYS}. */\r\nexport const OPEN_GRAPH_KEYS = [\"title\", \"description\", \"image\", \"url\", \"type\"] as const;\r\n\r\n/** The members of `twitter`, on the same terms as {@link METADATA_KEYS}. */\r\nexport const TWITTER_KEYS = [\"card\", \"title\", \"description\", \"image\"] as const;\r\n\r\n/**\r\n * Instantiates only when `Difference` is empty. When it is not, the compiler\r\n * names the offending key in the error — \"Type '\\\"tittle\\\"' does not satisfy\r\n * the constraint 'never'\" — which is the whole message a drift needs.\r\n *\r\n * A mutual `extends` (`Exactly<A extends B, B extends A>`) says the same thing\r\n * more directly and TypeScript rejects it as a circular constraint (TS2313), so\r\n * the sets are compared by difference instead, once in each direction. BOTH\r\n * directions matter: a list that has fallen behind the type makes the build gate\r\n * reject a key it should accept, and a list that has run ahead makes it accept\r\n * one nothing renders.\r\n */\r\ntype NoDifference<Difference extends never> = Difference;\r\n\r\n/**\r\n * The drift guards, written out per key set rather than through one generic\r\n * helper: a `SameKeys<List, Keys>` alias would apply `NoDifference` to an\r\n * UNRESOLVED `Exclude<List, Keys>`, which the compiler cannot show is empty and\r\n * so rejects at the declaration (TS2344) whatever the real key sets are. Passed\r\n * concrete types, it resolves and checks the thing it is meant to check.\r\n *\r\n * Exported so they are not \"unused\", and named so a failure reads as what it\r\n * is: the key list and the type have diverged.\r\n */\r\nexport type MetadataKeysAreExact = [\r\n NoDifference<Exclude<(typeof METADATA_KEYS)[number], keyof MetadataOutput>>,\r\n NoDifference<Exclude<keyof MetadataOutput, (typeof METADATA_KEYS)[number]>>,\r\n];\r\n\r\ntype OpenGraphKey = keyof NonNullable<MetadataOutput[\"openGraph\"]>;\r\n\r\nexport type OpenGraphKeysAreExact = [\r\n NoDifference<Exclude<(typeof OPEN_GRAPH_KEYS)[number], OpenGraphKey>>,\r\n NoDifference<Exclude<OpenGraphKey, (typeof OPEN_GRAPH_KEYS)[number]>>,\r\n];\r\n\r\ntype TwitterKey = keyof NonNullable<MetadataOutput[\"twitter\"]>;\r\n\r\nexport type TwitterKeysAreExact = [\r\n NoDifference<Exclude<(typeof TWITTER_KEYS)[number], TwitterKey>>,\r\n NoDifference<Exclude<TwitterKey, (typeof TWITTER_KEYS)[number]>>,\r\n];\r\n\r\n/**\r\n * Static object OR a function of the loader's data (products.page.tsx:81-84,\r\n * product-details.page.tsx:71-74). The function form runs server-side, after\r\n * the loader, with the same `data` the component will receive — which is why\r\n * it can describe the page instead of guessing at it.\r\n *\r\n * **`data` is always present, and that is now true rather than merely\r\n * declared.** The function form runs only when the loader resolved; when it\r\n * rejected, the framework emits `ERROR_PAGE_METADATA` and this never runs\r\n * (`server/resolve-page-metadata.ts`, which explains why at length). An earlier\r\n * revision passed `{ data: undefined, error }` on the boundary path while\r\n * declaring `data` non-optional — every page that read `data` unguarded then\r\n * threw a `TypeError` that replaced the loader's real error.\r\n */\r\nexport type PageMetadata<TLoader extends LoaderFunction | undefined = undefined> =\r\n | MetadataOutput\r\n | ((context: {\r\n data: LoaderData<TLoader>;\r\n shared: Readonly<SharedContext>;\r\n }) => MetadataOutput);\r\n"],"mappings":";;;;;;;;;;;;;;;;;AA+DA,MAAa,gBAAgB;CAC3B;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;AAGA,MAAa,kBAAkB;CAAC;CAAS;CAAe;CAAS;CAAO;AAAM;;AAG9E,MAAa,eAAe;CAAC;CAAQ;CAAS;CAAe;AAAO"}
1
+ {"version":3,"file":"metadata.mjs","names":[],"sources":["../../../../../../web/src/metadata.ts"],"sourcesContent":["import type { SharedContext } from \"./index\";\r\nimport type { LoaderData, LoaderFunction } from \"./props\";\r\n\r\n/**\r\n * What the `metadata` export may produce. The pipeline injects it into\r\n * `<head>` before the first byte; `<Head />` only decides placement.\r\n *\r\n * **Every member here is a member something READS.** The two renderers are\r\n * `components/head.ts` (the SSR'd document) and\r\n * `client/navigation/document-metadata.ts` (the same head, rewritten after a\r\n * client navigation), and they agree key for key. This type is the list\r\n * of those keys and deliberately not one entry longer: a field the type promises\r\n * and no renderer consumes is the same silence as an unknown key — the page is\r\n * served without it and nothing says so.\r\n *\r\n * Adding a member is therefore a two-file change by construction. Add it here\r\n * and {@link METADATA_KEYS} stops matching, which is a compile error\r\n * ({@link MetadataKeysAreExact}); make it match and the build gate in\r\n * `build/read-metadata-keys.ts` accepts the key — but until a renderer emits a tag\r\n * for it, the key still does nothing. Write the renderer.\r\n */\r\nexport type MetadataOutput = {\r\n title?: string;\r\n description?: string;\r\n keywords?: string | readonly string[];\r\n canonical?: string;\r\n robots?: string;\r\n /**\r\n * `og:title`/`og:description` fall back to the top-level `title`/\r\n * `description` when `openGraph` is present but the member is absent —\r\n * no other member has a fallback.\r\n */\r\n openGraph?: {\r\n title?: string;\r\n description?: string;\r\n image?: string;\r\n url?: string;\r\n type?: string;\r\n };\r\n twitter?: {\r\n card?: string;\r\n title?: string;\r\n description?: string;\r\n image?: string;\r\n };\r\n};\r\n\r\n/**\r\n * The SAME key set as {@link MetadataOutput}, as a value.\r\n *\r\n * It exists because the defect this guards is one a type cannot reach.\r\n * `export const metadata = { tittle: \"x\" }` — no annotation — is a well-typed\r\n * program: TypeScript infers `{ tittle: string }` and has nothing to compare it\r\n * against, so the page is served with no title and the build says nothing. The\r\n * only place left to catch it is where pages are DISCOVERED, and discovery\r\n * refuses to run application code — it parses. A parser cannot ask a type what\r\n * its keys are, so the keys have to exist at runtime too.\r\n *\r\n * Two lists of one thing is exactly the drift this codebase refuses elsewhere,\r\n * so they are not two lists: {@link MetadataKeysAreExact} makes any\r\n * disagreement a compile error, in either direction. Add a key to the type\r\n * alone and this file stops compiling; add it here alone, likewise.\r\n */\r\nexport const METADATA_KEYS = [\r\n \"title\",\r\n \"description\",\r\n \"keywords\",\r\n \"canonical\",\r\n \"robots\",\r\n \"openGraph\",\r\n \"twitter\",\r\n] as const;\r\n\r\n/** The members of `openGraph`, on the same terms as {@link METADATA_KEYS}. */\r\nexport const OPEN_GRAPH_KEYS = [\"title\", \"description\", \"image\", \"url\", \"type\"] as const;\r\n\r\n/** The members of `twitter`, on the same terms as {@link METADATA_KEYS}. */\r\nexport const TWITTER_KEYS = [\"card\", \"title\", \"description\", \"image\"] as const;\r\n\r\n/**\r\n * Instantiates only when `Difference` is empty. When it is not, the compiler\r\n * names the offending key in the error — \"Type '\\\"tittle\\\"' does not satisfy\r\n * the constraint 'never'\" — which is the whole message a drift needs.\r\n *\r\n * A mutual `extends` (`Exactly<A extends B, B extends A>`) says the same thing\r\n * more directly and TypeScript rejects it as a circular constraint (TS2313), so\r\n * the sets are compared by difference instead, once in each direction. BOTH\r\n * directions matter: a list that has fallen behind the type makes the build gate\r\n * reject a key it should accept, and a list that has run ahead makes it accept\r\n * one nothing renders.\r\n */\r\ntype NoDifference<Difference extends never> = Difference;\r\n\r\n/**\r\n * The drift guards, written out per key set rather than through one generic\r\n * helper: a `SameKeys<List, Keys>` alias would apply `NoDifference` to an\r\n * UNRESOLVED `Exclude<List, Keys>`, which the compiler cannot show is empty and\r\n * so rejects at the declaration (TS2344) whatever the real key sets are. Passed\r\n * concrete types, it resolves and checks the thing it is meant to check.\r\n *\r\n * Exported so they are not \"unused\", and named so a failure reads as what it\r\n * is: the key list and the type have diverged.\r\n */\r\nexport type MetadataKeysAreExact = [\r\n NoDifference<Exclude<(typeof METADATA_KEYS)[number], keyof MetadataOutput>>,\r\n NoDifference<Exclude<keyof MetadataOutput, (typeof METADATA_KEYS)[number]>>,\r\n];\r\n\r\ntype OpenGraphKey = keyof NonNullable<MetadataOutput[\"openGraph\"]>;\r\n\r\nexport type OpenGraphKeysAreExact = [\r\n NoDifference<Exclude<(typeof OPEN_GRAPH_KEYS)[number], OpenGraphKey>>,\r\n NoDifference<Exclude<OpenGraphKey, (typeof OPEN_GRAPH_KEYS)[number]>>,\r\n];\r\n\r\ntype TwitterKey = keyof NonNullable<MetadataOutput[\"twitter\"]>;\r\n\r\nexport type TwitterKeysAreExact = [\r\n NoDifference<Exclude<(typeof TWITTER_KEYS)[number], TwitterKey>>,\r\n NoDifference<Exclude<TwitterKey, (typeof TWITTER_KEYS)[number]>>,\r\n];\r\n\r\n/**\r\n * Static object OR a function of the loader's data (products.page.tsx:81-84,\r\n * product-details.page.tsx:71-74). The function form runs server-side, after\r\n * the loader, with the same `data` the component will receive — which is why\r\n * it can describe the page instead of guessing at it.\r\n *\r\n * **`data` is always present, and that is now true rather than merely\r\n * declared.** The function form runs only when the loader resolved; when it\r\n * rejected, the framework emits `ERROR_PAGE_METADATA` and this never runs\r\n * (`server/resolve-page-metadata.ts`, which explains why at length). An earlier\r\n * revision passed `{ data: undefined, error }` on the boundary path while\r\n * declaring `data` non-optional — every page that read `data` unguarded then\r\n * threw a `TypeError` that replaced the loader's real error.\r\n */\r\nexport type PageMetadata<TLoader extends LoaderFunction | undefined = undefined> =\r\n | MetadataOutput\r\n | ((context: { data: LoaderData<TLoader>; shared: Readonly<SharedContext> }) => MetadataOutput);\r\n"],"mappings":";;;;;;;;;;;;;;;;;AA+DA,MAAa,gBAAgB;CAC3B;CACA;CACA;CACA;CACA;CACA;CACA;AACF;;AAGA,MAAa,kBAAkB;CAAC;CAAS;CAAe;CAAS;CAAO;AAAM;;AAG9E,MAAa,eAAe;CAAC;CAAQ;CAAS;CAAe;AAAO"}
@@ -0,0 +1,33 @@
1
+ //#region ../node_modules/.pnpm/@mongez+concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs
2
+ function trim(text) {
3
+ return String(text).replace(/^\/|\/$/g, "");
4
+ }
5
+ /**
6
+ * Resolve `.` and `..` segments so the joined path can never escape its
7
+ * base path (`..` beyond the root is dropped instead of climbing above it).
8
+ */
9
+ function resolveDotSegments(path) {
10
+ const resolved = [];
11
+ for (const part of path.split("/")) {
12
+ if (!part || part === ".") continue;
13
+ if (part === "..") {
14
+ resolved.pop();
15
+ continue;
16
+ }
17
+ resolved.push(part);
18
+ }
19
+ return resolved.join("/");
20
+ }
21
+ /**
22
+ * Concatenate the given paths to one single path
23
+ *
24
+ * @param {...string} segments
25
+ * @returns {string}
26
+ */
27
+ function concatRoute(...segments) {
28
+ return "/" + resolveDotSegments(trim(segments.filter((value) => value && String(value).length > 0).map((segment) => "/" + trim(segment)).join("").replace(/(\/)+/g, "/")));
29
+ }
30
+
31
+ //#endregion
32
+ export { concatRoute };
33
+ //# sourceMappingURL=index.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.mjs","names":[],"sources":["../../../../../../../../../../../../../node_modules/.pnpm/@mongez+concat-route@1.2.0/node_modules/@mongez/concat-route/esm/index.mjs"],"sourcesContent":["//#region ../concat-route/src/index.ts\nfunction trim(text) {\n\treturn String(text).replace(/^\\/|\\/$/g, \"\");\n}\n/**\n* Resolve `.` and `..` segments so the joined path can never escape its\n* base path (`..` beyond the root is dropped instead of climbing above it).\n*/\nfunction resolveDotSegments(path) {\n\tconst resolved = [];\n\tfor (const part of path.split(\"/\")) {\n\t\tif (!part || part === \".\") continue;\n\t\tif (part === \"..\") {\n\t\t\tresolved.pop();\n\t\t\tcontinue;\n\t\t}\n\t\tresolved.push(part);\n\t}\n\treturn resolved.join(\"/\");\n}\n/**\n* Concatenate the given paths to one single path\n*\n* @param {...string} segments\n* @returns {string}\n*/\nfunction concatRoute(...segments) {\n\treturn \"/\" + resolveDotSegments(trim(segments.filter((value) => value && String(value).length > 0).map((segment) => \"/\" + trim(segment)).join(\"\").replace(/(\\/)+/g, \"/\")));\n}\n\n//#endregion\nexport { concatRoute as default };\n//# sourceMappingURL=index.mjs.map"],"x_google_ignoreList":[0],"mappings":";AACA,SAAS,KAAK,MAAM;CACnB,OAAO,OAAO,IAAI,CAAC,CAAC,QAAQ,YAAY,EAAE;AAC3C;;;;;AAKA,SAAS,mBAAmB,MAAM;CACjC,MAAM,WAAW,CAAC;CAClB,KAAK,MAAM,QAAQ,KAAK,MAAM,GAAG,GAAG;EACnC,IAAI,CAAC,QAAQ,SAAS,KAAK;EAC3B,IAAI,SAAS,MAAM;GAClB,SAAS,IAAI;GACb;EACD;EACA,SAAS,KAAK,IAAI;CACnB;CACA,OAAO,SAAS,KAAK,GAAG;AACzB;;;;;;;AAOA,SAAS,YAAY,GAAG,UAAU;CACjC,OAAO,MAAM,mBAAmB,KAAK,SAAS,QAAQ,UAAU,SAAS,OAAO,KAAK,CAAC,CAAC,SAAS,CAAC,CAAC,CAAC,KAAK,YAAY,MAAM,KAAK,OAAO,CAAC,CAAC,CAAC,KAAK,EAAE,CAAC,CAAC,QAAQ,UAAU,GAAG,CAAC,CAAC;AAC1K"}
@@ -1,4 +1,4 @@
1
- //#region ../web/src/runtime/register-modules.d.ts
1
+ //#region ../web/src/register-modules.d.ts
2
2
  /**
3
3
  * The universal portion of a page or layout module namespace.
4
4
  *
@@ -1,4 +1,4 @@
1
- //#region ../web/src/runtime/register-modules.ts
1
+ //#region ../web/src/register-modules.ts
2
2
  const registeredModules = /* @__PURE__ */ new WeakSet();
3
3
  function isThenable(value) {
4
4
  return value !== null && (typeof value === "object" || typeof value === "function") && typeof value.then === "function";
@@ -0,0 +1 @@
1
+ {"version":3,"file":"register-modules.mjs","names":[],"sources":["../../../../../../web/src/register-modules.ts"],"sourcesContent":["/**\n * The universal portion of a page or layout module namespace.\n *\n * `register` deliberately has no arguments and must finish synchronously: it\n * runs while both the server and browser evaluate the same module graph.\n */\nexport type RegisterableModuleNamespace = {\n readonly register?: () => unknown;\n};\n\nconst registeredModules = new WeakSet<RegisterableModuleNamespace>();\n\nfunction isThenable(value: unknown): value is PromiseLike<unknown> {\n return (\n value !== null &&\n (typeof value === \"object\" || typeof value === \"function\") &&\n typeof (value as { then?: unknown }).then === \"function\"\n );\n}\n\n/**\n * Run each module's optional universal registration hook at most once for\n * that module namespace instance. A replacement namespace from HMR naturally\n * has a new identity and is therefore registered again.\n */\nexport function registerModules(modules: readonly RegisterableModuleNamespace[]): void {\n for (const module of modules) {\n if (registeredModules.has(module)) continue;\n\n const result = module.register?.();\n\n if (isThenable(result)) {\n throw new Error(\n \"Warlock register() hooks must be synchronous and must not return a Promise or thenable.\",\n );\n }\n\n // A throwing hook (including one that returned a thenable above) is not\n // recorded, so a later route composition still exposes and can retry it.\n registeredModules.add(module);\n }\n}\n"],"mappings":";AAUA,MAAM,oCAAoB,IAAI,QAAqC;AAEnE,SAAS,WAAW,OAA+C;CACjE,OACE,UAAU,SACT,OAAO,UAAU,YAAY,OAAO,UAAU,eAC/C,OAAQ,MAA6B,SAAS;AAElD;;;;;;AAOA,SAAgB,gBAAgB,SAAuD;CACrF,KAAK,MAAM,UAAU,SAAS;EAC5B,IAAI,kBAAkB,IAAI,MAAM,GAAG;EAEnC,MAAM,SAAS,OAAO,WAAW;EAEjC,IAAI,WAAW,MAAM,GACnB,MAAM,IAAI,MACR,yFACF;EAKF,kBAAkB,IAAI,MAAM;CAC9B;AACF"}
package/esm/route.d.mts CHANGED
@@ -1,15 +1,48 @@
1
+ import { HttpContext } from "@warlock.js/core";
2
+ import { Infer } from "@warlock.js/seal";
3
+
1
4
  //#region ../web/src/route.d.ts
5
+ /**
6
+ * One guard the PAGE itself declares on `route.middleware` — the same shape a
7
+ * layout's own `middleware` export already runs (`../routing/layout-policy.ts`).
8
+ * Ordering is fixed pipeline-wide, not per declaration: every layout on the
9
+ * chain runs outermost-first, and the page's OWN middleware — whichever level
10
+ * declared it — always runs LAST, closest to the loader
11
+ * (`server/execute-page-request.ts`'s `LEVEL_ORDER`). A layout's auth gate can
12
+ * therefore never be bypassed by a page's own guard.
13
+ */
14
+ type RouteMiddleware = (ctx: HttpContext) => unknown | Promise<unknown>;
2
15
  /**
3
16
  * The `route` export's accepted shapes: the configured object
4
17
  * (`{ path, name } as const` — the `as const` is what keeps `path` a literal
5
18
  * a conditional type can parse, product-details.page.tsx:6-14) or the bare
6
19
  * path string for the 2-line minimum page (contact-us.page.tsx:27, where the
7
20
  * name is derived).
21
+ *
22
+ * `validate` and `middleware` are what let a page declare what it ACCEPTS,
23
+ * on the route itself rather than in a layout one level up — canon `f2e514c0`:
24
+ * anything a page varies on is declared by the page. `validate` is a Seal
25
+ * schema run over `{ params, query }` — kept as two SEPARATE keys, never
26
+ * merged into one bag, so a `:id` path segment and a `?id=` query key can
27
+ * never collide or silently shadow one another (canon `b79c4f55`).
8
28
  */
9
29
  type RouteDeclaration = string | {
10
30
  readonly path: string;
11
- readonly name?: string;
31
+ readonly name?: string; /** A Seal object schema validated against `{ params, query }`. */
32
+ readonly validate?: unknown; /** This page's own guards, run LAST in the pipeline's middleware chain. */
33
+ readonly middleware?: readonly RouteMiddleware[];
12
34
  };
35
+ /**
36
+ * What `request.validated()` types as when a `route.validate` schema is
37
+ * declared: `{ params, query }`, each `Infer.Output` of the matching half of
38
+ * the schema — never a flattened merge of the two (canon `b79c4f55`, point 1).
39
+ * `undefined` (no `validate` declared) types as an empty object, mirroring
40
+ * `../validation.ts`'s `ValidatedOutput` for the pre-existing top-level
41
+ * `validation` export.
42
+ */
43
+ type RouteValidatedOutput<TRoute> = TRoute extends {
44
+ readonly validate: infer TSchema;
45
+ } ? Infer.Output<TSchema> : Record<string, never>;
13
46
  //#endregion
14
- export { RouteDeclaration };
47
+ export { RouteDeclaration, RouteValidatedOutput };
15
48
  //# sourceMappingURL=route.d.mts.map
@@ -0,0 +1,22 @@
1
+ //#region ../web/src/routing/duplicate-route-path.ts
2
+ /**
3
+ * The collision-check wording both page installers raise when two pages
4
+ * compose to the same effective route path — source-agnostic: which page won
5
+ * the race to register first is the only fact either installer's own loop
6
+ * contributes, not where either page's module came from.
7
+ *
8
+ * Names neither installer: an earlier version of this message named
9
+ * `install-page-routes.ts` by path even when production's installer was the
10
+ * one raising it (a cross-citing promise, not a mechanism — see this
11
+ * function's callers for why that class of promise is exactly what this
12
+ * module now replaces). The message is accurate for whichever installer
13
+ * calls it.
14
+ */
15
+ function duplicateRoutePathMessage(input) {
16
+ const { effectivePath, existingFile, newFile, composition } = input;
17
+ return `composed route path "${effectivePath}"${composition === void 0 ? "" : ` (layout prefix "${composition.layoutPrefix}" + route.path "${composition.routePath}")`} is declared by two pages — "${existingFile}" and "${newFile}". Every page's composed route path must be unique.`;
18
+ }
19
+
20
+ //#endregion
21
+ export { duplicateRoutePathMessage };
22
+ //# sourceMappingURL=duplicate-route-path.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"duplicate-route-path.mjs","names":[],"sources":["../../../../../../../web/src/routing/duplicate-route-path.ts"],"sourcesContent":["/**\n * The collision-check wording both page installers raise when two pages\n * compose to the same effective route path — source-agnostic: which page won\n * the race to register first is the only fact either installer's own loop\n * contributes, not where either page's module came from.\n *\n * Names neither installer: an earlier version of this message named\n * `install-page-routes.ts` by path even when production's installer was the\n * one raising it (a cross-citing promise, not a mechanism — see this\n * function's callers for why that class of promise is exactly what this\n * module now replaces). The message is accurate for whichever installer\n * calls it.\n */\nexport function duplicateRoutePathMessage(input: {\n effectivePath: string;\n existingFile: string;\n newFile: string;\n /** The declared-route composition that produced `effectivePath`, when the colliding page declared one. */\n composition?: { layoutPrefix: string; routePath: string };\n}): string {\n const { effectivePath, existingFile, newFile, composition } = input;\n const compositionSuffix =\n composition === undefined\n ? \"\"\n : ` (layout prefix \"${composition.layoutPrefix}\" + route.path \"${composition.routePath}\")`;\n\n return (\n `composed route path \"${effectivePath}\"${compositionSuffix} is declared by two pages — ` +\n `\"${existingFile}\" and \"${newFile}\". Every page's composed route path must be unique.`\n );\n}\n"],"mappings":";;;;;;;;;;;;;;AAaA,SAAgB,0BAA0B,OAM/B;CACT,MAAM,EAAE,eAAe,cAAc,SAAS,gBAAgB;CAM9D,OACE,wBAAwB,cAAc,GALtC,gBAAgB,SACZ,KACA,oBAAoB,YAAY,aAAa,kBAAkB,YAAY,UAAU,IAG9B,+BACvD,aAAa,SAAS,QAAQ;AAEtC"}
@@ -0,0 +1,50 @@
1
+ import { composeRoutePath } from "./compose-route-path.mjs";
2
+ import { NestedLayoutsNotSupportedError, selectPageLayout } from "./layout-policy.mjs";
3
+
4
+ //#region ../web/src/routing/layout-level.ts
5
+ /**
6
+ * The layout-level composition rule shared by both page installers
7
+ * (`../server/install-page-routes.ts`'s dev loop, `../server/install-page-routes-from-manifest.ts`'s
8
+ * production loop): given a page's layout chain, outermost first, which layout
9
+ * hosts the render slot and what URL prefix does the whole chain compose to.
10
+ *
11
+ * Source-agnostic: this module never asks where a layout's module came from
12
+ * (`vite.ssrLoadModule` in dev, an already-loaded manifest entry in
13
+ * production) — it only consumes what each caller already read off that
14
+ * module (`renders`, `prefix`), classified against its own chain identifiers.
15
+ *
16
+ * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing
17
+ * here may import `node:fs`, `node:path`, `vite`, or `fastify`.
18
+ */
19
+ /**
20
+ * Resolves the layout LEVEL for one page's chain — the collapse from "every
21
+ * layout on the path" to "the one module the render pipeline's single layout
22
+ * slot holds" (`execute-page-request.ts`'s `PageRouteEntry["triple"]`).
23
+ *
24
+ * RENDERING is a selection: at most one layout on the chain may render, and
25
+ * {@link selectPageLayout} picks it. MIDDLEWARE and PREFIX are compositions:
26
+ * every layout on the path contributes, outermost first — a guard on an outer
27
+ * layout that the page's own directory knows nothing about is exactly the
28
+ * guard that must still run, and a prefix nobody composed is a URL nobody
29
+ * wrote down. This function resolves the render selection and the prefix
30
+ * composition; each caller composes its own middleware/loader chain, because
31
+ * that composition needs the loaded module objects this module never sees.
32
+ *
33
+ * `pageId` is caller context for {@link NestedLayoutsNotSupportedError} only —
34
+ * an app-root-relative page identifier, in practice.
35
+ */
36
+ function resolveLayoutLevel(pageId, chain) {
37
+ const selection = selectPageLayout(chain.map((entry) => ({
38
+ layout: entry.id,
39
+ renders: entry.renders
40
+ })));
41
+ if (selection.type === "rejected") throw new NestedLayoutsNotSupportedError(pageId, selection.layouts);
42
+ return {
43
+ hostId: selection.type === "selected" ? selection.layout : chain.at(-1)?.id,
44
+ prefix: chain.reduce((composed, entry) => composeRoutePath(composed, entry.prefix ?? "/"), "/")
45
+ };
46
+ }
47
+
48
+ //#endregion
49
+ export { resolveLayoutLevel };
50
+ //# sourceMappingURL=layout-level.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"layout-level.mjs","names":[],"sources":["../../../../../../../web/src/routing/layout-level.ts"],"sourcesContent":["/**\n * The layout-level composition rule shared by both page installers\n * (`../server/install-page-routes.ts`'s dev loop, `../server/install-page-routes-from-manifest.ts`'s\n * production loop): given a page's layout chain, outermost first, which layout\n * hosts the render slot and what URL prefix does the whole chain compose to.\n *\n * Source-agnostic: this module never asks where a layout's module came from\n * (`vite.ssrLoadModule` in dev, an already-loaded manifest entry in\n * production) — it only consumes what each caller already read off that\n * module (`renders`, `prefix`), classified against its own chain identifiers.\n *\n * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing\n * here may import `node:fs`, `node:path`, `vite`, or `fastify`.\n */\nimport { composeRoutePath } from \"./compose-route-path\";\nimport { NestedLayoutsNotSupportedError, selectPageLayout } from \"./layout-policy\";\n\n/** One layout on a page's chain, already classified by the caller against its own loaded module. */\nexport type LayoutLevelEntry = {\n /** Opaque identifier for the layout — an app-root-relative POSIX path, in practice. */\n id: string;\n /** Whether this layout's module has a default export — see `layout-policy.ts`. */\n renders: boolean;\n /** This layout's declared `prefix` export, when it has one. */\n prefix: string | undefined;\n};\n\nexport type LayoutLevel = {\n /**\n * The layout the handler's layout slot is registered under, or `undefined`\n * when the chain is empty: the layout that RENDERS, or — when none does —\n * the nearest one, which is the slot both installers have always used and\n * so the choice that changes nothing but the middleware for a chain with no\n * wrapper in it.\n */\n hostId: string | undefined;\n /** Every layout's `prefix`, composed outermost first. */\n prefix: string;\n};\n\n/**\n * Resolves the layout LEVEL for one page's chain — the collapse from \"every\n * layout on the path\" to \"the one module the render pipeline's single layout\n * slot holds\" (`execute-page-request.ts`'s `PageRouteEntry[\"triple\"]`).\n *\n * RENDERING is a selection: at most one layout on the chain may render, and\n * {@link selectPageLayout} picks it. MIDDLEWARE and PREFIX are compositions:\n * every layout on the path contributes, outermost first — a guard on an outer\n * layout that the page's own directory knows nothing about is exactly the\n * guard that must still run, and a prefix nobody composed is a URL nobody\n * wrote down. This function resolves the render selection and the prefix\n * composition; each caller composes its own middleware/loader chain, because\n * that composition needs the loaded module objects this module never sees.\n *\n * `pageId` is caller context for {@link NestedLayoutsNotSupportedError} only —\n * an app-root-relative page identifier, in practice.\n */\nexport function resolveLayoutLevel(\n pageId: string,\n chain: readonly LayoutLevelEntry[],\n): LayoutLevel {\n const selection = selectPageLayout(\n chain.map((entry) => ({ layout: entry.id, renders: entry.renders })),\n );\n\n if (selection.type === \"rejected\") {\n throw new NestedLayoutsNotSupportedError(pageId, selection.layouts);\n }\n\n return {\n hostId: selection.type === \"selected\" ? selection.layout : chain.at(-1)?.id,\n prefix: chain.reduce((composed, entry) => composeRoutePath(composed, entry.prefix ?? \"/\"), \"/\"),\n };\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AAyDA,SAAgB,mBACd,QACA,OACa;CACb,MAAM,YAAY,iBAChB,MAAM,KAAK,WAAW;EAAE,QAAQ,MAAM;EAAI,SAAS,MAAM;CAAQ,EAAE,CACrE;CAEA,IAAI,UAAU,SAAS,YACrB,MAAM,IAAI,+BAA+B,QAAQ,UAAU,OAAO;CAGpE,OAAO;EACL,QAAQ,UAAU,SAAS,aAAa,UAAU,SAAS,MAAM,GAAG,EAAE,CAAC,EAAE;EACzE,QAAQ,MAAM,QAAQ,UAAU,UAAU,iBAAiB,UAAU,MAAM,UAAU,GAAG,GAAG,GAAG;CAChG;AACF"}