- 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)
164 lines
7.0 KiB
TypeScript
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' } }
|
|
}
|