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

85 lines
3.6 KiB
TypeScript

// The routes the layer tells the CMS about (D-33) and compares with it at
// server start (D-20c). Built at build time from the resolved pages, whose
// meta carries requiresAuth and seo (the module adds both to Nuxt's page
// meta extraction keys), and written into the server template #seo-routes
// 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)
/**
* The robots value a page asks for through its meta: seo.robots when set,
* else 'noindex, nofollow' for a page with requiresAuth: true (a signed-in
* or auth page, D-23), else null (the CMS decides).
*/
export function pageMetaRobots(meta: Record<string, unknown> | undefined | null): string | null {
if (!meta || typeof meta !== 'object') return null
const seo = meta.seo
if (seo && typeof seo === 'object') {
const robots = (seo as { robots?: unknown }).robots
if (typeof robots === 'string' && robots.trim() !== '') return robots.trim()
}
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')
}