i18ntk 4.1.0 → 4.2.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.
Files changed (49) hide show
  1. package/CHANGELOG.md +64 -5
  2. package/README.md +73 -17
  3. package/SECURITY.md +10 -4
  4. package/main/i18ntk-analyze.js +10 -20
  5. package/main/i18ntk-backup.js +106 -44
  6. package/main/i18ntk-init.js +153 -157
  7. package/main/i18ntk-setup.js +36 -13
  8. package/main/i18ntk-sizing.js +44 -27
  9. package/main/i18ntk-translate.js +311 -41
  10. package/main/i18ntk-usage.js +272 -103
  11. package/main/i18ntk-validate.js +38 -31
  12. package/main/manage/commands/AnalyzeCommand.js +7 -17
  13. package/main/manage/commands/CommandRouter.js +6 -6
  14. package/main/manage/commands/SizingCommand.js +5 -2
  15. package/main/manage/commands/TranslateCommand.js +73 -56
  16. package/main/manage/commands/ValidateCommand.js +58 -26
  17. package/main/manage/index.js +11 -42
  18. package/main/manage/managers/InteractiveMenu.js +11 -40
  19. package/main/manage/services/InitService.js +114 -118
  20. package/main/manage/services/UsageService.js +247 -96
  21. package/package.json +19 -14
  22. package/runtime/enhanced.d.ts +5 -5
  23. package/runtime/enhanced.js +49 -25
  24. package/runtime/i18ntk.d.ts +30 -7
  25. package/runtime/index.d.ts +48 -19
  26. package/runtime/index.js +175 -90
  27. package/settings/settings-cli.js +115 -38
  28. package/settings/settings-manager.js +24 -6
  29. package/ui-locales/de.json +192 -11
  30. package/ui-locales/en.json +182 -8
  31. package/ui-locales/es.json +193 -12
  32. package/ui-locales/fr.json +189 -8
  33. package/ui-locales/ja.json +190 -8
  34. package/ui-locales/ru.json +191 -9
  35. package/ui-locales/zh.json +194 -9
  36. package/utils/cli-helper.js +8 -12
  37. package/utils/config-helper.js +1 -1
  38. package/utils/config-manager.js +8 -6
  39. package/utils/localized-confirm.js +55 -0
  40. package/utils/menu-layout.js +41 -0
  41. package/utils/report-writer.js +110 -0
  42. package/utils/security.js +15 -22
  43. package/utils/translate/api.js +31 -3
  44. package/utils/translate/placeholder.js +42 -1
  45. package/utils/translate/report.js +32 -4
  46. package/utils/translate/safe-network.js +24 -4
  47. package/utils/usage-insights.js +435 -0
  48. package/utils/usage-source.js +50 -0
  49. package/utils/watch-locales.js +1 -8
