From 9cc2cda924a49f038db551e87850bbcf13aad5c7 Mon Sep 17 00:00:00 2001 From: Jakub Zych Date: Wed, 4 Nov 2026 11:49:14 +0100 Subject: [PATCH] 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 --- .playground/app/pages/konto.vue | 11 ++++++ .playground/app/pages/prywatne/index.vue | 9 +++++ .playground/app/pages/szkic.vue | 9 +++++ .playground/nuxt.config.ts | 5 ++- app/plugins/seo-route-robots.ts | 50 ++++++++++++++++++++++++ lib/routes.ts | 25 ++++++++++++ lib/seo-module.ts | 7 ++++ testing/expect-seo.txt | 9 +++++ tests/routes.test.ts | 19 +++++++++ 9 files changed, 143 insertions(+), 1 deletion(-) create mode 100644 .playground/app/pages/konto.vue create mode 100644 .playground/app/pages/prywatne/index.vue create mode 100644 .playground/app/pages/szkic.vue create mode 100644 app/plugins/seo-route-robots.ts create mode 100644 lib/routes.ts create mode 100644 tests/routes.test.ts diff --git a/.playground/app/pages/konto.vue b/.playground/app/pages/konto.vue new file mode 100644 index 0000000..b799cbd --- /dev/null +++ b/.playground/app/pages/konto.vue @@ -0,0 +1,11 @@ + + + diff --git a/.playground/app/pages/prywatne/index.vue b/.playground/app/pages/prywatne/index.vue new file mode 100644 index 0000000..3a443b7 --- /dev/null +++ b/.playground/app/pages/prywatne/index.vue @@ -0,0 +1,9 @@ + + + diff --git a/.playground/app/pages/szkic.vue b/.playground/app/pages/szkic.vue new file mode 100644 index 0000000..f928ce6 --- /dev/null +++ b/.playground/app/pages/szkic.vue @@ -0,0 +1,9 @@ + + + diff --git a/.playground/nuxt.config.ts b/.playground/nuxt.config.ts index 297f9ef..4ced7c0 100644 --- a/.playground/nuxt.config.ts +++ b/.playground/nuxt.config.ts @@ -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/**'], + }, }) diff --git a/app/plugins/seo-route-robots.ts b/app/plugins/seo-route-robots.ts new file mode 100644 index 0000000..0bca8d0 --- /dev/null +++ b/app/plugins/seo-route-robots.ts @@ -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 | 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)) + }) + } +}) diff --git a/lib/routes.ts b/lib/routes.ts new file mode 100644 index 0000000..a530084 --- /dev/null +++ b/lib/routes.ts @@ -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 | 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 +} diff --git a/lib/seo-module.ts b/lib/seo-module.ts index b8faee1..09ba357 100644 --- a/lib/seo-module.ts +++ b/lib/seo-module.ts @@ -73,6 +73,13 @@ export default defineNuxtModule({ 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 diff --git a/testing/expect-seo.txt b/testing/expect-seo.txt index 2ebdf93..58f3a5a 100644 --- a/testing/expect-seo.txt +++ b/testing/expect-seo.txt @@ -66,3 +66,12 @@ noreport /stary # Labels in the page's content locale (pl answer: the pl dictionary). 200 /blog/lwia-grzywa contains=

Ścieżka nawigacji

+ +# 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= count=name="robots":1 header=X-Robots-Tag: noindex, nofollow +200 /szkic contains= header=X-Robots-Tag: noindex, follow +200 /prywatne contains= header=X-Robots-Tag: noindex, nofollow +200 /plain noheader=X-Robots-Tag count=name="robots":0 +200 /blog/lwia-grzywa contains= diff --git a/tests/routes.test.ts b/tests/routes.test.ts new file mode 100644 index 0000000..ce5ada2 --- /dev/null +++ b/tests/routes.test.ts @@ -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) +})