@shipi18n/cli 1.1.0 → 1.1.2

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/bin/shipi18n.js CHANGED
@@ -5,6 +5,7 @@ import chalk from 'chalk';
5
5
  import { translateCommand } from '../src/commands/translate.js';
6
6
  import { keysCommand } from '../src/commands/keys.js';
7
7
  import { configCommand } from '../src/commands/config.js';
8
+ import { initCommand } from '../src/commands/init.js';
8
9
  import { readFileSync } from 'fs';
9
10
  import { dirname, join } from 'path';
10
11
  import { fileURLToPath } from 'url';
@@ -25,13 +26,14 @@ program
25
26
  .version(packageJson.version, '-v, --version', 'Output the current version')
26
27
  .addHelpText('after', `
27
28
  ${chalk.cyan('Examples:')}
29
+ $ shipi18n init
28
30
  $ shipi18n translate en.json --target es,fr,de
29
31
  $ shipi18n keys list
30
32
  $ shipi18n config set apiKey sk_live_...
31
33
 
32
34
  ${chalk.cyan('Get started:')}
33
- 1. Sign up at ${chalk.underline('https://shipi18n.com')}
34
- 2. Get your API key (free tier: 100 keys, 3 languages)
35
+ 1. Run ${chalk.yellow('shipi18n init')} to detect your i18n setup
36
+ 2. Sign up at ${chalk.underline('https://shipi18n.com')} and get your API key
35
37
  3. Run: ${chalk.yellow('shipi18n config set apiKey YOUR_KEY')}
36
38
  4. Translate: ${chalk.yellow('shipi18n translate en.json --target es,fr')}
37
39
 
@@ -39,6 +41,7 @@ ${chalk.gray('Documentation: https://shipi18n.com/docs/cli')}
39
41
  `);
40
42
 
41
43
  // Add commands
44
+ initCommand(program);
42
45
  translateCommand(program);
43
46
  keysCommand(program);
44
47
  configCommand(program);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@shipi18n/cli",
3
- "version": "1.1.0",
3
+ "version": "1.1.2",
4
4
  "description": "Command-line tool for translating locale files with Shipi18n",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -0,0 +1,443 @@
