@expofp/config 3.28.2 → 3.29.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
@@ -32,5 +32,9 @@ been retired (`?hide=`, `?centerxy=`, `?lang=`, …) are listed in `retiredKeys`
32
32
  old link opens a clean plan instead of replaying its query as search text.
33
33
 
34
34
  Also: `applyIntents` (dispatch `selectBooth` / `changeLanguage` / … to the floor plan),
35
- `serializeConfigResources` (collect schema-tagged assets + payloads for offline copies), and
36
- `toDebugSettings` (config schema → `@expofp/debug` panel). Types re-export from `@expofp/schema`.
35
+ `serializeConfigResources` (collect schema-tagged assets + payloads for offline copies and normalized
36
+ expo folders), `resolveUrl` / `compactUrl` (the `$/<expo>/<hash>/<name>` ⇄
37
+ `https://<expo>.expofp.com/immutable/<hash>/<name>` codec of a normalized folder's store refs —
38
+ `loadConfig` follows the folder's pointer `manifest.json` to its immutable snapshot and expands the
39
+ refs, so an effective config only ever carries full URLs), and `toDebugSettings` (config schema →
40
+ `@expofp/debug` panel). Types re-export from `@expofp/schema`.
package/dist/index.d.ts CHANGED
@@ -6,6 +6,7 @@ export { type LegacyOneShot, type LegacyPrimary, type LegacyUrlQuery, parseLegac
6
6
  export { loadConfig } from './lib/load-config.js';
7
7
  export { type StorageLike } from './lib/local-storage-codec.js';
8
8
  export { type ConfigResourceStore, serializeConfigResources, } from './lib/serialize-config-resources.js';
9
+ export { compactUrl, resolveUrl } from './lib/store-ref.js';
9
10
  export { parseFromUrlTolerant } from './lib/url-codec.js';
10
11
  export { parseIntentsFromUrl } from './lib/url-intents.js';
11
12
  export { type Config, type Consent, type Manifest, type Options } from '@expofp/schema';
package/dist/index.js CHANGED
@@ -7,5 +7,6 @@ export { parseDeprecatedUrlParams } from './lib/deprecated-url-params.js';
7
7
  export { parseLegacyQuery, serializeSelection, splitPlatformParams, } from './lib/legacy-url.js';
8
8
  export { loadConfig } from './lib/load-config.js';
9
9
  export { serializeConfigResources, } from './lib/serialize-config-resources.js';
10
+ export { compactUrl, resolveUrl } from './lib/store-ref.js';
10
11
  export { parseFromUrlTolerant } from './lib/url-codec.js';
11
12
  export { parseIntentsFromUrl } from './lib/url-intents.js';
@@ -8,10 +8,11 @@ import { parseFromStorage } from './local-storage-codec.js';
8
8
  import { normalizeFpSvgLayerAliases } from './normalize-fp-svg.js';
9
9
  import { normalizeLegacyData } from './normalize-legacy-data.js';
10
10
  import { applyRebookingData } from './rebooking.js';
11
+ import { resolveUrl } from './store-ref.js';
11
12
  import { parseFromUrlTolerant } from './url-codec.js';
12
13
  import { parseIntentsFromUrl } from './url-intents.js';
13
14
  import { readValidateFlag } from './validate-flag.js';
14
- import { documentRef, setValidateManifest, validateOrClone } from './visit-resources.js';
15
+ import { documentRef, isRefShaped, setValidateManifest, validateOrClone, visitResources, } from './visit-resources.js';
15
16
  const log = debug('efp:config');
16
17
  /**
17
18
  * Assemble the effective config — the runtime's single, complete copy of the
@@ -42,11 +43,17 @@ export async function loadConfig(manifest, options, url, storage) {
42
43
  // layers merge, so it can't come off the effective config. See readValidateFlag.
43
44
  const validateSchema = readValidateFlag(url, storage);
44
45
  setValidateManifest(validateSchema); // gates the referenced-document parses (visit-resources)
45
- // Take an OWN copy of the resolved manifest (validated when the flag is on —
46
- // see validateOrClone): `resolve` deep-freezes what it settles, an offline
47
- // manifest carries the full event data (booths, exhibitors, …), and
48
- // `normalizeLegacyData` below mutates those nested objects in place.
49
- manifest = validateOrClone(ManifestSchema, await resolve(manifest));
46
+ // A normalized expo folder's mutable `manifest.json` is a tiny pointer
47
+ // (`{"$ref": "$/<expo>/<hash>/manifest.json"}`) — follow the chain down to
48
+ // the snapshot first. Then take an OWN copy of the settled manifest
49
+ // (validated when the flag is on — see validateOrClone): `resolve`
50
+ // deep-freezes what it settles, an offline or normalized manifest carries
51
+ // the full event data (booths, exhibitors, …), and `normalizeLegacyData`
52
+ // below mutates those nested objects in place. Store refs (`$/…`) expand
53
+ // into their canonical immutable URLs right here — the effective config
54
+ // only ever carries full URLs, so no consumer sees the notation.
55
+ manifest = validateOrClone(ManifestSchema, await resolveManifestChain(manifest));
56
+ await expandManifestStoreRefs(manifest);
50
57
  // The legacy event-data directory and the data-revision pin are INPUTS,
51
58
  // consumed right here — the effective config carries only the refs derived
52
59
  // from them, so neither is merged.
@@ -195,6 +202,45 @@ export async function loadConfig(manifest, options, url, storage) {
195
202
  deepFreeze(config);
196
203
  return config;
197
204
  }
205
+ /**
206
+ * Hops a manifest pointer chain may take before it must have settled. The
207
+ * normalized form needs exactly one (mutable pointer → immutable snapshot);
208
+ * the ceiling only guards against a cyclic or runaway chain.
209
+ */
210
+ const MANIFEST_POINTER_HOPS_MAX = 3;
211
+ /**
212
+ * Follow the «pointer → snapshot» chain: a settled manifest that is itself
213
+ * ref-shaped is a pointer — resolve what it points at, until a document
214
+ * settles. A store ref (`$/…`) expands by the fixed convention before its
215
+ * fetch, the input ref included; a rooted local pointer resolves against
216
+ * the page like any rooted URL. Chain-walking lives here, not in
217
+ * `@expofp/resolve` — the resolver stays one-layered and domain-free.
218
+ */
219
+ async function resolveManifestChain(input) {
220
+ let current = input;
221
+ for (let hops = 0;; hops++) {
222
+ if (isRefShaped(current))
223
+ current = { ...current, $ref: resolveUrl(current.$ref) };
224
+ const settled = await resolve(current);
225
+ if (!isRefShaped(settled))
226
+ return settled;
227
+ if (hops >= MANIFEST_POINTER_HOPS_MAX) {
228
+ throw new Error(`loadConfig: manifest pointer chain deeper than ${MANIFEST_POINTER_HOPS_MAX} at ${settled.$ref}`);
229
+ }
230
+ log('loadConfig', 'manifest is a pointer, following it to', settled.$ref);
231
+ current = { $ref: settled.$ref };
232
+ }
233
+ }
234
+ /**
235
+ * Expand every store ref (`$/…`) of a normalized manifest — asset URLs and
236
+ * document `$ref`s alike — into its canonical URL, via the one schema-guided
237
+ * walk. Passthrough for other values, so compat and offline manifests come
238
+ * out untouched.
239
+ */
240
+ async function expandManifestStoreRefs(manifest) {
241
+ const expand = (url) => resolveUrl(url);
242
+ await visitResources(ManifestSchema, manifest, { asset: expand, ref: expand });
243
+ }
198
244
  async function loadLegacyDataJs(baseUrl, version) {
199
245
  // data.js is a window-assigning script like fp.svg.js — resolve() evaluates
200
246
  // it to its globals and the expo data document rides on `__data`. The
@@ -8,7 +8,7 @@ import type { FpSvgLayerJs } from '@expofp/schema';
8
8
  * (`window['__fpBooth'] = …` — the chain had every layer script clobber the
9
9
  * shared `window.__fp`). The schemas and the floorplan know only the plain
10
10
  * `__fp`/`__fpPaths` keys, so the loader folds the alias onto them on the
11
- * payload's own copy, before the resource walk — the `svgAsset` href rewrite
11
+ * payload's own copy, before the resource walk — the `svgAsset` URL rewrite
12
12
  * then covers a folded drawing like a plain one. The folded values skip the
13
13
  * (opt-in) schema validation, which ran before the fold — the same trust the
14
14
  * untouched alias keys always had.
@@ -7,7 +7,7 @@
7
7
  * (`window['__fpBooth'] = …` — the chain had every layer script clobber the
8
8
  * shared `window.__fp`). The schemas and the floorplan know only the plain
9
9
  * `__fp`/`__fpPaths` keys, so the loader folds the alias onto them on the
10
- * payload's own copy, before the resource walk — the `svgAsset` href rewrite
10
+ * payload's own copy, before the resource walk — the `svgAsset` URL rewrite
11
11
  * then covers a folded drawing like a plain one. The folded values skip the
12
12
  * (opt-in) schema validation, which ran before the fold — the same trust the
13
13
  * untouched alias keys always had.
@@ -16,10 +16,10 @@ export declare function toAbsoluteUrl(path: string, baseUrl: string): string;
16
16
  export declare function isAbsoluteUrl(path: string): boolean;
17
17
  /** The ref's own directory — the base its document's relative paths resolve against. */
18
18
  export declare function refDirectory(ref: string): string;
19
- /** Every distinct `<image>` asset href in one SVG document string. */
20
- export declare function collectSvgImageHrefs(svg: string): string[];
21
- /** Rewrite every `<image>` asset href in one SVG document string. */
22
- export declare function rewriteSvgImageHrefs(svg: string, rewrite: (href: string) => string): string;
19
+ /** Every distinct `<image>` asset URL in one SVG document string. */
20
+ export declare function collectSvgImageAssets(svg: string): string[];
21
+ /** Rewrite every `<image>` asset URL in one SVG document string. */
22
+ export declare function rewriteSvgImageAssets(svg: string, rewrite: (url: string) => string): string;
23
23
  /** Every distinct non-`data:` `url(…)` in one CSS text. */
24
24
  export declare function collectCssUrls(css: string): string[];
25
25
  /** Rewrite every non-`data:` `url(…)` in one CSS text. */
@@ -42,37 +42,44 @@ function execAll(regex, text) {
42
42
  }
43
43
  return matches;
44
44
  }
45
- /* ── SVG documents (`svgAsset`) — `<image>` hrefs are asset paths ─────────── */
45
+ /* ── SVG documents (`svgAsset`) — an `<image>`'s URLs are asset paths ─────── */
46
46
  const IMAGE_TAG = /<image\b[^>]*>/g;
47
- const HREF_ATTR = /(\b(?:xlink:)?href\s*=\s*)(?:"([^"]*)"|'([^']*)')/g;
48
- /** In-document (`#id`) and empty hrefs are not asset URLs. */
49
- function isAssetHref(href) {
50
- return !!href && !href.startsWith('#');
47
+ /**
48
+ * The asset-bearing attributes of an `<image>`: the href it draws, and
49
+ * `data-model-3d` — the URL of the 3D model the floor plan renders *instead* of
50
+ * the image, whose href then never loads (floorplan's `layer-models.ts`). Both are
51
+ * authored relative to the drawing, so both need the same rebase; a model URL
52
+ * left behind resolves against the page and 404s.
53
+ */
54
+ const ASSET_ATTR = /(\b(?:(?:xlink:)?href|data-model-3d)\s*=\s*)(?:"([^"]*)"|'([^']*)')/g;
55
+ /** In-document (`#id`) and empty references are not asset URLs. */
56
+ function isAssetUrl(url) {
57
+ return !!url && !url.startsWith('#');
51
58
  }
