Skip to content

Commit 15b73bb

Browse files
committed
feat: support i18n detect
1 parent 91fcf07 commit 15b73bb

File tree

15 files changed

+981
-54
lines changed

15 files changed

+981
-54
lines changed

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

Lines changed: 9 additions & 1 deletion
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 &&
@@ -177,7 +186,6 @@ export const useModernI18n = (): UseModernI18nReturn => {
177186
i18nInstance,
178187
updateLanguage,
179188
localePathRedirect,
180-
entryName,
181189
languages,
182190
hasRouter,
183191
navigate,

packages/runtime/plugin-i18n/src/runtime/i18n/detection/config.ts

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,4 @@
1-
import type { RuntimeContext } from '@modern-js/runtime';
2-
import { deepMerge } from '../../../shared/deepMerge';
1+
import { deepMerge } from '../../../shared/deepMerge.js';
32
import type { LanguageDetectorOptions } from '../instance';
43

54
export const DEFAULT_I18NEXT_DETECTION_OPTIONS = {
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+
};

packages/runtime/plugin-i18n/src/runtime/i18n/instance.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ export interface LanguageDetectorOptions {
2727
caches?: LanguageDetectorCaches;
2828
cookieExpirationDate?: Date;
2929
cookieDomain?: string;
30+
lookupHeader?: string;
3031
}
3132

3233
export type I18nInitOptions = {

0 commit comments

Comments
 (0)