Files
vue-seo-layer/app/composables/useSchemaNode.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

45 lines
1.8 KiB
TypeScript

// Request-scoped schema.org nodes a page or another layer adds to the page's
// one JSON-LD @graph (an ItemList on a listing, a FAQPage under a FAQ block).
// useCmsSeo merges them with the CMS's nodes by @id (mergeGraph), so a page
// never carries a second JSON-LD script. The list lives on the Nuxt app
// instance, not in useState: it is rebuilt by the components' setup on the
// server and again on the client, so it never travels in the payload.
import type { MaybeRefOrGetter, ShallowRef } from 'vue'
import type { SeoSchemaNode } from '../utils/types.ts'
interface SchemaEntry {
id: number
node: MaybeRefOrGetter<SeoSchemaNode | null | undefined>
}
type AppWithNodes = { _seoSchemaNodes?: ShallowRef<SchemaEntry[]>; _seoSchemaNodeSeq?: number }
function store(): { list: ShallowRef<SchemaEntry[]>; app: AppWithNodes } {
const app = useNuxtApp() as unknown as AppWithNodes
app._seoSchemaNodes ??= shallowRef<SchemaEntry[]>([])
return { list: app._seoSchemaNodes, app }
}
/** The nodes registered for the current page, resolved (refs and getters read). */
export function useSchemaNodes(): () => SeoSchemaNode[] {
const { list } = store()
return () => list.value.map((e) => toValue(e.node)).filter((n): n is SeoSchemaNode => !!n && typeof n === 'object')
}
/**
* Adds `node` (a plain object, a ref or a getter; null adds nothing) to the
* page's @graph for as long as the calling component lives. A node with the
* @id of a CMS node is merged into it, the CMS values first.
*/
export function useSchemaNode(node: MaybeRefOrGetter<SeoSchemaNode | null | undefined>): void {
const { list, app } = store()
const id = (app._seoSchemaNodeSeq = (app._seoSchemaNodeSeq ?? 0) + 1)
list.value = [...list.value, { id, node }]
if (import.meta.client) {
onScopeDispose(() => {
list.value = list.value.filter((e) => e.id !== id)
})
}
}