feat: route report and drift check at server start

- the module writes #seo-routes (static paths and route patterns) from the
  resolved pages; options seo.routeReport, seo.pagination and
  seo.htmlSitemap reach the public runtime config
- seo-route-report: POST /registry/routes once per process with the
  route-report token, one retry after 60 s on failure, nothing without a
  token, with the layer off or with routeReport false
- seo-drift: one "[vue-seo-layer] drift:" warning per mismatch between
  the CMS's site answer (URL patterns, pagination, HTML sitemap paths)
  and the build
- GET /api/__seo__/site and useSeoSite()
- smoke grammar routereport and log, fixture registry route, drift run
This commit is contained in:
Jakub Zych
2026-11-04 11:49:21 +01:00
parent 9cc2cda924
commit 3abc986f42
21 changed files with 599 additions and 20 deletions

View File

@@ -4,7 +4,9 @@
// testing/smoke-ssr.mjs builds it against the fixture API. The /blog pages
// are route-cached (swr) as on a production site, so the smoke and
// testing/purge-check.mjs see redirects, 410 and purges apply to cached
// pages. /prywatne/** is declared noindex.
// pages. /prywatne/** is declared noindex; the HTML sitemap paths follow
// the fixture site answer (testing/fixtures/seo/site.json), so the startup
// drift check finds nothing.
export default defineNuxtConfig({
extends: ['..'],
compatibilityDate: '2025-07-15',
@@ -17,5 +19,6 @@ export default defineNuxtConfig({
},
seo: {
noindexRoutes: ['/prywatne/**'],
htmlSitemap: { paths: { pl: '/mapa-strony', en: '/en/sitemap' } },
},
})

View File

@@ -0,0 +1,22 @@
// The CMS's site answer (pagination form and segments, the home crumb, the
// HTML sitemap paths, the content URL patterns …), fetched once per page
// through GET /api/__seo__/site and shared by every caller on the server
// and in the browser (stable key, so the payload carries it to hydration).
// `data.value` is null with the layer off, with a backend before SEO
// v0.2.0 or with the backend down.
//
// const { data: site } = await useSeoSite()
import type { SeoSiteAnswer } from '../utils/types.ts'
export const SEO_SITE_KEY = 'seo-layer:site'
export function useSeoSite() {
return useFetch('/api/__seo__/site', {
key: SEO_SITE_KEY,
transform: (res: { site?: SeoSiteAnswer | null } | null): SeoSiteAnswer | null =>
res && typeof res === 'object' && res.site && typeof res.site === 'object' ? res.site : null,
default: () => null,
dedupe: 'defer',
})
}

36
lib/options.ts Normal file
View File

@@ -0,0 +1,36 @@
// The layer's pagination and HTML-sitemap module options normalised for
// the runtime config (lib/seo-module.ts) and their readers in app and
// server code. Pure, so `node --test` drives it.
export interface SeoPaginationOption {
form: 'query' | 'path'
param: string
segments: Record<string, string>
}
export type SeoHtmlSitemapOption = { paths: Record<string, string> } | false
/** The pagination option with its defaults, as the runtime config carries it. */
export function paginationOption(value: unknown): SeoPaginationOption {
const v = value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
const segments: Record<string, string> = {}
const raw = v.segments !== null && typeof v.segments === 'object' ? (v.segments as Record<string, unknown>) : {}
for (const [locale, segment] of Object.entries(raw)) if (typeof segment === 'string' && /^[\p{L}\p{N}_-]+$/u.test(segment)) segments[locale] = segment
return {
form: v.form === 'path' ? 'path' : 'query',
param: typeof v.param === 'string' && /^[A-Za-z0-9_-]+$/.test(v.param) ? v.param : 'page',
segments,
}
}
/** The HTML sitemap option: false, or the paths (each '/' plus at least one character). */
export function htmlSitemapOption(value: unknown): SeoHtmlSitemapOption {
if (value === false) return false
const v = value !== null && typeof value === 'object' ? (value as Record<string, unknown>) : {}
const paths: Record<string, string> = {}
const raw = v.paths !== null && typeof v.paths === 'object' ? (v.paths as Record<string, unknown>) : {}
for (const [locale, path] of Object.entries(raw)) {
if (typeof path === 'string' && /^\/[^?#\s]+$/.test(path) && !path.includes(':') && !path.includes('*')) paths[locale] = path.replace(/\/+$/, '')
}
return { paths }
}

View File

@@ -5,6 +5,21 @@
// for the route report and the drift check. Pure TypeScript, so
// `node --test` drives it.
import { flattenPages, isNoindex, pagePattern, type GuardPage } from './guard.ts'
export interface RoutePage extends GuardPage {
name?: string
meta?: Record<string, unknown>
children?: RoutePage[]
}
export interface RoutePattern {
/** A route-rule style pattern: '/blog/:slug', '/docs/**', '/'. */
pattern: string
/** True when the route ends in a catch-all ('[...slug]'). */
catchAll: boolean
}
/** True when a robots value keeps the page out of the index. */
export const robotsNoindex = (robots: unknown) => typeof robots === 'string' && /(^|[\s,])(noindex|none)($|[\s,])/i.test(robots)
@@ -23,3 +38,47 @@ export function pageMetaRobots(meta: Record<string, unknown> | undefined | null)
if (meta.requiresAuth === true) return 'noindex, nofollow'
return null
}
/** The layer's own HTML sitemap pages, which the CMS knows already. */
const isLayerPage = (name: unknown) => typeof name === 'string' && name.startsWith('seo-sitemap-')
/**
* The paths without parameters that may be indexed, sorted: no page whose
* meta keeps it out of the index (requiresAuth, a noindex seo.robots), none
* under seo.noindexRoutes, none of the layer's HTML sitemap pages and no
* client-only page.
*/
export function staticRoutes(pages: readonly RoutePage[], noindexRoutes: readonly string[]): string[] {
const out = new Set<string>()
for (const page of flattenPages(pages) as Array<RoutePage & { fullPath: string }>) {
// A parent with children renders through one of them.
if (Array.isArray(page.children) && page.children.some((c) => c.path === '')) continue
const path = page.fullPath
if (path.includes(':') || path.includes('*')) continue
if (page.mode === 'client' || isLayerPage(page.name)) continue
if (robotsNoindex(pageMetaRobots(page.meta))) continue
if (isNoindex(path, noindexRoutes)) continue
out.add(path)
}
return [...out].sort()
}
/** Every page's route pattern ('/posts/:slug', '/**' for a catch-all), sorted. */
export function routePatterns(pages: readonly RoutePage[]): RoutePattern[] {
const seen = new Map<string, RoutePattern>()
for (const page of flattenPages(pages)) {
const pattern = pagePattern(page.fullPath)
if (!seen.has(pattern)) seen.set(pattern, { pattern, catchAll: pattern.endsWith('/**') })
}
return [...seen.values()].sort((a, b) => a.pattern.localeCompare(b.pattern))
}
/** The body of the server template #seo-routes. */
export function routesTemplate(staticPaths: readonly string[], patterns: readonly RoutePattern[]): string {
return [
'// Generated by vue-seo-layer (lib/routes.ts) from the resolved pages.',
`export const staticRoutes = ${JSON.stringify(staticPaths)}`,
`export const routePatterns = ${JSON.stringify(patterns)}`,
'',
].join('\n')
}

View File

@@ -8,8 +8,10 @@
// 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, useLogger } from 'nuxt/kit'
import { addServerTemplate, defineNuxtModule, useLogger } from 'nuxt/kit'
import { findOffenders, guardMessage, noindexRule, type GuardPage } from './guard.ts'
import { htmlSitemapOption, paginationOption, type SeoHtmlSitemapOption, type SeoPaginationOption } from './options.ts'
import { routePatterns, routesTemplate, staticRoutes, type RoutePage } from './routes.ts'
export interface SeoModuleOptions {
/**
@@ -45,6 +47,25 @@ export interface SeoModuleOptions {
* build guard then checks nothing. Default true.
*/
indexable: boolean
/**
* Report the site's static routes to the CMS's page registry at server
* start (D-33). Needs the route-report token (NUXT_SEO_ROUTE_REPORT_TOKEN,
* else NUXT_SEO_NOTFOUND_TOKEN). Default true.
*/
routeReport: boolean
/**
* The pagination URL form the site's routes are built for (D-40): query
* (?page=2) or path (/blog/strona/2, the segment per locale). The CMS's
* resolve answer wins at runtime; the startup drift check logs a
* difference.
*/
pagination: SeoPaginationOption
/**
* The HTML sitemap pages (D-34), by locale: the overview at the path, a
* group at <path>/<group>. false registers none. A path an app page
* already has is left to the app.
*/
htmlSitemap: SeoHtmlSitemapOption
}
export const SEO_MODULE_DEFAULTS: SeoModuleOptions = {
@@ -55,6 +76,9 @@ export const SEO_MODULE_DEFAULTS: SeoModuleOptions = {
resolveFiles: ['pdf'],
noindexRoutes: [],
indexable: true,
routeReport: true,
pagination: { form: 'query', param: 'page', segments: { pl: 'strona', en: 'page' } },
htmlSitemap: { paths: { pl: '/mapa-strony', en: '/sitemap' } },
}
const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((e): e is string => typeof e === 'string' && e !== '') : [])
@@ -72,6 +96,8 @@ export default defineNuxtModule<SeoModuleOptions>({
// a global ssr: false into a '/**' route rule.
const ssr = nuxt.options.ssr !== false
const inlineRouteRules = nuxt.options.experimental?.inlineRouteRules === true
const pagination = paginationOption(options.pagination)
const htmlSitemap = htmlSitemapOption(options.htmlSitemap)
// requiresAuth and seo are read from definePageMeta at build time, so
// the route report leaves signed-in and noindex pages out (D-23).
@@ -123,6 +149,17 @@ export default defineNuxtModule<SeoModuleOptions>({
pages = resolved as GuardPage[]
guard()
})
// The route artefact (#seo-routes) for the route report and the drift
// check: written from the resolved pages when Nitro bundles the
// server, so no network call happens at build time.
addServerTemplate({
filename: '#seo-routes',
getContents: () => {
const resolved = (pages ?? []) as RoutePage[]
return routesTemplate(staticRoutes(resolved, noindexRoutes), routePatterns(resolved))
},
})
nuxt.hook('nitro:build:before', (nitro) => {
nitroRules = nitro.options.routeRules as Record<string, unknown>
if (!checked || nuxt.options.dev) guard()
@@ -141,6 +178,9 @@ export default defineNuxtModule<SeoModuleOptions>({
resolveFiles: Array.isArray(options.resolveFiles) ? [...options.resolveFiles] : [...SEO_MODULE_DEFAULTS.resolveFiles],
noindexRoutes,
indexable,
routeReport: options.routeReport !== false,
pagination,
htmlSitemap,
}
},
})

View File

@@ -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 --no-site && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo.txt && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-off.txt --env NUXT_PUBLIC_SEO_CMS=false && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-off.txt --env NUXT_PUBLIC_SEO_ENABLED=false && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-down.txt --env NUXT_SEO_API_BASE=http://127.0.0.1:1/_seo/api/v1 && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-notoken.txt --env NUXT_SEO_NOTFOUND_TOKEN=",
"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 && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-off.txt --env NUXT_PUBLIC_SEO_CMS=false && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-off.txt --env NUXT_PUBLIC_SEO_ENABLED=false && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-down.txt --env NUXT_SEO_API_BASE=http://127.0.0.1:1/_seo/api/v1 && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-notoken.txt --env NUXT_SEO_NOTFOUND_TOKEN= && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-drift.txt --site-file site-drift.json --env NUXT_PUBLIC_SEO_ROUTE_REPORT=false",
"guard:check": "node testing/guard-check.mjs",
"purge:check": "node testing/purge-check.mjs --output .playground/.output --fixtures testing/fixtures --path /blog/lwia-grzywa && node testing/purge-token-check.mjs --output .playground/.output --fixtures testing/fixtures --path /blog/lwia-grzywa"
},

View File

@@ -0,0 +1,18 @@
// GET /api/__seo__/site: the CMS's site answer (GET <apiBase>/site, SEO
// plugin v0.2.0) through the layer's server and its cache, for useSeoSite()
// on the server and in the browser. Always 200 with {site: <answer> | null}:
// null with the layer off, with a backend before v0.2.0 (its /site answers
// 404) or with a backend that has not answered since start. Never
// route-cached ('/api/__seo__/**' cache false) and never indexed.
import { seoServerConfig } from '../../lib/config.ts'
import { siteAnswer } from '../../lib/site.ts'
export default defineEventHandler(async (event) => {
setResponseHeaders(event, { 'cache-control': 'no-store', 'x-robots-tag': 'noindex' })
const cfg = seoServerConfig(useRuntimeConfig(event))
if (!cfg.enabled) return { site: null }
const forwarded = event.context.seoForwardedFor as string | undefined
const state = await siteAnswer(cfg, forwarded ? { 'x-forwarded-for': forwarded } : {})
return { site: state.state === 'ok' ? state.site : null }
})

View File

@@ -17,8 +17,10 @@ export interface SeoServerConfig {
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. */
/** Bearer token of the route report; falls back to notfoundToken. */
routeReportToken: string
/** Module option seo.routeReport (NUXT_PUBLIC_SEO_ROUTE_REPORT): report the static routes at start. */
routeReport: boolean
/** 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']). */
@@ -74,6 +76,7 @@ export function seoServerConfig(config: unknown): SeoServerConfig {
resolveCacheTtl: num(seo.resolveCacheTtl, DEFAULT_RESOLVE_CACHE_TTL),
notfoundToken,
routeReportToken: str(seo.routeReportToken) || notfoundToken,
routeReport: pub.routeReport !== false && pub.routeReport !== 'false',
rootFiles: pub.rootFiles !== false && pub.rootFiles !== 'false',
resolveFiles,
}

84
server/lib/drift.ts Normal file
View File

@@ -0,0 +1,84 @@
// The startup drift check (D-20c, RESEARCH Open Question 9): Nuxt routes
// and the layer's pagination and HTML-sitemap options are fixed at build
// time, while the CMS decides content URL patterns, the pagination form and
// the HTML-sitemap paths. At server start the layer compares the two and
// logs one warning per mismatch, so an editor's change in the CMS that the
// frontend does not follow is visible. Pure, so `node --test` drives it.
import { patternCovers } from '../../lib/guard.ts'
import type { RoutePattern } from '../../lib/routes.ts'
import type { SeoSiteAnswer } from '../../app/utils/types.ts'
export const DRIFT_PREFIX = '[vue-seo-layer] drift:'
export interface LayerPaginationOption {
form: 'query' | 'path'
param: string
segments: Record<string, string>
}
export interface DriftInput {
site: SeoSiteAnswer
routes: readonly RoutePattern[]
pagination: LayerPaginationOption
/** seo.htmlSitemap.paths, or false when the layer serves no HTML sitemap. */
htmlSitemap: Record<string, string> | false
}
/** A CMS URL pattern in route-rule form: '{slug}' and ':slug' alike become ':slug'. */
const cmsPattern = (p: string) => p.replace(/\{(\w+)\}/g, ':$1').split('?')[0]!
/**
* True when a Nuxt route serves the CMS pattern: a route of the same shape
* (a parameter stands for any segment), or a catch-all under a static
* prefix ('/docs/**'). A catch-all at the root ('[...slug].vue') is a
* generic page renderer and does not count as a route for a content type.
*/
export function routeServes(route: RoutePattern, pattern: string): boolean {
if (route.catchAll && route.pattern === '/**') return false
return patternCovers(route.pattern, cmsPattern(pattern))
}
/** One message per mismatch between the CMS's site answer and the build. */
export function driftWarnings(input: DriftInput): string[] {
const out: string[] = []
const site = input.site ?? {}
for (const r of Array.isArray(site.routes) ? site.routes : []) {
if (!r || typeof r.pattern !== 'string' || r.pattern === '') continue
if (!input.routes.some((route) => routeServes(route, r.pattern))) {
out.push(`${DRIFT_PREFIX} the CMS's ${r.type || 'content'} URLs (${r.pattern}) match no Nuxt route`)
}
}
const p = site.pagination
if (p && typeof p === 'object') {
const form = p.form === 'path' ? 'path' : p.form === 'query' ? 'query' : null
if (form && form !== input.pagination.form) {
out.push(`${DRIFT_PREFIX} the CMS paginates in the ${form} form, the layer was built with ${input.pagination.form} (seo.pagination.form)`)
} else if (form === 'query' && typeof p.param === 'string' && p.param && p.param !== input.pagination.param) {
out.push(`${DRIFT_PREFIX} the CMS's page parameter is ${p.param}, the layer's is ${input.pagination.param} (seo.pagination.param)`)
} else if (form === 'path') {
for (const [locale, segment] of Object.entries(p.segments ?? {})) {
const own = input.pagination.segments[locale]
if (typeof segment === 'string' && segment && own !== segment) {
out.push(`${DRIFT_PREFIX} the CMS's ${locale} page segment is ${segment}, the layer's is ${own ?? 'unset'} (seo.pagination.segments)`)
}
}
}
}
const html = site.html_sitemap
if (html && html.enabled === true && html.paths && typeof html.paths === 'object') {
if (input.htmlSitemap === false) {
out.push(`${DRIFT_PREFIX} the CMS has an HTML sitemap, the layer serves none (seo.htmlSitemap is false)`)
} else {
for (const [locale, path] of Object.entries(html.paths)) {
if (typeof path !== 'string' || path === '') continue
const own = input.htmlSitemap[locale]
if (own !== path) out.push(`${DRIFT_PREFIX} the CMS serves the ${locale} HTML sitemap at ${path}, the layer at ${own ?? 'no path'} (seo.htmlSitemap.paths)`)
}
}
}
return out
}

33
server/lib/routeReport.ts Normal file
View File

@@ -0,0 +1,33 @@
// The route report (D-33): at server start the layer posts its static
// routes (#seo-routes, built from the pages) to the SEO plugin's
// POST /_seo/api/v1/registry/routes with the route-report token, so the
// CMS's page registry knows every frontend page. Pure helpers for the
// Nitro plugin server/plugins/seo-route-report.ts, so `node --test` drives
// them.
/** The plugin's limits: at most 2000 paths of at most 2048 bytes each. */
export const ROUTE_REPORT_MAX_ROUTES = 2000
export const ROUTE_REPORT_MAX_BYTES = 2048
/** The one retry after a failed report waits this long. */
export const ROUTE_REPORT_RETRY_MS = 60_000
/** The request body: the paths within the plugin's limits. */
export function routeReportBody(paths: readonly string[]): { routes: string[] } {
const routes = paths
.filter((p) => typeof p === 'string' && p.startsWith('/') && Buffer.byteLength(p, 'utf8') <= ROUTE_REPORT_MAX_BYTES)
.slice(0, ROUTE_REPORT_MAX_ROUTES)
return { routes }
}
/**
* What an answer means: sent (2xx), absent (404: a backend without the
* page registry, or with both tokens empty; never retried), refused (401 or
* 403: the token is wrong; never retried) or retry (no answer, or any other
* status: tried once more after ROUTE_REPORT_RETRY_MS).
*/
export function routeReportOutcome(status: number | null): 'sent' | 'absent' | 'refused' | 'retry' {
if (status !== null && status >= 200 && status < 300) return 'sent'
if (status === 404) return 'absent'
if (status === 401 || status === 403) return 'refused'
return 'retry'
}

View File

@@ -0,0 +1,40 @@
// The startup drift check (D-20c): compares the CMS's content-type URL
// patterns, pagination form and segments and HTML-sitemap paths (the site
// answer, GET <apiBase>/site) with the build's routes (#seo-routes) and the
// layer's seo.pagination and seo.htmlSitemap options, and logs one
// "[vue-seo-layer] drift: ..." warning per mismatch. It runs once after the
// plugin returns, never blocks the start and never fails it; a backend
// without a site answer (before SEO v0.2.0) or one that does not answer is
// skipped. Off with the layer off or while prerendering.
import { routePatterns } from '#seo-routes'
import { seoServerConfig } from '../lib/config.ts'
import { driftWarnings, type LayerPaginationOption } from '../lib/drift.ts'
import { siteAnswer } from '../lib/site.ts'
export default defineNitroPlugin(() => {
if (import.meta.prerender) return
const config = useRuntimeConfig()
const cfg = seoServerConfig(config)
if (!cfg.enabled) return
const pub = ((config.public as Record<string, unknown>).seo ?? {}) as {
pagination?: LayerPaginationOption
htmlSitemap?: { paths?: Record<string, string> } | false
}
const pagination: LayerPaginationOption = {
form: pub.pagination?.form === 'path' ? 'path' : 'query',
param: pub.pagination?.param || 'page',
segments: { ...(pub.pagination?.segments ?? {}) },
}
const htmlSitemap = pub.htmlSitemap === false || (pub.htmlSitemap as unknown) === 'false' ? false : { ...(pub.htmlSitemap?.paths ?? {}) }
setTimeout(async () => {
try {
const state = await siteAnswer(cfg, {})
if (state.state !== 'ok') return
for (const warning of driftWarnings({ site: state.site, routes: routePatterns, pagination, htmlSitemap })) console.warn(warning)
} catch (err) {
console.warn('[vue-seo-layer] drift check failed:', (err as Error)?.message ?? err)
}
}, 0).unref?.()
})

View File

@@ -0,0 +1,51 @@
// Reports the site's static routes to the CMS's page registry once per
// server process (D-33): POST <apiBase>/registry/routes with
// `Authorization: Bearer <route-report token>` and {routes: [path, ...]}.
// The paths come from the build (#seo-routes): pages without parameters,
// without the ones whose meta keeps them out of the index (requiresAuth, a
// noindex seo.robots) and without seo.noindexRoutes. It never delays the
// start: the report goes out after the plugin returns, a failure (no
// answer, a 5xx) is tried once more after 60 s, and a 404 (a backend without
// the registry) or a refused token is logged once and not retried. Nothing
// is sent with the layer off (seo.cms, public.seo.enabled), with
// seo.routeReport false, without a token (NUXT_SEO_ROUTE_REPORT_TOKEN, else
// NUXT_SEO_NOTFOUND_TOKEN) or while prerendering. The token stays in the
// server-only runtime config and goes to the configured apiBase only.
import { staticRoutes } from '#seo-routes'
import { seoServerConfig } from '../lib/config.ts'
import { ROUTE_REPORT_RETRY_MS, routeReportBody, routeReportOutcome } from '../lib/routeReport.ts'
export default defineNitroPlugin(() => {
if (import.meta.prerender) return
const cfg = seoServerConfig(useRuntimeConfig())
if (!cfg.enabled || !cfg.routeReport || !cfg.routeReportToken) return
const body = routeReportBody(staticRoutes)
const send = async (attempt: number): Promise<void> => {
let status: number | null = null
let reason = ''
try {
const res = await $fetch.raw('/registry/routes', {
baseURL: cfg.apiBase,
method: 'POST',
body,
headers: { authorization: `Bearer ${cfg.routeReportToken}` },
timeout: Math.max(cfg.timeoutMs, 5000),
ignoreResponseError: true,
})
status = res.status
} catch (err) {
reason = (err as Error)?.message ?? String(err)
}
const outcome = routeReportOutcome(status)
if (outcome === 'sent') console.info(`[vue-seo-layer] route report: ${body.routes.length} routes sent`)
else if (outcome === 'absent') console.info('[vue-seo-layer] route report: the CMS has no page registry (404), nothing reported')
else if (outcome === 'refused') console.warn(`[vue-seo-layer] route report: the CMS refused the token (${status}); set NUXT_SEO_ROUTE_REPORT_TOKEN to golem15.seo.route_report_token`)
else if (attempt === 1) {
console.warn(`[vue-seo-layer] route report failed (${status ?? reason}); trying again in ${ROUTE_REPORT_RETRY_MS / 1000} s`)
setTimeout(() => void send(2), ROUTE_REPORT_RETRY_MS).unref?.()
} else console.warn(`[vue-seo-layer] route report failed again (${status ?? reason}); not retried before the next start`)
}
setTimeout(() => void send(1), 0).unref?.()
})

8
server/seo-routes.d.ts vendored Normal file
View File

@@ -0,0 +1,8 @@
// The server template the layer's module writes from the resolved pages
// (lib/routes.ts, addServerTemplate).
declare module '#seo-routes' {
/** Paths without parameters that may be indexed (the route report). */
export const staticRoutes: string[]
/** Every page's route pattern (the drift check). */
export const routePatterns: Array<{ pattern: string; catchAll: boolean }>
}

View File

@@ -0,0 +1,12 @@
# The startup drift check against a site answer that differs from the
# build (testing/fixtures/seo/site-drift.json): a content type whose URL
# pattern no Nuxt route serves, the path pagination form against the
# layer's query form and another pl HTML sitemap path. One warning each;
# the server still starts and serves pages. Run with
# NUXT_PUBLIC_SEO_ROUTE_REPORT=false, so no route report is sent.
200 /plain contains=<title>Plain page | Playground</title>
log [vue-seo-layer] drift::3
log [vue-seo-layer] drift: the CMS's journal-post URLs (/wpisy/:slug) match no Nuxt route:1
log [vue-seo-layer] drift: the CMS paginates in the path form, the layer was built with query (seo.pagination.form):1
log [vue-seo-layer] drift: the CMS serves the pl HTML sitemap at /mapa, the layer at /mapa-strony (seo.htmlSitemap.paths):1
routereport 0

View File

@@ -1,3 +1,5 @@
# No 404 token (NUXT_SEO_NOTFOUND_TOKEN empty): a 404 page sends no report.
404 /kontakt
noreport /kontakt
# Without a token the route report is not sent either.
routereport 0

View File

@@ -75,3 +75,12 @@ noreport /stary
200 /prywatne contains=<meta name="robots" content="noindex, nofollow"> header=X-Robots-Tag: noindex, nofollow
200 /plain noheader=X-Robots-Tag count=name="robots":0
200 /blog/lwia-grzywa contains=<meta name="robots" content="index, follow">
# The site answer through the layer's server, for useSeoSite().
200 /api/__seo__/site contains="site_url":"https://example.test" header=X-Robots-Tag: noindex
# The route report at server start: the static paths with the token, not
# the signed-in page, the page with a noindex seo.robots or the declared
# noindex route. The build matches the site answer: no drift warning.
routereport 1 has=/ has=/plain has=/about lacks=/konto lacks=/szkic lacks=/prywatne
log [vue-seo-layer] drift::0

View File

@@ -4,11 +4,14 @@
// GET /_seo/api/v1/resolve?url=… the answer for that exact url from
// <dir>/seo/resolve.json, then
// <dir>/resolve.json, else not_found
// GET /_seo/api/v1/site <dir>/seo/site.json (404 without the
// file or with { site: false }: an
// older backend)
// GET /_seo/api/v1/site <dir>/seo/site.json, or <dir>/seo/<siteFile>
// (404 without the file or with
// { site: false }: an older backend)
// POST /_seo/api/v1/notfound 204 with `Authorization: Bearer <token>`,
// 401 with another token, 404 without one
// POST /_seo/api/v1/registry/routes the route report: the same token rules;
// 404 with { site: false } (an older
// backend has no page registry)
// GET /_seo/api/v1/verification/<name> <dir>/verification/<name> or
// <dir>/seo/verification/<name>, else 404
// GET /robots.txt, /llms.txt, <dir>/seo/root/<name>, else 404
@@ -52,10 +55,10 @@ function contentType(name) {
/**
* Starts the fixture API on 127.0.0.1.
* @param {{ dir: string, port?: number, token?: string, site?: boolean }} opts
* @param {{ dir: string, port?: number, token?: string, site?: boolean, siteFile?: string }} opts
* @returns {Promise<FixtureApi>}
*/
export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN, site = true }) {
export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN, site = true, siteFile = 'site.json' }) {
/** @type {FixtureRequest[]} */
const requests = []
/** @type {Record<string, unknown>} */
@@ -64,7 +67,7 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN,
let siteAnswer = null
const load = () => {
answers = { ...(readJson(join(dir, 'resolve.json')) ?? {}), ...(readJson(join(dir, 'seo', 'resolve.json')) ?? {}) }
siteAnswer = site ? readJson(join(dir, 'seo', 'site.json')) : null
siteAnswer = site ? readJson(join(dir, 'seo', siteFile)) : null
}
load()
@@ -103,6 +106,10 @@ export function startFixtureApi({ dir, port = 0, token = FIXTURE_NOTFOUND_TOKEN,
if (!token) return { status: 404, body: { error: 'not_found' } }
return req.headers.authorization === `Bearer ${token}` ? { status: 204 } : { status: 401, body: { error: 'unauthorized' } }
}
if (path === '/registry/routes' && req.method === 'POST') {
if (!site || !token) return { status: 404, body: { error: 'not_found' } }
return req.headers.authorization === `Bearer ${token}` ? { status: 204 } : { status: 401, body: { error: 'unauthorized' } }
}
const v = /^\/verification\/([A-Za-z0-9._-]+)$/.exec(path)
if (v && req.method === 'GET') {
for (const file of [join(dir, 'verification', v[1]), join(dir, 'seo', 'verification', v[1])]) {

View File

@@ -0,0 +1,16 @@
{
"site_url": "https://example.test",
"default_locale": "pl",
"locales": ["pl", "en"],
"unprefixed_locale": "pl",
"links": { "new_tab": true },
"html_sitemap": { "enabled": true, "paths": { "pl": "/mapa", "en": "/en/sitemap" }, "page_size": 100 },
"routes": [
{ "type": "post", "pattern": "/posts/:slug" },
{ "type": "journal-post", "pattern": "/wpisy/:slug" }
],
"root_files": [],
"llms": false,
"pagination": { "form": "path", "param": "page", "segments": { "pl": "strona", "en": "page" } },
"home_crumb": { "name": "Start", "url": "https://example.test/" }
}

View File

@@ -5,7 +5,7 @@
"unprefixed_locale": "pl",
"links": { "new_tab": true },
"html_sitemap": { "enabled": true, "paths": { "pl": "/mapa-strony", "en": "/en/sitemap" }, "page_size": 100 },
"routes": [{ "type": "post", "pattern": "/blog/:slug" }],
"routes": [{ "type": "post", "pattern": "/posts/:slug" }],
"root_files": ["google1234.html", "a1b2c3d4e5f60718293a4b5c6d7e8f90.txt"],
"llms": true,
"pagination": { "form": "query", "param": "page", "segments": {} },

View File

@@ -2,7 +2,7 @@
// 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 <dir> --expect <file> [--no-site] [--env KEY=VALUE]...
// node testing/smoke-ssr.mjs --output <.output dir> --fixtures <dir> --expect <file> [--no-site] [--site-file NAME] [--env KEY=VALUE]...
//
// Expectation grammar, one check per line ('#' starts a comment line):
// STATUS PATH [contains=TEXT] [notcontains=TEXT] [count=TEXT:N] [type=PREFIX]
@@ -12,6 +12,12 @@
// 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
// routereport N [has=PATH]... [lacks=PATH]...
// exactly N route reports (POST /registry/routes) reached
// the API by the end of the run, each with the token;
// every has= path is in the last one, no lacks= path is
// log TEXT:N the server's output holds exactly N occurrences of TEXT
// (waits up to 3 s for them; N after the last colon)
// 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
@@ -28,8 +34,9 @@
// 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.
// makes the fixture API answer /site (and the route report) with 404, as a
// backend before SEO v0.2.0 does; --site-file answers /site with another
// file of <fixtures>/seo.
//
// The same approach as vue-faq-layer's testing/smoke-ssr.mjs; plain .mjs
// with JSDoc types so it runs from node_modules unchanged.
@@ -45,7 +52,7 @@ const OPTION_KEYS = ['contains', 'notcontains', 'count', 'type', 'header', 'nohe
/** @param {string[]} argv */
function parseArgs(argv) {
/** @type {{ output?: string, fixtures?: string, expect?: string, site: boolean, env: Record<string, string> }} */
/** @type {{ output?: string, fixtures?: string, expect?: string, site: boolean, siteFile?: string, env: Record<string, string> }} */
const out = { env: {}, site: true }
for (let i = 0; i < argv.length; i++) {
const arg = argv[i]
@@ -58,6 +65,7 @@ function parseArgs(argv) {
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 === '--site-file') out.siteFile = next()
else if (arg === '--env') {
const kv = next()
const eq = kv.indexOf('=')
@@ -66,7 +74,7 @@ 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, site: boolean, env: Record<string, string> }} */ (out)
return /** @type {{ output: string, fixtures: string, expect: string, site: boolean, siteFile?: string, env: Record<string, string> }} */ (out)
}
/**
@@ -156,9 +164,10 @@ async function main() {
const reports = lines.filter((l) => l.startsWith('report ')).map((l) => l.slice('report '.length).trim())
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<void> } | null} */
const routeReports = lines.filter((l) => l.startsWith('routereport '))
const steps = lines.filter((l) => !l.startsWith('report ') && !l.startsWith('noreport ') && !l.startsWith('routereport '))
const api = await startFixtureApi({ dir: resolve(args.fixtures), site: args.site, ...(args.siteFile ? { siteFile: args.siteFile } : {}) })
/** @type {{ url: string, close: () => Promise<void>, output: () => string } | null} */
let nuxt = null
const failures = /** @type {string[]} */ ([])
try {
@@ -174,6 +183,15 @@ async function main() {
if (got !== Number(resolves[2])) failures.push(`${line}: the API saw ${got} resolve calls`)
continue
}
const log = /^log\s+(.+):(\d+)$/.exec(line)
if (log) {
const want = Number(log[2])
const seen = () => occurrences(nuxt?.output() ?? '', log[1])
for (let i = 0; i < 30 && seen() < want; i++) await new Promise((r) => setTimeout(r, 100))
if (want === 0) await new Promise((r) => setTimeout(r, 500))
if (seen() !== want) failures.push(`${line}: the server output has ${seen()}\n${nuxt?.output() ?? ''}`)
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(', ')})`)
@@ -215,6 +233,26 @@ async function main() {
for (const p of noreports) {
if (reported().includes(p)) failures.push(`noreport ${p}: a 404 report reached the API`)
}
const routePosts = () => api.requests.filter((r) => r.method === 'POST' && r.url.endsWith('/registry/routes'))
for (const line of routeReports) {
const m = /^routereport\s+(\d+)((?:\s+(?:has|lacks)=\S+)*)$/.exec(line)
if (!m) {
failures.push(`${line}: expected "routereport N [has=PATH]... [lacks=PATH]..."`)
continue
}
const want = Number(m[1])
for (let i = 0; i < 20 && routePosts().length < want; i++) await new Promise((r) => setTimeout(r, 100))
if (want === 0) await new Promise((r) => setTimeout(r, 500))
const posts = routePosts()
if (posts.length !== want) failures.push(`${line}: the API saw ${posts.length} route reports`)
if (posts.some((r) => r.authorization !== `Bearer ${FIXTURE_NOTFOUND_TOKEN}`)) failures.push(`${line}: a route report carried the wrong token`)
const last = /** @type {any} */ (posts[posts.length - 1]?.body)?.routes ?? []
for (const opt of (m[2] ?? '').trim().split(/\s+/).filter(Boolean)) {
const [key, path] = [opt.slice(0, opt.indexOf('=')), opt.slice(opt.indexOf('=') + 1)]
if (key === 'has' && !last.includes(path)) failures.push(`${line}: ${path} is not in the report ${JSON.stringify(last)}`)
if (key === 'lacks' && last.includes(path)) failures.push(`${line}: ${path} is in the report ${JSON.stringify(last)}`)
}
}
if (api.requests.some((r) => r.url.endsWith('/notfound') && r.authorization !== `Bearer ${FIXTURE_NOTFOUND_TOKEN}`)) {
failures.push('a 404 report carried the wrong token')
}

View File

@@ -1,6 +1,23 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { pageMetaRobots, robotsNoindex } from '../lib/routes.ts'
import { htmlSitemapOption, paginationOption } from '../lib/options.ts'
import { pageMetaRobots, robotsNoindex, routePatterns, routesTemplate, staticRoutes } from '../lib/routes.ts'
import { DRIFT_PREFIX, driftWarnings, routeServes } from '../server/lib/drift.ts'
import { ROUTE_REPORT_MAX_ROUTES, routeReportBody, routeReportOutcome } from '../server/lib/routeReport.ts'
const pages = [
{ path: '/', name: 'index' },
{ path: '/blog', name: 'blog' },
{ path: '/posts/:slug()', name: 'posts-slug' },
{ path: '/:slug(.*)*', name: 'slug' },
{ path: '/konto', name: 'konto', meta: { requiresAuth: true } },
{ path: '/szkic', name: 'szkic', meta: { seo: { robots: 'noindex, follow' } } },
{ path: '/otwarty', name: 'otwarty', meta: { seo: { robots: 'index, follow' } } },
{ path: '/prywatne', name: 'prywatne' },
{ path: '/panel', name: 'panel', mode: 'client' },
{ path: '/mapa-strony', name: 'seo-sitemap-pl' },
{ path: '/ustawienia', name: 'ustawienia', children: [{ path: '', name: 'ustawienia-index' }, { path: 'profil', name: 'ustawienia-profil' }] },
]
test('pageMetaRobots: seo.robots, then requiresAuth, else the CMS decides', () => {
assert.equal(pageMetaRobots({ requiresAuth: true }), 'noindex, nofollow')
@@ -17,3 +34,84 @@ test('robotsNoindex: noindex and none keep a page out', () => {
assert.equal(robotsNoindex('index, follow'), false)
assert.equal(robotsNoindex(null), false)
})
test('staticRoutes: indexable paths without parameters', () => {
assert.deepEqual(staticRoutes(pages, ['/prywatne/**']), ['/', '/blog', '/otwarty', '/ustawienia', '/ustawienia/profil'])
})
test('routePatterns: page paths as patterns, catch-alls marked', () => {
const out = routePatterns(pages)
assert.deepEqual(out.find((p) => p.pattern === '/posts/:slug'), { pattern: '/posts/:slug', catchAll: false })
assert.deepEqual(out.find((p) => p.pattern === '/**'), { pattern: '/**', catchAll: true })
assert.equal(new Set(out.map((p) => p.pattern)).size, out.length)
})
test('routesTemplate: an ES module with both lists', () => {
const src = routesTemplate(['/'], [{ pattern: '/', catchAll: false }])
assert.match(src, /export const staticRoutes = \["\/"\]/)
assert.match(src, /export const routePatterns = \[\{"pattern":"\/","catchAll":false\}\]/)
})
test('routeReportBody: within the plugin limits', () => {
assert.deepEqual(routeReportBody(['/', '/blog', 'nope', '/' + 'a'.repeat(2048)]).routes, ['/', '/blog'])
assert.equal(routeReportBody(Array.from({ length: 2500 }, (_, i) => `/p${i}`)).routes.length, ROUTE_REPORT_MAX_ROUTES)
})
test('routeReportOutcome: sent, absent, refused or one retry', () => {
assert.equal(routeReportOutcome(204), 'sent')
assert.equal(routeReportOutcome(200), 'sent')
assert.equal(routeReportOutcome(404), 'absent')
assert.equal(routeReportOutcome(401), 'refused')
assert.equal(routeReportOutcome(403), 'refused')
assert.equal(routeReportOutcome(503), 'retry')
assert.equal(routeReportOutcome(null), 'retry')
})
test('options: pagination and HTML sitemap with their checks', () => {
assert.deepEqual(paginationOption({ form: 'path', param: 'p', segments: { pl: 'strona', en: 'a b' } }), { form: 'path', param: 'p', segments: { pl: 'strona' } })
assert.deepEqual(paginationOption(undefined), { form: 'query', param: 'page', segments: {} })
assert.equal(htmlSitemapOption(false), false)
assert.deepEqual(htmlSitemapOption({ paths: { pl: '/mapa-strony/', en: 'sitemap', de: '/x/:id' } }), { paths: { pl: '/mapa-strony' } })
})
const layer = { pagination: { form: 'query' as const, param: 'page', segments: { pl: 'strona', en: 'page' } }, htmlSitemap: { pl: '/mapa-strony', en: '/en/sitemap' } }
const routes = routePatterns(pages)
test('routeServes: a route of the same shape or a prefixed catch-all; not the root catch-all', () => {
assert.equal(routeServes({ pattern: '/posts/:slug', catchAll: false }, '/posts/:id'), true)
assert.equal(routeServes({ pattern: '/posts/:slug', catchAll: false }, '/posts/{slug}'), true)
assert.equal(routeServes({ pattern: '/docs/**', catchAll: true }, '/docs/:a/:b'), true)
assert.equal(routeServes({ pattern: '/**', catchAll: true }, '/wpisy/:slug'), false)
})
test('driftWarnings: nothing when the CMS and the build agree', () => {
const site = {
routes: [{ type: 'post', pattern: '/posts/:slug' }],
pagination: { form: 'query' as const, param: 'page', segments: {} },
html_sitemap: { enabled: true, paths: { pl: '/mapa-strony', en: '/en/sitemap' } },
}
assert.deepEqual(driftWarnings({ site, routes, ...layer }), [])
})
test('driftWarnings: one warning per mismatch', () => {
const site = {
routes: [{ type: 'journal-post', pattern: '/wpisy/:slug' }],
pagination: { form: 'path' as const, param: 'page', segments: { pl: 'strona' } },
html_sitemap: { enabled: true, paths: { pl: '/mapa', en: '/en/sitemap' } },
}
const out = driftWarnings({ site, routes, ...layer })
assert.equal(out.length, 3)
assert.ok(out.every((w) => w.startsWith(DRIFT_PREFIX)))
assert.match(out[0]!, /journal-post URLs \(\/wpisy\/:slug\) match no Nuxt route/)
assert.match(out[1]!, /path form/)
assert.match(out[2]!, /pl HTML sitemap at \/mapa/)
})
test('driftWarnings: segments and param, a switched-off layer sitemap, a switched-off CMS sitemap', () => {
const pathLayer = { ...layer, pagination: { form: 'path' as const, param: 'page', segments: { pl: 'strona' } } }
assert.equal(driftWarnings({ site: { pagination: { form: 'path', segments: { pl: 'str' } } }, routes, ...pathLayer }).length, 1)
assert.equal(driftWarnings({ site: { pagination: { form: 'query', param: 'p' } }, routes, ...layer }).length, 1)
assert.equal(driftWarnings({ site: { html_sitemap: { enabled: true, paths: { pl: '/mapa-strony' } } }, routes, ...layer, htmlSitemap: false }).length, 1)
assert.deepEqual(driftWarnings({ site: { html_sitemap: { enabled: false, paths: { pl: '/x' } } }, routes, ...layer }), [])
assert.deepEqual(driftWarnings({ site: {}, routes, ...layer }), [])
})