Skip to content

Commit 4d2cf8f

Browse files
committed
feat: i18n plugin support auto detect language
1 parent 2a96054 commit 4d2cf8f

File tree

9 files changed

+176
-67
lines changed

9 files changed

+176
-67
lines changed

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

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { RuntimeContext } from '@modern-js/runtime';
12
import type { LanguageDetectorOptions } from '../instance';
23

34
export const DEFAULT_I18NEXT_DETECTION_OPTIONS = {
@@ -66,3 +67,7 @@ export function mergeDetectionOptions(
6667

6768
return merged as LanguageDetectorOptions;
6869
}
70+
71+
export function exportServerLngToWindow(context: RuntimeContext, lng: string) {
72+
context.__i18nData__ = { lng };
73+
}

packages/runtime/plugin-i18n/src/runtime/i18n/detection/index.node.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import type { RuntimeContext } from '@modern-js/runtime';
12
import { LanguageDetector } from 'i18next-http-middleware';
23
import type { I18nInstance } from '../instance';
34

@@ -15,9 +16,21 @@ export const detectLanguage = (
1516
i18nInstance: I18nInstance,
1617
request?: any,
1718
): string | undefined => {
18-
const detector = (i18nInstance as any)?.services?.languageDetector;
19+
const detector = i18nInstance.services?.languageDetector;
1920
if (detector && typeof detector.detect === 'function' && request) {
20-
return detector.detect(request, {});
21+
try {
22+
return detector.detect(request, {});
23+
} catch (error) {
24+
console.warn('[@modern-js/plugin-i18n] Language detection failed', {
25+
error,
26+
context: 'server-detection',
27+
});
28+
return undefined;
29+
}
2130
}
2231
return undefined;
2332
};
33+
34+
export function exportServerLngToWindow(context: RuntimeContext, lng: string) {
35+
context.__i18nData__ = { lng };
36+
}

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

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,7 +19,7 @@ export const detectLanguage = (
1919
): string | undefined => {
2020
// Access the detector from i18next services
2121
// The detector is registered via useI18nextLanguageDetector() and initialized in init()
22-
const detector = (i18nInstance as any)?.services?.languageDetector;
22+
const detector = i18nInstance.services?.languageDetector;
2323
if (detector && typeof detector.detect === 'function') {
2424
try {
2525
const result = detector.detect();
@@ -31,7 +31,10 @@ export const detectLanguage = (
3131
return result[0];
3232
}
3333
} catch (error) {
34-
// Silently fail if detection fails
34+
console.warn('[@modern-js/plugin-i18n] Language detection failed', {
35+
error,
36+
context: 'browser-detection',
37+
});
3538
return undefined;
3639
}
3740
}

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

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,17 @@ export interface I18nInstance {
77
use: (plugin: any) => void;
88
createInstance: (options?: I18nInitOptions) => I18nInstance;
99
cloneInstance?: () => I18nInstance; // ssr need
10+
translator?: {
11+
language?: string;
12+
[key: string]: any;
13+
};
14+
services?: {
15+
languageDetector?: {
16+
detect: (request?: any, options?: any) => string | string[] | undefined;
17+
[key: string]: any;
18+
};
19+
[key: string]: any;
20+
};
1021
}
1122

1223
type LanguageDetectorOrder = string[];

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

Lines changed: 120 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,33 @@ import { ModernI18nProvider } from './context';
1010
import type { I18nInitOptions, I18nInstance } from './i18n';
1111
import { getI18nInstance } from './i18n';
1212
import { detectLanguage, useI18nextLanguageDetector } from './i18n/detection';
13-
import { mergeDetectionOptions } from './i18n/detection/config';
13+
import {
14+
exportServerLngToWindow,
15+
mergeDetectionOptions,
16+
} from './i18n/detection/config';
1417
import { getI18nextProvider, getInitReactI18next } from './i18n/instance';
1518
import { getEntryPath } from './utils';
1619

20+
/**
21+
* Validate languages array configuration
22+
* @param languages - Array of language codes to validate
23+
* @returns true if all languages are valid, false otherwise
24+
*/
25+
const validateLanguages = (languages: string[]): boolean => {
26+
return languages.every(lang => typeof lang === 'string' && lang.length > 0);
27+
};
28+
29+
/**
30+
* Get language from SSR data in a type-safe way
31+
* @param window - The window object
32+
* @returns The language from SSR data or undefined
33+
*/
34+
const getLanguageFromSSRData = (window: Window): string | undefined => {
35+
// Type-safe access to SSR data via global Window interface
36+
const ssrData = window._SSR_DATA;
37+
return ssrData?.data?.i18nData?.lng;
38+
};
39+
1740
export interface I18nPluginOptions {
1841
entryName?: string;
1942
localeDetection?: BaseLocaleDetectionOptions;
@@ -37,11 +60,17 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
3760
languages = [],
3861
fallbackLanguage = 'en',
3962
} = localeDetection || {};
63+
64+
// Validate languages configuration
65+
if (!validateLanguages(languages)) {
66+
console.warn(
67+
'[@modern-js/plugin-i18n] Invalid languages configuration. All language codes must be non-empty strings.',
68+
{ languages },
69+
);
70+
}
71+
4072
let I18nextProvider: React.FunctionComponent<any> | null;
4173

42-
// Helper function to detect language from path
43-
// Returns: { detected: true, language: string } if language found in path
44-
// { detected: false } if no language found in path
4574
const detectLanguageFromPath = (
4675
pathname: string,
4776
): {
@@ -56,7 +85,6 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
5685
const segments = relativePath.split('/').filter(Boolean);
5786
const firstSegment = segments[0];
5887

59-
// Check if first segment is a valid language
6088
if (firstSegment && languages.includes(firstSegment)) {
6189
return { detected: true, language: firstSegment };
6290
}
@@ -73,33 +101,45 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
73101
i18nInstance.use(initReactI18next);
74102
}
75103

76-
// Get pathname from appropriate source
77104
const getPathname = () => {
78105
if (isBrowser()) {
79106
return window.location.pathname;
80107
}
81108
return context.ssrContext?.request?.pathname || '/';
82109
};
83110

84-
// Step 1: Detect language with priority: path > i18next detector > fallback
111+
// Language detection priority: SSR data > path > i18next detector > fallback
85112
let detectedLanguage: string | undefined;
86113

87-
// Priority 1: Path detection (if enabled)
88-
if (localePathRedirect) {
89-
const pathname = getPathname();
90-
const pathDetection = detectLanguageFromPath(pathname);
91-
if (pathDetection.detected && pathDetection.language) {
92-
detectedLanguage = pathDetection.language;
93-
}
114+
// Priority 1: SSR data
115+
if (isBrowser()) {
116+
try {
117+
const ssrLanguage = getLanguageFromSSRData(window);
118+
if (
119+
ssrLanguage &&
120+
(languages.length === 0 || languages.includes(ssrLanguage))
121+
) {
122+
detectedLanguage = ssrLanguage;
123+
}
124+
} catch (error) {}
125+
}
126+
127+
// Priority 2: Path detection
128+
if (!detectedLanguage && localePathRedirect) {
129+
try {
130+
const pathname = getPathname();
131+
const pathDetection = detectLanguageFromPath(pathname);
132+
if (pathDetection.detected && pathDetection.language) {
133+
detectedLanguage = pathDetection.language;
134+
}
135+
} catch (error) {}
94136
}
95137

96-
// Step 2: Register detector and prepare detection options if enabled
97138
if (i18nextDetector) {
98139
useI18nextLanguageDetector(i18nInstance);
99140
}
100141

101-
// Merge detection options and exclude 'path' if localePathRedirect is enabled
102-
// to avoid conflict with manual path detection
142+
// Exclude 'path' from detection order to avoid conflict with manual path detection
103143
const mergedDetection = i18nextDetector
104144
? mergeDetectionOptions(userInitOptions?.detection)
105145
: userInitOptions?.detection;
@@ -109,59 +149,77 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
109149
);
110150
}
111151

112-
// Step 3: Use i18next detector if path didn't detect (Priority 2)
152+
// Priority 3: i18next detector
113153
if (!detectedLanguage && i18nextDetector) {
114-
// Initialize with fallback language first to enable detector
115-
const initOptions: I18nInitOptions = {
116-
fallbackLng: fallbackLanguage,
117-
supportedLngs: languages,
118-
...(userInitOptions || {}),
119-
detection: mergedDetection,
120-
};
121-
await i18nInstance.init(initOptions);
154+
if (!i18nInstance.isInitialized) {
155+
const initialLng = userInitOptions?.lng || fallbackLanguage;
156+
const initOptions: any = {
157+
...(userInitOptions || {}),
158+
lng: initialLng,
159+
fallbackLng: fallbackLanguage,
160+
supportedLngs: languages,
161+
detection: mergedDetection,
162+
};
163+
await i18nInstance.init(initOptions);
164+
}
122165

123-
// Use detector to detect language
124166
let detectorLang: string | undefined;
125-
if (isBrowser()) {
126-
detectorLang = detectLanguage(i18nInstance);
127-
} else {
128-
const request = context.ssrContext?.request;
129-
if (request) {
130-
detectorLang = detectLanguage(i18nInstance, request as any);
167+
try {
168+
if (isBrowser()) {
169+
detectorLang = detectLanguage(i18nInstance);
170+
} else {
171+
const request = context.ssrContext?.request;
172+
if (request) {
173+
detectorLang = detectLanguage(i18nInstance, request as any);
174+
}
131175
}
132-
}
133176

134-
// Validate detected language
135-
if (detectorLang) {
136-
if (languages.length === 0 || languages.includes(detectorLang)) {
137-
detectedLanguage = detectorLang;
177+
if (detectorLang) {
178+
if (languages.length === 0 || languages.includes(detectorLang)) {
179+
detectedLanguage = detectorLang;
180+
}
181+
} else if (i18nInstance.isInitialized && i18nInstance.language) {
182+
// Fallback to instance's current language if detector didn't detect
183+
const currentLang = i18nInstance.language;
184+
if (languages.length === 0 || languages.includes(currentLang)) {
185+
detectedLanguage = currentLang;
186+
}
138187
}
139-
}
188+
} catch (error) {}
140189
}
141190

142-
// Priority 3: Use fallback language
143-
const finalLanguage = detectedLanguage || fallbackLanguage;
191+
// Priority 4: Use user config language or fallback
192+
const finalLanguage =
193+
detectedLanguage || userInitOptions?.lng || fallbackLanguage;
144194

145-
// Step 4: Initialize i18n if not already initialized
146195
if (!i18nInstance.isInitialized) {
147-
const initOptions: I18nInitOptions = {
196+
const initOptions: any = {
197+
...(userInitOptions || {}),
148198
lng: finalLanguage,
149199
fallbackLng: fallbackLanguage,
150200
supportedLngs: languages,
151-
...(userInitOptions || {}),
152201
detection: mergedDetection,
153202
};
154203
await i18nInstance.init(initOptions);
155-
} else {
156-
// Update language if different
204+
} else if (i18nInstance.language !== finalLanguage) {
205+
await i18nInstance.changeLanguage(finalLanguage);
206+
}
207+
208+
// Clone instance for SSR
209+
if (!isBrowser() && i18nInstance.cloneInstance) {
210+
i18nInstance = i18nInstance.cloneInstance();
157211
if (i18nInstance.language !== finalLanguage) {
158212
await i18nInstance.changeLanguage(finalLanguage);
159213
}
160214
}
161215

162-
// Clone instance for SSR if needed
163-
if (!isBrowser() && i18nInstance.cloneInstance) {
164-
i18nInstance = i18nInstance.cloneInstance();
216+
// Ensure language is correct (critical for SSR)
217+
if (i18nInstance.language !== finalLanguage) {
218+
await i18nInstance.changeLanguage(finalLanguage);
219+
}
220+
221+
if (!isBrowser()) {
222+
exportServerLngToWindow(context, finalLanguage);
165223
}
166224

167225
context.i18nInstance = i18nInstance;
@@ -171,23 +229,22 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
171229
return props => {
172230
const runtimeContext = useRuntimeContext();
173231
const i18nInstance = (runtimeContext as any).i18nInstance;
174-
const [lang, setLang] = useState(i18nInstance.language);
232+
const initialLang =
233+
i18nInstance?.language || (localeDetection?.fallbackLanguage ?? 'en');
234+
const [lang, setLang] = useState(initialLang);
175235

176-
if (!isBrowser()) {
177-
(i18nInstance as any).translator.language = i18nInstance.language;
236+
// Sync translator.language with i18nInstance.language
237+
if (i18nInstance?.language && i18nInstance.translator) {
238+
i18nInstance.translator.language = i18nInstance.language;
178239
}
179240

180-
// Get pathname from appropriate source based on environment
181241
const getCurrentPathname = () => {
182242
if (isBrowser()) {
183243
return window.location.pathname;
184-
} else {
185-
// In SSR, get pathname from request context
186-
return runtimeContext.request?.pathname || '/';
187244
}
245+
return runtimeContext.request?.pathname || '/';
188246
};
189247

190-
// Initialize language from URL on mount (only when localeDetection is enabled)
191248
useEffect(() => {
192249
if (localePathRedirect) {
193250
const currentPathname = getCurrentPathname();
@@ -196,15 +253,17 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
196253
const currentLang = pathDetection.language;
197254
if (currentLang !== lang) {
198255
setLang(currentLang);
199-
// Update i18n instance language
200256
i18nInstance.changeLanguage(currentLang);
201257
}
202258
}
259+
} else {
260+
// Sync language when localePathRedirect is false (important for CSR)
261+
const instanceLang = i18nInstance.language;
262+
if (instanceLang && instanceLang !== lang) {
263+
setLang(instanceLang);
264+
}
203265
}
204266
}, []);
205-
206-
// Context value contains language, i18nInstance, and plugin configuration
207-
// changeLanguage is now implemented in the useModernI18n hook
208267
const contextValue = {
209268
language: lang,
210269
i18nInstance,

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

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,15 @@ export const getLocaleDetectionOptions = (
4040

4141
if (hasEntryConfig(localeDetection)) {
4242
const { localeDetectionByEntry, ...globalConfig } = localeDetection;
43-
return localeDetectionByEntry?.[entryName] || globalConfig;
43+
const entryConfig = localeDetectionByEntry?.[entryName];
44+
// Merge entry-specific config with global config, entry config takes precedence
45+
if (entryConfig) {
46+
return {
47+
...globalConfig,
48+
...entryConfig,
49+
};
50+
}
51+
return globalConfig;
4452
}
4553

4654
return localeDetection;

packages/runtime/plugin-runtime/src/core/server/string/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -57,6 +57,7 @@ export const renderString: RenderString = async (
5757
config,
5858
}),
5959
new SSRDataCollector({
60+
runtimeContext,
6061
request,
6162
ssrConfig,
6263
ssrContext: runtimeContext.ssrContext!,

0 commit comments

Comments
 (0)