i18next-cli 0.9.8 → 0.9.10

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.
Files changed (43) hide show
  1. package/CHANGELOG.md +11 -1
  2. package/README.md +69 -0
  3. package/dist/cjs/cli.js +1 -1
  4. package/dist/cjs/extractor/core/extractor.js +1 -1
  5. package/dist/cjs/extractor/core/translation-manager.js +1 -1
  6. package/dist/cjs/extractor/parsers/ast-visitors.js +1 -1
  7. package/dist/cjs/extractor/parsers/jsx-parser.js +1 -1
  8. package/dist/cjs/heuristic-config.js +1 -1
  9. package/dist/cjs/status.js +1 -1
  10. package/dist/cjs/syncer.js +1 -1
  11. package/dist/cjs/utils/file-utils.js +1 -1
  12. package/dist/esm/cli.js +1 -1
  13. package/dist/esm/extractor/core/extractor.js +1 -1
  14. package/dist/esm/extractor/core/translation-manager.js +1 -1
  15. package/dist/esm/extractor/parsers/ast-visitors.js +1 -1
  16. package/dist/esm/extractor/parsers/jsx-parser.js +1 -1
  17. package/dist/esm/heuristic-config.js +1 -1
  18. package/dist/esm/status.js +1 -1
  19. package/dist/esm/syncer.js +1 -1
  20. package/dist/esm/utils/file-utils.js +1 -1
  21. package/package.json +1 -1
  22. package/src/cli.ts +1 -1
  23. package/src/extractor/core/extractor.ts +13 -7
  24. package/src/extractor/core/translation-manager.ts +38 -21
  25. package/src/extractor/parsers/ast-visitors.ts +123 -53
  26. package/src/extractor/parsers/jsx-parser.ts +11 -1
  27. package/src/heuristic-config.ts +14 -3
  28. package/src/status.ts +21 -13
  29. package/src/syncer.ts +77 -67
  30. package/src/types.ts +22 -1
  31. package/src/utils/file-utils.ts +54 -1
  32. package/types/extractor/core/extractor.d.ts.map +1 -1
  33. package/types/extractor/core/translation-manager.d.ts.map +1 -1
  34. package/types/extractor/parsers/ast-visitors.d.ts +33 -15
  35. package/types/extractor/parsers/ast-visitors.d.ts.map +1 -1
  36. package/types/extractor/parsers/jsx-parser.d.ts.map +1 -1
  37. package/types/heuristic-config.d.ts.map +1 -1
  38. package/types/status.d.ts.map +1 -1
  39. package/types/syncer.d.ts.map +1 -1
  40. package/types/types.d.ts +19 -1
  41. package/types/types.d.ts.map +1 -1
  42. package/types/utils/file-utils.d.ts +16 -1
  43. package/types/utils/file-utils.d.ts.map +1 -1
@@ -1,8 +1,7 @@
1
1
  import { TranslationResult, ExtractedKey, I18nextToolkitConfig } from '../../types'
2
- import { readFile } from 'node:fs/promises'
3
2
  import { resolve } from 'node:path'
4
3
  import { getNestedValue, setNestedValue, getNestedKeys } from '../../utils/nested-object'
5
- import { getOutputPath } from '../../utils/file-utils'
4
+ import { getOutputPath, loadTranslationFile } from '../../utils/file-utils'
6
5
 
