Files
vue-seo-layer/lib/guard.ts
Jakub Zych b4680af0e3 feat: the build fails when an indexable route renders only in the browser (tracer)
- lib/guard.ts: pure checks (isNoindex with Nitro route-rule patterns,
  findOffenders over the app's ssr flag, every merged route rule,
  .client.vue pages and experimental.inlineRouteRules)
- module options seo.noindexRoutes and seo.indexable; declared noindex
  routes get X-Robots-Tag: noindex, nofollow through their route rule
- a production build fails with the D-70 message; nuxt dev and
  nuxt prepare only warn
- testing/guard-check.mjs builds four fixture apps (guard:check)
2026-11-04 11:42:08 +01:00

164 lines
7.0 KiB
TypeScript

// The SSR build guard (D-70): an indexable page must render on the server,
// so a crawler sees its content without running JavaScript. The module
// (lib/seo-module.ts) feeds these pure checks with what Nuxt knows once the
// layers are merged: the app's ssr flag, every route rule (nuxt.options
// routeRules, nitro routeRules and the rules of every layer and module), the
// resolved pages (a .client.vue page has mode 'client') and the
// experimental inline route rules flag. A route that is meant to render in
// the browser only builds when the app declares it noindex in
// seo.noindexRoutes, or when the whole site is not indexable
// (seo.indexable: false). Pure TypeScript, so `node --test` drives it.
export const GUARD_MESSAGE = '[vue-seo-layer] indexable routes must render on the server (D-70)'
/** What the guard reads from the merged Nuxt configuration. */
export interface GuardInput {
/** nuxt.options.ssr as the app set it (false: the whole app renders in the browser). */
ssr: boolean
/** seo.indexable: false means the site is never indexed, so nothing is checked. */
indexable: boolean
/** Every route rule after the layers merged, by pattern. */
routeRules: Record<string, unknown>
/** The resolved pages (children included); a .client.vue page has mode 'client'. */
pages: GuardPage[]
/** seo.noindexRoutes: route-rule patterns the app declares noindex. */
noindexRoutes: string[]
/** experimental.inlineRouteRules: defineRouteRules in pages, which the guard refuses. */
inlineRouteRules: boolean
}
export interface GuardPage {
path: string
mode?: string
children?: GuardPage[]
}
/** Segments of a path (without its query) or pattern, without empty ones. */
const segments = (p: string) => p.split('/').filter((s) => s !== '')
const isParam = (s: string) => s === '*' || s.startsWith(':')
const isCatchAll = (s: string) => s === '**' || s.startsWith('**:')
/**
* True when the route-rule pattern `rule` covers every path `target`
* stands for. `target` is a concrete path or itself a pattern: `**` (or
* `**:name`) matches the rest of the path including nothing, `*` and
* `:name` one segment, anything else the same segment. So '/x/**' covers
* '/x', '/x/a' and '/x/*'; '/x/*' covers '/x/a' and '/x/:id' but not
* '/x/**' or '/x'. These are Nitro's route-rule semantics.
*/
export function patternCovers(rule: string, target: string): boolean {
const r = segments(rule)
const t = segments(target)
for (let i = 0; i < r.length; i++) {
const seg = r[i]!
if (isCatchAll(seg)) return true
const other = t[i]
if (other === undefined) return false
if (isCatchAll(other)) return false
if (isParam(seg)) continue
if (isParam(other) || other !== seg) return false
}
return r.length === t.length
}
/**
* True when `path` (a concrete path or a route pattern) lies under one of
* the declared noindex patterns (seo.noindexRoutes), so it may render in
* the browser only.
*/
export function isNoindex(path: string, noindexRoutes: readonly string[]): boolean {
return noindexRoutes.some((p) => typeof p === 'string' && p !== '' && patternCovers(p, path))
}
/**
* A Nuxt page path as a route-rule pattern: ':slug()' and ':slug' become
* ':slug', a catch-all (':slug(.*)*', '[...slug]') becomes '**', an
* optional parameter (':slug?', '[[slug]]') stays a parameter of its own
* segment (the variant without it is returned by pagePatterns).
*/
export function pagePattern(path: string): string {
const out = segments(path).map((seg) => {
if (/^:[\w-]+\(\.\*\)\*$/.test(seg) || /^:[\w-]+\*$/.test(seg)) return '**'
const m = /^:([\w-]+)(?:\([^)]*\))?\??$/.exec(seg)
if (m) return `:${m[1]}`
return seg
})
return '/' + out.join('/')
}
/**
* Every pattern a page path stands for: one, or two when its last segment
* is optional (with and without it).
*/
export function pagePatterns(path: string): string[] {
const segs = segments(path)
const last = segs[segs.length - 1]
const main = pagePattern(path)
if (last && /^:[\w-]+(?:\([^)]*\))?\?$/.test(last)) {
return [main, pagePattern('/' + segs.slice(0, -1).join('/'))]
}
return [main]
}
/** The pages with their full paths, children joined to their parents. */
export function flattenPages(pages: readonly GuardPage[], parent = ''): Array<GuardPage & { fullPath: string }> {
const out: Array<GuardPage & { fullPath: string }> = []
for (const page of pages) {
const own = typeof page.path === 'string' ? page.path : ''
const fullPath = own.startsWith('/') ? own : `${parent.replace(/\/+$/, '')}/${own}`.replace(/\/+$/, '') || '/'
out.push({ ...page, fullPath })
if (Array.isArray(page.children) && page.children.length > 0) out.push(...flattenPages(page.children, fullPath))
}
return out
}
const rendersOnClient = (rule: unknown) => rule !== null && typeof rule === 'object' && (rule as { ssr?: unknown }).ssr === false
/**
* The guard's findings: one entry per route that would be served without
* server rendering although it is indexable. Empty when the build may go
* on. A global ssr: false is reported once (Nuxt also turns it into a
* '/**' rule, which is then not repeated).
*/
export function findOffenders(input: GuardInput): string[] {
if (input.indexable === false) return []
const noindex = Array.isArray(input.noindexRoutes) ? input.noindexRoutes : []
const out: string[] = []
const add = (s: string) => {
if (!out.includes(s)) out.push(s)
}
if (input.inlineRouteRules) add('experimental.inlineRouteRules (remove it: defineRouteRules can turn server rendering off where the guard cannot see it)')
const globalOff = input.ssr === false && !isNoindex('/**', noindex)
if (globalOff) add('ssr: false (the whole app)')
for (const [pattern, rule] of Object.entries(input.routeRules ?? {})) {
if (!rendersOnClient(rule)) continue
if (globalOff && pattern === '/**') continue
if (!isNoindex(pattern, noindex)) add(pattern)
}
for (const page of flattenPages(input.pages ?? [])) {
if (page.mode !== 'client') continue
for (const pattern of pagePatterns(page.fullPath)) {
if (!isNoindex(pattern, noindex)) add(`${page.fullPath} (.client.vue page)`)
}
}
return out
}
/** The build error for the offenders. */
export function guardMessage(offenders: readonly string[]): string {
return (
`${GUARD_MESSAGE}: ${offenders.join(', ')}. ` +
'Declare a route that may render in the browser only in seo.noindexRoutes (it is then served noindex), ' +
'or set seo.indexable: false for a site that is never indexed.'
)
}
/** The route rule of a declared noindex pattern: X-Robots-Tag unless the app set its own. */
export function noindexRule(rule: unknown): Record<string, unknown> {
const base = rule !== null && typeof rule === 'object' ? (rule as Record<string, unknown>) : {}
const headers = base.headers !== null && typeof base.headers === 'object' ? (base.headers as Record<string, string>) : {}
const own = Object.keys(headers).some((k) => k.toLowerCase() === 'x-robots-tag')
return { ...base, headers: own ? headers : { ...headers, 'x-robots-tag': 'noindex, nofollow' } }
}