@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 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
@@ -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";
@@ -2,6 +2,7 @@ export interface SyncOptions {
2
2
  localesDir?: string;
3
3
  sourceDir?: string;
4
4
  baseLang?: string;
5
+ force?: boolean;
5
6
  }
6
7
  export declare class CLIService {
7
8
  sync(options?: SyncOptions): Promise<void>;
@@ -1 +1 @@
1
- {"version":3,"file":"cli.service.d.ts","sourceRoot":"","sources":["../../../src/infrastructure/services/cli.service.ts"],"names":[],"mappings":"AAWA,MAAM,WAAW,WAAW;IAC1B,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB,QAAQ,CAAC,EAAE,MAAM,CAAC;CACnB;AAED,qBAAa,UAAU;IACf,IAAI,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;IAsD9C,SAAS,CAAC,OAAO,GAAE,WAAgB,GAAG,OAAO,CAAC,IAAI,CAAC;CAsD1D;AAED,eAAO,MAAM,UAAU,YAAmB,CAAC"}
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
- result[key] = source[key];
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,GAC5D,OAAO,CAAC,IAAI,CAAC;YA6DF,gBAAgB;CAuC/B;AAED,eAAO,MAAM,sBAAsB,wBAA+B,CAAC;AACnE,OAAO,EAAE,sBAAsB,EAAE,CAAC"}
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(text);
188
+ const encodedText = encodeURIComponent(safeText);
175
189
  const url = `${GOOGLE_TRANSLATE_API_URL}?client=gtx&sl=${sourceLanguage}&tl=${targetLanguage}&dt=t&q=${encodedText}`;
176
- const controller = new AbortController();
177
- const timeoutId = setTimeout(() => controller.abort(), timeout);
178
- try {
179
- const response = await fetch(url, {
180
- signal: controller.signal,
181
- });
182
- if (!response.ok) {
183
- throw new Error(`API request failed: ${response.status}`);
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
- const data = await response.json();
186
- if (Array.isArray(data) &&
187
- data.length > 0 &&
188
- Array.isArray(data[0]) &&
189
- data[0].length > 0 &&
190
- Array.isArray(data[0][0]) &&
191
- typeof data[0][0][0] === "string") {
192
- return data[0][0][0];
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":"AAGA;;;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"}
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.trim().length === 0)
30
- return true;
31
- if (targetValue === sourceValue && !/^\d+$/.test(sourceValue))
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;
@@ -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.0.6",
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
- "dotenv": "^16.4.5"
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": "^5.4.5",
45
- "eslint": "^8.57.0",
50
+ "@typescript-eslint/eslint-plugin": "^7.7.0",
46
51
  "@typescript-eslint/parser": "^7.7.0",
47
- "@typescript-eslint/eslint-plugin": "^7.7.0"
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
- result[key] = source[key];
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(text);
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
- const controller = new AbortController();
247
- const timeoutId = setTimeout(() => controller.abort(), timeout);
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
- try {
250
- const response = await fetch(url, {
251
- signal: controller.signal,
252
- });
287
+ const data = await response.json();
253
288
 
254
- if (!response.ok) {
255
- throw new Error(`API request failed: ${response.status}`);
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
- const data = await response.json();
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
- if (
261
- Array.isArray(data) &&
262
- data.length > 0 &&
263
- Array.isArray(data[0]) &&
264
- data[0].length > 0 &&
265
- Array.isArray(data[0][0]) &&
266
- typeof data[0][0][0] === "string"
267
- ) {
268
- return data[0][0][0];
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.trim().length === 0) return true;
29
- if (targetValue === sourceValue && !/^\d+$/.test(sourceValue)) return true;
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;
@@ -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);