@shipi18n/cli 1.0.1 → 1.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/package.json +1 -1
- package/src/commands/translate.js +146 -6
package/package.json
CHANGED
|
@@ -5,6 +5,73 @@ import { Shipi18nAPI } from '../lib/api.js';
|
|
|
5
5
|
import { getConfig } from '../lib/config.js';
|
|
6
6
|
import { logger, formatError } from '../utils/logger.js';
|
|
7
7
|
|
|
8
|
+
/**
|
|
9
|
+
* Flatten a nested object into dot-notation keys
|
|
10
|
+
*/
|
|
11
|
+
function flattenObject(obj, prefix = '') {
|
|
12
|
+
const result = {};
|
|
13
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
14
|
+
const newKey = prefix ? `${prefix}.${key}` : key;
|
|
15
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
16
|
+
Object.assign(result, flattenObject(value, newKey));
|
|
17
|
+
} else {
|
|
18
|
+
result[newKey] = value;
|
|
19
|
+
}
|
|
20
|
+
}
|
|
21
|
+
return result;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Unflatten dot-notation keys back into nested object
|
|
26
|
+
*/
|
|
27
|
+
function unflattenObject(obj) {
|
|
28
|
+
const result = {};
|
|
29
|
+
for (const [key, value] of Object.entries(obj)) {
|
|
30
|
+
const keys = key.split('.');
|
|
31
|
+
let current = result;
|
|
32
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
33
|
+
if (!current[keys[i]]) {
|
|
34
|
+
current[keys[i]] = {};
|
|
35
|
+
}
|
|
36
|
+
current = current[keys[i]];
|
|
37
|
+
}
|
|
38
|
+
current[keys[keys.length - 1]] = value;
|
|
39
|
+
}
|
|
40
|
+
return result;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* Deep merge two objects
|
|
45
|
+
*/
|
|
46
|
+
function deepMerge(target, source) {
|
|
47
|
+
const result = { ...target };
|
|
48
|
+
for (const [key, value] of Object.entries(source)) {
|
|
49
|
+
if (value && typeof value === 'object' && !Array.isArray(value)) {
|
|
50
|
+
result[key] = deepMerge(result[key] || {}, value);
|
|
51
|
+
} else {
|
|
52
|
+
result[key] = value;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
return result;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
/**
|
|
59
|
+
* Find keys that exist in source but not in target
|
|
60
|
+
*/
|
|
61
|
+
function findMissingKeys(sourceJson, targetJson) {
|
|
62
|
+
const sourceFlat = flattenObject(sourceJson);
|
|
63
|
+
const targetFlat = flattenObject(targetJson);
|
|
64
|
+
|
|
65
|
+
const missingKeys = {};
|
|
66
|
+
for (const [key, value] of Object.entries(sourceFlat)) {
|
|
67
|
+
if (!(key in targetFlat)) {
|
|
68
|
+
missingKeys[key] = value;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
return unflattenObject(missingKeys);
|
|
73
|
+
}
|
|
74
|
+
|
|
8
75
|
export function translateCommand(program) {
|
|
9
76
|
program
|
|
10
77
|
.command('translate <input>')
|
|
@@ -16,6 +83,7 @@ export function translateCommand(program) {
|
|
|
16
83
|
.option('--preserve-placeholders', 'Preserve placeholders like {name}, {{value}}, etc.', true)
|
|
17
84
|
.option('--no-fallback', 'Disable fallback to source language for missing translations')
|
|
18
85
|
.option('--no-regional-fallback', 'Disable regional fallback (e.g., pt-BR -> pt)')
|
|
86
|
+
.option('-i, --incremental', 'Only translate new/missing keys (skip existing translations)')
|
|
19
87
|
.action(async (input, options) => {
|
|
20
88
|
const spinner = logger.spinner('Translating...');
|
|
21
89
|
|
|
@@ -53,13 +121,79 @@ export function translateCommand(program) {
|
|
|
53
121
|
// Parse target languages
|
|
54
122
|
const targetLanguages = options.target.split(',').map(lang => lang.trim());
|
|
55
123
|
const sourceLanguage = options.source;
|
|
124
|
+
const outputDir = options.output;
|
|
125
|
+
const inputFileName = parse(input).name;
|
|
126
|
+
|
|
127
|
+
// Incremental mode: load existing translations and find missing keys
|
|
128
|
+
let jsonToTranslate = json;
|
|
129
|
+
const existingTranslations = {};
|
|
130
|
+
let incrementalStats = { total: 0, existing: 0, toTranslate: 0 };
|
|
131
|
+
|
|
132
|
+
if (options.incremental) {
|
|
133
|
+
spinner.text = 'Checking existing translations...';
|
|
56
134
|
|
|
57
|
-
|
|
135
|
+
const sourceKeyCount = Object.keys(flattenObject(json)).length;
|
|
136
|
+
incrementalStats.total = sourceKeyCount;
|
|
137
|
+
|
|
138
|
+
// Load existing translations for each target language
|
|
139
|
+
for (const lang of targetLanguages) {
|
|
140
|
+
const targetFile = join(outputDir, lang, `${inputFileName}.json`);
|
|
141
|
+
const altTargetFile = join(outputDir, `${lang}.json`);
|
|
142
|
+
|
|
143
|
+
let existingFile = null;
|
|
144
|
+
if (existsSync(targetFile)) {
|
|
145
|
+
existingFile = targetFile;
|
|
146
|
+
} else if (existsSync(altTargetFile)) {
|
|
147
|
+
existingFile = altTargetFile;
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
if (existingFile) {
|
|
151
|
+
try {
|
|
152
|
+
const existingContent = readFileSync(existingFile, 'utf8');
|
|
153
|
+
existingTranslations[lang] = JSON.parse(existingContent);
|
|
154
|
+
} catch (e) {
|
|
155
|
+
logger.warn(`Could not parse ${existingFile}, will re-translate`);
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
// Find keys that need translation (missing from ANY target language)
|
|
161
|
+
const allMissingKeys = {};
|
|
162
|
+
for (const lang of targetLanguages) {
|
|
163
|
+
const existing = existingTranslations[lang] || {};
|
|
164
|
+
const missing = findMissingKeys(json, existing);
|
|
165
|
+
const missingFlat = flattenObject(missing);
|
|
166
|
+
|
|
167
|
+
for (const [key, value] of Object.entries(missingFlat)) {
|
|
168
|
+
if (!(key in allMissingKeys)) {
|
|
169
|
+
allMissingKeys[key] = value;
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
const missingKeyCount = Object.keys(allMissingKeys).length;
|
|
175
|
+
incrementalStats.existing = sourceKeyCount - missingKeyCount;
|
|
176
|
+
incrementalStats.toTranslate = missingKeyCount;
|
|
177
|
+
|
|
178
|
+
if (missingKeyCount === 0) {
|
|
179
|
+
spinner.succeed(chalk.green('All translations up to date!'));
|
|
180
|
+
logger.log('');
|
|
181
|
+
logger.log(chalk.gray(` ${sourceKeyCount} key${sourceKeyCount !== 1 ? 's' : ''} already translated`));
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
jsonToTranslate = unflattenObject(allMissingKeys);
|
|
186
|
+
spinner.text = `Translating ${missingKeyCount} new key${missingKeyCount !== 1 ? 's' : ''} to ${targetLanguages.length} language${targetLanguages.length > 1 ? 's' : ''}...`;
|
|
187
|
+
logger.log('');
|
|
188
|
+
logger.info(`Incremental mode: ${chalk.cyan(missingKeyCount)} new key${missingKeyCount !== 1 ? 's' : ''} to translate (${incrementalStats.existing} already exist)`);
|
|
189
|
+
} else {
|
|
190
|
+
spinner.text = `Translating to ${targetLanguages.length} language${targetLanguages.length > 1 ? 's' : ''}...`;
|
|
191
|
+
}
|
|
58
192
|
|
|
59
193
|
// Translate with fallback support
|
|
60
194
|
const api = new Shipi18nAPI(apiKey);
|
|
61
195
|
const translations = await api.translateJSON({
|
|
62
|
-
json,
|
|
196
|
+
json: jsonToTranslate,
|
|
63
197
|
sourceLanguage,
|
|
64
198
|
targetLanguages,
|
|
65
199
|
preservePlaceholders: options.preservePlaceholders,
|
|
@@ -69,10 +203,10 @@ export function translateCommand(program) {
|
|
|
69
203
|
},
|
|
70
204
|
});
|
|
71
205
|
|
|
72
|
-
|
|
206
|
+
const keyCount = Object.keys(flattenObject(jsonToTranslate)).length;
|
|
207
|
+
spinner.succeed(chalk.green(`Translated ${keyCount} key${keyCount !== 1 ? 's' : ''} to ${targetLanguages.length} language${targetLanguages.length > 1 ? 's' : ''}!`));
|
|
73
208
|
|
|
74
209
|
// Save translated files
|
|
75
|
-
const outputDir = options.output;
|
|
76
210
|
if (!existsSync(outputDir)) {
|
|
77
211
|
mkdirSync(outputDir, { recursive: true });
|
|
78
212
|
}
|
|
@@ -81,9 +215,15 @@ export function translateCommand(program) {
|
|
|
81
215
|
for (const [langCode, content] of Object.entries(translations)) {
|
|
82
216
|
if (langCode === 'warnings' || langCode === 'fallbackInfo' || langCode === 'namespaceInfo') continue;
|
|
83
217
|
|
|
218
|
+
// In incremental mode, merge with existing translations
|
|
219
|
+
let finalContent = content;
|
|
220
|
+
if (options.incremental && existingTranslations[langCode]) {
|
|
221
|
+
finalContent = deepMerge(existingTranslations[langCode], content);
|
|
222
|
+
}
|
|
223
|
+
|
|
84
224
|
const outputFile = join(outputDir, `${langCode}.json`);
|
|
85
|
-
writeFileSync(outputFile, JSON.stringify(
|
|
86
|
-
logger.success(`Saved: ${chalk.cyan(outputFile)}`);
|
|
225
|
+
writeFileSync(outputFile, JSON.stringify(finalContent, null, 2), 'utf8');
|
|
226
|
+
logger.success(`Saved: ${chalk.cyan(outputFile)}${options.incremental ? chalk.gray(' (merged)') : ''}`);
|
|
87
227
|
savedCount++;
|
|
88
228
|
}
|
|
89
229
|
|