Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion packages/runtime/plugin-i18n/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,13 @@
},
"dependencies": {
"@modern-js/plugin": "workspace:*",
"@modern-js/server-core": "workspace:*",
"@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",
Expand Down
6 changes: 2 additions & 4 deletions packages/runtime/plugin-i18n/src/cli/index.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand Down
14 changes: 11 additions & 3 deletions packages/runtime/plugin-i18n/src/runtime/context.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 &&
Expand All @@ -132,7 +141,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
Expand All @@ -148,7 +157,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
Expand Down Expand Up @@ -177,7 +186,6 @@ export const useModernI18n = (): UseModernI18nReturn => {
i18nInstance,
updateLanguage,
localePathRedirect,
entryName,
languages,
hasRouter,
navigate,
Expand Down
28 changes: 28 additions & 0 deletions packages/runtime/plugin-i18n/src/runtime/i18n/detection/config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import { deepMerge } from '../../../shared/deepMerge.js';
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);
}
293 changes: 293 additions & 0 deletions packages/runtime/plugin-i18n/src/runtime/i18n/detection/index.ts
Original file line number Diff line number Diff line change
@@ -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<void> => {
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<string | undefined> => {
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<LanguageDetectionResult> => {
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 };
};
Loading