i18next-cli 1.7.1 → 1.9.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.
Files changed (39) hide show
  1. package/CHANGELOG.md +15 -0
  2. package/README.md +13 -1
  3. package/dist/cjs/cli.js +1 -1
  4. package/dist/cjs/extractor/core/translation-manager.js +1 -1
  5. package/dist/cjs/extractor/parsers/ast-visitors.js +1 -1
  6. package/dist/cjs/extractor/parsers/comment-parser.js +1 -1
  7. package/dist/cjs/extractor/parsers/jsx-parser.js +1 -1
  8. package/dist/cjs/extractor/plugin-manager.js +1 -1
  9. package/dist/cjs/syncer.js +1 -1
  10. package/dist/cjs/utils/default-value.js +1 -0
  11. package/dist/esm/cli.js +1 -1
  12. package/dist/esm/extractor/core/translation-manager.js +1 -1
  13. package/dist/esm/extractor/parsers/ast-visitors.js +1 -1
  14. package/dist/esm/extractor/parsers/comment-parser.js +1 -1
  15. package/dist/esm/extractor/parsers/jsx-parser.js +1 -1
  16. package/dist/esm/extractor/plugin-manager.js +1 -1
  17. package/dist/esm/syncer.js +1 -1
  18. package/dist/esm/utils/default-value.js +1 -0
  19. package/package.json +1 -1
  20. package/src/cli.ts +1 -1
  21. package/src/extractor/core/translation-manager.ts +59 -8
  22. package/src/extractor/parsers/ast-visitors.ts +182 -48
  23. package/src/extractor/parsers/comment-parser.ts +43 -6
  24. package/src/extractor/parsers/jsx-parser.ts +14 -1
  25. package/src/extractor/plugin-manager.ts +23 -2
  26. package/src/syncer.ts +5 -2
  27. package/src/types.ts +4 -1
  28. package/src/utils/default-value.ts +44 -0
  29. package/types/extractor/core/translation-manager.d.ts.map +1 -1
  30. package/types/extractor/parsers/ast-visitors.d.ts +13 -3
  31. package/types/extractor/parsers/ast-visitors.d.ts.map +1 -1
  32. package/types/extractor/parsers/comment-parser.d.ts.map +1 -1
  33. package/types/extractor/parsers/jsx-parser.d.ts.map +1 -1
  34. package/types/extractor/plugin-manager.d.ts.map +1 -1
  35. package/types/syncer.d.ts.map +1 -1
  36. package/types/types.d.ts +2 -1
  37. package/types/types.d.ts.map +1 -1
  38. package/types/utils/default-value.d.ts +29 -0
  39. package/types/utils/default-value.d.ts.map +1 -0
@@ -3,6 +3,7 @@ import { resolve, basename, extname } from 'node:path'
3
3
  import { glob } from 'glob'
4
4
  import { getNestedValue, setNestedValue, getNestedKeys } from '../../utils/nested-object'
5
5
  import { getOutputPath, loadTranslationFile } from '../../utils/file-utils'
6
+ import { resolveDefaultValue } from '../../utils/default-value'
6
7
 