7
6
  /**
8
7
  * Converts a glob pattern to a regular expression for matching keys
@@ -51,13 +50,15 @@ export async function getTranslations (
51
50
  const defaultNS = config.extract.defaultNS ?? 'translation'
52
51
  const keySeparator = config.extract.keySeparator ?? '.'
53
52
  const patternsToPreserve = [...(config.extract.preservePatterns || [])]
53
+ const mergeNamespaces = config.extract.mergeNamespaces ?? false
54
54
  for (const key of objectKeys) {
55
55
  // Convert the object key to a glob pattern to preserve all its children
56
56
  patternsToPreserve.push(`${key}.*`)
57
57
  }
58
58
  const preservePatterns = patternsToPreserve.map(globToRegex)
59
- if (!config.extract.primaryLanguage) config.extract.primaryLanguage = config.locales[0] || 'en'
60
- if (!config.extract.secondaryLanguages) config.extract.secondaryLanguages = config.locales.filter((l: string) => l !== config.extract.primaryLanguage)
59
+ config.extract.primaryLanguage ||= config.locales[0] || 'en'
60
+ config.extract.secondaryLanguages ||= config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
61
+ const primaryLanguage = config.extract.primaryLanguage
61
62
 
62
63
  // Group keys by namespace
63
64
  const keysByNS = new Map<string, ExtractedKey[]>()
@@ -72,21 +73,16 @@ export async function getTranslations (
72
73
  const results: TranslationResult[] = []
73
74
 
74
75
  for (const locale of config.locales) {
75
- for (const [ns, nsKeys] of keysByNS.entries()) {
76
- const outputPath = getOutputPath(config.extract.output, locale, ns)
76
+ const mergedTranslations: Record<string, any> = {}
77
+ const mergedExisting: Record<string, any> = {}
77
78
 
79
+ for (const [ns, nsKeys] of keysByNS.entries()) {
80
+ const outputPath = getOutputPath(config.extract.output, locale, mergeNamespaces ? undefined : ns)
78
81
  const fullPath = resolve(process.cwd(), outputPath)
79
82
 
80
- let oldContent = ''
81
- let existingTranslations: Record<string, any> = {}
82
- try {
83
- oldContent = await readFile(fullPath, 'utf-8')
84
- existingTranslations = JSON.parse(oldContent)
85
- } catch (e) { /* File doesn't exist, which is fine */ }
86
-
83
+ const existingTranslations = await loadTranslationFile(fullPath) || {}
87
84
  const newTranslations: Record<string, any> = {}
88
85
 
89
- // 1. Preserve keys from existing translations that match patterns
90
86
  const existingKeys = getNestedKeys(existingTranslations, keySeparator)
91
87
  for (const existingKey of existingKeys) {
92
88
  if (preservePatterns.some(re => re.test(existingKey))) {
@@ -95,24 +91,45 @@ export async function getTranslations (
95
91
  }
96
92
  }
97
93
 
98
- // 2. Merge in newly found keys for this namespace
99
94
  const sortedKeys = (config.extract.sort === false)
100
95
  ? nsKeys
101
- : nsKeys.sort((a, b) => a.key.localeCompare(b.key))
96
+ : [...nsKeys].sort((a, b) => a.key.localeCompare(b.key))
97
+
102
98
  for (const { key, defaultValue } of sortedKeys) {
103
99
  const existingValue = getNestedValue(existingTranslations, key, keySeparator)
104
- const valueToSet = existingValue ?? (locale === config.extract?.primaryLanguage ? defaultValue : '')
100
+ const valueToSet = existingValue ?? (locale === primaryLanguage ? defaultValue : (config.extract.defaultValue ?? ''))
105
101
  setNestedValue(newTranslations, key, valueToSet, keySeparator)
106
102
  }
107
103
 
108
- const indentation = config.extract.indentation ?? 2
109
- const newContent = JSON.stringify(newTranslations, null, indentation)
104
+ if (mergeNamespaces) {
105
+ mergedTranslations[ns] = newTranslations
106
+ if (Object.keys(existingTranslations).length > 0) {
107
+ mergedExisting[ns] = existingTranslations
108
+ }
109
+ } else {
110
+ const oldContent = existingTranslations ? JSON.stringify(existingTranslations, null, config.extract.indentation ?? 2) : ''
111
+ const newContent = JSON.stringify(newTranslations, null, config.extract.indentation ?? 2)
112
+
113
+ results.push({
114
+ path: fullPath,
115
+ updated: newContent !== oldContent,
116
+ newTranslations,
117
+ existingTranslations,
118
+ })
119
+ }
120
+ }
121
+
122
+ if (mergeNamespaces) {
123
+ const outputPath = getOutputPath(config.extract.output, locale)
124
+ const fullPath = resolve(process.cwd(), outputPath)
125
+ const oldContent = Object.keys(mergedExisting).length > 0 ? JSON.stringify(mergedExisting, null, config.extract.indentation ?? 2) : ''
126
+ const newContent = JSON.stringify(mergedTranslations, null, config.extract.indentation ?? 2)
110
127
 
111
128
  results.push({
112
129
  path: fullPath,
113
130
  updated: newContent !== oldContent,
114
- newTranslations,
115
- existingTranslations,
131
+ newTranslations: mergedTranslations,
132
+ existingTranslations: mergedExisting,
116
133
  })
117
134
  }
118
135
  }
