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:
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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user