@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 CHANGED
@@ -1,9 +1,24 @@
1
1
  # @shipi18n/cli
2
2
 
3
+ [![npm version](https://img.shields.io/npm/v/@shipi18n/cli)](https://www.npmjs.com/package/@shipi18n/cli)
4
+ [![npm downloads](https://img.shields.io/npm/dw/@shipi18n/cli)](https://www.npmjs.com/package/@shipi18n/cli)
5
+ [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
6
+ [![GitHub last commit](https://img.shields.io/github/last-commit/Shipi18n/shipi18n-cli)](https://github.com/Shipi18n/shipi18n-cli)
7
+ [![CI](https://github.com/Shipi18n/shipi18n-cli/actions/workflows/ci.yml/badge.svg)](https://github.com/Shipi18n/shipi18n-cli/actions)
8
+ [![codecov](https://codecov.io/gh/Shipi18n/shipi18n-cli/branch/main/graph/badge.svg)](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
- ## License
430
+ ## Documentation & Resources
372
431
 
373
- MIT
432
+ ๐Ÿ“š **Full Documentation:** [shipi18n.com/integrations/cli](https://shipi18n.com/integrations/cli)
374
433
 
375
- ## Links
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
- - [Shipi18n Website](https://shipi18n.com)
378
- - [Documentation](https://shipi18n.com/docs)
379
- - [API Reference](https://shipi18n.com/docs/api)
380
- - [GitHub](https://github.com/Shipi18n/shipi18n-cli)
381
- - [Support](https://github.com/Shipi18n/shipi18n-cli/issues)
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
- Built with โค๏ธ by [Shipi18n](https://shipi18n.com) - Smart translation API for developers
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.0.0",
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
- "internationalization",
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
- spinner.text = `Translating to ${targetLanguages.length} language${targetLanguages.length > 1 ? 's' : ''}...`;
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
- // Translate
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
- 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' : ''}!`));
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(content, null, 2), 'utf8');
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({ json, sourceLanguage = 'en', targetLanguages, preservePlaceholders = true }) {
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(targetLanguages),
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.warnings = jsonStr;
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
- return parsed;
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
  /**