i18next-cli 1.6.0 → 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (32) hide show
  1. package/CHANGELOG.md +11 -0
  2. package/README.md +108 -46
  3. package/dist/cjs/cli.js +1 -1
  4. package/dist/cjs/extractor/core/extractor.js +1 -1
  5. package/dist/cjs/extractor/core/key-finder.js +1 -1
  6. package/dist/cjs/extractor/parsers/ast-visitors.js +1 -1
  7. package/dist/cjs/extractor/parsers/comment-parser.js +1 -1
  8. package/dist/cjs/extractor/plugin-manager.js +1 -1
  9. package/dist/esm/cli.js +1 -1
  10. package/dist/esm/extractor/core/extractor.js +1 -1
  11. package/dist/esm/extractor/core/key-finder.js +1 -1
  12. package/dist/esm/extractor/parsers/ast-visitors.js +1 -1
  13. package/dist/esm/extractor/parsers/comment-parser.js +1 -1
  14. package/dist/esm/extractor/plugin-manager.js +1 -1
  15. package/package.json +1 -1
  16. package/src/cli.ts +1 -1
  17. package/src/extractor/core/extractor.ts +13 -51
  18. package/src/extractor/core/key-finder.ts +48 -9
  19. package/src/extractor/parsers/ast-visitors.ts +171 -20
  20. package/src/extractor/parsers/comment-parser.ts +116 -2
  21. package/src/extractor/plugin-manager.ts +8 -3
  22. package/src/types.ts +26 -0
  23. package/types/extractor/core/extractor.d.ts +2 -2
  24. package/types/extractor/core/extractor.d.ts.map +1 -1
  25. package/types/extractor/core/key-finder.d.ts.map +1 -1
  26. package/types/extractor/parsers/ast-visitors.d.ts +48 -3
  27. package/types/extractor/parsers/ast-visitors.d.ts.map +1 -1
  28. package/types/extractor/parsers/comment-parser.d.ts.map +1 -1
  29. package/types/extractor/plugin-manager.d.ts +2 -2
  30. package/types/extractor/plugin-manager.d.ts.map +1 -1
  31. package/types/types.d.ts +24 -0
  32. package/types/types.d.ts.map +1 -1
@@ -3,11 +3,10 @@ import chalk from 'chalk'
3
3
  import { parse } from '@swc/core'
4
4
  import { mkdir, readFile, writeFile } from 'node:fs/promises'
5
5
  import { dirname } from 'node:path'
6
- import type { Logger, ExtractedKey, PluginContext, I18nextToolkitConfig } from '../../types'
6
+ import type { Logger, I18nextToolkitConfig, Plugin, PluginContext } from '../../types'
7
7
  import { findKeys } from './key-finder'
8
8
  import { getTranslations } from './translation-manager'
9
9
  import { validateExtractorConfig, ExtractorError } from '../../utils/validation'
10
- import { createPluginContext } from '../plugin-manager'
11
10
  import { extractKeysFromComments } from '../parsers/comment-parser'
12
11
  import { ASTVisitors } from '../parsers/ast-visitors'
13
12
  import { ConsoleLogger } from '../../utils/logger'
