i18next-cli 1.10.1 → 1.10.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (59) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/README.md +3 -0
  3. package/dist/cjs/cli.js +1 -1
  4. package/dist/cjs/extractor/core/ast-visitors.js +1 -0
  5. package/dist/cjs/extractor/core/key-finder.js +1 -1
  6. package/dist/cjs/extractor/core/translation-manager.js +1 -1
  7. package/dist/cjs/extractor/parsers/call-expression-handler.js +1 -0
  8. package/dist/cjs/extractor/parsers/expression-resolver.js +1 -0
  9. package/dist/cjs/extractor/parsers/jsx-handler.js +1 -0
  10. package/dist/cjs/extractor/parsers/jsx-parser.js +1 -1
  11. package/dist/cjs/extractor/parsers/scope-manager.js +1 -0
  12. package/dist/esm/cli.js +1 -1
  13. package/dist/esm/extractor/core/ast-visitors.js +1 -0
  14. package/dist/esm/extractor/core/key-finder.js +1 -1
  15. package/dist/esm/extractor/core/translation-manager.js +1 -1
  16. package/dist/esm/extractor/parsers/call-expression-handler.js +1 -0
  17. package/dist/esm/extractor/parsers/expression-resolver.js +1 -0
  18. package/dist/esm/extractor/parsers/jsx-handler.js +1 -0
  19. package/dist/esm/extractor/parsers/jsx-parser.js +1 -1
  20. package/dist/esm/extractor/parsers/scope-manager.js +1 -0
  21. package/package.json +1 -1
  22. package/src/cli.ts +1 -1
  23. package/src/extractor/core/ast-visitors.ts +170 -0
  24. package/src/extractor/core/extractor.ts +1 -1
  25. package/src/extractor/core/key-finder.ts +2 -2
  26. package/src/extractor/core/translation-manager.ts +88 -8
  27. package/src/extractor/index.ts +1 -1
  28. package/src/extractor/parsers/call-expression-handler.ts +506 -0
  29. package/src/extractor/parsers/expression-resolver.ts +178 -0
  30. package/src/extractor/parsers/jsx-handler.ts +358 -0
  31. package/src/extractor/parsers/jsx-parser.ts +5 -1
  32. package/src/extractor/parsers/scope-manager.ts +327 -0
  33. package/src/extractor.ts +1 -1
  34. package/src/types.ts +82 -0
  35. package/types/extractor/core/ast-visitors.d.ts +75 -0
  36. package/types/extractor/core/ast-visitors.d.ts.map +1 -0
  37. package/types/extractor/core/extractor.d.ts +1 -1
  38. package/types/extractor/core/extractor.d.ts.map +1 -1
  39. package/types/extractor/core/key-finder.d.ts.map +1 -1
  40. package/types/extractor/core/translation-manager.d.ts.map +1 -1
  41. package/types/extractor/index.d.ts +1 -1
  42. package/types/extractor/index.d.ts.map +1 -1
  43. package/types/extractor/parsers/call-expression-handler.d.ts +74 -0
  44. package/types/extractor/parsers/call-expression-handler.d.ts.map +1 -0
  45. package/types/extractor/parsers/expression-resolver.d.ts +62 -0
  46. package/types/extractor/parsers/expression-resolver.d.ts.map +1 -0
  47. package/types/extractor/parsers/jsx-handler.d.ts +44 -0
  48. package/types/extractor/parsers/jsx-handler.d.ts.map +1 -0
  49. package/types/extractor/parsers/scope-manager.d.ts +99 -0
  50. package/types/extractor/parsers/scope-manager.d.ts.map +1 -0
  51. package/types/extractor.d.ts +1 -1
  52. package/types/extractor.d.ts.map +1 -1
  53. package/types/types.d.ts +77 -0
  54. package/types/types.d.ts.map +1 -1
  55. package/dist/cjs/extractor/parsers/ast-visitors.js +0 -1
  56. package/dist/esm/extractor/parsers/ast-visitors.js +0 -1
  57. package/src/extractor/parsers/ast-visitors.ts +0 -1510
  58. package/types/extractor/parsers/ast-visitors.d.ts +0 -352
  59. 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
- // First, compare case-insensitively
30
- const caseInsensitiveComparison = a.localeCompare(b, undefined, { sensitivity: 'base' })
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
+ }
53
+
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)
31
75
 
32
- // If they're equal case-insensitively, sort by case (lowercase first)
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
@@ -125,6 +178,33 @@ function buildNewTranslationsForNs (
125
178
  }
126
179
  }
127
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
+
128
208
  // 1. Build the object first, without any sorting.
129
209
  for (const { key, defaultValue } of filteredKeys) {
130
210
  const existingValue = getNestedValue(existingTranslations, key, keySeparator ?? '.')
@@ -162,7 +242,7 @@ function buildNewTranslationsForNs (
162
242
  // 2. If sorting is enabled, recursively sort the entire object.
163
243
  // This correctly handles both top-level and nested keys.
164
244
  if (sort === true) {
165
- return sortObject(newTranslations)
245
+ return sortObject(newTranslations, config)
166
246
  }
167
247
  // Custom sort function logic remains as a future enhancement if needed,
168
248
  // but for now, this robustly handles the most common `sort: true` case.
@@ -195,7 +275,7 @@ function buildNewTranslationsForNs (
195
275
 
196
276
  // 3. Rebuild the object in the final sorted order.
197
277
  for (const key of topLevelKeys) {
198
- sortedObject[key] = newTranslations[key]
278
+ sortedObject[key] = sortObject(newTranslations[key], config)
199
279
  }
200
280
  newTranslations = sortedObject
201
281
  }
@@ -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 './parsers/ast-visitors'
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
+ }