i18ntk 2.6.0 → 3.0.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 CHANGED
@@ -5,6 +5,25 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [3.0.0] - 2026-05-05
9
+
10
+ ### Added
11
+ - **`i18ntk-translate`**: Zero-dependency CLI tool that converts English source JSON locale files into any target language via Google's free Translate API.
12
+ - **Placeholder protection**: Intelligent detection, masking, and unmasking of dynamic placeholder tokens (`{name}`, `{{count}}`, `%d`, `%s`, `:param`, `{{variable}}`, `%{name}`, `${var}`, etc.) to prevent corruption during translation.
13
+ - **Custom regex support**: `--custom-regex` flag to define additional placeholder patterns for detection and protection.
14
+ - **Interactive control flow**: Two-level user controls — global choice (skip all / send all / ask per key) and per-key interactive mode where each affected key can be individually flagged.
15
+ - **Fully automated CLI mode**: `--no-confirm --skip-placeholders` or `--no-confirm --send-placeholders` flags for unattended CI/CD use.
16
+ - **Post-translation report**: Comprehensive report (stdout, file, or both) listing every skipped key with its original value and a reminder for manual translation.
17
+ - **Multi-file batch processing**: `--source-dir` and `--files` flags support translating all JSON files in a directory at once.
18
+ - **Dry-run mode**: `--dry-run` flag previews which keys would be skipped without making API calls.
19
+ - **UTF-8 BOM output**: `--bom` flag for output files with UTF-8 byte order mark.
20
+ - **Custom translation function**: `--translate-fn` flag to inject an alternative translation API while maintaining the placeholder safety workflow.
21
+ - **Rate-limit handling**: Exponential backoff/retry logic for Google Translate API rate limits and network errors.
22
+ - **Deep JSON traversal**: Full support for nested objects and arrays, preserving data types, null values, and non-string leaf values.
23
+
24
+ ### Changed
25
+ - Version bumped to 3.0.0 (major release with new translation tool feature).
26
+
8
27
  ## [2.6.0] - 2026-05-03
9
28
 
10
29
  ### Security
package/README.md CHANGED
@@ -1,6 +1,6 @@
1
- # i18ntk v2.6.0
1
+ # i18ntk v3.0.0
2
2
 
3
- Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, usage tracking, and translation completion.
3
+ Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, usage tracking, translation completion, automatic locale translation, and runtime translation loading.
4
4
 
