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
25 changes: 25 additions & 0 deletions docs/content/en/2.concepts/6.internationalization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- **`<html lang>`** 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.
Expand Down
24 changes: 24 additions & 0 deletions docs/content/fr/2.concepts/6.internationalization.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
- **`<html lang>`** 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.
::
10 changes: 9 additions & 1 deletion layer/app/plugins/i18n.ts
Original file line number Diff line number Diff line change
@@ -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')

Expand Down Expand Up @@ -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 })
}
})
})
7 changes: 7 additions & 0 deletions layer/modules/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
7 changes: 6 additions & 1 deletion layer/nuxt.config.ts
Original file line number Diff line number Diff line change
@@ -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)

Expand Down Expand Up @@ -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 || []
Expand Down
5 changes: 4 additions & 1 deletion layer/server/mcp/tools/list-pages.ts
Original file line number Diff line number Diff line change
@@ -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({
Expand Down Expand Up @@ -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 `<html lang>`, 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
Expand All @@ -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)
},
})
8 changes: 8 additions & 0 deletions layer/utils/locale.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
}
3 changes: 2 additions & 1 deletion layer/utils/pages.ts
Original file line number Diff line number Diff line change
@@ -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.
Expand Down Expand Up @@ -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))
}
Loading