@@ -1,4 +1,4 @@
1
- import type { Module, Node, CallExpression, VariableDeclarator, JSXElement, ArrowFunctionExpression, ObjectExpression } from '@swc/core'
1
+ import type { Module, Node, CallExpression, VariableDeclarator, JSXElement, ArrowFunctionExpression, ObjectExpression, Expression } from '@swc/core'
2
2
  import type { PluginContext, I18nextToolkitConfig, Logger } from '../../types'
3
3
  import { extractFromTransComponent } from './jsx-parser'
4
4
 
@@ -337,7 +337,7 @@ export class ASTVisitors {
337
337
  if (callee.type !== 'Identifier') return
338
338
 
339
339
  const scopeInfo = this.getVarFromScope(callee.value)
340
- const isFunctionToParse = (this.config.extract.functions || []).includes(callee.value) || scopeInfo !== undefined
340
+ const isFunctionToParse = (this.config.extract.functions || ['t']).includes(callee.value) || scopeInfo !== undefined
341
341
  if (!isFunctionToParse || node.arguments.length === 0) return
342
342
 
343
343
  const firstArg = node.arguments[0].expression
@@ -359,8 +359,26 @@ export class ASTVisitors {
359
359
 
360
360
  if (keysToProcess.length === 0) return
361
361
 
362
- const options = node.arguments.length > 1 ? node.arguments[1].expression : undefined
363
- const defaultValue = this.getDefaultValue(node, keysToProcess[keysToProcess.length - 1])
362
+ let defaultValue: string | undefined
363
+ let options: ObjectExpression | undefined
364
+
365
+ if (node.arguments.length > 1) {
366
+ const arg2 = node.arguments[1].expression
367
+ if (arg2.type === 'ObjectExpression') {
368
+ options = arg2
369
+ } else if (arg2.type === 'StringLiteral') {
370
+ defaultValue = arg2.value
371
+ }
372
+ }
373
+ if (node.arguments.length > 2) {
374
+ const arg3 = node.arguments[2].expression
375
+ if (arg3.type === 'ObjectExpression') {
376
+ options = arg3
377
+ }
378
+ }
379
+
380
+ const defaultValueFromOptions = options ? this.getObjectPropValue(options, 'defaultValue') : undefined
381
+ const finalDefaultValue = (typeof defaultValueFromOptions === 'string' ? defaultValueFromOptions : defaultValue)
364
382
 
365
383
  // Loop through each key found (could be one or more) and process it
366
384
  for (let i = 0; i < keysToProcess.length; i++) {
@@ -391,35 +409,50 @@ export class ASTVisitors {
391
409
  // The explicit defaultValue only applies to the LAST key in the fallback array.
392
410
  // For all preceding keys, their own key is their fallback.
393
411
  const isLastKey = i === keysToProcess.length - 1
394
- const dv = isLastKey ? defaultValue : key
412
+ const dv = isLastKey ? (finalDefaultValue || key) : key
395
413
 
396
414
  // Handle plurals, context, and returnObjects
397
- let keyHandled = false
398
415
  if (options?.type === 'ObjectExpression') {
399
- // Handle context
416
+ const contextProp = this.getObjectProperty(options, 'context')
417
+
418
+ // 1. Handle Dynamic Context (Ternary) first
419
+ if (contextProp?.value?.type === 'ConditionalExpression') {
420
+ const contextValues = this.resolvePossibleStringValues(contextProp.value)
421
+ const contextSeparator = this.config.extract.contextSeparator ?? '_'
422
+
423
+ if (contextValues.length > 0) {
424
+ contextValues.forEach(context => {
425
+ this.pluginContext.addKey({ key: `${finalKey}${contextSeparator}${context}`, ns, defaultValue: dv })
426
+ })
427
+ // For dynamic context, also add the base key as a fallback
428
+ this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv })
429
+ continue // This key is fully handled, move to the next in the array
430
+ }
431
+ }
432
+
433
+ // 2. Handle Static Context
400
434
  const contextValue = this.getObjectPropValue(options, 'context')
401
435
  if (typeof contextValue === 'string' && contextValue) {
402
436
  const contextSeparator = this.config.extract.contextSeparator ?? '_'
403
437
  this.pluginContext.addKey({ key: `${finalKey}${contextSeparator}${contextValue}`, ns, defaultValue: dv })
404
- keyHandled = true
438
+ continue // This key is fully handled
405
439
  }
406
440
 
407
- // Handle plurals
408
- if (!keyHandled && this.getObjectPropValue(options, 'count') !== undefined) {
441
+ // 3. Handle Plurals
442
+ if (this.getObjectPropValue(options, 'count') !== undefined) {
409
443
  this.handlePluralKeys(finalKey, dv, ns)
410
- keyHandled = true
444
+ continue // This key is fully handled
411
445
  }
412
446
 
413
- // Handle returnObjects
414
- if (!keyHandled && this.getObjectPropValue(options, 'returnObjects') === true) {
447
+ // 4. Handle returnObjects
448
+ if (this.getObjectPropValue(options, 'returnObjects') === true) {
415
449
  this.objectKeys.add(finalKey)
416
- // We still add the base key itself
450
+ // Fall through to add the base key itself
417
451
  }
418
452
  }
419
453
 
420
- if (!keyHandled) {
421
- this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv })
422
- }
454
+ // 5. Default case: Add the simple key
455
+ this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv })
423
456
  }
