@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipi18n/cli",
3
- "version": "1.0.1",
3
+ "version": "1.1.0",
4
4
  "description": "Command-line tool for translating locale files with Shipi18n",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -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
- spinner.text = `Translating to ${targetLanguages.length} language${targetLanguages.length > 1 ? 's' : ''}...`;
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
- spinner.succeed(chalk.green(`Translated to ${targetLanguages.length} language${targetLanguages.length > 1 ? 's' : ''}!`));
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(content, null, 2), 'utf8');
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