feat: noindex for declared and signed-in routes

- app plugin seo-route-robots: a page whose meta sets requiresAuth: true
  gets robots noindex, nofollow, a page with seo.robots gets its value,
  a path under seo.noindexRoutes gets noindex, nofollow; meta tag above
  the CMS head entry and X-Robots-Tag during SSR, never on staging
- requiresAuth and seo added to Nuxt's page meta extraction keys
- playground /konto, /szkic and /prywatne with their smoke lines
This commit is contained in:
Jakub Zych
2026-11-04 11:49:14 +01:00
parent b4680af0e3
commit 9cc2cda924
9 changed files with 143 additions and 1 deletions

View File

@@ -0,0 +1,11 @@
<script setup lang="ts">
// A signed-in page: requiresAuth in its meta makes it noindex, nofollow
// (meta tag and X-Robots-Tag) and keeps it out of the route report, with
// no SEO code on the page (D-23).
definePageMeta({ requiresAuth: true })
useSeoMeta({ title: 'Konto' })
</script>
<template>
<main><h1>Konto</h1></main>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
// Under seo.noindexRoutes ('/prywatne/**' in nuxt.config.ts): noindex
// through the route rule and the meta tag, left out of the route report.
useSeoMeta({ title: 'Prywatne' })
</script>
<template>
<main><h1>Prywatne</h1></main>
</template>

View File

@@ -0,0 +1,9 @@
<script setup lang="ts">
// A page that sets its own robots value through its meta (seo.robots).
definePageMeta({ seo: { robots: 'noindex, follow' } })
useSeoMeta({ title: 'Szkic' })
</script>
<template>
<main><h1>Szkic</h1></main>
</template>

View File

