@warlock.js/web 5.2.0 → 5.2.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +15 -0
- package/esm/build/contribution.d.mts +1 -1
- package/esm/build/contribution.mjs +1 -1
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +1 -1
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-client-registry.mjs +1 -1
- package/esm/build/generate-client-registry.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs +1 -1
- package/esm/build/generate-pages-barrel.mjs.map +1 -1
- package/esm/build/page-default-export.mjs +1 -1
- package/esm/build/page-default-export.mjs.map +1 -1
- package/esm/build/page-routes-manifest.mjs +1 -1
- package/esm/build/page-routes-manifest.mjs.map +1 -1
- package/esm/build/public-files.mjs +1 -1
- package/esm/build/public-files.mjs.map +1 -1
- package/esm/build/read-route-exports.mjs +1 -1
- package/esm/build/read-route-exports.mjs.map +1 -1
- package/esm/client/build-hydrated-tree.mjs +1 -1
- package/esm/client/build-hydrated-tree.mjs.map +1 -1
- package/esm/client/hydrate-page.mjs +1 -1
- package/esm/client/hydrate-page.mjs.map +1 -1
- package/esm/client/navigation/current-route.d.mts +1 -1
- package/esm/client/navigation/current-route.mjs +1 -1
- package/esm/client/navigation/current-route.mjs.map +1 -1
- package/esm/client/navigation/fetch-page-data.mjs +1 -1
- package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
- package/esm/client/navigation/navigation-root.mjs +1 -1
- package/esm/client/navigation/navigation-root.mjs.map +1 -1
- package/esm/client/navigation/prefetch.mjs +1 -1
- package/esm/client/navigation/prefetch.mjs.map +1 -1
- package/esm/client/navigation/refresh.d.mts +1 -1
- package/esm/client/navigation/refresh.mjs +1 -1
- package/esm/client/navigation/refresh.mjs.map +1 -1
- package/esm/client/navigation/scroll-to-fragment.mjs +1 -1
- package/esm/client/navigation/scroll-to-fragment.mjs.map +1 -1
- package/esm/client/navigation/verbs.d.mts +1 -1
- package/esm/client/navigation/verbs.mjs +1 -1
- package/esm/client/navigation/verbs.mjs.map +1 -1
- package/esm/client/runtime/manifest.d.mts +1 -1
- package/esm/client/runtime/manifest.mjs +1 -1
- package/esm/client/runtime/manifest.mjs.map +1 -1
- package/esm/client/runtime/matcher.d.mts +1 -1
- package/esm/client/runtime/matcher.mjs +1 -1
- package/esm/client/runtime/matcher.mjs.map +1 -1
- package/esm/client/runtime/types.d.mts +1 -1
- package/esm/components/default-app.mjs +1 -1
- package/esm/components/default-app.mjs.map +1 -1
- package/esm/components/document-context.d.mts +1 -1
- package/esm/components/document-context.mjs +1 -1
- package/esm/components/document-context.mjs.map +1 -1
- package/esm/components/head.d.mts +1 -1
- package/esm/components/head.mjs +1 -1
- package/esm/components/head.mjs.map +1 -1
- package/esm/components/link.d.mts +1 -1
- package/esm/components/link.mjs +1 -1
- package/esm/components/link.mjs.map +1 -1
- package/esm/components/scripts.d.mts +1 -1
- package/esm/components/scripts.mjs +1 -1
- package/esm/components/scripts.mjs.map +1 -1
- package/esm/context.d.mts +1 -1
- package/esm/hydration/index.mjs +1 -1
- package/esm/hydration/index.mjs.map +1 -1
- package/esm/hydration-payload.mjs +1 -1
- package/esm/hydration-payload.mjs.map +1 -1
- package/esm/index.d.mts +1 -1
- package/esm/loaders.d.mts +1 -1
- package/esm/metadata.d.mts +1 -1
- package/esm/metadata.mjs +1 -1
- package/esm/metadata.mjs.map +1 -1
- package/esm/props.d.mts +1 -1
- package/esm/route.d.mts +1 -1
- package/esm/routing/compose-route-path.d.mts +1 -1
- package/esm/routing/compose-route-path.mjs +1 -1
- package/esm/routing/compose-route-path.mjs.map +1 -1
- package/esm/routing/data-request.mjs +1 -1
- package/esm/routing/data-request.mjs.map +1 -1
- package/esm/routing/filesystem-route.mjs +1 -1
- package/esm/routing/filesystem-route.mjs.map +1 -1
- package/esm/routing/layout-policy.mjs +1 -1
- package/esm/routing/layout-policy.mjs.map +1 -1
- package/esm/routing/navigator.mjs +1 -1
- package/esm/routing/navigator.mjs.map +1 -1
- package/esm/routing/query-string.d.mts +1 -1
- package/esm/routing/query-string.mjs +1 -1
- package/esm/routing/query-string.mjs.map +1 -1
- package/esm/routing/route-identity.mjs +1 -1
- package/esm/routing/route-identity.mjs.map +1 -1
- package/esm/routing/route-table.d.mts +1 -1
- package/esm/routing/route-table.mjs +1 -1
- package/esm/routing/route-table.mjs.map +1 -1
- package/esm/routing/router-events.d.mts +1 -1
- package/esm/routing/router-events.mjs +1 -1
- package/esm/routing/router-events.mjs.map +1 -1
- package/esm/routing/url-fragment.mjs +1 -1
- package/esm/routing/url-fragment.mjs.map +1 -1
- package/esm/runtime/register-modules.d.mts +1 -1
- package/esm/runtime/register-modules.mjs +1 -1
- package/esm/runtime/register-modules.mjs.map +1 -1
- package/esm/server/build-hydration-payload.mjs +1 -1
- package/esm/server/build-hydration-payload.mjs.map +1 -1
- package/esm/server/client-asset-url-prefix.d.mts +1 -1
- package/esm/server/client-asset-url-prefix.mjs +1 -1
- package/esm/server/client-asset-url-prefix.mjs.map +1 -1
- package/esm/server/create-page-module-loader.d.mts +1 -1
- package/esm/server/create-page-module-loader.mjs +1 -1
- package/esm/server/create-page-module-loader.mjs.map +1 -1
- package/esm/server/create-page-route-handler.d.mts +1 -1
- package/esm/server/create-page-route-handler.mjs +1 -1
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/dev-server.mjs +1 -1
- package/esm/server/dev-server.mjs.map +1 -1
- package/esm/server/error-page.d.mts +1 -1
- package/esm/server/error-page.mjs +1 -1
- package/esm/server/error-page.mjs.map +1 -1
- package/esm/server/execute-page-request.d.mts +1 -1
- package/esm/server/execute-page-request.mjs +1 -1
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/execute-page-request.types.d.mts +1 -1
- package/esm/server/hydration-client-url.d.mts +1 -1
- package/esm/server/hydration-client-url.mjs +1 -1
- package/esm/server/hydration-client-url.mjs.map +1 -1
- package/esm/server/install-page-routes-from-manifest.d.mts +1 -1
- package/esm/server/install-page-routes-from-manifest.mjs +1 -1
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +1 -1
- package/esm/server/install-page-routes.mjs +1 -1
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/install-production-page-routes.mjs +1 -1
- package/esm/server/install-production-page-routes.mjs.map +1 -1
- package/esm/server/match-page-route.mjs +1 -1
- package/esm/server/match-page-route.mjs.map +1 -1
- package/esm/server/not-found-page.d.mts +1 -1
- package/esm/server/not-found-page.mjs +1 -1
- package/esm/server/not-found-page.mjs.map +1 -1
- package/esm/server/page-context.d.mts +1 -1
- package/esm/server/page-context.mjs +1 -1
- package/esm/server/page-context.mjs.map +1 -1
- package/esm/server/page-file-change.mjs +1 -1
- package/esm/server/page-file-change.mjs.map +1 -1
- package/esm/server/page-manifest.d.mts +1 -1
- package/esm/server/page-manifest.mjs +1 -1
- package/esm/server/page-manifest.mjs.map +1 -1
- package/esm/server/page-render-bundle.mjs +1 -1
- package/esm/server/page-render-bundle.mjs.map +1 -1
- package/esm/server/page-route-reload.mjs +1 -1
- package/esm/server/page-route-reload.mjs.map +1 -1
- package/esm/server/register-production-public-files.mjs +1 -1
- package/esm/server/register-production-public-files.mjs.map +1 -1
- package/esm/server/render-page.d.mts +1 -1
- package/esm/server/render-page.mjs +1 -1
- package/esm/server/render-page.mjs.map +1 -1
- package/esm/server/resolve-page-metadata.mjs +1 -1
- package/esm/server/resolve-page-metadata.mjs.map +1 -1
- package/esm/server/resolve-validation-data.mjs +1 -1
- package/esm/server/resolve-validation-data.mjs.map +1 -1
- package/esm/server/settle-page-response.d.mts +1 -1
- package/esm/server/settle-page-response.mjs +1 -1
- package/esm/server/settle-page-response.mjs.map +1 -1
- package/esm/server/stylesheet-urls.d.mts +1 -1
- package/esm/server/stylesheet-urls.mjs +1 -1
- package/esm/server/stylesheet-urls.mjs.map +1 -1
- package/esm/server/unregistered-pages.mjs +1 -1
- package/esm/server/unregistered-pages.mjs.map +1 -1
- package/esm/server/web-connector-factory.d.mts +1 -1
- package/esm/server/web-connector-factory.mjs +1 -1
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.d.mts +1 -1
- package/esm/server/web-connector.mjs +1 -1
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/shared.d.mts +1 -1
- package/esm/shared.mjs +1 -1
- package/esm/shared.mjs.map +1 -1
- package/esm/validation.d.mts +1 -1
- package/esm/vite/app-convention-aliases.mjs +1 -1
- package/esm/vite/app-convention-aliases.mjs.map +1 -1
- package/esm/vite/build-client.d.mts +1 -1
- package/esm/vite/build-client.mjs +1 -1
- package/esm/vite/build-client.mjs.map +1 -1
- package/esm/vite/gate-a-resolve.d.mts +1 -1
- package/esm/vite/gate-a-resolve.mjs +1 -1
- package/esm/vite/gate-a-resolve.mjs.map +1 -1
- package/esm/vite/gate-b-secrets.d.mts +1 -1
- package/esm/vite/gate-b-secrets.mjs +1 -1
- package/esm/vite/gate-b-secrets.mjs.map +1 -1
- package/esm/vite/gate-c-verify.d.mts +1 -1
- package/esm/vite/gate-c-verify.mjs +1 -1
- package/esm/vite/gate-c-verify.mjs.map +1 -1
- package/esm/vite/hydration-entries.d.mts +1 -1
- package/esm/vite/hydration-entries.mjs +1 -1
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/index.d.mts +1 -1
- package/esm/vite/index.mjs +1 -1
- package/esm/vite/index.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.d.mts +1 -1
- package/esm/vite/page-registry-plugin.mjs +1 -1
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/esm/vite/projection.d.mts +1 -1
- package/esm/vite/projection.mjs +1 -1
- package/esm/vite/projection.mjs.map +1 -1
- package/package.json +3 -3
package/esm/components/head.mjs
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { useDocumentContext } from "./document-context.mjs";
|
|
2
2
|
import { Fragment, createElement } from "react";
|
|
3
3
|
|
|
4
|
-
//#region web/src/components/head.ts
|
|
4
|
+
//#region ../web/src/components/head.ts
|
|
5
5
|
/**
|
|
6
6
|
* OPTIONAL placement override for the framework's `<head>` injection — the
|
|
7
7
|
* page's `metadata` output, the stylesheet/preload tags and the
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"head.mjs","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"head.mjs","names":[],"sources":["../../../../../../../web/src/components/head.ts"],"sourcesContent":["import { createElement, Fragment, type ReactElement } from \"react\";\nimport { useDocumentContext } from \"./document-context\";\n\n/**\n * OPTIONAL placement override for the framework's `<head>` injection — the\n * page's `metadata` output, the stylesheet/preload tags and the\n * canonical/alternate links land here when it is present, and at the default\n * position when it is not (root.tsx:85-92). Takes no props: it says WHERE, and\n * only that.\n */\nexport function Head(): ReactElement {\n const { metadata } = useDocumentContext(\"Head\");\n\n const keywords =\n metadata?.keywords === undefined\n ? undefined\n : Array.isArray(metadata.keywords)\n ? metadata.keywords.join(\", \")\n : metadata.keywords;\n\n const openGraph = metadata?.openGraph;\n // Falls back to the top-level fields — the only two fallbacks the type declares.\n const ogTitle = openGraph?.title ?? metadata?.title;\n const ogDescription = openGraph?.description ?? metadata?.description;\n\n const twitter = metadata?.twitter;\n\n return createElement(\n Fragment,\n null,\n createElement(\"meta\", { charSet: \"utf-8\" }),\n metadata?.title !== undefined ? createElement(\"title\", null, metadata.title) : null,\n metadata?.description !== undefined\n ? createElement(\"meta\", { name: \"description\", content: metadata.description })\n : null,\n keywords !== undefined ? createElement(\"meta\", { name: \"keywords\", content: keywords }) : null,\n metadata?.canonical !== undefined\n ? createElement(\"link\", { rel: \"canonical\", href: metadata.canonical })\n : null,\n metadata?.robots !== undefined\n ? createElement(\"meta\", { name: \"robots\", content: metadata.robots })\n : null,\n openGraph !== undefined && ogTitle !== undefined\n ? createElement(\"meta\", { property: \"og:title\", content: ogTitle })\n : null,\n openGraph !== undefined && ogDescription !== undefined\n ? createElement(\"meta\", { property: \"og:description\", content: ogDescription })\n : null,\n openGraph?.image !== undefined\n ? createElement(\"meta\", { property: \"og:image\", content: openGraph.image })\n : null,\n openGraph?.url !== undefined\n ? createElement(\"meta\", { property: \"og:url\", content: openGraph.url })\n : null,\n openGraph?.type !== undefined\n ? createElement(\"meta\", { property: \"og:type\", content: openGraph.type })\n : null,\n twitter?.card !== undefined\n ? createElement(\"meta\", { name: \"twitter:card\", content: twitter.card })\n : null,\n twitter?.title !== undefined\n ? createElement(\"meta\", { name: \"twitter:title\", content: twitter.title })\n : null,\n twitter?.description !== undefined\n ? createElement(\"meta\", { name: \"twitter:description\", content: twitter.description })\n : null,\n twitter?.image !== undefined\n ? createElement(\"meta\", { name: \"twitter:image\", content: twitter.image })\n : null,\n );\n}\n"],"mappings":";;;;;;;;;;;AAUA,SAAgB,OAAqB;CACnC,MAAM,EAAE,aAAa,mBAAmB,MAAM;CAE9C,MAAM,WACJ,UAAU,aAAa,SACnB,SACA,MAAM,QAAQ,SAAS,QAAQ,IAC7B,SAAS,SAAS,KAAK,IAAI,IAC3B,SAAS;CAEjB,MAAM,YAAY,UAAU;CAE5B,MAAM,UAAU,WAAW,SAAS,UAAU;CAC9C,MAAM,gBAAgB,WAAW,eAAe,UAAU;CAE1D,MAAM,UAAU,UAAU;CAE1B,OAAO,cACL,UACA,MACA,cAAc,QAAQ,EAAE,SAAS,QAAQ,CAAC,GAC1C,UAAU,UAAU,SAAY,cAAc,SAAS,MAAM,SAAS,KAAK,IAAI,MAC/E,UAAU,gBAAgB,SACtB,cAAc,QAAQ;EAAE,MAAM;EAAe,SAAS,SAAS;CAAY,CAAC,IAC5E,MACJ,aAAa,SAAY,cAAc,QAAQ;EAAE,MAAM;EAAY,SAAS;CAAS,CAAC,IAAI,MAC1F,UAAU,cAAc,SACpB,cAAc,QAAQ;EAAE,KAAK;EAAa,MAAM,SAAS;CAAU,CAAC,IACpE,MACJ,UAAU,WAAW,SACjB,cAAc,QAAQ;EAAE,MAAM;EAAU,SAAS,SAAS;CAAO,CAAC,IAClE,MACJ,cAAc,UAAa,YAAY,SACnC,cAAc,QAAQ;EAAE,UAAU;EAAY,SAAS;CAAQ,CAAC,IAChE,MACJ,cAAc,UAAa,kBAAkB,SACzC,cAAc,QAAQ;EAAE,UAAU;EAAkB,SAAS;CAAc,CAAC,IAC5E,MACJ,WAAW,UAAU,SACjB,cAAc,QAAQ;EAAE,UAAU;EAAY,SAAS,UAAU;CAAM,CAAC,IACxE,MACJ,WAAW,QAAQ,SACf,cAAc,QAAQ;EAAE,UAAU;EAAU,SAAS,UAAU;CAAI,CAAC,IACpE,MACJ,WAAW,SAAS,SAChB,cAAc,QAAQ;EAAE,UAAU;EAAW,SAAS,UAAU;CAAK,CAAC,IACtE,MACJ,SAAS,SAAS,SACd,cAAc,QAAQ;EAAE,MAAM;EAAgB,SAAS,QAAQ;CAAK,CAAC,IACrE,MACJ,SAAS,UAAU,SACf,cAAc,QAAQ;EAAE,MAAM;EAAiB,SAAS,QAAQ;CAAM,CAAC,IACvE,MACJ,SAAS,gBAAgB,SACrB,cAAc,QAAQ;EAAE,MAAM;EAAuB,SAAS,QAAQ;CAAY,CAAC,IACnF,MACJ,SAAS,UAAU,SACf,cAAc,QAAQ;EAAE,MAAM;EAAiB,SAAS,QAAQ;CAAM,CAAC,IACvE,IACN;AACF"}
|
package/esm/components/link.mjs
CHANGED
|
@@ -3,7 +3,7 @@ import { prefetchPageData } from "../client/navigation/prefetch.mjs";
|
|
|
3
3
|
import { currentNavigator } from "../routing/navigator.mjs";
|
|
4
4
|
import { createElement } from "react";
|
|
5
5
|
|
|
6
|
-
//#region web/src/components/link.ts
|
|
6
|
+
//#region ../web/src/components/link.ts
|
|
7
7
|
const DESTINATION_PROPS = [
|
|
8
8
|
"to",
|
|
9
9
|
"href",
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"link.mjs","names":[],"sources":["C:/Work/Warlock.js/web/src/components/link.ts"],"sourcesContent":["import { createElement } from \"react\";\nimport type {\n AnchorHTMLAttributes,\n ComponentType,\n FocusEvent,\n MouseEvent,\n ReactElement,\n} from \"react\";\nimport { prefetchPageData } from \"../client/navigation/prefetch\";\nimport { currentNavigator } from \"../routing/navigator\";\nimport { href, knownRouteNames } from \"../routing/route-table\";\n\n/**\n * `<Link>` is SUGAR over `href()`, and deliberately thin.\n *\n * `href(name, params, query)` is the durable primitive — it serves emails,\n * redirects, `Location` headers and every non-React caller, none of which can\n * render a component. This file adds one thing to it: an anchor element.\n *\n * It renders a real `<a href>`. Client-side interception is a later slice of\n * the navigation runtime and lands here without changing this API, which is the\n * point of routing everything through `href` first: navigation becomes a\n * BEHAVIOUR change, not an API change.\n *\n * ── Parity with `@mongez/react-router` ───────────────────────────────────────\n * `href`, `newTab`, `email`, `tel`, `component` and `prefetch` are spelled\n * exactly as MRR spells them, so a component moved across keeps compiling.\n * `params` and `query` are ours and have no MRR equivalent: they pair with the\n * typed `href()` helper, which is what makes a route NAME — rather than a URL —\n * the thing a call site names.\n *\n * ── The semantic divergence this file bridges ────────────────────────────────\n * MRR's `to` is a PATH. Ours was a route NAME, and only a name — which meant a\n * component moved across from MRR compiled and then threw at render, because\n * `\"/products\"` is not the name of anything. The two packages disagreed about\n * what the most-used prop in either of them MEANS.\n *\n * Since 2026-08-24 (owner ruling) `to`/`href` accept BOTH, discriminated by\n * SHAPE — see {@link isLiteralUrl}. That is what makes MRR code portable, and\n * it costs nothing at a Warlock call site, because the two grammars cannot\n * collide: a route name never begins with `/` and never carries a `scheme:`.\n * The ruling RESTS on that, so this file asserts it rather than trusting it\n * ({@link RouteNameShapeCollisionError}).\n */\n\ntype AnchorProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"href\">;\n\n/**\n * Where the link goes. Every field is documented here once; which COMBINATIONS\n * are legal is decided by {@link LinkDestination}.\n */\ntype LinkDestinationProps = {\n /**\n * A route NAME, or a literal URL — told apart by SHAPE.\n *\n * `\"products.details\"` is a NAME and is resolved through the route table. A\n * page that moves changes its URL and keeps its name, so every call site\n * survives the move; a dead name throws at render naming the routes that do\n * exist, rather than rendering an anchor that 404s. This is the form to\n * prefer, and the only one `params` and `query` apply to.\n *\n * `\"/pricing\"`, `\"https://stripe.com\"`, `\"mailto:sales@example.com\"` and any\n * other `scheme:` are LITERAL — passed through to the element untouched, with\n * no route lookup at all. An app links out, and a route name is not a thing\n * you can have for a page that is not yours.\n */\n to?: string;\n /**\n * An alias of {@link to}, for parity with `@mongez/react-router`. Identical\n * in every respect, including which shapes it accepts.\n */\n href?: string;\n /** Renders a `mailto:` link. Not an in-app navigation. */\n email?: string;\n /** Renders a `tel:` link. Not an in-app navigation. */\n tel?: string;\n /**\n * Values for the route's `:param` segments, e.g. `{ id }` for\n * `\"/products/:id\"`. Only meaningful with a route NAME.\n */\n params?: Record<string, unknown>;\n /**\n * Query string values; an `undefined` value is omitted. Only meaningful with\n * a route NAME.\n */\n query?: Record<string, unknown>;\n};\n\n/**\n * EXACTLY ONE destination, enforced by the type.\n *\n * The alternative — a documented precedence such as \"`to` wins over `href`\" —\n * is silent by construction: the losing prop goes on compiling and goes on\n * reading like it works at the call site, and the anchor points at the wrong\n * page. Refusing the pair costs a call site one edit and can never be\n * misread. The runtime refuses it as well, because a JavaScript caller and a\n * cast both get past this.\n */\ntype LinkDestination =\n | { to: string; href?: never; email?: never; tel?: never }\n | { href: string; to?: never; email?: never; tel?: never }\n | { email: string; to?: never; href?: never; tel?: never }\n | { tel: string; to?: never; href?: never; email?: never };\n\nexport type LinkProps = AnchorProps &\n LinkDestinationProps &\n LinkDestination & {\n /**\n * Open in a new browsing context: `target=\"_blank\"` plus the `rel` that\n * stops the opened page from reaching back through `window.opener`.\n *\n * A caller's own `target`/`rel` win — this only fills in what was not said.\n */\n newTab?: boolean;\n /**\n * Render as something other than `<a>` — a tag name or a component.\n *\n * It receives the resolved `href`, the click handler and every remaining\n * prop, so a design-system anchor keeps client-side navigation as long as\n * it spreads what it is given onto the element it renders.\n *\n * `ComponentType<any>` is MRR's signature, kept verbatim: the component is\n * the caller's and its props are unknowable from here.\n */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n component?: ComponentType<any> | string;\n /**\n * Fetch this page's data when the pointer or the keyboard reaches the link,\n * so the click that follows swaps without a round trip.\n *\n * A GUESS, and treated as one everywhere: it is never awaited, a failure is\n * silent, and the click behaves exactly as it would without it. Opt-in per\n * link rather than on by default, because every prefetch is a request the\n * user did not ask for and someone pays for the bandwidth.\n *\n * IGNORED for anything that is not an in-app navigation — an external URL,\n * `mailto:`, `tel:`, `newTab`, any explicit `target`. Prefetching those\n * would mean issuing a cross-origin request to a third party on hover,\n * which is not a thing a link component may decide to do.\n */\n prefetch?: boolean;\n };\n\nconst DESTINATION_PROPS = [\"to\", \"href\", \"email\", \"tel\"] as const;\n\nexport class AmbiguousLinkDestinationError extends Error {\n public constructor(public readonly providedProps: readonly string[]) {\n super(\n `Warlock <Link> was given ${providedProps\n .map(name => JSON.stringify(name))\n .join(\" and \")}, but a link goes to exactly one place. There is no ` +\n \"precedence between them on purpose: one of the two would silently win, and the \" +\n \"call site would go on naming a destination that never renders. Delete the one \" +\n \"you did not mean.\",\n );\n this.name = \"AmbiguousLinkDestinationError\";\n }\n}\n\nexport class MissingLinkDestinationError extends Error {\n public constructor() {\n super(\n `Warlock <Link> was given no destination. Pass exactly one of ${DESTINATION_PROPS.map(\n name => JSON.stringify(name),\n ).join(\", \")}. It is not defaulted to the current page: an anchor with an empty ` +\n \"`href` renders as a working link and reloads the page when clicked, which is a \" +\n \"harder fault to see than this message.\",\n );\n this.name = \"MissingLinkDestinationError\";\n }\n}\n\nexport class RouteArgumentsOnLiteralUrlError extends Error {\n public constructor(\n public readonly url: string,\n public readonly providedProps: readonly string[],\n ) {\n super(\n `Warlock <Link> was given ${providedProps\n .map(name => JSON.stringify(name))\n .join(\" and \")} alongside the literal URL \"${url}\". Those apply to a route NAME, ` +\n \"which is resolved through the route table; a literal URL is passed through exactly \" +\n \"as written, so they would have been dropped and the link would have pointed at an \" +\n \"unfiltered page that still looked right at the call site. Put the values in the URL, \" +\n \"or name the route.\",\n );\n this.name = \"RouteArgumentsOnLiteralUrlError\";\n }\n}\n\n/**\n * The ruling's one assumption, broken. See the module doc comment: telling a\n * literal URL from a route NAME by shape is only safe while no route is NAMED\n * like a URL, and nothing in the route pipeline validates a hand-declared\n * `route.name`. So the collision is checked at the one place it could do harm,\n * where it is a loud refusal instead of an anchor that silently points\n * somewhere else.\n */\nexport class RouteNameShapeCollisionError extends Error {\n public constructor(public readonly routeName: string) {\n super(\n `Warlock route table: a route is NAMED ${JSON.stringify(routeName)}, which is shaped ` +\n \"like a URL. <Link> tells a literal URL from a route name by shape — a destination \" +\n \"starting with `/` or carrying a `scheme:` is passed through untouched — so this name \" +\n \"can never be resolved, and every link to it would silently point at that path \" +\n \"instead. Rename the route (`route = { path, name }`) to a dotted name such as \" +\n `${JSON.stringify(routeName.replace(/^\\/+/, \"\").replace(/\\//g, \".\") || \"index\")}.`,\n );\n this.name = \"RouteNameShapeCollisionError\";\n }\n}\n\ntype Destination = {\n /** What lands on the element's `href`. */\n url: string;\n /**\n * Whether this URL is a page in THIS app — the only kind the client\n * navigation runtime may be asked about, and the only kind that may be\n * prefetched. `mailto:`, `tel:` and an external URL hand off to another\n * application or another origin entirely, so intercepting any of them would\n * break it, and speculatively fetching one would be a cross-origin request\n * the developer never asked for.\n */\n isInApp: boolean;\n};\n\n/**\n * Any RFC 3986 scheme — `https:`, `mailto:`, `tel:`, `whatsapp:`, an app's own\n * custom one. Matched generically rather than as a list of known schemes: a\n * list would silently resolve `bitcoin:...` through the route table, which is\n * the exact failure this ruling exists to remove, and it would have to grow\n * forever.\n */\nconst SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:/i;\n\n/**\n * Whether this destination is a URL to be used as written, rather than a route\n * name to resolve.\n *\n * The whole discriminator, and deliberately the whole of it: two cheap shape\n * tests, no parsing, no matching. Anything more would be a SECOND route matcher\n * living beside the server's, which this codebase refuses everywhere it comes\n * up — a matcher that disagreed with the real one would produce links to pages\n * that do not exist.\n */\nfunction isLiteralUrl(destination: string): boolean {\n return destination.startsWith(\"/\") || SCHEME_PATTERN.test(destination);\n}\n\n/**\n * Whether a literal URL addresses THIS app.\n *\n * A path is ours. A `scheme:` is not — including `https:` to our own origin,\n * which would need `window.location` to recognise and would make the answer\n * depend on where the code is running. And `//host/path` is PROTOCOL-RELATIVE:\n * it starts with a slash and is nonetheless another origin, which is precisely\n * the case a \"starts with `/`\" test alone would hand to the navigator, where it\n * becomes a `pushState` to a foreign origin — a SecurityError — or a\n * speculative fetch of a third-party host.\n */\nfunction addressesThisApp(url: string): boolean {\n return url.startsWith(\"/\") && !url.startsWith(\"//\");\n}\n\n/**\n * Refuses the one table that would make {@link isLiteralUrl} wrong.\n *\n * Reached only for a destination already judged literal, so the cost is a scan\n * of the published names for links that were never going to hit the table\n * anyway — and zero for the route-name form, which is the common one. The right\n * permanent home for this is `publishRouteTable`, at boot, once (see the report\n * on this card).\n */\nfunction assertNotARouteName(url: string): void {\n if (knownRouteNames().includes(url)) throw new RouteNameShapeCollisionError(url);\n}\n\nconst ROUTE_ARGUMENT_PROPS = [\"params\", \"query\"] as const;\n\nfunction resolveDestination(props: LinkDestinationProps): Destination {\n const provided = DESTINATION_PROPS.filter(name => props[name] !== undefined);\n\n if (provided.length > 1) throw new AmbiguousLinkDestinationError(provided);\n\n if (provided.length === 0) throw new MissingLinkDestinationError();\n\n if (props.email !== undefined) return { url: `mailto:${props.email}`, isInApp: false };\n\n if (props.tel !== undefined) return { url: `tel:${props.tel}`, isInApp: false };\n\n const destination = (props.to ?? props.href) as string;\n\n /*\n LITERAL: `/pricing`, `https://stripe.com`, `mailto:…`, `whatsapp://…`. It\n goes to the element exactly as written and the route table is never\n consulted — there is nothing to look up, and looking anyway is what used to\n throw `UnknownRouteNameError` on every link out of the application.\n */\n if (isLiteralUrl(destination)) {\n const routeArguments = ROUTE_ARGUMENT_PROPS.filter(name => props[name] !== undefined);\n\n if (routeArguments.length > 0) {\n throw new RouteArgumentsOnLiteralUrlError(destination, routeArguments);\n }\n\n assertNotARouteName(destination);\n\n return { url: destination, isInApp: addressesThisApp(destination) };\n }\n\n /*\n A NAME, resolved against the route table published at boot from the SAME\n discovery result the server registered its routes from. The previous version\n of this file restated six URLs in a literal map, so linking to any seventh\n page in the application threw — the map was the limit on what could be\n linked, and nothing said so at the call site.\n */\n return { url: href(destination, props.params, props.query), isInApp: true };\n}\n\n/**\n * Whether this click should be left entirely to the browser.\n *\n * Every case here is a click that MEANS something other than \"go there in this\n * tab\", and intercepting any of them would take away behaviour the user\n * explicitly asked for:\n *\n * - a modifier or middle button: open in a new tab/window, or download\n * - `download`: save the resource, do not render it\n * - already prevented: something upstream in the tree handled this click\n *\n * Left button with no modifiers is the only click that means plain navigation.\n * The `target` case is decided before this, from the RESOLVED target, because\n * `newTab` sets it after the caller's props are read.\n */\nfunction isPlainLeftClick(event: MouseEvent<HTMLAnchorElement>): boolean {\n return (\n event.button === 0 &&\n !event.metaKey &&\n !event.ctrlKey &&\n !event.shiftKey &&\n !event.altKey &&\n !event.defaultPrevented\n );\n}\n\n/**\n * A target other than `_self` names ANOTHER browsing context — `_blank`, but\n * also `_parent`, `_top` and any named frame. Client navigation rewrites the\n * history of THIS one, so none of them are ours to intercept.\n */\nfunction opensAnotherContext(target: string | undefined): boolean {\n return target !== undefined && target !== \"_self\";\n}\n\nexport function Link({\n to,\n href: hrefAlias,\n email,\n tel,\n params,\n query,\n newTab,\n prefetch,\n component: Component = \"a\",\n children,\n onClick,\n ...elementProps\n}: LinkProps): ReactElement {\n const { url, isInApp } = resolveDestination({\n to,\n href: hrefAlias,\n email,\n tel,\n params,\n query,\n });\n\n const target = elementProps.target ?? (newTab === true ? \"_blank\" : undefined);\n\n // Only a DEFAULT: a caller that wrote its own `rel` (`\"me noopener\"`,\n // `\"external\"`) meant it, and overwriting it would delete a value the page\n // depends on to say something this component knows nothing about.\n const rel =\n elementProps.rel ?? (target === \"_blank\" ? \"noopener noreferrer\" : undefined);\n\n const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {\n // The caller's handler runs FIRST and unconditionally — it may be doing\n // analytics, closing a menu, or calling `preventDefault()` to veto the\n // navigation outright. Deciding before it ran would let this component\n // navigate away from a click the application had already cancelled.\n onClick?.(event);\n\n // `mailto:`, `tel:` and anything aimed at another browsing context leave\n // this page standing. The runtime is not consulted at all — asking it would\n // spend a page-data fetch on a click that was never going to navigate here.\n if (!isInApp || opensAnotherContext(target)) return;\n\n if (!isPlainLeftClick(event)) return;\n\n /*\n Asked for per click, never captured at render: the runtime registers\n itself when the hydration bundle mounts, which is AFTER the first render\n of every anchor on the page. A value read at render time would be\n `undefined` forever for exactly the links present at hydration — that is,\n all of them.\n\n Absent (server render, or before hydration) the anchor is left alone and\n does what an anchor does. That is the whole progressive-enhancement story:\n links work before this code runs, and work better after.\n */\n if (currentNavigator()?.(url) !== true) return;\n\n event.preventDefault();\n };\n\n /*\n The SAME gate the click uses, asked before any speculative request exists:\n only a destination this app would have navigated to itself may be fetched\n ahead of time. `mailto:`, `tel:`, an external URL and anything aimed at\n another browsing context are all clicks that leave this page, and none of\n them has page data to fetch.\n */\n const prefetchesOnInteraction =\n prefetch === true && isInApp && !opensAnotherContext(target);\n\n /*\n Attached ONLY when prefetching — a link without the prop keeps whatever\n handlers the caller passed, on the element, unwrapped.\n\n Hover AND focus, because a keyboard user never generates the first one and\n would otherwise be the only visitor who never gets the optimisation.\n\n Fire-and-forget by construction: `prefetchPageData` never rejects and is\n never awaited, so nothing here can delay the event or surface a failure. It\n is also safe to reach on the server — it no-ops without a browser — which is\n why this file can import it directly rather than through a `connect*` seam\n like the navigator's. The navigator needs a seam because the runtime behind\n it drags React state and the page registry into the server bundle; the\n prefetch cache is a `Map` and a `fetch` call, inert until an event fires.\n */\n const prefetchHandlers = prefetchesOnInteraction\n ? {\n onMouseEnter: (event: MouseEvent<HTMLAnchorElement>): void => {\n elementProps.onMouseEnter?.(event);\n void prefetchPageData(url);\n },\n onFocus: (event: FocusEvent<HTMLAnchorElement>): void => {\n elementProps.onFocus?.(event);\n void prefetchPageData(url);\n },\n }\n : undefined;\n\n return createElement(\n Component,\n { ...elementProps, ...prefetchHandlers, target, rel, href: url, onClick: handleClick },\n children,\n );\n}\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"link.mjs","names":[],"sources":["../../../../../../../web/src/components/link.ts"],"sourcesContent":["import { createElement } from \"react\";\nimport type {\n AnchorHTMLAttributes,\n ComponentType,\n FocusEvent,\n MouseEvent,\n ReactElement,\n} from \"react\";\nimport { prefetchPageData } from \"../client/navigation/prefetch\";\nimport { currentNavigator } from \"../routing/navigator\";\nimport { href, knownRouteNames } from \"../routing/route-table\";\n\n/**\n * `<Link>` is SUGAR over `href()`, and deliberately thin.\n *\n * `href(name, params, query)` is the durable primitive — it serves emails,\n * redirects, `Location` headers and every non-React caller, none of which can\n * render a component. This file adds one thing to it: an anchor element.\n *\n * It renders a real `<a href>`. Client-side interception is a later slice of\n * the navigation runtime and lands here without changing this API, which is the\n * point of routing everything through `href` first: navigation becomes a\n * BEHAVIOUR change, not an API change.\n *\n * ── Parity with `@mongez/react-router` ───────────────────────────────────────\n * `href`, `newTab`, `email`, `tel`, `component` and `prefetch` are spelled\n * exactly as MRR spells them, so a component moved across keeps compiling.\n * `params` and `query` are ours and have no MRR equivalent: they pair with the\n * typed `href()` helper, which is what makes a route NAME — rather than a URL —\n * the thing a call site names.\n *\n * ── The semantic divergence this file bridges ────────────────────────────────\n * MRR's `to` is a PATH. Ours was a route NAME, and only a name — which meant a\n * component moved across from MRR compiled and then threw at render, because\n * `\"/products\"` is not the name of anything. The two packages disagreed about\n * what the most-used prop in either of them MEANS.\n *\n * Since 2026-08-24 (owner ruling) `to`/`href` accept BOTH, discriminated by\n * SHAPE — see {@link isLiteralUrl}. That is what makes MRR code portable, and\n * it costs nothing at a Warlock call site, because the two grammars cannot\n * collide: a route name never begins with `/` and never carries a `scheme:`.\n * The ruling RESTS on that, so this file asserts it rather than trusting it\n * ({@link RouteNameShapeCollisionError}).\n */\n\ntype AnchorProps = Omit<AnchorHTMLAttributes<HTMLAnchorElement>, \"href\">;\n\n/**\n * Where the link goes. Every field is documented here once; which COMBINATIONS\n * are legal is decided by {@link LinkDestination}.\n */\ntype LinkDestinationProps = {\n /**\n * A route NAME, or a literal URL — told apart by SHAPE.\n *\n * `\"products.details\"` is a NAME and is resolved through the route table. A\n * page that moves changes its URL and keeps its name, so every call site\n * survives the move; a dead name throws at render naming the routes that do\n * exist, rather than rendering an anchor that 404s. This is the form to\n * prefer, and the only one `params` and `query` apply to.\n *\n * `\"/pricing\"`, `\"https://stripe.com\"`, `\"mailto:sales@example.com\"` and any\n * other `scheme:` are LITERAL — passed through to the element untouched, with\n * no route lookup at all. An app links out, and a route name is not a thing\n * you can have for a page that is not yours.\n */\n to?: string;\n /**\n * An alias of {@link to}, for parity with `@mongez/react-router`. Identical\n * in every respect, including which shapes it accepts.\n */\n href?: string;\n /** Renders a `mailto:` link. Not an in-app navigation. */\n email?: string;\n /** Renders a `tel:` link. Not an in-app navigation. */\n tel?: string;\n /**\n * Values for the route's `:param` segments, e.g. `{ id }` for\n * `\"/products/:id\"`. Only meaningful with a route NAME.\n */\n params?: Record<string, unknown>;\n /**\n * Query string values; an `undefined` value is omitted. Only meaningful with\n * a route NAME.\n */\n query?: Record<string, unknown>;\n};\n\n/**\n * EXACTLY ONE destination, enforced by the type.\n *\n * The alternative — a documented precedence such as \"`to` wins over `href`\" —\n * is silent by construction: the losing prop goes on compiling and goes on\n * reading like it works at the call site, and the anchor points at the wrong\n * page. Refusing the pair costs a call site one edit and can never be\n * misread. The runtime refuses it as well, because a JavaScript caller and a\n * cast both get past this.\n */\ntype LinkDestination =\n | { to: string; href?: never; email?: never; tel?: never }\n | { href: string; to?: never; email?: never; tel?: never }\n | { email: string; to?: never; href?: never; tel?: never }\n | { tel: string; to?: never; href?: never; email?: never };\n\nexport type LinkProps = AnchorProps &\n LinkDestinationProps &\n LinkDestination & {\n /**\n * Open in a new browsing context: `target=\"_blank\"` plus the `rel` that\n * stops the opened page from reaching back through `window.opener`.\n *\n * A caller's own `target`/`rel` win — this only fills in what was not said.\n */\n newTab?: boolean;\n /**\n * Render as something other than `<a>` — a tag name or a component.\n *\n * It receives the resolved `href`, the click handler and every remaining\n * prop, so a design-system anchor keeps client-side navigation as long as\n * it spreads what it is given onto the element it renders.\n *\n * `ComponentType<any>` is MRR's signature, kept verbatim: the component is\n * the caller's and its props are unknowable from here.\n */\n // eslint-disable-next-line @typescript-eslint/no-explicit-any\n component?: ComponentType<any> | string;\n /**\n * Fetch this page's data when the pointer or the keyboard reaches the link,\n * so the click that follows swaps without a round trip.\n *\n * A GUESS, and treated as one everywhere: it is never awaited, a failure is\n * silent, and the click behaves exactly as it would without it. Opt-in per\n * link rather than on by default, because every prefetch is a request the\n * user did not ask for and someone pays for the bandwidth.\n *\n * IGNORED for anything that is not an in-app navigation — an external URL,\n * `mailto:`, `tel:`, `newTab`, any explicit `target`. Prefetching those\n * would mean issuing a cross-origin request to a third party on hover,\n * which is not a thing a link component may decide to do.\n */\n prefetch?: boolean;\n };\n\nconst DESTINATION_PROPS = [\"to\", \"href\", \"email\", \"tel\"] as const;\n\nexport class AmbiguousLinkDestinationError extends Error {\n public constructor(public readonly providedProps: readonly string[]) {\n super(\n `Warlock <Link> was given ${providedProps\n .map(name => JSON.stringify(name))\n .join(\" and \")}, but a link goes to exactly one place. There is no ` +\n \"precedence between them on purpose: one of the two would silently win, and the \" +\n \"call site would go on naming a destination that never renders. Delete the one \" +\n \"you did not mean.\",\n );\n this.name = \"AmbiguousLinkDestinationError\";\n }\n}\n\nexport class MissingLinkDestinationError extends Error {\n public constructor() {\n super(\n `Warlock <Link> was given no destination. Pass exactly one of ${DESTINATION_PROPS.map(\n name => JSON.stringify(name),\n ).join(\", \")}. It is not defaulted to the current page: an anchor with an empty ` +\n \"`href` renders as a working link and reloads the page when clicked, which is a \" +\n \"harder fault to see than this message.\",\n );\n this.name = \"MissingLinkDestinationError\";\n }\n}\n\nexport class RouteArgumentsOnLiteralUrlError extends Error {\n public constructor(\n public readonly url: string,\n public readonly providedProps: readonly string[],\n ) {\n super(\n `Warlock <Link> was given ${providedProps\n .map(name => JSON.stringify(name))\n .join(\" and \")} alongside the literal URL \"${url}\". Those apply to a route NAME, ` +\n \"which is resolved through the route table; a literal URL is passed through exactly \" +\n \"as written, so they would have been dropped and the link would have pointed at an \" +\n \"unfiltered page that still looked right at the call site. Put the values in the URL, \" +\n \"or name the route.\",\n );\n this.name = \"RouteArgumentsOnLiteralUrlError\";\n }\n}\n\n/**\n * The ruling's one assumption, broken. See the module doc comment: telling a\n * literal URL from a route NAME by shape is only safe while no route is NAMED\n * like a URL, and nothing in the route pipeline validates a hand-declared\n * `route.name`. So the collision is checked at the one place it could do harm,\n * where it is a loud refusal instead of an anchor that silently points\n * somewhere else.\n */\nexport class RouteNameShapeCollisionError extends Error {\n public constructor(public readonly routeName: string) {\n super(\n `Warlock route table: a route is NAMED ${JSON.stringify(routeName)}, which is shaped ` +\n \"like a URL. <Link> tells a literal URL from a route name by shape — a destination \" +\n \"starting with `/` or carrying a `scheme:` is passed through untouched — so this name \" +\n \"can never be resolved, and every link to it would silently point at that path \" +\n \"instead. Rename the route (`route = { path, name }`) to a dotted name such as \" +\n `${JSON.stringify(routeName.replace(/^\\/+/, \"\").replace(/\\//g, \".\") || \"index\")}.`,\n );\n this.name = \"RouteNameShapeCollisionError\";\n }\n}\n\ntype Destination = {\n /** What lands on the element's `href`. */\n url: string;\n /**\n * Whether this URL is a page in THIS app — the only kind the client\n * navigation runtime may be asked about, and the only kind that may be\n * prefetched. `mailto:`, `tel:` and an external URL hand off to another\n * application or another origin entirely, so intercepting any of them would\n * break it, and speculatively fetching one would be a cross-origin request\n * the developer never asked for.\n */\n isInApp: boolean;\n};\n\n/**\n * Any RFC 3986 scheme — `https:`, `mailto:`, `tel:`, `whatsapp:`, an app's own\n * custom one. Matched generically rather than as a list of known schemes: a\n * list would silently resolve `bitcoin:...` through the route table, which is\n * the exact failure this ruling exists to remove, and it would have to grow\n * forever.\n */\nconst SCHEME_PATTERN = /^[a-z][a-z0-9+.-]*:/i;\n\n/**\n * Whether this destination is a URL to be used as written, rather than a route\n * name to resolve.\n *\n * The whole discriminator, and deliberately the whole of it: two cheap shape\n * tests, no parsing, no matching. Anything more would be a SECOND route matcher\n * living beside the server's, which this codebase refuses everywhere it comes\n * up — a matcher that disagreed with the real one would produce links to pages\n * that do not exist.\n */\nfunction isLiteralUrl(destination: string): boolean {\n return destination.startsWith(\"/\") || SCHEME_PATTERN.test(destination);\n}\n\n/**\n * Whether a literal URL addresses THIS app.\n *\n * A path is ours. A `scheme:` is not — including `https:` to our own origin,\n * which would need `window.location` to recognise and would make the answer\n * depend on where the code is running. And `//host/path` is PROTOCOL-RELATIVE:\n * it starts with a slash and is nonetheless another origin, which is precisely\n * the case a \"starts with `/`\" test alone would hand to the navigator, where it\n * becomes a `pushState` to a foreign origin — a SecurityError — or a\n * speculative fetch of a third-party host.\n */\nfunction addressesThisApp(url: string): boolean {\n return url.startsWith(\"/\") && !url.startsWith(\"//\");\n}\n\n/**\n * Refuses the one table that would make {@link isLiteralUrl} wrong.\n *\n * Reached only for a destination already judged literal, so the cost is a scan\n * of the published names for links that were never going to hit the table\n * anyway — and zero for the route-name form, which is the common one. The right\n * permanent home for this is `publishRouteTable`, at boot, once (see the report\n * on this card).\n */\nfunction assertNotARouteName(url: string): void {\n if (knownRouteNames().includes(url)) throw new RouteNameShapeCollisionError(url);\n}\n\nconst ROUTE_ARGUMENT_PROPS = [\"params\", \"query\"] as const;\n\nfunction resolveDestination(props: LinkDestinationProps): Destination {\n const provided = DESTINATION_PROPS.filter(name => props[name] !== undefined);\n\n if (provided.length > 1) throw new AmbiguousLinkDestinationError(provided);\n\n if (provided.length === 0) throw new MissingLinkDestinationError();\n\n if (props.email !== undefined) return { url: `mailto:${props.email}`, isInApp: false };\n\n if (props.tel !== undefined) return { url: `tel:${props.tel}`, isInApp: false };\n\n const destination = (props.to ?? props.href) as string;\n\n /*\n LITERAL: `/pricing`, `https://stripe.com`, `mailto:…`, `whatsapp://…`. It\n goes to the element exactly as written and the route table is never\n consulted — there is nothing to look up, and looking anyway is what used to\n throw `UnknownRouteNameError` on every link out of the application.\n */\n if (isLiteralUrl(destination)) {\n const routeArguments = ROUTE_ARGUMENT_PROPS.filter(name => props[name] !== undefined);\n\n if (routeArguments.length > 0) {\n throw new RouteArgumentsOnLiteralUrlError(destination, routeArguments);\n }\n\n assertNotARouteName(destination);\n\n return { url: destination, isInApp: addressesThisApp(destination) };\n }\n\n /*\n A NAME, resolved against the route table published at boot from the SAME\n discovery result the server registered its routes from. The previous version\n of this file restated six URLs in a literal map, so linking to any seventh\n page in the application threw — the map was the limit on what could be\n linked, and nothing said so at the call site.\n */\n return { url: href(destination, props.params, props.query), isInApp: true };\n}\n\n/**\n * Whether this click should be left entirely to the browser.\n *\n * Every case here is a click that MEANS something other than \"go there in this\n * tab\", and intercepting any of them would take away behaviour the user\n * explicitly asked for:\n *\n * - a modifier or middle button: open in a new tab/window, or download\n * - `download`: save the resource, do not render it\n * - already prevented: something upstream in the tree handled this click\n *\n * Left button with no modifiers is the only click that means plain navigation.\n * The `target` case is decided before this, from the RESOLVED target, because\n * `newTab` sets it after the caller's props are read.\n */\nfunction isPlainLeftClick(event: MouseEvent<HTMLAnchorElement>): boolean {\n return (\n event.button === 0 &&\n !event.metaKey &&\n !event.ctrlKey &&\n !event.shiftKey &&\n !event.altKey &&\n !event.defaultPrevented\n );\n}\n\n/**\n * A target other than `_self` names ANOTHER browsing context — `_blank`, but\n * also `_parent`, `_top` and any named frame. Client navigation rewrites the\n * history of THIS one, so none of them are ours to intercept.\n */\nfunction opensAnotherContext(target: string | undefined): boolean {\n return target !== undefined && target !== \"_self\";\n}\n\nexport function Link({\n to,\n href: hrefAlias,\n email,\n tel,\n params,\n query,\n newTab,\n prefetch,\n component: Component = \"a\",\n children,\n onClick,\n ...elementProps\n}: LinkProps): ReactElement {\n const { url, isInApp } = resolveDestination({\n to,\n href: hrefAlias,\n email,\n tel,\n params,\n query,\n });\n\n const target = elementProps.target ?? (newTab === true ? \"_blank\" : undefined);\n\n // Only a DEFAULT: a caller that wrote its own `rel` (`\"me noopener\"`,\n // `\"external\"`) meant it, and overwriting it would delete a value the page\n // depends on to say something this component knows nothing about.\n const rel =\n elementProps.rel ?? (target === \"_blank\" ? \"noopener noreferrer\" : undefined);\n\n const handleClick = (event: MouseEvent<HTMLAnchorElement>): void => {\n // The caller's handler runs FIRST and unconditionally — it may be doing\n // analytics, closing a menu, or calling `preventDefault()` to veto the\n // navigation outright. Deciding before it ran would let this component\n // navigate away from a click the application had already cancelled.\n onClick?.(event);\n\n // `mailto:`, `tel:` and anything aimed at another browsing context leave\n // this page standing. The runtime is not consulted at all — asking it would\n // spend a page-data fetch on a click that was never going to navigate here.\n if (!isInApp || opensAnotherContext(target)) return;\n\n if (!isPlainLeftClick(event)) return;\n\n /*\n Asked for per click, never captured at render: the runtime registers\n itself when the hydration bundle mounts, which is AFTER the first render\n of every anchor on the page. A value read at render time would be\n `undefined` forever for exactly the links present at hydration — that is,\n all of them.\n\n Absent (server render, or before hydration) the anchor is left alone and\n does what an anchor does. That is the whole progressive-enhancement story:\n links work before this code runs, and work better after.\n */\n if (currentNavigator()?.(url) !== true) return;\n\n event.preventDefault();\n };\n\n /*\n The SAME gate the click uses, asked before any speculative request exists:\n only a destination this app would have navigated to itself may be fetched\n ahead of time. `mailto:`, `tel:`, an external URL and anything aimed at\n another browsing context are all clicks that leave this page, and none of\n them has page data to fetch.\n */\n const prefetchesOnInteraction =\n prefetch === true && isInApp && !opensAnotherContext(target);\n\n /*\n Attached ONLY when prefetching — a link without the prop keeps whatever\n handlers the caller passed, on the element, unwrapped.\n\n Hover AND focus, because a keyboard user never generates the first one and\n would otherwise be the only visitor who never gets the optimisation.\n\n Fire-and-forget by construction: `prefetchPageData` never rejects and is\n never awaited, so nothing here can delay the event or surface a failure. It\n is also safe to reach on the server — it no-ops without a browser — which is\n why this file can import it directly rather than through a `connect*` seam\n like the navigator's. The navigator needs a seam because the runtime behind\n it drags React state and the page registry into the server bundle; the\n prefetch cache is a `Map` and a `fetch` call, inert until an event fires.\n */\n const prefetchHandlers = prefetchesOnInteraction\n ? {\n onMouseEnter: (event: MouseEvent<HTMLAnchorElement>): void => {\n elementProps.onMouseEnter?.(event);\n void prefetchPageData(url);\n },\n onFocus: (event: FocusEvent<HTMLAnchorElement>): void => {\n elementProps.onFocus?.(event);\n void prefetchPageData(url);\n },\n }\n : undefined;\n\n return createElement(\n Component,\n { ...elementProps, ...prefetchHandlers, target, rel, href: url, onClick: handleClick },\n children,\n );\n}\n"],"mappings":"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"}
|
|
@@ -2,7 +2,7 @@ import { PAYLOAD_SCRIPT_ID, escapePayload, useDocumentContext } from "./document
|
|
|
2
2
|
import { isNonHydrating } from "../server/page-render-bundle.mjs";
|
|
3
3
|
import { Fragment, createElement } from "react";
|
|
4
4
|
|
|
5
|
-
//#region web/src/components/scripts.ts
|
|
5
|
+
//#region ../web/src/components/scripts.ts
|
|
6
6
|
/**
|
|
7
7
|
* OPTIONAL placement override for the serialized loader data and `shared`.
|
|
8
8
|
* Hydration module emission is separate wiring.
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"scripts.mjs","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"scripts.mjs","names":[],"sources":["../../../../../../../web/src/components/scripts.ts"],"sourcesContent":["import { createElement, Fragment, type ReactElement } from \"react\";\nimport { escapePayload, PAYLOAD_SCRIPT_ID, useDocumentContext } from \"./document-context\";\nimport { isNonHydrating } from \"../server/page-render-bundle\";\n\nexport type ScriptsProps = {\n /** Per-request CSP nonce for the inline payload script (root.tsx:119). */\n nonce?: string;\n};\n\n/**\n * OPTIONAL placement override for the serialized loader data and `shared`.\n * Hydration module emission is separate wiring.\n */\nexport function Scripts(props: ScriptsProps): ReactElement {\n const { payload, nonce } = useDocumentContext(\"Scripts\");\n\n // `renderPageFailure` marks its payload non-hydrating (page-render-bundle.ts):\n // a module-load/registration throw happens before any triple exists, so\n // there is nothing trustworthy for a hydration script to describe. A normal\n // app `error.page.tsx` payload (from `finishRender`) is never marked and\n // keeps emitting `__WARLOCK_DATA__` below.\n if (isNonHydrating(payload)) return createElement(Fragment, null);\n\n // Explicit prop wins: v5/app's root.tsx passes `shared.nonce` today\n // (root.tsx:119) and must keep working unchanged. Only an absent prop falls\n // back to the framework's nonce slot (document-context.ts).\n const resolvedNonce = props.nonce ?? nonce;\n\n // `dangerouslySetInnerHTML`, not children: the serializer's escaped output must\n // reach the document byte-for-byte. React's default child-text escaping\n // (HTML-entity escaping) would double-process it and corrupt the JSON\n // (spike P7's escaping contract).\n return createElement(\"script\", {\n id: PAYLOAD_SCRIPT_ID,\n type: \"application/json\",\n nonce: resolvedNonce,\n dangerouslySetInnerHTML: { __html: escapePayload(JSON.stringify(payload)) },\n });\n}\n"],"mappings":";;;;;;;;;AAaA,SAAgB,QAAQ,OAAmC;CACzD,MAAM,EAAE,SAAS,UAAU,mBAAmB,SAAS;CAOvD,IAAI,eAAe,OAAO,GAAG,OAAO,cAAc,UAAU,IAAI;CAWhE,OAAO,cAAc,UAAU;EAC7B,IAAI;EACJ,MAAM;EACN,OAToB,MAAM,SAAS;EAUnC,yBAAyB,EAAE,QAAQ,cAAc,KAAK,UAAU,OAAO,CAAC,EAAE;CAC5E,CAAC;AACH"}
|
package/esm/context.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { SharedContext } from "./index.mjs";
|
|
2
2
|
import { HttpContext, Request } from "@warlock.js/core";
|
|
3
3
|
|
|
4
|
-
//#region web/src/context.d.ts
|
|
4
|
+
//#region ../web/src/context.d.ts
|
|
5
5
|
type HttpContext$1<TRequest extends Request = Request> = HttpContext<TRequest>;
|
|
6
6
|
type PageContext<TRequest extends Request = Request> = HttpContext<TRequest> & {
|
|
7
7
|
shared: SharedContext;
|
package/esm/hydration/index.mjs
CHANGED
|
@@ -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/hydration/index.ts
|
|
9
9
|
/**
|
|
10
10
|
* The client entry: registry in, hydration out. No page names, no app paths.
|
|
11
11
|
*
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.mjs","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"index.mjs","names":[],"sources":["../../../../../../../web/src/hydration/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,6 +1,6 @@
|
|
|
1
1
|
import { PAYLOAD_SCRIPT_ID } from "./components/document-context.mjs";
|
|
2
2
|
|
|
3
|
-
//#region web/src/hydration-payload.ts
|
|
3
|
+
//#region ../web/src/hydration-payload.ts
|
|
4
4
|
/**
|
|
5
5
|
* Exported so a payload-shape assertion can be written against the contract
|
|
6
6
|
* itself. A spec that hardcodes its own copy of this list silently becomes a
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"hydration-payload.mjs","names":[],"sources":["
|
|
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] 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 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 FIVE 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;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,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
|
@@ -14,7 +14,7 @@ import { refresh } from "./client/navigation/refresh.mjs";
|
|
|
14
14
|
import { Head } from "./components/head.mjs";
|
|
15
15
|
import { Scripts } from "./components/scripts.mjs";
|
|
16
16
|
|
|
17
|
-
//#region web/src/index.d.ts
|
|
17
|
+
//#region ../web/src/index.d.ts
|
|
18
18
|
/**
|
|
19
19
|
* THE AUDIT SURFACE — everything the browser receives, declared by the app.
|
|
20
20
|
*
|
package/esm/loaders.d.mts
CHANGED
|
@@ -4,7 +4,7 @@ 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
|
-
//#region web/src/loaders.d.ts
|
|
7
|
+
//#region ../web/src/loaders.d.ts
|
|
8
8
|
type PageLoaderContext<TValidation extends PageValidation | undefined, _TRoute extends RouteDeclaration | undefined> = {
|
|
9
9
|
request: Request<ValidatedOutput<TValidation>>;
|
|
10
10
|
response: Response;
|
package/esm/metadata.d.mts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
import { LoaderData, LoaderFunction } from "./props.mjs";
|
|
2
2
|
import { SharedContext } from "./index.mjs";
|
|
3
3
|
|
|
4
|
-
//#region web/src/metadata.d.ts
|
|
4
|
+
//#region ../web/src/metadata.d.ts
|
|
5
5
|
/**
|
|
6
6
|
* What the `metadata` export may produce. The pipeline injects it into
|
|
7
7
|
* `<head>` before the first byte; `<Head />` only decides placement.
|
package/esm/metadata.mjs
CHANGED
package/esm/metadata.mjs.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"metadata.mjs","names":[],"sources":["
|
|
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"}
|
package/esm/props.d.mts
CHANGED
|
@@ -2,7 +2,7 @@ import { SharedContext } from "./index.mjs";
|
|
|
2
2
|
import { ReactNode } from "react";
|
|
3
3
|
import { Response } from "@warlock.js/core";
|
|
4
4
|
|
|
5
|
-
//#region web/src/props.d.ts
|
|
5
|
+
//#region ../web/src/props.d.ts
|
|
6
6
|
/** Any loader authored with `satisfies` — the concrete function type. */
|
|
7
7
|
type LoaderFunction = (...args: any[]) => unknown;
|
|
8
8
|
/**
|
package/esm/route.d.mts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"compose-route-path.mjs","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"compose-route-path.mjs","names":[],"sources":["../../../../../../../web/src/routing/compose-route-path.ts"],"sourcesContent":["/**\n * Route-path composition — the single, pure rule for turning a layout's\n * declared `prefix` and a page's declared `route.path` into the page's\n * effective, registered path. Previously three hand-written copies: the dev\n * installer (`web/src/server/install-page-routes.ts`), the production\n * manifest installer (`web/src/server/install-page-routes-from-manifest.ts`)\n * and build discovery (`web/src/build/discover-pages.ts`) each carried this\n * exact rule so build and boot could not quietly disagree about it; all\n * three now delegate here instead.\n *\n * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing\n * here may import `node:fs`, `node:path`, `vite`, or `fastify`. This module\n * receives canonical values and trusts nothing about them beyond the input\n * contract asserted below — it asserts rather than trusts, but it never\n * repairs.\n */\n\n/**\n * A page's effective path: its nearest layout's `prefix` followed by its own\n * declared `route.path`. A root prefix (\"/\") and a root path (\"/\") each\n * contribute nothing, so neither adds a slash of its own; every other case\n * joins on the slash `routePath` already starts with, which is what keeps a\n * double slash out of the result, and an empty concatenation is the site\n * root.\n */\nexport function composeRoutePath(layoutPrefix: string, routePath: string): string {\n const normalizedPrefix = layoutPrefix === \"/\" ? \"\" : layoutPrefix.replace(/\\/+$/, \"\");\n const normalizedRoute = routePath === \"/\" ? \"\" : routePath;\n const composed = `${normalizedPrefix}${normalizedRoute}`;\n\n return composed === \"\" ? \"/\" : composed;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;AAyBA,SAAgB,iBAAiB,cAAsB,WAA2B;CAGhF,MAAM,WAAW,GAFQ,iBAAiB,MAAM,KAAK,aAAa,QAAQ,QAAQ,EAAE,IAC5D,cAAc,MAAM,KAAK;CAGjD,OAAO,aAAa,KAAK,MAAM;AACjC"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"data-request.mjs","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"data-request.mjs","names":[],"sources":["../../../../../../../web/src/routing/data-request.ts"],"sourcesContent":["/**\n * The marker that turns a page request into a DATA request.\n *\n * A client navigation needs exactly what a full page load needs — middleware,\n * validation, loaders, redirects, cookies, the settled status — and differs in\n * one respect only: it wants the hydration payload as JSON instead of a\n * rendered document. So it is deliberately NOT a separate `/_loader` route.\n *\n * WHY NOT A SEPARATE ROUTE. A `/_loader?path=/products/42` endpoint has to\n * resolve that path to a page itself, which is a SECOND implementation of route\n * semantics living beside the server's. This codebase already refuses that\n * bargain for the browser — the hydration payload carries the route `name` so\n * the client never re-matches — and the same reasoning applies here with more\n * force: a loader endpoint that disagreed with the real route about params,\n * prefixes or which page owns a path would answer a different request than the\n * one the user navigated to. Same URL, same route, same matcher, same pipeline;\n * only the final representation differs.\n *\n * WHY A HEADER AND NOT `?_data=1`. The query string belongs to the page — it is\n * what `validation` and loaders read. Injecting a framework key into it means a\n * page with strict query validation rejects its own client navigations, and\n * every loader that echoes its query starts leaking a private flag.\n *\n * Responses to a data request must carry `Vary: <this header>` so a shared\n * cache can never hand a document to a client that asked for JSON, or the\n * reverse. Page responses are `private, no-store` today, which makes that\n * theoretical — `Vary` is what keeps it theoretical if the caching policy\n * changes.\n */\nexport const WARLOCK_DATA_REQUEST_HEADER = \"x-warlock-data\";\n\n/**\n * The value the client sends. Any non-empty value is honoured on the way in —\n * the header's PRESENCE is the signal — but the client sends this one so the\n * traffic is self-describing in a log or a network panel.\n */\nexport const WARLOCK_DATA_REQUEST_VALUE = \"1\";\n\n/**\n * Declared explicitly because the payload goes on the wire ALREADY SERIALIZED,\n * as a string, and core only auto-picks `application/json` for object bodies.\n * See the send site for why it must be a string.\n */\nexport const DATA_RESPONSE_CONTENT_TYPE = \"application/json\";\n\n/**\n * Whether a request asked for the payload rather than the document.\n *\n * Presence-based on purpose: a proxy that rewrites the value, or a client on a\n * newer version that sends something more specific, still means \"data\". Only an\n * absent or empty header means \"render the document\".\n */\nexport function isDataRequest(headerValue: string | string[] | undefined): boolean {\n const value = Array.isArray(headerValue) ? headerValue[0] : headerValue;\n\n return typeof value === \"string\" && value.length > 0;\n}\n"],"mappings":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA6BA,MAAa,8BAA8B;;;;;;AAc3C,MAAa,6BAA6B;;;;;;;;AAS1C,SAAgB,cAAc,aAAqD;CACjF,MAAM,QAAQ,MAAM,QAAQ,WAAW,IAAI,YAAY,KAAK;CAE5D,OAAO,OAAO,UAAU,YAAY,MAAM,SAAS;AACrD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"filesystem-route.mjs","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"filesystem-route.mjs","names":[],"sources":["../../../../../../../web/src/routing/filesystem-route.ts"],"sourcesContent":["export type FilesystemRouteInput = {\n /** POSIX path relative to `src/web`, ending in `.page.tsx`. */\n pageFile: string;\n /** Layout prefixes keyed by their POSIX directory relative to `src/web`; root uses `\"\"`. */\n layoutPrefixes?: Readonly<Record<string, string>>;\n};\n\nfunction isGroup(segment: string): boolean {\n return /^\\([^/]+\\)$/.test(segment);\n}\n\nfunction routeSegment(segment: string): string {\n const dynamic = /^\\[([A-Za-z_][A-Za-z0-9_]*)\\]$/.exec(segment);\n\n return dynamic ? `:${dynamic[1]}` : segment;\n}\n\nfunction prefixSegments(prefix: string): string[] {\n return prefix.split(\"/\").filter(Boolean);\n}\n\nfunction pageParts(pageFile: string): { directories: string[]; basename: string } {\n if (pageFile.includes(\"\\\\\")) {\n throw new Error(`filesystem-route: pageFile must use POSIX separators: \"${pageFile}\"`);\n }\n\n if (!pageFile.endsWith(\".page.tsx\")) {\n throw new Error(`filesystem-route: pageFile must end in .page.tsx: \"${pageFile}\"`);\n }\n\n const parts = pageFile.split(\"/\");\n const filename = parts.pop() as string;\n\n return {\n directories: parts,\n basename: filename.slice(0, -\".page.tsx\".length),\n };\n}\n\n/** Derive the effective URL for a page with no explicit `route` export. */\nexport function deriveFilesystemRoutePath(input: FilesystemRouteInput): string {\n const { directories, basename } = pageParts(input.pageFile);\n const prefixes = input.layoutPrefixes ?? {};\n const segments = [...prefixSegments(prefixes[\"\"] ?? \"\")];\n\n for (let index = 0; index < directories.length; index++) {\n const directory = directories[index];\n const directoryPath = directories.slice(0, index + 1).join(\"/\");\n const prefix = prefixes[directoryPath];\n\n if (prefix !== undefined) {\n segments.push(...prefixSegments(prefix));\n } else if (!isGroup(directory)) {\n segments.push(routeSegment(directory));\n }\n }\n\n if (basename !== \"index\") {\n segments.push(routeSegment(basename));\n }\n\n return segments.length === 0 ? \"/\" : `/${segments.join(\"/\")}`;\n}\n\n/** Derive the stable dotted route name from a page's filesystem identity. */\nexport function deriveFilesystemRouteName(pageFile: string): string {\n const { directories, basename } = pageParts(pageFile);\n const segments = directories.filter((segment) => !isGroup(segment)).map(routeSegment);\n\n if (basename !== \"index\") {\n segments.push(routeSegment(basename));\n }\n\n return segments.map((segment) => segment.replace(/^:/, \"\")).join(\".\") || \"index\";\n}\n"],"mappings":";AAOA,SAAS,QAAQ,SAA0B;CACzC,OAAO,cAAc,KAAK,OAAO;AACnC;AAEA,SAAS,aAAa,SAAyB;CAC7C,MAAM,UAAU,iCAAiC,KAAK,OAAO;CAE7D,OAAO,UAAU,IAAI,QAAQ,OAAO;AACtC;AAEA,SAAS,eAAe,QAA0B;CAChD,OAAO,OAAO,MAAM,GAAG,EAAE,OAAO,OAAO;AACzC;AAEA,SAAS,UAAU,UAA+D;CAChF,IAAI,SAAS,SAAS,IAAI,GACxB,MAAM,IAAI,MAAM,0DAA0D,SAAS,EAAE;CAGvF,IAAI,CAAC,SAAS,SAAS,WAAW,GAChC,MAAM,IAAI,MAAM,sDAAsD,SAAS,EAAE;CAGnF,MAAM,QAAQ,SAAS,MAAM,GAAG;CAGhC,OAAO;EACL,aAAa;EACb,UAJe,MAAM,IAIJ,EAAE,MAAM,GAAG,EAAmB;CACjD;AACF;;AAGA,SAAgB,0BAA0B,OAAqC;CAC7E,MAAM,EAAE,aAAa,aAAa,UAAU,MAAM,QAAQ;CAC1D,MAAM,WAAW,MAAM,kBAAkB,CAAC;CAC1C,MAAM,WAAW,CAAC,GAAG,eAAe,SAAS,OAAO,EAAE,CAAC;CAEvD,KAAK,IAAI,QAAQ,GAAG,QAAQ,YAAY,QAAQ,SAAS;EACvD,MAAM,YAAY,YAAY;EAE9B,MAAM,SAAS,SADO,YAAY,MAAM,GAAG,QAAQ,CAAC,EAAE,KAAK,GACvB;EAEpC,IAAI,WAAW,QACb,SAAS,KAAK,GAAG,eAAe,MAAM,CAAC;OAClC,IAAI,CAAC,QAAQ,SAAS,GAC3B,SAAS,KAAK,aAAa,SAAS,CAAC;CAEzC;CAEA,IAAI,aAAa,SACf,SAAS,KAAK,aAAa,QAAQ,CAAC;CAGtC,OAAO,SAAS,WAAW,IAAI,MAAM,IAAI,SAAS,KAAK,GAAG;AAC5D;;AAGA,SAAgB,0BAA0B,UAA0B;CAClE,MAAM,EAAE,aAAa,aAAa,UAAU,QAAQ;CACpD,MAAM,WAAW,YAAY,QAAQ,YAAY,CAAC,QAAQ,OAAO,CAAC,EAAE,IAAI,YAAY;CAEpF,IAAI,aAAa,SACf,SAAS,KAAK,aAAa,QAAQ,CAAC;CAGtC,OAAO,SAAS,KAAK,YAAY,QAAQ,QAAQ,MAAM,EAAE,CAAC,EAAE,KAAK,GAAG,KAAK;AAC3E"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"layout-policy.mjs","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"layout-policy.mjs","names":[],"sources":["../../../../../../../web/src/routing/layout-policy.ts"],"sourcesContent":["/**\n * Layout policy — the single, pure rule for turning a page's ENUMERATED\n * layout chain into a selection decision.\n *\n * Enumeration and selection are deliberately separate concerns. Discovery\n * ({@link \"../build/discover-pages.ts\"}'s `layoutChainFor`) walks a page's\n * directory ancestry and reports every `layout.tsx` it finds, outermost\n * first, honestly and unfiltered — it does not decide whether that chain is\n * usable. This module is the one place that decision is made: given a chain,\n * how many layouts does composition get to use, and which one(s)?\n *\n * THE RULE COUNTS RENDERING LAYOUTS, NOT FILES. A layout that contributes no\n * element to the document — one with no default export, carrying only\n * `prefix`, `middleware` or other named exports — is not a second wrapper and\n * never was. Counting files instead of wrappers made an authorization boundary\n * indistinguishable from a nested layout, and the resulting refusal told app\n * authors to delete the boundary to make the build pass. Nested RENDERING\n * layouts remain unsupported; everything else composes freely.\n *\n * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing\n * here may import `node:fs`, `node:path`, `vite`, or `fastify`. This module\n * receives a canonical chain and trusts nothing about it beyond the input\n * contract asserted below — it asserts rather than trusts, but it never\n * repairs. It therefore never DECIDES what renders either: that answer needs\n * the filesystem, so the caller — which has it — classifies each entry and\n * passes the classification in. Discovery owns the fact; this module owns the\n * rule. The `layout` identifiers are opaque (paths, in practice) and this\n * module never inspects their shape; it only counts and selects.\n *\n * REJECTION IS DATA, NOT A THROW: a chain with two or more rendering layouts\n * does not make {@link selectPageLayout} raise — it returns the rejected\n * rendering layouts, in order. What a rejection MEANS to the user is\n * nonetheless fixed here: {@link NestedLayoutsNotSupportedError} is the single\n * error contract for it — one class, one message shape, built from the\n * rejection data plus caller-supplied page identity. Callers decide only WHEN\n * to raise it and supply that context; none of them wraps the rejection in a\n * category or wording of its own. A shared policy whose failure semantics fork\n * per caller is shared in the happy path and forked in the sad one — and the\n * sad path is the one users meet.\n */\n\n/**\n * One layout in a chain, with the caller's answer to the only question this\n * module needs about it: does it render?\n *\n * `renders` is true when the layout's module has a default export — the thing\n * that puts an element in the document. Everything else it exports (`prefix`,\n * `middleware`, helpers) is invisible to this rule.\n */\nexport type LayoutChainEntry = {\n /** Opaque identifier for the layout — an app-root-relative POSIX path, in practice. */\n layout: string;\n /** Whether this layout contributes an element to the document. */\n renders: boolean;\n};\n\n/**\n * A chain as {@link selectPageLayout} accepts it: classified entries, or bare\n * identifiers for a caller that has not classified its chain yet.\n *\n * TRANSITIONAL: a bare string is read as a RENDERING layout, which is the\n * conservative reading (it can only make the rule stricter, never looser) and\n * reproduces this module's pre-classification behaviour exactly. It exists so\n * the boot-time manifest installer — which holds loaded modules rather than\n * source files — keeps working unchanged until it classifies too; remove the\n * string arm once every caller passes {@link LayoutChainEntry}s.\n */\nexport type LayoutChainInput = readonly (string | LayoutChainEntry)[];\n\n/**\n * The policy's decision for one page's layout chain:\n *\n * - `\"none\"` — no layout on the chain renders; the page composes against no\n * layout. A chain of three middleware-only layouts lands here exactly as an\n * empty chain does, because neither has a wrapper in it.\n * - `\"selected\"` — exactly one layout renders; `layout` is that element, which,\n * being the only rendering one, is simultaneously the outermost and the\n * nearest rendering layout — there is no distinction to draw between the two\n * when there is only one.\n * - `\"rejected\"` — more than one layout renders; `layouts` carries the\n * RENDERING layouts only, outermost-first, in chain order, so a consumer can\n * name every layout actually at fault without naming the guards between them.\n */\nexport type LayoutPolicyResult =\n | { type: \"none\" }\n | { type: \"selected\"; layout: string }\n | { type: \"rejected\"; layouts: readonly string[] };\n\n/**\n * Raised when a `chain` passed to {@link selectPageLayout} contains an empty\n * layout identifier. An empty string is not a layout identifier a caller could\n * have meant; this module refuses it rather than silently treating it as\n * absent or as a real selection.\n */\nexport class EmptyLayoutChainEntryError extends Error {\n public constructor(public readonly chain: readonly string[]) {\n super(\n `layout-policy: chain [${chain.map((entry) => `\"${entry}\"`).join(\", \")}] contains an empty ` +\n \"string. Every element of a layout chain passed to selectPageLayout must be a non-empty \" +\n \"layout identifier — omit the entry entirely rather than passing an empty string for it.\",\n );\n this.name = \"EmptyLayoutChainEntryError\";\n }\n}\n\n/**\n * The single error contract for a `rejected` selection — the one class and\n * one message every caller of {@link selectPageLayout} raises when it refuses\n * a page whose path holds more than one RENDERING layout. `pageFile` and\n * `layoutFiles` are the caller's context (its audience-appropriate identifiers\n * for the page and the rejected rendering layouts — app-root-relative POSIX\n * paths, in practice); the category and wording are this module's.\n *\n * The wording names the rendering layouts and ONLY the rendering layouts, and\n * it does not offer removal as a remedy. Its predecessor said \"remove or\n * consolidate the extra layout\", which, on a chain whose second element was a\n * `middleware`-only authorization boundary, instructed the reader to delete\n * their security guard to make the build pass.\n */\nexport class NestedLayoutsNotSupportedError extends Error {\n public constructor(\n public readonly pageFile: string,\n public readonly layoutFiles: readonly string[],\n ) {\n super(\n `\"${pageFile}\" has more than one layout on its path that renders: ` +\n `${layoutFiles.map((file) => `\"${file}\"`).join(\", \")}. Pages currently support at most one ` +\n \"RENDERING layout — a layout with a default export — and nesting more than one is not yet \" +\n \"supported. Layouts that render nothing, such as a `prefix`- or `middleware`-only layout, \" +\n \"do not count against this and may nest freely. To fix: consolidate the rendering layouts \" +\n \"named above into one — and do not remove a middleware-only layout to satisfy this, since \" +\n \"none of them is what this refuses.\",\n );\n this.name = \"NestedLayoutsNotSupportedError\";\n }\n}\n\n/** The classified form of an entry, whichever way the caller spelled it. */\nfunction toEntry(entry: string | LayoutChainEntry): LayoutChainEntry {\n return typeof entry === \"string\" ? { layout: entry, renders: true } : entry;\n}\n\n/**\n * Selects which layout, if any, a page RENDERS inside, given its layout chain\n * as enumerated outermost-first.\n *\n * Only entries with `renders: true` are counted: no rendering layout yields\n * `{ type: \"none\" }`; exactly one yields `{ type: \"selected\"; layout }`; more\n * than one yields `{ type: \"rejected\"; layouts }` carrying just those rendering\n * layouts — this function throws nothing for that case; see the module doc for\n * why rejection is data, not a throw.\n *\n * Throws {@link EmptyLayoutChainEntryError} when any entry's identifier is an\n * empty string — the one input-contract violation this module refuses rather\n * than passes through as a selection.\n */\nexport function selectPageLayout(chain: LayoutChainInput): LayoutPolicyResult {\n const entries = chain.map(toEntry);\n\n if (entries.some((entry) => entry.layout === \"\")) {\n throw new EmptyLayoutChainEntryError(entries.map((entry) => entry.layout));\n }\n\n const rendering = entries.filter((entry) => entry.renders).map((entry) => entry.layout);\n\n if (rendering.length === 0) {\n return { type: \"none\" };\n }\n\n if (rendering.length === 1) {\n return { type: \"selected\", layout: rendering[0] };\n }\n\n return { type: \"rejected\", layouts: rendering };\n}\n"],"mappings":";;;;;;;AA8FA,IAAa,6BAAb,cAAgD,MAAM;CACjB;CAAnC,AAAO,YAAY,AAAgB,OAA0B;EAC3D,MACE,yBAAyB,MAAM,KAAK,UAAU,IAAI,MAAM,EAAE,EAAE,KAAK,IAAI,EAAE,mMAGzE;EALiC;EAMjC,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;AAgBA,IAAa,iCAAb,cAAoD,MAAM;CAEtC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,aAChB;EACA,MACE,IAAI,SAAS,uDACR,YAAY,KAAK,SAAS,IAAI,KAAK,EAAE,EAAE,KAAK,IAAI,EAAE,ibAMzD;EAXgB;EACA;EAWhB,KAAK,OAAO;CACd;AACF;;AAGA,SAAS,QAAQ,OAAoD;CACnE,OAAO,OAAO,UAAU,WAAW;EAAE,QAAQ;EAAO,SAAS;CAAK,IAAI;AACxE;;;;;;;;;;;;;;;AAgBA,SAAgB,iBAAiB,OAA6C;CAC5E,MAAM,UAAU,MAAM,IAAI,OAAO;CAEjC,IAAI,QAAQ,MAAM,UAAU,MAAM,WAAW,EAAE,GAC7C,MAAM,IAAI,2BAA2B,QAAQ,KAAK,UAAU,MAAM,MAAM,CAAC;CAG3E,MAAM,YAAY,QAAQ,QAAQ,UAAU,MAAM,OAAO,EAAE,KAAK,UAAU,MAAM,MAAM;CAEtF,IAAI,UAAU,WAAW,GACvB,OAAO,EAAE,MAAM,OAAO;CAGxB,IAAI,UAAU,WAAW,GACvB,OAAO;EAAE,MAAM;EAAY,QAAQ,UAAU;CAAG;CAGlD,OAAO;EAAE,MAAM;EAAY,SAAS;CAAU;AAChD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"navigator.mjs","names":[],"sources":["
|
|
1
|
+
{"version":3,"file":"navigator.mjs","names":[],"sources":["../../../../../../../web/src/routing/navigator.ts"],"sourcesContent":["/**\n * The one seam between `<Link>` and the client navigation runtime.\n *\n * `<Link>` is UNIVERSAL — the same component renders on the server and in the\n * browser — so it cannot import the runtime: doing so would drag `fetch`, the\n * page registry and React state into the server render, and into the bundle of\n * any app that never navigates client-side.\n *\n * So the runtime REGISTERS itself here instead, and `<Link>` asks. On the\n * server nothing has registered, `currentNavigator()` is `undefined`, and the\n * anchor behaves as a plain anchor — which is exactly right, because on the\n * server it IS a plain anchor.\n *\n * This is also what makes the feature progressively enhancing rather than\n * load-bearing: until the hydration bundle has run and registered, every link\n * on the page still works as an ordinary link.\n */\n\n/**\n * @returns `true` if the runtime accepted the navigation and the caller should\n * suppress the browser's default. `false` means \"not mine\" — let the browser do\n * what it was going to do.\n */\nexport type Navigator = (url: string, options?: { replace?: boolean }) => boolean;\n\nlet navigator: Navigator | undefined;\n\n/**\n * Installed by the navigation runtime at mount, and torn down with `undefined`.\n *\n * @returns the previous navigator, so a caller that installs one can restore\n * what was there — the same shape the other `connect*` seams in this package\n * use, and what makes a test able to leave the module as it found it.\n */\nexport function connectNavigator(next: Navigator | undefined): Navigator | undefined {\n const previous = navigator;\n\n navigator = next;\n\n return previous;\n}\n\nexport function currentNavigator(): Navigator | undefined {\n return navigator;\n}\n"],"mappings":";AAyBA,IAAI;;;;;;;;AASJ,SAAgB,iBAAiB,MAAoD;CACnF,MAAM,WAAW;CAEjB,YAAY;CAEZ,OAAO;AACT;AAEA,SAAgB,mBAA0C;CACxD,OAAO;AACT"}
|