@warlock.js/web 5.2.4 → 5.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (62) hide show
  1. package/esm/build/discover-pages.mjs +5 -7
  2. package/esm/build/discover-pages.mjs.map +1 -1
  3. package/esm/client/hydrate-page.mjs +4 -3
  4. package/esm/client/hydrate-page.mjs.map +1 -1
  5. package/esm/client/navigation/fetch-page-data.mjs +2 -2
  6. package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
  7. package/esm/client/navigation/navigation-root.mjs +5 -1
  8. package/esm/client/navigation/navigation-root.mjs.map +1 -1
  9. package/esm/components/document-context.mjs.map +1 -1
  10. package/esm/hydration-payload.mjs +6 -2
  11. package/esm/hydration-payload.mjs.map +1 -1
  12. package/esm/index.d.mts +4 -2
  13. package/esm/index.mjs +2 -1
  14. package/esm/localization.d.mts +21 -0
  15. package/esm/localization.mjs +28 -0
  16. package/esm/localization.mjs.map +1 -0
  17. package/esm/routing/data-request.mjs +5 -3
  18. package/esm/routing/data-request.mjs.map +1 -1
  19. package/esm/routing/filesystem-route.mjs +36 -7
  20. package/esm/routing/filesystem-route.mjs.map +1 -1
  21. package/esm/routing/page-file-segment.mjs +66 -0
  22. package/esm/routing/page-file-segment.mjs.map +1 -0
  23. package/esm/routing/page-route-grammar.mjs +79 -0
  24. package/esm/routing/page-route-grammar.mjs.map +1 -0
  25. package/esm/routing/route-identity.d.mts +69 -0
  26. package/esm/routing/route-identity.mjs +100 -44
  27. package/esm/routing/route-identity.mjs.map +1 -1
  28. package/esm/server/build-hydration-payload.mjs +3 -2
  29. package/esm/server/build-hydration-payload.mjs.map +1 -1
  30. package/esm/server/create-page-route-handler.d.mts +34 -1
  31. package/esm/server/create-page-route-handler.mjs +35 -4
  32. package/esm/server/create-page-route-handler.mjs.map +1 -1
  33. package/esm/server/framework-default-not-found-stylesheet.mjs +102 -0
  34. package/esm/server/framework-default-not-found-stylesheet.mjs.map +1 -0
  35. package/esm/server/install-page-routes-from-manifest.mjs +13 -16
  36. package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
  37. package/esm/server/install-page-routes.d.mts +3 -1
  38. package/esm/server/install-page-routes.mjs +11 -11
  39. package/esm/server/install-page-routes.mjs.map +1 -1
  40. package/esm/server/not-found-page.d.mts +1 -13
  41. package/esm/server/not-found-page.mjs +50 -5
  42. package/esm/server/not-found-page.mjs.map +1 -1
  43. package/esm/server/register-production-public-files.mjs +16 -1
  44. package/esm/server/register-production-public-files.mjs.map +1 -1
  45. package/esm/server/render-page.d.mts +1 -8
  46. package/esm/server/render-page.mjs +24 -21
  47. package/esm/server/render-page.mjs.map +1 -1
  48. package/esm/server/response-cache-floor.mjs +79 -0
  49. package/esm/server/response-cache-floor.mjs.map +1 -0
  50. package/esm/server/set-cookie-cache-floor-hook.mjs +41 -0
  51. package/esm/server/set-cookie-cache-floor-hook.mjs.map +1 -0
  52. package/esm/server/web-connector.d.mts +1 -1
  53. package/esm/server/web-connector.mjs +23 -4
  54. package/esm/server/web-connector.mjs.map +1 -1
  55. package/llms-full.txt +62 -33
  56. package/llms.txt +1 -1
  57. package/package.json +4 -3
  58. package/skills/create-a-page/SKILL.md +59 -28
  59. package/skills/navigate-on-the-client/SKILL.md +16 -11
  60. package/skills/serve-styles/SKILL.md +2 -1
  61. package/skills/use-layouts/SKILL.md +3 -6
  62. package/skills/write-the-root/SKILL.md +0 -2
@@ -1 +1 @@
1
- {"version":3,"file":"document-context.mjs","names":[],"sources":["../../../../../../../web/src/components/document-context.ts"],"sourcesContent":["import { createContext, useContext } from \"react\";\nimport type { MetadataOutput } from \"../metadata\";\n\n/**\n * The JSON-safe error shape carried from the server document to browser\n * hydration.\n *\n * This is deliberately NOT the original thrown object. Error prototypes,\n * identity, non-enumerable fields and arbitrary custom values do not survive a\n * JSON boundary reliably. The server renders `ErrorPageProps` with the\n * original value, then normalizes it to this lossy representation only for the\n * hydration payload. Normalization also owns disclosure: `stack` is optional\n * and must be omitted or redacted when server internals are not safe to expose\n * to the browser.\n */\nexport type SerializedPageError = {\n readonly name: string;\n readonly message: string;\n readonly stack?: string;\n};\n\n/**\n * Props received by the application-owned `error.page.tsx` during SSR.\n *\n * Deliberately preserve the thrown value here. An application can use its own\n * error classes, symbols, or structured values while rendering on the server;\n * this public component contract is not a JSON boundary.\n */\nexport type ErrorPageProps = {\n readonly error: unknown;\n readonly status: number;\n};\n\n/**\n * The JSON-safe counterpart of {@link ErrorPageProps}, used only after the\n * document crosses from SSR into browser hydration. Keeping this distinct\n * prevents a serialized approximation from being mistaken for the original\n * thrown value available to the server render.\n */\nexport type SerializedErrorPageProps = {\n readonly error: SerializedPageError;\n readonly status: number;\n};\n\nexport type HydrationDocumentPayloadSource = {\n readonly appData: unknown;\n readonly layoutData: unknown;\n readonly pageData: unknown;\n readonly shared: unknown;\n /**\n * The matched page manifest entry's stable `name` — the same field the\n * manifest entry contract `{ type, name, path, load }` declares. It is on\n * the wire so the browser can look up WHICH page the server rendered\n * instead of re-matching `location.pathname` itself: re-matching is a\n * second implementation of route semantics, and it can disagree with the\n * server on the very request it is hydrating.\n */\n readonly name: string;\n /**\n * The params the SERVER matched for this request — `bundle.route.params`\n * (`server/execute-page-request.ts:288`), carried untransformed. Same reason\n * `name` is here: the browser must not re-derive them from\n * `location.pathname`, because deriving them IS a second matcher.\n *\n * OPTIONAL, and ungated on purpose — see {@link metadata} below for the rule\n * both new keys share. The server always emits it (`{}` for a route with no\n * dynamic segments), so absence means the payload came from a producer that\n * predates this key; `currentRoute()` then reports `{}` rather than failing a\n * page over an accessor.\n */\n readonly params?: Readonly<Record<string, string>>;\n /**\n * The page metadata the server resolved at stage 8, carried WHOLE — the same\n * `MetadataOutput` `<Head/>` rendered into the document on the first request.\n *\n * Why it has to be on the wire at all: `<Head/>` renders inside the App\n * level, and the App level is not part of the hydrated tree (the client\n * mounts at `#root`, which App contains). So on a client navigation there is\n * no React render that can reach `<head>` — without this key the browser\n * never learns the new page's title and the tab keeps the old one.\n *\n * OPTIONAL, deliberately: `bundle.metadata` is itself optional\n * (`server/execute-page-request.ts:296`) — a page that exports no `metadata`\n * produces none, and a loader short-circuit skips stage 8 entirely. Gating a\n * key the server is right not to produce would make `readHydrationPayload`\n * throw on a valid page. Present-but-not-an-object is still MALFORMED and\n * still throws; only ABSENT is accepted.\n */\n readonly metadata?: MetadataOutput;\n /**\n * Present only when the server selected the application-owned error page for\n * this response. Atomic rather than two independently optional top-level\n * fields: a status without an error (or the reverse) cannot describe a tree\n * the browser can hydrate.\n *\n * `name` above intentionally remains the ORIGINAL matched route. This field\n * selects the `ErrorPage` module projected into that route's client\n * composition; it does not turn the error page into a second browsable route.\n */\n readonly errorPage?: SerializedErrorPageProps;\n};\n\nexport const PAYLOAD_SCRIPT_ID = \"__WARLOCK_DATA__\";\n\nconst LINE_SEPARATOR = String.fromCharCode(0x2028);\nconst PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);\n\n/** Escape JSON text for raw insertion into an application/json script. */\nexport function escapePayload(json: string): string {\n return json\n .split(\"<\")\n .join(\"\\\\u003c\")\n .split(\">\")\n .join(\"\\\\u003e\")\n .split(LINE_SEPARATOR)\n .join(\"\\\\u2028\")\n .split(PARAGRAPH_SEPARATOR)\n .join(\"\\\\u2029\");\n}\n\n/**\n * What `<Head/>`/`<Scripts/>` need to render real elements instead of the\n * framework injecting them by string surgery post-render (Suki, room seq\n * 1205): the resolved page metadata and the exact payload the hydration\n * script will read back. Provided once, around the root element, before\n * `renderToString` runs (`render-page.ts`'s stage 9 — the bundle is already\n * complete by then). Universal: no server-only imports, so the client's\n * hydration entry (a later slice) can provide the same shape from the parsed\n * payload script.\n */\nexport type DocumentContextValue = {\n metadata: MetadataOutput | undefined;\n payload: HydrationDocumentPayloadSource;\n /**\n * The nonce/lang/dir SLOTS: fed by the render provider from CORE request\n * fields — request nonce, request locale — never from app-owned `shared`\n * keys, which an app can overwrite. The provider-side\n * wiring is a separate slice, so these are absent at runtime until it\n * lands; every reader must treat them as optional.\n */\n nonce?: string;\n lang?: string;\n dir?: string;\n};\n\nexport const DocumentContext = createContext<DocumentContextValue | undefined>(undefined);\n\n/**\n * Require the page pipeline's universal document state. The payload id and\n * escaping helpers remain exported above for the existing server seam.\n */\nexport function useDocumentContext(componentName: string): DocumentContextValue {\n const value = useContext(DocumentContext);\n\n if (!value) {\n throw new Error(\n `<${componentName}/> was rendered outside the page pipeline's document context ` +\n \"(web/src/components/document-context.ts). Fix: only render it inside \" +\n \"an App/Layout/Page component tree the pipeline itself renders.\",\n );\n }\n\n return value;\n}\n"],"mappings":";;;AAsGA,MAAa,oBAAoB;AAEjC,MAAM,iBAAiB,OAAO,aAAa,IAAM;AACjD,MAAM,sBAAsB,OAAO,aAAa,IAAM;;AAGtD,SAAgB,cAAc,MAAsB;CAClD,OAAO,KACJ,MAAM,GAAG,CAAC,CACV,KAAK,SAAS,CAAC,CACf,MAAM,GAAG,CAAC,CACV,KAAK,SAAS,CAAC,CACf,MAAM,cAAc,CAAC,CACrB,KAAK,SAAS,CAAC,CACf,MAAM,mBAAmB,CAAC,CAC1B,KAAK,SAAS;AACnB;AA2BA,MAAa,kBAAkB,cAAgD,MAAS;;;;;AAMxF,SAAgB,mBAAmB,eAA6C;CAC9E,MAAM,QAAQ,WAAW,eAAe;CAExC,IAAI,CAAC,OACH,MAAM,IAAI,MACR,IAAI,cAAc,iMAGpB;CAGF,OAAO;AACT"}
1
+ {"version":3,"file":"document-context.mjs","names":[],"sources":["../../../../../../../web/src/components/document-context.ts"],"sourcesContent":["import { createContext, useContext } from \"react\";\nimport type { MetadataOutput } from \"../metadata\";\n\n/**\n * The JSON-safe error shape carried from the server document to browser\n * hydration.\n *\n * This is deliberately NOT the original thrown object. Error prototypes,\n * identity, non-enumerable fields and arbitrary custom values do not survive a\n * JSON boundary reliably. The server renders `ErrorPageProps` with the\n * original value, then normalizes it to this lossy representation only for the\n * hydration payload. Normalization also owns disclosure: `stack` is optional\n * and must be omitted or redacted when server internals are not safe to expose\n * to the browser.\n */\nexport type SerializedPageError = {\n readonly name: string;\n readonly message: string;\n readonly stack?: string;\n};\n\n/**\n * Props received by the application-owned `error.page.tsx` during SSR.\n *\n * Deliberately preserve the thrown value here. An application can use its own\n * error classes, symbols, or structured values while rendering on the server;\n * this public component contract is not a JSON boundary.\n */\nexport type ErrorPageProps = {\n readonly error: unknown;\n readonly status: number;\n};\n\n/**\n * The JSON-safe counterpart of {@link ErrorPageProps}, used only after the\n * document crosses from SSR into browser hydration. Keeping this distinct\n * prevents a serialized approximation from being mistaken for the original\n * thrown value available to the server render.\n */\nexport type SerializedErrorPageProps = {\n readonly error: SerializedPageError;\n readonly status: number;\n};\n\nexport type HydrationDocumentPayloadSource = {\n readonly appData: unknown;\n readonly layoutData: unknown;\n readonly pageData: unknown;\n readonly shared: unknown;\n /**\n * The matched page manifest entry's stable `name` — the same field the\n * manifest entry contract `{ type, name, path, load }` declares. It is on\n * the wire so the browser can look up WHICH page the server rendered\n * instead of re-matching `location.pathname` itself: re-matching is a\n * second implementation of route semantics, and it can disagree with the\n * server on the very request it is hydrating.\n */\n readonly name: string;\n /** The request locale selected by core for this exact render. */\n readonly locale: string;\n /**\n * The params the SERVER matched for this request — `bundle.route.params`\n * (`server/execute-page-request.ts:288`), carried untransformed. Same reason\n * `name` is here: the browser must not re-derive them from\n * `location.pathname`, because deriving them IS a second matcher.\n *\n * OPTIONAL, and ungated on purpose — see {@link metadata} below for the rule\n * both new keys share. The server always emits it (`{}` for a route with no\n * dynamic segments), so absence means the payload came from a producer that\n * predates this key; `currentRoute()` then reports `{}` rather than failing a\n * page over an accessor.\n */\n readonly params?: Readonly<Record<string, string>>;\n /**\n * The page metadata the server resolved at stage 8, carried WHOLE — the same\n * `MetadataOutput` `<Head/>` rendered into the document on the first request.\n *\n * Why it has to be on the wire at all: `<Head/>` renders inside the App\n * level, and the App level is not part of the hydrated tree (the client\n * mounts at `#root`, which App contains). So on a client navigation there is\n * no React render that can reach `<head>` — without this key the browser\n * never learns the new page's title and the tab keeps the old one.\n *\n * OPTIONAL, deliberately: `bundle.metadata` is itself optional\n * (`server/execute-page-request.ts:296`) — a page that exports no `metadata`\n * produces none, and a loader short-circuit skips stage 8 entirely. Gating a\n * key the server is right not to produce would make `readHydrationPayload`\n * throw on a valid page. Present-but-not-an-object is still MALFORMED and\n * still throws; only ABSENT is accepted.\n */\n readonly metadata?: MetadataOutput;\n /**\n * Present only when the server selected the application-owned error page for\n * this response. Atomic rather than two independently optional top-level\n * fields: a status without an error (or the reverse) cannot describe a tree\n * the browser can hydrate.\n *\n * `name` above intentionally remains the ORIGINAL matched route. This field\n * selects the `ErrorPage` module projected into that route's client\n * composition; it does not turn the error page into a second browsable route.\n */\n readonly errorPage?: SerializedErrorPageProps;\n};\n\nexport const PAYLOAD_SCRIPT_ID = \"__WARLOCK_DATA__\";\n\nconst LINE_SEPARATOR = String.fromCharCode(0x2028);\nconst PARAGRAPH_SEPARATOR = String.fromCharCode(0x2029);\n\n/** Escape JSON text for raw insertion into an application/json script. */\nexport function escapePayload(json: string): string {\n return json\n .split(\"<\")\n .join(\"\\\\u003c\")\n .split(\">\")\n .join(\"\\\\u003e\")\n .split(LINE_SEPARATOR)\n .join(\"\\\\u2028\")\n .split(PARAGRAPH_SEPARATOR)\n .join(\"\\\\u2029\");\n}\n\n/**\n * What `<Head/>`/`<Scripts/>` need to render real elements instead of the\n * framework injecting them by string surgery post-render (Suki, room seq\n * 1205): the resolved page metadata and the exact payload the hydration\n * script will read back. Provided once, around the root element, before\n * `renderToString` runs (`render-page.ts`'s stage 9 — the bundle is already\n * complete by then). Universal: no server-only imports, so the client's\n * hydration entry (a later slice) can provide the same shape from the parsed\n * payload script.\n */\nexport type DocumentContextValue = {\n metadata: MetadataOutput | undefined;\n payload: HydrationDocumentPayloadSource;\n /**\n * The nonce/lang/dir SLOTS: fed by the render provider from CORE request\n * fields — request nonce, request locale — never from app-owned `shared`\n * keys, which an app can overwrite. The provider-side\n * wiring is a separate slice, so these are absent at runtime until it\n * lands; every reader must treat them as optional.\n */\n nonce?: string;\n lang?: string;\n dir?: string;\n};\n\nexport const DocumentContext = createContext<DocumentContextValue | undefined>(undefined);\n\n/**\n * Require the page pipeline's universal document state. The payload id and\n * escaping helpers remain exported above for the existing server seam.\n */\nexport function useDocumentContext(componentName: string): DocumentContextValue {\n const value = useContext(DocumentContext);\n\n if (!value) {\n throw new Error(\n `<${componentName}/> was rendered outside the page pipeline's document context ` +\n \"(web/src/components/document-context.ts). Fix: only render it inside \" +\n \"an App/Layout/Page component tree the pipeline itself renders.\",\n );\n }\n\n return value;\n}\n"],"mappings":";;;AAwGA,MAAa,oBAAoB;AAEjC,MAAM,iBAAiB,OAAO,aAAa,IAAM;AACjD,MAAM,sBAAsB,OAAO,aAAa,IAAM;;AAGtD,SAAgB,cAAc,MAAsB;CAClD,OAAO,KACJ,MAAM,GAAG,CAAC,CACV,KAAK,SAAS,CAAC,CACf,MAAM,GAAG,CAAC,CACV,KAAK,SAAS,CAAC,CACf,MAAM,cAAc,CAAC,CACrB,KAAK,SAAS,CAAC,CACf,MAAM,mBAAmB,CAAC,CAC1B,KAAK,SAAS;AACnB;AA2BA,MAAa,kBAAkB,cAAgD,MAAS;;;;;AAMxF,SAAgB,mBAAmB,eAA6C;CAC9E,MAAM,QAAQ,WAAW,eAAe;CAExC,IAAI,CAAC,OACH,MAAM,IAAI,MACR,IAAI,cAAc,iMAGpB;CAGF,OAAO;AACT"}
@@ -12,7 +12,8 @@ const REQUIRED_PAYLOAD_KEYS = [
12
12
  "layoutData",
13
13
  "pageData",
14
14
  "shared",
15
- "name"
15
+ "name",
16
+ "locale"
16
17
  ];
