// The one JSON-LD @graph of a page: the CMS's nodes (the resolve answer's // jsonld) plus the nodes layers and pages add through useSchemaNode, merged // by @id. Pure, so `node --test` drives it directly. import type { SeoJsonLd, SeoSchemaNode } from './types.ts' const SCHEMA_CONTEXT = 'https://schema.org' function isNode(value: unknown): value is SeoSchemaNode { return value !== null && typeof value === 'object' && !Array.isArray(value) } /** The nodes of a jsonld value: its @graph, or the value itself when it is a single node. */ function nodesOf(jsonld: unknown): SeoSchemaNode[] { if (!isNode(jsonld)) return [] const graph = (jsonld as SeoJsonLd)['@graph'] if (Array.isArray(graph)) return graph.filter(isNode) const { '@context': _context, ...node } = jsonld as Record return Object.keys(node).length > 0 ? [node as SeoSchemaNode] : [] } /** * Merges the CMS graph and the layer nodes into one {"@context", "@graph"} * object, or null when there is no node at all (then the page gets no * JSON-LD script). A layer node whose @id matches a node already in the * graph is merged into it, the values already there first (the CMS is the * editor's source of truth; the layer fills in what it lacks); a node * without @id or with a new @id is appended. The order of first appearance * is kept, so the output is stable between renders. */ export function mergeGraph(cmsJsonld: unknown, nodes: readonly unknown[] = []): { '@context': string; '@graph': SeoSchemaNode[] } | null { const graph: SeoSchemaNode[] = [] const byId = new Map() const add = (node: SeoSchemaNode) => { const id = typeof node['@id'] === 'string' && node['@id'] !== '' ? node['@id'] : null const { '@context': _context, ...clean } = node as Record if (id !== null && byId.has(id)) { const at = byId.get(id) as number graph[at] = { ...clean, ...graph[at] } as SeoSchemaNode return } if (id !== null) byId.set(id, graph.length) graph.push({ ...clean } as SeoSchemaNode) } for (const node of nodesOf(cmsJsonld)) add(node) for (const node of nodes) if (isNode(node)) add(node) if (graph.length === 0) return null const context = isNode(cmsJsonld) && typeof (cmsJsonld as SeoJsonLd)['@context'] === 'string' ? ((cmsJsonld as SeoJsonLd)['@context'] as string) : SCHEMA_CONTEXT return { '@context': context, '@graph': graph } }