@shipi18n/cli 1.0.0 โ 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/README.md +90 -9
- package/package.json +14 -4
- package/src/commands/translate.js +188 -8
- package/src/lib/api.js +200 -5
package/README.md
CHANGED
|
@@ -1,9 +1,24 @@
|
|
|
1
1
|
# @shipi18n/cli
|
|
2
2
|
|
|
3
|
+
[](https://www.npmjs.com/package/@shipi18n/cli)
|
|
4
|
+
[](https://www.npmjs.com/package/@shipi18n/cli)
|
|
5
|
+
[](https://opensource.org/licenses/MIT)
|
|
6
|
+
[](https://github.com/Shipi18n/shipi18n-cli)
|
|
7
|
+
[](https://github.com/Shipi18n/shipi18n-cli/actions)
|
|
8
|
+
[](https://codecov.io/gh/Shipi18n/shipi18n-cli)
|
|
9
|
+
|
|
3
10
|
Command-line tool for translating locale files with [Shipi18n](https://shipi18n.com).
|
|
4
11
|
|
|
5
12
|
> **๐ Translate JSON files in seconds** - One command, multiple languages!
|
|
6
13
|
|
|
14
|
+
## Why Shipi18n CLI?
|
|
15
|
+
|
|
16
|
+
- **Stop copy-pasting into Google Translate** - One command translates to 100+ languages
|
|
17
|
+
- **Placeholders stay intact** - `{name}`, `{{count}}`, `%s` are preserved automatically
|
|
18
|
+
- **Works with your existing setup** - Drop into any React, Vue, Next.js, i18next project
|
|
19
|
+
- **90-day Translation Memory** - Same content? Cached. No extra cost.
|
|
20
|
+
- **Free tier included** - 100 keys, 3 languages, no credit card required
|
|
21
|
+
|
|
7
22
|
## Features
|
|
8
23
|
|
|
9
24
|
- โ
**Translate JSON files** to 100+ languages with one command
|
|
@@ -82,6 +97,8 @@ shipi18n translate <input> [options]
|
|
|
82
97
|
- `-o, --output <dir>` - Output directory (default: `./locales`)
|
|
83
98
|
- `--api-key <key>` - API key (overrides config)
|
|
84
99
|
- `--preserve-placeholders` - Preserve placeholders (default: `true`)
|
|
100
|
+
- `--no-fallback` - Disable fallback to source for missing translations
|
|
101
|
+
- `--no-regional-fallback` - Disable regional fallback (e.g., pt-BR โ pt)
|
|
85
102
|
|
|
86
103
|
**Examples:**
|
|
87
104
|
|
|
@@ -97,6 +114,48 @@ shipi18n translate ja.json --source ja --target en,es
|
|
|
97
114
|
|
|
98
115
|
# Use inline API key
|
|
99
116
|
shipi18n translate en.json --target es --api-key sk_live_...
|
|
117
|
+
|
|
118
|
+
# Translate with regional variants (pt-BR will fallback to pt if needed)
|
|
119
|
+
shipi18n translate en.json --target es,pt-BR,zh-TW
|
|
120
|
+
|
|
121
|
+
# Disable fallback (strict mode - fail if translation missing)
|
|
122
|
+
shipi18n translate en.json --target es --no-fallback
|
|
123
|
+
```
|
|
124
|
+
|
|
125
|
+
### Fallback Behavior
|
|
126
|
+
|
|
127
|
+
By default, the CLI handles missing translations gracefully:
|
|
128
|
+
|
|
129
|
+
| Scenario | Default Behavior |
|
|
130
|
+
|----------|-----------------|
|
|
131
|
+
| Missing translation for a language | Uses source content (English) |
|
|
132
|
+
| Missing regional variant (pt-BR) | Falls back to base language (pt), then source |
|
|
133
|
+
| Missing translation for a key | Fills from source content |
|
|
134
|
+
|
|
135
|
+
**Fallback output example:**
|
|
136
|
+
```
|
|
137
|
+
โ Translated to 3 languages!
|
|
138
|
+
โ Saved: ./locales/es.json
|
|
139
|
+
โ Saved: ./locales/pt-BR.json
|
|
140
|
+
โ Saved: ./locales/zh-TW.json
|
|
141
|
+
|
|
142
|
+
Fallback information:
|
|
143
|
+
โข pt-BR โ pt (regional fallback)
|
|
144
|
+
โข zh-TW โ en (source fallback)
|
|
145
|
+
โข es: 2 keys used fallback
|
|
146
|
+
- checkout.terms
|
|
147
|
+
- checkout.privacy
|
|
148
|
+
|
|
149
|
+
โจ Successfully translated 3 files!
|
|
150
|
+
```
|
|
151
|
+
|
|
152
|
+
**Disable fallback:**
|
|
153
|
+
```bash
|
|
154
|
+
# Strict mode - no fallback to source
|
|
155
|
+
shipi18n translate en.json --target es --no-fallback
|
|
156
|
+
|
|
157
|
+
# Disable regional fallback only (pt-BR won't fall back to pt)
|
|
158
|
+
shipi18n translate en.json --target pt-BR --no-regional-fallback
|
|
100
159
|
```
|
|
101
160
|
|
|
102
161
|
### Keys Management
|
|
@@ -368,18 +427,40 @@ npm link
|
|
|
368
427
|
shipi18n --help
|
|
369
428
|
```
|
|
370
429
|
|
|
371
|
-
##
|
|
430
|
+
## Documentation & Resources
|
|
372
431
|
|
|
373
|
-
|
|
432
|
+
๐ **Full Documentation:** [shipi18n.com/integrations/cli](https://shipi18n.com/integrations/cli)
|
|
374
433
|
|
|
375
|
-
|
|
434
|
+
| Resource | Link |
|
|
435
|
+
|----------|------|
|
|
436
|
+
| **Getting Started** | [shipi18n.com](https://shipi18n.com) |
|
|
437
|
+
| **API Reference** | [shipi18n.com/api](https://shipi18n.com/api) |
|
|
438
|
+
| **i18next Best Practices** | [shipi18n.com/integrations/react](https://shipi18n.com/integrations/react) |
|
|
439
|
+
| **Blog & Tutorials** | [shipi18n.com/blog](https://shipi18n.com/blog) |
|
|
376
440
|
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
441
|
+
## Related Packages
|
|
442
|
+
|
|
443
|
+
| Package | Description |
|
|
444
|
+
|---------|-------------|
|
|
445
|
+
| [@shipi18n/api](https://www.npmjs.com/package/@shipi18n/api) | Node.js SDK for programmatic use |
|
|
446
|
+
| [vite-plugin-shipi18n](https://www.npmjs.com/package/vite-plugin-shipi18n) | Vite plugin for build-time translation |
|
|
447
|
+
| [i18next-shipi18n-backend](https://www.npmjs.com/package/i18next-shipi18n-backend) | i18next backend for dynamic loading |
|
|
448
|
+
| [shipi18n-github-action](https://github.com/marketplace/actions/shipi18n-auto-translate) | GitHub Action for CI/CD |
|
|
449
|
+
|
|
450
|
+
## Examples
|
|
451
|
+
|
|
452
|
+
- [Node.js Example](https://github.com/Shipi18n/shipi18n-nodejs-example) - Basic usage examples
|
|
453
|
+
- [Vue Example](https://github.com/Shipi18n/shipi18n-vue-example) - Vue 3 + vue-i18n integration
|
|
454
|
+
|
|
455
|
+
## License
|
|
456
|
+
|
|
457
|
+
MIT
|
|
382
458
|
|
|
383
459
|
---
|
|
384
460
|
|
|
385
|
-
|
|
461
|
+
<p align="center">
|
|
462
|
+
<a href="https://shipi18n.com">shipi18n.com</a> ยท
|
|
463
|
+
<a href="https://github.com/Shipi18n">GitHub</a> ยท
|
|
464
|
+
<a href="https://shipi18n.com/pricing">Pricing</a>
|
|
465
|
+
</p>
|
|
466
|
+
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@shipi18n/cli",
|
|
3
|
-
"version": "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",
|
|
@@ -19,14 +19,24 @@
|
|
|
19
19
|
"dev": "node bin/shipi18n.js",
|
|
20
20
|
"build": "echo 'No build step needed for now'",
|
|
21
21
|
"test": "NODE_OPTIONS='--experimental-vm-modules' jest",
|
|
22
|
-
"test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --watch"
|
|
22
|
+
"test:watch": "NODE_OPTIONS='--experimental-vm-modules' jest --watch",
|
|
23
|
+
"test:coverage": "NODE_OPTIONS='--experimental-vm-modules' jest --coverage"
|
|
23
24
|
},
|
|
24
25
|
"keywords": [
|
|
25
|
-
"translation",
|
|
26
26
|
"i18n",
|
|
27
|
-
"
|
|
27
|
+
"translation",
|
|
28
28
|
"localization",
|
|
29
|
+
"internationalization",
|
|
30
|
+
"locale",
|
|
31
|
+
"translate",
|
|
29
32
|
"cli",
|
|
33
|
+
"i18next",
|
|
34
|
+
"react-intl",
|
|
35
|
+
"json-translation",
|
|
36
|
+
"locale-files",
|
|
37
|
+
"multilingual",
|
|
38
|
+
"l10n",
|
|
39
|
+
"ai-translation",
|
|
30
40
|
"shipi18n"
|
|
31
41
|
],
|
|
32
42
|
"author": "Shipi18n",
|
|
@@ -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>')
|
|
@@ -14,6 +81,9 @@ export function translateCommand(program) {
|
|
|
14
81
|
.option('-o, --output <dir>', 'Output directory', './locales')
|
|
15
82
|
.option('--api-key <key>', 'API key (overrides config)')
|
|
16
83
|
.option('--preserve-placeholders', 'Preserve placeholders like {name}, {{value}}, etc.', true)
|
|
84
|
+
.option('--no-fallback', 'Disable fallback to source language for missing translations')
|
|
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)')
|
|
17
87
|
.action(async (input, options) => {
|
|
18
88
|
const spinner = logger.spinner('Translating...');
|
|
19
89
|
|
|
@@ -51,38 +121,148 @@ export function translateCommand(program) {
|
|
|
51
121
|
// Parse target languages
|
|
52
122
|
const targetLanguages = options.target.split(',').map(lang => lang.trim());
|
|
53
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...';
|
|
134
|
+
|
|
135
|
+
const sourceKeyCount = Object.keys(flattenObject(json)).length;
|
|
136
|
+
incrementalStats.total = sourceKeyCount;
|
|
54
137
|
|
|
55
|
-
|
|
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`);
|
|
56
142
|
|
|
57
|
-
|
|
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
|
+
}
|
|
192
|
+
|
|
193
|
+
// Translate with fallback support
|
|
58
194
|
const api = new Shipi18nAPI(apiKey);
|
|
59
195
|
const translations = await api.translateJSON({
|
|
60
|
-
json,
|
|
196
|
+
json: jsonToTranslate,
|
|
61
197
|
sourceLanguage,
|
|
62
198
|
targetLanguages,
|
|
63
199
|
preservePlaceholders: options.preservePlaceholders,
|
|
200
|
+
fallback: {
|
|
201
|
+
fallbackToSource: options.fallback !== false,
|
|
202
|
+
regionalFallback: options.regionalFallback !== false,
|
|
203
|
+
},
|
|
64
204
|
});
|
|
65
205
|
|
|
66
|
-
|
|
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' : ''}!`));
|
|
67
208
|
|
|
68
209
|
// Save translated files
|
|
69
|
-
const outputDir = options.output;
|
|
70
210
|
if (!existsSync(outputDir)) {
|
|
71
211
|
mkdirSync(outputDir, { recursive: true });
|
|
72
212
|
}
|
|
73
213
|
|
|
74
214
|
let savedCount = 0;
|
|
75
215
|
for (const [langCode, content] of Object.entries(translations)) {
|
|
76
|
-
if (langCode === 'warnings') continue;
|
|
216
|
+
if (langCode === 'warnings' || langCode === 'fallbackInfo' || langCode === 'namespaceInfo') continue;
|
|
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
|
+
}
|
|
77
223
|
|
|
78
224
|
const outputFile = join(outputDir, `${langCode}.json`);
|
|
79
|
-
writeFileSync(outputFile, JSON.stringify(
|
|
80
|
-
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)') : ''}`);
|
|
81
227
|
savedCount++;
|
|
82
228
|
}
|
|
83
229
|
|
|
230
|
+
// Show fallback info if any fallbacks were used
|
|
231
|
+
if (translations.fallbackInfo && translations.fallbackInfo.used) {
|
|
232
|
+
const fallbackInfo = translations.fallbackInfo;
|
|
233
|
+
logger.log('');
|
|
234
|
+
logger.info('Fallback information:');
|
|
235
|
+
|
|
236
|
+
// Regional fallbacks
|
|
237
|
+
if (Object.keys(fallbackInfo.regionalFallbacks).length > 0) {
|
|
238
|
+
for (const [lang, baseLang] of Object.entries(fallbackInfo.regionalFallbacks)) {
|
|
239
|
+
logger.log(` ${chalk.blue('โข')} ${lang} โ ${baseLang} ${chalk.gray('(regional fallback)')}`);
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
// Languages that fell back to source
|
|
244
|
+
if (fallbackInfo.languagesFallbackToSource.length > 0) {
|
|
245
|
+
for (const lang of fallbackInfo.languagesFallbackToSource) {
|
|
246
|
+
logger.log(` ${chalk.yellow('โข')} ${lang} โ ${sourceLanguage} ${chalk.gray('(source fallback)')}`);
|
|
247
|
+
}
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// Keys that used fallback
|
|
251
|
+
if (Object.keys(fallbackInfo.keysFallback).length > 0) {
|
|
252
|
+
for (const [lang, keys] of Object.entries(fallbackInfo.keysFallback)) {
|
|
253
|
+
logger.log(` ${chalk.yellow('โข')} ${lang}: ${keys.length} key${keys.length > 1 ? 's' : ''} used fallback`);
|
|
254
|
+
if (keys.length <= 5) {
|
|
255
|
+
keys.forEach(key => {
|
|
256
|
+
logger.log(` ${chalk.gray('- ' + key)}`);
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
|
|
84
263
|
// Show warnings if any
|
|
85
264
|
if (translations.warnings && translations.warnings.length > 0) {
|
|
265
|
+
logger.log('');
|
|
86
266
|
logger.warn('Warnings:');
|
|
87
267
|
translations.warnings.forEach(warning => {
|
|
88
268
|
logger.log(` ${chalk.yellow('โข')} ${warning.message}`);
|
package/src/lib/api.js
CHANGED
|
@@ -14,14 +14,39 @@ export class Shipi18nAPI {
|
|
|
14
14
|
|
|
15
15
|
/**
|
|
16
16
|
* Translate JSON file
|
|
17
|
+
* @param {Object} options
|
|
18
|
+
* @param {Object|string} options.json - JSON content to translate
|
|
19
|
+
* @param {string} options.sourceLanguage - Source language code
|
|
20
|
+
* @param {string[]} options.targetLanguages - Target language codes
|
|
21
|
+
* @param {boolean} options.preservePlaceholders - Preserve placeholders
|
|
22
|
+
* @param {Object} options.fallback - Fallback options
|
|
23
|
+
* @param {boolean} options.fallback.fallbackToSource - Use source content when translation missing (default: true)
|
|
24
|
+
* @param {boolean} options.fallback.regionalFallback - Enable pt-BR -> pt fallback (default: true)
|
|
25
|
+
* @param {string} options.fallback.fallbackLanguage - Custom fallback language
|
|
17
26
|
*/
|
|
18
|
-
async translateJSON({
|
|
27
|
+
async translateJSON({
|
|
28
|
+
json,
|
|
29
|
+
sourceLanguage = 'en',
|
|
30
|
+
targetLanguages,
|
|
31
|
+
preservePlaceholders = true,
|
|
32
|
+
fallback = {}
|
|
33
|
+
}) {
|
|
19
34
|
if (!this.apiKey) {
|
|
20
35
|
throw new Error('API key is required. Set SHIPI18N_API_KEY or run: shipi18n config set apiKey YOUR_KEY');
|
|
21
36
|
}
|
|
22
37
|
|
|
38
|
+
const {
|
|
39
|
+
fallbackToSource = true,
|
|
40
|
+
regionalFallback = true,
|
|
41
|
+
fallbackLanguage,
|
|
42
|
+
} = fallback;
|
|
43
|
+
|
|
44
|
+
const sourceContent = typeof json === 'string' ? JSON.parse(json) : json;
|
|
23
45
|
const jsonString = typeof json === 'string' ? json : JSON.stringify(json);
|
|
24
46
|
|
|
47
|
+
// Process regional languages - add base languages for fallback
|
|
48
|
+
const { processedTargets, regionalMap } = this.processRegionalLanguages(targetLanguages, regionalFallback);
|
|
49
|
+
|
|
25
50
|
const response = await fetch(`${this.baseUrl}/api/translate`, {
|
|
26
51
|
method: 'POST',
|
|
27
52
|
headers: {
|
|
@@ -32,7 +57,7 @@ export class Shipi18nAPI {
|
|
|
32
57
|
inputMethod: 'text',
|
|
33
58
|
text: jsonString,
|
|
34
59
|
sourceLanguage,
|
|
35
|
-
targetLanguages: JSON.stringify(
|
|
60
|
+
targetLanguages: JSON.stringify(processedTargets),
|
|
36
61
|
preservePlaceholders: String(preservePlaceholders),
|
|
37
62
|
}),
|
|
38
63
|
});
|
|
@@ -50,8 +75,8 @@ export class Shipi18nAPI {
|
|
|
50
75
|
// Parse JSON strings back to objects
|
|
51
76
|
const parsed = {};
|
|
52
77
|
for (const [lang, jsonStr] of Object.entries(result)) {
|
|
53
|
-
if (lang === 'warnings') {
|
|
54
|
-
parsed
|
|
78
|
+
if (lang === 'warnings' || lang === 'namespaceInfo') {
|
|
79
|
+
parsed[lang] = jsonStr;
|
|
55
80
|
continue;
|
|
56
81
|
}
|
|
57
82
|
try {
|
|
@@ -61,7 +86,177 @@ export class Shipi18nAPI {
|
|
|
61
86
|
}
|
|
62
87
|
}
|
|
63
88
|
|
|
64
|
-
|
|
89
|
+
// Apply fallback logic
|
|
90
|
+
return this.applyFallbacks(
|
|
91
|
+
parsed,
|
|
92
|
+
sourceContent,
|
|
93
|
+
targetLanguages,
|
|
94
|
+
sourceLanguage,
|
|
95
|
+
fallbackToSource,
|
|
96
|
+
regionalFallback,
|
|
97
|
+
fallbackLanguage,
|
|
98
|
+
regionalMap
|
|
99
|
+
);
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Process regional language codes for fallback support
|
|
104
|
+
*/
|
|
105
|
+
processRegionalLanguages(targetLanguages, regionalFallback) {
|
|
106
|
+
const regionalMap = {};
|
|
107
|
+
const processedTargets = [];
|
|
108
|
+
const baseLanguagesAdded = new Set();
|
|
109
|
+
|
|
110
|
+
for (const lang of targetLanguages) {
|
|
111
|
+
if (lang.includes('-') && regionalFallback) {
|
|
112
|
+
const baseLang = lang.split('-')[0];
|
|
113
|
+
regionalMap[lang] = baseLang;
|
|
114
|
+
|
|
115
|
+
if (!baseLanguagesAdded.has(baseLang) && !targetLanguages.includes(baseLang)) {
|
|
116
|
+
processedTargets.push(baseLang);
|
|
117
|
+
baseLanguagesAdded.add(baseLang);
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (!processedTargets.includes(lang)) {
|
|
122
|
+
processedTargets.push(lang);
|
|
123
|
+
}
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
return { processedTargets, regionalMap };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Apply fallback logic to translation results
|
|
131
|
+
*/
|
|
132
|
+
applyFallbacks(result, sourceContent, targetLanguages, sourceLanguage, fallbackToSource, regionalFallback, fallbackLanguage, regionalMap) {
|
|
133
|
+
const fallbackInfo = {
|
|
134
|
+
used: false,
|
|
135
|
+
languagesFallbackToSource: [],
|
|
136
|
+
regionalFallbacks: {},
|
|
137
|
+
keysFallback: {},
|
|
138
|
+
};
|
|
139
|
+
|
|
140
|
+
for (const lang of targetLanguages) {
|
|
141
|
+
const translation = result[lang];
|
|
142
|
+
|
|
143
|
+
// Case 1: Entire language missing
|
|
144
|
+
if (!translation || Object.keys(translation).length === 0) {
|
|
145
|
+
// Try regional fallback first
|
|
146
|
+
if (regionalFallback && regionalMap[lang]) {
|
|
147
|
+
const baseLang = regionalMap[lang];
|
|
148
|
+
const baseTranslation = result[baseLang];
|
|
149
|
+
|
|
150
|
+
if (baseTranslation && Object.keys(baseTranslation).length > 0) {
|
|
151
|
+
result[lang] = { ...baseTranslation };
|
|
152
|
+
fallbackInfo.used = true;
|
|
153
|
+
fallbackInfo.regionalFallbacks[lang] = baseLang;
|
|
154
|
+
continue;
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Fall back to source
|
|
159
|
+
if (fallbackToSource) {
|
|
160
|
+
result[lang] = { ...sourceContent };
|
|
161
|
+
fallbackInfo.used = true;
|
|
162
|
+
fallbackInfo.languagesFallbackToSource.push(lang);
|
|
163
|
+
}
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
// Case 2: Check for missing keys
|
|
168
|
+
if (fallbackToSource && typeof translation === 'object') {
|
|
169
|
+
const missingKeys = this.findMissingKeys(sourceContent, translation);
|
|
170
|
+
|
|
171
|
+
if (missingKeys.length > 0) {
|
|
172
|
+
fallbackInfo.used = true;
|
|
173
|
+
fallbackInfo.keysFallback[lang] = missingKeys;
|
|
174
|
+
|
|
175
|
+
for (const key of missingKeys) {
|
|
176
|
+
const fallbackValue = this.getNestedValue(sourceContent, key);
|
|
177
|
+
|
|
178
|
+
// Try regional fallback first
|
|
179
|
+
if (regionalFallback && regionalMap[lang]) {
|
|
180
|
+
const baseLang = regionalMap[lang];
|
|
181
|
+
const baseTranslation = result[baseLang];
|
|
182
|
+
const baseValue = baseTranslation ? this.getNestedValue(baseTranslation, key) : undefined;
|
|
183
|
+
|
|
184
|
+
if (baseValue !== undefined) {
|
|
185
|
+
this.setNestedValue(translation, key, baseValue);
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
if (fallbackValue !== undefined) {
|
|
191
|
+
this.setNestedValue(translation, key, fallbackValue);
|
|
192
|
+
}
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
if (fallbackInfo.used) {
|
|
199
|
+
result.fallbackInfo = fallbackInfo;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
return result;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Find missing keys in translation
|
|
207
|
+
*/
|
|
208
|
+
findMissingKeys(source, translation, prefix = '') {
|
|
209
|
+
const missing = [];
|
|
210
|
+
|
|
211
|
+
for (const key of Object.keys(source)) {
|
|
212
|
+
const fullKey = prefix ? `${prefix}.${key}` : key;
|
|
213
|
+
const sourceValue = source[key];
|
|
214
|
+
const translationValue = translation[key];
|
|
215
|
+
|
|
216
|
+
if (translationValue === undefined || translationValue === null || translationValue === '') {
|
|
217
|
+
missing.push(fullKey);
|
|
218
|
+
} else if (
|
|
219
|
+
typeof sourceValue === 'object' &&
|
|
220
|
+
sourceValue !== null &&
|
|
221
|
+
!Array.isArray(sourceValue) &&
|
|
222
|
+
typeof translationValue === 'object' &&
|
|
223
|
+
translationValue !== null
|
|
224
|
+
) {
|
|
225
|
+
missing.push(...this.findMissingKeys(sourceValue, translationValue, fullKey));
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
return missing;
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
/**
|
|
233
|
+
* Get nested value from object using dot notation
|
|
234
|
+
*/
|
|
235
|
+
getNestedValue(obj, path) {
|
|
236
|
+
return path.split('.').reduce((current, key) => {
|
|
237
|
+
if (current && typeof current === 'object' && key in current) {
|
|
238
|
+
return current[key];
|
|
239
|
+
}
|
|
240
|
+
return undefined;
|
|
241
|
+
}, obj);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* Set nested value in object using dot notation
|
|
246
|
+
*/
|
|
247
|
+
setNestedValue(obj, path, value) {
|
|
248
|
+
const keys = path.split('.');
|
|
249
|
+
let current = obj;
|
|
250
|
+
|
|
251
|
+
for (let i = 0; i < keys.length - 1; i++) {
|
|
252
|
+
const key = keys[i];
|
|
253
|
+
if (!(key in current) || typeof current[key] !== 'object') {
|
|
254
|
+
current[key] = {};
|
|
255
|
+
}
|
|
256
|
+
current = current[key];
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
current[keys[keys.length - 1]] = value;
|
|
65
260
|
}
|
|
66
261
|
|
|
67
262
|
/**
|