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

39 lines
2.0 KiB
TypeScript

// The resolve call and the shared answer of the current page. On the server
// it takes the answer the Nitro middleware (server/middleware/seo.ts) already
// resolved for this request through its cache (event.context.seo), so a page
// costs one resolve call; for a request the middleware did not resolve (a
// path it skips, such as a dotted author slug) it calls the internal base (runtimeConfig.seo.apiBase) and forwards the
// visitor's x-forwarded-for plus the Nuxt peer, so the backend's per-IP
// throttle (seo-resolve, 600 a minute) keeps one bucket per visitor, also on
// route-cached renders. In the browser it calls the same-origin public base.
// Any failure (timeout, 4xx, 5xx, an unexpected body) answers null: the page
// then renders with its own head.
import { seoAnswer } from '../utils/seo.ts'
import type { SeoResolveAnswer } from '../utils/types.ts'
/** The resolve answer of the page being rendered, null when there is none. */
export function useSeoAnswer() {
return useState<SeoResolveAnswer | null>('seo-layer:answer', () => null)
}
export async function resolveSeo(url: string): Promise<SeoResolveAnswer | null> {
const config = useRuntimeConfig()
const timeout = Number((config.seo as { timeoutMs?: number } | undefined)?.timeoutMs) || 1500
try {
if (import.meta.server) {
const event = useRequestEvent()
const resolved = event?.context.seoResolved as { url: string; answer: SeoResolveAnswer | null } | undefined
if (resolved && resolved.url === url) return resolved.answer
const base = (config.seo as { apiBase: string }).apiBase
const recorded = event?.context.seoForwardedFor as string | undefined
const headers: Record<string, string> = recorded ? { 'x-forwarded-for': recorded } : {}
return seoAnswer(await $fetch('/resolve', { baseURL: base, query: { url }, headers, timeout }))
}
const base = (config.public.seo as { apiBase: string }).apiBase
return seoAnswer(await $fetch('/resolve', { baseURL: base, query: { url }, timeout }))
} catch {
return null
}
}