i18ntk 2.6.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 +62 -16
- package/README.md +55 -19
- package/main/i18ntk-sizing.js +471 -218
- package/main/i18ntk-translate.js +833 -0
- package/main/i18ntk-validate.js +26 -14
- package/main/manage/commands/CommandRouter.js +7 -1
- package/main/manage/commands/TranslateCommand.js +463 -0
- package/main/manage/commands/ValidateCommand.js +25 -13
- package/main/manage/index.js +11 -5
- package/package.json +14 -3
- package/settings/settings-cli.js +75 -29
- package/settings/settings-manager.js +109 -1
- package/ui-locales/de.json +5 -2
- package/ui-locales/en.json +5 -2
- package/ui-locales/es.json +5 -2
- package/ui-locales/fr.json +5 -2
- package/ui-locales/ja.json +5 -2
- package/ui-locales/ru.json +7 -4
- package/ui-locales/zh.json +5 -2
- package/utils/config-manager.js +20 -4
- package/utils/security.js +4 -3
- package/utils/translate/api.js +168 -0
- package/utils/translate/cli.js +95 -0
- package/utils/translate/placeholder.js +153 -0
- package/utils/translate/protection.js +243 -0
- package/utils/translate/report.js +117 -0
- package/utils/translate/traverse.js +148 -0
- package/utils/validation-risk.js +175 -0
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
const path = require('path');
|
|
2
|
+
const SecurityUtils = require('../security');
|
|
3
|
+
|
|
4
|
+
function generateReport(skippedKeys, translatedCount, totalCount, options = {}) {
|
|
5
|
+
const {
|
|
6
|
+
sourceFile,
|
|
7
|
+
targetLang,
|
|
8
|
+
dryRun = false,
|
|
9
|
+
timestamp = new Date().toISOString(),
|
|
10
|
+
placeholderProtected = 0,
|
|
11
|
+
protectedSkipped = 0,
|
|
12
|
+
} = options;
|
|
13
|
+
const placeholderSkipped = skippedKeys.filter(key => key.skipReason !== 'protected');
|
|
14
|
+
const protectedKeys = skippedKeys.filter(key => key.skipReason === 'protected');
|
|
15
|
+
|
|
16
|
+
const lines = [];
|
|
17
|
+
lines.push('='.repeat(72));
|
|
18
|
+
lines.push(' I18NTK POST-TRANSLATION REPORT');
|
|
19
|
+
lines.push('='.repeat(72));
|
|
20
|
+
lines.push(` Generated: ${timestamp}`);
|
|
21
|
+
lines.push(` Source file: ${sourceFile || 'N/A'}`);
|
|
22
|
+
lines.push(` Target language: ${targetLang || 'N/A'}`);
|
|
23
|
+
if (dryRun) {
|
|
24
|
+
lines.push(` Mode: DRY-RUN (no API calls made)`);
|
|
25
|
+
}
|
|
26
|
+
lines.push(` Total keys: ${totalCount}`);
|
|
27
|
+
lines.push(` Translated: ${translatedCount}`);
|
|
28
|
+
lines.push(` Placeholder-safe: ${String(placeholderProtected).padStart(6)}`);
|
|
29
|
+
lines.push(` Protected: ${String(protectedSkipped).padStart(6)}`);
|
|
30
|
+
lines.push(` Skipped: ${skippedKeys.length}`);
|
|
31
|
+
lines.push('='.repeat(72));
|
|
32
|
+
|
|
33
|
+
if (skippedKeys.length === 0) {
|
|
34
|
+
lines.push('');
|
|
35
|
+
lines.push(' All strings were processed. No keys were skipped.');
|
|
36
|
+
lines.push('');
|
|
37
|
+
} else {
|
|
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
|
+
}
|
|
52
|
+
lines.push('');
|
|
53
|
+
lines.push(` ${'-'.repeat(64)}`);
|
|
54
|
+
lines.push(` Key Path Original Value`);
|
|
55
|
+
lines.push(` ${'-'.repeat(64)}`);
|
|
56
|
+
|
|
57
|
+
for (const skip of skippedKeys) {
|
|
58
|
+
const keyDisplay = skip.keyPath.length > 50
|
|
59
|
+
? skip.keyPath.substring(0, 47) + '...'
|
|
60
|
+
: skip.keyPath.padEnd(50);
|
|
61
|
+
const valDisplay = skip.value.length > 80
|
|
62
|
+
? skip.value.substring(0, 77) + '...'
|
|
63
|
+
: skip.value;
|
|
64
|
+
lines.push(` ${keyDisplay} ${valDisplay}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
lines.push(` ${'-'.repeat(64)}`);
|
|
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
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
lines.push('');
|
|
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
|
+
}
|
|
92
|
+
lines.push('='.repeat(72));
|
|
93
|
+
|
|
94
|
+
return lines.join('\n');
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function writeReport(reportText, filePath) {
|
|
98
|
+
if (!filePath) return;
|
|
99
|
+
try {
|
|
100
|
+
const resolvedPath = path.resolve(process.cwd(), filePath);
|
|
101
|
+
SecurityUtils.safeWriteFileSync(resolvedPath, reportText + '\n', path.dirname(resolvedPath), 'utf-8');
|
|
102
|
+
} catch (e) {
|
|
103
|
+
console.error('Failed to write report file:', e.message);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
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)`;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
module.exports = {
|
|
114
|
+
generateReport,
|
|
115
|
+
writeReport,
|
|
116
|
+
formatSummaryLine,
|
|
117
|
+
};
|
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
function deepTraverse(obj, visitor, keyPath = '') {
|
|
2
|
+
if (obj === null || obj === undefined) {
|
|
3
|
+
visitor({ type: 'null', keyPath, value: null });
|
|
4
|
+
return;
|
|
5
|
+
}
|
|
6
|
+
if (typeof obj === 'string') {
|
|
7
|
+
visitor({ type: 'string', keyPath, value: obj });
|
|
8
|
+
return;
|
|
9
|
+
}
|
|
10
|
+
if (typeof obj === 'number' || typeof obj === 'boolean') {
|
|
11
|
+
visitor({ type: 'leaf', keyPath, value: obj });
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (Array.isArray(obj)) {
|
|
15
|
+
visitor({ type: 'array-start', keyPath, value: obj });
|
|
16
|
+
for (let i = 0; i < obj.length; i++) {
|
|
17
|
+
const childPath = keyPath ? `${keyPath}[${i}]` : `[${i}]`;
|
|
18
|
+
deepTraverse(obj[i], visitor, childPath);
|
|
19
|
+
}
|
|
20
|
+
visitor({ type: 'array-end', keyPath, value: obj });
|
|
21
|
+
return;
|
|
22
|
+
}
|
|
23
|
+
if (typeof obj === 'object') {
|
|
24
|
+
visitor({ type: 'object-start', keyPath, value: obj });
|
|
25
|
+
for (const key of Object.keys(obj)) {
|
|
26
|
+
const childPath = keyPath ? `${keyPath}.${key}` : key;
|
|
27
|
+
deepTraverse(obj[key], visitor, childPath);
|
|
28
|
+
}
|
|
29
|
+
visitor({ type: 'object-end', keyPath, value: obj });
|
|
30
|
+
return;
|
|
31
|
+
}
|
|
32
|
+
visitor({ type: 'unknown', keyPath, value: String(obj) });
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
function collectLeaves(obj, prefix = '') {
|
|
36
|
+
const entries = [];
|
|
37
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
38
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
39
|
+
if (typeof value === 'string') {
|
|
40
|
+
entries.push({ keyPath: fullKey, value });
|
|
41
|
+
} else if (Array.isArray(value)) {
|
|
42
|
+
for (let i = 0; i < value.length; i++) {
|
|
43
|
+
const itemPath = `${fullKey}[${i}]`;
|
|
44
|
+
if (typeof value[i] === 'string') {
|
|
45
|
+
entries.push({ keyPath: itemPath, value: value[i] });
|
|
46
|
+
} else if (typeof value[i] === 'object' && value[i] !== null && !Array.isArray(value[i])) {
|
|
47
|
+
entries.push(...collectLeaves(value[i], itemPath));
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
} else if (typeof value === 'object' && value !== null) {
|
|
51
|
+
entries.push(...collectLeaves(value, fullKey));
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return entries;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function setLeaf(obj, keyPath, value) {
|
|
58
|
+
const parts = [];
|
|
59
|
+
let current = '';
|
|
60
|
+
for (let i = 0; i < keyPath.length; i++) {
|
|
61
|
+
const ch = keyPath[i];
|
|
62
|
+
if (ch === '.' && keyPath[i - 1] !== '\\') {
|
|
63
|
+
if (current) parts.push(current);
|
|
64
|
+
current = '';
|
|
65
|
+
} else if (ch === '[') {
|
|
66
|
+
if (current) parts.push(current);
|
|
67
|
+
current = '';
|
|
68
|
+
i++;
|
|
69
|
+
while (i < keyPath.length && keyPath[i] !== ']') {
|
|
70
|
+
current += keyPath[i];
|
|
71
|
+
i++;
|
|
72
|
+
}
|
|
73
|
+
parts.push(`[${current}]`);
|
|
74
|
+
current = '';
|
|
75
|
+
} else {
|
|
76
|
+
current += ch;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (current) parts.push(current);
|
|
80
|
+
|
|
81
|
+
let target = obj;
|
|
82
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
83
|
+
const part = parts[i];
|
|
84
|
+
if (part.startsWith('[') && part.endsWith(']')) {
|
|
85
|
+
const idx = parseInt(part.slice(1, -1), 10);
|
|
86
|
+
if (!(idx in target)) target[idx] = {};
|
|
87
|
+
target = target[idx];
|
|
88
|
+
} else {
|
|
89
|
+
if (!(part in target)) target[part] = {};
|
|
90
|
+
target = target[part];
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
const last = parts[parts.length - 1];
|
|
95
|
+
if (last.startsWith('[') && last.endsWith(']')) {
|
|
96
|
+
target[parseInt(last.slice(1, -1), 10)] = value;
|
|
97
|
+
} else {
|
|
98
|
+
target[last] = value;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function getLeaf(obj, keyPath) {
|
|
103
|
+
const parts = [];
|
|
104
|
+
let current = '';
|
|
105
|
+
for (let i = 0; i < keyPath.length; i++) {
|
|
106
|
+
const ch = keyPath[i];
|
|
107
|
+
if (ch === '.' && keyPath[i - 1] !== '\\') {
|
|
108
|
+
if (current) parts.push(current);
|
|
109
|
+
current = '';
|
|
110
|
+
} else if (ch === '[') {
|
|
111
|
+
if (current) parts.push(current);
|
|
112
|
+
current = '';
|
|
113
|
+
i++;
|
|
114
|
+
while (i < keyPath.length && keyPath[i] !== ']') {
|
|
115
|
+
current += keyPath[i];
|
|
116
|
+
i++;
|
|
117
|
+
}
|
|
118
|
+
parts.push(`[${current}]`);
|
|
119
|
+
current = '';
|
|
120
|
+
} else {
|
|
121
|
+
current += ch;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (current) parts.push(current);
|
|
125
|
+
|
|
126
|
+
let target = obj;
|
|
127
|
+
for (const part of parts) {
|
|
128
|
+
if (target === null || target === undefined) return undefined;
|
|
129
|
+
if (part.startsWith('[') && part.endsWith(']')) {
|
|
130
|
+
target = target[parseInt(part.slice(1, -1), 10)];
|
|
131
|
+
} else {
|
|
132
|
+
target = target[part];
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
return target;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
function deepClone(obj) {
|
|
139
|
+
return JSON.parse(JSON.stringify(obj));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
module.exports = {
|
|
143
|
+
deepTraverse,
|
|
144
|
+
collectLeaves,
|
|
145
|
+
setLeaf,
|
|
146
|
+
getLeaf,
|
|
147
|
+
deepClone,
|
|
148
|
+
};
|
|
@@ -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
|
+
};
|