52
- /** Every distinct `<image>` asset href in one SVG document string. */
53
- export function collectSvgImageHrefs(svg) {
54
- const hrefs = new Set();
59
+ /** Every distinct `<image>` asset URL in one SVG document string. */
60
+ export function collectSvgImageAssets(svg) {
61
+ const urls = new Set();
55
62
  if (!svg.includes('<image'))
56
63
  return [];
57
64
  for (const tag of svg.match(IMAGE_TAG) ?? []) {
58
- for (const match of execAll(HREF_ATTR, tag)) {
59
- const href = match[2] ?? match[3];
60
- if (isAssetHref(href))
61
- hrefs.add(href);
65
+ for (const match of execAll(ASSET_ATTR, tag)) {
66
+ const url = match[2] ?? match[3];
67
+ if (isAssetUrl(url))
68
+ urls.add(url);
62
69
  }
63
70
  }
64
- return [...hrefs];
71
+ return [...urls];
65
72
  }
66
- /** Rewrite every `<image>` asset href in one SVG document string. */
67
- export function rewriteSvgImageHrefs(svg, rewrite) {
73
+ /** Rewrite every `<image>` asset URL in one SVG document string. */
74
+ export function rewriteSvgImageAssets(svg, rewrite) {
68
75
  if (!svg.includes('<image'))
69
76
  return svg;
70
- return svg.replace(IMAGE_TAG, (tag) => tag.replace(HREF_ATTR, (match, attr, doubleQuoted, singleQuoted) => {
71
- const href = doubleQuoted ?? singleQuoted ?? '';
72
- if (!isAssetHref(href))
77
+ return svg.replace(IMAGE_TAG, (tag) => tag.replace(ASSET_ATTR, (match, attr, doubleQuoted, singleQuoted) => {
78
+ const url = doubleQuoted ?? singleQuoted ?? '';
79
+ if (!isAssetUrl(url))
73
80
  return match;
74
81
  const quote = doubleQuoted !== undefined ? '"' : "'";
75
- return `${attr}${quote}${rewrite(href)}${quote}`;
82
+ return `${attr}${quote}${rewrite(url)}${quote}`;
76
83
  }));
77
84
  }
78
85
  /* ── CSS text (`cssAsset`) — `url(…)`s are asset paths ────────────────────── */
@@ -0,0 +1,40 @@
1
+ /**
2
+ * The `$/` notation of the normalized expo form (plan G § 3) — the codec
3
+ * between a store ref and the canonical URL it aliases.
4
+ *
5
+ * Every object of an expo's content-addressed store lives at one URL fixed
6
+ * forever — `https://<expo>.expofp.com/immutable/<hash>/<name>`, the expo's
7
+ * production host whichever contour generated or serves the folder — and
8
+ * the store ref `$/<expo>/<hash>/<name>` is that URL's alias by pure prefix
9
+ * substitution. No document, page or base URL takes part, so a snapshot
10
+ * reads identically from a contour page, an integrator's host, a local
11
+ * `floorplan.js`, an inline copy or a Node tool. The two forms are
12
+ * equivalent and a creator compacts at will: shorter bytes, an explicit
13
+ * "store content" marker, and an alias that survives a domain move.
14
+ *
15
+ * The marker is unambiguous by generation invariant, not by URL illegality:
16
+ * a store only ever returns canonical URLs, and no passthrough value ever
17
+ * starts with `$/`. A ref left unexpanded by mistake breaks loudly — an
18
+ * honest 404 on `<base>/$/…` — where a `#/…` fragment would fail silently.
19
+ *
20
+ * The schema `.meta` walker (`visit-resources`) calls the codec at every
21
+ * URL-carrying place — manifest fields, document `$ref`s, CSS `url(…)`
22
+ * tokens, `<image>` hrefs of a drawing — so prose like `US$/day` is never
23
+ * touched, and the effective config only ever carries full URLs.
24
+ */
25
+ /** Whether a URL-carrying value is a store ref (`$/…`). */
26
+ export declare function isStoreRef(value: string): boolean;
27
+ /**
28
+ * Expand a store ref into the canonical URL it aliases:
29
+ * `$/demo/ZL-InrvJgYpz1-k2/logo.png` →
30
+ * `https://demo.expofp.com/immutable/ZL-InrvJgYpz1-k2/logo.png`. Any other
31
+ * value — a full URL, a rooted or relative path, a `data:` URI — passes
32
+ * through untouched. A malformed `$/…` is a generation bug and throws.
33
+ */
34
+ export declare function resolveUrl(ref: string): string;
35
+ /**
36
+ * Compact a canonical store URL into its `$/` alias — the inverse of
37
+ * {@link resolveUrl}; any other URL passes through untouched.
38
+ */
39
+ export declare function compactUrl(url: string): string;
40
+ //# sourceMappingURL=store-ref.d.ts.map
@@ -0,0 +1,56 @@
1
+ /**
2
+ * The `$/` notation of the normalized expo form (plan G § 3) — the codec
3
+ * between a store ref and the canonical URL it aliases.
4
+ *
5
+ * Every object of an expo's content-addressed store lives at one URL fixed
6
+ * forever — `https://<expo>.expofp.com/immutable/<hash>/<name>`, the expo's
7
+ * production host whichever contour generated or serves the folder — and
8
+ * the store ref `$/<expo>/<hash>/<name>` is that URL's alias by pure prefix
9
+ * substitution. No document, page or base URL takes part, so a snapshot
10
+ * reads identically from a contour page, an integrator's host, a local
11
+ * `floorplan.js`, an inline copy or a Node tool. The two forms are
12
+ * equivalent and a creator compacts at will: shorter bytes, an explicit
13
+ * "store content" marker, and an alias that survives a domain move.
14
+ *
15
+ * The marker is unambiguous by generation invariant, not by URL illegality:
16
+ * a store only ever returns canonical URLs, and no passthrough value ever
17
+ * starts with `$/`. A ref left unexpanded by mistake breaks loudly — an
18
+ * honest 404 on `<base>/$/…` — where a `#/…` fragment would fail silently.
19
+ *
20
+ * The schema `.meta` walker (`visit-resources`) calls the codec at every
21
+ * URL-carrying place — manifest fields, document `$ref`s, CSS `url(…)`
22
+ * tokens, `<image>` hrefs of a drawing — so prose like `US$/day` is never
23
+ * touched, and the effective config only ever carries full URLs.
24
+ */
25
+ const STORE_REF_PREFIX = '$/';
26
+ /** `$/<expo>/<key>` — the expo is one lowercase DNS label, the key is the rest verbatim. */
27
+ const STORE_REF = /^\$\/([a-z0-9-]+)\/(.+)$/;
28
+ /** The canonical store URL: the expo's production host, `/immutable/<key>`. */
29
+ const CANONICAL_URL = /^https:\/\/([a-z0-9-]+)\.expofp\.com\/immutable\/(.+)$/;
30
+ /** Whether a URL-carrying value is a store ref (`$/…`). */
31
+ export function isStoreRef(value) {
32
+ return value.startsWith(STORE_REF_PREFIX);
33
+ }
34
+ /**
35
+ * Expand a store ref into the canonical URL it aliases:
36
+ * `$/demo/ZL-InrvJgYpz1-k2/logo.png` →
37
+ * `https://demo.expofp.com/immutable/ZL-InrvJgYpz1-k2/logo.png`. Any other
38
+ * value — a full URL, a rooted or relative path, a `data:` URI — passes
39
+ * through untouched. A malformed `$/…` is a generation bug and throws.
40
+ */
41
+ export function resolveUrl(ref) {
42
+ if (!isStoreRef(ref))
43
+ return ref;
44
+ const match = STORE_REF.exec(ref);
45
+ if (!match)
46
+ throw new Error(`resolveUrl: malformed store ref ${JSON.stringify(ref)}`);
47
+ return `https://${match[1]}.expofp.com/immutable/${match[2]}`;
48
+ }
49
+ /**
50
+ * Compact a canonical store URL into its `$/` alias — the inverse of
51
+ * {@link resolveUrl}; any other URL passes through untouched.
52
+ */
53
+ export function compactUrl(url) {
54
+ const match = CANONICAL_URL.exec(url);
55
+ return match ? `${STORE_REF_PREFIX}${match[1]}/${match[2]}` : url;
56
+ }
@@ -21,13 +21,23 @@ import { type Ref } from '@expofp/resolve';
21
21
  import type * as z from 'zod';
22
22
  export interface ResourceHandler {
23
23
  /**
24
- * One asset URL; return the URL to write in its place. `optional` marks an
25
- * `optionalAsset`-tagged leaf — a URL minted without an existence check, so
26
- * the resource may not exist and storing it is best-effort.
24
+ * One asset URL; return the URL to write in its place, or `undefined` to
25
+ * drop the field. Dropping is only meaningful for `optional` leaves — an
26
+ * `optionalAsset` tag marks a URL minted without an existence check, so the
27
+ * resource may not exist and storing it is best-effort (a store that finds
28
+ * it missing drops the field rather than write a dead URL; today's optional
29
+ * assets are all object fields, where a drop deletes the key).
27
30
  */
28
- asset(url: string, optional?: boolean): string | Promise<string>;
31
+ asset(url: string, optional?: boolean): string | undefined | Promise<string | undefined>;
29
32
  /** A ref's resolved — and internally rewritten — document; return the new ref URL. */
30
33
  document?(refUrl: string, document: unknown): string | Promise<string>;
34
+ /**
35
+ * Rewrite a ref's URL in place WITHOUT resolving its document — the
36
+ * expansion hook for store refs (`$/…`) in a normalized manifest, where
37
+ * the document must stay lazy. When present it takes the whole ref
38
+ * branch: `document`/`documentError` do not fire on the same walk.
39
+ */
40
+ ref?(refUrl: string): string | Promise<string>;
31
41
  /**
32
42
  * Tolerate a ref that failed to resolve — the field is dropped. Omitted,
33
43
  * the failure propagates and aborts the walk; throw from here to abort
@@ -82,4 +92,8 @@ export declare function validateOrClone<T>(schema: z.ZodType, value: T): T;
82
92
  export declare function documentRef<R extends Ref<unknown>>(ref: R, schema: z.ZodType, normalize?: (document: object) => void): R;
83
93
  /** Rewrite every schema-tagged resource of `value` through `handler`, in place. */
84
94
  export declare function visitResources(schema: z.ZodType, value: object, handler: ResourceHandler, options?: VisitResourcesOptions): Promise<void>;
95
+ /** Package-internal: `loadConfig`'s pointer-chain loop probes with it too. */
96
+ export declare function isRefShaped(value: unknown): value is {
97
+ $ref: string;
98
+ };
85
99
  //# sourceMappingURL=visit-resources.d.ts.map
@@ -20,7 +20,8 @@
20
20
  import { getResolved, isResolved, resolve } from '@expofp/resolve';
21
21
  import { deepClone } from '@expofp/utils';
22
22
  import { refSchemaOf, toJsonSchema, typeMatches } from './json-schema.js';
23
- import { collectCssUrls, collectSvgImageHrefs, inlineCssImports, isAbsoluteUrl, refDirectory, rewriteCssUrls, rewriteSvgImageHrefs, toAbsoluteUrl, } from './resource-urls.js';
23
+ import { collectCssUrls, collectSvgImageAssets, inlineCssImports, isAbsoluteUrl, refDirectory, rewriteCssUrls, rewriteSvgImageAssets, toAbsoluteUrl, } from './resource-urls.js';
24
+ import { isStoreRef, resolveUrl } from './store-ref.js';
24
25
  import { timeValidation } from './validate-timing.js';
25
26
  /**
26
27
  * Gates every schema validation (`parse`) of the large expo documents —
@@ -82,7 +83,10 @@ export function documentRef(ref, schema, normalize) {
82
83
  const base = refDirectory(refUrl);
83
84
  const rebase = isAbsoluteUrl(base);
84
85
  await visitResources(schema, document, {
85
- asset: (url) => (rebase ? toAbsoluteUrl(url, base) : url),
86
+ // A store ref (`$/…` — normalized snapshots) expands by the fixed
87
+ // convention, wherever the document came from (store-ref.ts); other
88
+ // paths get the standard web rebase.
89
+ asset: (url) => (isStoreRef(url) ? resolveUrl(url) : rebase ? toAbsoluteUrl(url, base) : url),
86
90
  });
87
91
  return document;
88
92
  };
@@ -105,6 +109,10 @@ async function walk(node, value, assign, handler, options) {
105
109
  // Rooted refs are already local — honored verbatim, like every other
106
110
  // rooted resource URL.
107
111
  if (isRefShaped(value)) {
112
+ if (handler.ref) {
113
+ assign({ $ref: await handler.ref(value.$ref) });
114
+ return;
115
+ }
108
116
  if (!handler.document || value.$ref.startsWith('/'))
109
117
  return;
110
118
  const refUrl = value.$ref;
@@ -138,10 +146,10 @@ async function walk(node, value, assign, handler, options) {
138
146
  }
139
147
  else if (node?.svgAsset) {
140
148
  const rewrites = new Map();
141
- for (const href of collectSvgImageHrefs(value)) {
142
- rewrites.set(href, await handler.asset(href));
149
+ for (const url of collectSvgImageAssets(value)) {
150
+ rewrites.set(url, await handler.asset(url));
143
151
  }
144
- assign(rewriteSvgImageHrefs(value, (href) => rewrites.get(href) ?? href));
152
+ assign(rewriteSvgImageAssets(value, (url) => rewrites.get(url) ?? url));
145
153
  }
146
154
  else if (node?.cssAsset) {
147
155
  // a handler that can fetch stylesheets gets @imports spliced in first,
@@ -155,8 +163,10 @@ async function walk(node, value, assign, handler, options) {
155
163
  for (const url of collectCssUrls(css)) {
156
164
  if (external.has(url))
157
165
  continue; // a kept @import's URL stays remote
158
- // only scheme-qualified CSS urls are fetchable resources (legacy parity)
159
- if (/^[a-z][a-z0-9+.-]*:/i.test(url)) {
166
+ // Only scheme-qualified CSS urls are fetchable resources (legacy
167
+ // parity) — plus store refs (`$/…`), which a normalized snapshot
168
+ // carries there and the load must expand back symmetrically.
169
+ if (isStoreRef(url) || /^[a-z][a-z0-9+.-]*:/i.test(url)) {
160
170
  rewrites.set(url, await handler.asset(url));
161
171
  }
162
172
  }
@@ -189,7 +199,8 @@ async function walk(node, value, assign, handler, options) {
189
199
  }
190
200
  }
191
201
  }
192
- function isRefShaped(value) {
202
+ /** Package-internal: `loadConfig`'s pointer-chain loop probes with it too. */
203
+ export function isRefShaped(value) {
193
204
  return (typeof value === 'object' && value !== null && '$ref' in value && typeof value.$ref === 'string');
194
205
  }
195
206
  /** The root is an object/array container — leaves rewrite inside it. */
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@expofp/config",
3
- "version": "3.28.2",
3
+ "version": "3.29.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.28.2",
33
- "@expofp/schema": "3.28.2",
34
- "@expofp/utils": "3.28.2"
32
+ "@expofp/resolve": "3.29.0",
33
+ "@expofp/schema": "3.29.0",
34
+ "@expofp/utils": "3.29.0"
35
35
  }
36
36
  }