@shipi18n/cli 1.0.1 → 1.1.1

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.1",
4
4
  "description": "Command-line tool for translating locale files with Shipi18n",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -4,6 +4,7 @@ import chalk from 'chalk';
4
4
  import { Shipi18nAPI } from '../lib/api.js';
5
5
  import { getConfig } from '../lib/config.js';
6
6
  import { logger, formatError } from '../utils/logger.js';
7
+ import { flattenObject, unflattenObject, deepMerge, findMissingKeys } from '../utils/incremental.js';
7
8
 
8
9
  export function translateCommand(program) {
9
10
  program
@@ -16,6 +17,7 @@ export function translateCommand(program) {
16
17
  .option('--preserve-placeholders', 'Preserve placeholders like {name}, {{value}}, etc.', true)
17
18
  .option('--no-fallback', 'Disable fallback to source language for missing translations')
18
19
  .option('--no-regional-fallback', 'Disable regional fallback (e.g., pt-BR -> pt)')
20
+ .option('-i, --incremental', 'Only translate new/missing keys (skip existing translations)')
19
21
  .action(async (input, options) => {
20
22
  const spinner = logger.spinner('Translating...');
21
23
 
@@ -53,13 +55,79 @@ export function translateCommand(program) {
53
55
  // Parse target languages
54
56
  const targetLanguages = options.target.split(',').map(lang => lang.trim());
55
57
  const sourceLanguage = options.source;
58
+ const outputDir = options.output;
59
+ const inputFileName = parse(input).name;
60
+
61
+ // Incremental mode: load existing translations and find missing keys
62
+ let jsonToTranslate = json;
63
+ const existingTranslations = {};
64
+ let incrementalStats = { total: 0, existing: 0, toTranslate: 0 };
65
+
66
+ if (options.incremental) {
67
+ spinner.text = 'Checking existing translations...';
68
+
69
+ const sourceKeyCount = Object.keys(flattenObject(json)).length;
70
+ incrementalStats.total = sourceKeyCount;
71
+
72
+ // Load existing translations for each target language
73
+ for (const lang of targetLanguages) {
74
+ const targetFile = join(outputDir, lang, `${inputFileName}.json`);
75
+ const altTargetFile = join(outputDir, `${lang}.json`);
76
+
77
+ let existingFile = null;
78
+ if (existsSync(targetFile)) {
79
+ existingFile = targetFile;
80
+ } else if (existsSync(altTargetFile)) {
81
+ existingFile = altTargetFile;
82
+ }
83
+
84
+ if (existingFile) {
85
+ try {
86
+ const existingContent = readFileSync(existingFile, 'utf8');
87
+ existingTranslations[lang] = JSON.parse(existingContent);
88
+ } catch (e) {
89
+ logger.warn(`Could not parse ${existingFile}, will re-translate`);
90
+ }
91
+ }
92
+ }
93
+
94
+ // Find keys that need translation (missing from ANY target language)
95
+ const allMissingKeys = {};
96
+ for (const lang of targetLanguages) {
97
+ const existing = existingTranslations[lang] || {};
98
+ const missing = findMissingKeys(json, existing);
99
+ const missingFlat = flattenObject(missing);
56
100
 
57
- spinner.text = `Translating to ${targetLanguages.length} language${targetLanguages.length > 1 ? 's' : ''}...`;
101
+ for (const [key, value] of Object.entries(missingFlat)) {
102
+ if (!(key in allMissingKeys)) {
103
+ allMissingKeys[key] = value;
104
+ }
105
+ }
106
+ }
107
+
108
+ const missingKeyCount = Object.keys(allMissingKeys).length;
109
+ incrementalStats.existing = sourceKeyCount - missingKeyCount;
110
+ incrementalStats.toTranslate = missingKeyCount;
111
+
112
+ if (missingKeyCount === 0) {
113
+ spinner.succeed(chalk.green('All translations up to date!'));
114
+ logger.log('');
115
+ logger.log(chalk.gray(` ${sourceKeyCount} key${sourceKeyCount !== 1 ? 's' : ''} already translated`));
116
+ return;
117
+ }
118
+
119
+ jsonToTranslate = unflattenObject(allMissingKeys);
120
+ spinner.text = `Translating ${missingKeyCount} new key${missingKeyCount !== 1 ? 's' : ''} to ${targetLanguages.length} language${targetLanguages.length > 1 ? 's' : ''}...`;
121
+ logger.log('');
122
+ logger.info(`Incremental mode: ${chalk.cyan(missingKeyCount)} new key${missingKeyCount !== 1 ? 's' : ''} to translate (${incrementalStats.existing} already exist)`);
123
+ } else {
124
+ spinner.text = `Translating to ${targetLanguages.length} language${targetLanguages.length > 1 ? 's' : ''}...`;
125
+ }
58
126
 
59
127
  // Translate with fallback support
60
128
  const api = new Shipi18nAPI(apiKey);
61
129
  const translations = await api.translateJSON({
62
- json,
130
+ json: jsonToTranslate,
63
131
  sourceLanguage,
64
132
  targetLanguages,
65
133
  preservePlaceholders: options.preservePlaceholders,
@@ -69,10 +137,10 @@ export function translateCommand(program) {
69
137
  },
70
138
  });
71
139
 
72
- spinner.succeed(chalk.green(`Translated to ${targetLanguages.length} language${targetLanguages.length > 1 ? 's' : ''}!`));
140
+ const keyCount = Object.keys(flattenObject(jsonToTranslate)).length;
141
+ spinner.succeed(chalk.green(`Translated ${keyCount} key${keyCount !== 1 ? 's' : ''} to ${targetLanguages.length} language${targetLanguages.length > 1 ? 's' : ''}!`));
73
142
 
74
143
  // Save translated files
75
- const outputDir = options.output;
76
144
  if (!existsSync(outputDir)) {
77
145
  mkdirSync(outputDir, { recursive: true });
78
146
  }
@@ -81,9 +149,15 @@ export function translateCommand(program) {
81
149
  for (const [langCode, content] of Object.entries(translations)) {
82
150
  if (langCode === 'warnings' || langCode === 'fallbackInfo' || langCode === 'namespaceInfo') continue;
83
151
 
152
+ // In incremental mode, merge with existing translations
153
+ let finalContent = content;
154
+ if (options.incremental && existingTranslations[langCode]) {
155
+ finalContent = deepMerge(existingTranslations[langCode], content);
156
+ }
157
+
84
158
  const outputFile = join(outputDir, `${langCode}.json`);
85
- writeFileSync(outputFile, JSON.stringify(content, null, 2), 'utf8');
86
- logger.success(`Saved: ${chalk.cyan(outputFile)}`);
159
+ writeFileSync(outputFile, JSON.stringify(finalContent, null, 2), 'utf8');
160
+ logger.success(`Saved: ${chalk.cyan(outputFile)}${options.incremental ? chalk.gray(' (merged)') : ''}`);
87
161
  savedCount++;
88
162
  }
89
163
 
@@ -0,0 +1,90 @@
1
+ /**
2
+ * Utilities for incremental translation
3
+ */
4
+
5
+ /**
6
+ * Flatten a nested object into dot-notation keys
7
+ * @param {Object} obj - Nested object
8
+ * @param {string} prefix - Key prefix (used for recursion)
9
+ * @returns {Object} Flattened object with dot-notation keys
10
+ */
11
+ export 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
+ * @param {Object} obj - Flattened object with dot-notation keys
27
+ * @returns {Object} Nested object
28
+ */
29
+ export function unflattenObject(obj) {
30
+ const result = {};
31
+ for (const [key, value] of Object.entries(obj)) {
32
+ const keys = key.split('.');
33
+ let current = result;
34
+ for (let i = 0; i < keys.length - 1; i++) {
35
+ if (!current[keys[i]]) {
36
+ current[keys[i]] = {};
37
+ }
38
+ current = current[keys[i]];
39
+ }
40
+ current[keys[keys.length - 1]] = value;
41
+ }
42
+ return result;
43
+ }
44
+
45
+ /**
46
+ * Deep merge two objects
47
+ * @param {Object} target - Target object
48
+ * @param {Object} source - Source object to merge in
49
+ * @returns {Object} Merged object
50
+ */
51
+ export function deepMerge(target, source) {
52
+ const result = { ...target };
53
+ for (const [key, value] of Object.entries(source)) {
54
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
55
+ result[key] = deepMerge(result[key] || {}, value);
56
+ } else {
57
+ result[key] = value;
58
+ }
59
+ }
60
+ return result;
61
+ }
62
+
63
+ /**
64
+ * Find keys that exist in source but not in target
65
+ * @param {Object} sourceJson - Source JSON object
66
+ * @param {Object} targetJson - Target JSON object
67
+ * @returns {Object} Object containing only missing keys
68
+ */
69
+ export function findMissingKeys(sourceJson, targetJson) {
70
+ const sourceFlat = flattenObject(sourceJson);
71
+ const targetFlat = flattenObject(targetJson);
72
+
73
+ const missingKeys = {};
74
+ for (const [key, value] of Object.entries(sourceFlat)) {
75
+ if (!(key in targetFlat)) {
76
+ missingKeys[key] = value;
77
+ }
78
+ }
79
+
80
+ return unflattenObject(missingKeys);
81
+ }
82
+
83
+ /**
84
+ * Count the number of leaf keys in a nested object
85
+ * @param {Object} obj - Object to count keys in
86
+ * @returns {number} Number of leaf keys
87
+ */
88
+ export function countKeys(obj) {
89
+ return Object.keys(flattenObject(obj)).length;
90
+ }