@@ -0,0 +1,55 @@
1
+ 'use strict';
2
+
3
+ const YES_TOKENS = {
4
+ en: ['y', 'yes'],
5
+ de: ['j', 'ja'],
6
+ es: ['s', 'si', 's\u00ed'],
7
+ fr: ['o', 'oui'],
8
+ ru: ['\u0434', '\u0434\u0430'],
9
+ ja: ['\u306f\u3044', '\u306f', 'y', 'yes'],
10
+ zh: ['\u662f', '\u5bf9', '\u5c0d', '\u597d', 'y', 'yes'],
11
+ };
12
+
13
+ const NO_TOKENS = {
14
+ en: ['n', 'no'],
15
+ de: ['n', 'nein'],
16
+ es: ['n', 'no'],
17
+ fr: ['n', 'non'],
18
+ ru: ['\u043d', '\u043d\u0435\u0442'],
19
+ ja: ['\u3044\u3044\u3048', '\u3044\u3048', '\u3044\u3084', 'n', 'no'],
20
+ zh: ['\u5426', '\u4e0d', '\u4e0d\u8981', 'n', 'no'],
21
+ };
22
+
23
+ function normalizeToken(value) {
24
+ return String(value || '').trim().toLowerCase();
25
+ }
26
+
27
+ function getTokens(language, tokenMap) {
28
+ const lang = normalizeToken(language || 'en');
29
+ return new Set([...(tokenMap.en || []), ...(tokenMap[lang] || [])].map(normalizeToken));
30
+ }
31
+
32
+ function isAffirmative(value, language = 'en') {
33
+ return getTokens(language, YES_TOKENS).has(normalizeToken(value));
34
+ }
35
+
36
+ function isNegative(value, language = 'en') {
37
+ return getTokens(language, NO_TOKENS).has(normalizeToken(value));
38
+ }
39
+
40
+ function parseConfirmation(value, options = {}) {
41
+ const { language = 'en', defaultValue = false } = options;
42
+ const normalized = normalizeToken(value);
43
+ if (!normalized) return Boolean(defaultValue);
44
+ if (isAffirmative(normalized, language)) return true;
45
+ if (isNegative(normalized, language)) return false;
46
+ return Boolean(defaultValue);
47
+ }
48
+
49
+ module.exports = {
50
+ YES_TOKENS,
51
+ NO_TOKENS,
52
+ isAffirmative,
53
+ isNegative,
54
+ parseConfirmation,
55
+ };
@@ -0,0 +1,41 @@
1
+ const DEFAULT_OPTIONS = [
2
+ [1, 'init'],
3
+ [2, 'analyze'],
4
+ [3, 'validate'],
5
+ [4, 'usage'],
6
+ [5, 'complete'],
7
+ null,
8
+ [6, 'sizing'],
9
+ [7, 'fix'],
10
+ [8, 'status'],
11
+ [9, 'delete'],
12
+ null,
13
+ [10, 'settings'],
14
+ [11, 'help'],
15
+ [12, 'language'],
16
+ [13, 'scanner'],
17
+ [14, 'translate'],
18
+ null,
19
+ [0, 'exit'],
20
+ ];
21
+
22
+ function buildMainMenuLines(translate, options = {}) {
23
+ const t = typeof translate === 'function' ? translate : key => key;
24
+ const includeTranslate = options.includeTranslate !== false;
25
+ const menuOptions = DEFAULT_OPTIONS.filter(option => includeTranslate || !option || option[1] !== 'translate');
26
+
27
+ return [
28
+ '',
29
+ t('menu.title'),
30
+ t('menu.separator'),
31
+ ...menuOptions.map(option => {
32
+ if (!option) return '';
33
+ const [number, key] = option;
34
+ return `${String(number).padStart(2, ' ')}. ${t(`menu.options.${key}`)}`;
35
+ }),
36
+ ];
37
+ }
38
+
39
+ module.exports = {
40
+ buildMainMenuLines,
41
+ };
@@ -0,0 +1,110 @@
1
+ 'use strict';
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const SecurityUtils = require('./security');
6
+
7
+ const EXTENSIONS = {
8
+ markdown: '.md',
9
+ md: '.md',
10
+ json: '.json',
11
+ text: '.txt',
12
+ txt: '.txt',
13
+ };
14
+
15
+ function normalizeReportFormat(format) {
16
+ const normalized = String(format || 'markdown').trim().toLowerCase();
17
+ if (normalized === 'md') return 'markdown';
18
+ if (normalized === 'txt') return 'text';
19
+ return Object.prototype.hasOwnProperty.call(EXTENSIONS, normalized) ? normalized : 'markdown';
20
+ }
21
+
22
+ function extensionForReportFormat(format) {
23
+ return EXTENSIONS[normalizeReportFormat(format)] || '.md';
24
+ }
25
+
26
+ function buildReportFile(baseName, format) {
27
+ const safeBase = String(baseName || 'i18ntk-report').replace(/[^\w.-]/g, '_').replace(/\.+$/g, '') || 'i18ntk-report';
28
+ return {
29
+ fileName: `${safeBase}${extensionForReportFormat(format)}`,
30
+ format: normalizeReportFormat(format),
31
+ };
32
+ }
33
+
34
+ function objectToMarkdown(value, heading = 'Report', depth = 0) {
35
+ if (depth === 0) {
36
+ return [`# ${heading}`, '', objectToMarkdown(value, heading, depth + 1)].join('\n').trimEnd();
37
+ }
38
+
39
+ if (Array.isArray(value)) {
40
+ if (value.length === 0) return '- (none)';
41
+ return value.map(item => {
42
+ if (item && typeof item === 'object') {
43
+ return `- ${JSON.stringify(item)}`;
44
+ }
45
+ return `- ${String(item)}`;
46
+ }).join('\n');
47
+ }
48
+
49
+ if (value && typeof value === 'object') {
50
+ const lines = [];
51
+ for (const [key, item] of Object.entries(value)) {
52
+ if (item && typeof item === 'object') {
53
+ lines.push(`${'#'.repeat(Math.min(depth + 1, 6))} ${key}`);
54
+ lines.push('');
55
+ lines.push(objectToMarkdown(item, key, depth + 1));
56
+ lines.push('');
57
+ } else {
58
+ lines.push(`- **${key}:** ${String(item)}`);
59
+ }
60
+ }
61
+ return lines.join('\n').trimEnd();
62
+ }
63
+
64
+ return String(value ?? '');
65
+ }
66
+
67
+ function formatReportContent(report, format = 'markdown', options = {}) {
68
+ const normalized = normalizeReportFormat(format);
69
+
70
+ if (normalized === 'json') {
71
+ const payload = typeof report === 'string' ? { report } : report;
72
+ return `${JSON.stringify(payload, null, 2)}\n`;
73
+ }
74
+
75
+ if (typeof report === 'string') {
76
+ return report.endsWith('\n') ? report : `${report}\n`;
77
+ }
78
+
79
+ if (normalized === 'text') {
80
+ return `${JSON.stringify(report, null, 2)}\n`;
81
+ }
82
+
83
+ return `${objectToMarkdown(report, options.title || 'I18NTK Report')}\n`;
84
+ }
85
+
86
+ async function writeReportFile(outputDir, baseName, report, options = {}) {
87
+ const format = normalizeReportFormat(options.format);
88
+ const { fileName } = buildReportFile(baseName, format);
89
+ const resolvedOutputDir = path.resolve(outputDir || './i18ntk-reports');
90
+ const reportPath = path.join(resolvedOutputDir, fileName);
91
+ const validatedOutputDir = SecurityUtils.validatePath(resolvedOutputDir, process.cwd());
92
+ if (!validatedOutputDir) {
93
+ throw new Error(`Invalid report output directory: ${outputDir}`);
94
+ }
95
+ fs.mkdirSync(validatedOutputDir, { recursive: true });
96
+ const content = formatReportContent(report, format, options);
97
+ const success = await SecurityUtils.safeWriteFile(reportPath, content, validatedOutputDir, 'utf8');
98
+ if (!success) {
99
+ throw new Error(`Failed to write report: ${reportPath}`);
100
+ }
101
+ return reportPath;
102
+ }
103
+
104
+ module.exports = {
105
+ buildReportFile,
106
+ extensionForReportFormat,
107
+ formatReportContent,
108
+ normalizeReportFormat,
109
+ writeReportFile,
110
+ };
package/utils/security.js CHANGED
@@ -42,13 +42,16 @@ function initializeInternalRoots() {
42
42
  roots.add(path.resolve(candidate));
43
43
  }
