@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
package/README.md
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# @expofp/config
|
|
2
|
+
|
|
3
|
+
The effective-config layer for the ExpoFP SDK. `loadConfig()` assembles the runtime's single copy of
|
|
4
|
+
the expo data by layering manifest (+ legacy `data.js`) → options → localStorage → URL, resolving
|
|
5
|
+
`$ref`s via `@expofp/resolve` against `@expofp/schema`; `setConfig`/`getConfig` hold the result.
|
|
6
|
+
|
|
7
|
+
```ts
|
|
8
|
+
setConfig(await loadConfig(manifestUrl));
|
|
9
|
+
const config = getConfig();
|
|
10
|
+
```
|
|
11
|
+
|
|
12
|
+
**Schema validation** of the manifest and referenced documents (`data.js`, drawing, wayfinding) is an
|
|
13
|
+
opt-in diagnostic — off by default (parsing the large expo documents costs time). Enable it per page
|
|
14
|
+
load with `?validateSchema=1` in the URL or `efp-validateSchema=true` in localStorage (also a
|
|
15
|
+
`@expofp/debug` panel toggle); timing for the parses it turns on logs on the `efp:config:validate`
|
|
16
|
+
namespace. It is a load-time input only — the effective config never carries it.
|
|
17
|
+
|
|
18
|
+
**Intents from the URL** ride the query in two forms, merged canonical-first: the bracketed array
|
|
19
|
+
(`?intents[0][name]=selectBooth&intents[0][args][0]=A-12`) and a typable shortcut — the intent name
|
|
20
|
+
as the key (`?selectBooth=A-12`, `?changeLanguage=fr`; multi-arg via `?name[0]=…&name[1]=…`). The
|
|
21
|
+
shortcut keys are the `IntentsSchema` arm names, so the schema stays the URL allowlist; one shortcut
|
|
22
|
+
per intent name, and a value that doesn't fit its intent is dropped (a visitor-edited URL never
|
|
23
|
+
breaks boot).
|
|
24
|
+
|
|
25
|
+
**The legacy URL grammar** (`legacy-url.ts`) is the compact deep-link language the floor plan has
|
|
26
|
+
always spoken — `?<slug>`, `?route:B-12:A-3:false`, `?tour=t1`, `?planner=a:b&from=c`, one-shot
|
|
27
|
+
params like `?blue-dot=…`. `parseLegacyQuery` turns a raw `location.search` into typed commands and
|
|
28
|
+
`serializeSelection` writes a selection back in the same grammar (round-trip covered by tests), so
|
|
29
|
+
shared links stay bidirectional. The floor plan's `url-dispatch` service binds the commands to
|
|
30
|
+
stores and public methods; this codec stays pure — no DOM, no stores.
|
|
31
|
+
|
|
32
|
+
Also: `applyIntents` (dispatch `selectBooth` / `changeLanguage` / … to the floor plan),
|
|
33
|
+
`serializeConfigResources` (collect schema-tagged assets + payloads for offline copies), and
|
|
34
|
+
`toDebugSettings` (config schema → `@expofp/debug` panel). Types re-export from `@expofp/schema`.
|
|
35
|
+
Build/test: `nx build config` · `nx test config`.
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { applyIntents } from './lib/apply-intents.js';
|
|
2
|
+
export { getConfig, setConfig } from './lib/config-store.js';
|
|
3
|
+
export { type DebugSettingDescriptor, toDebugSettings } from './lib/debug-settings.js';
|
|
4
|
+
export { type LegacyOneShot, type LegacyPrimary, type LegacyUrlQuery, parseLegacyQuery, serializeSelection, type UrlSelection, } from './lib/legacy-url.js';
|
|
5
|
+
export { loadConfig } from './lib/load-config.js';
|
|
6
|
+
export { type StorageLike } from './lib/local-storage-codec.js';
|
|
7
|
+
export { type ConfigResourceStore, serializeConfigResources, } from './lib/serialize-config-resources.js';
|
|
8
|
+
export { parseFromUrlTolerant } from './lib/url-codec.js';
|
|
9
|
+
export { parseIntentsFromUrl } from './lib/url-intents.js';
|
|
10
|
+
export { type Config, type Consent, type Manifest, type Options } from '@expofp/schema';
|
|
11
|
+
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
export { applyIntents } from './lib/apply-intents.js';
|
|
2
|
+
export { getConfig, setConfig } from './lib/config-store.js';
|
|
3
|
+
export { toDebugSettings } from './lib/debug-settings.js';
|
|
4
|
+
export { parseLegacyQuery, serializeSelection, } from './lib/legacy-url.js';
|
|
5
|
+
export { loadConfig } from './lib/load-config.js';
|
|
6
|
+
export { serializeConfigResources, } from './lib/serialize-config-resources.js';
|
|
7
|
+
export { parseFromUrlTolerant } from './lib/url-codec.js';
|
|
8
|
+
export { parseIntentsFromUrl } from './lib/url-intents.js';
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { type Intent } from '@expofp/schema';
|
|
2
|
+
type IntentArgs = {
|
|
3
|
+
[I in Intent as I['name']]: I['args'];
|
|
4
|
+
};
|
|
5
|
+
type FloorPlanLike = {
|
|
6
|
+
[K in keyof IntentArgs]: (...args: IntentArgs[K]) => void;
|
|
7
|
+
};
|
|
8
|
+
export declare function applyIntents(fp: FloorPlanLike, intents: Intent[]): void;
|
|
9
|
+
export {};
|
|
10
|
+
//# sourceMappingURL=apply-intents.d.ts.map
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
// The generic `K` keeps name↔args correlated for TS, so `fp[name](...args)` checks
|
|
2
|
+
// out with no cast and no switch. Inlining this in the loop is what fails: there
|
|
3
|
+
// `intent` is the whole union, so name/args decorrelate (the original error).
|
|
4
|
+
function applyOne(fp, intent) {
|
|
5
|
+
// Intents ride on the deep-frozen effective config, but a method may
|
|
6
|
+
// normalize its input in place (RouteStore.selectCurrentPosition rewrites
|
|
7
|
+
// the point's fields) — hand it an owned mutable copy, the way any host
|
|
8
|
+
// calling the API would. Args are schema-validated plain data, so a JSON
|
|
9
|
+
// round-trip is lossless.
|
|
10
|
+
const args = JSON.parse(JSON.stringify(intent.args));
|
|
11
|
+
fp[intent.name](...args);
|
|
12
|
+
}
|
|
13
|
+
export function applyIntents(fp, intents) {
|
|
14
|
+
for (const intent of intents) {
|
|
15
|
+
try {
|
|
16
|
+
applyOne(fp, intent);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
// Intents ride in from the page URL — a bad deep link (unknown booth,
|
|
20
|
+
// half-formed args) must not take down the page or the intents after it.
|
|
21
|
+
console.error(`Intent ${intent.name} failed:`, error);
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import { type Config } from '@expofp/schema';
|
|
2
|
+
/** Install the active config (called once `loadConfig` has produced it). */
|
|
3
|
+
export declare function setConfig(newConfig: Config): void;
|
|
4
|
+
/** The active config. Throws if called before {@link setConfig}. */
|
|
5
|
+
export declare function getConfig(): Config;
|
|
6
|
+
//# sourceMappingURL=config-store.d.ts.map
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
let config;
|
|
2
|
+
/** Install the active config (called once `loadConfig` has produced it). */
|
|
3
|
+
export function setConfig(newConfig) {
|
|
4
|
+
config = newConfig;
|
|
5
|
+
}
|
|
6
|
+
/** The active config. Throws if called before {@link setConfig}. */
|
|
7
|
+
export function getConfig() {
|
|
8
|
+
if (!config) {
|
|
9
|
+
throw new Error('Config not loaded yet');
|
|
10
|
+
}
|
|
11
|
+
return config;
|
|
12
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type * as z from 'zod';
|
|
2
|
+
export type DebugSettingDescriptor = {
|
|
3
|
+
type: 'boolean';
|
|
4
|
+
optional: boolean;
|
|
5
|
+
} | {
|
|
6
|
+
type: 'string';
|
|
7
|
+
optional: boolean;
|
|
8
|
+
} | {
|
|
9
|
+
type: 'enum';
|
|
10
|
+
values: readonly string[];
|
|
11
|
+
optional: boolean;
|
|
12
|
+
};
|
|
13
|
+
export declare function toDebugSettings(schema: z.ZodType, options?: {
|
|
14
|
+
prefix?: string;
|
|
15
|
+
}): Record<string, DebugSettingDescriptor>;
|
|
16
|
+
//# sourceMappingURL=debug-settings.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Debug-panel setting descriptors derived from a config schema — the bridge
|
|
3
|
+
* between the config's localStorage layer and `@expofp/debug`'s settings UI.
|
|
4
|
+
*
|
|
5
|
+
* `toDebugSettings(LocalStorageConfigSchema)` emits one plain-data descriptor
|
|
6
|
+
* per scalar field, keyed by the exact storage key the localStorage codec
|
|
7
|
+
* reads (`efp-` + field name), so a debug-panel edit is picked up by
|
|
8
|
+
* `loadConfig` on the next page load. Optional fields are marked `optional`:
|
|
9
|
+
* their editors get an explicit "default" state that removes the key, handing
|
|
10
|
+
* the decision back to the lower config layers. Fields with no scalar editor
|
|
11
|
+
* (objects, arrays, unions, numbers so far) are skipped and logged.
|
|
12
|
+
*
|
|
13
|
+
* The descriptor shape mirrors `@expofp/debug`'s `SettingDescriptor`
|
|
14
|
+
* structurally — no dependency between the packages; the host (the floorplan)
|
|
15
|
+
* passes the output to its `registerSettings`.
|
|
16
|
+
*/
|
|
17
|
+
import debug from 'debug';
|
|
18
|
+
import { toJsonSchema } from './json-schema.js';
|
|
19
|
+
import { DEFAULT_STORAGE_PREFIX } from './local-storage-codec.js';
|
|
20
|
+
const log = debug('efp:config');
|
|
21
|
+
export function toDebugSettings(schema, options = {}) {
|
|
22
|
+
const prefix = options.prefix ?? DEFAULT_STORAGE_PREFIX;
|
|
23
|
+
const root = toJsonSchema(schema);
|
|
24
|
+
const required = new Set(root.required ?? []);
|
|
25
|
+
const settings = {};
|
|
26
|
+
for (const [field, node] of Object.entries(root.properties ?? {})) {
|
|
27
|
+
const descriptor = toDescriptor(node, !required.has(field));
|
|
28
|
+
if (!descriptor) {
|
|
29
|
+
log('toDebugSettings', 'no editor for field, skipping', field, node.type);
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
settings[prefix + field] = descriptor;
|
|
33
|
+
}
|
|
34
|
+
return settings;
|
|
35
|
+
}
|
|
36
|
+
function toDescriptor(node, optional) {
|
|
37
|
+
if (node.type === 'string' && node.enum) {
|
|
38
|
+
return { type: 'enum', values: node.enum, optional };
|
|
39
|
+
}
|
|
40
|
+
if (node.type === 'boolean' || node.type === 'string') {
|
|
41
|
+
return { type: node.type, optional };
|
|
42
|
+
}
|
|
43
|
+
return undefined;
|
|
44
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JSON-Schema introspection for the schema-driven machinery here and in
|
|
3
|
+
* `@expofp/config` (asset resolution, resource serialization, url-codec,
|
|
4
|
+
* url-intents, local-storage-codec): they all walk a plain Zod schema alongside
|
|
5
|
+
* a value, so they need a structural view of `z.toJSONSchema` output and
|
|
6
|
+
* string→scalar coercion by declared type.
|
|
7
|
+
*/
|
|
8
|
+
import * as z from 'zod';
|
|
9
|
+
/**
|
|
10
|
+
* The slice of a JSON Schema node the walkers care about. `.meta()` keys pass
|
|
11
|
+
* through `z.toJSONSchema` and surface here — they are how a schema declares
|
|
12
|
+
* where its external resources live.
|
|
13
|
+
*/
|
|
14
|
+
export interface JsonSchemaNode {
|
|
15
|
+
type?: string;
|
|
16
|
+
properties?: Record<string, JsonSchemaNode>;
|
|
17
|
+
/**
|
|
18
|
+
* The schema for keys not covered by `properties`. A `z.record(z.string(), V)`
|
|
19
|
+
* has dynamic keys and no fixed `properties`, so its value schema `V` rides
|
|
20
|
+
* here (e.g. `fpSvgLayerJsRefs`); a loose object's open tail also surfaces
|
|
21
|
+
* here (as `{}`/`true`, both inert to the walk).
|
|
22
|
+
*/
|
|
23
|
+
additionalProperties?: JsonSchemaNode | boolean;
|
|
24
|
+
required?: string[];
|
|
25
|
+
items?: JsonSchemaNode;
|
|
26
|
+
/** Per-position schemas of a tuple (`z.tuple`); its rest element rides on `items`. */
|
|
27
|
+
prefixItems?: JsonSchemaNode[];
|
|
28
|
+
anyOf?: JsonSchemaNode[];
|
|
29
|
+
oneOf?: JsonSchemaNode[];
|
|
30
|
+
/** Allowed values of a string-enum leaf. */
|
|
31
|
+
enum?: string[];
|
|
32
|
+
/** Value of a `z.literal` leaf — a discriminated union arm's tag. */
|
|
33
|
+
const?: unknown;
|
|
34
|
+
/** A string leaf holding one asset URL/path. */
|
|
35
|
+
asset?: boolean;
|
|
36
|
+
/** An `asset` whose URL is minted without an existence check — the resource may be missing. */
|
|
37
|
+
optionalAsset?: boolean;
|
|
38
|
+
/** A string leaf holding an SVG document whose `<image>` hrefs are asset paths. */
|
|
39
|
+
svgAsset?: boolean;
|
|
40
|
+
/** A string leaf holding CSS text whose `url(…)`s are asset paths. */
|
|
41
|
+
cssAsset?: boolean;
|
|
42
|
+
/** Stand-in for a `refSchema` meta — resolve it with {@link refSchemaOf}. */
|
|
43
|
+
refSchemaId?: string;
|
|
44
|
+
}
|
|
45
|
+
/** `schema` as a JSON Schema node tree (memoized per schema instance). */
|
|
46
|
+
export declare function toJsonSchema(schema: z.ZodType): JsonSchemaNode;
|
|
47
|
+
/** The live document schema a `$ref` node's `refSchema` meta points at. */
|
|
48
|
+
export declare function refSchemaOf(node: JsonSchemaNode): z.ZodType | undefined;
|
|
49
|
+
/** Does `value` plausibly belong to this schema branch? Used to pick union arms. */
|
|
50
|
+
export declare function typeMatches(node: JsonSchemaNode, value: unknown): boolean;
|
|
51
|
+
/**
|
|
52
|
+
* Walk the schema and a deflattened structure (raw strings at the leaves, the
|
|
53
|
+
* way a query string or storage yields values) together, coercing each leaf to
|
|
54
|
+
* its declared scalar type. Objects keep only the keys the schema names; a
|
|
55
|
+
* lone value destined for an array becomes a 1-element array; tuple positions
|
|
56
|
+
* coerce each by their own schema. A union coerces by the arm its literal
|
|
57
|
+
* discriminator names and is otherwise left as-is for the schema's `parse` to
|
|
58
|
+
* judge. Lenient like {@link coerceScalar} — this shapes the candidate, the
|
|
59
|
+
* schema's own `parse` does the real validation afterwards.
|
|
60
|
+
*/
|
|
61
|
+
export declare function coerceValue(node: JsonSchemaNode, value: unknown): unknown;
|
|
62
|
+
/**
|
|
63
|
+
* Coerce a raw string to its declared scalar type. Lenient on purpose — the
|
|
64
|
+
* schema's own `parse` does the real validation afterwards.
|
|
65
|
+
*/
|
|
66
|
+
export declare function coerceScalar(raw: string, type: string | undefined): string | number | boolean;
|
|
67
|
+
//# sourceMappingURL=json-schema.d.ts.map
|
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared JSON-Schema introspection for the schema-driven machinery here and in
|
|
3
|
+
* `@expofp/config` (asset resolution, resource serialization, url-codec,
|
|
4
|
+
* url-intents, local-storage-codec): they all walk a plain Zod schema alongside
|
|
5
|
+
* a value, so they need a structural view of `z.toJSONSchema` output and
|
|
6
|
+
* string→scalar coercion by declared type.
|
|
7
|
+
*/
|
|
8
|
+
import * as z from 'zod';
|
|
9
|
+
const cache = new WeakMap();
|
|
10
|
+
// A `.meta({ refSchema: SomeSchema })` points a `$ref` field at its document's
|
|
11
|
+
// LIVE schema. `z.toJSONSchema` emits pure JSON (a live schema cannot ride
|
|
12
|
+
// through it), so the conversion stamps a string id on the node and parks the
|
|
13
|
+
// schema here; `refSchemaOf` joins them back up.
|
|
14
|
+
const refSchemas = new Map();
|
|
15
|
+
let nextRefSchemaId = 0;
|
|
16
|
+
/** `schema` as a JSON Schema node tree (memoized per schema instance). */
|
|
17
|
+
export function toJsonSchema(schema) {
|
|
18
|
+
let node = cache.get(schema);
|
|
19
|
+
if (!node) {
|
|
20
|
+
node = z.toJSONSchema(schema, {
|
|
21
|
+
io: 'input',
|
|
22
|
+
override: ({ zodSchema, jsonSchema }) => {
|
|
23
|
+
const refSchema = z.globalRegistry.get(zodSchema)?.refSchema;
|
|
24
|
+
if (refSchema) {
|
|
25
|
+
const id = String(nextRefSchemaId++);
|
|
26
|
+
refSchemas.set(id, refSchema);
|
|
27
|
+
jsonSchema.refSchemaId = id;
|
|
28
|
+
}
|
|
29
|
+
},
|
|
30
|
+
});
|
|
31
|
+
cache.set(schema, node);
|
|
32
|
+
}
|
|
33
|
+
return node;
|
|
34
|
+
}
|
|
35
|
+
/** The live document schema a `$ref` node's `refSchema` meta points at. */
|
|
36
|
+
export function refSchemaOf(node) {
|
|
37
|
+
return node.refSchemaId === undefined ? undefined : refSchemas.get(node.refSchemaId);
|
|
38
|
+
}
|
|
39
|
+
/** Does `value` plausibly belong to this schema branch? Used to pick union arms. */
|
|
40
|
+
export function typeMatches(node, value) {
|
|
41
|
+
switch (node.type) {
|
|
42
|
+
case 'object':
|
|
43
|
+
return typeof value === 'object' && value !== null && !Array.isArray(value);
|
|
44
|
+
case 'array':
|
|
45
|
+
return Array.isArray(value);
|
|
46
|
+
case 'string':
|
|
47
|
+
return typeof value === 'string';
|
|
48
|
+
case 'number':
|
|
49
|
+
case 'integer':
|
|
50
|
+
return typeof value === 'number';
|
|
51
|
+
case 'boolean':
|
|
52
|
+
return typeof value === 'boolean';
|
|
53
|
+
default:
|
|
54
|
+
return true; // untyped branch — let the recursion decide
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* Walk the schema and a deflattened structure (raw strings at the leaves, the
|
|
59
|
+
* way a query string or storage yields values) together, coercing each leaf to
|
|
60
|
+
* its declared scalar type. Objects keep only the keys the schema names; a
|
|
61
|
+
* lone value destined for an array becomes a 1-element array; tuple positions
|
|
62
|
+
* coerce each by their own schema. A union coerces by the arm its literal
|
|
63
|
+
* discriminator names and is otherwise left as-is for the schema's `parse` to
|
|
64
|
+
* judge. Lenient like {@link coerceScalar} — this shapes the candidate, the
|
|
65
|
+
* schema's own `parse` does the real validation afterwards.
|
|
66
|
+
*/
|
|
67
|
+
export function coerceValue(node, value) {
|
|
68
|
+
if (value === undefined) {
|
|
69
|
+
return undefined;
|
|
70
|
+
}
|
|
71
|
+
const variants = node.anyOf ?? node.oneOf;
|
|
72
|
+
if (variants) {
|
|
73
|
+
const variant = discriminatedVariant(variants, value);
|
|
74
|
+
return variant ? coerceValue(variant, value) : value; // no single arm — let schema.parse decide
|
|
75
|
+
}
|
|
76
|
+
if (node.type === 'object' && node.properties) {
|
|
77
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
78
|
+
return value; // shape mismatch — surface it as a schema error
|
|
79
|
+
}
|
|
80
|
+
const source = value;
|
|
81
|
+
const out = {};
|
|
82
|
+
for (const [key, child] of Object.entries(node.properties)) {
|
|
83
|
+
if (key in source) {
|
|
84
|
+
out[key] = coerceValue(child, source[key]);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
return out;
|
|
88
|
+
}
|
|
89
|
+
if (node.type === 'array') {
|
|
90
|
+
const list = Array.isArray(value) ? value : [value]; // lenient: a lone value → 1-elem array
|
|
91
|
+
const tuple = node.prefixItems;
|
|
92
|
+
if (tuple) {
|
|
93
|
+
return list.map((element, index) => coerceValue(tuple[index] ?? node.items ?? {}, element));
|
|
94
|
+
}
|
|
95
|
+
const item = node.items ?? {};
|
|
96
|
+
return list.map((element) => coerceValue(item, element));
|
|
97
|
+
}
|
|
98
|
+
return typeof value === 'string' ? coerceScalar(value, node.type) : value;
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* The single union arm whose literal (`const`) properties all match `value` —
|
|
102
|
+
* how a discriminated union picks the arm to coerce by. The leaves are still
|
|
103
|
+
* raw strings when this runs, so a non-string literal matches its string form.
|
|
104
|
+
* `undefined` when the value is no object or when no (or several) arms match —
|
|
105
|
+
* nothing structural to say, the union stays uncoerced.
|
|
106
|
+
*/
|
|
107
|
+
function discriminatedVariant(variants, value) {
|
|
108
|
+
if (typeof value !== 'object' || value === null || Array.isArray(value)) {
|
|
109
|
+
return undefined;
|
|
110
|
+
}
|
|
111
|
+
const source = value;
|
|
112
|
+
const matches = variants.filter((variant) => {
|
|
113
|
+
const literals = Object.entries(variant.properties ?? {}).filter(([, property]) => property.const !== undefined);
|
|
114
|
+
return (literals.length > 0 &&
|
|
115
|
+
literals.every(([key, property]) => {
|
|
116
|
+
const raw = source[key];
|
|
117
|
+
return raw === property.const || raw === String(property.const);
|
|
118
|
+
}));
|
|
119
|
+
});
|
|
120
|
+
return matches.length === 1 ? matches[0] : undefined;
|
|
121
|
+
}
|
|
122
|
+
/**
|
|
123
|
+
* Coerce a raw string to its declared scalar type. Lenient on purpose — the
|
|
124
|
+
* schema's own `parse` does the real validation afterwards.
|
|
125
|
+
*/
|
|
126
|
+
export function coerceScalar(raw, type) {
|
|
127
|
+
if (type === 'number' || type === 'integer') {
|
|
128
|
+
// `Number('') === 0` — a blank value (`?camera[bearing]=`) must stay a
|
|
129
|
+
// string for the schema's parse to reject, not silently become a real 0
|
|
130
|
+
return raw.trim() === '' ? raw : Number(raw);
|
|
131
|
+
}
|
|
132
|
+
if (type === 'boolean') {
|
|
133
|
+
return /^(1|true|yes|on)$/i.test(raw);
|
|
134
|
+
}
|
|
135
|
+
return raw; // string, enum, or unknown
|
|
136
|
+
}
|
|
@@ -0,0 +1,167 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Legacy URL grammar — the compact deep-link language the floor plan has always
|
|
3
|
+
* spoken (`?<slug>`, `?route:B-12:A-3:false`, `?tour=t1`, `?planner=a:b&from=c`,
|
|
4
|
+
* one-shot params like `?blue-dot=…` or `?viewermode=1`), as one pure codec:
|
|
5
|
+
*
|
|
6
|
+
* parseLegacyQuery(search) raw `location.search` → typed commands
|
|
7
|
+
* serializeSelection(selection) selection descriptor → `location.search`
|
|
8
|
+
*
|
|
9
|
+
* Both halves speak the SAME grammar, so shortcuts stay bidirectional: the slug
|
|
10
|
+
* a shared link carries is the slug the plan writes back when the visitor picks
|
|
11
|
+
* an entity (round-trip covered by tests). The floor plan maps commands to its
|
|
12
|
+
* public methods / stores and its UI state to a selection descriptor — nothing
|
|
13
|
+
* here touches a store or the DOM.
|
|
14
|
+
*
|
|
15
|
+
* Encoding contract (inherited from the legacy router, byte-compatible):
|
|
16
|
+
* the WHOLE query payload is one `encodeURIComponent` unit — `route:a:b`
|
|
17
|
+
* travels as `?route%3Aa%3Ab`, `planner&source=bookmarks` as
|
|
18
|
+
* `?planner%26source%3Dbookmarks` — and the parser decodes the whole string
|
|
19
|
+
* before splitting. Keys owned by the config layer (`UrlConfigSchema`) and
|
|
20
|
+
* intent-shortcut keys (`IntentsSchema` arm names) are consumed by `loadConfig`
|
|
21
|
+
* at boot, so the parser strips them from the residual slug here — one place
|
|
22
|
+
* knows the full key partition of the query.
|
|
23
|
+
*
|
|
24
|
+
* LEGACY GRAMMAR — frozen. Do not add keys or forms here. New URL capabilities
|
|
25
|
+
* are born in `UrlConfigSchema` (config keys) or `IntentsSchema` (intent arms)
|
|
26
|
+
* — see docs/evgeny-thoughts/2026-07-09-legacy-url-retirement.md §2. The parse
|
|
27
|
+
* side stays alive for links already shared in the wild, but only as a
|
|
28
|
+
* translator into those two channels; the sole legitimate consumer is
|
|
29
|
+
* `packages/floorplan/src/services/url-dispatch.ts` (lint-enforced).
|
|
30
|
+
*/
|
|
31
|
+
import { type Camera, type ListPanel } from '@expofp/schema';
|
|
32
|
+
/** The list panels addressable by keyword (`?bookmarks`, `?sessions`, …). */
|
|
33
|
+
export type LegacyListPanel = ListPanel;
|
|
34
|
+
/**
|
|
35
|
+
* The exclusive part of a query — at most one of these dispatches per URL,
|
|
36
|
+
* mirroring the legacy router's `if/else` chain (its order is the array order
|
|
37
|
+
* of {@link parseLegacyQuery}'s classifier).
|
|
38
|
+
*/
|
|
39
|
+
export type LegacyPrimary = {
|
|
40
|
+
type: 'kiosk';
|
|
41
|
+
enabled: boolean | null;
|
|
42
|
+
} | {
|
|
43
|
+
type: 'list';
|
|
44
|
+
list: LegacyListPanel;
|
|
45
|
+
} | {
|
|
46
|
+
type: 'tour';
|
|
47
|
+
tourId: string;
|
|
48
|
+
} | {
|
|
49
|
+
type: 'planner';
|
|
50
|
+
fromBookmarks: boolean;
|
|
51
|
+
items: string[];
|
|
52
|
+
from?: string;
|
|
53
|
+
} | {
|
|
54
|
+
type: 'route';
|
|
55
|
+
to?: string;
|
|
56
|
+
from?: string;
|
|
57
|
+
accessible: boolean;
|
|
58
|
+
waypoints: string[];
|
|
59
|
+
title?: string;
|
|
60
|
+
} | {
|
|
61
|
+
type: 'language';
|
|
62
|
+
langId?: string;
|
|
63
|
+
} | {
|
|
64
|
+
type: 'printPdf';
|
|
65
|
+
} | {
|
|
66
|
+
type: 'visibility';
|
|
67
|
+
hidden: string[];
|
|
68
|
+
} | {
|
|
69
|
+
type: 'buildRoute';
|
|
70
|
+
} | {
|
|
71
|
+
type: 'pathway';
|
|
72
|
+
pathwayId: string;
|
|
73
|
+
boothIds: string[];
|
|
74
|
+
exhibitorIds: string[];
|
|
75
|
+
tourId?: string;
|
|
76
|
+
} | {
|
|
77
|
+
type: 'selectExhibitors';
|
|
78
|
+
values: string[];
|
|
79
|
+
} | {
|
|
80
|
+
type: 'ownedByFilters';
|
|
81
|
+
} | {
|
|
82
|
+
type: 'ownedByConfig';
|
|
83
|
+
} | {
|
|
84
|
+
type: 'select';
|
|
85
|
+
slug: string;
|
|
86
|
+
};
|
|
87
|
+
/**
|
|
88
|
+
* Independent one-shot boot effects — the params the legacy router scrubbed
|
|
89
|
+
* from the URL as it consumed them. They are not written back (the single
|
|
90
|
+
* writer re-serializes the URL from state after dispatch, which drops them).
|
|
91
|
+
*/
|
|
92
|
+
export type LegacyOneShot = {
|
|
93
|
+
type: 'heatmap';
|
|
94
|
+
yah: boolean;
|
|
95
|
+
kiosk: boolean;
|
|
96
|
+
} | {
|
|
97
|
+
type: 'blueDot';
|
|
98
|
+
x: number;
|
|
99
|
+
y: number;
|
|
100
|
+
layer?: string;
|
|
101
|
+
lat?: number;
|
|
102
|
+
lng?: number;
|
|
103
|
+
} | {
|
|
104
|
+
type: 'viewerMode';
|
|
105
|
+
enabled: boolean;
|
|
106
|
+
} | {
|
|
107
|
+
type: 'previewMode';
|
|
108
|
+
enabled: boolean;
|
|
109
|
+
} | {
|
|
110
|
+
type: 'uiScale';
|
|
111
|
+
scale: number | null;
|
|
112
|
+
raw: string;
|
|
113
|
+
} | {
|
|
114
|
+
type: 'resetUiScale';
|
|
115
|
+
} | {
|
|
116
|
+
type: 'legacyBookmarks';
|
|
117
|
+
appendExhibitorId: number | null;
|
|
118
|
+
} | {
|
|
119
|
+
type: 'camera';
|
|
120
|
+
camera: Camera;
|
|
121
|
+
roll?: number;
|
|
122
|
+
};
|
|
123
|
+
export interface LegacyUrlQuery {
|
|
124
|
+
primary: LegacyPrimary;
|
|
125
|
+
oneShots: LegacyOneShot[];
|
|
126
|
+
}
|
|
127
|
+
/**
|
|
128
|
+
* What the floor plan currently shows, for the URL — the serializer's input.
|
|
129
|
+
* `slug` covers every entity kind and free search text alike: the value IS the
|
|
130
|
+
* query, and the parser's `select` command resolves it back by catalog lookup.
|
|
131
|
+
*/
|
|
132
|
+
export type UrlSelection = {
|
|
133
|
+
type: 'none';
|
|
134
|
+
} | {
|
|
135
|
+
type: 'slug';
|
|
136
|
+
slug: string;
|
|
137
|
+
} | {
|
|
138
|
+
type: 'route';
|
|
139
|
+
to?: string;
|
|
140
|
+
from?: string;
|
|
141
|
+
accessible: boolean;
|
|
142
|
+
waypoints?: string[];
|
|
143
|
+
} | {
|
|
144
|
+
type: 'planner';
|
|
145
|
+
fromBookmarks: boolean;
|
|
146
|
+
items?: string[];
|
|
147
|
+
from?: string;
|
|
148
|
+
} | {
|
|
149
|
+
type: 'list';
|
|
150
|
+
list: LegacyListPanel;
|
|
151
|
+
} | {
|
|
152
|
+
type: 'tour';
|
|
153
|
+
tourId: string;
|
|
154
|
+
} | {
|
|
155
|
+
type: 'filter';
|
|
156
|
+
key: string;
|
|
157
|
+
value: string;
|
|
158
|
+
};
|
|
159
|
+
export declare function parseLegacyQuery(search: string): LegacyUrlQuery;
|
|
160
|
+
/**
|
|
161
|
+
* The selection's canonical query — `''` for none, else `?<payload>` with the
|
|
162
|
+
* whole payload as one `encodeURIComponent` unit (the legacy contract). This is
|
|
163
|
+
* the string the single URL writer puts in the address bar, byte-compatible
|
|
164
|
+
* with the URLs the legacy router wrote (shared links keep working).
|
|
165
|
+
*/
|
|
166
|
+
export declare function serializeSelection(selection: UrlSelection): string;
|
|
167
|
+
//# sourceMappingURL=legacy-url.d.ts.map
|