@contentful/experiences-sdk-core 0.5.6 → 0.6.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/CHANGELOG.md +28 -0
- package/dist/debug-logger.d.ts +25 -0
- package/dist/debug-logger.js +45 -0
- package/dist/debug-logger.js.map +1 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/resolve-experience.d.ts +11 -4
- package/dist/resolve-experience.js +10 -5
- package/dist/resolve-experience.js.map +1 -1
- package/dist/types.d.ts +9 -1
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -1,3 +1,31 @@
|
|
|
1
|
+
## 0.6.0 (2026-08-05)
|
|
2
|
+
|
|
3
|
+
### 🚀 Features
|
|
4
|
+
|
|
5
|
+
- ⚠️ replace isPreview with top-level debug mode [AIS-243] ([#98](https://github.com/contentful/experiences/pull/98))
|
|
6
|
+
|
|
7
|
+
### ⚠️ Breaking Changes
|
|
8
|
+
|
|
9
|
+
- replace isPreview with top-level debug mode [AIS-243] ([#98](https://github.com/contentful/experiences/pull/98))
|
|
10
|
+
`isPreview` is removed from the render context and the nested
|
|
11
|
+
`context` option is gone. Pass `metadata` and `debug` as top-level options on
|
|
12
|
+
`fetchExperience`/`resolveExperience` and as props on the renderers.
|
|
13
|
+
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
14
|
+
* refactor: derive metadata once in sveltekit apps
|
|
15
|
+
Extract a single `metadata` object in the loaders and thread it through
|
|
16
|
+
both fetchExperience and the renderer, instead of duplicating the
|
|
17
|
+
`{ slug }` literal in +page.svelte. Addresses PR review feedback.
|
|
18
|
+
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
19
|
+
* refactor: mount DebugExperience above the tree and aggregate resolveData timing
|
|
20
|
+
Move the auto-mounted <DebugExperience> panel above the rendered tree in
|
|
21
|
+
both React and Svelte renderers (client + server) so the debug dump is
|
|
22
|
+
visible without scrolling past the experience. Update the doc comments
|
|
23
|
+
and README to match.
|
|
24
|
+
Replace per-node resolveData timing lines with a single aggregate span
|
|
25
|
+
over the whole fan-out — keeps the timing signal without emitting a log
|
|
26
|
+
line per node, which gets noisy on large trees.
|
|
27
|
+
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
|
|
28
|
+
|
|
1
29
|
## 0.5.6 (2026-08-04)
|
|
2
30
|
|
|
3
31
|
This was a version bump only for core to align it with other projects, there were no code changes.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A scoped debug logger. `scope` names the emitting subsystem (e.g. `core`,
|
|
3
|
+
* `client`, `react`) so interleaved logs stay legible.
|
|
4
|
+
*/
|
|
5
|
+
interface DebugLogger {
|
|
6
|
+
/** Log a message with optional structured data. */
|
|
7
|
+
log(message: string, data?: unknown): void;
|
|
8
|
+
/**
|
|
9
|
+
* Log a message whose payload is expensive to build. The thunk only runs
|
|
10
|
+
* when debug is enabled — keep large payload serialization out of the hot
|
|
11
|
+
* path when debug is off.
|
|
12
|
+
*/
|
|
13
|
+
lazy(message: string, getData: () => unknown): void;
|
|
14
|
+
/** Time an async span and log its duration when it settles. */
|
|
15
|
+
time<T>(label: string, fn: () => Promise<T>): Promise<T>;
|
|
16
|
+
/** True when this logger is actually emitting. */
|
|
17
|
+
readonly enabled: boolean;
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Build a debug logger. Returns a shared no-op when `debug` is falsy so
|
|
21
|
+
* callers can hold onto the result unconditionally and pay nothing when off.
|
|
22
|
+
*/
|
|
23
|
+
declare function createDebugLogger(debug: boolean | undefined, scope: string): DebugLogger;
|
|
24
|
+
|
|
25
|
+
export { type DebugLogger, createDebugLogger };
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
const PREFIX = "[experiences:debug]";
|
|
2
|
+
function now() {
|
|
3
|
+
if (typeof performance !== "undefined" && typeof performance.now === "function") {
|
|
4
|
+
return performance.now();
|
|
5
|
+
}
|
|
6
|
+
if (typeof Date !== "undefined" && typeof Date.now === "function") return Date.now();
|
|
7
|
+
return 0;
|
|
8
|
+
}
|
|
9
|
+
const NOOP_LOGGER = {
|
|
10
|
+
log() {
|
|
11
|
+
},
|
|
12
|
+
lazy() {
|
|
13
|
+
},
|
|
14
|
+
time: (_label, fn) => fn(),
|
|
15
|
+
enabled: false
|
|
16
|
+
};
|
|
17
|
+
function createDebugLogger(debug, scope) {
|
|
18
|
+
if (!debug || typeof console === "undefined") return NOOP_LOGGER;
|
|
19
|
+
const tag = `${PREFIX}[${scope}]`;
|
|
20
|
+
return {
|
|
21
|
+
enabled: true,
|
|
22
|
+
log(message, data) {
|
|
23
|
+
if (data === void 0) console.log(`${tag} ${message}`);
|
|
24
|
+
else console.log(`${tag} ${message}`, data);
|
|
25
|
+
},
|
|
26
|
+
lazy(message, getData) {
|
|
27
|
+
console.log(`${tag} ${message}`, getData());
|
|
28
|
+
},
|
|
29
|
+
async time(label, fn) {
|
|
30
|
+
const start = now();
|
|
31
|
+
try {
|
|
32
|
+
const result = await fn();
|
|
33
|
+
console.log(`${tag} \u23F1 ${label} \u2014 ${(now() - start).toFixed(1)}ms`);
|
|
34
|
+
return result;
|
|
35
|
+
} catch (err) {
|
|
36
|
+
console.log(`${tag} \u23F1 ${label} \u2014 failed after ${(now() - start).toFixed(1)}ms`, err);
|
|
37
|
+
throw err;
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
export {
|
|
43
|
+
createDebugLogger
|
|
44
|
+
};
|
|
45
|
+
//# sourceMappingURL=debug-logger.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../src/debug-logger.ts"],"sourcesContent":["/*\n * Debug logging for the SDK. The whole surface is gated on a single `debug`\n * boolean that threads through `fetchExperience` / `resolveExperience` and the\n * renderers (see `ExperienceContext.debug`). When `debug` is off, every call\n * here is a cheap no-op — nothing is logged, no strings are built (callers pass\n * thunks for anything expensive to serialize).\n *\n * This is intentionally console-based, not a pluggable logger. v1 goal is\n * \"turn it on, see what the SDK is doing\"; a customer-supplied sink can land\n * later without changing call sites (the `debug` boolean would widen to\n * `boolean | DebugSink`).\n */\n\n/** Prefix on every debug line so customers can filter their console. */\nconst PREFIX = '[experiences:debug]';\n\n/**\n * Monotonic-ish clock for span timings. Prefers `performance.now()` (present\n * in modern Node and every browser); falls back to `Date.now()`. Returns 0\n * when neither is available so timing degrades to \"no duration\" rather than\n * throwing.\n */\nfunction now(): number {\n if (typeof performance !== 'undefined' && typeof performance.now === 'function') {\n return performance.now();\n }\n if (typeof Date !== 'undefined' && typeof Date.now === 'function') return Date.now();\n return 0;\n}\n\n/**\n * A scoped debug logger. `scope` names the emitting subsystem (e.g. `core`,\n * `client`, `react`) so interleaved logs stay legible.\n */\nexport interface DebugLogger {\n /** Log a message with optional structured data. */\n log(message: string, data?: unknown): void;\n /**\n * Log a message whose payload is expensive to build. The thunk only runs\n * when debug is enabled — keep large payload serialization out of the hot\n * path when debug is off.\n */\n lazy(message: string, getData: () => unknown): void;\n /** Time an async span and log its duration when it settles. */\n time<T>(label: string, fn: () => Promise<T>): Promise<T>;\n /** True when this logger is actually emitting. */\n readonly enabled: boolean;\n}\n\nconst NOOP_LOGGER: DebugLogger = {\n log() {},\n lazy() {},\n time: (_label, fn) => fn(),\n enabled: false,\n};\n\n/**\n * Build a debug logger. Returns a shared no-op when `debug` is falsy so\n * callers can hold onto the result unconditionally and pay nothing when off.\n */\nexport function createDebugLogger(debug: boolean | undefined, scope: string): DebugLogger {\n if (!debug || typeof console === 'undefined') return NOOP_LOGGER;\n\n const tag = `${PREFIX}[${scope}]`;\n return {\n enabled: true,\n log(message, data) {\n if (data === undefined) console.log(`${tag} ${message}`);\n else console.log(`${tag} ${message}`, data);\n },\n lazy(message, getData) {\n console.log(`${tag} ${message}`, getData());\n },\n async time(label, fn) {\n const start = now();\n try {\n const result = await fn();\n console.log(`${tag} ⏱ ${label} — ${(now() - start).toFixed(1)}ms`);\n return result;\n } catch (err) {\n console.log(`${tag} ⏱ ${label} — failed after ${(now() - start).toFixed(1)}ms`, err);\n throw err;\n }\n },\n };\n}\n"],"mappings":"AAcA,MAAM,SAAS;AAQf,SAAS,MAAc;AACrB,MAAI,OAAO,gBAAgB,eAAe,OAAO,YAAY,QAAQ,YAAY;AAC/E,WAAO,YAAY,IAAI;AAAA,EACzB;AACA,MAAI,OAAO,SAAS,eAAe,OAAO,KAAK,QAAQ,WAAY,QAAO,KAAK,IAAI;AACnF,SAAO;AACT;AAqBA,MAAM,cAA2B;AAAA,EAC/B,MAAM;AAAA,EAAC;AAAA,EACP,OAAO;AAAA,EAAC;AAAA,EACR,MAAM,CAAC,QAAQ,OAAO,GAAG;AAAA,EACzB,SAAS;AACX;AAMO,SAAS,kBAAkB,OAA4B,OAA4B;AACxF,MAAI,CAAC,SAAS,OAAO,YAAY,YAAa,QAAO;AAErD,QAAM,MAAM,GAAG,MAAM,IAAI,KAAK;AAC9B,SAAO;AAAA,IACL,SAAS;AAAA,IACT,IAAI,SAAS,MAAM;AACjB,UAAI,SAAS,OAAW,SAAQ,IAAI,GAAG,GAAG,IAAI,OAAO,EAAE;AAAA,UAClD,SAAQ,IAAI,GAAG,GAAG,IAAI,OAAO,IAAI,IAAI;AAAA,IAC5C;AAAA,IACA,KAAK,SAAS,SAAS;AACrB,cAAQ,IAAI,GAAG,GAAG,IAAI,OAAO,IAAI,QAAQ,CAAC;AAAA,IAC5C;AAAA,IACA,MAAM,KAAK,OAAO,IAAI;AACpB,YAAM,QAAQ,IAAI;AAClB,UAAI;AACF,cAAM,SAAS,MAAM,GAAG;AACxB,gBAAQ,IAAI,GAAG,GAAG,WAAM,KAAK,YAAO,IAAI,IAAI,OAAO,QAAQ,CAAC,CAAC,IAAI;AACjE,eAAO;AAAA,MACT,SAAS,KAAK;AACZ,gBAAQ,IAAI,GAAG,GAAG,WAAM,KAAK,yBAAoB,IAAI,IAAI,OAAO,QAAQ,CAAC,CAAC,MAAM,GAAG;AACnF,cAAM;AAAA,MACR;AAAA,IACF;AAAA,EACF;AACF;","names":[]}
|
package/dist/index.d.ts
CHANGED
|
@@ -1,2 +1,3 @@
|
|
|
1
1
|
export { ComponentTypeNode, ComponentTypeRef, DesignPropValue, DesignToken, ExperienceContext, ExperienceNode, ExperiencePayload, ExperienceSys, ManualDesignValue, PortableRegistration, PortableRenderNode, PortableRenderPlan, PortableTemplate, ResolveContext, ResolveToken, TemplateNode, TemplateRef, ValuesByViewport, ViewportDef } from './types.js';
|
|
2
2
|
export { ResolveExperienceOptions, ResolverConfig, resolveExperience } from './resolve-experience.js';
|
|
3
|
+
export { DebugLogger, createDebugLogger } from './debug-logger.js';
|
package/dist/index.js
CHANGED
package/dist/index.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './types';\nexport { resolveExperience } from './resolve-experience';\nexport type { ResolverConfig, ResolveExperienceOptions } from './resolve-experience';\n"],"mappings":"AAAA,cAAc;AACd,SAAS,yBAAyB;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../src/index.ts"],"sourcesContent":["export * from './types';\nexport { resolveExperience } from './resolve-experience';\nexport type { ResolverConfig, ResolveExperienceOptions } from './resolve-experience';\nexport { createDebugLogger } from './debug-logger';\nexport type { DebugLogger } from './debug-logger';\n"],"mappings":"AAAA,cAAc;AACd,SAAS,yBAAyB;AAElC,SAAS,yBAAyB;","names":[]}
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { ExperiencePayload, PortableRenderPlan } from './types.js';
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
4
|
* Structural type the resolver walker depends on. Matches the React /
|
|
@@ -17,10 +17,17 @@ interface ResolverConfig {
|
|
|
17
17
|
}
|
|
18
18
|
interface ResolveExperienceOptions {
|
|
19
19
|
/**
|
|
20
|
-
*
|
|
21
|
-
*
|
|
20
|
+
* Arbitrary per-render metadata exposed to every resolver as
|
|
21
|
+
* `ctx.experience.metadata`. Flattened to a top-level option (was nested
|
|
22
|
+
* under `experience`). Defaults to `{}`.
|
|
22
23
|
*/
|
|
23
|
-
|
|
24
|
+
metadata?: Record<string, unknown>;
|
|
25
|
+
/**
|
|
26
|
+
* Observability switch. When on, `resolveExperience` logs the resolution
|
|
27
|
+
* steps and per-node `resolveData` fan-out timings. Threads through to the
|
|
28
|
+
* resolver context as `ctx.experience.debug`. Defaults to `false`.
|
|
29
|
+
*/
|
|
30
|
+
debug?: boolean;
|
|
24
31
|
}
|
|
25
32
|
/**
|
|
26
33
|
* Turns an Experience payload (XDA response shape) into a PortableRenderPlan
|
|
@@ -1,10 +1,11 @@
|
|
|
1
|
+
import { createDebugLogger } from "./debug-logger";
|
|
1
2
|
function getResolver(entry) {
|
|
2
3
|
if (typeof entry !== "object" || entry === null) return void 0;
|
|
3
4
|
const candidate = entry.resolveData;
|
|
4
5
|
return typeof candidate === "function" ? candidate : void 0;
|
|
5
6
|
}
|
|
6
7
|
const DEFAULT_EXPERIENCE = {
|
|
7
|
-
|
|
8
|
+
debug: false,
|
|
8
9
|
metadata: {},
|
|
9
10
|
viewports: []
|
|
10
11
|
};
|
|
@@ -57,12 +58,15 @@ function buildNode(node, config, nodeRefs) {
|
|
|
57
58
|
return built;
|
|
58
59
|
}
|
|
59
60
|
async function resolveExperience(payload, config, options = {}) {
|
|
61
|
+
const log = createDebugLogger(options.debug, "core");
|
|
62
|
+
log.lazy("resolveExperience called with payload", () => payload);
|
|
60
63
|
const nodeRefs = [];
|
|
61
64
|
const nodes = [];
|
|
62
65
|
for (const node of payload.nodes) {
|
|
63
66
|
const built = buildNode(node, config, nodeRefs);
|
|
64
67
|
if (built !== null) nodes.push(built);
|
|
65
68
|
}
|
|
69
|
+
log.log(`built ${nodes.length} top-level node(s); ${nodeRefs.length} declare resolveData`);
|
|
66
70
|
const templateUrn = payload.sys?.template?.sys.urn;
|
|
67
71
|
let template;
|
|
68
72
|
if (typeof templateUrn === "string" && templateUrn.length > 0) {
|
|
@@ -72,11 +76,10 @@ async function resolveExperience(payload, config, options = {}) {
|
|
|
72
76
|
};
|
|
73
77
|
}
|
|
74
78
|
const experience = {
|
|
75
|
-
|
|
76
|
-
...options.experience,
|
|
79
|
+
debug: options.debug ?? DEFAULT_EXPERIENCE.debug,
|
|
77
80
|
metadata: {
|
|
78
81
|
...DEFAULT_EXPERIENCE.metadata,
|
|
79
|
-
...options.
|
|
82
|
+
...options.metadata ?? {}
|
|
80
83
|
},
|
|
81
84
|
viewports: payload.viewports
|
|
82
85
|
};
|
|
@@ -111,7 +114,9 @@ async function resolveExperience(payload, config, options = {}) {
|
|
|
111
114
|
);
|
|
112
115
|
}
|
|
113
116
|
}
|
|
114
|
-
if (tasks.length > 0)
|
|
117
|
+
if (tasks.length > 0) {
|
|
118
|
+
await log.time(`${tasks.length} resolveData hook(s)`, () => Promise.all(tasks));
|
|
119
|
+
}
|
|
115
120
|
return {
|
|
116
121
|
viewports: payload.viewports,
|
|
117
122
|
nodes,
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/resolve-experience.ts"],"sourcesContent":["/*\n * Single async entry that turns an XDA Experience payload into a\n * runtime-neutral PortableRenderPlan ready to render.\n *\n * v1 behavior:\n * - Walk the payload's nodes recursively. Each ComponentType node becomes\n * a PortableRenderNode with `registration.componentTypeId` extracted\n * from `componentType.sys.urn` (last slash-segment).\n * - Split content + design properties onto `node.props.{content,design}`.\n * Design-prop envelopes (DesignToken / ManualDesignValue / ValuesByViewport)\n * are preserved on the IR; the design package unwraps them at render time.\n * - Template-variant nodes are skipped with a console.warn — out of v1 scope.\n * - For every component whose registration declares `resolveData`, run the\n * resolver (sync or async) in parallel with peers, and attach the result\n * to `node.props.resolved`.\n * - Unknown component-type-id is a render-time concern (handled by the\n * framework adapter via `renderUnknown`); the IR still emits the node.\n */\n\nimport type {\n ComponentTypeNode,\n DesignPropValue,\n ExperienceContext,\n ExperienceNode,\n ExperiencePayload,\n PortableRenderNode,\n PortableRenderPlan,\n PortableTemplate,\n ResolveContext,\n} from './types';\n\n/**\n * Structural type the resolver walker depends on. Matches the React /\n * Svelte adapter `Config` shape but doesn't require importing them —\n * render-core stays decoupled from any framework.\n *\n * Registry values are typed as `unknown` because each adapter accepts\n * either a bare framework component (function / Svelte class / etc.) OR\n * a config-object shape with `{ component, defaults?, resolveData? }`.\n * The resolver only cares about `resolveData`; it duck-types each entry\n * at runtime and ignores anything without it.\n */\nexport interface ResolverConfig {\n components: Record<string, unknown>;\n templates?: Record<string, unknown>;\n}\n\nfunction getResolver(\n entry: unknown\n):\n | ((ctx: ResolveContext) => Record<string, unknown> | Promise<Record<string, unknown>>)\n | undefined {\n if (typeof entry !== 'object' || entry === null) return undefined;\n const candidate = (entry as { resolveData?: unknown }).resolveData;\n return typeof candidate === 'function'\n ? (candidate as (\n ctx: ResolveContext\n ) => Record<string, unknown> | Promise<Record<string, unknown>>)\n : undefined;\n}\n\nexport interface ResolveExperienceOptions {\n /**\n * Per-render runtime context exposed to every resolver as `ctx.experience`.\n * Defaults to `{ isPreview: false, metadata: {} }`.\n */\n experience?: Partial<ExperienceContext>;\n}\n\nconst DEFAULT_EXPERIENCE: ExperienceContext = {\n isPreview: false,\n metadata: {},\n viewports: [],\n};\n\nfunction isComponentTypeNode(node: ExperienceNode): node is ComponentTypeNode {\n return 'componentType' in node;\n}\n\n/**\n * Extract the flat id (componentType or template) from its `ResourceLink`\n * URN. Real URN shapes:\n * crn:contentful:::experience:spaces/$self/environments/$self/componentTypes/<id>\n * crn:contentful:::experience:spaces/$self/environments/$self/templates/<id>\n *\n * The id is the final path segment. We split on `/` and take the last\n * non-empty piece so this also tolerates trailing slashes or alternative\n * prefix shapes.\n */\nfunction extractIdFromUrn(urn: string): string {\n const segments = urn.split('/').filter((s) => s.length > 0);\n return segments[segments.length - 1] ?? urn;\n}\n\n/**\n * Recursively turn a payload node into an IR node. The collected `nodeRefs`\n * array is for the resolver pass — every built node with a registered\n * resolver gets a reference appended so we can run them in parallel without\n * walking the tree twice.\n */\nfunction buildNode(\n node: ExperienceNode,\n config: ResolverConfig,\n nodeRefs: PortableRenderNode[]\n): PortableRenderNode | null {\n if (!isComponentTypeNode(node)) {\n if (typeof console !== 'undefined') {\n console.warn(\n '[@contentful/experiences-sdk-core] Skipping Template-variant node — Templates are not supported in v1.'\n );\n }\n return null;\n }\n\n const componentTypeId = extractIdFromUrn(node.componentType.sys.urn);\n\n const slots: Record<string, PortableRenderNode[]> = {};\n if (node.slots) {\n for (const [slotName, children] of Object.entries(node.slots)) {\n if (!Array.isArray(children)) {\n throw new TypeError(\n `Slot \"${slotName}\" on component \"${componentTypeId}\" must be an array of nodes.`\n );\n }\n const built: PortableRenderNode[] = [];\n for (const child of children) {\n const childNode = buildNode(child, config, nodeRefs);\n if (childNode === null) continue;\n built.push(childNode);\n }\n slots[slotName] = built;\n }\n }\n\n const built: PortableRenderNode = {\n registration: { componentTypeId },\n props: {\n content: { ...(node.contentProperties ?? {}) },\n design: { ...(node.designProperties ?? {}) } as Record<string, DesignPropValue>,\n },\n slots,\n };\n if (node.id) built.nodeId = node.id;\n if (getResolver(config.components[componentTypeId])) {\n nodeRefs.push(built);\n }\n return built;\n}\n\n/**\n * Turns an Experience payload (XDA response shape) into a PortableRenderPlan\n * ready to hand to a renderer. Walks the tree, classifies props, captures\n * slots, and runs any component-declared `resolveData` hooks (sync or async)\n * in parallel.\n *\n * Implementation note: the function is always async — even when no component\n * declares a resolver, the cost is one microtask. Customers get a single\n * uniform call site.\n */\nexport async function resolveExperience(\n payload: ExperiencePayload,\n config: ResolverConfig,\n options: ResolveExperienceOptions = {}\n): Promise<PortableRenderPlan> {\n // Pass 1: walk the payload into the IR. Collect refs to nodes that need\n // resolveData so pass 2 can run them in parallel without re-walking.\n const nodeRefs: PortableRenderNode[] = [];\n const nodes: PortableRenderNode[] = [];\n for (const node of payload.nodes) {\n const built = buildNode(node, config, nodeRefs);\n if (built !== null) nodes.push(built);\n }\n\n // Build the page-level template stub if the payload carries one. XDA\n // payloads don't yet emit template-level content/design properties, so\n // the IR carries empty bags.\n const templateUrn = payload.sys?.template?.sys.urn;\n let template: PortableTemplate | undefined;\n if (typeof templateUrn === 'string' && templateUrn.length > 0) {\n template = {\n templateId: extractIdFromUrn(templateUrn),\n props: { content: {}, design: {} },\n };\n }\n\n // Pass 2: run resolveData hooks for components AND the template in parallel.\n // `viewports` is always sourced from the payload — caller-supplied\n // options.experience.viewports is ignored (the list is fact, not opinion).\n const experience: ExperienceContext = {\n ...DEFAULT_EXPERIENCE,\n ...options.experience,\n metadata: {\n ...DEFAULT_EXPERIENCE.metadata,\n ...(options.experience?.metadata ?? {}),\n },\n viewports: payload.viewports,\n };\n\n const tasks: Array<Promise<void>> = [];\n\n for (const node of nodeRefs) {\n const resolver = getResolver(config.components[node.registration.componentTypeId]);\n if (!resolver) continue;\n const ctx: ResolveContext = {\n content: node.props.content,\n design: node.props.design,\n experience,\n };\n tasks.push(\n Promise.resolve(resolver(ctx)).then((resolved) => {\n node.props.resolved = resolved;\n })\n );\n }\n\n if (template) {\n const tplResolver = getResolver(config.templates?.[template.templateId]);\n if (tplResolver) {\n const ctx: ResolveContext = {\n content: template.props.content,\n design: template.props.design,\n experience,\n };\n const tpl = template;\n tasks.push(\n Promise.resolve(tplResolver(ctx)).then((resolved) => {\n tpl.props.resolved = resolved;\n })\n );\n }\n }\n\n if (tasks.length > 0) await Promise.all(tasks);\n\n return {\n viewports: payload.viewports,\n nodes,\n ...(template ? { template } : {}),\n };\n}\n"],"mappings":"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","names":["built"]}
|
|
1
|
+
{"version":3,"sources":["../src/resolve-experience.ts"],"sourcesContent":["/*\n * Single async entry that turns an XDA Experience payload into a\n * runtime-neutral PortableRenderPlan ready to render.\n *\n * v1 behavior:\n * - Walk the payload's nodes recursively. Each ComponentType node becomes\n * a PortableRenderNode with `registration.componentTypeId` extracted\n * from `componentType.sys.urn` (last slash-segment).\n * - Split content + design properties onto `node.props.{content,design}`.\n * Design-prop envelopes (DesignToken / ManualDesignValue / ValuesByViewport)\n * are preserved on the IR; the design package unwraps them at render time.\n * - Template-variant nodes are skipped with a console.warn — out of v1 scope.\n * - For every component whose registration declares `resolveData`, run the\n * resolver (sync or async) in parallel with peers, and attach the result\n * to `node.props.resolved`.\n * - Unknown component-type-id is a render-time concern (handled by the\n * framework adapter via `renderUnknown`); the IR still emits the node.\n */\n\nimport { createDebugLogger } from './debug-logger';\nimport type {\n ComponentTypeNode,\n DesignPropValue,\n ExperienceContext,\n ExperienceNode,\n ExperiencePayload,\n PortableRenderNode,\n PortableRenderPlan,\n PortableTemplate,\n ResolveContext,\n} from './types';\n\n/**\n * Structural type the resolver walker depends on. Matches the React /\n * Svelte adapter `Config` shape but doesn't require importing them —\n * render-core stays decoupled from any framework.\n *\n * Registry values are typed as `unknown` because each adapter accepts\n * either a bare framework component (function / Svelte class / etc.) OR\n * a config-object shape with `{ component, defaults?, resolveData? }`.\n * The resolver only cares about `resolveData`; it duck-types each entry\n * at runtime and ignores anything without it.\n */\nexport interface ResolverConfig {\n components: Record<string, unknown>;\n templates?: Record<string, unknown>;\n}\n\nfunction getResolver(\n entry: unknown\n):\n | ((ctx: ResolveContext) => Record<string, unknown> | Promise<Record<string, unknown>>)\n | undefined {\n if (typeof entry !== 'object' || entry === null) return undefined;\n const candidate = (entry as { resolveData?: unknown }).resolveData;\n return typeof candidate === 'function'\n ? (candidate as (\n ctx: ResolveContext\n ) => Record<string, unknown> | Promise<Record<string, unknown>>)\n : undefined;\n}\n\nexport interface ResolveExperienceOptions {\n /**\n * Arbitrary per-render metadata exposed to every resolver as\n * `ctx.experience.metadata`. Flattened to a top-level option (was nested\n * under `experience`). Defaults to `{}`.\n */\n metadata?: Record<string, unknown>;\n /**\n * Observability switch. When on, `resolveExperience` logs the resolution\n * steps and per-node `resolveData` fan-out timings. Threads through to the\n * resolver context as `ctx.experience.debug`. Defaults to `false`.\n */\n debug?: boolean;\n}\n\nconst DEFAULT_EXPERIENCE: ExperienceContext = {\n debug: false,\n metadata: {},\n viewports: [],\n};\n\nfunction isComponentTypeNode(node: ExperienceNode): node is ComponentTypeNode {\n return 'componentType' in node;\n}\n\n/**\n * Extract the flat id (componentType or template) from its `ResourceLink`\n * URN. Real URN shapes:\n * crn:contentful:::experience:spaces/$self/environments/$self/componentTypes/<id>\n * crn:contentful:::experience:spaces/$self/environments/$self/templates/<id>\n *\n * The id is the final path segment. We split on `/` and take the last\n * non-empty piece so this also tolerates trailing slashes or alternative\n * prefix shapes.\n */\nfunction extractIdFromUrn(urn: string): string {\n const segments = urn.split('/').filter((s) => s.length > 0);\n return segments[segments.length - 1] ?? urn;\n}\n\n/**\n * Recursively turn a payload node into an IR node. The collected `nodeRefs`\n * array is for the resolver pass — every built node with a registered\n * resolver gets a reference appended so we can run them in parallel without\n * walking the tree twice.\n */\nfunction buildNode(\n node: ExperienceNode,\n config: ResolverConfig,\n nodeRefs: PortableRenderNode[]\n): PortableRenderNode | null {\n if (!isComponentTypeNode(node)) {\n if (typeof console !== 'undefined') {\n console.warn(\n '[@contentful/experiences-sdk-core] Skipping Template-variant node — Templates are not supported in v1.'\n );\n }\n return null;\n }\n\n const componentTypeId = extractIdFromUrn(node.componentType.sys.urn);\n\n const slots: Record<string, PortableRenderNode[]> = {};\n if (node.slots) {\n for (const [slotName, children] of Object.entries(node.slots)) {\n if (!Array.isArray(children)) {\n throw new TypeError(\n `Slot \"${slotName}\" on component \"${componentTypeId}\" must be an array of nodes.`\n );\n }\n const built: PortableRenderNode[] = [];\n for (const child of children) {\n const childNode = buildNode(child, config, nodeRefs);\n if (childNode === null) continue;\n built.push(childNode);\n }\n slots[slotName] = built;\n }\n }\n\n const built: PortableRenderNode = {\n registration: { componentTypeId },\n props: {\n content: { ...(node.contentProperties ?? {}) },\n design: { ...(node.designProperties ?? {}) } as Record<string, DesignPropValue>,\n },\n slots,\n };\n if (node.id) built.nodeId = node.id;\n if (getResolver(config.components[componentTypeId])) {\n nodeRefs.push(built);\n }\n return built;\n}\n\n/**\n * Turns an Experience payload (XDA response shape) into a PortableRenderPlan\n * ready to hand to a renderer. Walks the tree, classifies props, captures\n * slots, and runs any component-declared `resolveData` hooks (sync or async)\n * in parallel.\n *\n * Implementation note: the function is always async — even when no component\n * declares a resolver, the cost is one microtask. Customers get a single\n * uniform call site.\n */\nexport async function resolveExperience(\n payload: ExperiencePayload,\n config: ResolverConfig,\n options: ResolveExperienceOptions = {}\n): Promise<PortableRenderPlan> {\n const log = createDebugLogger(options.debug, 'core');\n log.lazy('resolveExperience called with payload', () => payload);\n\n // Pass 1: walk the payload into the IR. Collect refs to nodes that need\n // resolveData so pass 2 can run them in parallel without re-walking.\n const nodeRefs: PortableRenderNode[] = [];\n const nodes: PortableRenderNode[] = [];\n for (const node of payload.nodes) {\n const built = buildNode(node, config, nodeRefs);\n if (built !== null) nodes.push(built);\n }\n log.log(`built ${nodes.length} top-level node(s); ${nodeRefs.length} declare resolveData`);\n\n // Build the page-level template stub if the payload carries one. XDA\n // payloads don't yet emit template-level content/design properties, so\n // the IR carries empty bags.\n const templateUrn = payload.sys?.template?.sys.urn;\n let template: PortableTemplate | undefined;\n if (typeof templateUrn === 'string' && templateUrn.length > 0) {\n template = {\n templateId: extractIdFromUrn(templateUrn),\n props: { content: {}, design: {} },\n };\n }\n\n // Pass 2: run resolveData hooks for components AND the template in parallel.\n // `viewports` is always sourced from the payload — the viewport list is fact,\n // not opinion, so it can't be overridden by the caller.\n const experience: ExperienceContext = {\n debug: options.debug ?? DEFAULT_EXPERIENCE.debug,\n metadata: {\n ...DEFAULT_EXPERIENCE.metadata,\n ...(options.metadata ?? {}),\n },\n viewports: payload.viewports,\n };\n\n const tasks: Array<Promise<void>> = [];\n\n for (const node of nodeRefs) {\n const resolver = getResolver(config.components[node.registration.componentTypeId]);\n if (!resolver) continue;\n const ctx: ResolveContext = {\n content: node.props.content,\n design: node.props.design,\n experience,\n };\n tasks.push(\n Promise.resolve(resolver(ctx)).then((resolved) => {\n node.props.resolved = resolved;\n })\n );\n }\n\n if (template) {\n const tplResolver = getResolver(config.templates?.[template.templateId]);\n if (tplResolver) {\n const ctx: ResolveContext = {\n content: template.props.content,\n design: template.props.design,\n experience,\n };\n const tpl = template;\n tasks.push(\n Promise.resolve(tplResolver(ctx)).then((resolved) => {\n tpl.props.resolved = resolved;\n })\n );\n }\n }\n\n // Time the fan-out as a whole rather than per-resolver — one aggregate line\n // keeps the timing signal without a line per node (which gets noisy fast).\n if (tasks.length > 0) {\n await log.time(`${tasks.length} resolveData hook(s)`, () => Promise.all(tasks));\n }\n\n return {\n viewports: payload.viewports,\n nodes,\n ...(template ? { template } : {}),\n };\n}\n"],"mappings":"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","names":["built"]}
|
package/dist/types.d.ts
CHANGED
|
@@ -8,9 +8,17 @@
|
|
|
8
8
|
* The *active* viewport is render-time only and lives on the framework
|
|
9
9
|
* adapter's RenderContext — exposing it here would mean async resolvers
|
|
10
10
|
* re-fire on every viewport change, which would be a footgun.
|
|
11
|
+
*
|
|
12
|
+
* `debug` is the single observability switch. When on it: emits verbose logs
|
|
13
|
+
* from `resolveExperience` and `fetchExperience`; renders the visible
|
|
14
|
+
* missing-component box (see the adapters' `MissingComponent`); and turns the
|
|
15
|
+
* default `renderUnknown` fallback into the richer debug component. It replaced
|
|
16
|
+
* the old `isPreview` render flag — a single boolean that threads through both
|
|
17
|
+
* fetch and render so a customer can't enable one half and be confused by the
|
|
18
|
+
* other.
|
|
11
19
|
*/
|
|
12
20
|
interface ExperienceContext {
|
|
13
|
-
|
|
21
|
+
debug: boolean;
|
|
14
22
|
metadata: Record<string, unknown>;
|
|
15
23
|
viewports: ViewportDef[];
|
|
16
24
|
}
|