diff --git a/docs/content/docs/02.guide/01.index.md b/docs/content/docs/02.guide/01.index.md index c7faa663d..572cdfce9 100644 --- a/docs/content/docs/02.guide/01.index.md +++ b/docs/content/docs/02.guide/01.index.md @@ -97,3 +97,29 @@ export default defineNuxtConfig({ // ... }) ``` + +## Custom Locale Prefix + +By default, locale codes are used as URL prefixes (e.g., `/en/about`, `/fr/about`). You can customize the URL prefix for each locale using the `prefix` property. This is useful when you want URLs that differ from your locale codes. + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + i18n: { + defaultLocale: 'en', + locales: [ + { code: 'en', language: 'en-US' }, + { code: 'pt-BR', language: 'pt-BR', prefix: 'brazil' }, + { code: 'es-MX', language: 'es-MX', prefix: 'mexico' } + ] + } +}) +``` + +With this configuration, your routes will look like: +- `/about` (English - default locale) +- `/brazil/about` (Brazilian Portuguese) +- `/mexico/about` (Mexican Spanish) + +::callout{icon="i-heroicons-light-bulb"} +The locale `code` is still used internally for route names (e.g., `about___pt-BR`) and i18n functions. Only the URL path uses the custom prefix. +:: diff --git a/docs/content/docs/02.guide/91.new-features.md b/docs/content/docs/02.guide/91.new-features.md index 0206f4089..8b62bc04d 100644 --- a/docs/content/docs/02.guide/91.new-features.md +++ b/docs/content/docs/02.guide/91.new-features.md @@ -5,6 +5,25 @@ toc: depth: 3 --- +### Custom URL prefix per locale +You can now configure a custom URL prefix for each locale using the `prefix` property. This is useful for business-oriented or SEO-friendly URLs where you want to use a different prefix than the locale code. + +For example, if you want to use `/brazil/` instead of `/pt-BR/` for your Brazilian Portuguese locale: + +```ts [nuxt.config.ts] +export default defineNuxtConfig({ + i18n: { + locales: [ + { code: 'en', language: 'en-US' }, + { code: 'pt-BR', language: 'pt-BR', prefix: 'brazil' } + ] + } +}) +``` + +This will generate routes like `/brazil/about` instead of `/pt-BR/about`, while keeping the locale code (`pt-BR`) for internal use (route names, i18n functions, etc.). + +See the [`prefix` option](/docs/api/options#prefix) for more details. ### Custom routes via `definePageMeta()`{lang="ts"} We have added support for setting custom routes for pages using the `definePageMeta()`{lang="ts"} API, which is now the recommended way to set custom routes for pages. diff --git a/docs/content/docs/04.api/00.options.md b/docs/content/docs/04.api/00.options.md index ab69f6138..5908d45ff 100644 --- a/docs/content/docs/04.api/00.options.md +++ b/docs/content/docs/04.api/00.options.md @@ -149,6 +149,24 @@ See also [Multiple files lazy loading](/docs/guide/lazy-load-translations#multip - type: `null | boolean`{lang="ts-type"} - Set `domainDefault` to `true` for each locale that should act as a default locale for the particular domain. This property is required when using [`differentDomains`](/docs/api/options#differentdomains) while one or more of the domains having multiple locales +### `prefix` + +- type: `null | string`{lang="ts-type"} +- A custom URL prefix for this locale. When set, routes will use this prefix instead of the locale code. + +For example, setting `prefix: 'brazil'` for `pt-BR` locale will result in `/brazil/about` instead of `/pt-BR/about`. + +```json +[ + { "code": "en", "language": "en-US", "file": "en.js" }, + { "code": "pt-BR", "language": "pt-BR", "file": "pt-BR.js", "prefix": "brazil" } +] +``` + +::callout{icon="i-heroicons-light-bulb"} +The route name will still use the locale `code` (e.g., `about___pt-BR`), only the URL path uses the custom prefix. +:: + ### `...` - any custom property set on the object will be exposed at runtime. This can be used, for example, to define the language name for the purpose of using it in a language selector on the page. diff --git a/src/kit/gen.ts b/src/kit/gen.ts index e4ca9e52a..a236ad3dd 100644 --- a/src/kit/gen.ts +++ b/src/kit/gen.ts @@ -73,7 +73,8 @@ function createLocalizeAliases(ctx: RouteContext): RouteContext['localizeAliases return aliases.map((x) => { const alias = ctx.handleTrailingSlash(x, !!options.parent) const shouldPrefix = options.shouldPrefix(x, locale, options) - return shouldPrefix ? join('/', locale, alias) : alias + const prefix = ctx.getLocalePrefix(locale) + return shouldPrefix ? join('/', prefix, alias) : alias }) } } @@ -117,7 +118,8 @@ export function localizeSingleRoute( // use custom path if found const unprefixed = routeOptions.paths?.[locale] ?? route.path - const prefixed = join('/', locale, unprefixed) + const prefix = ctx.getLocalePrefix(locale) + const prefixed = join('/', prefix, unprefixed) const usePrefix = options.shouldPrefix(unprefixed, locale, options) const data = { route, prefixed, unprefixed, locale, usePrefix, ctx, options } @@ -144,6 +146,7 @@ export type RouteContext = { trailingSlash: boolean optionsResolver: RouteOptionsResolver isDefaultLocale: (locale: string) => boolean + getLocalePrefix: (locale: string) => string localizeAliases: (route: LocalizableRoute, locale: string, options: LocalizeRouteParams) => string[] localizeChildren: ( route: LocalizableRoute, @@ -188,6 +191,7 @@ function createLocalizeRouteName(opts: { export function createRouteContext(opts: { trailingSlash: boolean defaultLocales: string[] + localePrefixes?: Record optionsResolver?: RouteOptionsResolver routesNameSeparator?: string defaultLocaleRouteNameSuffix?: string @@ -195,6 +199,7 @@ export function createRouteContext(opts: { const ctx = { localizers: [] as RouteContext['localizers'] } as RouteContext ctx.trailingSlash = opts.trailingSlash ?? false ctx.isDefaultLocale = (locale: string) => opts.defaultLocales.includes(locale) + ctx.getLocalePrefix = (locale: string) => opts.localePrefixes?.[locale] || locale ctx.localizeRouteName = createLocalizeRouteName(opts) ctx.optionsResolver = createDefaultOptionsResolver(opts) ctx.localizeAliases = createLocalizeAliases(ctx) diff --git a/src/routing.ts b/src/routing.ts index 2690fb2cf..6f824b98f 100644 --- a/src/routing.ts +++ b/src/routing.ts @@ -64,6 +64,20 @@ type SetupLocalizeRoutesOptions = { optionsResolver?: RouteOptionsResolver } +/** + * Build a map of locale codes to their custom prefixes + */ +function buildLocalePrefixes(locales: LocaleObject[]): Record { + const prefixes: Record = {} + for (const locale of locales) { + // Use custom prefix if defined and non-empty, otherwise use locale code + if (locale.prefix && locale.prefix.length > 0) { + prefixes[locale.code] = locale.prefix + } + } + return prefixes +} + /** * Localize routes */ @@ -74,6 +88,7 @@ export function localizeRoutes(routes: LocalizableRoute[], config: SetupLocalize optionsResolver: config.optionsResolver, trailingSlash: config.trailingSlash ?? false, defaultLocales: resolveDefaultLocales(config), + localePrefixes: buildLocalePrefixes(config.locales), routesNameSeparator: config.routesNameSeparator, defaultLocaleRouteNameSuffix: config.defaultLocaleRouteNameSuffix, }) diff --git a/src/types.ts b/src/types.ts index 4d300b0b3..77a0a561a 100644 --- a/src/types.ts +++ b/src/types.ts @@ -515,6 +515,13 @@ export interface LocaleObject { * This property is required when using differentDomains while one or more of the domains having multiple locales. */ domainDefault?: boolean + /** + * Custom URL prefix for this locale. + * + * When set, routes will use this prefix instead of the locale code. + * For example, setting `prefix: 'brazil'` for `pt-BR` locale will result in `/brazil/about` instead of `/pt-BR/about`. + */ + prefix?: string /** * The name of the file containing locale messages for this locale. * diff --git a/test/locale-prefix.test.ts b/test/locale-prefix.test.ts new file mode 100644 index 000000000..df625140d --- /dev/null +++ b/test/locale-prefix.test.ts @@ -0,0 +1,242 @@ +import { describe, it, expect } from 'vitest' +import { localizeRoutes } from '../src/routing' +import type { LocaleObject } from '../src/types' +import type { LocalizableRoute } from '../src/kit/gen' + +const baseOptions = { + strategy: 'prefix' as const, + trailingSlash: false, + routesNameSeparator: '___', + defaultLocaleRouteNameSuffix: 'default' +} + +describe('custom locale prefix', () => { + describe('basic functionality', () => { + it('should use custom prefix when defined', () => { + const routes: LocalizableRoute[] = [ + { path: '/', name: 'index' }, + { path: '/about', name: 'about' } + ] + const locales: LocaleObject[] = [ + { code: 'en', language: 'en-US' }, + { code: 'pt-BR', language: 'pt-BR', prefix: 'brazil' } + ] + + const localizedRoutes = localizeRoutes(routes, { ...baseOptions, locales }) + + // Should have routes with /en and /brazil prefixes + expect(localizedRoutes.some(r => r.path === '/en')).toBe(true) + expect(localizedRoutes.some(r => r.path === '/en/about')).toBe(true) + expect(localizedRoutes.some(r => r.path === '/brazil')).toBe(true) + expect(localizedRoutes.some(r => r.path === '/brazil/about')).toBe(true) + + // Should NOT have routes with /pt-BR prefix + expect(localizedRoutes.some(r => r.path === '/pt-BR')).toBe(false) + expect(localizedRoutes.some(r => r.path === '/pt-BR/about')).toBe(false) + }) + + it('should use locale code when prefix is not defined', () => { + const routes: LocalizableRoute[] = [ + { path: '/', name: 'index' } + ] + const locales: LocaleObject[] = [ + { code: 'en', language: 'en-US' }, + { code: 'ja', language: 'ja-JP' } + ] + + const localizedRoutes = localizeRoutes(routes, { ...baseOptions, locales }) + + expect(localizedRoutes.some(r => r.path === '/en')).toBe(true) + expect(localizedRoutes.some(r => r.path === '/ja')).toBe(true) + }) + + it('should preserve route name with locale code (not prefix)', () => { + const routes: LocalizableRoute[] = [ + { path: '/about', name: 'about' } + ] + const locales: LocaleObject[] = [ + { code: 'pt-BR', language: 'pt-BR', prefix: 'brazil' } + ] + + const localizedRoutes = localizeRoutes(routes, { ...baseOptions, locales }) + + // Route name should still use the locale code, not the prefix + expect(localizedRoutes.some(r => r.name === 'about___pt-BR')).toBe(true) + expect(localizedRoutes.some(r => r.name === 'about___brazil')).toBe(false) + }) + }) + + describe('with different strategies', () => { + it('should work with prefix_except_default strategy', () => { + const routes: LocalizableRoute[] = [ + { path: '/', name: 'index' }, + { path: '/about', name: 'about' } + ] + const locales: LocaleObject[] = [ + { code: 'en', language: 'en-US' }, + { code: 'pt-BR', language: 'pt-BR', prefix: 'brazil' } + ] + + const localizedRoutes = localizeRoutes(routes, { + ...baseOptions, + strategy: 'prefix_except_default', + defaultLocale: 'en', + locales + }) + + // Default locale (en) should not have prefix + expect(localizedRoutes.some(r => r.path === '/' && r.name === 'index___en')).toBe(true) + expect(localizedRoutes.some(r => r.path === '/about' && r.name === 'about___en')).toBe(true) + + // pt-BR should use custom prefix 'brazil' + expect(localizedRoutes.some(r => r.path === '/brazil')).toBe(true) + expect(localizedRoutes.some(r => r.path === '/brazil/about')).toBe(true) + expect(localizedRoutes.some(r => r.path === '/pt-BR')).toBe(false) + }) + + it('should work with prefix_and_default strategy', () => { + const routes: LocalizableRoute[] = [ + { path: '/', name: 'index' } + ] + const locales: LocaleObject[] = [ + { code: 'en', language: 'en-US', prefix: 'us' }, + { code: 'pt-BR', language: 'pt-BR', prefix: 'brazil' } + ] + + const localizedRoutes = localizeRoutes(routes, { + ...baseOptions, + strategy: 'prefix_and_default', + defaultLocale: 'en', + locales + }) + + // Should have both unprefixed and prefixed routes for default locale + expect(localizedRoutes.some(r => r.path === '/')).toBe(true) + expect(localizedRoutes.some(r => r.path === '/us')).toBe(true) + expect(localizedRoutes.some(r => r.path === '/brazil')).toBe(true) + + // Should NOT have routes with locale codes as prefix + expect(localizedRoutes.some(r => r.path === '/en')).toBe(false) + expect(localizedRoutes.some(r => r.path === '/pt-BR')).toBe(false) + }) + }) + + describe('with children routes', () => { + it('should apply custom prefix to parent and children routes', () => { + const routes: LocalizableRoute[] = [ + { + path: '/user/:id', + name: 'user', + children: [ + { path: 'profile', name: 'user-profile' }, + { path: 'settings', name: 'user-settings' } + ] + } + ] + const locales: LocaleObject[] = [ + { code: 'pt-BR', language: 'pt-BR', prefix: 'brazil' } + ] + + const localizedRoutes = localizeRoutes(routes, { ...baseOptions, locales }) + + // Parent route should use custom prefix + const userRoute = localizedRoutes.find(r => r.name === 'user___pt-BR') + expect(userRoute).toBeDefined() + expect(userRoute?.path).toBe('/brazil/user/:id') + + // Children should have correct names + expect(userRoute?.children?.some(c => c.name === 'user-profile___pt-BR')).toBe(true) + expect(userRoute?.children?.some(c => c.name === 'user-settings___pt-BR')).toBe(true) + }) + }) + + describe('with aliases', () => { + it('should apply custom prefix to route aliases', () => { + const routes: LocalizableRoute[] = [ + { + path: '/about', + name: 'about', + alias: ['/about-us', '/info'] + } + ] + const locales: LocaleObject[] = [ + { code: 'pt-BR', language: 'pt-BR', prefix: 'brazil' } + ] + + const localizedRoutes = localizeRoutes(routes, { ...baseOptions, locales }) + + const aboutRoute = localizedRoutes.find(r => r.name === 'about___pt-BR') + expect(aboutRoute).toBeDefined() + expect(aboutRoute?.path).toBe('/brazil/about') + expect(aboutRoute?.alias).toContain('/brazil/about-us') + expect(aboutRoute?.alias).toContain('/brazil/info') + }) + }) + + describe('with trailing slash', () => { + it('should apply trailing slash with custom prefix', () => { + const routes: LocalizableRoute[] = [ + { path: '/about', name: 'about' } + ] + const locales: LocaleObject[] = [ + { code: 'pt-BR', language: 'pt-BR', prefix: 'brazil' } + ] + + const localizedRoutes = localizeRoutes(routes, { + ...baseOptions, + trailingSlash: true, + locales + }) + + expect(localizedRoutes.some(r => r.path === '/brazil/about/')).toBe(true) + }) + }) + + describe('edge cases', () => { + it('should handle empty prefix as using locale code', () => { + const routes: LocalizableRoute[] = [ + { path: '/about', name: 'about' } + ] + const locales: LocaleObject[] = [ + { code: 'pt-BR', language: 'pt-BR', prefix: '' } + ] + + const localizedRoutes = localizeRoutes(routes, { ...baseOptions, locales }) + + // Empty prefix should fallback to locale code + expect(localizedRoutes.some(r => r.path === '/pt-BR/about')).toBe(true) + }) + + it('should handle prefix with special characters', () => { + const routes: LocalizableRoute[] = [ + { path: '/about', name: 'about' } + ] + const locales: LocaleObject[] = [ + { code: 'pt-BR', language: 'pt-BR', prefix: 'br' } + ] + + const localizedRoutes = localizeRoutes(routes, { ...baseOptions, locales }) + + expect(localizedRoutes.some(r => r.path === '/br/about')).toBe(true) + }) + + it('should handle multiple locales with mixed prefix configurations', () => { + const routes: LocalizableRoute[] = [ + { path: '/about', name: 'about' } + ] + const locales: LocaleObject[] = [ + { code: 'en', language: 'en-US' }, // no custom prefix + { code: 'pt-BR', language: 'pt-BR', prefix: 'brazil' }, // custom prefix + { code: 'es-MX', language: 'es-MX', prefix: 'mexico' } // custom prefix + ] + + const localizedRoutes = localizeRoutes(routes, { ...baseOptions, locales }) + + expect(localizedRoutes.some(r => r.path === '/en/about')).toBe(true) + expect(localizedRoutes.some(r => r.path === '/brazil/about')).toBe(true) + expect(localizedRoutes.some(r => r.path === '/mexico/about')).toBe(true) + expect(localizedRoutes.some(r => r.path === '/pt-BR/about')).toBe(false) + expect(localizedRoutes.some(r => r.path === '/es-MX/about')).toBe(false) + }) + }) +})