i18ntk 4.0.0 → 4.2.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.
- package/CHANGELOG.md +116 -29
- package/README.md +83 -18
- package/SECURITY.md +13 -5
- package/main/i18ntk-analyze.js +10 -20
- package/main/i18ntk-backup.js +227 -111
- package/main/i18ntk-init.js +153 -157
- package/main/i18ntk-scanner.js +9 -7
- package/main/i18ntk-setup.js +36 -13
- package/main/i18ntk-sizing.js +18 -50
- package/main/i18ntk-translate.js +169 -21
- package/main/i18ntk-usage.js +298 -154
- package/main/i18ntk-validate.js +49 -37
- package/main/manage/commands/AnalyzeCommand.js +7 -17
- package/main/manage/commands/CommandRouter.js +6 -6
- package/main/manage/commands/TranslateCommand.js +65 -56
- package/main/manage/commands/ValidateCommand.js +34 -26
- package/main/manage/index.js +11 -42
- package/main/manage/managers/InteractiveMenu.js +11 -40
- package/main/manage/services/InitService.js +114 -118
- package/main/manage/services/UsageService.js +244 -85
- package/package.json +55 -4
- package/runtime/enhanced.d.ts +5 -5
- package/runtime/enhanced.js +49 -25
- package/runtime/i18ntk.d.ts +30 -7
- package/runtime/index.d.ts +48 -19
- package/runtime/index.js +188 -97
- package/settings/settings-cli.js +115 -38
- package/settings/settings-manager.js +24 -6
- package/ui-locales/de.json +192 -11
- package/ui-locales/en.json +182 -8
- package/ui-locales/es.json +193 -12
- package/ui-locales/fr.json +189 -8
- package/ui-locales/ja.json +190 -8
- package/ui-locales/ru.json +191 -9
- package/ui-locales/zh.json +194 -9
- package/utils/cli-helper.js +8 -12
- package/utils/config-helper.js +1 -1
- package/utils/config-manager.js +8 -6
- package/utils/localized-confirm.js +55 -0
- package/utils/menu-layout.js +41 -0
- package/utils/report-writer.js +110 -0
- package/utils/security.js +15 -22
- package/utils/translate/api.js +31 -3
- package/utils/translate/placeholder.js +42 -1
- package/utils/translate/protection.js +17 -12
- package/utils/translate/report.js +3 -2
- package/utils/translate/safe-network.js +24 -4
- package/utils/usage-insights.js +435 -0
- package/utils/usage-source.js +50 -0
- package/utils/watch-locales.js +13 -9
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
package/utils/translate/api.js
CHANGED
|
@@ -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 =
|
|
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({
|
|
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(
|
|
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, // & 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,
|
|
@@ -75,10 +75,15 @@ function parseContextString(context) {
|
|
|
75
75
|
return { mode: 'before', words };
|
|
76
76
|
}
|
|
77
77
|
|
|
78
|
-
const
|
|
79
|
-
if (
|
|
80
|
-
const
|
|
81
|
-
const
|
|
78
|
+
const prefix = trimmed.substring(0, 'surrounded:'.length);
|
|
79
|
+
if (prefix.toLowerCase() === 'surrounded:') {
|
|
80
|
+
const rest = trimmed.substring('surrounded:'.length);
|
|
81
|
+
const idx = rest.indexOf(',');
|
|
82
|
+
if (idx === -1) return null;
|
|
83
|
+
const left = rest.slice(0, idx).trim();
|
|
84
|
+
const right = rest.slice(idx + 1).trim();
|
|
85
|
+
const leftWords = left.split('|').map(w => w.trim()).filter(Boolean);
|
|
86
|
+
const rightWords = right.split('|').map(w => w.trim()).filter(Boolean);
|
|
82
87
|
if (leftWords.length === 0 || rightWords.length === 0) return null;
|
|
83
88
|
return { mode: 'surrounded', left: leftWords, right: rightWords };
|
|
84
89
|
}
|
|
@@ -243,7 +248,7 @@ function shouldPreserveWholeValue(keyPath, value, protection) {
|
|
|
243
248
|
if (protection.keys.some(rule => keyMatchesRule(keyPath, rule))) return true;
|
|
244
249
|
const valueText = String(value);
|
|
245
250
|
return protection.values.includes(valueText) ||
|
|
246
|
-
(protection.normalizedTerms || []).some(rule => rule.value === valueText);
|
|
251
|
+
(protection.normalizedTerms || []).some(rule => rule.type === 'global' && rule.value === valueText);
|
|
247
252
|
}
|
|
248
253
|
|
|
249
254
|
function addReplacement(replacements, original) {
|
|
@@ -261,27 +266,27 @@ function shouldProtectInContext(value, rule, index, fullText) {
|
|
|
261
266
|
|
|
262
267
|
if (context.mode === 'after') {
|
|
263
268
|
const alternation = context.words.map(escapeRegExp).join('|');
|
|
264
|
-
const regex = new RegExp(
|
|
269
|
+
const regex = new RegExp(`(^|[\\s\\p{P}])(${alternation})\\s+$`, 'iu');
|
|
265
270
|
return regex.test(before);
|
|
266
271
|
}
|
|
267
272
|
|
|
268
273
|
if (context.mode === 'before') {
|
|
269
274
|
const alternation = context.words.map(escapeRegExp).join('|');
|
|
270
|
-
const regex = new RegExp(`^\\s+(${alternation})\\
|
|
275
|
+
const regex = new RegExp(`^\\s+(${alternation})([\\s\\p{P}]|$)`, 'iu');
|
|
271
276
|
return regex.test(after);
|
|
272
277
|
}
|
|
273
278
|
|
|
274
279
|
if (context.mode === 'standalone') {
|
|
275
|
-
const isBoundaryBefore = before === '' ||
|
|
276
|
-
const isBoundaryAfter = after === '' || /^[\s.,!?;:]/.test(after);
|
|
280
|
+
const isBoundaryBefore = before === '' || /(?:\s|\(|\[|\{|"|'|\-|–|—|。|、|」)$/.test(before);
|
|
281
|
+
const isBoundaryAfter = after === '' || /^[\s.,!?;:)\]}<>"'\-–—。、」]/.test(after);
|
|
277
282
|
return isBoundaryBefore && isBoundaryAfter;
|
|
278
283
|
}
|
|
279
284
|
|
|
280
285
|
if (context.mode === 'surrounded') {
|
|
281
286
|
const leftAlternation = context.left.map(escapeRegExp).join('|');
|
|
282
287
|
const rightAlternation = context.right.map(escapeRegExp).join('|');
|
|
283
|
-
const leftRegex = new RegExp(
|
|
284
|
-
const rightRegex = new RegExp(`^\\s+(${rightAlternation})\\
|
|
288
|
+
const leftRegex = new RegExp(`(^|[\\s\\p{P}])(${leftAlternation})\\s+$`, 'iu');
|
|
289
|
+
const rightRegex = new RegExp(`^\\s+(${rightAlternation})([\\s\\p{P}]|$)`, 'iu');
|
|
285
290
|
return leftRegex.test(before) && rightRegex.test(after);
|
|
286
291
|
}
|
|
287
292
|
|
|
@@ -361,7 +366,7 @@ function hasProtectionRules(protection) {
|
|
|
361
366
|
protection &&
|
|
362
367
|
(
|
|
363
368
|
(protection.normalizedTerms && protection.normalizedTerms.length) ||
|
|
364
|
-
protection.terms.length ||
|
|
369
|
+
(protection.terms && protection.terms.length) ||
|
|
365
370
|
protection.keys.length ||
|
|
366
371
|
protection.values.length ||
|
|
367
372
|
protection.patterns.length
|
|
@@ -104,10 +104,11 @@ function writeReport(reportText, filePath) {
|
|
|
104
104
|
}
|
|
105
105
|
}
|
|
106
106
|
|
|
107
|
-
function formatSummaryLine(skippedCount, translatedCount, totalCount, placeholderProtected = 0, protectedSkipped = 0) {
|
|
107
|
+
function formatSummaryLine(skippedCount, translatedCount, totalCount, placeholderProtected = 0, protectedSkipped = 0, existingKept = 0) {
|
|
108
108
|
const protectedPart = placeholderProtected > 0 ? `, ${placeholderProtected} placeholder-safe` : '';
|
|
109
109
|
const glossaryPart = protectedSkipped > 0 ? `, ${protectedSkipped} protected` : '';
|
|
110
|
-
|
|
110
|
+
const existingPart = existingKept > 0 ? `, ${existingKept} existing kept` : '';
|
|
111
|
+
return `[translate] ${translatedCount} translated${protectedPart}${glossaryPart}${existingPart}, ${skippedCount} skipped (of ${totalCount} total keys)`;
|
|
111
112
|
}
|
|
112
113
|
|
|
113
114
|
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
|
|
39
|
-
|| normalized.startsWith('fe80:')
|
|
40
|
-
|| normalized.startsWith('fc')
|
|
41
|
-
|| normalized.startsWith('fd')
|
|
61
|
+
|| isPrivateIPv6(normalized)
|
|
42
62
|
|| isPrivateIPv4(normalized);
|
|
43
63
|
}
|
|
44
64
|
|