Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
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
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'
131 changes: 131 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,131 @@
import * as z from 'zod/mini'

/**
* Header/opt-in constant for requesting `extensions.sourceMap` from XDA.
*
* @remarks
* Setting either this header or `extensions.sourceMap: {}` on the request
* body signals opt-in.
*
* @public
*/
export const EXTENSIONS_SOURCE_MAP_HEADER = 'x-contentful-extensions-sourcemap' as const

/**
* Zod schema for a variant row in the source-map, currently only the
* `personalization` type is emitted.
*
* @remarks
* The wire authority is `@contentful/view-delivery-contract`; this schema is
* a subset of it, covering only the fields read by
* {@link resolveNodeViewPayload}.
*
* @public
*/
export const SourceMapVariant = z.object({
type: z.literal('personalization'),
id: z.string(),
experienceId: z.optional(z.string()),
optimizationId: z.optional(z.string()),
variantId: z.optional(z.string()),
variantIndex: z.optional(z.number()),
})

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

/**
* Zod schema for a layer row in the source-map. Discriminated on `kind`.
*
* @remarks
* Only `Experience` and `Fragment` layers carry a `variants` reference and
* are considered attributable by
* {@link resolveNodeViewPayload}. The remaining kinds (`ComponentType`,
* `Template`, `Slot`, `InlineFragment`) are accepted so `.parse` does not
* fail on future-added fields.
*
* @public
*/
export const SourceMapLayer = z.discriminatedUnion('kind', [
z.object({ kind: z.literal('ComponentType'), id: z.string() }),
z.object({ kind: z.literal('Template'), id: z.string() }),
z.object({ kind: z.literal('Slot'), id: z.string() }),
z.object({
kind: z.literal('Experience'),
id: z.string(),
variants: z.array(z.number()),
dataAssembly: z.optional(z.number()),
}),
z.object({
kind: z.literal('Fragment'),
id: z.string(),
variants: z.array(z.number()),
dataAssembly: z.optional(z.number()),
}),
z.object({
kind: z.literal('InlineFragment'),
id: z.string(),
dataAssembly: z.optional(z.number()),
}),
])

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

/**
* Zod schema for a per-hydrated-node mapping row.
*
* @remarks
* `layers` runs leaf-to-root; `scope` indexes the nearest data-context
* boundary layer used for entity-id attribution.
*
* @public
*/
export const SourceMapNode = z.object({
layers: z.array(z.number()),
scope: z.number(),
contentProperties: z.array(z.unknown()),
})

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

/**
* Zod schema for the top-level source-map payload. Field names mirror
* `DeliveryViewSourceMapSchema` from `@contentful/view-delivery-contract` so
* `.parse` will not fail on future-added fields; only `variants`, `layers`,
* and `nodes` are read by consumers in this package.
*
* @public
*/
export const SourceMap = z.object({
version: z.literal(1),
variants: z.array(SourceMapVariant),
spaces: z.array(z.string()),
environments: z.array(z.string()),
locales: z.array(z.string()),
entries: z.array(z.unknown()),
assets: z.array(z.unknown()),
layers: z.array(SourceMapLayer),
dataAssemblies: z.array(z.unknown()),
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}.
* Insights events include {@link ViewEvent}, {@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
104 changes: 104 additions & 0 deletions packages/universal/api-schemas/src/insights/event/NodeViewEvent.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,104 @@
import * as z from 'zod/mini'
import { UniversalEventProperties } from '../../experience/event/UniversalEventProperties'

/**
* Structural kinds an `exo_node_view` event can be attributed to.
*
* @remarks
* Narrowed to the two kinds that carry a `variants` reference in the XDA
* source-map contract. `InlineFragment` and structural rows are never
* attributable and are therefore not surfaced here.
*
* @public
*/
const NodeViewEntityKind = z.union([z.literal('Experience'), z.literal('Fragment')])

/**
* Zod schema describing an `exo_node_view` event used for XDA graph node
* viewport tracking.
*
* @remarks
* These events track the exposure of rendered XDA nodes in the browser
* viewport. They are self-contained: all metadata required by the ingestor
* is embedded in the payload and no server-side lookup is needed.
*
* Unlike {@link ViewEvent}, which is entry-centric, `NodeViewEvent` is
* graph-node-centric and carries structural metadata resolved from the XDA
* `extensions.sourceMap`.
*
* @public
*/
export const NodeViewEvent = z.extend(UniversalEventProperties, {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Isn't a NodeViewEvent what was a ComponentViewEvent conceptually? I understand, that there's additional metadata but it feels a bit to Contentful specific for me instead of an open platform concept.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Yes, it's an extension of the ViewEvent concept to cover ExO-backed nodes (its' not a clean extension as some fields are renamed here to avoid clashes, e.g. (9t-)experienceId->optimizationId.
It could theoretically be made more Contentful-agnostic (even though it's of course still Contentful Optimization-specific via experienceId and variantIndex), but I'm not sure what that would buy us. It could make expressing the relationships more awkward if we're not talking about the ExO-entities directly and there is some abstraction over e.g. the concrete entities already to keep it extensible that could in principle be remapped outside Contentful. But maybe I'm not following where you're coming from with this 🤷‍♂️

@BraunreutherA BAlex (BraunreutherA) Jul 10, 2026

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

So far we have tried to create not too many events and rather stay agnostic and use the more generic fields of the events.
I just want to make sure that we're not having a long list of different event types in the future which are all pretty much the same.

The Event types have to get implemented on the backend then aswell.

/**
* Stable anonymous user identifier for this event.
*/
anonymousId: z.string(),

/**
* Discriminator identifying this as an XDA node view event.
*/
type: z.literal('exo_node_view'),

/**
* `sys.id` of the Experience or Fragment that owns this node.
*/
entityId: z.string(),

/**
* Structural kind of the owning entity.
*/
entityKind: NodeViewEntityKind,

/**
* Variant identifier selected for this node.
*
* @remarks
* Resolved from `extensions.sourceMap.variants[].variantId`, falling back to
* `extensions.sourceMap.variants[].id`.
*/
variantId: z.string(),

/**
* Variant index selected for this node.
*
* @remarks
* Resolved from `extensions.sourceMap.variants[].variantIndex`, falling back
* to the selected `extensions.sourceMap.layers[].variants[]` reference. The
* default variant is index `0`.
*/
variantIndex: z.number(),

/**
* Ninetailed experience (optimization) ID associated with this node.
*/
optimizationId: z.string(),

/**
* UUID identifying a single active view session for this node.
*
* @remarks
* Multiple events emitted for the same active view share this identifier.
*/
viewId: z.string(),

/**
* Monotonically increasing visible duration for the active view, in
* milliseconds.
*
* @remarks
* Updated and re-emitted while the same view remains active.
*/
viewDurationMs: z.number(),

/**
* `sys.id` of the parent Experience when this node is nested inside one.
*/
parentExperienceId: z.optional(z.string()),
})

/**
* TypeScript type inferred from {@link NodeViewEvent}.
*
* @public
*/
export type NodeViewEvent = z.infer<typeof NodeViewEvent>
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './BatchInsightsEvent'
export * from './ClickEvent'
export * from './HoverEvent'
export * from './InsightsEvent'
export * from './NodeViewEvent'
Loading
Loading