diff --git a/.playground/nuxt.config.ts b/.playground/nuxt.config.ts index 4ced7c0..57076d8 100644 --- a/.playground/nuxt.config.ts +++ b/.playground/nuxt.config.ts @@ -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' } }, }, }) diff --git a/app/composables/useSeoSite.ts b/app/composables/useSeoSite.ts new file mode 100644 index 0000000..398a853 --- /dev/null +++ b/app/composables/useSeoSite.ts @@ -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', + }) +} diff --git a/lib/options.ts b/lib/options.ts new file mode 100644 index 0000000..15d917f --- /dev/null +++ b/lib/options.ts @@ -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 +} + +export type SeoHtmlSitemapOption = { paths: Record } | 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) : {} + const segments: Record = {} + const raw = v.segments !== null && typeof v.segments === 'object' ? (v.segments as Record) : {} + 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) : {} + const paths: Record = {} + const raw = v.paths !== null && typeof v.paths === 'object' ? (v.paths as Record) : {} + 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 } +} diff --git a/lib/routes.ts b/lib/routes.ts index a530084..707713f 100644 --- a/lib/routes.ts +++ b/lib/routes.ts @@ -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 + 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 | 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() + for (const page of flattenPages(pages) as Array) { + // 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() + 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') +} diff --git a/lib/seo-module.ts b/lib/seo-module.ts index 09ba357..5344aee 100644 --- a/lib/seo-module.ts +++ b/lib/seo-module.ts @@ -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 /. 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({ // 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({ 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 if (!checked || nuxt.options.dev) guard() @@ -141,6 +178,9 @@ export default defineNuxtModule({ resolveFiles: Array.isArray(options.resolveFiles) ? [...options.resolveFiles] : [...SEO_MODULE_DEFAULTS.resolveFiles], noindexRoutes, indexable, + routeReport: options.routeReport !== false, + pagination, + htmlSitemap, } }, }) diff --git a/package.json b/package.json index 63e233c..b2f4791 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 --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" }, diff --git a/server/api/__seo__/site.get.ts b/server/api/__seo__/site.get.ts new file mode 100644 index 0000000..75bd865 --- /dev/null +++ b/server/api/__seo__/site.get.ts @@ -0,0 +1,18 @@ +// GET /api/__seo__/site: the CMS's site answer (GET /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: | 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 } +}) diff --git a/server/lib/config.ts b/server/lib/config.ts index 2d8f726..90f2388 100644 --- a/server/lib/config.ts +++ b/server/lib/config.ts @@ -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, } diff --git a/server/lib/drift.ts b/server/lib/drift.ts new file mode 100644 index 0000000..813cc1f --- /dev/null +++ b/server/lib/drift.ts @@ -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 +} + +export interface DriftInput { + site: SeoSiteAnswer + routes: readonly RoutePattern[] + pagination: LayerPaginationOption + /** seo.htmlSitemap.paths, or false when the layer serves no HTML sitemap. */ + htmlSitemap: Record | 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 +} diff --git a/server/lib/routeReport.ts b/server/lib/routeReport.ts new file mode 100644 index 0000000..7059924 --- /dev/null +++ b/server/lib/routeReport.ts @@ -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' +} diff --git a/server/plugins/seo-drift.ts b/server/plugins/seo-drift.ts new file mode 100644 index 0000000..ead61eb --- /dev/null +++ b/server/plugins/seo-drift.ts @@ -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 /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).seo ?? {}) as { + pagination?: LayerPaginationOption + htmlSitemap?: { paths?: Record } | 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?.() +}) diff --git a/server/plugins/seo-route-report.ts b/server/plugins/seo-route-report.ts new file mode 100644 index 0000000..5714c6c --- /dev/null +++ b/server/plugins/seo-route-report.ts @@ -0,0 +1,51 @@ +// Reports the site's static routes to the CMS's page registry once per +// server process (D-33): POST /registry/routes with +// `Authorization: Bearer ` 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 => { + 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?.() +}) diff --git a/server/seo-routes.d.ts b/server/seo-routes.d.ts new file mode 100644 index 0000000..25985f8 --- /dev/null +++ b/server/seo-routes.d.ts @@ -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 }> +} diff --git a/testing/expect-seo-drift.txt b/testing/expect-seo-drift.txt new file mode 100644 index 0000000..6120e89 --- /dev/null +++ b/testing/expect-seo-drift.txt @@ -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=Plain page | Playground +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 diff --git a/testing/expect-seo-notoken.txt b/testing/expect-seo-notoken.txt index 112510e..3ad540c 100644 --- a/testing/expect-seo-notoken.txt +++ b/testing/expect-seo-notoken.txt @@ -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 diff --git a/testing/expect-seo.txt b/testing/expect-seo.txt index 58f3a5a..0ce997e 100644 --- a/testing/expect-seo.txt +++ b/testing/expect-seo.txt @@ -75,3 +75,12 @@ noreport /stary 200 /prywatne contains= header=X-Robots-Tag: noindex, nofollow 200 /plain noheader=X-Robots-Tag count=name="robots":0 200 /blog/lwia-grzywa contains= + +# 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 diff --git a/testing/fixture-api.mjs b/testing/fixture-api.mjs index 0306f55..11c9997 100644 --- a/testing/fixture-api.mjs +++ b/testing/fixture-api.mjs @@ -4,11 +4,14 @@ // GET /_seo/api/v1/resolve?url=… the answer for that exact url from // /seo/resolve.json, then // /resolve.json, else not_found -// GET /_seo/api/v1/site /seo/site.json (404 without the -// file or with { site: false }: an -// older backend) +// GET /_seo/api/v1/site /seo/site.json, or /seo/ +// (404 without the file or with +// { site: false }: an older backend) // POST /_seo/api/v1/notfound 204 with `Authorization: Bearer `, // 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/ /verification/ or // /seo/verification/, else 404 // GET /robots.txt, /llms.txt, /seo/root/, 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} */ -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} */ @@ -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])]) { diff --git a/testing/fixtures/seo/site-drift.json b/testing/fixtures/seo/site-drift.json new file mode 100644 index 0000000..d7d25af --- /dev/null +++ b/testing/fixtures/seo/site-drift.json @@ -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/" } +} diff --git a/testing/fixtures/seo/site.json b/testing/fixtures/seo/site.json index 17316fd..81d436e 100644 --- a/testing/fixtures/seo/site.json +++ b/testing/fixtures/seo/site.json @@ -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": {} }, diff --git a/testing/smoke-ssr.mjs b/testing/smoke-ssr.mjs index ca95a3c..b9669ee 100644 --- a/testing/smoke-ssr.mjs +++ b/testing/smoke-ssr.mjs @@ -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 --expect [--no-site] [--env KEY=VALUE]... +// node testing/smoke-ssr.mjs --output <.output dir> --fixtures --expect [--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 /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 }} */ + /** @type {{ output?: string, fixtures?: string, expect?: string, site: boolean, siteFile?: string, env: Record }} */ 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 }} */ (out) + return /** @type {{ output: string, fixtures: string, expect: string, site: boolean, siteFile?: string, env: Record }} */ (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 } | 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, 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') } diff --git a/tests/routes.test.ts b/tests/routes.test.ts index ce5ada2..aec0f37 100644 --- a/tests/routes.test.ts +++ b/tests/routes.test.ts @@ -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 }), []) +})