i18next-cli 1.10.0 → 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 +12 -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/comment-parser.js +1 -1
- 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/comment-parser.js +1 -1
- 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 +93 -8
- package/src/extractor/index.ts +1 -1
- package/src/extractor/parsers/call-expression-handler.ts +506 -0
- package/src/extractor/parsers/comment-parser.ts +38 -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/comment-parser.d.ts.map +1 -1
- 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
|
@@ -19,26 +19,79 @@ function globToRegex (glob: string): RegExp {
|
|
|
19
19
|
/**
|
|
20
20
|
* Recursively sorts the keys of an object.
|
|
21
21
|
*/
|
|
22
|
-
function sortObject (obj: any): any {
|
|
22
|
+
function sortObject (obj: any, config?: I18nextToolkitConfig): any {
|
|
23
23
|
if (typeof obj !== 'object' || obj === null || Array.isArray(obj)) {
|
|
24
24
|
return obj
|
|
25
25
|
}
|
|
26
26
|
|
|
27
27
|
const sortedObj: Record<string, any> = {}
|
|
28
|
+
const pluralSeparator = config?.extract?.pluralSeparator ?? '_'
|
|
29
|
+
|
|
30
|
+
// Define the canonical order for plural forms
|
|
31
|
+
const pluralOrder = ['zero', 'one', 'two', 'few', 'many', 'other']
|
|
32
|
+
const ordinalPluralOrder = pluralOrder.map(form => `ordinal_${form}`)
|
|
33
|
+
|
|
28
34
|
const keys = Object.keys(obj).sort((a, b) => {
|
|
29
|
-
//
|
|
30
|
-
const
|
|
35
|
+
// Helper function to extract base key and form info
|
|
36
|
+
const getKeyInfo = (key: string) => {
|
|
37
|
+
// Handle ordinal plurals: key_ordinal_form or key_context_ordinal_form
|
|
38
|
+
for (const form of ordinalPluralOrder) {
|
|
39
|
+
if (key.endsWith(`${pluralSeparator}${form}`)) {
|
|
40
|
+
const base = key.slice(0, -(pluralSeparator.length + form.length))
|
|
41
|
+
return { base, form, isOrdinal: true, isPlural: true, fullKey: key }
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
// Handle cardinal plurals: key_form or key_context_form
|
|
45
|
+
for (const form of pluralOrder) {
|
|
46
|
+
if (key.endsWith(`${pluralSeparator}${form}`)) {
|
|
47
|
+
const base = key.slice(0, -(pluralSeparator.length + form.length))
|
|
48
|
+
return { base, form, isOrdinal: false, isPlural: true, fullKey: key }
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
return { base: key, form: '', isOrdinal: false, isPlural: false, fullKey: key }
|
|
52
|
+
}
|
|
31
53
|
|
|
32
|
-
|
|
54
|
+
const aInfo = getKeyInfo(a)
|
|
55
|
+
const bInfo = getKeyInfo(b)
|
|
56
|
+
|
|
57
|
+
// If both are plural forms
|
|
58
|
+
if (aInfo.isPlural && bInfo.isPlural) {
|
|
59
|
+
// First compare by base key (alphabetically)
|
|
60
|
+
const baseComparison = aInfo.base.localeCompare(bInfo.base, undefined, { sensitivity: 'base' })
|
|
61
|
+
if (baseComparison !== 0) {
|
|
62
|
+
return baseComparison
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
// Same base key - now sort by plural form order
|
|
66
|
+
// Ordinal forms come after cardinal forms
|
|
67
|
+
if (aInfo.isOrdinal !== bInfo.isOrdinal) {
|
|
68
|
+
return aInfo.isOrdinal ? 1 : -1
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
// Both same type (cardinal or ordinal), sort by canonical order
|
|
72
|
+
const orderArray = aInfo.isOrdinal ? ordinalPluralOrder : pluralOrder
|
|
73
|
+
const aIndex = orderArray.indexOf(aInfo.form)
|
|
74
|
+
const bIndex = orderArray.indexOf(bInfo.form)
|
|
75
|
+
|
|
76
|
+
if (aIndex !== -1 && bIndex !== -1) {
|
|
77
|
+
return aIndex - bIndex
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
// Fallback to alphabetical if forms not found in order array
|
|
81
|
+
return aInfo.form.localeCompare(bInfo.form)
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// If one is plural and one is not, or both are non-plural
|
|
85
|
+
// Regular alphabetical sorting (case-insensitive, then by case)
|
|
86
|
+
const caseInsensitiveComparison = a.localeCompare(b, undefined, { sensitivity: 'base' })
|
|
33
87
|
if (caseInsensitiveComparison === 0) {
|
|
34
88
|
return a.localeCompare(b, undefined, { sensitivity: 'case' })
|
|
35
89
|
}
|
|
36
|
-
|
|
37
90
|
return caseInsensitiveComparison
|
|
38
91
|
})
|
|
39
92
|
|
|
40
93
|
for (const key of keys) {
|
|
41
|
-
sortedObj[key] = sortObject(obj[key])
|
|
94
|
+
sortedObj[key] = sortObject(obj[key], config)
|
|
42
95
|
}
|
|
43
96
|
|
|
44
97
|
return sortedObj
|
|
@@ -85,6 +138,11 @@ function buildNewTranslationsForNs (
|
|
|
85
138
|
|
|
86
139
|
// Filter nsKeys to only include keys relevant to this language
|
|
87
140
|
const filteredKeys = nsKeys.filter(({ key, hasCount, isOrdinal }) => {
|
|
141
|
+
// FIRST: Check if key matches preservePatterns and should be excluded
|
|
142
|
+
if (preservePatterns.some(re => re.test(key))) {
|
|
143
|
+
return false // Skip keys that match preserve patterns
|
|
144
|
+
}
|
|
145
|
+
|
|
88
146
|
if (!hasCount) {
|
|
89
147
|
// Non-plural keys are always included
|
|
90
148
|
return true
|
|
@@ -120,6 +178,33 @@ function buildNewTranslationsForNs (
|
|
|
120
178
|
}
|
|
121
179
|
}
|
|
122
180
|
|
|
181
|
+
// SPECIAL HANDLING: Preserve existing _zero forms even if not in extracted keys
|
|
182
|
+
// This ensures that optional _zero forms are not removed when they exist
|
|
183
|
+
if (removeUnusedKeys) {
|
|
184
|
+
const existingKeys = getNestedKeys(existingTranslations, keySeparator ?? '.')
|
|
185
|
+
for (const existingKey of existingKeys) {
|
|
186
|
+
// Check if this is a _zero form that should be preserved
|
|
187
|
+
const keyParts = existingKey.split(pluralSeparator)
|
|
188
|
+
const lastPart = keyParts[keyParts.length - 1]
|
|
189
|
+
|
|
190
|
+
if (lastPart === 'zero') {
|
|
191
|
+
// Check if the base plural key exists in our extracted keys
|
|
192
|
+
const baseKey = keyParts.slice(0, -1).join(pluralSeparator)
|
|
193
|
+
const hasBaseInExtracted = filteredKeys.some(({ key }) => {
|
|
194
|
+
const extractedParts = key.split(pluralSeparator)
|
|
195
|
+
const extractedBase = extractedParts.slice(0, -1).join(pluralSeparator)
|
|
196
|
+
return extractedBase === baseKey
|
|
197
|
+
})
|
|
198
|
+
|
|
199
|
+
if (hasBaseInExtracted) {
|
|
200
|
+
// Preserve the existing _zero form
|
|
201
|
+
const value = getNestedValue(existingTranslations, existingKey, keySeparator ?? '.')
|
|
202
|
+
setNestedValue(newTranslations, existingKey, value, keySeparator ?? '.')
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
|
|
123
208
|
// 1. Build the object first, without any sorting.
|
|
124
209
|
for (const { key, defaultValue } of filteredKeys) {
|
|
125
210
|
const existingValue = getNestedValue(existingTranslations, key, keySeparator ?? '.')
|
|
@@ -157,7 +242,7 @@ function buildNewTranslationsForNs (
|
|
|
157
242
|
// 2. If sorting is enabled, recursively sort the entire object.
|
|
158
243
|
// This correctly handles both top-level and nested keys.
|
|
159
244
|
if (sort === true) {
|
|
160
|
-
return sortObject(newTranslations)
|
|
245
|
+
return sortObject(newTranslations, config)
|
|
161
246
|
}
|
|
162
247
|
// Custom sort function logic remains as a future enhancement if needed,
|
|
163
248
|
// but for now, this robustly handles the most common `sort: true` case.
|
|
@@ -190,7 +275,7 @@ function buildNewTranslationsForNs (
|
|
|
190
275
|
|
|
191
276
|
// 3. Rebuild the object in the final sorted order.
|
|
192
277
|
for (const key of topLevelKeys) {
|
|
193
|
-
sortedObject[key] = newTranslations[key]
|
|
278
|
+
sortedObject[key] = sortObject(newTranslations[key], config)
|
|
194
279
|
}
|
|
195
280
|
newTranslations = sortedObject
|
|
196
281
|
}
|
package/src/extractor/index.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
export * from './core/extractor'
|
|
2
2
|
export * from './core/key-finder'
|
|
3
3
|
export * from './core/translation-manager'
|
|
4
|
-
export * from './
|
|
4
|
+
export * from './core/ast-visitors'
|
|
5
5
|
export * from './parsers/comment-parser'
|
|
6
6
|
export * from './parsers/jsx-parser'
|
|
7
7
|
export * from './plugin-manager'
|
|
@@ -0,0 +1,506 @@
|
|
|
1
|
+
import type { CallExpression, ArrowFunctionExpression, ObjectExpression } from '@swc/core'
|
|
2
|
+
import type { PluginContext, I18nextToolkitConfig, Logger, ExtractedKey, ScopeInfo } from '../../types'
|
|
3
|
+
import { ExpressionResolver } from './expression-resolver'
|
|
4
|
+
import { getObjectProperty, getObjectPropValue } from './ast-utils'
|
|
5
|
+
|
|
6
|
+
export class CallExpressionHandler {
|
|
7
|
+
private pluginContext: PluginContext
|
|
8
|
+
private config: Omit<I18nextToolkitConfig, 'plugins'>
|
|
9
|
+
private logger: Logger
|
|
10
|
+
private expressionResolver: ExpressionResolver
|
|
11
|
+
public objectKeys = new Set<string>()
|
|
12
|
+
|
|
13
|
+
constructor (
|
|
14
|
+
config: Omit<I18nextToolkitConfig, 'plugins'>,
|
|
15
|
+
pluginContext: PluginContext,
|
|
16
|
+
logger: Logger,
|
|
17
|
+
expressionResolver: ExpressionResolver
|
|
18
|
+
) {
|
|
19
|
+
this.config = config
|
|
20
|
+
this.pluginContext = pluginContext
|
|
21
|
+
this.logger = logger
|
|
22
|
+
this.expressionResolver = expressionResolver
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* Processes function call expressions to extract translation keys.
|
|
27
|
+
*
|
|
28
|
+
* This is the core extraction method that handles:
|
|
29
|
+
* - Standard t() calls with string literals
|
|
30
|
+
* - Selector API calls with arrow functions: `t($ => $.path.to.key)`
|
|
31
|
+
* - Namespace resolution from multiple sources
|
|
32
|
+
* - Default value extraction from various argument patterns
|
|
33
|
+
* - Pluralization and context handling
|
|
34
|
+
* - Key prefix application from scope
|
|
35
|
+
*
|
|
36
|
+
* @param node - Call expression node to process
|
|
37
|
+
* @param getScopeInfo - Function to retrieve scope information for variables
|
|
38
|
+
*/
|
|
39
|
+
handleCallExpression (node: CallExpression, getScopeInfo: (name: string) => ScopeInfo | undefined): void {
|
|
40
|
+
const functionName = this.getFunctionName(node.callee)
|
|
41
|
+
if (!functionName) return
|
|
42
|
+
|
|
43
|
+
// The scope lookup will only work for simple identifiers, which is okay for this fix.
|
|
44
|
+
const scopeInfo = getScopeInfo(functionName)
|
|
45
|
+
const configuredFunctions = this.config.extract.functions || ['t', '*.t']
|
|
46
|
+
let isFunctionToParse = scopeInfo !== undefined // A scoped variable (from useTranslation, etc.) is always parsed.
|
|
47
|
+
if (!isFunctionToParse) {
|
|
48
|
+
for (const pattern of configuredFunctions) {
|
|
49
|
+
if (pattern.startsWith('*.')) {
|
|
50
|
+
// Handle wildcard suffix (e.g., '*.t' matches 'i18n.t')
|
|
51
|
+
if (functionName.endsWith(pattern.substring(1))) {
|
|
52
|
+
isFunctionToParse = true
|
|
53
|
+
break
|
|
54
|
+
}
|
|
55
|
+
} else {
|
|
56
|
+
// Handle exact match
|
|
57
|
+
if (pattern === functionName) {
|
|
58
|
+
isFunctionToParse = true
|
|
59
|
+
break
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
if (!isFunctionToParse || node.arguments.length === 0) return
|
|
65
|
+
|
|
66
|
+
const { keysToProcess, isSelectorAPI } = this.handleCallExpressionArgument(node, 0)
|
|
67
|
+
|
|
68
|
+
if (keysToProcess.length === 0) return
|
|
69
|
+
|
|
70
|
+
let isOrdinalByKey = false
|
|
71
|
+
const pluralSeparator = this.config.extract.pluralSeparator ?? '_'
|
|
72
|
+
|
|
73
|
+
for (let i = 0; i < keysToProcess.length; i++) {
|
|
74
|
+
if (keysToProcess[i].endsWith(`${pluralSeparator}ordinal`)) {
|
|
75
|
+
isOrdinalByKey = true
|
|
76
|
+
// Normalize the key by stripping the suffix
|
|
77
|
+
keysToProcess[i] = keysToProcess[i].slice(0, -8)
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
let defaultValue: string | undefined
|
|
82
|
+
let options: ObjectExpression | undefined
|
|
83
|
+
|
|
84
|
+
if (node.arguments.length > 1) {
|
|
85
|
+
const arg2 = node.arguments[1].expression
|
|
86
|
+
if (arg2.type === 'ObjectExpression') {
|
|
87
|
+
options = arg2
|
|
88
|
+
} else if (arg2.type === 'StringLiteral') {
|
|
89
|
+
defaultValue = arg2.value
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
if (node.arguments.length > 2) {
|
|
93
|
+
const arg3 = node.arguments[2].expression
|
|
94
|
+
if (arg3.type === 'ObjectExpression') {
|
|
95
|
+
options = arg3
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
const defaultValueFromOptions = options ? getObjectPropValue(options, 'defaultValue') : undefined
|
|
99
|
+
const finalDefaultValue = (typeof defaultValueFromOptions === 'string' ? defaultValueFromOptions : defaultValue)
|
|
100
|
+
|
|
101
|
+
// Loop through each key found (could be one or more) and process it
|
|
102
|
+
for (let i = 0; i < keysToProcess.length; i++) {
|
|
103
|
+
let key = keysToProcess[i]
|
|
104
|
+
let ns: string | undefined
|
|
105
|
+
|
|
106
|
+
// Determine namespace (explicit ns > ns:key > scope ns > default)
|
|
107
|
+
// See https://www.i18next.com/overview/api#getfixedt
|
|
108
|
+
if (options) {
|
|
109
|
+
const nsVal = getObjectPropValue(options, 'ns')
|
|
110
|
+
if (typeof nsVal === 'string') ns = nsVal
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
const nsSeparator = this.config.extract.nsSeparator ?? ':'
|
|
114
|
+
if (!ns && nsSeparator && key.includes(nsSeparator)) {
|
|
115
|
+
const parts = key.split(nsSeparator)
|
|
116
|
+
ns = parts.shift()
|
|
117
|
+
key = parts.join(nsSeparator)
|
|
118
|
+
|
|
119
|
+
if (!key || key.trim() === '') {
|
|
120
|
+
this.logger.warn(`Skipping key that became empty after namespace removal: '${ns}${nsSeparator}'`)
|
|
121
|
+
continue
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
if (!ns && scopeInfo?.defaultNs) ns = scopeInfo.defaultNs
|
|
126
|
+
if (!ns) ns = this.config.extract.defaultNS
|
|
127
|
+
|
|
128
|
+
let finalKey = key
|
|
129
|
+
|
|
130
|
+
// Apply keyPrefix AFTER namespace extraction
|
|
131
|
+
if (scopeInfo?.keyPrefix) {
|
|
132
|
+
const keySeparator = this.config.extract.keySeparator ?? '.'
|
|
133
|
+
|
|
134
|
+
// Apply keyPrefix - handle case where keyPrefix already ends with separator
|
|
135
|
+
if (keySeparator !== false) {
|
|
136
|
+
if (scopeInfo.keyPrefix.endsWith(keySeparator)) {
|
|
137
|
+
finalKey = `${scopeInfo.keyPrefix}${key}`
|
|
138
|
+
} else {
|
|
139
|
+
finalKey = `${scopeInfo.keyPrefix}${keySeparator}${key}`
|
|
140
|
+
}
|
|
141
|
+
} else {
|
|
142
|
+
finalKey = `${scopeInfo.keyPrefix}${key}`
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
// Validate keyPrefix combinations that create problematic keys
|
|
146
|
+
if (keySeparator !== false) {
|
|
147
|
+
// Check for patterns that would create empty segments in the nested key structure
|
|
148
|
+
const segments = finalKey.split(keySeparator)
|
|
149
|
+
const hasEmptySegment = segments.some(segment => segment.trim() === '')
|
|
150
|
+
|
|
151
|
+
if (hasEmptySegment) {
|
|
152
|
+
this.logger.warn(`Skipping key with empty segments: '${finalKey}' (keyPrefix: '${scopeInfo.keyPrefix}', key: '${key}')`)
|
|
153
|
+
continue
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const isLastKey = i === keysToProcess.length - 1
|
|
159
|
+
const dv = isLastKey ? (finalDefaultValue || key) : key
|
|
160
|
+
|
|
161
|
+
// Handle plurals, context, and returnObjects
|
|
162
|
+
if (options) {
|
|
163
|
+
const contextProp = getObjectProperty(options, 'context')
|
|
164
|
+
|
|
165
|
+
const keysWithContext: ExtractedKey[] = []
|
|
166
|
+
|
|
167
|
+
// 1. Handle Context
|
|
168
|
+
if (contextProp?.value?.type === 'StringLiteral' || contextProp?.value.type === 'NumericLiteral' || contextProp?.value.type === 'BooleanLiteral') {
|
|
169
|
+
// If the context is static, we don't need to add the base key
|
|
170
|
+
const contextValue = `${contextProp.value.value}`
|
|
171
|
+
|
|
172
|
+
const contextSeparator = this.config.extract.contextSeparator ?? '_'
|
|
173
|
+
// Ignore context: ''
|
|
174
|
+
if (contextValue !== '') {
|
|
175
|
+
keysWithContext.push({ key: `${finalKey}${contextSeparator}${contextValue}`, ns, defaultValue: dv })
|
|
176
|
+
}
|
|
177
|
+
} else if (contextProp?.value) {
|
|
178
|
+
const contextValues = this.expressionResolver.resolvePossibleContextStringValues(contextProp.value)
|
|
179
|
+
const contextSeparator = this.config.extract.contextSeparator ?? '_'
|
|
180
|
+
|
|
181
|
+
if (contextValues.length > 0) {
|
|
182
|
+
contextValues.forEach(context => {
|
|
183
|
+
keysWithContext.push({ key: `${finalKey}${contextSeparator}${context}`, ns, defaultValue: dv })
|
|
184
|
+
})
|
|
185
|
+
// For dynamic context, also add the base key as a fallback
|
|
186
|
+
keysWithContext.push({ key: finalKey, ns, defaultValue: dv })
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
// 2. Handle Plurals
|
|
191
|
+
const hasCount = getObjectPropValue(options, 'count') !== undefined
|
|
192
|
+
const isOrdinalByOption = getObjectPropValue(options, 'ordinal') === true
|
|
193
|
+
if (hasCount || isOrdinalByKey) {
|
|
194
|
+
// Check if plurals are disabled
|
|
195
|
+
if (this.config.extract.disablePlurals) {
|
|
196
|
+
// When plurals are disabled, treat count as a regular option (for interpolation only)
|
|
197
|
+
// Still handle context normally
|
|
198
|
+
if (keysWithContext.length > 0) {
|
|
199
|
+
keysWithContext.forEach(this.pluginContext.addKey)
|
|
200
|
+
} else {
|
|
201
|
+
this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv })
|
|
202
|
+
}
|
|
203
|
+
} else {
|
|
204
|
+
// Original plural handling logic when plurals are enabled
|
|
205
|
+
// Always pass the base key to handlePluralKeys - it will handle context internally
|
|
206
|
+
this.handlePluralKeys(finalKey, ns, options, isOrdinalByOption || isOrdinalByKey, finalDefaultValue)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
continue // This key is fully handled
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
if (keysWithContext.length > 0) {
|
|
213
|
+
keysWithContext.forEach(this.pluginContext.addKey)
|
|
214
|
+
|
|
215
|
+
continue // This key is now fully handled
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
// 3. Handle returnObjects
|
|
219
|
+
if (getObjectPropValue(options, 'returnObjects') === true) {
|
|
220
|
+
this.objectKeys.add(finalKey)
|
|
221
|
+
// Fall through to add the base key itself
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// 4. Handle selector API as implicit returnObjects
|
|
226
|
+
if (isSelectorAPI) {
|
|
227
|
+
this.objectKeys.add(finalKey)
|
|
228
|
+
// Fall through to add the base key itself
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
// 5. Default case: Add the simple key
|
|
232
|
+
this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv })
|
|
233
|
+
}
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
/**
|
|
237
|
+
* Processed a call expression to extract keys from the specified argument.
|
|
238
|
+
*
|
|
239
|
+
* @param node - The call expression node
|
|
240
|
+
* @param argIndex - The index of the argument to process
|
|
241
|
+
* @returns An object containing the keys to process and a flag indicating if the selector API is used
|
|
242
|
+
*/
|
|
243
|
+
private handleCallExpressionArgument (
|
|
244
|
+
node: CallExpression,
|
|
245
|
+
argIndex: number
|
|
246
|
+
): { keysToProcess: string[]; isSelectorAPI: boolean } {
|
|
247
|
+
const firstArg = node.arguments[argIndex].expression
|
|
248
|
+
const keysToProcess: string[] = []
|
|
249
|
+
let isSelectorAPI = false
|
|
250
|
+
|
|
251
|
+
if (firstArg.type === 'ArrowFunctionExpression') {
|
|
252
|
+
const key = this.extractKeyFromSelector(firstArg)
|
|
253
|
+
if (key) {
|
|
254
|
+
keysToProcess.push(key)
|
|
255
|
+
isSelectorAPI = true
|
|
256
|
+
}
|
|
257
|
+
} else if (firstArg.type === 'ArrayExpression') {
|
|
258
|
+
for (const element of firstArg.elements) {
|
|
259
|
+
if (element?.expression) {
|
|
260
|
+
keysToProcess.push(...this.expressionResolver.resolvePossibleKeyStringValues(element.expression))
|
|
261
|
+
}
|
|
262
|
+
}
|
|
263
|
+
} else {
|
|
264
|
+
keysToProcess.push(...this.expressionResolver.resolvePossibleKeyStringValues(firstArg))
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
return {
|
|
268
|
+
keysToProcess: keysToProcess.filter((key) => !!key),
|
|
269
|
+
isSelectorAPI,
|
|
270
|
+
}
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/**
|
|
274
|
+
* Extracts translation key from selector API arrow function.
|
|
275
|
+
*
|
|
276
|
+
* Processes selector expressions like:
|
|
277
|
+
* - `$ => $.path.to.key` → 'path.to.key'
|
|
278
|
+
* - `$ => $.app['title'].main` → 'app.title.main'
|
|
279
|
+
* - `$ => { return $.nested.key; }` → 'nested.key'
|
|
280
|
+
*
|
|
281
|
+
* Handles both dot notation and bracket notation, respecting
|
|
282
|
+
* the configured key separator or flat key structure.
|
|
283
|
+
*
|
|
284
|
+
* @param node - Arrow function expression from selector call
|
|
285
|
+
* @returns Extracted key path or null if not statically analyzable
|
|
286
|
+
*/
|
|
287
|
+
private extractKeyFromSelector (node: ArrowFunctionExpression): string | null {
|
|
288
|
+
let body = node.body
|
|
289
|
+
|
|
290
|
+
// Handle block bodies, e.g., $ => { return $.key; }
|
|
291
|
+
if (body.type === 'BlockStatement') {
|
|
292
|
+
const returnStmt = body.stmts.find(s => s.type === 'ReturnStatement')
|
|
293
|
+
if (returnStmt?.type === 'ReturnStatement' && returnStmt.argument) {
|
|
294
|
+
body = returnStmt.argument
|
|
295
|
+
} else {
|
|
296
|
+
return null
|
|
297
|
+
}
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
let current = body
|
|
301
|
+
const parts: string[] = []
|
|
302
|
+
|
|
303
|
+
// Recursively walk down MemberExpressions
|
|
304
|
+
while (current.type === 'MemberExpression') {
|
|
305
|
+
const prop = current.property
|
|
306
|
+
|
|
307
|
+
if (prop.type === 'Identifier') {
|
|
308
|
+
// This handles dot notation: .key
|
|
309
|
+
parts.unshift(prop.value)
|
|
310
|
+
} else if (prop.type === 'Computed' && prop.expression.type === 'StringLiteral') {
|
|
311
|
+
// This handles bracket notation: ['key']
|
|
312
|
+
parts.unshift(prop.expression.value)
|
|
313
|
+
} else {
|
|
314
|
+
// This is a dynamic property like [myVar] or a private name, which we cannot resolve.
|
|
315
|
+
return null
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
current = current.object
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
if (parts.length > 0) {
|
|
322
|
+
const keySeparator = this.config.extract.keySeparator
|
|
323
|
+
const joiner = typeof keySeparator === 'string' ? keySeparator : '.'
|
|
324
|
+
return parts.join(joiner)
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
return null
|
|
328
|
+
}
|
|
329
|
+
|
|
330
|
+
/**
|
|
331
|
+
* Generates plural form keys based on the primary language's plural rules.
|
|
332
|
+
*
|
|
333
|
+
* Uses Intl.PluralRules to determine the correct plural categories
|
|
334
|
+
* for the configured primary language and generates suffixed keys
|
|
335
|
+
* for each category (e.g., 'item_one', 'item_other').
|
|
336
|
+
*
|
|
337
|
+
* @param key - Base key name for pluralization
|
|
338
|
+
* @param ns - Namespace for the keys
|
|
339
|
+
* @param options - object expression options
|
|
340
|
+
* @param isOrdinal - isOrdinal flag
|
|
341
|
+
*/
|
|
342
|
+
private handlePluralKeys (key: string, ns: string | undefined, options: ObjectExpression, isOrdinal: boolean, defaultValueFromCall?: string): void {
|
|
343
|
+
try {
|
|
344
|
+
const type = isOrdinal ? 'ordinal' : 'cardinal'
|
|
345
|
+
|
|
346
|
+
// Generate plural forms for ALL target languages to ensure we have all necessary keys
|
|
347
|
+
const allPluralCategories = new Set<string>()
|
|
348
|
+
|
|
349
|
+
for (const locale of this.config.locales) {
|
|
350
|
+
try {
|
|
351
|
+
const pluralRules = new Intl.PluralRules(locale, { type })
|
|
352
|
+
const categories = pluralRules.resolvedOptions().pluralCategories
|
|
353
|
+
categories.forEach(cat => allPluralCategories.add(cat))
|
|
354
|
+
} catch (e) {
|
|
355
|
+
// If a locale is invalid, fall back to English rules
|
|
356
|
+
const englishRules = new Intl.PluralRules('en', { type })
|
|
357
|
+
const categories = englishRules.resolvedOptions().pluralCategories
|
|
358
|
+
categories.forEach(cat => allPluralCategories.add(cat))
|
|
359
|
+
}
|
|
360
|
+
}
|
|
361
|
+
|
|
362
|
+
const pluralCategories = Array.from(allPluralCategories).sort()
|
|
363
|
+
const pluralSeparator = this.config.extract.pluralSeparator ?? '_'
|
|
364
|
+
|
|
365
|
+
// Get all possible default values once at the start
|
|
366
|
+
const defaultValue = getObjectPropValue(options, 'defaultValue')
|
|
367
|
+
const otherDefault = getObjectPropValue(options, `defaultValue${pluralSeparator}other`)
|
|
368
|
+
const ordinalOtherDefault = getObjectPropValue(options, `defaultValue${pluralSeparator}ordinal${pluralSeparator}other`)
|
|
369
|
+
|
|
370
|
+
// Get the count value and determine target category if available
|
|
371
|
+
const countValue = getObjectPropValue(options, 'count')
|
|
372
|
+
let targetCategory: string | undefined
|
|
373
|
+
|
|
374
|
+
if (typeof countValue === 'number') {
|
|
375
|
+
try {
|
|
376
|
+
const primaryLanguage = this.config.extract?.primaryLanguage || this.config.locales[0] || 'en'
|
|
377
|
+
const pluralRules = new Intl.PluralRules(primaryLanguage, { type })
|
|
378
|
+
targetCategory = pluralRules.select(countValue)
|
|
379
|
+
} catch (e) {
|
|
380
|
+
// If we can't determine the category, continue with normal logic
|
|
381
|
+
}
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Handle context - both static and dynamic
|
|
385
|
+
const contextProp = getObjectProperty(options, 'context')
|
|
386
|
+
const keysToGenerate: Array<{ key: string, context?: string }> = []
|
|
387
|
+
|
|
388
|
+
if (contextProp?.value) {
|
|
389
|
+
// Handle dynamic context by resolving all possible values
|
|
390
|
+
const contextValues = this.expressionResolver.resolvePossibleContextStringValues(contextProp.value)
|
|
391
|
+
|
|
392
|
+
if (contextValues.length > 0) {
|
|
393
|
+
// Generate keys for each context value
|
|
394
|
+
for (const contextValue of contextValues) {
|
|
395
|
+
if (contextValue.length > 0) {
|
|
396
|
+
keysToGenerate.push({ key, context: contextValue })
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
// For dynamic context, also generate base plural forms if generateBasePluralForms is not disabled
|
|
401
|
+
const shouldGenerateBaseForms = this.config.extract?.generateBasePluralForms !== false
|
|
402
|
+
if (shouldGenerateBaseForms) {
|
|
403
|
+
keysToGenerate.push({ key })
|
|
404
|
+
}
|
|
405
|
+
} else {
|
|
406
|
+
// Couldn't resolve context, fall back to base key only
|
|
407
|
+
keysToGenerate.push({ key })
|
|
408
|
+
}
|
|
409
|
+
} else {
|
|
410
|
+
// No context, always generate base plural forms
|
|
411
|
+
keysToGenerate.push({ key })
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
// Generate plural forms for each key variant
|
|
415
|
+
for (const { key: baseKey, context } of keysToGenerate) {
|
|
416
|
+
for (const category of pluralCategories) {
|
|
417
|
+
// 1. Look for the most specific default value
|
|
418
|
+
const specificDefaultKey = isOrdinal ? `defaultValue${pluralSeparator}ordinal${pluralSeparator}${category}` : `defaultValue${pluralSeparator}${category}`
|
|
419
|
+
const specificDefault = getObjectPropValue(options, specificDefaultKey)
|
|
420
|
+
|
|
421
|
+
// 2. Determine the final default value using a clear fallback chain
|
|
422
|
+
let finalDefaultValue: string | undefined
|
|
423
|
+
if (typeof specificDefault === 'string') {
|
|
424
|
+
finalDefaultValue = specificDefault
|
|
425
|
+
} else if (category === 'one' && typeof defaultValue === 'string') {
|
|
426
|
+
finalDefaultValue = defaultValue
|
|
427
|
+
} else if (isOrdinal && typeof ordinalOtherDefault === 'string') {
|
|
428
|
+
finalDefaultValue = ordinalOtherDefault
|
|
429
|
+
} else if (!isOrdinal && typeof otherDefault === 'string') {
|
|
430
|
+
finalDefaultValue = otherDefault
|
|
431
|
+
} else if (typeof defaultValue === 'string') {
|
|
432
|
+
finalDefaultValue = defaultValue
|
|
433
|
+
} else if (defaultValueFromCall && targetCategory === category) {
|
|
434
|
+
finalDefaultValue = defaultValueFromCall
|
|
435
|
+
} else {
|
|
436
|
+
finalDefaultValue = baseKey
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
// 3. Construct the final plural key
|
|
440
|
+
let finalKey: string
|
|
441
|
+
if (context) {
|
|
442
|
+
const contextSeparator = this.config.extract.contextSeparator ?? '_'
|
|
443
|
+
finalKey = isOrdinal
|
|
444
|
+
? `${baseKey}${contextSeparator}${context}${pluralSeparator}ordinal${pluralSeparator}${category}`
|
|
445
|
+
: `${baseKey}${contextSeparator}${context}${pluralSeparator}${category}`
|
|
446
|
+
} else {
|
|
447
|
+
finalKey = isOrdinal
|
|
448
|
+
? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
|
|
449
|
+
: `${baseKey}${pluralSeparator}${category}`
|
|
450
|
+
}
|
|
451
|
+
|
|
452
|
+
this.pluginContext.addKey({
|
|
453
|
+
key: finalKey,
|
|
454
|
+
ns,
|
|
455
|
+
defaultValue: finalDefaultValue,
|
|
456
|
+
hasCount: true,
|
|
457
|
+
isOrdinal
|
|
458
|
+
})
|
|
459
|
+
}
|
|
460
|
+
}
|
|
461
|
+
} catch (e) {
|
|
462
|
+
this.logger.warn(`Could not determine plural rules for language "${this.config.extract?.primaryLanguage}". Falling back to simple key extraction.`)
|
|
463
|
+
// Fallback to a simple key if Intl API fails
|
|
464
|
+
const defaultValue = defaultValueFromCall || getObjectPropValue(options, 'defaultValue')
|
|
465
|
+
this.pluginContext.addKey({ key, ns, defaultValue: typeof defaultValue === 'string' ? defaultValue : key })
|
|
466
|
+
}
|
|
467
|
+
}
|
|
468
|
+
|
|
469
|
+
/**
|
|
470
|
+
* Serializes a callee node (Identifier or MemberExpression) into a string.
|
|
471
|
+
*
|
|
472
|
+
* Produces a dotted name for simple callees that can be used for scope lookups
|
|
473
|
+
* or configuration matching.
|
|
474
|
+
*
|
|
475
|
+
* @param callee - The CallExpression callee node to serialize
|
|
476
|
+
* @returns A dotted string name for supported callees, or null when the callee
|
|
477
|
+
* is a computed/unsupported expression.
|
|
478
|
+
*/
|
|
479
|
+
private getFunctionName (callee: CallExpression['callee']): string | null {
|
|
480
|
+
if (callee.type === 'Identifier') {
|
|
481
|
+
return callee.value
|
|
482
|
+
}
|
|
483
|
+
if (callee.type === 'MemberExpression') {
|
|
484
|
+
const parts: string[] = []
|
|
485
|
+
let current: any = callee
|
|
486
|
+
while (current.type === 'MemberExpression') {
|
|
487
|
+
if (current.property.type === 'Identifier') {
|
|
488
|
+
parts.unshift(current.property.value)
|
|
489
|
+
} else {
|
|
490
|
+
return null // Cannot handle computed properties like i18n['t']
|
|
491
|
+
}
|
|
492
|
+
current = current.object
|
|
493
|
+
}
|
|
494
|
+
// Handle `this` as the base of the expression (e.g., this._i18n.t)
|
|
495
|
+
if (current.type === 'ThisExpression') {
|
|
496
|
+
parts.unshift('this')
|
|
497
|
+
} else if (current.type === 'Identifier') {
|
|
498
|
+
parts.unshift(current.value)
|
|
499
|
+
} else {
|
|
500
|
+
return null // Base of the expression is not a simple identifier
|
|
501
|
+
}
|
|
502
|
+
return parts.join('.')
|
|
503
|
+
}
|
|
504
|
+
return null
|
|
505
|
+
}
|
|
506
|
+
}
|