@@ -60,6 +59,8 @@ export async function runExtractor (
60
59
 
61
60
  validateExtractorConfig(config)
62
61
 
62
+ const plugins = config.plugins || []
63
+
63
64
  const spinner = ora('Running i18next key extractor...\n').start()
64
65
 
65
66
  try {
@@ -87,9 +88,9 @@ export async function runExtractor (
87
88
  }
88
89
 
89
90
  // Run afterSync hooks from plugins
90
- if ((config.plugins || []).length > 0) {
91
+ if (plugins.length > 0) {
91
92
  spinner.text = 'Running post-extraction plugins...'
92
- for (const plugin of (config.plugins || [])) {
93
+ for (const plugin of plugins) {
93
94
  await plugin.afterSync?.(results, config)
94
95
  }
95
96
  }
@@ -129,16 +130,17 @@ export async function runExtractor (
129
130
  */
130
131
  export async function processFile (
131
132
  file: string,
132
- config: I18nextToolkitConfig,
133
- allKeys: Map<string, ExtractedKey>,
133
+ plugins: Plugin[],
134
134
  astVisitors: ASTVisitors,
135
+ pluginContext: PluginContext,
136
+ config: Omit<I18nextToolkitConfig, 'plugins'>,
135
137
  logger: Logger = new ConsoleLogger()
136
138
  ): Promise<void> {
137
139
  try {
138
140
  let code = await readFile(file, 'utf-8')
139
141
 
140
142
  // Run onLoad hooks from plugins with error handling
141
- for (const plugin of (config.plugins || [])) {
143
+ for (const plugin of plugins) {
142
144
  try {
143
145
  const result = await plugin.onLoad?.(code, file)
144
146
  if (result !== undefined) {
@@ -157,60 +159,20 @@ export async function processFile (
157
159
  comments: true
158
160
  })
159
161
 
160
- // 1. Create the base context with config and logger.
161
- const pluginContext = createPluginContext(allKeys, config, logger)
162
-
163
- // 2. "Wire up" the visitor's scope method to the context.
162
+ // "Wire up" the visitor's scope method to the context.
164
163
  // This avoids a circular dependency while giving plugins access to the scope.
165
164
  pluginContext.getVarFromScope = astVisitors.getVarFromScope.bind(astVisitors)
166
165
 
167
- // Extract keys from comments with scope resolution
168
- extractKeysFromComments(code, pluginContext, config, astVisitors.getVarFromScope.bind(astVisitors))
169
-
166
+ // 3. FIRST: Visit the AST to build scope information
170
167
  astVisitors.visit(ast)
171
168
 
172
- // Run plugin visitors
173
- if ((config.plugins || []).length > 0) {
174
- traverseEveryNode(ast, (config.plugins || []), pluginContext, logger)
175
- }
169
+ // 4. THEN: Extract keys from comments with scope resolution (now scope info is available)
170
+ extractKeysFromComments(code, pluginContext, config, astVisitors.getVarFromScope.bind(astVisitors))
176
171
  } catch (error) {
177
172
  throw new ExtractorError('Failed to process file', file, error as Error)
178
173
  }
179
174
  }
180
175
 
181
- /**
182
- * Recursively traverses AST nodes and calls plugin onVisitNode hooks.
183
- *
184
- * @param node - The AST node to traverse
185
- * @param plugins - Array of plugins to run hooks for
186
- * @param pluginContext - Context object with helper methods for plugins
187
- *
188
- * @internal
189
- */
190
- function traverseEveryNode (node: any, plugins: any[], pluginContext: PluginContext, logger: Logger = new ConsoleLogger()): void {
191
- if (!node || typeof node !== 'object') return
192
-
193
- // Call plugins for this node
194
- for (const plugin of plugins) {
195
- try {
196
- plugin.onVisitNode?.(node, pluginContext)
197
- } catch (err) {
198
- logger.warn(`Plugin ${plugin.name} onVisitNode failed:`, err)
199
- }
200
- }
201
-
202
- for (const key of Object.keys(node)) {
203
- const child = node[key]
204
- if (Array.isArray(child)) {
205
- for (const c of child) {
206
- if (c && typeof c === 'object') traverseEveryNode(c, plugins, pluginContext, logger)
207
- }
208
- } else if (child && typeof child === 'object') {
209
- traverseEveryNode(child, plugins, pluginContext, logger)
210
- }
211
- }
212
- }
213
-
214
176
  /**
215
177
  * Simplified extraction function that returns translation results without file writing.
216
178
  * Used primarily for testing and programmatic access.
@@ -1,9 +1,10 @@
1
1
  import { glob } from 'glob'
2
+ import type { Expression } from '@swc/core'
2
3
  import type { ExtractedKey, Logger, I18nextToolkitConfig } from '../../types'
3
4
  import { processFile } from './extractor'
4
5
  import { ConsoleLogger } from '../../utils/logger'
5
6
  import { initializePlugins, createPluginContext } from '../plugin-manager'
6
- import { ASTVisitors } from '../parsers/ast-visitors'
7
+ import { type ASTVisitorHooks, ASTVisitors } from '../parsers/ast-visitors'
7
8
 
8
9
  /**
9
10
  * Main function for finding translation keys across all source files in a project.
@@ -37,27 +38,65 @@ export async function findKeys (
37
38
  config: I18nextToolkitConfig,
38
39
  logger: Logger = new ConsoleLogger()
39
40
  ): Promise<{ allKeys: Map<string, ExtractedKey>, objectKeys: Set<string> }> {
41
+ const { plugins: pluginsOrUndefined, ...otherConfig } = config
42
+ const plugins = pluginsOrUndefined || []
43
+
40
44
  const sourceFiles = await processSourceFiles(config)
41
45
  const allKeys = new Map<string, ExtractedKey>()
42
46
 
43
47
  // 1. Create the base context with config and logger.
44
- const pluginContext = createPluginContext(allKeys, config, logger)
48
+ const pluginContext = createPluginContext(allKeys, plugins, otherConfig, logger)
49
+
50
+ // 2. Create hooks for plugins to hook into AST
51
+ const hooks = {
52
+ onBeforeVisitNode: (node) => {
53
+ for (const plugin of plugins) {
54
+ try {
55
+ plugin.onVisitNode?.(node, pluginContext)
56
+ } catch (err) {
57
+ logger.warn(`Plugin ${plugin.name} onVisitNode failed:`, err)
58
+ }
59
+ }
60
+ },
61
+ resolvePossibleKeyStringValues: (expression: Expression) => {
62
+ return plugins.flatMap(plugin => {
63
+ try {
64
+ return plugin.extractKeysFromExpression?.(expression, config, logger) ?? []
65
+ } catch (err) {
66
+ logger.warn(`Plugin ${plugin.name} extractKeysFromExpression failed:`, err)
67
+ return []
68
+ }
69
+ })
70
+ },
71
+ resolvePossibleContextStringValues: (expression: Expression) => {
72
+ return plugins.flatMap(plugin => {
73
+ try {
74
+ return plugin.extractContextFromExpression?.(expression, config, logger) ?? []
75
+ } catch (err) {
76
+ logger.warn(`Plugin ${plugin.name} extractContextFromExpression failed:`, err)
77
+ return []
78
+ }
79
+ })
80
+ },
81
+ } satisfies ASTVisitorHooks
45
82
 
46
- // 2. Create the visitor instance, passing it the context.
47
- const astVisitors = new ASTVisitors(config, pluginContext, logger)
83
+ // 3. Create the visitor instance, passing it the context.
84
+ const astVisitors = new ASTVisitors(otherConfig, pluginContext, logger, hooks)
48
85
 
49
- // 3. "Wire up" the visitor's scope method to the context.
86
+ // 4. "Wire up" the visitor's scope method to the context.
50
87
  // This avoids a circular dependency while giving plugins access to the scope.
51
88
  pluginContext.getVarFromScope = astVisitors.getVarFromScope.bind(astVisitors)
52
89
 
53
- await initializePlugins(config.plugins || [])
90
+ // 5. Initialize plugins
91
+ await initializePlugins(plugins)
54
92
 
93
+ // 6. Process each file
55
94
  for (const file of sourceFiles) {
56
- await processFile(file, config, allKeys, astVisitors, logger)
95
+ await processFile(file, plugins, astVisitors, pluginContext, otherConfig, logger)
57
96
  }
58
97
 
59
- // Run onEnd hooks
60
- for (const plugin of (config.plugins || [])) {
98
+ // 7. Run onEnd hooks
99
+ for (const plugin of plugins) {
61
100
  await plugin.onEnd?.(allKeys)
62
101
  }
63
102
 
@@ -9,6 +9,13 @@ interface UseTranslationHookConfig {
9
9
  keyPrefixArg: number;
10
10
  }
11
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
+
12
19
  /**
13
20
  * AST visitor class that traverses JavaScript/TypeScript syntax trees to extract translation keys.
14
21
  *
@@ -33,12 +40,15 @@ interface UseTranslationHookConfig {
33
40
  */
34
41
  export class ASTVisitors {
35
42
  private readonly pluginContext: PluginContext
36
- private readonly config: I18nextToolkitConfig
43
+ private readonly config: Omit<I18nextToolkitConfig, 'plugins'>
37
44
  private readonly logger: Logger
38
45
  private scopeStack: Array<Map<string, ScopeInfo>> = []
46
+ private hooks: ASTVisitorHooks
39
47
 
40
48
  public objectKeys = new Set<string>()
41
49
 
50
+ private scope: Map<string, { defaultNs?: string; keyPrefix?: string }> = new Map()
51
+
42
52
  /**
43
53
  * Creates a new AST visitor instance.
44
54
  *
@@ -47,13 +57,20 @@ export class ASTVisitors {
47
57
  * @param logger - Logger for warnings and debug information
48
58
  */
49
59
  constructor (
50
- config: I18nextToolkitConfig,
60
+ config: Omit<I18nextToolkitConfig, 'plugins'>,
51
61
  pluginContext: PluginContext,
52
- logger: Logger
62
+ logger: Logger,
63
+ hooks?: ASTVisitorHooks
53
64
  ) {
54
65
  this.pluginContext = pluginContext
55
66
  this.config = config
56
67
  this.logger = logger
68
+ this.hooks = {
69
+ onBeforeVisitNode: hooks?.onBeforeVisitNode,
70
+ onAfterVisitNode: hooks?.onAfterVisitNode,
71
+ resolvePossibleKeyStringValues: hooks?.resolvePossibleKeyStringValues,
72
+ resolvePossibleContextStringValues: hooks?.resolvePossibleContextStringValues
73
+ }
57
74
  }
58
75
 
59
76
  /**
@@ -91,6 +108,8 @@ export class ASTVisitors {
91
108
  isNewScope = true
92
109
  }
93
110
 
111
+ this.hooks.onBeforeVisitNode?.(node)
112
+
94
113
  // --- VISIT LOGIC ---
95
114
  // Handle specific node types
96
115
  switch (node.type) {
@@ -104,6 +123,9 @@ export class ASTVisitors {
104
123
  this.handleJSXElement(node)
105
124
  break
106
125
  }
126
+
127
+ this.hooks.onAfterVisitNode?.(node)
128
+
107
129
  // --- END VISIT LOGIC ---
108
130
 
109
131
  // --- RECURSION ---
@@ -180,11 +202,20 @@ export class ASTVisitors {
180
202
  * @private
181
203
  */
182
204
  public getVarFromScope (name: string): ScopeInfo | undefined {
205
+ // First check the proper scope stack (this is the primary source of truth)
183
206
  for (let i = this.scopeStack.length - 1; i >= 0; i--) {
184
207
  if (this.scopeStack[i].has(name)) {
185
- return this.scopeStack[i].get(name)
208
+ const scopeInfo = this.scopeStack[i].get(name)
209
+ return scopeInfo
186
210
  }
187
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
+
188
219
  return undefined
189
220
  }
190
221
 
@@ -222,6 +253,9 @@ export class ASTVisitors {
222
253
  const hookConfig = this.getUseTranslationConfig(callee.value)
223
254
  if (hookConfig) {
224
255
  this.handleUseTranslationDeclarator(node, callExpr, hookConfig)
256
+
257
+ // ALSO store in the legacy scope for comment parsing compatibility
258
+ this.handleUseTranslationForComments(node, callExpr, hookConfig)
225
259
  return
226
260
  }
227
261
  }
@@ -236,6 +270,84 @@ export class ASTVisitors {
236
270
  }
237
271
  }
238
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
+
239
351
  /**
240
352
  * Processes useTranslation hook declarations to extract scope information.
241
353
  *
@@ -456,8 +568,17 @@ export class ASTVisitors {
456
568
  const keysWithContext: ExtractedKey[] = []
457
569
 
458
570
  // 1. Handle Context
459
- if (contextProp?.value?.type === 'ConditionalExpression') {
460
- const contextValues = this.resolvePossibleStringValues(contextProp.value)
571
+ if (contextProp?.value?.type === 'StringLiteral' || contextProp?.value.type === 'NumericLiteral' || contextProp?.value.type === 'BooleanLiteral') {
572
+ // If the context is static, we don't need to add the base key
573
+ const contextValue = `${contextProp.value.value}`
574
+
575
+ const contextSeparator = this.config.extract.contextSeparator ?? '_'
576
+ // Ignore context: ''
577
+ if (contextValue !== '') {
578
+ keysWithContext.push({ key: `${finalKey}${contextSeparator}${contextValue}`, ns, defaultValue: dv })
579
+ }
580
+ } else if (contextProp?.value) {
581
+ const contextValues = this.resolvePossibleContextStringValues(contextProp.value)
461
582
  const contextSeparator = this.config.extract.contextSeparator ?? '_'
462
583
 
463
584
  if (contextValues.length > 0) {
@@ -467,11 +588,6 @@ export class ASTVisitors {
467
588
  // For dynamic context, also add the base key as a fallback
468
589
  keysWithContext.push({ key: finalKey, ns, defaultValue: dv })
469
590
  }
470
- } else if (contextProp?.value?.type === 'StringLiteral') {
471
- const contextValue = contextProp.value.value
472
-
473
- const contextSeparator = this.config.extract.contextSeparator ?? '_'
474
- keysWithContext.push({ key: `${finalKey}${contextSeparator}${contextValue}`, ns, defaultValue: dv })
475
591
  }
476
592
 
477
593
  // 2. Handle Plurals
@@ -540,11 +656,11 @@ export class ASTVisitors {
540
656
  } else if (firstArg.type === 'ArrayExpression') {
541
657
  for (const element of firstArg.elements) {
542
658
  if (element?.expression) {
543
- keysToProcess.push(...this.resolvePossibleStringValues(element.expression))
659
+ keysToProcess.push(...this.resolvePossibleKeyStringValues(element.expression))
544
660
  }
545
661
  }
546
662
  } else {
547
- keysToProcess.push(...this.resolvePossibleStringValues(firstArg))
663
+ keysToProcess.push(...this.resolvePossibleKeyStringValues(firstArg))
548
664
  }
549
665
 
550
666
  return {
@@ -674,7 +790,7 @@ export class ASTVisitors {
674
790
 
675
791
  if (extractedAttributes) {
676
792
  if (extractedAttributes.keyExpression) {
677
- const keyValues = this.resolvePossibleStringValues(extractedAttributes.keyExpression)
793
+ const keyValues = this.resolvePossibleKeyStringValues(extractedAttributes.keyExpression)
678
794
  keysToProcess.push(...keyValues)
679
795
  } else {
680
796
  keysToProcess.push(extractedAttributes.serializedChildren)
@@ -752,7 +868,7 @@ export class ASTVisitors {
752
868
  )
753
869
  const isOrdinal = !!ordinalAttr
754
870
 
755
- const contextValues = this.resolvePossibleStringValues(contextExpression)
871
+ const contextValues = this.resolvePossibleContextStringValues(contextExpression)
756
872
  const contextSeparator = this.config.extract.contextSeparator ?? '_'
757
873
 
758
874
  // Generate all combinations of context and plural forms
@@ -772,7 +888,7 @@ export class ASTVisitors {
772
888
  extractedKeys.forEach(extractedKey => this.generatePluralKeysForTrans(extractedKey.key, extractedKey.defaultValue, extractedKey.ns, isOrdinal, optionsNode))
773
889
  }
774
890
  } else if (contextExpression) {
775
- const contextValues = this.resolvePossibleStringValues(contextExpression)
891
+ const contextValues = this.resolvePossibleContextStringValues(contextExpression)
776
892
  const contextSeparator = this.config.extract.contextSeparator ?? '_'
777
893
 
778
894
  if (contextValues.length > 0) {
@@ -963,13 +1079,48 @@ export class ASTVisitors {
963
1079
  return null
964
1080
  }
965
1081
 
1082
+ /**
1083
+ * Resolves an expression to one or more possible context string values that can be
1084
+ * determined statically from the AST. This is a wrapper around the plugin hook
1085
+ * `extractContextFromExpression` and {@link resolvePossibleStringValuesFromExpression}.
1086
+ *
1087
+ * @param expression - The SWC AST expression node to resolve
1088
+ * @returns An array of possible context string values that the expression may produce.
1089
+ *
1090
+ * @private
1091
+ */
1092
+ private resolvePossibleContextStringValues (expression: Expression) {
1093
+ const strings = this.hooks.resolvePossibleContextStringValues?.(expression) ?? []
1094
+
1095
+ return [...strings, ...this.resolvePossibleStringValuesFromExpression(expression)]
1096
+ }
1097
+
1098
+ /**
1099
+ * Resolves an expression to one or more possible key string values that can be
1100
+ * determined statically from the AST. This is a wrapper around the plugin hook
1101
+ * `extractKeysFromExpression` and {@link resolvePossibleStringValuesFromExpression}.
1102
+ *
1103
+ * @param expression - The SWC AST expression node to resolve
1104
+ * @returns An array of possible key string values that the expression may produce.
1105
+ *
1106
+ * @private
1107
+ */
1108
+ private resolvePossibleKeyStringValues (expression: Expression) {
1109
+ const strings = this.hooks.resolvePossibleKeyStringValues?.(expression) ?? []
1110
+
1111
+ return [...strings, ...this.resolvePossibleStringValuesFromExpression(expression)]
1112
+ }
1113
+
966
1114
  /**
967
1115
  * Resolves an expression to one or more possible string values that can be
968
1116
  * determined statically from the AST.
969
1117
  *
970
1118
  * Supports:
971
1119
  * - StringLiteral -> single value (filtered to exclude empty strings for context)
1120
+ * - NumericLiteral -> single value
1121
+ * - BooleanLiteral -> single value
972
1122
  * - ConditionalExpression (ternary) -> union of consequent and alternate resolved values
1123
+ * - TemplateLiteral -> union of all possible string values
973
1124
  * - The identifier `undefined` -> empty array
974
1125
  *
975
1126
  * For any other expression types (identifiers, function calls, member expressions,
@@ -980,15 +1131,15 @@ export class ASTVisitors {
980
1131
  * @param returnEmptyStrings - Whether to include empty strings in the result
981
1132
  * @returns An array of possible string values that the expression may produce.
982
1133
  */
983
- private resolvePossibleStringValues (expression: Expression, returnEmptyStrings = false): string[] {
1134
+ private resolvePossibleStringValuesFromExpression (expression: Expression, returnEmptyStrings = false): string[] {
984
1135
  if (expression.type === 'StringLiteral') {
985
1136
  // Filter out empty strings as they should be treated as "no context" like i18next does
986
1137
  return expression.value || returnEmptyStrings ? [expression.value] : []
987
1138
  }
988
1139
 
989
1140
  if (expression.type === 'ConditionalExpression') { // This is a ternary operator
990
- const consequentValues = this.resolvePossibleStringValues(expression.consequent, returnEmptyStrings)
991
- const alternateValues = this.resolvePossibleStringValues(expression.alternate, returnEmptyStrings)
1141
+ const consequentValues = this.resolvePossibleStringValuesFromExpression(expression.consequent, returnEmptyStrings)
1142
+ const alternateValues = this.resolvePossibleStringValuesFromExpression(expression.alternate, returnEmptyStrings)
992
1143
  return [...consequentValues, ...alternateValues]
993
1144
  }
994
1145
 
@@ -1030,7 +1181,7 @@ export class ASTVisitors {
1030
1181
  (heads, expression, i) => {
1031
1182
  return heads.flatMap((head) => {
1032
1183
  const tail = tails[i]?.cooked ?? ''
1033
- return this.resolvePossibleStringValues(expression, true).map(
1184
+ return this.resolvePossibleStringValuesFromExpression(expression, true).map(
1034
1185
  (expressionValue) => `${head}${expressionValue}${tail}`
1035
1186
  )
1036
1187
  })
@@ -43,6 +43,8 @@ export function extractKeysFromComments (
43
43
  const remainder = text.slice(match.index + match[0].length)
44
44
 
45
45
  const defaultValue = parseDefaultValueFromComment(remainder)
46
+ const context = parseContextFromComment(remainder)
47
+ const count = parseCountFromComment(remainder)
46
48
 
47
49
  // 1. Check for namespace in options object first (e.g., { ns: 'common' })
48
50
  ns = parseNsFromComment(remainder)
@@ -55,7 +57,7 @@ export function extractKeysFromComments (
55
57
  key = parts.join(nsSeparator)
56
58
  }
57
59
 
58
- // 3. NEW: If no explicit namespace found, try to resolve from scope
60
+ // 3. If no explicit namespace found, try to resolve from scope
59
61
  // This allows commented t() calls to inherit namespace from useTranslation scope
60
62
  if (!ns && scopeResolver) {
61
63
  const scopeInfo = scopeResolver('t')
@@ -67,11 +69,89 @@ export function extractKeysFromComments (
67
69
  // 4. Final fallback to configured default namespace
68
70
  if (!ns) ns = config.extract.defaultNS
69
71
 
70
- pluginContext.addKey({ key, ns, defaultValue: defaultValue ?? key })
72
+ // 5. Handle context and count combinations
73
+ if (context && count) {
74
+ // Generate all combinations: base plural + context+plural
75
+ generatePluralKeys(key, defaultValue ?? key, ns, pluginContext, config)
76
+ generateContextPluralKeys(key, defaultValue ?? key, ns, context, pluginContext, config)
77
+ } else if (context) {
78
+ // Just context variants
79
+ pluginContext.addKey({ key, ns, defaultValue: defaultValue ?? key })
80
+ pluginContext.addKey({ key: `${key}_${context}`, ns, defaultValue: defaultValue ?? key })
81
+ } else if (count) {
82
+ // Just plural variants
83
+ generatePluralKeys(key, defaultValue ?? key, ns, pluginContext, config)
84
+ } else {
85
+ // Simple key
86
+ pluginContext.addKey({ key, ns, defaultValue: defaultValue ?? key })
87
+ }
71
88
  }
72
89
  }
73
90
  }
74
91
 
92
+ /**
93
+ * Generates plural keys for a given base key
94
+ */
95
+ function generatePluralKeys (
96
+ key: string,
97
+ defaultValue: string,
98
+ ns: string | undefined,
99
+ pluginContext: PluginContext,
100
+ config: I18nextToolkitConfig
101
+ ): void {
102
+ const primaryLanguage = config.extract.primaryLanguage || config.locales[0] || 'en'
103
+ const pluralRules = new Intl.PluralRules(primaryLanguage)
104
+
105
+ // Get all possible plural categories for the primary language
106
+ const testNumbers = [0, 1, 2, 3, 5, 100] // Test various numbers to find all categories
107
+ const categories = new Set<string>()
108
+
109
+ for (const num of testNumbers) {
110
+ categories.add(pluralRules.select(num))
111
+ }
112
+
113
+ // Generate keys for each plural category
114
+ for (const category of categories) {
115
+ pluginContext.addKey({
116
+ key: `${key}_${category}`,
117
+ ns,
118
+ defaultValue
119
+ })
120
+ }
121
+ }
122
+
123
+ /**
124
+ * Generates context + plural combination keys
125
+ */
126
+ function generateContextPluralKeys (
127
+ key: string,
128
+ defaultValue: string,
129
+ ns: string | undefined,
130
+ context: string,
131
+ pluginContext: PluginContext,
132
+ config: I18nextToolkitConfig
133
+ ): void {
134
+ const primaryLanguage = config.extract.primaryLanguage || config.locales[0] || 'en'
135
+ const pluralRules = new Intl.PluralRules(primaryLanguage)
136
+
137
+ // Get all possible plural categories for the primary language
138
+ const testNumbers = [0, 1, 2, 3, 5, 100]
139
+ const categories = new Set<string>()
140
+
141
+ for (const num of testNumbers) {
142
+ categories.add(pluralRules.select(num))
143
+ }
144
+
145
+ // Generate keys for each context + plural combination
146
+ for (const category of categories) {
147
+ pluginContext.addKey({
148
+ key: `${key}_${context}_${category}`,
149
+ ns,
150
+ defaultValue
151
+ })
152
+ }
153
+ }
154
+
75
155
  /**
76
156
  * Parses default value from the remainder of a comment after a translation function call.
77
157
  * Supports both string literals and object syntax with defaultValue property.
@@ -136,3 +216,37 @@ function collectCommentTexts (src: string): string[] {
136
216
 
137
217
  return texts
138
218
  }
219
+
220
+ /**
221
+ * Parses context from the remainder of a comment after a translation function call.
222
+ * Looks for context specified in options object syntax.
223
+ *
224
+ * @param remainder - The remaining text after the translation key
225
+ * @returns The parsed context value or undefined if none found
226
+ *
227
+ * @internal
228
+ */
229
+ function parseContextFromComment (remainder: string): string | undefined {
230
+ // Look for context in an options object, e.g., { context: 'male' }
231
+ const contextObj = /^\s*,\s*\{[^}]*context\s*:\s*(['"])(.*?)\1/.exec(remainder)
232
+ if (contextObj) return contextObj[2]
233
+
234
+ return undefined
235
+ }
236
+
237
+ /**
238
+ * Parses count from the remainder of a comment after a translation function call.
239
+ * Looks for count specified in options object syntax.
240
+ *
241
+ * @param remainder - The remaining text after the translation key
242
+ * @returns The parsed count value or undefined if none found
243
+ *
244
+ * @internal
245
+ */
246
+ function parseCountFromComment (remainder: string): number | undefined {
247
+ // Look for count in an options object, e.g., { count: 1 }
248
+ const countObj = /^\s*,\s*\{[^}]*count\s*:\s*(\d+)/.exec(remainder)
249
+ if (countObj) return parseInt(countObj[1], 10)
250
+
251
+ return undefined
252
+ }