+
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)
+})