i18next-cli 1.10.1 → 1.10.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +8 -0
- package/README.md +3 -0
- package/dist/cjs/cli.js +1 -1
- package/dist/cjs/extractor/core/ast-visitors.js +1 -0
- package/dist/cjs/extractor/core/key-finder.js +1 -1
- package/dist/cjs/extractor/core/translation-manager.js +1 -1
- package/dist/cjs/extractor/parsers/call-expression-handler.js +1 -0
- package/dist/cjs/extractor/parsers/expression-resolver.js +1 -0
- package/dist/cjs/extractor/parsers/jsx-handler.js +1 -0
- package/dist/cjs/extractor/parsers/scope-manager.js +1 -0
- package/dist/esm/cli.js +1 -1
- package/dist/esm/extractor/core/ast-visitors.js +1 -0
- package/dist/esm/extractor/core/key-finder.js +1 -1
- package/dist/esm/extractor/core/translation-manager.js +1 -1
- package/dist/esm/extractor/parsers/call-expression-handler.js +1 -0
- package/dist/esm/extractor/parsers/expression-resolver.js +1 -0
- package/dist/esm/extractor/parsers/jsx-handler.js +1 -0
- package/dist/esm/extractor/parsers/scope-manager.js +1 -0
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/extractor/core/ast-visitors.ts +170 -0
- package/src/extractor/core/extractor.ts +1 -1
- package/src/extractor/core/key-finder.ts +2 -2
- package/src/extractor/core/translation-manager.ts +88 -8
- package/src/extractor/index.ts +1 -1
- package/src/extractor/parsers/call-expression-handler.ts +506 -0
- package/src/extractor/parsers/expression-resolver.ts +178 -0
- package/src/extractor/parsers/jsx-handler.ts +358 -0
- package/src/extractor/parsers/scope-manager.ts +327 -0
- package/src/extractor.ts +1 -1
- package/src/types.ts +82 -0
- package/types/extractor/core/ast-visitors.d.ts +75 -0
- package/types/extractor/core/ast-visitors.d.ts.map +1 -0
- package/types/extractor/core/extractor.d.ts +1 -1
- package/types/extractor/core/extractor.d.ts.map +1 -1
- package/types/extractor/core/key-finder.d.ts.map +1 -1
- package/types/extractor/core/translation-manager.d.ts.map +1 -1
- package/types/extractor/index.d.ts +1 -1
- package/types/extractor/index.d.ts.map +1 -1
- package/types/extractor/parsers/call-expression-handler.d.ts +74 -0
- package/types/extractor/parsers/call-expression-handler.d.ts.map +1 -0
- package/types/extractor/parsers/expression-resolver.d.ts +62 -0
- package/types/extractor/parsers/expression-resolver.d.ts.map +1 -0
- package/types/extractor/parsers/jsx-handler.d.ts +44 -0
- package/types/extractor/parsers/jsx-handler.d.ts.map +1 -0
- package/types/extractor/parsers/scope-manager.d.ts +99 -0
- package/types/extractor/parsers/scope-manager.d.ts.map +1 -0
- package/types/extractor.d.ts +1 -1
- package/types/extractor.d.ts.map +1 -1
- package/types/types.d.ts +77 -0
- package/types/types.d.ts.map +1 -1
- package/dist/cjs/extractor/parsers/ast-visitors.js +0 -1
- package/dist/esm/extractor/parsers/ast-visitors.js +0 -1
- package/src/extractor/parsers/ast-visitors.ts +0 -1510
- package/types/extractor/parsers/ast-visitors.d.ts +0 -352
- package/types/extractor/parsers/ast-visitors.d.ts.map +0 -1
|
@@ -0,0 +1,178 @@
|
|
|
1
|
+
import type { Expression, TsType, TemplateLiteral, TsTemplateLiteralType } from '@swc/core'
|
|
2
|
+
import type { ASTVisitorHooks } from '../../types'
|
|
3
|
+
|
|
4
|
+
export class ExpressionResolver {
|
|
5
|
+
private hooks: ASTVisitorHooks
|
|
6
|
+
|
|
7
|
+
constructor (hooks: ASTVisitorHooks) {
|
|
8
|
+
this.hooks = hooks
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Resolves an expression to one or more possible context string values that can be
|
|
13
|
+
* determined statically from the AST. This is a wrapper around the plugin hook
|
|
14
|
+
* `extractContextFromExpression` and {@link resolvePossibleStringValuesFromExpression}.
|
|
15
|
+
*
|
|
16
|
+
* @param expression - The SWC AST expression node to resolve
|
|
17
|
+
* @returns An array of possible context string values that the expression may produce.
|
|
18
|
+
*/
|
|
19
|
+
resolvePossibleContextStringValues (expression: Expression): string[] {
|
|
20
|
+
const strings = this.hooks.resolvePossibleContextStringValues?.(expression) ?? []
|
|
21
|
+
return [...strings, ...this.resolvePossibleStringValuesFromExpression(expression)]
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Resolves an expression to one or more possible key string values that can be
|
|
26
|
+
* determined statically from the AST. This is a wrapper around the plugin hook
|
|
27
|
+
* `extractKeysFromExpression` and {@link resolvePossibleStringValuesFromExpression}.
|
|
28
|
+
*
|
|
29
|
+
* @param expression - The SWC AST expression node to resolve
|
|
30
|
+
* @returns An array of possible key string values that the expression may produce.
|
|
31
|
+
*/
|
|
32
|
+
resolvePossibleKeyStringValues (expression: Expression): string[] {
|
|
33
|
+
const strings = this.hooks.resolvePossibleKeyStringValues?.(expression) ?? []
|
|
34
|
+
return [...strings, ...this.resolvePossibleStringValuesFromExpression(expression)]
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Resolves an expression to one or more possible string values that can be
|
|
39
|
+
* determined statically from the AST.
|
|
40
|
+
*
|
|
41
|
+
* Supports:
|
|
42
|
+
* - StringLiteral -> single value (filtered to exclude empty strings for context)
|
|
43
|
+
* - NumericLiteral -> single value
|
|
44
|
+
* - BooleanLiteral -> single value
|
|
45
|
+
* - ConditionalExpression (ternary) -> union of consequent and alternate resolved values
|
|
46
|
+
* - TemplateLiteral -> union of all possible string values
|
|
47
|
+
* - The identifier `undefined` -> empty array
|
|
48
|
+
*
|
|
49
|
+
* For any other expression types (identifiers, function calls, member expressions,
|
|
50
|
+
* etc.) the value cannot be determined statically and an empty array is returned.
|
|
51
|
+
*
|
|
52
|
+
* @param expression - The SWC AST expression node to resolve
|
|
53
|
+
* @param returnEmptyStrings - Whether to include empty strings in the result
|
|
54
|
+
* @returns An array of possible string values that the expression may produce.
|
|
55
|
+
*/
|
|
56
|
+
private resolvePossibleStringValuesFromExpression (expression: Expression, returnEmptyStrings = false): string[] {
|
|
57
|
+
if (expression.type === 'StringLiteral') {
|
|
58
|
+
// Filter out empty strings as they should be treated as "no context" like i18next does
|
|
59
|
+
return expression.value || returnEmptyStrings ? [expression.value] : []
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (expression.type === 'ConditionalExpression') { // This is a ternary operator
|
|
63
|
+
const consequentValues = this.resolvePossibleStringValuesFromExpression(expression.consequent, returnEmptyStrings)
|
|
64
|
+
const alternateValues = this.resolvePossibleStringValuesFromExpression(expression.alternate, returnEmptyStrings)
|
|
65
|
+
return [...consequentValues, ...alternateValues]
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
if (expression.type === 'Identifier' && expression.value === 'undefined') {
|
|
69
|
+
return [] // Handle the `undefined` case
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
if (expression.type === 'TemplateLiteral') {
|
|
73
|
+
return this.resolvePossibleStringValuesFromTemplateString(expression)
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (expression.type === 'NumericLiteral' || expression.type === 'BooleanLiteral') {
|
|
77
|
+
return [`${expression.value}`] // Handle literals like 5 or true
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Support building translation keys for
|
|
81
|
+
// `variable satisfies 'coaching' | 'therapy'`
|
|
82
|
+
if (expression.type === 'TsSatisfiesExpression' || expression.type === 'TsAsExpression') {
|
|
83
|
+
const annotation = expression.typeAnnotation
|
|
84
|
+
return this.resolvePossibleStringValuesFromType(annotation, returnEmptyStrings)
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
// We can't statically determine the value of other expressions (e.g., variables, function calls)
|
|
88
|
+
return []
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
private resolvePossibleStringValuesFromType (type: TsType, returnEmptyStrings = false): string[] {
|
|
92
|
+
if (type.type === 'TsUnionType') {
|
|
93
|
+
return type.types.flatMap((t) => this.resolvePossibleStringValuesFromType(t, returnEmptyStrings))
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (type.type === 'TsLiteralType') {
|
|
97
|
+
if (type.literal.type === 'StringLiteral') {
|
|
98
|
+
// Filter out empty strings as they should be treated as "no context" like i18next does
|
|
99
|
+
return type.literal.value || returnEmptyStrings ? [type.literal.value] : []
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
if (type.literal.type === 'TemplateLiteral') {
|
|
103
|
+
return this.resolvePossibleStringValuesFromTemplateLiteralType(type.literal)
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
if (type.literal.type === 'NumericLiteral' || type.literal.type === 'BooleanLiteral') {
|
|
107
|
+
return [`${type.literal.value}`] // Handle literals like 5 or true
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
// We can't statically determine the value of other expressions (e.g., variables, function calls)
|
|
112
|
+
return []
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Resolves a template literal string to one or more possible strings that can be
|
|
117
|
+
* determined statically from the AST.
|
|
118
|
+
*
|
|
119
|
+
* @param templateString - The SWC AST template literal string to resolve
|
|
120
|
+
* @returns An array of possible string values that the template may produce.
|
|
121
|
+
*/
|
|
122
|
+
private resolvePossibleStringValuesFromTemplateString (templateString: TemplateLiteral): string[] {
|
|
123
|
+
// If there are no expressions, we can just return the cooked value
|
|
124
|
+
if (templateString.quasis.length === 1 && templateString.expressions.length === 0) {
|
|
125
|
+
// Ex. `translation.key.no.substitution`
|
|
126
|
+
return [templateString.quasis[0].cooked || '']
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// Ex. `translation.key.with.expression.${x ? 'title' : 'description'}`
|
|
130
|
+
const [firstQuasis, ...tails] = templateString.quasis
|
|
131
|
+
|
|
132
|
+
const stringValues = templateString.expressions.reduce(
|
|
133
|
+
(heads, expression, i) => {
|
|
134
|
+
return heads.flatMap((head) => {
|
|
135
|
+
const tail = tails[i]?.cooked ?? ''
|
|
136
|
+
return this.resolvePossibleStringValuesFromExpression(expression, true).map(
|
|
137
|
+
(expressionValue) => `${head}${expressionValue}${tail}`
|
|
138
|
+
)
|
|
139
|
+
})
|
|
140
|
+
},
|
|
141
|
+
[firstQuasis.cooked ?? '']
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return stringValues
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* Resolves a template literal type to one or more possible strings that can be
|
|
149
|
+
* determined statically from the AST.
|
|
150
|
+
*
|
|
151
|
+
* @param templateLiteralType - The SWC AST template literal type to resolve
|
|
152
|
+
* @returns An array of possible string values that the template may produce.
|
|
153
|
+
*/
|
|
154
|
+
private resolvePossibleStringValuesFromTemplateLiteralType (templateLiteralType: TsTemplateLiteralType): string[] {
|
|
155
|
+
// If there are no types, we can just return the cooked value
|
|
156
|
+
if (templateLiteralType.quasis.length === 1 && templateLiteralType.types.length === 0) {
|
|
157
|
+
// Ex. `translation.key.no.substitution`
|
|
158
|
+
return [templateLiteralType.quasis[0].cooked || '']
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
// Ex. `translation.key.with.expression.${'title' | 'description'}`
|
|
162
|
+
const [firstQuasis, ...tails] = templateLiteralType.quasis
|
|
163
|
+
|
|
164
|
+
const stringValues = templateLiteralType.types.reduce(
|
|
165
|
+
(heads, type, i) => {
|
|
166
|
+
return heads.flatMap((head) => {
|
|
167
|
+
const tail = tails[i]?.cooked ?? ''
|
|
168
|
+
return this.resolvePossibleStringValuesFromType(type, true).map(
|
|
169
|
+
(expressionValue) => `${head}${expressionValue}${tail}`
|
|
170
|
+
)
|
|
171
|
+
})
|
|
172
|
+
},
|
|
173
|
+
[firstQuasis.cooked ?? '']
|
|
174
|
+
)
|
|
175
|
+
|
|
176
|
+
return stringValues
|
|
177
|
+
}
|
|
178
|
+
}
|
|
@@ -0,0 +1,358 @@
|
|
|
1
|
+
import type { JSXElement, ObjectExpression } from '@swc/core'
|
|
2
|
+
import type { PluginContext, I18nextToolkitConfig, ExtractedKey } from '../../types'
|
|
3
|
+
import { ExpressionResolver } from './expression-resolver'
|
|
4
|
+
import { extractFromTransComponent } from './jsx-parser'
|
|
5
|
+
import { getObjectPropValue } from './ast-utils'
|
|
6
|
+
|
|
7
|
+
export class JSXHandler {
|
|
8
|
+
private config: Omit<I18nextToolkitConfig, 'plugins'>
|
|
9
|
+
private pluginContext: PluginContext
|
|
10
|
+
private expressionResolver: ExpressionResolver
|
|
11
|
+
|
|
12
|
+
constructor (
|
|
13
|
+
config: Omit<I18nextToolkitConfig, 'plugins'>,
|
|
14
|
+
pluginContext: PluginContext,
|
|
15
|
+
expressionResolver: ExpressionResolver
|
|
16
|
+
) {
|
|
17
|
+
this.config = config
|
|
18
|
+
this.pluginContext = pluginContext
|
|
19
|
+
this.expressionResolver = expressionResolver
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Processes JSX elements to extract translation keys from Trans components.
|
|
24
|
+
*
|
|
25
|
+
* Identifies configured Trans components and delegates to the JSX parser
|
|
26
|
+
* for complex children serialization and attribute extraction.
|
|
27
|
+
*
|
|
28
|
+
* @param node - JSX element node to process
|
|
29
|
+
* @param getScopeInfo - Function to retrieve scope information for variables
|
|
30
|
+
*/
|
|
31
|
+
handleJSXElement (node: JSXElement, getScopeInfo: (name: string) => { defaultNs?: string; keyPrefix?: string } | undefined): void {
|
|
32
|
+
const elementName = this.getElementName(node)
|
|
33
|
+
|
|
34
|
+
if (elementName && (this.config.extract.transComponents || ['Trans']).includes(elementName)) {
|
|
35
|
+
const extractedAttributes = extractFromTransComponent(node, this.config)
|
|
36
|
+
|
|
37
|
+
const keysToProcess: string[] = []
|
|
38
|
+
|
|
39
|
+
if (extractedAttributes) {
|
|
40
|
+
if (extractedAttributes.keyExpression) {
|
|
41
|
+
const keyValues = this.expressionResolver.resolvePossibleKeyStringValues(extractedAttributes.keyExpression)
|
|
42
|
+
keysToProcess.push(...keyValues)
|
|
43
|
+
} else {
|
|
44
|
+
keysToProcess.push(extractedAttributes.serializedChildren)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
let extractedKeys: ExtractedKey[]
|
|
48
|
+
|
|
49
|
+
const { contextExpression, optionsNode, defaultValue, hasCount, isOrdinal, serializedChildren } = extractedAttributes
|
|
50
|
+
|
|
51
|
+
// If ns is not explicitly set on the component, try to find it from the key
|
|
52
|
+
// or the `t` prop
|
|
53
|
+
if (!extractedAttributes.ns) {
|
|
54
|
+
extractedKeys = keysToProcess.map(key => {
|
|
55
|
+
const nsSeparator = this.config.extract.nsSeparator ?? ':'
|
|
56
|
+
let ns: string | undefined
|
|
57
|
+
|
|
58
|
+
// If the key contains a namespace separator, it takes precedence
|
|
59
|
+
// over the default t ns value
|
|
60
|
+
if (nsSeparator && key.includes(nsSeparator)) {
|
|
61
|
+
let parts: string[]
|
|
62
|
+
([ns, ...parts] = key.split(nsSeparator))
|
|
63
|
+
|
|
64
|
+
key = parts.join(nsSeparator)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
return {
|
|
68
|
+
key,
|
|
69
|
+
ns,
|
|
70
|
+
defaultValue: defaultValue || serializedChildren,
|
|
71
|
+
hasCount,
|
|
72
|
+
isOrdinal,
|
|
73
|
+
}
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
const tProp = node.opening.attributes?.find(
|
|
77
|
+
attr =>
|
|
78
|
+
attr.type === 'JSXAttribute' &&
|
|
79
|
+
attr.name.type === 'Identifier' &&
|
|
80
|
+
attr.name.value === 't'
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
// Check if the prop value is an identifier (e.g., t={t})
|
|
84
|
+
if (
|
|
85
|
+
tProp?.type === 'JSXAttribute' &&
|
|
86
|
+
tProp.value?.type === 'JSXExpressionContainer' &&
|
|
87
|
+
tProp.value.expression.type === 'Identifier'
|
|
88
|
+
) {
|
|
89
|
+
const tIdentifier = tProp.value.expression.value
|
|
90
|
+
const scopeInfo = getScopeInfo(tIdentifier)
|
|
91
|
+
if (scopeInfo?.defaultNs) {
|
|
92
|
+
extractedKeys.forEach(key => {
|
|
93
|
+
if (!key.ns) {
|
|
94
|
+
key.ns = scopeInfo.defaultNs
|
|
95
|
+
}
|
|
96
|
+
})
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
} else {
|
|
100
|
+
const { ns } = extractedAttributes
|
|
101
|
+
extractedKeys = keysToProcess.map(key => {
|
|
102
|
+
return {
|
|
103
|
+
key,
|
|
104
|
+
ns,
|
|
105
|
+
defaultValue: defaultValue || serializedChildren,
|
|
106
|
+
hasCount,
|
|
107
|
+
isOrdinal,
|
|
108
|
+
}
|
|
109
|
+
})
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
extractedKeys.forEach(key => {
|
|
113
|
+
// Apply defaultNS from config if no namespace was found on the component and
|
|
114
|
+
// the key does not contain a namespace prefix
|
|
115
|
+
if (!key.ns) {
|
|
116
|
+
key.ns = this.config.extract.defaultNS
|
|
117
|
+
}
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
// Handle the combination of context and count
|
|
121
|
+
if (contextExpression && hasCount) {
|
|
122
|
+
// Check if plurals are disabled
|
|
123
|
+
if (this.config.extract.disablePlurals) {
|
|
124
|
+
// When plurals are disabled, treat count as a regular option
|
|
125
|
+
// Still handle context normally
|
|
126
|
+
const contextValues = this.expressionResolver.resolvePossibleContextStringValues(contextExpression)
|
|
127
|
+
const contextSeparator = this.config.extract.contextSeparator ?? '_'
|
|
128
|
+
|
|
129
|
+
if (contextValues.length > 0) {
|
|
130
|
+
// For static context (string literal), only add context variants
|
|
131
|
+
if (contextExpression.type === 'StringLiteral') {
|
|
132
|
+
for (const context of contextValues) {
|
|
133
|
+
for (const extractedKey of extractedKeys) {
|
|
134
|
+
const contextKey = `${extractedKey.key}${contextSeparator}${context}`
|
|
135
|
+
this.pluginContext.addKey({ key: contextKey, ns: extractedKey.ns, defaultValue: extractedKey.defaultValue })
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
} else {
|
|
139
|
+
// For dynamic context, add both base and context variants
|
|
140
|
+
extractedKeys.forEach(extractedKey => {
|
|
141
|
+
this.pluginContext.addKey({
|
|
142
|
+
key: extractedKey.key,
|
|
143
|
+
ns: extractedKey.ns,
|
|
144
|
+
defaultValue: extractedKey.defaultValue
|
|
145
|
+
})
|
|
146
|
+
})
|
|
147
|
+
for (const context of contextValues) {
|
|
148
|
+
for (const extractedKey of extractedKeys) {
|
|
149
|
+
const contextKey = `${extractedKey.key}${contextSeparator}${context}`
|
|
150
|
+
this.pluginContext.addKey({ key: contextKey, ns: extractedKey.ns, defaultValue: extractedKey.defaultValue })
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
} else {
|
|
155
|
+
// Fallback to just base keys if context resolution fails
|
|
156
|
+
extractedKeys.forEach(extractedKey => {
|
|
157
|
+
this.pluginContext.addKey({
|
|
158
|
+
key: extractedKey.key,
|
|
159
|
+
ns: extractedKey.ns,
|
|
160
|
+
defaultValue: extractedKey.defaultValue
|
|
161
|
+
})
|
|
162
|
+
})
|
|
163
|
+
}
|
|
164
|
+
} else {
|
|
165
|
+
// Original plural handling logic when plurals are enabled
|
|
166
|
+
// Find isOrdinal prop on the <Trans> component
|
|
167
|
+
const ordinalAttr = node.opening.attributes?.find(
|
|
168
|
+
(attr) =>
|
|
169
|
+
attr.type === 'JSXAttribute' &&
|
|
170
|
+
attr.name.type === 'Identifier' &&
|
|
171
|
+
attr.name.value === 'ordinal'
|
|
172
|
+
)
|
|
173
|
+
const isOrdinal = !!ordinalAttr
|
|
174
|
+
|
|
175
|
+
const contextValues = this.expressionResolver.resolvePossibleContextStringValues(contextExpression)
|
|
176
|
+
const contextSeparator = this.config.extract.contextSeparator ?? '_'
|
|
177
|
+
|
|
178
|
+
// Generate all combinations of context and plural forms
|
|
179
|
+
if (contextValues.length > 0) {
|
|
180
|
+
// Generate base plural forms (no context)
|
|
181
|
+
extractedKeys.forEach(extractedKey => this.generatePluralKeysForTrans(extractedKey.key, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode))
|
|
182
|
+
|
|
183
|
+
// Generate context + plural combinations
|
|
184
|
+
for (const context of contextValues) {
|
|
185
|
+
for (const extractedKey of extractedKeys) {
|
|
186
|
+
const contextKey = `${extractedKey.key}${contextSeparator}${context}`
|
|
187
|
+
this.generatePluralKeysForTrans(contextKey, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode)
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
} else {
|
|
191
|
+
// Fallback to just plural forms if context resolution fails
|
|
192
|
+
extractedKeys.forEach(extractedKey => this.generatePluralKeysForTrans(extractedKey.key, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode))
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
} else if (contextExpression) {
|
|
196
|
+
const contextValues = this.expressionResolver.resolvePossibleContextStringValues(contextExpression)
|
|
197
|
+
const contextSeparator = this.config.extract.contextSeparator ?? '_'
|
|
198
|
+
|
|
199
|
+
if (contextValues.length > 0) {
|
|
200
|
+
// Add context variants
|
|
201
|
+
for (const context of contextValues) {
|
|
202
|
+
for (const { key, ns, defaultValue } of extractedKeys) {
|
|
203
|
+
this.pluginContext.addKey({ key: `${key}${contextSeparator}${context}`, ns, defaultValue })
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
// Only add the base key as a fallback if the context is dynamic (i.e., not a simple string).
|
|
207
|
+
if (contextExpression.type !== 'StringLiteral') {
|
|
208
|
+
extractedKeys.forEach(extractedKey => {
|
|
209
|
+
this.pluginContext.addKey({
|
|
210
|
+
key: extractedKey.key,
|
|
211
|
+
ns: extractedKey.ns,
|
|
212
|
+
defaultValue: extractedKey.defaultValue
|
|
213
|
+
})
|
|
214
|
+
})
|
|
215
|
+
}
|
|
216
|
+
} else {
|
|
217
|
+
// If no context values were resolved, just add base keys
|
|
218
|
+
extractedKeys.forEach(extractedKey => {
|
|
219
|
+
this.pluginContext.addKey({
|
|
220
|
+
key: extractedKey.key,
|
|
221
|
+
ns: extractedKey.ns,
|
|
222
|
+
defaultValue: extractedKey.defaultValue
|
|
223
|
+
})
|
|
224
|
+
})
|
|
225
|
+
}
|
|
226
|
+
} else if (hasCount) {
|
|
227
|
+
// Check if plurals are disabled
|
|
228
|
+
if (this.config.extract.disablePlurals) {
|
|
229
|
+
// When plurals are disabled, just add the base keys (no plural forms)
|
|
230
|
+
extractedKeys.forEach(extractedKey => {
|
|
231
|
+
this.pluginContext.addKey({
|
|
232
|
+
key: extractedKey.key,
|
|
233
|
+
ns: extractedKey.ns,
|
|
234
|
+
defaultValue: extractedKey.defaultValue
|
|
235
|
+
})
|
|
236
|
+
})
|
|
237
|
+
} else {
|
|
238
|
+
// Original plural handling logic when plurals are enabled
|
|
239
|
+
// Find isOrdinal prop on the <Trans> component
|
|
240
|
+
const ordinalAttr = node.opening.attributes?.find(
|
|
241
|
+
(attr) =>
|
|
242
|
+
attr.type === 'JSXAttribute' &&
|
|
243
|
+
attr.name.type === 'Identifier' &&
|
|
244
|
+
attr.name.value === 'ordinal'
|
|
245
|
+
)
|
|
246
|
+
const isOrdinal = !!ordinalAttr
|
|
247
|
+
|
|
248
|
+
extractedKeys.forEach(extractedKey => this.generatePluralKeysForTrans(extractedKey.key, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode))
|
|
249
|
+
}
|
|
250
|
+
} else {
|
|
251
|
+
// No count or context - just add the base keys
|
|
252
|
+
extractedKeys.forEach(extractedKey => {
|
|
253
|
+
this.pluginContext.addKey({
|
|
254
|
+
key: extractedKey.key,
|
|
255
|
+
ns: extractedKey.ns,
|
|
256
|
+
defaultValue: extractedKey.defaultValue
|
|
257
|
+
})
|
|
258
|
+
})
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
/**
|
|
265
|
+
* Generates plural keys for Trans components, with support for tOptions plural defaults.
|
|
266
|
+
*
|
|
267
|
+
* @param key - Base key name for pluralization
|
|
268
|
+
* @param defaultValue - Default value for the keys
|
|
269
|
+
* @param ns - Namespace for the keys
|
|
270
|
+
* @param isOrdinal - Whether to generate ordinal plural forms
|
|
271
|
+
* @param optionsNode - Optional tOptions object expression for plural-specific defaults
|
|
272
|
+
*/
|
|
273
|
+
private generatePluralKeysForTrans (key: string, defaultValue: string | undefined, ns: string | undefined, isOrdinal: boolean, optionsNode?: ObjectExpression): void {
|
|
274
|
+
try {
|
|
275
|
+
const type = isOrdinal ? 'ordinal' : 'cardinal'
|
|
276
|
+
const pluralCategories = new Intl.PluralRules(this.config.extract?.primaryLanguage, { type }).resolvedOptions().pluralCategories
|
|
277
|
+
const pluralSeparator = this.config.extract.pluralSeparator ?? '_'
|
|
278
|
+
|
|
279
|
+
// Get plural-specific default values from tOptions if available
|
|
280
|
+
let otherDefault: string | undefined
|
|
281
|
+
let ordinalOtherDefault: string | undefined
|
|
282
|
+
|
|
283
|
+
if (optionsNode) {
|
|
284
|
+
otherDefault = getObjectPropValue(optionsNode, `defaultValue${pluralSeparator}other`) as string | undefined
|
|
285
|
+
ordinalOtherDefault = getObjectPropValue(optionsNode, `defaultValue${pluralSeparator}ordinal${pluralSeparator}other`) as string | undefined
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
for (const category of pluralCategories) {
|
|
289
|
+
// Look for the most specific default value (e.g., defaultValue_ordinal_one)
|
|
290
|
+
const specificDefaultKey = isOrdinal ? `defaultValue${pluralSeparator}ordinal${pluralSeparator}${category}` : `defaultValue${pluralSeparator}${category}`
|
|
291
|
+
const specificDefault = optionsNode ? getObjectPropValue(optionsNode, specificDefaultKey) as string | undefined : undefined
|
|
292
|
+
|
|
293
|
+
// Determine the final default value using a clear fallback chain
|
|
294
|
+
let finalDefaultValue: string | undefined
|
|
295
|
+
if (typeof specificDefault === 'string') {
|
|
296
|
+
// 1. Use the most specific default if it exists (e.g., defaultValue_one)
|
|
297
|
+
finalDefaultValue = specificDefault
|
|
298
|
+
} else if (category === 'one' && typeof defaultValue === 'string') {
|
|
299
|
+
// 2. SPECIAL CASE: The 'one' category falls back to the main default value (children content)
|
|
300
|
+
finalDefaultValue = defaultValue
|
|
301
|
+
} else if (isOrdinal && typeof ordinalOtherDefault === 'string') {
|
|
302
|
+
// 3a. Other ordinal categories fall back to 'defaultValue_ordinal_other'
|
|
303
|
+
finalDefaultValue = ordinalOtherDefault
|
|
304
|
+
} else if (!isOrdinal && typeof otherDefault === 'string') {
|
|
305
|
+
// 3b. Other cardinal categories fall back to 'defaultValue_other'
|
|
306
|
+
finalDefaultValue = otherDefault
|
|
307
|
+
} else if (typeof defaultValue === 'string') {
|
|
308
|
+
// 4. If no '_other' is found, all categories can fall back to the main default value
|
|
309
|
+
finalDefaultValue = defaultValue
|
|
310
|
+
} else {
|
|
311
|
+
// 5. Final fallback to the base key itself
|
|
312
|
+
finalDefaultValue = key
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
const finalKey = isOrdinal
|
|
316
|
+
? `${key}${pluralSeparator}ordinal${pluralSeparator}${category}`
|
|
317
|
+
: `${key}${pluralSeparator}${category}`
|
|
318
|
+
|
|
319
|
+
this.pluginContext.addKey({
|
|
320
|
+
key: finalKey,
|
|
321
|
+
ns,
|
|
322
|
+
defaultValue: finalDefaultValue,
|
|
323
|
+
hasCount: true,
|
|
324
|
+
isOrdinal
|
|
325
|
+
})
|
|
326
|
+
}
|
|
327
|
+
} catch (e) {
|
|
328
|
+
// Fallback to a simple key if Intl API fails
|
|
329
|
+
this.pluginContext.addKey({ key, ns, defaultValue })
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Extracts element name from JSX opening tag.
|
|
335
|
+
*
|
|
336
|
+
* Handles both simple identifiers and member expressions:
|
|
337
|
+
* - `<Trans>` → 'Trans'
|
|
338
|
+
* - `<React.Trans>` → 'React.Trans'
|
|
339
|
+
*
|
|
340
|
+
* @param node - JSX element node
|
|
341
|
+
* @returns Element name or undefined if not extractable
|
|
342
|
+
*/
|
|
343
|
+
private getElementName (node: JSXElement): string | undefined {
|
|
344
|
+
if (node.opening.name.type === 'Identifier') {
|
|
345
|
+
return node.opening.name.value
|
|
346
|
+
} else if (node.opening.name.type === 'JSXMemberExpression') {
|
|
347
|
+
let curr: any = node.opening.name
|
|
348
|
+
const names: string[] = []
|
|
349
|
+
while (curr.type === 'JSXMemberExpression') {
|
|
350
|
+
if (curr.property.type === 'Identifier') names.unshift(curr.property.value)
|
|
351
|
+
curr = curr.object
|
|
352
|
+
}
|
|
353
|
+
if (curr.type === 'Identifier') names.unshift(curr.value)
|
|
354
|
+
return names.join('.')
|
|
355
|
+
}
|
|
356
|
+
return undefined
|
|
357
|
+
}
|
|
358
|
+
}
|