diff --git a/src/module.ts b/src/module.ts index 0ea1b93d..95136d19 100644 --- a/src/module.ts +++ b/src/module.ts @@ -15,6 +15,7 @@ import { createResolver, defineNuxtModule, extendRouteRules, + hasNuxtModule, importModule, resolveModule, } from '@nuxt/kit' @@ -307,6 +308,14 @@ export default defineNuxtModule({ 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')) diff --git a/src/runtime/server/composables/getPathRobotConfig.ts b/src/runtime/server/composables/getPathRobotConfig.ts index 794f6ad7..c44cd8e5 100644 --- a/src/runtime/server/composables/getPathRobotConfig.ts +++ b/src/runtime/server/composables/getPathRobotConfig.ts @@ -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(['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 - 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).code === 'string' - ? (locale as Record).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 @@ -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)?.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) + if (basePath !== routeRulesPath) { + routeRulesPath = basePath robotRouteRules = matchRules(routeRulesPath) } } diff --git a/src/runtime/server/util/withoutLocalePrefix.ts b/src/runtime/server/util/withoutLocalePrefix.ts new file mode 100644 index 00000000..521e1c6a --- /dev/null +++ b/src/runtime/server/util/withoutLocalePrefix.ts @@ -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(['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 + 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).code === 'string' + ? (locale as Record).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 { + const i18nConfig = parseRuntimeI18nConfig(publicRuntimeConfig.i18n) + return i18nConfig ? resolveLocaleFromRoute(path, i18nConfig).basePath : path +} diff --git a/test/e2e/i18n-route-rules-prefix.test.ts b/test/e2e/i18n-route-rules-prefix.test.ts new file mode 100644 index 00000000..b9de58b9 --- /dev/null +++ b/test/e2e/i18n-route-rules-prefix.test.ts @@ -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(//)?.[1] +} + +describe('i18n route rules', () => { + it('applies an unprefixed route rule to a locale prefixed path', async () => { + expect(robotsMeta(await $fetch('/fr/admin/login?mockProductionEnv=true'))).toBe('noindex, nofollow') + }) +})