@expofp/config 3.22.0 → 3.23.0

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 CHANGED
@@ -27,7 +27,9 @@ always spoken — `?<slug>`, `?route:B-12:A-3:false`, `?tour=t1`, `?planner=a:b&
27
27
  params like `?blue-dot=…`. `parseLegacyQuery` turns a raw `location.search` into typed commands and
28
28
  `serializeSelection` writes a selection back in the same grammar (round-trip covered by tests), so
29
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.
30
+ stores and public methods; this codec stays pure — no DOM, no stores. Keys whose capability has
31
+ been retired (`?hide=`, `?centerxy=`, `?lang=`, …) are listed in `retiredKeys` and swallowed, so an
32
+ old link opens a clean plan instead of replaying its query as search text.
31
33
 
32
34
  Also: `applyIntents` (dispatch `selectBooth` / `changeLanguage` / … to the floor plan),
33
35
  `serializeConfigResources` (collect schema-tagged assets + payloads for offline copies), and
package/dist/index.d.ts CHANGED
@@ -1,6 +1,7 @@
1
1
  export { applyIntents } from './lib/apply-intents.js';
2
2
  export { getConfig, setConfig } from './lib/config-store.js';
3
3
  export { type DebugSettingDescriptor, toDebugSettings } from './lib/debug-settings.js';
4
+ export { parseDeprecatedUrlParams } from './lib/deprecated-url-params.js';
4
5
  export { type LegacyOneShot, type LegacyPrimary, type LegacyUrlQuery, parseLegacyQuery, serializeSelection, splitPlatformParams, type UrlSelection, } from './lib/legacy-url.js';
5
6
  export { loadConfig } from './lib/load-config.js';
6
7
  export { type StorageLike } from './lib/local-storage-codec.js';
package/dist/index.js CHANGED
@@ -1,6 +1,9 @@
1
1
  export { applyIntents } from './lib/apply-intents.js';
2
2
  export { getConfig, setConfig } from './lib/config-store.js';
3
3
  export { toDebugSettings } from './lib/debug-settings.js';
4
+ // TEMPORARY, with the shim itself — the floor plan needs it for the runtime
5
+ // `applyParameters` path, which parses the query a second time
6
+ export { parseDeprecatedUrlParams } from './lib/deprecated-url-params.js';
4
7
  export { parseLegacyQuery, serializeSelection, splitPlatformParams, } from './lib/legacy-url.js';
5
8
  export { loadConfig } from './lib/load-config.js';
6
9
  export { serializeConfigResources, } from './lib/serialize-config-resources.js';
@@ -0,0 +1,33 @@
1
+ /**
2
+ * TEMPORARY compat shim for the deprecated `?noOverlay=`, `?allowConsent=` and
3
+ * `?agenda`, translated into config fields while integrators move to the
4
+ * canonical `?visibility[overlay]=`, `?consent=` and `?showList=sessions`.
5
+ *
6
+ * A module of its own, outside `UrlConfigSchema`, so the whole shim retires in
7
+ * one move: delete this file and the compiler points at every consumer; the
8
+ * keys then join `retiredKeys` in the legacy codec. `?agenda` broke when the
9
+ * panel was renamed to sessions with no URL alias left (b227ed255, Oct 2025) —
10
+ * installed mobile-SDK apps emit the old spelling, and no SDK release can fix
11
+ * them; this entry is the missing alias.
12
+ *
13
+ * Precedence: an alias is dropped when the same query carries its canonical
14
+ * key, resolved HERE rather than by merge order (`noOverlay` lands in a
15
+ * different field than `visibility[overlay]`, one the FloorPlan latches at
16
+ * load), so both consumers apply the slice without precedence logic.
17
+ */
18
+ import { type Config, type Consent, type Intent, type Visibility } from '@expofp/schema';
19
+ /** The deprecated keys for the legacy codec's partition — derived, the schema is the only list. */
20
+ export declare const DEPRECATED_URL_KEYS: string[];
21
+ /**
22
+ * The canonical readings the aliases defer to — callers that already parsed
23
+ * the query pass theirs in (`intents`: every canonical intent, bracketed
24
+ * entries before shortcut keys); otherwise the shim reads the query itself.
25
+ */
26
+ export interface CanonicalAliasTargets {
27
+ visibility?: Visibility;
28
+ consent?: Consent;
29
+ intents?: Intent[];
30
+ }
31
+ /** The deprecated query params as a config slice; `{}` when the URL has none. */
32
+ export declare function parseDeprecatedUrlParams(url: string | URL, canonical?: CanonicalAliasTargets): Partial<Config>;
33
+ //# sourceMappingURL=deprecated-url-params.d.ts.map
@@ -0,0 +1,72 @@
1
+ /**
2
+ * TEMPORARY compat shim for the deprecated `?noOverlay=`, `?allowConsent=` and
3
+ * `?agenda`, translated into config fields while integrators move to the
4
+ * canonical `?visibility[overlay]=`, `?consent=` and `?showList=sessions`.
5
+ *
6
+ * A module of its own, outside `UrlConfigSchema`, so the whole shim retires in
7
+ * one move: delete this file and the compiler points at every consumer; the
8
+ * keys then join `retiredKeys` in the legacy codec. `?agenda` broke when the
9
+ * panel was renamed to sessions with no URL alias left (b227ed255, Oct 2025) —
10
+ * installed mobile-SDK apps emit the old spelling, and no SDK release can fix
11
+ * them; this entry is the missing alias.
12
+ *
13
+ * Precedence: an alias is dropped when the same query carries its canonical
14
+ * key, resolved HERE rather than by merge order (`noOverlay` lands in a
15
+ * different field than `visibility[overlay]`, one the FloorPlan latches at
16
+ * load), so both consumers apply the slice without precedence logic.
17
+ */
18
+ import { UrlConfigSchema, } from '@expofp/schema';
19
+ import * as z from 'zod';
20
+ import { parseFromUrlTolerant } from './url-codec.js';
21
+ import { parseIntentsFromUrl } from './url-intents.js';
22
+ /**
23
+ * Parsed through the same tolerant codec as the canonical URL slice, so the
24
+ * accepted spellings stay exactly what these params always accepted.
25
+ */
26
+ const DeprecatedUrlSchema = z.object({
27
+ /** → `visibility.overlay` (inverted). */
28
+ noOverlay: z.boolean().optional(),
29
+ /** → `consent`: `true` → `'granted'`, `false` → `'denied'`. */
30
+ allowConsent: z.boolean().optional(),
31
+ /** → the `showList` intent on `sessions`. A string: the SDKs emit a bare `?agenda`. */
32
+ agenda: z.string().optional(),
33
+ });
34
+ /** The deprecated keys for the legacy codec's partition — derived, the schema is the only list. */
35
+ export const DEPRECATED_URL_KEYS = Object.keys(DeprecatedUrlSchema.shape);
36
+ /** The deprecated query params as a config slice; `{}` when the URL has none. */
37
+ export function parseDeprecatedUrlParams(url, canonical = parseCanonicalTargets(url)) {
38
+ const { value } = parseFromUrlTolerant(DeprecatedUrlSchema, url);
39
+ const slice = {};
40
+ // stays the `noOverlay` config field: routing through it leaves the
41
+ // visibility node untouched — exactly what this param always did
42
+ if (value.noOverlay !== undefined && canonical.visibility?.overlay === undefined) {
43
+ slice.noOverlay = value.noOverlay;
44
+ }
45
+ if (value.allowConsent !== undefined && canonical.consent === undefined) {
46
+ slice.consent = value.allowConsent ? 'granted' : 'denied';
47
+ }
48
+ // the panel is `sessions` — and any canonical list panel wins: two
49
+ // `showList` intents would play in sequence, the alias taking the screen
50
+ if (value.agenda !== undefined && !opensList(canonical.intents)) {
51
+ slice.intents = [{ name: 'showList', args: ['sessions'] }];
52
+ }
53
+ return slice;
54
+ }
55
+ function opensList(intents) {
56
+ return (intents ?? []).some((intent) => intent.name === 'showList');
57
+ }
58
+ /** The canonical keys each alias defers to, read from the same query. */
59
+ const CanonicalUrlSchema = UrlConfigSchema.pick({
60
+ visibility: true,
61
+ consent: true,
62
+ intents: true,
63
+ });
64
+ /** Self-parse fallback: the same canonical readings, from the query itself. */
65
+ function parseCanonicalTargets(url) {
66
+ const { value } = parseFromUrlTolerant(CanonicalUrlSchema, url);
67
+ return {
68
+ visibility: value.visibility,
69
+ consent: value.consent,
70
+ intents: [...(value.intents ?? []), ...parseIntentsFromUrl(url).intents],
71
+ };
72
+ }
@@ -27,8 +27,12 @@
27
27
  * side stays alive for links already shared in the wild, but only as a
28
28
  * translator into those two channels; the sole legitimate consumer is
29
29
  * `packages/floorplan/src/services/url-dispatch.ts` (lint-enforced).
30
+ *
31
+ * Keys whose capability has been retired keep one line each in
32
+ * {@link retiredKeys} — no translation, just enough for an old link to degrade
33
+ * to a clean plan instead of replaying as search text.
30
34
  */
31
- import { type Camera, type ListPanel } from '@expofp/schema';
35
+ import { type ListPanel } from '@expofp/schema';
32
36
  /** The list panels addressable by keyword (`?bookmarks`, `?sessions`, …). */
33
37
  export type LegacyListPanel = ListPanel;
34
38
  /**
@@ -57,22 +61,6 @@ export type LegacyPrimary = {
57
61
  accessible: boolean;
58
62
  waypoints: string[];
59
63
  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
64
  } | {
77
65
  type: 'selectExhibitors';
78
66
  values: string[];
@@ -112,13 +100,6 @@ export type LegacyOneShot = {
112
100
  raw: string;
113
101
  } | {
114
102
  type: 'resetUiScale';
115
- } | {
116
- type: 'legacyBookmarks';
117
- appendExhibitorId: number | null;
118
- } | {
119
- type: 'camera';
120
- camera: Camera;
121
- roll?: number;
122
103
  };
123
104
  export interface LegacyUrlQuery {
124
105
  primary: LegacyPrimary;
@@ -27,9 +27,14 @@
27
27
  * side stays alive for links already shared in the wild, but only as a
28
28
  * translator into those two channels; the sole legitimate consumer is
29
29
  * `packages/floorplan/src/services/url-dispatch.ts` (lint-enforced).
30
+ *
31
+ * Keys whose capability has been retired keep one line each in
32
+ * {@link retiredKeys} — no translation, just enough for an old link to degrade
33
+ * to a clean plan instead of replaying as search text.
30
34
  */
31
35
  import { IntentsSchema, UrlConfigSchema } from '@expofp/schema';
32
36
  import { safeDecode } from '@expofp/utils';
37
+ import { DEPRECATED_URL_KEYS } from './deprecated-url-params.js';
33
38
  /* ── Parse: location.search → commands ─────────────────────────────────────── */
34
39
  /** Query keys consumed by `loadConfig` before dispatch: config slice + intent shortcuts. */
35
40
  const configOwnedKeys = new Set([
@@ -39,11 +44,86 @@ const configOwnedKeys = new Set([
39
44
  /** One-shot keys the parser consumes into commands (never part of the slug). */
40
45
  const oneShotKeys = new Set(['blue-dot', 'viewermode', 'previewMode', 'uiscale', 'resetuiscale']);
41
46
  /**
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.
47
+ * RETIRED keys — capability gone, only swallowed (in `key=value` form: a bare
48
+ * slug that merely spells one of these words still resolves via the catalog),
49
+ * so an old link opens a clean plan instead of replaying as search text. The
50
+ * list only ever shrinks; a parameter that still DOES something belongs in
51
+ * `UrlConfigSchema` or `IntentsSchema`, never here.
52
+ *
53
+ * | retired | canonical form today |
54
+ * | ---------------------- | ------------------------------- |
55
+ * | `centerxy=x,y` | `camera[x]` / `camera[y]` |
56
+ * | `center=lat,lng` | `camera[lat]` / `camera[lng]` |
57
+ * | `z=` | `camera[floor]` |
58
+ * | `bearing=` | `camera[bearing]` |
59
+ * | `zoomtime=` | `camera[zoomTime]` |
60
+ * | `roll=` | — internal renderer knob |
61
+ * | `hide=a,b` | `visibility[a]=false` |
62
+ * | `lang=` | `changeLanguage=` |
63
+ * | `preview=` | — the preview link needs none |
64
+ * | `copy_exh=` | — a removed development seed |
65
+ * | `pathway=id` | `showPathway[0]=id` |
66
+ * | `booths=a,b` | — part of `showPathway` |
67
+ * | `b=` / `ba=` | — bookmark import, removed |
68
+ * | `-pdf` | — a print mode that never was |
69
+ * | `build-route` | `startBuildRoute` |
70
+ * | `sw=` / `yah=` / `k=` | — readers live on in legacy |
71
+ * | `kiosk_<framing>=` | `kiosk[…]` — the anchor node |
72
+ *
73
+ * `sw`/`yah`/`k` still have readers in the legacy runtime (service-worker
74
+ * toggle, YAH-marker command, saved-kiosk id) but none here — the mobile SDK
75
+ * enums emit them and they die out with app installs; their sibling `?agenda`
76
+ * is translated by the shim instead. `kiosk_<framing>` is the 11 fields the
77
+ * old 14-key QR channel spoke beyond the surviving `kiosk_x/y/z` (those
78
+ * partition via {@link directReadKeys}); unswallowed, one replayed as search
79
+ * text — and beside a route slug its tail fused into the last colon-part,
80
+ * silently flipping the accessible flag. `tour=`
81
+ * keeps its live meaning (`selectTour` covers what the pathway link used it
82
+ * for), and retiring `b=`/`ba=` as plain keys drops their emulated query wipe:
83
+ * one-shots beside them now apply.
84
+ */
85
+ const retiredKeys = new Set([
86
+ 'centerxy',
87
+ 'center',
88
+ 'z',
89
+ 'bearing',
90
+ 'zoomtime',
91
+ 'roll',
92
+ 'hide',
93
+ 'lang',
94
+ 'preview',
95
+ 'copy_exh',
96
+ 'pathway',
97
+ 'booths',
98
+ 'b',
99
+ 'ba',
100
+ 'sw',
101
+ 'yah',
102
+ 'k',
103
+ // the framing/cosmetic 11 of the old 14-key kiosk QR channel — `kiosk_x/y/z`
104
+ // live on in directReadKeys, and the field names mirror KioskSchema
105
+ 'kiosk_heading',
106
+ 'kiosk_lat',
107
+ 'kiosk_lng',
108
+ 'kiosk_iconSizePercent',
109
+ 'kiosk_uiScale',
110
+ 'kiosk_mapRollDegrees',
111
+ 'kiosk_mapInitialPtScale',
112
+ 'kiosk_mapPtScale',
113
+ 'kiosk_mapCenterX',
114
+ 'kiosk_mapCenterY',
115
+ 'kiosk_mapPitch',
116
+ ]);
117
+ /** Retired keywords: a whole-slug word rather than a `key=value` pair. */
118
+ const retiredKeywords = new Set(['-pdf', 'build-route']);
119
+ /**
120
+ * The deprecated aliases the `loadConfig` compat shim still consumes — config
121
+ * keys as far as this codec is concerned, so they partition with
122
+ * `configOwnedKeys` and match in both spellings (which is what catches the
123
+ * SDKs' valueless `?agenda`, at the cost of a booth slugged `agenda`).
124
+ * TEMPORARY: when the shim goes, move these into `retiredKeys`.
45
125
  */
46
- const legacyCameraKeys = new Set(['centerxy', 'center', 'z', 'bearing', 'zoomtime', 'roll']);
126
+ const deprecatedKeys = new Set(DEPRECATED_URL_KEYS);
47
127
  /**
48
128
  * Query keys owned by the DELIVERY PLATFORM, not the floor plan: `?expo=` is
49
129
  * the routing param the sha-delivery platform materializes for expo-aware
@@ -61,31 +141,40 @@ const legacyCameraKeys = new Set(['centerxy', 'center', 'z', 'bearing', 'zoomtim
61
141
  * whereas config keys are instructions riding alongside the selection state.
62
142
  */
63
143
  const platformParamKeys = new Set(['expo']);
144
+ /**
145
+ * Query keys the floor plan reads STRAIGHT from `location.search`: `?layer=`
146
+ * (init-layers) and `?kiosk_x/y/z` (the anchor a kiosk's route QR hands the
147
+ * scanning phone). Spelled out here — this package must not depend on the
148
+ * floor plan — so a reader change has to be mirrored. They partition like the
149
+ * platform's params: stripped in `key=value` form only, never `ownedByConfig`.
150
+ * Unpartitioned they replayed as search text, and the route QR's own link
151
+ * (`?route:…:true&kiosk_x=…`) lost its accessible flag — the 4th colon-part of
152
+ * a slug that had swallowed the whole `&kiosk_*` tail.
153
+ */
154
+ const directReadKeys = new Set(['layer', 'kiosk_x', 'kiosk_y', 'kiosk_z']);
155
+ /**
156
+ * Companion keys of a primary form — read from `params` by its classifier,
157
+ * never part of the slug: `&title=` names a shared route (the route branch
158
+ * reads it; no canonical form exists). Stripped in `key=value` form only, like
159
+ * the partitions above — left in place, the tail fused into the route slug's
160
+ * last colon-part, where it silently flipped `:true` to false or ate a
161
+ * trailing FROM. A bare slug that merely spells `title` still resolves via
162
+ * the catalog. (The planner's `&source=`/`&from=` companions need no entry:
163
+ * that branch classifies on `params` before the slug is consulted, and the
164
+ * slug of a params-classified query is never replayed as search text.)
165
+ */
166
+ const companionKeys = new Set(['title']);
64
167
  export function parseLegacyQuery(search) {
65
168
  const rawQuery = search.startsWith('?') ? search.slice(1) : search;
66
169
  // the legacy contract: the payload is one encodeURIComponent unit
67
170
  const decoded = safeDecode(rawQuery);
68
171
  const params = new URLSearchParams(decoded);
69
- const oneShots = collectOneShots(rawQuery, params);
70
- // `?preview=`, `?b=`/`?ba=` (leading param) and `?copy_exh` historically
71
- // wiped the whole query before the other one-shots could see it — only the
72
- // heatmap flags (processed first) and the bookmark redirect itself survive
73
- const wiped = rawQuery.startsWith('preview=') ||
74
- rawQuery.startsWith('b=') ||
75
- rawQuery.startsWith('ba=') ||
76
- decoded.includes('copy_exh');
77
- if (wiped) {
78
- return {
79
- primary: { type: 'select', slug: '' },
80
- oneShots: oneShots.filter((s) => s.type === 'heatmap' || s.type === 'legacyBookmarks'),
81
- };
82
- }
83
172
  return {
84
- primary: classifyPrimary(residualSlug(decoded), params, hasConfigOwnedParts(decoded)),
85
- oneShots,
173
+ primary: classifyPrimary(residualSlug(decoded), params, hasOwnedParts(decoded)),
174
+ oneShots: collectOneShots(params),
86
175
  };
87
176
  }
88
- function classifyPrimary(slug, params, configOwned) {
177
+ function classifyPrimary(slug, params, owned) {
89
178
  if (params.has('kiosk')) {
90
179
  const value = params.get('kiosk');
91
180
  return { type: 'kiosk', enabled: value === '1' ? true : value === '0' ? false : null };
@@ -119,50 +208,36 @@ function classifyPrimary(slug, params, configOwned) {
119
208
  return { type: 'list', list: 'visited' };
120
209
  if (slug === 'language')
121
210
  return { type: 'list', list: 'language' };
122
- if (params.has('lang')) {
123
- return { type: 'language', langId: params.get('lang')?.toLowerCase() };
124
- }
125
211
  if (slug === 'sessions')
126
212
  return { type: 'list', list: 'sessions' };
127
213
  if (slug === 'exhibitors')
128
214
  return { type: 'list', list: 'exhibitors' };
129
215
  if (slug === 'speakers')
130
216
  return { type: 'list', list: 'speakers' };
131
- if (slug === '-pdf')
132
- return { type: 'printPdf' };
133
- if (slug.startsWith('hide')) {
134
- const hidden = (new URLSearchParams(slug).get('hide') ?? '').split(',').filter(Boolean);
135
- return { type: 'visibility', hidden };
136
- }
137
- if (slug === 'build-route')
138
- return { type: 'buildRoute' };
139
- const pathwayId = params.get('pathway');
140
- if (pathwayId) {
141
- return {
142
- type: 'pathway',
143
- pathwayId,
144
- boothIds: params.get('booths')?.split(',') ?? [],
145
- exhibitorIds: params.get('exhibitors')?.split(',') ?? [],
146
- tourId: params.get('tour') ?? undefined,
147
- };
148
- }
149
- if (slug.startsWith('exhibitors') && slug.includes('=')) {
150
- return { type: 'selectExhibitors', values: slug.split('=')[1].split(',') };
217
+ if (slug.startsWith('exhibitors=')) {
218
+ const value = slug.slice('exhibitors='.length);
219
+ // `?exhibitors=` and `?exhibitors=true` are exactly the two values the
220
+ // FILTER manager reads as "open the exhibitors list" — classified as a
221
+ // selection, the link also selected an exhibitor named "true". Anything
222
+ // else after `exhibitors=` is a display-name list.
223
+ if (value === '' || value === 'true')
224
+ return { type: 'ownedByFilters' };
225
+ return { type: 'selectExhibitors', values: value.split(',') };
151
226
  }
152
227
  if (slug.includes('=') && (slug.startsWith('categories=') || /^poiTypes?=/.test(slug))) {
153
228
  return { type: 'ownedByFilters' };
154
229
  }
155
- // Last, after every params-based branch above (`?kiosk=1&camera[x]=5` is
156
- // still the kiosk toggle): an empty slug that got empty because config/
157
- // intent keys owned the whole query is NOT the "clear the selection"
158
- // command a genuinely empty query is — replaying it as `select('')` would
159
- // close whatever is open.
160
- if (!slug && configOwned) {
230
+ // Last, after every params-based branch above: a slug emptied because
231
+ // config/intent keys (or the deprecated aliases) owned the whole query is
232
+ // NOT the clear-selection command an empty query is — `select('')` would
233
+ // close whatever is open. Retired-only queries deliberately ARE (see
234
+ // hasOwnedParts).
235
+ if (!slug && owned) {
161
236
  return { type: 'ownedByConfig' };
162
237
  }
163
238
  return { type: 'select', slug };
164
239
  }
165
- function collectOneShots(rawQuery, params) {
240
+ function collectOneShots(params) {
166
241
  const oneShots = [];
167
242
  if (params.get('heatmap') === 'true') {
168
243
  oneShots.push({
@@ -171,14 +246,6 @@ function collectOneShots(rawQuery, params) {
171
246
  kiosk: params.get('subtype') === 'kiosk',
172
247
  });
173
248
  }
174
- if (rawQuery.startsWith('b=') || rawQuery.startsWith('ba=')) {
175
- const appendRaw = new URLSearchParams(rawQuery).get('ba');
176
- const appendExhibitorId = appendRaw === null ? null : parseInt(appendRaw, 10);
177
- oneShots.push({
178
- type: 'legacyBookmarks',
179
- appendExhibitorId: Number.isFinite(appendExhibitorId) ? appendExhibitorId : null,
180
- });
181
- }
182
249
  const blueDot = params.get('blue-dot');
183
250
  if (blueDot !== null) {
184
251
  const p = blueDot.split(',');
@@ -218,87 +285,68 @@ function collectOneShots(rawQuery, params) {
218
285
  const valid = !Number.isNaN(scale) && scale >= 0.8 && scale <= 1.4;
219
286
  oneShots.push({ type: 'uiScale', scale: valid ? scale : null, raw: uiScaleRaw });
220
287
  }
221
- const camera = legacyCamera(params);
222
- if (camera) {
223
- oneShots.push(camera);
224
- }
225
288
  return oneShots;
226
289
  }
227
- /** Translate the legacy camera params into the schema-typed `Camera` shape. */
228
- function legacyCamera(params) {
229
- const camera = {};
230
- const centerxy = numberPair(params.get('centerxy'));
231
- if (centerxy)
232
- [camera.x, camera.y] = centerxy;
233
- const center = numberPair(params.get('center'));
234
- if (center)
235
- [camera.lat, camera.lng] = center;
236
- const floor = params.get('z');
237
- if (floor)
238
- camera.floor = floor;
239
- const bearing = finiteNumber(params.get('bearing'));
240
- if (bearing !== undefined)
241
- camera.bearing = bearing;
242
- const zoomTime = finiteNumber(params.get('zoomtime'));
243
- if (zoomTime !== undefined)
244
- camera.zoomTime = zoomTime;
245
- const roll = finiteNumber(params.get('roll'));
246
- if (!Object.keys(camera).length && roll === undefined)
247
- return null;
248
- return roll === undefined ? { type: 'camera', camera } : { type: 'camera', camera, roll };
290
+ /** A retired part, matched in `key=value` form only (see {@link retiredKeys}). */
291
+ function isRetiredPart(part) {
292
+ if (retiredKeywords.has(part))
293
+ return true;
294
+ return retiredKeys.has(part.split('=')[0]) && part.includes('=');
249
295
  }
250
- function numberPair(value) {
251
- if (!value)
252
- return undefined;
253
- const parts = value.split(',').map(parseFloat);
254
- if (parts.length !== 2 || !parts.every(Number.isFinite))
255
- return undefined;
256
- return [parts[0], parts[1]];
296
+ /**
297
+ * A part `loadConfig` consumes: a config-slice key, an intent shortcut, or a
298
+ * deprecated alias — bracketed sub-keys and the valueless spelling included.
299
+ */
300
+ function isConsumedByConfig(part) {
301
+ const rawKey = part.split('=')[0];
302
+ // the legacy scalar `?heatmap=true` is slug-resident (see residualSlug)
303
+ if (rawKey === 'heatmap')
304
+ return false;
305
+ const key = rawKey.split('[')[0];
306
+ return configOwnedKeys.has(key) || deprecatedKeys.has(key);
257
307
  }
258
- function finiteNumber(value) {
259
- if (value === null || value === '')
260
- return undefined;
261
- const parsed = parseFloat(value);
262
- return Number.isFinite(parsed) ? parsed : undefined;
308
+ /**
309
+ * Whether the query holds a part the config layer consumes. Retired and
310
+ * one-shot keys deliberately do NOT count: a query made only of those means
311
+ * nothing, keeping the legacy empty select (which closes the open panel).
312
+ */
313
+ function hasOwnedParts(decoded) {
314
+ return decoded.split('&').some((part) => !!part && isConsumedByConfig(part));
263
315
  }
264
316
  /**
265
317
  * The slug is the decoded query minus every key another consumer owns: the
266
- * config layer's keys and intent shortcuts (read by `loadConfig`), the
267
- * one-shot keys consumed above, and the platform's routing params.
268
- * `heatmap`/`type`/`subtype` stay in the slug —
269
- * heatmap mode historically froze the URL with them in place (its data loader
270
- * re-reads them), and the select fallback knows not to treat a query carrying
271
- * `heatmap=true` as search text.
318
+ * retired keys swallowed above, the platform's routing params, the keys the
319
+ * floor plan reads straight from the query ({@link directReadKeys}), the
320
+ * grammar's own companion keys ({@link companionKeys}), the config layer's
321
+ * keys and intent shortcuts (read by `loadConfig`), and the one-shot keys. `heatmap`/`type`/`subtype` stay in the slug — heatmap mode historically
322
+ * froze the URL with them in place (its data loader re-reads them), and the
323
+ * select fallback knows not to treat a query carrying `heatmap=true` as search
324
+ * text.
272
325
  */
273
- function hasConfigOwnedParts(decoded) {
274
- return decoded.split('&').some((part) => {
275
- const rawKey = part.split('=')[0];
276
- // the legacy scalar `?heatmap=true` is slug-resident (see residualSlug)
277
- if (rawKey === 'heatmap')
278
- return false;
279
- return configOwnedKeys.has(rawKey.split('[')[0]);
280
- });
281
- }
282
326
  function residualSlug(decoded) {
283
327
  return decoded
284
328
  .split('&')
285
329
  .filter((part) => {
286
330
  if (!part)
287
331
  return false;
288
- const key = part.split('=')[0].split('[')[0];
289
- if (legacyCameraKeys.has(key) && part.includes('='))
332
+ if (isRetiredPart(part))
290
333
  return false;
291
- if (platformParamKeys.has(key) && part.includes('='))
334
+ const key = part.split('=')[0].split('[')[0];
335
+ // all stripped in `key=value` form only, so a booth named "expo",
336
+ // "layer" or "title" still resolves via the catalog
337
+ if ((platformParamKeys.has(key) || directReadKeys.has(key) || companionKeys.has(key)) &&
338
+ part.includes('=')) {
292
339
  return false;
340
+ }
293
341
  // Carve-out (retirement plan §4.8): `heatmap` is a UrlConfigSchema key
294
342
  // now, so the canonical bracketed `heatmap[…]=` form is config-owned and
295
343
  // strips like any other — but the legacy scalar `?heatmap=true` must
296
- // KEEP the slug residency described above (the select fallback
297
- // recognizes the literal `heatmap=true`; the frozen URL carries it for
298
- // the data loader). Discriminate on the raw key: no bracket ⇒ legacy.
299
- if (part.split('=')[0] === 'heatmap')
300
- return true;
301
- return !configOwnedKeys.has(key) && !oneShotKeys.has(key);
344
+ // KEEP its slug residency (the select fallback recognizes the literal
345
+ // `heatmap=true`; the frozen URL carries it for the data loader).
346
+ // `isConsumedByConfig` discriminates on the raw key: no bracket ⇒ legacy.
347
+ if (isConsumedByConfig(part))
348
+ return false;
349
+ return !oneShotKeys.has(key);
302
350
  })
303
351
  .join('&');
304
352
  }
@@ -3,6 +3,7 @@ import { resolve } from '@expofp/resolve';
3
3
  import { ConfigDefaults, DataJsSchema, FpSvgJsSchema, FpSvgLayerJsSchema, LocalStorageConfigSchema, ManifestSchema, OptionsSchema, UrlConfigSchema, WfDataJsSchema, } from '@expofp/schema';
4
4
  import { deepClone, deepFreeze } from '@expofp/utils';
5
5
  import debug from 'debug';
6
+ import { parseDeprecatedUrlParams } from './deprecated-url-params.js';
6
7
  import { parseFromStorage } from './local-storage-codec.js';
7
8
  import { normalizeFpSvgLayerAliases } from './normalize-fp-svg.js';
8
9
  import { normalizeLegacyData } from './normalize-legacy-data.js';
@@ -162,13 +163,24 @@ export async function loadConfig(manifest, options, url, storage) {
162
163
  // shortcut intents (`?selectBooth=A-12`) append after the bracketed
163
164
  // `intents[…]` entries — both play, canonical form first
164
165
  const shortcuts = parseIntentsFromUrl(url);
165
- if (shortcuts.intents.length) {
166
- urlConfig.intents = [...(urlConfig.intents ?? []), ...shortcuts.intents];
167
- }
166
+ const canonicalIntents = [...(urlConfig.intents ?? []), ...shortcuts.intents];
167
+ // TEMPORARY: the deprecated shim, handed the canonical readings parsed
168
+ // above so it does not re-parse the query. Its fields merge first so the
169
+ // canonical keys below win; its `?agenda` intent appends, since assigning
170
+ // the slice would drop what the canonical keys put there. Delete with the
171
+ // module.
172
+ const { intents: aliasIntents = [], ...deprecatedFields } = parseDeprecatedUrlParams(url, {
173
+ visibility: urlConfig.visibility,
174
+ consent: urlConfig.consent,
175
+ intents: canonicalIntents,
176
+ });
177
+ const intents = [...canonicalIntents, ...aliasIntents];
178
+ if (intents.length)
179
+ urlConfig.intents = intents;
168
180
  invalidKeys.push(...shortcuts.invalidKeys);
169
181
  if (invalidKeys.length)
170
182
  log('loadConfig', 'ignoring invalid URL config params:', invalidKeys);
171
- applyConsentAlias(urlConfig);
183
+ assignDefined(config, deprecatedFields);
172
184
  assignDefined(config, urlConfig);
173
185
  }
174
186
  normalizeLegacyData(config, legacyDataUrlBase);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/config",
3
- "version": "3.22.0",
3
+ "version": "3.23.0",
4
4
  "type": "module",
5
5
  "description": "ExpoFP SDK internal: config layer and schemas",
6
6
  "homepage": "https://developer.expofp.com/",
@@ -29,8 +29,8 @@
29
29
  "debug": "^4.4.3",
30
30
  "tslib": "^2.3.0",
31
31
  "zod": "4.4.3",
32
- "@expofp/resolve": "3.22.0",
33
- "@expofp/utils": "3.22.0",
34
- "@expofp/schema": "3.22.0"
32
+ "@expofp/resolve": "3.23.0",
33
+ "@expofp/schema": "3.23.0",
34
+ "@expofp/utils": "3.23.0"
35
35
  }
36
36
  }