Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions src/header-rules.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,33 @@ export interface RobotsHeaderRulesInput {
buildAssetsDir: string
}

/**
* Find the `X-Robots-Tag` header in a route rule's headers, in any casing.
*/
export function findRobotsHeader(headers: Record<string, unknown> | undefined): { key: string, value: string } | undefined {
for (const [key, value] of Object.entries(headers || {})) {
if (key.toLowerCase() === 'x-robots-tag' && typeof value === 'string' && value.trim())
return { key, value }
}
}

/**
* Use the `X-Robots-Tag` header of a route rule as its robots rule.
*
* The runtime sends the robots rule as the header and the meta tag. Without this, it would
* replace the configured header and the meta tag would disagree with it.
* A route rule that sets `robots` keeps it.
*/
export function applyRobotsHeaderRouteRules(routeRules: Record<string, (RobotsRouteRuleConfig & { headers?: Record<string, unknown> }) | undefined>): void {
for (const rules of Object.values(routeRules)) {
if (!rules || typeof rules.robots !== 'undefined')
continue
const header = findRobotsHeader(rules.headers)
if (header)
rules.robots = header.value
}
}

/**
* Resolve the `X-Robots-Tag` header for each route rule.
*
Expand Down
10 changes: 8 additions & 2 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,7 @@ import { setupNitroRuntimeCompatibility, setupRuntimeAliases, useModuleLogger }
import { withoutTrailingSlash, withTrailingSlash } from 'ufo'
import { AiBots, AiTrainingBots, NonHelpfulBots } from './const'
import { setupDevToolsUI } from './devtools'
import { resolveRobotsHeaderRules } from './header-rules'
import { applyRobotsHeaderRouteRules, findRobotsHeader, resolveRobotsHeaderRules } from './header-rules'
import { mapPathForI18nPages, resolveI18nConfig, splitPathForI18nLocales } from './i18n'
import { isNuxtGenerate, resolveContentProvider, resolveNitroPreset } from './kit'
import { registerTypeTemplates } from './templates'
Expand Down Expand Up @@ -529,6 +529,10 @@ export default defineNuxtModule<ModuleOptions>({
// @ts-expect-error runtime type
await nuxt.hooks.callHook('robots:config', config)

// a route rule that sets the header is that route's robots rule
applyRobotsHeaderRouteRules(nuxt.options.routeRules || {})
applyRobotsHeaderRouteRules(nuxt.options.nitro.routeRules || {})

// convert robot routeRules to header routeRules for static hosting
if (config.header) {
const noIndexPaths = [withoutTrailingSlash(nuxt.options.app.buildAssetsDir), `${nuxt.options.app.buildAssetsDir}**`]
Expand All @@ -544,9 +548,11 @@ export default defineNuxtModule<ModuleOptions>({
buildAssetsDir: nuxt.options.app.buildAssetsDir,
})
for (const [route, rule] of Object.entries(headerRules)) {
// Reuse the configured header name. A static host would send two differently cased headers.
const headerName = findRobotsHeader(nuxt.options.routeRules?.[route]?.headers)?.key || 'X-Robots-Tag'
extendRouteRules(route, {
headers: {
'X-Robots-Tag': rule,
[headerName]: rule,
},
}, { override: true })
}
Expand Down
28 changes: 28 additions & 0 deletions test/e2e/headersRouteRuleExplicit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { createResolver } from '@nuxt/kit'
import { $fetch, setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
import { fetchRobotsHeaders } from './utils/headers'

const { resolve } = createResolver(import.meta.url)

process.env.NODE_ENV = 'production'
await setup({
rootDir: resolve('../fixtures/static-headers'),
build: true,
nuxtConfig: {
routeRules: {
'/': { headers: { 'x-robots-tag': 'noindex, nofollow' } },
},
},
})

// The site is indexable. A route rule that sets the header itself must keep it.
describe('a route rule that sets the `X-Robots-Tag` header', () => {
it('sends the configured header', async () => {
expect(await fetchRobotsHeaders('/')).toEqual(['noindex, nofollow'])
})
it('renders a robots meta tag with the same value', async () => {
const html = await $fetch<string>('/')
expect(html).toMatch(/<meta name="robots" content="noindex, nofollow"/)
})
})
25 changes: 25 additions & 0 deletions test/e2e/headersRouteRuleExplicitStatic.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { createResolver } from '@nuxt/kit'
import { setup } from '@nuxt/test-utils'
import { describe, expect, it } from 'vitest'
import { readHeadersFile, staticRobotsHeaders } from './utils/headers'

const { resolve } = createResolver(import.meta.url)

process.env.NODE_ENV = 'production'
await setup({
rootDir: resolve('../fixtures/static-headers'),
build: true,
server: false,
nuxtConfig: {
nitro: { preset: 'cloudflare-module' },
routeRules: {
'/': { headers: { 'x-robots-tag': 'noindex, nofollow' } },
},
},
})

describe('_headers for a route rule that sets the `X-Robots-Tag` header', () => {
it('sends the configured header once', () => {
expect(staticRobotsHeaders(readHeadersFile(), '/')).toEqual(['noindex, nofollow'])
})
})
3 changes: 3 additions & 0 deletions test/fixtures/nuxt5/nuxt.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,9 @@ export default defineNuxtConfig({
'/private': {
robots: false,
},
'/explicit-header': {
headers: { 'x-robots-tag': 'noindex, noarchive' },
},
},
compatibilityDate: '2026-10-06',
})
1 change: 1 addition & 0 deletions test/fixtures/nuxt5/server/api/compat.get.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ const routeRule = {
export default defineEventHandler(event => ({
trailingRule: getPathRobotConfig(event, { path: '/slash/', skipSiteIndexable: true }),
lateRule: getPathRobotConfig(event, { path: '/late', skipSiteIndexable: true }),
explicitHeader: getPathRobotConfig(event, { path: '/explicit-header', skipSiteIndexable: true }),
deepRobots: getDeepPathRobotConfig(event, { path: '/private' }),
detectedBot: isBot(event),
normalisedRouteRule: normaliseRobotsRouteRule(routeRule),
Expand Down
1 change: 1 addition & 0 deletions test/fixtures/nuxt5/test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,7 @@ try {
assert.deepEqual(context.normalisedRouteRule, { allow: false })
assert.equal(context.routeRule.robots, false)
assert.equal(context.robots.indexable, false)
assert.equal(context.explicitHeader.rule, 'noindex, noarchive')
const debugPath = await fetch(`${origin}/__robots__/debug-path.json?path=/private`).then(response => response.json())
assert.equal(debugPath.path, '/private')
assert.equal(debugPath.indexable, false)
Expand Down
Loading