From bc59ce152281999d29816f11ec20bfbce19ccc7d Mon Sep 17 00:00:00 2001 From: Baptiste Leproux Date: Wed, 30 Sep 2026 12:30:22 +0200 Subject: [PATCH] fix(i18n): redirect uppercase locale URLs and normalize locale inputs --- .../en/2.concepts/6.internationalization.md | 25 +++++++++++++++++++ .../fr/2.concepts/6.internationalization.md | 24 ++++++++++++++++++ layer/app/plugins/i18n.ts | 10 +++++++- layer/modules/config.ts | 7 ++++++ layer/nuxt.config.ts | 7 +++++- layer/server/mcp/tools/list-pages.ts | 5 +++- layer/utils/locale.ts | 8 ++++++ layer/utils/pages.ts | 3 ++- 8 files changed, 85 insertions(+), 4 deletions(-) diff --git a/docs/content/en/2.concepts/6.internationalization.md b/docs/content/en/2.concepts/6.internationalization.md index 40908968a..91b20d8b4 100644 --- a/docs/content/en/2.concepts/6.internationalization.md +++ b/docs/content/en/2.concepts/6.internationalization.md @@ -89,6 +89,31 @@ content/ Each locale should mirror the same directory structure to maintain consistent navigation across languages. :: +## Regional locale codes + +You can use locale codes that include a region, like `zh-TW`, `zh-CN`, or `pt-BR`: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + modules: ['@nuxtjs/i18n'], + i18n: { + defaultLocale: 'en', + locales: [ + { code: 'en', name: 'English' }, + { code: 'zh-TW', name: '繁體中文' }, + ], + } +}) +``` + +- **URLs** use the lowercase code, like `/zh-tw/getting-started/installation`. +- **Content folders** can keep the original case: `content/zh-TW/` and `content/zh-tw/` both work. +- **``** keeps the regional tag, like `zh-TW`. + +::warning +Use the lowercase code (`zh-tw`) wherever you reference the locale, for example in internal links. Docus always redirects visitors to the lowercase URL. +:: + ## Locale fallback Docus warns and skips any locale that does not exist in your `content/` directory. Missing locales are not registered. diff --git a/docs/content/fr/2.concepts/6.internationalization.md b/docs/content/fr/2.concepts/6.internationalization.md index 0153a5c21..46012c819 100644 --- a/docs/content/fr/2.concepts/6.internationalization.md +++ b/docs/content/fr/2.concepts/6.internationalization.md @@ -94,3 +94,27 @@ content/ ::warning Chaque locale doit refléter la même structure de répertoires pour maintenir une navigation cohérente entre les langues. :: + +## Codes de locale régionaux + +Vous pouvez utiliser des codes de locale qui incluent une région, comme `zh-TW`, `zh-CN` ou `pt-BR` : + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + modules: ['@nuxtjs/i18n'], + i18n: { + defaultLocale: 'en', + locales: [ + { code: 'en', name: 'English' }, + { code: 'zh-TW', name: '繁體中文' }, + ], + } +}) +``` +- **Les URL** utilisent le code en minuscules, comme `/zh-tw/getting-started/installation`. +- **Les dossiers de contenu** peuvent garder leur casse d'origine : `content/zh-TW/` et `content/zh-tw/` fonctionnent tous les deux. +- **``** conserve le code régional, comme `zh-TW`. + +::warning +Utilisez le code en minuscules (`zh-tw`) partout où vous faites référence à la locale, par exemple dans les liens internes. Docus redirige toujours vers l'URL en minuscules. +:: diff --git a/layer/app/plugins/i18n.ts b/layer/app/plugins/i18n.ts index 2743daae9..9312a78d2 100644 --- a/layer/app/plugins/i18n.ts +++ b/layer/app/plugins/i18n.ts @@ -1,6 +1,6 @@ import type { RouteLocationNormalized } from 'vue-router' import { consola } from 'consola' -import { findLocaleFile } from '../../utils/locale' +import { findLocaleFile, getLocaleRedirect } from '../../utils/locale' const log = consola.withTag('docus') @@ -47,11 +47,19 @@ export default defineNuxtPlugin(async () => { return } + const localeCodes = useDocusI18n().locales.map(locale => locale.code) + addRouteMiddleware((to: RouteLocationNormalized) => { if (to.path === '/') { const cookieLocale = useCookie('i18n_redirected').value || i18nConfig.defaultLocale || 'en' return navigateTo(`/${cookieLocale}`) } + + // Content paths are lowercase, so `/zh-TW/...` would 404 + const redirect = getLocaleRedirect(to.fullPath, localeCodes) + if (redirect) { + return navigateTo(redirect, { redirectCode: 301 }) + } }) }) diff --git a/layer/modules/config.ts b/layer/modules/config.ts index 0d402066f..277d4627d 100644 --- a/layer/modules/config.ts +++ b/layer/modules/config.ts @@ -140,6 +140,13 @@ export default defineNuxtModule({ const normalizedLocales = i18nOptions.locales.map(normalizeLocaleEntry) + const renamedCodes = i18nOptions.locales + .map(locale => typeof locale === 'string' ? locale : locale.code) + .filter(code => code !== normalizeLocale(code)) + if (renamedCodes.length) { + log.info(`Locale codes are lowercased in URLs: ${renamedCodes.map(code => `${code} → ${normalizeLocale(code)}`).join(', ')}. Use the lowercase code to reference a locale, e.g. in \`localePath()\` or \`bundle.onlyLocales\`.`) + } + // Filter locales to only include existing ones const filteredLocales = normalizedLocales.filter((locale) => { const localeCode = locale.code diff --git a/layer/nuxt.config.ts b/layer/nuxt.config.ts index 21ab5df94..1b3a1bb37 100644 --- a/layer/nuxt.config.ts +++ b/layer/nuxt.config.ts @@ -1,5 +1,6 @@ import { join } from 'node:path' import { extendViteConfig, createResolver, useNuxt } from '@nuxt/kit' +import { getLocaleRedirect } from './utils/locale' const { resolve } = createResolver(import.meta.url) @@ -101,12 +102,16 @@ export default defineNuxtConfig({ routes.push('/') } else { - routes.push(...(i18nOptions.locales?.map((locale: string | { code: string }) => typeof locale === 'string' ? `/${locale}` : `/${locale.code}`) || [])) + const localeCodes = i18nOptions.locales?.map((locale: string | { code: string }) => typeof locale === 'string' ? locale : locale.code) || [] + routes.push(...localeCodes.map(code => `/${code}`)) // With one sitemap per locale, `/sitemap.xml` only redirects to the // index, and Nitro would write that redirect as an HTML file the CDN // then serves for the XML URL. nitroConfig.prerender.ignore = nitroConfig.prerender.ignore || [] nitroConfig.prerender.ignore.push('/sitemap.xml') + // Uppercase locale links redirect to their lowercase page. + // Prerendering them would overwrite that page on case-insensitive disks. + nitroConfig.prerender.ignore.push(path => !!getLocaleRedirect(path, localeCodes)) } nitroConfig.prerender.routes = nitroConfig.prerender.routes || [] diff --git a/layer/server/mcp/tools/list-pages.ts b/layer/server/mcp/tools/list-pages.ts index 461f30e88..edca09cdb 100644 --- a/layer/server/mcp/tools/list-pages.ts +++ b/layer/server/mcp/tools/list-pages.ts @@ -1,5 +1,6 @@ import { listAgentPages } from '#agent-discovery' import { z } from 'zod' +import { normalizeLocale } from '../../../utils/locale' import { getAvailableLocales } from '../../utils/content' export default defineMcpTool({ @@ -38,6 +39,8 @@ OUTPUT: Returns a structured list with: handler: async ({ locale }) => { const event = useEvent() const availableLocales = getAvailableLocales(useRuntimeConfig(event).public) + // Agents may pass the tag from ``, like `zh-TW` + const requestedLocale = locale && normalizeLocale(locale) const localeOf = (path: string) => availableLocales.find(code => path === `/${code}` || path.startsWith(`/${code}/`)) // Landing pages (`/`, `/en`) are not documentation, and never were listed here @@ -54,6 +57,6 @@ OUTPUT: Returns a structured list with: locale: localeOf(page.route), url: page.url, })) - .filter(page => !locale || !availableLocales.includes(locale) || page.locale === locale) + .filter(page => !requestedLocale || !availableLocales.includes(requestedLocale) || page.locale === requestedLocale) }, }) diff --git a/layer/utils/locale.ts b/layer/utils/locale.ts index 3a4b549ec..e68a8980d 100644 --- a/layer/utils/locale.ts +++ b/layer/utils/locale.ts @@ -18,3 +18,11 @@ export function getLocaleKey(code: string): string { export function findLocaleFile(code: string, fileNames: string[]): string | undefined { return fileNames.find(fileName => fileName.toLowerCase() === `${normalizeLocale(code)}.json`) } + +/** Lowercases the locale prefix of a path, or returns `undefined` when there is nothing to redirect. */ +export function getLocaleRedirect(path: string, codes: string[]): string | undefined { + const prefix = path.match(/^\/([^/?#]+)/)?.[1] || '' + const code = normalizeLocale(prefix) + + return prefix !== code && codes.includes(code) ? `/${code}${path.slice(prefix.length + 1)}` : undefined +} diff --git a/layer/utils/pages.ts b/layer/utils/pages.ts index 25fcd4747..27245bd1a 100644 --- a/layer/utils/pages.ts +++ b/layer/utils/pages.ts @@ -1,5 +1,6 @@ import { existsSync, readdirSync } from 'node:fs' import { joinURL } from 'ufo' +import { normalizeLocale } from './locale' /** * Checks if the user has their own index.vue file in the pages folder. @@ -29,5 +30,5 @@ export function findLocaleFolder(rootDir: string, locale: string): string | unde const contentDir = joinURL(rootDir, 'content') if (!existsSync(contentDir)) return undefined - return readdirSync(contentDir).find(dir => dir.toLowerCase() === locale) + return readdirSync(contentDir).find(dir => normalizeLocale(dir) === normalizeLocale(locale)) }