i18ntk 3.2.0 → 4.0.0

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.
@@ -161,9 +161,14 @@ class I18nCompletionTool {
161
161
  return lowered.startsWith('backup-') || lowered === 'backup' || lowered === 'reports' || lowered === 'i18ntk-reports';
162
162
  }
163
163
 
164
- // Get all JSON files from a language directory
165
- getLanguageFiles(language) {
166
- const languageDir = path.join(this.sourceDir, language);
164
+ // Get all JSON files from a language directory
165
+ getLanguageFiles(language) {
166
+ const monolithFile = path.join(this.sourceDir, `${language}.json`);
167
+ if (SecurityUtils.safeExistsSync(monolithFile, this.config.projectRoot)) {
168
+ return [`${language}.json`];
169
+ }
170
+
171
+ const languageDir = path.join(this.sourceDir, language);
167
172
 
168
173
  if (!SecurityUtils.safeExistsSync(languageDir, this.config.projectRoot)) {
169
174
  return [];
@@ -173,8 +178,20 @@ class I18nCompletionTool {
173
178
  .filter(file => {
174
179
  return file.endsWith('.json') &&
175
180
  !this.config.excludeFiles.includes(file);
176
- });
177
- }
181
+ });
182
+ }
183
+
184
+ getLanguageFilePath(language, fileName) {
185
+ if (fileName === `${language}.json`) {
186
+ return path.join(this.sourceDir, fileName);
187
+ }
188
+
189
+ return path.join(this.sourceDir, language, fileName);
190
+ }
191
+
192
+ usesMonolithFile(language) {
193
+ return SecurityUtils.safeExistsSync(path.join(this.sourceDir, `${language}.json`), this.config.projectRoot);
194
+ }
178
195
 
179
196
  // Parse key path and determine which file it belongs to
