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
package/main/i18ntk-validate.js
CHANGED
|
@@ -46,9 +46,10 @@ const watchLocales = require('../utils/watch-locales');
|
|
|
46
46
|
const { getGlobalReadline, closeGlobalReadline } = require('../utils/cli');
|
|
47
47
|
const { getUnifiedConfig, parseCommonArgs, displayHelp, validateSourceDir, displayPaths } = require('../utils/config-helper');
|
|
48
48
|
const I18nInitializer = require('./i18ntk-init');
|
|
49
|
-
const JsonOutput = require('../utils/json-output');
|
|
50
|
-
const ExitCodes = require('../utils/exit-codes');
|
|
51
|
-
const SetupEnforcer = require('../utils/setup-enforcer');
|
|
49
|
+
const JsonOutput = require('../utils/json-output');
|
|
50
|
+
const ExitCodes = require('../utils/exit-codes');
|
|
51
|
+
const SetupEnforcer = require('../utils/setup-enforcer');
|
|
52
|
+
const { detectTranslationContentRisks } = require('../utils/validation-risk');
|
|
52
53
|
|
|
53
54
|
// Ensure setup is complete before running
|
|
54
55
|
(async () => {
|
|
@@ -405,17 +406,28 @@ class I18nValidator {
|
|
|
405
406
|
}
|
|
406
407
|
}
|
|
407
408
|
|
|
408
|
-
detectRiskyKeys(obj, language, fileName, prefix = '') {
|
|
409
|
-
for (const [key, value] of Object.entries(obj || {})) {
|
|
410
|
-
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
411
|
-
if (typeof value === 'string') {
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
409
|
+
detectRiskyKeys(obj, language, fileName, prefix = '') {
|
|
410
|
+
for (const [key, value] of Object.entries(obj || {})) {
|
|
411
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
412
|
+
if (typeof value === 'string') {
|
|
413
|
+
const issues = detectTranslationContentRisks(value, {
|
|
414
|
+
keyPath: fullKey,
|
|
415
|
+
sourceLanguage: this.config.sourceLanguage,
|
|
416
|
+
targetLanguage: language,
|
|
417
|
+
allowedEnglishTerms: this.config.allowedEnglishTerms,
|
|
418
|
+
englishThresholdPercent: this.config.englishContentThresholdPercent
|
|
419
|
+
});
|
|
420
|
+
|
|
421
|
+
issues.forEach(issue => {
|
|
422
|
+
const reporter = this.config.strictMode ? this.addError.bind(this) : this.addWarning.bind(this);
|
|
423
|
+
const message = issue.type === 'english_content'
|
|
424
|
+
? `Possible untranslated English content in ${language}/${fileName}`
|
|
425
|
+
: `Potential risky content in ${language}/${fileName}`;
|
|
426
|
+
reporter(message, { key: fullKey, value, ...issue });
|
|
427
|
+
});
|
|
428
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
429
|
+
this.detectRiskyKeys(value, language, fileName, fullKey);
|
|
430
|
+
}
|
|
419
431
|
}
|
|
420
432
|
}
|
|
421
433
|
|
|
@@ -7,11 +7,17 @@
|
|
|
7
7
|
* Wraps i18ntk-translate.js behind a user-friendly menu flow.
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
|
-
const fs = require('fs');
|
|
11
10
|
const path = require('path');
|
|
11
|
+
const SecurityUtils = require('../../../utils/security');
|
|
12
|
+
const configManager = require('../../../utils/config-manager');
|
|
12
13
|
const { getUnifiedConfig } = require('../../../utils/config-helper');
|
|
13
14
|
const { loadTranslations } = require('../../../utils/i18n-helper');
|
|
14
15
|
const SetupEnforcer = require('../../../utils/setup-enforcer');
|
|
16
|
+
const {
|
|
17
|
+
createProtectionFile,
|
|
18
|
+
readProtectionFile,
|
|
19
|
+
saveProtectionFile
|
|
20
|
+
} = require('../../../utils/translate/protection');
|
|
15
21
|
|
|
16
22
|
class TranslateCommand {
|
|
17
23
|
constructor(config = {}, ui = null) {
|
|
@@ -23,6 +29,7 @@ class TranslateCommand {
|
|
|
23
29
|
this.sourceDir = null;
|
|
24
30
|
this.sourceLang = null;
|
|
25
31
|
this.targetLang = null;
|
|
32
|
+
this.configuredTargetLangs = [];
|
|
26
33
|
}
|
|
27
34
|
|
|
28
35
|
setRuntimeDependencies(prompt, isNonInteractiveMode, safeClose) {
|
|
@@ -42,10 +49,17 @@ class TranslateCommand {
|
|
|
42
49
|
loadTranslations('en', path.resolve(__dirname, '..', '..', '..', 'ui-locales'));
|
|
43
50
|
|
|
44
51
|
const config = this.config || {};
|
|
45
|
-
|
|
52
|
+
let unified;
|
|
53
|
+
try {
|
|
54
|
+
unified = await getUnifiedConfig('translate', options);
|
|
55
|
+
} catch (_) {
|
|
56
|
+
unified = config;
|
|
57
|
+
}
|
|
58
|
+
this.autoTranslateSettings = this.getAutoTranslateSettings(unified);
|
|
46
59
|
|
|
47
60
|
const defaultSourceDir = unified.sourceDir || unified.i18nDir || path.resolve(process.cwd(), 'locales', 'en');
|
|
48
61
|
this.sourceLang = unified.sourceLanguage || 'en';
|
|
62
|
+
this.configuredTargetLangs = this.getConfiguredTargetLanguages(unified, defaultSourceDir);
|
|
49
63
|
|
|
50
64
|
console.log('\n============================================================');
|
|
51
65
|
console.log(' \u{1F310} AUTO TRANSLATE (BETA)');
|
|
@@ -53,11 +67,11 @@ class TranslateCommand {
|
|
|
53
67
|
|
|
54
68
|
if (this.isNonInteractiveMode) {
|
|
55
69
|
this.sourceDir = defaultSourceDir;
|
|
56
|
-
if (!
|
|
70
|
+
if (!SecurityUtils.safeExistsSync(this.sourceDir, path.dirname(this.sourceDir))) {
|
|
57
71
|
console.error(`Source locale directory not found: ${this.sourceDir}`);
|
|
58
72
|
return { success: false, error: 'Source directory not found' };
|
|
59
73
|
}
|
|
60
|
-
const jsonFiles =
|
|
74
|
+
const jsonFiles = SecurityUtils.safeReaddirSync(this.sourceDir, path.dirname(this.sourceDir)).filter(f => f.endsWith('.json')).sort();
|
|
61
75
|
return await this.nonInteractiveFlow(jsonFiles);
|
|
62
76
|
}
|
|
63
77
|
|
|
@@ -70,8 +84,9 @@ class TranslateCommand {
|
|
|
70
84
|
// Step 2: Choose source language
|
|
71
85
|
this.sourceLang = await this.promptSourceLang(ask);
|
|
72
86
|
if (!this.sourceLang) return { success: false, error: 'No source language selected' };
|
|
87
|
+
this.configuredTargetLangs = this.getConfiguredTargetLanguages(unified, this.sourceDir);
|
|
73
88
|
|
|
74
|
-
const jsonFiles =
|
|
89
|
+
const jsonFiles = SecurityUtils.safeReaddirSync(this.sourceDir, path.dirname(this.sourceDir))
|
|
75
90
|
.filter(f => f.endsWith('.json'))
|
|
76
91
|
.sort();
|
|
77
92
|
|
|
@@ -85,12 +100,19 @@ class TranslateCommand {
|
|
|
85
100
|
|
|
86
101
|
async promptSourceDir(ask, defaultDir) {
|
|
87
102
|
while (true) {
|
|
88
|
-
console.log(
|
|
89
|
-
console.log(
|
|
103
|
+
console.log('\n Source locale directory');
|
|
104
|
+
console.log(` Default: ${defaultDir}`);
|
|
105
|
+
console.log(` Current project: ${process.cwd()}`);
|
|
106
|
+
console.log(' Accepted: an absolute path, or a path relative to the current project.');
|
|
107
|
+
console.log(' Examples:');
|
|
108
|
+
console.log(' ./locales/en');
|
|
109
|
+
console.log(` ${defaultDir}`);
|
|
110
|
+
console.log(' The folder must contain the source JSON files to translate.');
|
|
111
|
+
console.log(' Press Enter to use the default.');
|
|
90
112
|
const input = await ask(' > ');
|
|
91
113
|
|
|
92
114
|
if (!input.trim()) {
|
|
93
|
-
if (!
|
|
115
|
+
if (!SecurityUtils.safeExistsSync(defaultDir, path.dirname(defaultDir))) {
|
|
94
116
|
console.log(` Default directory not found: ${defaultDir}`);
|
|
95
117
|
console.log(' Please enter an existing directory with JSON locale files.');
|
|
96
118
|
continue;
|
|
@@ -99,25 +121,36 @@ class TranslateCommand {
|
|
|
99
121
|
return defaultDir;
|
|
100
122
|
}
|
|
101
123
|
|
|
102
|
-
const
|
|
103
|
-
|
|
124
|
+
const cleanInput = input.trim().replace(/^["']|["']$/g, '');
|
|
125
|
+
const resolved = path.isAbsolute(cleanInput)
|
|
126
|
+
? path.resolve(cleanInput)
|
|
127
|
+
: path.resolve(process.cwd(), cleanInput);
|
|
128
|
+
if (!SecurityUtils.safeExistsSync(resolved, path.dirname(resolved))) {
|
|
104
129
|
console.log(` Directory not found: ${resolved}`);
|
|
130
|
+
console.log(' Enter an existing folder, for example ./locales/en.');
|
|
105
131
|
continue;
|
|
106
132
|
}
|
|
107
|
-
|
|
133
|
+
const stats = SecurityUtils.safeStatSync(resolved, path.dirname(resolved));
|
|
134
|
+
if (!stats || !stats.isDirectory()) {
|
|
108
135
|
console.log(` Not a directory: ${resolved}`);
|
|
109
136
|
continue;
|
|
110
137
|
}
|
|
138
|
+
console.log(` Using source directory: ${resolved}`);
|
|
111
139
|
return resolved;
|
|
112
140
|
}
|
|
113
141
|
}
|
|
114
142
|
|
|
115
143
|
async promptSourceLang(ask) {
|
|
116
144
|
while (true) {
|
|
117
|
-
console.log(
|
|
145
|
+
console.log('\n Source language code');
|
|
146
|
+
console.log(` Default: ${this.sourceLang}`);
|
|
147
|
+
console.log(' This should match the language of the source JSON values.');
|
|
148
|
+
console.log(' Example: en');
|
|
149
|
+
console.log(' Press Enter to use the default.');
|
|
118
150
|
const input = await ask(' > ');
|
|
119
151
|
|
|
120
152
|
if (!input.trim()) {
|
|
153
|
+
console.log(` Using source language: ${this.sourceLang}`);
|
|
121
154
|
return this.sourceLang;
|
|
122
155
|
}
|
|
123
156
|
|
|
@@ -130,35 +163,44 @@ class TranslateCommand {
|
|
|
130
163
|
}
|
|
131
164
|
|
|
132
165
|
async interactiveFlow(jsonFiles, ask) {
|
|
166
|
+
await this.maybeConfigureProtection(ask);
|
|
133
167
|
|
|
134
|
-
console.log('\n Target language
|
|
135
|
-
|
|
136
|
-
|
|
168
|
+
console.log('\n Target language(s)');
|
|
169
|
+
if (this.configuredTargetLangs.length > 0) {
|
|
170
|
+
console.log(` a) All configured target languages: ${this.configuredTargetLangs.join(', ')}`);
|
|
171
|
+
} else {
|
|
172
|
+
console.log(' a) All configured target languages: none configured');
|
|
173
|
+
}
|
|
174
|
+
console.log(' Or enter one or more comma/space-separated language codes.');
|
|
175
|
+
console.log(' Examples: de, es, fr or de es fr or zh');
|
|
176
|
+
console.log(` Source language "${this.sourceLang}" will be excluded automatically.`);
|
|
137
177
|
const langInput = await ask(' > ');
|
|
138
178
|
|
|
139
|
-
const targetLangs = langInput
|
|
140
|
-
.trim()
|
|
141
|
-
.split(/[,;\s]+/)
|
|
142
|
-
.map(s => s.toLowerCase().trim())
|
|
143
|
-
.filter(s => s.length >= 2);
|
|
179
|
+
const targetLangs = this.parseTargetLanguages(langInput);
|
|
144
180
|
|
|
145
181
|
if (targetLangs.length === 0) {
|
|
146
|
-
console.log(' No valid
|
|
182
|
+
console.log(' No valid target languages selected. Aborting.');
|
|
183
|
+
if (this.configuredTargetLangs.length === 0) {
|
|
184
|
+
console.log(' Configure defaultLanguages in .i18ntk-config, or enter target codes manually.');
|
|
185
|
+
}
|
|
147
186
|
return { success: false, error: 'Invalid language code' };
|
|
148
187
|
}
|
|
149
188
|
|
|
150
189
|
console.log(`\n Target languages: ${targetLangs.join(', ')}`);
|
|
151
190
|
|
|
152
191
|
console.log(`\n Which file(s) to translate?`);
|
|
153
|
-
|
|
192
|
+
const filePreview = jsonFiles.length <= 6
|
|
193
|
+
? jsonFiles.join(', ')
|
|
194
|
+
: `${jsonFiles.slice(0, 6).join(', ')}, ...`;
|
|
195
|
+
console.log(` a) All JSON files (${jsonFiles.length}: ${filePreview})`);
|
|
154
196
|
jsonFiles.forEach((f, i) => {
|
|
155
197
|
console.log(` ${i + 1}) ${f}`);
|
|
156
198
|
});
|
|
157
199
|
|
|
158
|
-
const fileChoice = await ask('\n Choice [a/
|
|
200
|
+
const fileChoice = await ask('\n Choice [a/all or file number]: ');
|
|
159
201
|
let sourceFiles;
|
|
160
202
|
|
|
161
|
-
if (fileChoice.toLowerCase()
|
|
203
|
+
if (['a', 'all', '*'].includes(fileChoice.trim().toLowerCase())) {
|
|
162
204
|
sourceFiles = jsonFiles.map(f => path.join(this.sourceDir, f));
|
|
163
205
|
} else {
|
|
164
206
|
const idx = parseInt(fileChoice, 10) - 1;
|
|
@@ -169,10 +211,12 @@ class TranslateCommand {
|
|
|
169
211
|
sourceFiles = [path.join(this.sourceDir, jsonFiles[idx])];
|
|
170
212
|
}
|
|
171
213
|
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
214
|
+
if (this.autoTranslateSettings.dryRunFirst !== false) {
|
|
215
|
+
// Dry-run for first language only (all languages use same source so same keys)
|
|
216
|
+
const firstLang = targetLangs[0];
|
|
217
|
+
console.log(`\n Dry-run preview for "${firstLang}"...\n`);
|
|
218
|
+
await this.runTranslate(sourceFiles, firstLang, { dryRun: true });
|
|
219
|
+
}
|
|
176
220
|
|
|
177
221
|
console.log('\n Proceed with actual translation?');
|
|
178
222
|
const answer = await ask(' [y]es / [n]o: ');
|
|
@@ -207,34 +251,211 @@ class TranslateCommand {
|
|
|
207
251
|
return { success: false, error: 'Non-interactive mode not supported from menu' };
|
|
208
252
|
}
|
|
209
253
|
|
|
254
|
+
getConfiguredTargetLanguages(config = {}, sourceDir = this.sourceDir) {
|
|
255
|
+
const candidates = []
|
|
256
|
+
.concat(config.defaultLanguages || [])
|
|
257
|
+
.concat(config.targetLanguages || [])
|
|
258
|
+
.concat(config.supportedLanguages || [])
|
|
259
|
+
.concat(config.settings?.defaultLanguages || []);
|
|
260
|
+
|
|
261
|
+
const parentDir = sourceDir ? path.dirname(sourceDir) : null;
|
|
262
|
+
if (parentDir && SecurityUtils.safeExistsSync(parentDir, path.dirname(parentDir))) {
|
|
263
|
+
const siblingDirs = SecurityUtils.safeReaddirSync(parentDir, path.dirname(parentDir), { withFileTypes: true })
|
|
264
|
+
.filter(entry => entry.isDirectory())
|
|
265
|
+
.map(entry => entry.name);
|
|
266
|
+
candidates.push(...siblingDirs);
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
return this.normalizeLanguageList(candidates);
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
normalizeLanguageList(languages) {
|
|
273
|
+
const seen = new Set();
|
|
274
|
+
const sourceLang = String(this.sourceLang || '').toLowerCase();
|
|
275
|
+
const normalized = [];
|
|
276
|
+
|
|
277
|
+
for (const lang of languages || []) {
|
|
278
|
+
if (typeof lang !== 'string') continue;
|
|
279
|
+
const clean = lang.trim().toLowerCase();
|
|
280
|
+
if (!/^[a-z]{2,3}(?:[-_][a-z0-9]{2,8})?$/i.test(clean)) continue;
|
|
281
|
+
if (clean === sourceLang || seen.has(clean)) continue;
|
|
282
|
+
seen.add(clean);
|
|
283
|
+
normalized.push(clean);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
return normalized.sort();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
parseTargetLanguages(input) {
|
|
290
|
+
const clean = String(input || '').trim().toLowerCase();
|
|
291
|
+
if (['a', 'all', '*'].includes(clean)) {
|
|
292
|
+
return [...this.configuredTargetLangs];
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
return this.normalizeLanguageList(clean.split(/[,;\s]+/));
|
|
296
|
+
}
|
|
297
|
+
|
|
298
|
+
getAutoTranslateSettings(config = {}) {
|
|
299
|
+
const settings = config.autoTranslate || config.settings?.autoTranslate || {};
|
|
300
|
+
return {
|
|
301
|
+
placeholderMode: ['preserve', 'skip', 'send'].includes(settings.placeholderMode)
|
|
302
|
+
? settings.placeholderMode
|
|
303
|
+
: 'preserve',
|
|
304
|
+
concurrency: this.toInt(settings.concurrency, 6, 1, 25),
|
|
305
|
+
batchSize: this.toInt(settings.batchSize, 100, 1, 10000),
|
|
306
|
+
progressInterval: this.toInt(settings.progressInterval, 25, 1, 10000),
|
|
307
|
+
retryCount: this.toInt(settings.retryCount, 3, 0, 10),
|
|
308
|
+
retryDelay: this.toInt(settings.retryDelay, 1000, 0, 30000),
|
|
309
|
+
timeout: this.toInt(settings.timeout, 15000, 1000, 120000),
|
|
310
|
+
dryRunFirst: settings.dryRunFirst !== false,
|
|
311
|
+
reportStdout: settings.reportStdout !== false,
|
|
312
|
+
bom: settings.bom === true,
|
|
313
|
+
protectionEnabled: settings.protectionEnabled !== false,
|
|
314
|
+
protectionFile: settings.protectionFile || './i18ntk-auto-translate.json',
|
|
315
|
+
promptProtectionSetup: settings.promptProtectionSetup !== false,
|
|
316
|
+
promptProtectionUpdate: settings.promptProtectionUpdate !== false
|
|
317
|
+
};
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
async updateAutoTranslateSetting(key, value) {
|
|
321
|
+
try {
|
|
322
|
+
await configManager.updateConfig({ autoTranslate: { [key]: value } });
|
|
323
|
+
await configManager.saveConfig();
|
|
324
|
+
this.autoTranslateSettings[key] = value;
|
|
325
|
+
} catch (error) {
|
|
326
|
+
console.log(` Warning: could not save Auto Translate setting "${key}": ${error.message}`);
|
|
327
|
+
}
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
async maybeConfigureProtection(ask) {
|
|
331
|
+
const settings = this.autoTranslateSettings;
|
|
332
|
+
if (!settings.protectionEnabled) return;
|
|
333
|
+
|
|
334
|
+
const protectionPath = path.resolve(process.cwd(), settings.protectionFile);
|
|
335
|
+
const exists = SecurityUtils.safeExistsSync(protectionPath, path.dirname(protectionPath));
|
|
336
|
+
|
|
337
|
+
if (!exists && settings.promptProtectionSetup) {
|
|
338
|
+
console.log('\n Protected terms and keys');
|
|
339
|
+
console.log(' Auto Translate can keep brand names, product terms, exact values, or key paths unchanged.');
|
|
340
|
+
console.log(` Protection file: ${protectionPath}`);
|
|
341
|
+
console.log(' Create this JSON file now?');
|
|
342
|
+
const answer = await ask(' [y]es / [n]o / [d]on\'t ask again: ');
|
|
343
|
+
const clean = answer.trim().toLowerCase();
|
|
344
|
+
if (clean === 'd' || clean === 'dont ask again' || clean === "don't ask again") {
|
|
345
|
+
await this.updateAutoTranslateSetting('promptProtectionSetup', false);
|
|
346
|
+
return;
|
|
347
|
+
}
|
|
348
|
+
if (/^y|yes$/i.test(clean)) {
|
|
349
|
+
createProtectionFile(settings.protectionFile);
|
|
350
|
+
await this.promptProtectionEntries(ask, settings.protectionFile);
|
|
351
|
+
return;
|
|
352
|
+
}
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
if (exists && settings.promptProtectionUpdate) {
|
|
356
|
+
console.log('\n Protected terms and keys');
|
|
357
|
+
console.log(` Current protection file: ${protectionPath}`);
|
|
358
|
+
console.log(' Update protection rules for this run?');
|
|
359
|
+
const answer = await ask(' [y]es / [n]o / [d]on\'t ask again: ');
|
|
360
|
+
const clean = answer.trim().toLowerCase();
|
|
361
|
+
if (clean === 'd' || clean === 'dont ask again' || clean === "don't ask again") {
|
|
362
|
+
await this.updateAutoTranslateSetting('promptProtectionUpdate', false);
|
|
363
|
+
return;
|
|
364
|
+
}
|
|
365
|
+
if (/^y|yes$/i.test(clean)) {
|
|
366
|
+
await this.promptProtectionEntries(ask, settings.protectionFile);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
splitList(input) {
|
|
372
|
+
return String(input || '')
|
|
373
|
+
.split(/[,;\n]+/)
|
|
374
|
+
.map(item => item.trim())
|
|
375
|
+
.filter(Boolean);
|
|
376
|
+
}
|
|
377
|
+
|
|
378
|
+
mergeList(existing, additions) {
|
|
379
|
+
const seen = new Set(existing || []);
|
|
380
|
+
for (const item of additions || []) {
|
|
381
|
+
if (!seen.has(item)) seen.add(item);
|
|
382
|
+
}
|
|
383
|
+
return Array.from(seen);
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
async promptProtectionEntries(ask, protectionFile) {
|
|
387
|
+
let config;
|
|
388
|
+
try {
|
|
389
|
+
config = readProtectionFile(protectionFile);
|
|
390
|
+
} catch (_) {
|
|
391
|
+
config = {
|
|
392
|
+
version: 1,
|
|
393
|
+
terms: [],
|
|
394
|
+
keys: [],
|
|
395
|
+
values: [],
|
|
396
|
+
patterns: []
|
|
397
|
+
};
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
console.log('\n Add protected terms separated by commas.');
|
|
401
|
+
console.log(' Example: BrandName, PRODUCT_CODE, API');
|
|
402
|
+
const terms = this.splitList(await ask(' Terms [Enter to skip]: '));
|
|
403
|
+
|
|
404
|
+
console.log('\n Add protected key paths separated by commas.');
|
|
405
|
+
console.log(' Exact keys and * wildcards are supported.');
|
|
406
|
+
console.log(' Example: app.brandName, legal.companyName, product.*.symbol');
|
|
407
|
+
const keys = this.splitList(await ask(' Keys [Enter to skip]: '));
|
|
408
|
+
|
|
409
|
+
console.log('\n Add exact values to copy unchanged separated by commas.');
|
|
410
|
+
console.log(' Example: BrandName Ltd, support@example.com');
|
|
411
|
+
const values = this.splitList(await ask(' Values [Enter to skip]: '));
|
|
412
|
+
|
|
413
|
+
console.log('\n Add optional JavaScript regex patterns separated by commas.');
|
|
414
|
+
console.log(' Example: [A-Z]{2,}-\\d+');
|
|
415
|
+
const patterns = this.splitList(await ask(' Patterns [Enter to skip]: '));
|
|
416
|
+
|
|
417
|
+
config.terms = this.mergeList(config.terms, terms);
|
|
418
|
+
config.keys = this.mergeList(config.keys, keys);
|
|
419
|
+
config.values = this.mergeList(config.values, values);
|
|
420
|
+
config.patterns = this.mergeList(config.patterns, patterns);
|
|
421
|
+
|
|
422
|
+
const savedPath = saveProtectionFile(protectionFile, config);
|
|
423
|
+
console.log(` Protection file saved: ${savedPath}`);
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
toInt(value, fallback, min, max) {
|
|
427
|
+
const parsed = parseInt(value, 10);
|
|
428
|
+
if (!Number.isInteger(parsed)) return fallback;
|
|
429
|
+
return Math.min(Math.max(parsed, min), max);
|
|
430
|
+
}
|
|
431
|
+
|
|
210
432
|
async runTranslate(sourceFiles, targetLang, opts = {}) {
|
|
211
|
-
const {
|
|
433
|
+
const { parseArgs, run } = require('../../i18ntk-translate');
|
|
434
|
+
const settings = this.autoTranslateSettings || this.getAutoTranslateSettings();
|
|
212
435
|
|
|
213
436
|
for (const src of sourceFiles) {
|
|
214
|
-
const args = [
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
437
|
+
const args = parseArgs(['node', 'i18ntk-translate', src, targetLang]);
|
|
438
|
+
args.noConfirm = true;
|
|
439
|
+
args.sourceLang = this.sourceLang || 'en';
|
|
440
|
+
args.dryRun = opts.dryRun === true;
|
|
441
|
+
args.reportStdout = settings.reportStdout;
|
|
442
|
+
args.bom = settings.bom;
|
|
443
|
+
args.concurrency = settings.concurrency;
|
|
444
|
+
args.batchSize = settings.batchSize;
|
|
445
|
+
args.progressInterval = settings.progressInterval;
|
|
446
|
+
args.retryCount = settings.retryCount;
|
|
447
|
+
args.retryDelay = settings.retryDelay;
|
|
448
|
+
args.timeout = settings.timeout;
|
|
449
|
+
args.protectionEnabled = settings.protectionEnabled;
|
|
450
|
+
args.protectionFile = settings.protectionFile;
|
|
451
|
+
args.preservePlaceholders = settings.placeholderMode === 'preserve';
|
|
452
|
+
args.skipPlaceholders = settings.placeholderMode === 'skip';
|
|
453
|
+
args.sendPlaceholders = settings.placeholderMode === 'send';
|
|
454
|
+
|
|
455
|
+
const result = await run(args);
|
|
456
|
+
if (!result || result.success !== true) {
|
|
457
|
+
throw new Error(result?.error || `Translation failed for ${src}`);
|
|
225
458
|
}
|
|
226
|
-
|
|
227
|
-
await new Promise((resolve, reject) => {
|
|
228
|
-
const proc = spawn('node', args, {
|
|
229
|
-
stdio: 'inherit',
|
|
230
|
-
cwd: process.cwd()
|
|
231
|
-
});
|
|
232
|
-
proc.on('close', (code) => {
|
|
233
|
-
if (code === 0) resolve();
|
|
234
|
-
else reject(new Error(`Exit code ${code}`));
|
|
235
|
-
});
|
|
236
|
-
proc.on('error', reject);
|
|
237
|
-
});
|
|
238
459
|
}
|
|
239
460
|
}
|
|
240
461
|
}
|
|
@@ -18,8 +18,9 @@ const watchLocales = require('../../../utils/watch-locales');
|
|
|
18
18
|
const { getGlobalReadline, closeGlobalReadline } = require('../../../utils/cli');
|
|
19
19
|
const { getUnifiedConfig, parseCommonArgs, displayHelp, validateSourceDir, displayPaths } = require('../../../utils/config-helper');
|
|
20
20
|
const I18nInitializer = require('../../i18ntk-init');
|
|
21
|
-
const JsonOutput = require('../../../utils/json-output');
|
|
22
|
-
const ExitCodes = require('../../../utils/exit-codes');
|
|
21
|
+
const JsonOutput = require('../../../utils/json-output');
|
|
22
|
+
const ExitCodes = require('../../../utils/exit-codes');
|
|
23
|
+
const { detectTranslationContentRisks } = require('../../../utils/validation-risk');
|
|
23
24
|
|
|
24
25
|
loadTranslations('en', path.resolve(__dirname, '../../../ui-locales'));
|
|
25
26
|
|
|
@@ -383,17 +384,28 @@ class ValidateCommand {
|
|
|
383
384
|
}
|
|
384
385
|
}
|
|
385
386
|
|
|
386
|
-
detectRiskyKeys(obj, language, fileName, prefix = '') {
|
|
387
|
-
for (const [key, value] of Object.entries(obj || {})) {
|
|
388
|
-
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
389
|
-
if (typeof value === 'string') {
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
387
|
+
detectRiskyKeys(obj, language, fileName, prefix = '') {
|
|
388
|
+
for (const [key, value] of Object.entries(obj || {})) {
|
|
389
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
390
|
+
if (typeof value === 'string') {
|
|
391
|
+
const issues = detectTranslationContentRisks(value, {
|
|
392
|
+
keyPath: fullKey,
|
|
393
|
+
sourceLanguage: this.config.sourceLanguage,
|
|
394
|
+
targetLanguage: language,
|
|
395
|
+
allowedEnglishTerms: this.config.allowedEnglishTerms,
|
|
396
|
+
englishThresholdPercent: this.config.englishContentThresholdPercent
|
|
397
|
+
});
|
|
398
|
+
|
|
399
|
+
issues.forEach(issue => {
|
|
400
|
+
const reporter = this.config.strictMode ? this.addError.bind(this) : this.addWarning.bind(this);
|
|
401
|
+
const message = issue.type === 'english_content'
|
|
402
|
+
? `Possible untranslated English content in ${language}/${fileName}`
|
|
403
|
+
: `Potential risky content in ${language}/${fileName}`;
|
|
404
|
+
reporter(message, { key: fullKey, value, ...issue });
|
|
405
|
+
});
|
|
406
|
+
} else if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
407
|
+
this.detectRiskyKeys(value, language, fileName, fullKey);
|
|
408
|
+
}
|
|
397
409
|
}
|
|
398
410
|
}
|
|
399
411
|
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "i18ntk",
|
|
3
|
-
"version": "3.
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"description": "Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, auto translation, fixing, reporting, and runtime translation loading.",
|
|
5
5
|
"keywords": [
|
|
6
6
|
"i18n",
|
|
@@ -123,6 +123,7 @@
|
|
|
123
123
|
"utils/translate/traverse.js",
|
|
124
124
|
"utils/translate/report.js",
|
|
125
125
|
"utils/translate/cli.js",
|
|
126
|
+
"utils/translate/protection.js",
|
|
126
127
|
"utils/framework-detector.js",
|
|
127
128
|
"utils/i18n-helper.js",
|
|
128
129
|
"utils/init-helper.js",
|
|
@@ -137,6 +138,7 @@
|
|
|
137
138
|
"utils/security.js",
|
|
138
139
|
"utils/setup-enforcer.js",
|
|
139
140
|
"utils/terminal-icons.js",
|
|
141
|
+
"utils/validation-risk.js",
|
|
140
142
|
"utils/version-utils.js",
|
|
141
143
|
"utils/watch-locales.js",
|
|
142
144
|
"LICENSE",
|