@@ -4,7 +4,7 @@
// testing/smoke-ssr.mjs builds it against the fixture API. The /blog pages
// are route-cached (swr) as on a production site, so the smoke and
// testing/purge-check.mjs see redirects, 410 and purges apply to cached
// pages.
// pages. /prywatne/** is declared noindex.
export default defineNuxtConfig({
extends: ['..'],
compatibilityDate: '2025-07-15',
@@ -15,4 +15,7 @@ export default defineNuxtConfig({
// (testing/expect.txt, /relative) needs uncached pages.
'/blog/**': { swr: 300 },
},
seo: {
noindexRoutes: ['/prywatne/**'],
},
})

View File

@@ -0,0 +1,50 @@
// Noindex for the routes an app declares (D-23, D-21), without page code:
//
// - a page whose meta sets requiresAuth: true (a signed-in or auth page)
// gets robots "noindex, nofollow";
// - a page whose meta sets seo: { robots: '...' } gets that value;
// - a path under seo.noindexRoutes gets "noindex, nofollow" (its route
// rule already sends the header; this adds the meta tag).
//
// The value goes into a robots meta tag that wins over the CMS's (a lower
// tagPriority than the head plugin's 'critical') and, during SSR, into the
// X-Robots-Tag header once the navigation is done, after the route
// middleware. Every other page keeps the CMS robots; on staging the CMS's
// noindex, nofollow stays. Works with the layer off too: it reads no CMS.
import { isNoindex } from '../../lib/guard.ts'
import { pageMetaRobots } from '../../lib/routes.ts'
/** Wins over the CMS head entry (tagPriority 'critical', -8). */
const ROUTE_ROBOTS_PRIORITY = -10
export default defineNuxtPlugin((nuxtApp) => {
const config = useRuntimeConfig()
const declared = (config.public.seo as { noindexRoutes?: unknown } | undefined)?.noindexRoutes
const noindexRoutes = Array.isArray(declared) ? declared.filter((p): p is string => typeof p === 'string') : []
const router = useRouter()
const answer = useSeoAnswer()
const robotsFor = (route: { path: string; meta: Record<string, unknown> }): string | null => {
if (answer.value?.staging === true) return null
const own = pageMetaRobots(route.meta)
if (own) return own
return isNoindex(route.path, noindexRoutes) ? 'noindex, nofollow' : null
}
useHead(
computed(() => {
const robots = robotsFor(router.currentRoute.value)
return robots ? { meta: [{ name: 'robots', content: robots }] } : {}
}),
{ tagPriority: ROUTE_ROBOTS_PRIORITY },
)
if (import.meta.server) {
router.afterEach((to, _from, failure) => {
if (failure) return
const robots = robotsFor(to)
if (robots) nuxtApp.runWithContext(() => (useResponseHeader('X-Robots-Tag').value = robots))
})
}
})

25
lib/routes.ts Normal file
View File

@@ -0,0 +1,25 @@
// The routes the layer tells the CMS about (D-33) and compares with it at
// server start (D-20c). Built at build time from the resolved pages, whose
// meta carries requiresAuth and seo (the module adds both to Nuxt's page
// meta extraction keys), and written into the server template #seo-routes
// for the route report and the drift check. Pure TypeScript, so
// `node --test` drives it.
/** True when a robots value keeps the page out of the index. */
export const robotsNoindex = (robots: unknown) => typeof robots === 'string' && /(^|[\s,])(noindex|none)($|[\s,])/i.test(robots)
/**
* The robots value a page asks for through its meta: seo.robots when set,
* else 'noindex, nofollow' for a page with requiresAuth: true (a signed-in
* or auth page, D-23), else null (the CMS decides).
*/
export function pageMetaRobots(meta: Record<string, unknown> | undefined | null): string | null {
if (!meta || typeof meta !== 'object') return null
const seo = meta.seo
if (seo && typeof seo === 'object') {
const robots = (seo as { robots?: unknown }).robots
if (typeof robots === 'string' && robots.trim() !== '') return robots.trim()
}
if (meta.requiresAuth === true) return 'noindex, nofollow'
return null
}

View File

@@ -73,6 +73,13 @@ export default defineNuxtModule<SeoModuleOptions>({
const ssr = nuxt.options.ssr !== false
const inlineRouteRules = nuxt.options.experimental?.inlineRouteRules === true
// requiresAuth and seo are read from definePageMeta at build time, so
// the route report leaves signed-in and noindex pages out (D-23).
const extract = new Set(nuxt.options.experimental.extraPageMetaExtractionKeys ?? [])
extract.add('requiresAuth')
extract.add('seo')
nuxt.options.experimental.extraPageMetaExtractionKeys = [...extract]
// 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

View File

@@ -66,3 +66,12 @@ noreport /stary
# Labels in the page's content locale (pl answer: the pl dictionary).
200 /blog/lwia-grzywa contains=<p class="seo-visually-hidden">Ścieżka nawigacji</p>
# Declared noindex without page code: a page whose meta requires sign-in,
# a page with its own seo.robots and a path under seo.noindexRoutes get the
# meta tag and the header; an ordinary page keeps the CMS robots.
200 /konto contains=<meta name="robots" content="noindex, nofollow"> count=name="robots":1 header=X-Robots-Tag: noindex, nofollow
200 /szkic contains=<meta name="robots" content="noindex, follow"> header=X-Robots-Tag: noindex, follow
200 /prywatne contains=<meta name="robots" content="noindex, nofollow"> header=X-Robots-Tag: noindex, nofollow
200 /plain noheader=X-Robots-Tag count=name="robots":0
200 /blog/lwia-grzywa contains=<meta name="robots" content="index, follow">

19
tests/routes.test.ts Normal file
View File

@@ -0,0 +1,19 @@
import assert from 'node:assert/strict'
import test from 'node:test'
import { pageMetaRobots, robotsNoindex } from '../lib/routes.ts'
test('pageMetaRobots: seo.robots, then requiresAuth, else the CMS decides', () => {
assert.equal(pageMetaRobots({ requiresAuth: true }), 'noindex, nofollow')
assert.equal(pageMetaRobots({ requiresAuth: true, seo: { robots: 'noindex, follow' } }), 'noindex, follow')
assert.equal(pageMetaRobots({ seo: { robots: ' index, follow ' } }), 'index, follow')
assert.equal(pageMetaRobots({ requiresAuth: false }), null)
assert.equal(pageMetaRobots({ seo: { breadcrumbs: false } }), null)
assert.equal(pageMetaRobots(undefined), null)
})
test('robotsNoindex: noindex and none keep a page out', () => {
assert.equal(robotsNoindex('noindex, follow'), true)
assert.equal(robotsNoindex('none'), true)
assert.equal(robotsNoindex('index, follow'), false)
assert.equal(robotsNoindex(null), false)
})