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:
11
.playground/app/pages/konto.vue
Normal file
11
.playground/app/pages/konto.vue
Normal 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>
|
||||
9
.playground/app/pages/prywatne/index.vue
Normal file
9
.playground/app/pages/prywatne/index.vue
Normal 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>
|
||||
9
.playground/app/pages/szkic.vue
Normal file
9
.playground/app/pages/szkic.vue
Normal 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>
|
||||
@@ -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/**'],
|
||||
},
|
||||
})
|
||||
|
||||
50
app/plugins/seo-route-robots.ts
Normal file
50
app/plugins/seo-route-robots.ts
Normal 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
25
lib/routes.ts
Normal 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
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
19
tests/routes.test.ts
Normal 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)
|
||||
})
|
||||
Reference in New Issue
Block a user