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)
This commit is contained in:
Jakub Zych
2026-11-04 11:42:08 +01:00
parent 6636cde70a
commit b4680af0e3
14 changed files with 500 additions and 1 deletions

163
lib/guard.ts Normal file
View File

@@ -0,0 +1,163 @@
// 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' } }
}

View File

@@ -8,7 +8,8 @@
// 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 } from 'nuxt/kit'
import { defineNuxtModule, useLogger } from 'nuxt/kit'
import { findOffenders, guardMessage, noindexRule, type GuardPage } from './guard.ts'
export interface SeoModuleOptions {
/**
@@ -32,6 +33,18 @@ export interface SeoModuleOptions {
rootFiles: boolean
/** File extensions the Nitro middleware still resolves, so an old file address can redirect. */
resolveFiles: string[]
/**
* Route-rule patterns ('/p/**', '/konto', '/x/*') the app declares
* noindex: they are served with X-Robots-Tag: noindex, nofollow and may
* render in the browser only (ssr: false, a .client.vue page) without
* failing the build guard (D-70, D-23).
*/
noindexRoutes: string[]
/**
* false for a site that is never indexed (an internal tool, a panel): the
* build guard then checks nothing. Default true.
*/
indexable: boolean
}
export const SEO_MODULE_DEFAULTS: SeoModuleOptions = {
@@ -40,8 +53,12 @@ export const SEO_MODULE_DEFAULTS: SeoModuleOptions = {
links: { newTabHint: true },
rootFiles: true,
resolveFiles: ['pdf'],
noindexRoutes: [],
indexable: true,
}
const strings = (v: unknown): string[] => (Array.isArray(v) ? v.filter((e): e is string => typeof e === 'string' && e !== '') : [])
export default defineNuxtModule<SeoModuleOptions>({
meta: {
name: 'vue-seo-layer',
@@ -49,6 +66,61 @@ export default defineNuxtModule<SeoModuleOptions>({
},
defaults: SEO_MODULE_DEFAULTS,
setup(options, nuxt) {
const noindexRoutes = strings(options.noindexRoutes)
const indexable = options.indexable !== false
// The app's own ssr flag, read before Nuxt's server setup may rewrite
// a global ssr: false into a '/**' route rule.
const ssr = nuxt.options.ssr !== false
const inlineRouteRules = nuxt.options.experimental?.inlineRouteRules === true
// Build guard (D-70). Nitro's config hook runs once every layer and
// module has merged its rules into it; there the declared noindex
// routes get their X-Robots-Tag rule. The pages are resolved after
// that, so the check runs when both are known: once the pages are
// resolved, or before the Nitro build for an app without pages. It
// reads the final Nitro rules (nuxt.options.routeRules, nitro.routeRules
// and every layer's), the .client.vue pages and the inline route rules
// flag.
let nitroRules: Record<string, unknown> | null = null
let pages: GuardPage[] | null = null
let checked = false
const logger = useLogger('vue-seo-layer')
const guard = () => {
if (nitroRules === null) return
checked = true
const offenders = findOffenders({
ssr,
indexable,
routeRules: { ...(nuxt.options.routeRules ?? {}), ...nitroRules },
pages: pages ?? [],
noindexRoutes,
inlineRouteRules,
})
if (offenders.length === 0) return
const message = guardMessage(offenders)
// A production build fails; nuxt dev and nuxt prepare (run on
// install) only warn, so a developer can still fix the page.
if (nuxt.options.dev || nuxt.options._prepare) logger.warn(message)
else throw new Error(message)
}
nuxt.hook('nitro:config', (config) => {
config.routeRules ||= {}
const rules = config.routeRules as Record<string, unknown>
for (const pattern of noindexRoutes) rules[pattern] = noindexRule(rules[pattern])
nitroRules = rules
})
nuxt.hook('nitro:init', (nitro) => {
nitroRules = nitro.options.routeRules as Record<string, unknown>
})
nuxt.hook('pages:resolved', (resolved) => {
pages = resolved as GuardPage[]
guard()
})
nuxt.hook('nitro:build:before', (nitro) => {
nitroRules = nitro.options.routeRules as Record<string, unknown>
if (!checked || nuxt.options.dev) guard()
})
const runtime = nuxt.options.runtimeConfig as { public: Record<string, unknown> }
const current = (runtime.public.seo ?? {}) as Record<string, unknown>
// Only the layer's own keys: the shared config key may carry
@@ -60,6 +132,8 @@ export default defineNuxtModule<SeoModuleOptions>({
links: { ...((current.links as Record<string, unknown>) ?? {}), newTabHint: options.links?.newTabHint !== false },
rootFiles: options.rootFiles !== false,
resolveFiles: Array.isArray(options.resolveFiles) ? [...options.resolveFiles] : [...SEO_MODULE_DEFAULTS.resolveFiles],
noindexRoutes,
indexable,
}
},
})

View File

@@ -10,6 +10,7 @@
"test": "node --test --test-reporter=tap tests/*.test.ts",
"dev:prepare": "nuxt prepare .playground",
"smoke:ssr": "node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect.txt --no-site && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo.txt && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-off.txt --env NUXT_PUBLIC_SEO_CMS=false && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-off.txt --env NUXT_PUBLIC_SEO_ENABLED=false && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-down.txt --env NUXT_SEO_API_BASE=http://127.0.0.1:1/_seo/api/v1 && node testing/smoke-ssr.mjs --output .playground/.output --fixtures testing/fixtures --expect testing/expect-seo-notoken.txt --env NUXT_SEO_NOTFOUND_TOKEN=",
"guard:check": "node testing/guard-check.mjs",
"purge:check": "node testing/purge-check.mjs --output .playground/.output --fixtures testing/fixtures --path /blog/lwia-grzywa && node testing/purge-token-check.mjs --output .playground/.output --fixtures testing/fixtures --path /blog/lwia-grzywa"
},
"engines": {

107
testing/guard-check.mjs Normal file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env node
// Build-guard check (D-70): builds the fixture apps under testing/guard,
// each extending this layer, and checks that the guard fails the build of
// every app with an indexable route that renders only in the browser and
// passes the one that declares that route noindex:
//
// indexable-ssr-false '/x/**' (Nuxt routeRules) and '/y/**' (Nitro
// routeRules) with ssr: false: exit 1 naming both
// client-page app/pages/panel.client.vue: exit 1 naming /panel
// global-ssr-false ssr: false for the whole app: exit 1
// noindex-ok '/x/**' with ssr: false declared in
// seo.noindexRoutes: exit 0; its server answers /x/a
// with X-Robots-Tag: noindex, nofollow and / without
// the header
//
// node testing/guard-check.mjs
//
// Each build runs the layer's own nuxt binary with the fixture as its
// working directory. Prints "guard-check: ok (<n> checks)" or one
// "guard-check: FAIL ..." line per failure and exits 1.
import { spawn } from 'node:child_process'
import { existsSync } from 'node:fs'
import { dirname, join, resolve } from 'node:path'
import { fileURLToPath } from 'node:url'
import { startNuxtServer } from './nuxt-server.mjs'
const here = dirname(fileURLToPath(import.meta.url))
const layerRoot = resolve(here, '..')
const nuxtBin = join(layerRoot, 'node_modules', '.bin', 'nuxt')
const MESSAGE = '[vue-seo-layer] indexable routes must render on the server (D-70)'
/** @param {string} fixture @returns {Promise<{ code: number, log: string }>} */
function build(fixture) {
return new Promise((done, fail) => {
const child = spawn(nuxtBin, ['build'], {
cwd: join(here, 'guard', fixture),
env: { ...process.env, NODE_ENV: 'production', NUXT_TELEMETRY_DISABLED: '1' },
stdio: ['ignore', 'pipe', 'pipe'],
})
let log = ''
child.stdout.on('data', (b) => (log += b))
child.stderr.on('data', (b) => (log += b))
child.once('error', fail)
child.once('exit', (code) => done({ code: code ?? 1, log }))
})
}
async function main() {
if (!existsSync(nuxtBin)) throw new Error(`${nuxtBin} does not exist; run pnpm install in the layer first`)
/** @type {string[]} */
const failures = []
let checks = 0
const check = (/** @type {boolean} */ ok, /** @type {string} */ what) => {
checks++
if (!ok) failures.push(what)
}
/** @type {Array<{ fixture: string, names: string[] }>} */
const offenders = [
{ fixture: 'indexable-ssr-false', names: ['/x/**', '/y/**'] },
{ fixture: 'client-page', names: ['/panel'] },
{ fixture: 'global-ssr-false', names: ['ssr: false'] },
]
for (const { fixture, names } of offenders) {
const { code, log } = await build(fixture)
check(code !== 0, `${fixture}: the build passed, want it to fail`)
const line = log.split('\n').find((l) => l.includes(MESSAGE)) ?? ''
check(line !== '', `${fixture}: the build log lacks "${MESSAGE}"\n${log.slice(-2000)}`)
for (const name of names) check(line.includes(name), `${fixture}: the guard message does not name ${name}: ${line}`)
}
const ok = await build('noindex-ok')
check(ok.code === 0, `noindex-ok: the build failed (exit ${ok.code})\n${ok.log.slice(-2000)}`)
check(!ok.log.includes(MESSAGE), 'noindex-ok: the build log carries the guard message')
if (ok.code === 0) {
const server = await startNuxtServer({
outputDir: join(here, 'guard', 'noindex-ok', '.output'),
// No backend: the header comes from the route rule alone.
apiBase: 'http://127.0.0.1:1/_seo/api/v1',
})
try {
const declared = await fetch(`${server.url}/x/a`, { redirect: 'manual', headers: { Accept: 'text/html' } })
await declared.text()
check(declared.status === 200, `noindex-ok: /x/a answered ${declared.status}, want 200`)
const header = declared.headers.get('x-robots-tag')
check(header === 'noindex, nofollow', `noindex-ok: /x/a X-Robots-Tag is ${JSON.stringify(header)}, want "noindex, nofollow"`)
const home = await fetch(`${server.url}/`, { redirect: 'manual', headers: { Accept: 'text/html' } })
await home.text()
check(home.status === 200, `noindex-ok: / answered ${home.status}, want 200`)
check(home.headers.get('x-robots-tag') === null, `noindex-ok: / carries X-Robots-Tag ${JSON.stringify(home.headers.get('x-robots-tag'))}, want none`)
} finally {
await server.close()
}
}
if (failures.length > 0) {
for (const f of failures) console.error(`guard-check: FAIL ${f}`)
process.exit(1)
}
console.log(`guard-check: ok (${checks} checks)`)
}
main().catch((err) => {
console.error(`guard-check: FAIL setup: ${err instanceof Error ? err.message : String(err)}`)
process.exit(1)
})

View File

@@ -0,0 +1,3 @@
<template>
<main><h1>Guard fixture</h1></main>
</template>

View File

@@ -0,0 +1,3 @@
<template>
<main><h1>Panel</h1></main>
</template>

View File

@@ -0,0 +1,7 @@
// Build-guard fixture (testing/guard-check.mjs): a .client.vue page
// (app/pages/panel.client.vue) is not rendered on the server. The build
// must fail with the D-70 message.
export default defineNuxtConfig({
extends: ['../../..'],
compatibilityDate: '2025-07-15',
})

View File

@@ -0,0 +1,3 @@
<template>
<main><h1>Guard fixture</h1></main>
</template>

View File

@@ -0,0 +1,7 @@
// Build-guard fixture (testing/guard-check.mjs): the whole app renders in
// the browser. The build must fail with the D-70 message.
export default defineNuxtConfig({
extends: ['../../..'],
compatibilityDate: '2025-07-15',
ssr: false,
})

View File

@@ -0,0 +1,3 @@
<template>
<main><h1>Guard fixture</h1></main>
</template>

View File

@@ -0,0 +1,15 @@
// Build-guard fixture (testing/guard-check.mjs): an indexable route that
// renders only in the browser, once as a Nuxt route rule and once as a
// Nitro route rule. The build must fail with the D-70 message naming both.
export default defineNuxtConfig({
extends: ['../../..'],
compatibilityDate: '2025-07-15',
routeRules: {
'/x/**': { ssr: false },
},
nitro: {
routeRules: {
'/y/**': { ssr: false },
},
},
})

View File

@@ -0,0 +1,3 @@
<template>
<main><h1>Guard fixture</h1></main>
</template>

View File

@@ -0,0 +1,13 @@
// Build-guard fixture (testing/guard-check.mjs): the same browser-only
// route, declared noindex. The build passes and the route is served with
// X-Robots-Tag: noindex, nofollow.
export default defineNuxtConfig({
extends: ['../../..'],
compatibilityDate: '2025-07-15',
routeRules: {
'/x/**': { ssr: false },
},
seo: {
noindexRoutes: ['/x/**'],
},
})

97
tests/guard.test.ts Normal file
View File

@@ -0,0 +1,97 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { GUARD_MESSAGE, findOffenders, flattenPages, guardMessage, isNoindex, noindexRule, pagePattern, pagePatterns, patternCovers } from '../lib/guard.ts'
const base = { ssr: true, indexable: true, routeRules: {}, pages: [], noindexRoutes: [], inlineRouteRules: false }
test('isNoindex: Nitro route-rule patterns', () => {
assert.equal(isNoindex('/x', ['/x/**']), true)
assert.equal(isNoindex('/x/a/b', ['/x/**']), true)
assert.equal(isNoindex('/xy', ['/x/**']), false)
assert.equal(isNoindex('/x/a', ['/x/*']), true)
assert.equal(isNoindex('/x/a/b', ['/x/*']), false)
assert.equal(isNoindex('/x', ['/x/*']), false)
assert.equal(isNoindex('/konto', ['/konto']), true)
assert.equal(isNoindex('/konto/dane', ['/konto']), false)
assert.equal(isNoindex('/anything', ['/**']), true)
assert.equal(isNoindex('/x/a', []), false)
assert.equal(isNoindex('/x/a', ['']), false)
})
test('patternCovers: a pattern against another pattern', () => {
assert.equal(patternCovers('/p/**', '/p/**'), true)
assert.equal(patternCovers('/p/**', '/p/:id'), true)
assert.equal(patternCovers('/p/*', '/p/:id'), true)
assert.equal(patternCovers('/p/*', '/p/**'), false, 'one segment does not cover a catch-all')
assert.equal(patternCovers('/p/:id', '/p/edit'), true)
assert.equal(patternCovers('/p/edit', '/p/:id'), false, 'a static segment does not cover a parameter')
assert.equal(patternCovers('/**', '/**'), true)
})
test('pagePattern: Nuxt page paths become route-rule patterns', () => {
assert.equal(pagePattern('/posts/:slug()'), '/posts/:slug')
assert.equal(pagePattern('/:slug(.*)*'), '/**')
assert.equal(pagePattern('/'), '/')
assert.equal(pagePattern('/panel'), '/panel')
assert.deepEqual(pagePatterns('/a/:id?'), ['/a/:id', '/a'])
assert.deepEqual(pagePatterns('/a/:id()'), ['/a/:id'])
})
test('flattenPages: children get their full path', () => {
const flat = flattenPages([{ path: '/konto', children: [{ path: '', mode: 'client' }, { path: 'dane' }] }])
assert.deepEqual(
flat.map((p) => p.fullPath),
['/konto', '/konto', '/konto/dane'],
)
})
test('findOffenders: nothing to report on a server-rendered app', () => {
assert.deepEqual(findOffenders({ ...base, routeRules: { '/**': { swr: 300 } }, pages: [{ path: '/' }, { path: '/blog/:slug()' }] }), [])
})
test('findOffenders: a route rule with ssr false', () => {
assert.deepEqual(findOffenders({ ...base, routeRules: { '/x/**': { ssr: false }, '/y': { ssr: true } } }), ['/x/**'])
})
test('findOffenders: a declared noindex route passes', () => {
assert.deepEqual(findOffenders({ ...base, routeRules: { '/x/**': { ssr: false } }, noindexRoutes: ['/x/**'] }), [])
assert.deepEqual(findOffenders({ ...base, routeRules: { '/x/a': { ssr: false } }, noindexRoutes: ['/x/**'] }), [])
assert.deepEqual(findOffenders({ ...base, routeRules: { '/x/**': { ssr: false } }, noindexRoutes: ['/x/*'] }), ['/x/**'], 'a one-segment declaration does not cover a catch-all rule')
})
test('findOffenders: a .client.vue page, nested or not', () => {
assert.deepEqual(findOffenders({ ...base, pages: [{ path: '/panel', mode: 'client' }] }), ['/panel (.client.vue page)'])
assert.deepEqual(findOffenders({ ...base, pages: [{ path: '/konto', children: [{ path: 'dane', mode: 'client' }] }] }), ['/konto/dane (.client.vue page)'])
assert.deepEqual(findOffenders({ ...base, pages: [{ path: '/panel', mode: 'client' }], noindexRoutes: ['/panel'] }), [])
assert.deepEqual(findOffenders({ ...base, pages: [{ path: '/p/:id()', mode: 'client' }], noindexRoutes: ['/p/**'] }), [])
})
test('findOffenders: global ssr false, reported once', () => {
assert.deepEqual(findOffenders({ ...base, ssr: false, routeRules: { '/**': { ssr: false } } }), ['ssr: false (the whole app)'])
assert.deepEqual(findOffenders({ ...base, ssr: false, noindexRoutes: ['/**'] }), [])
})
test('findOffenders: inline route rules are refused', () => {
const out = findOffenders({ ...base, inlineRouteRules: true })
assert.equal(out.length, 1)
assert.match(out[0]!, /experimental\.inlineRouteRules/)
})
test('findOffenders: seo.indexable false checks nothing', () => {
assert.deepEqual(findOffenders({ ...base, indexable: false, ssr: false, routeRules: { '/x/**': { ssr: false } }, inlineRouteRules: true }), [])
})
test('guardMessage: the D-70 message lists the offenders', () => {
const msg = guardMessage(['/x/**', '/panel (.client.vue page)'])
assert.ok(msg.startsWith(`${GUARD_MESSAGE}: /x/**, /panel (.client.vue page). `))
assert.ok(msg.includes('seo.noindexRoutes'))
})
test('noindexRule: adds X-Robots-Tag and keeps the rest of the rule and an own header', () => {
assert.deepEqual(noindexRule(undefined), { headers: { 'x-robots-tag': 'noindex, nofollow' } })
assert.deepEqual(noindexRule({ ssr: false, headers: { 'cache-control': 'no-store' } }), {
ssr: false,
headers: { 'cache-control': 'no-store', 'x-robots-tag': 'noindex, nofollow' },
})
assert.deepEqual(noindexRule({ headers: { 'X-Robots-Tag': 'none' } }), { headers: { 'X-Robots-Tag': 'none' } })
})