Files
vue-seo-layer/server/lib/drift.ts
Jakub Zych 3abc986f42 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
2026-11-04 11:49:21 +01:00

85 lines
3.8 KiB
TypeScript

// 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
}