17
18
  const ABSENT_PAYLOAD_MESSAGE = `Warlock hydration payload is absent: #${PAYLOAD_SCRIPT_ID}, owned by web/src/components/document-context.ts, was not found.`;
18
19
  const MALFORMED_PAYLOAD_MESSAGE = `Warlock hydration payload was found at #${PAYLOAD_SCRIPT_ID} but could not be read.`;
@@ -67,6 +68,9 @@ function requireErrorPagePayload(value) {
67
68
  function requireHydrationPayload(value) {
68
69
  if (!isPlainObject(value)) malformedPayload();
69
70
  for (const key of REQUIRED_PAYLOAD_KEYS) if (!Object.prototype.hasOwnProperty.call(value, key)) malformedPayload();
71
+ if (typeof value.name !== "string") malformedPayload();
72
+ const locale = value.locale;
73
+ if (typeof locale !== "string" || locale.length === 0) malformedPayload();
70
74
  for (const key of OPTIONAL_OBJECT_PAYLOAD_KEYS) {
71
75
  const optional = value[key];
72
76
  if (optional !== void 0 && !isPlainObject(optional)) malformedPayload();
@@ -78,7 +82,7 @@ function requireHydrationPayload(value) {
78
82
  /**
79
83
  * Read the fixed payload script without changing the server-rendered root.
80
84
  *
81
- * Extra fields are ignored. The gate owns the FIVE required keys — absent or
85
+ * Extra fields are ignored. The gate owns the SIX required keys — absent or
82
86
  * malformed, both throw — plus a shape check on the three optional ones
83
87
  * ({@link OPTIONAL_OBJECT_PAYLOAD_KEYS}); it deliberately does not require
84
88
  * those to be present. `errorPage`, when present, is additionally validated as
@@ -1 +1 @@
1
- {"version":3,"file":"hydration-payload.mjs","names":[],"sources":["../../../../../../web/src/hydration-payload.ts"],"sourcesContent":["import {\n PAYLOAD_SCRIPT_ID,\n type HydrationDocumentPayloadSource,\n} from \"./components/document-context\";\n\nexport type { HydrationDocumentPayloadSource } from \"./components/document-context\";\nexport type {\n ErrorPageProps,\n SerializedErrorPageProps,\n SerializedPageError,\n} from \"./components/document-context\";\n\n/**\n * Exported so a payload-shape assertion can be written against the contract\n * itself. A spec that hardcodes its own copy of this list silently becomes a\n * claim about a PAST revision — that is exactly how the rev. 3 keys landed with\n * two specs still asserting the rev. 2 shape.\n */\nexport const REQUIRED_PAYLOAD_KEYS = [\n \"appData\",\n \"layoutData\",\n \"pageData\",\n \"shared\",\n \"name\",\n] 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"}
1
+ {"version":3,"file":"hydration-payload.mjs","names":[],"sources":["../../../../../../web/src/hydration-payload.ts"],"sourcesContent":["import {\n PAYLOAD_SCRIPT_ID,\n type HydrationDocumentPayloadSource,\n} from \"./components/document-context\";\n\nexport type { HydrationDocumentPayloadSource } from \"./components/document-context\";\nexport type {\n ErrorPageProps,\n SerializedErrorPageProps,\n SerializedPageError,\n} from \"./components/document-context\";\n\n/**\n * Exported so a payload-shape assertion can be written against the contract\n * itself. A spec that hardcodes its own copy of this list silently becomes a\n * claim about a PAST revision — that is exactly how the rev. 3 keys landed with\n * two specs still asserting the rev. 2 shape.\n */\nexport const REQUIRED_PAYLOAD_KEYS = [\n \"appData\",\n \"layoutData\",\n \"pageData\",\n \"shared\",\n \"name\",\n \"locale\",\n] as const;\n\nconst ABSENT_PAYLOAD_MESSAGE =\n `Warlock hydration payload is absent: #${PAYLOAD_SCRIPT_ID}, owned by ` +\n \"web/src/components/document-context.ts, was not found.\";\nconst MALFORMED_PAYLOAD_MESSAGE =\n `Warlock hydration payload was found at #${PAYLOAD_SCRIPT_ID} but could not be read.`;\n\nfunction malformedPayload(): never {\n throw new Error(MALFORMED_PAYLOAD_MESSAGE);\n}\n\n/**\n * The keys that are allowed to be ABSENT but not allowed to be wrong.\n *\n * `metadata`, `params` and `errorPage` are optional because the server is right\n * always produce them — a page with no `metadata` export resolves none, and a\n * older payload carries none of these additions. Failing a whole page over an\n * absent accessor would turn a compatible payload into a blank screen, so\n * absence is accepted.\n *\n * Present-but-not-an-object is a different claim entirely: it means something\n * produced a payload with these names meaning something else, and every reader\n * downstream would then be indexing a string. That is MALFORMED under the same\n * rule the required keys live by, so it throws. Arrays included — `typeof []`\n * is `\"object\"`, and an array of params is not params.\n */\nexport const OPTIONAL_OBJECT_PAYLOAD_KEYS = [\"metadata\", \"params\", \"errorPage\"] as const;\n\nfunction isPlainObject(value: unknown): boolean {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction hasExactStringKeys(\n value: Record<PropertyKey, unknown>,\n required: readonly string[],\n optional: readonly string[] = [],\n): boolean {\n const allowed = new Set([...required, ...optional]);\n const keys = Reflect.ownKeys(value);\n\n return (\n required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&\n keys.every((key) => typeof key === \"string\" && allowed.has(key))\n );\n}\n\n/**\n * Validate the explicit serialization boundary, not an `Error` instance.\n * `JSON.stringify(new Error(\"boom\"))` is normally `{}` because its useful\n * fields are non-enumerable; accepting that would hydrate an error page with a\n * different contract from the one the server rendered.\n */\nfunction requireErrorPagePayload(value: unknown): void {\n if (!isPlainObject(value)) malformedPayload();\n\n const errorPage = value as Record<PropertyKey, unknown>;\n\n if (!hasExactStringKeys(errorPage, [\"error\", \"status\"])) malformedPayload();\n if (!isPlainObject(errorPage.error)) malformedPayload();\n\n const error = errorPage.error as Record<PropertyKey, unknown>;\n\n if (!hasExactStringKeys(error, [\"name\", \"message\"], [\"stack\"])) malformedPayload();\n if (typeof error.name !== \"string\" || typeof error.message !== \"string\") {\n malformedPayload();\n }\n if (error.stack !== undefined && typeof error.stack !== \"string\") malformedPayload();\n\n if (\n typeof errorPage.status !== \"number\" ||\n !Number.isInteger(errorPage.status) ||\n errorPage.status < 500 ||\n errorPage.status > 599\n ) {\n malformedPayload();\n }\n}\n\nfunction requireHydrationPayload(value: unknown): HydrationDocumentPayloadSource {\n if (!isPlainObject(value)) malformedPayload();\n\n for (const key of REQUIRED_PAYLOAD_KEYS) {\n if (!Object.prototype.hasOwnProperty.call(value, key)) malformedPayload();\n }\n\n if (typeof (value as Record<string, unknown>).name !== \"string\") malformedPayload();\n const locale = (value as Record<string, unknown>).locale;\n if (typeof locale !== \"string\" || locale.length === 0) malformedPayload();\n\n for (const key of OPTIONAL_OBJECT_PAYLOAD_KEYS) {\n const optional = (value as Record<string, unknown>)[key];\n\n if (optional !== undefined && !isPlainObject(optional)) malformedPayload();\n }\n\n const errorPage = (value as Record<string, unknown>).errorPage;\n if (errorPage !== undefined) requireErrorPagePayload(errorPage);\n\n return value as HydrationDocumentPayloadSource;\n}\n\n/**\n * Read the fixed payload script without changing the server-rendered root.\n *\n * Extra fields are ignored. The gate owns the SIX required keys — absent or\n * malformed, both throw — plus a shape check on the three optional ones\n * ({@link OPTIONAL_OBJECT_PAYLOAD_KEYS}); it deliberately does not require\n * those to be present. `errorPage`, when present, is additionally validated as\n * one atomic `{ error, status }` selection with a serialized error and a 5xx.\n */\nexport function readHydrationPayload(documentNode: Document): HydrationDocumentPayloadSource {\n const element = documentNode.getElementById(PAYLOAD_SCRIPT_ID);\n\n if (element === null) throw new Error(ABSENT_PAYLOAD_MESSAGE);\n\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(element.textContent ?? \"\");\n } catch {\n malformedPayload();\n }\n\n return requireHydrationPayload(parsed);\n}\n"],"mappings":";;;;;;;;;AAkBA,MAAa,wBAAwB;CACnC;CACA;CACA;CACA;CACA;CACA;AACF;AAEA,MAAM,yBACJ,yCAAyC,kBAAkB;AAE7D,MAAM,4BACJ,2CAA2C,kBAAkB;AAE/D,SAAS,mBAA0B;CACjC,MAAM,IAAI,MAAM,yBAAyB;AAC3C;;;;;;;;;;;;;;;;AAiBA,MAAa,+BAA+B;CAAC;CAAY;CAAU;AAAW;AAE9E,SAAS,cAAc,OAAyB;CAC9C,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAEA,SAAS,mBACP,OACA,UACA,WAA8B,CAAC,GACtB;CACT,MAAM,UAAU,IAAI,IAAI,CAAC,GAAG,UAAU,GAAG,QAAQ,CAAC;CAClD,MAAM,OAAO,QAAQ,QAAQ,KAAK;CAElC,OACE,SAAS,OAAO,QAAQ,OAAO,UAAU,eAAe,KAAK,OAAO,GAAG,CAAC,KACxE,KAAK,OAAO,QAAQ,OAAO,QAAQ,YAAY,QAAQ,IAAI,GAAG,CAAC;AAEnE;;;;;;;AAQA,SAAS,wBAAwB,OAAsB;CACrD,IAAI,CAAC,cAAc,KAAK,GAAG,iBAAiB;CAE5C,MAAM,YAAY;CAElB,IAAI,CAAC,mBAAmB,WAAW,CAAC,SAAS,QAAQ,CAAC,GAAG,iBAAiB;CAC1E,IAAI,CAAC,cAAc,UAAU,KAAK,GAAG,iBAAiB;CAEtD,MAAM,QAAQ,UAAU;CAExB,IAAI,CAAC,mBAAmB,OAAO,CAAC,QAAQ,SAAS,GAAG,CAAC,OAAO,CAAC,GAAG,iBAAiB;CACjF,IAAI,OAAO,MAAM,SAAS,YAAY,OAAO,MAAM,YAAY,UAC7D,iBAAiB;CAEnB,IAAI,MAAM,UAAU,UAAa,OAAO,MAAM,UAAU,UAAU,iBAAiB;CAEnF,IACE,OAAO,UAAU,WAAW,YAC5B,CAAC,OAAO,UAAU,UAAU,MAAM,KAClC,UAAU,SAAS,OACnB,UAAU,SAAS,KAEnB,iBAAiB;AAErB;AAEA,SAAS,wBAAwB,OAAgD;CAC/E,IAAI,CAAC,cAAc,KAAK,GAAG,iBAAiB;CAE5C,KAAK,MAAM,OAAO,uBAChB,IAAI,CAAC,OAAO,UAAU,eAAe,KAAK,OAAO,GAAG,GAAG,iBAAiB;CAG1E,IAAI,OAAQ,MAAkC,SAAS,UAAU,iBAAiB;CAClF,MAAM,SAAU,MAAkC;CAClD,IAAI,OAAO,WAAW,YAAY,OAAO,WAAW,GAAG,iBAAiB;CAExE,KAAK,MAAM,OAAO,8BAA8B;EAC9C,MAAM,WAAY,MAAkC;EAEpD,IAAI,aAAa,UAAa,CAAC,cAAc,QAAQ,GAAG,iBAAiB;CAC3E;CAEA,MAAM,YAAa,MAAkC;CACrD,IAAI,cAAc,QAAW,wBAAwB,SAAS;CAE9D,OAAO;AACT;;;;;;;;;;AAWA,SAAgB,qBAAqB,cAAwD;CAC3F,MAAM,UAAU,aAAa,eAAe,iBAAiB;CAE7D,IAAI,YAAY,MAAM,MAAM,IAAI,MAAM,sBAAsB;CAE5D,IAAI;CAEJ,IAAI;EACF,SAAS,KAAK,MAAM,QAAQ,eAAe,EAAE;CAC/C,QAAQ;EACN,iBAAiB;CACnB;CAEA,OAAO,wBAAwB,MAAM;AACvC"}
package/esm/index.d.mts CHANGED
@@ -4,6 +4,7 @@ import { AppProps, LayoutProps, PageProps } from "./props.mjs";
4
4
  import { PageMetadata } from "./metadata.mjs";
5
5
  import { ErrorPageProps, SerializedErrorPageProps, SerializedPageError } from "./components/document-context.mjs";
6
6
  import { shared, useShared } from "./shared.mjs";
7
+ import { LocaleProvider, LocaleProviderProps, Translate, useLocale, useTrans } from "./localization.mjs";
7
8
  import { QueryStringInput, QueryStringLeaf, QueryStringNested, QueryStringObject, QueryStringOptions, QueryStringValue, RepeatedKeyStrategy, UnserializableQueryValueError, queryString, queryStringOf, resetQueryStringOptions, setQueryStringOptions } from "./routing/query-string.mjs";
8
9
  import { RouteParameters, RouteQuery, href } from "./routing/route-table.mjs";
9
10
  import { Link } from "./components/link.mjs";
@@ -32,5 +33,6 @@ import { Scripts } from "./components/scripts.mjs";
32
33
  */
33
34
  interface SharedContext {}
34
35
  //#endregion
35
- export { type AppLoader, type AppProps, type ErrorPageProps, Head, type HttpContext, type LayoutLoader, type LayoutProps, Link, type MatchedRoute, type NavigationEndPayload, type NavigationErrorPayload, type NavigationStartPayload, type PageContext, type PageLoader, type PageMetadata, type PageProps, type QueryStringInput, type QueryStringLeaf, type QueryStringNested, type QueryStringObject, type QueryStringOptions, type QueryStringValue, type RepeatedKeyStrategy, type RouteParameters, type RouteQuery, Scripts, type SerializedErrorPageProps, type SerializedPageError, SharedContext, UnserializableQueryValueError, createRouterEvents, currentRoute, getHash, href, navigateBack, navigateTo, previousRoute, queryString, queryStringOf, refresh, resetQueryStringOptions, routerEvents, setQueryStringOptions, shared, useShared };
36
- //# sourceMappingURL=index.d.mts.map
36
+ export { type AppLoader, type AppProps, type ErrorPageProps, Head, type HttpContext, type LayoutLoader, type LayoutProps, Link, LocaleProvider, type LocaleProviderProps, type MatchedRoute, type NavigationEndPayload, type NavigationErrorPayload, type NavigationStartPayload, type PageContext, type PageLoader, type PageMetadata, type PageProps, type QueryStringInput, type QueryStringLeaf, type QueryStringNested, type QueryStringObject, type QueryStringOptions, type QueryStringValue, type RepeatedKeyStrategy, type RouteParameters, type RouteQuery, Scripts, type SerializedErrorPageProps, type SerializedPageError, SharedContext, type Translate, UnserializableQueryValueError, createRouterEvents, currentRoute, getHash, href, navigateBack, navigateTo, previousRoute, queryString, queryStringOf, refresh, resetQueryStringOptions, routerEvents, setQueryStringOptions, shared, useLocale, useShared, useTrans };
37
+ //# sourceMappingURL=index.d.mts.map
38
+ import "./server/create-page-route-handler.mjs";
package/esm/index.mjs CHANGED
@@ -1,4 +1,5 @@
1
1
  import { shared, useShared } from "./shared.mjs";
2
+ import { LocaleProvider, useLocale, useTrans } from "./localization.mjs";
2
3
  import { UnserializableQueryValueError, queryString, queryStringOf, resetQueryStringOptions, setQueryStringOptions } from "./routing/query-string.mjs";
3
4
  import { href } from "./routing/route-table.mjs";
4
5
  import { Link } from "./components/link.mjs";
@@ -9,4 +10,4 @@ import { refresh } from "./client/navigation/refresh.mjs";
9
10
  import { Head } from "./components/head.mjs";
10
11
  import { Scripts } from "./components/scripts.mjs";
11
12
 
12
- export { Head, Link, Scripts, UnserializableQueryValueError, createRouterEvents, currentRoute, getHash, href, navigateBack, navigateTo, previousRoute, queryString, queryStringOf, refresh, resetQueryStringOptions, routerEvents, setQueryStringOptions, shared, useShared };
13
+ export { Head, Link, LocaleProvider, Scripts, UnserializableQueryValueError, createRouterEvents, currentRoute, getHash, href, navigateBack, navigateTo, previousRoute, queryString, queryStringOf, refresh, resetQueryStringOptions, routerEvents, setQueryStringOptions, shared, useLocale, useShared, useTrans };
@@ -0,0 +1,21 @@
1
+ import { Converter, Translatable, transFrom } from "@mongez/localization";
2
+ import { ReactNode } from "react";
3
+
4
+ //#region ../web/src/localization.d.ts
5
+ type LocaleProviderProps = {
6
+ readonly locale: string;
7
+ readonly children: ReactNode;
8
+ };
9
+ type Translate = (keyword: Translatable, placeholders?: unknown, converter?: Converter) => ReturnType<typeof transFrom>;
10
+ /** Bind translations to the request locale carried by the hydration payload. */
11
+ declare function LocaleProvider({
12
+ locale,
13
+ children
14
+ }: LocaleProviderProps): import("react").JSX.Element;
15
+ /** Read the locale selected for the current server render or client page. */
16
+ declare function useLocale(): string;
17
+ /** Translate without consulting @mongez/localization's process-global locale. */
18
+ declare function useTrans(): Translate;
19
+ //#endregion
20
+ export { LocaleProvider, LocaleProviderProps, Translate, useLocale, useTrans };
21
+ //# sourceMappingURL=localization.d.mts.map
@@ -0,0 +1,28 @@
1
+ import { transFrom } from "@mongez/localization";
2
+ import { createContext, useCallback, useContext } from "react";
3
+ import { jsx } from "react/jsx-runtime";
4
+
5
+ //#region ../web/src/localization.tsx
6
+ const LocaleContext = createContext(void 0);
7
+ /** Bind translations to the request locale carried by the hydration payload. */
8
+ function LocaleProvider({ locale, children }) {
9
+ return /* @__PURE__ */ jsx(LocaleContext.Provider, {
10
+ value: locale,
11
+ children
12
+ });
13
+ }
14
+ /** Read the locale selected for the current server render or client page. */
15
+ function useLocale() {
16
+ const locale = useContext(LocaleContext);
17
+ if (locale === void 0) throw new Error("useLocale() was called outside Warlock's LocaleProvider. Render the component through the @warlock.js/web page pipeline.");
18
+ return locale;
19
+ }
20
+ /** Translate without consulting @mongez/localization's process-global locale. */
21
+ function useTrans() {
22
+ const locale = useLocale();
23
+ return useCallback((keyword, placeholders, converter) => transFrom(locale, keyword, placeholders, converter), [locale]);
24
+ }
25
+
26
+ //#endregion
27
+ export { LocaleProvider, useLocale, useTrans };
28
+ //# sourceMappingURL=localization.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"localization.mjs","names":[],"sources":["../../../../../../web/src/localization.tsx"],"sourcesContent":["import {\n transFrom,\n type Converter,\n type Translatable,\n} from \"@mongez/localization\";\nimport { createContext, useCallback, useContext, type ReactNode } from \"react\";\n\nexport type LocaleProviderProps = {\n readonly locale: string;\n readonly children: ReactNode;\n};\n\nexport type Translate = (\n keyword: Translatable,\n placeholders?: unknown,\n converter?: Converter,\n) => ReturnType<typeof transFrom>;\n\nconst LocaleContext = createContext<string | undefined>(undefined);\n\n/** Bind translations to the request locale carried by the hydration payload. */\nexport function LocaleProvider({ locale, children }: LocaleProviderProps) {\n return <LocaleContext.Provider value={locale}>{children}</LocaleContext.Provider>;\n}\n\n/** Read the locale selected for the current server render or client page. */\nexport function useLocale(): string {\n const locale = useContext(LocaleContext);\n\n if (locale === undefined) {\n throw new Error(\n \"useLocale() was called outside Warlock's LocaleProvider. Render the component \" +\n \"through the @warlock.js/web page pipeline.\",\n );\n }\n\n return locale;\n}\n\n/** Translate without consulting @mongez/localization's process-global locale. */\nexport function useTrans(): Translate {\n const locale = useLocale();\n\n return useCallback(\n (keyword, placeholders, converter) =>\n transFrom(locale, keyword, placeholders, converter),\n [locale],\n );\n}\n"],"mappings":";;;;;AAkBA,MAAM,gBAAgB,cAAkC,MAAS;;AAGjE,SAAgB,eAAe,EAAE,QAAQ,YAAiC;CACxE,OAAO,oBAAC,cAAc,UAAf;EAAwB,OAAO;EAAS;CAAiC;AAClF;;AAGA,SAAgB,YAAoB;CAClC,MAAM,SAAS,WAAW,aAAa;CAEvC,IAAI,WAAW,QACb,MAAM,IAAI,MACR,0HAEF;CAGF,OAAO;AACT;;AAGA,SAAgB,WAAsB;CACpC,MAAM,SAAS,UAAU;CAEzB,OAAO,aACJ,SAAS,cAAc,cACtB,UAAU,QAAQ,SAAS,cAAc,SAAS,GACpD,CAAC,MAAM,CACT;AACF"}
@@ -24,9 +24,11 @@
24
24
  *
25
25
  * Responses to a data request must carry `Vary: <this header>` so a shared
26
26
  * cache can never hand a document to a client that asked for JSON, or the
27
- * reverse. Page responses are `private, no-store` today, which makes that
28
- * theoretical `Vary` is what keeps it theoretical if the caching policy
29
- * changes.
27
+ * reverse. Most page responses are `no-store` which makes that theoretical
28
+ * — but an opted-in route (`route.cache`, `../routing/route-identity.ts`) is
29
+ * genuinely `public, max-age=<n>` on BOTH representations
30
+ * (`response-cache-floor.ts`), so `Vary` is what keeps a shared cache from
31
+ * ever conflating them.
30
32
  */
31
33
  const WARLOCK_DATA_REQUEST_HEADER = "x-warlock-data";
32
34
  /**
@@ -1 +1 @@
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
+ {"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. Most page responses are `no-store` which makes that theoretical\n * — but an opted-in route (`route.cache`, `../routing/route-identity.ts`) is\n * genuinely `public, max-age=<n>` on BOTH representations\n * (`response-cache-floor.ts`), so `Vary` is what keeps a shared cache from\n * ever conflating them.\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":";;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;AA+BA,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,14 +1,38 @@
1
+ import { PageFileSegmentNotSupportedError, classifyPageFileSegment } from "./page-file-segment.mjs";
2
+
1
3
  //#region ../web/src/routing/filesystem-route.ts
2
4
  function isGroup(segment) {
3
5
  return /^\([^/]+\)$/.test(segment);
4
6
  }
5
- function routeSegment(segment) {
7
+ /**
8
+ * Translate one filesystem segment (a directory name or the page basename)
9
+ * into its route form. Consults {@link classifyPageFileSegment} first and
10
+ * throws {@link PageFileSegmentNotSupportedError} for any segment outside
11
+ * the supported grammar, naming `pageFile` for context, instead of silently
12
+ * passing the segment through as a literal URL segment.
13
+ */
14
+ function routeSegment(segment, pageFile) {
15
+ const verdict = classifyPageFileSegment(segment);
16
+ if (verdict.type === "rejected") throw new PageFileSegmentNotSupportedError(pageFile, segment, verdict.reason);
6
17
  const dynamic = /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/.exec(segment);
7
18
  return dynamic ? `:${dynamic[1]}` : segment;
8
19
  }
9
20
  function prefixSegments(prefix) {
10
21
  return prefix.split("/").filter(Boolean);
11
22
  }
23
+ /**
24
+ * Split a layout `prefix` into route segments AND validate each one through
25
+ * {@link routeSegment}, exactly like a directory segment. A `prefix` is
26
+ * author-controlled text, not derived from the filesystem, so nothing about
27
+ * it is exempt from the grammar `page-file-segment.ts` defines — without
28
+ * this, a `prefix` such as `"/docs/[...slug]"` would smuggle a rejected
29
+ * shape straight into the URL. `pageFile` identifies the page whose layout
30
+ * contributed `prefix`, so a rejection points at the layout, not the page.
31
+ */
32
+ function validatedPrefixSegments(prefix, pageFile) {
33
+ const source = `${pageFile} (via layout prefix '${prefix}')`;
34
+ return prefixSegments(prefix).map((segment) => routeSegment(segment, source));
35
+ }
12
36
  function pageParts(pageFile) {
13
37
  if (pageFile.includes("\\")) throw new Error(`filesystem-route: pageFile must use POSIX separators: "${pageFile}"`);
14
38
  if (!pageFile.endsWith(".page.tsx")) throw new Error(`filesystem-route: pageFile must end in .page.tsx: "${pageFile}"`);
@@ -22,21 +46,26 @@ function pageParts(pageFile) {
22
46
  function deriveFilesystemRoutePath(input) {
23
47
  const { directories, basename } = pageParts(input.pageFile);
24
48
  const prefixes = input.layoutPrefixes ?? {};
25
- const segments = [...prefixSegments(prefixes[""] ?? "")];
49
+ const segments = [...validatedPrefixSegments(prefixes[""] ?? "", input.pageFile)];
26
50
  for (let index = 0; index < directories.length; index++) {
27
51
  const directory = directories[index];
28
52
  const prefix = prefixes[directories.slice(0, index + 1).join("/")];
29
- if (prefix !== void 0) segments.push(...prefixSegments(prefix));
30
- else if (!isGroup(directory)) segments.push(routeSegment(directory));
53
+ const routed = routeSegment(directory, input.pageFile);
54
+ if (prefix !== void 0) segments.push(...validatedPrefixSegments(prefix, input.pageFile));
55
+ else if (!isGroup(directory)) segments.push(routed);
31
56
  }
32
- if (basename !== "index") segments.push(routeSegment(basename));
57
+ if (basename !== "index") segments.push(routeSegment(basename, input.pageFile));
33
58
  return segments.length === 0 ? "/" : `/${segments.join("/")}`;
34
59
  }
35
60
  /** Derive the stable dotted route name from a page's filesystem identity. */
36
61
  function deriveFilesystemRouteName(pageFile) {
37
62
  const { directories, basename } = pageParts(pageFile);
38
- const segments = directories.filter((segment) => !isGroup(segment)).map(routeSegment);
39
- if (basename !== "index") segments.push(routeSegment(basename));
63
+ const segments = [];
64
+ for (const directory of directories) {
65
+ const routed = routeSegment(directory, pageFile);
66
+ if (!isGroup(directory)) segments.push(routed);
67
+ }
68
+ if (basename !== "index") segments.push(routeSegment(basename, pageFile));
40
69
  return segments.map((segment) => segment.replace(/^:/, "")).join(".") || "index";
41
70
  }
42
71
 
@@ -1 +1 @@
1
- {"version":3,"file":"filesystem-route.mjs","names":[],"sources":["../../../../../../../web/src/routing/filesystem-route.ts"],"sourcesContent":["export type FilesystemRouteInput = {\n /** POSIX path relative to `src/web`, ending in `.page.tsx`. */\n pageFile: string;\n /** Layout prefixes keyed by their POSIX directory relative to `src/web`; root uses `\"\"`. */\n layoutPrefixes?: Readonly<Record<string, string>>;\n};\n\nfunction isGroup(segment: string): boolean {\n return /^\\([^/]+\\)$/.test(segment);\n}\n\nfunction routeSegment(segment: string): string {\n const dynamic = /^\\[([A-Za-z_][A-Za-z0-9_]*)\\]$/.exec(segment);\n\n return dynamic ? `:${dynamic[1]}` : segment;\n}\n\nfunction prefixSegments(prefix: string): string[] {\n return prefix.split(\"/\").filter(Boolean);\n}\n\nfunction pageParts(pageFile: string): { directories: string[]; basename: string } {\n if (pageFile.includes(\"\\\\\")) {\n throw new Error(`filesystem-route: pageFile must use POSIX separators: \"${pageFile}\"`);\n }\n\n if (!pageFile.endsWith(\".page.tsx\")) {\n throw new Error(`filesystem-route: pageFile must end in .page.tsx: \"${pageFile}\"`);\n }\n\n const parts = pageFile.split(\"/\");\n const filename = parts.pop() as string;\n\n return {\n directories: parts,\n basename: filename.slice(0, -\".page.tsx\".length),\n };\n}\n\n/** Derive the effective URL for a page with no explicit `route` export. */\nexport function deriveFilesystemRoutePath(input: FilesystemRouteInput): string {\n const { directories, basename } = pageParts(input.pageFile);\n const prefixes = input.layoutPrefixes ?? {};\n const segments = [...prefixSegments(prefixes[\"\"] ?? \"\")];\n\n for (let index = 0; index < directories.length; index++) {\n const directory = directories[index];\n const directoryPath = directories.slice(0, index + 1).join(\"/\");\n const prefix = prefixes[directoryPath];\n\n if (prefix !== undefined) {\n segments.push(...prefixSegments(prefix));\n } else if (!isGroup(directory)) {\n segments.push(routeSegment(directory));\n }\n }\n\n if (basename !== \"index\") {\n segments.push(routeSegment(basename));\n }\n\n return segments.length === 0 ? \"/\" : `/${segments.join(\"/\")}`;\n}\n\n/** Derive the stable dotted route name from a page's filesystem identity. */\nexport function deriveFilesystemRouteName(pageFile: string): string {\n const { directories, basename } = pageParts(pageFile);\n const segments = directories.filter((segment) => !isGroup(segment)).map(routeSegment);\n\n if (basename !== \"index\") {\n segments.push(routeSegment(basename));\n }\n\n return segments.map((segment) => segment.replace(/^:/, \"\")).join(\".\") || \"index\";\n}\n"],"mappings":";AAOA,SAAS,QAAQ,SAA0B;CACzC,OAAO,cAAc,KAAK,OAAO;AACnC;AAEA,SAAS,aAAa,SAAyB;CAC7C,MAAM,UAAU,iCAAiC,KAAK,OAAO;CAE7D,OAAO,UAAU,IAAI,QAAQ,OAAO;AACtC;AAEA,SAAS,eAAe,QAA0B;CAChD,OAAO,OAAO,MAAM,GAAG,CAAC,CAAC,OAAO,OAAO;AACzC;AAEA,SAAS,UAAU,UAA+D;CAChF,IAAI,SAAS,SAAS,IAAI,GACxB,MAAM,IAAI,MAAM,0DAA0D,SAAS,EAAE;CAGvF,IAAI,CAAC,SAAS,SAAS,WAAW,GAChC,MAAM,IAAI,MAAM,sDAAsD,SAAS,EAAE;CAGnF,MAAM,QAAQ,SAAS,MAAM,GAAG;CAGhC,OAAO;EACL,aAAa;EACb,UAJe,MAAM,IAIJ,CAAC,CAAC,MAAM,GAAG,EAAmB;CACjD;AACF;;AAGA,SAAgB,0BAA0B,OAAqC;CAC7E,MAAM,EAAE,aAAa,aAAa,UAAU,MAAM,QAAQ;CAC1D,MAAM,WAAW,MAAM,kBAAkB,CAAC;CAC1C,MAAM,WAAW,CAAC,GAAG,eAAe,SAAS,OAAO,EAAE,CAAC;CAEvD,KAAK,IAAI,QAAQ,GAAG,QAAQ,YAAY,QAAQ,SAAS;EACvD,MAAM,YAAY,YAAY;EAE9B,MAAM,SAAS,SADO,YAAY,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC,KAAK,GACvB;EAEpC,IAAI,WAAW,QACb,SAAS,KAAK,GAAG,eAAe,MAAM,CAAC;OAClC,IAAI,CAAC,QAAQ,SAAS,GAC3B,SAAS,KAAK,aAAa,SAAS,CAAC;CAEzC;CAEA,IAAI,aAAa,SACf,SAAS,KAAK,aAAa,QAAQ,CAAC;CAGtC,OAAO,SAAS,WAAW,IAAI,MAAM,IAAI,SAAS,KAAK,GAAG;AAC5D;;AAGA,SAAgB,0BAA0B,UAA0B;CAClE,MAAM,EAAE,aAAa,aAAa,UAAU,QAAQ;CACpD,MAAM,WAAW,YAAY,QAAQ,YAAY,CAAC,QAAQ,OAAO,CAAC,CAAC,CAAC,IAAI,YAAY;CAEpF,IAAI,aAAa,SACf,SAAS,KAAK,aAAa,QAAQ,CAAC;CAGtC,OAAO,SAAS,KAAK,YAAY,QAAQ,QAAQ,MAAM,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,KAAK;AAC3E"}
1
+ {"version":3,"file":"filesystem-route.mjs","names":[],"sources":["../../../../../../../web/src/routing/filesystem-route.ts"],"sourcesContent":["import { PageFileSegmentNotSupportedError, classifyPageFileSegment } from \"./page-file-segment\";\r\n\r\nexport type FilesystemRouteInput = {\r\n /** POSIX path relative to `src/web`, ending in `.page.tsx`. */\r\n pageFile: string;\r\n /** Layout prefixes keyed by their POSIX directory relative to `src/web`; root uses `\"\"`. */\r\n layoutPrefixes?: Readonly<Record<string, string>>;\r\n};\r\n\r\nfunction isGroup(segment: string): boolean {\r\n return /^\\([^/]+\\)$/.test(segment);\r\n}\r\n\r\n/**\r\n * Translate one filesystem segment (a directory name or the page basename)\r\n * into its route form. Consults {@link classifyPageFileSegment} first and\r\n * throws {@link PageFileSegmentNotSupportedError} for any segment outside\r\n * the supported grammar, naming `pageFile` for context, instead of silently\r\n * passing the segment through as a literal URL segment.\r\n */\r\nfunction routeSegment(segment: string, pageFile: string): string {\r\n const verdict = classifyPageFileSegment(segment);\r\n\r\n if (verdict.type === \"rejected\") {\r\n throw new PageFileSegmentNotSupportedError(pageFile, segment, verdict.reason);\r\n }\r\n\r\n const dynamic = /^\\[([A-Za-z_][A-Za-z0-9_]*)\\]$/.exec(segment);\r\n\r\n return dynamic ? `:${dynamic[1]}` : segment;\r\n}\r\n\r\nfunction prefixSegments(prefix: string): string[] {\r\n return prefix.split(\"/\").filter(Boolean);\r\n}\r\n\r\n/**\r\n * Split a layout `prefix` into route segments AND validate each one through\r\n * {@link routeSegment}, exactly like a directory segment. A `prefix` is\r\n * author-controlled text, not derived from the filesystem, so nothing about\r\n * it is exempt from the grammar `page-file-segment.ts` defines — without\r\n * this, a `prefix` such as `\"/docs/[...slug]\"` would smuggle a rejected\r\n * shape straight into the URL. `pageFile` identifies the page whose layout\r\n * contributed `prefix`, so a rejection points at the layout, not the page.\r\n */\r\nfunction validatedPrefixSegments(prefix: string, pageFile: string): string[] {\r\n const source = `${pageFile} (via layout prefix '${prefix}')`;\r\n\r\n return prefixSegments(prefix).map((segment) => routeSegment(segment, source));\r\n}\r\n\r\nfunction pageParts(pageFile: string): { directories: string[]; basename: string } {\r\n if (pageFile.includes(\"\\\\\")) {\r\n throw new Error(`filesystem-route: pageFile must use POSIX separators: \"${pageFile}\"`);\r\n }\r\n\r\n if (!pageFile.endsWith(\".page.tsx\")) {\r\n throw new Error(`filesystem-route: pageFile must end in .page.tsx: \"${pageFile}\"`);\r\n }\r\n\r\n const parts = pageFile.split(\"/\");\r\n const filename = parts.pop() as string;\r\n\r\n return {\r\n directories: parts,\r\n basename: filename.slice(0, -\".page.tsx\".length),\r\n };\r\n}\r\n\r\n/** Derive the effective URL for a page with no explicit `route` export. */\r\nexport function deriveFilesystemRoutePath(input: FilesystemRouteInput): string {\r\n const { directories, basename } = pageParts(input.pageFile);\r\n const prefixes = input.layoutPrefixes ?? {};\r\n const segments = [...validatedPrefixSegments(prefixes[\"\"] ?? \"\", input.pageFile)];\r\n\r\n for (let index = 0; index < directories.length; index++) {\r\n const directory = directories[index];\r\n const directoryPath = directories.slice(0, index + 1).join(\"/\");\r\n const prefix = prefixes[directoryPath];\r\n\r\n // Validate EVERY directory name before deciding whether it contributes.\r\n // Contribution and legality are separate questions, and answering them in\r\n // one branch is what let a malformed name through: a directory that owns a\r\n // layout `prefix` took the prefix branch and was never classified, so\r\n // bracket syntax inside a group name went unexamined whenever that group\r\n // also carried a prefix. The name derivation always classified every\r\n // directory; this path did not, and the two disagreed.\r\n const routed = routeSegment(directory, input.pageFile);\r\n\r\n if (prefix !== undefined) {\r\n segments.push(...validatedPrefixSegments(prefix, input.pageFile));\r\n } else if (!isGroup(directory)) {\r\n segments.push(routed);\r\n }\r\n }\r\n\r\n if (basename !== \"index\") {\r\n segments.push(routeSegment(basename, input.pageFile));\r\n }\r\n\r\n return segments.length === 0 ? \"/\" : `/${segments.join(\"/\")}`;\r\n}\r\n\r\n/** Derive the stable dotted route name from a page's filesystem identity. */\r\nexport function deriveFilesystemRouteName(pageFile: string): string {\r\n const { directories, basename } = pageParts(pageFile);\r\n const segments: string[] = [];\r\n\r\n for (const directory of directories) {\r\n const routed = routeSegment(directory, pageFile);\r\n\r\n if (!isGroup(directory)) {\r\n segments.push(routed);\r\n }\r\n }\r\n\r\n if (basename !== \"index\") {\r\n segments.push(routeSegment(basename, pageFile));\r\n }\r\n\r\n return segments.map((segment) => segment.replace(/^:/, \"\")).join(\".\") || \"index\";\r\n}\r\n"],"mappings":";;;AASA,SAAS,QAAQ,SAA0B;CACzC,OAAO,cAAc,KAAK,OAAO;AACnC;;;;;;;;AASA,SAAS,aAAa,SAAiB,UAA0B;CAC/D,MAAM,UAAU,wBAAwB,OAAO;CAE/C,IAAI,QAAQ,SAAS,YACnB,MAAM,IAAI,iCAAiC,UAAU,SAAS,QAAQ,MAAM;CAG9E,MAAM,UAAU,iCAAiC,KAAK,OAAO;CAE7D,OAAO,UAAU,IAAI,QAAQ,OAAO;AACtC;AAEA,SAAS,eAAe,QAA0B;CAChD,OAAO,OAAO,MAAM,GAAG,CAAC,CAAC,OAAO,OAAO;AACzC;;;;;;;;;;AAWA,SAAS,wBAAwB,QAAgB,UAA4B;CAC3E,MAAM,SAAS,GAAG,SAAS,uBAAuB,OAAO;CAEzD,OAAO,eAAe,MAAM,CAAC,CAAC,KAAK,YAAY,aAAa,SAAS,MAAM,CAAC;AAC9E;AAEA,SAAS,UAAU,UAA+D;CAChF,IAAI,SAAS,SAAS,IAAI,GACxB,MAAM,IAAI,MAAM,0DAA0D,SAAS,EAAE;CAGvF,IAAI,CAAC,SAAS,SAAS,WAAW,GAChC,MAAM,IAAI,MAAM,sDAAsD,SAAS,EAAE;CAGnF,MAAM,QAAQ,SAAS,MAAM,GAAG;CAGhC,OAAO;EACL,aAAa;EACb,UAJe,MAAM,IAIJ,CAAC,CAAC,MAAM,GAAG,EAAmB;CACjD;AACF;;AAGA,SAAgB,0BAA0B,OAAqC;CAC7E,MAAM,EAAE,aAAa,aAAa,UAAU,MAAM,QAAQ;CAC1D,MAAM,WAAW,MAAM,kBAAkB,CAAC;CAC1C,MAAM,WAAW,CAAC,GAAG,wBAAwB,SAAS,OAAO,IAAI,MAAM,QAAQ,CAAC;CAEhF,KAAK,IAAI,QAAQ,GAAG,QAAQ,YAAY,QAAQ,SAAS;EACvD,MAAM,YAAY,YAAY;EAE9B,MAAM,SAAS,SADO,YAAY,MAAM,GAAG,QAAQ,CAAC,CAAC,CAAC,KAAK,GACvB;EASpC,MAAM,SAAS,aAAa,WAAW,MAAM,QAAQ;EAErD,IAAI,WAAW,QACb,SAAS,KAAK,GAAG,wBAAwB,QAAQ,MAAM,QAAQ,CAAC;OAC3D,IAAI,CAAC,QAAQ,SAAS,GAC3B,SAAS,KAAK,MAAM;CAExB;CAEA,IAAI,aAAa,SACf,SAAS,KAAK,aAAa,UAAU,MAAM,QAAQ,CAAC;CAGtD,OAAO,SAAS,WAAW,IAAI,MAAM,IAAI,SAAS,KAAK,GAAG;AAC5D;;AAGA,SAAgB,0BAA0B,UAA0B;CAClE,MAAM,EAAE,aAAa,aAAa,UAAU,QAAQ;CACpD,MAAM,WAAqB,CAAC;CAE5B,KAAK,MAAM,aAAa,aAAa;EACnC,MAAM,SAAS,aAAa,WAAW,QAAQ;EAE/C,IAAI,CAAC,QAAQ,SAAS,GACpB,SAAS,KAAK,MAAM;CAExB;CAEA,IAAI,aAAa,SACf,SAAS,KAAK,aAAa,UAAU,QAAQ,CAAC;CAGhD,OAAO,SAAS,KAAK,YAAY,QAAQ,QAAQ,MAAM,EAAE,CAAC,CAAC,CAAC,KAAK,GAAG,KAAK;AAC3E"}
@@ -0,0 +1,66 @@
1
+ //#region ../web/src/routing/page-file-segment.ts
2
+ const allowed = { type: "allowed" };
3
+ function rejected(reason) {
4
+ return {
5
+ type: "rejected",
6
+ reason
7
+ };
8
+ }
9
+ /** A whole, unqualified parameter name — no leading digit, no punctuation. */
10
+ const parameterNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/;
11
+ /** A single balanced, non-nested bracket group such as `[id]` or `[...slug]`. */
12
+ const bracketGroupPattern = /\[[^[\]]*]/g;
13
+ /** A `(group)` directory — the whole segment wrapped in one pair of parens. */
14
+ const groupSegmentPattern = /^\(([^/]+)\)$/;
15
+ /**
16
+ * Decides whether one filesystem segment is inside the grammar
17
+ * `filesystem-route.ts` can translate. Pure and total: every input yields a
18
+ * verdict and nothing throws. `segment` must already be the isolated
19
+ * segment (a single directory name, or the page basename with `.page.tsx`
20
+ * already stripped) — no normalization is performed here.
21
+ */
22
+ function classifyPageFileSegment(segment) {
23
+ const groupMatch = groupSegmentPattern.exec(segment);
24
+ if (groupMatch) {
25
+ const groupName = groupMatch[1];
26
+ if (groupName.includes("[") || groupName.includes("]")) return rejected(`Segment "${segment}" is a group, and a group contributes nothing to the URL path, so bracket syntax inside it can never produce a dynamic segment — move the dynamic segment out of the group, as in "(marketing)/[id]/page.page.tsx" rather than "(marketing[id])/page.page.tsx".`);
27
+ return allowed;
28
+ }
29
+ if (!segment.includes("[") && !segment.includes("]")) return allowed;
30
+ const groups = segment.match(bracketGroupPattern) ?? [];
31
+ const remaining = groups.reduce((text, group) => text.replace(group, ""), segment);
32
+ if (remaining.includes("[") || remaining.includes("]")) return rejected(`Segment "${segment}" has unbalanced "[" or "]" brackets, which page routes do not support — balance the brackets, as in a whole-segment param like "[id]".`);
33
+ if (groups.length > 1) return rejected(`Segment "${segment}" contains more than one bracket group, and page routes require a dynamic segment to occupy its whole filesystem segment — split them into separate directory segments, as in "[id]/[slug]".`);
34
+ const [group] = groups;
35
+ if (remaining !== "") return rejected(`Segment "${segment}" mixes a bracket group with other text, and page routes require a dynamic segment to occupy its whole filesystem segment — declare it as its own segment, as in "[id]".`);
36
+ const name = group.slice(1, -1);
37
+ if (name === "") return rejected(`Segment "${segment}" has an empty parameter name — declare a name inside the brackets, as in "[id]".`);
38
+ if (name.startsWith("...")) return rejected(`Segment "${segment}" is a catch-all pattern, which page routes do not support — page routes support only a whole-segment param such as "[id]", not a catch-all.`);
39
+ if (parameterNamePattern.test(name)) return allowed;
40
+ return rejected(`Segment "${segment}" declares a parameter name page routes do not support — a parameter name must start with a letter or "_" and contain only letters, digits and "_", as in "[id]" or "[user_id]".`);
41
+ }
42
+ /**
43
+ * The single error contract for a rejected page-file segment — the one
44
+ * class and one message every caller of {@link classifyPageFileSegment}
45
+ * raises when it refuses a page whose filesystem segment was rejected.
46
+ * `pageFile` is the caller's context (its audience-appropriate identifier
47
+ * for the page — an app-root-relative POSIX path, in practice); `segment`
48
+ * and `reason` come from the verdict; the category and wording are this
49
+ * module's.
50
+ */
51
+ var PageFileSegmentNotSupportedError = class extends Error {
52
+ pageFile;
53
+ segment;
54
+ reason;
55
+ constructor(pageFile, segment, reason) {
56
+ super(`"${pageFile}" contains the filesystem segment "${segment}", which is not supported: ${reason} Page routes support a plain static segment or a whole-segment dynamic param such as "[id]".`);
57
+ this.pageFile = pageFile;
58
+ this.segment = segment;
59
+ this.reason = reason;
60
+ this.name = "PageFileSegmentNotSupportedError";
61
+ }
62
+ };
63
+
64
+ //#endregion
65
+ export { PageFileSegmentNotSupportedError, classifyPageFileSegment };
66
+ //# sourceMappingURL=page-file-segment.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-file-segment.mjs","names":[],"sources":["../../../../../../../web/src/routing/page-file-segment.ts"],"sourcesContent":["/**\n * Page-file segment grammar — the single, pure predicate that decides\n * whether one filesystem segment of a page file (a directory name or the\n * `.page.tsx` basename) is inside the grammar `filesystem-route.ts` can\n * actually translate into a route. This is a SIBLING of\n * `page-route-grammar.ts`, not an extension of it: that module validates a\n * page's DECLARED colon-form `route.path` (`/users/:id`); this one validates\n * a FILESYSTEM segment as it appears on disk (`[id]`) before it is turned\n * into one.\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 single path segment string and trusts nothing about it beyond\n * what it checks — it asserts rather than trusts, but it never repairs a\n * malformed segment into a valid one.\n *\n * ALLOWED — and ONLY these shapes; anything else is rejected by default:\n * a plain static segment containing no \"[\" and no \"]\" at all; a `(group)`\n * directory whose name contains no \"[\" and no \"]\"; and exactly `[name]`,\n * where the segment is nothing but a single bracket pair and `name` matches\n * `^[A-Za-z_][A-Za-z0-9_]*$` — i.e. precisely the shape `filesystem-route.ts`\n * can translate into a `:name` route param.\n *\n * REJECTED — each with a reason naming the offending segment and, where one\n * exists, the supported alternative: a `(group)` directory whose name\n * contains \"[\" or \"]\" anywhere (`(bad[id])`, `([x])`) — a group contributes\n * nothing to the URL path, so bracket syntax inside one can never produce a\n * dynamic segment, and the fix is to move the dynamic segment out of the\n * group; a catch-all group (`[...slug]`, `[...]`); two or more bracket\n * groups in one segment (`[id].[slug]`, `[a]-[b]`); a bracket group mixed\n * with other text (`pre[id]`); an empty parameter name (`[]`); a parameter\n * name that does not start with a letter or `_`, or contains a character\n * other than a letter, digit or `_` (`[1bad]`, `[a-b]`, `[a b]`); and\n * unbalanced or stray `[`/`]` characters.\n *\n * REJECTION IS DATA, NOT A THROW: {@link classifyPageFileSegment} is total\n * and never raises. What a rejection MEANS to the user is nonetheless fixed\n * here: {@link PageFileSegmentNotSupportedError} is the single error\n * contract every caller raises when it refuses a rejected segment — one\n * class, one message shape, built from the rejection reason plus\n * caller-supplied page identity. Callers decide only WHEN to raise it and\n * supply that context; none of them wraps the rejection in a category or\n * wording of its own.\n */\n\n/**\n * The predicate's verdict for one filesystem segment:\n *\n * - `\"allowed\"` — the segment is one of the grammar's allowed shapes.\n * - `\"rejected\"` — the segment is outside the grammar; `reason` is a\n * complete, user-readable sentence naming the offending segment and the\n * supported alternative if one exists.\n */\nexport type PageFileSegmentVerdict = { type: \"allowed\" } | { type: \"rejected\"; reason: string };\n\nconst allowed: PageFileSegmentVerdict = { type: \"allowed\" };\n\nfunction rejected(reason: string): PageFileSegmentVerdict {\n return { type: \"rejected\", reason };\n}\n\n/** A whole, unqualified parameter name — no leading digit, no punctuation. */\nconst parameterNamePattern = /^[A-Za-z_][A-Za-z0-9_]*$/;\n\n/** A single balanced, non-nested bracket group such as `[id]` or `[...slug]`. */\nconst bracketGroupPattern = /\\[[^[\\]]*]/g;\n\n/** A `(group)` directory — the whole segment wrapped in one pair of parens. */\nconst groupSegmentPattern = /^\\(([^/]+)\\)$/;\n\n/**\n * Decides whether one filesystem segment is inside the grammar\n * `filesystem-route.ts` can translate. Pure and total: every input yields a\n * verdict and nothing throws. `segment` must already be the isolated\n * segment (a single directory name, or the page basename with `.page.tsx`\n * already stripped) — no normalization is performed here.\n */\nexport function classifyPageFileSegment(segment: string): PageFileSegmentVerdict {\n const groupMatch = groupSegmentPattern.exec(segment);\n\n if (groupMatch) {\n const groupName = groupMatch[1];\n\n if (groupName.includes(\"[\") || groupName.includes(\"]\")) {\n return rejected(\n `Segment \"${segment}\" is a group, and a group contributes nothing to the URL path, so ` +\n `bracket syntax inside it can never produce a dynamic segment — move the dynamic ` +\n `segment out of the group, as in \"(marketing)/[id]/page.page.tsx\" rather than ` +\n `\"(marketing[id])/page.page.tsx\".`,\n );\n }\n\n return allowed;\n }\n\n if (!segment.includes(\"[\") && !segment.includes(\"]\")) {\n return allowed;\n }\n\n const groups = segment.match(bracketGroupPattern) ?? [];\n const remaining = groups.reduce((text, group) => text.replace(group, \"\"), segment);\n\n if (remaining.includes(\"[\") || remaining.includes(\"]\")) {\n return rejected(\n `Segment \"${segment}\" has unbalanced \"[\" or \"]\" brackets, which page routes do not ` +\n `support — balance the brackets, as in a whole-segment param like \"[id]\".`,\n );\n }\n\n if (groups.length > 1) {\n return rejected(\n `Segment \"${segment}\" contains more than one bracket group, and page routes require a ` +\n `dynamic segment to occupy its whole filesystem segment — split them into separate ` +\n `directory segments, as in \"[id]/[slug]\".`,\n );\n }\n\n const [group] = groups;\n\n if (remaining !== \"\") {\n return rejected(\n `Segment \"${segment}\" mixes a bracket group with other text, and page routes require a ` +\n `dynamic segment to occupy its whole filesystem segment — declare it as its own segment, ` +\n `as in \"[id]\".`,\n );\n }\n\n const name = group.slice(1, -1);\n\n if (name === \"\") {\n return rejected(\n `Segment \"${segment}\" has an empty parameter name — declare a name inside the brackets, ` +\n `as in \"[id]\".`,\n );\n }\n\n if (name.startsWith(\"...\")) {\n return rejected(\n `Segment \"${segment}\" is a catch-all pattern, which page routes do not support — page ` +\n `routes support only a whole-segment param such as \"[id]\", not a catch-all.`,\n );\n }\n\n if (parameterNamePattern.test(name)) {\n return allowed;\n }\n\n return rejected(\n `Segment \"${segment}\" declares a parameter name page routes do not support — a parameter ` +\n `name must start with a letter or \"_\" and contain only letters, digits and \"_\", as in ` +\n `\"[id]\" or \"[user_id]\".`,\n );\n}\n\n/**\n * The single error contract for a rejected page-file segment — the one\n * class and one message every caller of {@link classifyPageFileSegment}\n * raises when it refuses a page whose filesystem segment was rejected.\n * `pageFile` is the caller's context (its audience-appropriate identifier\n * for the page — an app-root-relative POSIX path, in practice); `segment`\n * and `reason` come from the verdict; the category and wording are this\n * module's.\n */\nexport class PageFileSegmentNotSupportedError extends Error {\n public constructor(\n public readonly pageFile: string,\n public readonly segment: string,\n public readonly reason: string,\n ) {\n super(\n `\"${pageFile}\" contains the filesystem segment \"${segment}\", which is not supported: ` +\n `${reason} Page routes support a plain static segment or a whole-segment dynamic param ` +\n `such as \"[id]\".`,\n );\n this.name = \"PageFileSegmentNotSupportedError\";\n }\n}\n"],"mappings":";AAuDA,MAAM,UAAkC,EAAE,MAAM,UAAU;AAE1D,SAAS,SAAS,QAAwC;CACxD,OAAO;EAAE,MAAM;EAAY;CAAO;AACpC;;AAGA,MAAM,uBAAuB;;AAG7B,MAAM,sBAAsB;;AAG5B,MAAM,sBAAsB;;;;;;;;AAS5B,SAAgB,wBAAwB,SAAyC;CAC/E,MAAM,aAAa,oBAAoB,KAAK,OAAO;CAEnD,IAAI,YAAY;EACd,MAAM,YAAY,WAAW;EAE7B,IAAI,UAAU,SAAS,GAAG,KAAK,UAAU,SAAS,GAAG,GACnD,OAAO,SACL,YAAY,QAAQ,gQAItB;EAGF,OAAO;CACT;CAEA,IAAI,CAAC,QAAQ,SAAS,GAAG,KAAK,CAAC,QAAQ,SAAS,GAAG,GACjD,OAAO;CAGT,MAAM,SAAS,QAAQ,MAAM,mBAAmB,KAAK,CAAC;CACtD,MAAM,YAAY,OAAO,QAAQ,MAAM,UAAU,KAAK,QAAQ,OAAO,EAAE,GAAG,OAAO;CAEjF,IAAI,UAAU,SAAS,GAAG,KAAK,UAAU,SAAS,GAAG,GACnD,OAAO,SACL,YAAY,QAAQ,wIAEtB;CAGF,IAAI,OAAO,SAAS,GAClB,OAAO,SACL,YAAY,QAAQ,6LAGtB;CAGF,MAAM,CAAC,SAAS;CAEhB,IAAI,cAAc,IAChB,OAAO,SACL,YAAY,QAAQ,yKAGtB;CAGF,MAAM,OAAO,MAAM,MAAM,GAAG,EAAE;CAE9B,IAAI,SAAS,IACX,OAAO,SACL,YAAY,QAAQ,kFAEtB;CAGF,IAAI,KAAK,WAAW,KAAK,GACvB,OAAO,SACL,YAAY,QAAQ,6IAEtB;CAGF,IAAI,qBAAqB,KAAK,IAAI,GAChC,OAAO;CAGT,OAAO,SACL,YAAY,QAAQ,iLAGtB;AACF;;;;;;;;;;AAWA,IAAa,mCAAb,cAAsD,MAAM;CAExC;CACA;CACA;CAHlB,AAAO,YACL,AAAgB,UAChB,AAAgB,SAChB,AAAgB,QAChB;EACA,MACE,IAAI,SAAS,qCAAqC,QAAQ,6BACrD,OAAO,6FAEd;EARgB;EACA;EACA;EAOhB,KAAK,OAAO;CACd;AACF"}
@@ -0,0 +1,79 @@
1
+ //#region ../web/src/routing/page-route-grammar.ts
2
+ const allowed = { type: "allowed" };
3
+ function rejected(reason) {
4
+ return {
5
+ type: "rejected",
6
+ reason
7
+ };
8
+ }
9
+ /** Whole-segment `:param` — the entire segment is `:` followed by a plain name. */
10
+ const wholeSegmentParamPattern = /^:[A-Za-z0-9_]+$/;
11
+ /**
12
+ * Classifies one segment of a slash-led path. `isFinal` matters because `*`
13
+ * is allowed only as the whole final segment, and an empty segment at the
14
+ * end means a trailing slash rather than a doubled one.
15
+ */
16
+ function classifySegment(segment, path, isFinal) {
17
+ if (segment === "") {
18
+ if (isFinal) return rejected(`Path "${path}" ends with a trailing slash, which page routes do not support — declare "${path.slice(0, -1)}" without the trailing slash instead.`);
19
+ return rejected(`Path "${path}" contains an empty segment from a doubled slash, which page routes do not support — remove the extra slash.`);
20
+ }
21
+ if (segment === "*") {
22
+ if (isFinal) return allowed;
23
+ return rejected(`Path "${path}" uses "*" before its final segment, and page routes support "*" only as a terminal catch-all — move the "*" to the end of the path, as in "/a/b/*".`);
24
+ }
25
+ if (segment.includes("*")) {
26
+ if (isFinal) return rejected(`Segment "${segment}" of "${path}" attaches "*" to other text, and page routes support "*" only as a whole final segment — declare it as its own segment, as in "/prefix/*".`);
27
+ return rejected(`Segment "${segment}" of "${path}" uses "*" before the final segment, and page routes support "*" only as a whole terminal segment — move the "*" to the end of the path, as in "/a/b/*".`);
28
+ }
29
+ if (segment.includes("\\:")) return rejected(`Segment "${segment}" of "${path}" escapes a colon with a backslash, which page routes do not support — page routes treat ":" only as a whole-segment param marker, so a literal colon cannot be declared; rename the segment to avoid the colon.`);
30
+ if (!segment.includes(":")) return allowed;
31
+ if (wholeSegmentParamPattern.test(segment)) return allowed;
32
+ if (segment.includes("(")) return rejected(`Segment "${segment}" of "${path}" constrains its param with a regex, which page routes do not support — use a plain whole-segment param such as ":id" and validate the value in the page instead.`);
33
+ if (segment.endsWith("?")) return rejected(`Segment "${segment}" of "${path}" marks its param optional with "?", which page routes do not support — declare two pages instead, one with the param segment and one without it.`);
34
+ if (segment.split(":").length - 1 > 1) return rejected(`Segment "${segment}" of "${path}" declares more than one param, and page routes require a param to occupy its whole segment — split the params into separate segments, as in ":lat/:lng".`);
35
+ return rejected(`Segment "${segment}" of "${path}" mixes a ":" param with other text, and page routes require a param to occupy its whole segment — use a plain whole-segment param such as ":id" instead.`);
36
+ }
37
+ /**
38
+ * Decides whether a declared page `route.path` is inside the page-route
39
+ * grammar. Pure and total: every input yields a verdict and nothing throws.
40
+ * `path` must already be canonical (no normalization is performed here — as
41
+ * with the rest of this directory, this module asserts, it never repairs).
42
+ */
43
+ function classifyPageRoutePath(path) {
44
+ if (path === "*") return allowed;
45
+ if (path === "") return rejected(`The page route path is empty — declare "/" for the site root instead.`);
46
+ if (!path.startsWith("/")) return rejected(`Path "${path}" does not start with "/", and the only page route allowed without a leading slash is the exact root wildcard "*" — declare "/${path}" instead.`);
47
+ if (path === "/") return allowed;
48
+ const segments = path.slice(1).split("/");
49
+ const lastIndex = segments.length - 1;
50
+ for (const [index, segment] of segments.entries()) {
51
+ const verdict = classifySegment(segment, path, index === lastIndex);
52
+ if (verdict.type === "rejected") return verdict;
53
+ }
54
+ return allowed;
55
+ }
56
+ /**
57
+ * The single error contract for a rejected page route path — the one class
58
+ * and one message every caller of {@link classifyPageRoutePath} raises when
59
+ * it refuses a page whose declared path was rejected. `pageFile` is the
60
+ * caller's context (its audience-appropriate identifier for the page — an
61
+ * app-root-relative POSIX path, in practice); `routePath` and `reason` come
62
+ * from the verdict; the category and wording are this module's.
63
+ */
64
+ var PageRoutePathNotSupportedError = class extends Error {
65
+ pageFile;
66
+ routePath;
67
+ reason;
68
+ constructor(pageFile, routePath, reason) {
69
+ super(`"${pageFile}" declares the page route path "${routePath}", which is not supported: ${reason} Page routes support a narrower grammar than API routes — a page path may be "/", static segments, whole-segment ":param" segments, the exact root wildcard "*", or a path whose final segment is exactly "*" (such as "/prefix/*").`);
70
+ this.pageFile = pageFile;
71
+ this.routePath = routePath;
72
+ this.reason = reason;
73
+ this.name = "PageRoutePathNotSupportedError";
74
+ }
75
+ };
76
+
77
+ //#endregion
78
+ export { PageRoutePathNotSupportedError, classifyPageRoutePath };
79
+ //# sourceMappingURL=page-route-grammar.mjs.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"page-route-grammar.mjs","names":[],"sources":["../../../../../../../web/src/routing/page-route-grammar.ts"],"sourcesContent":["/**\r\n * Page-route grammar — the single, pure predicate that decides whether a\r\n * page's DECLARED, colon-form `route.path` (`/users/:id`) is inside the PAGE\r\n * route grammar. Page routes deliberately support a NARROWER grammar than\r\n * API routes. A page's FILESYSTEM segment grammar — the shape of a directory\r\n * name or basename on disk, such as `[id]` — is a separate concern owned by\r\n * the sibling module `page-file-segment.ts`; this predicate does not cover it.\r\n *\r\n * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing\r\n * here may import `node:fs`, `node:path`, `vite`, or `fastify`. This module\r\n * receives a canonical route path string and trusts nothing about it beyond\r\n * what it checks — it asserts rather than trusts, but it never repairs a\r\n * malformed path into a valid one.\r\n *\r\n * ALLOWED — and ONLY these shapes; anything else is rejected by default:\r\n * the root path `/`; static segments (`/users/settings`); whole-segment\r\n * `:param` segments where the param occupies its entire segment\r\n * (`/users/:id`); the exact root wildcard `*` (the one path allowed without\r\n * a leading slash — distinct from `/*`); and slash-led paths whose FINAL\r\n * segment is exactly `*` (`/*`, `/prefix/*`, `/a/b/*`).\r\n *\r\n * REJECTED — each with a reason naming the offending construct and, where\r\n * one exists, the supported alternative: regex-constrained params\r\n * (`/users/:id(\\d+)`), optional params (`/users/:id?`), multiple params in\r\n * one segment (`/near/:lat-:lng`), params mixed with static text\r\n * (`/file/:name.png`, `/pre:id`), backslash-escaped colons\r\n * (`/path/\\:literal`), a `*` that does not occupy a whole terminal segment\r\n * (`/a/prefix*`, or a wildcard before the final segment), paths missing\r\n * their leading slash, empty\r\n * segments from doubled slashes (`/a//b`), and trailing slashes (`/users/`).\r\n *\r\n * REJECTION IS DATA, NOT A THROW: {@link classifyPageRoutePath} is total and\r\n * never raises. What a rejection MEANS to the user is nonetheless fixed\r\n * here: {@link PageRoutePathNotSupportedError} is the single error contract\r\n * every caller raises when it refuses a rejected path — one class, one\r\n * message shape, built from the rejection reason plus caller-supplied page\r\n * identity. Callers decide only WHEN to raise it and supply that context;\r\n * none of them wraps the rejection in a category or wording of its own.\r\n */\r\n\r\n/**\r\n * The predicate's verdict for one declared page route path:\r\n *\r\n * - `\"allowed\"` — the path is one of the grammar's allowed shapes.\r\n * - `\"rejected\"` — the path is outside the grammar; `reason` is a complete,\r\n * user-readable sentence naming the offending construct (the segment, when\r\n * one segment is at fault) and the supported alternative if one exists.\r\n */\r\nexport type PageRoutePathVerdict = { type: \"allowed\" } | { type: \"rejected\"; reason: string };\r\n\r\nconst allowed: PageRoutePathVerdict = { type: \"allowed\" };\r\n\r\nfunction rejected(reason: string): PageRoutePathVerdict {\r\n return { type: \"rejected\", reason };\r\n}\r\n\r\n/** Whole-segment `:param` — the entire segment is `:` followed by a plain name. */\r\nconst wholeSegmentParamPattern = /^:[A-Za-z0-9_]+$/;\r\n\r\n/**\r\n * Classifies one segment of a slash-led path. `isFinal` matters because `*`\r\n * is allowed only as the whole final segment, and an empty segment at the\r\n * end means a trailing slash rather than a doubled one.\r\n */\r\nfunction classifySegment(segment: string, path: string, isFinal: boolean): PageRoutePathVerdict {\r\n if (segment === \"\") {\r\n if (isFinal) {\r\n return rejected(\r\n `Path \"${path}\" ends with a trailing slash, which page routes do not support — ` +\r\n `declare \"${path.slice(0, -1)}\" without the trailing slash instead.`,\r\n );\r\n }\r\n\r\n return rejected(\r\n `Path \"${path}\" contains an empty segment from a doubled slash, which page routes do not ` +\r\n `support — remove the extra slash.`,\r\n );\r\n }\r\n\r\n if (segment === \"*\") {\r\n if (isFinal) {\r\n return allowed;\r\n }\r\n\r\n return rejected(\r\n `Path \"${path}\" uses \"*\" before its final segment, and page routes support \"*\" only as a ` +\r\n `terminal catch-all — move the \"*\" to the end of the path, as in \"/a/b/*\".`,\r\n );\r\n }\r\n\r\n if (segment.includes(\"*\")) {\r\n if (isFinal) {\r\n return rejected(\r\n `Segment \"${segment}\" of \"${path}\" attaches \"*\" to other text, and page routes support ` +\r\n `\"*\" only as a whole final segment — declare it as its own segment, as in \"/prefix/*\".`,\r\n );\r\n }\r\n\r\n return rejected(\r\n `Segment \"${segment}\" of \"${path}\" uses \"*\" before the final segment, and page routes ` +\r\n `support \"*\" only as a whole terminal segment — move the \"*\" to the end of the path, as ` +\r\n `in \"/a/b/*\".`,\r\n );\r\n }\r\n\r\n if (segment.includes(\"\\\\:\")) {\r\n return rejected(\r\n `Segment \"${segment}\" of \"${path}\" escapes a colon with a backslash, which page routes do ` +\r\n `not support — page routes treat \":\" only as a whole-segment param marker, so a literal ` +\r\n `colon cannot be declared; rename the segment to avoid the colon.`,\r\n );\r\n }\r\n\r\n if (!segment.includes(\":\")) {\r\n return allowed;\r\n }\r\n\r\n if (wholeSegmentParamPattern.test(segment)) {\r\n return allowed;\r\n }\r\n\r\n if (segment.includes(\"(\")) {\r\n return rejected(\r\n `Segment \"${segment}\" of \"${path}\" constrains its param with a regex, which page routes do ` +\r\n `not support — use a plain whole-segment param such as \":id\" and validate the value in ` +\r\n `the page instead.`,\r\n );\r\n }\r\n\r\n if (segment.endsWith(\"?\")) {\r\n return rejected(\r\n `Segment \"${segment}\" of \"${path}\" marks its param optional with \"?\", which page routes do ` +\r\n `not support — declare two pages instead, one with the param segment and one without it.`,\r\n );\r\n }\r\n\r\n if (segment.split(\":\").length - 1 > 1) {\r\n return rejected(\r\n `Segment \"${segment}\" of \"${path}\" declares more than one param, and page routes require a ` +\r\n `param to occupy its whole segment — split the params into separate segments, as in ` +\r\n `\":lat/:lng\".`,\r\n );\r\n }\r\n\r\n return rejected(\r\n `Segment \"${segment}\" of \"${path}\" mixes a \":\" param with other text, and page routes ` +\r\n `require a param to occupy its whole segment — use a plain whole-segment param such as ` +\r\n `\":id\" instead.`,\r\n );\r\n}\r\n\r\n/**\r\n * Decides whether a declared page `route.path` is inside the page-route\r\n * grammar. Pure and total: every input yields a verdict and nothing throws.\r\n * `path` must already be canonical (no normalization is performed here — as\r\n * with the rest of this directory, this module asserts, it never repairs).\r\n */\r\nexport function classifyPageRoutePath(path: string): PageRoutePathVerdict {\r\n if (path === \"*\") {\r\n return allowed;\r\n }\r\n\r\n if (path === \"\") {\r\n return rejected(`The page route path is empty — declare \"/\" for the site root instead.`);\r\n }\r\n\r\n if (!path.startsWith(\"/\")) {\r\n return rejected(\r\n `Path \"${path}\" does not start with \"/\", and the only page route allowed without a leading ` +\r\n `slash is the exact root wildcard \"*\" — declare \"/${path}\" instead.`,\r\n );\r\n }\r\n\r\n if (path === \"/\") {\r\n return allowed;\r\n }\r\n\r\n const segments = path.slice(1).split(\"/\");\r\n const lastIndex = segments.length - 1;\r\n\r\n for (const [index, segment] of segments.entries()) {\r\n const verdict = classifySegment(segment, path, index === lastIndex);\r\n\r\n if (verdict.type === \"rejected\") {\r\n return verdict;\r\n }\r\n }\r\n\r\n return allowed;\r\n}\r\n\r\n/**\r\n * The single error contract for a rejected page route path — the one class\r\n * and one message every caller of {@link classifyPageRoutePath} raises when\r\n * it refuses a page whose declared path was rejected. `pageFile` is the\r\n * caller's context (its audience-appropriate identifier for the page — an\r\n * app-root-relative POSIX path, in practice); `routePath` and `reason` come\r\n * from the verdict; the category and wording are this module's.\r\n */\r\nexport class PageRoutePathNotSupportedError extends Error {\r\n public constructor(\r\n public readonly pageFile: string,\r\n public readonly routePath: string,\r\n public readonly reason: string,\r\n ) {\r\n super(\r\n `\"${pageFile}\" declares the page route path \"${routePath}\", which is not supported: ` +\r\n `${reason} Page routes support a narrower grammar than API routes — a page path may be ` +\r\n `\"/\", static segments, whole-segment \":param\" segments, the exact root wildcard \"*\", or ` +\r\n `a path whose final segment is exactly \"*\" (such as \"/prefix/*\").`,\r\n );\r\n this.name = \"PageRoutePathNotSupportedError\";\r\n }\r\n}\r\n"],"mappings":";AAkDA,MAAM,UAAgC,EAAE,MAAM,UAAU;AAExD,SAAS,SAAS,QAAsC;CACtD,OAAO;EAAE,MAAM;EAAY;CAAO;AACpC;;AAGA,MAAM,2BAA2B;;;;;;AAOjC,SAAS,gBAAgB,SAAiB,MAAc,SAAwC;CAC9F,IAAI,YAAY,IAAI;EAClB,IAAI,SACF,OAAO,SACL,SAAS,KAAK,4EACA,KAAK,MAAM,GAAG,EAAE,EAAE,sCAClC;EAGF,OAAO,SACL,SAAS,KAAK,6GAEhB;CACF;CAEA,IAAI,YAAY,KAAK;EACnB,IAAI,SACF,OAAO;EAGT,OAAO,SACL,SAAS,KAAK,qJAEhB;CACF;CAEA,IAAI,QAAQ,SAAS,GAAG,GAAG;EACzB,IAAI,SACF,OAAO,SACL,YAAY,QAAQ,QAAQ,KAAK,4IAEnC;EAGF,OAAO,SACL,YAAY,QAAQ,QAAQ,KAAK,yJAGnC;CACF;CAEA,IAAI,QAAQ,SAAS,KAAK,GACxB,OAAO,SACL,YAAY,QAAQ,QAAQ,KAAK,iNAGnC;CAGF,IAAI,CAAC,QAAQ,SAAS,GAAG,GACvB,OAAO;CAGT,IAAI,yBAAyB,KAAK,OAAO,GACvC,OAAO;CAGT,IAAI,QAAQ,SAAS,GAAG,GACtB,OAAO,SACL,YAAY,QAAQ,QAAQ,KAAK,kKAGnC;CAGF,IAAI,QAAQ,SAAS,GAAG,GACtB,OAAO,SACL,YAAY,QAAQ,QAAQ,KAAK,kJAEnC;CAGF,IAAI,QAAQ,MAAM,GAAG,CAAC,CAAC,SAAS,IAAI,GAClC,OAAO,SACL,YAAY,QAAQ,QAAQ,KAAK,0JAGnC;CAGF,OAAO,SACL,YAAY,QAAQ,QAAQ,KAAK,0JAGnC;AACF;;;;;;;AAQA,SAAgB,sBAAsB,MAAoC;CACxE,IAAI,SAAS,KACX,OAAO;CAGT,IAAI,SAAS,IACX,OAAO,SAAS,uEAAuE;CAGzF,IAAI,CAAC,KAAK,WAAW,GAAG,GACtB,OAAO,SACL,SAAS,KAAK,gIACwC,KAAK,WAC7D;CAGF,IAAI,SAAS,KACX,OAAO;CAGT,MAAM,WAAW,KAAK,MAAM,CAAC,CAAC,CAAC,MAAM,GAAG;CACxC,MAAM,YAAY,SAAS,SAAS;CAEpC,KAAK,MAAM,CAAC,OAAO,YAAY,SAAS,QAAQ,GAAG;EACjD,MAAM,UAAU,gBAAgB,SAAS,MAAM,UAAU,SAAS;EAElE,IAAI,QAAQ,SAAS,YACnB,OAAO;CAEX;CAEA,OAAO;AACT;;;;;;;;;AAUA,IAAa,iCAAb,cAAoD,MAAM;CAEtC;CACA;CACA;CAHlB,AAAO,YACL,AAAgB,UAChB,AAAgB,WAChB,AAAgB,QAChB;EACA,MACE,IAAI,SAAS,kCAAkC,UAAU,6BACpD,OAAO,qOAGd;EATgB;EACA;EACA;EAQhB,KAAK,OAAO;CACd;AACF"}
@@ -0,0 +1,69 @@
1
+ //#region ../web/src/routing/route-identity.d.ts
2
+ /**
3
+ * Route identity — the single, pure implementation of "what is this page's
4
+ * route path and name". The dev installer
5
+ * (`web/src/server/install-page-routes.ts`), the production manifest
6
+ * installer (`web/src/server/install-page-routes-from-manifest.ts`) and
7
+ * discovery (`web/src/build/discover-pages.ts`) each hand-derived this on
8
+ * their own until all three were made to delegate here.
9
+ *
10
+ * {@link resolvePageRouteName} is the ONE answer to "what is this page's
11
+ * route name": an explicit `name` on the declared `route` export wins,
12
+ * otherwise the name comes from the page's own FILE PATH
13
+ * (`deriveFilesystemRouteName`) — never from `route.path`. The route name is
14
+ * an identity key (`routing/route-table.ts`'s lookup key, `components/link.ts`,
15
+ * `server/render-page.ts`, the generated client registry and the hydration
16
+ * payload all address a page by it), and an identity key must be stable under
17
+ * the change most likely to happen to a page — its URL, renamed for SEO,
18
+ * localization or restructuring. A file path is also unique by construction,
19
+ * while a declared `path: "/"` yields no usable name at all.
20
+ *
21
+ * Pure string logic only: no `fs`, no `path`, no Node built-ins. Every input
22
+ * this module accepts is already CANONICAL — a POSIX, app-root-relative
23
+ * source path (e.g. `"src/web/index.page.tsx"`).
24
+ *
25
+ * {@link canonicalizeRouteExport} is also the ONE seam every declared
26
+ * `route.path` passes through on its way into either installer
27
+ * (`install-page-routes.ts`, `install-page-routes-from-manifest.ts`), so it is
28
+ * where `../routing/page-route-grammar.ts`'s `classifyPageRoutePath` is
29
+ * applied: a rejected path raises {@link PageRoutePathNotSupportedError}
30
+ * naming the offending page file, rather than being published literally.
31
+ *
32
+ * Well-formedness of the declared `route` export itself (is it a string or an
33
+ * object, does the object have a `path`) is the extractor's problem — already
34
+ * rejected at build before either derivation function here is called.
35
+ *
36
+ * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing
37
+ * here may import `node:fs`, `node:path`, `vite`, or `fastify`. Modules in
38
+ * this directory receive canonical values and trust nothing — they assert
39
+ * rather than trust, but they never repair. A module that needs the
40
+ * filesystem does not belong here. The purity is deliberate: it keeps these
41
+ * modules consumable from the dev server, the build, the production runtime,
42
+ * and — if ever needed — the browser client, without dragging any of those
43
+ * environments along.
44
+ */
45
+ /**
46
+ * A page's opt-in into shared-cache storage for its document AND its data
47
+ * representation (`x-warlock-data`) — the two must never diverge, because a
48
+ * cacheable data payload leaks exactly what an uncacheable document was
49
+ * protecting (`../server/create-page-route-handler.ts`).
50
+ *
51
+ * `public: true` is not a flag with a `false` counterpart: the framework is
52
+ * closed by default (`../server/response-cache-floor.ts`), so the only
53
+ * meaningful state this object can express is "yes, cache me" — a page that
54
+ * wants the default simply omits `cache` entirely. `maxAge` has no framework
55
+ * default and never will: a route's freshness window is a decision only the
56
+ * route's author can make safely, and guessing one would be exactly the kind
57
+ * of silent, environment-dependent behaviour this feature exists to remove.
58
+ * Both keys are required — see {@link InvalidPageCacheOptInError}.
59
+ *
60
+ * Shaped as an object, not a boolean or a bare number, so a later addition
61
+ * (e.g. CDN surrogate keys) extends it without a breaking change.
62
+ */
63
+ type PageCacheOptIn = {
64
+ public: true; /** Freshness window in seconds, emitted as `Cache-Control: public, max-age=<maxAge>`. */
65
+ maxAge: number;
66
+ };
67
+ //#endregion
68
+ export { PageCacheOptIn };
69
+ //# sourceMappingURL=route-identity.d.mts.map