7
8
  /**
8
9
  * Converts a glob pattern to a regular expression for matching keys
@@ -52,6 +53,7 @@ function buildNewTranslationsForNs (
52
53
  existingTranslations: Record<string, any>,
53
54
  config: I18nextToolkitConfig,
54
55
  locale: string,
56
+ namespace: string,
55
57
  preservePatterns: RegExp[],
56
58
  objectKeys: Set<string>
57
59
  ): Record<string, any> {
@@ -61,8 +63,49 @@ function buildNewTranslationsForNs (
61
63
  removeUnusedKeys = true,
62
64
  primaryLanguage,
63
65
  defaultValue: emptyDefaultValue = '',
66
+ pluralSeparator = '_',
64
67
  } = config.extract
65
68
 
69
+ // Get the plural categories for the target language
70
+ const targetLanguagePluralCategories = new Set<string>()
71
+ try {
72
+ const cardinalRules = new Intl.PluralRules(locale, { type: 'cardinal' })
73
+ const ordinalRules = new Intl.PluralRules(locale, { type: 'ordinal' })
74
+
75
+ cardinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(cat))
76
+ ordinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(`ordinal_${cat}`))
77
+ } catch (e) {
78
+ // Fallback to English if locale is invalid
79
+ const cardinalRules = new Intl.PluralRules(primaryLanguage || 'en', { type: 'cardinal' })
80
+ const ordinalRules = new Intl.PluralRules(primaryLanguage || 'en', { type: 'ordinal' })
81
+
82
+ cardinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(cat))
83
+ ordinalRules.resolvedOptions().pluralCategories.forEach(cat => targetLanguagePluralCategories.add(`ordinal_${cat}`))
84
+ }
85
+
86
+ // Filter nsKeys to only include keys relevant to this language
87
+ const filteredKeys = nsKeys.filter(({ key, hasCount, isOrdinal }) => {
88
+ if (!hasCount) {
89
+ // Non-plural keys are always included
90
+ return true
91
+ }
92
+
93
+ // For plural keys, check if this specific plural form is needed for the target language
94
+ const keyParts = key.split(pluralSeparator)
95
+
96
+ if (isOrdinal && keyParts.includes('ordinal')) {
97
+ // For ordinal plurals: key_context_ordinal_category or key_ordinal_category
98
+ const lastPart = keyParts[keyParts.length - 1]
99
+ return targetLanguagePluralCategories.has(`ordinal_${lastPart}`)
100
+ } else if (hasCount) {
101
+ // For cardinal plurals: key_context_category or key_category
102
+ const lastPart = keyParts[keyParts.length - 1]
103
+ return targetLanguagePluralCategories.has(lastPart)
104
+ }
105
+
106
+ return true
107
+ })
108
+
66
109
  // If `removeUnusedKeys` is true, start with an empty object. Otherwise, start with a clone of the existing translations.
67
110
  let newTranslations: Record<string, any> = removeUnusedKeys
68
111
  ? {}
@@ -78,9 +121,9 @@ function buildNewTranslationsForNs (
78
121
  }
79
122
 
80
123
  // 1. Build the object first, without any sorting.
81
- for (const { key, defaultValue } of nsKeys) {
124
+ for (const { key, defaultValue } of filteredKeys) {
82
125
  const existingValue = getNestedValue(existingTranslations, key, keySeparator ?? '.')
83
- const isLeafInNewKeys = !nsKeys.some(otherKey => otherKey.key.startsWith(`${key}${keySeparator}`) && otherKey.key !== key)
126
+ const isLeafInNewKeys = !filteredKeys.some(otherKey => otherKey.key.startsWith(`${key}${keySeparator}`) && otherKey.key !== key)
84
127
 
85
128
  // Determine if we should preserve an existing object
86
129
  const shouldPreserveObject = typeof existingValue === 'object' && existingValue !== null && (
@@ -96,9 +139,17 @@ function buildNewTranslationsForNs (
96
139
  continue
97
140
  }
98
141
 
99
- const valueToSet = (existingValue === undefined || isStaleObject)
100
- ? (locale === primaryLanguage ? defaultValue : emptyDefaultValue)
101
- : existingValue
142
+ let valueToSet: string
143
+ if (existingValue === undefined || isStaleObject) {
144
+ if (locale === primaryLanguage) {
145
+ valueToSet = defaultValue || key
146
+ } else {
147
+ // For secondary languages, use the resolved default value
148
+ valueToSet = resolveDefaultValue(emptyDefaultValue, key, namespace, locale)
149
+ }
150
+ } else {
151
+ valueToSet = existingValue
152
+ }
102
153
 
103
154
  setNestedValue(newTranslations, key, valueToSet, keySeparator ?? '.')
104
155
  }
@@ -117,7 +168,7 @@ function buildNewTranslationsForNs (
117
168
  // Create a map of top-level keys to a representative ExtractedKey object.
118
169
  // This is needed for the custom sort function.
119
170
  const keyMap = new Map<string, ExtractedKey>()
120
- for (const ek of nsKeys) {
171
+ for (const ek of filteredKeys) {
121
172
  const topLevelKey = keySeparator === false ? ek.key : ek.key.split(keySeparator as string)[0]
122
173
  if (!keyMap.has(topLevelKey)) {
123
174
  keyMap.set(topLevelKey, ek)
@@ -223,7 +274,7 @@ export async function getTranslations (
223
274
  for (const ns of namespacesToProcess) {
224
275
  const nsKeys = keysByNS.get(ns) || []
225
276
  const existingTranslations = existingMergedFile[ns] || {}
226
- newMergedTranslations[ns] = buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, preservePatterns, objectKeys)
277
+ newMergedTranslations[ns] = buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, ns, preservePatterns, objectKeys)
227
278
  }
228
279
 
229
280
  const oldContent = JSON.stringify(existingMergedFile, null, indentation)
@@ -247,7 +298,7 @@ export async function getTranslations (
247
298
  const outputPath = getOutputPath(config.extract.output, locale, ns)
248
299
  const fullPath = resolve(process.cwd(), outputPath)
249
300
  const existingTranslations = await loadTranslationFile(fullPath) || {}
250
- const newTranslations = buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, preservePatterns, objectKeys)
301
+ const newTranslations = buildNewTranslationsForNs(nsKeys, existingTranslations, config, locale, ns, preservePatterns, objectKeys)
251
302
 
252
303
  const oldContent = JSON.stringify(existingTranslations, null, indentation)
253
304
  const newContent = JSON.stringify(newTranslations, null, indentation)
@@ -1,4 +1,4 @@
1
- import type { Module, Node, CallExpression, VariableDeclarator, JSXElement, ArrowFunctionExpression, ObjectExpression, Expression, TemplateLiteral } from '@swc/core'
1
+ import type { Module, Node, CallExpression, VariableDeclarator, JSXElement, ArrowFunctionExpression, ObjectExpression, Expression, TemplateLiteral, TsType, TsTemplateLiteralType } from '@swc/core'
2
2
  import type { PluginContext, I18nextToolkitConfig, Logger, ExtractedKey, ScopeInfo } from '../../types'
3
3
  import { extractFromTransComponent } from './jsx-parser'
4
4
  import { getObjectProperty, getObjectPropValue } from './ast-utils'
@@ -597,12 +597,12 @@ export class ASTVisitors {
597
597
  // If we have keys with context pluralize them
598
598
  if (keysWithContext.length > 0) {
599
599
  for (const { key, ns } of keysWithContext) {
600
- // Pass the combined ordinal flag to the handler
601
- this.handlePluralKeys(key, ns, options, isOrdinalByOption || isOrdinalByKey)
600
+ // Pass the combined ordinal flag and the default value to the handler
601
+ this.handlePluralKeys(key, ns, options, isOrdinalByOption || isOrdinalByKey, finalDefaultValue)
602
602
  }
603
603
  } else {
604
604
  // Otherwise pluralize the base key
605
- this.handlePluralKeys(finalKey, ns, options, isOrdinalByOption || isOrdinalByKey)
605
+ this.handlePluralKeys(finalKey, ns, options, isOrdinalByOption || isOrdinalByKey, finalDefaultValue)
606
606
  }
607
607
 
608
608
  continue // This key is fully handled
@@ -683,11 +683,27 @@ export class ASTVisitors {
683
683
  *
684
684
  * @private
685
685
  */