424
457
  }
425
458
 
@@ -455,39 +488,6 @@ export class ASTVisitors {
455
488
  }
456
489
  }
457
490
 
458
- /**
459
- * Extracts default value from translation function call arguments.
460
- *
461
- * Supports multiple patterns:
462
- * - String as second argument: `t('key', 'Default')`
463
- * - Object with defaultValue: `t('key', { defaultValue: 'Default' })`
464
- * - Falls back to the key itself if no default found
465
- *
466
- * @param node - Call expression node
467
- * @param fallback - Fallback value if no default found
468
- * @returns Extracted default value
469
- *
470
- * @private
471
- */
472
- private getDefaultValue (node: CallExpression, fallback: string): string {
473
- if (node.arguments.length <= 1) return fallback
474
-
475
- const secondArg = node.arguments[1].expression
476
-
477
- if (secondArg.type === 'StringLiteral') {
478
- return secondArg.value || fallback
479
- }
480
-
481
- if (secondArg.type === 'ObjectExpression') {
482
- const val = this.getObjectPropValue(secondArg, 'defaultValue')
483
- if (typeof val === 'string') return val || fallback
484
- if (typeof val === 'number' || typeof val === 'boolean') return String(val)
485
- return fallback
486
- }
487
-
488
- return fallback
489
- }
490
-
491
491
  /**
492
492
  * Processes JSX elements to extract translation keys from Trans components.
493
493
  *
@@ -532,11 +532,20 @@ export class ASTVisitors {
532
532
  extractedKey.ns = this.config.extract.defaultNS
533
533
  }
534
534
 
535
- // If the component has a `count` prop, use the plural handler
536
- if (extractedKey.hasCount) {
535
+ if (extractedKey.contextExpression) {
536
+ const contextValues = this.resolvePossibleStringValues(extractedKey.contextExpression)
537
+ const contextSeparator = this.config.extract.contextSeparator ?? '_'
538
+
539
+ if (contextValues.length > 0) {
540
+ for (const context of contextValues) {
541
+ this.pluginContext.addKey({ key: `${extractedKey.key}${contextSeparator}${context}`, ns: extractedKey.ns, defaultValue: extractedKey.defaultValue })
542
+ }
543
+ // Add the base key as well
544
+ this.pluginContext.addKey(extractedKey)
545
+ }
546
+ } else if (extractedKey.hasCount) {
537
547
  this.handlePluralKeys(extractedKey.key, extractedKey.defaultValue, extractedKey.ns)
538
548
  } else {
539
- // Otherwise, add the key as-is
540
549
  this.pluginContext.addKey(extractedKey)
541
550
  }
542
551
  // The duplicated addKey call has been removed.
@@ -671,4 +680,65 @@ export class ASTVisitors {
671
680
 
672
681
  return null
673
682
  }
683
+
684
+ /**
685
+ * Resolves an expression to one or more possible string values that can be
686
+ * determined statically from the AST.
687
+ *
688
+ * Supports:
689
+ * - StringLiteral -> single value
690
+ * - ConditionalExpression (ternary) -> union of consequent and alternate resolved values
691
+ * - The identifier `undefined` -> empty array
692
+ *
693
+ * For any other expression types (identifiers, function calls, member expressions,
694
+ * etc.) the value cannot be determined statically and an empty array is returned.
695
+ *
696
+ * @private
697
+ * @param expression - The SWC AST expression node to resolve
698
+ * @returns An array of possible string values that the expression may produce.
699
+ */
700
+ private resolvePossibleStringValues (expression: Expression): string[] {
701
+ if (expression.type === 'StringLiteral') {
702
+ return [expression.value]
703
+ }
704
+
705
+ if (expression.type === 'ConditionalExpression') { // This is a ternary operator
706
+ const consequentValues = this.resolvePossibleStringValues(expression.consequent)
707
+ const alternateValues = this.resolvePossibleStringValues(expression.alternate)
708
+ return [...consequentValues, ...alternateValues]
709
+ }
710
+
711
+ if (expression.type === 'Identifier' && expression.value === 'undefined') {
712
+ return [] // Handle the `undefined` case
713
+ }
714
+
715
+ // We can't statically determine the value of other expressions (e.g., variables, function calls)
716
+ return []
717
+ }
718
+
719
+ /**
720
+ * Finds and returns the full property node (KeyValueProperty) for the given
721
+ * property name from an ObjectExpression.
722
+ *
723
+ * Matches both identifier keys (e.g., { ns: 'value' }) and string literal keys
724
+ * (e.g., { 'ns': 'value' }).
725
+ *
726
+ * This helper returns the full property node rather than just its primitive
727
+ * value so callers can inspect expression types (ConditionalExpression, etc.).
728
+ *
729
+ * @private
730
+ * @param object - The SWC ObjectExpression to search
731
+ * @param propName - The property name to locate
732
+ * @returns The matching KeyValueProperty node if found, otherwise undefined.
733
+ */
734
+ private getObjectProperty (object: ObjectExpression, propName: string): any {
735
+ return (object.properties).find(
736
+ (p) =>
737
+ p.type === 'KeyValueProperty' &&
738
+ (
739
+ (p.key?.type === 'Identifier' && p.key.value === propName) ||
740
+ (p.key?.type === 'StringLiteral' && p.key.value === propName)
741
+ )
742
+ )
743
+ }
674
744
  }
