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:
163
lib/guard.ts
Normal file
163
lib/guard.ts
Normal 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' } }
|
||||
}
|
||||
@@ -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,
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
@@ -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
107
testing/guard-check.mjs
Normal 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)
|
||||
})
|
||||
3
testing/guard/client-page/app/pages/index.vue
Normal file
3
testing/guard/client-page/app/pages/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<main><h1>Guard fixture</h1></main>
|
||||
</template>
|
||||
3
testing/guard/client-page/app/pages/panel.client.vue
Normal file
3
testing/guard/client-page/app/pages/panel.client.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<main><h1>Panel</h1></main>
|
||||
</template>
|
||||
7
testing/guard/client-page/nuxt.config.ts
Normal file
7
testing/guard/client-page/nuxt.config.ts
Normal 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',
|
||||
})
|
||||
3
testing/guard/global-ssr-false/app/pages/index.vue
Normal file
3
testing/guard/global-ssr-false/app/pages/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<main><h1>Guard fixture</h1></main>
|
||||
</template>
|
||||
7
testing/guard/global-ssr-false/nuxt.config.ts
Normal file
7
testing/guard/global-ssr-false/nuxt.config.ts
Normal 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,
|
||||
})
|
||||
3
testing/guard/indexable-ssr-false/app/pages/index.vue
Normal file
3
testing/guard/indexable-ssr-false/app/pages/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<main><h1>Guard fixture</h1></main>
|
||||
</template>
|
||||
15
testing/guard/indexable-ssr-false/nuxt.config.ts
Normal file
15
testing/guard/indexable-ssr-false/nuxt.config.ts
Normal 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 },
|
||||
},
|
||||
},
|
||||
})
|
||||
3
testing/guard/noindex-ok/app/pages/index.vue
Normal file
3
testing/guard/noindex-ok/app/pages/index.vue
Normal file
@@ -0,0 +1,3 @@
|
||||
<template>
|
||||
<main><h1>Guard fixture</h1></main>
|
||||
</template>
|
||||
13
testing/guard/noindex-ok/nuxt.config.ts
Normal file
13
testing/guard/noindex-ok/nuxt.config.ts
Normal 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
97
tests/guard.test.ts
Normal 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' } })
|
||||
})
|
||||
Reference in New Issue
Block a user