- 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
51 lines
2.4 KiB
TypeScript
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 }
|
|
}
|