i18ntk 4.3.3 → 4.4.1

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.
@@ -4,6 +4,9 @@ const KEY_BOUNDARY = /[A-Za-z0-9_.:*-]/;
4
4
  const HUMAN_TEXT_MIN = 3;
5
5
  const HUMAN_TEXT_MAX = 120;
6
6
  const MAX_DYNAMIC_EXPANSIONS = 25;
7
+ const LOCALE_IMPORT_PATTERN = /\bimport\s+(?:\*\s+as\s+)?([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+\.json)['"]/g;
8
+ const LOCALE_REQUIRE_PATTERN = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*['"]([^'"]+\.json)['"]\s*\)/g;
9
+ const LOCALE_PROPERTY_READ = /(?<![\w$'"\/\.-])([A-Za-z_$][\w$]*)\s*\.\s*([A-Za-z_$][\w$]+(?:\s*\.\s*[A-Za-z_$][\w$]+)*)\s*(?:[;,\n\)\]\}])/g;
7
10
 
8
11
  function stripComments(content) {
9
12
  return String(content || '')
@@ -28,6 +31,7 @@ function isBoundaryAt(content, index) {
28
31
  function findLiteralKeyReferences(content, availableKeys) {
29
32
  const source = stripComments(content);
30
33
  const references = [];
34
+ const literalContexts = [];
31
35
 
32
36
  for (const key of Array.from(availableKeys || []).sort((a, b) => b.length - a.length)) {
33
37
  if (!key || typeof key !== 'string') continue;
@@ -43,11 +47,14 @@ function findLiteralKeyReferences(content, availableKeys) {
43
47
  const beforeOnLine = source.slice(lineStart, index);
44
48
  const looksLikeObjectValue = /:\s*['"`]?$/.test(beforeOnLine);
45
49
  if (beforeOk && afterOk && !looksLikeObjectValue) {
50
+ const context = classifyLiteralContext(source, index, key);
46
51
  references.push({
47
52
  key,
48
- matchType: 'literal',
53
+ matchType: context.isTelemetry ? 'literal-telemetry' : 'literal',
49
54
  ...findLineColumn(source, index),
55
+ context,
50
56
  });
57
+ literalContexts.push({ key, index, context });
51
58
  break;
52
59
  }
53
60
 
@@ -58,6 +65,39 @@ function findLiteralKeyReferences(content, availableKeys) {
58
65
  return references;
59
66
  }
60
67
 
68
+ const TELEMETRY_PATTERNS = [
69
+ /\.?\b(trackEvent|emitDomainEvent|emitEvent|track|analytics\.send|analytics\.track|gtag|dataLayer\.push|logEvent)\s*\(\s*['"`]?\s*$/,
70
+ /\.?\b(event|telemetry)\s*[.\s]*['"`]?\s*$/,
71
+ ];
72
+
73
+ function classifyLiteralContext(source, matchIndex, key) {
74
+ const lineStart = source.lastIndexOf('\n', matchIndex) + 1;
75
+ const before = source.slice(lineStart, matchIndex).trimEnd();
76
+
77
+ let isTelemetry = false;
78
+ let containerCall = null;
79
+ let contextNote = null;
80
+
81
+ for (const pattern of TELEMETRY_PATTERNS) {
82
+ if (pattern.test(before)) {
83
+ isTelemetry = true;
84
+ containerCall = before.replace(pattern, '').trim();
85
+ contextNote = 'Appears inside a telemetry/event/analytics call — probably not a translation key.';
86
+ break;
87
+ }
88
+ }
89
+
90
+ if (!isTelemetry) {
91
+ const callMatch = /\.?\b(\w+)\s*\(\s*['"`]?\s*$/.exec(before);
92
+ if (callMatch && callMatch[1] !== 't' && callMatch[1] !== 'tx' && callMatch[1] !== 'translate' && callMatch[1] !== 'i18n' && callMatch[1] !== '$t') {
93
+ containerCall = callMatch[1];
94
+ contextNote = `Appears inside ${containerCall}() — not a recognized translation call.`;
95
+ }
96
+ }
97
+
98
+ return { isTelemetry, containerCall, contextNote };
99
+ }
100
+
61
101
  function parseStringList(raw) {
62
102
  const values = [];
63
103
  const source = String(raw || '');
@@ -206,7 +246,7 @@ function inferDynamicKeyReferences(content, availableKeys) {
206
246
  const bindings = collectSimpleBindings(source);
207
247
  const references = [];
208
248
  const seen = new Set();
209
- const callPattern = /(?:\bt|\btx|\bi18n\.t|\$t|\btranslate)\s*\(\s*(`[^`]*`|['"][^'"\r\n]+['"]|[^,\)\r\n]+)/g;
249
+ const callPattern = /(?:\bt|\btx|\.tx|\bi18n\.t|\$t|\btranslate)\s*\(\s*(`[^`]*`|['"][^'"\r\n]+['"]|[^,\)\r\n]+)/g;
210
250
  let match;
211
251
 
212
252
  while ((match = callPattern.exec(source)) !== null) {
@@ -244,7 +284,7 @@ function findUnresolvedDynamicReferences(content, availableKeys) {
244
284
  const bindings = collectSimpleBindings(source);
245
285
  const unresolved = [];
246
286
  const seen = new Set();
247
- const callPattern = /(?:\bt|\btx|\bi18n\.t|\$t|\btranslate)\s*\(\s*(`[^`]*`|['"][^'"\r\n]+['"]|[^,\)\r\n]+)/g;
287
+ const callPattern = /(?:\bt|\btx|\.tx|\bi18n\.t|\$t|\btranslate)\s*\(\s*(`[^`]*`|['"][^'"\r\n]+['"]|[^,\)\r\n]+)/g;
248
288
  let match;
249
289
 
250
290
  while ((match = callPattern.exec(source)) !== null) {
@@ -372,6 +412,182 @@ function buildNamespaceRecommendation(relativePath, keyReferences, availableKeys
372
412
  };
373
413
  }
374
414
 
415
+ function findImportedLocaleKeys(content) {
416
+ const references = [];
417
+ const imports = new Map();
418
+
419
+ let match;
420
+ const importPattern = /\bimport\s+(?:\*\s+as\s+)?([A-Za-z_$][\w$]*)\s+from\s+['"]([^'"]+\.json)['"]/g;
421
+ while ((match = importPattern.exec(content)) !== null) {
422
+ const specifier = match[2];
423
+ if (/\b(locales?|i18n|translations?)\b/i.test(specifier)) {
424
+ const namespace = specifier.replace(/\\/g, '/').split('/').pop().replace(/\.json$/, '');
425
+ imports.set(match[1], namespace);
426
+ }
427
+ }
428
+
429
+ const requirePattern = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*require\s*\(\s*['"]([^'"]+\.json)['"]\s*\)/g;
430
+ while ((match = requirePattern.exec(content)) !== null) {
431
+ const specifier = match[2];
432
+ if (/\b(locales?|i18n|translations?)\b/i.test(specifier)) {
433
+ const namespace = specifier.replace(/\\/g, '/').split('/').pop().replace(/\.json$/, '');
434
+ imports.set(match[1], namespace);
435
+ }
436
+ }
437
+
438
+ for (const [varName, namespace] of imports) {
439
+ const escaped = varName.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
440
+ const propPattern = new RegExp(`(?<![\\w$'"\/\.-])${escaped}\\s*\\.\\s*([A-Za-z_$][\\w$]+(?:\\s*\\.\\s*[A-Za-z_$][\\w$]+)*)\\s*(?:[;,\\n\\)\\]\\}])`, 'g');
441
+ let propMatch;
442
+ while ((propMatch = propPattern.exec(content)) !== null) {
443
+ const propertyPath = propMatch[1].replace(/\s+/g, '');
444
+ const key = propertyPath.startsWith(namespace + '.') ? propertyPath : `${namespace}.${propertyPath}`;
445
+ const location = findLineColumn(content, propMatch.index);
446
+ references.push({
447
+ key,
448
+ matchType: 'imported-locale',
449
+ line: location.line,
450
+ column: location.column,
451
+ });
452
+ }
453
+ }
454
+
455
+ return references;
456
+ }
457
+
458
+ function findLocalTranslationWrappers(content) {
459
+ const wrappers = new Map();
460
+ const source = stripComments(content);
461
+
462
+ const arrowWrapper = /\b(?:const|let|var)\s+([A-Za-z_$][\w$]*)\s*=\s*(?:\((?:[^)]*)\)\s*=>|(?:async\s+)?\([^)]*\)\s*=>)\s*(?:\{[^}]*\}|\S)/g;
463
+ let match;
464
+ while ((match = arrowWrapper.exec(source)) !== null) {
465
+ const name = match[1];
466
+ if (name === 't' || name === 'tx' || name === 'translate' || name === 'i18n' || name === '$t') continue;
467
+
468
+ const declStart = match.index;
469
+ const declEnd = findMatchingBrace(source, source.indexOf('=>', declStart) + 2);
470
+ const bodyEnd = declEnd > 0 ? declEnd : Math.min(declStart + 500, source.length);
471
+ const body = source.slice(declStart, bodyEnd);
472
+
473
+ if (/\b(?:t|tx|i18n\.t|translate|\$t)\((?:['"`][^'"`\r\n]+['"`]|`|\w+)/.test(body)) {
474
+ const params = extractFirstParam(source, declStart);
475
+ wrappers.set(name, {
476
+ name,
477
+ params,
478
+ line: findLineColumn(source, declStart).line,
479
+ type: 'translation-wrapper',
480
+ });
481
+ }
482
+ }
483
+
484
+ return wrappers;
485
+ }
486
+
487
+ function findMatchingBrace(source, start) {
488
+ let depth = 0;
489
+ for (let i = start; i < Math.min(start + 1000, source.length); i++) {
490
+ if (source[i] === '{') depth++;
491
+ if (source[i] === '}') {
492
+ depth--;
493
+ if (depth === 0) return i;
494
+ }
495
+ }
496
+ return -1;
497
+ }
498
+
499
+ function extractFirstParam(source, declStart) {
500
+ const afterEquals = source.slice(declStart).match(/=\s*(?:async\s+)?(?:\(([^)]*)\)|([A-Za-z_$][\w$]*)\s*=>)/);
501
+ if (!afterEquals) return ['key'];
502
+ const params = (afterEquals[1] || afterEquals[2] || 'key').split(',').map(p => p.trim().split(/\s|=|:/)[0]).filter(Boolean);
503
+ return params.length > 0 ? params : ['key'];
504
+ }
505
+
506
+ function findLocalWrapperCallReferences(content, wrappers, availableKeys) {
507
+ const source = stripComments(content);
508
+ const references = [];
509
+
510
+ for (const [name, wrapper] of wrappers) {
511
+ const escaped = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
512
+ const callPattern = new RegExp(`(?<!\\w)${escaped}\\s*\\(\\s*['"\`]([^'"\`\\r\\n]+)['"\`]`, 'g');
513
+ let match;
514
+ while ((match = callPattern.exec(source)) !== null) {
515
+ const key = match[1];
516
+ if (!availableKeys || !availableKeys.size || availableKeys.has(key)) {
517
+ references.push({
518
+ key,
519
+ matchType: 'local-wrapper',
520
+ line: findLineColumn(source, match.index).line,
521
+ column: findLineColumn(source, match.index).column,
522
+ wrapperName: name,
523
+ });
524
+ }
525
+ }
526
+ }
527
+
528
+ return references;
529
+ }
530
+
531
+ function findClientBoundaryIssues(content, relativePath) {
532
+ const issues = [];
533
+ if (/['"]use client['"]/.test(content) || /['"]use client['"]/.test(String(content).slice(0, 200))) {
534
+ const importPattern = /\bimport\s+\w+\s+from\s+['"]([^'"]+\.json)['"]/g;
535
+ let match;
536
+ while ((match = importPattern.exec(content)) !== null) {
537
+ if (/\b(locales?|i18n|translations?)\b/i.test(match[1])) {
538
+ issues.push({
539
+ filePath: relativePath,
540
+ importPath: match[1],
541
+ message: `"use client" file imports locale JSON (${match[1]}). This bypasses i18ntk runtime and increases client bundle size. Use a server bridge route instead.`,
542
+ });
543
+ }
544
+ }
545
+ }
546
+ return issues;
547
+ }
548
+
549
+ function detectCopyFormatters(content) {
550
+ const formatters = [];
551
+ const declarationPattern = /\b(?:const|let|var)\s+(tx|copy|formatCopy|formatMessage|fmt|localize)\s*=\s*(?:useCallback\s*\(|useMemo\s*\(|\([^)]*\)\s*=>|function\s*\()/g;
552
+ let match;
553
+ while ((match = declarationPattern.exec(content)) !== null) {
554
+ const name = match[1];
555
+ const isTx = name === 'tx';
556
+
557
+ if (!isTx) {
558
+ formatters.push({ name, line: findLineColumn(content, match.index).line, type: 'copyFormatter' });
559
+ continue;
560
+ }
561
+
562
+ const afterEquals = content.slice(match.index + match[0].length, Math.min(match.index + match[0].length + 500, content.length));
563
+ const callsTranslationRuntime = /\b(?:t|i18n\.t|\.getTranslation|translate)\s*\(/.test(afterEquals);
564
+
565
+ if (!callsTranslationRuntime) {
566
+ formatters.push({
567
+ name,
568
+ line: findLineColumn(content, match.index).line,
569
+ type: 'suspectedCopyFormatter',
570
+ message: `Local function "tx" does not call a known translation runtime and may be a copy formatter. Calls to this function will be treated as translation keys. Rename to "copy" or configure "copyFormatters" to suppress.`,
571
+ });
572
+ }
573
+ }
574
+ return formatters;
575
+ }
576
+
577
+ function detectMojibakeInTranslations(key, value, sourceLanguage, targetLanguage) {
578
+ if (typeof value !== 'string' || !value) return null;
579
+ const artifacts = value.match(/[A-Za-z\u00C0-\u00FF]+\?[A-Za-z\u00C0-\u00FF]+/g);
580
+ if (artifacts) {
581
+ return {
582
+ key,
583
+ locale: targetLanguage,
584
+ artifact: artifacts[0],
585
+ message: `Replacement-character artifact detected: "${artifacts[0]}" in translation "${value.slice(0, 80)}"`,
586
+ };
587
+ }
588
+ return null;
589
+ }
590
+
375
591
  function analyzeSourceForUsageInsights({
376
592
  content,
377
593
  relativePath,
@@ -384,9 +600,16 @@ function analyzeSourceForUsageInsights({
384
600
  const dynamicReferences = inferDynamicKeyReferences(content, availableKeys);
385
601
  const unresolvedDynamicReferences = findUnresolvedDynamicReferences(content, availableKeys);
386
602
  const literalReferences = findLiteralKeyReferences(content, availableKeys);
603
+ const importedLocaleReferences = findImportedLocaleKeys(content);
604
+ const clientBoundaryIssues = findClientBoundaryIssues(content, relativePath);
605
+ const copyFormatters = detectCopyFormatters(content);
606
+ const localWrappers = findLocalTranslationWrappers(content);
607
+ const localWrapperRefs = findLocalWrapperCallReferences(content, localWrappers, availableKeys);
387
608
  const exactInferredKeys = new Set([
388
609
  ...dynamicReferences.map(ref => ref.key),
389
610
  ...literalReferences.map(ref => ref.key),
611
+ ...importedLocaleReferences.map(ref => ref.key),
612
+ ...localWrapperRefs.map(ref => ref.key),
390
613
  ]);
391
614
 
392
615
  for (const key of directKeys || []) {
@@ -410,17 +633,39 @@ function analyzeSourceForUsageInsights({
410
633
  references.push(ref);
411
634
  }
412
635
 
636
+ for (const ref of localWrapperRefs) {
637
+ if (seen.has(ref.key)) continue;
638
+ seen.add(ref.key);
639
+ references.push(ref);
640
+ }
641
+
413
642
  for (const ref of literalReferences) {
414
643
  if (seen.has(ref.key)) continue;
415
644
  seen.add(ref.key);
416
645
  references.push(ref);
417
646
  }
418
647
 
648
+ for (const ref of importedLocaleReferences) {
649
+ if (seen.has(ref.key)) continue;
650
+ seen.add(ref.key);
651
+ references.push(ref);
652
+ }
653
+
654
+ for (const ref of localWrapperRefs) {
655
+ if (seen.has(ref.key)) continue;
656
+ seen.add(ref.key);
657
+ references.push(ref);
658
+ }
659
+
419
660
  return {
420
661
  keyReferences: references,
421
662
  unresolvedDynamicReferences,
422
663
  hardcodedTexts: collectHardcodedText(content, relativePath, translationValueIndex),
423
664
  namespaceRecommendation: buildNamespaceRecommendation(relativePath, references, availableKeys),
665
+ importedLocaleReferences,
666
+ clientBoundaryIssues,
667
+ copyFormatters,
668
+ localWrappers: Array.from(localWrappers.values()),
424
669
  };
425
670
  }
426
671
 
@@ -431,5 +676,11 @@ module.exports = {
431
676
  findLiteralKeyReferences,
432
677
  inferDynamicKeyReferences,
433
678
  findUnresolvedDynamicReferences,
679
+ findImportedLocaleKeys,
680
+ findClientBoundaryIssues,
681
+ detectCopyFormatters,
682
+ detectMojibakeInTranslations,
683
+ findLocalTranslationWrappers,
684
+ findLocalWrapperCallReferences,
434
685
  looksLikeHumanText,
435
686
  };