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
@@ -1,1510 +0,0 @@
1
- import type { Module, Node, CallExpression, VariableDeclarator, JSXElement, ArrowFunctionExpression, ObjectExpression, Expression, TemplateLiteral, TsType, TsTemplateLiteralType } from '@swc/core'
2
- import type { PluginContext, I18nextToolkitConfig, Logger, ExtractedKey, ScopeInfo } from '../../types'
3
- import { extractFromTransComponent } from './jsx-parser'
4
- import { getObjectProperty, getObjectPropValue } from './ast-utils'
5
-
6
- interface UseTranslationHookConfig {
7
- name: string;
8
- nsArg: number;
9
- keyPrefixArg: number;
10
- }
11
-
12
- export interface ASTVisitorHooks {
13
- onBeforeVisitNode?: (node: Node) => void
14
- onAfterVisitNode?: (node: Node) => void
15
- resolvePossibleContextStringValues?: (expression: Expression, returnEmptyStrings?: boolean) => string[]
16
- resolvePossibleKeyStringValues?: (expression: Expression, returnEmptyStrings?: boolean) => string[]
17
- }
18
-
19
- /**
20
- * AST visitor class that traverses JavaScript/TypeScript syntax trees to extract translation keys.
21
- *
22
- * This class implements a manual recursive walker that:
23
- * - Maintains scope information for tracking useTranslation and getFixedT calls
24
- * - Extracts keys from t() function calls with various argument patterns
25
- * - Handles JSX Trans components with complex children serialization
26
- * - Supports both string literals and selector API for type-safe keys
27
- * - Processes pluralization and context variants
28
- * - Manages namespace resolution from multiple sources
29
- *
30
- * The visitor respects configuration options for separators, function names,
31
- * component names, and other extraction settings.
32
- *
33
- * @example
34
- * ```typescript
35
- * const visitors = new ASTVisitors(config, pluginContext, logger)
36
- * visitors.visit(parsedAST)
37
- *
38
- * // The pluginContext will now contain all extracted keys
39
- * ```
40
- */
41
- export class ASTVisitors {
42
- private readonly pluginContext: PluginContext
43
- private readonly config: Omit<I18nextToolkitConfig, 'plugins'>
44
- private readonly logger: Logger
45
- private scopeStack: Array<Map<string, ScopeInfo>> = []
46
- private hooks: ASTVisitorHooks
47
-
48
- public objectKeys = new Set<string>()
49
-
50
- private scope: Map<string, { defaultNs?: string; keyPrefix?: string }> = new Map()
51
-
52
- /**
53
- * Creates a new AST visitor instance.
54
- *
55
- * @param config - Toolkit configuration with extraction settings
56
- * @param pluginContext - Context for adding discovered translation keys
57
- * @param logger - Logger for warnings and debug information
58
- */
59
- constructor (
60
- config: Omit<I18nextToolkitConfig, 'plugins'>,
61
- pluginContext: PluginContext,
62
- logger: Logger,
63
- hooks?: ASTVisitorHooks
64
- ) {
65
- this.pluginContext = pluginContext
66
- this.config = config
67
- this.logger = logger
68
- this.hooks = {
69
- onBeforeVisitNode: hooks?.onBeforeVisitNode,
70
- onAfterVisitNode: hooks?.onAfterVisitNode,
71
- resolvePossibleKeyStringValues: hooks?.resolvePossibleKeyStringValues,
72
- resolvePossibleContextStringValues: hooks?.resolvePossibleContextStringValues
73
- }
74
- }
75
-
76
- /**
77
- * Main entry point for AST traversal.
78
- * Creates a root scope and begins the recursive walk through the syntax tree.
79
- *
80
- * @param node - The root module node to traverse
81
- */
82
- public visit (node: Module): void {
83
- this.enterScope() // Create the root scope for the file
84
- this.walk(node)
85
- this.exitScope() // Clean up the root scope
86
- }
87
-
88
- /**
89
- * Recursively walks through AST nodes, handling scoping and visiting logic.
90
- *
91
- * This is the core traversal method that:
92
- * 1. Manages function scopes (enter/exit)
93
- * 2. Dispatches to specific handlers based on node type
94
- * 3. Recursively processes child nodes
95
- * 4. Maintains proper scope cleanup
96
- *
97
- * @param node - The current AST node to process
98
- *
99
- * @private
100
- */
101
- private walk (node: Node | any): void {
102
- if (!node) return
103
-
104
- let isNewScope = false
105
- // ENTER SCOPE for functions
106
- if (node.type === 'Function' || node.type === 'ArrowFunctionExpression' || node.type === 'FunctionExpression') {
107
- this.enterScope()
108
- isNewScope = true
109
- }
110
-
111
- this.hooks.onBeforeVisitNode?.(node)
112
-
113
- // --- VISIT LOGIC ---
114
- // Handle specific node types
115
- switch (node.type) {
116
- case 'VariableDeclarator':
117
- this.handleVariableDeclarator(node)
118
- break
119
- case 'CallExpression':
120
- this.handleCallExpression(node)
121
- break
122
- case 'JSXElement':
123
- this.handleJSXElement(node)
124
- break
125
- }
126
-
127
- this.hooks.onAfterVisitNode?.(node)
128
-
129
- // --- END VISIT LOGIC ---
130
-
131
- // --- RECURSION ---
132
- // Recurse into the children of the current node
133
- for (const key in node) {
134
- if (key === 'span') continue
135
-
136
- const child = node[key]
137
- if (Array.isArray(child)) {
138
- for (const item of child) {
139
- // Be less strict: if it's a non-null object, walk it.
140
- // This allows traversal into nodes that might not have a `.type` property
141
- // but still contain other valid AST nodes.
142
- if (item && typeof item === 'object') {
143
- this.walk(item)
144
- }
145
- }
146
- } else if (child && typeof child === 'object') {
147
- // The condition for single objects should be the same as for array items.
148
- // Do not require `child.type`. This allows traversal into class method bodies.
149
- this.walk(child)
150
- }
151
- }
152
- // --- END RECURSION ---
153
-
154
- // LEAVE SCOPE for functions
155
- if (isNewScope) {
156
- this.exitScope()
157
- }
158
- }
159
-
160
- /**
161
- * Enters a new variable scope by pushing a new scope map onto the stack.
162
- * Used when entering functions to isolate variable declarations.
163
- *
164
- * @private
165
- */
166
- private enterScope (): void {
167
- this.scopeStack.push(new Map())
168
- }
169
-
170
- /**
171
- * Exits the current variable scope by popping the top scope map.
172
- * Used when leaving functions to clean up variable tracking.
173
- *
174
- * @private
175
- */
176
- private exitScope (): void {
177
- this.scopeStack.pop()
178
- }
179
-
180
- /**
181
- * Stores variable information in the current scope.
182
- * Used to track translation functions and their configuration.
183
- *
184
- * @param name - Variable name to store
185
- * @param info - Scope information about the variable
186
- *
187
- * @private
188
- */
189
- private setVarInScope (name: string, info: ScopeInfo): void {
190
- if (this.scopeStack.length > 0) {
191
- this.scopeStack[this.scopeStack.length - 1].set(name, info)
192
- }
193
- }
194
-
195
- /**
196
- * Retrieves variable information from the scope chain.
197
- * Searches from innermost to outermost scope.
198
- *
199
- * @param name - Variable name to look up
200
- * @returns Scope information if found, undefined otherwise
201
- *
202
- * @private
203
- */
204
- public getVarFromScope (name: string): ScopeInfo | undefined {
205
- // First check the proper scope stack (this is the primary source of truth)
206
- for (let i = this.scopeStack.length - 1; i >= 0; i--) {
207
- if (this.scopeStack[i].has(name)) {
208
- const scopeInfo = this.scopeStack[i].get(name)
209
- return scopeInfo
210
- }
211
- }
212
-
213
- // Then check the legacy scope tracking for useTranslation calls (for comment parsing)
214
- const legacyScope = this.scope.get(name)
215
- if (legacyScope) {
216
- return legacyScope
217
- }
218
-
219
- return undefined
220
- }
221
-
222
- /**
223
- * Handles variable declarations that might define translation functions.
224
- *
225
- * Processes two patterns:
226
- * 1. `const { t } = useTranslation(...)` - React i18next pattern
227
- * 2. `const t = i18next.getFixedT(...)` - Core i18next pattern
228
- *
229
- * Extracts namespace and key prefix information for later use.
230
- *
231
- * @param node - Variable declarator node to process
232
- *
233
- * @private
234
- */
235
- private handleVariableDeclarator (node: VariableDeclarator): void {
236
- const init = node.init
237
- if (!init) return
238
-
239
- // Determine the actual call expression, looking inside AwaitExpressions.
240
- const callExpr =
241
- init.type === 'AwaitExpression' && init.argument.type === 'CallExpression'
242
- ? init.argument
243
- : init.type === 'CallExpression'
244
- ? init
245
- : null
246
-
247
- if (!callExpr) return
248
-
249
- const callee = callExpr.callee
250
-
251
- // Handle: const { t } = useTranslation(...)
252
- if (callee.type === 'Identifier') {
253
- const hookConfig = this.getUseTranslationConfig(callee.value)
254
- if (hookConfig) {
255
- this.handleUseTranslationDeclarator(node, callExpr, hookConfig)
256
-
257
- // ALSO store in the legacy scope for comment parsing compatibility
258
- this.handleUseTranslationForComments(node, callExpr, hookConfig)
259
- return
260
- }
261
- }
262
-
263
- // Handle: const t = i18next.getFixedT(...)
264
- if (
265
- callee.type === 'MemberExpression' &&
266
- callee.property.type === 'Identifier' &&
267
- callee.property.value === 'getFixedT'
268
- ) {
269
- this.handleGetFixedTDeclarator(node, callExpr)
270
- }
271
- }
272
-
273
- /**
274
- * Handles useTranslation calls for comment scope resolution.
275
- * This is a separate method to store scope info in the legacy scope map
276
- * that the comment parser can access.
277
- *
278
- * @param node - Variable declarator with useTranslation call
279
- * @param callExpr - The CallExpression node representing the useTranslation invocation
280
- * @param hookConfig - Configuration describing argument positions for namespace and keyPrefix
281
- *
282
- * @private
283
- */
284
- private handleUseTranslationForComments (node: VariableDeclarator, callExpr: CallExpression, hookConfig: UseTranslationHookConfig): void {
285
- let variableName: string | undefined
286
-
287
- // Handle simple assignment: let t = useTranslation()
288
- if (node.id.type === 'Identifier') {
289
- variableName = node.id.value
290
- }
291
-
292
- // Handle array destructuring: const [t, i18n] = useTranslation()
293
- if (node.id.type === 'ArrayPattern') {
294
- const firstElement = node.id.elements[0]
295
- if (firstElement?.type === 'Identifier') {
296
- variableName = firstElement.value
297
- }
298
- }
299
-
300
- // Handle object destructuring: const { t } or { t: t1 } = useTranslation()
301
- if (node.id.type === 'ObjectPattern') {
302
- for (const prop of node.id.properties) {
303
- if (prop.type === 'AssignmentPatternProperty' && prop.key.type === 'Identifier' && prop.key.value === 't') {
304
- // This handles { t = defaultT }
305
- variableName = 't'
306
- break
307
- }
308
- if (prop.type === 'KeyValuePatternProperty' && prop.key.type === 'Identifier' && prop.key.value === 't' && prop.value.type === 'Identifier') {
309
- // This handles { t: myT }
310
- variableName = prop.value.value
311
- break
312
- }
313
- }
314
- }
315
-
316
- // If we couldn't find a `t` function being declared, exit
317
- if (!variableName) return
318
-
319
- // Extract namespace from useTranslation arguments
320
- const nsArg = callExpr.arguments?.[hookConfig.nsArg]?.expression
321
- const optionsArg = callExpr.arguments?.[hookConfig.keyPrefixArg]?.expression
322
-
323
- let defaultNs: string | undefined
324
- let keyPrefix: string | undefined
325
-
326
- // Parse namespace argument
327
- if (nsArg?.type === 'StringLiteral') {
328
- defaultNs = nsArg.value
329
- } else if (nsArg?.type === 'ArrayExpression' && nsArg.elements[0]?.expression.type === 'StringLiteral') {
330
- defaultNs = nsArg.elements[0].expression.value
331
- }
332
-
333
- // Parse keyPrefix from options object
334
- if (optionsArg?.type === 'ObjectExpression') {
335
- const keyPrefixProp = optionsArg.properties.find(
336
- prop => prop.type === 'KeyValueProperty' &&
337
- prop.key.type === 'Identifier' &&
338
- prop.key.value === 'keyPrefix'
339
- )
340
- if (keyPrefixProp?.type === 'KeyValueProperty' && keyPrefixProp.value.type === 'StringLiteral') {
341
- keyPrefix = keyPrefixProp.value.value
342
- }
343
- }
344
-
345
- // Store in the legacy scope map for comment parsing
346
- if (defaultNs || keyPrefix) {
347
- this.scope.set(variableName, { defaultNs, keyPrefix })
348
- }
349
- }
350
-
351
- /**
352
- * Processes useTranslation hook declarations to extract scope information.
353
- *
354
- * Handles various destructuring patterns:
355
- * - `const [t] = useTranslation('ns')` - Array destructuring
356
- * - `const { t } = useTranslation('ns')` - Object destructuring
357
- * - `const { t: myT } = useTranslation('ns')` - Aliased destructuring
358
- *
359
- * Extracts namespace from the first argument and keyPrefix from options.
360
- *
361
- * @param node - Variable declarator with useTranslation call
362
- * @param callExpr - The CallExpression node representing the useTranslation invocation
363
- * @param hookConfig - Configuration describing argument positions for namespace and keyPrefix
364
- *
365
- * @private
366
- */
367
- private handleUseTranslationDeclarator (node: VariableDeclarator, callExpr: CallExpression, hookConfig: UseTranslationHookConfig): void {
368
- let variableName: string | undefined
369
-
370
- // Handle simple assignment: let t = useTranslation()
371
- if (node.id.type === 'Identifier') {
372
- variableName = node.id.value
373
- }
374
-
375
- // Handle array destructuring: const [t, i18n] = useTranslation()
376
- if (node.id.type === 'ArrayPattern') {
377
- const firstElement = node.id.elements[0]
378
- if (firstElement?.type === 'Identifier') {
379
- variableName = firstElement.value
380
- }
381
- }
382
-
383
- // Handle object destructuring: const { t } or { t: t1 } = useTranslation()
384
- if (node.id.type === 'ObjectPattern') {
385
- for (const prop of node.id.properties) {
386
- if (prop.type === 'AssignmentPatternProperty' && prop.key.type === 'Identifier' && prop.key.value === 't') {
387
- // This handles { t = defaultT }
388
- variableName = 't'
389
- break
390
- }
391
- if (prop.type === 'KeyValuePatternProperty' && prop.key.type === 'Identifier' && prop.key.value === 't' && prop.value.type === 'Identifier') {
392
- // This handles { t: myT }
393
- variableName = prop.value.value
394
- break
395
- }
396
- }
397
- }
398
-
399
- // If we couldn't find a `t` function being declared, exit
400
- if (!variableName) return
401
-
402
- // Use the configured argument indices from hookConfig
403
- const nsArg = callExpr.arguments?.[hookConfig.nsArg]?.expression
404
-
405
- let defaultNs: string | undefined
406
- if (nsArg?.type === 'StringLiteral') {
407
- defaultNs = nsArg.value
408
- } else if (nsArg?.type === 'ArrayExpression' && nsArg.elements[0]?.expression.type === 'StringLiteral') {
409
- defaultNs = nsArg.elements[0].expression.value
410
- }
411
-
412
- const optionsArg = callExpr.arguments?.[hookConfig.keyPrefixArg]?.expression
413
- let keyPrefix: string | undefined
414
- if (optionsArg?.type === 'ObjectExpression') {
415
- const kp = getObjectPropValue(optionsArg, 'keyPrefix')
416
- keyPrefix = typeof kp === 'string' ? kp : undefined
417
- }
418
-
419
- // Store the scope info for the declared variable
420
- this.setVarInScope(variableName, { defaultNs, keyPrefix })
421
- }
422
-
423
- /**
424
- * Processes getFixedT function declarations to extract scope information.
425
- *
426
- * Handles the pattern: `const t = i18next.getFixedT(lng, ns, keyPrefix)`
427
- * - Ignores the first argument (language)
428
- * - Extracts namespace from the second argument
429
- * - Extracts key prefix from the third argument
430
- *
431
- * @param node - Variable declarator with getFixedT call
432
- * @param callExpr - The CallExpression node representing the getFixedT invocation
433
- *
434
- * @private
435
- */
436
- private handleGetFixedTDeclarator (node: VariableDeclarator, callExpr: CallExpression): void {
437
- // Ensure we are assigning to a simple variable, e.g., const t = ...
438
- if (node.id.type !== 'Identifier' || !node.init || node.init.type !== 'CallExpression') return
439
-
440
- const variableName = node.id.value
441
- const args = callExpr.arguments
442
-
443
- // getFixedT(lng, ns, keyPrefix)
444
- // We ignore the first argument (lng) for key extraction.
445
- const nsArg = args[1]?.expression
446
- const keyPrefixArg = args[2]?.expression
447
-
448
- const defaultNs = (nsArg?.type === 'StringLiteral') ? nsArg.value : undefined
449
- const keyPrefix = (keyPrefixArg?.type === 'StringLiteral') ? keyPrefixArg.value : undefined
450
-
451
- if (defaultNs || keyPrefix) {
452
- this.setVarInScope(variableName, { defaultNs, keyPrefix })
453
- }
454
- }
455
-
456
- /**
457
- * Processes function call expressions to extract translation keys.
458
- *
459
- * This is the core extraction method that handles:
460
- * - Standard t() calls with string literals
461
- * - Selector API calls with arrow functions: `t($ => $.path.to.key)`
462
- * - Namespace resolution from multiple sources
463
- * - Default value extraction from various argument patterns
464
- * - Pluralization and context handling
465
- * - Key prefix application from scope
466
- *
467
- * @param node - Call expression node to process
468
- *
469
- * @private
470
- */
471
- private handleCallExpression (node: CallExpression): void {
472
- const functionName = this.getFunctionName(node.callee)
473
- if (!functionName) return
474
-
475
- // The scope lookup will only work for simple identifiers, which is okay for this fix.
476
- const scopeInfo = this.getVarFromScope(functionName)
477
- const configuredFunctions = this.config.extract.functions || ['t', '*.t']
478
- let isFunctionToParse = scopeInfo !== undefined // A scoped variable (from useTranslation, etc.) is always parsed.
479
- if (!isFunctionToParse) {
480
- for (const pattern of configuredFunctions) {
481
- if (pattern.startsWith('*.')) {
482
- // Handle wildcard suffix (e.g., '*.t' matches 'i18n.t')
483
- if (functionName.endsWith(pattern.substring(1))) {
484
- isFunctionToParse = true
485
- break
486
- }
487
- } else {
488
- // Handle exact match
489
- if (pattern === functionName) {
490
- isFunctionToParse = true
491
- break
492
- }
493
- }
494
- }
495
- }
496
- if (!isFunctionToParse || node.arguments.length === 0) return
497
-
498
- const { keysToProcess, isSelectorAPI } = this.handleCallExpressionArgument(node, 0)
499
-
500
- if (keysToProcess.length === 0) return
501
-
502
- let isOrdinalByKey = false
503
- const pluralSeparator = this.config.extract.pluralSeparator ?? '_'
504
-
505
- for (let i = 0; i < keysToProcess.length; i++) {
506
- if (keysToProcess[i].endsWith(`${pluralSeparator}ordinal`)) {
507
- isOrdinalByKey = true
508
- // Normalize the key by stripping the suffix
509
- keysToProcess[i] = keysToProcess[i].slice(0, -8)
510
- }
511
- }
512
-
513
- let defaultValue: string | undefined
514
- let options: ObjectExpression | undefined
515
-
516
- if (node.arguments.length > 1) {
517
- const arg2 = node.arguments[1].expression
518
- if (arg2.type === 'ObjectExpression') {
519
- options = arg2
520
- } else if (arg2.type === 'StringLiteral') {
521
- defaultValue = arg2.value
522
- }
523
- }
524
- if (node.arguments.length > 2) {
525
- const arg3 = node.arguments[2].expression
526
- if (arg3.type === 'ObjectExpression') {
527
- options = arg3
528
- }
529
- }
530
- const defaultValueFromOptions = options ? getObjectPropValue(options, 'defaultValue') : undefined
531
- const finalDefaultValue = (typeof defaultValueFromOptions === 'string' ? defaultValueFromOptions : defaultValue)
532
-
533
- // Loop through each key found (could be one or more) and process it
534
- for (let i = 0; i < keysToProcess.length; i++) {
535
- let key = keysToProcess[i]
536
- let ns: string | undefined
537
-
538
- // Determine namespace (explicit ns > ns:key > scope ns > default)
539
- // See https://www.i18next.com/overview/api#getfixedt
540
- if (options) {
541
- const nsVal = getObjectPropValue(options, 'ns')
542
- if (typeof nsVal === 'string') ns = nsVal
543
- }
544
-
545
- const nsSeparator = this.config.extract.nsSeparator ?? ':'
546
- if (!ns && nsSeparator && key.includes(nsSeparator)) {
547
- const parts = key.split(nsSeparator)
548
- ns = parts.shift()
549
- key = parts.join(nsSeparator)
550
-
551
- if (!key || key.trim() === '') {
552
- this.logger.warn(`Skipping key that became empty after namespace removal: '${ns}${nsSeparator}'`)
553
- continue
554
- }
555
- }
556
-
557
- if (!ns && scopeInfo?.defaultNs) ns = scopeInfo.defaultNs
558
- if (!ns) ns = this.config.extract.defaultNS
559
-
560
- let finalKey = key
561
-
562
- // Apply keyPrefix AFTER namespace extraction
563
- if (scopeInfo?.keyPrefix) {
564
- const keySeparator = this.config.extract.keySeparator ?? '.'
565
-
566
- // Apply keyPrefix - handle case where keyPrefix already ends with separator
567
- if (keySeparator !== false) {
568
- if (scopeInfo.keyPrefix.endsWith(keySeparator)) {
569
- finalKey = `${scopeInfo.keyPrefix}${key}`
570
- } else {
571
- finalKey = `${scopeInfo.keyPrefix}${keySeparator}${key}`
572
- }
573
- } else {
574
- finalKey = `${scopeInfo.keyPrefix}${key}`
575
- }
576
-
577
- // Validate keyPrefix combinations that create problematic keys
578
- if (keySeparator !== false) {
579
- // Check for patterns that would create empty segments in the nested key structure
580
- const segments = finalKey.split(keySeparator)
581
- const hasEmptySegment = segments.some(segment => segment.trim() === '')
582
-
583
- if (hasEmptySegment) {
584
- this.logger.warn(`Skipping key with empty segments: '${finalKey}' (keyPrefix: '${scopeInfo.keyPrefix}', key: '${key}')`)
585
- continue
586
- }
587
- }
588
- }
589
-
590
- const isLastKey = i === keysToProcess.length - 1
591
- const dv = isLastKey ? (finalDefaultValue || key) : key
592
-
593
- // Handle plurals, context, and returnObjects
594
- if (options) {
595
- const contextProp = getObjectProperty(options, 'context')
596
-
597
- const keysWithContext: ExtractedKey[] = []
598
-
599
- // 1. Handle Context
600
- if (contextProp?.value?.type === 'StringLiteral' || contextProp?.value.type === 'NumericLiteral' || contextProp?.value.type === 'BooleanLiteral') {
601
- // If the context is static, we don't need to add the base key
602
- const contextValue = `${contextProp.value.value}`
603
-
604
- const contextSeparator = this.config.extract.contextSeparator ?? '_'
605
- // Ignore context: ''
606
- if (contextValue !== '') {
607
- keysWithContext.push({ key: `${finalKey}${contextSeparator}${contextValue}`, ns, defaultValue: dv })
608
- }
609
- } else if (contextProp?.value) {
610
- const contextValues = this.resolvePossibleContextStringValues(contextProp.value)
611
- const contextSeparator = this.config.extract.contextSeparator ?? '_'
612
-
613
- if (contextValues.length > 0) {
614
- contextValues.forEach(context => {
615
- keysWithContext.push({ key: `${finalKey}${contextSeparator}${context}`, ns, defaultValue: dv })
616
- })
617
- // For dynamic context, also add the base key as a fallback
618
- keysWithContext.push({ key: finalKey, ns, defaultValue: dv })
619
- }
620
- }
621
-
622
- // 2. Handle Plurals
623
- const hasCount = getObjectPropValue(options, 'count') !== undefined
624
- const isOrdinalByOption = getObjectPropValue(options, 'ordinal') === true
625
- if (hasCount || isOrdinalByKey) {
626
- // Check if plurals are disabled
627
- if (this.config.extract.disablePlurals) {
628
- // When plurals are disabled, treat count as a regular option (for interpolation only)
629
- // Still handle context normally
630
- if (keysWithContext.length > 0) {
631
- keysWithContext.forEach(this.pluginContext.addKey)
632
- } else {
633
- // No context, just add the base key (no plurals even if count is present)
634
- this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv })
635
- }
636
- } else {
637
- // Original plural handling logic when plurals are enabled
638
- // Always pass the base key to handlePluralKeys - it will handle context internally
639
- this.handlePluralKeys(finalKey, ns, options, isOrdinalByOption || isOrdinalByKey, finalDefaultValue)
640
- }
641
-
642
- continue // This key is fully handled
643
- }
644
-
645
- if (keysWithContext.length > 0) {
646
- keysWithContext.forEach(this.pluginContext.addKey)
647
-
648
- continue // This key is now fully handled
649
- }
650
-
651
- // 3. Handle returnObjects
652
- if (getObjectPropValue(options, 'returnObjects') === true) {
653
- this.objectKeys.add(finalKey)
654
- // Fall through to add the base key itself
655
- }
656
- }
657
-
658
- // 4. Handle selector API as implicit returnObjects
659
- if (isSelectorAPI) {
660
- this.objectKeys.add(finalKey)
661
- // Fall through to add the base key itself
662
- }
663
-
664
- // 5. Default case: Add the simple key
665
- this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv })
666
- }
667
- }
668
-
669
- /**
670
- * Processed a call expression to extract keys from the specified argument.
671
- *
672
- * @param node - The call expression node
673
- * @param argIndex - The index of the argument to process
674
- * @returns An object containing the keys to process and a flag indicating if the selector API is used
675
- */
676
- private handleCallExpressionArgument (
677
- node: CallExpression,
678
- argIndex: number
679
- ): { keysToProcess: string[]; isSelectorAPI: boolean } {
680
- const firstArg = node.arguments[argIndex].expression
681
- const keysToProcess: string[] = []
682
- let isSelectorAPI = false
683
-
684
- if (firstArg.type === 'ArrowFunctionExpression') {
685
- const key = this.extractKeyFromSelector(firstArg)
686
- if (key) {
687
- keysToProcess.push(key)
688
- isSelectorAPI = true
689
- }
690
- } else if (firstArg.type === 'ArrayExpression') {
691
- for (const element of firstArg.elements) {
692
- if (element?.expression) {
693
- keysToProcess.push(...this.resolvePossibleKeyStringValues(element.expression))
694
- }
695
- }
696
- } else {
697
- keysToProcess.push(...this.resolvePossibleKeyStringValues(firstArg))
698
- }
699
-
700
- return {
701
- keysToProcess: keysToProcess.filter((key) => !!key),
702
- isSelectorAPI,
703
- }
704
- }
705
-
706
- /**
707
- * Generates plural form keys based on the primary language's plural rules.
708
- *
709
- * Uses Intl.PluralRules to determine the correct plural categories
710
- * for the configured primary language and generates suffixed keys
711
- * for each category (e.g., 'item_one', 'item_other').
712
- *
713
- * @param key - Base key name for pluralization
714
- * @param ns - Namespace for the keys
715
- * @param options - object expression options
716
- * @param isOrdinal - isOrdinal flag
717
- *
718
- * @private
719
- */
720
- private handlePluralKeys (key: string, ns: string | undefined, options: ObjectExpression, isOrdinal: boolean, defaultValueFromCall?: string): void {
721
- try {
722
- const type = isOrdinal ? 'ordinal' : 'cardinal'
723
-
724
- // Generate plural forms for ALL target languages to ensure we have all necessary keys
725
- const allPluralCategories = new Set<string>()
726
-
727
- for (const locale of this.config.locales) {
728
- try {
729
- const pluralRules = new Intl.PluralRules(locale, { type })
730
- const categories = pluralRules.resolvedOptions().pluralCategories
731
- categories.forEach(cat => allPluralCategories.add(cat))
732
- } catch (e) {
733
- // If a locale is invalid, fall back to English rules
734
- const englishRules = new Intl.PluralRules('en', { type })
735
- const categories = englishRules.resolvedOptions().pluralCategories
736
- categories.forEach(cat => allPluralCategories.add(cat))
737
- }
738
- }
739
-
740
- const pluralCategories = Array.from(allPluralCategories).sort()
741
- const pluralSeparator = this.config.extract.pluralSeparator ?? '_'
742
-
743
- // Get all possible default values once at the start
744
- const defaultValue = getObjectPropValue(options, 'defaultValue')
745
- const otherDefault = getObjectPropValue(options, `defaultValue${pluralSeparator}other`)
746
- const ordinalOtherDefault = getObjectPropValue(options, `defaultValue${pluralSeparator}ordinal${pluralSeparator}other`)
747
-
748
- // Get the count value and determine target category if available
749
- const countValue = getObjectPropValue(options, 'count')
750
- let targetCategory: string | undefined
751
-
752
- if (typeof countValue === 'number') {
753
- try {
754
- const primaryLanguage = this.config.extract?.primaryLanguage || this.config.locales[0] || 'en'
755
- const pluralRules = new Intl.PluralRules(primaryLanguage, { type })
756
- targetCategory = pluralRules.select(countValue)
757
- } catch (e) {
758
- // If we can't determine the category, continue with normal logic
759
- }
760
- }
761
-
762
- // Handle context - both static and dynamic
763
- const contextProp = getObjectProperty(options, 'context')
764
- const keysToGenerate: Array<{ key: string, context?: string }> = []
765
-
766
- if (contextProp?.value) {
767
- // Handle dynamic context by resolving all possible values
768
- const contextValues = this.resolvePossibleContextStringValues(contextProp.value)
769
-
770
- if (contextValues.length > 0) {
771
- // Generate keys for each context value
772
- for (const contextValue of contextValues) {
773
- if (contextValue.length > 0) { // Skip empty contexts
774
- keysToGenerate.push({ key, context: contextValue })
775
- }
776
- }
777
-
778
- // For dynamic context, also generate base plural forms if generateBasePluralForms is not disabled
779
- const shouldGenerateBaseForms = this.config.extract?.generateBasePluralForms !== false
780
- if (shouldGenerateBaseForms) {
781
- keysToGenerate.push({ key })
782
- }
783
- } else {
784
- // Couldn't resolve context, fall back to base key only
785
- keysToGenerate.push({ key })
786
- }
787
- } else {
788
- // No context, always generate base plural forms
789
- keysToGenerate.push({ key })
790
- }
791
-
792
- // Generate plural forms for each key variant
793
- for (const { key: baseKey, context } of keysToGenerate) {
794
- for (const category of pluralCategories) {
795
- // 1. Look for the most specific default value
796
- const specificDefaultKey = isOrdinal ? `defaultValue${pluralSeparator}ordinal${pluralSeparator}${category}` : `defaultValue${pluralSeparator}${category}`
797
- const specificDefault = getObjectPropValue(options, specificDefaultKey)
798
-
799
- // 2. Determine the final default value using a clear fallback chain
800
- let finalDefaultValue: string | undefined
801
- if (typeof specificDefault === 'string') {
802
- finalDefaultValue = specificDefault
803
- } else if (category === 'one' && typeof defaultValue === 'string') {
804
- finalDefaultValue = defaultValue
805
- } else if (isOrdinal && typeof ordinalOtherDefault === 'string') {
806
- finalDefaultValue = ordinalOtherDefault
807
- } else if (!isOrdinal && typeof otherDefault === 'string') {
808
- finalDefaultValue = otherDefault
809
- } else if (typeof defaultValue === 'string') {
810
- finalDefaultValue = defaultValue
811
- } else if (defaultValueFromCall && targetCategory === category) {
812
- finalDefaultValue = defaultValueFromCall
813
- } else {
814
- finalDefaultValue = baseKey
815
- }
816
-
817
- // 3. Construct the final plural key
818
- let finalKey: string
819
- if (context) {
820
- const contextSeparator = this.config.extract.contextSeparator ?? '_'
821
- finalKey = isOrdinal
822
- ? `${baseKey}${contextSeparator}${context}${pluralSeparator}ordinal${pluralSeparator}${category}`
823
- : `${baseKey}${contextSeparator}${context}${pluralSeparator}${category}`
824
- } else {
825
- finalKey = isOrdinal
826
- ? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
827
- : `${baseKey}${pluralSeparator}${category}`
828
- }
829
-
830
- this.pluginContext.addKey({
831
- key: finalKey,
832
- ns,
833
- defaultValue: finalDefaultValue,
834
- hasCount: true,
835
- isOrdinal
836
- })
837
- }
838
- }
839
- } catch (e) {
840
- this.logger.warn(`Could not determine plural rules for language "${this.config.extract?.primaryLanguage}". Falling back to simple key extraction.`)
841
- // Fallback to a simple key if Intl API fails
842
- const defaultValue = defaultValueFromCall || getObjectPropValue(options, 'defaultValue')
843
- this.pluginContext.addKey({ key, ns, defaultValue: typeof defaultValue === 'string' ? defaultValue : key })
844
- }
845
- }
846
-
847
- /**
848
- * Processes JSX elements to extract translation keys from Trans components.
849
- *
850
- * Identifies configured Trans components and delegates to the JSX parser
851
- * for complex children serialization and attribute extraction.
852
- *
853
- * @param node - JSX element node to process
854
- *
855
- * @private
856
- */
857
- private handleJSXElement (node: JSXElement): void {
858
- const elementName = this.getElementName(node)
859
-
860
- if (elementName && (this.config.extract.transComponents || ['Trans']).includes(elementName)) {
861
- const extractedAttributes = extractFromTransComponent(node, this.config)
862
-
863
- const keysToProcess: string[] = []
864
-
865
- if (extractedAttributes) {
866
- if (extractedAttributes.keyExpression) {
867
- const keyValues = this.resolvePossibleKeyStringValues(extractedAttributes.keyExpression)
868
- keysToProcess.push(...keyValues)
869
- } else {
870
- keysToProcess.push(extractedAttributes.serializedChildren)
871
- }
872
-
873
- let extractedKeys: ExtractedKey[]
874
-
875
- const { contextExpression, optionsNode, defaultValue, hasCount, isOrdinal, serializedChildren } = extractedAttributes
876
-
877
- // If ns is not explicitly set on the component, try to find it from the key
878
- // or the `t` prop
879
- if (!extractedAttributes.ns) {
880
- extractedKeys = keysToProcess.map(key => {
881
- const nsSeparator = this.config.extract.nsSeparator ?? ':'
882
- let ns: string | undefined
883
-
884
- // If the key contains a namespace separator, it takes precedence
885
- // over the default t ns value
886
- if (nsSeparator && key.includes(nsSeparator)) {
887
- let parts: string[]
888
- ([ns, ...parts] = key.split(nsSeparator))
889
-
890
- key = parts.join(nsSeparator)
891
- }
892
-
893
- return {
894
- key,
895
- ns,
896
- defaultValue: defaultValue || serializedChildren,
897
- hasCount,
898
- isOrdinal,
899
- }
900
- })
901
-
902
- const tProp = node.opening.attributes?.find(
903
- attr =>
904
- attr.type === 'JSXAttribute' &&
905
- attr.name.type === 'Identifier' &&
906
- attr.name.value === 't'
907
- )
908
-
909
- // Check if the prop value is an identifier (e.g., t={t})
910
- if (
911
- tProp?.type === 'JSXAttribute' &&
912
- tProp.value?.type === 'JSXExpressionContainer' &&
913
- tProp.value.expression.type === 'Identifier'
914
- ) {
915
- const tIdentifier = tProp.value.expression.value
916
- const scopeInfo = this.getVarFromScope(tIdentifier)
917
- if (scopeInfo?.defaultNs) {
918
- extractedKeys.forEach(key => {
919
- if (!key.ns) {
920
- key.ns = scopeInfo.defaultNs
921
- }
922
- })
923
- }
924
- }
925
- } else {
926
- const { ns } = extractedAttributes
927
- extractedKeys = keysToProcess.map(key => {
928
- return {
929
- key,
930
- ns,
931
- defaultValue: defaultValue || serializedChildren,
932
- hasCount,
933
- isOrdinal,
934
- }
935
- })
936
- }
937
-
938
- extractedKeys.forEach(key => {
939
- // Apply defaultNS from config if no namespace was found on the component and
940
- // the key does not contain a namespace prefix
941
- if (!key.ns) {
942
- key.ns = this.config.extract.defaultNS
943
- }
944
- })
945
-
946
- // Handle the combination of context and count
947
- if (contextExpression && hasCount) {
948
- // Check if plurals are disabled
949
- if (this.config.extract.disablePlurals) {
950
- // When plurals are disabled, treat count as a regular option
951
- // Still handle context normally
952
- const contextValues = this.resolvePossibleContextStringValues(contextExpression)
953
- const contextSeparator = this.config.extract.contextSeparator ?? '_'
954
-
955
- if (contextValues.length > 0) {
956
- // For static context (string literal), only add context variants
957
- if (contextExpression.type === 'StringLiteral') {
958
- for (const context of contextValues) {
959
- for (const extractedKey of extractedKeys) {
960
- const contextKey = `${extractedKey.key}${contextSeparator}${context}`
961
- this.pluginContext.addKey({ key: contextKey, ns: extractedKey.ns, defaultValue: extractedKey.defaultValue })
962
- }
963
- }
964
- } else {
965
- // For dynamic context, add both base and context variants
966
- extractedKeys.forEach(extractedKey => {
967
- this.pluginContext.addKey({
968
- key: extractedKey.key,
969
- ns: extractedKey.ns,
970
- defaultValue: extractedKey.defaultValue
971
- })
972
- })
973
- for (const context of contextValues) {
974
- for (const extractedKey of extractedKeys) {
975
- const contextKey = `${extractedKey.key}${contextSeparator}${context}`
976
- this.pluginContext.addKey({ key: contextKey, ns: extractedKey.ns, defaultValue: extractedKey.defaultValue })
977
- }
978
- }
979
- }
980
- } else {
981
- // Fallback to just base keys if context resolution fails
982
- extractedKeys.forEach(extractedKey => {
983
- this.pluginContext.addKey({
984
- key: extractedKey.key,
985
- ns: extractedKey.ns,
986
- defaultValue: extractedKey.defaultValue
987
- })
988
- })
989
- }
990
- } else {
991
- // Original plural handling logic when plurals are enabled
992
- // Find isOrdinal prop on the <Trans> component
993
- const ordinalAttr = node.opening.attributes?.find(
994
- (attr) =>
995
- attr.type === 'JSXAttribute' &&
996
- attr.name.type === 'Identifier' &&
997
- attr.name.value === 'ordinal'
998
- )
999
- const isOrdinal = !!ordinalAttr
1000
-
1001
- const contextValues = this.resolvePossibleContextStringValues(contextExpression)
1002
- const contextSeparator = this.config.extract.contextSeparator ?? '_'
1003
-
1004
- // Generate all combinations of context and plural forms
1005
- if (contextValues.length > 0) {
1006
- // Generate base plural forms (no context)
1007
- extractedKeys.forEach(extractedKey => this.generatePluralKeysForTrans(extractedKey.key, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode))
1008
-
1009
- // Generate context + plural combinations
1010
- for (const context of contextValues) {
1011
- for (const extractedKey of extractedKeys) {
1012
- const contextKey = `${extractedKey.key}${contextSeparator}${context}`
1013
- this.generatePluralKeysForTrans(contextKey, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode)
1014
- }
1015
- }
1016
- } else {
1017
- // Fallback to just plural forms if context resolution fails
1018
- extractedKeys.forEach(extractedKey => this.generatePluralKeysForTrans(extractedKey.key, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode))
1019
- }
1020
- }
1021
- } else if (contextExpression) {
1022
- const contextValues = this.resolvePossibleContextStringValues(contextExpression)
1023
- const contextSeparator = this.config.extract.contextSeparator ?? '_'
1024
-
1025
- if (contextValues.length > 0) {
1026
- // Add context variants
1027
- for (const context of contextValues) {
1028
- for (const { key, ns, defaultValue } of extractedKeys) {
1029
- this.pluginContext.addKey({ key: `${key}${contextSeparator}${context}`, ns, defaultValue })
1030
- }
1031
- }
1032
- // Only add the base key as a fallback if the context is dynamic (i.e., not a simple string).
1033
- if (contextExpression.type !== 'StringLiteral') {
1034
- extractedKeys.forEach(extractedKey => {
1035
- this.pluginContext.addKey({
1036
- key: extractedKey.key,
1037
- ns: extractedKey.ns,
1038
- defaultValue: extractedKey.defaultValue
1039
- })
1040
- })
1041
- }
1042
- } else {
1043
- // If no context values were resolved, just add base keys
1044
- extractedKeys.forEach(extractedKey => {
1045
- this.pluginContext.addKey({
1046
- key: extractedKey.key,
1047
- ns: extractedKey.ns,
1048
- defaultValue: extractedKey.defaultValue
1049
- })
1050
- })
1051
- }
1052
- } else if (hasCount) {
1053
- // Check if plurals are disabled
1054
- if (this.config.extract.disablePlurals) {
1055
- // When plurals are disabled, just add the base keys (no plural forms)
1056
- extractedKeys.forEach(extractedKey => {
1057
- this.pluginContext.addKey({
1058
- key: extractedKey.key,
1059
- ns: extractedKey.ns,
1060
- defaultValue: extractedKey.defaultValue
1061
- })
1062
- })
1063
- } else {
1064
- // Original plural handling logic when plurals are enabled
1065
- // Find isOrdinal prop on the <Trans> component
1066
- const ordinalAttr = node.opening.attributes?.find(
1067
- (attr) =>
1068
- attr.type === 'JSXAttribute' &&
1069
- attr.name.type === 'Identifier' &&
1070
- attr.name.value === 'ordinal'
1071
- )
1072
- const isOrdinal = !!ordinalAttr
1073
-
1074
- extractedKeys.forEach(extractedKey => this.generatePluralKeysForTrans(extractedKey.key, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode))
1075
- }
1076
- } else {
1077
- // No count or context - just add the base keys
1078
- extractedKeys.forEach(extractedKey => {
1079
- this.pluginContext.addKey({
1080
- key: extractedKey.key,
1081
- ns: extractedKey.ns,
1082
- defaultValue: extractedKey.defaultValue
1083
- })
1084
- })
1085
- }
1086
- }
1087
- }
1088
- }
1089
-
1090
- /**
1091
- * Generates plural keys for Trans components, with support for tOptions plural defaults.
1092
- *
1093
- * @param key - Base key name for pluralization
1094
- * @param defaultValue - Default value for the keys
1095
- * @param ns - Namespace for the keys
1096
- * @param isOrdinal - Whether to generate ordinal plural forms
1097
- * @param optionsNode - Optional tOptions object expression for plural-specific defaults
1098
- *
1099
- * @private
1100
- */
1101
- private generatePluralKeysForTrans (key: string, defaultValue: string | undefined, ns: string | undefined, isOrdinal: boolean, optionsNode?: ObjectExpression): void {
1102
- try {
1103
- const type = isOrdinal ? 'ordinal' : 'cardinal'
1104
- const pluralCategories = new Intl.PluralRules(this.config.extract?.primaryLanguage, { type }).resolvedOptions().pluralCategories
1105
- const pluralSeparator = this.config.extract.pluralSeparator ?? '_'
1106
-
1107
- // Get plural-specific default values from tOptions if available
1108
- let otherDefault: string | undefined
1109
- let ordinalOtherDefault: string | undefined
1110
-
1111
- if (optionsNode) {
1112
- otherDefault = getObjectPropValue(optionsNode, `defaultValue${pluralSeparator}other`) as string | undefined
1113
- ordinalOtherDefault = getObjectPropValue(optionsNode, `defaultValue${pluralSeparator}ordinal${pluralSeparator}other`) as string | undefined
1114
- }
1115
-
1116
- for (const category of pluralCategories) {
1117
- // Look for the most specific default value (e.g., defaultValue_ordinal_one)
1118
- const specificDefaultKey = isOrdinal ? `defaultValue${pluralSeparator}ordinal${pluralSeparator}${category}` : `defaultValue${pluralSeparator}${category}`
1119
- const specificDefault = optionsNode ? getObjectPropValue(optionsNode, specificDefaultKey) as string | undefined : undefined
1120
-
1121
- // Determine the final default value using a clear fallback chain
1122
- let finalDefaultValue: string | undefined
1123
- if (typeof specificDefault === 'string') {
1124
- // 1. Use the most specific default if it exists (e.g., defaultValue_one)
1125
- finalDefaultValue = specificDefault
1126
- } else if (category === 'one' && typeof defaultValue === 'string') {
1127
- // 2. SPECIAL CASE: The 'one' category falls back to the main default value (children content)
1128
- finalDefaultValue = defaultValue
1129
- } else if (isOrdinal && typeof ordinalOtherDefault === 'string') {
1130
- // 3a. Other ordinal categories fall back to 'defaultValue_ordinal_other'
1131
- finalDefaultValue = ordinalOtherDefault
1132
- } else if (!isOrdinal && typeof otherDefault === 'string') {
1133
- // 3b. Other cardinal categories fall back to 'defaultValue_other'
1134
- finalDefaultValue = otherDefault
1135
- } else if (typeof defaultValue === 'string') {
1136
- // 4. If no '_other' is found, all categories can fall back to the main default value
1137
- finalDefaultValue = defaultValue
1138
- } else {
1139
- // 5. Final fallback to the base key itself
1140
- finalDefaultValue = key
1141
- }
1142
-
1143
- const finalKey = isOrdinal
1144
- ? `${key}${pluralSeparator}ordinal${pluralSeparator}${category}`
1145
- : `${key}${pluralSeparator}${category}`
1146
-
1147
- this.pluginContext.addKey({
1148
- key: finalKey,
1149
- ns,
1150
- defaultValue: finalDefaultValue,
1151
- hasCount: true,
1152
- isOrdinal
1153
- })
1154
- }
1155
- } catch (e) {
1156
- this.logger.warn(`Could not determine plural rules for language "${this.config.extract?.primaryLanguage}". Falling back to simple key extraction.`)
1157
- // Fallback to a simple key if Intl API fails
1158
- this.pluginContext.addKey({ key, ns, defaultValue })
1159
- }
1160
- }
1161
-
1162
- /**
1163
- * Extracts element name from JSX opening tag.
1164
- *
1165
- * Handles both simple identifiers and member expressions:
1166
- * - `<Trans>` → 'Trans'
1167
- * - `<React.Trans>` → 'React.Trans'
1168
- *
1169
- * @param node - JSX element node
1170
- * @returns Element name or undefined if not extractable
1171
- *
1172
- * @private
1173
- */
1174
- private getElementName (node: JSXElement): string | undefined {
1175
- if (node.opening.name.type === 'Identifier') {
1176
- return node.opening.name.value
1177
- } else if (node.opening.name.type === 'JSXMemberExpression') {
1178
- let curr: any = node.opening.name
1179
- const names: string[] = []
1180
- while (curr.type === 'JSXMemberExpression') {
1181
- if (curr.property.type === 'Identifier') names.unshift(curr.property.value)
1182
- curr = curr.object
1183
- }
1184
- if (curr.type === 'Identifier') names.unshift(curr.value)
1185
- return names.join('.')
1186
- }
1187
- return undefined
1188
- }
1189
-
1190
- /**
1191
- * Extracts translation key from selector API arrow function.
1192
- *
1193
- * Processes selector expressions like:
1194
- * - `$ => $.path.to.key` → 'path.to.key'
1195
- * - `$ => $.app['title'].main` → 'app.title.main'
1196
- * - `$ => { return $.nested.key; }` → 'nested.key'
1197
- *
1198
- * Handles both dot notation and bracket notation, respecting
1199
- * the configured key separator or flat key structure.
1200
- *
1201
- * @param node - Arrow function expression from selector call
1202
- * @returns Extracted key path or null if not statically analyzable
1203
- *
1204
- * @private
1205
- */
1206
- private extractKeyFromSelector (node: ArrowFunctionExpression): string | null {
1207
- let body = node.body
1208
-
1209
- // Handle block bodies, e.g., $ => { return $.key; }
1210
- if (body.type === 'BlockStatement') {
1211
- const returnStmt = body.stmts.find(s => s.type === 'ReturnStatement')
1212
- if (returnStmt?.type === 'ReturnStatement' && returnStmt.argument) {
1213
- body = returnStmt.argument
1214
- } else {
1215
- return null
1216
- }
1217
- }
1218
-
1219
- let current = body
1220
- const parts: string[] = []
1221
-
1222
- // Recursively walk down MemberExpressions
1223
- while (current.type === 'MemberExpression') {
1224
- const prop = current.property
1225
-
1226
- if (prop.type === 'Identifier') {
1227
- // This handles dot notation: .key
1228
- parts.unshift(prop.value)
1229
- } else if (prop.type === 'Computed' && prop.expression.type === 'StringLiteral') {
1230
- // This handles bracket notation: ['key']
1231
- parts.unshift(prop.expression.value)
1232
- } else {
1233
- // This is a dynamic property like [myVar] or a private name, which we cannot resolve.
1234
- return null
1235
- }
1236
-
1237
- current = current.object
1238
- }
1239
-
1240
- if (parts.length > 0) {
1241
- const keySeparator = this.config.extract.keySeparator
1242
- const joiner = typeof keySeparator === 'string' ? keySeparator : '.'
1243
- return parts.join(joiner)
1244
- }
1245
-
1246
- return null
1247
- }
1248
-
1249
- /**
1250
- * Resolves an expression to one or more possible context string values that can be
1251
- * determined statically from the AST. This is a wrapper around the plugin hook
1252
- * `extractContextFromExpression` and {@link resolvePossibleStringValuesFromExpression}.
1253
- *
1254
- * @param expression - The SWC AST expression node to resolve
1255
- * @returns An array of possible context string values that the expression may produce.
1256
- *
1257
- * @private
1258
- */
1259
- private resolvePossibleContextStringValues (expression: Expression) {
1260
- const strings = this.hooks.resolvePossibleContextStringValues?.(expression) ?? []
1261
-
1262
- return [...strings, ...this.resolvePossibleStringValuesFromExpression(expression)]
1263
- }
1264
-
1265
- /**
1266
- * Resolves an expression to one or more possible key string values that can be
1267
- * determined statically from the AST. This is a wrapper around the plugin hook
1268
- * `extractKeysFromExpression` and {@link resolvePossibleStringValuesFromExpression}.
1269
- *
1270
- * @param expression - The SWC AST expression node to resolve
1271
- * @returns An array of possible key string values that the expression may produce.
1272
- *
1273
- * @private
1274
- */
1275
- private resolvePossibleKeyStringValues (expression: Expression) {
1276
- const strings = this.hooks.resolvePossibleKeyStringValues?.(expression) ?? []
1277
-
1278
- return [...strings, ...this.resolvePossibleStringValuesFromExpression(expression)]
1279
- }
1280
-
1281
- /**
1282
- * Resolves an expression to one or more possible string values that can be
1283
- * determined statically from the AST.
1284
- *
1285
- * Supports:
1286
- * - StringLiteral -> single value (filtered to exclude empty strings for context)
1287
- * - NumericLiteral -> single value
1288
- * - BooleanLiteral -> single value
1289
- * - ConditionalExpression (ternary) -> union of consequent and alternate resolved values
1290
- * - TemplateLiteral -> union of all possible string values
1291
- * - The identifier `undefined` -> empty array
1292
- *
1293
- * For any other expression types (identifiers, function calls, member expressions,
1294
- * etc.) the value cannot be determined statically and an empty array is returned.
1295
- *
1296
- * @private
1297
- * @param expression - The SWC AST expression node to resolve
1298
- * @param returnEmptyStrings - Whether to include empty strings in the result
1299
- * @returns An array of possible string values that the expression may produce.
1300
- */
1301
- private resolvePossibleStringValuesFromExpression (expression: Expression, returnEmptyStrings = false): string[] {
1302
- if (expression.type === 'StringLiteral') {
1303
- // Filter out empty strings as they should be treated as "no context" like i18next does
1304
- return expression.value || returnEmptyStrings ? [expression.value] : []
1305
- }
1306
-
1307
- if (expression.type === 'ConditionalExpression') { // This is a ternary operator
1308
- const consequentValues = this.resolvePossibleStringValuesFromExpression(expression.consequent, returnEmptyStrings)
1309
- const alternateValues = this.resolvePossibleStringValuesFromExpression(expression.alternate, returnEmptyStrings)
1310
- return [...consequentValues, ...alternateValues]
1311
- }
1312
-
1313
- if (expression.type === 'Identifier' && expression.value === 'undefined') {
1314
- return [] // Handle the `undefined` case
1315
- }
1316
-
1317
- if (expression.type === 'TemplateLiteral') {
1318
- return this.resolvePossibleStringValuesFromTemplateString(expression)
1319
- }
1320
-
1321
- if (expression.type === 'NumericLiteral' || expression.type === 'BooleanLiteral') {
1322
- return [`${expression.value}`] // Handle literals like 5 or true
1323
- }
1324
-
1325
- // Support building translation keys for
1326
- // `variable satisfies 'coaching' | 'therapy'`
1327
- if (expression.type === 'TsSatisfiesExpression' || expression.type === 'TsAsExpression') {
1328
- const annotation = expression.typeAnnotation
1329
-
1330
- return this.resolvePossibleStringValuesFromType(annotation, returnEmptyStrings)
1331
- }
1332
-
1333
- // We can't statically determine the value of other expressions (e.g., variables, function calls)
1334
- return []
1335
- }
1336
-
1337
- private resolvePossibleStringValuesFromType (type: TsType, returnEmptyStrings = false): string[] {
1338
- if (type.type === 'TsUnionType') {
1339
- return type.types.flatMap((t) => this.resolvePossibleStringValuesFromType(t, returnEmptyStrings))
1340
- }
1341
-
1342
- if (type.type === 'TsLiteralType') {
1343
- if (type.literal.type === 'StringLiteral') {
1344
- // Filter out empty strings as they should be treated as "no context" like i18next does
1345
- return type.literal.value || returnEmptyStrings ? [type.literal.value] : []
1346
- }
1347
-
1348
- if (type.literal.type === 'TemplateLiteral') {
1349
- return this.resolvePossibleStringValuesFromTemplateLiteralType(type.literal)
1350
- }
1351
-
1352
- if (type.literal.type === 'NumericLiteral' || type.literal.type === 'BooleanLiteral') {
1353
- return [`${type.literal.value}`] // Handle literals like 5 or true
1354
- }
1355
- }
1356
-
1357
- // We can't statically determine the value of other expressions (e.g., variables, function calls)
1358
- return []
1359
- }
1360
-
1361
- /**
1362
- * Resolves a template literal string to one or more possible strings that can be
1363
- * determined statically from the AST.
1364
- *
1365
- * @private
1366
- * @param templateString - The SWC AST template literal string to resolve
1367
- * @returns An array of possible string values that the template may produce.
1368
- */
1369
- private resolvePossibleStringValuesFromTemplateString (templateString: TemplateLiteral): string[] {
1370
- // If there are no expressions, we can just return the cooked value
1371
- if (templateString.quasis.length === 1 && templateString.expressions.length === 0) {
1372
- // Ex. `translation.key.no.substitution`
1373
- return [templateString.quasis[0].cooked || '']
1374
- }
1375
-
1376
- // Ex. `translation.key.with.expression.${x ? 'title' : 'description'}`
1377
- const [firstQuasis, ...tails] = templateString.quasis
1378
-
1379
- const stringValues = templateString.expressions.reduce(
1380
- (heads, expression, i) => {
1381
- return heads.flatMap((head) => {
1382
- const tail = tails[i]?.cooked ?? ''
1383
- return this.resolvePossibleStringValuesFromExpression(expression, true).map(
1384
- (expressionValue) => `${head}${expressionValue}${tail}`
1385
- )
1386
- })
1387
- },
1388
- [firstQuasis.cooked ?? '']
1389
- )
1390
-
1391
- return stringValues
1392
- }
1393
-
1394
- /**
1395
- * Resolves a template literal type to one or more possible strings that can be
1396
- * determined statically from the AST.
1397
- *
1398
- * @private
1399
- * @param templateLiteralType - The SWC AST template literal type to resolve
1400
- * @returns An array of possible string values that the template may produce.
1401
- */
1402
- private resolvePossibleStringValuesFromTemplateLiteralType (templateLiteralType: TsTemplateLiteralType): string[] {
1403
- // If there are no types, we can just return the cooked value
1404
- if (templateLiteralType.quasis.length === 1 && templateLiteralType.types.length === 0) {
1405
- // Ex. `translation.key.no.substitution`
1406
- return [templateLiteralType.quasis[0].cooked || '']
1407
- }
1408
-
1409
- // Ex. `translation.key.with.expression.${'title' | 'description'}`
1410
- const [firstQuasis, ...tails] = templateLiteralType.quasis
1411
-
1412
- const stringValues = templateLiteralType.types.reduce(
1413
- (heads, type, i) => {
1414
- return heads.flatMap((head) => {
1415
- const tail = tails[i]?.cooked ?? ''
1416
- return this.resolvePossibleStringValuesFromType(type, true).map(
1417
- (expressionValue) => `${head}${expressionValue}${tail}`
1418
- )
1419
- })
1420
- },
1421
- [firstQuasis.cooked ?? '']
1422
- )
1423
-
1424
- return stringValues
1425
- }
1426
-
1427
- /**
1428
- * Finds the configuration for a given useTranslation function name.
1429
- * Applies default argument positions if none are specified.
1430
- *
1431
- * @param name - The identifier name to look up in the configured useTranslationNames
1432
- * @returns The resolved UseTranslationHookConfig when a match is found, otherwise undefined
1433
- */
1434
- private getUseTranslationConfig (name: string): UseTranslationHookConfig | undefined {
1435
- const useTranslationNames = this.config.extract.useTranslationNames || ['useTranslation']
1436
-
1437
- for (const item of useTranslationNames) {
1438
- if (typeof item === 'string' && item === name) {
1439
- // Default behavior for simple string entries
1440
- return { name, nsArg: 0, keyPrefixArg: 1 }
1441
- }
1442
- if (typeof item === 'object' && item.name === name) {
1443
- // Custom configuration with specified or default argument positions
1444
- return {
1445
- name: item.name,
1446
- nsArg: item.nsArg ?? 0,
1447
- keyPrefixArg: item.keyPrefixArg ?? 1,
1448
- }
1449
- }
1450
- }
1451
- return undefined
1452
- }
1453
-
1454
- /**
1455
- * Serializes a callee node (Identifier or MemberExpression) into a string.
1456
- *
1457
- * Produces a dotted name for simple callees that can be used for scope lookups
1458
- * or configuration matching.
1459
- *
1460
- * Supported inputs:
1461
- * - Identifier: returns the identifier name (e.g., `t` -> "t")
1462
- * - MemberExpression with Identifier parts: returns a dotted path of identifiers
1463
- * (e.g., `i18n.t` -> "i18n.t", `i18n.getFixedT` -> "i18n.getFixedT")
1464
- *
1465
- * Behavior notes:
1466
- * - Computed properties are not supported and cause this function to return null
1467
- * (e.g., `i18n['t']` -> null).
1468
- * - The base of a MemberExpression must be a simple Identifier. More complex bases
1469
- * (other expressions, `this`, etc.) will result in null.
1470
- * - This function does not attempt to resolve or evaluate expressions — it only
1471
- * serializes static identifier/member chains.
1472
- *
1473
- * Examples:
1474
- * - Identifier callee: { type: 'Identifier', value: 't' } -> "t"
1475
- * - Member callee: { type: 'MemberExpression', object: { type: 'Identifier', value: 'i18n' }, property: { type: 'Identifier', value: 't' } } -> "i18n.t"
1476
- *
1477
- * @param callee - The CallExpression callee node to serialize
1478
- * @returns A dotted string name for supported callees, or null when the callee
1479
- * is a computed/unsupported expression.
1480
- *
1481
- * @private
1482
- */
1483
- private getFunctionName (callee: CallExpression['callee']): string | null {
1484
- if (callee.type === 'Identifier') {
1485
- return callee.value
1486
- }
1487
- if (callee.type === 'MemberExpression') {
1488
- const parts: string[] = []
1489
- let current: any = callee
1490
- while (current.type === 'MemberExpression') {
1491
- if (current.property.type === 'Identifier') {
1492
- parts.unshift(current.property.value)
1493
- } else {
1494
- return null // Cannot handle computed properties like i18n['t']
1495
- }
1496
- current = current.object
1497
- }
1498
- // Handle `this` as the base of the expression (e.g., this._i18n.t)
1499
- if (current.type === 'ThisExpression') {
1500
- parts.unshift('this')
1501
- } else if (current.type === 'Identifier') {
1502
- parts.unshift(current.value)
1503
- } else {
1504
- return null // Base of the expression is not a simple identifier
1505
- }
1506
- return parts.join('.')
1507
- }
1508
- return null
1509
- }
1510
- }