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
9 changes: 9 additions & 0 deletions src/module.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ import {
createResolver,
defineNuxtModule,
extendRouteRules,
hasNuxtModule,
importModule,
resolveModule,
} from '@nuxt/kit'
Expand Down Expand Up @@ -307,6 +308,14 @@ export default defineNuxtModule<ModuleOptions>({
nuxt.hook('close', unregister)
}

// Route rule matching strips locale prefixes. Apps without i18n get an identity function and no i18n runtime code.
addServerTemplate({
filename: '#nuxt-robots/i18n.mjs',
getContents: () => hasNuxtModule('@nuxtjs/i18n') || nuxt.options.runtimeConfig.public.i18n
? `export { withoutLocalePrefix } from ${JSON.stringify(resolve('./runtime/server/util/withoutLocalePrefix'))}`
: 'export const withoutLocalePrefix = path => path',
})

// Allow `definePageMeta({ robots: false })` to set per-page robots rules
nuxt.options.experimental.extraPageMetaExtractionKeys = nuxt.options.experimental.extraPageMetaExtractionKeys || []
if (!nuxt.options.experimental.extraPageMetaExtractionKeys.includes('robots'))
Expand Down
38 changes: 6 additions & 32 deletions src/runtime/server/composables/getPathRobotConfig.ts
Original file line number Diff line number Diff line change
@@ -1,41 +1,16 @@
import type { RequestEvent } from 'nuxt/server'
import type { RuntimeI18nConfig } from 'nuxtseo-shared/i18n-runtime'
import type { RobotsContext } from '../../types'
import { matchPathToRule, normaliseRobotsRouteRule } from '@nuxtjs/robots/util'
import { getRequestHeader, matchRouteRules, useRuntimeConfig } from 'nuxt/server'
import { resolveLocaleFromRoute } from 'nuxtseo-shared/i18n-runtime'
import { parseURL, withoutBase, withoutTrailingSlash } from 'ufo'
// @ts-expect-error Virtual server template registered by the module.
import { withoutLocalePrefix } from '#nuxt-robots/i18n.mjs'
// @ts-expect-error Virtual server template registered by the module.
import { pageMetaMatchers } from '#nuxt-robots/page-meta.mjs'
import { useNitroApp } from '#nuxtseo/nitro'
import { getSiteRobotConfig } from './getSiteRobotConfig'
import { useRuntimeConfigNuxtRobots } from './useRuntimeConfigNuxtRobots'

const i18nStrategies = new Set<RuntimeI18nConfig['strategy']>(['no_prefix', 'prefix_except_default', 'prefix', 'prefix_and_default'])

function parseRuntimeI18nConfig(input: unknown): RuntimeI18nConfig | null {
if (!input || typeof input !== 'object')
return null
const config = input as Record<string, unknown>
if (!Array.isArray(config.locales))
return null
const locales = config.locales.flatMap((locale) => {
const code = typeof locale === 'string'
? locale
: locale && typeof locale === 'object' && typeof (locale as Record<string, unknown>).code === 'string'
? (locale as Record<string, string>).code
: null
return code ? [{ code, hreflang: code }] : []
})
if (!locales.length)
return null
const defaultLocale = typeof config.defaultLocale === 'string' ? config.defaultLocale : locales[0]!.code
const strategy = typeof config.strategy === 'string' && i18nStrategies.has(config.strategy as RuntimeI18nConfig['strategy'])
? config.strategy as RuntimeI18nConfig['strategy']
: 'prefix'
return { defaultLocale, locales, strategy }
}

export function getPathRobotConfig(e: RequestEvent, options?: { userAgent?: string, skipSiteIndexable?: boolean, path?: string }): RobotsContext {
const runtimeConfig = useRuntimeConfig()
// has already been resolved
Expand Down Expand Up @@ -116,11 +91,10 @@ export function getPathRobotConfig(e: RequestEvent, options?: { userAgent?: stri
// if we're using i18n we need to strip leading prefixes so the rule will match
// note this is for < v10 i18n behavior as it now handles route rules itself
// TODO we may consider checking the version explicitly rather than the presence of the rules
const i18nConfig = parseRuntimeI18nConfig((runtimeConfig.public as Record<string, unknown>)?.i18n)
if (i18nConfig && typeof robotRouteRules.robots === 'undefined') {
const resolvedRoute = resolveLocaleFromRoute(routeRulesPath, i18nConfig)
if (resolvedRoute.basePath !== routeRulesPath) {
routeRulesPath = resolvedRoute.basePath
if (typeof robotRouteRules.robots === 'undefined') {
const basePath: string = withoutLocalePrefix(routeRulesPath, runtimeConfig.public as Record<string, unknown>)
if (basePath !== routeRulesPath) {
routeRulesPath = basePath
robotRouteRules = matchRules(routeRulesPath)
}
}
Expand Down
38 changes: 38 additions & 0 deletions src/runtime/server/util/withoutLocalePrefix.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import type { RuntimeI18nConfig } from 'nuxtseo-shared/i18n-runtime'
import { resolveLocaleFromRoute } from 'nuxtseo-shared/i18n-runtime'

// The module loads this file only when the app uses i18n. Other apps get an identity function.

const i18nStrategies = new Set<RuntimeI18nConfig['strategy']>(['no_prefix', 'prefix_except_default', 'prefix', 'prefix_and_default'])

function parseRuntimeI18nConfig(input: unknown): RuntimeI18nConfig | null {
if (!input || typeof input !== 'object')
return null
const config = input as Record<string, unknown>
if (!Array.isArray(config.locales))
return null
const locales = config.locales.flatMap((locale) => {
const code = typeof locale === 'string'
? locale
: locale && typeof locale === 'object' && typeof (locale as Record<string, unknown>).code === 'string'
? (locale as Record<string, string>).code
: null
return code ? [{ code, hreflang: code }] : []
})
if (!locales.length)
return null
const defaultLocale = typeof config.defaultLocale === 'string' ? config.defaultLocale : locales[0]!.code
const strategy = typeof config.strategy === 'string' && i18nStrategies.has(config.strategy as RuntimeI18nConfig['strategy'])
? config.strategy as RuntimeI18nConfig['strategy']
: 'prefix'
return { defaultLocale, locales, strategy }
}

/**
* Remove the locale prefix from a path, so a route rule for `/secret` matches `/fr/secret`.
* Returns the path unchanged when the public runtime config has no i18n locales.
*/
export function withoutLocalePrefix(path: string, publicRuntimeConfig: Record<string, unknown>): string {
const i18nConfig = parseRuntimeI18nConfig(publicRuntimeConfig.i18n)
return i18nConfig ? resolveLocaleFromRoute(path, i18nConfig).basePath : path
}
28 changes: 28 additions & 0 deletions test/e2e/i18n-route-rules-prefix.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'

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

process.env.NODE_ENV = 'production'

await setup({
rootDir: resolve('../fixtures/i18n'),
build: true,
server: true,
nuxtConfig: {
routeRules: {
'/admin/**': { robots: false },
},
},
})

function robotsMeta(html: string) {
return html.match(/<meta name="robots" content="([^"]+)">/)?.[1]
}

describe('i18n route rules', () => {
it('applies an unprefixed route rule to a locale prefixed path', async () => {
expect(robotsMeta(await $fetch<string>('/fr/admin/login?mockProductionEnv=true'))).toBe('noindex, nofollow')
})
})
Loading