i18next-cli 0.9.9 → 0.9.11

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 (39) hide show
  1. package/CHANGELOG.md +25 -10
  2. package/README.md +75 -3
  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/heuristic-config.js +1 -1
  8. package/dist/cjs/status.js +1 -1
  9. package/dist/cjs/syncer.js +1 -1
  10. package/dist/cjs/utils/file-utils.js +1 -1
  11. package/dist/esm/cli.js +1 -1
  12. package/dist/esm/extractor/core/extractor.js +1 -1
  13. package/dist/esm/extractor/core/translation-manager.js +1 -1
  14. package/dist/esm/extractor/parsers/ast-visitors.js +1 -1
  15. package/dist/esm/heuristic-config.js +1 -1
  16. package/dist/esm/status.js +1 -1
  17. package/dist/esm/syncer.js +1 -1
  18. package/dist/esm/utils/file-utils.js +1 -1
  19. package/package.json +1 -1
  20. package/src/cli.ts +1 -1
  21. package/src/extractor/core/extractor.ts +13 -7
  22. package/src/extractor/core/translation-manager.ts +38 -21
  23. package/src/extractor/parsers/ast-visitors.ts +71 -16
  24. package/src/heuristic-config.ts +14 -3
  25. package/src/status.ts +21 -13
  26. package/src/syncer.ts +77 -67
  27. package/src/types.ts +18 -0
  28. package/src/utils/file-utils.ts +54 -1
  29. package/types/extractor/core/extractor.d.ts.map +1 -1
  30. package/types/extractor/core/translation-manager.d.ts.map +1 -1
  31. package/types/extractor/parsers/ast-visitors.d.ts +5 -1
  32. package/types/extractor/parsers/ast-visitors.d.ts.map +1 -1
  33. package/types/heuristic-config.d.ts.map +1 -1
  34. package/types/status.d.ts.map +1 -1
  35. package/types/syncer.d.ts.map +1 -1
  36. package/types/types.d.ts +16 -0
  37. package/types/types.d.ts.map +1 -1
  38. package/types/utils/file-utils.d.ts +16 -1
  39. package/types/utils/file-utils.d.ts.map +1 -1
@@ -11,6 +11,7 @@ import { createPluginContext } from '../plugin-manager'
11
11
  import { extractKeysFromComments } from '../parsers/comment-parser'
12
12
  import { ASTVisitors } from '../parsers/ast-visitors'
13
13
  import { ConsoleLogger } from '../../utils/logger'
14
+ import { serializeTranslationFile } from '../../utils/file-utils'
14
15
 
15
16
  /**
16
17
  * Main extractor function that runs the complete key extraction and file generation process.
@@ -42,8 +43,8 @@ export async function runExtractor (
42
43
  config: I18nextToolkitConfig,
43
44
  logger: Logger = new ConsoleLogger()
44
45
  ): Promise<boolean> {
45
- if (!config.extract.primaryLanguage) config.extract.primaryLanguage = config.locales[0] || 'en'
46
- if (!config.extract.secondaryLanguages) config.extract.secondaryLanguages = config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
46
+ config.extract.primaryLanguage ||= config.locales[0] || 'en'
47
+ config.extract.secondaryLanguages ||= config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
47
48
 
48
49
  validateExtractorConfig(config)
49
50
 
@@ -59,8 +60,13 @@ export async function runExtractor (
59
60
  for (const result of results) {
60
61
  if (result.updated) {
61
62
  anyFileUpdated = true
63
+ const fileContent = serializeTranslationFile(
64
+ result.newTranslations,
65
+ config.extract.outputFormat,
66
+ config.extract.indentation
67
+ )
62
68
  await mkdir(dirname(result.path), { recursive: true })
63
- await writeFile(result.path, JSON.stringify(result.newTranslations, null, 2))
69
+ await writeFile(result.path, fileContent)
64
70
  logger.info(chalk.green(`Updated: ${result.path}`))
65
71
  }
66
72
  }
@@ -180,10 +186,10 @@ function traverseEveryNode (node: any, plugins: any[], pluginContext: PluginCont
180
186
  * ```
181
187
  */
182
188
  export async function extract (config: I18nextToolkitConfig) {
183
- if (!config.extract.primaryLanguage) config.extract.primaryLanguage = config.locales[0]
184
- if (!config.extract.secondaryLanguages) config.extract.secondaryLanguages = config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
185
- if (!config.extract.functions) config.extract.functions = ['t']
186
- if (!config.extract.transComponents) config.extract.transComponents = ['Trans']
189
+ config.extract.primaryLanguage ||= config.locales[0] || 'en'
190
+ config.extract.secondaryLanguages ||= config.locales.filter((l: string) => l !== config?.extract?.primaryLanguage)
191
+ config.extract.functions ||= ['t']
192
+ config.extract.transComponents ||= ['Trans']
187
193
  const { allKeys, objectKeys } = await findKeys(config)
188
194
  return getTranslations(allKeys, objectKeys, config)
189
195
  }
