From 160b53b2f5490274fac67546f5c410f097c896cd Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 09:15:26 +0100 Subject: [PATCH] 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 --- .playground/app/pages/[...slug].vue | 18 +++ app/composables/useCmsSeo.ts | 31 ++++ app/composables/useSchemaNode.ts | 44 ++++++ app/composables/useSeoResolve.ts | 9 +- app/middleware/seo.global.ts | 4 +- app/plugins/seo-head.ts | 21 +-- app/utils/graph.ts | 50 +++++++ app/utils/pagePath.ts | 39 +++++ app/utils/seo.ts | 31 +++- app/utils/types.ts | 75 +++++++++- lib/seo-module.ts | 65 +++++++++ nuxt.config.ts | 40 ++++- package.json | 2 +- server/api/__seo__/resolve.get.ts | 24 +++ server/lib/config.ts | 80 ++++++++++ server/lib/resolve.ts | 38 +++++ server/middleware/seo.ts | 21 +++ testing/expect-seo.txt | 15 ++ testing/fixture-api.mjs | 91 +++++++++--- testing/fixtures/seo/resolve.json | 85 +++++++++++ testing/nuxt-server.mjs | 77 ++++++++++ testing/smoke-ssr.mjs | 217 +++++++++++++++------------- tests/graph.test.ts | 63 ++++++++ tests/pagePath.test.ts | 31 ++++ tests/seo.test.ts | 43 ++++++ 25 files changed, 1074 insertions(+), 140 deletions(-) create mode 100644 .playground/app/pages/[...slug].vue create mode 100644 app/composables/useCmsSeo.ts create mode 100644 app/composables/useSchemaNode.ts create mode 100644 app/utils/graph.ts create mode 100644 app/utils/pagePath.ts create mode 100644 lib/seo-module.ts create mode 100644 server/api/__seo__/resolve.get.ts create mode 100644 server/lib/config.ts create mode 100644 server/lib/resolve.ts create mode 100644 server/middleware/seo.ts create mode 100644 testing/expect-seo.txt create mode 100644 testing/fixtures/seo/resolve.json create mode 100644 testing/nuxt-server.mjs create mode 100644 tests/graph.test.ts create mode 100644 tests/pagePath.test.ts diff --git a/.playground/app/pages/[...slug].vue b/.playground/app/pages/[...slug].vue new file mode 100644 index 0000000..4611653 --- /dev/null +++ b/.playground/app/pages/[...slug].vue @@ -0,0 +1,18 @@ + + + diff --git a/app/composables/useCmsSeo.ts b/app/composables/useCmsSeo.ts new file mode 100644 index 0000000..b568c78 --- /dev/null +++ b/app/composables/useCmsSeo.ts @@ -0,0 +1,31 @@ +// The page head from the CMS resolve answer: title, description, robots, +// canonical, Open Graph (with og:locale and its alternates), X/Twitter, +// verification meta, , hreflang links and exactly one JSON-LD +// script holding one @graph (the CMS nodes plus those added through +// useSchemaNode, merged by @id). One entry with tagPriority 'critical', so +// its tags win over the same tags a page, a layer or a module sets; a value +// the answer lacks adds no tag and the page's own stays. The layer's app +// plugin (seo-head) is the only caller: pages and other layers never set CMS +// values themselves. + +import type { Ref } from 'vue' +import { mergeGraph } from '../utils/graph.ts' +import { seoHeadInput } from '../utils/seo.ts' +import type { SeoResolveAnswer } from '../utils/types.ts' + +export function useCmsSeo(answer: Ref): void { + const origin = useRequestURL().origin + const nodes = useSchemaNodes() + useHead( + computed(() => { + const head = seoHeadInput(answer.value, origin) + const cmsGraph = answer.value?.action === 'ok' ? answer.value.jsonld : null + const graph = mergeGraph(cmsGraph, nodes()) + // unhead serializes the object and escapes "<" inside it, so a value + // such as "" cannot end the script element. + const script = graph ? [{ type: 'application/ld+json', key: 'seo-jsonld', innerHTML: graph }] : [] + return { ...head, script } + }), + { tagPriority: 'critical' }, + ) +} diff --git a/app/composables/useSchemaNode.ts b/app/composables/useSchemaNode.ts new file mode 100644 index 0000000..cae3267 --- /dev/null +++ b/app/composables/useSchemaNode.ts @@ -0,0 +1,44 @@ +// 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 +} + +type AppWithNodes = { _seoSchemaNodes?: ShallowRef; _seoSchemaNodeSeq?: number } + +function store(): { list: ShallowRef; app: AppWithNodes } { + const app = useNuxtApp() as unknown as AppWithNodes + app._seoSchemaNodes ??= shallowRef([]) + 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): 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) + }) + } +} diff --git a/app/composables/useSeoResolve.ts b/app/composables/useSeoResolve.ts index 2704568..2603c5a 100644 --- a/app/composables/useSeoResolve.ts +++ b/app/composables/useSeoResolve.ts @@ -1,5 +1,8 @@ // The resolve call and the shared answer of the current page. On the server -// it calls the internal base (runtimeConfig.seo.apiBase) and forwards the +// 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. @@ -19,8 +22,10 @@ export async function resolveSeo(url: string): Promise const timeout = Number((config.seo as { timeoutMs?: number } | undefined)?.timeoutMs) || 1500 try { if (import.meta.server) { - const base = (config.seo as { apiBase: string }).apiBase 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 = recorded ? { 'x-forwarded-for': recorded } : {} return seoAnswer(await $fetch('/resolve', { baseURL: base, query: { url }, headers, timeout })) diff --git a/app/middleware/seo.global.ts b/app/middleware/seo.global.ts index f77deb0..eb9cbfe 100644 --- a/app/middleware/seo.global.ts +++ b/app/middleware/seo.global.ts @@ -3,12 +3,12 @@ // answer is kept in shared state for the head plugin; on hydration the // server's answer comes with the payload and nothing is fetched again. -import { seoRedirectTarget, seoResolvableUrl, seoXRobotsTag } from '../utils/seo.ts' +import { seoEnabled, seoRedirectTarget, seoResolvableUrl, seoXRobotsTag } from '../utils/seo.ts' export default defineNuxtRouteMiddleware(async (to) => { const nuxtApp = useNuxtApp() const config = useRuntimeConfig() - if ((config.public.seo as { enabled?: boolean } | undefined)?.enabled === false) return + if (!seoEnabled(config.public.seo)) return if (import.meta.client && nuxtApp.isHydrating && nuxtApp.payload.serverRendered) return const state = useSeoAnswer() diff --git a/app/plugins/seo-head.ts b/app/plugins/seo-head.ts index 06898df..2d326ae 100644 --- a/app/plugins/seo-head.ts +++ b/app/plugins/seo-head.ts @@ -1,15 +1,18 @@ // One head entry for the whole app, following the resolve answer of the -// current page. tagPriority 'critical' makes its tags win over the same -// tags a page or another module sets (unhead keeps the lower weight), so -// editor values from the SEO plugin override the content defaults; a value -// the answer lacks leaves the page's own tag in place. +// current page (useCmsSeo). tagPriority 'critical' makes its tags win over +// the same tags a page or another module sets (unhead keeps the lower +// weight), so editor values from the SEO plugin override the content +// defaults; a value the answer lacks leaves the page's own tag in place. +// +// The answer comes from the route middleware (seo.global.ts): during SSR it +// is the one the Nitro middleware already resolved (event.context.seo), kept +// in useState so hydration fetches nothing; on client navigation it is +// fetched again. -import { seoHeadInput } from '../utils/seo.ts' +import { seoEnabled } from '../utils/seo.ts' export default defineNuxtPlugin(() => { const config = useRuntimeConfig() - if ((config.public.seo as { enabled?: boolean } | undefined)?.enabled === false) return - const answer = useSeoAnswer() - const origin = useRequestURL().origin - useHead(computed(() => seoHeadInput(answer.value, origin)), { tagPriority: 'critical' }) + if (!seoEnabled(config.public.seo)) return + useCmsSeo(useSeoAnswer()) }) diff --git a/app/utils/graph.ts b/app/utils/graph.ts new file mode 100644 index 0000000..88452f8 --- /dev/null +++ b/app/utils/graph.ts @@ -0,0 +1,50 @@ +// 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 + 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() + const add = (node: SeoSchemaNode) => { + const id = typeof node['@id'] === 'string' && node['@id'] !== '' ? node['@id'] : null + const { '@context': _context, ...clean } = node as Record + 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 } +} diff --git a/app/utils/pagePath.ts b/app/utils/pagePath.ts new file mode 100644 index 0000000..c2932d5 --- /dev/null +++ b/app/utils/pagePath.ts @@ -0,0 +1,39 @@ +// Which requests the Nitro SEO middleware resolves. Pure, so `node --test` +// drives it directly; the name starts with `is` like Nuxt's own helpers and +// is unique enough for the auto-import into the consuming app. + +/** + * File extensions that are never pages: build assets, images, fonts, data + * and the root files (robots.txt, sitemap.xml, verification files), which + * server/middleware/seo-root.ts handles. + */ +const ASSET_EXTENSIONS = new Set([ + 'js', 'mjs', 'cjs', 'css', 'map', 'json', 'xml', 'txt', 'html', 'htm', 'xsl', 'webmanifest', + 'ico', 'png', 'jpg', 'jpeg', 'gif', 'webp', 'avif', 'svg', 'bmp', 'tif', 'tiff', + 'woff', 'woff2', 'ttf', 'otf', 'eot', + 'mp3', 'mp4', 'webm', 'ogg', 'wav', 'mov', + 'pdf', 'zip', 'gz', 'br', 'wasm', +]) + +/** + * True when `path` (path and query, as h3's event.path) is a page the SEO + * middleware resolves: not Nuxt's or the layers' internal paths (/_nuxt, + * /__nuxt_error, /_journal, /_seo …), not /api, and not a file with one of + * the asset extensions, unless the extension is listed in `resolveFiles` + * (module option seo.resolveFiles, default ['pdf'], so an old PDF address + * can still redirect). Pages a Nuxt route renders under a dotted path (an + * author slug with ".com") are still resolved by the route middleware. + */ +export function isPagePath(path: string, resolveFiles: readonly string[] = []): boolean { + if (typeof path !== 'string' || !path.startsWith('/') || path.startsWith('//')) return false + const pathname = (path.split('?')[0] ?? '').split('#')[0] ?? '' + if (pathname.startsWith('/_')) return false + if (pathname === '/api' || pathname.startsWith('/api/')) return false + const last = pathname.slice(pathname.lastIndexOf('/') + 1) + const dot = last.lastIndexOf('.') + if (dot > 0) { + const ext = last.slice(dot + 1).toLowerCase() + if (ASSET_EXTENSIONS.has(ext) && !resolveFiles.map((e) => e.toLowerCase()).includes(ext)) return false + } + return true +} diff --git a/app/utils/seo.ts b/app/utils/seo.ts index 3d90e41..7cb83ee 100644 --- a/app/utils/seo.ts +++ b/app/utils/seo.ts @@ -79,8 +79,22 @@ export function seoXRobotsTag(answer: SeoResolveAnswer | null): string { interface SeoHeadInput { title?: string titleTemplate?: string - meta: Array<{ name?: string; property?: string; content: string }> - link: Array<{ rel: string; href: string }> + htmlAttrs?: { lang: string } + meta: Array<{ name?: string; property?: string; content: string; key?: string }> + link: Array<{ rel: string; href: string; hreflang?: string; key?: string }> +} + +/** + * False when the layer is switched off: the runtime config + * public.seo.enabled (NUXT_PUBLIC_SEO_ENABLED, since v0.1.0) or its alias + * public.seo.cms (module option seo.cms, NUXT_PUBLIC_SEO_CMS) is false or + * "false". Either one turns every CMS call off. + */ +export function seoEnabled(publicSeo: unknown): boolean { + if (!publicSeo || typeof publicSeo !== 'object') return true + const p = publicSeo as { enabled?: unknown; cms?: unknown } + const off = (v: unknown) => v === false || v === 'false' + return !off(p.enabled) && !off(p.cms) } /** @@ -131,6 +145,19 @@ export function seoHeadInput(answer: SeoResolveAnswer | null, origin?: string): } } if (seo.canonical) head.link.push({ rel: 'canonical', href: seoAbsolute(seo.canonical, origin) }) + + // SEO plugin v0.2.0 keys; an older backend leaves them out and adds no tag. + if (answer && typeof answer.html_lang === 'string' && answer.html_lang) head.htmlAttrs = { lang: answer.html_lang } + property('og:locale', seo.og?.locale) + const alternateLocales = Array.isArray(seo.og?.locale_alternates) ? seo.og.locale_alternates : [] + alternateLocales.forEach((locale, i) => { + if (typeof locale === 'string' && locale) head.meta.push({ property: 'og:locale:alternate', content: locale, key: `og-locale-alt-${i}` }) + }) + for (const alt of Array.isArray(answer?.alternates) ? answer.alternates : []) { + if (alt && typeof alt.hreflang === 'string' && alt.hreflang && typeof alt.url === 'string' && alt.url) { + head.link.push({ rel: 'alternate', hreflang: alt.hreflang, href: seoAbsolute(alt.url, origin), key: `alt-${alt.hreflang}` }) + } + } return head } diff --git a/app/utils/types.ts b/app/utils/types.ts index 0999920..d532867 100644 --- a/app/utils/types.ts +++ b/app/utils/types.ts @@ -17,7 +17,16 @@ export interface SeoHead { canonical: string robots: string x_robots_tag: string - og: { title: string; description: string; image: string; url: string } + og: { + title: string + description: string + image: string + url: string + /** og:locale of the page (pl_PL); absent from backends before SEO v0.2.0. */ + locale?: string + /** og:locale:alternate values, one per other translated locale. */ + locale_alternates?: string[] + } twitter: { card: string; site: string } verification: SeoMetaTag[] } @@ -35,4 +44,68 @@ export interface SeoResolveAnswer { staging: boolean /** Set on ok answers only. */ seo: SeoHead | null + // The keys below are additive (SEO plugin v0.2.0); an older backend leaves + // them out and every consumer treats an absent key as empty. + /** The value (pl, pl-PL), set on ok answers. */ + html_lang?: string + /** hreflang alternates including the page itself and x-default; absolute URLs. */ + alternates?: SeoAlternate[] + /** One {"@context", "@graph"} object, or null. */ + jsonld?: SeoJsonLd | null + breadcrumbs?: SeoBreadcrumb[] + /** Present only when the request carries a page number. */ + pagination?: SeoPaginationAnswer + outbound?: SeoOutbound + autolinks?: Array<{ phrase: string; url: string }> + autolink_max?: number +} + +export interface SeoAlternate { + hreflang: string + url: string +} + +/** A schema.org node; @id links nodes across the graph. */ +export type SeoSchemaNode = Record & { '@id'?: string; '@type'?: string | string[] } + +export interface SeoJsonLd { + '@context'?: string + '@graph'?: SeoSchemaNode[] +} + +export interface SeoBreadcrumb { + name: string + url: string + path: string +} + +export interface SeoPaginationAnswer { + form?: 'query' | 'path' + param?: string + segment?: string + page?: number + /** A page value that is not a page number (?page=abc): the layer answers 404. */ + invalid?: boolean +} + +export interface SeoOutbound { + new_tab?: boolean + nofollow_domains?: string[] + sponsored_domains?: string[] +} + +/** GET /_seo/api/v1/site. Every key may be absent on an older backend. */ +export interface SeoSiteAnswer { + site_url?: string + default_locale?: string + locales?: string[] + unprefixed_locale?: string + links?: { new_tab?: boolean } + html_sitemap?: { enabled?: boolean; paths?: Record; page_size?: number } + routes?: Array<{ type: string; pattern: string }> + /** Root file names the backend serves (verification files, the IndexNow key file). */ + root_files?: string[] + llms?: boolean + pagination?: { form?: 'query' | 'path'; param?: string; segments?: Record } + home_crumb?: { name?: string; url?: string } | null } diff --git a/lib/seo-module.ts b/lib/seo-module.ts new file mode 100644 index 0000000..b01eda4 --- /dev/null +++ b/lib/seo-module.ts @@ -0,0 +1,65 @@ +// The layer's local Nuxt module (configKey `seo`). It copies the module +// options into runtimeConfig.public.seo, where the layer's app and server +// code read them. It lives in lib/ rather than modules/ so Nuxt's +// local-module scan cannot register it a second time. Layer source never +// uses the ~ or @ aliases: they resolve to the consuming project. +// +// The config key `seo` is shared with nuxt-seo-utils (@nuxtjs/seo) where an +// app still uses it: each module reads only its own keys, so the layer's +// options never use a name nuxt-seo-utils has (enabled, fallbackTitle …). + +import { defineNuxtModule } from 'nuxt/kit' + +export interface SeoModuleOptions { + /** + * false turns every CMS call off (no resolve, no head from the CMS, no + * redirects, no root files, no 404 reports), for a site without the SEO + * plugin. An alias of public.seo.enabled (NUXT_PUBLIC_SEO_ENABLED), which + * keeps working; either one false switches the layer off. + */ + cms: boolean + /** Breadcrumbs from the CMS (components in 33-08). */ + breadcrumbs: boolean + links: { + /** + * A visually hidden "(opens in a new tab)" hint inside external links + * while the CMS's links_new_tab is on. Copied to + * runtimeConfig.public.seo.links.newTabHint for the body-link pipeline. + */ + newTabHint: boolean + } + /** Serve robots.txt, the sitemaps, llms.txt and the CMS's root files from the backend. */ + rootFiles: boolean + /** File extensions the Nitro middleware still resolves, so an old file address can redirect. */ + resolveFiles: string[] +} + +export const SEO_MODULE_DEFAULTS: SeoModuleOptions = { + cms: true, + breadcrumbs: true, + links: { newTabHint: true }, + rootFiles: true, + resolveFiles: ['pdf'], +} + +export default defineNuxtModule({ + meta: { + name: 'vue-seo-layer', + configKey: 'seo', + }, + defaults: SEO_MODULE_DEFAULTS, + setup(options, nuxt) { + const runtime = nuxt.options.runtimeConfig as { public: Record } + const current = (runtime.public.seo ?? {}) as Record + // Only the layer's own keys: the shared config key may carry + // nuxt-seo-utils options, which stay out of the public runtime config. + runtime.public.seo = { + ...current, + cms: options.cms !== false, + breadcrumbs: options.breadcrumbs !== false, + links: { ...((current.links as Record) ?? {}), newTabHint: options.links?.newTabHint !== false }, + rootFiles: options.rootFiles !== false, + resolveFiles: Array.isArray(options.resolveFiles) ? [...options.resolveFiles] : [...SEO_MODULE_DEFAULTS.resolveFiles], + } + }, +}) diff --git a/nuxt.config.ts b/nuxt.config.ts index 7fa693e..5dcfe66 100644 --- a/nuxt.config.ts +++ b/nuxt.config.ts @@ -1,43 +1,69 @@ // vue-seo-layer: the frontend half of the SummerCMS SEO plugin // (golem15.seo). For every page it asks GET /_seo/api/v1/resolve?url=…, // enforces the redirect and 410 decisions, and sets the head (title, -// description, canonical, robots, Open Graph, X/Twitter, verification) from -// the answer with priority over the page's own tags. It also reports 404s to -// the plugin's log and serves the verification files at the site root. An -// app consumes it with `extends: ['vue-seo-layer']`. +// description, canonical, robots, Open Graph, X/Twitter, verification, +// hreflang, and one JSON-LD @graph) from the answer with priority +// over the page's own tags. It also reports 404s to the plugin's log and +// serves the site's root files (verification files and, with a backend that +// answers /_seo/api/v1/site, robots.txt, the sitemaps, llms.txt and the +// IndexNow key). An app consumes it with `extends: ['vue-seo-layer']`. // // Every path below is absolute and built from this file's directory: a // relative or ~ path in a layer config resolves against the consuming -// project, not the layer. The layer declares no Nuxt modules. +// project, not the layer. The layer's only module is its own local one +// (lib/seo-module.ts); it pulls in no package. + +import { fileURLToPath } from 'node:url' + +const currentDir = fileURLToPath(new URL('.', import.meta.url)) export default defineNuxtConfig({ compatibilityDate: '2025-07-15', devtools: { enabled: false }, + modules: [`${currentDir}lib/seo-module.ts`], + runtimeConfig: { seo: { // Server-only base for SSR calls (NUXT_SEO_API_BASE). It never comes // from request input. apiBase: 'http://127.0.0.1:8096/_seo/api/v1', + // Server-only origin of the Go binary for the root files + // (NUXT_SEO_BACKEND_BASE). Empty: apiBase without /_seo/api/v1, so + // http://127.0.0.1:8096 by default. + backendBase: '', // Server-only bearer token of POST /notfound (NUXT_SEO_NOTFOUND_TOKEN), // the backend's golem15.seo.notfound_token. Empty: 404s are not // reported. Not under NUXT_SITE_: nuxt-site-config copies every // NUXT_SITE_* variable into the site config, which is published in // every page's payload. notfoundToken: '', + // Server-only bearer token of the route report + // (NUXT_SEO_ROUTE_REPORT_TOKEN). Empty: notfoundToken is used. + routeReportToken: '', // How long a resolve call may take before the page renders without it // (NUXT_SEO_TIMEOUT_MS). timeoutMs: 1500, + // Seconds a resolve answer stays in the layer's cache + // (NUXT_SEO_RESOLVE_CACHE_TTL); the site cache purge clears it. 0 + // turns the cache off. + resolveCacheTtl: 60, }, public: { seo: { // Same-origin base for browser navigations (NUXT_PUBLIC_SEO_API_BASE). - // The site's proxy routes GET /resolve to the backend. + // The site's proxy routes GET /resolve to the backend; set it + // to /api/__seo__ to resolve through the Nuxt server instead. apiBase: '/_seo/api/v1', // NUXT_PUBLIC_SEO_ENABLED=false turns the layer off: no resolve - // calls, no head, no redirects, no 404 reports. + // calls, no head, no redirects, no root files, no 404 reports. The + // module option seo.cms (NUXT_PUBLIC_SEO_CMS) is an alias. enabled: true, }, }, }, + + routeRules: { + '/api/__seo__/**': { cache: false }, + }, }) diff --git a/package.json b/package.json index 818b3d8..c1bfa0e 100644 --- a/package.json +++ b/package.json @@ -9,7 +9,7 @@ "build:playground": "nuxt build .playground", "test": "node --test --test-reporter=tap tests/*.test.ts", "dev:prepare": "nuxt prepare .playground", - "smoke:ssr": "node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect.txt" + "smoke:ssr": "node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect.txt --no-site && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo.txt" }, "engines": { "node": ">=22.18" diff --git a/server/api/__seo__/resolve.get.ts b/server/api/__seo__/resolve.get.ts new file mode 100644 index 0000000..d866cac --- /dev/null +++ b/server/api/__seo__/resolve.get.ts @@ -0,0 +1,24 @@ +// GET /api/__seo__/resolve?url=: 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 +}) diff --git a/server/lib/config.ts b/server/lib/config.ts new file mode 100644 index 0000000..2d8f726 --- /dev/null +++ b/server/lib/config.ts @@ -0,0 +1,80 @@ +// The SEO layer's settings as the server code reads them from runtime +// config. Pure TypeScript outside Nitro's auto-import directories, so +// `node --test`, the middleware and the routes import it directly. + +import { seoEnabled } from '../../app/utils/seo.ts' + +export interface SeoServerConfig { + /** False when public.seo.enabled or its alias public.seo.cms is false: no CMS call at all. */ + enabled: boolean + /** Server-only base of the plugin's API (NUXT_SEO_API_BASE), without a trailing slash. */ + apiBase: string + /** Server-only origin of the Go binary for the root files (NUXT_SEO_BACKEND_BASE). */ + backendBase: string + /** How long a call may take before the page renders without it (NUXT_SEO_TIMEOUT_MS). */ + timeoutMs: number + /** Seconds a resolve answer stays cached (NUXT_SEO_RESOLVE_CACHE_TTL); 0 turns the cache off. */ + resolveCacheTtl: number + /** Bearer token of POST /notfound (NUXT_SEO_NOTFOUND_TOKEN); empty: no reports. */ + notfoundToken: string + /** Bearer token of the route report (33-08); falls back to notfoundToken. */ + routeReportToken: string + /** Module option seo.rootFiles: serve robots.txt, the sitemaps, llms.txt and the listed root files. */ + rootFiles: boolean + /** Module option seo.resolveFiles: file extensions the middleware still resolves (['pdf']). */ + resolveFiles: string[] +} + +export const DEFAULT_SEO_API_BASE = 'http://127.0.0.1:8096/_seo/api/v1' +export const DEFAULT_RESOLVE_CACHE_TTL = 60 + +const API_SUFFIX = /\/_seo\/api\/v1\/?$/ + +function section(config: unknown, name: string): Record { + if (config === null || typeof config !== 'object') return {} + const value = (config as Record)[name] + return value !== null && typeof value === 'object' ? (value as Record) : {} +} + +const str = (v: unknown) => (typeof v === 'string' ? v.trim() : typeof v === 'number' ? String(v) : '') + +function num(v: unknown, fallback: number): number { + if (v === '' || v === null || v === undefined) return fallback + const n = Number(v) + return Number.isFinite(n) && n >= 0 ? n : fallback +} + +/** + * The backend origin for root files: backendBase when set, else apiBase + * without its /_seo/api/v1 suffix (so an app that only sets + * NUXT_SEO_API_BASE needs nothing else), else apiBase's origin. + */ +export function seoBackendBase(backendBase: string, apiBase: string): string { + if (backendBase) return backendBase.replace(/\/+$/, '') + if (API_SUFFIX.test(apiBase)) return apiBase.replace(API_SUFFIX, '') + try { + return new URL(apiBase).origin + } catch { + return '' + } +} + +/** Reads the layer's settings from a runtime config object (useRuntimeConfig(event)). */ +export function seoServerConfig(config: unknown): SeoServerConfig { + const seo = section(config, 'seo') + const pub = section(section(config, 'public'), 'seo') + const apiBase = (str(seo.apiBase) || DEFAULT_SEO_API_BASE).replace(/\/+$/, '') + const notfoundToken = str(seo.notfoundToken) + const resolveFiles = Array.isArray(pub.resolveFiles) ? pub.resolveFiles.filter((e): e is string => typeof e === 'string') : ['pdf'] + return { + enabled: seoEnabled(pub), + apiBase, + backendBase: seoBackendBase(str(seo.backendBase), apiBase), + timeoutMs: num(seo.timeoutMs, 1500) || 1500, + resolveCacheTtl: num(seo.resolveCacheTtl, DEFAULT_RESOLVE_CACHE_TTL), + notfoundToken, + routeReportToken: str(seo.routeReportToken) || notfoundToken, + rootFiles: pub.rootFiles !== false && pub.rootFiles !== 'false', + resolveFiles, + } +} diff --git a/server/lib/resolve.ts b/server/lib/resolve.ts new file mode 100644 index 0000000..5687f75 --- /dev/null +++ b/server/lib/resolve.ts @@ -0,0 +1,38 @@ +// The server side of the resolve call: GET /resolve?url=, forwarding the visitor's address so the backend's per-IP throttle +// (seo-resolve) keeps one bucket per visitor, also on route-cached renders +// (server/plugins/seo-client-address.ts records it). Any failure (timeout, +// 4xx, 5xx, an unexpected body) answers null and the page renders without +// CMS head tags (fail open); with the layer switched off nothing is called. +// +// Nitro auto-imports (useRuntimeConfig, $fetch) are used inside the +// functions only, so `node --test` can import this file. + +import type { H3Event } from 'h3' +import { seoAnswer } from '../../app/utils/seo.ts' +import type { SeoResolveAnswer } from '../../app/utils/types.ts' +import { seoServerConfig, type SeoServerConfig } from './config.ts' + +/** The x-forwarded-for value the SEO calls carry for this request. */ +export function seoForwardedFor(event: H3Event): Record { + const recorded = event.context.seoForwardedFor as string | undefined + return recorded ? { 'x-forwarded-for': recorded } : {} +} + +async function fetchResolve(cfg: SeoServerConfig, url: string, headers: Record): Promise { + try { + return seoAnswer(await $fetch('/resolve', { baseURL: cfg.apiBase, query: { url }, headers, timeout: cfg.timeoutMs })) + } catch { + return null + } +} + +/** + * The resolve answer for `url` (path and query), or null on any failure or + * when the layer is off (public.seo.enabled or seo.cms false). + */ +export async function resolveCached(event: H3Event, url: string): Promise { + const cfg = seoServerConfig(useRuntimeConfig(event)) + if (!cfg.enabled) return null + return fetchResolve(cfg, url, seoForwardedFor(event)) +} diff --git a/server/middleware/seo.ts b/server/middleware/seo.ts new file mode 100644 index 0000000..7a950b2 --- /dev/null +++ b/server/middleware/seo.ts @@ -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 } +}) diff --git a/testing/expect-seo.txt b/testing/expect-seo.txt new file mode 100644 index 0000000..bd3c9f9 --- /dev/null +++ b/testing/expect-seo.txt @@ -0,0 +1,15 @@ +# SSR expectations of the SEO layer's v0.2.0 features against the fixture +# SEO API (testing/fixtures, answers in testing/fixtures/seo). Grammar in +# testing/smoke-ssr.mjs. testing/expect.txt keeps the v0.1.1 behaviour and +# runs against a backend without /site (--no-site). + +# Tracer: a page rendered by Nuxt carries the CMS title, canonical, hreflang +# and one JSON-LD graph from the resolve answer. +200 /blog/lwia-grzywa contains=Lwia grzywa | Example count=application/ld+json:1 count=rel="canonical":1 contains=hreflang="x-default" contains=lang="pl"> +200 /blog/lwia-grzywa contains= contains= +200 /blog/lwia-grzywa notcontains=Own catch-all title +# One graph: the CMS nodes, the layer's node merged by @id (CMS values win), +# a new layer node appended; "<" inside the JSON cannot end the script. +200 /blog/lwia-grzywa count=data-hid="seo-jsonld":1 count="@type":"WebPage":1 contains="name":"Lwia grzywa" notcontains=Layer name loses contains="inLanguage":"pl" contains=#itemlist +200 /blog/lwia-grzywa notcontains=", + "mainEntityOfPage": { + "@id": "https://example.test/blog/lwia-grzywa#webpage" + } + } + ] + }, + "breadcrumbs": [ + { + "name": "Start", + "url": "https://example.test/", + "path": "/" + }, + { + "name": "Blog", + "url": "https://example.test/blog", + "path": "/blog" + }, + { + "name": "Lwia grzywa", + "url": "https://example.test/blog/lwia-grzywa", + "path": "/blog/lwia-grzywa" + } + ] + } +} diff --git a/testing/nuxt-server.mjs b/testing/nuxt-server.mjs new file mode 100644 index 0000000..38417ff --- /dev/null +++ b/testing/nuxt-server.mjs @@ -0,0 +1,77 @@ +// Boots a built Nuxt server (`/server/index.mjs`) on 127.0.0.1 +// against a given SEO API base and waits until it answers HTTP. The same +// helper as vue-journal-layer's testing/nuxt-server.mjs, with the SEO base. +// Plain .mjs with JSDoc types so it runs from node_modules unchanged. + +import { spawn } from 'node:child_process' +import { createServer } from 'node:net' +import { existsSync } from 'node:fs' +import { join, resolve } from 'node:path' + +/** @returns {Promise} */ +function freePort() { + return new Promise((done, fail) => { + const srv = createServer() + srv.once('error', fail) + srv.listen(0, '127.0.0.1', () => { + const { port } = /** @type {import('node:net').AddressInfo} */ (srv.address()) + srv.close(() => done(port)) + }) + }) +} + +/** + * The returned `output()` gives the server's captured stdout and stderr so + * far, for checks on what the server logged. + * + * @param {{ outputDir: string, apiBase: string, port?: number, env?: Record, timeoutMs?: number }} opts + * @returns {Promise<{ url: string, close: () => Promise, output: () => string }>} + */ +export async function startNuxtServer({ outputDir, apiBase, port = 0, env = {}, timeoutMs = 30000 }) { + const entry = join(resolve(outputDir), 'server', 'index.mjs') + if (!existsSync(entry)) throw new Error(`nuxt-server: ${entry} does not exist; run pnpm build:playground first`) + const listenPort = port || (await freePort()) + const url = `http://127.0.0.1:${listenPort}` + + const child = spawn(process.execPath, [entry], { + env: { + ...process.env, + NODE_ENV: 'production', + NITRO_HOST: '127.0.0.1', + NITRO_PORT: String(listenPort), + NUXT_SEO_API_BASE: apiBase, + ...env, + }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + let log = '' + child.stdout.on('data', (b) => (log += b)) + child.stderr.on('data', (b) => (log += b)) + let exited = false + child.once('exit', () => (exited = true)) + + const close = () => + new Promise((/** @type {(v?: void) => void} */ done) => { + if (exited) return done() + child.once('exit', () => done()) + child.kill('SIGTERM') + setTimeout(() => { + if (!exited) child.kill('SIGKILL') + }, 5000).unref() + }) + + const deadline = Date.now() + timeoutMs + while (Date.now() < deadline) { + if (exited) throw new Error(`nuxt-server: server exited before answering\n${log}`) + try { + // A Nuxt-internal path: it touches no page, so the boot probe never + // resolves through the SEO API or fills a route cache. + await fetch(url + '/_nuxt/', { redirect: 'manual', signal: AbortSignal.timeout(2000) }) + return { url, close, output: () => log } + } catch { + await new Promise((r) => setTimeout(r, 200)) + } + } + await close() + throw new Error(`nuxt-server: no answer on ${url} within ${timeoutMs} ms\n${log}`) +} diff --git a/testing/smoke-ssr.mjs b/testing/smoke-ssr.mjs index 140275e..ca95a3c 100644 --- a/testing/smoke-ssr.mjs +++ b/testing/smoke-ssr.mjs @@ -2,38 +2,51 @@ // SSR smoke check: boots the fixture SEO API and the built playground // server, then checks each line of an expectations file. // -// node testing/smoke-ssr.mjs --output <.output dir> --fixtures --expect [--env KEY=VALUE]... +// node testing/smoke-ssr.mjs --output <.output dir> --fixtures --expect [--no-site] [--env KEY=VALUE]... // // Expectation grammar, one check per line ('#' starts a comment line): -// STATUS PATH [contains=TEXT] [notcontains=TEXT] [type=PREFIX] [header=NAME: TEXT] -// report PATH -// A value runs until the next ` contains=`, ` notcontains=`, ` type=`, -// ` header=` or the end of the line, so it may contain spaces. header= -// checks that the response header NAME contains TEXT. A `report PATH` line -// requires a POST /notfound for PATH to have reached the fixture API by the -// end of the run (the server is started with the fixture's 404 token). Requests are plain GETs that do -// not follow redirects and carry `X-Forwarded-For: 203.0.113.7` and -// `Accept: text/html` (as a browser does, so errors render as pages); afterwards -// at least one fixture API request must have carried that address (the SSR -// fetches forward the visitor's x-forwarded-for). +// STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N] [type=PREFIX] +// [header=NAME: VALUE] [noheader=NAME] [reqheader=NAME: VALUE] +// report PATH a POST /notfound for PATH reached the API by the end of the run +// noreport PATH no POST /notfound for PATH reached the API by the end of the run +// resolves PATH N the API has seen exactly N resolve calls for PATH so far +// (PATH * counts every resolve call) +// apicalls N the API has seen exactly N requests so far +// A value runs until the next ` contains=`, ` notcontains=`, ` count=`, +// ` type=`, ` header=`, ` noheader=`, ` reqheader=` or the end of the line, so +// it may contain spaces. Every option may repeat on a line and every +// occurrence applies. count= wants exactly N occurrences of TEXT in the body +// (N is the number after the last colon). header= wants the response header +// NAME to equal VALUE (both trimmed); noheader= wants it absent; reqheader= +// adds a request header (a crafted Host or X-Forwarded-Host). The resolves +// and apicalls lines are checked in file order, between the requests; the +// report lines after the last request. +// +// Requests are plain GETs that do not follow redirects and carry +// `X-Forwarded-For: 203.0.113.7` and `Accept: text/html` (as a browser does, +// so errors render as pages); when the API saw any request, at least one +// must have carried that address (the SSR fetches forward the visitor's +// x-forwarded-for). The server is started with the fixture's 404 token and +// the fixture API as NUXT_SEO_API_BASE; --env overrides either. --no-site +// makes the fixture API answer /site with 404, as a backend before SEO +// v0.2.0 does. // // The same approach as vue-faq-layer's testing/smoke-ssr.mjs; plain .mjs // with JSDoc types so it runs from node_modules unchanged. -import { spawn } from 'node:child_process' -import { existsSync, readFileSync } from 'node:fs' -import { createServer } from 'node:net' -import { join, resolve } from 'node:path' +import { readFileSync } from 'node:fs' +import { resolve } from 'node:path' import { FIXTURE_NOTFOUND_TOKEN, startFixtureApi } from './fixture-api.mjs' +import { startNuxtServer } from './nuxt-server.mjs' const VISITOR_IP = '203.0.113.7' const API_PREFIX = '/_seo/api/v1' -const API_ENV = 'NUXT_SEO_API_BASE' +const OPTION_KEYS = ['contains', 'notcontains', 'count', 'type', 'header', 'noheader', 'reqheader'] /** @param {string[]} argv */ function parseArgs(argv) { - /** @type {{ output?: string, fixtures?: string, expect?: string, env: Record }} */ - const out = { env: {} } + /** @type {{ output?: string, fixtures?: string, expect?: string, site: boolean, env: Record }} */ + const out = { env: {}, site: true } for (let i = 0; i < argv.length; i++) { const arg = argv[i] const next = () => { @@ -44,6 +57,7 @@ function parseArgs(argv) { if (arg === '--output') out.output = next() else if (arg === '--fixtures') out.fixtures = next() else if (arg === '--expect') out.expect = next() + else if (arg === '--no-site') out.site = false else if (arg === '--env') { const kv = next() const eq = kv.indexOf('=') @@ -52,20 +66,22 @@ function parseArgs(argv) { } else throw new Error(`unknown argument ${arg}`) } if (!out.output || !out.fixtures || !out.expect) throw new Error('--output, --fixtures and --expect are required') - return /** @type {{ output: string, fixtures: string, expect: string, env: Record }} */ (out) + return /** @type {{ output: string, fixtures: string, expect: string, site: boolean, env: Record }} */ (out) } +/** + * @typedef {{ status: number, path: string, options: Array<{ key: string, value: string }> }} ExpectCheck + */ + /** * @param {string} line - * @returns {{ status: number, path: string, contains?: string, notcontains?: string, type?: string, header?: string }} + * @returns {ExpectCheck} */ export function parseExpectLine(line) { const m = /^(\d{3})\s+(\S+)(.*)$/.exec(line.trim()) if (!m) throw new Error('expected "STATUS PATH [options]"') - /** @type {{ status: number, path: string, contains?: string, notcontains?: string, type?: string, header?: string }} */ - const check = { status: Number(m[1]), path: m[2] } const rest = m[3] - const re = /\s(contains|notcontains|type|header)=/g + const re = new RegExp(`\\s(${OPTION_KEYS.join('|')})=`, 'g') /** @type {Array<{ key: string, start: number, valueStart: number }>} */ const marks = [] let hit @@ -74,67 +90,61 @@ export function parseExpectLine(line) { if (marks.length > 0 && rest.slice(0, marks[0].start).trim() !== '') { throw new Error(`unknown option text "${rest.slice(0, marks[0].start).trim()}"`) } - /** @type {Record} */ - const options = {} - marks.forEach((mark, i) => { + const options = marks.map((mark, i) => { const end = i + 1 < marks.length ? marks[i + 1].start : rest.length - options[mark.key] = rest.slice(mark.valueStart, end).trim() + return { key: mark.key, value: rest.slice(mark.valueStart, end).trim() } }) - return { ...check, ...options } + return { status: Number(m[1]), path: m[2], options } } -/** @returns {Promise} */ -function freePort() { - return new Promise((done, fail) => { - const srv = createServer() - srv.once('error', fail) - srv.listen(0, '127.0.0.1', () => { - const { port } = /** @type {import('node:net').AddressInfo} */ (srv.address()) - srv.close(() => done(port)) - }) - }) +/** NAME: VALUE split at the first colon. @param {string} value */ +function nameValue(value) { + const colon = value.indexOf(':') + return colon > 0 ? { name: value.slice(0, colon).trim(), value: value.slice(colon + 1).trim() } : { name: value.trim(), value: '' } +} + +/** @param {string} body @param {string} text */ +function occurrences(body, text) { + if (text === '') return 0 + let n = 0 + for (let at = body.indexOf(text); at !== -1; at = body.indexOf(text, at + text.length)) n++ + return n } /** - * Boots `/server/index.mjs` on 127.0.0.1 and waits until it answers. - * @param {{ outputDir: string, env: Record, timeoutMs?: number }} opts - * @returns {Promise<{ url: string, close: () => Promise }>} + * The failures of one response against its check. + * @param {ExpectCheck} check @param {{ status: number, headers: Headers, body: string }} res + * @returns {string[]} */ -async function startNuxtServer({ outputDir, env, timeoutMs = 30000 }) { - const entry = join(resolve(outputDir), 'server', 'index.mjs') - if (!existsSync(entry)) throw new Error(`${entry} does not exist; run pnpm build:playground first`) - const port = await freePort() - const url = `http://127.0.0.1:${port}` - const child = spawn(process.execPath, [entry], { - env: { ...process.env, NODE_ENV: 'production', NITRO_HOST: '127.0.0.1', NITRO_PORT: String(port), ...env }, - stdio: ['ignore', 'pipe', 'pipe'], - }) - let log = '' - child.stdout.on('data', (b) => (log += b)) - child.stderr.on('data', (b) => (log += b)) - let exited = false - child.once('exit', () => (exited = true)) - const close = () => - new Promise((/** @type {(v?: void) => void} */ done) => { - if (exited) return done() - child.once('exit', () => done()) - child.kill('SIGTERM') - setTimeout(() => { - if (!exited) child.kill('SIGKILL') - }, 5000).unref() - }) - const deadline = Date.now() + timeoutMs - while (Date.now() < deadline) { - if (exited) throw new Error(`server exited before answering\n${log}`) - try { - await fetch(url + '/', { redirect: 'manual', signal: AbortSignal.timeout(2000) }) - return { url, close } - } catch { - await new Promise((r) => setTimeout(r, 200)) +export function checkResponse(check, res) { + if (res.status !== check.status) return [`status ${res.status}, want ${check.status}`] + /** @type {string[]} */ + const out = [] + const type = res.headers.get('content-type') ?? '' + for (const { key, value } of check.options) { + if (key === 'contains' && !res.body.includes(value)) out.push(`body lacks "${value}"`) + else if (key === 'notcontains' && res.body.includes(value)) out.push(`body contains "${value}"`) + else if (key === 'count') { + const colon = value.lastIndexOf(':') + const want = colon > 0 ? Number(value.slice(colon + 1)) : NaN + if (!Number.isInteger(want)) { + out.push(`count=${value}: want TEXT:N`) + continue + } + const text = value.slice(0, colon) + const got = occurrences(res.body, text) + if (got !== want) out.push(`body has ${got} of "${text}", want ${want}`) + } else if (key === 'type' && !type.startsWith(value)) out.push(`content-type "${type}", want prefix "${value}"`) + else if (key === 'header') { + const h = nameValue(value) + const got = res.headers.get(h.name) + if (got === null || got.trim() !== h.value) out.push(`header ${h.name} is ${JSON.stringify(got)}, want "${h.value}"`) + } else if (key === 'noheader') { + const got = res.headers.get(value) + if (got !== null) out.push(`header ${value} is ${JSON.stringify(got)}, want none`) } } - await close() - throw new Error(`no answer on ${url} within ${timeoutMs} ms\n${log}`) + return out } async function main() { @@ -145,17 +155,30 @@ async function main() { .filter((l) => l !== '' && !l.startsWith('#')) const reports = lines.filter((l) => l.startsWith('report ')).map((l) => l.slice('report '.length).trim()) - const checks = lines.filter((l) => !l.startsWith('report ')) - const api = await startFixtureApi({ dir: resolve(args.fixtures) }) + const noreports = lines.filter((l) => l.startsWith('noreport ')).map((l) => l.slice('noreport '.length).trim()) + const steps = lines.filter((l) => !l.startsWith('report ') && !l.startsWith('noreport ')) + const api = await startFixtureApi({ dir: resolve(args.fixtures), site: args.site }) /** @type {{ url: string, close: () => Promise } | null} */ let nuxt = null const failures = /** @type {string[]} */ ([]) try { nuxt = await startNuxtServer({ outputDir: args.output, - env: { [API_ENV]: `${api.url}${API_PREFIX}`, NUXT_SEO_NOTFOUND_TOKEN: FIXTURE_NOTFOUND_TOKEN, ...args.env }, + apiBase: `${api.url}${API_PREFIX}`, + env: { NUXT_SEO_NOTFOUND_TOKEN: FIXTURE_NOTFOUND_TOKEN, ...args.env }, }) - for (const line of checks) { + for (const line of steps) { + const resolves = /^resolves\s+(\S+)\s+(\d+)$/.exec(line) + if (resolves) { + const got = api.resolveCount(resolves[1] === '*' ? undefined : resolves[1]) + if (got !== Number(resolves[2])) failures.push(`${line}: the API saw ${got} resolve calls`) + continue + } + const calls = /^apicalls\s+(\d+)$/.exec(line) + if (calls) { + if (api.requests.length !== Number(calls[1])) failures.push(`${line}: the API saw ${api.requests.length} requests (${api.requests.map((r) => `${r.method} ${r.url}`).join(', ')})`) + continue + } let check try { check = parseExpectLine(line) @@ -163,41 +186,39 @@ async function main() { failures.push(`${line}: ${/** @type {Error} */ (err).message}`) continue } + /** @type {Record} */ + const headers = { 'X-Forwarded-For': VISITOR_IP, Accept: 'text/html' } + for (const o of check.options) { + if (o.key !== 'reqheader') continue + const h = nameValue(o.value) + headers[h.name] = h.value + } let res try { - res = await fetch(nuxt.url + check.path, { - redirect: 'manual', - headers: { 'X-Forwarded-For': VISITOR_IP, Accept: 'text/html' }, - signal: AbortSignal.timeout(20000), - }) + res = await fetch(nuxt.url + check.path, { redirect: 'manual', headers, signal: AbortSignal.timeout(20000) }) } catch (err) { failures.push(`${line}: request failed: ${/** @type {Error} */ (err).message}`) continue } const body = await res.text() - const type = res.headers.get('content-type') ?? '' - if (res.status !== check.status) failures.push(`${line}: status ${res.status}, want ${check.status}`) - else if (check.contains !== undefined && !body.includes(check.contains)) failures.push(`${line}: body lacks "${check.contains}"`) - else if (check.notcontains !== undefined && body.includes(check.notcontains)) failures.push(`${line}: body contains "${check.notcontains}"`) - else if (check.type !== undefined && !type.startsWith(check.type)) failures.push(`${line}: content-type "${type}", want prefix "${check.type}"`) - else if (check.header !== undefined) { - const colon = check.header.indexOf(':') - const name = colon > 0 ? check.header.slice(0, colon).trim() : check.header - const want = colon > 0 ? check.header.slice(colon + 1).trim() : '' - const got = res.headers.get(name) - if (got === null || !got.includes(want)) failures.push(`${line}: header ${name} is ${JSON.stringify(got)}, want it to contain "${want}"`) - } + for (const f of checkResponse(check, { status: res.status, headers: res.headers, body })) failures.push(`${line}: ${f}`) } // The 404 reports go out after the response; give them a moment. const reported = () => api.requests.filter((r) => r.method === 'POST' && r.url.endsWith('/notfound')).map((r) => /** @type {any} */ (r.body)?.url) for (let i = 0; i < 20 && reports.some((p) => !reported().includes(p)); i++) await new Promise((r) => setTimeout(r, 100)) + if (noreports.length > 0) await new Promise((r) => setTimeout(r, 500)) for (const p of reports) { if (!reported().includes(p)) failures.push(`report ${p}: no 404 report reached the API (got ${JSON.stringify(reported())})`) + const n = reported().filter((u) => u === p).length + if (n > 1) failures.push(`report ${p}: reported ${n} times, want once per request`) + } + for (const p of noreports) { + if (reported().includes(p)) failures.push(`noreport ${p}: a 404 report reached the API`) } if (api.requests.some((r) => r.url.endsWith('/notfound') && r.authorization !== `Bearer ${FIXTURE_NOTFOUND_TOKEN}`)) { failures.push('a 404 report carried the wrong token') } - if (!api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) { + if (api.requests.length > 0 && !api.requests.some((r) => (r.xff ?? '').includes(VISITOR_IP))) { failures.push('x-forwarded-for: the SSR fetches did not forward the visitor address') } } finally { @@ -209,7 +230,7 @@ async function main() { for (const f of failures) console.error(`smoke-ssr: FAIL ${f}`) process.exit(1) } - console.log(`smoke-ssr: ok (${lines.length} checks)`) + console.log(`smoke-ssr: ok (${lines.length} checks, ${args.expect})`) } main().catch((err) => { diff --git a/tests/graph.test.ts b/tests/graph.test.ts new file mode 100644 index 0000000..968fe4d --- /dev/null +++ b/tests/graph.test.ts @@ -0,0 +1,63 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { mergeGraph } from '../app/utils/graph.ts' + +const cms = { + '@context': 'https://schema.org', + '@graph': [ + { '@type': 'WebPage', '@id': 'https://acme.test/x#webpage', name: 'CMS name' }, + { '@type': 'Organization', '@id': 'https://acme.test/#org', name: 'Acme' }, + ], +} + +test('mergeGraph: a layer node with a CMS @id merges into it, CMS values first', () => { + const g = mergeGraph(cms, [{ '@id': 'https://acme.test/x#webpage', name: 'Layer name', inLanguage: 'pl' }]) + assert.ok(g) + assert.equal(g['@graph'].length, 2) + assert.deepEqual(g['@graph'][0], { '@id': 'https://acme.test/x#webpage', name: 'CMS name', inLanguage: 'pl', '@type': 'WebPage' }) +}) + +test('mergeGraph: a node with a new @id or none is appended, in order', () => { + const g = mergeGraph(cms, [{ '@type': 'ItemList', '@id': 'https://acme.test/x#itemlist' }, { '@type': 'Thing' }]) + assert.ok(g) + assert.deepEqual( + g['@graph'].map((n) => n['@type']), + ['WebPage', 'Organization', 'ItemList', 'Thing'], + ) +}) + +test('mergeGraph: no duplicate @id, also within the CMS graph and among layer nodes', () => { + const g = mergeGraph({ '@graph': [{ '@id': 'a', x: 1 }, { '@id': 'a', y: 2 }] }, [{ '@id': 'a', z: 3 }, { '@id': 'b' }, { '@id': 'b', w: 4 }]) + assert.ok(g) + const ids = g['@graph'].map((n) => n['@id']) + assert.deepEqual(ids, ['a', 'b']) + assert.deepEqual(g['@graph'][0], { '@id': 'a', x: 1, y: 2, z: 3 }) + assert.deepEqual(g['@graph'][1], { '@id': 'b', w: 4 }) +}) + +test('mergeGraph: one context, nested @context removed from nodes', () => { + const g = mergeGraph(null, [{ '@context': 'https://schema.org', '@type': 'FAQPage', '@id': 'f' }]) + assert.ok(g) + assert.equal(g['@context'], 'https://schema.org') + assert.deepEqual(g['@graph'], [{ '@type': 'FAQPage', '@id': 'f' }]) +}) + +test('mergeGraph: a single-node jsonld counts as one node', () => { + const g = mergeGraph({ '@context': 'https://schema.org', '@type': 'WebSite', '@id': 'w' }, []) + assert.deepEqual(g, { '@context': 'https://schema.org', '@graph': [{ '@type': 'WebSite', '@id': 'w' }] }) +}) + +test('mergeGraph: nothing to emit is null (no JSON-LD script)', () => { + assert.equal(mergeGraph(null, []), null) + assert.equal(mergeGraph(undefined), null) + assert.equal(mergeGraph({ '@graph': [] }, [null, 'x', 3]), null) + assert.equal(mergeGraph('nope', []), null) +}) + +test('mergeGraph: inputs are not mutated', () => { + const input = JSON.parse(JSON.stringify(cms)) + const node = { '@id': 'https://acme.test/x#webpage', extra: true } + mergeGraph(input, [node]) + assert.deepEqual(input, cms) + assert.deepEqual(node, { '@id': 'https://acme.test/x#webpage', extra: true }) +}) diff --git a/tests/pagePath.test.ts b/tests/pagePath.test.ts new file mode 100644 index 0000000..706ab70 --- /dev/null +++ b/tests/pagePath.test.ts @@ -0,0 +1,31 @@ +import assert from 'node:assert/strict' +import test from 'node:test' +import { isPagePath } from '../app/utils/pagePath.ts' + +test('isPagePath: Nuxt, layer and API paths are skipped', () => { + for (const p of ['/_nuxt/x', '/_nuxt/entry.js', '/__nuxt_error?url=/x', '/_seo/api/v1/resolve', '/_journal/api/v1/posts', '/api/x', '/api', '/api?x=1']) { + assert.equal(isPagePath(p), false, p) + } +}) + +test('isPagePath: asset and root files are skipped', () => { + for (const p of ['/favicon.ico', '/robots.txt', '/sitemap.xml', '/google0123.html', '/img/a.webp', '/fonts/x.woff2', '/blog/x/_payload.json', '/doc.pdf']) { + assert.equal(isPagePath(p), false, p) + } +}) + +test('isPagePath: pages are resolved, with their query', () => { + for (const p of ['/', '/blog/x', '/blog?page=2', '/autor/info@golem15.com', '/a.b/c', '/v1.2']) { + assert.equal(isPagePath(p), true, p) + } +}) + +test('isPagePath: resolveFiles extensions are resolved', () => { + assert.equal(isPagePath('/doc.pdf', ['pdf']), true) + assert.equal(isPagePath('/DOC.PDF', ['pdf']), true) + assert.equal(isPagePath('/a.png', ['pdf']), false) +}) + +test('isPagePath: no protocol-relative or relative input', () => { + for (const p of ['//evil.test/x', 'blog', '']) assert.equal(isPagePath(p), false, p) +}) diff --git a/tests/seo.test.ts b/tests/seo.test.ts index e731718..b44da75 100644 --- a/tests/seo.test.ts +++ b/tests/seo.test.ts @@ -4,6 +4,7 @@ import { SEO_STAGING_ROBOTS, seoAbsolute, seoAnswer, + seoEnabled, seoHeadInput, seoRedirectTarget, seoResolvableUrl, @@ -122,3 +123,45 @@ test('paths in canonical and social URLs become absolute on the request origin', assert.equal(meta['og:image'], 'https://site.test/img.png') assert.equal(meta['og:url'], 'https://site.test/about') }) + +test('seoHeadInput: v0.2.0 keys add html lang, og:locale and hreflang links', () => { + const answer = { + ...okAnswer({ og: { title: '', description: '', image: '', url: '', locale: 'pl_PL', locale_alternates: ['en_US', 'de_DE'] } }), + html_lang: 'pl', + alternates: [ + { hreflang: 'pl', url: 'https://acme.test/about' }, + { hreflang: 'x-default', url: '/about' }, + ], + } + const head = seoHeadInput(answer, 'https://site.test') + assert.deepEqual(head.htmlAttrs, { lang: 'pl' }) + assert.ok(head.meta.some((m) => m.property === 'og:locale' && m.content === 'pl_PL')) + assert.deepEqual( + head.meta.filter((m) => m.property === 'og:locale:alternate').map((m) => m.content), + ['en_US', 'de_DE'], + ) + assert.deepEqual( + head.link.filter((l) => l.rel === 'alternate'), + [ + { rel: 'alternate', hreflang: 'pl', href: 'https://acme.test/about', key: 'alt-pl' }, + { rel: 'alternate', hreflang: 'x-default', href: 'https://site.test/about', key: 'alt-x-default' }, + ], + ) +}) + +test('seoHeadInput: an answer without the v0.2.0 keys gives the v0.1.1 head', () => { + const head = seoHeadInput(okAnswer(), 'https://site.test') + assert.equal(head.htmlAttrs, undefined) + assert.ok(!head.meta.some((m) => m.property?.startsWith('og:locale'))) + assert.ok(!head.link.some((l) => l.rel === 'alternate')) +}) + +test('seoEnabled: enabled and its alias cms, booleans or strings', () => { + assert.equal(seoEnabled(undefined), true) + assert.equal(seoEnabled({}), true) + assert.equal(seoEnabled({ enabled: true, cms: true }), true) + assert.equal(seoEnabled({ enabled: false }), false) + assert.equal(seoEnabled({ enabled: 'false' }), false) + assert.equal(seoEnabled({ cms: false }), false) + assert.equal(seoEnabled({ enabled: true, cms: 'false' }), false) +})