Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
28ef3fc
feat(api-schemas): add sourceMap and exo_view schemas
FailedSitcom May 22, 2026
0a52df0
feat(core-sdk): add trackNodeView and node-view event builder
FailedSitcom May 22, 2026
2a73cb1
feat(web-sdk): add automatic exo_view runtime and payload resolution
FailedSitcom May 22, 2026
a77d528
feat(api-schemas): add anonymousId to NodeViewEvent
FailedSitcom May 22, 2026
aa9c02a
feat(core-sdk): add NodeViewTrackingArgs and derive anonymousId in tr…
FailedSitcom May 22, 2026
3ec336b
feat(web-sdk): add configurable node view auto-tracking controls
FailedSitcom May 22, 2026
4a3e7cc
feat(web-sdk): propagate full node metadata through dataset attributes
FailedSitcom May 22, 2026
e234240
feat(react-web-sdk): add useOptimizedNode hook
FailedSitcom May 13, 2026
31be7e0
fix(react-web-sdk): clear stale node tracking attributes
FailedSitcom May 13, 2026
563ebfd
feat(react-web-sdk): stamp full node metadata in useOptimizedNode
FailedSitcom May 22, 2026
5ee64ba
refactor(web-sdk): remove autoTrackNodeViews in favour of autoTrackNo…
FailedSitcom May 22, 2026
bc512d0
refactor: improve readibility of `useOptimizedNode`
FailedSitcom May 22, 2026
1b14810
refactor: improve `createNodeViewDetector` readiblility
FailedSitcom May 22, 2026
7b4208a
refactor: improve readability of `refactor: improve readability of `r…
FailedSitcom May 22, 2026
ccc091a
Drop layers, change variant to variantId
bosunski May 27, 2026
cabdb96
Handle unavailable localStorage in web SDK
bosunski May 27, 2026
e8eb445
EXA-1487 Emit node view variant index
bosunski May 29, 2026
030c58a
EXA-1487 Prefer source map variant metadata
bosunski May 29, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@
"build:ci": "pnpm --filter @contentful/* --stream build:ci",
"build:pkgs": "pnpm build && pnpm run pack:pkgs",
"build:rsdoctor": "pnpm --filter @contentful/* --stream build:rsdoctor",
"clean": "pnpm -r --parallel clean",
"clean": "pnpm clean",
Copy link
Copy Markdown

@bosunski Bosun Egberinde (bosunski) May 29, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

pnpm help clean doesn't have -r or --parallel

"docs:generate": "typedoc",
"docs:watch": "typedoc --watch",
"format:check": "prettier . --check",
Expand Down
1 change: 1 addition & 0 deletions packages/universal/api-schemas/src/experience/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,3 +7,4 @@ export * from './ExperienceResponse'
export * from './optimization'
export * from './profile'
export * from './ResponseEnvelope'
export * from './sourceMap'
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from '@rstest/core'
import { SourceMap } from './SourceMap'

const VALID_SOURCE_MAP = {
variants: [
{ type: 'personalization', id: 'default' },
{ type: 'personalization', id: 'variant-a' },
],
layers: [
{ kind: 'Experience', id: 'exp-id', variants: [1] },
{ kind: 'Fragment', id: 'frag-id' },
],
nodes: {
'node-1': { layers: [0], scope: 0 },
'node-2': { layers: [1, 0], scope: 1 },
},
}

describe('SourceMap schema', () => {
it('accepts a valid sourceMap', () => {
const result = SourceMap.safeParse(VALID_SOURCE_MAP)

expect(result.success).toBe(true)
})

it('accepts explicit optimization metadata on variants', () => {
const result = SourceMap.safeParse({
...VALID_SOURCE_MAP,
variants: [
{ type: 'personalization', id: 'default' },
{
type: 'personalization',
id: 'variant-a',
experienceId: 'exp-id',
optimizationId: 'opt-id',
variantId: 'variant-a',
variantIndex: 1,
},
],
})

expect(result.success).toBe(true)
})

it('accepts layers without variants', () => {
const result = SourceMap.safeParse({
...VALID_SOURCE_MAP,
layers: [{ kind: 'Fragment', id: 'frag-id' }],
})

expect(result.success).toBe(true)
})

it('rejects missing nodes map', () => {
const { nodes: _removed, ...withoutNodes } = VALID_SOURCE_MAP
const result = SourceMap.safeParse(withoutNodes)

expect(result.success).toBe(false)
})

it('rejects node missing scope', () => {
const result = SourceMap.safeParse({
...VALID_SOURCE_MAP,
nodes: { 'node-1': { layers: [0] } },
})

expect(result.success).toBe(false)
})

it('accepts an empty variants array', () => {
const result = SourceMap.safeParse({ ...VALID_SOURCE_MAP, variants: [] })

expect(result.success).toBe(true)
})
})
133 changes: 133 additions & 0 deletions packages/universal/api-schemas/src/experience/sourceMap/SourceMap.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,133 @@
import * as z from 'zod/mini'

/**
* A single variant entry from the XDA `extensions.sourceMap.variants` array.
*
* @public
*/
export const SourceMapVariant = z.object({
/**
* Variant category, e.g. `'personalization'`.
*/
type: z.string(),
/**
* Variant identifier, e.g. `'default'` or a variant sys.id.
*/
id: z.string(),
/**
* Contentful Experience `sys.id` associated with this variant.
*/
experienceId: z.optional(z.string()),
/**
* Ninetailed optimization ID associated with this variant.
*/
optimizationId: z.optional(z.string()),
/**
* Selected variant identifier associated with this variant.
*/
variantId: z.optional(z.string()),
/**
* Selected variant index associated with this variant.
*/
variantIndex: z.optional(z.number()),
})

