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
This commit is contained in:
Jakub Zych
2026-11-04 09:15:26 +01:00
parent 7d52c5d2e5
commit 160b53b2f5
25 changed files with 1074 additions and 140 deletions

21
server/middleware/seo.ts Normal file
View File

@@ -0,0 +1,21 @@
// Resolves every page request through the CMS before Nuxt renders it, so the
// render (and the head) uses one answer and no second call. Skipped: Nuxt's
// and the layers' internal paths (/_nuxt, /__nuxt_error, /_seo …), /api and
// asset files (isPagePath). The answer is left in event.context.seo for the
// app (useSeoResolve reads event.context.seoResolved).
import { isPagePath } from '../../app/utils/pagePath.ts'
import { seoServerConfig } from '../lib/config.ts'
import { resolveCached } from '../lib/resolve.ts'
export default defineEventHandler(async (event) => {
if (event.method !== 'GET' && event.method !== 'HEAD') return
const cfg = seoServerConfig(useRuntimeConfig(event))
if (!cfg.enabled) return
const url = event.path
if (!isPagePath(url, cfg.resolveFiles)) return
const answer = await resolveCached(event, url)
event.context.seo = answer
event.context.seoResolved = { url, answer }
})