@@ -55,6 +55,16 @@ export function extractFromTransComponent (node: JSXElement, config: I18nextTool
55
55
  )
56
56
  const hasCount = !!countAttr
57
57
 
58
+ const contextAttr = node.opening.attributes?.find(
59
+ (attr) =>
60
+ attr.type === 'JSXAttribute' &&
61
+ attr.name.type === 'Identifier' &&
62
+ attr.name.value === 'context'
63
+ )
64
+ const contextExpression = (contextAttr?.type === 'JSXAttribute' && contextAttr.value?.type === 'JSXExpressionContainer')
65
+ ? contextAttr.value.expression
66
+ : undefined
67
+
58
68
  let key: string
59
69
  if (i18nKeyAttr?.type === 'JSXAttribute' && i18nKeyAttr.value?.type === 'StringLiteral') {
60
70
  key = i18nKeyAttr.value.value
@@ -81,7 +91,7 @@ export function extractFromTransComponent (node: JSXElement, config: I18nextTool
81
91
  defaultValue = serializeJSXChildren(node.children, config)
82
92
  }
83
93
 
84
- return { key, ns, defaultValue: defaultValue || key, hasCount }
94
+ return { key, ns, defaultValue: defaultValue || key, hasCount, contextExpression }
85
95
  }
86
96
 
87
97
  /**
@@ -1,6 +1,6 @@
1
1
  import { glob } from 'glob'
2
2
  import { readdir } from 'node:fs/promises'
3
- import { dirname, join } from 'node:path'
3
+ import { dirname, join, extname } from 'node:path'
4
4
  import type { I18nextToolkitConfig } from './types'
5
5
 
6
6
  // A list of common glob patterns for the primary language ('en') or ('dev') translation files.
@@ -32,10 +32,20 @@ export async function detectConfig (): Promise<Partial<I18nextToolkitConfig> | n
32
32
  if (files.length > 0) {
33
33
  const firstFile = files[0]
34
34
  const basePath = dirname(dirname(firstFile))
35
+ const extension = extname(firstFile)
36
+
37
+ // Infer outputFormat from the file extension
38
+ let outputFormat: I18nextToolkitConfig['extract']['outputFormat'] = 'json'
39
+ if (extension === '.ts') {
40
+ outputFormat = 'ts'
41
+ } else if (extension === '.js') {
42
+ // We can't know if it's ESM or CJS, so we default to a safe choice.
43
+ // The tool's file loaders can handle both.
44
+ outputFormat = 'js'
45
+ }
35
46
 
36
47
  try {
37
48
  const allDirs = await readdir(basePath)
38
- // CORRECTED REGEX: Now accepts 'dev' in addition to standard locale codes.
39
49
  let locales = allDirs.filter(dir => /^(dev|[a-z]{2}(-[A-Z]{2})?)$/.test(dir))
40
50
 
41
51
  if (locales.length > 0) {
@@ -57,7 +67,8 @@ export async function detectConfig (): Promise<Partial<I18nextToolkitConfig> | n
57
67
  'pages/**/*.{js,jsx,ts,tsx}',
