@warlock.js/web 5.1.0 → 5.2.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +182 -0
- package/README.md +126 -12
- package/esm/build/contribution.d.mts +10 -13
- package/esm/build/contribution.mjs +45 -57
- package/esm/build/contribution.mjs.map +1 -1
- package/esm/build/discover-pages.mjs +104 -44
- package/esm/build/discover-pages.mjs.map +1 -1
- package/esm/build/generate-client-registry.mjs +46 -12
- package/esm/build/generate-client-registry.mjs.map +1 -1
- package/esm/build/generate-pages-barrel.mjs +54 -12
- package/esm/build/generate-pages-barrel.mjs.map +1 -1
- package/esm/build/page-default-export.mjs +48 -0
- package/esm/build/page-default-export.mjs.map +1 -0
- package/esm/build/page-routes-manifest.mjs +21 -0
- package/esm/build/page-routes-manifest.mjs.map +1 -0
- package/esm/build/public-files.mjs +45 -0
- package/esm/build/public-files.mjs.map +1 -0
- package/esm/build/read-route-exports.mjs.map +1 -1
- package/esm/client/build-hydrated-tree.mjs +39 -8
- package/esm/client/build-hydrated-tree.mjs.map +1 -1
- package/esm/client/navigation/fetch-page-data.mjs.map +1 -1
- package/esm/client/navigation/prefetch.mjs.map +1 -1
- package/esm/client/runtime/index.d.mts +2 -1
- package/esm/client/runtime/index.mjs +2 -1
- package/esm/client/runtime/manifest.mjs +2 -1
- package/esm/client/runtime/manifest.mjs.map +1 -1
- package/esm/client/runtime/matcher.d.mts +12 -0
- package/esm/client/runtime/matcher.mjs +12 -0
- package/esm/client/runtime/matcher.mjs.map +1 -1
- package/esm/client/runtime/types.d.mts +14 -1
- package/esm/components/document-context.d.mts +39 -1
- package/esm/components/document-context.mjs.map +1 -1
- package/esm/components/link.mjs.map +1 -1
- package/esm/components/scripts.mjs +3 -1
- package/esm/components/scripts.mjs.map +1 -1
- package/esm/context.d.mts +5 -101
- package/esm/hydration-payload.d.mts +1 -1
- package/esm/hydration-payload.mjs +36 -7
- package/esm/hydration-payload.mjs.map +1 -1
- package/esm/index.d.mts +3 -2
- package/esm/loaders.d.mts +6 -35
- package/esm/props.d.mts +15 -7
- package/esm/route.d.mts +1 -16
- package/esm/routing/filesystem-route.mjs +45 -0
- package/esm/routing/filesystem-route.mjs.map +1 -0
- package/esm/routing/layout-policy.mjs.map +1 -1
- package/esm/routing/query-string.mjs.map +1 -1
- package/esm/routing/route-table.mjs.map +1 -1
- package/esm/runtime/register-modules.d.mts +19 -0
- package/esm/runtime/register-modules.mjs +22 -0
- package/esm/runtime/register-modules.mjs.map +1 -0
- package/esm/server/build-hydration-payload.mjs +1 -0
- package/esm/server/build-hydration-payload.mjs.map +1 -1
- package/esm/server/create-page-module-loader.mjs +1 -0
- package/esm/server/create-page-module-loader.mjs.map +1 -1
- package/esm/server/create-page-route-handler.d.mts +30 -5
- package/esm/server/create-page-route-handler.mjs +127 -42
- package/esm/server/create-page-route-handler.mjs.map +1 -1
- package/esm/server/dev-server.mjs +1 -12
- package/esm/server/dev-server.mjs.map +1 -1
- package/esm/server/error-page.d.mts +14 -0
- package/esm/server/error-page.mjs +39 -0
- package/esm/server/error-page.mjs.map +1 -0
- package/esm/server/execute-page-request.d.mts +6 -3
- package/esm/server/execute-page-request.mjs +78 -79
- package/esm/server/execute-page-request.mjs.map +1 -1
- package/esm/server/execute-page-request.types.d.mts +32 -91
- package/esm/server/hydration-client-url.mjs +1 -1
- package/esm/server/index.d.mts +5 -5
- package/esm/server/index.mjs +3 -4
- package/esm/server/install-page-routes-from-manifest.d.mts +16 -6
- package/esm/server/install-page-routes-from-manifest.mjs +70 -9
- package/esm/server/install-page-routes-from-manifest.mjs.map +1 -1
- package/esm/server/install-page-routes.d.mts +31 -14
- package/esm/server/install-page-routes.mjs +90 -38
- package/esm/server/install-page-routes.mjs.map +1 -1
- package/esm/server/install-production-page-routes.mjs +2 -35
- package/esm/server/install-production-page-routes.mjs.map +1 -1
- package/esm/server/match-page-route.mjs +1 -1
- package/esm/server/match-page-route.mjs.map +1 -1
- package/esm/server/not-found-page.mjs +1 -1
- package/esm/server/not-found-page.mjs.map +1 -1
- package/esm/server/page-context.d.mts +2 -8
- package/esm/server/page-context.mjs +2 -15
- package/esm/server/page-context.mjs.map +1 -1
- package/esm/server/page-file-change.mjs +77 -0
- package/esm/server/page-file-change.mjs.map +1 -0
- package/esm/server/page-manifest.d.mts +24 -9
- package/esm/server/page-manifest.mjs.map +1 -1
- package/esm/server/page-render-bundle.mjs +38 -0
- package/esm/server/page-render-bundle.mjs.map +1 -0
- package/esm/server/page-route-reload.mjs +54 -0
- package/esm/server/page-route-reload.mjs.map +1 -0
- package/esm/server/register-production-public-files.mjs +40 -0
- package/esm/server/register-production-public-files.mjs.map +1 -0
- package/esm/server/render-page.d.mts +10 -13
- package/esm/server/render-page.mjs +153 -27
- package/esm/server/render-page.mjs.map +1 -1
- package/esm/server/resolve-page-metadata.mjs +1 -1
- package/esm/server/resolve-page-metadata.mjs.map +1 -1
- package/esm/server/settle-page-response.d.mts +58 -1
- package/esm/server/settle-page-response.mjs +110 -45
- package/esm/server/settle-page-response.mjs.map +1 -1
- package/esm/server/stylesheet-urls.d.mts +40 -29
- package/esm/server/stylesheet-urls.mjs +136 -51
- package/esm/server/stylesheet-urls.mjs.map +1 -1
- package/esm/server/unregistered-pages.mjs +42 -0
- package/esm/server/unregistered-pages.mjs.map +1 -0
- package/esm/server/web-connector-factory.d.mts +1 -2
- package/esm/server/web-connector-factory.mjs +6 -5
- package/esm/server/web-connector-factory.mjs.map +1 -1
- package/esm/server/web-connector.mjs +137 -17
- package/esm/server/web-connector.mjs.map +1 -1
- package/esm/shared.d.mts +17 -2
- package/esm/shared.mjs +2 -6
- package/esm/shared.mjs.map +1 -1
- package/esm/vite/build-client.mjs +1 -1
- package/esm/vite/build-client.mjs.map +1 -1
- package/esm/vite/gate-a-resolve.mjs +66 -12
- package/esm/vite/gate-a-resolve.mjs.map +1 -1
- package/esm/vite/gate-b-secrets.mjs +34 -16
- package/esm/vite/gate-b-secrets.mjs.map +1 -1
- package/esm/vite/gate-c-verify.mjs +5 -5
- package/esm/vite/gate-c-verify.mjs.map +1 -1
- package/esm/vite/hydration-entries.mjs +1 -1
- package/esm/vite/hydration-entries.mjs.map +1 -1
- package/esm/vite/index.d.mts +8 -5
- package/esm/vite/index.mjs +123 -10
- package/esm/vite/index.mjs.map +1 -1
- package/esm/vite/page-registry-plugin.d.mts +21 -2
- package/esm/vite/page-registry-plugin.mjs +47 -13
- package/esm/vite/page-registry-plugin.mjs.map +1 -1
- package/esm/vite/projection.d.mts +1 -1
- package/esm/vite/projection.mjs +37 -14
- package/esm/vite/projection.mjs.map +1 -1
- package/llms-full.txt +220 -56
- package/llms.txt +5 -3
- package/package.json +3 -3
- package/skills/add-web-to-an-app/SKILL.md +3 -3
- package/skills/create-a-page/SKILL.md +117 -10
- package/skills/load-page-data/SKILL.md +92 -12
- package/skills/navigate-on-the-client/SKILL.md +1 -1
- package/skills/serve-styles/SKILL.md +13 -12
- package/skills/use-layouts/SKILL.md +14 -8
- package/skills/write-the-root/SKILL.md +3 -1
- package/esm/server/buffered-response.d.mts +0 -58
- package/esm/server/buffered-response.mjs +0 -115
- package/esm/server/buffered-response.mjs.map +0 -1
package/esm/context.d.mts
CHANGED
|
@@ -1,107 +1,11 @@
|
|
|
1
1
|
import { SharedContext } from "./index.mjs";
|
|
2
|
+
import { HttpContext, Request } from "@warlock.js/core";
|
|
2
3
|
|
|
3
4
|
//#region ../web/src/context.d.ts
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
*
|
|
7
|
-
* Core's `Response#notFound()` / `#redirect()` return an unbranded
|
|
8
|
-
* `Response`/`this` (core/src/http/response.ts:862-866, 1028-1034). The page
|
|
9
|
-
* contract needs those returns to NOT union into `PageProps["data"]`
|
|
10
|
-
* (v5/app/src/app/products/web/product-details.page.tsx:45-57), so web brands
|
|
11
|
-
* them on ITS OWN response surface — the one a loader's context carries —
|
|
12
|
-
* without touching core. `LoaderData` excludes exactly this brand.
|
|
13
|
-
*/
|
|
14
|
-
declare const loaderShortCircuit: unique symbol;
|
|
15
|
-
interface LoaderShortCircuit {
|
|
16
|
-
readonly [loaderShortCircuit]: true;
|
|
17
|
-
}
|
|
18
|
-
/**
|
|
19
|
-
* The request as a loader sees it. Structural and deliberately minimal for M1:
|
|
20
|
-
* exactly the members the v5/app loaders exercise, typed by the page's own
|
|
21
|
-
* `validation` and `route` generics.
|
|
22
|
-
*
|
|
23
|
-
* TODO(Stream A.6 — core ctx migration): once core ships
|
|
24
|
-
* `HttpContext { request, response }` and the declared `request.user` /
|
|
25
|
-
* `request.locals` / `request.nonce`, this narrows to a refinement of core's `Request`
|
|
26
|
-
* instead of a standalone structural type.
|
|
27
|
-
*/
|
|
28
|
-
interface WebRequest<TValidated = Record<string, never>, TParams extends string = string> {
|
|
29
|
-
/**
|
|
30
|
-
* The validated inputs, typed off the page's `validation.schema` with
|
|
31
|
-
* `Infer.Output` semantics — `.default()` fields stay required
|
|
32
|
-
* (v5/app/src/app/auth/schema/login.schema.ts:29-38). The key-list parameter
|
|
33
|
-
* is optional and selects a subset, mirroring core
|
|
34
|
-
* (core/src/http/request.ts:610).
|
|
35
|
-
*/
|
|
36
|
-
validated(inputs?: readonly (keyof TValidated | (string & {}))[]): TValidated;
|
|
37
|
-
/**
|
|
38
|
-
* Typed FROM THE ROUTE: `TParams` is derived from the route path literal, so
|
|
39
|
-
* `request.input("id")` types against `path: "/:id"` and a key that is not a
|
|
40
|
-
* route parameter is a compile error
|
|
41
|
-
* (v5/app/src/web/__type-tests__/route-params.type-test.ts).
|
|
42
|
-
*/
|
|
43
|
-
input(key: TParams, defaultValue?: unknown): string;
|
|
44
|
-
/**
|
|
45
|
-
* The authenticated user, when middleware resolved one.
|
|
46
|
-
*
|
|
47
|
-
* `any`, deliberately, and recorded as a deviation in the M1 report: the
|
|
48
|
-
* reference app hands `request.user` to services typed against the APP's own
|
|
49
|
-
* model (`navService.forUser(user: User)`,
|
|
50
|
-
* v5/app/src/web/layouts/dashboard.layout.tsx:35) and reads `.id` with no
|
|
51
|
-
* null-check (settings.page.tsx:65). No type this package can declare is
|
|
52
|
-
* assignable to an app-owned model class; the real declaration has to come
|
|
53
|
-
* from core's own `Request.user` and lands with Stream A.6.
|
|
54
|
-
*
|
|
55
|
-
* Optional, matching core (`Request.user?`, core/src/http/request.ts:94) —
|
|
56
|
-
* a required member here would reject core's real `Request` at the seam.
|
|
57
|
-
* `any` absorbs the `undefined`, so loader-side `request.user.id` reads
|
|
58
|
-
* keep compiling (the recorded M1 deviation stands).
|
|
59
|
-
*/
|
|
60
|
-
user?: any;
|
|
61
|
-
}
|
|
62
|
-
/**
|
|
63
|
-
* The response as a loader sees it. A loader may mutate the response during
|
|
64
|
-
* render (headers, status) and may SHORT-CIRCUIT it — the short-circuiting
|
|
65
|
-
* methods return the branded type that never reaches `PageProps["data"]`.
|
|
66
|
-
*
|
|
67
|
-
* TODO(Stream A.6): same story as `WebRequest` — becomes a refinement of
|
|
68
|
-
* core's `Response` once the ctx object lands in core.
|
|
69
|
-
*/
|
|
70
|
-
interface WebResponse {
|
|
71
|
-
/** Buffered, committed in tree order after every loader settles. */
|
|
72
|
-
header(key: string, value: string): WebResponse;
|
|
73
|
-
/** The not-found page's loader's real output is the status (not-found.page.tsx:25). */
|
|
74
|
-
setStatusCode(statusCode: number): WebResponse;
|
|
75
|
-
/** A short-circuit, not a value — never unions into `data`. */
|
|
76
|
-
redirect(url: string, statusCode?: number): LoaderShortCircuit;
|
|
77
|
-
/** 301 flavour of the same short-circuit. */
|
|
78
|
-
permanentRedirect(url: string): LoaderShortCircuit;
|
|
79
|
-
/** A 404 is an ANSWER, not an incident (product-details.page.tsx:45-57). */
|
|
80
|
-
notFound(body?: unknown): LoaderShortCircuit;
|
|
81
|
-
}
|
|
82
|
-
/**
|
|
83
|
-
* The one context object every handler receives.
|
|
84
|
-
*
|
|
85
|
-
* TODO(Stream A.6): core is to OWN `HttpContext { request, response }` and the
|
|
86
|
-
* re-parameterised `Middleware<TContext extends HttpContext>`. Core today has
|
|
87
|
-
* no `HttpContext` and its `Middleware` is positional `(request, response)`
|
|
88
|
-
* (core/src/router/types.ts:17), so importing the type from
|
|
89
|
-
* `@warlock.js/core` cannot resolve to the v5 shape yet — this structural
|
|
90
|
-
* declaration stands in until core declares it.
|
|
91
|
-
*/
|
|
92
|
-
interface HttpContext {
|
|
93
|
-
request: WebRequest;
|
|
94
|
-
response: WebResponse;
|
|
95
|
-
}
|
|
96
|
-
/**
|
|
97
|
-
* The web layer's context: `HttpContext` plus the per-request payload. The
|
|
98
|
-
* keys are framework-owned and the list is closed — `request`, `response`,
|
|
99
|
-
* `shared`. App data goes on `request.locals` (private) or `shared` (public),
|
|
100
|
-
* never onto the context itself.
|
|
101
|
-
*/
|
|
102
|
-
interface PageContext extends HttpContext {
|
|
5
|
+
type HttpContext$1<TRequest extends Request = Request> = HttpContext<TRequest>;
|
|
6
|
+
type PageContext<TRequest extends Request = Request> = HttpContext<TRequest> & {
|
|
103
7
|
shared: SharedContext;
|
|
104
|
-
}
|
|
8
|
+
};
|
|
105
9
|
//#endregion
|
|
106
|
-
export {
|
|
10
|
+
export { HttpContext$1 as HttpContext, PageContext };
|
|
107
11
|
//# sourceMappingURL=context.d.mts.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
|
|
1
|
+
import { ErrorPageProps, SerializedErrorPageProps, SerializedPageError } from "./components/document-context.mjs";
|
|
@@ -22,11 +22,11 @@ function malformedPayload() {
|
|
|
22
22
|
/**
|
|
23
23
|
* The keys that are allowed to be ABSENT but not allowed to be wrong.
|
|
24
24
|
*
|
|
25
|
-
* `metadata` and `
|
|
25
|
+
* `metadata`, `params` and `errorPage` are optional because the server is right
|
|
26
26
|
* always produce them — a page with no `metadata` export resolves none, and a
|
|
27
|
-
* payload
|
|
28
|
-
*
|
|
29
|
-
*
|
|
27
|
+
* older payload carries none of these additions. Failing a whole page over an
|
|
28
|
+
* absent accessor would turn a compatible payload into a blank screen, so
|
|
29
|
+
* absence is accepted.
|
|
30
30
|
*
|
|
31
31
|
* Present-but-not-an-object is a different claim entirely: it means something
|
|
32
32
|
* produced a payload with these names meaning something else, and every reader
|
|
@@ -34,10 +34,36 @@ function malformedPayload() {
|
|
|
34
34
|
* rule the required keys live by, so it throws. Arrays included — `typeof []`
|
|
35
35
|
* is `"object"`, and an array of params is not params.
|
|
36
36
|
*/
|
|
37
|
-
const OPTIONAL_OBJECT_PAYLOAD_KEYS = [
|
|
37
|
+
const OPTIONAL_OBJECT_PAYLOAD_KEYS = [
|
|
38
|
+
"metadata",
|
|
39
|
+
"params",
|
|
40
|
+
"errorPage"
|
|
41
|
+
];
|
|
38
42
|
function isPlainObject(value) {
|
|
39
43
|
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
40
44
|
}
|
|
45
|
+
function hasExactStringKeys(value, required, optional = []) {
|
|
46
|
+
const allowed = new Set([...required, ...optional]);
|
|
47
|
+
const keys = Reflect.ownKeys(value);
|
|
48
|
+
return required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) && keys.every((key) => typeof key === "string" && allowed.has(key));
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* Validate the explicit serialization boundary, not an `Error` instance.
|
|
52
|
+
* `JSON.stringify(new Error("boom"))` is normally `{}` because its useful
|
|
53
|
+
* fields are non-enumerable; accepting that would hydrate an error page with a
|
|
54
|
+
* different contract from the one the server rendered.
|
|
55
|
+
*/
|
|
56
|
+
function requireErrorPagePayload(value) {
|
|
57
|
+
if (!isPlainObject(value)) malformedPayload();
|
|
58
|
+
const errorPage = value;
|
|
59
|
+
if (!hasExactStringKeys(errorPage, ["error", "status"])) malformedPayload();
|
|
60
|
+
if (!isPlainObject(errorPage.error)) malformedPayload();
|
|
61
|
+
const error = errorPage.error;
|
|
62
|
+
if (!hasExactStringKeys(error, ["name", "message"], ["stack"])) malformedPayload();
|
|
63
|
+
if (typeof error.name !== "string" || typeof error.message !== "string") malformedPayload();
|
|
64
|
+
if (error.stack !== void 0 && typeof error.stack !== "string") malformedPayload();
|
|
65
|
+
if (typeof errorPage.status !== "number" || !Number.isInteger(errorPage.status) || errorPage.status < 500 || errorPage.status > 599) malformedPayload();
|
|
66
|
+
}
|
|
41
67
|
function requireHydrationPayload(value) {
|
|
42
68
|
if (!isPlainObject(value)) malformedPayload();
|
|
43
69
|
for (const key of REQUIRED_PAYLOAD_KEYS) if (!Object.prototype.hasOwnProperty.call(value, key)) malformedPayload();
|
|
@@ -45,15 +71,18 @@ function requireHydrationPayload(value) {
|
|
|
45
71
|
const optional = value[key];
|
|
46
72
|
if (optional !== void 0 && !isPlainObject(optional)) malformedPayload();
|
|
47
73
|
}
|
|
74
|
+
const errorPage = value.errorPage;
|
|
75
|
+
if (errorPage !== void 0) requireErrorPagePayload(errorPage);
|
|
48
76
|
return value;
|
|
49
77
|
}
|
|
50
78
|
/**
|
|
51
79
|
* Read the fixed payload script without changing the server-rendered root.
|
|
52
80
|
*
|
|
53
81
|
* Extra fields are ignored. The gate owns the FIVE required keys — absent or
|
|
54
|
-
* malformed, both throw — plus a shape check on the
|
|
82
|
+
* malformed, both throw — plus a shape check on the three optional ones
|
|
55
83
|
* ({@link OPTIONAL_OBJECT_PAYLOAD_KEYS}); it deliberately does not require
|
|
56
|
-
* those to be present.
|
|
84
|
+
* those to be present. `errorPage`, when present, is additionally validated as
|
|
85
|
+
* one atomic `{ error, status }` selection with a serialized error and a 5xx.
|
|
57
86
|
*/
|
|
58
87
|
function readHydrationPayload(documentNode) {
|
|
59
88
|
const element = documentNode.getElementById(PAYLOAD_SCRIPT_ID);
|
|
@@ -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\";\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` and `
|
|
1
|
+
{"version":3,"file":"hydration-payload.mjs","names":[],"sources":["../../../../../../web/src/hydration-payload.ts"],"sourcesContent":["import {\n PAYLOAD_SCRIPT_ID,\n type HydrationDocumentPayloadSource,\n} from \"./components/document-context\";\n\nexport type { HydrationDocumentPayloadSource } from \"./components/document-context\";\nexport type {\n ErrorPageProps,\n SerializedErrorPageProps,\n SerializedPageError,\n} from \"./components/document-context\";\n\n/**\n * Exported so a payload-shape assertion can be written against the contract\n * itself. A spec that hardcodes its own copy of this list silently becomes a\n * claim about a PAST revision — that is exactly how the rev. 3 keys landed with\n * two specs still asserting the rev. 2 shape.\n */\nexport const REQUIRED_PAYLOAD_KEYS = [\n \"appData\",\n \"layoutData\",\n \"pageData\",\n \"shared\",\n \"name\",\n] as const;\n\nconst ABSENT_PAYLOAD_MESSAGE =\n `Warlock hydration payload is absent: #${PAYLOAD_SCRIPT_ID}, owned by ` +\n \"web/src/components/document-context.ts, was not found.\";\nconst MALFORMED_PAYLOAD_MESSAGE =\n `Warlock hydration payload was found at #${PAYLOAD_SCRIPT_ID} but could not be read.`;\n\nfunction malformedPayload(): never {\n throw new Error(MALFORMED_PAYLOAD_MESSAGE);\n}\n\n/**\n * The keys that are allowed to be ABSENT but not allowed to be wrong.\n *\n * `metadata`, `params` and `errorPage` are optional because the server is right\n * always produce them — a page with no `metadata` export resolves none, and a\n * older payload carries none of these additions. Failing a whole page over an\n * absent accessor would turn a compatible payload into a blank screen, so\n * absence is accepted.\n *\n * Present-but-not-an-object is a different claim entirely: it means something\n * produced a payload with these names meaning something else, and every reader\n * downstream would then be indexing a string. That is MALFORMED under the same\n * rule the required keys live by, so it throws. Arrays included — `typeof []`\n * is `\"object\"`, and an array of params is not params.\n */\nexport const OPTIONAL_OBJECT_PAYLOAD_KEYS = [\"metadata\", \"params\", \"errorPage\"] as const;\n\nfunction isPlainObject(value: unknown): boolean {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\nfunction hasExactStringKeys(\n value: Record<PropertyKey, unknown>,\n required: readonly string[],\n optional: readonly string[] = [],\n): boolean {\n const allowed = new Set([...required, ...optional]);\n const keys = Reflect.ownKeys(value);\n\n return (\n required.every((key) => Object.prototype.hasOwnProperty.call(value, key)) &&\n keys.every((key) => typeof key === \"string\" && allowed.has(key))\n );\n}\n\n/**\n * Validate the explicit serialization boundary, not an `Error` instance.\n * `JSON.stringify(new Error(\"boom\"))` is normally `{}` because its useful\n * fields are non-enumerable; accepting that would hydrate an error page with a\n * different contract from the one the server rendered.\n */\nfunction requireErrorPagePayload(value: unknown): void {\n if (!isPlainObject(value)) malformedPayload();\n\n const errorPage = value as Record<PropertyKey, unknown>;\n\n if (!hasExactStringKeys(errorPage, [\"error\", \"status\"])) malformedPayload();\n if (!isPlainObject(errorPage.error)) malformedPayload();\n\n const error = errorPage.error as Record<PropertyKey, unknown>;\n\n if (!hasExactStringKeys(error, [\"name\", \"message\"], [\"stack\"])) malformedPayload();\n if (typeof error.name !== \"string\" || typeof error.message !== \"string\") {\n malformedPayload();\n }\n if (error.stack !== undefined && typeof error.stack !== \"string\") malformedPayload();\n\n if (\n typeof errorPage.status !== \"number\" ||\n !Number.isInteger(errorPage.status) ||\n errorPage.status < 500 ||\n errorPage.status > 599\n ) {\n malformedPayload();\n }\n}\n\nfunction requireHydrationPayload(value: unknown): HydrationDocumentPayloadSource {\n if (!isPlainObject(value)) malformedPayload();\n\n for (const key of REQUIRED_PAYLOAD_KEYS) {\n if (!Object.prototype.hasOwnProperty.call(value, key)) malformedPayload();\n }\n\n for (const key of OPTIONAL_OBJECT_PAYLOAD_KEYS) {\n const optional = (value as Record<string, unknown>)[key];\n\n if (optional !== undefined && !isPlainObject(optional)) malformedPayload();\n }\n\n const errorPage = (value as Record<string, unknown>).errorPage;\n if (errorPage !== undefined) requireErrorPagePayload(errorPage);\n\n return value as HydrationDocumentPayloadSource;\n}\n\n/**\n * Read the fixed payload script without changing the server-rendered root.\n *\n * Extra fields are ignored. The gate owns the FIVE required keys — absent or\n * malformed, both throw — plus a shape check on the three optional ones\n * ({@link OPTIONAL_OBJECT_PAYLOAD_KEYS}); it deliberately does not require\n * those to be present. `errorPage`, when present, is additionally validated as\n * one atomic `{ error, status }` selection with a serialized error and a 5xx.\n */\nexport function readHydrationPayload(documentNode: Document): HydrationDocumentPayloadSource {\n const element = documentNode.getElementById(PAYLOAD_SCRIPT_ID);\n\n if (element === null) throw new Error(ABSENT_PAYLOAD_MESSAGE);\n\n let parsed: unknown;\n\n try {\n parsed = JSON.parse(element.textContent ?? \"\");\n } catch {\n malformedPayload();\n }\n\n return requireHydrationPayload(parsed);\n}\n"],"mappings":";;;;;;;;;AAkBA,MAAa,wBAAwB;CACnC;CACA;CACA;CACA;CACA;AACF;AAEA,MAAM,yBACJ,yCAAyC,kBAAkB;AAE7D,MAAM,4BACJ,2CAA2C,kBAAkB;AAE/D,SAAS,mBAA0B;CACjC,MAAM,IAAI,MAAM,yBAAyB;AAC3C;;;;;;;;;;;;;;;;AAiBA,MAAa,+BAA+B;CAAC;CAAY;CAAU;AAAW;AAE9E,SAAS,cAAc,OAAyB;CAC9C,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;AAEA,SAAS,mBACP,OACA,UACA,WAA8B,CAAC,GACtB;CACT,MAAM,UAAU,IAAI,IAAI,CAAC,GAAG,UAAU,GAAG,QAAQ,CAAC;CAClD,MAAM,OAAO,QAAQ,QAAQ,KAAK;CAElC,OACE,SAAS,OAAO,QAAQ,OAAO,UAAU,eAAe,KAAK,OAAO,GAAG,CAAC,KACxE,KAAK,OAAO,QAAQ,OAAO,QAAQ,YAAY,QAAQ,IAAI,GAAG,CAAC;AAEnE;;;;;;;AAQA,SAAS,wBAAwB,OAAsB;CACrD,IAAI,CAAC,cAAc,KAAK,GAAG,iBAAiB;CAE5C,MAAM,YAAY;CAElB,IAAI,CAAC,mBAAmB,WAAW,CAAC,SAAS,QAAQ,CAAC,GAAG,iBAAiB;CAC1E,IAAI,CAAC,cAAc,UAAU,KAAK,GAAG,iBAAiB;CAEtD,MAAM,QAAQ,UAAU;CAExB,IAAI,CAAC,mBAAmB,OAAO,CAAC,QAAQ,SAAS,GAAG,CAAC,OAAO,CAAC,GAAG,iBAAiB;CACjF,IAAI,OAAO,MAAM,SAAS,YAAY,OAAO,MAAM,YAAY,UAC7D,iBAAiB;CAEnB,IAAI,MAAM,UAAU,UAAa,OAAO,MAAM,UAAU,UAAU,iBAAiB;CAEnF,IACE,OAAO,UAAU,WAAW,YAC5B,CAAC,OAAO,UAAU,UAAU,MAAM,KAClC,UAAU,SAAS,OACnB,UAAU,SAAS,KAEnB,iBAAiB;AAErB;AAEA,SAAS,wBAAwB,OAAgD;CAC/E,IAAI,CAAC,cAAc,KAAK,GAAG,iBAAiB;CAE5C,KAAK,MAAM,OAAO,uBAChB,IAAI,CAAC,OAAO,UAAU,eAAe,KAAK,OAAO,GAAG,GAAG,iBAAiB;CAG1E,KAAK,MAAM,OAAO,8BAA8B;EAC9C,MAAM,WAAY,MAAkC;EAEpD,IAAI,aAAa,UAAa,CAAC,cAAc,QAAQ,GAAG,iBAAiB;CAC3E;CAEA,MAAM,YAAa,MAAkC;CACrD,IAAI,cAAc,QAAW,wBAAwB,SAAS;CAE9D,OAAO;AACT;;;;;;;;;;AAWA,SAAgB,qBAAqB,cAAwD;CAC3F,MAAM,UAAU,aAAa,eAAe,iBAAiB;CAE7D,IAAI,YAAY,MAAM,MAAM,IAAI,MAAM,sBAAsB;CAE5D,IAAI;CAEJ,IAAI;EACF,SAAS,KAAK,MAAM,QAAQ,eAAe,EAAE;CAC/C,QAAQ;EACN,iBAAiB;CACnB;CAEA,OAAO,wBAAwB,MAAM;AACvC"}
|
package/esm/index.d.mts
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
|
+
import { HttpContext, PageContext } from "./context.mjs";
|
|
1
2
|
import { AppLoader, LayoutLoader, PageLoader } from "./loaders.mjs";
|
|
2
3
|
import { AppProps, LayoutProps, PageProps } from "./props.mjs";
|
|
3
4
|
import { PageMetadata } from "./metadata.mjs";
|
|
5
|
+
import { ErrorPageProps, SerializedErrorPageProps, SerializedPageError } from "./components/document-context.mjs";
|
|
4
6
|
import { shared, useShared } from "./shared.mjs";
|
|
5
7
|
import { QueryStringInput, QueryStringLeaf, QueryStringNested, QueryStringObject, QueryStringOptions, QueryStringValue, RepeatedKeyStrategy, UnserializableQueryValueError, queryString, queryStringOf, resetQueryStringOptions, setQueryStringOptions } from "./routing/query-string.mjs";
|
|
6
8
|
import { RouteParameters, RouteQuery, href } from "./routing/route-table.mjs";
|
|
@@ -11,7 +13,6 @@ import { MatchedRoute, currentRoute, previousRoute } from "./client/navigation/c
|
|
|
11
13
|
import { refresh } from "./client/navigation/refresh.mjs";
|
|
12
14
|
import { Head } from "./components/head.mjs";
|
|
13
15
|
import { Scripts } from "./components/scripts.mjs";
|
|
14
|
-
import { PageContext } from "./context.mjs";
|
|
15
16
|
|
|
16
17
|
//#region ../web/src/index.d.ts
|
|
17
18
|
/**
|
|
@@ -31,5 +32,5 @@ import { PageContext } from "./context.mjs";
|
|
|
31
32
|
*/
|
|
32
33
|
interface SharedContext {}
|
|
33
34
|
//#endregion
|
|
34
|
-
export { type AppLoader, type AppProps, Head, 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, SharedContext, UnserializableQueryValueError, createRouterEvents, currentRoute, getHash, href, navigateBack, navigateTo, previousRoute, queryString, queryStringOf, refresh, resetQueryStringOptions, routerEvents, setQueryStringOptions, shared, useShared };
|
|
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 };
|
|
35
36
|
//# sourceMappingURL=index.d.mts.map
|
package/esm/loaders.d.mts
CHANGED
|
@@ -1,46 +1,17 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { PageContext } from "./context.mjs";
|
|
2
|
+
import { RouteDeclaration } from "./route.mjs";
|
|
2
3
|
import { PageValidation, ValidatedOutput } from "./validation.mjs";
|
|
3
4
|
import { SharedContext } from "./index.mjs";
|
|
4
|
-
import {
|
|
5
|
+
import { Request, Response } from "@warlock.js/core";
|
|
5
6
|
|
|
6
7
|
//#region ../web/src/loaders.d.ts
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
* `TRoute` types `request.input()` off the path literal. A sibling export
|
|
11
|
-
* cannot contextually type an already-authored function — the generics are
|
|
12
|
-
* the mechanism that links them (product-details.page.tsx:20-41).
|
|
13
|
-
*/
|
|
14
|
-
type PageLoaderContext<TValidation extends PageValidation | undefined, TRoute extends RouteDeclaration | undefined> = {
|
|
15
|
-
request: WebRequest<ValidatedOutput<TValidation>, RouteParamsOf<TRoute>>;
|
|
16
|
-
response: WebResponse;
|
|
8
|
+
type PageLoaderContext<TValidation extends PageValidation | undefined, _TRoute extends RouteDeclaration | undefined> = {
|
|
9
|
+
request: Request<ValidatedOutput<TValidation>>;
|
|
10
|
+
response: Response;
|
|
17
11
|
shared: SharedContext;
|
|
18
12
|
};
|
|
19
|
-
/**
|
|
20
|
-
* The page controller's contract. Used with `satisfies`, NEVER `:` — an
|
|
21
|
-
* annotation replaces the inferred type and `PageProps<typeof loader>` loses
|
|
22
|
-
* `data` (v5/app README §A). The return is `unknown` because `satisfies`
|
|
23
|
-
* preserves whatever the loader actually returns; the short-circuit brand and
|
|
24
|
-
* the props types do the narrowing on the way out.
|
|
25
|
-
*
|
|
26
|
-
* `TValidation` may be `undefined` — a page with no `validation` export
|
|
27
|
-
* (product-details.page.tsx:69). `TRoute` may be the config object or a bare
|
|
28
|
-
* string literal (contact-us.page.tsx:27).
|
|
29
|
-
*/
|
|
30
13
|
type PageLoader<TValidation extends PageValidation | undefined = undefined, TRoute extends RouteDeclaration | undefined = undefined> = (context: PageLoaderContext<TValidation, TRoute>) => unknown;
|
|
31
|
-
/**
|
|
32
|
-
* A layout's controller. Bare — no generics — in every v5/app use
|
|
33
|
-
* (products/web/layout.tsx:53-58, dashboard.layout.tsx:34-36): a layout has
|
|
34
|
-
* no `validation`/`route` exports to link, and the contract is the annotated
|
|
35
|
-
* type, not a parameter list (`async () => …` is still a controller with the
|
|
36
|
-
* full context available).
|
|
37
|
-
*/
|
|
38
14
|
type LayoutLoader = (context: PageContext) => unknown;
|
|
39
|
-
/**
|
|
40
|
-
* The application root's controller (root.tsx:56-58). Same shape as
|
|
41
|
-
* `LayoutLoader`; a separate name because the two revalidate and nest under
|
|
42
|
-
* different rules, and conflating them would make that drift invisible.
|
|
43
|
-
*/
|
|
44
15
|
type AppLoader = (context: PageContext) => unknown;
|
|
45
16
|
//#endregion
|
|
46
17
|
export { AppLoader, LayoutLoader, PageLoader };
|
package/esm/props.d.mts
CHANGED
|
@@ -1,17 +1,15 @@
|
|
|
1
1
|
import { SharedContext } from "./index.mjs";
|
|
2
|
-
import { LoaderShortCircuit } from "./context.mjs";
|
|
3
2
|
import { ReactNode } from "react";
|
|
3
|
+
import { Response } from "@warlock.js/core";
|
|
4
4
|
|
|
5
5
|
//#region ../web/src/props.d.ts
|
|
6
6
|
/** Any loader authored with `satisfies` — the concrete function type. */
|
|
7
7
|
type LoaderFunction = (...args: any[]) => unknown;
|
|
8
8
|
/**
|
|
9
|
-
* The loader's literal return shape
|
|
10
|
-
*
|
|
11
|
-
* (product-details.page.tsx:45-57, login.page.tsx:71-84). No loader → no
|
|
12
|
-
* data.
|
|
9
|
+
* The loader's literal return shape minus core Response. Returning that exact
|
|
10
|
+
* class is terminal; every other value is data.
|
|
13
11
|
*/
|
|
14
|
-
type LoaderData<TLoader> = TLoader extends LoaderFunction ? Exclude<Awaited<ReturnType<TLoader>>,
|
|
12
|
+
type LoaderData<TLoader> = TLoader extends LoaderFunction ? Exclude<Awaited<ReturnType<TLoader>>, Response> : undefined;
|
|
15
13
|
/**
|
|
16
14
|
* What the pipeline hands a page component: its own loader's data plus the
|
|
17
15
|
* per-request payload. Never `request` or `response` — the component also
|
|
@@ -20,6 +18,7 @@ type LoaderData<TLoader> = TLoader extends LoaderFunction ? Exclude<Awaited<Retu
|
|
|
20
18
|
type PageProps<TLoader extends LoaderFunction | undefined = undefined> = {
|
|
21
19
|
data: LoaderData<TLoader>;
|
|
22
20
|
shared: Readonly<SharedContext>;
|
|
21
|
+
params: Readonly<Record<string, string>>;
|
|
23
22
|
};
|
|
24
23
|
/**
|
|
25
24
|
* A layout additionally receives the subtree it wraps. Usable bare —
|
|
@@ -37,6 +36,15 @@ type AppProps<TLoader extends LoaderFunction | undefined = undefined> = {
|
|
|
37
36
|
shared: Readonly<SharedContext>;
|
|
38
37
|
children: ReactNode;
|
|
39
38
|
};
|
|
39
|
+
/**
|
|
40
|
+
* Props passed only while the server renders an application `error.page.tsx`.
|
|
41
|
+
* The thrown value intentionally remains intact here; its browser counterpart
|
|
42
|
+
* is normalized at the hydration boundary.
|
|
43
|
+
*/
|
|
44
|
+
type ServerErrorPageProps = {
|
|
45
|
+
error: unknown;
|
|
46
|
+
status: number;
|
|
47
|
+
};
|
|
40
48
|
//#endregion
|
|
41
|
-
export { AppProps, LayoutProps, LoaderData, LoaderFunction, PageProps };
|
|
49
|
+
export { AppProps, LayoutProps, LoaderData, LoaderFunction, PageProps, ServerErrorPageProps };
|
|
42
50
|
//# sourceMappingURL=props.d.mts.map
|
package/esm/route.d.mts
CHANGED
|
@@ -10,21 +10,6 @@ type RouteDeclaration = string | {
|
|
|
10
10
|
readonly path: string;
|
|
11
11
|
readonly name?: string;
|
|
12
12
|
};
|
|
13
|
-
type RoutePath<TRoute> = TRoute extends string ? TRoute : TRoute extends {
|
|
14
|
-
readonly path: infer TPath extends string;
|
|
15
|
-
} ? TPath : never;
|
|
16
|
-
/**
|
|
17
|
-
* Parameter names out of a path literal: `"/:id"` → `"id"`,
|
|
18
|
-
* `"/a/:x/:y"` → `"x" | "y"`, `"/"` and `"*"` → `never` (so `request.input()`
|
|
19
|
-
* is uncallable where no parameter exists to read).
|
|
20
|
-
*/
|
|
21
|
-
type PathParams<TPath extends string> = TPath extends `${string}:${infer TRest}` ? TRest extends `${infer TParam}/${infer TTail}` ? TParam | PathParams<`/${TTail}`> : TRest : never;
|
|
22
|
-
/**
|
|
23
|
-
* `undefined` (no route generic supplied) keeps `input()` loose rather than
|
|
24
|
-
* uncallable — the contract only narrows when the loader links its route via
|
|
25
|
-
* `satisfies PageLoader<…, typeof route>`.
|
|
26
|
-
*/
|
|
27
|
-
type RouteParamsOf<TRoute> = [TRoute] extends [undefined] ? string : PathParams<RoutePath<TRoute>>;
|
|
28
13
|
//#endregion
|
|
29
|
-
export { RouteDeclaration
|
|
14
|
+
export { RouteDeclaration };
|
|
30
15
|
//# sourceMappingURL=route.d.mts.map
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
//#region ../web/src/routing/filesystem-route.ts
|
|
2
|
+
function isGroup(segment) {
|
|
3
|
+
return /^\([^/]+\)$/.test(segment);
|
|
4
|
+
}
|
|
5
|
+
function routeSegment(segment) {
|
|
6
|
+
const dynamic = /^\[([A-Za-z_][A-Za-z0-9_]*)\]$/.exec(segment);
|
|
7
|
+
return dynamic ? `:${dynamic[1]}` : segment;
|
|
8
|
+
}
|
|
9
|
+
function prefixSegments(prefix) {
|
|
10
|
+
return prefix.split("/").filter(Boolean);
|
|
11
|
+
}
|
|
12
|
+
function pageParts(pageFile) {
|
|
13
|
+
if (pageFile.includes("\\")) throw new Error(`filesystem-route: pageFile must use POSIX separators: "${pageFile}"`);
|
|
14
|
+
if (!pageFile.endsWith(".page.tsx")) throw new Error(`filesystem-route: pageFile must end in .page.tsx: "${pageFile}"`);
|
|
15
|
+
const parts = pageFile.split("/");
|
|
16
|
+
return {
|
|
17
|
+
directories: parts,
|
|
18
|
+
basename: parts.pop().slice(0, -9)
|
|
19
|
+
};
|
|
20
|
+
}
|
|
21
|
+
/** Derive the effective URL for a page with no explicit `route` export. */
|
|
22
|
+
function deriveFilesystemRoutePath(input) {
|
|
23
|
+
const { directories, basename } = pageParts(input.pageFile);
|
|
24
|
+
const prefixes = input.layoutPrefixes ?? {};
|
|
25
|
+
const segments = [...prefixSegments(prefixes[""] ?? "")];
|
|
26
|
+
for (let index = 0; index < directories.length; index++) {
|
|
27
|
+
const directory = directories[index];
|
|
28
|
+
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));
|
|
31
|
+
}
|
|
32
|
+
if (basename !== "index") segments.push(routeSegment(basename));
|
|
33
|
+
return segments.length === 0 ? "/" : `/${segments.join("/")}`;
|
|
34
|
+
}
|
|
35
|
+
/** Derive the stable dotted route name from a page's filesystem identity. */
|
|
36
|
+
function deriveFilesystemRouteName(pageFile) {
|
|
37
|
+
const { directories, basename } = pageParts(pageFile);
|
|
38
|
+
const segments = directories.filter((segment) => !isGroup(segment)).map(routeSegment);
|
|
39
|
+
if (basename !== "index") segments.push(routeSegment(basename));
|
|
40
|
+
return segments.map((segment) => segment.replace(/^:/, "")).join(".") || "index";
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
//#endregion
|
|
44
|
+
export { deriveFilesystemRouteName, deriveFilesystemRoutePath };
|
|
45
|
+
//# sourceMappingURL=filesystem-route.mjs.map
|
|
@@ -0,0 +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,EAAE,OAAO,OAAO;AACzC;AAEA,SAAS,UAAU,UAA+D;CAChF,IAAI,SAAS,SAAS,IAAI,GACxB,MAAM,IAAI,MAAM,0DAA0D,SAAS,EAAE;CAGvF,IAAI,CAAC,SAAS,SAAS,WAAW,GAChC,MAAM,IAAI,MAAM,sDAAsD,SAAS,EAAE;CAGnF,MAAM,QAAQ,SAAS,MAAM,GAAG;CAGhC,OAAO;EACL,aAAa;EACb,UAJe,MAAM,IAIJ,EAAE,MAAM,GAAG,EAAmB;CACjD;AACF;;AAGA,SAAgB,0BAA0B,OAAqC;CAC7E,MAAM,EAAE,aAAa,aAAa,UAAU,MAAM,QAAQ;CAC1D,MAAM,WAAW,MAAM,kBAAkB,CAAC;CAC1C,MAAM,WAAW,CAAC,GAAG,eAAe,SAAS,OAAO,EAAE,CAAC;CAEvD,KAAK,IAAI,QAAQ,GAAG,QAAQ,YAAY,QAAQ,SAAS;EACvD,MAAM,YAAY,YAAY;EAE9B,MAAM,SAAS,SADO,YAAY,MAAM,GAAG,QAAQ,CAAC,EAAE,KAAK,GACvB;EAEpC,IAAI,WAAW,QACb,SAAS,KAAK,GAAG,eAAe,MAAM,CAAC;OAClC,IAAI,CAAC,QAAQ,SAAS,GAC3B,SAAS,KAAK,aAAa,SAAS,CAAC;CAEzC;CAEA,IAAI,aAAa,SACf,SAAS,KAAK,aAAa,QAAQ,CAAC;CAGtC,OAAO,SAAS,WAAW,IAAI,MAAM,IAAI,SAAS,KAAK,GAAG;AAC5D;;AAGA,SAAgB,0BAA0B,UAA0B;CAClE,MAAM,EAAE,aAAa,aAAa,UAAU,QAAQ;CACpD,MAAM,WAAW,YAAY,QAAQ,YAAY,CAAC,QAAQ,OAAO,CAAC,EAAE,IAAI,YAAY;CAEpF,IAAI,aAAa,SACf,SAAS,KAAK,aAAa,QAAQ,CAAC;CAGtC,OAAO,SAAS,KAAK,YAAY,QAAQ,QAAQ,MAAM,EAAE,CAAC,EAAE,KAAK,GAAG,KAAK;AAC3E"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"layout-policy.mjs","names":[],"sources":["../../../../../../../web/src/routing/layout-policy.ts"],"sourcesContent":["/**\n * Layout policy — the single, pure rule for turning a page's ENUMERATED\n * layout chain into a selection decision.\n *\n * Enumeration and selection are deliberately separate concerns. Discovery\n * ({@link \"../build/discover-pages.ts\"}'s `layoutChainFor`) walks a page's\n * directory ancestry and reports every `layout.tsx` it finds, outermost\n * first, honestly and unfiltered — it does not decide whether that chain is\n * usable. This module is the one place that decision is made: given a chain,\n * how many layouts does composition get to use, and which one(s)?\n *\n * THE RULE COUNTS RENDERING LAYOUTS, NOT FILES. A layout that contributes no\n * element to the document — one with no default export, carrying only\n * `prefix`, `middleware` or other named exports — is not a second wrapper and\n * never was. Counting files instead of wrappers made an authorization boundary\n * indistinguishable from a nested layout, and the resulting refusal told app\n * authors to delete the boundary to make the build pass. Nested RENDERING\n * layouts remain unsupported; everything else composes freely.\n *\n * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing\n * here may import `node:fs`, `node:path`, `vite`, or `fastify`. This module\n * receives a canonical chain and trusts nothing about it beyond the input\n * contract asserted below — it asserts rather than trusts, but it never\n * repairs. It therefore never DECIDES what renders either: that answer needs\n * the filesystem, so the caller — which has it — classifies each entry and\n * passes the classification in. Discovery owns the fact; this module owns the\n * rule. The `layout` identifiers are opaque (paths, in practice) and this\n * module never inspects their shape; it only counts and selects.\n *\n * REJECTION IS DATA, NOT A THROW: a chain with two or more rendering layouts\n * does not make {@link selectPageLayout} raise — it returns the rejected\n * rendering layouts, in order. What a rejection MEANS to the user is\n * nonetheless fixed here: {@link NestedLayoutsNotSupportedError} is the single\n * error contract for it — one class, one message shape, built from the\n * rejection data plus caller-supplied page identity. Callers decide only WHEN\n * to raise it and supply that context; none of them wraps the rejection in a\n * category or wording of its own. A shared policy whose failure semantics fork\n * per caller is shared in the happy path and forked in the sad one — and the\n * sad path is the one users meet.\n */\n\n/**\n * One layout in a chain, with the caller's answer to the only question this\n * module needs about it: does it render?\n *\n * `renders` is true when the layout's module has a default export — the thing\n * that puts an element in the document. Everything else it exports (`prefix`,\n * `middleware`, helpers) is invisible to this rule.\n */\nexport type LayoutChainEntry = {\n /** Opaque identifier for the layout — an app-root-relative POSIX path, in practice. */\n layout: string;\n /** Whether this layout contributes an element to the document. */\n renders: boolean;\n};\n\n/**\n * A chain as {@link selectPageLayout} accepts it: classified entries, or bare\n * identifiers for a caller that has not classified its chain yet.\n *\n * TRANSITIONAL: a bare string is read as a RENDERING layout, which is the\n * conservative reading (it can only make the rule stricter, never looser) and\n * reproduces this module's pre-classification behaviour exactly. It exists so\n * the boot-time manifest installer — which holds loaded modules rather than\n * source files — keeps working unchanged until it classifies too; remove the\n * string arm once every caller passes {@link LayoutChainEntry}s.\n */\nexport type LayoutChainInput = readonly (string | LayoutChainEntry)[];\n\n/**\n * The policy's decision for one page's layout chain:\n *\n * - `\"none\"` — no layout on the chain renders; the page composes against no\n * layout. A chain of three middleware-only layouts lands here exactly as an\n * empty chain does, because neither has a wrapper in it.\n * - `\"selected\"` — exactly one layout renders; `layout` is that element, which,\n * being the only rendering one, is simultaneously the outermost and the\n * nearest rendering layout — there is no distinction to draw between the two\n * when there is only one.\n * - `\"rejected\"` — more than one layout renders; `layouts` carries the\n * RENDERING layouts only, outermost-first, in chain order, so a consumer can\n * name every layout actually at fault without naming the guards between them.\n */\nexport type LayoutPolicyResult =\n | { type: \"none\" }\n | { type: \"selected\"; layout: string }\n | { type: \"rejected\"; layouts: readonly string[] };\n\n/**\n * Raised when a `chain` passed to {@link selectPageLayout} contains an empty\n * layout identifier. An empty string is not a layout identifier a caller could\n * have meant; this module refuses it rather than silently treating it as\n * absent or as a real selection.\n */\nexport class EmptyLayoutChainEntryError extends Error {\n public constructor(public readonly chain: readonly string[]) {\n super(\n `layout-policy: chain [${chain.map((entry) => `\"${entry}\"`).join(\", \")}] contains an empty ` +\n \"string. Every element of a layout chain passed to selectPageLayout must be a non-empty \" +\n \"layout identifier — omit the entry entirely rather than passing an empty string for it.\",\n );\n this.name = \"EmptyLayoutChainEntryError\";\n }\n}\n\n/**\n * The single error contract for a `rejected` selection — the one class and\n * one message every caller of {@link selectPageLayout} raises when it refuses\n * a page whose path holds more than one RENDERING layout. `pageFile` and\n * `layoutFiles` are the caller's context (its audience-appropriate identifiers\n * for the page and the rejected rendering layouts — app-root-relative POSIX\n * paths, in practice); the category and wording are this module's.\n *\n * The wording names the rendering layouts and ONLY the rendering layouts, and\n * it does not offer removal as a remedy. Its predecessor said \"remove or\n * consolidate the extra layout\", which, on a chain whose second element was a\n * `middleware`-only authorization boundary, instructed the reader to delete\n * their security guard to make the build pass.\n */\nexport class NestedLayoutsNotSupportedError extends Error {\n public constructor(\n public readonly pageFile: string,\n public readonly layoutFiles: readonly string[],\n ) {\n super(\n `\"${pageFile}\" has more than one layout on its path that renders: ` +\n `${layoutFiles.map((file) => `\"${file}\"`).join(\", \")}. Pages currently support at most one ` +\n \"RENDERING layout — a layout with a default export — and nesting more than one is not yet \" +\n \"supported. Layouts that render nothing, such as a `prefix`- or `middleware`-only layout, \" +\n \"do not count against this and may nest freely. To fix: consolidate the rendering layouts \" +\n \"named above into one — and do not remove a middleware-only layout to satisfy this, since \" +\n \"none of them is what this refuses.\",\n );\n this.name = \"NestedLayoutsNotSupportedError\";\n }\n}\n\n/** The classified form of an entry, whichever way the caller spelled it. */\nfunction toEntry(entry: string | LayoutChainEntry): LayoutChainEntry {\n return typeof entry === \"string\" ? { layout: entry, renders: true } : entry;\n}\n\n/**\n * Selects which layout, if any, a page RENDERS inside, given its layout chain\n * as enumerated outermost-first.\n *\n * Only entries with `renders: true` are counted: no rendering layout yields\n * `{ type: \"none\" }`; exactly one yields `{ type: \"selected\"; layout }`; more\n * than one yields `{ type: \"rejected\"; layouts }` carrying just those rendering\n * layouts — this function throws nothing for that case; see the module doc for\n * why rejection is data, not a throw.\n *\n * Throws {@link EmptyLayoutChainEntryError} when any entry's identifier is an\n * empty string — the one input-contract violation this module refuses rather\n * than passes through as a selection.\n */\nexport function selectPageLayout(chain: LayoutChainInput): LayoutPolicyResult {\n const entries = chain.map(toEntry);\n\n if (entries.some((entry) => entry.layout === \"\")) {\n throw new EmptyLayoutChainEntryError(entries.map((entry) => entry.layout));\n }\n\n const rendering = entries.filter((entry) => entry.renders).map((entry) => entry.layout);\n\n if (rendering.length === 0) {\n return { type: \"none\" };\n }\n\n if (rendering.length === 1) {\n return { type: \"selected\", layout: rendering[0] };\n }\n\n return { type: \"rejected\", layouts: rendering };\n}\n"],"mappings":";;;;;;;AA8FA,IAAa,6BAAb,cAAgD,MAAM;CACjB;CAAnC,AAAO,YAAY,AAAgB,OAA0B;EAC3D,MACE,yBAAyB,MAAM,KAAK,UAAU,IAAI,MAAM,EAAE,
|
|
1
|
+
{"version":3,"file":"layout-policy.mjs","names":[],"sources":["../../../../../../../web/src/routing/layout-policy.ts"],"sourcesContent":["/**\n * Layout policy — the single, pure rule for turning a page's ENUMERATED\n * layout chain into a selection decision.\n *\n * Enumeration and selection are deliberately separate concerns. Discovery\n * ({@link \"../build/discover-pages.ts\"}'s `layoutChainFor`) walks a page's\n * directory ancestry and reports every `layout.tsx` it finds, outermost\n * first, honestly and unfiltered — it does not decide whether that chain is\n * usable. This module is the one place that decision is made: given a chain,\n * how many layouts does composition get to use, and which one(s)?\n *\n * THE RULE COUNTS RENDERING LAYOUTS, NOT FILES. A layout that contributes no\n * element to the document — one with no default export, carrying only\n * `prefix`, `middleware` or other named exports — is not a second wrapper and\n * never was. Counting files instead of wrappers made an authorization boundary\n * indistinguishable from a nested layout, and the resulting refusal told app\n * authors to delete the boundary to make the build pass. Nested RENDERING\n * layouts remain unsupported; everything else composes freely.\n *\n * DIRECTORY CONTRACT — applies to everything in `web/src/routing/`: nothing\n * here may import `node:fs`, `node:path`, `vite`, or `fastify`. This module\n * receives a canonical chain and trusts nothing about it beyond the input\n * contract asserted below — it asserts rather than trusts, but it never\n * repairs. It therefore never DECIDES what renders either: that answer needs\n * the filesystem, so the caller — which has it — classifies each entry and\n * passes the classification in. Discovery owns the fact; this module owns the\n * rule. The `layout` identifiers are opaque (paths, in practice) and this\n * module never inspects their shape; it only counts and selects.\n *\n * REJECTION IS DATA, NOT A THROW: a chain with two or more rendering layouts\n * does not make {@link selectPageLayout} raise — it returns the rejected\n * rendering layouts, in order. What a rejection MEANS to the user is\n * nonetheless fixed here: {@link NestedLayoutsNotSupportedError} is the single\n * error contract for it — one class, one message shape, built from the\n * rejection data plus caller-supplied page identity. Callers decide only WHEN\n * to raise it and supply that context; none of them wraps the rejection in a\n * category or wording of its own. A shared policy whose failure semantics fork\n * per caller is shared in the happy path and forked in the sad one — and the\n * sad path is the one users meet.\n */\n\n/**\n * One layout in a chain, with the caller's answer to the only question this\n * module needs about it: does it render?\n *\n * `renders` is true when the layout's module has a default export — the thing\n * that puts an element in the document. Everything else it exports (`prefix`,\n * `middleware`, helpers) is invisible to this rule.\n */\nexport type LayoutChainEntry = {\n /** Opaque identifier for the layout — an app-root-relative POSIX path, in practice. */\n layout: string;\n /** Whether this layout contributes an element to the document. */\n renders: boolean;\n};\n\n/**\n * A chain as {@link selectPageLayout} accepts it: classified entries, or bare\n * identifiers for a caller that has not classified its chain yet.\n *\n * TRANSITIONAL: a bare string is read as a RENDERING layout, which is the\n * conservative reading (it can only make the rule stricter, never looser) and\n * reproduces this module's pre-classification behaviour exactly. It exists so\n * the boot-time manifest installer — which holds loaded modules rather than\n * source files — keeps working unchanged until it classifies too; remove the\n * string arm once every caller passes {@link LayoutChainEntry}s.\n */\nexport type LayoutChainInput = readonly (string | LayoutChainEntry)[];\n\n/**\n * The policy's decision for one page's layout chain:\n *\n * - `\"none\"` — no layout on the chain renders; the page composes against no\n * layout. A chain of three middleware-only layouts lands here exactly as an\n * empty chain does, because neither has a wrapper in it.\n * - `\"selected\"` — exactly one layout renders; `layout` is that element, which,\n * being the only rendering one, is simultaneously the outermost and the\n * nearest rendering layout — there is no distinction to draw between the two\n * when there is only one.\n * - `\"rejected\"` — more than one layout renders; `layouts` carries the\n * RENDERING layouts only, outermost-first, in chain order, so a consumer can\n * name every layout actually at fault without naming the guards between them.\n */\nexport type LayoutPolicyResult =\n | { type: \"none\" }\n | { type: \"selected\"; layout: string }\n | { type: \"rejected\"; layouts: readonly string[] };\n\n/**\n * Raised when a `chain` passed to {@link selectPageLayout} contains an empty\n * layout identifier. An empty string is not a layout identifier a caller could\n * have meant; this module refuses it rather than silently treating it as\n * absent or as a real selection.\n */\nexport class EmptyLayoutChainEntryError extends Error {\n public constructor(public readonly chain: readonly string[]) {\n super(\n `layout-policy: chain [${chain.map((entry) => `\"${entry}\"`).join(\", \")}] contains an empty ` +\n \"string. Every element of a layout chain passed to selectPageLayout must be a non-empty \" +\n \"layout identifier — omit the entry entirely rather than passing an empty string for it.\",\n );\n this.name = \"EmptyLayoutChainEntryError\";\n }\n}\n\n/**\n * The single error contract for a `rejected` selection — the one class and\n * one message every caller of {@link selectPageLayout} raises when it refuses\n * a page whose path holds more than one RENDERING layout. `pageFile` and\n * `layoutFiles` are the caller's context (its audience-appropriate identifiers\n * for the page and the rejected rendering layouts — app-root-relative POSIX\n * paths, in practice); the category and wording are this module's.\n *\n * The wording names the rendering layouts and ONLY the rendering layouts, and\n * it does not offer removal as a remedy. Its predecessor said \"remove or\n * consolidate the extra layout\", which, on a chain whose second element was a\n * `middleware`-only authorization boundary, instructed the reader to delete\n * their security guard to make the build pass.\n */\nexport class NestedLayoutsNotSupportedError extends Error {\n public constructor(\n public readonly pageFile: string,\n public readonly layoutFiles: readonly string[],\n ) {\n super(\n `\"${pageFile}\" has more than one layout on its path that renders: ` +\n `${layoutFiles.map((file) => `\"${file}\"`).join(\", \")}. Pages currently support at most one ` +\n \"RENDERING layout — a layout with a default export — and nesting more than one is not yet \" +\n \"supported. Layouts that render nothing, such as a `prefix`- or `middleware`-only layout, \" +\n \"do not count against this and may nest freely. To fix: consolidate the rendering layouts \" +\n \"named above into one — and do not remove a middleware-only layout to satisfy this, since \" +\n \"none of them is what this refuses.\",\n );\n this.name = \"NestedLayoutsNotSupportedError\";\n }\n}\n\n/** The classified form of an entry, whichever way the caller spelled it. */\nfunction toEntry(entry: string | LayoutChainEntry): LayoutChainEntry {\n return typeof entry === \"string\" ? { layout: entry, renders: true } : entry;\n}\n\n/**\n * Selects which layout, if any, a page RENDERS inside, given its layout chain\n * as enumerated outermost-first.\n *\n * Only entries with `renders: true` are counted: no rendering layout yields\n * `{ type: \"none\" }`; exactly one yields `{ type: \"selected\"; layout }`; more\n * than one yields `{ type: \"rejected\"; layouts }` carrying just those rendering\n * layouts — this function throws nothing for that case; see the module doc for\n * why rejection is data, not a throw.\n *\n * Throws {@link EmptyLayoutChainEntryError} when any entry's identifier is an\n * empty string — the one input-contract violation this module refuses rather\n * than passes through as a selection.\n */\nexport function selectPageLayout(chain: LayoutChainInput): LayoutPolicyResult {\n const entries = chain.map(toEntry);\n\n if (entries.some((entry) => entry.layout === \"\")) {\n throw new EmptyLayoutChainEntryError(entries.map((entry) => entry.layout));\n }\n\n const rendering = entries.filter((entry) => entry.renders).map((entry) => entry.layout);\n\n if (rendering.length === 0) {\n return { type: \"none\" };\n }\n\n if (rendering.length === 1) {\n return { type: \"selected\", layout: rendering[0] };\n }\n\n return { type: \"rejected\", layouts: rendering };\n}\n"],"mappings":";;;;;;;AA8FA,IAAa,6BAAb,cAAgD,MAAM;CACjB;CAAnC,AAAO,YAAY,AAAgB,OAA0B;EAC3D,MACE,yBAAyB,MAAM,KAAK,UAAU,IAAI,MAAM,EAAE,EAAE,KAAK,IAAI,EAAE,mMAGzE;EALiC;EAMjC,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;;;AAgBA,IAAa,iCAAb,cAAoD,MAAM;CAEtC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,aAChB;EACA,MACE,IAAI,SAAS,uDACR,YAAY,KAAK,SAAS,IAAI,KAAK,EAAE,EAAE,KAAK,IAAI,EAAE,ibAMzD;EAXgB;EACA;EAWhB,KAAK,OAAO;CACd;AACF;;AAGA,SAAS,QAAQ,OAAoD;CACnE,OAAO,OAAO,UAAU,WAAW;EAAE,QAAQ;EAAO,SAAS;CAAK,IAAI;AACxE;;;;;;;;;;;;;;;AAgBA,SAAgB,iBAAiB,OAA6C;CAC5E,MAAM,UAAU,MAAM,IAAI,OAAO;CAEjC,IAAI,QAAQ,MAAM,UAAU,MAAM,WAAW,EAAE,GAC7C,MAAM,IAAI,2BAA2B,QAAQ,KAAK,UAAU,MAAM,MAAM,CAAC;CAG3E,MAAM,YAAY,QAAQ,QAAQ,UAAU,MAAM,OAAO,EAAE,KAAK,UAAU,MAAM,MAAM;CAEtF,IAAI,UAAU,WAAW,GACvB,OAAO,EAAE,MAAM,OAAO;CAGxB,IAAI,UAAU,WAAW,GACvB,OAAO;EAAE,MAAM;EAAY,QAAQ,UAAU;CAAG;CAGlD,OAAO;EAAE,MAAM;EAAY,SAAS;CAAU;AAChD"}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"query-string.mjs","names":[],"sources":["../../../../../../../web/src/routing/query-string.ts"],"sourcesContent":["/**\n * The query string: BOTH directions, one grammar, one file.\n *\n * ── The rule this module exists to obey ──────────────────────────────────────\n *\n * `queryStringOf` is what `href(name, params, query)` appends, and therefore\n * what every `<Link>` puts in the document; `queryString.parse` is what reads it\n * back. They live together because a second, independently-written\n * query-string implementation would mean one thing writes URLs and a different\n * thing reads them. Query-string libraries genuinely disagree about arrays,\n * nested objects and space escaping, so the moment the two drift the round trip\n * loses data SILENTLY — the wrong value renders and no error is raised\n * anywhere. That is the two-route-matchers failure class (canon 9c8f878b) one\n * layer down.\n *\n * Escaping is not two rules that agree today. Both directions stand on\n * `URLSearchParams`: the encoder builds one and calls `.toString()`, the decoder\n * hands the string back to `new URLSearchParams(...)`. `+`-for-space and every\n * percent-encoding rule are therefore ONE rule and cannot drift.\n *\n * ── The grammar, and why it is not ours to choose ────────────────────────────\n *\n * Structure — arrays and nested objects — is the part `URLSearchParams` has no\n * opinion about, so it was read off the SERVER rather than invented. Warlock is\n * a backend framework and core already parses query strings for list endpoints\n * and filters; a client that emitted anything else would not have fixed this\n * module's bug, it would have moved it across the wire where it is harder to\n * see. Measured, with file:line, in\n * `reports/query-grammar-findings-2026-08-24.md`:\n *\n * - `core/src/http/server.ts:14-36` configures Fastify with NO\n * `querystringParser`, so find-my-way's `fast-querystring` default applies:\n * flat keys, `+` is a space, a repeated key collapses to an array.\n * - `core/src/http/request.ts:491` then runs `request.query` through\n * `parseBody` — the SAME bracket-aware parser as the request body.\n * - `core/src/http/request.ts:516-520` reads `key[]` as an array marker, and\n * `:568-575` wraps a single `key[]=a` into the one-element array `[\"a\"]`.\n * - `core/src/http/request.ts:557-561` turns `key[sub]=v` into\n * `{key: {sub: v}}`, one level deep.\n *\n * So: `tags[]=a&tags[]=b` for arrays, `filter[status]=active` for objects,\n * `filter[tags][]=a` for an array inside an object. Bare repeated keys\n * (`tags=a&tags=b`) would also reach core as an array, but only at cardinality\n * two or more — `tags=a` comes back as the scalar `\"a\"`, and a shape that\n * changes with the number of matches is a `TypeError` waiting for the day a\n * filter matches one item. `[]` is the form core wrote a branch for.\n *\n * ── What is REFUSED, and why refusing is the safe answer ─────────────────────\n *\n * Two levels of nesting is not a gap in core, it is measured data loss:\n * `a[b][c]=x` takes the `][` branch at `core/src/http/request.ts:528-551`,\n * computes `Number(\"b\")` as `NaN`, and the value lands on a `NaN` index and\n * vanishes — core answers `{a: []}`. So the encoder throws\n * {@link UnserializableQueryValueError} rather than write it. `href()` already\n * throws on a missing route parameter rather than emit a link that renders\n * correctly and 404s; this is the same call.\n *\n * ── What the encoder still normalises, and what that costs ───────────────────\n *\n * Leaf values are serialised with `String(value)`, so every value arrives back\n * as a `string`: `href(\"p\", {}, { page: 2 })` writes `?page=2`, and `2` and\n * `\"2\"` are the same URL. The round-trip law that can actually hold is\n * `parse(encode(query)) === query with String() applied to its leaves and\n * undefined dropped`, and that is what the spec asserts. Asserting anything\n * stronger would be asserting a decoder that guesses types, which is how\n * `?zip=01234` becomes the number `1234`.\n *\n * The decoder does NOT split values on `,`: that would turn the legitimate\n * string `\"Doe, John\"` into `[\"Doe\", \" John\"]`, trading a visible limitation for\n * a silent corruption of ordinary data. Arrays are carried by `[]`, which is\n * unambiguous, so there is nothing left to guess at.\n *\n * ── Universal by requirement, not by accident ────────────────────────────────\n *\n * `web` renders on the server first, so every export here is safe to import and\n * to CALL with no DOM. `@mongez/react-router`'s equivalent reads\n * `window.location.search` unguarded (query-string.ts:22) and throws under SSR.\n * Here the browser-only readers return their empty answer instead — see\n * `queryString.all` for the hydration caveat that comes with that.\n *\n * ── Still NOT here: `toQueryString` and `update` ─────────────────────────────\n *\n * MRR exposes `toQueryString(params)` and `update(params)`, which SERIALISE.\n * The serialiser they need is no longer module-private — `queryStringOf` is\n * exported from here — so the old blocker is gone. What remains is that\n * `web/src/index.ts` does not re-export it yet, and the barrel is owned\n * elsewhere. That is a one-line follow-up, not a redesign.\n */\n\n/** A decoded leaf. Always a `string` — see the header on `String(value)`. */\nexport type QueryStringLeaf = string;\n\n/**\n * The inside of a `key[sub]` bag: one level deep, exactly as far as core parses.\n *\n * There is no deeper case to model. `key[sub][deeper]` is refused by the encoder\n * and carried verbatim as a literal key by the decoder, so a nested bag only\n * ever holds a leaf or an array of leaves.\n */\nexport type QueryStringNested = Record<string, QueryStringLeaf | QueryStringLeaf[]>;\n\n/** A decoded value: a leaf, a `key[]` array, or a `key[sub]` bag. */\nexport type QueryStringValue = QueryStringLeaf | QueryStringLeaf[] | QueryStringNested;\n\n/** A decoded query string. */\nexport type QueryStringObject = Record<string, QueryStringValue>;\n\n/** What {@link queryStringOf} accepts. Values are validated, not trusted. */\nexport type QueryStringInput = Readonly<Record<string, unknown>>;\n\n/**\n * Thrown when a query value has a shape the wire format cannot carry.\n *\n * Deliberately LOUD. The alternative is emitting something core silently\n * mangles — `a[b][c]=x` arrives as `{a: []}` — which is this defect all over\n * again, one layer further from where anyone would look for it.\n */\nexport class UnserializableQueryValueError extends Error {\n public constructor(\n public readonly queryKey: string,\n public readonly shape: string,\n ) {\n super(\n `Warlock cannot put ${shape} in a query string at \"${queryKey}\". The wire format is the ` +\n \"one @warlock.js/core parses (core/src/http/request.ts:503-591): a value may be a \" +\n \"scalar, an array of scalars (`key[]=a&key[]=b`), or an object one level deep whose \" +\n \"values are scalars or arrays of scalars (`key[sub]=a`, `key[sub][]=a`). Anything \" +\n \"deeper is refused rather than written, because core reads `a[b][c]=x` back as \" +\n \"`{a: []}` — the value is destroyed on arrival with no error. Flatten the value, or \" +\n \"JSON.stringify it into a single scalar and parse it on the server.\",\n );\n this.name = \"UnserializableQueryValueError\";\n }\n}\n\n/**\n * Is this a BAG of query keys, as opposed to a value with its own string form?\n *\n * A `Date`, a `URL` or anything else carrying its own `toString` is a scalar\n * here — `String(value)` is meaningful for it, and turning it into\n * `since[getTime]=...` would be absurd. A plain object, or a class instance that\n * would otherwise stringify to the useless `\"[object Object]\"`, is a bag and\n * gets enumerated.\n *\n * The `typeof` check matters for null-prototype objects: they have no\n * `toString` at all, and `String()` on one THROWS.\n */\nfunction isKeyBag(value: object): boolean {\n const stringForm = (value as { toString?: unknown }).toString;\n\n return typeof stringForm !== \"function\" || stringForm === Object.prototype.toString;\n}\n\n/** Refuse anything that is not a leaf. Used everywhere a leaf is the only legal shape. */\nfunction requireLeaf(key: string, value: unknown): void {\n if (Array.isArray(value)) {\n throw new UnserializableQueryValueError(key, \"a nested array\");\n }\n\n if (typeof value === \"object\" && value !== null && isKeyBag(value)) {\n throw new UnserializableQueryValueError(key, \"a nested object\");\n }\n}\n\n/**\n * Append `key[]=element` per element.\n *\n * An `undefined` element is skipped, matching the top-level rule. Because the\n * `[]` form carries no indices, skipping shortens the array rather than leaving\n * a hole — there is no hole to leave.\n *\n * An EMPTY array appends nothing, so the key is absent from the URL. The format\n * has no way to say \"an array with no elements\": `key[]=` means the one-element\n * array `[\"\"]`, which is a different value. A visible limitation beats a guess.\n */\nfunction appendArray(search: URLSearchParams, key: string, value: readonly unknown[]): void {\n const arrayKey = `${key}[]`;\n\n for (const element of value) {\n if (element === undefined) continue;\n\n requireLeaf(arrayKey, element);\n\n search.append(arrayKey, String(element));\n }\n}\n\n/** Append `key[sub]=value`, or `key[sub][]=value` when the member is an array. */\nfunction appendBag(search: URLSearchParams, key: string, value: object): void {\n for (const [subKey, subValue] of Object.entries(value)) {\n if (subValue === undefined) continue;\n\n const nestedKey = `${key}[${subKey}]`;\n\n if (Array.isArray(subValue)) {\n appendArray(search, nestedKey, subValue);\n continue;\n }\n\n requireLeaf(nestedKey, subValue);\n\n search.append(nestedKey, String(subValue));\n }\n}\n\n/**\n * Serialise a query object to a search string, INCLUDING the leading `?`.\n *\n * Returns `\"\"` — not `\"?\"` — when there is nothing to write, so the result can\n * always be concatenated onto a path. `href()` is the primary caller; the\n * grammar it emits is the one core parses, and the whole justification is in the\n * module header.\n *\n * @throws {UnserializableQueryValueError} when a value nests deeper than the\n * wire format can carry.\n */\nexport function queryStringOf(query: QueryStringInput | undefined): string {\n if (query === undefined) return \"\";\n\n const search = new URLSearchParams();\n\n for (const [key, value] of Object.entries(query)) {\n if (value === undefined) continue;\n\n if (Array.isArray(value)) {\n appendArray(search, key, value);\n continue;\n }\n\n if (typeof value === \"object\" && value !== null && isKeyBag(value)) {\n appendBag(search, key, value);\n continue;\n }\n\n search.append(key, String(value));\n }\n\n const serialized = search.toString();\n\n return serialized === \"\" ? \"\" : `?${serialized}`;\n}\n\n/**\n * What to do when the same PLAIN key appears more than once, e.g. `?tag=a&tag=b`.\n *\n * - `\"last\"` — keep the final occurrence. Matches `URLSearchParams.get()`, the\n * read side of the primitive the encoder writes with, and keeps every value a\n * `string`.\n * - `\"first\"` — keep the earliest occurrence.\n * - `\"array\"` — collect every occurrence. Under this strategy a key that occurs\n * ONCE is still an array of one, because a shape that changes with the number\n * of values is a `TypeError` waiting for the day a filter matches a single\n * item.\n *\n * It does NOT govern `key[]` or `key[sub]`; those are shapes the encoder writes\n * deliberately, and their meaning is fixed. See {@link QueryStringOptions}.\n */\nexport type RepeatedKeyStrategy = \"last\" | \"first\" | \"array\";\n\n/**\n * The whole option surface.\n *\n * ── Why this is one flag and not MRR's pluggable parsers ─────────────────────\n *\n * MRR's `setQueryStringOptions` swaps `objectParser` and `stringParser`\n * (query-string.ts:10-15). Neither survives contact with a fixed, shared\n * encoder:\n *\n * - A pluggable `objectParser` is a licence to install a READER that disagrees\n * with the writer. It is precisely the silent round-trip break this module\n * was written to prevent, offered as a supported API.\n * - A pluggable `stringParser` would be worse: it cannot reach `href()`, which\n * calls `queryStringOf` directly with no hook. Setting it would change what\n * this module emits while every `<Link>` on the page kept emitting the old\n * format — two writers, disagreeing, by configuration.\n *\n * `repeatedKeys` is safe for one specific reason, and the reason survived the\n * move to bracket notation: `queryStringOf` iterates `Object.entries`, so it can\n * never emit a duplicate PLAIN key. It now does emit duplicate `key[]` pairs —\n * that is how an array is written — but those go down the array branch of the\n * decoder, which this flag does not touch. Were the flag to govern the bracket\n * forms as well, `repeatedKeys: \"array\"` would decode `filter[x]=1` as\n * `{filter: {x: [\"1\"]}}` and break the round trip BY CONFIGURATION.\n *\n * So the flag still decides only something the encoder has no opinion about: a\n * repeated plain key, which can only arrive from a URL this package did not\n * write — hand-typed, external links, `GET` forms.\n *\n * Set it at BOOT, not per request: this is process-global config, and a\n * request-scoped write would be read by whatever request happens to be\n * rendering (the defect catalogued at route-table.ts:10-25).\n */\nexport type QueryStringOptions = {\n readonly repeatedKeys?: RepeatedKeyStrategy;\n};\n\ntype ResolvedQueryStringOptions = Required<QueryStringOptions>;\n\nconst DEFAULT_OPTIONS: ResolvedQueryStringOptions = {\n repeatedKeys: \"last\",\n};\n\n/**\n * Held on `globalThis`, not in a module binding, for the reason measured and\n * written up at route-table.ts:43-65: in dev the process runs two module graphs\n * over these files (tsx/Node and Vite's SSR runner), so a `let` written by boot\n * code is not the `let` a component reads. A split here would be quiet — the\n * second graph would silently fall back to the defaults and decode a repeated\n * key the other way.\n */\nconst OPTIONS_SLOT = Symbol.for(\"warlock.web.queryStringOptions\");\n\ntype OptionsHost = typeof globalThis & {\n [OPTIONS_SLOT]?: ResolvedQueryStringOptions;\n};\n\nfunction currentOptions(): ResolvedQueryStringOptions {\n return (globalThis as OptionsHost)[OPTIONS_SLOT] ?? DEFAULT_OPTIONS;\n}\n\n/**\n * Merge query-string options over the current ones.\n *\n * Named for `@mongez/react-router`'s function so migrating call sites keep\n * compiling; the OPTIONS are deliberately narrower, and `QueryStringOptions`\n * explains exactly which ones were refused and why.\n */\nexport function setQueryStringOptions(options: QueryStringOptions): void {\n (globalThis as OptionsHost)[OPTIONS_SLOT] = { ...currentOptions(), ...options };\n}\n\n/**\n * Drop back to the defaults.\n *\n * Exists because the options are process-global: a test that set one would\n * otherwise leak it into every later test in the same worker, passing in file\n * order and failing under `--shuffle`. Same reasoning as `resetRouteTable`.\n */\nexport function resetQueryStringOptions(): void {\n delete (globalThis as OptionsHost)[OPTIONS_SLOT];\n}\n\n/**\n * The search string of the current document, or `\"\"` when there is no document.\n *\n * The `typeof` guard is the SSR contract: no export in this module may touch a\n * DOM global without one.\n */\nfunction browserSearch(): string {\n if (typeof window === \"undefined\") return \"\";\n\n return window.location?.search ?? \"\";\n}\n\n/**\n * Reduce anything search-shaped to the pairs themselves.\n *\n * Accepts `\"?a=1\"`, `\"a=1\"`, `\"/path?a=1\"` and a full URL, because\n * `location.href`, `location.search` and a hand-written literal all turn up at\n * this door and `new URLSearchParams(\"/path?a=1\")` would answer with the key\n * `\"/path?a\"` — a wrong answer with no error. Cutting at the first `?` and the\n * first `#` is safe rather than heuristic: `queryStringOf` percent-encodes both\n * characters inside values (`%3F`, `%23`), so a literal one is always a\n * delimiter.\n */\nfunction searchPairsOf(source: string): string {\n const withoutHash = source.split(\"#\", 1)[0];\n const questionMark = withoutHash.indexOf(\"?\");\n\n return questionMark === -1 ? withoutHash : withoutHash.slice(questionMark + 1);\n}\n\n/**\n * The three key shapes the encoder writes. Everything else is a literal key.\n *\n * The character classes exclude brackets, so each pattern matches exactly one\n * shape: `tags[]` cannot satisfy `NESTED`, and `a[b][c]` satisfies none of them.\n */\nconst ARRAY_KEY = /^([^[\\]]+)\\[\\]$/;\nconst NESTED_KEY = /^([^[\\]]+)\\[([^[\\]]+)\\]$/;\nconst NESTED_ARRAY_KEY = /^([^[\\]]+)\\[([^[\\]]+)\\]\\[\\]$/;\n\ntype ParsedKey =\n | { kind: \"plain\"; name: string }\n | { kind: \"array\"; name: string }\n | { kind: \"nested\"; name: string; subKey: string }\n | { kind: \"nestedArray\"; name: string; subKey: string };\n\n/**\n * Classify a decoded key.\n *\n * A shape neither side produces — `a[b][c]`, `items[0][name]`, an unbalanced\n * `a[` — falls through to `\"plain\"` and is carried VERBATIM. Those can only come\n * from a URL warlock did not write, and guessing at them is exactly how a\n * decoder drifts from its writer. Carrying the key as text loses nothing.\n */\nfunction parseKey(key: string): ParsedKey {\n const nestedArray = NESTED_ARRAY_KEY.exec(key);\n\n if (nestedArray) {\n return { kind: \"nestedArray\", name: nestedArray[1], subKey: nestedArray[2] };\n }\n\n const array = ARRAY_KEY.exec(key);\n\n if (array) return { kind: \"array\", name: array[1] };\n\n const nested = NESTED_KEY.exec(key);\n\n if (nested) return { kind: \"nested\", name: nested[1], subKey: nested[2] };\n\n return { kind: \"plain\", name: key };\n}\n\n/** A fresh bag with NO prototype. See {@link decode} for why that is not optional. */\nfunction emptyBag<T extends object>(): T {\n return Object.create(null) as T;\n}\n\nfunction isBag(value: unknown): value is QueryStringNested {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * The array living at `container[name]`, created if it is not there yet.\n *\n * A conflicting earlier shape is REPLACED rather than written into. `?a=1&a[]=2`\n * is not something the encoder can emit, so it is a foreign URL contradicting\n * itself; appending to a string would be a silent no-op, and last-shape-wins at\n * least matches how `repeatedKeys: \"last\"` resolves the scalar version.\n */\nfunction arrayAt(container: Record<string, unknown>, name: string): string[] {\n const existing = container[name];\n\n if (Array.isArray(existing)) return existing;\n\n const created: string[] = [];\n\n container[name] = created;\n\n return created;\n}\n\n/** The bag living at `result[name]`, created if absent. Same last-shape-wins rule. */\nfunction bagAt(result: QueryStringObject, name: string): QueryStringNested {\n const existing = result[name];\n\n if (isBag(existing)) return existing;\n\n const created = emptyBag<QueryStringNested>();\n\n result[name] = created;\n\n return created;\n}\n\n/**\n * The single decode path. Every reader below goes through here.\n *\n * Every container has a NULL PROTOTYPE — the result and each `key[sub]` bag.\n * Query keys are attacker-controlled, and assigning `?__proto__=x` onto a `{}`\n * literal hits the inherited setter, which DISCARDS the key: data loss with no\n * error. Bracket parsing makes this sharper than it was when this decoder was\n * flat, because `?filter[__proto__]=x` now reaches a nested container too —\n * which is why `bagAt` builds with `Object.create(null)` rather than `{}`.\n * MRR's parser walks `key.split(\"[\")` onto plain objects and needs the blocklist\n * at query-string-parsers.ts:7 as a result; with no prototype there is no setter\n * to hit and the key is stored as the ordinary data it is.\n */\nfunction decode(source: string): QueryStringObject {\n const pairs = searchPairsOf(source);\n const result = emptyBag<QueryStringObject>();\n\n if (pairs === \"\") return result;\n\n const { repeatedKeys } = currentOptions();\n\n for (const [key, value] of new URLSearchParams(pairs)) {\n const parsed = parseKey(key);\n\n if (parsed.kind === \"array\") {\n arrayAt(result, parsed.name).push(value);\n continue;\n }\n\n if (parsed.kind === \"nestedArray\") {\n arrayAt(bagAt(result, parsed.name), parsed.subKey).push(value);\n continue;\n }\n\n if (parsed.kind === \"nested\") {\n // Last wins. The encoder cannot repeat a `key[sub]` pair — `Object.entries`\n // yields each sub-key once — so this only arises from a foreign URL, and\n // `repeatedKeys` deliberately does not reach here (QueryStringOptions).\n bagAt(result, parsed.name)[parsed.subKey] = value;\n continue;\n }\n\n if (!(parsed.name in result)) {\n result[parsed.name] = repeatedKeys === \"array\" ? [value] : value;\n continue;\n }\n\n if (repeatedKeys === \"first\") continue;\n\n if (repeatedKeys === \"last\") {\n result[parsed.name] = value;\n continue;\n }\n\n arrayAt(result, parsed.name).push(value);\n }\n\n return result;\n}\n\n/**\n * Reading the current query string, and any query string.\n *\n * Named for `@mongez/react-router`'s object so the familiar calls keep working;\n * the implementation is not ported — see the module header for what changed and\n * why.\n */\nexport const queryString = {\n /**\n * The current document's query string, decoded.\n *\n * On the server this is `{}`, because the browser location is the only source\n * this module has and a per-request one would be module state two concurrent\n * requests could race over (route-table.ts:10-25).\n *\n * That makes it a HYDRATION HAZARD in a component: the server renders `{}`\n * and the browser renders the real values, so the two trees differ. In a\n * component, take the query from the page's own props and hand it to\n * {@link queryString.parse}, which is universal. `all()` is for browser-only\n * code — an event handler, an effect, a client-side helper.\n */\n all(): QueryStringObject {\n return decode(browserSearch());\n },\n\n /**\n * Decode a query string that was handed to you.\n *\n * Universal: it reads no globals, so this is the entry point that is safe on\n * the server. Accepts a bare pair list, a leading `?`, a path, or a full URL.\n */\n parse(search: string): QueryStringObject {\n return decode(search);\n },\n\n /**\n * One key from the current query string.\n *\n * PRESENCE, not truthiness. MRR returns `all[key] || defaultValue`\n * (query-string.ts:43), which hands back the default for `?a=` — so a\n * deliberately-cleared filter reads as though it was never set, and the page\n * shows the default instead of the empty state. Here `?a=` returns `\"\"`, and\n * only an absent key returns the default.\n *\n * @param defaultValue returned only when the key is absent. Defaults to\n * `null`, as MRR's does.\n */\n get<T = null>(key: string, defaultValue: T = null as T): QueryStringValue | T {\n const all = decode(browserSearch());\n\n return key in all ? all[key] : defaultValue;\n },\n\n /**\n * The current query string verbatim, without the leading `?`.\n *\n * `\"\"` on the server, and `\"\"` when there is no query — the same two cases\n * `queryStringOf` collapses when it writes.\n */\n toString(): string {\n return searchPairsOf(browserSearch());\n },\n};\n"],"mappings":"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"}
|
|
1
|
+
{"version":3,"file":"query-string.mjs","names":[],"sources":["../../../../../../../web/src/routing/query-string.ts"],"sourcesContent":["/**\n * The query string: BOTH directions, one grammar, one file.\n *\n * ── The rule this module exists to obey ──────────────────────────────────────\n *\n * `queryStringOf` is what `href(name, params, query)` appends, and therefore\n * what every `<Link>` puts in the document; `queryString.parse` is what reads it\n * back. They live together because a second, independently-written\n * query-string implementation would mean one thing writes URLs and a different\n * thing reads them. Query-string libraries genuinely disagree about arrays,\n * nested objects and space escaping, so the moment the two drift the round trip\n * loses data SILENTLY — the wrong value renders and no error is raised\n * anywhere. That is the two-route-matchers failure class (canon 9c8f878b) one\n * layer down.\n *\n * Escaping is not two rules that agree today. Both directions stand on\n * `URLSearchParams`: the encoder builds one and calls `.toString()`, the decoder\n * hands the string back to `new URLSearchParams(...)`. `+`-for-space and every\n * percent-encoding rule are therefore ONE rule and cannot drift.\n *\n * ── The grammar, and why it is not ours to choose ────────────────────────────\n *\n * Structure — arrays and nested objects — is the part `URLSearchParams` has no\n * opinion about, so it was read off the SERVER rather than invented. Warlock is\n * a backend framework and core already parses query strings for list endpoints\n * and filters; a client that emitted anything else would not have fixed this\n * module's bug, it would have moved it across the wire where it is harder to\n * see. Measured, with file:line, in\n * `reports/query-grammar-findings-2026-08-24.md`:\n *\n * - `core/src/http/server.ts:14-36` configures Fastify with NO\n * `querystringParser`, so find-my-way's `fast-querystring` default applies:\n * flat keys, `+` is a space, a repeated key collapses to an array.\n * - `core/src/http/request.ts:491` then runs `request.query` through\n * `parseBody` — the SAME bracket-aware parser as the request body.\n * - `core/src/http/request.ts:516-520` reads `key[]` as an array marker, and\n * `:568-575` wraps a single `key[]=a` into the one-element array `[\"a\"]`.\n * - `core/src/http/request.ts:557-561` turns `key[sub]=v` into\n * `{key: {sub: v}}`, one level deep.\n *\n * So: `tags[]=a&tags[]=b` for arrays, `filter[status]=active` for objects,\n * `filter[tags][]=a` for an array inside an object. Bare repeated keys\n * (`tags=a&tags=b`) would also reach core as an array, but only at cardinality\n * two or more — `tags=a` comes back as the scalar `\"a\"`, and a shape that\n * changes with the number of matches is a `TypeError` waiting for the day a\n * filter matches one item. `[]` is the form core wrote a branch for.\n *\n * ── What is REFUSED, and why refusing is the safe answer ─────────────────────\n *\n * Two levels of nesting is not a gap in core, it is measured data loss:\n * `a[b][c]=x` takes the `][` branch at `core/src/http/request.ts:528-551`,\n * computes `Number(\"b\")` as `NaN`, and the value lands on a `NaN` index and\n * vanishes — core answers `{a: []}`. So the encoder throws\n * {@link UnserializableQueryValueError} rather than write it. `href()` already\n * throws on a missing route parameter rather than emit a link that renders\n * correctly and 404s; this is the same call.\n *\n * ── What the encoder still normalises, and what that costs ───────────────────\n *\n * Leaf values are serialised with `String(value)`, so every value arrives back\n * as a `string`: `href(\"p\", {}, { page: 2 })` writes `?page=2`, and `2` and\n * `\"2\"` are the same URL. The round-trip law that can actually hold is\n * `parse(encode(query)) === query with String() applied to its leaves and\n * undefined dropped`, and that is what the spec asserts. Asserting anything\n * stronger would be asserting a decoder that guesses types, which is how\n * `?zip=01234` becomes the number `1234`.\n *\n * The decoder does NOT split values on `,`: that would turn the legitimate\n * string `\"Doe, John\"` into `[\"Doe\", \" John\"]`, trading a visible limitation for\n * a silent corruption of ordinary data. Arrays are carried by `[]`, which is\n * unambiguous, so there is nothing left to guess at.\n *\n * ── Universal by requirement, not by accident ────────────────────────────────\n *\n * `web` renders on the server first, so every export here is safe to import and\n * to CALL with no DOM. `@mongez/react-router`'s equivalent reads\n * `window.location.search` unguarded (query-string.ts:22) and throws under SSR.\n * Here the browser-only readers return their empty answer instead — see\n * `queryString.all` for the hydration caveat that comes with that.\n *\n * ── Still NOT here: `toQueryString` and `update` ─────────────────────────────\n *\n * MRR exposes `toQueryString(params)` and `update(params)`, which SERIALISE.\n * The serialiser they need is no longer module-private — `queryStringOf` is\n * exported from here — so the old blocker is gone. What remains is that\n * `web/src/index.ts` does not re-export it yet, and the barrel is owned\n * elsewhere. That is a one-line follow-up, not a redesign.\n */\n\n/** A decoded leaf. Always a `string` — see the header on `String(value)`. */\nexport type QueryStringLeaf = string;\n\n/**\n * The inside of a `key[sub]` bag: one level deep, exactly as far as core parses.\n *\n * There is no deeper case to model. `key[sub][deeper]` is refused by the encoder\n * and carried verbatim as a literal key by the decoder, so a nested bag only\n * ever holds a leaf or an array of leaves.\n */\nexport type QueryStringNested = Record<string, QueryStringLeaf | QueryStringLeaf[]>;\n\n/** A decoded value: a leaf, a `key[]` array, or a `key[sub]` bag. */\nexport type QueryStringValue = QueryStringLeaf | QueryStringLeaf[] | QueryStringNested;\n\n/** A decoded query string. */\nexport type QueryStringObject = Record<string, QueryStringValue>;\n\n/** What {@link queryStringOf} accepts. Values are validated, not trusted. */\nexport type QueryStringInput = Readonly<Record<string, unknown>>;\n\n/**\n * Thrown when a query value has a shape the wire format cannot carry.\n *\n * Deliberately LOUD. The alternative is emitting something core silently\n * mangles — `a[b][c]=x` arrives as `{a: []}` — which is this defect all over\n * again, one layer further from where anyone would look for it.\n */\nexport class UnserializableQueryValueError extends Error {\n public constructor(\n public readonly queryKey: string,\n public readonly shape: string,\n ) {\n super(\n `Warlock cannot put ${shape} in a query string at \"${queryKey}\". The wire format is the ` +\n \"one @warlock.js/core parses (core/src/http/request.ts:503-591): a value may be a \" +\n \"scalar, an array of scalars (`key[]=a&key[]=b`), or an object one level deep whose \" +\n \"values are scalars or arrays of scalars (`key[sub]=a`, `key[sub][]=a`). Anything \" +\n \"deeper is refused rather than written, because core reads `a[b][c]=x` back as \" +\n \"`{a: []}` — the value is destroyed on arrival with no error. Flatten the value, or \" +\n \"JSON.stringify it into a single scalar and parse it on the server.\",\n );\n this.name = \"UnserializableQueryValueError\";\n }\n}\n\n/**\n * Is this a BAG of query keys, as opposed to a value with its own string form?\n *\n * A `Date`, a `URL` or anything else carrying its own `toString` is a scalar\n * here — `String(value)` is meaningful for it, and turning it into\n * `since[getTime]=...` would be absurd. A plain object, or a class instance that\n * would otherwise stringify to the useless `\"[object Object]\"`, is a bag and\n * gets enumerated.\n *\n * The `typeof` check matters for null-prototype objects: they have no\n * `toString` at all, and `String()` on one THROWS.\n */\nfunction isKeyBag(value: object): boolean {\n const stringForm = (value as { toString?: unknown }).toString;\n\n return typeof stringForm !== \"function\" || stringForm === Object.prototype.toString;\n}\n\n/** Refuse anything that is not a leaf. Used everywhere a leaf is the only legal shape. */\nfunction requireLeaf(key: string, value: unknown): void {\n if (Array.isArray(value)) {\n throw new UnserializableQueryValueError(key, \"a nested array\");\n }\n\n if (typeof value === \"object\" && value !== null && isKeyBag(value)) {\n throw new UnserializableQueryValueError(key, \"a nested object\");\n }\n}\n\n/**\n * Append `key[]=element` per element.\n *\n * An `undefined` element is skipped, matching the top-level rule. Because the\n * `[]` form carries no indices, skipping shortens the array rather than leaving\n * a hole — there is no hole to leave.\n *\n * An EMPTY array appends nothing, so the key is absent from the URL. The format\n * has no way to say \"an array with no elements\": `key[]=` means the one-element\n * array `[\"\"]`, which is a different value. A visible limitation beats a guess.\n */\nfunction appendArray(search: URLSearchParams, key: string, value: readonly unknown[]): void {\n const arrayKey = `${key}[]`;\n\n for (const element of value) {\n if (element === undefined) continue;\n\n requireLeaf(arrayKey, element);\n\n search.append(arrayKey, String(element));\n }\n}\n\n/** Append `key[sub]=value`, or `key[sub][]=value` when the member is an array. */\nfunction appendBag(search: URLSearchParams, key: string, value: object): void {\n for (const [subKey, subValue] of Object.entries(value)) {\n if (subValue === undefined) continue;\n\n const nestedKey = `${key}[${subKey}]`;\n\n if (Array.isArray(subValue)) {\n appendArray(search, nestedKey, subValue);\n continue;\n }\n\n requireLeaf(nestedKey, subValue);\n\n search.append(nestedKey, String(subValue));\n }\n}\n\n/**\n * Serialise a query object to a search string, INCLUDING the leading `?`.\n *\n * Returns `\"\"` — not `\"?\"` — when there is nothing to write, so the result can\n * always be concatenated onto a path. `href()` is the primary caller; the\n * grammar it emits is the one core parses, and the whole justification is in the\n * module header.\n *\n * @throws {UnserializableQueryValueError} when a value nests deeper than the\n * wire format can carry.\n */\nexport function queryStringOf(query: QueryStringInput | undefined): string {\n if (query === undefined) return \"\";\n\n const search = new URLSearchParams();\n\n for (const [key, value] of Object.entries(query)) {\n if (value === undefined) continue;\n\n if (Array.isArray(value)) {\n appendArray(search, key, value);\n continue;\n }\n\n if (typeof value === \"object\" && value !== null && isKeyBag(value)) {\n appendBag(search, key, value);\n continue;\n }\n\n search.append(key, String(value));\n }\n\n const serialized = search.toString();\n\n return serialized === \"\" ? \"\" : `?${serialized}`;\n}\n\n/**\n * What to do when the same PLAIN key appears more than once, e.g. `?tag=a&tag=b`.\n *\n * - `\"last\"` — keep the final occurrence. Matches `URLSearchParams.get()`, the\n * read side of the primitive the encoder writes with, and keeps every value a\n * `string`.\n * - `\"first\"` — keep the earliest occurrence.\n * - `\"array\"` — collect every occurrence. Under this strategy a key that occurs\n * ONCE is still an array of one, because a shape that changes with the number\n * of values is a `TypeError` waiting for the day a filter matches a single\n * item.\n *\n * It does NOT govern `key[]` or `key[sub]`; those are shapes the encoder writes\n * deliberately, and their meaning is fixed. See {@link QueryStringOptions}.\n */\nexport type RepeatedKeyStrategy = \"last\" | \"first\" | \"array\";\n\n/**\n * The whole option surface.\n *\n * ── Why this is one flag and not MRR's pluggable parsers ─────────────────────\n *\n * MRR's `setQueryStringOptions` swaps `objectParser` and `stringParser`\n * (query-string.ts:10-15). Neither survives contact with a fixed, shared\n * encoder:\n *\n * - A pluggable `objectParser` is a licence to install a READER that disagrees\n * with the writer. It is precisely the silent round-trip break this module\n * was written to prevent, offered as a supported API.\n * - A pluggable `stringParser` would be worse: it cannot reach `href()`, which\n * calls `queryStringOf` directly with no hook. Setting it would change what\n * this module emits while every `<Link>` on the page kept emitting the old\n * format — two writers, disagreeing, by configuration.\n *\n * `repeatedKeys` is safe for one specific reason, and the reason survived the\n * move to bracket notation: `queryStringOf` iterates `Object.entries`, so it can\n * never emit a duplicate PLAIN key. It now does emit duplicate `key[]` pairs —\n * that is how an array is written — but those go down the array branch of the\n * decoder, which this flag does not touch. Were the flag to govern the bracket\n * forms as well, `repeatedKeys: \"array\"` would decode `filter[x]=1` as\n * `{filter: {x: [\"1\"]}}` and break the round trip BY CONFIGURATION.\n *\n * So the flag still decides only something the encoder has no opinion about: a\n * repeated plain key, which can only arrive from a URL this package did not\n * write — hand-typed, external links, `GET` forms.\n *\n * Set it at BOOT, not per request: this is process-global config, and a\n * request-scoped write would be read by whatever request happens to be\n * rendering (the defect catalogued at route-table.ts:10-25).\n */\nexport type QueryStringOptions = {\n readonly repeatedKeys?: RepeatedKeyStrategy;\n};\n\ntype ResolvedQueryStringOptions = Required<QueryStringOptions>;\n\nconst DEFAULT_OPTIONS: ResolvedQueryStringOptions = {\n repeatedKeys: \"last\",\n};\n\n/**\n * Held on `globalThis`, not in a module binding, for the reason measured and\n * written up at route-table.ts:43-65: in dev the process runs two module graphs\n * over these files (tsx/Node and Vite's SSR runner), so a `let` written by boot\n * code is not the `let` a component reads. A split here would be quiet — the\n * second graph would silently fall back to the defaults and decode a repeated\n * key the other way.\n */\nconst OPTIONS_SLOT = Symbol.for(\"warlock.web.queryStringOptions\");\n\ntype OptionsHost = typeof globalThis & {\n [OPTIONS_SLOT]?: ResolvedQueryStringOptions;\n};\n\nfunction currentOptions(): ResolvedQueryStringOptions {\n return (globalThis as OptionsHost)[OPTIONS_SLOT] ?? DEFAULT_OPTIONS;\n}\n\n/**\n * Merge query-string options over the current ones.\n *\n * Named for `@mongez/react-router`'s function so migrating call sites keep\n * compiling; the OPTIONS are deliberately narrower, and `QueryStringOptions`\n * explains exactly which ones were refused and why.\n */\nexport function setQueryStringOptions(options: QueryStringOptions): void {\n (globalThis as OptionsHost)[OPTIONS_SLOT] = { ...currentOptions(), ...options };\n}\n\n/**\n * Drop back to the defaults.\n *\n * Exists because the options are process-global: a test that set one would\n * otherwise leak it into every later test in the same worker, passing in file\n * order and failing under `--shuffle`. Same reasoning as `resetRouteTable`.\n */\nexport function resetQueryStringOptions(): void {\n delete (globalThis as OptionsHost)[OPTIONS_SLOT];\n}\n\n/**\n * The search string of the current document, or `\"\"` when there is no document.\n *\n * The `typeof` guard is the SSR contract: no export in this module may touch a\n * DOM global without one.\n */\nfunction browserSearch(): string {\n if (typeof window === \"undefined\") return \"\";\n\n return window.location?.search ?? \"\";\n}\n\n/**\n * Reduce anything search-shaped to the pairs themselves.\n *\n * Accepts `\"?a=1\"`, `\"a=1\"`, `\"/path?a=1\"` and a full URL, because\n * `location.href`, `location.search` and a hand-written literal all turn up at\n * this door and `new URLSearchParams(\"/path?a=1\")` would answer with the key\n * `\"/path?a\"` — a wrong answer with no error. Cutting at the first `?` and the\n * first `#` is safe rather than heuristic: `queryStringOf` percent-encodes both\n * characters inside values (`%3F`, `%23`), so a literal one is always a\n * delimiter.\n */\nfunction searchPairsOf(source: string): string {\n const withoutHash = source.split(\"#\", 1)[0];\n const questionMark = withoutHash.indexOf(\"?\");\n\n return questionMark === -1 ? withoutHash : withoutHash.slice(questionMark + 1);\n}\n\n/**\n * The three key shapes the encoder writes. Everything else is a literal key.\n *\n * The character classes exclude brackets, so each pattern matches exactly one\n * shape: `tags[]` cannot satisfy `NESTED`, and `a[b][c]` satisfies none of them.\n */\nconst ARRAY_KEY = /^([^[\\]]+)\\[\\]$/;\nconst NESTED_KEY = /^([^[\\]]+)\\[([^[\\]]+)\\]$/;\nconst NESTED_ARRAY_KEY = /^([^[\\]]+)\\[([^[\\]]+)\\]\\[\\]$/;\n\ntype ParsedKey =\n | { kind: \"plain\"; name: string }\n | { kind: \"array\"; name: string }\n | { kind: \"nested\"; name: string; subKey: string }\n | { kind: \"nestedArray\"; name: string; subKey: string };\n\n/**\n * Classify a decoded key.\n *\n * A shape neither side produces — `a[b][c]`, `items[0][name]`, an unbalanced\n * `a[` — falls through to `\"plain\"` and is carried VERBATIM. Those can only come\n * from a URL warlock did not write, and guessing at them is exactly how a\n * decoder drifts from its writer. Carrying the key as text loses nothing.\n */\nfunction parseKey(key: string): ParsedKey {\n const nestedArray = NESTED_ARRAY_KEY.exec(key);\n\n if (nestedArray) {\n return { kind: \"nestedArray\", name: nestedArray[1], subKey: nestedArray[2] };\n }\n\n const array = ARRAY_KEY.exec(key);\n\n if (array) return { kind: \"array\", name: array[1] };\n\n const nested = NESTED_KEY.exec(key);\n\n if (nested) return { kind: \"nested\", name: nested[1], subKey: nested[2] };\n\n return { kind: \"plain\", name: key };\n}\n\n/** A fresh bag with NO prototype. See {@link decode} for why that is not optional. */\nfunction emptyBag<T extends object>(): T {\n return Object.create(null) as T;\n}\n\nfunction isBag(value: unknown): value is QueryStringNested {\n return typeof value === \"object\" && value !== null && !Array.isArray(value);\n}\n\n/**\n * The array living at `container[name]`, created if it is not there yet.\n *\n * A conflicting earlier shape is REPLACED rather than written into. `?a=1&a[]=2`\n * is not something the encoder can emit, so it is a foreign URL contradicting\n * itself; appending to a string would be a silent no-op, and last-shape-wins at\n * least matches how `repeatedKeys: \"last\"` resolves the scalar version.\n */\nfunction arrayAt(container: Record<string, unknown>, name: string): string[] {\n const existing = container[name];\n\n if (Array.isArray(existing)) return existing;\n\n const created: string[] = [];\n\n container[name] = created;\n\n return created;\n}\n\n/** The bag living at `result[name]`, created if absent. Same last-shape-wins rule. */\nfunction bagAt(result: QueryStringObject, name: string): QueryStringNested {\n const existing = result[name];\n\n if (isBag(existing)) return existing;\n\n const created = emptyBag<QueryStringNested>();\n\n result[name] = created;\n\n return created;\n}\n\n/**\n * The single decode path. Every reader below goes through here.\n *\n * Every container has a NULL PROTOTYPE — the result and each `key[sub]` bag.\n * Query keys are attacker-controlled, and assigning `?__proto__=x` onto a `{}`\n * literal hits the inherited setter, which DISCARDS the key: data loss with no\n * error. Bracket parsing makes this sharper than it was when this decoder was\n * flat, because `?filter[__proto__]=x` now reaches a nested container too —\n * which is why `bagAt` builds with `Object.create(null)` rather than `{}`.\n * MRR's parser walks `key.split(\"[\")` onto plain objects and needs the blocklist\n * at query-string-parsers.ts:7 as a result; with no prototype there is no setter\n * to hit and the key is stored as the ordinary data it is.\n */\nfunction decode(source: string): QueryStringObject {\n const pairs = searchPairsOf(source);\n const result = emptyBag<QueryStringObject>();\n\n if (pairs === \"\") return result;\n\n const { repeatedKeys } = currentOptions();\n\n for (const [key, value] of new URLSearchParams(pairs)) {\n const parsed = parseKey(key);\n\n if (parsed.kind === \"array\") {\n arrayAt(result, parsed.name).push(value);\n continue;\n }\n\n if (parsed.kind === \"nestedArray\") {\n arrayAt(bagAt(result, parsed.name), parsed.subKey).push(value);\n continue;\n }\n\n if (parsed.kind === \"nested\") {\n // Last wins. The encoder cannot repeat a `key[sub]` pair — `Object.entries`\n // yields each sub-key once — so this only arises from a foreign URL, and\n // `repeatedKeys` deliberately does not reach here (QueryStringOptions).\n bagAt(result, parsed.name)[parsed.subKey] = value;\n continue;\n }\n\n if (!(parsed.name in result)) {\n result[parsed.name] = repeatedKeys === \"array\" ? [value] : value;\n continue;\n }\n\n if (repeatedKeys === \"first\") continue;\n\n if (repeatedKeys === \"last\") {\n result[parsed.name] = value;\n continue;\n }\n\n arrayAt(result, parsed.name).push(value);\n }\n\n return result;\n}\n\n/**\n * Reading the current query string, and any query string.\n *\n * Named for `@mongez/react-router`'s object so the familiar calls keep working;\n * the implementation is not ported — see the module header for what changed and\n * why.\n */\nexport const queryString = {\n /**\n * The current document's query string, decoded.\n *\n * On the server this is `{}`, because the browser location is the only source\n * this module has and a per-request one would be module state two concurrent\n * requests could race over (route-table.ts:10-25).\n *\n * That makes it a HYDRATION HAZARD in a component: the server renders `{}`\n * and the browser renders the real values, so the two trees differ. In a\n * component, take the query from the page's own props and hand it to\n * {@link queryString.parse}, which is universal. `all()` is for browser-only\n * code — an event handler, an effect, a client-side helper.\n */\n all(): QueryStringObject {\n return decode(browserSearch());\n },\n\n /**\n * Decode a query string that was handed to you.\n *\n * Universal: it reads no globals, so this is the entry point that is safe on\n * the server. Accepts a bare pair list, a leading `?`, a path, or a full URL.\n */\n parse(search: string): QueryStringObject {\n return decode(search);\n },\n\n /**\n * One key from the current query string.\n *\n * PRESENCE, not truthiness. MRR returns `all[key] || defaultValue`\n * (query-string.ts:43), which hands back the default for `?a=` — so a\n * deliberately-cleared filter reads as though it was never set, and the page\n * shows the default instead of the empty state. Here `?a=` returns `\"\"`, and\n * only an absent key returns the default.\n *\n * @param defaultValue returned only when the key is absent. Defaults to\n * `null`, as MRR's does.\n */\n get<T = null>(key: string, defaultValue: T = null as T): QueryStringValue | T {\n const all = decode(browserSearch());\n\n return key in all ? all[key] : defaultValue;\n },\n\n /**\n * The current query string verbatim, without the leading `?`.\n *\n * `\"\"` on the server, and `\"\"` when there is no query — the same two cases\n * `queryStringOf` collapses when it writes.\n */\n toString(): string {\n return searchPairsOf(browserSearch());\n },\n};\n"],"mappings":";;;;;;;;AAqHA,IAAa,gCAAb,cAAmD,MAAM;CAErC;CACA;CAFlB,AAAO,YACL,AAAgB,UAChB,AAAgB,OAChB;EACA,MACE,sBAAsB,MAAM,yBAAyB,SAAS,6fAOhE;EAXgB;EACA;EAWhB,KAAK,OAAO;CACd;AACF;;;;;;;;;;;;;AAcA,SAAS,SAAS,OAAwB;CACxC,MAAM,aAAc,MAAiC;CAErD,OAAO,OAAO,eAAe,cAAc,eAAe,OAAO,UAAU;AAC7E;;AAGA,SAAS,YAAY,KAAa,OAAsB;CACtD,IAAI,MAAM,QAAQ,KAAK,GACrB,MAAM,IAAI,8BAA8B,KAAK,gBAAgB;CAG/D,IAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,SAAS,KAAK,GAC/D,MAAM,IAAI,8BAA8B,KAAK,iBAAiB;AAElE;;;;;;;;;;;;AAaA,SAAS,YAAY,QAAyB,KAAa,OAAiC;CAC1F,MAAM,WAAW,GAAG,IAAI;CAExB,KAAK,MAAM,WAAW,OAAO;EAC3B,IAAI,YAAY,QAAW;EAE3B,YAAY,UAAU,OAAO;EAE7B,OAAO,OAAO,UAAU,OAAO,OAAO,CAAC;CACzC;AACF;;AAGA,SAAS,UAAU,QAAyB,KAAa,OAAqB;CAC5E,KAAK,MAAM,CAAC,QAAQ,aAAa,OAAO,QAAQ,KAAK,GAAG;EACtD,IAAI,aAAa,QAAW;EAE5B,MAAM,YAAY,GAAG,IAAI,GAAG,OAAO;EAEnC,IAAI,MAAM,QAAQ,QAAQ,GAAG;GAC3B,YAAY,QAAQ,WAAW,QAAQ;GACvC;EACF;EAEA,YAAY,WAAW,QAAQ;EAE/B,OAAO,OAAO,WAAW,OAAO,QAAQ,CAAC;CAC3C;AACF;;;;;;;;;;;;AAaA,SAAgB,cAAc,OAA6C;CACzE,IAAI,UAAU,QAAW,OAAO;CAEhC,MAAM,SAAS,IAAI,gBAAgB;CAEnC,KAAK,MAAM,CAAC,KAAK,UAAU,OAAO,QAAQ,KAAK,GAAG;EAChD,IAAI,UAAU,QAAW;EAEzB,IAAI,MAAM,QAAQ,KAAK,GAAG;GACxB,YAAY,QAAQ,KAAK,KAAK;GAC9B;EACF;EAEA,IAAI,OAAO,UAAU,YAAY,UAAU,QAAQ,SAAS,KAAK,GAAG;GAClE,UAAU,QAAQ,KAAK,KAAK;GAC5B;EACF;EAEA,OAAO,OAAO,KAAK,OAAO,KAAK,CAAC;CAClC;CAEA,MAAM,aAAa,OAAO,SAAS;CAEnC,OAAO,eAAe,KAAK,KAAK,IAAI;AACtC;AA0DA,MAAM,kBAA8C,EAClD,cAAc,OAChB;;;;;;;;;AAUA,MAAM,eAAe,OAAO,IAAI,gCAAgC;AAMhE,SAAS,iBAA6C;CACpD,OAAQ,WAA2B,iBAAiB;AACtD;;;;;;;;AASA,SAAgB,sBAAsB,SAAmC;CACvE,AAAC,WAA2B,gBAAgB;EAAE,GAAG,eAAe;EAAG,GAAG;CAAQ;AAChF;;;;;;;;AASA,SAAgB,0BAAgC;CAC9C,OAAQ,WAA2B;AACrC;;;;;;;AAQA,SAAS,gBAAwB;CAC/B,IAAI,OAAO,WAAW,aAAa,OAAO;CAE1C,OAAO,OAAO,UAAU,UAAU;AACpC;;;;;;;;;;;;AAaA,SAAS,cAAc,QAAwB;CAC7C,MAAM,cAAc,OAAO,MAAM,KAAK,CAAC,EAAE;CACzC,MAAM,eAAe,YAAY,QAAQ,GAAG;CAE5C,OAAO,iBAAiB,KAAK,cAAc,YAAY,MAAM,eAAe,CAAC;AAC/E;;;;;;;AAQA,MAAM,YAAY;AAClB,MAAM,aAAa;AACnB,MAAM,mBAAmB;;;;;;;;;AAgBzB,SAAS,SAAS,KAAwB;CACxC,MAAM,cAAc,iBAAiB,KAAK,GAAG;CAE7C,IAAI,aACF,OAAO;EAAE,MAAM;EAAe,MAAM,YAAY;EAAI,QAAQ,YAAY;CAAG;CAG7E,MAAM,QAAQ,UAAU,KAAK,GAAG;CAEhC,IAAI,OAAO,OAAO;EAAE,MAAM;EAAS,MAAM,MAAM;CAAG;CAElD,MAAM,SAAS,WAAW,KAAK,GAAG;CAElC,IAAI,QAAQ,OAAO;EAAE,MAAM;EAAU,MAAM,OAAO;EAAI,QAAQ,OAAO;CAAG;CAExE,OAAO;EAAE,MAAM;EAAS,MAAM;CAAI;AACpC;;AAGA,SAAS,WAAgC;CACvC,OAAO,OAAO,OAAO,IAAI;AAC3B;AAEA,SAAS,MAAM,OAA4C;CACzD,OAAO,OAAO,UAAU,YAAY,UAAU,QAAQ,CAAC,MAAM,QAAQ,KAAK;AAC5E;;;;;;;;;AAUA,SAAS,QAAQ,WAAoC,MAAwB;CAC3E,MAAM,WAAW,UAAU;CAE3B,IAAI,MAAM,QAAQ,QAAQ,GAAG,OAAO;CAEpC,MAAM,UAAoB,CAAC;CAE3B,UAAU,QAAQ;CAElB,OAAO;AACT;;AAGA,SAAS,MAAM,QAA2B,MAAiC;CACzE,MAAM,WAAW,OAAO;CAExB,IAAI,MAAM,QAAQ,GAAG,OAAO;CAE5B,MAAM,UAAU,SAA4B;CAE5C,OAAO,QAAQ;CAEf,OAAO;AACT;;;;;;;;;;;;;;AAeA,SAAS,OAAO,QAAmC;CACjD,MAAM,QAAQ,cAAc,MAAM;CAClC,MAAM,SAAS,SAA4B;CAE3C,IAAI,UAAU,IAAI,OAAO;CAEzB,MAAM,EAAE,iBAAiB,eAAe;CAExC,KAAK,MAAM,CAAC,KAAK,UAAU,IAAI,gBAAgB,KAAK,GAAG;EACrD,MAAM,SAAS,SAAS,GAAG;EAE3B,IAAI,OAAO,SAAS,SAAS;GAC3B,QAAQ,QAAQ,OAAO,IAAI,EAAE,KAAK,KAAK;GACvC;EACF;EAEA,IAAI,OAAO,SAAS,eAAe;GACjC,QAAQ,MAAM,QAAQ,OAAO,IAAI,GAAG,OAAO,MAAM,EAAE,KAAK,KAAK;GAC7D;EACF;EAEA,IAAI,OAAO,SAAS,UAAU;GAI5B,MAAM,QAAQ,OAAO,IAAI,EAAE,OAAO,UAAU;GAC5C;EACF;EAEA,IAAI,EAAE,OAAO,QAAQ,SAAS;GAC5B,OAAO,OAAO,QAAQ,iBAAiB,UAAU,CAAC,KAAK,IAAI;GAC3D;EACF;EAEA,IAAI,iBAAiB,SAAS;EAE9B,IAAI,iBAAiB,QAAQ;GAC3B,OAAO,OAAO,QAAQ;GACtB;EACF;EAEA,QAAQ,QAAQ,OAAO,IAAI,EAAE,KAAK,KAAK;CACzC;CAEA,OAAO;AACT;;;;;;;;AASA,MAAa,cAAc;;;;;;;;;;;;;;CAczB,MAAyB;EACvB,OAAO,OAAO,cAAc,CAAC;CAC/B;;;;;;;CAQA,MAAM,QAAmC;EACvC,OAAO,OAAO,MAAM;CACtB;;;;;;;;;;;;;CAcA,IAAc,KAAa,eAAkB,MAAiC;EAC5E,MAAM,MAAM,OAAO,cAAc,CAAC;EAElC,OAAO,OAAO,MAAM,IAAI,OAAO;CACjC;;;;;;;CAQA,WAAmB;EACjB,OAAO,cAAc,cAAc,CAAC;CACtC;AACF"}
|