@umituz/web-localization 1.0.6 → 1.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +1 -0
- package/dist/infrastructure/services/cli.service.d.ts +1 -0
- package/dist/infrastructure/services/cli.service.d.ts.map +1 -1
- package/dist/infrastructure/services/cli.service.js +3 -2
- package/dist/infrastructure/services/google-translate.service.d.ts +1 -1
- package/dist/infrastructure/services/google-translate.service.d.ts.map +1 -1
- package/dist/infrastructure/services/google-translate.service.js +67 -25
- package/dist/infrastructure/utils/file.util.d.ts.map +1 -1
- package/dist/infrastructure/utils/text-validator.util.js +3 -4
- package/dist/integrations/i18n.setup.d.ts +23 -0
- package/dist/integrations/i18n.setup.d.ts.map +1 -0
- package/dist/integrations/i18n.setup.js +38 -0
- package/dist/scripts/cli.js +1 -0
- package/package.json +12 -6
- package/src/index.ts +1 -0
- package/src/infrastructure/services/cli.service.ts +5 -2
- package/src/infrastructure/services/google-translate.service.ts +76 -28
- package/src/infrastructure/utils/text-validator.util.ts +2 -2
- package/src/integrations/i18n.setup.ts +62 -0
- package/src/scripts/cli.ts +1 -0
package/dist/index.d.ts
CHANGED
|
@@ -8,4 +8,5 @@ export * from "./infrastructure/services/google-translate.service";
|
|
|
8
8
|
export * from "./infrastructure/constants";
|
|
9
9
|
export * from "./infrastructure/utils/text-validator.util";
|
|
10
10
|
export * from "./infrastructure/utils/rate-limit.util";
|
|
11
|
+
export * from "./integrations/i18n.setup";
|
|
11
12
|
//# sourceMappingURL=index.d.ts.map
|
package/dist/index.d.ts.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,cAAc,sCAAsC,CAAC;AACrD,cAAc,mDAAmD,CAAC;AAClE,cAAc,oDAAoD,CAAC;AACnE,cAAc,4BAA4B,CAAC;AAC3C,cAAc,4CAA4C,CAAC;AAC3D,cAAc,wCAAwC,CAAC"}
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,cAAc,sCAAsC,CAAC;AACrD,cAAc,mDAAmD,CAAC;AAClE,cAAc,oDAAoD,CAAC;AACnE,cAAc,4BAA4B,CAAC;AAC3C,cAAc,4CAA4C,CAAC;AAC3D,cAAc,wCAAwC,CAAC;AACvD,cAAc,2BAA2B,CAAC"}
|
package/dist/index.js
CHANGED
|
@@ -8,3 +8,4 @@ export * from "./infrastructure/services/google-translate.service";
|
|
|
8
8
|
export * from "./infrastructure/constants";
|
|
9
9
|
export * from "./infrastructure/utils/text-validator.util";
|
|
10
10
|
export * from "./infrastructure/utils/rate-limit.util";
|
|
11
|
+
export * from "./integrations/i18n.setup";
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"cli.service.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/services/cli.service.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"cli.service.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/services/cli.service.ts"],"names":[],"mappings":"AAUA,MAAM,WAAW,WAAW;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,KAAK,CAAC,EAAE,OAAO,CAAC;CACjB;AAED,qBAAa,UAAU;IACf,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IA0D9C,SAAS,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;CAuD1D;AAED,eAAO,MAAM,UAAU,YAAmB,CAAC"}
|
|
@@ -34,7 +34,8 @@ export class CLIService {
|
|
|
34
34
|
result[key] = syncObject(source[key], target[key] || {});
|
|
35
35
|
}
|
|
36
36
|
else if (target[key] === undefined) {
|
|
37
|
-
|
|
37
|
+
// Let empty string indicate untranslated state
|
|
38
|
+
result[key] = typeof source[key] === "string" ? "" : source[key];
|
|
38
39
|
}
|
|
39
40
|
}
|
|
40
41
|
// Remove extra keys
|
|
@@ -80,7 +81,7 @@ export class CLIService {
|
|
|
80
81
|
await googleTranslateService.translateObject(baseData, targetData, langCode.split("-")[0], // ISO 639-1
|
|
81
82
|
"", stats, (key, from, to) => {
|
|
82
83
|
process.stdout.write(chalk.gray(` • ${key}: ${from.substring(0, 15)}... → ${to.substring(0, 15)}...\r`));
|
|
83
|
-
});
|
|
84
|
+
}, options.force);
|
|
84
85
|
if (stats.successCount > 0) {
|
|
85
86
|
fs.writeFileSync(targetPath, generateTypeScriptContent(targetData, langCode));
|
|
86
87
|
console.log(chalk.green(` ✅ Successfully translated ${stats.successCount} keys.`));
|
|
@@ -12,7 +12,7 @@ declare class GoogleTranslateService implements ITranslationService {
|
|
|
12
12
|
private ensureInitialized;
|
|
13
13
|
translate(request: TranslationRequest): Promise<TranslationResponse>;
|
|
14
14
|
translateBatch(requests: TranslationRequest[]): Promise<TranslationStats>;
|
|
15
|
-
translateObject(sourceObject: Record<string, unknown>, targetObject: Record<string, unknown>, targetLanguage: string, path?: string, stats?: TranslationStats, onTranslate?: (key: string, from: string, to: string) => void): Promise<void>;
|
|
15
|
+
translateObject(sourceObject: Record<string, unknown>, targetObject: Record<string, unknown>, targetLanguage: string, path?: string, stats?: TranslationStats, onTranslate?: (key: string, from: string, to: string) => void, force?: boolean): Promise<void>;
|
|
16
16
|
private callTranslateAPI;
|
|
17
17
|
}
|
|
18
18
|
export declare const googleTranslateService: GoogleTranslateService;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"google-translate.service.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/services/google-translate.service.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EACV,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,EACjB,MAAM,6CAA6C,CAAC;AACrD,OAAO,KAAK,EACV,mBAAmB,EACnB,wBAAwB,EACzB,MAAM,0DAA0D,CAAC;AAalE,cAAM,sBAAuB,YAAW,mBAAmB;IACzD,OAAO,CAAC,MAAM,CAAyC;IACvD,OAAO,CAAC,WAAW,CAA4B;IAE/C,UAAU,CAAC,MAAM,EAAE,wBAAwB,GAAG,IAAI;IASlD,aAAa,IAAI,OAAO;IAIxB,OAAO,CAAC,iBAAiB;IAQnB,SAAS,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAsDpE,cAAc,CAAC,QAAQ,EAAE,kBAAkB,EAAE,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAyDzE,eAAe,CACnB,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACrC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACrC,cAAc,EAAE,MAAM,EACtB,IAAI,SAAK,EACT,KAAK,GAAE,gBAMN,EACD,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,KAAK,IAAI,
|
|
1
|
+
{"version":3,"file":"google-translate.service.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/services/google-translate.service.ts"],"names":[],"mappings":"AAAA;;;GAGG;AAEH,OAAO,KAAK,EACV,kBAAkB,EAClB,mBAAmB,EACnB,gBAAgB,EACjB,MAAM,6CAA6C,CAAC;AACrD,OAAO,KAAK,EACV,mBAAmB,EACnB,wBAAwB,EACzB,MAAM,0DAA0D,CAAC;AAalE,cAAM,sBAAuB,YAAW,mBAAmB;IACzD,OAAO,CAAC,MAAM,CAAyC;IACvD,OAAO,CAAC,WAAW,CAA4B;IAE/C,UAAU,CAAC,MAAM,EAAE,wBAAwB,GAAG,IAAI;IASlD,aAAa,IAAI,OAAO;IAIxB,OAAO,CAAC,iBAAiB;IAQnB,SAAS,CAAC,OAAO,EAAE,kBAAkB,GAAG,OAAO,CAAC,mBAAmB,CAAC;IAsDpE,cAAc,CAAC,QAAQ,EAAE,kBAAkB,EAAE,GAAG,OAAO,CAAC,gBAAgB,CAAC;IAyDzE,eAAe,CACnB,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACrC,YAAY,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EACrC,cAAc,EAAE,MAAM,EACtB,IAAI,SAAK,EACT,KAAK,GAAE,gBAMN,EACD,WAAW,CAAC,EAAE,CAAC,GAAG,EAAE,MAAM,EAAE,IAAI,EAAE,MAAM,EAAE,EAAE,EAAE,MAAM,KAAK,IAAI,EAC7D,KAAK,UAAQ,GACZ,OAAO,CAAC,IAAI,CAAC;YAiEF,gBAAgB;CAkF/B;AAED,eAAO,MAAM,sBAAsB,wBAA+B,CAAC;AACnE,OAAO,EAAE,sBAAsB,EAAE,CAAC"}
|
|
@@ -118,7 +118,7 @@ class GoogleTranslateService {
|
|
|
118
118
|
failureCount: 0,
|
|
119
119
|
skippedCount: 0,
|
|
120
120
|
translatedKeys: [],
|
|
121
|
-
}, onTranslate) {
|
|
121
|
+
}, onTranslate, force = false) {
|
|
122
122
|
if (!sourceObject || typeof sourceObject !== "object")
|
|
123
123
|
return;
|
|
124
124
|
if (!targetObject || typeof targetObject !== "object")
|
|
@@ -135,11 +135,11 @@ class GoogleTranslateService {
|
|
|
135
135
|
if (!targetObject[key] || typeof targetObject[key] !== "object") {
|
|
136
136
|
targetObject[key] = {};
|
|
137
137
|
}
|
|
138
|
-
await this.translateObject(enValue, targetObject[key], targetLanguage, currentPath, stats, onTranslate);
|
|
138
|
+
await this.translateObject(enValue, targetObject[key], targetLanguage, currentPath, stats, onTranslate, force);
|
|
139
139
|
}
|
|
140
140
|
else if (typeof enValue === "string") {
|
|
141
141
|
stats.totalCount++;
|
|
142
|
-
if (needsTranslation(targetValue, enValue)) {
|
|
142
|
+
if (force || needsTranslation(targetValue, enValue)) {
|
|
143
143
|
textsToTranslate.push({ key, enValue, currentPath });
|
|
144
144
|
}
|
|
145
145
|
else {
|
|
@@ -161,41 +161,83 @@ class GoogleTranslateService {
|
|
|
161
161
|
const translatedItem = results.translatedKeys[resultIndex];
|
|
162
162
|
if (translatedItem && translatedItem.from === enValue && translatedItem.to !== enValue) {
|
|
163
163
|
targetObject[key] = translatedItem.to;
|
|
164
|
+
stats.successCount++;
|
|
164
165
|
if (onTranslate)
|
|
165
166
|
onTranslate(currentPath, enValue, translatedItem.to);
|
|
166
167
|
resultIndex++;
|
|
167
168
|
}
|
|
169
|
+
else {
|
|
170
|
+
stats.failureCount++;
|
|
171
|
+
}
|
|
168
172
|
}
|
|
169
173
|
}
|
|
170
174
|
}
|
|
171
175
|
}
|
|
172
|
-
async callTranslateAPI(text, targetLanguage, sourceLanguage) {
|
|
176
|
+
async callTranslateAPI(text, targetLanguage, sourceLanguage, retries = 3, backoffMs = 2000) {
|
|
177
|
+
// 1. Variable Protection (Extract {{variables}})
|
|
178
|
+
const varMap = new Map();
|
|
179
|
+
let counter = 0;
|
|
180
|
+
// Find all {{something}} patterns
|
|
181
|
+
let safeText = text.replace(/\{\{([^}]+)\}\}/g, (match) => {
|
|
182
|
+
const placeholder = `_VAR${counter}_`; // Using a simple token less likely to be split
|
|
183
|
+
varMap.set(placeholder, match);
|
|
184
|
+
counter++;
|
|
185
|
+
return placeholder;
|
|
186
|
+
});
|
|
173
187
|
const timeout = this.config?.timeout || DEFAULT_TIMEOUT;
|
|
174
|
-
const encodedText = encodeURIComponent(
|
|
188
|
+
const encodedText = encodeURIComponent(safeText);
|
|
175
189
|
const url = `${GOOGLE_TRANSLATE_API_URL}?client=gtx&sl=${sourceLanguage}&tl=${targetLanguage}&dt=t&q=${encodedText}`;
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
190
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
191
|
+
const controller = new AbortController();
|
|
192
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
193
|
+
try {
|
|
194
|
+
const response = await fetch(url, {
|
|
195
|
+
signal: controller.signal,
|
|
196
|
+
});
|
|
197
|
+
if (!response.ok) {
|
|
198
|
+
if (response.status === 429 || response.status >= 500) {
|
|
199
|
+
if (attempt < retries) {
|
|
200
|
+
clearTimeout(timeoutId);
|
|
201
|
+
// Exponential backoff
|
|
202
|
+
const delay = backoffMs * Math.pow(2, attempt);
|
|
203
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
204
|
+
continue;
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
throw new Error(`API request failed: ${response.status}`);
|
|
208
|
+
}
|
|
209
|
+
const data = await response.json();
|
|
210
|
+
let translatedStr = safeText;
|
|
211
|
+
if (Array.isArray(data) &&
|
|
212
|
+
data.length > 0 &&
|
|
213
|
+
Array.isArray(data[0]) &&
|
|
214
|
+
data[0].length > 0 &&
|
|
215
|
+
typeof data[0][0][0] === "string") {
|
|
216
|
+
translatedStr = data[0].map((item) => item[0]).join('');
|
|
217
|
+
}
|
|
218
|
+
// 2. Re-inject Variables
|
|
219
|
+
if (varMap.size > 0) {
|
|
220
|
+
// Sometimes Google adds spaces, like _VAR0_ -> _ VAR0 _
|
|
221
|
+
for (const [placeholder, originalVar] of varMap.entries()) {
|
|
222
|
+
const regex = new RegExp(placeholder.split('').join('\\s*'), 'g');
|
|
223
|
+
translatedStr = translatedStr.replace(regex, originalVar);
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
return translatedStr;
|
|
184
227
|
}
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
228
|
+
catch (error) {
|
|
229
|
+
clearTimeout(timeoutId);
|
|
230
|
+
if (attempt === retries) {
|
|
231
|
+
throw error;
|
|
232
|
+
}
|
|
233
|
+
const delay = backoffMs * Math.pow(2, attempt);
|
|
234
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
235
|
+
}
|
|
236
|
+
finally {
|
|
237
|
+
clearTimeout(timeoutId);
|
|
193
238
|
}
|
|
194
|
-
return text;
|
|
195
|
-
}
|
|
196
|
-
finally {
|
|
197
|
-
clearTimeout(timeoutId);
|
|
198
239
|
}
|
|
240
|
+
return text;
|
|
199
241
|
}
|
|
200
242
|
}
|
|
201
243
|
export const googleTranslateService = new GoogleTranslateService();
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"file.util.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/utils/file.util.ts"],"names":[],"mappings":"
|
|
1
|
+
{"version":3,"file":"file.util.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/utils/file.util.ts"],"names":[],"mappings":"AAEA;;;GAGG;AACH,wBAAgB,mBAAmB,CAAC,QAAQ,EAAE,MAAM,GAAG,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAoB7E;AAED;;GAEG;AACH,wBAAgB,yBAAyB,CAAC,GAAG,EAAE,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,EAAE,QAAQ,CAAC,EAAE,MAAM,GAAG,MAAM,CAajG"}
|
|
@@ -26,9 +26,8 @@ export function shouldSkipWord(text) {
|
|
|
26
26
|
export function needsTranslation(targetValue, sourceValue) {
|
|
27
27
|
if (typeof targetValue !== "string")
|
|
28
28
|
return true;
|
|
29
|
-
if (targetValue.
|
|
30
|
-
return true;
|
|
31
|
-
if
|
|
32
|
-
return true;
|
|
29
|
+
if (targetValue.length === 0)
|
|
30
|
+
return true; // Empty string means untranslated
|
|
31
|
+
// Do NOT return true if target === source anymore, to avoid infinite translations for words that are identical in both languages
|
|
33
32
|
return false;
|
|
34
33
|
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import i18n from 'i18next';
|
|
2
|
+
export interface SetupI18nOptions {
|
|
3
|
+
resources: Record<string, {
|
|
4
|
+
translation: any;
|
|
5
|
+
}>;
|
|
6
|
+
defaultLng?: string;
|
|
7
|
+
fallbackLng?: string;
|
|
8
|
+
onInit?: (instance: typeof i18n) => void;
|
|
9
|
+
detection?: any;
|
|
10
|
+
seo?: {
|
|
11
|
+
titleKey: string;
|
|
12
|
+
descriptionKey: string;
|
|
13
|
+
defaultImage?: string;
|
|
14
|
+
twitterHandle?: string;
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
/**
|
|
18
|
+
* Static i18n initialization to simplify main app code.
|
|
19
|
+
* @description All common configuration including SEO integration is hidden inside this package.
|
|
20
|
+
*/
|
|
21
|
+
export declare function setupI18n(options: SetupI18nOptions): import("i18next").i18n;
|
|
22
|
+
export default i18n;
|
|
23
|
+
//# sourceMappingURL=i18n.setup.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"i18n.setup.d.ts","sourceRoot":"","sources":["../../src/integrations/i18n.setup.ts"],"names":[],"mappings":"AAAA,OAAO,IAAI,MAAM,SAAS,CAAC;AAK3B,MAAM,WAAW,gBAAgB;IAC/B,SAAS,EAAE,MAAM,CAAC,MAAM,EAAE;QAAE,WAAW,EAAE,GAAG,CAAA;KAAE,CAAC,CAAC;IAChD,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,WAAW,CAAC,EAAE,MAAM,CAAC;IACrB,MAAM,CAAC,EAAE,CAAC,QAAQ,EAAE,OAAO,IAAI,KAAK,IAAI,CAAC;IACzC,SAAS,CAAC,EAAE,GAAG,CAAC;IAChB,GAAG,CAAC,EAAE;QACJ,QAAQ,EAAE,MAAM,CAAC;QACjB,cAAc,EAAE,MAAM,CAAC;QACvB,YAAY,CAAC,EAAE,MAAM,CAAC;QACtB,aAAa,CAAC,EAAE,MAAM,CAAC;KACxB,CAAC;CACH;AAED;;;GAGG;AACH,wBAAgB,SAAS,CAAC,OAAO,EAAE,gBAAgB,0BAoClD;AAED,eAAe,IAAI,CAAC"}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import i18n from 'i18next';
|
|
2
|
+
import { initReactI18next } from 'react-i18next';
|
|
3
|
+
import LanguageDetector from 'i18next-browser-languagedetector';
|
|
4
|
+
import { initSEO } from '@umituz/web-seo';
|
|
5
|
+
/**
|
|
6
|
+
* Static i18n initialization to simplify main app code.
|
|
7
|
+
* @description All common configuration including SEO integration is hidden inside this package.
|
|
8
|
+
*/
|
|
9
|
+
export function setupI18n(options) {
|
|
10
|
+
const { resources, defaultLng = 'en-US', fallbackLng = 'en-US', onInit, detection = {
|
|
11
|
+
order: ['localStorage', 'navigator'],
|
|
12
|
+
caches: ['localStorage'],
|
|
13
|
+
}, seo, } = options;
|
|
14
|
+
i18n
|
|
15
|
+
.use(LanguageDetector)
|
|
16
|
+
.use(initReactI18next)
|
|
17
|
+
.init({
|
|
18
|
+
resources,
|
|
19
|
+
lng: defaultLng,
|
|
20
|
+
fallbackLng,
|
|
21
|
+
interpolation: {
|
|
22
|
+
escapeValue: false,
|
|
23
|
+
},
|
|
24
|
+
detection,
|
|
25
|
+
})
|
|
26
|
+
.then(() => {
|
|
27
|
+
if (seo) {
|
|
28
|
+
initSEO({
|
|
29
|
+
i18n,
|
|
30
|
+
...seo,
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
if (onInit)
|
|
34
|
+
onInit(i18n);
|
|
35
|
+
});
|
|
36
|
+
return i18n;
|
|
37
|
+
}
|
|
38
|
+
export default i18n;
|
package/dist/scripts/cli.js
CHANGED
|
@@ -29,6 +29,7 @@ program
|
|
|
29
29
|
.description("Automatically translate missing strings using Google Translate")
|
|
30
30
|
.option("-d, --locales-dir <dir>", "Directory containing locale files", "src/locales")
|
|
31
31
|
.option("-b, --base-lang <lang>", "Base language code", "en-US")
|
|
32
|
+
.option("-f, --force", "Force re-translation of all strings", false)
|
|
32
33
|
.action(async (options) => {
|
|
33
34
|
try {
|
|
34
35
|
await cliService.translate(options);
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@umituz/web-localization",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.1.2",
|
|
4
4
|
"description": "Google Translate integrated localization package for web applications",
|
|
5
5
|
"main": "./src/index.ts",
|
|
6
6
|
"types": "./src/index.ts",
|
|
@@ -13,6 +13,7 @@
|
|
|
13
13
|
".": "./src/index.ts",
|
|
14
14
|
"./services": "./src/infrastructure/services/index.ts",
|
|
15
15
|
"./utils": "./src/infrastructure/utils/index.ts",
|
|
16
|
+
"./setup": "./src/integrations/i18n.setup.ts",
|
|
16
17
|
"./package.json": "./package.json"
|
|
17
18
|
},
|
|
18
19
|
"scripts": {
|
|
@@ -35,16 +36,21 @@
|
|
|
35
36
|
"access": "public"
|
|
36
37
|
},
|
|
37
38
|
"dependencies": {
|
|
38
|
-
"commander": "^12.0.0",
|
|
39
39
|
"chalk": "^5.3.0",
|
|
40
|
-
"
|
|
40
|
+
"commander": "^12.0.0",
|
|
41
|
+
"dotenv": "^16.4.5",
|
|
42
|
+
"ts-morph": "^27.0.2",
|
|
43
|
+
"i18next": "^23.11.2",
|
|
44
|
+
"react-i18next": "^14.1.1",
|
|
45
|
+
"i18next-browser-languagedetector": "^7.2.1",
|
|
46
|
+
"@umituz/web-seo": "file:../web-seo"
|
|
41
47
|
},
|
|
42
48
|
"devDependencies": {
|
|
43
49
|
"@types/node": "^20.12.7",
|
|
44
|
-
"typescript": "^
|
|
45
|
-
"eslint": "^8.57.0",
|
|
50
|
+
"@typescript-eslint/eslint-plugin": "^7.7.0",
|
|
46
51
|
"@typescript-eslint/parser": "^7.7.0",
|
|
47
|
-
"
|
|
52
|
+
"eslint": "^8.57.0",
|
|
53
|
+
"typescript": "^5.4.5"
|
|
48
54
|
},
|
|
49
55
|
"files": [
|
|
50
56
|
"src",
|
package/src/index.ts
CHANGED
|
@@ -9,3 +9,4 @@ export * from "./infrastructure/services/google-translate.service";
|
|
|
9
9
|
export * from "./infrastructure/constants";
|
|
10
10
|
export * from "./infrastructure/utils/text-validator.util";
|
|
11
11
|
export * from "./infrastructure/utils/rate-limit.util";
|
|
12
|
+
export * from "./integrations/i18n.setup";
|
|
@@ -12,6 +12,7 @@ export interface SyncOptions {
|
|
|
12
12
|
localesDir?: string;
|
|
13
13
|
sourceDir?: string;
|
|
14
14
|
baseLang?: string;
|
|
15
|
+
force?: boolean;
|
|
15
16
|
}
|
|
16
17
|
|
|
17
18
|
export class CLIService {
|
|
@@ -52,7 +53,8 @@ export class CLIService {
|
|
|
52
53
|
(target[key] as Record<string, unknown>) || {}
|
|
53
54
|
);
|
|
54
55
|
} else if (target[key] === undefined) {
|
|
55
|
-
|
|
56
|
+
// Let empty string indicate untranslated state
|
|
57
|
+
result[key] = typeof source[key] === "string" ? "" : source[key];
|
|
56
58
|
}
|
|
57
59
|
}
|
|
58
60
|
// Remove extra keys
|
|
@@ -113,7 +115,8 @@ export class CLIService {
|
|
|
113
115
|
stats,
|
|
114
116
|
(key, from, to) => {
|
|
115
117
|
process.stdout.write(chalk.gray(` • ${key}: ${from.substring(0, 15)}... → ${to.substring(0, 15)}...\r`));
|
|
116
|
-
}
|
|
118
|
+
},
|
|
119
|
+
options.force
|
|
117
120
|
);
|
|
118
121
|
|
|
119
122
|
if (stats.successCount > 0) {
|
|
@@ -172,7 +172,8 @@ class GoogleTranslateService implements ITranslationService {
|
|
|
172
172
|
skippedCount: 0,
|
|
173
173
|
translatedKeys: [],
|
|
174
174
|
},
|
|
175
|
-
onTranslate?: (key: string, from: string, to: string) => void
|
|
175
|
+
onTranslate?: (key: string, from: string, to: string) => void,
|
|
176
|
+
force = false
|
|
176
177
|
): Promise<void> {
|
|
177
178
|
if (!sourceObject || typeof sourceObject !== "object") return;
|
|
178
179
|
if (!targetObject || typeof targetObject !== "object") return;
|
|
@@ -196,11 +197,12 @@ class GoogleTranslateService implements ITranslationService {
|
|
|
196
197
|
targetLanguage,
|
|
197
198
|
currentPath,
|
|
198
199
|
stats,
|
|
199
|
-
onTranslate
|
|
200
|
+
onTranslate,
|
|
201
|
+
force
|
|
200
202
|
);
|
|
201
203
|
} else if (typeof enValue === "string") {
|
|
202
204
|
stats.totalCount++;
|
|
203
|
-
if (needsTranslation(targetValue, enValue)) {
|
|
205
|
+
if (force || needsTranslation(targetValue, enValue)) {
|
|
204
206
|
textsToTranslate.push({key, enValue, currentPath});
|
|
205
207
|
} else {
|
|
206
208
|
stats.skippedCount++;
|
|
@@ -226,8 +228,11 @@ class GoogleTranslateService implements ITranslationService {
|
|
|
226
228
|
|
|
227
229
|
if (translatedItem && translatedItem.from === enValue && translatedItem.to !== enValue) {
|
|
228
230
|
targetObject[key] = translatedItem.to;
|
|
231
|
+
stats.successCount++;
|
|
229
232
|
if (onTranslate) onTranslate(currentPath, enValue, translatedItem.to);
|
|
230
233
|
resultIndex++;
|
|
234
|
+
} else {
|
|
235
|
+
stats.failureCount++;
|
|
231
236
|
}
|
|
232
237
|
}
|
|
233
238
|
}
|
|
@@ -237,41 +242,84 @@ class GoogleTranslateService implements ITranslationService {
|
|
|
237
242
|
private async callTranslateAPI(
|
|
238
243
|
text: string,
|
|
239
244
|
targetLanguage: string,
|
|
240
|
-
sourceLanguage: string
|
|
245
|
+
sourceLanguage: string,
|
|
246
|
+
retries = 3,
|
|
247
|
+
backoffMs = 2000
|
|
241
248
|
): Promise<string> {
|
|
249
|
+
// 1. Variable Protection (Extract {{variables}})
|
|
250
|
+
const varMap = new Map<string, string>();
|
|
251
|
+
let counter = 0;
|
|
252
|
+
|
|
253
|
+
// Find all {{something}} patterns
|
|
254
|
+
let safeText = text.replace(/\{\{([^}]+)\}\}/g, (match) => {
|
|
255
|
+
const placeholder = `_VAR${counter}_`; // Using a simple token less likely to be split
|
|
256
|
+
varMap.set(placeholder, match);
|
|
257
|
+
counter++;
|
|
258
|
+
return placeholder;
|
|
259
|
+
});
|
|
260
|
+
|
|
242
261
|
const timeout = this.config?.timeout || DEFAULT_TIMEOUT;
|
|
243
|
-
const encodedText = encodeURIComponent(
|
|
262
|
+
const encodedText = encodeURIComponent(safeText);
|
|
244
263
|
const url = `${GOOGLE_TRANSLATE_API_URL}?client=gtx&sl=${sourceLanguage}&tl=${targetLanguage}&dt=t&q=${encodedText}`;
|
|
245
264
|
|
|
246
|
-
|
|
247
|
-
|
|
265
|
+
for (let attempt = 0; attempt <= retries; attempt++) {
|
|
266
|
+
const controller = new AbortController();
|
|
267
|
+
const timeoutId = setTimeout(() => controller.abort(), timeout);
|
|
268
|
+
|
|
269
|
+
try {
|
|
270
|
+
const response = await fetch(url, {
|
|
271
|
+
signal: controller.signal,
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
if (!response.ok) {
|
|
275
|
+
if (response.status === 429 || response.status >= 500) {
|
|
276
|
+
if (attempt < retries) {
|
|
277
|
+
clearTimeout(timeoutId);
|
|
278
|
+
// Exponential backoff
|
|
279
|
+
const delay = backoffMs * Math.pow(2, attempt);
|
|
280
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
281
|
+
continue;
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
throw new Error(`API request failed: ${response.status}`);
|
|
285
|
+
}
|
|
248
286
|
|
|
249
|
-
|
|
250
|
-
const response = await fetch(url, {
|
|
251
|
-
signal: controller.signal,
|
|
252
|
-
});
|
|
287
|
+
const data = await response.json();
|
|
253
288
|
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
289
|
+
let translatedStr = safeText;
|
|
290
|
+
if (
|
|
291
|
+
Array.isArray(data) &&
|
|
292
|
+
data.length > 0 &&
|
|
293
|
+
Array.isArray(data[0]) &&
|
|
294
|
+
data[0].length > 0 &&
|
|
295
|
+
typeof data[0][0][0] === "string"
|
|
296
|
+
) {
|
|
297
|
+
translatedStr = data[0].map((item: any) => item[0]).join('');
|
|
298
|
+
}
|
|
257
299
|
|
|
258
|
-
|
|
300
|
+
// 2. Re-inject Variables
|
|
301
|
+
if (varMap.size > 0) {
|
|
302
|
+
// Sometimes Google adds spaces, like _VAR0_ -> _ VAR0 _
|
|
303
|
+
for (const [placeholder, originalVar] of varMap.entries()) {
|
|
304
|
+
const regex = new RegExp(placeholder.split('').join('\\s*'), 'g');
|
|
305
|
+
translatedStr = translatedStr.replace(regex, originalVar);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
259
308
|
|
|
260
|
-
|
|
261
|
-
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
309
|
+
return translatedStr;
|
|
310
|
+
} catch (error) {
|
|
311
|
+
clearTimeout(timeoutId);
|
|
312
|
+
if (attempt === retries) {
|
|
313
|
+
throw error;
|
|
314
|
+
}
|
|
315
|
+
const delay = backoffMs * Math.pow(2, attempt);
|
|
316
|
+
await new Promise(resolve => setTimeout(resolve, delay));
|
|
317
|
+
} finally {
|
|
318
|
+
clearTimeout(timeoutId);
|
|
269
319
|
}
|
|
270
|
-
|
|
271
|
-
return text;
|
|
272
|
-
} finally {
|
|
273
|
-
clearTimeout(timeoutId);
|
|
274
320
|
}
|
|
321
|
+
|
|
322
|
+
return text;
|
|
275
323
|
}
|
|
276
324
|
}
|
|
277
325
|
|
|
@@ -25,7 +25,7 @@ export function shouldSkipWord(text: string): boolean {
|
|
|
25
25
|
*/
|
|
26
26
|
export function needsTranslation(targetValue: unknown, sourceValue: string): boolean {
|
|
27
27
|
if (typeof targetValue !== "string") return true;
|
|
28
|
-
if (targetValue.
|
|
29
|
-
if
|
|
28
|
+
if (targetValue.length === 0) return true; // Empty string means untranslated
|
|
29
|
+
// Do NOT return true if target === source anymore, to avoid infinite translations for words that are identical in both languages
|
|
30
30
|
return false;
|
|
31
31
|
}
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
import i18n from 'i18next';
|
|
2
|
+
import { initReactI18next } from 'react-i18next';
|
|
3
|
+
import LanguageDetector from 'i18next-browser-languagedetector';
|
|
4
|
+
import { initSEO } from '@umituz/web-seo';
|
|
5
|
+
|
|
6
|
+
export interface SetupI18nOptions {
|
|
7
|
+
resources: Record<string, { translation: any }>;
|
|
8
|
+
defaultLng?: string;
|
|
9
|
+
fallbackLng?: string;
|
|
10
|
+
onInit?: (instance: typeof i18n) => void;
|
|
11
|
+
detection?: any;
|
|
12
|
+
seo?: {
|
|
13
|
+
titleKey: string;
|
|
14
|
+
descriptionKey: string;
|
|
15
|
+
defaultImage?: string;
|
|
16
|
+
twitterHandle?: string;
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Static i18n initialization to simplify main app code.
|
|
22
|
+
* @description All common configuration including SEO integration is hidden inside this package.
|
|
23
|
+
*/
|
|
24
|
+
export function setupI18n(options: SetupI18nOptions) {
|
|
25
|
+
const {
|
|
26
|
+
resources,
|
|
27
|
+
defaultLng = 'en-US',
|
|
28
|
+
fallbackLng = 'en-US',
|
|
29
|
+
onInit,
|
|
30
|
+
detection = {
|
|
31
|
+
order: ['localStorage', 'navigator'],
|
|
32
|
+
caches: ['localStorage'],
|
|
33
|
+
},
|
|
34
|
+
seo,
|
|
35
|
+
} = options;
|
|
36
|
+
|
|
37
|
+
i18n
|
|
38
|
+
.use(LanguageDetector)
|
|
39
|
+
.use(initReactI18next)
|
|
40
|
+
.init({
|
|
41
|
+
resources,
|
|
42
|
+
lng: defaultLng,
|
|
43
|
+
fallbackLng,
|
|
44
|
+
interpolation: {
|
|
45
|
+
escapeValue: false,
|
|
46
|
+
},
|
|
47
|
+
detection,
|
|
48
|
+
})
|
|
49
|
+
.then(() => {
|
|
50
|
+
if (seo) {
|
|
51
|
+
initSEO({
|
|
52
|
+
i18n,
|
|
53
|
+
...seo,
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
if (onInit) onInit(i18n);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
return i18n;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
export default i18n;
|
package/src/scripts/cli.ts
CHANGED
|
@@ -34,6 +34,7 @@ program
|
|
|
34
34
|
.description("Automatically translate missing strings using Google Translate")
|
|
35
35
|
.option("-d, --locales-dir <dir>", "Directory containing locale files", "src/locales")
|
|
36
36
|
.option("-b, --base-lang <lang>", "Base language code", "en-US")
|
|
37
|
+
.option("-f, --force", "Force re-translation of all strings", false)
|
|
37
38
|
.action(async (options) => {
|
|
38
39
|
try {
|
|
39
40
|
await cliService.translate(options);
|