@@ -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
  }
@@ -118,7 +118,10 @@ export class ASTVisitors {
118
118
  const child = node[key]
119
119
  if (Array.isArray(child)) {
120
120
  for (const item of child) {
121
- if (item && typeof item === 'object' && item.type) {
121
+ // Be less strict: if it's a non-null object, walk it.
122
+ // This allows traversal into nodes that might not have a `.type` property
123
+ // but still contain other valid AST nodes.
124
+ if (item && typeof item === 'object') {
122
125
  this.walk(item)
123
126
  }
124
127
  }
@@ -337,7 +340,7 @@ export class ASTVisitors {
337
340
  if (callee.type !== 'Identifier') return
338
341
 
339
342
  const scopeInfo = this.getVarFromScope(callee.value)
340
- const isFunctionToParse = (this.config.extract.functions || []).includes(callee.value) || scopeInfo !== undefined
343
+ const isFunctionToParse = (this.config.extract.functions || ['t']).includes(callee.value) || scopeInfo !== undefined
341
344
  if (!isFunctionToParse || node.arguments.length === 0) return
342
345
 
343
346
  const firstArg = node.arguments[0].expression
@@ -376,7 +379,6 @@ export class ASTVisitors {
376
379
  options = arg3
377
380
  }
378
381
  }
379
-
380
382
  const defaultValueFromOptions = options ? this.getObjectPropValue(options, 'defaultValue') : undefined
381
383
  const finalDefaultValue = (typeof defaultValueFromOptions === 'string' ? defaultValueFromOptions : defaultValue)
382
384
 
@@ -386,7 +388,7 @@ export class ASTVisitors {
386
388
  let ns: string | undefined
387
389
 
388
390
  // Determine namespace (explicit ns > scope ns > ns:key > default)
389
- if (options?.type === 'ObjectExpression') {
391
+ if (options) {
390
392
  const nsVal = this.getObjectPropValue(options, 'ns')
391
393
  if (typeof nsVal === 'string') ns = nsVal
392
394
  }
@@ -406,13 +408,11 @@ export class ASTVisitors {
406
408
  finalKey = `${scopeInfo.keyPrefix}${keySeparator}${key}`
407
409
  }
408
410
 
409
- // The explicit defaultValue only applies to the LAST key in the fallback array.
410
- // For all preceding keys, their own key is their fallback.
411
411
  const isLastKey = i === keysToProcess.length - 1
412
412
  const dv = isLastKey ? (finalDefaultValue || key) : key
413
413
 
414
414
  // Handle plurals, context, and returnObjects
415
- if (options?.type === 'ObjectExpression') {
415
+ if (options) {
416
416
  const contextProp = this.getObjectProperty(options, 'context')
417
417
 
418
418
  // 1. Handle Dynamic Context (Ternary) first
@@ -440,8 +440,8 @@ export class ASTVisitors {
440
440
 
441
441
  // 3. Handle Plurals
442
442
  if (this.getObjectPropValue(options, 'count') !== undefined) {
443
- this.handlePluralKeys(finalKey, dv, ns)
444
- continue // This key is fully handled
443
+ this.handlePluralKeys(finalKey, ns, options)
444
+ continue
445
445
  }
446
446
 
447
447
  // 4. Handle returnObjects
@@ -464,27 +464,82 @@ export class ASTVisitors {
464
464
  * for each category (e.g., 'item_one', 'item_other').
465
465
  *
466
466
  * @param key - Base key name for pluralization
467
- * @param defaultValue - Default value to use for all plural forms
468
467
  * @param ns - Namespace for the keys
468
+ * @param options - object expression options
469
469
  *
470
470
  * @private
471
471
  */
472
- private handlePluralKeys (key: string, defaultValue: string | undefined, ns: string | undefined): void {
472
+ private handlePluralKeys (key: string, ns: string | undefined, options: ObjectExpression): void {
473
473
  try {
474
- const pluralCategories = new Intl.PluralRules(this.config.extract?.primaryLanguage).resolvedOptions().pluralCategories
474
+ const isOrdinal = this.getObjectPropValue(options, 'ordinal') === true
475
+ const type = isOrdinal ? 'ordinal' : 'cardinal'
476
+
477
+ const pluralCategories = new Intl.PluralRules(this.config.extract?.primaryLanguage, { type }).resolvedOptions().pluralCategories
475
478
  const pluralSeparator = this.config.extract.pluralSeparator ?? '_'
476
479
 
480
+ const defaultValue = this.getObjectPropValue(options, 'defaultValue')
481
+ const defaultValueOther = this.getObjectPropValue(options, 'defaultValue_other')
482
+
483
+ for (const category of pluralCategories) {
484
+ // Construct the specific defaultValue key, e.g., `defaultValue_ordinal_one` or `defaultValue_one`
485
+ const specificDefaultKey = isOrdinal ? `defaultValue_ordinal_${category}` : `defaultValue_${category}`
486
+ const specificDefault = this.getObjectPropValue(options, specificDefaultKey)
487
+
488
+ let finalDefaultValue: string | undefined
489
+
490
+ if (typeof specificDefault === 'string') {
491
+ finalDefaultValue = specificDefault
492
+ } else if (category === 'one' && typeof defaultValue === 'string') {
493
+ // 'one' specifically falls back to the main `defaultValue` prop
494
+ finalDefaultValue = defaultValue
495
+ } else if (typeof defaultValueOther === 'string') {
496
+ // All other categories fall back to `defaultValue_other`
497
+ finalDefaultValue = defaultValueOther
498
+ } else if (typeof defaultValue === 'string') {
499
+ // If all else fails, use the main `defaultValue` as a last resort
500
+ finalDefaultValue = defaultValue
501
+ } else {
502
+ finalDefaultValue = key // Fallback to the key itself
503
+ }
504
+
505
+ // Construct the final key, e.g., `key_ordinal_one` or `key_one`
506
+ const finalKey = isOrdinal
507
+ ? `${key}${pluralSeparator}ordinal${pluralSeparator}${category}`
508
+ : `${key}${pluralSeparator}${category}`
509
+
510
+ this.pluginContext.addKey({
511
+ key: finalKey,
512
+ ns,
513
+ defaultValue: finalDefaultValue,
514
+ hasCount: true,
515
+ })
516
+ }
517
+ } catch (e) {
518
+ this.logger.warn(`Could not determine plural rules for language "${this.config.extract?.primaryLanguage}". Falling back to simple key extraction.`)
519
+ // Fallback to a simple key if Intl API fails
520
+ const defaultValue = this.getObjectPropValue(options, 'defaultValue')
521
+ this.pluginContext.addKey({ key, ns, defaultValue: typeof defaultValue === 'string' ? defaultValue : key })
522
+ }
523
+ }
524
+
525
+ /**
526
+ * Generates simple plural keys, typically for a <Trans> component.
527
+ */
528
+ private handleSimplePluralKeys (key: string, defaultValue: string | undefined, ns: string | undefined): void {
529
+ try {
530
+ const pluralCategories = new Intl.PluralRules(this.config.extract?.primaryLanguage).resolvedOptions().pluralCategories
531
+ const pluralSeparator = this.config.extract.pluralSeparator ?? '_'
477
532
  for (const category of pluralCategories) {
478
533
  this.pluginContext.addKey({
479
534
  key: `${key}${pluralSeparator}${category}`,
480
535
  ns,
481
536
  defaultValue,
482
- hasCount: true
537
+ hasCount: true,
483
538
  })
484
539
  }
485
540
  } catch (e) {
486
- this.logger.warn(`Could not determine plural rules for language "${this.config.extract?.primaryLanguage}". Falling back to simple key extraction.`)
487
- this.pluginContext.addKey({ key, defaultValue, ns })
541
+ this.logger.warn(`Could not determine plural rules for language "${this.config.extract?.primaryLanguage}".`)
542
+ this.pluginContext.addKey({ key, ns, defaultValue })
488
543
  }
489
544
  }
490
545
 
@@ -544,7 +599,7 @@ export class ASTVisitors {
544
599
  this.pluginContext.addKey(extractedKey)
545
600
  }
546
601
  } else if (extractedKey.hasCount) {
547
- this.handlePluralKeys(extractedKey.key, extractedKey.defaultValue, extractedKey.ns)
602
+ this.handleSimplePluralKeys(extractedKey.key, extractedKey.defaultValue, extractedKey.ns)
548
603
  } else {
549
604
  this.pluginContext.addKey(extractedKey)
550
605
  }
@@ -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
  }
package/src/types.ts CHANGED
@@ -83,6 +83,24 @@ export interface I18nextToolkitConfig {
83
83
 
84
84
  /** Secondary languages that get empty values initially */
85
85
  secondaryLanguages?: string[];
86
+
87
+ /**
88
+ * The format of the output translation files.
89
+ * 'json': Standard JSON file (default)
90
+ * 'js': JavaScript file with ES Module syntax (export default)
91
+ * 'js-esm': JavaScript file with ES Module syntax (export default)
92
+ * 'js-cjs': JavaScript file with CommonJS syntax (module.exports)
93
+ * 'ts': TypeScript file with ES Module syntax and `as const` for type safety
94
+ */
95
+ outputFormat?: 'json' | 'js' | 'js-esm' | 'js-esm' | 'js-cjs' | 'ts';
96
+
97
+ /**
98
+ * If true, all namespaces will be merged into a single file per language.
99
+ * The `output` path should not contain the `{{namespace}}` placeholder.
100
+ * Example output: `locales/en.js`
101
+ * (default: false)
102
+ */
103
+ mergeNamespaces?: boolean;
86
104
  };
87
105
 
88
106
  /** Configuration options for TypeScript type generation */