5
5
  ![i18ntk Logo](https://raw.githubusercontent.com/vladnoskv/i18ntk/main/docs/screenshots/i18ntk-logo-public.PNG)
6
6
 
@@ -9,27 +9,25 @@ Zero-dependency internationalization toolkit for setup, scanning, analysis, vali
9
9
  [![node](https://img.shields.io/badge/node-%3E%3D16-339933)](https://nodejs.org)
10
10
  [![dependencies](https://img.shields.io/badge/dependencies-0-success)](https://www.npmjs.com/package/i18ntk)
11
11
  [![license](https://img.shields.io/badge/license-MIT-yellow.svg)](LICENSE)
12
- [![socket](https://socket.dev/api/badge/npm/package/i18ntk/2.6.0)](https://socket.dev/npm/package/i18ntk/overview/2.6.0)
12
+ [![socket](https://socket.dev/api/badge/npm/package/i18ntk/3.0.0)](https://socket.dev/npm/package/i18ntk/overview/3.0.0)
13
13
 
14
14
  ## Upgrade Notice
15
15
 
16
- Versions earlier than `2.6.0` may contain known stability and security issues.
17
- They are considered unsupported for production use. Upgrade to `2.6.0` or newer.
16
+ Versions earlier than `3.0.0` may contain known stability and security issues.
17
+ They are considered unsupported for production use. Upgrade to `3.0.0` or newer.
18
18
 
19
- ## v2.6.0 — Deep-Code Audit Release
19
+ ## v3.0.0 - Auto Translate Release
20
20
 
21
- v2.6.0 is a comprehensive hardening release from a two-pass code audit fixing 35+ bugs and security issues across 18 files. Highlights:
21
+ v3.0.0 adds automatic JSON locale translation through the management menu and the standalone `i18ntk-translate` command. Highlights:
22
22
 
23
- - **Critical**: Fixed silent-write failures where `safeWriteFileSync` was called incorrectly across 4 modules.
24
- - **Security**: Replaced all remaining raw `fs` calls with validated `SecurityUtils` wrappers.
25
- - **Security**: Fixed path traversal bypass in the fallback `SecurityUtils` implementation.
26
- - **Security**: Fixed Windows path traversal false negatives (fragile `path.sep` comparison).
27
- - **Security**: Added `safeUnlinkSync` and `safeRmdirSync` for validated file/directory deletion.
28
- - **Runtime**: Fixed process event handler leak, missing `setInterval.unref()`, and JSON parse error handling.
29
- - **TypeScript**: Fixed `BasicI18nRuntime.translate/t` return type from `Promise<string>` to `string`.
30
- - **Scripts**: Fixed `npm_execpath` fallback in build/release scripts.
23
+ - **Auto Translate (Beta)**: Translate one or more source JSON files into one or more target languages from menu option 14.
24
+ - **Standalone CLI**: Use `i18ntk-translate <source-file> <target-lang>` for direct automation and batch translation.
25
+ - **Dry-run preview**: Review translated/skipped counts before writing target files.
26
+ - **Placeholder protection**: Detect and preserve placeholders such as `{name}`, `{{count}}`, `%s`, `%d`, `:id`, `%{name}`, and `${value}`.
27
+ - **Post-translation report**: Print or write translated and skipped key counts.
28
+ - **Zero dependencies**: Translation support uses built-in Node.js modules and the free Google Translate endpoint.
31
29
 
32
- For the full detailed changelog, see [CHANGELOG.md](./CHANGELOG.md). For migration notes, see [docs/migration-guide-v2.6.0.md](./docs/migration-guide-v2.6.0.md).
30
+ For the full detailed changelog, see [CHANGELOG.md](./CHANGELOG.md). For migration notes, see [docs/migration-guide-v3.0.0.md](./docs/migration-guide-v3.0.0.md).
33
31
 
34
32
  ## What i18ntk Does
35
33
 
@@ -38,6 +36,7 @@ For the full detailed changelog, see [CHANGELOG.md](./CHANGELOG.md). For migrati
38
36
  - Translation completeness analysis and usage tracking
39
37
  - Validation, sizing, and summary reporting
40
38
  - Missing-key completion and fixer workflows
39
+ - Automatic translation of JSON locale files
41
40
  - Runtime translation helpers for application code
42
41
  - Support for JS/TS, React, Vue, Angular, and generic projects
43
42
 
@@ -48,6 +47,7 @@ For the full detailed changelog, see [CHANGELOG.md](./CHANGELOG.md). For migrati
48
47
  3. Confirm the source language and locale directories.
49
48
  4. Run `i18ntk --command=analyze` or `i18ntk --command=validate` to inspect translation coverage.
50
49
  5. Use `i18ntk --command=complete` to fill missing keys when needed.
50
+ 6. Use `i18ntk --command=translate` or menu option 14 to auto-translate source JSON files.
51
51
 
52
52
  The full onboarding flow is documented in [docs/getting-started.md](docs/getting-started.md).
53
53
 
@@ -100,6 +100,7 @@ i18ntk --command=usage
100
100
  i18ntk --command=scanner
101
101
  i18ntk --command=sizing
102
102
  i18ntk --command=complete
103
+ i18ntk --command=translate
103
104
  i18ntk --command=summary
104
105
  ```
105
106
 
@@ -117,6 +118,7 @@ i18ntk-summary
117
118
  i18ntk-doctor
118
119
  i18ntk-fixer
119
120
  i18ntk-backup
121
+ i18ntk-translate
120
122
  ```
121
123
 
122
124
  Note: `i18ntk --command=backup` in the manager flow is disabled in current builds.
@@ -133,12 +135,42 @@ Use the standalone `i18ntk-backup` executable when backup operations are require
133
135
  - `--dry-run`
134
136
  - `--help`
135
137
 
138
+ Auto Translate also supports:
139
+
140
+ - `--source-lang <code>`
141
+ - `--files <pattern>`
142
+ - `--skip-placeholders`
143
+ - `--send-placeholders`
144
+ - `--report-file <path>`
145
+ - `--report-stdout`
146
+
136
147
  Example:
137
148
 
138
149
  ```bash
139
150
  i18ntk --command=analyze --source-dir=./src --i18n-dir=./locales --output-dir=./i18ntk-reports
140
151
  ```
141
152
 
153
+ ## Auto Translate
154
+
155
+ Interactive menu flow:
156
+
157
+ ```bash
158
+ i18ntk
159
+ # choose "Auto Translate (Beta)"
160
+ ```
161
+
162
+ Direct CLI examples:
163
+
164
+ ```bash
165
+ i18ntk-translate locales/en/common.json de
166
+ i18ntk-translate locales/en/common.json fr --dry-run --report-stdout
167
+ i18ntk-translate locales/en es --files "*.json" --no-confirm --skip-placeholders
168
+ ```
169
+
170
+ The manager flow accepts comma- or space-separated target language codes, previews the first target language with a dry run, asks for confirmation, then writes translated files under matching target-language directories such as `locales/de/common.json`.
171
+
172
+ See [docs/auto-translate.md](docs/auto-translate.md) for full usage details.
173
+
142
174
  ## Runtime API
143
175
 
144
176
  Use `i18ntk/runtime` when your application needs to read locale JSON files at runtime.
@@ -169,7 +201,7 @@ Example `.i18ntk-config`:
169
201
 
170
202
  ```json
171
203
  {
172
- "version": "2.6.0",
204
+ "version": "3.0.0",
173
205
  "sourceDir": "./locales",
174
206
  "i18nDir": "./locales",
175
207
  "outputDir": "./i18ntk-reports",
@@ -190,11 +222,12 @@ See [docs/api/CONFIGURATION.md](docs/api/CONFIGURATION.md) for the full configur
190
222
  - [API Reference](https://github.com/vladnoskv/i18ntk/blob/main/docs/api/API_REFERENCE.md)
191
223
  - [Configuration Guide](https://github.com/vladnoskv/i18ntk/blob/main/docs/api/CONFIGURATION.md)
192
224
  - [Runtime API Guide](https://github.com/vladnoskv/i18ntk/blob/main/docs/runtime.md)
225
+ - [Auto Translate Guide](https://github.com/vladnoskv/i18ntk/blob/main/docs/auto-translate.md)
193
226
  - [Scanner Guide](https://github.com/vladnoskv/i18ntk/blob/main/docs/scanner-guide.md)
194
227
  - [Environment Variables](https://github.com/vladnoskv/i18ntk/blob/main/docs/environment-variables.md)
228
+ - [Migration Guide v3.0.0](https://github.com/vladnoskv/i18ntk/blob/main/docs/migration-guide-v3.0.0.md)
195
229
  - [Migration Guide v2.6.0](https://github.com/vladnoskv/i18ntk/blob/main/docs/migration-guide-v2.6.0.md)
196
230
  - [Migration Guide v2.5.1](https://github.com/vladnoskv/i18ntk/blob/main/docs/migration-guide-v2.5.1.md)
197
- - [Migration Guide v2.5.0](https://github.com/vladnoskv/i18ntk/blob/main/docs/migration-guide-v2.5.0.md)
198
231
 
199
232
  ## Community
200
233
 
@@ -0,0 +1,502 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * I18NTK TRANSLATION GENERATOR
5
+ *
6
+ * Zero-dependency translation utility that converts English source JSON
7
+ * language files into any target language via Google's free Translate API.
8
+ *
9
+ * Usage:
10
+ * i18ntk-translate <source-file> <target-lang> [options]
11
+ * i18ntk-translate locales/en/common.json de
12
+ * i18ntk-translate locales/en/common.json fr --no-confirm --skip-placeholders
13
+ * i18ntk-translate locales/en/common.json es --dry-run
14
+ *
15
+ * Options:
16
+ * --source-dir <dir> Source directory (default: ./locales/en)
17
+ * --output-dir <dir> Output directory (default: ./locales/<lang>)
18
+ * --custom-regex <regex> Additional placeholder regex pattern
19
+ * --no-confirm Skip all confirmation dialogs
20
+ * --skip-placeholders Skip all strings containing placeholders
21
+ * --send-placeholders Translate all strings including placeholders
22
+ * --concurrency <n> Max concurrent API requests (default: 3)
23
+ * --dry-run Preview mode without API calls
24
+ * --report-file <path> Write report to file
25
+ * --report-stdout Print report to stdout
26
+ * --bom Output UTF-8 with BOM
27
+ * --translate-fn <module> Path to custom translation function module
28
+ * --retry-count <n> Max retries per request (default: 3)
29
+ * --retry-delay <ms> Base delay for retry backoff (default: 1000)
30
+ * --timeout <ms> HTTP request timeout (default: 15000)
31
+ * --source-lang <code> Source language code (default: en)
32
+ * --files <pattern> Glob pattern for multiple files (e.g. *.json)
33
+ * -h, --help Show help
34
+ */
35
+
36
+ const fs = require('fs');
37
+ const path = require('path');
38
+ const packageJson = require('../package.json');
39
+ const ExitCodes = require('../utils/exit-codes');
40
+ const { isInteractive } = require('../utils/prompt-helper');
41
+ const { detectPlaceholders, maskPlaceholders, unmaskPlaceholders } = require('../utils/translate/placeholder');
42
+ const { translateBatch } = require('../utils/translate/api');
43
+ const { collectLeaves, setLeaf, deepClone } = require('../utils/translate/traverse');
44
+ const { generateReport, writeReport, formatSummaryLine } = require('../utils/translate/report');
45
+ const {
46
+ confirmGlobalChoice,
47
+ confirmPerKey,
48
+ previewSkipped,
49
+ } = require('../utils/translate/cli');
50
+
51
+ const BOM = '\uFEFF';
52
+
53
+ function printHelp() {
54
+ console.log([
55
+ '',
56
+ `I18NTK Translation Generator - v${packageJson.version}`,
57
+ '',
58
+ 'Usage:',
59
+ ' i18ntk-translate <source-file> <target-lang> [options]',
60
+ ' i18ntk-translate <source-file> <target-lang> --source-dir <dir> [options]',
61
+ '',
62
+ 'Examples:',
63
+ ' i18ntk-translate locales/en/common.json de',
64
+ ' i18ntk-translate locales/en/common.json fr --dry-run',
65
+ ' i18ntk-translate locales/en/ es --files "*.json"',
66
+ ' i18ntk-translate locales/en/common.json ja --no-confirm --skip-placeholders',
67
+ ' i18ntk-translate locales/en/common.json ko --report-file report.txt',
68
+ '',
69
+ 'Options:',
70
+ ' --source-dir <dir> Source directory containing locale files',
71
+ ' --output-dir <dir> Output directory for translated files',
72
+ ' --source-lang <code> Source language code (default: en)',
73
+ ' --custom-regex <regex> Additional placeholder regex pattern',
74
+ ' --no-confirm Automate: skip confirmation dialogs',
75
+ ' --skip-placeholders Skip all strings with placeholder tokens',
76
+ ' --send-placeholders Translate all strings including placeholders',
77
+ ' --concurrency <n> Max concurrent API requests (default: 3)',
78
+ ' --dry-run Preview: show what would be skipped',
79
+ ' --report-file <path> Write post-translation report to file',
80
+ ' --report-stdout Print post-translation report to stdout',
81
+ ' --bom Write output files with UTF-8 BOM',
82
+ ' --translate-fn <module> Path to custom translation function module',
83
+ ' --retry-count <n> Max retries per failed request (default: 3)',
84
+ ' --retry-delay <ms> Base backoff delay in ms (default: 1000)',
85
+ ' --timeout <ms> HTTP request timeout in ms (default: 15000)',
86
+ ' -h, --help Show this help',
87
+ ].join('\n'));
88
+ }
89
+
90
+ function parseArgs(argv) {
91
+ const args = {
92
+ sourceFile: null,
93
+ targetLang: null,
94
+ sourceDir: null,
95
+ outputDir: null,
96
+ sourceLang: 'en',
97
+ customRegex: [],
98
+ noConfirm: false,
99
+ skipPlaceholders: false,
100
+ sendPlaceholders: false,
101
+ concurrency: 3,
102
+ dryRun: false,
103
+ reportFile: null,
104
+ reportStdout: false,
105
+ bom: false,
106
+ translateFnPath: null,
107
+ retryCount: 3,
108
+ retryDelay: 1000,
109
+ timeout: 15000,
110
+ filesPattern: null,
111
+ help: false,
112
+ unknown: [],
113
+ };
114
+
115
+ const positional = [];
116
+ for (let i = 2; i < argv.length; i++) {
117
+ const arg = argv[i];
118
+ if (arg === '-h' || arg === '--help') { args.help = true; }
119
+ else if (arg === '--no-confirm') { args.noConfirm = true; }
120
+ else if (arg === '--skip-placeholders') { args.skipPlaceholders = true; }
121
+ else if (arg === '--send-placeholders') { args.sendPlaceholders = true; }
122
+ else if (arg === '--dry-run') { args.dryRun = true; }
123
+ else if (arg === '--report-stdout') { args.reportStdout = true; }
124
+ else if (arg === '--bom') { args.bom = true; }
125
+ else if (arg === '--source-dir' && i + 1 < argv.length) { args.sourceDir = argv[++i]; }
126
+ else if (arg === '--output-dir' && i + 1 < argv.length) { args.outputDir = argv[++i]; }
127
+ else if (arg === '--source-lang' && i + 1 < argv.length) { args.sourceLang = argv[++i]; }
128
+ else if (arg === '--custom-regex' && i + 1 < argv.length) { args.customRegex.push(argv[++i]); }
129
+ else if (arg === '--concurrency' && i + 1 < argv.length) { args.concurrency = parseInt(argv[++i], 10) || 3; }
130
+ else if (arg === '--report-file' && i + 1 < argv.length) { args.reportFile = argv[++i]; }
131
+ else if (arg === '--translate-fn' && i + 1 < argv.length) { args.translateFnPath = argv[++i]; }
132
+ else if (arg === '--retry-count' && i + 1 < argv.length) { args.retryCount = parseInt(argv[++i], 10) || 3; }
133
+ else if (arg === '--retry-delay' && i + 1 < argv.length) { args.retryDelay = parseInt(argv[++i], 10) || 1000; }
134
+ else if (arg === '--timeout' && i + 1 < argv.length) { args.timeout = parseInt(argv[++i], 10) || 15000; }
135
+ else if (arg === '--files' && i + 1 < argv.length) { args.filesPattern = argv[++i]; }
136
+ else if (arg.startsWith('-')) { args.unknown.push(arg); }
137
+ else { positional.push(arg); }
138
+ }
139
+
140
+ if (positional.length >= 1) args.sourceFile = positional[0];
141
+ if (positional.length >= 2) args.targetLang = positional[1];
142
+
143
+ if (args.sendPlaceholders && args.skipPlaceholders) {
144
+ console.error('Error: --skip-placeholders and --send-placeholders are mutually exclusive.');
145
+ process.exit(1);
146
+ }
147
+
148
+ return args;
149
+ }
150
+
151
+ function loadCustomTranslateFn(modulePath) {
152
+ if (!modulePath) return null;
153
+ try {
154
+ const resolved = path.isAbsolute(modulePath) ? modulePath : path.resolve(process.cwd(), modulePath);
155
+ const mod = require(resolved);
156
+ if (typeof mod === 'function') return mod;
157
+ if (mod && typeof mod.translate === 'function') return mod.translate;
158
+ if (mod && typeof mod.default === 'function') return mod.default;
159
+ console.error(`Warning: Custom translate module "${modulePath}" does not export a function.`);
160
+ return null;
161
+ } catch (e) {
162
+ console.error(`Error: Failed to load translate function module "${modulePath}": ${e.message}`);
163
+ process.exit(1);
164
+ }
165
+ }
166
+
167
+ function resolveSourceFiles(sourceFile, sourceDir, filesPattern) {
168
+ if (sourceDir) {
169
+ const resolvedDir = path.resolve(process.cwd(), sourceDir);
170
+ if (!fs.existsSync(resolvedDir)) {
171
+ console.error(`Error: Source directory "${resolvedDir}" does not exist.`);
172
+ process.exit(1);
173
+ }
174
+ const entries = fs.readdirSync(resolvedDir);
175
+ const pattern = filesPattern || '*.json';
176
+ const regex = new RegExp('^' + pattern.replace(/\*/g, '.*').replace(/\?/g, '.') + '$');
177
+ const files = entries.filter((f) => regex.test(f) && f.endsWith('.json')).sort();
178
+ if (files.length === 0) {
179
+ console.error(`Error: No JSON files matching "${pattern}" found in "${resolvedDir}".`);
180
+ process.exit(1);
181
+ }
182
+ return files.map((f) => path.join(resolvedDir, f));
183
+ }
184
+
185
+ if (sourceFile) {
186
+ const resolved = path.resolve(process.cwd(), sourceFile);
187
+ if (!fs.existsSync(resolved)) {
188
+ console.error(`Error: Source file "${resolved}" does not exist.`);
189
+ process.exit(1);
190
+ }
191
+ return [resolved];
192
+ }
193
+
194
+ console.error('Error: No source file specified. Use --source-dir or provide a source file.');
195
+ process.exit(1);
196
+ }
197
+
198
+ function classifyLeaves(leaves, customRegex) {
199
+ const withPlaceholders = [];
200
+ const withoutPlaceholders = [];
201
+
202
+ for (const leaf of leaves) {
203
+ const placeholders = detectPlaceholders(leaf.value, customRegex);
204
+ if (placeholders.length > 0) {
205
+ withPlaceholders.push({ ...leaf, placeholders });
206
+ } else {
207
+ withoutPlaceholders.push(leaf);
208
+ }
209
+ }
210
+
211
+ return { withPlaceholders, withoutPlaceholders };
212
+ }
213
+
214
+ async function resolvePlaceholderStrategy(args) {
215
+ const interactive = isInteractive({ noPrompt: args.noConfirm });
216
+
217
+ if (args.sendPlaceholders) {
218
+ return { strategy: 'send', interactiveMode: false };
219
+ }
220
+ if (args.skipPlaceholders) {
221
+ return { strategy: 'skip', interactiveMode: false };
222
+ }
223
+ if (args.noConfirm) {
224
+ return { strategy: 'skip', interactiveMode: false };
225
+ }
226
+ if (!interactive) {
227
+ return { strategy: 'skip', interactiveMode: false };
228
+ }
229
+
230
+ const choice = await confirmGlobalChoice();
231
+ return { strategy: choice.strategy, interactiveMode: choice.interactive };
232
+ }
233
+
234
+ async function resolvePerKeyDecisions(withPlaceholders, interactive) {
235
+ const decisions = {};
236
+
237
+ if (!interactive) {
238
+ for (const leaf of withPlaceholders) {
239
+ decisions[leaf.keyPath] = 'skip';
240
+ }
241
+ return decisions;
242
+ }
243
+
244
+ let bulkDecision = null;
245
+ for (const leaf of withPlaceholders) {
246
+ if (bulkDecision) {
247
+ decisions[leaf.keyPath] = bulkDecision;
248
+ continue;
249
+ }
250
+ const choice = await confirmPerKey(leaf.keyPath, leaf.value, leaf.placeholders);
251
+ if (choice === 'skip-all') {
252
+ bulkDecision = 'skip';
253
+ decisions[leaf.keyPath] = 'skip';
254
+ } else if (choice === 'send-all') {
255
+ bulkDecision = 'send';
256
+ decisions[leaf.keyPath] = 'send';
257
+ } else {
258
+ decisions[leaf.keyPath] = choice;
259
+ }
260
+ }
261
+
262
+ return decisions;
263
+ }
264
+
265
+ function buildTranslateList(withPlaceholders, withoutPlaceholders, strategy, decisions) {
266
+ const toTranslate = [];
267
+ const toSkip = [];
268
+
269
+ for (const leaf of withoutPlaceholders) {
270
+ toTranslate.push(leaf);
271
+ }
272
+
273
+ if (strategy === 'send') {
274
+ for (const leaf of withPlaceholders) {
275
+ toTranslate.push(leaf);
276
+ }
277
+ } else {
278
+ for (const leaf of withPlaceholders) {
279
+ const decision = decisions[leaf.keyPath] || 'skip';
280
+ if (decision === 'send') {
281
+ toTranslate.push(leaf);
282
+ } else {
283
+ toSkip.push(leaf);
284
+ }
285
+ }
286
+ }
287
+
288
+ return { toTranslate, toSkip };
289
+ }
290
+
291
+ function maskAllForTranslation(toTranslate, customRegex) {
292
+ return toTranslate.map((leaf) => {
293
+ const { masked, map } = maskPlaceholders(leaf.value, customRegex);
294
+ return { ...leaf, masked, placeholderMap: map, needsUnmask: map.size > 0 };
295
+ });
296
+ }
297
+
298
+ async function runTranslation(maskedBatch, targetLang, options) {
299
+ const batchItems = maskedBatch.map((item) => ({ value: item.masked, keyPath: item.keyPath }));
300
+ const results = await translateBatch(batchItems, targetLang, options);
301
+ return results;
302
+ }
303
+
304
+ function applyResults(sourceData, translatedResults, maskedBatch, toSkip, bom) {
305
+ const output = deepClone(sourceData);
306
+
307
+ for (let i = 0; i < maskedBatch.length; i++) {
308
+ const item = maskedBatch[i];
309
+ const translated = translatedResults[i];
310
+ let finalValue;
311
+ if (item.needsUnmask) {
312
+ finalValue = unmaskPlaceholders(translated, item.placeholderMap);
313
+ } else {
314
+ finalValue = translated;
315
+ }
316
+ setLeaf(output, item.keyPath, finalValue);
317
+ }
318
+
319
+ for (const leaf of toSkip) {
320
+ setLeaf(output, leaf.keyPath, leaf.value);
321
+ }
322
+
323
+ return output;
324
+ }
325
+
326
+ function writeOutput(outputData, outputPath, bom) {
327
+ const dir = path.dirname(outputPath);
328
+ if (!fs.existsSync(dir)) {
329
+ fs.mkdirSync(dir, { recursive: true });
330
+ }
331
+ let content = JSON.stringify(outputData, null, 2) + '\n';
332
+ if (bom) {
333
+ content = BOM + content;
334
+ }
335
+ fs.writeFileSync(outputPath, content, 'utf-8');
336
+ }
337
+
338
+ async function processFile(sourcePath, targetLang, args) {
339
+ const fileName = path.basename(sourcePath);
340
+ const targetDir = args.outputDir || path.join(path.dirname(path.dirname(sourcePath)), targetLang);
341
+ const targetPath = path.join(targetDir, fileName);
342
+
343
+ let sourceData;
344
+ try {
345
+ const raw = fs.readFileSync(sourcePath, 'utf-8');
346
+ sourceData = JSON.parse(raw);
347
+ } catch (e) {
348
+ console.error(`Error reading "${sourcePath}": ${e.message}`);
349
+ return null;
350
+ }
351
+
352
+ const leaves = collectLeaves(sourceData);
353
+ if (leaves.length === 0) {
354
+ console.log(`[${fileName}] No translatable strings found.`);
355
+ writeOutput(sourceData, targetPath, args.bom);
356
+ return { total: 0, translated: 0, skipped: 0, skippedKeys: [] };
357
+ }
358
+
359
+ const { withPlaceholders, withoutPlaceholders } = classifyLeaves(leaves, args.customRegex);
360
+
361
+ if (args.dryRun && withPlaceholders.length > 0) {
362
+ await previewSkipped(withPlaceholders);
363
+ return {
364
+ total: leaves.length,
365
+ translated: withoutPlaceholders.length,
366
+ skipped: withPlaceholders.length,
367
+ skippedKeys: withPlaceholders,
368
+ dryRun: true,
369
+ };
370
+ }
371
+
372
+ if (args.dryRun) {
373
+ console.log(`[${fileName}] Dry-run: ${leaves.length} strings, all would be translated.`);
374
+ return {
375
+ total: leaves.length,
376
+ translated: leaves.length,
377
+ skipped: 0,
378
+ skippedKeys: [],
379
+ dryRun: true,
380
+ };
381
+ }
382
+
383
+ const { strategy, interactiveMode } = await resolvePlaceholderStrategy(args);
384
+ const decisions = await resolvePerKeyDecisions(withPlaceholders, interactiveMode);
385
+ const { toTranslate, toSkip } = buildTranslateList(withPlaceholders, withoutPlaceholders, strategy, decisions);
386
+
387
+ if (toSkip.length > 0) {
388
+ console.log(`[${fileName}] Skipping ${toSkip.length} keys with placeholders.`);
389
+ }
390
+
391
+ const maskedBatch = maskAllForTranslation(toTranslate, args.customRegex);
392
+
393
+ const translateOptions = {
394
+ sourceLang: args.sourceLang,
395
+ concurrency: args.concurrency,
396
+ retryCount: args.retryCount,
397
+ retryDelay: args.retryDelay,
398
+ timeout: args.timeout,
399
+ customFn: args.translateFn,
400
+ onProgress: (info) => {
401
+ if (info.completed % 10 === 0 || info.completed === info.total) {
402
+ process.stdout.write(`\r[${fileName}] Translating... ${info.completed}/${info.total}`);
403
+ }
404
+ },
405
+ onError: (err) => {
406
+ console.error(`\n[${fileName}] Warning: Failed to translate key "${err.item.keyPath}": ${err.message}`);
407
+ },
408
+ };
409
+
410
+ let translatedResults;
411
+ if (maskedBatch.length > 0) {
412
+ translatedResults = await runTranslation(maskedBatch, targetLang, translateOptions);
413
+ process.stdout.write('\n');
414
+ } else {
415
+ translatedResults = [];
416
+ }
417
+
418
+ const output = applyResults(sourceData, translatedResults, maskedBatch, toSkip, args.bom);
419
+ writeOutput(output, targetPath, args.bom);
420
+
421
+ console.log(`[${fileName}] Written: ${targetPath}`);
422
+
423
+ return {
424
+ total: leaves.length,
425
+ translated: translatedResults.length,
426
+ skipped: toSkip.length,
427
+ skippedKeys: toSkip,
428
+ };
429
+ }
430
+
431
+ async function main() {
432
+ const args = parseArgs(process.argv);
433
+
434
+ if (args.help) {
435
+ printHelp();
436
+ process.exit(ExitCodes.SUCCESS);
437
+ }
438
+
439
+ if (args.unknown.length > 0) {
440
+ console.error(`Unknown options: ${args.unknown.join(', ')}`);
441
+ console.error('Use --help for usage information.');
442
+ process.exit(1);
443
+ }
444
+
445
+ if (!args.targetLang) {
446
+ console.error('Error: Target language code is required.');
447
+ console.error('Usage: i18ntk-translate <source-file> <target-lang> [options]');
448
+ process.exit(1);
449
+ }
450
+
451
+ if (args.translateFnPath) {
452
+ args.translateFn = loadCustomTranslateFn(args.translateFnPath);
453
+ }
454
+
455
+ const sourceFiles = resolveSourceFiles(args.sourceFile, args.sourceDir, args.filesPattern);
456
+
457
+ const allSkippedKeys = [];
458
+ let grandTotal = 0;
459
+ let grandTranslated = 0;
460
+ let grandSkipped = 0;
461
+
462
+ for (const srcPath of sourceFiles) {
463
+ const result = await processFile(srcPath, args.targetLang, args);
464
+ if (result) {
465
+ grandTotal += result.total;
466
+ grandTranslated += result.translated;
467
+ grandSkipped += result.skipped;
468
+ if (result.skippedKeys && result.skippedKeys.length > 0) {
469
+ allSkippedKeys.push(...result.skippedKeys);
470
+ }
471
+ }
472
+ }
473
+
474
+ console.log('');
475
+ console.log(formatSummaryLine(grandSkipped, grandTranslated, grandTotal));
476
+
477
+ if (allSkippedKeys.length > 0 || args.reportFile || args.reportStdout) {
478
+ const report = generateReport(allSkippedKeys, grandTranslated, grandTotal, {
479
+ sourceFile: sourceFiles.length === 1 ? sourceFiles[0] : `${sourceFiles.length} files`,
480
+ targetLang: args.targetLang,
481
+ dryRun: args.dryRun,
482
+ });
483
+
484
+ if (args.reportStdout || (!args.reportFile && allSkippedKeys.length > 0)) {
485
+ console.log('');
486
+ console.log(report);
487
+ }
488
+
489
+ if (args.reportFile) {
490
+ const reportPath = path.resolve(process.cwd(), args.reportFile);
491
+ writeReport(report, reportPath);
492
+ console.log(`Report written: ${reportPath}`);
493
+ }
494
+ }
495
+
496
+ process.exit(ExitCodes.SUCCESS);
497
+ }
498
+
499
+ main().catch((err) => {
500
+ console.error('Fatal error:', err.message);
501
+ process.exit(1);
502
+ });