/**
* TypeScript type inferred from {@link SourceMapVariant}.
*
* @public
*/
export type SourceMapVariant = z.infer<typeof SourceMapVariant>

/**
* A structural layer from the XDA `extensions.sourceMap.layers` array.
*
* @public
*/
export const SourceMapLayer = z.object({
/**
* Structural kind of the layer.
*
* @remarks
* Possible values include `'Experience'`, `'Fragment'`,
* `'InlineFragment'`, and `'InlineComponent'`.
*/
kind: z.string(),
/**
* Contentful sys.id of the Experience or Fragment entry this layer
* represents.
*/
id: z.string(),
/**
* Optional indices into `SourceMap.variants[]` that apply to this layer.
*
* @remarks
* Present only on layers that correspond to an optimization target.
*/
variants: z.optional(z.array(z.number())),
})

/**
* TypeScript type inferred from {@link SourceMapLayer}.
*
* @public
*/
export type SourceMapLayer = z.infer<typeof SourceMapLayer>

/**
* Metadata for a single rendered node from the XDA
* `extensions.sourceMap.nodes` map.
*
* @public
*/
export const SourceMapNode = z.object({
/**
* Leaf-to-root indices into `SourceMap.layers[]` for this node.
*/
layers: z.array(z.number()),
/**
* Index of the nearest ancestor Fragment or Experience layer in
* `SourceMap.layers[]`.
*/
scope: z.number(),
})

/**
* TypeScript type inferred from {@link SourceMapNode}.
*
* @public
*/
export type SourceMapNode = z.infer<typeof SourceMapNode>

/**
* Zod schema for the `extensions.sourceMap` object returned in XDA
* responses.
*
* @remarks
* The sourceMap provides structural context for each rendered node,
* enabling the SDK to resolve entity identity and variant selection
* without additional server round-trips.
*
* @public
*/
export const SourceMap = z.object({
/**
* Flat list of variant entries referenced by layers.
*/
variants: z.array(SourceMapVariant),
/**
* Flat list of structural layers ordered leaf-to-root.
*/
layers: z.array(SourceMapLayer),
/**
* Map from rendered node ID to node metadata.
*/
nodes: z.record(z.string(), SourceMapNode),
})

/**
* TypeScript type inferred from {@link SourceMap}.
*
* @public
*/
export type SourceMap = z.infer<typeof SourceMap>
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './SourceMap'
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,23 @@ import * as z from 'zod/mini'
import { ViewEvent } from '../../experience/event'
import { ClickEvent } from './ClickEvent'
import { HoverEvent } from './HoverEvent'
import { NodeViewEvent } from './NodeViewEvent'

/**
* Zod schema describing an Insights event.
*
* @remarks
* Insights events include {@link ViewEvent},
* {@link ClickEvent}, and {@link HoverEvent}.
* {@link ClickEvent}, {@link HoverEvent}, and {@link NodeViewEvent}.
*
* @public
*/
export const InsightsEvent = z.discriminatedUnion('type', [ViewEvent, ClickEvent, HoverEvent])
export const InsightsEvent = z.discriminatedUnion('type', [
ViewEvent,
ClickEvent,
HoverEvent,
NodeViewEvent,
])

/**
* TypeScript type inferred from {@link InsightsEvent}.
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,78 @@
import { describe, expect, it } from '@rstest/core'
import { InsightsEvent } from './InsightsEvent'
import { NodeViewEvent } from './NodeViewEvent'

const BASE_UNIVERSAL = {
channel: 'web' as const,
context: {
campaign: {},
gdpr: { isConsentGiven: true },
library: { name: 'test', version: '0.0.0' },
locale: 'en-US',
},
messageId: 'msg-1',
originalTimestamp: '2024-01-01T00:00:00.000Z',
sentAt: '2024-01-01T00:00:00.000Z',
timestamp: '2024-01-01T00:00:00.000Z',
}

const VALID_NODE_VIEW = {
...BASE_UNIVERSAL,
anonymousId: 'anon-id',
type: 'exo_node_view' as const,
entityId: 'exp-sys-id',
entityKind: 'Experience' as const,
variantId: 'variant-a',
variantIndex: 1,
optimizationId: 'opt-id',
viewId: 'view-uuid',
viewDurationMs: 1500,
}

describe('NodeViewEvent schema', () => {
it('accepts a valid payload', () => {
const result = NodeViewEvent.safeParse(VALID_NODE_VIEW)

expect(result.success).toBe(true)
})

it('accepts all valid entityKind values', () => {
const kinds = ['Experience', 'Fragment', 'InlineFragment', 'InlineComponent'] as const

for (const entityKind of kinds) {
const result = NodeViewEvent.safeParse({ ...VALID_NODE_VIEW, entityKind })

expect(result.success, `entityKind=${entityKind}`).toBe(true)
}
})

it('rejects an unknown entityKind', () => {
const result = NodeViewEvent.safeParse({ ...VALID_NODE_VIEW, entityKind: 'Unknown' })

expect(result.success).toBe(false)
})

it('rejects a missing required field', () => {
const { entityId: _removed, ...withoutEntityId } = VALID_NODE_VIEW
const result = NodeViewEvent.safeParse(withoutEntityId)

expect(result.success).toBe(false)
})

it('rejects a non-number viewDurationMs', () => {
const result = NodeViewEvent.safeParse({ ...VALID_NODE_VIEW, viewDurationMs: 'long' })

expect(result.success).toBe(false)
})
})

describe('InsightsEvent discriminated union', () => {
it('discriminates exo_node_view correctly', () => {
const result = InsightsEvent.safeParse(VALID_NODE_VIEW)

expect(result.success).toBe(true)
if (result.success) {
expect(result.data.type).toBe('exo_node_view')
}
})
})
Loading
Loading