58
68
  'components/**/*.{js,jsx,ts,tsx}'
59
69
  ],
60
- output: join(basePath, '{{language}}', '{{namespace}}.json'),
70
+ output: join(basePath, '{{language}}', `{{namespace}}${extension}`),
71
+ outputFormat,
61
72
  primaryLanguage: locales.includes('en') ? 'en' : locales[0],
62
73
  },
63
74
  }
package/src/status.ts CHANGED
@@ -1,11 +1,10 @@
1
1
  import chalk from 'chalk'
2
2
  import ora from 'ora'
3
3
  import { resolve } from 'node:path'
4
- import { readFile } from 'node:fs/promises'
5
4
  import { findKeys } from './extractor/core/key-finder'
6
5
  import { getNestedValue } from './utils/nested-object'
7
6
  import type { I18nextToolkitConfig, ExtractedKey } from './types'
8
- import { getOutputPath } from './utils/file-utils'
7
+ import { getOutputPath, loadTranslationFile } from './utils/file-utils'
9
8
 
10
9
  /**
11
10
  * Options for configuring the status report display.
@@ -56,8 +55,8 @@ interface StatusReport {
56
55
  * @throws {Error} When unable to extract keys or read translation files
57
56
  */
58
57
  export async function runStatus (config: I18nextToolkitConfig, options: StatusOptions = {}) {
59
- if (!config.extract.primaryLanguage) config.extract.primaryLanguage = config.locales[0] || 'en'
60
- if (!config.extract.secondaryLanguages) config.extract.secondaryLanguages = config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
58
+ config.extract.primaryLanguage ||= config.locales[0] || 'en'
59
+ config.extract.secondaryLanguages ||= config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
61
60
  const spinner = ora('Analyzing project localization status...\n').start()
62
61
  try {
63
62
  const report = await generateStatusReport(config)
@@ -84,9 +83,16 @@ export async function runStatus (config: I18nextToolkitConfig, options: StatusOp
84
83
  * @throws {Error} When key extraction fails or configuration is invalid
85
84
  */
86
85
  async function generateStatusReport (config: I18nextToolkitConfig): Promise<StatusReport> {
86
+ config.extract.primaryLanguage ||= config.locales[0] || 'en'
87
+ config.extract.secondaryLanguages ||= config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
88
+
87
89
  const { allKeys: allExtractedKeys } = await findKeys(config)
88
- const { primaryLanguage, keySeparator = '.', defaultNS = 'translation' } = config.extract
89
- const secondaryLanguages = config.locales.filter(l => l !== primaryLanguage)
90
+ const {
91
+ secondaryLanguages,
92
+ keySeparator = '.',
93
+ defaultNS = 'translation',
94
+ mergeNamespaces = false,
95
+ } = config.extract
90
96
 
91
97
  const keysByNs = new Map<string, ExtractedKey[]>()
92
98
  for (const key of allExtractedKeys.values()) {
@@ -105,17 +111,19 @@ async function generateStatusReport (config: I18nextToolkitConfig): Promise<Stat
105
111
  let totalTranslatedForLocale = 0
106
112
  const namespaces = new Map<string, any>()
107
113
 
114
+ const mergedTranslations = mergeNamespaces
115
+ ? await loadTranslationFile(resolve(process.cwd(), getOutputPath(config.extract.output, locale))) || {}
116
+ : null
117
+
108
118
  for (const [ns, keysInNs] of keysByNs.entries()) {
109
- const langFilePath = getOutputPath(config.extract.output, locale, ns)
110
- let translations: Record<string, any> = {}
111
- try {
112
- const content = await readFile(resolve(process.cwd(), langFilePath), 'utf-8')
113
- translations = JSON.parse(content)
114
- } catch {}
119
+ const translationsForNs = mergeNamespaces
120
+ ? mergedTranslations?.[ns] || {}
121
+ : await loadTranslationFile(resolve(process.cwd(), getOutputPath(config.extract.output, locale, ns))) || {}
115
122
 
116
123
  let translatedInNs = 0
117
124
  const keyDetails = keysInNs.map(({ key }) => {
118
- const value = getNestedValue(translations, key, keySeparator ?? '.')
125
+ // Search for the key within the correct namespace object
126
+ const value = getNestedValue(translationsForNs, key, keySeparator ?? '.')
119
127
  const isTranslated = !!value
120
128
  if (isTranslated) translatedInNs++
121
129
  return { key, isTranslated }
package/src/syncer.ts CHANGED
@@ -1,10 +1,11 @@
1
- import { readFile, writeFile, mkdir } from 'node:fs/promises'
2
- import { resolve, dirname } from 'path'
1
+ import { writeFile, mkdir } from 'node:fs/promises'
2
+ import { resolve, dirname, basename } from 'path'
3
3
  import chalk from 'chalk'
4
4
  import ora from 'ora'
5
+ import { glob } from 'glob'
5
6
  import type { I18nextToolkitConfig } from './types'
6
7
  import { getNestedKeys, getNestedValue, setNestedValue } from './utils/nested-object'
7
- import { getOutputPath } from './utils/file-utils'
8
+ import { getOutputPath, loadTranslationFile, serializeTranslationFile } from './utils/file-utils'
8
9
 
9
10
  /**
10
11
  * Synchronizes translation files across different locales by ensuring all secondary
@@ -38,77 +39,86 @@ import { getOutputPath } from './utils/file-utils'
38
39
  */
39
40
  export async function runSyncer (config: I18nextToolkitConfig) {
40
41
  const spinner = ora('Running i18next locale synchronizer...\n').start()
41
-
42
- config.extract.primaryLanguage ||= config.locales[0] || 'en'
43
- const { primaryLanguage } = config.extract
44
- const secondaryLanguages = config.locales.filter(l => l !== primaryLanguage)
45
- const keySeparator = config.extract.keySeparator ?? '.'
46
-
47
- const logMessages: string[] = []
48
- let wasAnythingSynced = false
49
-
50
- // Assume sync operates on the default namespace for simplicity
51
- const defaultNS = config.extract.defaultNS ?? 'translation'
52
-
53
- // 1. Get all keys from the primary language file
54
- const primaryPath = getOutputPath(config.extract.output, primaryLanguage, defaultNS)
55
-
56
- const fullPrimaryPath = resolve(process.cwd(), primaryPath)
57
-
58
- let primaryTranslations: Record<string, any>
59
42
  try {
60
- const primaryContent = await readFile(fullPrimaryPath, 'utf-8')
61
- primaryTranslations = JSON.parse(primaryContent)
62
- } catch (e) {
63
- console.error(`Primary language file not found at ${primaryPath}. Cannot sync.`)
64
- return
65
- }
66
-
67
- const primaryKeys = getNestedKeys(primaryTranslations, keySeparator)
68
-
69
- // 2. Iterate through secondary languages and sync them
70
- for (const lang of secondaryLanguages) {
71
- const secondaryPath = getOutputPath(config.extract.output, lang, defaultNS)
72
- const fullSecondaryPath = resolve(process.cwd(), secondaryPath)
73
-
74
- let secondaryTranslations: Record<string, any> = {}
75
- let oldContent = ''
76
- try {
77
- oldContent = await readFile(fullSecondaryPath, 'utf-8')
78
- secondaryTranslations = JSON.parse(oldContent)
79
- } catch (e) { /* File doesn't exist, will be created */ }
80
-
81
- const newSecondaryTranslations: Record<string, any> = {}
43
+ const primaryLanguage = config.extract.primaryLanguage || config.locales[0] || 'en'
44
+ const secondaryLanguages = config.locales.filter((l) => l !== primaryLanguage)
45
+ const {
46
+ output,
47
+ keySeparator = '.',
48
+ outputFormat = 'json',
49
+ indentation = 2,
50
+ defaultValue = '',
51
+ } = config.extract
52
+
53
+ const logMessages: string[] = []
54
+ let wasAnythingSynced = false
55
+
56
+ // 1. Find all namespace files for the primary language
57
+ const primaryNsPattern = getOutputPath(output, primaryLanguage, '*')
58
+ const primaryNsFiles = await glob(primaryNsPattern)
59
+
60
+ if (primaryNsFiles.length === 0) {
61
+ spinner.warn(`No translation files found for primary language "${primaryLanguage}". Nothing to sync.`)
62
+ return
63
+ }
82
64
 
83
- // Rebuild the secondary file based on the primary file's keys
84
- for (const key of primaryKeys) {
85
- const existingValue = getNestedValue(secondaryTranslations, key, keySeparator)
86
- // If value exists in old file, keep it. Otherwise, add as empty string.
87
- const valueToSet = existingValue ?? (config.extract?.defaultValue || '')
88
- setNestedValue(newSecondaryTranslations, key, valueToSet, keySeparator)
65
+ // 2. Loop through each primary namespace file
66
+ for (const primaryPath of primaryNsFiles) {
67
+ const ns = basename(primaryPath).split('.')[0]
68
+ const primaryTranslations = await loadTranslationFile(primaryPath)
69
+
70
+ if (!primaryTranslations) {
71
+ logMessages.push(` ${chalk.yellow('-')} Could not read primary file: ${primaryPath}`)
72
+ continue
73
+ }
74
+
75
+ const primaryKeys = getNestedKeys(primaryTranslations, keySeparator ?? '.')
76
+
77
+ // 3. For each secondary language, sync the current namespace
78
+ for (const lang of secondaryLanguages) {
79
+ const secondaryPath = getOutputPath(output, lang, ns)
80
+ const fullSecondaryPath = resolve(process.cwd(), secondaryPath)
81
+ const existingSecondaryTranslations = await loadTranslationFile(fullSecondaryPath) || {}
82
+ const newSecondaryTranslations: Record<string, any> = {}
83
+
84
+ for (const key of primaryKeys) {
85
+ const existingValue = getNestedValue(existingSecondaryTranslations, key, keySeparator ?? '.')
86
+ const valueToSet = existingValue ?? defaultValue
87
+ setNestedValue(newSecondaryTranslations, key, valueToSet, keySeparator ?? '.')
88
+ }
89
+
90
+ // Use JSON.stringify for a reliable object comparison, regardless of format
91
+ const oldContent = JSON.stringify(existingSecondaryTranslations)
92
+ const newContent = JSON.stringify(newSecondaryTranslations)
93
+
94
+ if (newContent !== oldContent) {
95
+ wasAnythingSynced = true
96
+ const serializedContent = serializeTranslationFile(newSecondaryTranslations, outputFormat, indentation)
97
+ await mkdir(dirname(fullSecondaryPath), { recursive: true })
98
+ await writeFile(fullSecondaryPath, serializedContent)
99
+ logMessages.push(` ${chalk.green('✓')} Synchronized: ${secondaryPath}`)
100
+ } else {
101
+ logMessages.push(` ${chalk.gray('-')} Already in sync: ${secondaryPath}`)
102
+ }
103
+ }
89
104
  }
90
105
 
91
- const indentation = config.extract.indentation ?? 2
92
- const newContent = JSON.stringify(newSecondaryTranslations, null, indentation)
106
+ spinner.succeed(chalk.bold('Synchronization complete!'))
107
+ logMessages.forEach(msg => console.log(msg))
93
108
 
94
- if (newContent !== oldContent) {
95
- wasAnythingSynced = true
96
- await mkdir(dirname(fullSecondaryPath), { recursive: true })
97
- await writeFile(fullSecondaryPath, newContent)
98
- logMessages.push(` ${chalk.green('✓')} Synchronized: ${secondaryPath}`)
109
+ if (wasAnythingSynced) {
110
+ printLocizeFunnel()
99
111
  } else {
100
- logMessages.push(` ${chalk.gray('-')} Already in sync: ${secondaryPath}`)
112
+ console.log(chalk.green.bold('\n✅ All locales are already in sync.'))
101
113
  }
114
+ } catch (error) {
115
+ spinner.fail(chalk.red('Synchronization failed.'))
116
+ console.error(error)
102
117
  }
118
+ }
103
119
 
104
- spinner.succeed(chalk.bold('Synchronization complete!'))
105
- logMessages.forEach(msg => console.log(msg))
106
-
107
- if (wasAnythingSynced) {
108
- console.log(chalk.green.bold('\n✅ Sync complete.'))
109
- console.log(chalk.yellow('🚀 Ready to collaborate with translators? Move your files to the cloud.'))
110
- console.log(` Get started with the official TMS for i18next: ${chalk.cyan('npx i18next-cli locize-migrate')}`)
111
- } else {
112
- console.log(chalk.green.bold('\n✅ All locales are already in sync.'))
113
- }
120
+ function printLocizeFunnel () {
121
+ console.log(chalk.green.bold('\n✅ Sync complete.'))
122
+ console.log(chalk.yellow('🚀 Ready to collaborate with translators? Move your files to the cloud.'))
123
+ console.log(` Get started with the official TMS for i18next: ${chalk.cyan('npx i18next-cli locize-migrate')}`)
114
124
  }