i18ntk 3.0.0 → 3.1.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 +43 -16
- package/README.md +20 -17
- package/main/i18ntk-sizing.js +471 -218
- package/main/i18ntk-translate.js +399 -68
- package/main/i18ntk-validate.js +26 -14
- package/main/manage/commands/TranslateCommand.js +273 -52
- package/main/manage/commands/ValidateCommand.js +25 -13
- package/package.json +3 -1
- package/settings/settings-cli.js +75 -29
- package/settings/settings-manager.js +109 -1
- package/ui-locales/de.json +2 -2
- package/ui-locales/en.json +2 -2
- package/ui-locales/es.json +2 -2
- package/ui-locales/fr.json +2 -2
- package/ui-locales/ja.json +2 -2
- package/ui-locales/ru.json +4 -3
- package/ui-locales/zh.json +2 -2
- package/utils/config-manager.js +20 -4
- package/utils/security.js +4 -3
- package/utils/translate/cli.js +20 -16
- package/utils/translate/placeholder.js +60 -0
- package/utils/translate/protection.js +243 -0
- package/utils/translate/report.js +49 -22
- package/utils/validation-risk.js +175 -0
|
@@ -0,0 +1,243 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const SecurityUtils = require('../security');
|
|
3
|
+
|
|
4
|
+
const DEFAULT_PROTECTION_FILE = 'i18ntk-auto-translate.json';
|
|
5
|
+
const TOKEN_PREFIX = '__I18NTK_KEEP_';
|
|
6
|
+
|
|
7
|
+
function defaultProtectionConfig() {
|
|
8
|
+
return {
|
|
9
|
+
version: 1,
|
|
10
|
+
description: 'Auto Translate protection rules. Terms are masked before translation and restored after translation. Keys and values are copied from the source without translation.',
|
|
11
|
+
terms: [],
|
|
12
|
+
keys: [],
|
|
13
|
+
values: [],
|
|
14
|
+
patterns: []
|
|
15
|
+
};
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
function resolveProtectionFile(filePath, cwd = process.cwd()) {
|
|
19
|
+
const requested = filePath || DEFAULT_PROTECTION_FILE;
|
|
20
|
+
const resolved = path.isAbsolute(requested)
|
|
21
|
+
? path.resolve(requested)
|
|
22
|
+
: path.resolve(cwd, requested);
|
|
23
|
+
const root = path.resolve(cwd);
|
|
24
|
+
|
|
25
|
+
if (!resolved.startsWith(root + path.sep) && resolved !== root) {
|
|
26
|
+
throw new Error(`Protection file must be inside the project: ${requested}`);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return resolved;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function normalizeList(value) {
|
|
33
|
+
if (!Array.isArray(value)) return [];
|
|
34
|
+
return value
|
|
35
|
+
.filter(item => typeof item === 'string')
|
|
36
|
+
.map(item => item.trim())
|
|
37
|
+
.filter(Boolean);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function compilePatterns(patterns) {
|
|
41
|
+
const compiled = [];
|
|
42
|
+
for (const pattern of patterns) {
|
|
43
|
+
try {
|
|
44
|
+
compiled.push(new RegExp(pattern, 'g'));
|
|
45
|
+
} catch (error) {
|
|
46
|
+
console.warn(`Invalid Auto Translate protection pattern ignored: ${pattern} (${error.message})`);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return compiled;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function normalizeProtectionConfig(config = {}, filePath = null) {
|
|
53
|
+
const terms = normalizeList(config.terms);
|
|
54
|
+
const keys = normalizeList(config.keys);
|
|
55
|
+
const values = normalizeList(config.values);
|
|
56
|
+
const patterns = normalizeList(config.patterns);
|
|
57
|
+
|
|
58
|
+
return {
|
|
59
|
+
enabled: true,
|
|
60
|
+
filePath,
|
|
61
|
+
terms,
|
|
62
|
+
keys,
|
|
63
|
+
values,
|
|
64
|
+
patterns,
|
|
65
|
+
compiledPatterns: compilePatterns(patterns)
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function createProtectionFile(filePath, options = {}) {
|
|
70
|
+
const resolved = resolveProtectionFile(filePath, options.cwd);
|
|
71
|
+
const dir = path.dirname(resolved);
|
|
72
|
+
if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) {
|
|
73
|
+
SecurityUtils.safeMkdirSync(dir, path.dirname(dir), { recursive: true });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (!SecurityUtils.safeExistsSync(resolved, dir)) {
|
|
77
|
+
SecurityUtils.safeWriteFileSync(
|
|
78
|
+
resolved,
|
|
79
|
+
JSON.stringify(defaultProtectionConfig(), null, 2) + '\n',
|
|
80
|
+
dir,
|
|
81
|
+
'utf8'
|
|
82
|
+
);
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
return resolved;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function readProtectionFile(filePath, options = {}) {
|
|
89
|
+
const resolved = resolveProtectionFile(filePath, options.cwd);
|
|
90
|
+
const raw = SecurityUtils.safeReadFileSync(resolved, path.dirname(resolved), 'utf8');
|
|
91
|
+
if (!raw) return defaultProtectionConfig();
|
|
92
|
+
return JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
function saveProtectionFile(filePath, config, options = {}) {
|
|
96
|
+
const resolved = resolveProtectionFile(filePath, options.cwd);
|
|
97
|
+
const dir = path.dirname(resolved);
|
|
98
|
+
if (!SecurityUtils.safeExistsSync(dir, path.dirname(dir))) {
|
|
99
|
+
SecurityUtils.safeMkdirSync(dir, path.dirname(dir), { recursive: true });
|
|
100
|
+
}
|
|
101
|
+
const nextConfig = {
|
|
102
|
+
...defaultProtectionConfig(),
|
|
103
|
+
...(config || {}),
|
|
104
|
+
terms: normalizeList(config?.terms),
|
|
105
|
+
keys: normalizeList(config?.keys),
|
|
106
|
+
values: normalizeList(config?.values),
|
|
107
|
+
patterns: normalizeList(config?.patterns)
|
|
108
|
+
};
|
|
109
|
+
SecurityUtils.safeWriteFileSync(resolved, JSON.stringify(nextConfig, null, 2) + '\n', dir, 'utf8');
|
|
110
|
+
return resolved;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
function loadProtectionConfig(filePath, options = {}) {
|
|
114
|
+
if (options.enabled === false) {
|
|
115
|
+
return normalizeProtectionConfig({}, null);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
const resolved = resolveProtectionFile(filePath, options.cwd);
|
|
119
|
+
const dir = path.dirname(resolved);
|
|
120
|
+
|
|
121
|
+
if (!SecurityUtils.safeExistsSync(resolved, dir)) {
|
|
122
|
+
if (options.create) {
|
|
123
|
+
createProtectionFile(resolved, options);
|
|
124
|
+
} else {
|
|
125
|
+
return normalizeProtectionConfig({}, resolved);
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
const raw = SecurityUtils.safeReadFileSync(resolved, dir, 'utf8');
|
|
130
|
+
if (!raw) {
|
|
131
|
+
return normalizeProtectionConfig({}, resolved);
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
let parsed;
|
|
135
|
+
try {
|
|
136
|
+
parsed = JSON.parse(raw.replace(/^\uFEFF/, ''));
|
|
137
|
+
} catch (error) {
|
|
138
|
+
throw new Error(`Invalid Auto Translate protection JSON at ${resolved}: ${error.message}`);
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
return normalizeProtectionConfig(parsed, resolved);
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function escapeRegExp(value) {
|
|
145
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function keyMatchesRule(keyPath, rule) {
|
|
149
|
+
if (rule === keyPath) return true;
|
|
150
|
+
if (!rule.includes('*')) return false;
|
|
151
|
+
const regex = new RegExp(`^${rule.split('*').map(escapeRegExp).join('.*')}$`);
|
|
152
|
+
return regex.test(keyPath);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function shouldPreserveWholeValue(keyPath, value, protection) {
|
|
156
|
+
if (!protection || protection.enabled === false) return false;
|
|
157
|
+
if (protection.keys.some(rule => keyMatchesRule(keyPath, rule))) return true;
|
|
158
|
+
const valueText = String(value);
|
|
159
|
+
return protection.values.includes(valueText) || protection.terms.includes(valueText);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function addReplacement(replacements, original) {
|
|
163
|
+
if (!original) return;
|
|
164
|
+
if (replacements.some(item => item.original === original)) return;
|
|
165
|
+
replacements.push({ original });
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
function collectReplacements(value, protection) {
|
|
169
|
+
const text = String(value);
|
|
170
|
+
const replacements = [];
|
|
171
|
+
|
|
172
|
+
for (const term of protection.terms || []) {
|
|
173
|
+
if (text.includes(term)) {
|
|
174
|
+
addReplacement(replacements, term);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
for (const pattern of protection.compiledPatterns || []) {
|
|
179
|
+
pattern.lastIndex = 0;
|
|
180
|
+
let match;
|
|
181
|
+
while ((match = pattern.exec(text)) !== null) {
|
|
182
|
+
addReplacement(replacements, match[0]);
|
|
183
|
+
if (match[0] === '') pattern.lastIndex++;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
return replacements.sort((a, b) => b.original.length - a.original.length);
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
function protectText(value, protection) {
|
|
191
|
+
if (!protection || protection.enabled === false || typeof value !== 'string') {
|
|
192
|
+
return { value, map: new Map(), count: 0 };
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
const replacements = collectReplacements(value, protection);
|
|
196
|
+
if (replacements.length === 0) {
|
|
197
|
+
return { value, map: new Map(), count: 0 };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
let protectedValue = value;
|
|
201
|
+
const map = new Map();
|
|
202
|
+
replacements.forEach((replacement, index) => {
|
|
203
|
+
const token = `${TOKEN_PREFIX}${index}__`;
|
|
204
|
+
map.set(token, replacement.original);
|
|
205
|
+
protectedValue = protectedValue.split(replacement.original).join(token);
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
return { value: protectedValue, map, count: map.size };
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
function restoreText(value, map) {
|
|
212
|
+
if (!(map instanceof Map) || map.size === 0 || typeof value !== 'string') return value;
|
|
213
|
+
let restored = value;
|
|
214
|
+
for (const [token, original] of map.entries()) {
|
|
215
|
+
restored = restored.split(token).join(original);
|
|
216
|
+
}
|
|
217
|
+
return restored;
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function hasProtectionRules(protection) {
|
|
221
|
+
return Boolean(
|
|
222
|
+
protection &&
|
|
223
|
+
(
|
|
224
|
+
protection.terms.length ||
|
|
225
|
+
protection.keys.length ||
|
|
226
|
+
protection.values.length ||
|
|
227
|
+
protection.patterns.length
|
|
228
|
+
)
|
|
229
|
+
);
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
module.exports = {
|
|
233
|
+
DEFAULT_PROTECTION_FILE,
|
|
234
|
+
createProtectionFile,
|
|
235
|
+
defaultProtectionConfig,
|
|
236
|
+
hasProtectionRules,
|
|
237
|
+
loadProtectionConfig,
|
|
238
|
+
protectText,
|
|
239
|
+
readProtectionFile,
|
|
240
|
+
restoreText,
|
|
241
|
+
saveProtectionFile,
|
|
242
|
+
shouldPreserveWholeValue
|
|
243
|
+
};
|
|
@@ -1,4 +1,5 @@
|
|
|
1
|
-
const
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const SecurityUtils = require('../security');
|
|
2
3
|
|
|
3
4
|
function generateReport(skippedKeys, translatedCount, totalCount, options = {}) {
|
|
4
5
|
const {
|
|
@@ -6,7 +7,11 @@ function generateReport(skippedKeys, translatedCount, totalCount, options = {})
|
|
|
6
7
|
targetLang,
|
|
7
8
|
dryRun = false,
|
|
8
9
|
timestamp = new Date().toISOString(),
|
|
10
|
+
placeholderProtected = 0,
|
|
11
|
+
protectedSkipped = 0,
|
|
9
12
|
} = options;
|
|
13
|
+
const placeholderSkipped = skippedKeys.filter(key => key.skipReason !== 'protected');
|
|
14
|
+
const protectedKeys = skippedKeys.filter(key => key.skipReason === 'protected');
|
|
10
15
|
|
|
11
16
|
const lines = [];
|
|
12
17
|
lines.push('='.repeat(72));
|
|
@@ -20,6 +25,8 @@ function generateReport(skippedKeys, translatedCount, totalCount, options = {})
|
|
|
20
25
|
}
|
|
21
26
|
lines.push(` Total keys: ${totalCount}`);
|
|
22
27
|
lines.push(` Translated: ${translatedCount}`);
|
|
28
|
+
lines.push(` Placeholder-safe: ${String(placeholderProtected).padStart(6)}`);
|
|
29
|
+
lines.push(` Protected: ${String(protectedSkipped).padStart(6)}`);
|
|
23
30
|
lines.push(` Skipped: ${skippedKeys.length}`);
|
|
24
31
|
lines.push('='.repeat(72));
|
|
25
32
|
|
|
@@ -28,13 +35,20 @@ function generateReport(skippedKeys, translatedCount, totalCount, options = {})
|
|
|
28
35
|
lines.push(' All strings were processed. No keys were skipped.');
|
|
29
36
|
lines.push('');
|
|
30
37
|
} else {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
+
if (placeholderSkipped.length > 0) {
|
|
39
|
+
lines.push('');
|
|
40
|
+
lines.push(' WARNING: The following keys were SKIPPED because they contain');
|
|
41
|
+
lines.push(' dynamic placeholder tokens that should be manually translated');
|
|
42
|
+
lines.push(' to avoid runtime substitution breakage.');
|
|
43
|
+
lines.push('');
|
|
44
|
+
lines.push(' These entries were copied verbatim into the output file.');
|
|
45
|
+
lines.push(' You MUST manually translate them before using the file.');
|
|
46
|
+
}
|
|
47
|
+
if (protectedKeys.length > 0) {
|
|
48
|
+
lines.push('');
|
|
49
|
+
lines.push(' The following keys were copied unchanged because they matched');
|
|
50
|
+
lines.push(' Auto Translate protection rules for keys or exact values.');
|
|
51
|
+
}
|
|
38
52
|
lines.push('');
|
|
39
53
|
lines.push(` ${'-'.repeat(64)}`);
|
|
40
54
|
lines.push(` Key Path Original Value`);
|
|
@@ -51,20 +65,30 @@ function generateReport(skippedKeys, translatedCount, totalCount, options = {})
|
|
|
51
65
|
}
|
|
52
66
|
|
|
53
67
|
lines.push(` ${'-'.repeat(64)}`);
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
68
|
+
if (placeholderSkipped.length > 0) {
|
|
69
|
+
lines.push('');
|
|
70
|
+
lines.push(' REMINDER:');
|
|
71
|
+
lines.push(' 1. Open the target JSON file');
|
|
72
|
+
lines.push(' 2. Search for the placeholder-skipped keys listed above');
|
|
73
|
+
lines.push(' 3. Manually translate each value, preserving all placeholders');
|
|
74
|
+
lines.push(' exactly as they appear in the original');
|
|
75
|
+
lines.push(' 4. Verify placeholder integrity before runtime use');
|
|
76
|
+
lines.push('');
|
|
77
|
+
}
|
|
62
78
|
}
|
|
63
79
|
|
|
64
80
|
lines.push('');
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
81
|
+
if (skippedKeys.length === 0) {
|
|
82
|
+
lines.push(' The generated file can be used immediately after review.');
|
|
83
|
+
lines.push(' Placeholder tokens were preserved automatically where found.');
|
|
84
|
+
} else if (placeholderSkipped.length > 0) {
|
|
85
|
+
lines.push(' The generated file can be used immediately for all');
|
|
86
|
+
lines.push(' translated text. Only the skipped keys need');
|
|
87
|
+
lines.push(' manual attention.');
|
|
88
|
+
} else {
|
|
89
|
+
lines.push(' Protected keys and values were intentionally copied unchanged.');
|
|
90
|
+
lines.push(' Review the output file before using it in production.');
|
|
91
|
+
}
|
|
68
92
|
lines.push('='.repeat(72));
|
|
69
93
|
|
|
70
94
|
return lines.join('\n');
|
|
@@ -73,14 +97,17 @@ function generateReport(skippedKeys, translatedCount, totalCount, options = {})
|
|
|
73
97
|
function writeReport(reportText, filePath) {
|
|
74
98
|
if (!filePath) return;
|
|
75
99
|
try {
|
|
76
|
-
|
|
100
|
+
const resolvedPath = path.resolve(process.cwd(), filePath);
|
|
101
|
+
SecurityUtils.safeWriteFileSync(resolvedPath, reportText + '\n', path.dirname(resolvedPath), 'utf-8');
|
|
77
102
|
} catch (e) {
|
|
78
103
|
console.error('Failed to write report file:', e.message);
|
|
79
104
|
}
|
|
80
105
|
}
|
|
81
106
|
|
|
82
|
-
function formatSummaryLine(skippedCount, translatedCount, totalCount) {
|
|
83
|
-
|
|
107
|
+
function formatSummaryLine(skippedCount, translatedCount, totalCount, placeholderProtected = 0, protectedSkipped = 0) {
|
|
108
|
+
const protectedPart = placeholderProtected > 0 ? `, ${placeholderProtected} placeholder-safe` : '';
|
|
109
|
+
const glossaryPart = protectedSkipped > 0 ? `, ${protectedSkipped} protected` : '';
|
|
110
|
+
return `[translate] ${translatedCount} translated${protectedPart}${glossaryPart}, ${skippedCount} skipped (of ${totalCount} total keys)`;
|
|
84
111
|
}
|
|
85
112
|
|
|
86
113
|
module.exports = {
|
|
@@ -0,0 +1,175 @@
|
|
|
1
|
+
const DEFAULT_ENGLISH_THRESHOLD_PERCENT = 10;
|
|
2
|
+
|
|
3
|
+
const URL_PATTERN = /https?:\/\/[^\s"'<>]+/i;
|
|
4
|
+
const EMAIL_PATTERN = /[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}/;
|
|
5
|
+
const PRIVATE_KEY_PATTERN = /-----BEGIN [A-Z ]*PRIVATE KEY-----/;
|
|
6
|
+
const BEARER_TOKEN_PATTERN = /\bBearer\s+[A-Za-z0-9._~+/=-]{16,}/i;
|
|
7
|
+
const CREDENTIAL_ASSIGNMENT_PATTERN = /\b(api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|password|private[_-]?key|client[_-]?secret)\b\s*[:=]\s*["']?[A-Za-z0-9._~+/=-]{16,}/i;
|
|
8
|
+
const CREDENTIAL_KEY_PATTERN = /\b(api[_-]?key|access[_-]?token|auth[_-]?token|refresh[_-]?token|secret|password|private[_-]?key|client[_-]?secret)\b/i;
|
|
9
|
+
const OPAQUE_SECRET_PATTERN = /\b(AKIA[0-9A-Z]{16}|ASIA[0-9A-Z]{16}|AIza[0-9A-Za-z_-]{35}|sk_live_[0-9A-Za-z]{16,}|xox[baprs]-[0-9A-Za-z-]{16,}|gh[pousr]_[0-9A-Za-z_]{20,}|[A-Za-z0-9._~+/=-]{32,})\b/;
|
|
10
|
+
const JWT_PATTERN = /\beyJ[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\.[A-Za-z0-9_-]+\b/;
|
|
11
|
+
|
|
12
|
+
const ENGLISH_WORDS = new Set([
|
|
13
|
+
'a', 'about', 'above', 'after', 'again', 'all', 'also', 'an', 'and', 'any', 'are', 'as', 'at', 'available',
|
|
14
|
+
'back', 'based', 'be', 'because', 'before', 'below', 'best', 'book', 'build', 'by',
|
|
15
|
+
'can', 'cash', 'challenge', 'change', 'check', 'choose', 'click', 'close', 'coming', 'complete', 'confirm',
|
|
16
|
+
'continue', 'copy', 'create', 'current',
|
|
17
|
+
'data', 'delete', 'depth', 'details', 'done', 'down',
|
|
18
|
+
'earn', 'edit', 'email', 'empty', 'enter', 'error', 'exchange',
|
|
19
|
+
'failed', 'file', 'filter', 'for', 'from',
|
|
20
|
+
'governance',
|
|
21
|
+
'has', 'have', 'help', 'hide', 'history', 'home',
|
|
22
|
+
'if', 'in', 'into', 'is', 'it', 'its',
|
|
23
|
+
'key', 'keys',
|
|
24
|
+
'language', 'last', 'latest', 'learn', 'live', 'loading', 'log', 'login', 'logout',
|
|
25
|
+
'market', 'markets', 'message', 'missing', 'more',
|
|
26
|
+
'new', 'next', 'no', 'not',
|
|
27
|
+
'of', 'off', 'on', 'open', 'or', 'order', 'other', 'out', 'over', 'overview',
|
|
28
|
+
'page', 'participate', 'players', 'please', 'platform', 'predict', 'prediction', 'previous', 'public',
|
|
29
|
+
'real', 'record', 'remove', 'required', 'reset', 'result', 'results', 'rewards',
|
|
30
|
+
'save', 'search', 'select', 'settings', 'show', 'soon', 'start', 'status', 'structured', 'submit', 'success',
|
|
31
|
+
'the', 'their', 'this', 'through', 'to', 'track', 'try',
|
|
32
|
+
'up', 'update', 'use', 'used', 'using',
|
|
33
|
+
'value', 'view',
|
|
34
|
+
'warning', 'when', 'with', 'without'
|
|
35
|
+
]);
|
|
36
|
+
|
|
37
|
+
const DEFAULT_ALLOWED_ENGLISH_TERMS = new Set([
|
|
38
|
+
'api',
|
|
39
|
+
]);
|
|
40
|
+
|
|
41
|
+
function normalizeLanguage(language) {
|
|
42
|
+
return String(language || '').toLowerCase().split(/[-_]/)[0];
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
function toAllowedTermSet(terms) {
|
|
46
|
+
const allowed = new Set(DEFAULT_ALLOWED_ENGLISH_TERMS);
|
|
47
|
+
if (Array.isArray(terms)) {
|
|
48
|
+
terms.forEach(term => {
|
|
49
|
+
if (typeof term === 'string' && term.trim()) {
|
|
50
|
+
allowed.add(term.trim().toLowerCase());
|
|
51
|
+
}
|
|
52
|
+
});
|
|
53
|
+
}
|
|
54
|
+
return allowed;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function stripNonLanguageTokens(value) {
|
|
58
|
+
return String(value || '')
|
|
59
|
+
.replace(URL_PATTERN, ' ')
|
|
60
|
+
.replace(EMAIL_PATTERN, ' ')
|
|
61
|
+
.replace(/<[^>]+>/g, ' ')
|
|
62
|
+
.replace(/\{\{[^}]+\}\}|\{[^}]+\}|%[sdifjoO]/g, ' ');
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function isIgnoredEnglishToken(word, allowedTerms) {
|
|
66
|
+
const normalized = word.toLowerCase().replace(/^['-]+|['-]+$/g, '');
|
|
67
|
+
if (!normalized || normalized.length <= 2) return true;
|
|
68
|
+
if (allowedTerms.has(normalized)) return true;
|
|
69
|
+
if (/^[A-Z0-9_-]{2,}$/.test(word)) return true;
|
|
70
|
+
if (/\d/.test(word)) return true;
|
|
71
|
+
return false;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
function analyzeEnglishContent(value, options = {}) {
|
|
75
|
+
const allowedTerms = toAllowedTermSet(options.allowedEnglishTerms);
|
|
76
|
+
const words = stripNonLanguageTokens(value).match(/[A-Za-z][A-Za-z'-]*/g) || [];
|
|
77
|
+
const countedWords = [];
|
|
78
|
+
const englishWords = [];
|
|
79
|
+
|
|
80
|
+
words.forEach(word => {
|
|
81
|
+
if (isIgnoredEnglishToken(word, allowedTerms)) return;
|
|
82
|
+
|
|
83
|
+
const normalized = word.toLowerCase().replace(/^['-]+|['-]+$/g, '');
|
|
84
|
+
countedWords.push(normalized);
|
|
85
|
+
if (ENGLISH_WORDS.has(normalized)) {
|
|
86
|
+
englishWords.push(normalized);
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
const totalWordCount = countedWords.length;
|
|
91
|
+
const englishWordCount = englishWords.length;
|
|
92
|
+
const englishPercentage = totalWordCount === 0
|
|
93
|
+
? 0
|
|
94
|
+
: Number(((englishWordCount / totalWordCount) * 100).toFixed(2));
|
|
95
|
+
|
|
96
|
+
return {
|
|
97
|
+
englishPercentage,
|
|
98
|
+
englishWordCount,
|
|
99
|
+
totalWordCount,
|
|
100
|
+
englishWords: [...new Set(englishWords)].slice(0, 8)
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function hasSecretLikeValue(value, keyPath = '') {
|
|
105
|
+
const valueStr = String(value || '');
|
|
106
|
+
if (
|
|
107
|
+
PRIVATE_KEY_PATTERN.test(valueStr) ||
|
|
108
|
+
BEARER_TOKEN_PATTERN.test(valueStr) ||
|
|
109
|
+
CREDENTIAL_ASSIGNMENT_PATTERN.test(valueStr) ||
|
|
110
|
+
JWT_PATTERN.test(valueStr)
|
|
111
|
+
) {
|
|
112
|
+
return true;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
return CREDENTIAL_KEY_PATTERN.test(keyPath) && OPAQUE_SECRET_PATTERN.test(valueStr);
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
function detectTranslationContentRisks(value, options = {}) {
|
|
119
|
+
const valueStr = String(value || '');
|
|
120
|
+
const issues = [];
|
|
121
|
+
|
|
122
|
+
if (URL_PATTERN.test(valueStr)) {
|
|
123
|
+
issues.push({
|
|
124
|
+
type: 'url',
|
|
125
|
+
reason: 'Contains a URL; verify it is intentional and localized where needed.'
|
|
126
|
+
});
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
if (EMAIL_PATTERN.test(valueStr)) {
|
|
130
|
+
issues.push({
|
|
131
|
+
type: 'email',
|
|
132
|
+
reason: 'Contains an email address; verify it is intentional public contact content.'
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
if (hasSecretLikeValue(valueStr, options.keyPath)) {
|
|
137
|
+
issues.push({
|
|
138
|
+
type: 'secret',
|
|
139
|
+
reason: 'Looks like a credential or secret value, not ordinary translated content.'
|
|
140
|
+
});
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
const sourceLanguage = normalizeLanguage(options.sourceLanguage || 'en');
|
|
144
|
+
const targetLanguage = normalizeLanguage(options.targetLanguage);
|
|
145
|
+
if (targetLanguage && targetLanguage !== sourceLanguage) {
|
|
146
|
+
const threshold = Number.isFinite(Number(options.englishThresholdPercent))
|
|
147
|
+
? Number(options.englishThresholdPercent)
|
|
148
|
+
: DEFAULT_ENGLISH_THRESHOLD_PERCENT;
|
|
149
|
+
const english = analyzeEnglishContent(valueStr, options);
|
|
150
|
+
|
|
151
|
+
if (
|
|
152
|
+
english.englishPercentage > threshold &&
|
|
153
|
+
english.englishWordCount >= 3
|
|
154
|
+
) {
|
|
155
|
+
issues.push({
|
|
156
|
+
type: 'english_content',
|
|
157
|
+
reason: `Possible untranslated English content (${english.englishPercentage}% English words, threshold ${threshold}%).`,
|
|
158
|
+
englishPercentage: english.englishPercentage,
|
|
159
|
+
englishThresholdPercent: threshold,
|
|
160
|
+
englishWordCount: english.englishWordCount,
|
|
161
|
+
totalWordCount: english.totalWordCount,
|
|
162
|
+
englishWords: english.englishWords
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
return issues;
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
module.exports = {
|
|
171
|
+
DEFAULT_ENGLISH_THRESHOLD_PERCENT,
|
|
172
|
+
analyzeEnglishContent,
|
|
173
|
+
detectTranslationContentRisks,
|
|
174
|
+
hasSecretLikeValue
|
|
175
|
+
};
|