1
+ import chalk from 'chalk';
2
+ import { logger } from '../utils/logger.js';
3
+ import { existsSync, readFileSync, writeFileSync, readdirSync, statSync } from 'fs';
4
+ import { join, dirname, basename, extname } from 'path';
5
+ import inquirer from 'inquirer';
6
+
7
+ // Known i18n frameworks and their signatures
8
+ const I18N_FRAMEWORKS = {
9
+ 'i18next': {
10
+ packages: ['i18next', 'react-i18next', 'next-i18next', 'i18next-http-backend', 'i18next-browser-languagedetector'],
11
+ placeholderPattern: 'i18next',
12
+ },
13
+ 'react-intl': {
14
+ packages: ['react-intl', 'formatjs', '@formatjs/intl'],
15
+ placeholderPattern: 'icu',
16
+ },
17
+ 'vue-i18n': {
18
+ packages: ['vue-i18n', '@intlify/vue-i18n'],
19
+ placeholderPattern: 'i18next',
20
+ },
21
+ 'next-intl': {
22
+ packages: ['next-intl'],
23
+ placeholderPattern: 'icu',
24
+ },
25
+ 'lingui': {
26
+ packages: ['@lingui/core', '@lingui/react', '@lingui/macro'],
27
+ placeholderPattern: 'icu',
28
+ },
29
+ 'polyglot': {
30
+ packages: ['node-polyglot'],
31
+ placeholderPattern: 'printf',
32
+ }
33
+ };
34
+
35
+ // Common locale directory patterns
36
+ const LOCALE_DIR_PATTERNS = [
37
+ 'locales', 'locale', 'lang', 'langs', 'languages', 'i18n',
38
+ 'translations', 'messages', 'public/locales', 'src/locales',
39
+ 'src/i18n', 'src/translations', 'app/locales', 'assets/locales'
40
+ ];
41
+
42
+ // Common language codes
43
+ const LANGUAGE_CODES = [
44
+ 'en', 'es', 'fr', 'de', 'it', 'pt', 'ja', 'ko', 'zh', 'ru', 'ar', 'hi', 'nl', 'pl', 'tr', 'vi', 'th',
45
+ 'en-US', 'en-GB', 'es-ES', 'es-MX', 'fr-FR', 'fr-CA', 'de-DE', 'pt-BR', 'pt-PT', 'zh-CN', 'zh-TW'
46
+ ];
47
+
48
+ /**
49
+ * Detect i18n framework from package.json
50
+ */
51
+ function detectFramework(packageJson) {
52
+ const allDeps = {
53
+ ...(packageJson.dependencies || {}),
54
+ ...(packageJson.devDependencies || {})
55
+ };
56
+
57
+ const detected = [];
58
+
59
+ for (const [framework, config] of Object.entries(I18N_FRAMEWORKS)) {
60
+ const matchedPackages = config.packages.filter(pkg => allDeps[pkg]);
61
+ if (matchedPackages.length > 0) {
62
+ detected.push({
63
+ framework,
64
+ confidence: matchedPackages.length / config.packages.length,
65
+ matchedPackages,
66
+ placeholderPattern: config.placeholderPattern
67
+ });
68
+ }
69
+ }
70
+
71
+ detected.sort((a, b) => b.confidence - a.confidence);
72
+ return { detected, primary: detected[0] || null };
73
+ }
74
+
75
+ /**
76
+ * Recursively get all files in a directory
77
+ */
78
+ function getAllFiles(dirPath, basePath = '', files = []) {
79
+ if (!existsSync(dirPath)) return files;
80
+
81
+ const items = readdirSync(dirPath);
82
+
83
+ for (const item of items) {
84
+ // Skip node_modules, .git, etc.
85
+ if (item === 'node_modules' || item === '.git' || item === 'dist' || item === 'build') {
86
+ continue;
87
+ }
88
+
89
+ const fullPath = join(dirPath, item);
90
+ const relativePath = basePath ? `${basePath}/${item}` : item;
91
+
92
+ if (statSync(fullPath).isDirectory()) {
93
+ getAllFiles(fullPath, relativePath, files);
94
+ } else {
95
+ files.push(relativePath);
96
+ }
97
+ }
98
+
99
+ return files;
100
+ }
101
+
102
+ /**
103
+ * Detect locale file structure
104
+ */
105
+ function detectFileStructure(files) {
106
+ const result = {
107
+ localeDirectories: [],
108
+ sourceLanguage: null,
109
+ targetLanguages: [],
110
+ namespaces: [],
111
+ fileFormat: 'json',
112
+ structure: 'flat'
113
+ };
114
+
115
+ // Find locale directories
116
+ const dirCounts = {};
117
+ for (const file of files) {
118
+ const parts = file.split('/');
119
+ for (let i = 0; i < parts.length; i++) {
120
+ const dir = parts.slice(0, i + 1).join('/');
121
+ const dirName = parts[i].toLowerCase();
122
+ if (LOCALE_DIR_PATTERNS.includes(dirName) || LANGUAGE_CODES.includes(parts[i])) {
123
+ dirCounts[dir] = (dirCounts[dir] || 0) + 1;
124
+ }
125
+ }
126
+ }
127
+
128
+ const sortedDirs = Object.entries(dirCounts).sort((a, b) => b[1] - a[1]);
129
+ if (sortedDirs.length > 0) {
130
+ result.localeDirectories = sortedDirs.slice(0, 3).map(([dir]) => dir);
131
+ }
132
+
133
+ // Find language files
134
+ const langFiles = files.filter(f => {
135
+ const filename = f.split('/').pop();
136
+ const ext = filename.split('.').pop();
137
+ const name = filename.replace(`.${ext}`, '');
138
+ return (ext === 'json' || ext === 'yaml' || ext === 'yml') &&
139
+ (LANGUAGE_CODES.includes(name) || LANGUAGE_CODES.includes(name.toLowerCase()));
140
+ });
141
+
142
+ const languages = new Set();
143
+ const namespaces = new Set();
144
+
145
+ for (const file of langFiles) {
146
+ const parts = file.split('/');
147
+ const filename = parts.pop();
148
+ const ext = filename.split('.').pop();
149
+ const name = filename.replace(`.${ext}`, '');
150
+
151
+ if (LANGUAGE_CODES.includes(name) || LANGUAGE_CODES.includes(name.toLowerCase())) {
152
+ languages.add(name.toLowerCase());
153
+ result.structure = 'flat';
154
+ } else {
155
+ const parentDir = parts[parts.length - 1];
156
+ if (parentDir && (LANGUAGE_CODES.includes(parentDir) || LANGUAGE_CODES.includes(parentDir.toLowerCase()))) {
157
+ languages.add(parentDir.toLowerCase());
158
+ namespaces.add(name);
159
+ result.structure = 'nested';
160
+ }
161
+ }
162
+
163
+ if (ext === 'yaml' || ext === 'yml') {
164
+ result.fileFormat = 'yaml';
165
+ }
166
+ }
167
+
168
+ result.targetLanguages = Array.from(languages).filter(l => l !== 'en');
169
+ result.sourceLanguage = languages.has('en') ? 'en' : Array.from(languages)[0] || 'en';
170
+ result.namespaces = Array.from(namespaces);
171
+
172
+ return result;
173
+ }
174
+
175
+ /**
176
+ * Detect placeholder patterns from translation content
177
+ */
178
+ function detectPlaceholderPatterns(content) {
179
+ const PLACEHOLDER_PATTERNS = {
180
+ i18next: { regex: /\{\{[^}]+\}\}/g },
181
+ icu: { regex: /\{[a-zA-Z_][a-zA-Z0-9_]*(?:,\s*(?:number|date|time|plural|select|selectordinal))?[^}]*\}/g },
182
+ printf: { regex: /%[sd@]|%\d+\$[sd]/g },
183
+ ruby: { regex: /%\{[^}]+\}/g }
184
+ };
185
+
186
+ const flatContent = flattenObject(content);
187
+ const values = Object.values(flatContent).filter(v => typeof v === 'string');
188
+
189
+ const patternCounts = {};
190
+
191
+ for (const [patternName, patternConfig] of Object.entries(PLACEHOLDER_PATTERNS)) {
192
+ patternCounts[patternName] = 0;
193
+ for (const value of values) {
194
+ const matches = value.match(patternConfig.regex);
195
+ if (matches) {
196
+ patternCounts[patternName] += matches.length;
197
+ }
198
+ }
199
+ }
200
+
201
+ const sortedPatterns = Object.entries(patternCounts)
202
+ .filter(([_, count]) => count > 0)
203
+ .sort((a, b) => b[1] - a[1]);
204
+
205
+ return {
206
+ patterns: patternCounts,
207
+ primary: sortedPatterns[0]?.[0] || 'icu'
208
+ };
209
+ }
210
+
211
+ function flattenObject(obj, prefix = '') {
212
+ const result = {};
213
+ for (const [key, value] of Object.entries(obj)) {
214
+ const newKey = prefix ? `${prefix}.${key}` : key;
215
+ if (value && typeof value === 'object' && !Array.isArray(value)) {
216
+ Object.assign(result, flattenObject(value, newKey));
217
+ } else {
218
+ result[newKey] = value;
219
+ }
220
+ }
221
+ return result;
222
+ }
223
+
224
+ /**
225
+ * Generate GitHub Action workflow
226
+ */
227
+ function generateGitHubActionWorkflow({ sourceDir, targetLanguages, sourceLanguage }) {
228
+ return `name: Translate
229
+
230
+ on:
231
+ push:
232
+ branches: [main]
233
+ paths:
234
+ - '${sourceDir}/**'
235
+ workflow_dispatch:
236
+
237
+ jobs:
238
+ translate:
239
+ runs-on: ubuntu-latest
240
+ steps:
241
+ - uses: actions/checkout@v4
242
+
243
+ - name: Translate locale files
244
+ uses: shipi18n/shipi18n-github-action@v1
245
+ with:
246
+ api-key: \${{ secrets.SHIPI18N_API_KEY }}
247
+ source-dir: '${sourceDir}'
248
+ target-languages: '${targetLanguages.join(',')}'
249
+ source-language: '${sourceLanguage}'
250
+ incremental: 'true'
251
+ verify: 'true'
252
+ create-pr: 'true'
253
+ `;
254
+ }
255
+
256
+ export function initCommand(program) {
257
+ program
258
+ .command('init')
259
+ .description('Analyze your project and generate shipi18n configuration')
260
+ .option('-y, --yes', 'Skip prompts and use detected defaults')
261
+ .option('--no-workflow', 'Skip GitHub Action workflow generation')
262
+ .action(async (options) => {
263
+ logger.log('');
264
+ logger.log(chalk.cyan.bold('🔍 Shipi18n Project Analyzer'));
265
+ logger.log(chalk.gray('Detecting your i18n setup...'));
266
+ logger.log('');
267
+
268
+ const cwd = process.cwd();
269
+
270
+ // Step 1: Read package.json
271
+ let packageJson = null;
272
+ let frameworkResult = { detected: [], primary: null };
273
+ const packageJsonPath = join(cwd, 'package.json');
274
+
275
+ if (existsSync(packageJsonPath)) {
276
+ try {
277
+ packageJson = JSON.parse(readFileSync(packageJsonPath, 'utf8'));
278
+ frameworkResult = detectFramework(packageJson);
279
+
280
+ if (frameworkResult.primary) {
281
+ logger.success(`Framework: ${chalk.cyan(frameworkResult.primary.framework)}`);
282
+ logger.log(chalk.gray(` Detected packages: ${frameworkResult.primary.matchedPackages.join(', ')}`));
283
+ } else {
284
+ logger.warn('No i18n framework detected in package.json');
285
+ }
286
+ } catch (e) {
287
+ logger.warn('Could not parse package.json');
288
+ }
289
+ } else {
290
+ logger.warn('No package.json found');
291
+ }
292
+
293
+ // Step 2: Scan file structure
294
+ logger.log('');
295
+ const files = getAllFiles(cwd);
296
+ const fileStructure = detectFileStructure(files);
297
+
298
+ if (fileStructure.localeDirectories.length > 0) {
299
+ logger.success(`Locale directory: ${chalk.cyan(fileStructure.localeDirectories[0])}`);
300
+ logger.log(chalk.gray(` Structure: ${fileStructure.structure}`));
301
+ logger.log(chalk.gray(` Format: ${fileStructure.fileFormat}`));
302
+ } else {
303
+ logger.warn('No locale directory detected');
304
+ fileStructure.localeDirectories = ['locales'];
305
+ }
306
+
307
+ if (fileStructure.sourceLanguage) {
308
+ logger.success(`Source language: ${chalk.cyan(fileStructure.sourceLanguage)}`);
309
+ }
310
+
311
+ if (fileStructure.targetLanguages.length > 0) {
312
+ logger.success(`Target languages: ${chalk.cyan(fileStructure.targetLanguages.join(', '))}`);
313
+ } else {
314
+ logger.info('No target languages detected - using defaults (es, fr, de)');
315
+ fileStructure.targetLanguages = ['es', 'fr', 'de'];
316
+ }
317
+
318
+ if (fileStructure.namespaces.length > 0) {
319
+ logger.log(chalk.gray(` Namespaces: ${fileStructure.namespaces.join(', ')}`));
320
+ }
321
+
322
+ // Step 3: Detect placeholder patterns from sample file
323
+ let placeholderResult = { primary: 'icu' };
324
+
325
+ if (fileStructure.localeDirectories.length > 0) {
326
+ const localeDir = fileStructure.localeDirectories[0];
327
+ const sourceFile = files.find(f =>
328
+ f.startsWith(localeDir) &&
329
+ f.endsWith('.json') &&
330
+ (f.includes('/en') || f.includes('/en.') || f.endsWith('en.json'))
331
+ );
332
+
333
+ if (sourceFile) {
334
+ try {
335
+ const content = JSON.parse(readFileSync(join(cwd, sourceFile), 'utf8'));
336
+ placeholderResult = detectPlaceholderPatterns(content);
337
+ logger.success(`Placeholder format: ${chalk.cyan(placeholderResult.primary)}`);
338
+ } catch (e) {
339
+ // Use framework default if available
340
+ if (frameworkResult.primary?.placeholderPattern) {
341
+ placeholderResult.primary = frameworkResult.primary.placeholderPattern;
342
+ logger.info(`Placeholder format: ${chalk.cyan(placeholderResult.primary)} (from framework)`);
343
+ }
344
+ }
345
+ }
346
+ }
347
+
348
+ logger.log('');
349
+
350
+ // Build configuration
351
+ const sourceDir = fileStructure.structure === 'nested'
352
+ ? `${fileStructure.localeDirectories[0]}/${fileStructure.sourceLanguage || 'en'}`
353
+ : fileStructure.localeDirectories[0];
354
+
355
+ const config = {
356
+ sourceLanguage: fileStructure.sourceLanguage || 'en',
357
+ targetLanguages: fileStructure.targetLanguages.length > 0
358
+ ? fileStructure.targetLanguages
359
+ : ['es', 'fr', 'de'],
360
+ sourceDir,
361
+ outputDir: fileStructure.localeDirectories[0] || 'locales',
362
+ fileFormat: fileStructure.fileFormat || 'json',
363
+ placeholderFormat: placeholderResult.primary,
364
+ incremental: true,
365
+ verify: true,
366
+ selfCorrect: false
367
+ };
368
+
369
+ // Confirmation or auto-accept
370
+ let confirmed = options.yes;
371
+
372
+ if (!confirmed) {
373
+ logger.log(chalk.cyan.bold('📝 Proposed Configuration:'));
374
+ logger.log('');
375
+ logger.log(JSON.stringify(config, null, 2));
376
+ logger.log('');
377
+
378
+ const { proceed } = await inquirer.prompt([{
379
+ type: 'confirm',
380
+ name: 'proceed',
381
+ message: 'Create shipi18n.config.json with these settings?',
382
+ default: true
383
+ }]);
384
+ confirmed = proceed;
385
+ }
386
+
387
+ if (!confirmed) {
388
+ logger.info('Cancelled. Run again with different options or manually create config.');
389
+ return;
390
+ }
391
+
392
+ // Write config file
393
+ const configPath = join(cwd, 'shipi18n.config.json');
394
+ writeFileSync(configPath, JSON.stringify(config, null, 2));
395
+ logger.success(`Created ${chalk.cyan('shipi18n.config.json')}`);
396
+
397
+ // GitHub Action workflow
398
+ if (options.workflow !== false) {
399
+ let createWorkflow = options.yes;
400
+
401
+ if (!createWorkflow) {
402
+ const { workflow } = await inquirer.prompt([{
403
+ type: 'confirm',
404
+ name: 'workflow',
405
+ message: 'Create GitHub Action workflow for automatic translations?',
406
+ default: true
407
+ }]);
408
+ createWorkflow = workflow;
409
+ }
410
+
411
+ if (createWorkflow) {
412
+ const workflowDir = join(cwd, '.github', 'workflows');
413
+ const workflowPath = join(workflowDir, 'translate.yml');
414
+
415
+ // Create directories if needed
416
+ const { mkdirSync } = await import('fs');
417
+ mkdirSync(workflowDir, { recursive: true });
418
+
419
+ const workflow = generateGitHubActionWorkflow({
420
+ sourceDir: config.sourceDir,
421
+ targetLanguages: config.targetLanguages,
422
+ sourceLanguage: config.sourceLanguage
423
+ });
424
+
425
+ writeFileSync(workflowPath, workflow);
426
+ logger.success(`Created ${chalk.cyan('.github/workflows/translate.yml')}`);
427
+ }
428
+ }
429
+
430
+ // Next steps
431
+ logger.log('');
432
+ logger.log(chalk.cyan.bold('🚀 Next Steps:'));
433
+ logger.log('');
434
+ logger.log(` 1. Get your API key at ${chalk.underline('https://shipi18n.com')}`);
435
+ logger.log(` 2. Add secret: ${chalk.yellow('SHIPI18N_API_KEY')} to your GitHub repository`);
436
+ logger.log(` 3. Push changes to trigger automatic translations`);
437
+ logger.log('');
438
+ logger.log(chalk.gray('Or translate manually:'));
439
+ logger.log(` ${chalk.yellow(`shipi18n config set apiKey YOUR_KEY`)}`);
440
+ logger.log(` ${chalk.yellow(`shipi18n translate ${config.sourceDir} --target ${config.targetLanguages.join(',')}`)}`);
441
+ logger.log('');
442
+ });
443
+ }
@@ -4,73 +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
-
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
- }
7
+ import { flattenObject, unflattenObject, deepMerge, findMissingKeys } from '../utils/incremental.js';
74
8
 
75
9
  export function translateCommand(program) {
76
10
  program
package/src/lib/api.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import dotenv from 'dotenv';
2
2
  dotenv.config();
3
3
 
4
- const API_BASE_URL = process.env.SHIPI18N_API_URL || 'https://x9527l3blg.execute-api.us-east-1.amazonaws.com';
4
+ const API_BASE_URL = process.env.SHIPI18N_API_URL || 'https://ydjkwckq3f.execute-api.us-east-1.amazonaws.com';
5
5
 
6
6
  /**
7
7
  * Shipi18n API Client
@@ -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
+ }