@expofp/config 3.11.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +35 -0
- package/dist/index.d.ts +11 -0
- package/dist/index.js +8 -0
- package/dist/lib/apply-intents.d.ts +10 -0
- package/dist/lib/apply-intents.js +24 -0
- package/dist/lib/config-store.d.ts +6 -0
- package/dist/lib/config-store.js +12 -0
- package/dist/lib/debug-settings.d.ts +16 -0
- package/dist/lib/debug-settings.js +44 -0
- package/dist/lib/json-schema.d.ts +67 -0
- package/dist/lib/json-schema.js +136 -0
- package/dist/lib/legacy-url.d.ts +167 -0
- package/dist/lib/legacy-url.js +322 -0
- package/dist/lib/load-config.d.ts +26 -0
- package/dist/lib/load-config.js +286 -0
- package/dist/lib/local-storage-codec.d.ts +44 -0
- package/dist/lib/local-storage-codec.js +76 -0
- package/dist/lib/normalize-fp-svg.d.ts +26 -0
- package/dist/lib/normalize-fp-svg.js +36 -0
- package/dist/lib/normalize-legacy-data.d.ts +13 -0
- package/dist/lib/normalize-legacy-data.js +90 -0
- package/dist/lib/rebooking.d.ts +19 -0
- package/dist/lib/rebooking.js +48 -0
- package/dist/lib/resource-urls.d.ts +52 -0
- package/dist/lib/resource-urls.js +196 -0
- package/dist/lib/serialize-config-resources.d.ts +22 -0
- package/dist/lib/serialize-config-resources.js +20 -0
- package/dist/lib/strip-defaults.d.ts +27 -0
- package/dist/lib/strip-defaults.js +55 -0
- package/dist/lib/url-codec.d.ts +46 -0
- package/dist/lib/url-codec.js +165 -0
- package/dist/lib/url-intents.d.ts +30 -0
- package/dist/lib/url-intents.js +58 -0
- package/dist/lib/validate-flag.d.ts +10 -0
- package/dist/lib/validate-flag.js +36 -0
- package/dist/lib/validate-timing.d.ts +3 -0
- package/dist/lib/validate-timing.js +52 -0
- package/dist/lib/visit-resources.d.ts +85 -0
- package/dist/lib/visit-resources.js +198 -0
- package/package.json +36 -0
|
@@ -0,0 +1,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
|
+
}
|