@expofp/config 3.11.10
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/README.md +35 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/lib/apply-intents.d.ts +10 -0
- package/dist/lib/apply-intents.js +24 -0
- package/dist/lib/config-store.d.ts +6 -0
- package/dist/lib/config-store.js +12 -0
- package/dist/lib/debug-settings.d.ts +16 -0
- package/dist/lib/debug-settings.js +44 -0
- package/dist/lib/json-schema.d.ts +67 -0
- package/dist/lib/json-schema.js +136 -0
- package/dist/lib/legacy-url.d.ts +167 -0
- package/dist/lib/legacy-url.js +322 -0
- package/dist/lib/load-config.d.ts +26 -0
- package/dist/lib/load-config.js +286 -0
- package/dist/lib/local-storage-codec.d.ts +44 -0
- package/dist/lib/local-storage-codec.js +76 -0
- package/dist/lib/normalize-fp-svg.d.ts +26 -0
- package/dist/lib/normalize-fp-svg.js +36 -0
- package/dist/lib/normalize-legacy-data.d.ts +13 -0
- package/dist/lib/normalize-legacy-data.js +90 -0
- package/dist/lib/rebooking.d.ts +19 -0
- package/dist/lib/rebooking.js +48 -0
- package/dist/lib/resource-urls.d.ts +52 -0
- package/dist/lib/resource-urls.js +196 -0
- package/dist/lib/serialize-config-resources.d.ts +22 -0
- package/dist/lib/serialize-config-resources.js +20 -0
- package/dist/lib/strip-defaults.d.ts +27 -0
- package/dist/lib/strip-defaults.js +55 -0
- package/dist/lib/url-codec.d.ts +46 -0
- package/dist/lib/url-codec.js +165 -0
- package/dist/lib/url-intents.d.ts +30 -0
- package/dist/lib/url-intents.js +58 -0
- package/dist/lib/validate-flag.d.ts +10 -0
- package/dist/lib/validate-flag.js +36 -0
- package/dist/lib/validate-timing.d.ts +3 -0
- package/dist/lib/validate-timing.js +52 -0
- package/dist/lib/visit-resources.d.ts +85 -0
- package/dist/lib/visit-resources.js +198 -0
- package/package.json +36 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Return a clone of `schema` with every `.default()` and `.prefault()` removed,
|
|
4
|
+
* recursively.
|
|
5
|
+
*
|
|
6
|
+
* Layered config parses each source (manifest, options, localStorage, URL)
|
|
7
|
+
* against an all-optional schema and merges the results. A default left in
|
|
8
|
+
* those schemas would materialize on *every* layer's parse — and in Zod 4
|
|
9
|
+
* `.partial()` does NOT suppress it — so a higher, empty layer would stomp a
|
|
10
|
+
* lower layer's explicit value. Strip defaults for the per-layer parse and
|
|
11
|
+
* apply them once, afterwards (see `ConfigDefaults`).
|
|
12
|
+
*
|
|
13
|
+
* A stripped field becomes `.optional()`: a default exists precisely because
|
|
14
|
+
* the input may omit the key, so the stripped schema parses a partial layer
|
|
15
|
+
* (missing key → absent) instead of erroring on the now-defaultless field.
|
|
16
|
+
*
|
|
17
|
+
* Recurses through objects, arrays, and the optional/nullable/readonly wrapper
|
|
18
|
+
* chain, carrying each node's `.meta()` across (the `asset`/`cssAsset` tags
|
|
19
|
+
* `DataSchema` relies on). Nodes it does not recognize — unions, records,
|
|
20
|
+
* tuples, lazy, pipes — are returned untouched, so a default nested inside one
|
|
21
|
+
* of those is left in place, and object-level modes/refinements (`.strict()`,
|
|
22
|
+
* `.refine()`, `.min()`, …) on rebuilt containers are not carried over. The
|
|
23
|
+
* config schemas keep defaults at the object top level and use none of those,
|
|
24
|
+
* so the boundary does not bite; widen the checks below if that changes.
|
|
25
|
+
*/
|
|
26
|
+
export declare function stripDefaults(schema: z.core.$ZodType): z.ZodType;
|
|
27
|
+
//# sourceMappingURL=strip-defaults.d.ts.map
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import * as z from 'zod';
|
|
2
|
+
/**
|
|
3
|
+
* Return a clone of `schema` with every `.default()` and `.prefault()` removed,
|
|
4
|
+
* recursively.
|
|
5
|
+
*
|
|
6
|
+
* Layered config parses each source (manifest, options, localStorage, URL)
|
|
7
|
+
* against an all-optional schema and merges the results. A default left in
|
|
8
|
+
* those schemas would materialize on *every* layer's parse — and in Zod 4
|
|
9
|
+
* `.partial()` does NOT suppress it — so a higher, empty layer would stomp a
|
|
10
|
+
* lower layer's explicit value. Strip defaults for the per-layer parse and
|
|
11
|
+
* apply them once, afterwards (see `ConfigDefaults`).
|
|
12
|
+
*
|
|
13
|
+
* A stripped field becomes `.optional()`: a default exists precisely because
|
|
14
|
+
* the input may omit the key, so the stripped schema parses a partial layer
|
|
15
|
+
* (missing key → absent) instead of erroring on the now-defaultless field.
|
|
16
|
+
*
|
|
17
|
+
* Recurses through objects, arrays, and the optional/nullable/readonly wrapper
|
|
18
|
+
* chain, carrying each node's `.meta()` across (the `asset`/`cssAsset` tags
|
|
19
|
+
* `DataSchema` relies on). Nodes it does not recognize — unions, records,
|
|
20
|
+
* tuples, lazy, pipes — are returned untouched, so a default nested inside one
|
|
21
|
+
* of those is left in place, and object-level modes/refinements (`.strict()`,
|
|
22
|
+
* `.refine()`, `.min()`, …) on rebuilt containers are not carried over. The
|
|
23
|
+
* config schemas keep defaults at the object top level and use none of those,
|
|
24
|
+
* so the boundary does not bite; widen the checks below if that changes.
|
|
25
|
+
*/
|
|
26
|
+
export function stripDefaults(schema) {
|
|
27
|
+
if (schema instanceof z.ZodDefault || schema instanceof z.ZodPrefault) {
|
|
28
|
+
return keepMeta(schema, stripDefaults(schema.unwrap()).optional());
|
|
29
|
+
}
|
|
30
|
+
if (schema instanceof z.ZodOptional) {
|
|
31
|
+
return keepMeta(schema, stripDefaults(schema.unwrap()).optional());
|
|
32
|
+
}
|
|
33
|
+
if (schema instanceof z.ZodNullable) {
|
|
34
|
+
return keepMeta(schema, stripDefaults(schema.unwrap()).nullable());
|
|
35
|
+
}
|
|
36
|
+
if (schema instanceof z.ZodReadonly) {
|
|
37
|
+
return keepMeta(schema, stripDefaults(schema.unwrap()).readonly());
|
|
38
|
+
}
|
|
39
|
+
if (schema instanceof z.ZodArray) {
|
|
40
|
+
return keepMeta(schema, z.array(stripDefaults(schema.element)));
|
|
41
|
+
}
|
|
42
|
+
if (schema instanceof z.ZodObject) {
|
|
43
|
+
const shape = {};
|
|
44
|
+
for (const [key, field] of Object.entries(schema.shape)) {
|
|
45
|
+
shape[key] = stripDefaults(field);
|
|
46
|
+
}
|
|
47
|
+
return keepMeta(schema, z.object(shape));
|
|
48
|
+
}
|
|
49
|
+
return schema;
|
|
50
|
+
}
|
|
51
|
+
/** Copy `source`'s registered `.meta()` onto `target`, if it has any. */
|
|
52
|
+
function keepMeta(source, target) {
|
|
53
|
+
const meta = source.meta();
|
|
54
|
+
return meta ? target.meta(meta) : target;
|
|
55
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL query codec — value ⇄ `qs`-style bracketed query string, with coercion
|
|
3
|
+
* driven by an ordinary Zod schema (no `z.coerce`, nothing URL-specific in it):
|
|
4
|
+
*
|
|
5
|
+
* parseFromUrl(schema, input) URL or query string → validated value
|
|
6
|
+
* toQueryString(value) value → query string
|
|
7
|
+
*
|
|
8
|
+
* Decoding is two steps:
|
|
9
|
+
* 1. deflatten the bracketed query into a nested structure of strings:
|
|
10
|
+
* view[center][lat]=10 → { view: { center: { lat: '10' } } }
|
|
11
|
+
* tags[0]=a&tags[1]=b → { tags: ['a', 'b'] }
|
|
12
|
+
* exhibitors[0][id]=1 → { exhibitors: [ { id: '1' } ] }
|
|
13
|
+
* Numeric indices are honored up to `qs`'s default `arrayLimit` (20);
|
|
14
|
+
* above that the segment stays an object key, so a crafted
|
|
15
|
+
* `?intents[999999999]=x` cannot inflate a billion-slot sparse array.
|
|
16
|
+
* 2. walk the schema (`z.toJSONSchema`) alongside that structure, coerce each
|
|
17
|
+
* leaf to its declared type (string→number/boolean/…), then `schema.parse`.
|
|
18
|
+
*
|
|
19
|
+
* Encoding inverts step 1: nested value → bracketed keys (arrays use numeric
|
|
20
|
+
* indices). Round-trips for scalars, nested objects, scalar arrays and arrays of
|
|
21
|
+
* objects, provided you decode with the SAME schema. A discriminated union
|
|
22
|
+
* coerces by the arm its discriminator names (so `intents[…]` args get their
|
|
23
|
+
* types); other unions are left as-is for `schema.parse` to handle.
|
|
24
|
+
*
|
|
25
|
+
* The bracket syntax matches the widely-used `qs` library, so these query
|
|
26
|
+
* strings interop with `qs.parse` / `qs.stringify` and anything built on them.
|
|
27
|
+
*/
|
|
28
|
+
import type * as z from 'zod';
|
|
29
|
+
export declare function parseFromUrl<S extends z.ZodType>(schema: S, input: string | URL): z.infer<S>;
|
|
30
|
+
export declare function safeParseFromUrl<S extends z.ZodType>(schema: S, input: string | URL): z.ZodSafeParseResult<z.core.output<S>>;
|
|
31
|
+
/**
|
|
32
|
+
* Like `parseFromUrl`, but drops the query keys that fail validation instead
|
|
33
|
+
* of throwing — a visitor-edited URL must never break the caller. Expects an
|
|
34
|
+
* all-optional object schema (which every per-source config slice is), so the
|
|
35
|
+
* parse is guaranteed to succeed once the offending keys are gone; anything
|
|
36
|
+
* not attributable to a top-level key still throws (a programming error, not
|
|
37
|
+
* URL noise).
|
|
38
|
+
*/
|
|
39
|
+
export declare function parseFromUrlTolerant<S extends z.ZodType>(schema: S, input: string | URL): {
|
|
40
|
+
value: z.infer<S>;
|
|
41
|
+
invalidKeys: string[];
|
|
42
|
+
};
|
|
43
|
+
/** URL or query string → the deflattened structure, keys in first-occurrence order. */
|
|
44
|
+
export declare function deflattenQuery(input: string | URL): Record<string, unknown>;
|
|
45
|
+
export declare function toQueryString(value: unknown): string;
|
|
46
|
+
//# sourceMappingURL=url-codec.d.ts.map
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
import { coerceValue, toJsonSchema } from './json-schema.js';
|
|
2
|
+
/* ── Decode: URL → value ───────────────────────────────────────────────────── */
|
|
3
|
+
export function parseFromUrl(schema, input) {
|
|
4
|
+
return schema.parse(coerceToSchema(schema, input));
|
|
5
|
+
}
|
|
6
|
+
export function safeParseFromUrl(schema, input) {
|
|
7
|
+
return schema.safeParse(coerceToSchema(schema, input));
|
|
8
|
+
}
|
|
9
|
+
/**
|
|
10
|
+
* Like `parseFromUrl`, but drops the query keys that fail validation instead
|
|
11
|
+
* of throwing — a visitor-edited URL must never break the caller. Expects an
|
|
12
|
+
* all-optional object schema (which every per-source config slice is), so the
|
|
13
|
+
* parse is guaranteed to succeed once the offending keys are gone; anything
|
|
14
|
+
* not attributable to a top-level key still throws (a programming error, not
|
|
15
|
+
* URL noise).
|
|
16
|
+
*/
|
|
17
|
+
export function parseFromUrlTolerant(schema, input) {
|
|
18
|
+
const candidate = coerceToSchema(schema, input);
|
|
19
|
+
const invalidKeys = [];
|
|
20
|
+
let result = schema.safeParse(candidate);
|
|
21
|
+
while (!result.success) {
|
|
22
|
+
const offending = new Set();
|
|
23
|
+
for (const issue of result.error.issues) {
|
|
24
|
+
const key = String(issue.path[0] ?? '');
|
|
25
|
+
if (key in candidate)
|
|
26
|
+
offending.add(key);
|
|
27
|
+
}
|
|
28
|
+
if (!offending.size)
|
|
29
|
+
throw result.error;
|
|
30
|
+
for (const key of offending) {
|
|
31
|
+
delete candidate[key];
|
|
32
|
+
invalidKeys.push(key);
|
|
33
|
+
}
|
|
34
|
+
result = schema.safeParse(candidate);
|
|
35
|
+
}
|
|
36
|
+
return { value: result.data, invalidKeys };
|
|
37
|
+
}
|
|
38
|
+
function coerceToSchema(schema, input) {
|
|
39
|
+
return coerceValue(toJsonSchema(schema), deflatten(toSearchParams(input)));
|
|
40
|
+
}
|
|
41
|
+
/* ── Deflatten: bracketed keys → nested objects/arrays of strings (schema-blind) ── */
|
|
42
|
+
/** URL or query string → the deflattened structure, keys in first-occurrence order. */
|
|
43
|
+
export function deflattenQuery(input) {
|
|
44
|
+
return deflatten(toSearchParams(input));
|
|
45
|
+
}
|
|
46
|
+
function deflatten(params) {
|
|
47
|
+
const root = {};
|
|
48
|
+
for (const [rawKey, value] of params) {
|
|
49
|
+
assignPath(root, parseKey(rawKey), value);
|
|
50
|
+
}
|
|
51
|
+
return root;
|
|
52
|
+
}
|
|
53
|
+
/** "a[b][c]" → ["a","b","c"] · "a[]" → ["a",""] · "a[0]" → ["a","0"] · "a" → ["a"]. */
|
|
54
|
+
function parseKey(key) {
|
|
55
|
+
return key.replace(/\]/g, '').split('[');
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Prototype-chain keys, never valid config keys. A query like
|
|
59
|
+
* `?__proto__[x]=1` or `?constructor[prototype][x]=1` would otherwise walk into
|
|
60
|
+
* `Object.prototype` / `Function.prototype` and pollute every object on the
|
|
61
|
+
* page — so any path that names one is dropped whole.
|
|
62
|
+
*/
|
|
63
|
+
const FORBIDDEN_KEYS = new Set(['__proto__', 'constructor', 'prototype']);
|
|
64
|
+
/**
|
|
65
|
+
* `qs`'s default `arrayLimit`: the largest bracket segment still treated as an
|
|
66
|
+
* array index. Above it the segment stays an object key — an unchecked index
|
|
67
|
+
* would set `length` directly, and `coerceValue`/Zod then iterate that length
|
|
68
|
+
* (`?intents[999999999]=x` → a billion-slot walk, twice per URL boot).
|
|
69
|
+
*/
|
|
70
|
+
const ARRAY_INDEX_LIMIT = 20;
|
|
71
|
+
function isArrayIndex(segment) {
|
|
72
|
+
return /^\d+$/.test(segment) && Number(segment) <= ARRAY_INDEX_LIMIT;
|
|
73
|
+
}
|
|
74
|
+
function assignPath(root, path, value) {
|
|
75
|
+
let parent = root;
|
|
76
|
+
let parentKey = '';
|
|
77
|
+
let node = root;
|
|
78
|
+
for (let i = 0; i < path.length; i++) {
|
|
79
|
+
const segment = path[i];
|
|
80
|
+
if (FORBIDDEN_KEYS.has(segment))
|
|
81
|
+
return;
|
|
82
|
+
const isLast = i === path.length - 1;
|
|
83
|
+
const append = segment === '';
|
|
84
|
+
// an array addressed by anything but a capped index or an append is
|
|
85
|
+
// demoted to a plain object first (qs semantics) — writing the key into
|
|
86
|
+
// the array would otherwise grow `length` without bound
|
|
87
|
+
if (Array.isArray(node) && !append && !isArrayIndex(segment)) {
|
|
88
|
+
node = demoteArray(node, parent, parentKey);
|
|
89
|
+
}
|
|
90
|
+
if (isLast) {
|
|
91
|
+
if (append && Array.isArray(node)) {
|
|
92
|
+
node.push(value);
|
|
93
|
+
}
|
|
94
|
+
else {
|
|
95
|
+
node[segment] = value;
|
|
96
|
+
}
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
const next = path[i + 1];
|
|
100
|
+
const childIsArray = next === '' || isArrayIndex(next);
|
|
101
|
+
if (append && Array.isArray(node)) {
|
|
102
|
+
const child = childIsArray ? [] : {};
|
|
103
|
+
node.push(child);
|
|
104
|
+
parent = node;
|
|
105
|
+
parentKey = String(node.length - 1);
|
|
106
|
+
node = child;
|
|
107
|
+
}
|
|
108
|
+
else {
|
|
109
|
+
const container = node;
|
|
110
|
+
const current = container[segment];
|
|
111
|
+
// an earlier `?key=value` entry may have parked a scalar here — replace
|
|
112
|
+
// it with a container (last writer wins, same as the reverse order);
|
|
113
|
+
// descending into a string primitive would throw on assignment
|
|
114
|
+
if (current === null || typeof current !== 'object') {
|
|
115
|
+
container[segment] = childIsArray ? [] : {};
|
|
116
|
+
}
|
|
117
|
+
parent = node;
|
|
118
|
+
parentKey = segment;
|
|
119
|
+
node = container[segment];
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
/** Copy an array's entries onto a plain object (indices become keys) and swap it into the parent. */
|
|
124
|
+
function demoteArray(arr, parent, parentKey) {
|
|
125
|
+
const demoted = {};
|
|
126
|
+
arr.forEach((item, index) => {
|
|
127
|
+
demoted[index] = item;
|
|
128
|
+
});
|
|
129
|
+
parent[parentKey] = demoted;
|
|
130
|
+
return demoted;
|
|
131
|
+
}
|
|
132
|
+
/* ── Encode: value → bracketed query string (inverts deflatten) ─────────────── */
|
|
133
|
+
export function toQueryString(value) {
|
|
134
|
+
const params = new URLSearchParams();
|
|
135
|
+
encodeNode(value, '', params);
|
|
136
|
+
return params.toString();
|
|
137
|
+
}
|
|
138
|
+
function encodeNode(value, prefix, params) {
|
|
139
|
+
if (value === undefined || value === null) {
|
|
140
|
+
return;
|
|
141
|
+
}
|
|
142
|
+
if (Array.isArray(value)) {
|
|
143
|
+
value.forEach((item, index) => encodeNode(item, `${prefix}[${index}]`, params));
|
|
144
|
+
return;
|
|
145
|
+
}
|
|
146
|
+
if (typeof value === 'object') {
|
|
147
|
+
for (const [key, child] of Object.entries(value)) {
|
|
148
|
+
encodeNode(child, prefix ? `${prefix}[${key}]` : key, params);
|
|
149
|
+
}
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// objects/arrays/null returned above, so `value` is a scalar here
|
|
153
|
+
// eslint-disable-next-line @typescript-eslint/no-base-to-string -- value is a scalar, not an object
|
|
154
|
+
params.append(prefix, String(value));
|
|
155
|
+
}
|
|
156
|
+
/** Accepts a URL instance, a full URL string, or a bare query (leading `?` optional). */
|
|
157
|
+
function toSearchParams(input) {
|
|
158
|
+
if (input instanceof URL) {
|
|
159
|
+
return input.searchParams;
|
|
160
|
+
}
|
|
161
|
+
const q = input.indexOf('?');
|
|
162
|
+
const afterQuery = q >= 0 ? input.slice(q + 1) : input;
|
|
163
|
+
const hash = afterQuery.indexOf('#');
|
|
164
|
+
return new URLSearchParams(hash >= 0 ? afterQuery.slice(0, hash) : afterQuery);
|
|
165
|
+
}
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL intent shortcuts — the typable counterpart to the canonical bracketed
|
|
3
|
+
* form (`?intents[0][name]=selectBooth&intents[0][args][0]=A-12`): the intent
|
|
4
|
+
* name is the query key, its args are the value.
|
|
5
|
+
*
|
|
6
|
+
* ?selectBooth=A-12 one intent, a lone value is its 1-tuple args
|
|
7
|
+
* ?selectBooth=A-12&changeLanguage=fr two intents, played in param order
|
|
8
|
+
* ?selectRoute[0]=A-12&selectRoute[1]=B-30 qs brackets — here one waypoint-list argument
|
|
9
|
+
* ?showPathway[0]=p-1&showPathway[1][0]=e-1 …or a real multi-argument tuple
|
|
10
|
+
* ?openPlanner[fromBookmarks]=true args coerce to the arm's declared types
|
|
11
|
+
*
|
|
12
|
+
* The key set is the `IntentsSchema` arm names, so a new intent gets its
|
|
13
|
+
* shortcut automatically and the schema stays the URL allowlist — a query can
|
|
14
|
+
* never name a method outside the union. One shortcut per intent name (a
|
|
15
|
+
* repeated key keeps the last value, plain qs semantics); the bracketed
|
|
16
|
+
* `intents[…]` form remains the general syntax for repeats and exotic args.
|
|
17
|
+
*/
|
|
18
|
+
import { type Intent } from '@expofp/schema';
|
|
19
|
+
/**
|
|
20
|
+
* Extract shortcut intents from a page URL, in first-occurrence param order.
|
|
21
|
+
* Tolerant like `parseFromUrlTolerant`: a key whose value does not fit its
|
|
22
|
+
* intent's args is dropped and reported in `invalidKeys` — a visitor-edited
|
|
23
|
+
* URL must never break boot. Keys naming no intent are ignored (they belong
|
|
24
|
+
* to other query consumers).
|
|
25
|
+
*/
|
|
26
|
+
export declare function parseIntentsFromUrl(input: string | URL): {
|
|
27
|
+
intents: Intent[];
|
|
28
|
+
invalidKeys: string[];
|
|
29
|
+
};
|
|
30
|
+
//# sourceMappingURL=url-intents.d.ts.map
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* URL intent shortcuts — the typable counterpart to the canonical bracketed
|
|
3
|
+
* form (`?intents[0][name]=selectBooth&intents[0][args][0]=A-12`): the intent
|
|
4
|
+
* name is the query key, its args are the value.
|
|
5
|
+
*
|
|
6
|
+
* ?selectBooth=A-12 one intent, a lone value is its 1-tuple args
|
|
7
|
+
* ?selectBooth=A-12&changeLanguage=fr two intents, played in param order
|
|
8
|
+
* ?selectRoute[0]=A-12&selectRoute[1]=B-30 qs brackets — here one waypoint-list argument
|
|
9
|
+
* ?showPathway[0]=p-1&showPathway[1][0]=e-1 …or a real multi-argument tuple
|
|
10
|
+
* ?openPlanner[fromBookmarks]=true args coerce to the arm's declared types
|
|
11
|
+
*
|
|
12
|
+
* The key set is the `IntentsSchema` arm names, so a new intent gets its
|
|
13
|
+
* shortcut automatically and the schema stays the URL allowlist — a query can
|
|
14
|
+
* never name a method outside the union. One shortcut per intent name (a
|
|
15
|
+
* repeated key keeps the last value, plain qs semantics); the bracketed
|
|
16
|
+
* `intents[…]` form remains the general syntax for repeats and exotic args.
|
|
17
|
+
*/
|
|
18
|
+
import { IntentsSchema } from '@expofp/schema';
|
|
19
|
+
import { coerceValue, toJsonSchema } from './json-schema.js';
|
|
20
|
+
import { deflattenQuery } from './url-codec.js';
|
|
21
|
+
const intentNames = new Set(IntentsSchema.options.map((arm) => arm.shape.name.value));
|
|
22
|
+
/**
|
|
23
|
+
* Extract shortcut intents from a page URL, in first-occurrence param order.
|
|
24
|
+
* Tolerant like `parseFromUrlTolerant`: a key whose value does not fit its
|
|
25
|
+
* intent's args is dropped and reported in `invalidKeys` — a visitor-edited
|
|
26
|
+
* URL must never break boot. Keys naming no intent are ignored (they belong
|
|
27
|
+
* to other query consumers).
|
|
28
|
+
*/
|
|
29
|
+
export function parseIntentsFromUrl(input) {
|
|
30
|
+
const intents = [];
|
|
31
|
+
const invalidKeys = [];
|
|
32
|
+
for (const [name, value] of Object.entries(deflattenQuery(input))) {
|
|
33
|
+
if (!intentNames.has(name))
|
|
34
|
+
continue;
|
|
35
|
+
// A bare `?key` is an empty args tuple. Anything else is ambiguous between
|
|
36
|
+
// the args TUPLE and a single array-valued ARGUMENT: `?selectBooth[0]=A`
|
|
37
|
+
// is a 1-tuple, `?selectRoute[0]=A&selectRoute[1]=B` one waypoint list.
|
|
38
|
+
// Try the tuple reading first (existing shortcuts keep their meaning),
|
|
39
|
+
// then the same value wrapped as the tuple's sole argument; the first
|
|
40
|
+
// candidate that validates wins. Only this function knows about the
|
|
41
|
+
// ambiguity — the schema and the url-codec never see it.
|
|
42
|
+
const asTuple = Array.isArray(value) ? value : [value];
|
|
43
|
+
const candidates = value === '' ? [[]] : [asTuple, [asTuple]];
|
|
44
|
+
// args arrive as raw query strings — coerce them by the schema of the arm
|
|
45
|
+
// `name` picks (booleans/numbers inside object args), exactly like the
|
|
46
|
+
// bracketed `intents[…]` form going through the url-codec
|
|
47
|
+
const parsed = candidates
|
|
48
|
+
.map((args) => IntentsSchema.safeParse(coerceValue(toJsonSchema(IntentsSchema), { name, args })))
|
|
49
|
+
.find((result) => result.success);
|
|
50
|
+
if (parsed) {
|
|
51
|
+
intents.push(parsed.data);
|
|
52
|
+
}
|
|
53
|
+
else {
|
|
54
|
+
invalidKeys.push(name);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
return { intents, invalidKeys };
|
|
58
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type StorageLike } from './local-storage-codec.js';
|
|
2
|
+
/**
|
|
3
|
+
* Whether to validate schemas during this load. Reads `validateSchema` from the
|
|
4
|
+
* URL query (`?validateSchema=1`) then localStorage (`efp-validateSchema`), the
|
|
5
|
+
* URL winning; absent from both, off. A URL value other than `1/true/yes/on`
|
|
6
|
+
* reads as an explicit off (and overrides localStorage). Node callers (the
|
|
7
|
+
* offline CLI) pass neither and always get `false`.
|
|
8
|
+
*/
|
|
9
|
+
export declare function readValidateFlag(url?: string, storage?: StorageLike): boolean;
|
|
10
|
+
//# sourceMappingURL=validate-flag.d.ts.map
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The `validateSchema` opt-in, read up front from the page inputs.
|
|
3
|
+
*
|
|
4
|
+
* Schema validation on load — the manifest in `load-config.ts` and every
|
|
5
|
+
* referenced document in `visit-resources.ts` — is a diagnostic gate: the
|
|
6
|
+
* pipeline never depends on the parse output, so it is OFF by default because
|
|
7
|
+
* parsing the large expo documents costs time. This reads the opt-in from the
|
|
8
|
+
* same URL-query / localStorage layering every config flag uses (the shared
|
|
9
|
+
* codecs and the `validateSchema` field on `UrlConfigSchema` /
|
|
10
|
+
* `LocalStorageConfigSchema`), the URL winning over localStorage.
|
|
11
|
+
*
|
|
12
|
+
* It is read HERE, before the config layers are assembled, rather than off the
|
|
13
|
+
* effective config, because the parses it gates run first — the manifest parse
|
|
14
|
+
* is `loadConfig`'s opening step, and the document parses fire while the layers
|
|
15
|
+
* are still being fetched. For the same reason it does not honor `ignoreQuery`
|
|
16
|
+
* (that value is not settled yet): `?validateSchema=1` turns validation on even
|
|
17
|
+
* for an embed that otherwise isolates itself from the page URL, which is what a
|
|
18
|
+
* developer reaching for this diagnostic wants.
|
|
19
|
+
*/
|
|
20
|
+
import { LocalStorageConfigSchema, UrlConfigSchema } from '@expofp/schema';
|
|
21
|
+
import { parseFromStorage } from './local-storage-codec.js';
|
|
22
|
+
import { parseFromUrlTolerant } from './url-codec.js';
|
|
23
|
+
/**
|
|
24
|
+
* Whether to validate schemas during this load. Reads `validateSchema` from the
|
|
25
|
+
* URL query (`?validateSchema=1`) then localStorage (`efp-validateSchema`), the
|
|
26
|
+
* URL winning; absent from both, off. A URL value other than `1/true/yes/on`
|
|
27
|
+
* reads as an explicit off (and overrides localStorage). Node callers (the
|
|
28
|
+
* offline CLI) pass neither and always get `false`.
|
|
29
|
+
*/
|
|
30
|
+
export function readValidateFlag(url, storage) {
|
|
31
|
+
const fromUrl = url !== undefined ? parseFromUrlTolerant(UrlConfigSchema, url).value.validateSchema : undefined;
|
|
32
|
+
const fromStorage = storage
|
|
33
|
+
? parseFromStorage(LocalStorageConfigSchema, storage).validateSchema
|
|
34
|
+
: undefined;
|
|
35
|
+
return fromUrl ?? fromStorage ?? false;
|
|
36
|
+
}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import debug from 'debug';
|
|
2
|
+
/**
|
|
3
|
+
* Timing for the schema validations gated by the opt-in `validateSchema` flag
|
|
4
|
+
* (the manifest in `load-config.ts`, every referenced document in
|
|
5
|
+
* `visit-resources.ts`).
|
|
6
|
+
*
|
|
7
|
+
* {@link timeValidation} wraps one `parse()`, folds its cost into a running
|
|
8
|
+
* total kept since the module first loaded (i.e. per page load), and logs a
|
|
9
|
+
* cumulative summary on `efp:config:validate`. Logging is throttled to at most
|
|
10
|
+
* one line per second — a burst of document validations emits one leading line,
|
|
11
|
+
* then a trailing line once it settles carries the final total. When the
|
|
12
|
+
* namespace is disabled (the production default) the wrapper is a straight
|
|
13
|
+
* pass-through, so the cost is a single boolean check.
|
|
14
|
+
*/
|
|
15
|
+
const log = debug('efp:config:validate');
|
|
16
|
+
let totalMs = 0;
|
|
17
|
+
let count = 0;
|
|
18
|
+
// -Infinity so the first validation always clears the 1s gate and emits a
|
|
19
|
+
// leading line, however far into the page's life it runs.
|
|
20
|
+
let lastEmitMs = -Infinity;
|
|
21
|
+
let trailingScheduled = false;
|
|
22
|
+
function emit() {
|
|
23
|
+
lastEmitMs = performance.now();
|
|
24
|
+
log('%d validations, %sms total since page load', count, totalMs.toFixed(1));
|
|
25
|
+
}
|
|
26
|
+
/** Time one schema `parse()` and record it against the running summary. */
|
|
27
|
+
export function timeValidation(parse) {
|
|
28
|
+
if (!log.enabled)
|
|
29
|
+
return parse();
|
|
30
|
+
const start = performance.now();
|
|
31
|
+
try {
|
|
32
|
+
return parse();
|
|
33
|
+
}
|
|
34
|
+
finally {
|
|
35
|
+
totalMs += performance.now() - start;
|
|
36
|
+
count++;
|
|
37
|
+
const sinceEmit = performance.now() - lastEmitMs;
|
|
38
|
+
if (sinceEmit >= 1000) {
|
|
39
|
+
emit();
|
|
40
|
+
}
|
|
41
|
+
else if (!trailingScheduled) {
|
|
42
|
+
// Flush the final total after the burst settles, without holding a Node
|
|
43
|
+
// process open (unref is a no-op on the browser's numeric timer id).
|
|
44
|
+
trailingScheduled = true;
|
|
45
|
+
const timer = setTimeout(() => {
|
|
46
|
+
trailingScheduled = false;
|
|
47
|
+
emit();
|
|
48
|
+
}, 1000 - sinceEmit);
|
|
49
|
+
timer.unref?.();
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* visit-resources — the one schema-guided resource walk, used for both jobs:
|
|
3
|
+
*
|
|
4
|
+
* - **Absolutization** (`documentRef`): every ref the config mints carries a
|
|
5
|
+
* `process` hook = "validate with my schema, then walk me with a handler
|
|
6
|
+
* that rebases relative paths onto the ref's own directory". Resolution
|
|
7
|
+
* therefore always settles documents whose asset URLs are absolute.
|
|
8
|
+
* - **Serialization** (`serializeConfigResources`): the same walk over the
|
|
9
|
+
* whole config with a storage handler — and a `$ref` is just another kind
|
|
10
|
+
* of asset, one whose handling resolves the document and recurses into it.
|
|
11
|
+
*
|
|
12
|
+
* The walk is value-driven and schema-guided: meta tags say what a string
|
|
13
|
+
* leaf holds (`asset`, `svgAsset`, `cssAsset`), and a
|
|
14
|
+
* `{ $ref }` object is an external document (its node's `refSchema` meta
|
|
15
|
+
* points at the document's schema).
|
|
16
|
+
* Which behaviors fire falls out of which hooks the handler provides — a
|
|
17
|
+
* handler without `document` leaves refs alone. `value` is mutated in place
|
|
18
|
+
* and must be an object or array.
|
|
19
|
+
*/
|
|
20
|
+
import { type Ref } from '@expofp/resolve';
|
|
21
|
+
import type * as z from 'zod';
|
|
22
|
+
export interface ResourceHandler {
|
|
23
|
+
/**
|
|
24
|
+
* One asset URL; return the URL to write in its place. `optional` marks an
|
|
25
|
+
* `optionalAsset`-tagged leaf — a URL minted without an existence check, so
|
|
26
|
+
* the resource may not exist and storing it is best-effort.
|
|
27
|
+
*/
|
|
28
|
+
asset(url: string, optional?: boolean): string | Promise<string>;
|
|
29
|
+
/** A ref's resolved — and internally rewritten — document; return the new ref URL. */
|
|
30
|
+
document?(refUrl: string, document: unknown): string | Promise<string>;
|
|
31
|
+
/**
|
|
32
|
+
* Tolerate a ref that failed to resolve — the field is dropped. Omitted,
|
|
33
|
+
* the failure propagates and aborts the walk; throw from here to abort
|
|
34
|
+
* selectively.
|
|
35
|
+
*/
|
|
36
|
+
documentError?(refUrl: string, error: unknown): void;
|
|
37
|
+
/**
|
|
38
|
+
* Fetch one `@import`ed stylesheet's text so the walk can splice it into the
|
|
39
|
+
* importing CSS (see `inlineCssImports` — a stylesheet kept as a fetched
|
|
40
|
+
* file would be served with the wrong MIME type, its inner `url(…)`s left
|
|
41
|
+
* remote). Omitted, `@import` URLs are treated as opaque assets — right for
|
|
42
|
+
* live absolutization, where the browser fetches them itself.
|
|
43
|
+
*/
|
|
44
|
+
stylesheetText?(url: string): string | Promise<string>;
|
|
45
|
+
}
|
|
46
|
+
export interface VisitResourcesOptions {
|
|
47
|
+
signal?: AbortSignal | null;
|
|
48
|
+
}
|
|
49
|
+
/** Set by `loadConfig` from the `validateSchema` flag; gates the parses below. */
|
|
50
|
+
export declare function setValidateManifest(on: boolean): void;
|
|
51
|
+
/**
|
|
52
|
+
* An own, mutable copy of a document — validated when the flag is on.
|
|
53
|
+
*
|
|
54
|
+
* Every consumer of a resolved document needs its own copy anyway (`resolve`
|
|
55
|
+
* deep-freezes what it settles, and downstream code mutates in place), and a
|
|
56
|
+
* Zod parse of these loose, transform-free schemas already IS a fresh copy.
|
|
57
|
+
* So validation rides the copy that had to be made regardless: parse when
|
|
58
|
+
* validating, `deepClone` when not — one copy either way, never two.
|
|
59
|
+
*/
|
|
60
|
+
export declare function validateOrClone<T>(schema: z.ZodType, value: T): T;
|
|
61
|
+
/**
|
|
62
|
+
* Decorate a ref with the one processing rule for referenced documents: take
|
|
63
|
+
* an own copy — validated against `schema` when the flag is on (see
|
|
64
|
+
* `validateOrClone`) — then make every schema-tagged resource path absolute
|
|
65
|
+
* against the ref's own directory — the standard web rule for a live load.
|
|
66
|
+
* An optional `normalize` hook runs on the copy in between: it folds legacy
|
|
67
|
+
* shape quirks onto the canonical keys, so the schema-guided walk (and every
|
|
68
|
+
* consumer) sees the canonical shape (e.g. `normalizeFpSvgLayerAliases`).
|
|
69
|
+
*
|
|
70
|
+
* An offline copy instead references its saved documents by *relative*
|
|
71
|
+
* (page-relative, host-less) paths, and their assets already carry final
|
|
72
|
+
* serialized paths. Rebasing one of those onto the doc's own relative
|
|
73
|
+
* directory would double the path (`…/<doc-dir>/<doc-dir>/asset` → 404), so
|
|
74
|
+
* absolutization is skipped whenever the ref's directory is not itself
|
|
75
|
+
* absolute — the runtime then resolves the page-relative asset against the
|
|
76
|
+
* page, wherever the copy is mounted. (A rooted `/…` offline ref stays
|
|
77
|
+
* absolute, and its already-rooted assets pass through unchanged either way.)
|
|
78
|
+
*
|
|
79
|
+
* The rewrite runs on an own copy, so it never touches the fetch-cached raw
|
|
80
|
+
* document.
|
|
81
|
+
*/
|
|
82
|
+
export declare function documentRef<R extends Ref<unknown>>(ref: R, schema: z.ZodType, normalize?: (document: object) => void): R;
|
|
83
|
+
/** Rewrite every schema-tagged resource of `value` through `handler`, in place. */
|
|
84
|
+
export declare function visitResources(schema: z.ZodType, value: object, handler: ResourceHandler, options?: VisitResourcesOptions): Promise<void>;
|
|
85
|
+
//# sourceMappingURL=visit-resources.d.ts.map
|