Skip to content

Commit e4234cc

Browse files
authored
feat: i18n plugin support backend sdk (#7868)
1 parent eba4349 commit e4234cc

File tree

10 files changed

+429
-35
lines changed

10 files changed

+429
-35
lines changed

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

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,18 @@ export const mergeBackendOptions = (
77
backend?: BaseBackendOptions,
88
userInitOptions?: I18nInitOptions,
99
) => {
10+
const hasSdkFunction =
11+
typeof userInitOptions?.backend?.sdk === 'function' ||
12+
(backend?.enabled && backend?.sdk && typeof backend.sdk === 'function');
13+
14+
if (hasSdkFunction) {
15+
return baseMergeBackendOptions(
16+
{} as BackendOptions,
17+
backend as BackendOptions,
18+
userInitOptions?.backend,
19+
);
20+
}
21+
1022
const mergedBackend = backend?.enabled
1123
? baseMergeBackendOptions(
1224
DEFAULT_I18NEXT_BACKEND_OPTIONS,
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import Backend from 'i18next-fs-backend';
2+
import type { BaseBackendOptions } from '../../../shared/type';
23
import type { I18nInstance } from '../instance';
4+
import { SdkBackend } from './sdk-backend';
35

4-
export const useI18nextBackend = (i18nInstance: I18nInstance) => {
6+
export const useI18nextBackend = (
7+
i18nInstance: I18nInstance,
8+
backend?: BaseBackendOptions,
9+
) => {
10+
if (backend?.sdk && typeof backend.sdk === 'function') {
11+
return i18nInstance.use(SdkBackend);
12+
}
513
return i18nInstance.use(Backend);
614
};
Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,14 @@
11
import Backend from 'i18next-http-backend';
2+
import type { BaseBackendOptions } from '../../../shared/type';
23
import type { I18nInstance } from '../instance';
4+
import { SdkBackend } from './sdk-backend';
35

4-
export const useI18nextBackend = (i18nInstance: I18nInstance) => {
6+
export const useI18nextBackend = (
7+
i18nInstance: I18nInstance,
8+
backend?: BaseBackendOptions,
9+
) => {
10+
if (backend?.sdk && typeof backend.sdk === 'function') {
11+
return i18nInstance.use(SdkBackend);
12+
}
513
return i18nInstance.use(Backend);
614
};
Lines changed: 197 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,197 @@
1+
import type { I18nSdkLoadOptions, I18nSdkLoader } from '../../../shared/type';
2+
import type { Resources } from '../instance';
3+
4+
/**
5+
* i18next backend options interface
6+
*/
7+
interface BackendOptions {
8+
sdk?: I18nSdkLoader;
9+
[key: string]: unknown;
10+
}
11+
12+
/**
13+
* Custom i18next backend that uses SDK to load resources
14+
*/
15+
export class SdkBackend {
16+
static type = 'backend';
17+
type = 'backend' as const;
18+
sdk?: I18nSdkLoader;
19+
private allResourcesCache: Resources | null = null;
20+
private loadingPromises = new Map<string, Promise<unknown>>();
21+
22+
constructor(_services: unknown, _options: Record<string, unknown>) {
23+
void _services;
24+
void _options;
25+
}
26+
27+
init(
28+
_services: unknown,
29+
backendOptions: BackendOptions,
30+
_i18nextOptions: unknown,
31+
): void {
32+
void _services;
33+
void _i18nextOptions;
34+
this.sdk = backendOptions?.sdk;
35+
if (!this.sdk) {
36+
throw new Error(
37+
'SdkBackend requires an SDK function to be provided in backend options',
38+
);
39+
}
40+
}
41+
42+
read(
43+
language: string,
44+
namespace: string,
45+
callback: (error: Error | null, data: unknown) => void,
46+
) {
47+
if (!this.sdk) {
48+
callback(new Error('SDK function not initialized'), null);
49+
return;
50+
}
51+
52+
if (this.allResourcesCache) {
53+
const cached = this.extractFromCache(language, namespace);
54+
if (cached !== null) {
55+
callback(null, cached);
56+
return;
57+
}
58+
}
59+
60+
const cacheKey = `${language}:${namespace}`;
61+
const existingPromise = this.loadingPromises.get(cacheKey);
62+
if (existingPromise) {
63+
existingPromise
64+
.then(data => {
65+
const formattedData = this.formatResources(data, language, namespace);
66+
callback(null, formattedData);
67+
})
68+
.catch(error => {
69+
callback(
70+
error instanceof Error ? error : new Error(String(error)),
71+
null,
72+
);
73+
});
74+
return;
75+
}
76+
77+
try {
78+
const result = this.callSdk(language, namespace);
79+
const loadPromise =
80+
result instanceof Promise ? result : Promise.resolve(result);
81+
82+
this.loadingPromises.set(cacheKey, loadPromise);
83+
84+
loadPromise
85+
.then(data => {
86+
const formattedData = this.formatResources(data, language, namespace);
87+
this.updateCache(language, namespace, formattedData);
88+
this.loadingPromises.delete(cacheKey);
89+
callback(null, formattedData);
90+
})
91+
.catch(error => {
92+
this.loadingPromises.delete(cacheKey);
93+
callback(
94+
error instanceof Error ? error : new Error(String(error)),
95+
null,
96+
);
97+
});
98+
} catch (error) {
99+
callback(error instanceof Error ? error : new Error(String(error)), null);
100+
}
101+
}
102+
103+
private callSdk(
104+
language: string,
105+
namespace: string,
106+
): Promise<Resources> | Resources {
107+
if (!this.sdk) {
108+
throw new Error('SDK function not initialized');
109+
}
110+
111+
const options: I18nSdkLoadOptions = { lng: language, ns: namespace };
112+
return this.sdk(options);
113+
}
114+
115+
private extractFromCache(
116+
language: string,
117+
namespace: string,
118+
): Record<string, string> | null {
119+
if (!this.allResourcesCache) {
120+
return null;
121+
}
122+
123+
const langData = this.allResourcesCache[language];
124+
if (!langData || typeof langData !== 'object') {
125+
return null;
126+
}
127+
128+
const nsData = langData[namespace];
129+
if (!nsData || typeof nsData !== 'object') {
130+
return null;
131+
}
132+
133+
return nsData as Record<string, string>;
134+
}
135+
136+
private updateCache(
137+
language: string,
138+
namespace: string,
139+
data: unknown,
140+
): void {
141+
if (!this.allResourcesCache) {
142+
this.allResourcesCache = {};
143+
}
144+
145+
if (!this.allResourcesCache[language]) {
146+
this.allResourcesCache[language] = {};
147+
}
148+
149+
if (data && typeof data === 'object') {
150+
this.allResourcesCache[language][namespace] = data as Record<
151+
string,
152+
string
153+
>;
154+
}
155+
}
156+
157+
private formatResources(
158+
data: unknown,
159+
language: string,
160+
namespace: string,
161+
): Record<string, string> {
162+
if (!data || typeof data !== 'object') {
163+
return {};
164+
}
165+
166+
const dataObj = data as Record<string, unknown>;
167+
168+
const langData = dataObj[language];
169+
if (langData && typeof langData === 'object') {
170+
const nsData = (langData as Record<string, unknown>)[namespace];
171+
if (nsData && typeof nsData === 'object') {
172+
return nsData as Record<string, string>;
173+
}
174+
}
175+
176+
const hasLanguageKeys = Object.keys(dataObj).some(
177+
key => dataObj[key] && typeof dataObj[key] === 'object',
178+
);
179+
if (!hasLanguageKeys) {
180+
return dataObj as Record<string, string>;
181+
}
182+
183+
return {};
184+
}
185+
186+
create(
187+
_languages: string[],
188+
_namespace: string,
189+
_key: string,
190+
_fallbackValue: string,
191+
): void {
192+
// Not implemented - translations are managed by the external SDK service.
193+
// This method is called by i18next when saveMissing is enabled and addPath is configured.
194+
// For SDK backend, missing translations should be managed through the external SDK service,
195+
// not saved at runtime.
196+
}
197+
}

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

Lines changed: 18 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,7 @@ export const buildInitOptions = (
3737
supportedLngs: languages,
3838
detection: mergedDetection,
3939
backend: mergedBackend,
40+
initImmediate: userInitOptions?.initImmediate ?? true,
4041
...(userInitOptions || {}),
4142
react: {
4243
...(userInitOptions?.react || {}),
@@ -81,6 +82,22 @@ export const initializeI18nInstance = async (
8182
useSuspense,
8283
);
8384
await i18nInstance.init(initOptions);
85+
86+
if (mergedBackend && hasOptions(i18nInstance)) {
87+
const defaultNS =
88+
initOptions.defaultNS || initOptions.ns || 'translation';
89+
const ns = Array.isArray(defaultNS) ? defaultNS[0] : defaultNS;
90+
91+
let retries = 20;
92+
while (retries > 0) {
93+
const store = (i18nInstance as any).store;
94+
if (store?.data?.[finalLanguage]?.[ns]) {
95+
break;
96+
}
97+
await new Promise(resolve => setTimeout(resolve, 100));
98+
retries--;
99+
}
100+
}
84101
}
85102
};
86103

@@ -109,8 +126,8 @@ export const setupClonedInstance = async (
109126
userInitOptions: I18nInitOptions | undefined,
110127
): Promise<void> => {
111128
if (backendEnabled) {
112-
useI18nextBackend(i18nInstance);
113129
const mergedBackend = mergeBackendOptions(backend, userInitOptions);
130+
useI18nextBackend(i18nInstance, mergedBackend);
114131
if (mergedBackend && hasOptions(i18nInstance)) {
115132
i18nInstance.options.backend = {
116133
...i18nInstance.options.backend,

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

Lines changed: 12 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,9 @@ import {
2929
} from './i18n/utils';
3030
import { detectLanguageFromPath } from './utils';
3131

32+
export type { I18nSdkLoader, I18nSdkLoadOptions } from '../shared/type';
33+
export type { Resources } from './i18n/instance';
34+
3235
export interface I18nPluginOptions {
3336
entryName?: string;
3437
localeDetection?: BaseLocaleDetectionOptions;
@@ -75,16 +78,22 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
7578

7679
const pathname = getPathname(context);
7780

78-
// Setup i18next language detector if enabled
7981
if (i18nextDetector) {
8082
useI18nextLanguageDetector(i18nInstance);
8183
}
8284

85+
const mergedDetection = mergeDetectionOptions(
86+
i18nextDetector,
87+
detection,
88+
localePathRedirect,
89+
userInitOptions,
90+
);
91+
const mergedBackend = mergeBackendOptions(backend, userInitOptions);
92+
8393
if (backendEnabled) {
84-
useI18nextBackend(i18nInstance);
94+
useI18nextBackend(i18nInstance, mergedBackend);
8595
}
8696

87-
// Detect language with priority: SSR data > path > i18next detector > fallback
8897
const { finalLanguage } = await detectLanguageWithPriority(i18nInstance, {
8998
languages,
9099
fallbackLanguage,
@@ -96,16 +105,6 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
96105
ssrContext: context.ssrContext,
97106
});
98107

99-
// Merge options once for reuse
100-
const mergedDetection = mergeDetectionOptions(
101-
i18nextDetector,
102-
detection,
103-
localePathRedirect,
104-
userInitOptions,
105-
);
106-
const mergedBackend = mergeBackendOptions(backend, userInitOptions);
107-
108-
// Initialize instance if not already initialized
109108
await initializeI18nInstance(
110109
i18nInstance,
111110
finalLanguage,
@@ -116,7 +115,6 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
116115
userInitOptions,
117116
);
118117

119-
// Handle SSR cloned instance
120118
if (!isBrowser() && i18nInstance.cloneInstance) {
121119
i18nInstance = i18nInstance.cloneInstance();
122120
await setupClonedInstance(
@@ -133,7 +131,6 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
133131
);
134132
}
135133

136-
// Ensure language matches path if localePathRedirect is enabled
137134
if (localePathRedirect) {
138135
await ensureLanguageMatch(i18nInstance, finalLanguage);
139136
}
@@ -156,7 +153,6 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
156153
i18nInstance.translator.language = i18nInstance.language;
157154
}
158155

159-
// Initialize language from URL on mount (only when localeDetection is enabled)
160156
useEffect(() => {
161157
if (localePathRedirect) {
162158
const currentPathname = getPathname(
@@ -182,8 +178,6 @@ export const i18nPlugin = (options: I18nPluginOptions): RuntimePlugin => ({
182178
}
183179
}, []);
184180

185-
// Context value contains language, i18nInstance, and plugin configuration
186-
// changeLanguage is now implemented in the useModernI18n hook
187181
const contextValue = {
188182
language: lang,
189183
i18nInstance,

0 commit comments

Comments
 (0)