Skip to content

Commit cc1f202

Browse files
authored
feat(plugin-i18n): language detection (#7829)
1 parent 4ca5be3 commit cc1f202

File tree

24 files changed

+1209
-117
lines changed

24 files changed

+1209
-117
lines changed

packages/runtime/plugin-i18n/package.json

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -80,10 +80,13 @@
8080
},
8181
"dependencies": {
8282
"@modern-js/plugin": "workspace:*",
83+
"@modern-js/server-core": "workspace:*",
8384
"@modern-js/server-runtime": "workspace:*",
8485
"@modern-js/types": "workspace:*",
8586
"@modern-js/utils": "workspace:*",
86-
"@swc/helpers": "^0.5.17"
87+
"@swc/helpers": "^0.5.17",
88+
"i18next-browser-languagedetector": "^8.2.0",
89+
"i18next-http-middleware": "^3.8.1"
8790
},
8891
"peerDependencies": {
8992
"react": ">=17",

packages/runtime/plugin-i18n/src/cli/index.ts

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,6 @@
11
import type { AppTools, CliPlugin } from '@modern-js/app-tools';
2-
import {
3-
type LocaleDetectionOptions,
4-
getLocaleDetectionOptions,
5-
} from '../utils/config';
2+
import type { LocaleDetectionOptions } from '../shared/type';
3+
import { getLocaleDetectionOptions } from '../shared/utils';
64

75
export interface I18nPluginOptions {
86
localeDetection?: LocaleDetectionOptions;

packages/runtime/plugin-i18n/src/runtime/context.tsx

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,15 @@ export const useModernI18n = (): UseModernI18nReturn => {
123123
// Update i18n instance
124124
await i18nInstance.changeLanguage(newLang);
125125

126+
// Ensure detector caches the new language (cookie/localStorage)
127+
// This is important because changeLanguage might not always trigger cache update
128+
if (isBrowser() && i18nInstance.services?.languageDetector) {
129+
const detector = i18nInstance.services.languageDetector;
130+
if (typeof detector.cacheUserLanguage === 'function') {
131+
detector.cacheUserLanguage(newLang);
132+
}
133+
}
134+
126135
// Update URL if locale detection is enabled, we're in browser, and router is available
127136
if (
128137
localePathRedirect &&
@@ -132,7 +141,7 @@ export const useModernI18n = (): UseModernI18nReturn => {
132141
location
133142
) {
134143
const currentPath = location.pathname;
135-
const entryPath = getEntryPath(entryName);
144+
const entryPath = getEntryPath();
136145
const relativePath = currentPath.replace(entryPath, '');
137146

138147
// Build new path with updated language
@@ -148,7 +157,7 @@ export const useModernI18n = (): UseModernI18nReturn => {
148157
} else if (localePathRedirect && isBrowser() && !hasRouter) {
149158
// Fallback: use window.history API when router is not available
150159
const currentPath = window.location.pathname;
151-
const entryPath = getEntryPath(entryName);
160+
const entryPath = getEntryPath();
152161
const relativePath = currentPath.replace(entryPath, '');
153162

154163
// Build new path with updated language
@@ -177,7 +186,6 @@ export const useModernI18n = (): UseModernI18nReturn => {
177186
i18nInstance,
178187
updateLanguage,
179188
localePathRedirect,
180-
entryName,
181189
languages,
182190
hasRouter,
183191
navigate,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { deepMerge } from '../../../shared/deepMerge.js';
2+
import type { LanguageDetectorOptions } from '../instance';
3+
4+
export const DEFAULT_I18NEXT_DETECTION_OPTIONS = {
5+
caches: ['cookie', 'localStorage'],
6+
order: [
7+
'querystring',
8+
'cookie',
9+
'localStorage',
10+
'header',
11+
'navigator',
12+
'htmlTag',
13+
'path',
14+
'subdomain',
15+
],
16+
cookieMinutes: 60 * 24 * 365,
17+
lookupQuerystring: 'lng',
18+
lookupCookie: 'i18next',
19+
lookupLocalStorage: 'i18nextLng',
20+
lookupHeader: 'accept-language',
21+
};
22+
23+
export function mergeDetectionOptions(
24+
userOptions?: LanguageDetectorOptions,
25+
defaultOptions: LanguageDetectorOptions = DEFAULT_I18NEXT_DETECTION_OPTIONS,
26+
): LanguageDetectorOptions {
27+
return deepMerge(defaultOptions, userOptions);
28+
}
Lines changed: 293 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,293 @@
1+
import { type RuntimeContext, isBrowser } from '@modern-js/runtime';
2+
import { detectLanguageFromPath } from '../../utils';
3+
import type { I18nInitOptions, I18nInstance } from '../instance';
4+
import { mergeDetectionOptions as mergeDetectionOptionsUtil } from './config';
5+
import { detectLanguage } from './middleware';
6+
7+
export function exportServerLngToWindow(context: RuntimeContext, lng: string) {
8+
context.__i18nData__ = { lng };
9+
}
10+
11+
export const getLanguageFromSSRData = (window: Window): string | undefined => {
12+
const ssrData = window._SSR_DATA;
13+
return ssrData?.data?.i18nData?.lng as string | undefined;
14+
};
15+
16+
export interface LanguageDetectionOptions {
17+
languages: string[];
18+
fallbackLanguage: string;
19+
localePathRedirect: boolean;
20+
i18nextDetector: boolean;
21+
userInitOptions?: I18nInitOptions;
22+
pathname: string;
23+
ssrContext?: any;
24+
}
25+
26+
export interface LanguageDetectionResult {
27+
detectedLanguage?: string;
28+
finalLanguage: string;
29+
}
30+
31+
/**
32+
* Check if a language is supported
33+
*/
34+
const isLanguageSupported = (
35+
language: string | undefined,
36+
supportedLanguages: string[],
37+
): boolean => {
38+
if (!language) {
39+
return false;
40+
}
41+
return (
42+
supportedLanguages.length === 0 || supportedLanguages.includes(language)
43+
);
44+
};
45+
46+
/**
47+
* Priority 1: Detect language from SSR data
48+
*/
49+
const detectLanguageFromSSR = (languages: string[]): string | undefined => {
50+
if (!isBrowser()) {
51+
return undefined;
52+
}
53+
54+
try {
55+
const ssrLanguage = getLanguageFromSSRData(window);
56+
if (isLanguageSupported(ssrLanguage, languages)) {
57+
return ssrLanguage;
58+
}
59+
} catch (error) {
60+
// Silently ignore errors
61+
}
62+
63+
return undefined;
64+
};
65+
66+
/**
67+
* Priority 2: Detect language from URL path
68+
*/
69+
const detectLanguageFromPathPriority = (
70+
pathname: string,
71+
languages: string[],
72+
localePathRedirect: boolean,
73+
): string | undefined => {
74+
if (!localePathRedirect) {
75+
return undefined;
76+
}
77+
78+
try {
79+
const pathDetection = detectLanguageFromPath(
80+
pathname,
81+
languages,
82+
localePathRedirect,
83+
);
84+
if (pathDetection.detected && pathDetection.language) {
85+
return pathDetection.language;
86+
}
87+
} catch (error) {
88+
// Silently ignore errors
89+
}
90+
91+
return undefined;
92+
};
93+
94+
/**
95+
* Initialize i18n instance for detector if needed
96+
*/
97+
const initializeI18nForDetector = async (
98+
i18nInstance: I18nInstance,
99+
options: {
100+
languages: string[];
101+
fallbackLanguage: string;
102+
localePathRedirect: boolean;
103+
i18nextDetector: boolean;
104+
userInitOptions?: I18nInitOptions;
105+
},
106+
): Promise<void> => {
107+
if (i18nInstance.isInitialized) {
108+
return;
109+
}
110+
111+
const { mergedDetection } = mergeDetectionOptions(
112+
options.i18nextDetector,
113+
options.localePathRedirect,
114+
options.userInitOptions,
115+
);
116+
117+
// Don't set lng explicitly when detector is enabled, let the detector find the language
118+
// This allows localStorage/cookie to be read properly
119+
// Only set lng if user explicitly provided it, otherwise let detector work
120+
const userLng = options.userInitOptions?.lng;
121+
const { lng: _, ...restUserOptions } = options.userInitOptions || {};
122+
const initOptions: any = {
123+
...restUserOptions,
124+
...(userLng ? { lng: userLng } : {}),
125+
fallbackLng: options.fallbackLanguage,
126+
supportedLngs: options.languages,
127+
detection: mergedDetection,
128+
react: {
129+
...((options.userInitOptions as any)?.react || {}),
130+
useSuspense: isBrowser()
131+
? ((options.userInitOptions as any)?.react?.useSuspense ?? true)
132+
: false,
133+
},
134+
};
135+
await i18nInstance.init(initOptions);
136+
};
137+
138+
/**
139+
* Priority 3: Detect language using i18next detector
140+
*/
141+
const detectLanguageFromI18nextDetector = async (
142+
i18nInstance: I18nInstance,
143+
options: {
144+
languages: string[];
145+
fallbackLanguage: string;
146+
localePathRedirect: boolean;
147+
i18nextDetector: boolean;
148+
userInitOptions?: I18nInitOptions;
149+
ssrContext?: any;
150+
},
151+
): Promise<string | undefined> => {
152+
if (!options.i18nextDetector) {
153+
return undefined;
154+
}
155+
156+
await initializeI18nForDetector(i18nInstance, options);
157+
158+
try {
159+
const request = options.ssrContext?.request;
160+
// In browser environment, detectLanguage can work without request
161+
// In server environment, request is required
162+
if (!isBrowser() && !request) {
163+
return undefined;
164+
}
165+
166+
const detectorLang = detectLanguage(i18nInstance, request as any);
167+
168+
if (detectorLang && isLanguageSupported(detectorLang, options.languages)) {
169+
return detectorLang;
170+
}
171+
172+
// Fallback to instance's current language if detector didn't detect
173+
if (i18nInstance.isInitialized && i18nInstance.language) {
174+
const currentLang = i18nInstance.language;
175+
if (isLanguageSupported(currentLang, options.languages)) {
176+
return currentLang;
177+
}
178+
}
179+
} catch (error) {
180+
// Silently ignore errors
181+
}
182+
183+
return undefined;
184+
};
185+
186+
/**
187+
* Detect language with priority: SSR data > path > i18next detector > fallback
188+
*/
189+
export const detectLanguageWithPriority = async (
190+
i18nInstance: I18nInstance,
191+
options: LanguageDetectionOptions,
192+
): Promise<LanguageDetectionResult> => {
193+
const {
194+
languages,
195+
fallbackLanguage,
196+
localePathRedirect,
197+
i18nextDetector,
198+
userInitOptions,
199+
pathname,
200+
ssrContext,
201+
} = options;
202+
203+
// Priority 1: SSR data
204+
let detectedLanguage = detectLanguageFromSSR(languages);
205+
206+
// Priority 2: Path detection
207+
if (!detectedLanguage) {
208+
detectedLanguage = detectLanguageFromPathPriority(
209+
pathname,
210+
languages,
211+
localePathRedirect,
212+
);
213+
}
214+
215+
// Priority 3: i18next detector
216+
if (!detectedLanguage) {
217+
detectedLanguage = await detectLanguageFromI18nextDetector(i18nInstance, {
218+
languages,
219+
fallbackLanguage,
220+
localePathRedirect,
221+
i18nextDetector,
222+
userInitOptions,
223+
ssrContext,
224+
});
225+
}
226+
227+
// Priority 4: Use user config language or fallback
228+
const finalLanguage =
229+
detectedLanguage || userInitOptions?.lng || fallbackLanguage;
230+
231+
return { detectedLanguage, finalLanguage };
232+
};
233+
234+
/**
235+
* Options for building i18n init options
236+
*/
237+
export interface BuildInitOptionsParams {
238+
finalLanguage: string;
239+
fallbackLanguage: string;
240+
languages: string[];
241+
userInitOptions?: I18nInitOptions;
242+
mergedDetection?: any;
243+
mergeBackend?: any;
244+
}
245+
246+
/**
247+
* Build i18n initialization options
248+
*/
249+
export const buildInitOptions = (
250+
params: BuildInitOptionsParams,
251+
): I18nInitOptions => {
252+
const {
253+
finalLanguage,
254+
fallbackLanguage,
255+
languages,
256+
userInitOptions,
257+
mergedDetection,
258+
mergeBackend,
259+
} = params;
260+
261+
return {
262+
...(userInitOptions || {}),
263+
lng: finalLanguage,
264+
fallbackLng: fallbackLanguage,
265+
supportedLngs: languages,
266+
detection: mergedDetection,
267+
backend: mergeBackend,
268+
react: {
269+
useSuspense: isBrowser(),
270+
},
271+
} as any;
272+
};
273+
274+
/**
275+
* Merge detection and backend options
276+
*/
277+
export const mergeDetectionOptions = (
278+
i18nextDetector: boolean,
279+
localePathRedirect: boolean,
280+
userInitOptions?: I18nInitOptions,
281+
) => {
282+
// Exclude 'path' from detection order to avoid conflict with manual path detection
283+
const mergedDetection = i18nextDetector
284+
? mergeDetectionOptionsUtil(userInitOptions?.detection)
285+
: userInitOptions?.detection;
286+
if (localePathRedirect && mergedDetection?.order) {
287+
mergedDetection.order = mergedDetection.order.filter(
288+
(item: string) => item !== 'path',
289+
);
290+
}
291+
292+
return { mergedDetection };
293+
};

0 commit comments

Comments
 (0)