From c0d680deabd51342039fb5866e9e73d0e8c52e03 Mon Sep 17 00:00:00 2001 From: caohuilin Date: Tue, 11 Nov 2025 17:20:38 +0800 Subject: [PATCH 1/8] feat: add i18next detecte dependencies --- packages/runtime/plugin-i18n/package.json | 4 +++- pnpm-lock.yaml | 18 ++++++++++++++++++ 2 files changed, 21 insertions(+), 1 deletion(-) diff --git a/packages/runtime/plugin-i18n/package.json b/packages/runtime/plugin-i18n/package.json index ba600dac75a..fbe419091b0 100644 --- a/packages/runtime/plugin-i18n/package.json +++ b/packages/runtime/plugin-i18n/package.json @@ -83,7 +83,9 @@ "@modern-js/server-runtime": "workspace:*", "@modern-js/types": "workspace:*", "@modern-js/utils": "workspace:*", - "@swc/helpers": "^0.5.17" + "@swc/helpers": "^0.5.17", + "i18next-browser-languagedetector": "^8.2.0", + "i18next-http-middleware": "^3.8.1" }, "peerDependencies": { "react": ">=17", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 8eb47d04698..1a8668064f1 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1212,6 +1212,12 @@ importers: '@swc/helpers': specifier: ^0.5.17 version: 0.5.17 + i18next-browser-languagedetector: + specifier: ^8.2.0 + version: 8.2.0 + i18next-http-middleware: + specifier: ^3.8.1 + version: 3.8.2 devDependencies: '@modern-js/app-tools': specifier: workspace:* @@ -12278,6 +12284,12 @@ packages: resolution: {integrity: sha512-Y93lCzHYgGWdrJ66yIktxiaGULYc6oGiABxhcO5AufBeOyoIdZF7bIfLaOrbM0iGIOXQQgxxRrFEnb+Y6w1n4A==} engines: {node: '>=10.18'} + i18next-browser-languagedetector@8.2.0: + resolution: {integrity: sha512-P+3zEKLnOF0qmiesW383vsLdtQVyKtCNA9cjSoKCppTKPQVfKd2W8hbVo5ZhNJKDqeM7BOcvNoKJOjpHh4Js9g==} + + i18next-http-middleware@3.8.2: + resolution: {integrity: sha512-Z0i85W9dlX4dbceQD3EX2vj/PChebMjDCSLxE7ZJZ5Fgm8LPQmBmB9iJeE7oX3LH7Zzdu/a/28LCHOVHqaeW0Q==} + i18next@25.6.1: resolution: {integrity: sha512-yUWvdXtalZztmKrKw3yz/AvSP3yKyqIkVPx/wyvoYy9lkLmwzItLxp0iHZLG5hfVQ539Jor4XLO+U+NHIXg7pw==} peerDependencies: @@ -24740,6 +24752,12 @@ snapshots: hyperdyperid@1.2.0: {} + i18next-browser-languagedetector@8.2.0: + dependencies: + '@babel/runtime': 7.28.4 + + i18next-http-middleware@3.8.2: {} + i18next@25.6.1(typescript@5.6.3): dependencies: '@babel/runtime': 7.28.4 From 3489dbc6953a830f783790bce9659f918d0b42a2 Mon Sep 17 00:00:00 2001 From: caohuilin Date: Tue, 11 Nov 2025 17:24:07 +0800 Subject: [PATCH 2/8] feat: detection utils for runtime and server --- .../plugin-i18n/src/shared/deepMerge.ts | 38 +++++++++++++ .../runtime/plugin-i18n/src/shared/type.ts | 10 ++++ .../runtime/plugin-i18n/src/shared/utils.ts | 52 ++++++++++++++++++ .../runtime/plugin-i18n/src/utils/config.ts | 53 ------------------- 4 files changed, 100 insertions(+), 53 deletions(-) create mode 100644 packages/runtime/plugin-i18n/src/shared/deepMerge.ts create mode 100644 packages/runtime/plugin-i18n/src/shared/type.ts create mode 100644 packages/runtime/plugin-i18n/src/shared/utils.ts delete mode 100644 packages/runtime/plugin-i18n/src/utils/config.ts diff --git a/packages/runtime/plugin-i18n/src/shared/deepMerge.ts b/packages/runtime/plugin-i18n/src/shared/deepMerge.ts new file mode 100644 index 00000000000..2cd7cf8edf7 --- /dev/null +++ b/packages/runtime/plugin-i18n/src/shared/deepMerge.ts @@ -0,0 +1,38 @@ +function isPlainObject(value: any): boolean { + return ( + value !== null && + typeof value === 'object' && + !Array.isArray(value) && + !(value instanceof Date) + ); +} + +export function deepMerge>( + defaultOptions: T, + userOptions?: Partial, +): T { + if (!userOptions) { + return defaultOptions; + } + + const merged: Record = { ...defaultOptions }; + + for (const key in userOptions) { + const userValue = userOptions[key]; + if (userValue === undefined) { + continue; + } + + const defaultValue = merged[key]; + const isUserValueObject = isPlainObject(userValue); + const isDefaultValueObject = isPlainObject(defaultValue); + + if (isUserValueObject && isDefaultValueObject) { + merged[key] = deepMerge(defaultValue, userValue); + } else { + merged[key] = userValue; + } + } + + return merged as T; +} diff --git a/packages/runtime/plugin-i18n/src/shared/type.ts b/packages/runtime/plugin-i18n/src/shared/type.ts new file mode 100644 index 00000000000..2bee4dcdac8 --- /dev/null +++ b/packages/runtime/plugin-i18n/src/shared/type.ts @@ -0,0 +1,10 @@ +export interface BaseLocaleDetectionOptions { + localePathRedirect?: boolean; + i18nextDetector?: boolean; + languages?: string[]; + fallbackLanguage?: string; +} + +export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions { + localeDetectionByEntry?: Record; +} diff --git a/packages/runtime/plugin-i18n/src/shared/utils.ts b/packages/runtime/plugin-i18n/src/shared/utils.ts new file mode 100644 index 00000000000..d14c32518f4 --- /dev/null +++ b/packages/runtime/plugin-i18n/src/shared/utils.ts @@ -0,0 +1,52 @@ +import type { + BaseLocaleDetectionOptions, + LocaleDetectionOptions, +} from './type'; + +export function getEntryConfig>( + entryName: string, + config: T, + entryKey: string, +): T | undefined { + const entryConfigMap = (config as any)[entryKey] as + | Record + | undefined; + return entryConfigMap?.[entryName]; +} + +export function removeEntryConfigKey>( + config: T, + entryKey: string, +): Omit { + const { [entryKey]: _, ...rest } = config; + return rest; +} + +export function getLocaleDetectionOptions( + entryName: string, + localeDetection: BaseLocaleDetectionOptions, +): BaseLocaleDetectionOptions { + const fullConfig = localeDetection as LocaleDetectionOptions; + const entryConfig = getEntryConfig( + entryName, + fullConfig, + 'localeDetectionByEntry', + ); + + if (entryConfig) { + const globalConfig = removeEntryConfigKey( + fullConfig, + 'localeDetectionByEntry', + ); + return { + ...globalConfig, + ...entryConfig, + }; + } + + if ('localeDetectionByEntry' in fullConfig) { + return removeEntryConfigKey(fullConfig, 'localeDetectionByEntry'); + } + + return localeDetection; +} diff --git a/packages/runtime/plugin-i18n/src/utils/config.ts b/packages/runtime/plugin-i18n/src/utils/config.ts deleted file mode 100644 index 4f26386deca..00000000000 --- a/packages/runtime/plugin-i18n/src/utils/config.ts +++ /dev/null @@ -1,53 +0,0 @@ -/** - * Base locale detection configuration for a single entry - */ -export interface BaseLocaleDetectionOptions { - /** Whether to enable locale detection from path and automatic redirect */ - localePathRedirect?: boolean; - /** List of supported languages */ - languages?: string[]; - /** Fallback language when detection fails */ - fallbackLanguage?: string; -} - -/** - * Locale detection configuration that supports both global and per-entry settings - */ -export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions { - /** Per-entry locale detection configurations */ - localeDetectionByEntry?: Record; -} - -/** - * Gets the locale detection options for a specific entry, falling back to global config - * @param entryName - The name of the entry to get options for - * @param localeDetection - The global locale detection configuration - * @returns The resolved locale detection options for the entry - */ -export const getLocaleDetectionOptions = ( - entryName: string, - localeDetection: BaseLocaleDetectionOptions, -): BaseLocaleDetectionOptions => { - // Type guard to check if the config has localeDetectionByEntry - const hasEntryConfig = ( - config: BaseLocaleDetectionOptions, - ): config is LocaleDetectionOptions => - config && - typeof config === 'object' && - (config as any).localeDetectionByEntry !== undefined; - - if (hasEntryConfig(localeDetection)) { - const { localeDetectionByEntry, ...globalConfig } = localeDetection; - const entryConfig = localeDetectionByEntry?.[entryName]; - // Merge entry-specific config with global config, entry config takes precedence - if (entryConfig) { - return { - ...globalConfig, - ...entryConfig, - }; - } - return globalConfig; - } - - return localeDetection; -}; From cf31981f7a69f808ab59049d38e35bdb5a8233c7 Mon Sep 17 00:00:00 2001 From: caohuilin Date: Tue, 11 Nov 2025 17:25:19 +0800 Subject: [PATCH 3/8] feat: update use types path --- packages/runtime/plugin-i18n/src/cli/index.ts | 6 ++---- packages/runtime/plugin-i18n/src/runtime/index.tsx | 2 +- packages/runtime/plugin-i18n/src/server/index.ts | 6 ++---- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/packages/runtime/plugin-i18n/src/cli/index.ts b/packages/runtime/plugin-i18n/src/cli/index.ts index 7b7f4809373..8e3a0e2dca1 100644 --- a/packages/runtime/plugin-i18n/src/cli/index.ts +++ b/packages/runtime/plugin-i18n/src/cli/index.ts @@ -1,8 +1,6 @@ import type { AppTools, CliPlugin } from '@modern-js/app-tools'; -import { - type LocaleDetectionOptions, - getLocaleDetectionOptions, -} from '../utils/config'; +import type { LocaleDetectionOptions } from '../shared/type'; +import { getLocaleDetectionOptions } from '../shared/utils'; export interface I18nPluginOptions { localeDetection?: LocaleDetectionOptions; diff --git a/packages/runtime/plugin-i18n/src/runtime/index.tsx b/packages/runtime/plugin-i18n/src/runtime/index.tsx index a1f6cd73be6..2d74b05fd4f 100644 --- a/packages/runtime/plugin-i18n/src/runtime/index.tsx +++ b/packages/runtime/plugin-i18n/src/runtime/index.tsx @@ -5,7 +5,7 @@ import { } from '@modern-js/runtime'; import type React from 'react'; import { useEffect, useState } from 'react'; -import type { BaseLocaleDetectionOptions } from '../utils/config'; +import type { BaseLocaleDetectionOptions } from '../shared/type'; import { ModernI18nProvider } from './context'; import type { I18nInitOptions, I18nInstance } from './i18n'; import { getI18nInstance } from './i18n'; diff --git a/packages/runtime/plugin-i18n/src/server/index.ts b/packages/runtime/plugin-i18n/src/server/index.ts index cfca4898fb2..4ebb92fc7bc 100644 --- a/packages/runtime/plugin-i18n/src/server/index.ts +++ b/packages/runtime/plugin-i18n/src/server/index.ts @@ -1,8 +1,6 @@ import type { Context, Next, ServerPlugin } from '@modern-js/server-runtime'; -import { - type LocaleDetectionOptions, - getLocaleDetectionOptions, -} from '../utils/config.js'; +import type { LocaleDetectionOptions } from '../shared/type'; +import { getLocaleDetectionOptions } from '../shared/utils.js'; export interface I18nPluginOptions { localeDetection: LocaleDetectionOptions; From 46fbe74c6261f2e880c35715237ea5610509fa87 Mon Sep 17 00:00:00 2001 From: caohuilin Date: Tue, 11 Nov 2025 17:28:07 +0800 Subject: [PATCH 4/8] feat: defind detection config and use middleware --- .../src/runtime/i18n/detection/config.ts | 29 ++++++++++++++++++ .../runtime/i18n/detection/middleware.node.ts | 29 ++++++++++++++++++ .../src/runtime/i18n/detection/middleware.ts | 30 +++++++++++++++++++ .../plugin-i18n/src/runtime/i18n/instance.ts | 21 +++++++++++++ 4 files changed, 109 insertions(+) create mode 100644 packages/runtime/plugin-i18n/src/runtime/i18n/detection/config.ts create mode 100644 packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.node.ts create mode 100644 packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.ts diff --git a/packages/runtime/plugin-i18n/src/runtime/i18n/detection/config.ts b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/config.ts new file mode 100644 index 00000000000..2682f79c6cc --- /dev/null +++ b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/config.ts @@ -0,0 +1,29 @@ +import type { RuntimeContext } from '@modern-js/runtime'; +import { deepMerge } from '../../../shared/deepMerge'; +import type { LanguageDetectorOptions } from '../instance'; + +export const DEFAULT_I18NEXT_DETECTION_OPTIONS = { + caches: ['cookie', 'localStorage'], + order: [ + 'querystring', + 'cookie', + 'localStorage', + 'header', + 'navigator', + 'htmlTag', + 'path', + 'subdomain', + ], + cookieMinutes: 60 * 24 * 365, + lookupQuerystring: 'lng', + lookupCookie: 'i18next', + lookupLocalStorage: 'i18nextLng', + lookupHeader: 'accept-language', +}; + +export function mergeDetectionOptions( + userOptions?: LanguageDetectorOptions, + defaultOptions: LanguageDetectorOptions = DEFAULT_I18NEXT_DETECTION_OPTIONS, +): LanguageDetectorOptions { + return deepMerge(defaultOptions, userOptions); +} diff --git a/packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.node.ts b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.node.ts new file mode 100644 index 00000000000..3393dff3e84 --- /dev/null +++ b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.node.ts @@ -0,0 +1,29 @@ +import { LanguageDetector } from 'i18next-http-middleware'; +import type { I18nInstance } from '../instance'; + +export const useI18nextLanguageDetector = (i18nInstance: I18nInstance) => { + return i18nInstance.use(LanguageDetector); +}; + +export const detectLanguage = ( + i18nInstance: I18nInstance, + request?: any, +): string | undefined => { + const detector = i18nInstance.services?.languageDetector; + if (detector && typeof detector.detect === 'function' && request) { + try { + const result = detector.detect(request, {}); + // detector.detect() can return string | string[] | undefined + if (typeof result === 'string') { + return result; + } + if (Array.isArray(result) && result.length > 0) { + return result[0]; + } + return undefined; + } catch (error) { + return undefined; + } + } + return undefined; +}; diff --git a/packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.ts b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.ts new file mode 100644 index 00000000000..cc84bce7faa --- /dev/null +++ b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/middleware.ts @@ -0,0 +1,30 @@ +import LanguageDetector from 'i18next-browser-languagedetector'; +import type { I18nInstance } from '../instance'; + +export const useI18nextLanguageDetector = (i18nInstance: I18nInstance) => { + return i18nInstance.use(LanguageDetector); +}; + +export const detectLanguage = ( + i18nInstance: I18nInstance, + _request?: any, +): string | undefined => { + // Access the detector from i18next services + // The detector is registered via useI18nextLanguageDetector() and initialized in init() + const detector = i18nInstance.services?.languageDetector; + if (detector && typeof detector.detect === 'function') { + try { + const result = detector.detect(); + // detector.detect() can return string | string[] | undefined + if (typeof result === 'string') { + return result; + } + if (Array.isArray(result) && result.length > 0) { + return result[0]; + } + } catch (error) { + return undefined; + } + } + return undefined; +}; diff --git a/packages/runtime/plugin-i18n/src/runtime/i18n/instance.ts b/packages/runtime/plugin-i18n/src/runtime/i18n/instance.ts index e01eabcd8f6..0066bcbcfd0 100644 --- a/packages/runtime/plugin-i18n/src/runtime/i18n/instance.ts +++ b/packages/runtime/plugin-i18n/src/runtime/i18n/instance.ts @@ -7,6 +7,26 @@ export interface I18nInstance { use: (plugin: any) => void; createInstance: (options?: I18nInitOptions) => I18nInstance; cloneInstance?: () => I18nInstance; // ssr need + services?: { + languageDetector?: { + detect: (request?: any, options?: any) => string | string[] | undefined; + [key: string]: any; + }; + [key: string]: any; + }; +} + +type LanguageDetectorOrder = string[]; +type LanguageDetectorCaches = boolean | string[]; +export interface LanguageDetectorOptions { + order?: LanguageDetectorOrder; + lookupQuerystring?: string; + lookupCookie?: string; + lookupSession?: string; + lookupFromPathIndex?: number; + caches?: LanguageDetectorCaches; + cookieExpirationDate?: Date; + cookieDomain?: string; } export type I18nInitOptions = { @@ -14,6 +34,7 @@ export type I18nInitOptions = { fallbackLng?: string; supportedLngs?: string[]; initImmediate?: boolean; + detection?: LanguageDetectorOptions; }; export function isI18nInstance(obj: any): obj is I18nInstance { From 91fcf07dd0d37d3fd45c69144aae239ae0cef1f0 Mon Sep 17 00:00:00 2001 From: caohuilin Date: Tue, 11 Nov 2025 17:28:58 +0800 Subject: [PATCH 5/8] feat: remove getEntryPath params --- packages/runtime/plugin-i18n/src/runtime/context.tsx | 4 ++-- packages/runtime/plugin-i18n/src/runtime/utils.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/runtime/plugin-i18n/src/runtime/context.tsx b/packages/runtime/plugin-i18n/src/runtime/context.tsx index 1cb2050c5fb..bd3ded706f3 100644 --- a/packages/runtime/plugin-i18n/src/runtime/context.tsx +++ b/packages/runtime/plugin-i18n/src/runtime/context.tsx @@ -132,7 +132,7 @@ export const useModernI18n = (): UseModernI18nReturn => { location ) { const currentPath = location.pathname; - const entryPath = getEntryPath(entryName); + const entryPath = getEntryPath(); const relativePath = currentPath.replace(entryPath, ''); // Build new path with updated language @@ -148,7 +148,7 @@ export const useModernI18n = (): UseModernI18nReturn => { } else if (localePathRedirect && isBrowser() && !hasRouter) { // Fallback: use window.history API when router is not available const currentPath = window.location.pathname; - const entryPath = getEntryPath(entryName); + const entryPath = getEntryPath(); const relativePath = currentPath.replace(entryPath, ''); // Build new path with updated language diff --git a/packages/runtime/plugin-i18n/src/runtime/utils.ts b/packages/runtime/plugin-i18n/src/runtime/utils.ts index eeea0273867..940d23e4e27 100644 --- a/packages/runtime/plugin-i18n/src/runtime/utils.ts +++ b/packages/runtime/plugin-i18n/src/runtime/utils.ts @@ -1,7 +1,7 @@ import { getGlobalBasename } from '@modern-js/runtime/context'; import { MAIN_ENTRY_NAME } from '@modern-js/utils/universal/constants'; -export const getEntryPath = (entryName?: string): string => { +export const getEntryPath = (): string => { const basename = getGlobalBasename(); if (basename) { return basename === '/' ? '' : basename; From 15b73bb7397387abc246a2712427b393204c93c3 Mon Sep 17 00:00:00 2001 From: caohuilin Date: Tue, 11 Nov 2025 19:31:08 +0800 Subject: [PATCH 6/8] feat: support i18n detect --- .../plugin-i18n/src/runtime/context.tsx | 10 +- .../src/runtime/i18n/detection/config.ts | 3 +- .../src/runtime/i18n/detection/index.ts | 293 ++++++++++++++++++ .../plugin-i18n/src/runtime/i18n/instance.ts | 1 + .../runtime/plugin-i18n/src/runtime/index.tsx | 126 +++++--- .../runtime/plugin-i18n/src/runtime/utils.ts | 24 +- .../runtime/plugin-i18n/src/server/index.ts | 23 +- .../plugin-i18n/src/shared/detection.ts | 131 ++++++++ .../runtime/plugin-i18n/src/shared/type.ts | 3 + .../i18n/app-csr/src/modern.runtime.tsx | 1 - .../i18n/app-csr/tests/index.test.ts | 101 ++++++ .../i18n/app-ssr/tests/index.test.ts | 87 ++++++ .../i18n/routes-csr/test/index.test.ts | 87 ++++++ .../i18n/routes-ssr/test/index.test.ts | 116 +++++++ tests/integration/i18n/test-utils.ts | 29 ++ 15 files changed, 981 insertions(+), 54 deletions(-) create mode 100644 packages/runtime/plugin-i18n/src/runtime/i18n/detection/index.ts create mode 100644 packages/runtime/plugin-i18n/src/shared/detection.ts create mode 100644 tests/integration/i18n/test-utils.ts diff --git a/packages/runtime/plugin-i18n/src/runtime/context.tsx b/packages/runtime/plugin-i18n/src/runtime/context.tsx index bd3ded706f3..87722dde7b5 100644 --- a/packages/runtime/plugin-i18n/src/runtime/context.tsx +++ b/packages/runtime/plugin-i18n/src/runtime/context.tsx @@ -123,6 +123,15 @@ export const useModernI18n = (): UseModernI18nReturn => { // Update i18n instance await i18nInstance.changeLanguage(newLang); + // Ensure detector caches the new language (cookie/localStorage) + // This is important because changeLanguage might not always trigger cache update + if (isBrowser() && i18nInstance.services?.languageDetector) { + const detector = i18nInstance.services.languageDetector; + if (typeof detector.cacheUserLanguage === 'function') { + detector.cacheUserLanguage(newLang); + } + } + // Update URL if locale detection is enabled, we're in browser, and router is available if ( localePathRedirect && @@ -177,7 +186,6 @@ export const useModernI18n = (): UseModernI18nReturn => { i18nInstance, updateLanguage, localePathRedirect, - entryName, languages, hasRouter, navigate, diff --git a/packages/runtime/plugin-i18n/src/runtime/i18n/detection/config.ts b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/config.ts index 2682f79c6cc..5023f1d0db9 100644 --- a/packages/runtime/plugin-i18n/src/runtime/i18n/detection/config.ts +++ b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/config.ts @@ -1,5 +1,4 @@ -import type { RuntimeContext } from '@modern-js/runtime'; -import { deepMerge } from '../../../shared/deepMerge'; +import { deepMerge } from '../../../shared/deepMerge.js'; import type { LanguageDetectorOptions } from '../instance'; export const DEFAULT_I18NEXT_DETECTION_OPTIONS = { diff --git a/packages/runtime/plugin-i18n/src/runtime/i18n/detection/index.ts b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/index.ts new file mode 100644 index 00000000000..ddfec4949d2 --- /dev/null +++ b/packages/runtime/plugin-i18n/src/runtime/i18n/detection/index.ts @@ -0,0 +1,293 @@ +import { type RuntimeContext, isBrowser } from '@modern-js/runtime'; +import { detectLanguageFromPath } from '../../utils'; +import type { I18nInitOptions, I18nInstance } from '../instance'; +import { mergeDetectionOptions as mergeDetectionOptionsUtil } from './config'; +import { detectLanguage } from './middleware'; + +export function exportServerLngToWindow(context: RuntimeContext, lng: string) { + context.__i18nData__ = { lng }; +} + +export const getLanguageFromSSRData = (window: Window): string | undefined => { + const ssrData = window._SSR_DATA; + return ssrData?.data?.i18nData?.lng as string | undefined; +}; + +export interface LanguageDetectionOptions { + languages: string[]; + fallbackLanguage: string; + localePathRedirect: boolean; + i18nextDetector: boolean; + userInitOptions?: I18nInitOptions; + pathname: string; + ssrContext?: any; +} + +export interface LanguageDetectionResult { + detectedLanguage?: string; + finalLanguage: string; +} + +/** + * Check if a language is supported + */ +const isLanguageSupported = ( + language: string | undefined, + supportedLanguages: string[], +): boolean => { + if (!language) { + return false; + } + return ( + supportedLanguages.length === 0 || supportedLanguages.includes(language) + ); +}; + +/** + * Priority 1: Detect language from SSR data + */ +const detectLanguageFromSSR = (languages: string[]): string | undefined => { + if (!isBrowser()) { + return undefined; + } + + try { + const ssrLanguage = getLanguageFromSSRData(window); + if (isLanguageSupported(ssrLanguage, languages)) { + return ssrLanguage; + } + } catch (error) { + // Silently ignore errors + } + + return undefined; +}; + +/** + * Priority 2: Detect language from URL path + */ +const detectLanguageFromPathPriority = ( + pathname: string, + languages: string[], + localePathRedirect: boolean, +): string | undefined => { + if (!localePathRedirect) { + return undefined; + } + + try { + const pathDetection = detectLanguageFromPath( + pathname, + languages, + localePathRedirect, + ); + if (pathDetection.detected && pathDetection.language) { + return pathDetection.language; + } + } catch (error) { + // Silently ignore errors + } + + return undefined; +}; + +/** + * Initialize i18n instance for detector if needed + */ +const initializeI18nForDetector = async ( + i18nInstance: I18nInstance, + options: { + languages: string[]; + fallbackLanguage: string; + localePathRedirect: boolean; + i18nextDetector: boolean; + userInitOptions?: I18nInitOptions; + }, +): Promise => { + if (i18nInstance.isInitialized) { + return; + } + + const { mergedDetection } = mergeDetectionOptions( + options.i18nextDetector, + options.localePathRedirect, + options.userInitOptions, + ); + + // Don't set lng explicitly when detector is enabled, let the detector find the language + // This allows localStorage/cookie to be read properly + // Only set lng if user explicitly provided it, otherwise let detector work + const userLng = options.userInitOptions?.lng; + const { lng: _, ...restUserOptions } = options.userInitOptions || {}; + const initOptions: any = { + ...restUserOptions, + ...(userLng ? { lng: userLng } : {}), + fallbackLng: options.fallbackLanguage, + supportedLngs: options.languages, + detection: mergedDetection, + react: { + ...((options.userInitOptions as any)?.react || {}), + useSuspense: isBrowser() + ? ((options.userInitOptions as any)?.react?.useSuspense ?? true) + : false, + }, + }; + await i18nInstance.init(initOptions); +}; + +/** + * Priority 3: Detect language using i18next detector + */ +const detectLanguageFromI18nextDetector = async ( + i18nInstance: I18nInstance, + options: { + languages: string[]; + fallbackLanguage: string; + localePathRedirect: boolean; + i18nextDetector: boolean; + userInitOptions?: I18nInitOptions; + ssrContext?: any; + }, +): Promise => { + if (!options.i18nextDetector) { + return undefined; + } + + await initializeI18nForDetector(i18nInstance, options); + + try { + const request = options.ssrContext?.request; + // In browser environment, detectLanguage can work without request + // In server environment, request is required + if (!isBrowser() && !request) { + return undefined; + } + + const detectorLang = detectLanguage(i18nInstance, request as any); + + if (detectorLang && isLanguageSupported(detectorLang, options.languages)) { + return detectorLang; + } + + // Fallback to instance's current language if detector didn't detect + if (i18nInstance.isInitialized && i18nInstance.language) { + const currentLang = i18nInstance.language; + if (isLanguageSupported(currentLang, options.languages)) { + return currentLang; + } + } + } catch (error) { + // Silently ignore errors + } + + return undefined; +}; + +/** + * Detect language with priority: SSR data > path > i18next detector > fallback + */ +export const detectLanguageWithPriority = async ( + i18nInstance: I18nInstance, + options: LanguageDetectionOptions, +): Promise => { + const { + languages, + fallbackLanguage, + localePathRedirect, + i18nextDetector, + userInitOptions, + pathname, + ssrContext, + } = options; + + // Priority 1: SSR data + let detectedLanguage = detectLanguageFromSSR(languages); + + // Priority 2: Path detection + if (!detectedLanguage) { + detectedLanguage = detectLanguageFromPathPriority( + pathname, + languages, + localePathRedirect, + ); + } + + // Priority 3: i18next detector + if (!detectedLanguage) { + detectedLanguage = await detectLanguageFromI18nextDetector(i18nInstance, { + languages, + fallbackLanguage, + localePathRedirect, + i18nextDetector, + userInitOptions, + ssrContext, + }); + } + + // Priority 4: Use user config language or fallback + const finalLanguage = + detectedLanguage || userInitOptions?.lng || fallbackLanguage; + + return { detectedLanguage, finalLanguage }; +}; + +/** + * Options for building i18n init options + */ +export interface BuildInitOptionsParams { + finalLanguage: string; + fallbackLanguage: string; + languages: string[]; + userInitOptions?: I18nInitOptions; + mergedDetection?: any; + mergeBackend?: any; +} + +/** + * Build i18n initialization options + */ +export const buildInitOptions = ( + params: BuildInitOptionsParams, +): I18nInitOptions => { + const { + finalLanguage, + fallbackLanguage, + languages, + userInitOptions, + mergedDetection, + mergeBackend, + } = params; + + return { + ...(userInitOptions || {}), + lng: finalLanguage, + fallbackLng: fallbackLanguage, + supportedLngs: languages, + detection: mergedDetection, + backend: mergeBackend, + react: { + useSuspense: isBrowser(), + }, + } as any; +}; + +/** + * Merge detection and backend options + */ +export const mergeDetectionOptions = ( + i18nextDetector: boolean, + localePathRedirect: boolean, + userInitOptions?: I18nInitOptions, +) => { + // Exclude 'path' from detection order to avoid conflict with manual path detection + const mergedDetection = i18nextDetector + ? mergeDetectionOptionsUtil(userInitOptions?.detection) + : userInitOptions?.detection; + if (localePathRedirect && mergedDetection?.order) { + mergedDetection.order = mergedDetection.order.filter( + (item: string) => item !== 'path', + ); + } + + return { mergedDetection }; +}; diff --git a/packages/runtime/plugin-i18n/src/runtime/i18n/instance.ts b/packages/runtime/plugin-i18n/src/runtime/i18n/instance.ts index 0066bcbcfd0..55faba2fac4 100644 --- a/packages/runtime/plugin-i18n/src/runtime/i18n/instance.ts +++ b/packages/runtime/plugin-i18n/src/runtime/i18n/instance.ts @@ -27,6 +27,7 @@ export interface LanguageDetectorOptions { caches?: LanguageDetectorCaches; cookieExpirationDate?: Date; cookieDomain?: string; + lookupHeader?: string; } export type I18nInitOptions = { diff --git a/packages/runtime/plugin-i18n/src/runtime/index.tsx b/packages/runtime/plugin-i18n/src/runtime/index.tsx index 2d74b05fd4f..a3e7248d6cf 100644 --- a/packages/runtime/plugin-i18n/src/runtime/index.tsx +++ b/packages/runtime/plugin-i18n/src/runtime/index.tsx @@ -1,4 +1,5 @@ import { + type RuntimeContext, type RuntimePlugin, isBrowser, useRuntimeContext, @@ -9,8 +10,14 @@ import type { BaseLocaleDetectionOptions } from '../shared/type'; import { ModernI18nProvider } from './context'; import type { I18nInitOptions, I18nInstance } from './i18n'; import { getI18nInstance } from './i18n'; +import { + detectLanguageWithPriority, + exportServerLngToWindow, + mergeDetectionOptions, +} from './i18n/detection'; +import { useI18nextLanguageDetector } from './i18n/detection/middleware'; import { getI18nextProvider, getInitReactI18next } from './i18n/instance'; -import { getEntryPath, getLanguageFromPath } from './utils'; +import { detectLanguageFromPath } from './utils'; export interface I18nPluginOptions { entryName?: string; @@ -20,6 +27,13 @@ export interface I18nPluginOptions { initOptions?: I18nInitOptions; } +const getPathname = (context: RuntimeContext) => { + if (isBrowser()) { + return window.location.pathname; + } + return context.ssrContext?.request?.pathname || '/'; +}; + export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({ name: '@modern-js/plugin-i18n', setup: api => { @@ -30,26 +44,13 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({ localeDetection, } = options; const { - localePathRedirect, + localePathRedirect = false, + i18nextDetector = true, languages = [], fallbackLanguage = 'en', } = localeDetection || {}; - let I18nextProvider: React.FunctionComponent | null; - // Helper function to detect language from path - const detectLanguageFromPath = (pathname: string) => { - if (localePathRedirect) { - const relativePath = pathname.replace(getEntryPath(entryName), ''); - const detectedLang = getLanguageFromPath( - relativePath, - languages, - fallbackLanguage, - ); - // If no language is detected from path, use fallback language - return detectedLang || fallbackLanguage; - } - return fallbackLanguage; - }; + let I18nextProvider: React.FunctionComponent | null; api.onBeforeRender(async context => { let i18nInstance = await getI18nInstance(userI18nInstance); @@ -58,33 +59,55 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({ if (initReactI18next) { i18nInstance.use(initReactI18next); } - // Always detect language from path for consistency between SSR and client - let initialLanguage = fallbackLanguage; - if (localePathRedirect) { - if (isBrowser()) { - // In browser, get from window.location - initialLanguage = detectLanguageFromPath(window.location.pathname); - } else { - // In SSR, get from request context - const pathname = context.ssrContext?.request?.pathname || '/'; - initialLanguage = detectLanguageFromPath(pathname); - } + + const pathname = getPathname(context); + + // Setup i18next language detector if enabled + if (i18nextDetector) { + useI18nextLanguageDetector(i18nInstance); } + + // Detect language with priority: SSR data > path > i18next detector > fallback + const { finalLanguage } = await detectLanguageWithPriority(i18nInstance, { + languages, + fallbackLanguage, + localePathRedirect, + i18nextDetector, + userInitOptions, + pathname, + ssrContext: context.ssrContext, + }); + if (!i18nInstance.isInitialized) { + // Merge detection options if i18nextDetector is enabled + const { mergedDetection } = mergeDetectionOptions( + i18nextDetector, + localePathRedirect, + userInitOptions, + ); + const initOptions: I18nInitOptions = { - lng: initialLanguage, + lng: finalLanguage, fallbackLng: fallbackLanguage, supportedLngs: languages, + detection: mergedDetection, ...(userInitOptions || {}), }; await i18nInstance.init(initOptions); } if (!isBrowser() && i18nInstance.cloneInstance) { i18nInstance = i18nInstance.cloneInstance(); + if (i18nInstance.language !== finalLanguage) { + await i18nInstance.changeLanguage(finalLanguage); + } } - if (localePathRedirect && i18nInstance.language !== initialLanguage) { + if (localePathRedirect && i18nInstance.language !== finalLanguage) { // If instance is already initialized but language doesn't match the path, update it - await i18nInstance.changeLanguage(initialLanguage); + await i18nInstance.changeLanguage(finalLanguage); + } + + if (!isBrowser()) { + exportServerLngToWindow(context, finalLanguage); } context.i18nInstance = i18nInstance; }); @@ -93,31 +116,38 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({ return props => { const runtimeContext = useRuntimeContext(); const i18nInstance = (runtimeContext as any).i18nInstance; - const [lang, setLang] = useState(i18nInstance.language); + const initialLang = + i18nInstance?.language || (localeDetection?.fallbackLanguage ?? 'en'); + const [lang, setLang] = useState(initialLang); - if (!isBrowser) { - (i18nInstance as any).translator.language = i18nInstance.language; + if (i18nInstance?.language && i18nInstance.translator) { + i18nInstance.translator.language = i18nInstance.language; } // Get pathname from appropriate source based on environment - const getCurrentPathname = () => { - if (isBrowser()) { - return window.location.pathname; - } else { - // In SSR, get pathname from request context - return runtimeContext.request?.pathname || '/'; - } - }; // Initialize language from URL on mount (only when localeDetection is enabled) useEffect(() => { if (localePathRedirect) { - const currentPathname = getCurrentPathname(); - const currentLang = detectLanguageFromPath(currentPathname); - if (currentLang !== lang) { - setLang(currentLang); - // Update i18n instance language - i18nInstance.changeLanguage(currentLang); + const currentPathname = getPathname( + runtimeContext as RuntimeContext, + ); + const pathDetection = detectLanguageFromPath( + currentPathname, + languages, + localePathRedirect, + ); + if (pathDetection.detected && pathDetection.language) { + const currentLang = pathDetection.language; + if (currentLang !== lang) { + setLang(currentLang); + i18nInstance.changeLanguage(currentLang); + } + } + } else { + const instanceLang = i18nInstance.language; + if (instanceLang && instanceLang !== lang) { + setLang(instanceLang); } } }, []); diff --git a/packages/runtime/plugin-i18n/src/runtime/utils.ts b/packages/runtime/plugin-i18n/src/runtime/utils.ts index 940d23e4e27..3c0c2699e0e 100644 --- a/packages/runtime/plugin-i18n/src/runtime/utils.ts +++ b/packages/runtime/plugin-i18n/src/runtime/utils.ts @@ -1,5 +1,4 @@ import { getGlobalBasename } from '@modern-js/runtime/context'; -import { MAIN_ENTRY_NAME } from '@modern-js/utils/universal/constants'; export const getEntryPath = (): string => { const basename = getGlobalBasename(); @@ -54,3 +53,26 @@ export const buildLocalizedUrl = ( return `/${segments.join('/')}`; }; + +export const detectLanguageFromPath = ( + pathname: string, + languages: string[], + localePathRedirect: boolean, +): { + detected: boolean; + language?: string; +} => { + if (!localePathRedirect) { + return { detected: false }; + } + + const relativePath = pathname.replace(getEntryPath(), ''); + const segments = relativePath.split('/').filter(Boolean); + const firstSegment = segments[0]; + + if (firstSegment && languages.includes(firstSegment)) { + return { detected: true, language: firstSegment }; + } + + return { detected: false }; +}; diff --git a/packages/runtime/plugin-i18n/src/server/index.ts b/packages/runtime/plugin-i18n/src/server/index.ts index 4ebb92fc7bc..bc4c8d999ab 100644 --- a/packages/runtime/plugin-i18n/src/server/index.ts +++ b/packages/runtime/plugin-i18n/src/server/index.ts @@ -1,4 +1,5 @@ import type { Context, Next, ServerPlugin } from '@modern-js/server-runtime'; +import { detectLanguageFromRequest } from '../shared/detection.js'; import type { LocaleDetectionOptions } from '../shared/type'; import { getLocaleDetectionOptions } from '../shared/utils.js'; @@ -80,8 +81,10 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({ } const { localePathRedirect, + i18nextDetector = true, languages = [], fallbackLanguage = 'en', + detection, } = getLocaleDetectionOptions(entryName, options.localeDetection); const originUrlPath = route.urlPath; const urlPath = originUrlPath.endsWith('/') @@ -94,10 +97,28 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({ handler: async (c: Context, next: Next) => { const language = getLanguageFromPath(c.req, urlPath, languages); if (!language) { + // Try to detect language from request using the same detection config as client + let detectedLanguage: string | null = null; + if (i18nextDetector) { + // Create a compatible headers object + const headers = { + get: (name: string) => c.req.header(name) || null, + }; + detectedLanguage = detectLanguageFromRequest( + { + url: c.req.url, + headers, + }, + languages, + detection, + ); + } + // Use detected language or fallback to fallbackLanguage + const targetLanguage = detectedLanguage || fallbackLanguage; const localizedUrl = buildLocalizedUrl( c.req, originUrlPath, - fallbackLanguage, + targetLanguage, languages, ); return c.redirect(localizedUrl); diff --git a/packages/runtime/plugin-i18n/src/shared/detection.ts b/packages/runtime/plugin-i18n/src/shared/detection.ts new file mode 100644 index 00000000000..9eaca03535f --- /dev/null +++ b/packages/runtime/plugin-i18n/src/shared/detection.ts @@ -0,0 +1,131 @@ +import { + DEFAULT_I18NEXT_DETECTION_OPTIONS, + mergeDetectionOptions, +} from '../runtime/i18n/detection/config.js'; +import type { LanguageDetectorOptions } from '../runtime/i18n/instance'; + +/** + * Detect language from request using the same detection logic as i18next + * This ensures consistency between server-side and client-side detection + */ +export function detectLanguageFromRequest( + req: { + url: string; + headers: + | { + get: (name: string) => string | null; + } + | Headers; + }, + languages: string[], + detectionOptions?: LanguageDetectorOptions, +): string | null { + try { + // Merge user detection options with defaults + const mergedDetection = detectionOptions + ? mergeDetectionOptions(detectionOptions) + : DEFAULT_I18NEXT_DETECTION_OPTIONS; + + // Get detection order, excluding 'path' and browser-only detectors + const order = (mergedDetection.order || []).filter( + (item: string) => + !['path', 'localStorage', 'navigator', 'htmlTag', 'subdomain'].includes( + item, + ), + ); + + // If no order specified, use default server-side order + const detectionOrder = + order.length > 0 ? order : ['querystring', 'cookie', 'header']; + + // Helper to get header value + const getHeader = (name: string): string | null => { + if (req.headers instanceof Headers) { + return req.headers.get(name); + } + return req.headers.get(name); + }; + + // Try each detection method in order + for (const method of detectionOrder) { + let detectedLang: string | null = null; + + switch (method) { + case 'querystring': { + const lookupKey = + mergedDetection.lookupQuerystring || + DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupQuerystring || + 'lng'; + const host = getHeader('host') || 'localhost'; + const url = new URL(req.url, `http://${host}`); + detectedLang = url.searchParams.get(lookupKey); + break; + } + case 'cookie': { + const lookupKey = + mergedDetection.lookupCookie || + DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupCookie || + 'i18next'; + const cookieHeader = getHeader('Cookie'); + if (cookieHeader) { + const cookies = cookieHeader + .split(';') + .reduce((acc: Record, item: string) => { + const [key, value] = item.trim().split('='); + if (key && value) { + acc[key] = value; + } + return acc; + }, {}); + detectedLang = cookies[lookupKey] || null; + } + break; + } + case 'header': { + const lookupKey = + mergedDetection.lookupHeader || + DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupHeader || + 'accept-language'; + const acceptLanguage = getHeader(lookupKey); + if (acceptLanguage) { + // Parse Accept-Language header: "en-US,en;q=0.9,zh-CN;q=0.8,zh;q=0.7" + const languagesList = acceptLanguage + .split(',') + .map((lang: string) => { + const [code, q] = lang.trim().split(';'); + return { + code: code.split('-')[0], // Extract base language code + quality: q ? parseFloat(q.split('=')[1]) : 1.0, + }; + }) + .sort( + (a: { quality: number }, b: { quality: number }) => + b.quality - a.quality, + ); + + // Find first matching language + for (const lang of languagesList) { + if (languages.length === 0 || languages.includes(lang.code)) { + detectedLang = lang.code; + break; + } + } + } + break; + } + } + + // If detected and valid, return it + if ( + detectedLang && + (languages.length === 0 || languages.includes(detectedLang)) + ) { + return detectedLang; + } + } + } catch (error) { + // Silently ignore errors + } + + return null; +} diff --git a/packages/runtime/plugin-i18n/src/shared/type.ts b/packages/runtime/plugin-i18n/src/shared/type.ts index 2bee4dcdac8..31a3178c277 100644 --- a/packages/runtime/plugin-i18n/src/shared/type.ts +++ b/packages/runtime/plugin-i18n/src/shared/type.ts @@ -1,8 +1,11 @@ +import type { LanguageDetectorOptions } from '../runtime/i18n/instance'; + export interface BaseLocaleDetectionOptions { localePathRedirect?: boolean; i18nextDetector?: boolean; languages?: string[]; fallbackLanguage?: string; + detection?: LanguageDetectorOptions; } export interface LocaleDetectionOptions extends BaseLocaleDetectionOptions { diff --git a/tests/integration/i18n/app-csr/src/modern.runtime.tsx b/tests/integration/i18n/app-csr/src/modern.runtime.tsx index 691974c841f..5e794c5801a 100644 --- a/tests/integration/i18n/app-csr/src/modern.runtime.tsx +++ b/tests/integration/i18n/app-csr/src/modern.runtime.tsx @@ -5,7 +5,6 @@ export default defineRuntimeConfig({ i18n: { i18nInstance: i18next, initOptions: { - lng: 'en', fallbackLng: 'en', supportedLngs: ['zh', 'en'], resources: { diff --git a/tests/integration/i18n/app-csr/tests/index.test.ts b/tests/integration/i18n/app-csr/tests/index.test.ts index 3194bdb4cdb..df5b6568510 100644 --- a/tests/integration/i18n/app-csr/tests/index.test.ts +++ b/tests/integration/i18n/app-csr/tests/index.test.ts @@ -6,6 +6,7 @@ import { launchApp, launchOptions, } from '../../../../utils/modernTestUtils'; +import { clearI18nTestState } from '../../test-utils'; const projectDir = path.resolve(__dirname, '..'); @@ -21,6 +22,14 @@ describe('app-csr-i18n', () => { browser = await puppeteer.launch(launchOptions as any); page = await browser.newPage(); + // Set default Accept-Language to English to avoid unexpected redirects + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'en-US,en;q=0.9', + }); + }); + + beforeEach(async () => { + await clearI18nTestState(page); }); afterAll(async () => { if (browser) { @@ -32,6 +41,13 @@ describe('app-csr-i18n', () => { }); test('main-index', async () => { + // Set cookie to en to ensure consistent language detection + await page.setCookie({ + name: 'i18next', + value: 'en', + domain: 'localhost', + path: '/', + }); await page.goto(`http://localhost:${appPort}`, { waitUntil: ['networkidle0'], }); @@ -44,6 +60,13 @@ describe('app-csr-i18n', () => { expect(targetTextZh?.trim()).toEqual('你好,世界'); }); test('main-about', async () => { + // Set cookie to en to ensure consistent language detection + await page.setCookie({ + name: 'i18next', + value: 'en', + domain: 'localhost', + path: '/', + }); await page.goto(`http://localhost:${appPort}/about`, { waitUntil: ['networkidle0'], }); @@ -62,6 +85,13 @@ describe('app-csr-i18n', () => { expect(targetTextAboutZh?.trim()).toEqual('关于'); }); test('lang-redirect-to-en', async () => { + // Set cookie to en to ensure consistent language detection + await page.setCookie({ + name: 'i18next', + value: 'en', + domain: 'localhost', + path: '/', + }); await page.goto(`http://localhost:${appPort}/lang`, { waitUntil: ['networkidle0'], }); @@ -79,6 +109,77 @@ describe('app-csr-i18n', () => { }); expect(page.url()).toBe(`http://localhost:${appPort}/lang/en/about`); }); + + test('lang-redirect-based-on-cookie', async () => { + // Set cookie to zh before navigation + await page.setCookie({ + name: 'i18next', + value: 'zh', + domain: 'localhost', + path: '/', + }); + await page.goto(`http://localhost:${appPort}/lang`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /lang/zh based on cookie + expect(page.url()).toBe(`http://localhost:${appPort}/lang/zh`); + + // Change cookie to en + await page.setCookie({ + name: 'i18next', + value: 'en', + domain: 'localhost', + path: '/', + }); + await page.goto(`http://localhost:${appPort}/lang`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /lang/en based on cookie + expect(page.url()).toBe(`http://localhost:${appPort}/lang/en`); + }); + + test('lang-redirect-based-on-header', async () => { + // Set Accept-Language header to zh + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'zh-CN,zh;q=0.9', + }); + await page.goto(`http://localhost:${appPort}/lang`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /lang/zh based on Accept-Language header + expect(page.url()).toBe(`http://localhost:${appPort}/lang/zh`); + + // Clear localStorage and cookies before changing header + await clearI18nTestState(page); + + // Change header to en + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'en-US,en;q=0.9', + }); + await page.goto(`http://localhost:${appPort}/lang`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /lang/en based on Accept-Language header + expect(page.url()).toBe(`http://localhost:${appPort}/lang/en`); + }); + + test('lang-redirect-priority-cookie-over-header', async () => { + // Set both cookie and header, cookie should have higher priority + await page.setCookie({ + name: 'i18next', + value: 'zh', + domain: 'localhost', + path: '/', + }); + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'en-US,en;q=0.9', + }); + await page.goto(`http://localhost:${appPort}/lang`, { + waitUntil: ['networkidle0'], + }); + // Cookie should take priority over header + expect(page.url()).toBe(`http://localhost:${appPort}/lang/zh`); + }); test('lang-zh', async () => { await page.goto(`http://localhost:${appPort}/lang/zh`, { waitUntil: ['networkidle0'], diff --git a/tests/integration/i18n/app-ssr/tests/index.test.ts b/tests/integration/i18n/app-ssr/tests/index.test.ts index b3c0b4853a3..b6d9c3142a4 100644 --- a/tests/integration/i18n/app-ssr/tests/index.test.ts +++ b/tests/integration/i18n/app-ssr/tests/index.test.ts @@ -6,6 +6,7 @@ import { launchApp, launchOptions, } from '../../../../utils/modernTestUtils'; +import { clearI18nTestState } from '../../test-utils'; const projectDir = path.resolve(__dirname, '..'); @@ -21,6 +22,14 @@ describe('app-ssr-i18n', () => { browser = await puppeteer.launch(launchOptions as any); page = await browser.newPage(); + // Set default Accept-Language to English to avoid unexpected redirects + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'en-US,en;q=0.9', + }); + }); + + beforeEach(async () => { + await clearI18nTestState(page); }); afterAll(async () => { if (browser) { @@ -32,6 +41,13 @@ describe('app-ssr-i18n', () => { }); test('redirect-to-en', async () => { + // Set cookie to en to ensure consistent language detection + await page.setCookie({ + name: 'i18next', + value: 'en', + domain: 'localhost', + path: '/', + }); await page.goto(`http://localhost:${appPort}`, { waitUntil: ['networkidle0'], }); @@ -41,6 +57,77 @@ describe('app-ssr-i18n', () => { }); expect(page.url()).toBe(`http://localhost:${appPort}/en`); }); + + test('redirect-based-on-cookie', async () => { + // Set cookie to zh before navigation + await page.setCookie({ + name: 'i18next', + value: 'zh', + domain: 'localhost', + path: '/', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /zh based on cookie + expect(page.url()).toBe(`http://localhost:${appPort}/zh`); + + // Change cookie to en + await page.setCookie({ + name: 'i18next', + value: 'en', + domain: 'localhost', + path: '/', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /en based on cookie + expect(page.url()).toBe(`http://localhost:${appPort}/en`); + }); + + test('redirect-based-on-header', async () => { + // Set Accept-Language header to zh + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'zh-CN,zh;q=0.9', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /zh based on Accept-Language header + expect(page.url()).toBe(`http://localhost:${appPort}/zh`); + + // Clear localStorage and cookies before changing header + await clearI18nTestState(page); + + // Change header to en + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'en-US,en;q=0.9', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /en based on Accept-Language header + expect(page.url()).toBe(`http://localhost:${appPort}/en`); + }); + + test('redirect-priority-cookie-over-header', async () => { + // Set both cookie and header, cookie should have higher priority + await page.setCookie({ + name: 'i18next', + value: 'zh', + domain: 'localhost', + path: '/', + }); + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'en-US,en;q=0.9', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Cookie should take priority over header + expect(page.url()).toBe(`http://localhost:${appPort}/zh`); + }); test('page-zh', async () => { const response = await page.goto(`http://localhost:${appPort}/zh`, { waitUntil: ['networkidle0'], diff --git a/tests/integration/i18n/routes-csr/test/index.test.ts b/tests/integration/i18n/routes-csr/test/index.test.ts index 7034e122137..12b56762ebf 100644 --- a/tests/integration/i18n/routes-csr/test/index.test.ts +++ b/tests/integration/i18n/routes-csr/test/index.test.ts @@ -6,6 +6,7 @@ import { launchApp, launchOptions, } from '../../../../utils/modernTestUtils'; +import { clearI18nTestState } from '../../test-utils'; const projectDir = path.resolve(__dirname, '..'); @@ -21,6 +22,14 @@ describe('router-csr-i18n', () => { browser = await puppeteer.launch(launchOptions as any); page = await browser.newPage(); + // Set default Accept-Language to English to avoid unexpected redirects + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'en-US,en;q=0.9', + }); + }); + + beforeEach(async () => { + await clearI18nTestState(page); }); afterAll(async () => { if (browser) { @@ -32,6 +41,13 @@ describe('router-csr-i18n', () => { }); test('redirect-to-en', async () => { + // Set cookie to en to ensure consistent language detection + await page.setCookie({ + name: 'i18next', + value: 'en', + domain: 'localhost', + path: '/', + }); await page.goto(`http://localhost:${appPort}`, { waitUntil: ['networkidle0'], }); @@ -49,6 +65,77 @@ describe('router-csr-i18n', () => { }); expect(page.url()).toBe(`http://localhost:${appPort}/en/about`); }); + + test('redirect-based-on-cookie', async () => { + // Set cookie to zh before navigation + await page.setCookie({ + name: 'i18next', + value: 'zh', + domain: 'localhost', + path: '/', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /zh based on cookie + expect(page.url()).toBe(`http://localhost:${appPort}/zh`); + + // Change cookie to en + await page.setCookie({ + name: 'i18next', + value: 'en', + domain: 'localhost', + path: '/', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /en based on cookie + expect(page.url()).toBe(`http://localhost:${appPort}/en`); + }); + + test('redirect-based-on-header', async () => { + // Set Accept-Language header to zh + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'zh-CN,zh;q=0.9', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /zh based on Accept-Language header + expect(page.url()).toBe(`http://localhost:${appPort}/zh`); + + // Clear localStorage and cookies before changing header + await clearI18nTestState(page); + + // Change header to en + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'en-US,en;q=0.9', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /en based on Accept-Language header + expect(page.url()).toBe(`http://localhost:${appPort}/en`); + }); + + test('redirect-priority-cookie-over-header', async () => { + // Set both cookie and header, cookie should have higher priority + await page.setCookie({ + name: 'i18next', + value: 'zh', + domain: 'localhost', + path: '/', + }); + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'en-US,en;q=0.9', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Cookie should take priority over header + expect(page.url()).toBe(`http://localhost:${appPort}/zh`); + }); test('page-zh', async () => { await page.goto(`http://localhost:${appPort}/zh`, { waitUntil: ['networkidle0'], diff --git a/tests/integration/i18n/routes-ssr/test/index.test.ts b/tests/integration/i18n/routes-ssr/test/index.test.ts index 5b5338f56a5..a5d87451a54 100644 --- a/tests/integration/i18n/routes-ssr/test/index.test.ts +++ b/tests/integration/i18n/routes-ssr/test/index.test.ts @@ -21,6 +21,32 @@ describe('router-ssr-i18n', () => { browser = await puppeteer.launch(launchOptions as any); page = await browser.newPage(); + // Set default Accept-Language to English to avoid unexpected redirects + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'en-US,en;q=0.9', + }); + }); + + beforeEach(async () => { + // Clear cookies before each test to ensure clean state + const cookies = await page.cookies(); + for (const cookie of cookies) { + await page.deleteCookie(cookie); + } + // Clear localStorage to ensure clean state (only if page is loaded) + try { + await page.evaluate(() => { + if (typeof localStorage !== 'undefined') { + localStorage.clear(); + } + }); + } catch (error) { + // Ignore SecurityError if page is not loaded yet + } + // Reset header to English to ensure clean state + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'en-US,en;q=0.9', + }); }); afterAll(async () => { if (browser) { @@ -32,6 +58,13 @@ describe('router-ssr-i18n', () => { }); test('redirect-to-en', async () => { + // Set cookie to en to ensure consistent language detection + await page.setCookie({ + name: 'i18next', + value: 'en', + domain: 'localhost', + path: '/', + }); await page.goto(`http://localhost:${appPort}`, { waitUntil: ['networkidle0'], }); @@ -49,6 +82,89 @@ describe('router-ssr-i18n', () => { }); expect(page.url()).toBe(`http://localhost:${appPort}/en/about`); }); + + test('redirect-based-on-cookie', async () => { + // Set cookie to zh before navigation + await page.setCookie({ + name: 'i18next', + value: 'zh', + domain: 'localhost', + path: '/', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /zh based on cookie + expect(page.url()).toBe(`http://localhost:${appPort}/zh`); + + // Change cookie to en + await page.setCookie({ + name: 'i18next', + value: 'en', + domain: 'localhost', + path: '/', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /en based on cookie + expect(page.url()).toBe(`http://localhost:${appPort}/en`); + }); + + test('redirect-based-on-header', async () => { + // Set Accept-Language header to zh + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'zh-CN,zh;q=0.9', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /zh based on Accept-Language header + expect(page.url()).toBe(`http://localhost:${appPort}/zh`); + + // Clear localStorage and cookies before changing header + try { + await page.evaluate(() => { + if (typeof localStorage !== 'undefined') { + localStorage.clear(); + } + }); + } catch (error) { + // Ignore SecurityError if page is not loaded yet + } + const cookies = await page.cookies(); + for (const cookie of cookies) { + await page.deleteCookie(cookie); + } + + // Change header to en + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'en-US,en;q=0.9', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Should redirect to /en based on Accept-Language header + expect(page.url()).toBe(`http://localhost:${appPort}/en`); + }); + + test('redirect-priority-cookie-over-header', async () => { + // Set both cookie and header, cookie should have higher priority + await page.setCookie({ + name: 'i18next', + value: 'zh', + domain: 'localhost', + path: '/', + }); + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'en-US,en;q=0.9', + }); + await page.goto(`http://localhost:${appPort}`, { + waitUntil: ['networkidle0'], + }); + // Cookie should take priority over header + expect(page.url()).toBe(`http://localhost:${appPort}/zh`); + }); test('page-zh', async () => { const response = await page.goto(`http://localhost:${appPort}/zh`, { waitUntil: ['networkidle0'], diff --git a/tests/integration/i18n/test-utils.ts b/tests/integration/i18n/test-utils.ts new file mode 100644 index 00000000000..20a9f71d760 --- /dev/null +++ b/tests/integration/i18n/test-utils.ts @@ -0,0 +1,29 @@ +import type { Page } from 'puppeteer'; + +/** + * Clear all cookies, localStorage, and reset Accept-Language header + * This ensures a clean state before each test + */ +export async function clearI18nTestState(page: Page): Promise { + // Clear cookies before each test to ensure clean state + const cookies = await page.cookies(); + for (const cookie of cookies) { + await page.deleteCookie(cookie); + } + + // Clear localStorage to ensure clean state (only if page is loaded) + try { + await page.evaluate(() => { + if (typeof localStorage !== 'undefined') { + localStorage.clear(); + } + }); + } catch (error) { + // Ignore SecurityError if page is not loaded yet + } + + // Reset header to English to ensure clean state + await page.setExtraHTTPHeaders({ + 'Accept-Language': 'en-US,en;q=0.9', + }); +} From 1f55dca8e03a9274cb32a932c8b2bdf5fc0d9513 Mon Sep 17 00:00:00 2001 From: caohuilin Date: Wed, 12 Nov 2025 11:35:30 +0800 Subject: [PATCH 7/8] feat: use hono language detect for server --- packages/runtime/plugin-i18n/package.json | 1 + .../runtime/plugin-i18n/src/server/index.ts | 102 +++++++++++++++--- packages/server/core/src/hono.ts | 2 + pnpm-lock.yaml | 3 + .../integration/i18n/app-ssr/modern.config.ts | 1 + .../i18n/app-ssr/tests/index.test.ts | 87 --------------- 6 files changed, 95 insertions(+), 101 deletions(-) diff --git a/packages/runtime/plugin-i18n/package.json b/packages/runtime/plugin-i18n/package.json index fbe419091b0..25b7d203a01 100644 --- a/packages/runtime/plugin-i18n/package.json +++ b/packages/runtime/plugin-i18n/package.json @@ -80,6 +80,7 @@ }, "dependencies": { "@modern-js/plugin": "workspace:*", + "@modern-js/server-core": "workspace:*", "@modern-js/server-runtime": "workspace:*", "@modern-js/types": "workspace:*", "@modern-js/utils": "workspace:*", diff --git a/packages/runtime/plugin-i18n/src/server/index.ts b/packages/runtime/plugin-i18n/src/server/index.ts index bc4c8d999ab..c64844384ee 100644 --- a/packages/runtime/plugin-i18n/src/server/index.ts +++ b/packages/runtime/plugin-i18n/src/server/index.ts @@ -1,5 +1,10 @@ +import { languageDetector } from '@modern-js/server-core/hono'; import type { Context, Next, ServerPlugin } from '@modern-js/server-runtime'; -import { detectLanguageFromRequest } from '../shared/detection.js'; +import { + DEFAULT_I18NEXT_DETECTION_OPTIONS, + mergeDetectionOptions, +} from '../runtime/i18n/detection/config.js'; +import type { LanguageDetectorOptions } from '../runtime/i18n/instance'; import type { LocaleDetectionOptions } from '../shared/type'; import { getLocaleDetectionOptions } from '../shared/utils.js'; @@ -7,6 +12,71 @@ export interface I18nPluginOptions { localeDetection: LocaleDetectionOptions; } +/** + * Convert i18next detection options to hono languageDetector options + */ +const convertToHonoLanguageDetectorOptions = ( + languages: string[], + fallbackLanguage: string, + detectionOptions?: LanguageDetectorOptions, +) => { + // Merge user detection options with defaults + const mergedDetection = detectionOptions + ? mergeDetectionOptions(detectionOptions) + : DEFAULT_I18NEXT_DETECTION_OPTIONS; + + // Get detection order, excluding 'path' and browser-only detectors + const order = (mergedDetection.order || []).filter( + (item: string) => + !['path', 'localStorage', 'navigator', 'htmlTag', 'subdomain'].includes( + item, + ), + ); + + // If no order specified, use default server-side order + const detectionOrder = + order.length > 0 ? order : ['querystring', 'cookie', 'header']; + + // Map i18next order to hono order + const honoOrder = detectionOrder.map(item => { + // Map 'querystring' to 'querystring', 'cookie' to 'cookie', 'header' to 'header' + if (item === 'querystring') return 'querystring'; + if (item === 'cookie') return 'cookie'; + if (item === 'header') return 'header'; + return item; + }) as ('querystring' | 'cookie' | 'header' | 'path')[]; + + // Determine caches option + // hono languageDetector expects: false | "cookie"[] | undefined + const caches: false | ['cookie'] | undefined = + mergedDetection.caches === false + ? false + : Array.isArray(mergedDetection.caches) && + !mergedDetection.caches.includes('cookie') + ? false + : (['cookie'] as ['cookie']); + + return { + supportedLanguages: languages.length > 0 ? languages : [fallbackLanguage], + fallbackLanguage, + order: honoOrder, + lookupQueryString: + mergedDetection.lookupQuerystring || + DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupQuerystring || + 'lng', + lookupCookie: + mergedDetection.lookupCookie || + DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupCookie || + 'i18next', + lookupFromHeaderKey: + mergedDetection.lookupHeader || + DEFAULT_I18NEXT_DETECTION_OPTIONS.lookupHeader || + 'accept-language', + ...(caches !== undefined && { caches }), + ignoreCase: true, + }; +}; + const getLanguageFromPath = ( req: any, urlPath: string, @@ -86,32 +156,36 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({ fallbackLanguage = 'en', detection, } = getLocaleDetectionOptions(entryName, options.localeDetection); + console.log('====i18nextDetector', i18nextDetector); const originUrlPath = route.urlPath; const urlPath = originUrlPath.endsWith('/') ? `${originUrlPath}*` : `${originUrlPath}/*`; if (localePathRedirect) { + // Add languageDetector middleware before the redirect handler + if (i18nextDetector) { + const detectorOptions = convertToHonoLanguageDetectorOptions( + languages, + fallbackLanguage, + detection, + ); + middlewares.push({ + name: 'i18n-language-detector', + path: urlPath, + handler: languageDetector(detectorOptions), + }); + } + middlewares.push({ name: 'i18n-server-middleware', path: urlPath, handler: async (c: Context, next: Next) => { const language = getLanguageFromPath(c.req, urlPath, languages); if (!language) { - // Try to detect language from request using the same detection config as client + // Get detected language from languageDetector middleware let detectedLanguage: string | null = null; if (i18nextDetector) { - // Create a compatible headers object - const headers = { - get: (name: string) => c.req.header(name) || null, - }; - detectedLanguage = detectLanguageFromRequest( - { - url: c.req.url, - headers, - }, - languages, - detection, - ); + detectedLanguage = c.get('language') || null; } // Use detected language or fallback to fallbackLanguage const targetLanguage = detectedLanguage || fallbackLanguage; diff --git a/packages/server/core/src/hono.ts b/packages/server/core/src/hono.ts index 69db601412c..ca6c72e52cd 100644 --- a/packages/server/core/src/hono.ts +++ b/packages/server/core/src/hono.ts @@ -15,3 +15,5 @@ export { getCookie, deleteCookie, } from 'hono/cookie'; + +export { languageDetector } from 'hono/language'; diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 1a8668064f1..efdbd971180 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -1200,6 +1200,9 @@ importers: '@modern-js/plugin': specifier: workspace:* version: link:../../toolkit/plugin + '@modern-js/server-core': + specifier: workspace:* + version: link:../../server/core '@modern-js/server-runtime': specifier: workspace:* version: link:../../server/server-runtime diff --git a/tests/integration/i18n/app-ssr/modern.config.ts b/tests/integration/i18n/app-ssr/modern.config.ts index 1e59f2fd976..e91cd1e6efc 100644 --- a/tests/integration/i18n/app-ssr/modern.config.ts +++ b/tests/integration/i18n/app-ssr/modern.config.ts @@ -10,6 +10,7 @@ export default defineConfig({ i18nPlugin({ localeDetection: { localePathRedirect: true, + i18nextDetector: false, languages: ['zh', 'en'], fallbackLanguage: 'en', }, diff --git a/tests/integration/i18n/app-ssr/tests/index.test.ts b/tests/integration/i18n/app-ssr/tests/index.test.ts index b6d9c3142a4..b3c0b4853a3 100644 --- a/tests/integration/i18n/app-ssr/tests/index.test.ts +++ b/tests/integration/i18n/app-ssr/tests/index.test.ts @@ -6,7 +6,6 @@ import { launchApp, launchOptions, } from '../../../../utils/modernTestUtils'; -import { clearI18nTestState } from '../../test-utils'; const projectDir = path.resolve(__dirname, '..'); @@ -22,14 +21,6 @@ describe('app-ssr-i18n', () => { browser = await puppeteer.launch(launchOptions as any); page = await browser.newPage(); - // Set default Accept-Language to English to avoid unexpected redirects - await page.setExtraHTTPHeaders({ - 'Accept-Language': 'en-US,en;q=0.9', - }); - }); - - beforeEach(async () => { - await clearI18nTestState(page); }); afterAll(async () => { if (browser) { @@ -41,13 +32,6 @@ describe('app-ssr-i18n', () => { }); test('redirect-to-en', async () => { - // Set cookie to en to ensure consistent language detection - await page.setCookie({ - name: 'i18next', - value: 'en', - domain: 'localhost', - path: '/', - }); await page.goto(`http://localhost:${appPort}`, { waitUntil: ['networkidle0'], }); @@ -57,77 +41,6 @@ describe('app-ssr-i18n', () => { }); expect(page.url()).toBe(`http://localhost:${appPort}/en`); }); - - test('redirect-based-on-cookie', async () => { - // Set cookie to zh before navigation - await page.setCookie({ - name: 'i18next', - value: 'zh', - domain: 'localhost', - path: '/', - }); - await page.goto(`http://localhost:${appPort}`, { - waitUntil: ['networkidle0'], - }); - // Should redirect to /zh based on cookie - expect(page.url()).toBe(`http://localhost:${appPort}/zh`); - - // Change cookie to en - await page.setCookie({ - name: 'i18next', - value: 'en', - domain: 'localhost', - path: '/', - }); - await page.goto(`http://localhost:${appPort}`, { - waitUntil: ['networkidle0'], - }); - // Should redirect to /en based on cookie - expect(page.url()).toBe(`http://localhost:${appPort}/en`); - }); - - test('redirect-based-on-header', async () => { - // Set Accept-Language header to zh - await page.setExtraHTTPHeaders({ - 'Accept-Language': 'zh-CN,zh;q=0.9', - }); - await page.goto(`http://localhost:${appPort}`, { - waitUntil: ['networkidle0'], - }); - // Should redirect to /zh based on Accept-Language header - expect(page.url()).toBe(`http://localhost:${appPort}/zh`); - - // Clear localStorage and cookies before changing header - await clearI18nTestState(page); - - // Change header to en - await page.setExtraHTTPHeaders({ - 'Accept-Language': 'en-US,en;q=0.9', - }); - await page.goto(`http://localhost:${appPort}`, { - waitUntil: ['networkidle0'], - }); - // Should redirect to /en based on Accept-Language header - expect(page.url()).toBe(`http://localhost:${appPort}/en`); - }); - - test('redirect-priority-cookie-over-header', async () => { - // Set both cookie and header, cookie should have higher priority - await page.setCookie({ - name: 'i18next', - value: 'zh', - domain: 'localhost', - path: '/', - }); - await page.setExtraHTTPHeaders({ - 'Accept-Language': 'en-US,en;q=0.9', - }); - await page.goto(`http://localhost:${appPort}`, { - waitUntil: ['networkidle0'], - }); - // Cookie should take priority over header - expect(page.url()).toBe(`http://localhost:${appPort}/zh`); - }); test('page-zh', async () => { const response = await page.goto(`http://localhost:${appPort}/zh`, { waitUntil: ['networkidle0'], From 9b02af6a4b5915437e25839d36f804cbe60e2c50 Mon Sep 17 00:00:00 2001 From: caohuilin Date: Wed, 12 Nov 2025 11:42:49 +0800 Subject: [PATCH 8/8] feat: remove console --- packages/runtime/plugin-i18n/src/server/index.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/packages/runtime/plugin-i18n/src/server/index.ts b/packages/runtime/plugin-i18n/src/server/index.ts index c64844384ee..5b4ad279dd5 100644 --- a/packages/runtime/plugin-i18n/src/server/index.ts +++ b/packages/runtime/plugin-i18n/src/server/index.ts @@ -156,7 +156,6 @@ export const i18nServerPlugin = (options: I18nPluginOptions): ServerPlugin => ({ fallbackLanguage = 'en', detection, } = getLocaleDetectionOptions(entryName, options.localeDetection); - console.log('====i18nextDetector', i18nextDetector); const originUrlPath = route.urlPath; const urlPath = originUrlPath.endsWith('/') ? `${originUrlPath}*`