Files
vue-seo-layer/app/utils/graph.ts
Jakub Zych 160b53b2f5 feat: CMS head and one JSON-LD graph on every server-rendered page (tracer)
- Nitro middleware resolves page requests once and hands the answer to the
  render (event.context.seo); the route middleware reuses it
- useCmsSeo: html lang, hreflang links, og:locale and exactly one JSON-LD
  script with one @graph (CMS nodes plus useSchemaNode nodes, merged by @id)
- local module (configKey seo): cms (alias of public.seo.enabled),
  breadcrumbs, links.newTabHint, rootFiles, resolveFiles
- GET /api/__seo__/resolve for client navigation through the Nuxt server
- smoke harness: count=, exact header=, noheader=, reqheader=, repeated
  options, resolves/apicalls/noreport lines, --no-site; v0.1.1 suite runs
  unchanged against a backend without /site
2026-11-04 09:15:26 +01:00

51 lines
2.4 KiB
TypeScript

// 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<string, unknown>
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<string, number>()
const add = (node: SeoSchemaNode) => {
const id = typeof node['@id'] === 'string' && node['@id'] !== '' ? node['@id'] : null
const { '@context': _context, ...clean } = node as Record<string, unknown>
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 }
}