44
44
 
45
- const custom = String(envManager.get('I18NTK_INTERNAL_PATH_PREFIXES') || '')
46
- .split(',')
47
- .map((entry) => entry.trim())
48
- .filter(Boolean);
49
- for (const prefix of custom) {
50
- roots.add(path.resolve(prefix));
51
- }
45
+ const custom = String(envManager.get('I18NTK_INTERNAL_PATH_PREFIXES') || '')
46
+ .split(',')
47
+ .map((entry) => entry.trim())
48
+ .filter(Boolean);
49
+ for (const prefix of custom) {
50
+ const resolved = path.resolve(prefix);
51
+ if ([...roots].some((root) => isPathInside(root, resolved))) {
52
+ roots.add(resolved);
53
+ }
54
+ }
52
55
 
53
56
  return roots;
54
57
  }
@@ -229,12 +232,7 @@ static _logging = false;
229
232
  const useI18n = i18n && i18n.isInitialized && typeof i18n.t === 'function';
230
233
 
231
234
  try {
232
- // Check against whitelist patterns for our own package artifacts
233
- if (SecurityUtils.PACKAGE_ARTIFACT_WHITELIST.some(pattern => pattern.test(filePath))) {
234
- return filePath;
235
- }
236
-
237
- if (!filePath || typeof filePath !== 'string') {
235
+ if (!filePath || typeof filePath !== 'string') {
238
236
  const message = useI18n
239
237
  ? i18n.t('security.pathValidationFailed')
240
238
  : 'Path validation failed';
@@ -297,9 +295,9 @@ static _logging = false;
297
295
  // If the path doesn't exist yet, fall back to the resolved path
298
296
  }
299
297
 
300
- // Check for actual path traversal (going outside the base directory)
301
- const relativePath = path.relative(base, finalPath);
302
- if (relativePath.startsWith('..')) {
298
+ // Check for actual path traversal (going outside the base directory)
299
+ const relativePath = path.relative(base, finalPath);
300
+ if (relativePath.startsWith('..') || path.isAbsolute(relativePath)) {
303
301
  const message = useI18n
304
302
  ? i18n.t('security.pathTraversalAttempt')
305
303
  : 'Path traversal attempt';
@@ -623,12 +621,7 @@ static _logging = false;
623
621
  return false;
624
622
  }
625
623
 
626
- // Check against whitelist patterns for our own package artifacts
627
- if (SecurityUtils.PACKAGE_ARTIFACT_WHITELIST.some(pattern => pattern.test(filePath))) {
628
- return true;
629
- }
630
-
631
- // Allow legitimate Windows drive letter paths
624
+ // Allow legitimate Windows drive letter paths
632
625
  if (filePath.match(/^[A-Z]:[\/\\]/)) {
633
626
  const afterDrive = filePath.substring(3);
634
627
  // Only check the part after the drive letter for dangerous patterns
@@ -1,7 +1,13 @@
1
1
  const { URL } = require('url');
2
2
  const { safeHttpGet, safeHttpPost, buildGoogleTranslateUrl } = require('./safe-network');
3
3
 
4
- const DEFAULT_CONCURRENCY = 3;
4
+ const DEFAULT_CONCURRENCY = 12;
5
+ const PROVIDER_CONCURRENCY_LIMITS = {
6
+ google: 100,
7
+ deepl: 25,
8
+ libretranslate: 25,
9
+ custom: 100,
10
+ };
5
11
  const DEFAULT_RETRY_COUNT = 3;
6
12
  const DEFAULT_RETRY_DELAY = 1000;
7
13
  const MAX_BACKOFF_DELAY = 30000;
@@ -26,6 +32,16 @@ function normalizeProvider(provider) {
26
32
  return value;
27
33
  }
28
34
 
35
+ function getProviderConcurrencyLimit(provider) {
36
+ return PROVIDER_CONCURRENCY_LIMITS[normalizeProvider(provider)] || 25;
37
+ }
38
+
39
+ function clampProviderConcurrency(value, provider, fallback = DEFAULT_CONCURRENCY) {
40
+ const parsed = parseInt(value, 10);
41
+ if (!Number.isInteger(parsed)) return fallback;
42
+ return Math.min(Math.max(parsed, 1), getProviderConcurrencyLimit(provider));
43
+ }
44
+
29
45
  function normalizeDeepLLanguage(code) {
30
46
  return String(code || '').replace('-', '_').toUpperCase();
31
47
  }
@@ -271,12 +287,21 @@ async function translateBatch(batch, targetLang, options = {}) {
271
287
 
272
288
  completed++;
273
289
  if (typeof onProgress === 'function') {
274
- onProgress({ completed, total: batch.length, index: i, ok: result.ok });
290
+ onProgress({
291
+ completed,
292
+ total: batch.length,
293
+ index: i,
294
+ ok: result.ok,
295
+ keyPath: item && typeof item === 'object' ? item.keyPath : undefined,
296
+ });
275
297
  }
276
298
  }
277
299
  }
278
300
 
279
- const workerCount = Math.min(concurrency > 0 ? concurrency : DEFAULT_CONCURRENCY, batch.length);
301
+ const workerCount = Math.min(
302
+ clampProviderConcurrency(concurrency, options.provider, DEFAULT_CONCURRENCY),
303
+ batch.length
304
+ );
280
305
  const workers = Array.from({ length: workerCount }, () => worker());
281
306
 
282
307
  await Promise.all(workers);
@@ -294,6 +319,9 @@ module.exports = {
294
319
  translateText,
295
320
  translateBatch,
296
321
  DEFAULT_CONCURRENCY,
322
+ PROVIDER_CONCURRENCY_LIMITS,
323
+ getProviderConcurrencyLimit,
324
+ clampProviderConcurrency,
297
325
  DEFAULT_RETRY_COUNT,
298
326
  DEFAULT_RETRY_DELAY,
299
327
  };
@@ -1,4 +1,5 @@
1
1
  const DEFAULT_PLACEHOLDER_PATTERNS = [
2
+ /\$t\([^)]+\)/g, // $t(common.save) - i18next nested translation refs
2
3
  /\{\{[^}]+\}\}/g, // {{variable}} - double curly (Handlebars, Mustache)
3
4
  /\{[a-zA-Z_]\w*\}/g, // {name} - single curly (i18next, Python format named)
4
5
  /\{\d+\}/g, // {0} - indexed curly
@@ -6,12 +7,41 @@ const DEFAULT_PLACEHOLDER_PATTERNS = [
6
7
  /:[a-zA-Z_]\w*/g, // :param - colon-style (Rails, Swift)
7
8
  /%\{[a-zA-Z_]\w*\}/g, // %{name} - Ruby/Perl named
8
9
  /%\([a-zA-Z_]\w*\)[sd]/g, // %(name)s - Python named format (with type)
10
+ /%\([a-zA-Z_]\w*\)(?:[#+\- 0,(]*\d*(?:\.\d+)?)?[bcdeEfFgGnosxX]/g, // %(total).2f
9
11
  /\$\{[a-zA-Z_]\w*\}/g, // ${variable} - JS template literal style
10
12
  /<[a-zA-Z_]\w*>/g, // <name> - XML/HTML-style
11
13
  /@[a-zA-Z_]\w*/g, // @param - Java/Spring-style
12
14
  /&[a-zA-Z_]\w*;?/g, // &amp; HTML entity style (careful, broad match)
13
15
  ];
14
16
 
17
+ function findIcuPlaceholders(value) {
18
+ const matches = [];
19
+ const starter = /\{[a-zA-Z_]\w*\s*,\s*(?:plural|select|selectordinal)\s*,/g;
20
+ let match;
21
+
22
+ while ((match = starter.exec(value)) !== null) {
23
+ let depth = 0;
24
+ for (let index = match.index; index < value.length; index++) {
25
+ const char = value[index];
26
+ if (char === '{') depth++;
27
+ else if (char === '}') {
28
+ depth--;
29
+ if (depth === 0) {
30
+ matches.push({
31
+ start: match.index,
32
+ end: index + 1,
33
+ value: value.slice(match.index, index + 1),
34
+ });
35
+ starter.lastIndex = index + 1;
36
+ break;
37
+ }
38
+ }
39
+ }
40
+ }
41
+
42
+ return matches;
43
+ }
44
+
15
45
  function compilePatterns(customPatterns) {
16
46
  const patterns = [...DEFAULT_PLACEHOLDER_PATTERNS];
17
47
  if (customPatterns) {
@@ -39,6 +69,9 @@ function detectPlaceholders(value, customPatterns) {
39
69
  if (!value || typeof value !== 'string') return [];
40
70
  const patterns = compilePatterns(customPatterns);
41
71
  const found = new Set();
72
+ for (const match of findIcuPlaceholders(value)) {
73
+ found.add(match.value);
74
+ }
42
75
  for (const pattern of patterns) {
43
76
  pattern.lastIndex = 0;
44
77
  const matches = value.match(pattern);
@@ -51,6 +84,7 @@ function detectPlaceholders(value, customPatterns) {
51
84
 
52
85
  function hasPlaceholders(value, customPatterns) {
53
86
  if (!value || typeof value !== 'string') return false;
87
+ if (findIcuPlaceholders(value).length > 0) return true;
54
88
  const patterns = compilePatterns(customPatterns);
55
89
  for (const pattern of patterns) {
56
90
  pattern.lastIndex = 0;
@@ -65,7 +99,7 @@ function splitByPlaceholders(value, customPatterns) {
65
99
  }
66
100
 
67
101
  const patterns = compilePatterns(customPatterns);
68
- const matches = [];
102
+ const matches = findIcuPlaceholders(value);
69
103
 
70
104
  for (const pattern of patterns) {
71
105
  pattern.lastIndex = 0;
@@ -121,6 +155,12 @@ function maskPlaceholders(value, customPatterns) {
121
155
  const map = new Map();
122
156
  let idx = 0;
123
157
  let masked = value;
158
+ for (const match of findIcuPlaceholders(value)) {
159
+ const ph = `\uE000${idx}\uE001`;
160
+ map.set(ph, match.value);
161
+ idx++;
162
+ masked = masked.split(match.value).join(ph);
163
+ }
124
164
  for (const pattern of patterns) {
125
165
  pattern.lastIndex = 0;
126
166
  masked = masked.replace(pattern, (match) => {
@@ -146,6 +186,7 @@ module.exports = {
146
186
  DEFAULT_PLACEHOLDER_PATTERNS,
147
187
  compilePatterns,
148
188
  detectPlaceholders,
189
+ findIcuPlaceholders,
149
190
  hasPlaceholders,
150
191
  splitByPlaceholders,
151
192
  maskPlaceholders,
@@ -9,6 +9,7 @@ function generateReport(skippedKeys, translatedCount, totalCount, options = {})
9
9
  timestamp = new Date().toISOString(),
10
10
  placeholderProtected = 0,
11
11
  protectedSkipped = 0,
12
+ residualUntranslated = [],
12
13
  } = options;
13
14
  const placeholderSkipped = skippedKeys.filter(key => key.skipReason !== 'protected');
14
15
  const protectedKeys = skippedKeys.filter(key => key.skipReason === 'protected');
@@ -28,13 +29,36 @@ function generateReport(skippedKeys, translatedCount, totalCount, options = {})
28
29
  lines.push(` Placeholder-safe: ${String(placeholderProtected).padStart(6)}`);
29
30
  lines.push(` Protected: ${String(protectedSkipped).padStart(6)}`);
30
31
  lines.push(` Skipped: ${skippedKeys.length}`);
32
+ lines.push(` Leftover warnings: ${String(residualUntranslated.length).padStart(3)}`);
31
33
  lines.push('='.repeat(72));
32
34
 
33
- if (skippedKeys.length === 0) {
35
+ if (skippedKeys.length === 0 && residualUntranslated.length === 0) {
34
36
  lines.push('');
35
37
  lines.push(' All strings were processed. No keys were skipped.');
36
38
  lines.push('');
37
39
  } else {
40
+ if (residualUntranslated.length > 0) {
41
+ lines.push('');
42
+ lines.push(' WARNING: The following values still look untranslated after');
43
+ lines.push(' Auto Translate and one final retry.');
44
+ lines.push('');
45
+ lines.push(' Rerun Auto Translate to capture leftovers, then review this');
46
+ lines.push(' report if any warnings remain.');
47
+ lines.push('');
48
+ lines.push(` ${'-'.repeat(72)}`);
49
+ lines.push(' File Key Path Current Value');
50
+ lines.push(` ${'-'.repeat(72)}`);
51
+ for (const item of residualUntranslated) {
52
+ const fileDisplay = String(item.fileName || path.basename(sourceFile || '') || 'N/A').padEnd(20).slice(0, 20);
53
+ const keyDisplay = String(item.keyPath || '').padEnd(40).slice(0, 40);
54
+ const valDisplay = String(item.value || '').length > 90
55
+ ? String(item.value).slice(0, 87) + '...'
56
+ : String(item.value || '');
57
+ lines.push(` ${fileDisplay} ${keyDisplay} ${valDisplay}`);
58
+ }
59
+ lines.push(` ${'-'.repeat(72)}`);
60
+ }
61
+
38
62
  if (placeholderSkipped.length > 0) {
39
63
  lines.push('');
40
64
  lines.push(' WARNING: The following keys were SKIPPED because they contain');
@@ -78,7 +102,10 @@ function generateReport(skippedKeys, translatedCount, totalCount, options = {})
78
102
  }
79
103
 
80
104
  lines.push('');
81
- if (skippedKeys.length === 0) {
105
+ if (residualUntranslated.length > 0) {
106
+ lines.push(' Auto Translate did not fully complete because leftover');
107
+ lines.push(' placeholder-prefixed or English-looking values remain.');
108
+ } else if (skippedKeys.length === 0) {
82
109
  lines.push(' The generated file can be used immediately after review.');
83
110
  lines.push(' Placeholder tokens were preserved automatically where found.');
84
111
  } else if (placeholderSkipped.length > 0) {
@@ -104,10 +131,11 @@ function writeReport(reportText, filePath) {
104
131
  }
105
132
  }
106
133
 
107
- function formatSummaryLine(skippedCount, translatedCount, totalCount, placeholderProtected = 0, protectedSkipped = 0) {
134
+ function formatSummaryLine(skippedCount, translatedCount, totalCount, placeholderProtected = 0, protectedSkipped = 0, existingKept = 0) {
108
135
  const protectedPart = placeholderProtected > 0 ? `, ${placeholderProtected} placeholder-safe` : '';
109
136
  const glossaryPart = protectedSkipped > 0 ? `, ${protectedSkipped} protected` : '';
110
- return `[translate] ${translatedCount} translated${protectedPart}${glossaryPart}, ${skippedCount} skipped (of ${totalCount} total keys)`;
137
+ const existingPart = existingKept > 0 ? `, ${existingKept} existing kept` : '';
138
+ return `[translate] ${translatedCount} translated${protectedPart}${glossaryPart}${existingPart}, ${skippedCount} skipped (of ${totalCount} total keys)`;
111
139
  }
112
140
 
113
141
  module.exports = {
@@ -31,14 +31,34 @@ function isPrivateIPv4(hostname) {
31
31
  || a === 0;
32
32
  }
33
33
 
34
+ function isPrivateIPv6(hostname) {
35
+ const normalized = String(hostname || '').trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
36
+ if (normalized === '::1') return true;
37
+ if (normalized.startsWith('fe80:') || normalized.startsWith('fc') || normalized.startsWith('fd')) return true;
38
+
39
+ const mapped = normalized.match(/^::ffff:(?:(\d{1,3}(?:\.\d{1,3}){3})|([0-9a-f]+:[0-9a-f]+))$/i);
40
+ if (!mapped) return false;
41
+ if (mapped[1]) return isPrivateIPv4(mapped[1]);
42
+
43
+ const parts = mapped[2].split(':');
44
+ if (parts.length !== 2) return false;
45
+ const high = Number.parseInt(parts[0], 16);
46
+ const low = Number.parseInt(parts[1], 16);
47
+ if (!Number.isInteger(high) || !Number.isInteger(low)) return false;
48
+ const ipv4 = [
49
+ (high >> 8) & 0xff,
50
+ high & 0xff,
51
+ (low >> 8) & 0xff,
52
+ low & 0xff,
53
+ ].join('.');
54
+ return isPrivateIPv4(ipv4);
55
+ }
56
+
34
57
  function isPrivateHost(hostname) {
35
58
  const normalized = String(hostname || '').trim().toLowerCase().replace(/^\[|\]$/g, '').replace(/\.$/, '');
36
59
  return normalized === 'localhost'
37
60
  || normalized.endsWith('.localhost')
38
- || normalized === '::1'
39
- || normalized.startsWith('fe80:')
40
- || normalized.startsWith('fc')
41
- || normalized.startsWith('fd')
61
+ || isPrivateIPv6(normalized)
42
62
  || isPrivateIPv4(normalized);
43
63
  }
44
64