From b4680af0e32ec301f0c52f863250314354cf05be Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 11:42:08 +0100 Subject: [PATCH] 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) --- lib/guard.ts | 163 ++++++++++++++++++ lib/seo-module.ts | 76 +++++++- package.json | 1 + testing/guard-check.mjs | 107 ++++++++++++ testing/guard/client-page/app/pages/index.vue | 3 + .../client-page/app/pages/panel.client.vue | 3 + testing/guard/client-page/nuxt.config.ts | 7 + .../global-ssr-false/app/pages/index.vue | 3 + testing/guard/global-ssr-false/nuxt.config.ts | 7 + .../indexable-ssr-false/app/pages/index.vue | 3 + .../guard/indexable-ssr-false/nuxt.config.ts | 15 ++ testing/guard/noindex-ok/app/pages/index.vue | 3 + testing/guard/noindex-ok/nuxt.config.ts | 13 ++ tests/guard.test.ts | 97 +++++++++++ 14 files changed, 500 insertions(+), 1 deletion(-) create mode 100644 lib/guard.ts create mode 100644 testing/guard-check.mjs create mode 100644 testing/guard/client-page/app/pages/index.vue create mode 100644 testing/guard/client-page/app/pages/panel.client.vue create mode 100644 testing/guard/client-page/nuxt.config.ts create mode 100644 testing/guard/global-ssr-false/app/pages/index.vue create mode 100644 testing/guard/global-ssr-false/nuxt.config.ts create mode 100644 testing/guard/indexable-ssr-false/app/pages/index.vue create mode 100644 testing/guard/indexable-ssr-false/nuxt.config.ts create mode 100644 testing/guard/noindex-ok/app/pages/index.vue create mode 100644 testing/guard/noindex-ok/nuxt.config.ts create mode 100644 tests/guard.test.ts diff --git a/lib/guard.ts b/lib/guard.ts new file mode 100644 index 0000000..85ef66a --- /dev/null +++ b/lib/guard.ts @@ -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 + /** 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' } } +} diff --git a/lib/seo-module.ts b/lib/seo-module.ts index b01eda4..b8faee1 100644 --- a/lib/seo-module.ts +++ b/lib/seo-module.ts @@ -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({ meta: { name: 'vue-seo-layer', @@ -49,6 +66,61 @@ export default defineNuxtModule({ }, 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 | 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 + for (const pattern of noindexRoutes) rules[pattern] = noindexRule(rules[pattern]) + nitroRules = rules + }) + nuxt.hook('nitro:init', (nitro) => { + nitroRules = nitro.options.routeRules as Record + }) + nuxt.hook('pages:resolved', (resolved) => { + pages = resolved as GuardPage[] + guard() + }) + nuxt.hook('nitro:build:before', (nitro) => { + nitroRules = nitro.options.routeRules as Record + if (!checked || nuxt.options.dev) guard() + }) + const runtime = nuxt.options.runtimeConfig as { public: Record } const current = (runtime.public.seo ?? {}) as Record // Only the layer's own keys: the shared config key may carry @@ -60,6 +132,8 @@ export default defineNuxtModule({ links: { ...((current.links as Record) ?? {}), newTabHint: options.links?.newTabHint !== false }, rootFiles: options.rootFiles !== false, resolveFiles: Array.isArray(options.resolveFiles) ? [...options.resolveFiles] : [...SEO_MODULE_DEFAULTS.resolveFiles], + noindexRoutes, + indexable, } }, }) diff --git a/package.json b/package.json index eea3025..63e233c 100644 --- a/package.json +++ b/package.json @@ -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": { diff --git a/testing/guard-check.mjs b/testing/guard-check.mjs new file mode 100644 index 0000000..60373cd --- /dev/null +++ b/testing/guard-check.mjs @@ -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 ( 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) +}) diff --git a/testing/guard/client-page/app/pages/index.vue b/testing/guard/client-page/app/pages/index.vue new file mode 100644 index 0000000..f48e34c --- /dev/null +++ b/testing/guard/client-page/app/pages/index.vue @@ -0,0 +1,3 @@ + diff --git a/testing/guard/client-page/app/pages/panel.client.vue b/testing/guard/client-page/app/pages/panel.client.vue new file mode 100644 index 0000000..625d843 --- /dev/null +++ b/testing/guard/client-page/app/pages/panel.client.vue @@ -0,0 +1,3 @@ + diff --git a/testing/guard/client-page/nuxt.config.ts b/testing/guard/client-page/nuxt.config.ts new file mode 100644 index 0000000..ef6a81c --- /dev/null +++ b/testing/guard/client-page/nuxt.config.ts @@ -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', +}) diff --git a/testing/guard/global-ssr-false/app/pages/index.vue b/testing/guard/global-ssr-false/app/pages/index.vue new file mode 100644 index 0000000..f48e34c --- /dev/null +++ b/testing/guard/global-ssr-false/app/pages/index.vue @@ -0,0 +1,3 @@ + diff --git a/testing/guard/global-ssr-false/nuxt.config.ts b/testing/guard/global-ssr-false/nuxt.config.ts new file mode 100644 index 0000000..8f0ac63 --- /dev/null +++ b/testing/guard/global-ssr-false/nuxt.config.ts @@ -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, +}) diff --git a/testing/guard/indexable-ssr-false/app/pages/index.vue b/testing/guard/indexable-ssr-false/app/pages/index.vue new file mode 100644 index 0000000..f48e34c --- /dev/null +++ b/testing/guard/indexable-ssr-false/app/pages/index.vue @@ -0,0 +1,3 @@ + diff --git a/testing/guard/indexable-ssr-false/nuxt.config.ts b/testing/guard/indexable-ssr-false/nuxt.config.ts new file mode 100644 index 0000000..2911c00 --- /dev/null +++ b/testing/guard/indexable-ssr-false/nuxt.config.ts @@ -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 }, + }, + }, +}) diff --git a/testing/guard/noindex-ok/app/pages/index.vue b/testing/guard/noindex-ok/app/pages/index.vue new file mode 100644 index 0000000..f48e34c --- /dev/null +++ b/testing/guard/noindex-ok/app/pages/index.vue @@ -0,0 +1,3 @@ + diff --git a/testing/guard/noindex-ok/nuxt.config.ts b/testing/guard/noindex-ok/nuxt.config.ts new file mode 100644 index 0000000..172245c --- /dev/null +++ b/testing/guard/noindex-ok/nuxt.config.ts @@ -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/**'], + }, +}) diff --git a/tests/guard.test.ts b/tests/guard.test.ts new file mode 100644 index 0000000..1c75bb3 --- /dev/null +++ b/tests/guard.test.ts @@ -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' } }) +})