diff --git a/docs/guide/getting-started.md b/docs/guide/getting-started.md index b02de236..3986fe62 100644 --- a/docs/guide/getting-started.md +++ b/docs/guide/getting-started.md @@ -308,6 +308,43 @@ i18n: { } ``` +### 🌐 `globalLocaleRoutes` + +Allows you to define custom localized routes for specific pages. You can specify a custom path for each locale for a given page, or disable localization for certain pages entirely. + +**Type**: `Record | false>` + +- **Key** (`string`): The name of the page you want to customize or disable localization for. +- **Value**: + - **`Record`**: A set of locale codes with corresponding custom paths for the page. + - **`false`**: Disable localization for this page entirely. The page will not be localized, and it will remain accessible only through its default path. + +This option gives you the flexibility to localize certain pages differently while leaving others unaffected by localization. + +**Example**: + +```typescript +globalLocaleRoutes: { + page2: { + en: '/custom-page2-en', + de: '/custom-page2-de', + ru: '/custom-page2-ru', + }, + unlocalized: false, // Unlocalized page should not be localized +} +``` + +### Usage: + +In the example above: +- **`page2`**: Custom localized paths are defined for the page `page2` in English (`en`), German (`de`), and Russian (`ru`). Instead of following the standard localization pattern (like `/en/page2`), each locale will have a completely custom URL, such as `/custom-page2-en` for English, `/custom-page2-de` for German, and `/custom-page2-ru` for Russian. +- **`unlocalized`**: This page will not be localized, so it remains accessible only at `/unlocalized`, without any locale prefixes or custom paths. + +### Benefits: + +- **Control over URL structure**: Customize the URL for each locale, which can be helpful for SEO or branding purposes. +- **Flexible localization**: Disable localization for pages where it is unnecessary, keeping the URL structure simple and consistent for those specific routes. + --- # 🔄 Caching Mechanism diff --git a/src/module.ts b/src/module.ts index 98e68e3e..2f51fc9c 100644 --- a/src/module.ts +++ b/src/module.ts @@ -52,6 +52,7 @@ export default defineNuxtModule({ fallbackLocale: undefined, localeCookie: 'user-locale', routesLocaleLinks: {}, + globalLocaleRoutes: {}, plural: (key, count, _locale, getTranslation) => { const translation = getTranslation(key, {}) if (!translation) { @@ -75,7 +76,7 @@ export default defineNuxtModule({ const rootDirs = nuxt.options._layers.map(layer => layer.config.rootDir).reverse() const localeManager = new LocaleManager(options, rootDirs) - const pageManager = new PageManager(localeManager.locales, options.defaultLocale!, options.includeDefaultLocaleRoute!) + const pageManager = new PageManager(localeManager.locales, options.defaultLocale!, options.includeDefaultLocaleRoute!, options.globalLocaleRoutes) let plural = options.plural! if (typeof plural !== 'string') plural = plural.toString() diff --git a/src/page-manager.ts b/src/page-manager.ts index 3f07eb45..edf307b8 100644 --- a/src/page-manager.ts +++ b/src/page-manager.ts @@ -1,7 +1,7 @@ import path from 'node:path' import { readFileSync } from 'node:fs' import type { NuxtPage } from '@nuxt/schema' -import type { Locale } from './types' +import type { GlobalLocaleRoutes, Locale } from './types' import { extractDefineI18nRouteConfig, normalizePath, @@ -21,12 +21,14 @@ export class PageManager { includeDefaultLocaleRoute: boolean localizedPaths: { [key: string]: { [locale: string]: string } } = {} activeLocaleCodes: string[] + globalLocaleRoutes: Record | false | boolean> - constructor(locales: Locale[], defaultLocaleCode: string, includeDefaultLocaleRoute: boolean) { + constructor(locales: Locale[], defaultLocaleCode: string, includeDefaultLocaleRoute: boolean, globalLocaleRoutes: GlobalLocaleRoutes) { this.locales = locales this.defaultLocale = this.findLocaleByCode(defaultLocaleCode) || { code: defaultLocaleCode } this.includeDefaultLocaleRoute = includeDefaultLocaleRoute this.activeLocaleCodes = this.computeActiveLocaleCodes() + this.globalLocaleRoutes = globalLocaleRoutes || {} } private findLocaleByCode(code: string): Locale | undefined { @@ -43,7 +45,24 @@ export class PageManager { this.localizedPaths = this.extractLocalizedPaths(pages, rootDir) const additionalRoutes: NuxtPage[] = [] - pages.forEach(page => this.localizePage(page, additionalRoutes)) + pages.forEach((page) => { + const customRoute = this.globalLocaleRoutes[page.name ?? ''] ?? null + + // If globalLocaleRoutes for this page is false, skip localization + if (customRoute === false) { + return + } + + // Check if the page has custom routes in globalLocaleRoutes + if (customRoute && typeof customRoute === 'object') { + // Add routes based on custom globalLocaleRoutes + this.addCustomGlobalLocalizedRoutes(page, customRoute, additionalRoutes) + } + else { + // Default behavior: localize the page as usual + this.localizePage(page, additionalRoutes) + } + }) pages.push(...additionalRoutes) } @@ -56,16 +75,27 @@ export class PageManager { const localizedPaths: { [key: string]: { [locale: string]: string } } = {} pages.forEach((page) => { - if (page.file) { - const filePath = path.resolve(rootDir, page.file) - const fileContent = readFileSync(filePath, 'utf-8') - const i18nRouteConfig = extractDefineI18nRouteConfig(fileContent, filePath) - - if (i18nRouteConfig?.localeRoutes) { - const normalizedFullPath = normalizePath(path.join(parentPath, page.path)) - localizedPaths[normalizedFullPath] = i18nRouteConfig.localeRoutes + const pageName = page.name ?? '' + const globalLocalePath = this.globalLocaleRoutes[pageName] + + if (!globalLocalePath) { + // Fallback to extracting localized paths from the page file content (existing functionality) + if (page.file) { + const filePath = path.resolve(rootDir, page.file) + const fileContent = readFileSync(filePath, 'utf-8') + const i18nRouteConfig = extractDefineI18nRouteConfig(fileContent, filePath) + + if (i18nRouteConfig?.localeRoutes) { + const normalizedFullPath = normalizePath(path.join(parentPath, page.path)) + localizedPaths[normalizedFullPath] = i18nRouteConfig.localeRoutes + } } } + else if (typeof globalLocalePath === 'object') { + // Use globalLocaleRoutes if defined + const normalizedFullPath = normalizePath(path.join(parentPath, page.path)) + localizedPaths[normalizedFullPath] = globalLocalePath + } if (page.children?.length) { const parentFullPath = normalizePath(path.join(parentPath, page.path)) @@ -76,6 +106,27 @@ export class PageManager { return localizedPaths } + private addCustomGlobalLocalizedRoutes( + page: NuxtPage, + customRoutePaths: Record, + additionalRoutes: NuxtPage[], + ) { + this.locales.forEach((locale) => { + const customPath = customRoutePaths[locale.code] + if (!customPath) return + + const isDefaultLocale = isLocaleDefault(locale, this.defaultLocale, this.includeDefaultLocaleRoute) + if (isDefaultLocale) { + // Modify the page path if it's the default locale + page.path = normalizePath(customPath) + } + else { + // Create a new localized route for this locale + additionalRoutes.push(this.createLocalizedRoute(page, [locale.code], page.children ?? [], true, customPath)) + } + }) + } + private localizePage( page: NuxtPage, additionalRoutes: NuxtPage[], diff --git a/src/types.ts b/src/types.ts index 776c389d..104277de 100644 --- a/src/types.ts +++ b/src/types.ts @@ -13,6 +13,8 @@ export interface DefineI18nRouteConfig { export type Getter = (key: string, params?: Record, defaultValue?: string) => unknown export type PluralFunc = (key: string, count: number, locale: string, getter: Getter) => string | null +export type GlobalLocaleRoutes = Record | false | boolean> | null | undefined + export interface ModuleOptions { locales?: Locale[] meta?: boolean @@ -29,6 +31,7 @@ export interface ModuleOptions { disablePageLocales?: boolean fallbackLocale?: string localeCookie?: string + globalLocaleRoutes?: GlobalLocaleRoutes } export interface ModuleOptionsExtend extends ModuleOptions { diff --git a/test/basic.test.ts b/test/basic.test.ts index e3839fc8..fd6b6f79 100644 --- a/test/basic.test.ts +++ b/test/basic.test.ts @@ -217,3 +217,32 @@ test('test handling of missing locale data', async ({ page, goto }) => { await expect(page.locator('#translation')).toHaveText('page.example') }) + +test('Test globalLocaleRoutes for page2 and unlocalized', async ({ page, goto }) => { + // Test custom locale route for 'page2' in English + await goto('/custom-page2-en', { waitUntil: 'hydration' }) + + // Check that the custom route for English was applied and the content is correct + await expect(page).toHaveURL('/custom-page2-en') + + // Test custom locale route for 'page2' in German + await goto('/de/custom-page2-de', { waitUntil: 'hydration' }) + + // Check that the custom route for German was applied and the content is correct + await expect(page).toHaveURL('/de/custom-page2-de') + + // Test custom locale route for 'page2' in Russian + await goto('/ru/custom-page2-ru', { waitUntil: 'hydration' }) + + // Check that the custom route for Russian was applied and the content is correct + await expect(page).toHaveURL('/ru/custom-page2-ru') + + // Test that the 'unlocalized' page is not affected by localization + await goto('/unlocalized', { waitUntil: 'hydration' }) + + // Check that the unlocalized page remains the same and isn't localized + await expect(page).toHaveURL('/unlocalized') + + const response = await page.goto('/de/unlocalized', { waitUntil: 'networkidle' }) + expect(response?.status()).toBe(404) +}) diff --git a/test/fixtures/basic/nuxt.config.ts b/test/fixtures/basic/nuxt.config.ts index 18c34c37..348dd517 100644 --- a/test/fixtures/basic/nuxt.config.ts +++ b/test/fixtures/basic/nuxt.config.ts @@ -18,6 +18,14 @@ export default defineNuxtConfig({ routesLocaleLinks: { 'dir1-slug': 'index', }, + globalLocaleRoutes: { + page2: { + en: '/custom-page2-en', + de: '/custom-page2-de', + ru: '/custom-page2-ru', + }, + unlocalized: false, // Unlocalized page should not be localized + }, }, compatibilityDate: '2024-08-16', diff --git a/test/fixtures/basic/pages/page2.vue b/test/fixtures/basic/pages/page2.vue new file mode 100644 index 00000000..5c97891d --- /dev/null +++ b/test/fixtures/basic/pages/page2.vue @@ -0,0 +1,81 @@ + + + diff --git a/test/fixtures/basic/pages/unlocalized.vue b/test/fixtures/basic/pages/unlocalized.vue new file mode 100644 index 00000000..5c97891d --- /dev/null +++ b/test/fixtures/basic/pages/unlocalized.vue @@ -0,0 +1,81 @@ + + + diff --git a/test/pages-manager.test.ts b/test/pages-manager.test.ts index f7436f79..e9e2a70c 100644 --- a/test/pages-manager.test.ts +++ b/test/pages-manager.test.ts @@ -23,7 +23,7 @@ test.describe('PageManager', () => { let pageManager: PageManager test.beforeAll(() => { - pageManager = new PageManager(locales, defaultLocaleCode, includeDefaultLocaleRoute) + pageManager = new PageManager(locales, defaultLocaleCode, includeDefaultLocaleRoute, undefined) }) test('should correctly calculate active locale codes', async () => { @@ -172,7 +172,7 @@ test.describe('PageManager', () => { test('should include default locale routes when includeDefaultLocaleRoute is true', async () => { // Устанавливаем флаг includeDefaultLocaleRoute в true const includeDefaultLocaleRoute = true - const pageManagerWithDefaultLocale = new PageManager(locales, defaultLocaleCode, includeDefaultLocaleRoute) + const pageManagerWithDefaultLocale = new PageManager(locales, defaultLocaleCode, includeDefaultLocaleRoute, undefined) const pages: NuxtPage[] = [{ path: '/activity', @@ -198,3 +198,56 @@ test('should include default locale routes when includeDefaultLocaleRoute is tru ]), ) }) + +test('should handle globalLocaleRoutes correctly', async () => { + const globalLocaleRoutes = { + activity: { + en: '/custom-activity-en', + de: '/custom-activity-de', + ru: '/custom-activity-ru', + }, + unlocalized: false, // Unlocalized page should not be localized + } + + // Creating a new PageManager instance with globalLocaleRoutes + const pageManagerWithGlobalRoutes = new PageManager(locales, defaultLocaleCode, includeDefaultLocaleRoute, globalLocaleRoutes) + + const pages: NuxtPage[] = [{ + path: '/activity', + name: 'activity', + children: [{ path: 'skiing', name: 'Skiing' }], + }, { + path: '/unlocalized', + name: 'unlocalized', + }] + + const rootDir = '/mocked/root/dir' // Mocked root directory path + + // Extend pages with globalLocaleRoutes + pageManagerWithGlobalRoutes.extendPages(pages, rootDir) + + const expectedPages = [ + { + path: '/custom-activity-en', + name: 'activity', + children: [{ path: 'skiing', name: 'Skiing' }], + }, + { + path: '/unlocalized', + name: 'unlocalized', + }, + { + path: '/:locale(de)/custom-activity-de', + name: 'localized-activity-de', + children: [{ path: 'skiing', name: 'localized-Skiing-de', children: [] }], + }, + { + path: '/:locale(ru)/custom-activity-ru', + name: 'localized-activity-ru', + children: [{ path: 'skiing', name: 'localized-Skiing-ru', children: [] }], + }, + ] + + // Assert that the pages array matches the expected structure + expect(pages).toEqual(expectedPages) +})