@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.
Files changed (40) hide show
  1. package/README.md +35 -0
  2. package/dist/index.d.ts +11 -0
  3. package/dist/index.js +8 -0
  4. package/dist/lib/apply-intents.d.ts +10 -0
  5. package/dist/lib/apply-intents.js +24 -0
  6. package/dist/lib/config-store.d.ts +6 -0
  7. package/dist/lib/config-store.js +12 -0
  8. package/dist/lib/debug-settings.d.ts +16 -0
  9. package/dist/lib/debug-settings.js +44 -0
  10. package/dist/lib/json-schema.d.ts +67 -0
  11. package/dist/lib/json-schema.js +136 -0
  12. package/dist/lib/legacy-url.d.ts +167 -0
  13. package/dist/lib/legacy-url.js +322 -0
  14. package/dist/lib/load-config.d.ts +26 -0
  15. package/dist/lib/load-config.js +286 -0
  16. package/dist/lib/local-storage-codec.d.ts +44 -0
  17. package/dist/lib/local-storage-codec.js +76 -0
  18. package/dist/lib/normalize-fp-svg.d.ts +26 -0
  19. package/dist/lib/normalize-fp-svg.js +36 -0
  20. package/dist/lib/normalize-legacy-data.d.ts +13 -0
  21. package/dist/lib/normalize-legacy-data.js +90 -0
  22. package/dist/lib/rebooking.d.ts +19 -0
  23. package/dist/lib/rebooking.js +48 -0
  24. package/dist/lib/resource-urls.d.ts +52 -0
  25. package/dist/lib/resource-urls.js +196 -0
  26. package/dist/lib/serialize-config-resources.d.ts +22 -0
  27. package/dist/lib/serialize-config-resources.js +20 -0
  28. package/dist/lib/strip-defaults.d.ts +27 -0
  29. package/dist/lib/strip-defaults.js +55 -0
  30. package/dist/lib/url-codec.d.ts +46 -0
  31. package/dist/lib/url-codec.js +165 -0
  32. package/dist/lib/url-intents.d.ts +30 -0
  33. package/dist/lib/url-intents.js +58 -0
  34. package/dist/lib/validate-flag.d.ts +10 -0
  35. package/dist/lib/validate-flag.js +36 -0
  36. package/dist/lib/validate-timing.d.ts +3 -0
  37. package/dist/lib/validate-timing.js +52 -0
  38. package/dist/lib/visit-resources.d.ts +85 -0
  39. package/dist/lib/visit-resources.js +198 -0
  40. 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,3 @@
1
+ /** Time one schema `parse()` and record it against the running summary. */
2
+ export declare function timeValidation<T>(parse: () => T): T;
3
+ //# sourceMappingURL=validate-timing.d.ts.map
@@ -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