i18ntk 4.3.3 → 4.4.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/CHANGELOG.md +151 -88
- package/README.md +56 -51
- package/main/i18ntk-backup.js +77 -52
- package/main/i18ntk-complete.js +16 -5
- package/main/i18ntk-scanner.js +5 -0
- package/main/i18ntk-translate.js +20 -8
- package/main/i18ntk-usage.js +438 -127
- package/main/manage/commands/TranslateCommand.js +2 -2
- package/package.json +36 -19
- package/utils/config-helper.js +19 -3
- package/utils/english-placeholder-checker.js +15 -2
- package/utils/security.js +49 -6
- package/utils/translate/api.js +16 -1
- package/utils/translate/report.js +26 -2
- package/utils/usage-insights.js +254 -3
package/utils/usage-insights.js
CHANGED
|
@@ -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
|
};
|