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 +19 -0
- package/README.md +51 -18
- package/main/i18ntk-translate.js +502 -0
- package/main/manage/commands/CommandRouter.js +7 -1
- package/main/manage/commands/TranslateCommand.js +242 -0
- package/main/manage/index.js +11 -5
- package/package.json +12 -3
- package/ui-locales/de.json +3 -0
- package/ui-locales/en.json +3 -0
- package/ui-locales/es.json +3 -0
- package/ui-locales/fr.json +3 -0
- package/ui-locales/ja.json +3 -0
- package/ui-locales/ru.json +3 -1
- package/ui-locales/zh.json +3 -0
- package/utils/translate/api.js +168 -0
- package/utils/translate/cli.js +91 -0
- package/utils/translate/placeholder.js +93 -0
- package/utils/translate/report.js +90 -0
- package/utils/translate/traverse.js +148 -0
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
|
|
1
|
+
# i18ntk v3.0.0
|
|
2
2
|
|
|
3
|
-
Zero-dependency internationalization toolkit for setup, scanning, analysis, validation, usage tracking, and translation
|
|
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
|

|
|
6
6
|
|
|
@@ -9,27 +9,25 @@ Zero-dependency internationalization toolkit for setup, scanning, analysis, vali
|
|
|
9
9
|
[](https://nodejs.org)
|
|
10
10
|
[](https://www.npmjs.com/package/i18ntk)
|
|
11
11
|
[](LICENSE)
|
|
12
|
-
[](https://socket.dev/npm/package/i18ntk/overview/3.0.0)
|
|
13
13
|
|
|
14
14
|
## Upgrade Notice
|
|
15
15
|
|
|
16
|
-
Versions earlier than `
|
|
17
|
-
They are considered unsupported for production use. Upgrade to `
|
|
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
|
-
##
|
|
19
|
+
## v3.0.0 - Auto Translate Release
|
|
20
20
|
|
|
21
|
-
|
|
21
|
+
v3.0.0 adds automatic JSON locale translation through the management menu and the standalone `i18ntk-translate` command. Highlights:
|
|
22
22
|
|
|
23
|
-
- **
|
|
24
|
-
- **
|
|
25
|
-
- **
|
|
26
|
-
- **
|
|
27
|
-
- **
|
|
28
|
-
- **
|
|
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-
|
|
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": "
|
|
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
|
+
});
|