@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,322 @@
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 { IntentsSchema, UrlConfigSchema } from '@expofp/schema';
32
+ import { safeDecode } from '@expofp/utils';
33
+ /* ── Parse: location.search → commands ─────────────────────────────────────── */
34
+ /** Query keys consumed by `loadConfig` before dispatch: config slice + intent shortcuts. */
35
+ const configOwnedKeys = new Set([
36
+ ...Object.keys(UrlConfigSchema.shape),
37
+ ...IntentsSchema.options.map((arm) => arm.shape.name.value),
38
+ ]);
39
+ /** One-shot keys the parser consumes into commands (never part of the slug). */
40
+ const oneShotKeys = new Set(['blue-dot', 'viewermode', 'previewMode', 'uiscale', 'resetuiscale']);
41
+ /**
42
+ * The legacy camera params, translated into `config.camera`'s shape. Stripped
43
+ * from the slug only in their `key=value` form — a bare slug that merely spells
44
+ * one of these words (a booth named "center") still resolves via the catalog.
45
+ */
46
+ const legacyCameraKeys = new Set(['centerxy', 'center', 'z', 'bearing', 'zoomtime', 'roll']);
47
+ export function parseLegacyQuery(search) {
48
+ const rawQuery = search.startsWith('?') ? search.slice(1) : search;
49
+ // the legacy contract: the payload is one encodeURIComponent unit
50
+ const decoded = safeDecode(rawQuery);
51
+ const params = new URLSearchParams(decoded);
52
+ const oneShots = collectOneShots(rawQuery, params);
53
+ // `?preview=`, `?b=`/`?ba=` (leading param) and `?copy_exh` historically
54
+ // wiped the whole query before the other one-shots could see it — only the
55
+ // heatmap flags (processed first) and the bookmark redirect itself survive
56
+ const wiped = rawQuery.startsWith('preview=') ||
57
+ rawQuery.startsWith('b=') ||
58
+ rawQuery.startsWith('ba=') ||
59
+ decoded.includes('copy_exh');
60
+ if (wiped) {
61
+ return {
62
+ primary: { type: 'select', slug: '' },
63
+ oneShots: oneShots.filter((s) => s.type === 'heatmap' || s.type === 'legacyBookmarks'),
64
+ };
65
+ }
66
+ return {
67
+ primary: classifyPrimary(residualSlug(decoded), params, hasConfigOwnedParts(decoded)),
68
+ oneShots,
69
+ };
70
+ }
71
+ function classifyPrimary(slug, params, configOwned) {
72
+ if (params.has('kiosk')) {
73
+ const value = params.get('kiosk');
74
+ return { type: 'kiosk', enabled: value === '1' ? true : value === '0' ? false : null };
75
+ }
76
+ if (slug === 'tours')
77
+ return { type: 'list', list: 'tours' };
78
+ if (slug.startsWith('tour='))
79
+ return { type: 'tour', tourId: slug.split('=')[1] };
80
+ if (params.has('planner')) {
81
+ return {
82
+ type: 'planner',
83
+ fromBookmarks: params.get('source') === 'bookmarks',
84
+ items: (params.get('planner') ?? '').split(':').filter(Boolean),
85
+ from: params.get('from') ?? undefined,
86
+ };
87
+ }
88
+ if (slug.startsWith('route')) {
89
+ const parts = slug.split(':');
90
+ return {
91
+ type: 'route',
92
+ to: parts[1],
93
+ from: parts[2],
94
+ accessible: parts[3] === 'true',
95
+ waypoints: parts.slice(4),
96
+ title: params.get('title') ?? undefined,
97
+ };
98
+ }
99
+ if (slug === 'bookmarks')
100
+ return { type: 'list', list: 'bookmarks' };
101
+ if (slug === 'visited')
102
+ return { type: 'list', list: 'visited' };
103
+ if (slug === 'language')
104
+ return { type: 'list', list: 'language' };
105
+ if (params.has('lang')) {
106
+ return { type: 'language', langId: params.get('lang')?.toLowerCase() };
107
+ }
108
+ if (slug === 'sessions')
109
+ return { type: 'list', list: 'sessions' };
110
+ if (slug === 'exhibitors')
111
+ return { type: 'list', list: 'exhibitors' };
112
+ if (slug === 'speakers')
113
+ return { type: 'list', list: 'speakers' };
114
+ if (slug === '-pdf')
115
+ return { type: 'printPdf' };
116
+ if (slug.startsWith('hide')) {
117
+ const hidden = (new URLSearchParams(slug).get('hide') ?? '').split(',').filter(Boolean);
118
+ return { type: 'visibility', hidden };
119
+ }
120
+ if (slug === 'build-route')
121
+ return { type: 'buildRoute' };
122
+ const pathwayId = params.get('pathway');
123
+ if (pathwayId) {
124
+ return {
125
+ type: 'pathway',
126
+ pathwayId,
127
+ boothIds: params.get('booths')?.split(',') ?? [],
128
+ exhibitorIds: params.get('exhibitors')?.split(',') ?? [],
129
+ tourId: params.get('tour') ?? undefined,
130
+ };
131
+ }
132
+ if (slug.startsWith('exhibitors') && slug.includes('=')) {
133
+ return { type: 'selectExhibitors', values: slug.split('=')[1].split(',') };
134
+ }
135
+ if (slug.includes('=') && (slug.startsWith('categories=') || /^poiTypes?=/.test(slug))) {
136
+ return { type: 'ownedByFilters' };
137
+ }
138
+ // Last, after every params-based branch above (`?kiosk=1&camera[x]=5` is
139
+ // still the kiosk toggle): an empty slug that got empty because config/
140
+ // intent keys owned the whole query is NOT the "clear the selection"
141
+ // command a genuinely empty query is — replaying it as `select('')` would
142
+ // close whatever is open.
143
+ if (!slug && configOwned) {
144
+ return { type: 'ownedByConfig' };
145
+ }
146
+ return { type: 'select', slug };
147
+ }
148
+ function collectOneShots(rawQuery, params) {
149
+ const oneShots = [];
150
+ if (params.get('heatmap') === 'true') {
151
+ oneShots.push({
152
+ type: 'heatmap',
153
+ yah: params.get('type') === 'yah',
154
+ kiosk: params.get('subtype') === 'kiosk',
155
+ });
156
+ }
157
+ if (rawQuery.startsWith('b=') || rawQuery.startsWith('ba=')) {
158
+ const appendRaw = new URLSearchParams(rawQuery).get('ba');
159
+ const appendExhibitorId = appendRaw === null ? null : parseInt(appendRaw, 10);
160
+ oneShots.push({
161
+ type: 'legacyBookmarks',
162
+ appendExhibitorId: Number.isFinite(appendExhibitorId) ? appendExhibitorId : null,
163
+ });
164
+ }
165
+ const blueDot = params.get('blue-dot');
166
+ if (blueDot !== null) {
167
+ const p = blueDot.split(',');
168
+ const x = Number(p[0]);
169
+ const y = Number(p[1]);
170
+ if (p.length > 1 && Number.isFinite(x) && Number.isFinite(y)) {
171
+ oneShots.push({
172
+ type: 'blueDot',
173
+ x,
174
+ y,
175
+ layer: p[2],
176
+ lat: Number(p[3]) || undefined,
177
+ lng: Number(p[4]) || undefined,
178
+ });
179
+ }
180
+ }
181
+ const viewerMode = params.get('viewermode');
182
+ if (viewerMode === '1' || viewerMode === 'true') {
183
+ oneShots.push({ type: 'viewerMode', enabled: true });
184
+ }
185
+ else if (viewerMode === '0' || viewerMode === 'false') {
186
+ oneShots.push({ type: 'viewerMode', enabled: false });
187
+ }
188
+ const previewMode = params.get('previewMode');
189
+ if (previewMode === 'true') {
190
+ oneShots.push({ type: 'previewMode', enabled: true });
191
+ }
192
+ else if (previewMode === 'false') {
193
+ oneShots.push({ type: 'previewMode', enabled: false });
194
+ }
195
+ if (params.has('resetuiscale')) {
196
+ oneShots.push({ type: 'resetUiScale' });
197
+ }
198
+ const uiScaleRaw = params.get('uiscale');
199
+ if (uiScaleRaw) {
200
+ const scale = parseFloat(uiScaleRaw.replace(',', '.'));
201
+ const valid = !Number.isNaN(scale) && scale >= 0.8 && scale <= 1.4;
202
+ oneShots.push({ type: 'uiScale', scale: valid ? scale : null, raw: uiScaleRaw });
203
+ }
204
+ const camera = legacyCamera(params);
205
+ if (camera) {
206
+ oneShots.push(camera);
207
+ }
208
+ return oneShots;
209
+ }
210
+ /** Translate the legacy camera params into the schema-typed `Camera` shape. */
211
+ function legacyCamera(params) {
212
+ const camera = {};
213
+ const centerxy = numberPair(params.get('centerxy'));
214
+ if (centerxy)
215
+ [camera.x, camera.y] = centerxy;
216
+ const center = numberPair(params.get('center'));
217
+ if (center)
218
+ [camera.lat, camera.lng] = center;
219
+ const floor = params.get('z');
220
+ if (floor)
221
+ camera.floor = floor;
222
+ const bearing = finiteNumber(params.get('bearing'));
223
+ if (bearing !== undefined)
224
+ camera.bearing = bearing;
225
+ const zoomTime = finiteNumber(params.get('zoomtime'));
226
+ if (zoomTime !== undefined)
227
+ camera.zoomTime = zoomTime;
228
+ const roll = finiteNumber(params.get('roll'));
229
+ if (!Object.keys(camera).length && roll === undefined)
230
+ return null;
231
+ return roll === undefined ? { type: 'camera', camera } : { type: 'camera', camera, roll };
232
+ }
233
+ function numberPair(value) {
234
+ if (!value)
235
+ return undefined;
236
+ const parts = value.split(',').map(parseFloat);
237
+ if (parts.length !== 2 || !parts.every(Number.isFinite))
238
+ return undefined;
239
+ return [parts[0], parts[1]];
240
+ }
241
+ function finiteNumber(value) {
242
+ if (value === null || value === '')
243
+ return undefined;
244
+ const parsed = parseFloat(value);
245
+ return Number.isFinite(parsed) ? parsed : undefined;
246
+ }
247
+ /**
248
+ * The slug is the decoded query minus every key another consumer owns: the
249
+ * config layer's keys and intent shortcuts (read by `loadConfig`), and the
250
+ * one-shot keys consumed above. `heatmap`/`type`/`subtype` stay in the slug —
251
+ * heatmap mode historically froze the URL with them in place (its data loader
252
+ * re-reads them), and the select fallback knows not to treat a query carrying
253
+ * `heatmap=true` as search text.
254
+ */
255
+ function hasConfigOwnedParts(decoded) {
256
+ return decoded.split('&').some((part) => {
257
+ const rawKey = part.split('=')[0];
258
+ // the legacy scalar `?heatmap=true` is slug-resident (see residualSlug)
259
+ if (rawKey === 'heatmap')
260
+ return false;
261
+ return configOwnedKeys.has(rawKey.split('[')[0]);
262
+ });
263
+ }
264
+ function residualSlug(decoded) {
265
+ return decoded
266
+ .split('&')
267
+ .filter((part) => {
268
+ if (!part)
269
+ return false;
270
+ const key = part.split('=')[0].split('[')[0];
271
+ if (legacyCameraKeys.has(key) && part.includes('='))
272
+ return false;
273
+ // Carve-out (retirement plan §4.8): `heatmap` is a UrlConfigSchema key
274
+ // now, so the canonical bracketed `heatmap[…]=` form is config-owned and
275
+ // strips like any other — but the legacy scalar `?heatmap=true` must
276
+ // KEEP the slug residency described above (the select fallback
277
+ // recognizes the literal `heatmap=true`; the frozen URL carries it for
278
+ // the data loader). Discriminate on the raw key: no bracket ⇒ legacy.
279
+ if (part.split('=')[0] === 'heatmap')
280
+ return true;
281
+ return !configOwnedKeys.has(key) && !oneShotKeys.has(key);
282
+ })
283
+ .join('&');
284
+ }
285
+ /* ── Serialize: selection → location.search ────────────────────────────────── */
286
+ /**
287
+ * The selection's canonical query — `''` for none, else `?<payload>` with the
288
+ * whole payload as one `encodeURIComponent` unit (the legacy contract). This is
289
+ * the string the single URL writer puts in the address bar, byte-compatible
290
+ * with the URLs the legacy router wrote (shared links keep working).
291
+ */
292
+ export function serializeSelection(selection) {
293
+ const queryRaw = selectionPayload(selection);
294
+ return queryRaw ? '?' + encodeURIComponent(queryRaw) : '';
295
+ }
296
+ function selectionPayload(selection) {
297
+ switch (selection.type) {
298
+ case 'none':
299
+ return '';
300
+ case 'slug':
301
+ return selection.slug;
302
+ case 'route': {
303
+ const to = selection.to ? `:${selection.to}` : '';
304
+ const from = selection.from ? `:${selection.from}` : '';
305
+ const accessible = selection.accessible ? ':true' : ':false';
306
+ const waypoints = (selection.waypoints ?? []).map((w) => `:${w}`).join('');
307
+ return `route${to}${from}${accessible}${waypoints}`;
308
+ }
309
+ case 'planner': {
310
+ const base = selection.fromBookmarks
311
+ ? 'planner&source=bookmarks'
312
+ : `planner=${(selection.items ?? []).join(':')}`;
313
+ return selection.from ? `${base}&from=${selection.from}` : base;
314
+ }
315
+ case 'list':
316
+ return selection.list;
317
+ case 'tour':
318
+ return `tour=${selection.tourId}`;
319
+ case 'filter':
320
+ return `${selection.key}=${selection.value}`;
321
+ }
322
+ }
@@ -0,0 +1,26 @@
1
+ import { type Ref } from '@expofp/resolve';
2
+ import { type Config, type Manifest, type Options } from '@expofp/schema';
3
+ import { type StorageLike } from './local-storage-codec.js';
4
+ /**
5
+ * Assemble the effective config — the runtime's single, complete copy of the
6
+ * expo data — by layering, lowest to highest precedence:
7
+ * manifest (+ its legacy data.js) → options → localStorage → URL.
8
+ *
9
+ * With a `legacyDataUrlBase` (live events), the version probe settles the
10
+ * `?v=` cache-buster, refs to the sibling drawing / wayfinding files are
11
+ * derived from it (`data.js` never carries them), and all three legacy files —
12
+ * `data.js` evaluated to its `__data` document like every legacy `.js` file,
13
+ * asset-resolved and merged in — are fetched in parallel. A manifest can
14
+ * instead carry the merged data itself plus explicit refs (offline copies) —
15
+ * then nothing is derived. The config holds only the REFS; the payloads live
16
+ * in `@expofp/resolve`'s memo: `loadConfig` resolves `fpSvgJsRef` and
17
+ * `wfDataJsRef` before returning, so consumers read them synchronously with
18
+ * `getResolved` from then on. Legacy normalization and the rebooking merge
19
+ * run last, and the result is deep-frozen.
20
+ *
21
+ * `url` / `storage` are browser inputs (page query, `localStorage`); Node
22
+ * callers (the offline CLI) omit them, which also skips those layers and the
23
+ * rebooking token lookup.
24
+ */
25
+ export declare function loadConfig(manifest: Manifest | Ref<Manifest>, options: Options, url?: string, storage?: StorageLike): Promise<Config>;
26
+ //# sourceMappingURL=load-config.d.ts.map
@@ -0,0 +1,286 @@
1
+ /// <reference lib="dom" />
2
+ import { resolve } from '@expofp/resolve';
3
+ import { ConfigDefaults, DataJsSchema, FpSvgJsSchema, FpSvgLayerJsSchema, LocalStorageConfigSchema, ManifestSchema, OptionsSchema, UrlConfigSchema, WfDataJsSchema, } from '@expofp/schema';
4
+ import { deepClone, deepFreeze } from '@expofp/utils';
5
+ import debug from 'debug';
6
+ import { parseFromStorage } from './local-storage-codec.js';
7
+ import { normalizeFpSvgLayerAliases } from './normalize-fp-svg.js';
8
+ import { normalizeLegacyData } from './normalize-legacy-data.js';
9
+ import { applyRebookingData } from './rebooking.js';
10
+ import { parseFromUrlTolerant } from './url-codec.js';
11
+ import { parseIntentsFromUrl } from './url-intents.js';
12
+ import { readValidateFlag } from './validate-flag.js';
13
+ import { documentRef, setValidateManifest, validateOrClone } from './visit-resources.js';
14
+ const log = debug('efp:config');
15
+ /**
16
+ * Assemble the effective config — the runtime's single, complete copy of the
17
+ * expo data — by layering, lowest to highest precedence:
18
+ * manifest (+ its legacy data.js) → options → localStorage → URL.
19
+ *
20
+ * With a `legacyDataUrlBase` (live events), the version probe settles the
21
+ * `?v=` cache-buster, refs to the sibling drawing / wayfinding files are
22
+ * derived from it (`data.js` never carries them), and all three legacy files —
23
+ * `data.js` evaluated to its `__data` document like every legacy `.js` file,
24
+ * asset-resolved and merged in — are fetched in parallel. A manifest can
25
+ * instead carry the merged data itself plus explicit refs (offline copies) —
26
+ * then nothing is derived. The config holds only the REFS; the payloads live
27
+ * in `@expofp/resolve`'s memo: `loadConfig` resolves `fpSvgJsRef` and
28
+ * `wfDataJsRef` before returning, so consumers read them synchronously with
29
+ * `getResolved` from then on. Legacy normalization and the rebooking merge
30
+ * run last, and the result is deep-frozen.
31
+ *
32
+ * `url` / `storage` are browser inputs (page query, `localStorage`); Node
33
+ * callers (the offline CLI) omit them, which also skips those layers and the
34
+ * rebooking token lookup.
35
+ */
36
+ export async function loadConfig(manifest, options, url, storage) {
37
+ const config = { ...ConfigDefaults };
38
+ // Schema validation is an opt-in diagnostic, read up front from the page
39
+ // inputs (URL query / localStorage): the parses it gates run before those
40
+ // layers merge, so it can't come off the effective config. See readValidateFlag.
41
+ const validateSchema = readValidateFlag(url, storage);
42
+ setValidateManifest(validateSchema); // gates the referenced-document parses (visit-resources)
43
+ // Take an OWN copy of the resolved manifest (validated when the flag is on —
44
+ // see validateOrClone): `resolve` deep-freezes what it settles, an offline
45
+ // manifest carries the full event data (booths, exhibitors, …), and
46
+ // `normalizeLegacyData` below mutates those nested objects in place.
47
+ manifest = validateOrClone(ManifestSchema, await resolve(manifest));
48
+ // The legacy event-data directory (and the old manifests' version pointer,
49
+ // replaced by the version.json probe) are INPUTS, consumed right here — the
50
+ // effective config carries only the refs derived from them, so neither is
51
+ // merged.
52
+ const { legacyDataUrlBase, legacyDataVersion: _manifestVersion, ...manifestFields } = manifest;
53
+ applyConsentAlias(manifestFields);
54
+ assignDefined(config, manifestFields);
55
+ // don't trust, parse
56
+ options = OptionsSchema.parse(options);
57
+ applyConsentAlias(options);
58
+ // every legacy file URL is ?v= cache-busted; a timestamp busts when no
59
+ // version.json exists (TODO: drop once the legacy data directory is gone)
60
+ let legacyDataVersion = String(Date.now());
61
+ // live legacy directory: version probe, then the sibling file refs — data.js
62
+ // never carries the payload refs, so both are known before it lands
63
+ if (legacyDataUrlBase) {
64
+ if (isFromDesignerReferrer()) {
65
+ // The designer previews in-progress edits — the directory's files change
66
+ // without a version bump, so the published version must not pin `?v=`;
67
+ // the unique timestamp stands and every load fetches fresh files.
68
+ log('loadConfig', 'designer referrer — cache-busting with a timestamp');
69
+ }
70
+ else {
71
+ try {
72
+ legacyDataVersion = (await resolve({ $ref: `${legacyDataUrlBase}version.json` })).version;
73
+ }
74
+ catch {
75
+ log('loadConfig', 'no version.json found at', legacyDataUrlBase);
76
+ }
77
+ }
78
+ // Manifest-supplied refs win (offline copies point them at resolved .json
79
+ // documents); otherwise derive the conventional <base><file>.js URLs.
80
+ config.fpSvgJsRef ??= legacyFileRef(legacyDataUrlBase, 'fp.svg.js', legacyDataVersion);
81
+ config.wfDataJsRef ??= legacyFileRef(legacyDataUrlBase, 'wf.data.js', legacyDataVersion);
82
+ }
83
+ // Every ref the config carries is decorated with its document-processing
84
+ // hook (validate + absolutize resource URLs against the ref's directory) —
85
+ // after the manifest parse, which keeps refs as plain data, and covering
86
+ // manifest-supplied and derived refs alike. From here on, ANY
87
+ // `resolve(ref)` settles a good document.
88
+ if (config.fpSvgJsRef)
89
+ config.fpSvgJsRef = documentRef(config.fpSvgJsRef, FpSvgJsSchema);
90
+ if (config.wfDataJsRef)
91
+ config.wfDataJsRef = documentRef(config.wfDataJsRef, WfDataJsSchema);
92
+ const layerRefs = {};
93
+ for (const [name, ref] of Object.entries(config.fpSvgLayerJsRefs)) {
94
+ layerRefs[name] = fpSvgLayerDocumentRef(ref, name);
95
+ }
96
+ config.fpSvgLayerJsRefs = layerRefs;
97
+ // Resolve the expo data document, the drawing and the wayfinding graph
98
+ // eagerly, in parallel — one network round-trip instead of three. Works with
99
+ // or without a legacy directory (an offline manifest carries the merged
100
+ // data plus refs, and no directory). The payloads stay in the resolver's
101
+ // memo; consumers read them synchronously with `getResolved`.
102
+ const [data, fpSvg] = await Promise.all([
103
+ legacyDataUrlBase ? loadLegacyDataJs(legacyDataUrlBase, legacyDataVersion) : null,
104
+ config.fpSvgJsRef ? loadLegacyFpSvgJs(config.fpSvgJsRef, legacyDataUrlBase) : null,
105
+ prefetchWfData(config.wfDataJsRef),
106
+ ]);
107
+ if (data) {
108
+ // the directory fields and the payload refs are inputs consumed above —
109
+ // data.js never carries them, and a stray copy must not clobber the
110
+ // decorated, already-resolved refs
111
+ const { flags, legacyDataUrlBase: _dataDir, legacyDataVersion: _dataVersion, fpSvgJsRef: _fpRef, wfDataJsRef: _wfRef, fpSvgLayerJsRefs: _layerRefs, ...dataFields } = data;
112
+ if (flags) {
113
+ // legacy data.js may nest the deprecated alias under `flags`; fold it too
114
+ applyConsentAlias(flags);
115
+ assignDefined(config, flags); // deprecated nesting — root-level fields win
116
+ }
117
+ applyConsentAlias(dataFields);
118
+ assignDefined(config, dataFields);
119
+ }
120
+ if (fpSvg) {
121
+ // a pending-poll may have landed on a fresh ?v= URL — the config must
122
+ // point at the ref that actually resolved
123
+ config.fpSvgJsRef = fpSvg.ref;
124
+ if (!Object.keys(config.fpSvgLayerJsRefs).length && legacyDataUrlBase) {
125
+ config.fpSvgLayerJsRefs = buildFpSvgLayerJsRefs(fpSvg.payload, legacyDataUrlBase, legacyDataVersion);
126
+ }
127
+ }
128
+ assignDefined(config, options);
129
+ if (storage) {
130
+ // The sticky per-browser preferences (viewerMode / previewMode / uiScale)
131
+ // are schema-typed storage settings but must NOT merge into the effective
132
+ // config: the floor plan reads their keys lazily, so the legacy one-shot
133
+ // URL seeds (`?viewermode=0`, `?resetuiscale`, …) stay revocable within
134
+ // the same load — a frozen config value would keep a cleared mode on.
135
+ const { viewerMode: _viewerMode, previewMode: _previewMode, uiScale: _uiScale, ...storageConfig } = parseFromStorage(LocalStorageConfigSchema, storage);
136
+ assignDefined(config, storageConfig);
137
+ }
138
+ // Embeds set `ignoreQuery` (manifest / data.js / options) to isolate the
139
+ // floor plan from the page URL. The effective value is settled here — every
140
+ // lower layer has merged, and the URL cannot re-enable itself because
141
+ // `ignoreQuery` is not a UrlConfigSchema field.
142
+ if (url !== undefined && !config.ignoreQuery) {
143
+ // tolerant: the query is visitor-editable, a bad value must not break boot
144
+ const { value: urlConfig, invalidKeys } = parseFromUrlTolerant(UrlConfigSchema, url);
145
+ // shortcut intents (`?selectBooth=A-12`) append after the bracketed
146
+ // `intents[…]` entries — both play, canonical form first
147
+ const shortcuts = parseIntentsFromUrl(url);
148
+ if (shortcuts.intents.length) {
149
+ urlConfig.intents = [...(urlConfig.intents ?? []), ...shortcuts.intents];
150
+ }
151
+ invalidKeys.push(...shortcuts.invalidKeys);
152
+ if (invalidKeys.length)
153
+ log('loadConfig', 'ignoring invalid URL config params:', invalidKeys);
154
+ applyConsentAlias(urlConfig);
155
+ assignDefined(config, urlConfig);
156
+ }
157
+ normalizeLegacyData(config, legacyDataUrlBase);
158
+ // `ignoreQuery` means "isolate from the page URL": don't read the rebooking
159
+ // token (`?rt=`) from it either — pass no URL so a retained session token is
160
+ // still honored but the query is not.
161
+ await applyRebookingData(config, config.ignoreQuery ? undefined : url);
162
+ // validateSchema is a load-time input (read up front by readValidateFlag), not
163
+ // a config field — strip it so the effective config never carries it, like the
164
+ // deprecated allowConsent alias.
165
+ delete config.validateSchema;
166
+ deepFreeze(config);
167
+ return config;
168
+ }
169
+ async function loadLegacyDataJs(baseUrl, version) {
170
+ // data.js is a window-assigning script like fp.svg.js — resolve() evaluates
171
+ // it to its globals and the expo data document rides on `__data`. The
172
+ // decorated ref validates the payload (gated) and makes every schema-tagged
173
+ // asset path absolute against its directory (= baseUrl).
174
+ const payload = await resolve(documentRef(legacyFileRef(baseUrl, 'data.js', version), DataJsSchema));
175
+ if (!payload.__data)
176
+ log('loadLegacyDataJs', 'no __data global in data.js at', baseUrl);
177
+ // the resolver's memoized payload is frozen — the merge and normalization
178
+ // write into an own copy (a clone, so nothing here depends on parse)
179
+ return deepClone(payload.__data ?? {});
180
+ }
181
+ /** Retry cadence while the platform is still generating the plan (`__fpPending`). */
182
+ const FP_SVG_PENDING_RETRY_MS = 2000;
183
+ /**
184
+ * Resolve `fp.svg.js` eagerly (a script evaluated to its globals live, a
185
+ * resolved `fp.svg.json` document offline). A still-generating plan serves a
186
+ * `{ __fpPending: true }` stub instead of the drawing; poll — with a fresh
187
+ * `?v=` per attempt to step around CDN caching — until the real payload lands.
188
+ * Returns the payload (for layer-ref derivation) and the ref that finally
189
+ * resolved, which is what the config must carry for `getResolved`.
190
+ */
191
+ async function loadLegacyFpSvgJs(ref, baseUrl) {
192
+ let finalRef = ref; // already decorated by loadConfig
193
+ let fpSvgJs = await resolve(finalRef);
194
+ for (let attempt = 1; fpSvgJs.__fpPending && !fpSvgJs.__fp; attempt++) {
195
+ log('loadLegacyFpSvgJs', 'plan is still generating, retry', attempt);
196
+ await sleep(FP_SVG_PENDING_RETRY_MS);
197
+ // a fresh ?v= per attempt steps around CDN caching; an explicit ref (a
198
+ // resolved offline .json is never pending-stubbed) re-resolves as-is
199
+ finalRef = baseUrl
200
+ ? documentRef(legacyFileRef(baseUrl, 'fp.svg.js', String(attempt)), FpSvgJsSchema)
201
+ : ref;
202
+ fpSvgJs = await resolve(finalRef, { forceFetch: true });
203
+ }
204
+ return { payload: fpSvgJs, ref: finalRef };
205
+ }
206
+ /**
207
+ * Warm the wayfinding graph ref (`wf.data.js` live, `wf.data.json` offline).
208
+ * A plan without a graph used to load as a silent no-op script, so a failure
209
+ * only logs; the ref stays unsettled and consumers probing with `isResolved`
210
+ * keep wayfinding off.
211
+ */
212
+ async function prefetchWfData(ref) {
213
+ if (!ref)
214
+ return;
215
+ try {
216
+ await resolve(ref); // decorated by loadConfig
217
+ }
218
+ catch (err) {
219
+ log('prefetchWfData', 'wf.data.js not loaded, wayfinding stays off', err);
220
+ }
221
+ }
222
+ /**
223
+ * Decorate one layer's drawing ref: the standard document processing, plus
224
+ * the fold of the layer-suffixed drawing globals onto the canonical keys —
225
+ * newer converters emit only the suffixed globals in layer files (see
226
+ * `normalizeFpSvgLayerAliases`).
227
+ */
228
+ function fpSvgLayerDocumentRef(ref, layerName) {
229
+ return documentRef(ref, FpSvgLayerJsSchema, (document) => normalizeFpSvgLayerAliases(document, layerName));
230
+ }
231
+ /**
232
+ * One `$ref` per non-default layer, pointing at its `fp.svg.<layerName>.js`
233
+ * (the default layer's drawing is `fp.svg.js` itself, already resolved).
234
+ * Layer data is not fetched here — a consumer resolves a layer's ref on
235
+ * demand and reads it back with `getResolved`.
236
+ */
237
+ function buildFpSvgLayerJsRefs(fpSvgJs, baseUrl, version) {
238
+ const refs = {};
239
+ for (const layer of fpSvgJs.__fpLayers ?? []) {
240
+ if (layer.name === fpSvgJs.__fpDefaultLayer)
241
+ continue;
242
+ refs[layer.name] = fpSvgLayerDocumentRef(legacyFileRef(baseUrl, `fp.svg.${layer.name}.js`, version), layer.name);
243
+ }
244
+ return refs;
245
+ }
246
+ /**
247
+ * True when the page is embedded by the ExpoFP designer, which previews
248
+ * in-progress edits: the legacy directory's files then change without a
249
+ * `version.json` bump, so the published version must not pin the `?v=`
250
+ * cache-buster. A browser-only signal — in Node (the offline CLI) there is no
251
+ * document and no designer. Mirrors the floorplan's `utils/is-from-designer`.
252
+ */
253
+ function isFromDesignerReferrer() {
254
+ if (typeof document === 'undefined' || !document.referrer)
255
+ return false;
256
+ return (document.referrer.includes('app.expofp.com') ||
257
+ document.referrer.includes('app-show.expofp.com'));
258
+ }
259
+ /** `$ref` to a file in the legacy data directory, `?v=` cache-busted like all legacy loads. */
260
+ function legacyFileRef(baseUrl, fileName, version) {
261
+ const url = `${baseUrl}${fileName}`;
262
+ return { $ref: version ? `${url}?v=${version}` : url };
263
+ }
264
+ function sleep(ms) {
265
+ return new Promise((resolve) => setTimeout(resolve, ms));
266
+ }
267
+ /**
268
+ * Fold one input layer's deprecated `allowConsent` boolean into `consent`
269
+ * (`true` → `'granted'`, `false` → `'denied'`) before that layer merges, so
270
+ * cross-layer precedence stays exact: a layer's own `consent` beats its own
271
+ * alias, and a higher layer's alias still beats a lower layer's `consent`.
272
+ */
273
+ function applyConsentAlias(layer) {
274
+ if (layer.allowConsent !== undefined) {
275
+ layer.consent ??= layer.allowConsent ? 'granted' : 'denied';
276
+ delete layer.allowConsent;
277
+ }
278
+ }
279
+ /** `Object.assign`, except a key explicitly set to `undefined` never erases a lower layer. */
280
+ function assignDefined(target, source) {
281
+ for (const [key, value] of Object.entries(source)) {
282
+ if (value !== undefined) {
283
+ target[key] = value;
284
+ }
285
+ }
286
+ }