686
- private handlePluralKeys (key: string, ns: string | undefined, options: ObjectExpression, isOrdinal: boolean): void {
686
+ private handlePluralKeys (key: string, ns: string | undefined, options: ObjectExpression, isOrdinal: boolean, defaultValueFromCall?: string): void {
687
687
  try {
688
688
  const type = isOrdinal ? 'ordinal' : 'cardinal'
689
689
 
690
- const pluralCategories = new Intl.PluralRules(this.config.extract?.primaryLanguage, { type }).resolvedOptions().pluralCategories
690
+ // Generate plural forms for ALL target languages to ensure we have all necessary keys
691
+ const allPluralCategories = new Set<string>()
692
+
693
+ for (const locale of this.config.locales) {
694
+ try {
695
+ const pluralRules = new Intl.PluralRules(locale, { type })
696
+ const categories = pluralRules.resolvedOptions().pluralCategories
697
+ categories.forEach(cat => allPluralCategories.add(cat))
698
+ } catch (e) {
699
+ // If a locale is invalid, fall back to English rules
700
+ const englishRules = new Intl.PluralRules('en', { type })
701
+ const categories = englishRules.resolvedOptions().pluralCategories
702
+ categories.forEach(cat => allPluralCategories.add(cat))
703
+ }
704
+ }
705
+
706
+ const pluralCategories = Array.from(allPluralCategories).sort()
691
707
  const pluralSeparator = this.config.extract.pluralSeparator ?? '_'
692
708
 
693
709
  // Get all possible default values once at the start
@@ -695,50 +711,91 @@ export class ASTVisitors {
695
711
  const otherDefault = getObjectPropValue(options, `defaultValue${pluralSeparator}other`)
696
712
  const ordinalOtherDefault = getObjectPropValue(options, `defaultValue${pluralSeparator}ordinal${pluralSeparator}other`)
697
713
 
698
- for (const category of pluralCategories) {
699
- // 1. Look for the most specific default value (e.g., defaultValue_ordinal_one)
700
- const specificDefaultKey = isOrdinal ? `defaultValue${pluralSeparator}ordinal${pluralSeparator}${category}` : `defaultValue${pluralSeparator}${category}`
701
- const specificDefault = getObjectPropValue(options, specificDefaultKey)
714
+ // Get the count value and determine target category if available
715
+ const countValue = getObjectPropValue(options, 'count')
716
+ let targetCategory: string | undefined
717
+
718
+ if (typeof countValue === 'number') {
719
+ try {
720
+ const primaryLanguage = this.config.extract?.primaryLanguage || this.config.locales[0] || 'en'
721
+ const pluralRules = new Intl.PluralRules(primaryLanguage, { type })
722
+ targetCategory = pluralRules.select(countValue)
723
+ } catch (e) {
724
+ // If we can't determine the category, continue with normal logic
725
+ }
726
+ }
702
727
 
703
- // 2. Determine the final default value using a clear fallback chain
704
- let finalDefaultValue: string | undefined
705
- if (typeof specificDefault === 'string') {
706
- // 1. Use the most specific default if it exists (e.g., defaultValue_one)
707
- finalDefaultValue = specificDefault
708
- } else if (category === 'one' && typeof defaultValue === 'string') {
709
- // 2. SPECIAL CASE: The 'one' category falls back to the main 'defaultValue' prop
710
- finalDefaultValue = defaultValue
711
- } else if (isOrdinal && typeof ordinalOtherDefault === 'string') {
712
- // 3a. Other ordinal categories fall back to 'defaultValue_ordinal_other'
713
- finalDefaultValue = ordinalOtherDefault
714
- } else if (!isOrdinal && typeof otherDefault === 'string') {
715
- // 3b. Other cardinal categories fall back to 'defaultValue_other'
716
- finalDefaultValue = otherDefault
717
- } else if (typeof defaultValue === 'string') {
718
- // 4. If no '_other' is found, all categories can fall back to the main 'defaultValue'
719
- finalDefaultValue = defaultValue
720
- } else {
721
- // 5. Final fallback to the base key itself
722
- finalDefaultValue = key
728
+ // Check if context is present
729
+ const contextValue = getObjectPropValue(options, 'context')
730
+ const hasContext = typeof contextValue === 'string' && contextValue.length > 0
731
+
732
+ // Determine which key variants to generate
733
+ const keysToGenerate: Array<{ key: string, context?: string }> = []
734
+
735
+ if (hasContext) {
736
+ // Generate keys for the specific context
737
+ keysToGenerate.push({ key, context: contextValue })
738
+
739
+ // Only generate base plural forms if generateBasePluralForms is not disabled
740
+ const shouldGenerateBaseForms = this.config.extract?.generateBasePluralForms !== false
741
+ if (shouldGenerateBaseForms) {
742
+ keysToGenerate.push({ key })
723
743
  }
744
+ } else {
745
+ // No context, always generate base plural forms
746
+ keysToGenerate.push({ key })
747
+ }
724
748
 
725
- // 3. Construct the final plural key
726
- const finalKey = isOrdinal
727
- ? `${key}${pluralSeparator}ordinal${pluralSeparator}${category}`
728
- : `${key}${pluralSeparator}${category}`
749
+ // Generate plural forms for each key variant
750
+ for (const { key: baseKey, context } of keysToGenerate) {
751
+ for (const category of pluralCategories) {
752
+ // 1. Look for the most specific default value
753
+ const specificDefaultKey = isOrdinal ? `defaultValue${pluralSeparator}ordinal${pluralSeparator}${category}` : `defaultValue${pluralSeparator}${category}`
754
+ const specificDefault = getObjectPropValue(options, specificDefaultKey)
755
+
756
+ // 2. Determine the final default value using a clear fallback chain
757
+ let finalDefaultValue: string | undefined
758
+ if (typeof specificDefault === 'string') {
759
+ finalDefaultValue = specificDefault
760
+ } else if (category === 'one' && typeof defaultValue === 'string') {
761
+ finalDefaultValue = defaultValue
762
+ } else if (isOrdinal && typeof ordinalOtherDefault === 'string') {
763
+ finalDefaultValue = ordinalOtherDefault
764
+ } else if (!isOrdinal && typeof otherDefault === 'string') {
765
+ finalDefaultValue = otherDefault
766
+ } else if (typeof defaultValue === 'string') {
767
+ finalDefaultValue = defaultValue
768
+ } else if (defaultValueFromCall && targetCategory === category) {
769
+ finalDefaultValue = defaultValueFromCall
770
+ } else {
771
+ finalDefaultValue = baseKey
772
+ }
729
773
 
730
- this.pluginContext.addKey({
731
- key: finalKey,
732
- ns,
733
- defaultValue: finalDefaultValue,
734
- hasCount: true,
735
- isOrdinal
736
- })
774
+ // 3. Construct the final plural key
775
+ let finalKey: string
776
+ if (context) {
777
+ finalKey = isOrdinal
778
+ ? `${baseKey}${pluralSeparator}${context}${pluralSeparator}ordinal${pluralSeparator}${category}`
779
+ : `${baseKey}${pluralSeparator}${context}${pluralSeparator}${category}`
780
+ } else {
781
+ finalKey = isOrdinal
782
+ ? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
783
+ : `${baseKey}${pluralSeparator}${category}`
784
+ }
785
+
786
+ this.pluginContext.addKey({
787
+ key: finalKey,
788
+ ns,
789
+ defaultValue: finalDefaultValue,
790
+ hasCount: true,
791
+ isOrdinal
792
+ })
793
+ }
737
794
  }
738
795
  } catch (e) {
739
796
  this.logger.warn(`Could not determine plural rules for language "${this.config.extract?.primaryLanguage}". Falling back to simple key extraction.`)
740
797
  // Fallback to a simple key if Intl API fails
741
- const defaultValue = getObjectPropValue(options, 'defaultValue')
798
+ const defaultValue = defaultValueFromCall || getObjectPropValue(options, 'defaultValue')
742
799
  this.pluginContext.addKey({ key, ns, defaultValue: typeof defaultValue === 'string' ? defaultValue : key })
743
800
  }
744
801
  }
@@ -789,7 +846,13 @@ export class ASTVisitors {
789
846
  key = parts.join(nsSeparator)
790
847
  }
791
848
 
792
- return { key, ns, defaultValue: defaultValue || serializedChildren, hasCount, isOrdinal }
849
+ return {
850
+ key,
851
+ ns,
852
+ defaultValue: defaultValue || serializedChildren,
853
+ hasCount,
854
+ isOrdinal,
855
+ }
793
856
  })
794
857
 
795
858
  const tProp = node.opening.attributes?.find(
@@ -818,7 +881,13 @@ export class ASTVisitors {
818
881
  } else {
819
882
  const { ns } = extractedAttributes
820
883
  extractedKeys = keysToProcess.map(key => {
821
- return { key, ns, defaultValue: defaultValue || serializedChildren, hasCount, isOrdinal, }
884
+ return {
885
+ key,
886
+ ns,
887
+ defaultValue: defaultValue || serializedChildren,
888
+ hasCount,
889
+ isOrdinal,
890
+ }
822
891
  })
823
892
  }
824
893
 
@@ -1128,17 +1197,49 @@ export class ASTVisitors {
1128
1197
  return [`${expression.value}`] // Handle literals like 5 or true
1129
1198
  }
1130
1199
 
1200
+ // Support building translation keys for
1201
+ // `variable satisfies 'coaching' | 'therapy'`
1202
+ if (expression.type === 'TsSatisfiesExpression' || expression.type === 'TsAsExpression') {
1203
+ const annotation = expression.typeAnnotation
1204
+
1205
+ return this.resolvePossibleStringValuesFromType(annotation, returnEmptyStrings)
1206
+ }
1207
+
1208
+ // We can't statically determine the value of other expressions (e.g., variables, function calls)
1209
+ return []
1210
+ }
1211
+
1212
+ private resolvePossibleStringValuesFromType (type: TsType, returnEmptyStrings = false): string[] {
1213
+ if (type.type === 'TsUnionType') {
1214
+ return type.types.flatMap((t) => this.resolvePossibleStringValuesFromType(t, returnEmptyStrings))
1215
+ }
1216
+
1217
+ if (type.type === 'TsLiteralType') {
1218
+ if (type.literal.type === 'StringLiteral') {
1219
+ // Filter out empty strings as they should be treated as "no context" like i18next does
1220
+ return type.literal.value || returnEmptyStrings ? [type.literal.value] : []
1221
+ }
1222
+
1223
+ if (type.literal.type === 'TemplateLiteral') {
1224
+ return this.resolvePossibleStringValuesFromTemplateLiteralType(type.literal)
1225
+ }
1226
+
1227
+ if (type.literal.type === 'NumericLiteral' || type.literal.type === 'BooleanLiteral') {
1228
+ return [`${type.literal.value}`] // Handle literals like 5 or true
1229
+ }
1230
+ }
1231
+
1131
1232
  // We can't statically determine the value of other expressions (e.g., variables, function calls)
1132
1233
  return []
1133
1234
  }
1134
1235
 
1135
1236
  /**
1136
- * Resolves a template literal to one or more possible string values that can be
1237
+ * Resolves a template literal string to one or more possible strings that can be
1137
1238
  * determined statically from the AST.
1138
1239
  *
1139
1240
  * @private
1140
- * @param templateString - The SWC AST template literal node to resolve
1141
- * @returns An array of possible string values that the template literal may produce.
1241
+ * @param templateString - The SWC AST template literal string to resolve
1242
+ * @returns An array of possible string values that the template may produce.
1142
1243
  */
1143
1244
  private resolvePossibleStringValuesFromTemplateString (templateString: TemplateLiteral): string[] {
1144
1245
  // If there are no expressions, we can just return the cooked value
@@ -1147,7 +1248,7 @@ export class ASTVisitors {
1147
1248
  return [templateString.quasis[0].cooked || '']
1148
1249
  }
1149
1250
 
1150
- // Ex. `translation.key.with.expression.${x ? 'title' : 'description}`
1251
+ // Ex. `translation.key.with.expression.${x ? 'title' : 'description'}`
1151
1252
  const [firstQuasis, ...tails] = templateString.quasis
1152
1253
 
1153
1254
  const stringValues = templateString.expressions.reduce(
@@ -1165,6 +1266,39 @@ export class ASTVisitors {
1165
1266
  return stringValues
1166
1267
  }
1167
1268
 
1269
+ /**
1270
+ * Resolves a template literal type to one or more possible strings that can be
1271
+ * determined statically from the AST.
1272
+ *
1273
+ * @private
1274
+ * @param templateLiteralType - The SWC AST template literal type to resolve
1275
+ * @returns An array of possible string values that the template may produce.
1276
+ */
1277
+ private resolvePossibleStringValuesFromTemplateLiteralType (templateLiteralType: TsTemplateLiteralType): string[] {
1278
+ // If there are no types, we can just return the cooked value
1279
+ if (templateLiteralType.quasis.length === 1 && templateLiteralType.types.length === 0) {
1280
+ // Ex. `translation.key.no.substitution`
1281
+ return [templateLiteralType.quasis[0].cooked || '']
1282
+ }
1283
+
1284
+ // Ex. `translation.key.with.expression.${'title' | 'description'}`
1285
+ const [firstQuasis, ...tails] = templateLiteralType.quasis
1286
+
1287
+ const stringValues = templateLiteralType.types.reduce(
1288
+ (heads, type, i) => {
1289
+ return heads.flatMap((head) => {
1290
+ const tail = tails[i]?.cooked ?? ''
1291
+ return this.resolvePossibleStringValuesFromType(type, true).map(
1292
+ (expressionValue) => `${head}${expressionValue}${tail}`
1293
+ )
1294
+ })
1295
+ },
1296
+ [firstQuasis.cooked ?? '']
1297
+ )
1298
+
1299
+ return stringValues
1300
+ }
1301
+
1168
1302
  /**
1169
1303
  * Finds the configuration for a given useTranslation function name.
1170
1304
  * Applies default argument positions if none are specified.
@@ -83,9 +83,14 @@ export function extractKeysFromComments (
83
83
 
84
84
  // 5. Handle context and count combinations
85
85
  if (context && count) {
86
- // Generate all combinations: base plural + context+plural
87
- generatePluralKeys(key, defaultValue ?? key, ns, pluginContext, config, isOrdinal)
86
+ // Generate context+plural combinations
88
87
  generateContextPluralKeys(key, defaultValue ?? key, ns, context, pluginContext, config, isOrdinal)
88
+
89
+ // Only generate base plural forms if generateBasePluralForms is not disabled
90
+ const shouldGenerateBaseForms = config.extract?.generateBasePluralForms !== false
91
+ if (shouldGenerateBaseForms) {
92
+ generatePluralKeys(key, defaultValue ?? key, ns, pluginContext, config, isOrdinal)
93
+ }
89
94
  } else if (context) {
90
95
  // Just context variants
91
96
  pluginContext.addKey({ key, ns, defaultValue: defaultValue ?? key })
@@ -114,8 +119,24 @@ function generatePluralKeys (
114
119
  ): void {
115
120
  try {
116
121
  const type = isOrdinal ? 'ordinal' : 'cardinal'
117
- const primaryLanguage = config.extract.primaryLanguage || config.locales[0] || 'en'
118
- const pluralCategories = new Intl.PluralRules(primaryLanguage, { type }).resolvedOptions().pluralCategories
122
+
123
+ // Generate plural forms for ALL target languages to ensure we have all necessary keys
124
+ const allPluralCategories = new Set<string>()
125
+
126
+ for (const locale of config.locales) {
127
+ try {
128
+ const pluralRules = new Intl.PluralRules(locale, { type })
129
+ const categories = pluralRules.resolvedOptions().pluralCategories
130
+ categories.forEach(cat => allPluralCategories.add(cat))
131
+ } catch (e) {
132
+ // If a locale is invalid, fall back to English rules
133
+ const englishRules = new Intl.PluralRules('en', { type })
134
+ const categories = englishRules.resolvedOptions().pluralCategories
135
+ categories.forEach(cat => allPluralCategories.add(cat))
136
+ }
137
+ }
138
+
139
+ const pluralCategories = Array.from(allPluralCategories).sort()
119
140
  const pluralSeparator = config.extract.pluralSeparator ?? '_'
120
141
 
121
142
  // Generate keys for each plural category
@@ -152,8 +173,24 @@ function generateContextPluralKeys (
152
173
  ): void {
153
174
  try {
154
175
  const type = isOrdinal ? 'ordinal' : 'cardinal'
155
- const primaryLanguage = config.extract.primaryLanguage || config.locales[0] || 'en'
156
- const pluralCategories = new Intl.PluralRules(primaryLanguage, { type }).resolvedOptions().pluralCategories
176
+
177
+ // Generate plural forms for ALL target languages to ensure we have all necessary keys
178
+ const allPluralCategories = new Set<string>()
179
+
180
+ for (const locale of config.locales) {
181
+ try {
182
+ const pluralRules = new Intl.PluralRules(locale, { type })
183
+ const categories = pluralRules.resolvedOptions().pluralCategories
184
+ categories.forEach(cat => allPluralCategories.add(cat))
185
+ } catch (e) {
186
+ // If a locale is invalid, fall back to English rules
187
+ const englishRules = new Intl.PluralRules(config.extract.primaryLanguage || 'en', { type })
188
+ const categories = englishRules.resolvedOptions().pluralCategories
189
+ categories.forEach(cat => allPluralCategories.add(cat))
190
+ }
191
+ }
192
+
193
+ const pluralCategories = Array.from(allPluralCategories).sort()
157
194
  const pluralSeparator = config.extract.pluralSeparator ?? '_'
158
195
 
159
196
  // Generate keys for each context + plural combination
@@ -152,9 +152,22 @@ export function extractFromTransComponent (node: JSXElement, config: I18nextTool
152
152
 
153
153
  const serialized = serializeJSXChildren(node.children, config)
154
154
 
155
- let defaultValue = config.extract.defaultValue || ''
155
+ // Handle default value properly
156
+ let defaultValue: string
157
+
156
158
  if (defaultsAttr?.type === 'JSXAttribute' && defaultsAttr.value?.type === 'StringLiteral') {
159
+ // Explicit defaults attribute takes precedence
157
160
  defaultValue = defaultsAttr.value.value
161
+ } else {
162
+ // Use the configured default value or fall back to empty string
163
+ const configuredDefault = config.extract.defaultValue
164
+ if (typeof configuredDefault === 'string') {
165
+ defaultValue = configuredDefault
166
+ } else {
167
+ // For function-based defaults or undefined, use empty string as placeholder
168
+ // The translation manager will handle function resolution with proper context
169
+ defaultValue = ''
170
+ }
158
171
  }
159
172
 
160
173
  let keyExpression: Expression | undefined
@@ -49,9 +49,30 @@ export function createPluginContext (allKeys: Map<string, ExtractedKey>, plugins
49
49
  addKey: (keyInfo: ExtractedKey) => {
50
50
  // Use namespace in the unique map key to avoid collisions across namespaces
51
51
  const uniqueKey = `${keyInfo.ns ?? 'translation'}:${keyInfo.key}`
52
+ const defaultValue = keyInfo.defaultValue ?? keyInfo.key
52
53
 
53
- if (!allKeys.has(uniqueKey)) {
54
- const defaultValue = keyInfo.defaultValue ?? keyInfo.key
54
+ // Check if key already exists
55
+ const existingKey = allKeys.get(uniqueKey)
56
+
57
+ if (existingKey) {
58
+ // Check if existing value is a generic fallback
59
+ // For plural keys, the fallback is often the base key (e.g., "item.count" for "item.count_other")
60
+ // For regular keys, the fallback is the key itself
61
+ const isExistingGenericFallback =
62
+ existingKey.defaultValue === existingKey.key || // Regular key fallback
63
+ (existingKey.hasCount && existingKey.defaultValue &&
64
+ existingKey.key.includes('_') &&
65
+ existingKey.key.startsWith(existingKey.defaultValue)) // Plural key with base key fallback
66
+
67
+ const isNewGenericFallback = defaultValue === keyInfo.key
68
+
69
+ // If existing value is a generic fallback and new value is specific, replace it
70
+ if (isExistingGenericFallback && !isNewGenericFallback) {
71
+ allKeys.set(uniqueKey, { ...keyInfo, defaultValue })
72
+ }
73
+ // Otherwise keep the existing one
74
+ } else {
75
+ // New key, just add it
55
76
  allKeys.set(uniqueKey, { ...keyInfo, defaultValue })
56
77
  }
57
78
  },
package/src/syncer.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { writeFile, mkdir } from 'node:fs/promises'
2
- import { resolve, dirname, basename } from 'path'
2
+ import { resolve, dirname, basename } from 'node:path'
3
3
  import chalk from 'chalk'
4
4
  import ora from 'ora'
5
5
  import { glob } from 'glob'
@@ -7,6 +7,7 @@ import type { I18nextToolkitConfig } from './types'
7
7
  import { getNestedKeys, getNestedValue, setNestedValue } from './utils/nested-object'
8
8
  import { getOutputPath, loadTranslationFile, serializeTranslationFile } from './utils/file-utils'
9
9
  import { shouldShowFunnel, recordFunnelShown } from './utils/funnel-msg-tracker'
10
+ import { resolveDefaultValue } from './utils/default-value'
10
11
 
11
12
  /**
12
13
  * Synchronizes translation files across different locales by ensuring all secondary
@@ -84,7 +85,9 @@ export async function runSyncer (config: I18nextToolkitConfig) {
84
85
 
85
86
  for (const key of primaryKeys) {
86
87
  const existingValue = getNestedValue(existingSecondaryTranslations, key, keySeparator ?? '.')
87
- const valueToSet = existingValue ?? defaultValue
88
+
89
+ // Use the resolved default value if no existing value
90
+ const valueToSet = existingValue ?? resolveDefaultValue(defaultValue, key, ns, lang)
88
91
  setNestedValue(newSecondaryTranslations, key, valueToSet, keySeparator ?? '.')
89
92
  }
90
93
 
package/src/types.ts CHANGED
@@ -88,7 +88,7 @@ export interface I18nextToolkitConfig {
88
88
  indentation?: number | string;
89
89
 
90
90
  /** Default value to use for missing translations in secondary languages */
91
- defaultValue?: string;
91
+ defaultValue?: string | ((key: string, namespace: string, language: string) => string);
92
92
 
93
93
  /** Primary language that provides default values (default: first locale) */
94
94
  primaryLanguage?: string;
@@ -116,6 +116,9 @@ export interface I18nextToolkitConfig {
116
116
 
117
117
  /** If true, keys that are not found in the source code will be removed from translation files. (default: true) */
118
118
  removeUnusedKeys?: boolean;
119
+
120
+ // New option to control whether base plural forms are generated when context is present
121
+ generateBasePluralForms?: boolean
119
122
  };
120
123
 
121
124
  /** Configuration options for TypeScript type generation */
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Resolves the default value for a missing key in secondary languages.
3
+ * Supports both string and function-based default values.
4
+ *
5
+ * @param defaultValue - The configured default value (string or function)
6
+ * @param key - The translation key
7
+ * @param namespace - The namespace for the key
8
+ * @param language - The target language
9
+ * @returns The resolved default value
10
+ *
11
+ * @example
12
+ * ```typescript
13
+ * // String-based default value
14
+ * const result1 = resolveDefaultValue('[MISSING]', 'user.name', 'common', 'de')
15
+ * // Returns: '[MISSING]'
16
+ *
17
+ * // Function-based default value
18
+ * const defaultValueFn = (key, ns, lang) => `${lang.toUpperCase()}_${ns}_${key}`
19
+ * const result2 = resolveDefaultValue(defaultValueFn, 'user.name', 'common', 'de')
20
+ * // Returns: 'DE_common_user.name'
21
+ *
22
+ * // Error handling - function throws
23
+ * const errorFn = () => { throw new Error('Oops') }
24
+ * const result3 = resolveDefaultValue(errorFn, 'user.name', 'common', 'de')
25
+ * // Returns: '' (fallback to empty string)
26
+ * ```
27
+ */
28
+ export function resolveDefaultValue (
29
+ defaultValue: string | ((key: string, namespace: string, language: string) => string) | undefined,
30
+ key: string,
31
+ namespace: string,
32
+ language: string
33
+ ): string {
34
+ if (typeof defaultValue === 'function') {
35
+ try {
36
+ return defaultValue(key, namespace, language)
37
+ } catch (error) {
38
+ // If the function throws an error, fall back to empty string
39
+ return ''
40
+ }
41
+ }
42
+
43
+ return defaultValue || ''
44
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"translation-manager.d.ts","sourceRoot":"","sources":["../../../src/extractor/core/translation-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AAqJnF;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,EAC/B,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,EACvB,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,iBAAiB,EAAE,CAAC,CA8E9B"}
1
+ {"version":3,"file":"translation-manager.d.ts","sourceRoot":"","sources":["../../../src/extractor/core/translation-manager.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,iBAAiB,EAAE,YAAY,EAAE,oBAAoB,EAAE,MAAM,aAAa,CAAA;AAwMnF;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA4BG;AACH,wBAAsB,eAAe,CACnC,IAAI,EAAE,GAAG,CAAC,MAAM,EAAE,YAAY,CAAC,EAC/B,UAAU,EAAE,GAAG,CAAC,MAAM,CAAC,EACvB,MAAM,EAAE,oBAAoB,GAC3B,OAAO,CAAC,iBAAiB,EAAE,CAAC,CA8E9B"}