180
197
  parseKeyPath(keyPath) {
@@ -238,25 +255,30 @@ class I18nCompletionTool {
238
255
  }
239
256
 
240
257
  // Add missing keys to a language
241
- addMissingKeysToLanguage(language, missingKeys, dryRun = false) {
242
- const languageDir = path.join(this.sourceDir, language);
243
- const changes = [];
258
+ addMissingKeysToLanguage(language, missingKeys, dryRun = false) {
259
+ const languageDir = path.join(this.sourceDir, language);
260
+ const changes = [];
261
+ const usesMonolith = this.usesMonolithFile(language);
244
262
 
245
263
  // Group keys by file
246
264
  const keysByFile = {};
247
265
 
248
- missingKeys.forEach(keyPath => {
249
- const { file, key } = this.parseKeyPath(keyPath);
250
- if (!keysByFile[file]) {
251
- keysByFile[file] = [];
252
- }
266
+ missingKeys.forEach(keyPath => {
267
+ const { file, key } = usesMonolith
268
+ ? { file: `${language}.json`, key: keyPath }
269
+ : this.parseKeyPath(keyPath);
270
+ if (!keysByFile[file]) {
271
+ keysByFile[file] = [];
272
+ }
253
273
  keysByFile[file].push({ keyPath, key });
254
274
  });
255
275
 
256
- // Process each file
257
- for (const [fileName, keys] of Object.entries(keysByFile)) {
258
- const filePath = path.join(languageDir, fileName);
259
- let fileContent = {};
276
+ // Process each file
277
+ for (const [fileName, keys] of Object.entries(keysByFile)) {
278
+ const filePath = usesMonolith
279
+ ? path.join(this.sourceDir, fileName)
280
+ : path.join(languageDir, fileName);
281
+ let fileContent = {};
260
282
 
261
283
  // Load existing file or create new
262
284
  if (SecurityUtils.safeExistsSync(filePath, this.config.projectRoot)) {
@@ -266,12 +288,12 @@ class I18nCompletionTool {
266
288
  console.warn(t("completeTranslations.warning_could_not_parse_filepa", { filePath })); ;
267
289
  fileContent = {};
268
290
  }
269
- } else {
270
- // Create directory if it doesn't exist
271
- if (!SecurityUtils.safeExistsSync(languageDir, this.config.projectRoot)) {
272
- if (!dryRun) {
273
- SecurityUtils.safeMkdirSync(languageDir, this.config.projectRoot, { recursive: true });
274
- }
291
+ } else {
292
+ // Create directory if it doesn't exist
293
+ if (!usesMonolith && !SecurityUtils.safeExistsSync(languageDir, this.config.projectRoot)) {
294
+ if (!dryRun) {
295
+ SecurityUtils.safeMkdirSync(languageDir, this.config.projectRoot, { recursive: true });
296
+ }
275
297
  }
276
298
  }
277
299
 
@@ -303,19 +325,66 @@ class I18nCompletionTool {
303
325
  return changes;
304
326
  }
305
327
 
306
- // Generate appropriate translation value based on key and language
307
- generateTranslationValue(keyPath, language) {
308
- // Generate value from key path for source language
309
- const baseValue = this.generateValueFromKey(keyPath);
310
-
311
- // For source language, use the generated value
312
- if (language === this.config.sourceLanguage) {
313
- return baseValue;
314
- }
315
-
316
- // For other languages, use the not translated marker
317
- return this.config.notTranslatedMarker || 'NOT_TRANSLATED';
318
- }
328
+ // Generate appropriate translation value based on key and language
329
+ generateTranslationValue(keyPath, language) {
330
+ const sourceValue = this.getSourceValueForKeyPath(keyPath);
331
+ const baseValue = typeof sourceValue === 'string' && sourceValue.trim() !== ''
332
+ ? sourceValue
333
+ : this.generateValueFromKey(keyPath);
334
+
335
+ // For source language, use the generated value
336
+ if (language === this.config.sourceLanguage) {
337
+ return baseValue;
338
+ }
339
+
340
+ return `[${language.toUpperCase()}] ${baseValue}`;
341
+ }
342
+
343
+ getNestedValue(obj, keyPath) {
344
+ const keys = String(keyPath || '').split('.');
345
+ let current = obj;
346
+
347
+ for (const key of keys) {
348
+ if (!current || typeof current !== 'object' || !(key in current)) {
349
+ return undefined;
350
+ }
351
+ current = current[key];
352
+ }
353
+
354
+ return current;
355
+ }
356
+
357
+ getSourceValueForKeyPath(keyPath) {
358
+ if (!this.sourceLanguageDir && !this.usesMonolithFile(this.config.sourceLanguage)) {
359
+ return undefined;
360
+ }
361
+
362
+ const sourceFiles = this.getLanguageFiles(this.config.sourceLanguage);
363
+ const keyPathStr = String(keyPath || '');
364
+ const parsed = this.parseKeyPath(keyPathStr);
365
+
366
+ for (const fileName of sourceFiles) {
367
+ const sourceFilePath = this.getLanguageFilePath(this.config.sourceLanguage, fileName);
368
+ try {
369
+ const sourceContent = SecurityUtils.safeParseJSON(SecurityUtils.safeReadFileSync(sourceFilePath, this.config.projectRoot, 'utf8'));
370
+ if (!sourceContent || typeof sourceContent !== 'object') continue;
371
+
372
+ const candidates = [keyPathStr];
373
+ if (fileName === parsed.file) {
374
+ candidates.push(parsed.key);
375
+ }
376
+
377
+ for (const candidate of candidates) {
378
+ const value = this.getNestedValue(sourceContent, candidate);
379
+ if (value !== undefined) return value;
380
+ }
381
+ } catch (error) {
382
+ console.warn(t("complete.couldNotParseSource", { file: sourceFilePath }));
383
+ }
384
+ }
385
+
386
+ return undefined;
387
+ }
319
388
 
320
389
  // Generate a readable value from a key path
321
390
  generateValueFromKey(keyPath) {
@@ -351,18 +420,18 @@ class I18nCompletionTool {
351
420
  }
352
421
 
353
422
  // Get missing keys by comparing source language with target languages
354
- getMissingKeysFromComparison() {
355
- const sourceFiles = this.getLanguageFiles(this.config.sourceLanguage);
356
- const missingKeys = [];
357
-
358
- if (!SecurityUtils.safeExistsSync(this.sourceLanguageDir, this.config.projectRoot)) {
359
- console.log(t("complete.sourceLanguageNotFound", { sourceLanguage: this.config.sourceLanguage }));
360
- return [];
361
- }
362
-
363
- // Process each file in source language
364
- for (const fileName of sourceFiles) {
365
- const sourceFilePath = path.join(this.sourceLanguageDir, fileName);
423
+ getMissingKeysFromComparison() {
424
+ const sourceFiles = this.getLanguageFiles(this.config.sourceLanguage);
425
+ const missingKeys = [];
426
+
427
+ if (sourceFiles.length === 0) {
428
+ console.log(t("complete.sourceLanguageNotFound", { sourceLanguage: this.config.sourceLanguage }));
429
+ return [];
430
+ }
431
+
432
+ // Process each file in source language
433
+ for (const fileName of sourceFiles) {
434
+ const sourceFilePath = this.getLanguageFilePath(this.config.sourceLanguage, fileName);
366
435
 
367
436
  try {
368
437
  const sourceContent = SecurityUtils.safeParseJSON(SecurityUtils.safeReadFileSync(sourceFilePath, this.config.projectRoot, 'utf8'));
@@ -373,7 +442,9 @@ class I18nCompletionTool {
373
442
  for (const language of languages) {
374
443
  if (language === this.config.sourceLanguage) continue;
375
444
 
376
- const targetFilePath = path.join(this.sourceDir, language, fileName);
445
+ const targetFilePath = fileName === `${this.config.sourceLanguage}.json` || this.usesMonolithFile(language)
446
+ ? path.join(this.sourceDir, `${language}.json`)
447
+ : path.join(this.sourceDir, language, fileName);
377
448
  let targetKeys = [];
378
449
 
379
450
  if (SecurityUtils.safeExistsSync(targetFilePath, this.config.projectRoot)) {
@@ -109,49 +109,64 @@ class I18nTextScanner {
109
109
  const args = process.argv.slice(2);
110
110
  const parsed = {};
111
111
 
112
- args.forEach(arg => {
113
- if (arg.startsWith('--')) {
114
- const [key, ...valueParts] = arg.substring(2).split('=');
115
- const value = valueParts.join('=');
116
-
117
- switch (key) {
118
- case 'source-dir':
119
- parsed.sourceDir = value || '';
120
- break;
121
- case 'framework':
122
- parsed.framework = value || '';
123
- break;
124
- case 'patterns':
125
- parsed.patterns = value ? value.split(',').map(p => p.trim()).filter(Boolean) : [];
126
- break;
127
- case 'exclude':
128
- parsed.exclude = value ? value.split(',').map(e => e.trim()).filter(Boolean) : [];
129
- break;
130
- case 'output-dir':
131
- parsed.outputDir = value || '';
132
- break;
133
- case 'min-length':
134
- parsed.minLength = parseInt(value) || 3;
135
- break;
136
- case 'max-length':
137
- parsed.maxLength = parseInt(value) || 100;
138
- break;
139
- case 'output-report':
140
- parsed.outputReport = true;
112
+ for (let i = 0; i < args.length; i++) {
113
+ const arg = args[i];
114
+ if (arg.startsWith('--')) {
115
+ const [key, ...valueParts] = arg.substring(2).split('=');
116
+ let value = valueParts.join('=');
117
+ if (!value && args[i + 1] && !args[i + 1].startsWith('--')) {
118
+ value = args[i + 1];
119
+ }
120
+
121
+ switch (key) {
122
+ case 'source-dir':
123
+ parsed.sourceDir = value || '';
124
+ if (value === args[i + 1]) i++;
125
+ break;
126
+ case 'framework':
127
+ parsed.framework = value || '';
128
+ if (value === args[i + 1]) i++;
129
+ break;
130
+ case 'patterns':
131
+ parsed.patterns = value ? value.split(',').map(p => p.trim()).filter(Boolean) : [];
132
+ if (value === args[i + 1]) i++;
133
+ break;
134
+ case 'exclude':
135
+ parsed.exclude = value ? value.split(',').map(e => e.trim()).filter(Boolean) : [];
136
+ if (value === args[i + 1]) i++;
137
+ break;
138
+ case 'output-dir':
139
+ parsed.outputDir = value || '';
140
+ if (value === args[i + 1]) i++;
141
+ break;
142
+ case 'min-length':
143
+ parsed.minLength = parseInt(value) || 3;
144
+ if (value === args[i + 1]) i++;
145
+ break;
146
+ case 'max-length':
147
+ parsed.maxLength = parseInt(value) || 100;
148
+ if (value === args[i + 1]) i++;
149
+ break;
150
+ case 'output-report':
151
+ parsed.outputReport = true;
141
152
  break;
142
153
  case 'include-tests':
143
- parsed.includeTests = true;
144
- break;
145
- case 'help':
146
- case 'h':
147
- parsed.help = true;
148
- break;
149
- }
150
- }
151
- });
152
-
153
- return parsed;
154
- }
154
+ parsed.includeTests = true;
155
+ break;
156
+ case 'source-language':
157
+ parsed.sourceLanguage = value || '';
158
+ if (value === args[i + 1]) i++;
159
+ break;
160
+ case 'help':
161
+ case 'h':
162
+ parsed.help = true;
163
+ break;
164
+ }
165
+ }
166
+ }
167
+
168
+ return parsed;
169
+ }
155
170
 
156
171
  detectFramework(projectRoot) {
157
172
  const packagePath = path.join(projectRoot, 'package.json');
@@ -293,43 +308,148 @@ class I18nTextScanner {
293
308
  }
294
309
 
295
310
  isEnglishText(text) {
296
- // Enhanced text detection for Unicode and multilingual support
297
311
  const trimmed = text.trim();
298
312
  if (trimmed.length < 3) return false;
299
313
 
300
- // Skip if it's just numbers or special characters
301
314
  if (/^\d+$/.test(trimmed)) return false;
302
315
  if (/^[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>?]+$/.test(trimmed)) return false;
303
316
 
304
- // Allow Unicode characters including CJK, Cyrillic, etc.
305
317
  const validChars = trimmed.match(/[\p{L}\p{N}\s\-,.!?':"()\[\]{}]/gu) || [];
306
318
  const validRatio = validChars.length / trimmed.length;
307
319
 
308
- // Must have at least 50% valid characters and some alphabetic characters
309
320
  const hasAlpha = /[a-zA-Z\u00C0-\u024F\u1E00-\u1EFF\u0400-\u04FF\u4E00-\u9FFF\uAC00-\uD7AF]/u.test(trimmed);
310
321
 
311
322
  return validRatio >= 0.5 && hasAlpha;
312
323
  }
313
324
 
325
+ getLanguageProfile(langCode) {
326
+ const profiles = {
327
+ en: {
328
+ name: 'English',
329
+ charRegex: /[a-zA-Z\u00C0-\u024F]/u,
330
+ stopwords: ['the', 'and', 'for', 'are', 'but', 'not', 'you', 'all', 'can', 'had', 'her', 'was', 'one', 'our', 'out', 'has', 'have', 'from', 'they', 'that', 'with', 'this', 'will', 'your', 'which', 'their', 'them', 'than', 'then', 'been', 'being', 'would', 'should', 'could', 'about', 'after'],
331
+ minLength: 3,
332
+ maxLength: 150
333
+ },
334
+ de: {
335
+ name: 'German',
336
+ charRegex: /[a-zA-Z\u00C0-\u00FF\u0100-\u017F\u00DF\u1E00-\u1EFF]/u,
337
+ stopwords: ['der', 'die', 'das', 'und', 'ist', 'von', 'mit', 'sich', 'des', 'auf', 'dem', 'nicht', 'ein', 'eine', 'auch', 'als', 'aus', 'bei', 'nach', 'wie', 'oder', 'war', 'hat', 'ich', 'sie', 'einem', 'um', 'am', 'im', 'es'],
338
+ minLength: 3,
339
+ maxLength: 180
340
+ },
341
+ fr: {
342
+ name: 'French',
343
+ charRegex: /[a-zA-Z\u00C0-\u00FF\u0152\u0153]/u,
344
+ stopwords: ['le', 'la', 'les', 'des', 'est', 'pas', 'que', 'une', 'dans', 'sur', 'plus', 'par', 'pour', 'avec', 'aux', 'ces', 'ses', 'mes', 'tes', 'notre', 'votre', 'leur', 'dont', 'sont', 'comme', 'mais', 'alors', 'peut', 'tout', 'tous', 'fait'],
345
+ minLength: 3,
346
+ maxLength: 170
347
+ },
348
+ es: {
349
+ name: 'Spanish',
350
+ charRegex: /[a-zA-Z\u00C0-\u00FF\u00F1\u00D1]/u,
351
+ stopwords: ['que', 'los', 'las', 'del', 'como', 'por', 'para', 'con', 'una', 'sus', 'muy', 'más', 'pero', 'este', 'esta', 'hay', 'son', 'eran', 'fue', 'han', 'será', 'está', 'todo', 'otro', 'otra'],
352
+ minLength: 3,
353
+ maxLength: 150
354
+ },
355
+ ja: {
356
+ name: 'Japanese',
357
+ charRegex: /[\u3040-\u309F\u30A0-\u30FF\u4E00-\u9FFF\uFF66-\uFF9F]/u,
358
+ stopwords: ['の', 'に', 'は', 'を', 'た', 'が', 'で', 'て', 'と', 'し', 'れ', 'さ', 'る', 'す', 'ん', 'な', 'い', 'か', 'ま', 'も', 'こ', 'り', 'ち', 'き', 'ょ', 'う'],
359
+ minLength: 2,
360
+ maxLength: 80
361
+ },
362
+ zh: {
363
+ name: 'Chinese',
364
+ charRegex: /[\u4E00-\u9FFF\u3400-\u4DBF\uF900-\uFAFF]/u,
365
+ stopwords: ['的', '是', '在', '不', '了', '有', '和', '人', '这', '中', '大', '为', '上', '个', '国', '我', '以', '要', '他', '时', '来', '用', '们', '生', '到', '作', '地'],
366
+ minLength: 1,
367
+ maxLength: 50
368
+ },
369
+ ru: {
370
+ name: 'Russian',
371
+ charRegex: /[\u0400-\u04FF\u0500-\u052F]/u,
372
+ stopwords: ['и', 'в', 'не', 'на', 'что', 'как', 'по', 'к', 'от', 'это', 'за', 'то', 'для', 'все', 'его', 'она', 'так', 'же', 'но', 'был', 'быть', 'еще', 'уже', 'кто', 'мой', 'ее', 'их', 'из'],
373
+ minLength: 2,
374
+ maxLength: 200
375
+ },
376
+ ko: {
377
+ name: 'Korean',
378
+ charRegex: /[\uAC00-\uD7AF\u1100-\u11FF\u3130-\u318F]/u,
379
+ stopwords: ['이', '그', '저', '것', '수', '등', '들', '및', '년', '월', '일', '에서', '에게', '으로', '보다', '에게서', '의', '에', '는', '은', '가', '를', '과', '와', '도', '만', '까지', '부터'],
380
+ minLength: 1,
381
+ maxLength: 70
382
+ },
383
+ ar: {
384
+ name: 'Arabic',
385
+ charRegex: /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF]/u,
386
+ stopwords: ['في', 'من', 'على', 'عن', 'مع', 'هو', 'هي', 'كان', 'هذا', 'ذلك', 'بين', 'بعد', 'قبل', 'عند', 'حتى', 'الى', 'او', 'لا', 'ما', 'لم', 'لن', 'كل', 'بعض', 'أي'],
387
+ minLength: 2,
388
+ maxLength: 150
389
+ },
390
+ hi: {
391
+ name: 'Hindi',
392
+ charRegex: /[\u0900-\u097F]/u,
393
+ stopwords: ['का', 'की', 'के', 'है', 'हैं', 'था', 'थे', 'होगा', 'होगी', 'में', 'से', 'पर', 'को', 'तक', 'और', 'या', 'लेकिन', 'जब', 'तब', 'कि', 'यह', 'वह', 'एक', 'दो'],
394
+ minLength: 2,
395
+ maxLength: 160
396
+ },
397
+ vanilla: {
398
+ name: 'Generic Latin',
399
+ charRegex: /[a-zA-Z\u00C0-\u024F]/u,
400
+ stopwords: [],
401
+ minLength: 3,
402
+ maxLength: 150
403
+ }
404
+ };
405
+ return profiles[langCode] || profiles.en;
406
+ }
407
+
408
+ isTextInLanguage(text, langCode) {
409
+ const profile = this.getLanguageProfile(langCode);
410
+ const trimmed = text.trim();
411
+
412
+ if (trimmed.length < profile.minLength) return false;
413
+ if (trimmed.length > profile.maxLength) return false;
414
+
415
+ if (/^\d+$/.test(trimmed)) return false;
416
+ if (/^[!@#$%^&*()_+\-=\[\]{};':"\\|,.<>?]+$/.test(trimmed)) return false;
417
+
418
+ const hasScriptChar = profile.charRegex.test(trimmed);
419
+ if (!hasScriptChar) return false;
420
+
421
+ if (profile.stopwords.length > 0) {
422
+ const words = trimmed.toLowerCase().split(/\s+/);
423
+ for (const word of words) {
424
+ if (profile.stopwords.includes(word)) return true;
425
+ }
426
+ const validChars = trimmed.match(/[\p{L}\p{N}\s\-,.!?':"()\[\]{}]/gu) || [];
427
+ const validRatio = validChars.length / trimmed.length;
428
+ return validRatio >= 0.5;
429
+ }
430
+
431
+ return true;
432
+ }
433
+
314
434
  scanFile(filePath, patterns, minLength, maxLength) {
315
435
  try {
316
436
  const content = SecurityUtils.safeReadFileSync(filePath, path.dirname(filePath), 'utf8');
317
437
  const lines = content.split('\n');
318
438
  const results = [];
439
+ const sourceLang = this.config.sourceLanguage || 'en';
319
440
 
320
441
  patterns.forEach(pattern => {
321
442
  let match;
322
443
  while ((match = pattern.exec(content)) !== null) {
323
444
  const text = match[1] || match[0];
324
445
 
325
- // Skip translation function calls
326
446
  const beforeMatch = content.substring(Math.max(0, match.index - 20), match.index);
327
447
  if (beforeMatch.includes('t(') || beforeMatch.includes('i18next.t(') ||
328
448
  beforeMatch.includes('$t(') || beforeMatch.includes('translate(')) {
329
449
  continue;
330
450
  }
331
451
 
332
- if (text && this.isEnglishText(text) &&
452
+ if (text && this.isTextInLanguage(text, sourceLang) &&
333
453
  text.length >= minLength && text.length <= maxLength) {
334
454
 
335
455
  const lineNumber = content.substring(0, match.index).split('\n').length;
@@ -355,7 +475,23 @@ class I18nTextScanner {
355
475
  }
356
476
 
357
477
  generateSuggestion(text) {
358
- const key = text.toLowerCase()
478
+ const sourceLang = this.config.sourceLanguage || 'en';
479
+ const transliterations = {
480
+ ja: { 'あ': 'a', 'い': 'i', 'う': 'u', 'え': 'e', 'お': 'o', 'か': 'ka', 'き': 'ki', 'く': 'ku', 'け': 'ke', 'こ': 'ko', 'さ': 'sa', 'し': 'shi', 'す': 'su', 'せ': 'se', 'そ': 'so', 'た': 'ta', 'ち': 'chi', 'つ': 'tsu', 'て': 'te', 'と': 'to', 'な': 'na', 'に': 'ni', 'ぬ': 'nu', 'ね': 'ne', 'の': 'no', 'は': 'ha', 'ひ': 'hi', 'ふ': 'fu', 'へ': 'he', 'ほ': 'ho', 'ま': 'ma', 'み': 'mi', 'む': 'mu', 'め': 'me', 'も': 'mo', 'や': 'ya', 'ゆ': 'yu', 'よ': 'yo', 'ら': 'ra', 'り': 'ri', 'る': 'ru', 'れ': 're', 'ろ': 'ro', 'わ': 'wa', 'を': 'wo', 'ん': 'n' },
481
+ ru: { 'а': 'a', 'б': 'b', 'в': 'v', 'г': 'g', 'д': 'd', 'е': 'e', 'ё': 'yo', 'ж': 'zh', 'з': 'z', 'и': 'i', 'й': 'y', 'к': 'k', 'л': 'l', 'м': 'm', 'н': 'n', 'о': 'o', 'п': 'p', 'р': 'r', 'с': 's', 'т': 't', 'у': 'u', 'ф': 'f', 'х': 'kh', 'ц': 'ts', 'ч': 'ch', 'ш': 'sh', 'щ': 'sch', 'ъ': '', 'ы': 'y', 'ь': '', 'э': 'e', 'ю': 'yu', 'я': 'ya' },
482
+ zh: { '的': 'de', '一': 'yi', '是': 'shi', '在': 'zai', '不': 'bu', '了': 'le', '有': 'you', '和': 'he', '人': 'ren', '这': 'zhe', '中': 'zhong', '大': 'da', '为': 'wei', '上': 'shang', '个': 'ge', '国': 'guo', '我': 'wo', '以': 'yi_t', '要': 'yao', '他': 'ta', '时': 'shi_t', '来': 'lai', '用': 'yong', '们': 'men', '生': 'sheng', '到': 'dao', '作': 'zuo', '地': 'di' }
483
+ };
484
+
485
+ let transliterated = text;
486
+ const table = transliterations[sourceLang];
487
+ if (table) {
488
+ transliterated = '';
489
+ for (const ch of text) {
490
+ transliterated += table[ch] || ch;
491
+ }
492
+ }
493
+
494
+ const key = transliterated.toLowerCase()
359
495
  .replace(/[^a-z0-9\s]/g, '')
360
496
  .replace(/\s+/g, '_')
361
497
  .substring(0, 50);
@@ -559,6 +695,9 @@ class I18nTextScanner {
559
695
 
560
696
  this.sourceDir = this.config.sourceDir || './src';
561
697
 
698
+ // Source language for multi-language detection
699
+ this.sourceLanguage = args['source-language'] || this.config.sourceLanguage || 'en';
700
+
562
701
  // Resolve framework with precedence: CLI arg > config.framework.preference|string > auto-detect > fallback
563
702
  const cliFramework = args.framework;
564
703
  const cfgFramework = this.config.framework;
@@ -637,4 +776,4 @@ if (require.main === module) {
637
776
  })();
638
777
  }
639
778
 
640
- module.exports = I18nTextScanner;
779
+ module.exports = I18nTextScanner;