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.
- package/CHANGELOG.md +15 -0
- package/README.md +13 -1
- package/dist/cjs/cli.js +1 -1
- package/dist/cjs/extractor/core/translation-manager.js +1 -1
- package/dist/cjs/extractor/parsers/ast-visitors.js +1 -1
- package/dist/cjs/extractor/parsers/comment-parser.js +1 -1
- package/dist/cjs/extractor/parsers/jsx-parser.js +1 -1
- package/dist/cjs/extractor/plugin-manager.js +1 -1
- package/dist/cjs/syncer.js +1 -1
- package/dist/cjs/utils/default-value.js +1 -0
- package/dist/esm/cli.js +1 -1
- package/dist/esm/extractor/core/translation-manager.js +1 -1
- package/dist/esm/extractor/parsers/ast-visitors.js +1 -1
- package/dist/esm/extractor/parsers/comment-parser.js +1 -1
- package/dist/esm/extractor/parsers/jsx-parser.js +1 -1
- package/dist/esm/extractor/plugin-manager.js +1 -1
- package/dist/esm/syncer.js +1 -1
- package/dist/esm/utils/default-value.js +1 -0
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/extractor/core/translation-manager.ts +59 -8
- package/src/extractor/parsers/ast-visitors.ts +182 -48
- package/src/extractor/parsers/comment-parser.ts +43 -6
- package/src/extractor/parsers/jsx-parser.ts +14 -1
- package/src/extractor/plugin-manager.ts +23 -2
- package/src/syncer.ts +5 -2
- package/src/types.ts +4 -1
- package/src/utils/default-value.ts +44 -0
- package/types/extractor/core/translation-manager.d.ts.map +1 -1
- package/types/extractor/parsers/ast-visitors.d.ts +13 -3
- package/types/extractor/parsers/ast-visitors.d.ts.map +1 -1
- package/types/extractor/parsers/comment-parser.d.ts.map +1 -1
- package/types/extractor/parsers/jsx-parser.d.ts.map +1 -1
- package/types/extractor/plugin-manager.d.ts.map +1 -1
- package/types/syncer.d.ts.map +1 -1
- package/types/types.d.ts +2 -1
- package/types/types.d.ts.map +1 -1
- package/types/utils/default-value.d.ts +29 -0
- 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
|
|
124
|
+
for (const { key, defaultValue } of filteredKeys) {
|
|
82
125
|
const existingValue = getNestedValue(existingTranslations, key, keySeparator ?? '.')
|
|
83
|
-
const isLeafInNewKeys = !
|
|
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
|
-
|
|
100
|
-
|
|
101
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
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
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
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
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
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
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
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 {
|
|
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 {
|
|
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
|
|
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
|
|
1141
|
-
* @returns An array of possible string values that the template
|
|
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
|
|
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
|
-
|
|
118
|
-
|
|
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
|
-
|
|
156
|
-
|
|
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
|
-
|
|
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
|
|
54
|
-
|
|
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
|
-
|
|
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;
|
|
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"}
|