// 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 /** 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 { const out: Array = [] 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 { const base = rule !== null && typeof rule === 'object' ? (rule as Record) : {} const headers = base.headers !== null && typeof base.headers === 'object' ? (base.headers as Record) : {} const own = Object.keys(headers).some((k) => k.toLowerCase() === 'x-robots-tag') return { ...base, headers: own ? headers : { ...headers, 'x-robots-tag': 'noindex, nofollow' } } }