Skip to content

Commit 2a96054

Browse files
committed
feat: add detect language
1 parent 1fe93df commit 2a96054

File tree

4 files changed

+159
-40
lines changed

4 files changed

+159
-40
lines changed

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

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,20 @@ import type { I18nInstance } from '../instance';
44
export const useI18nextLanguageDetector = (i18nInstance: I18nInstance) => {
55
return i18nInstance.use(LanguageDetector);
66
};
7+
8+
/**
9+
* Detect language using i18next http middleware language detector
10+
* @param i18nInstance - The i18n instance
11+
* @param request - The request object (required for server-side detection)
12+
* @returns Detected language or undefined if not detected
13+
*/
14+
export const detectLanguage = (
15+
i18nInstance: I18nInstance,
16+
request?: any,
17+
): string | undefined => {
18+
const detector = (i18nInstance as any)?.services?.languageDetector;
19+
if (detector && typeof detector.detect === 'function' && request) {
20+
return detector.detect(request, {});
21+
}
22+
return undefined;
23+
};

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

Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,3 +4,36 @@ import type { I18nInstance } from '../instance';
44
export const useI18nextLanguageDetector = (i18nInstance: I18nInstance) => {
55
return i18nInstance.use(LanguageDetector);
66
};
7+
8+
/**
9+
* Detect language using i18next browser language detector
10+
* Uses the detector registered in i18nInstance services
11+
* Note: The detector is only available in services after i18nInstance.init() is called
12+
* @param i18nInstance - The i18n instance with registered LanguageDetector and initialized
13+
* @param _request - Optional request object (not used in browser, for compatibility)
14+
* @returns Detected language or undefined if not detected
15+
*/
16+
export const detectLanguage = (
17+
i18nInstance: I18nInstance,
18+
_request?: any,
19+
): string | undefined => {
20+
// Access the detector from i18next services
21+
// The detector is registered via useI18nextLanguageDetector() and initialized in init()
22+
const detector = (i18nInstance as any)?.services?.languageDetector;
23+
if (detector && typeof detector.detect === 'function') {
24+
try {
25+
const result = detector.detect();
26+
// detector.detect() can return string | string[] | undefined
27+
if (typeof result === 'string') {
28+
return result;
29+
}
30+
if (Array.isArray(result) && result.length > 0) {
31+
return result[0];
32+
}
33+
} catch (error) {
34+
// Silently fail if detection fails
35+
return undefined;
36+
}
37+
}
38+
return undefined;
39+
};

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

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ export interface I18nInstance {
33
language: string;
44
isInitialized: boolean;
55
init: (options?: I18nInitOptions) => void | Promise<void>;
6-
changeLanguage: (lang: string) => void | Promise<void>;
6+
changeLanguage: (lang?: string) => void | Promise<void>;
77
use: (plugin: any) => void;
88
createInstance: (options?: I18nInitOptions) => I18nInstance;
99
cloneInstance?: () => I18nInstance; // ssr need

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

Lines changed: 108 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -9,10 +9,10 @@ import type { BaseLocaleDetectionOptions } from '../utils/config';
99
import { ModernI18nProvider } from './context';
1010
import type { I18nInitOptions, I18nInstance } from './i18n';
1111
import { getI18nInstance } from './i18n';
12-
import { useI18nextLanguageDetector } from './i18n/detection';
12+
import { detectLanguage, useI18nextLanguageDetector } from './i18n/detection';
1313
import { mergeDetectionOptions } from './i18n/detection/config';
1414
import { getI18nextProvider, getInitReactI18next } from './i18n/instance';
15-
import { getEntryPath, getLanguageFromPath } from './utils';
15+
import { getEntryPath } from './utils';
1616

1717
export interface I18nPluginOptions {
1818
entryName?: string;
@@ -32,72 +32,138 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
3232
localeDetection,
3333
} = options;
3434
const {
35-
localePathRedirect,
36-
i18nextDetector,
35+
localePathRedirect = false,
36+
i18nextDetector = true,
3737
languages = [],
3838
fallbackLanguage = 'en',
3939
} = localeDetection || {};
4040
let I18nextProvider: React.FunctionComponent<any> | null;
4141

4242
// Helper function to detect language from path
43-
const detectLanguageFromPath = (pathname: string) => {
44-
if (localePathRedirect) {
45-
const relativePath = pathname.replace(getEntryPath(entryName), '');
46-
const detectedLang = getLanguageFromPath(
47-
relativePath,
48-
languages,
49-
fallbackLanguage,
50-
);
51-
// If no language is detected from path, use fallback language
52-
return detectedLang || fallbackLanguage;
43+
// Returns: { detected: true, language: string } if language found in path
44+
// { detected: false } if no language found in path
45+
const detectLanguageFromPath = (
46+
pathname: string,
47+
): {
48+
detected: boolean;
49+
language?: string;
50+
} => {
51+
if (!localePathRedirect) {
52+
return { detected: false };
53+
}
54+
55+
const relativePath = pathname.replace(getEntryPath(entryName), '');
56+
const segments = relativePath.split('/').filter(Boolean);
57+
const firstSegment = segments[0];
58+
59+
// Check if first segment is a valid language
60+
if (firstSegment && languages.includes(firstSegment)) {
61+
return { detected: true, language: firstSegment };
5362
}
54-
return fallbackLanguage;
63+
64+
return { detected: false };
5565
};
5666

5767
api.onBeforeRender(async context => {
5868
let i18nInstance = await getI18nInstance(userI18nInstance);
5969
const initReactI18next = await getInitReactI18next();
6070
I18nextProvider = await getI18nextProvider();
71+
6172
if (initReactI18next) {
6273
i18nInstance.use(initReactI18next);
6374
}
75+
76+
// Get pathname from appropriate source
77+
const getPathname = () => {
78+
if (isBrowser()) {
79+
return window.location.pathname;
80+
}
81+
return context.ssrContext?.request?.pathname || '/';
82+
};
83+
84+
// Step 1: Detect language with priority: path > i18next detector > fallback
85+
let detectedLanguage: string | undefined;
86+
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+
}
94+
}
95+
96+
// Step 2: Register detector and prepare detection options if enabled
6497
if (i18nextDetector) {
6598
useI18nextLanguageDetector(i18nInstance);
6699
}
67-
// Always detect language from path for consistency between SSR and client
68-
let initialLanguage = fallbackLanguage;
69-
if (localePathRedirect) {
100+
101+
// Merge detection options and exclude 'path' if localePathRedirect is enabled
102+
// to avoid conflict with manual path detection
103+
const mergedDetection = i18nextDetector
104+
? mergeDetectionOptions(userInitOptions?.detection)
105+
: userInitOptions?.detection;
106+
if (localePathRedirect && mergedDetection?.order) {
107+
mergedDetection.order = mergedDetection.order.filter(
108+
(item: string) => item !== 'path',
109+
);
110+
}
111+
112+
// Step 3: Use i18next detector if path didn't detect (Priority 2)
113+
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);
122+
123+
// Use detector to detect language
124+
let detectorLang: string | undefined;
70125
if (isBrowser()) {
71-
// In browser, get from window.location
72-
initialLanguage = detectLanguageFromPath(window.location.pathname);
126+
detectorLang = detectLanguage(i18nInstance);
73127
} else {
74-
// In SSR, get from request context
75-
const pathname = context.ssrContext?.request?.pathname || '/';
76-
initialLanguage = detectLanguageFromPath(pathname);
128+
const request = context.ssrContext?.request;
129+
if (request) {
130+
detectorLang = detectLanguage(i18nInstance, request as any);
131+
}
132+
}
133+
134+
// Validate detected language
135+
if (detectorLang) {
136+
if (languages.length === 0 || languages.includes(detectorLang)) {
137+
detectedLanguage = detectorLang;
138+
}
77139
}
78140
}
79-
if (!i18nInstance.isInitialized) {
80-
// Merge detection options: default options + user options
81-
const mergedDetection = i18nextDetector
82-
? mergeDetectionOptions(userInitOptions?.detection)
83-
: userInitOptions?.detection;
84141

142+
// Priority 3: Use fallback language
143+
const finalLanguage = detectedLanguage || fallbackLanguage;
144+
145+
// Step 4: Initialize i18n if not already initialized
146+
if (!i18nInstance.isInitialized) {
85147
const initOptions: I18nInitOptions = {
86-
lng: initialLanguage,
148+
lng: finalLanguage,
87149
fallbackLng: fallbackLanguage,
88150
supportedLngs: languages,
89151
...(userInitOptions || {}),
90152
detection: mergedDetection,
91153
};
92154
await i18nInstance.init(initOptions);
155+
} else {
156+
// Update language if different
157+
if (i18nInstance.language !== finalLanguage) {
158+
await i18nInstance.changeLanguage(finalLanguage);
159+
}
93160
}
161+
162+
// Clone instance for SSR if needed
94163
if (!isBrowser() && i18nInstance.cloneInstance) {
95164
i18nInstance = i18nInstance.cloneInstance();
96165
}
97-
if (localePathRedirect && i18nInstance.language !== initialLanguage) {
98-
// If instance is already initialized but language doesn't match the path, update it
99-
await i18nInstance.changeLanguage(initialLanguage);
100-
}
166+
101167
context.i18nInstance = i18nInstance;
102168
});
103169

@@ -107,7 +173,7 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
107173
const i18nInstance = (runtimeContext as any).i18nInstance;
108174
const [lang, setLang] = useState(i18nInstance.language);
109175

110-
if (!isBrowser) {
176+
if (!isBrowser()) {
111177
(i18nInstance as any).translator.language = i18nInstance.language;
112178
}
113179

@@ -125,11 +191,14 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
125191
useEffect(() => {
126192
if (localePathRedirect) {
127193
const currentPathname = getCurrentPathname();
128-
const currentLang = detectLanguageFromPath(currentPathname);
129-
if (currentLang !== lang) {
130-
setLang(currentLang);
131-
// Update i18n instance language
132-
i18nInstance.changeLanguage(currentLang);
194+
const pathDetection = detectLanguageFromPath(currentPathname);
195+
if (pathDetection.detected && pathDetection.language) {
196+
const currentLang = pathDetection.language;
197+
if (currentLang !== lang) {
198+
setLang(currentLang);
199+
// Update i18n instance language
200+
i18nInstance.changeLanguage(currentLang);
201+
}
133202
}
134203
}
135204
}, []);

0 commit comments

Comments
 (0)