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:
@@ -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' } },
|
||||
},
|
||||
})
|
||||
|
||||
22
app/composables/useSeoSite.ts
Normal file
22
app/composables/useSeoSite.ts
Normal 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
36
lib/options.ts
Normal 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 }
|
||||
}
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
|
||||
18
server/api/__seo__/site.get.ts
Normal file
18
server/api/__seo__/site.get.ts
Normal 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 }
|
||||
})
|
||||
@@ -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
84
server/lib/drift.ts
Normal 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
33
server/lib/routeReport.ts
Normal 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'
|
||||
}
|
||||
40
server/plugins/seo-drift.ts
Normal file
40
server/plugins/seo-drift.ts
Normal 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?.()
|
||||
})
|
||||
51
server/plugins/seo-route-report.ts
Normal file
51
server/plugins/seo-route-report.ts
Normal 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
8
server/seo-routes.d.ts
vendored
Normal 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 }>
|
||||
}
|
||||
12
testing/expect-seo-drift.txt
Normal file
12
testing/expect-seo-drift.txt
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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])]) {
|
||||
|
||||
16
testing/fixtures/seo/site-drift.json
Normal file
16
testing/fixtures/seo/site-drift.json
Normal 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/" }
|
||||
}
|
||||
@@ -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": {} },
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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 }), [])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user