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

View File

@@ -0,0 +1,24 @@
// GET /api/__seo__/resolve?url=<path and query>: the resolve answer through
// the layer's server (and its cache), for client-side navigation on a site
// whose proxy does not route /_seo/api/v1 to the backend. The browser uses
// it when public.seo.apiBase (NUXT_PUBLIC_SEO_API_BASE) is "/api/__seo__";
// the default stays the backend's same-origin /_seo/api/v1.
//
// 404 when the layer is off; 422 for a url that is not a path; 502 when the
// backend gives no usable answer. Never route-cached ('/api/__seo__/**'
// cache false) and never indexed.
import { seoServerConfig } from '../../lib/config.ts'
import { resolveCached } from '../../lib/resolve.ts'
export default defineEventHandler(async (event) => {
setResponseHeaders(event, { 'cache-control': 'no-store', 'x-robots-tag': 'noindex' })
if (!seoServerConfig(useRuntimeConfig(event)).enabled) throw createError({ statusCode: 404, statusMessage: 'Not Found' })
const url = getQuery(event).url
if (typeof url !== 'string' || !url.startsWith('/') || url.startsWith('//') || url.length > 2048) {
throw createError({ statusCode: 422, statusMessage: 'Bad url' })
}
const answer = await resolveCached(event, url)
if (!answer) throw createError({ statusCode: 502, statusMessage: 'No SEO answer' })
return answer
})