i18next-cli 1.24.12 → 1.24.14

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 (42) hide show
  1. package/dist/cjs/cli.js +1 -1
  2. package/dist/cjs/extractor/parsers/expression-resolver.js +1 -1
  3. package/dist/esm/cli.js +1 -1
  4. package/dist/esm/extractor/parsers/expression-resolver.js +1 -1
  5. package/package.json +6 -6
  6. package/types/cli.d.ts +3 -1
  7. package/types/cli.d.ts.map +1 -1
  8. package/types/extractor/parsers/expression-resolver.d.ts.map +1 -1
  9. package/CHANGELOG.md +0 -595
  10. package/src/cli.ts +0 -283
  11. package/src/config.ts +0 -215
  12. package/src/extractor/core/ast-visitors.ts +0 -259
  13. package/src/extractor/core/extractor.ts +0 -250
  14. package/src/extractor/core/key-finder.ts +0 -142
  15. package/src/extractor/core/translation-manager.ts +0 -750
  16. package/src/extractor/index.ts +0 -7
  17. package/src/extractor/parsers/ast-utils.ts +0 -87
  18. package/src/extractor/parsers/call-expression-handler.ts +0 -793
  19. package/src/extractor/parsers/comment-parser.ts +0 -424
  20. package/src/extractor/parsers/expression-resolver.ts +0 -353
  21. package/src/extractor/parsers/jsx-handler.ts +0 -488
  22. package/src/extractor/parsers/jsx-parser.ts +0 -1463
  23. package/src/extractor/parsers/scope-manager.ts +0 -445
  24. package/src/extractor/plugin-manager.ts +0 -116
  25. package/src/extractor.ts +0 -15
  26. package/src/heuristic-config.ts +0 -92
  27. package/src/index.ts +0 -22
  28. package/src/init.ts +0 -175
  29. package/src/linter.ts +0 -345
  30. package/src/locize.ts +0 -263
  31. package/src/migrator.ts +0 -208
  32. package/src/rename-key.ts +0 -398
  33. package/src/status.ts +0 -380
  34. package/src/syncer.ts +0 -133
  35. package/src/types-generator.ts +0 -139
  36. package/src/types.ts +0 -577
  37. package/src/utils/default-value.ts +0 -45
  38. package/src/utils/file-utils.ts +0 -167
  39. package/src/utils/funnel-msg-tracker.ts +0 -84
  40. package/src/utils/logger.ts +0 -36
  41. package/src/utils/nested-object.ts +0 -135
  42. package/src/utils/validation.ts +0 -72
@@ -1,793 +0,0 @@
1
- import type { CallExpression, ArrowFunctionExpression, ObjectExpression } from '@swc/core'
2
- import type { PluginContext, I18nextToolkitConfig, Logger, ExtractedKey, ScopeInfo } from '../../types'
3
- import { ExpressionResolver } from './expression-resolver'
4
- import { getObjectPropValueExpression, getObjectPropValue, isSimpleTemplateLiteral } from './ast-utils'
5
-
6
- export class CallExpressionHandler {
7
- private pluginContext: PluginContext
8
- private config: Omit<I18nextToolkitConfig, 'plugins'>
9
- private logger: Logger
10
- private expressionResolver: ExpressionResolver
11
- public objectKeys = new Set<string>()
12
- private getCurrentFile: () => string
13
- private getCurrentCode: () => string
14
- private lastSearchIndex: number = 0
15
-
16
- constructor (
17
- config: Omit<I18nextToolkitConfig, 'plugins'>,
18
- pluginContext: PluginContext,
19
- logger: Logger,
20
- expressionResolver: ExpressionResolver,
21
- getCurrentFile: () => string,
22
- getCurrentCode: () => string
23
- ) {
24
- this.config = config
25
- this.pluginContext = pluginContext
26
- this.logger = logger
27
- this.expressionResolver = expressionResolver
28
- this.getCurrentFile = getCurrentFile
29
- this.getCurrentCode = getCurrentCode
30
- }
31
-
32
- /**
33
- * Reset the search index when starting to process a new file.
34
- * This should be called before processing each file.
35
- */
36
- public resetSearchIndex (): void {
37
- this.lastSearchIndex = 0
38
- }
39
-
40
- /**
41
- * Helper method to calculate line and column from a position in the code.
42
- * Uses string searching instead of SWC span offsets to avoid accumulation bugs.
43
- */
44
- private getLocationFromNode (node: any): { line: number, column: number } | undefined {
45
- const code = this.getCurrentCode()
46
-
47
- // Extract searchable text from the node
48
- // For CallExpression, we can search for the key argument
49
- let searchText: string | undefined
50
-
51
- if (node.type === 'CallExpression' && node.arguments.length > 0) {
52
- const firstArg = node.arguments[0].expression
53
-
54
- if (firstArg.type === 'StringLiteral') {
55
- // Search for the string literal including quotes
56
- searchText = firstArg.raw ?? `'${firstArg.value}'`
57
- } else if (firstArg.type === 'TemplateLiteral') {
58
- // For template literals, search for the backtick
59
- searchText = '`'
60
- }
61
- }
62
-
63
- if (!searchText) return undefined
64
-
65
- // Search for the text starting from last known position
66
- const position = code.indexOf(searchText, this.lastSearchIndex)
67
-
68
- if (position === -1) {
69
- // Not found - might be a parsing issue, skip location tracking
70
- return undefined
71
- }
72
-
73
- // Update last search position for next search
74
- this.lastSearchIndex = position + searchText.length
75
-
76
- // Calculate line and column from the position
77
- const upToPosition = code.substring(0, position)
78
- const lines = upToPosition.split('\n')
79
-
80
- return {
81
- line: lines.length,
82
- column: lines[lines.length - 1].length
83
- }
84
- }
85
-
86
- /**
87
- * Processes function call expressions to extract translation keys.
88
- *
89
- * This is the core extraction method that handles:
90
- * - Standard t() calls with string literals
91
- * - Selector API calls with arrow functions: `t($ => $.path.to.key)`
92
- * - Namespace resolution from multiple sources
93
- * - Default value extraction from various argument patterns
94
- * - Pluralization and context handling
95
- * - Key prefix application from scope
96
- *
97
- * @param node - Call expression node to process
98
- * @param getScopeInfo - Function to retrieve scope information for variables
99
- */
100
- handleCallExpression (node: CallExpression, getScopeInfo: (name: string) => ScopeInfo | undefined): void {
101
- const functionName = this.getFunctionName(node.callee)
102
- if (!functionName) return
103
-
104
- // The scope lookup will only work for simple identifiers, which is okay for this fix.
105
- const scopeInfo = getScopeInfo(functionName)
106
- const configuredFunctions = this.config.extract.functions || ['t', '*.t']
107
- let isFunctionToParse = scopeInfo !== undefined // A scoped variable (from useTranslation, etc.) is always parsed.
108
- if (!isFunctionToParse) {
109
- for (const pattern of configuredFunctions) {
110
- if (pattern.startsWith('*.')) {
111
- // Handle wildcard suffix (e.g., '*.t' matches 'i18n.t')
112
- if (functionName.endsWith(pattern.substring(1))) {
113
- isFunctionToParse = true
114
- break
115
- }
116
- } else {
117
- // Handle exact match
118
- if (pattern === functionName) {
119
- isFunctionToParse = true
120
- break
121
- }
122
- }
123
- }
124
- }
125
- if (!isFunctionToParse || node.arguments.length === 0) return
126
-
127
- const { keysToProcess, isSelectorAPI } = this.handleCallExpressionArgument(node, 0)
128
-
129
- if (keysToProcess.length === 0) return
130
-
131
- let isOrdinalByKey = false
132
- const pluralSeparator = this.config.extract.pluralSeparator ?? '_'
133
-
134
- for (let i = 0; i < keysToProcess.length; i++) {
135
- if (keysToProcess[i].endsWith(`${pluralSeparator}ordinal`)) {
136
- isOrdinalByKey = true
137
- // Normalize the key by stripping the suffix
138
- keysToProcess[i] = keysToProcess[i].slice(0, -8)
139
- }
140
- }
141
-
142
- let defaultValue: string | undefined
143
- let options: ObjectExpression | undefined
144
-
145
- if (node.arguments.length > 1) {
146
- const arg2 = node.arguments[1].expression
147
- if (arg2.type === 'ObjectExpression') {
148
- options = arg2
149
- } else if (arg2.type === 'StringLiteral') {
150
- defaultValue = arg2.value
151
- } else if (arg2.type === 'TemplateLiteral' && isSimpleTemplateLiteral(arg2)) {
152
- defaultValue = arg2.quasis[0].cooked
153
- }
154
- }
155
- if (node.arguments.length > 2) {
156
- const arg3 = node.arguments[2].expression
157
- if (arg3.type === 'ObjectExpression') {
158
- options = arg3
159
- }
160
- }
161
- const defaultValueFromOptions = options ? getObjectPropValue(options, 'defaultValue') : undefined
162
- const finalDefaultValue = (typeof defaultValueFromOptions === 'string' ? defaultValueFromOptions : defaultValue)
163
-
164
- // Helper: detect if options object contains any defaultValue* properties
165
- const optionsHasDefaultProps = (opts?: ObjectExpression) => {
166
- if (!opts || !Array.isArray(opts.properties)) return false
167
- for (const p of opts.properties as any[]) {
168
- if (p && p.type === 'KeyValueProperty' && p.key) {
169
- const keyName = (p.key.type === 'Identifier' && p.key.value) || (p.key.type === 'StringLiteral' && p.key.value)
170
- if (typeof keyName === 'string' && keyName.startsWith('defaultValue')) return true
171
- }
172
- }
173
- return false
174
- }
175
-
176
- // explicit for base key when a string default was provided OR explicit plural defaults are present
177
- const explicitDefaultForBase = typeof finalDefaultValue === 'string' || optionsHasDefaultProps(options)
178
- // detect if options contain plural-specific defaultValue_* props
179
- const explicitPluralDefaultsInOptions = optionsHasDefaultProps(options)
180
- // If a base default string exists, consider it explicit for plural VARIANTS only when
181
- // it does NOT contain a count interpolation like '{{count}}' — templates with count
182
- // are often the runtime interpolation form and should NOT overwrite existing variant forms.
183
- const containsCountPlaceholder = (s?: string) => typeof s === 'string' && /{{\s*count\s*}}/.test(s)
184
- const explicitPluralForVariants = Boolean(explicitPluralDefaultsInOptions || (typeof finalDefaultValue === 'string' && !containsCountPlaceholder(finalDefaultValue)))
185
-
186
- // Loop through each key found (could be one or more) and process it
187
- for (let i = 0; i < keysToProcess.length; i++) {
188
- const originalKey = keysToProcess[i] // preserve original (possibly namespaced) form
189
- let key = keysToProcess[i]
190
- let ns: string | false | undefined
191
-
192
- // Determine namespace (explicit ns > ns:key > scope ns > default)
193
- // See https://www.i18next.com/overview/api#getfixedt
194
- if (options) {
195
- const nsVal = getObjectPropValue(options, 'ns')
196
- if (typeof nsVal === 'string') ns = nsVal
197
- }
198
-
199
- const nsSeparator = this.config.extract.nsSeparator ?? ':'
200
- if (!ns && nsSeparator && key.includes(nsSeparator)) {
201
- const parts = key.split(nsSeparator)
202
- ns = parts.shift()
203
- key = parts.join(nsSeparator)
204
-
205
- if (!key || key.trim() === '') {
206
- this.logger.warn(`Skipping key that became empty after namespace removal: '${ns}${nsSeparator}'`)
207
- continue
208
- }
209
- }
210
-
211
- if (!ns && scopeInfo?.defaultNs) ns = scopeInfo.defaultNs
212
- if (!ns) ns = this.config.extract.defaultNS
213
-
214
- let finalKey = key
215
-
216
- // Apply keyPrefix AFTER namespace extraction
217
- if (scopeInfo?.keyPrefix) {
218
- const keySeparator = this.config.extract.keySeparator ?? '.'
219
-
220
- // Apply keyPrefix - handle case where keyPrefix already ends with separator
221
- if (keySeparator !== false) {
222
- if (scopeInfo.keyPrefix.endsWith(keySeparator)) {
223
- finalKey = `${scopeInfo.keyPrefix}${key}`
224
- } else {
225
- finalKey = `${scopeInfo.keyPrefix}${keySeparator}${key}`
226
- }
227
- } else {
228
- finalKey = `${scopeInfo.keyPrefix}${key}`
229
- }
230
-
231
- // Validate keyPrefix combinations that create problematic keys
232
- if (keySeparator !== false) {
233
- // Check for patterns that would create empty segments in the nested key structure
234
- const segments = finalKey.split(keySeparator)
235
- const hasEmptySegment = segments.some(segment => segment.trim() === '')
236
-
237
- if (hasEmptySegment) {
238
- this.logger.warn(`Skipping key with empty segments: '${finalKey}' (keyPrefix: '${scopeInfo.keyPrefix}', key: '${key}')`)
239
- continue
240
- }
241
- }
242
- }
243
-
244
- const isLastKey = i === keysToProcess.length - 1
245
- // Use the original (possibly namespaced) key as the default when no explicit
246
- // default was provided and the source key contained a namespace prefix.
247
- const dv = isLastKey
248
- ? (typeof finalDefaultValue === 'string'
249
- ? finalDefaultValue
250
- : (nsSeparator && originalKey.includes(nsSeparator || ':') ? originalKey : key))
251
- : key
252
-
253
- // Handle plurals, context, and returnObjects
254
- if (options) {
255
- const contextPropValue = getObjectPropValueExpression(options, 'context')
256
-
257
- const keysWithContext: ExtractedKey[] = []
258
-
259
- // 1. Handle Context
260
- if (contextPropValue?.type === 'StringLiteral' || contextPropValue?.type === 'NumericLiteral' || contextPropValue?.type === 'BooleanLiteral') {
261
- // If the context is static, we don't need to add the base key
262
- const contextValue = `${contextPropValue.value}`
263
-
264
- const contextSeparator = this.config.extract.contextSeparator ?? '_'
265
- // Ignore context: ''
266
- if (contextValue !== '') {
267
- keysWithContext.push({ key: `${finalKey}${contextSeparator}${contextValue}`, ns, defaultValue: dv, explicitDefault: explicitDefaultForBase })
268
- }
269
- } else if (contextPropValue) {
270
- const contextValues = this.expressionResolver.resolvePossibleContextStringValues(contextPropValue)
271
- const contextSeparator = this.config.extract.contextSeparator ?? '_'
272
-
273
- if (contextValues.length > 0) {
274
- contextValues.forEach(context => {
275
- keysWithContext.push({ key: `${finalKey}${contextSeparator}${context}`, ns, defaultValue: dv, explicitDefault: explicitDefaultForBase })
276
- })
277
- }
278
- // For dynamic context, also add the base key as a fallback
279
- keysWithContext.push({
280
- key: finalKey,
281
- ns,
282
- defaultValue: dv,
283
- explicitDefault: explicitDefaultForBase,
284
- keyAcceptingContext: finalKey
285
- })
286
- }
287
-
288
- // 2. Handle Plurals
289
- // Robust detection for `{ count }`, `{ count: x }`, `{ 'count': x }` etc.
290
- // Support KeyValueProperty and common shorthand forms that SWC may emit.
291
- const propNameFromNode = (p: any): string | undefined => {
292
- if (!p) return undefined
293
- // Standard key:value property
294
- if (p.type === 'KeyValueProperty' && p.key) {
295
- if (p.key.type === 'Identifier') return p.key.value
296
- if (p.key.type === 'StringLiteral') return p.key.value
297
- }
298
- // SWC may represent shorthand properties differently (no explicit key node).
299
- // Try common shapes: property with `value` being an Identifier (shorthand).
300
- if (p.type === 'KeyValueProperty' && p.value && p.value.type === 'Identifier') {
301
- // e.g. { count: count } - already covered above, but keep safe fallback
302
- return p.key && p.key.type === 'Identifier' ? p.key.value : undefined
303
- }
304
- // Some AST variants use 'ShorthandProperty' or keep the Identifier directly.
305
- if ((p.type === 'ShorthandProperty' || p.type === 'Identifier') && (p as any).value) {
306
- return (p as any).value
307
- }
308
- // Fallback: if node has an 'id' or 'key' string value
309
- if (p.key && typeof p.key === 'string') return p.key
310
- return undefined
311
- }
312
-
313
- const hasCount = (() => {
314
- if (!options || !Array.isArray(options.properties)) return false
315
- for (const p of options.properties as any[]) {
316
- const name = propNameFromNode(p)
317
- if (name === 'count') return true
318
- }
319
- return false
320
- })()
321
-
322
- const isOrdinalByOption = (() => {
323
- if (!options || !Array.isArray(options.properties)) return false
324
- for (const p of options.properties as any[]) {
325
- const name = propNameFromNode(p)
326
- if (name === 'ordinal') {
327
- // If it's a key:value pair with a BooleanLiteral true, respect it.
328
- if (p.type === 'KeyValueProperty' && p.value && p.value.type === 'BooleanLiteral') {
329
- return Boolean(p.value.value)
330
- }
331
- // shorthand `ordinal` without explicit true -> treat as false
332
- return false
333
- }
334
- }
335
- return false
336
- })()
337
- if (hasCount || isOrdinalByKey) {
338
- // QUICK PATH: If ALL target locales only have the "other" category,
339
- // emit base/context keys directly (avoid generating *_other). This
340
- // mirrors the special-case in handlePluralKeys but is placed here as a
341
- // defensive guard to ensure keys are always emitted.
342
- try {
343
- const typeForCheck = isOrdinalByKey ? 'ordinal' : 'cardinal'
344
- // Prefer the configured primaryLanguage as the deciding signal for
345
- // "single-other" languages (ja/zh/ko). Fall back to union of locales.
346
- const primaryLang = this.config.extract?.primaryLanguage || (Array.isArray(this.config.locales) ? this.config.locales[0] : undefined) || 'en'
347
- let isSingleOther = false
348
- try {
349
- const primaryCategories = new Intl.PluralRules(primaryLang, { type: typeForCheck }).resolvedOptions().pluralCategories
350
- if (primaryCategories.length === 1 && primaryCategories[0] === 'other') {
351
- isSingleOther = true
352
- }
353
- } catch {
354
- // ignore and fall back to union-of-locales check below
355
- }
356
-
357
- if (!isSingleOther) {
358
- const allPluralCategoriesCheck = new Set<string>()
359
- for (const locale of this.config.locales) {
360
- try {
361
- const rules = new Intl.PluralRules(locale, { type: typeForCheck })
362
- rules.resolvedOptions().pluralCategories.forEach(c => allPluralCategoriesCheck.add(c))
363
- } catch {
364
- new Intl.PluralRules('en', { type: typeForCheck }).resolvedOptions().pluralCategories.forEach(c => allPluralCategoriesCheck.add(c))
365
- }
366
- }
367
- const pluralCategoriesCheck = Array.from(allPluralCategoriesCheck).sort()
368
- if (pluralCategoriesCheck.length === 1 && pluralCategoriesCheck[0] === 'other') {
369
- isSingleOther = true
370
- }
371
- }
372
-
373
- if (isSingleOther) {
374
- // Emit only base/context keys (no _other) and skip the heavy plural path.
375
- if (keysWithContext.length > 0) {
376
- for (const k of keysWithContext) {
377
- this.pluginContext.addKey({
378
- key: k.key,
379
- ns: k.ns,
380
- defaultValue: k.defaultValue,
381
- hasCount: true,
382
- isOrdinal: isOrdinalByKey
383
- })
384
- }
385
- } else {
386
- this.pluginContext.addKey({
387
- key: finalKey,
388
- ns,
389
- defaultValue: dv,
390
- hasCount: true,
391
- isOrdinal: isOrdinalByKey
392
- })
393
- }
394
- continue
395
- }
396
- } catch (e) {
397
- // Ignore Intl failures here and fall through to normal logic
398
- }
399
- // Check if plurals are disabled
400
- if (this.config.extract.disablePlurals) {
401
- // When plurals are disabled, treat count as a regular option (for interpolation only)
402
- // Still handle context normally
403
- if (keysWithContext.length > 0) {
404
- keysWithContext.forEach(this.pluginContext.addKey)
405
- } else {
406
- this.pluginContext.addKey({ key: finalKey, ns, defaultValue: dv, explicitDefault: explicitDefaultForBase })
407
- }
408
- } else {
409
- // Original plural handling logic when plurals are enabled
410
- // Always pass the base key to handlePluralKeys - it will handle context internally.
411
- // Pass explicitDefaultForBase so that when a call-site provided an explicit
412
- // base default (e.g. t('key', 'Default', { count })), plural variant keys
413
- // are treated as explicit and may be synced to that default.
414
- this.handlePluralKeys(finalKey, ns, options, isOrdinalByOption || isOrdinalByKey, finalDefaultValue, explicitPluralForVariants)
415
- }
416
-
417
- continue // This key is fully handled
418
- }
419
-
420
- if (keysWithContext.length > 0) {
421
- keysWithContext.forEach(this.pluginContext.addKey)
422
-
423
- continue // This key is now fully handled
424
- }
425
-
426
- // 3. Handle returnObjects
427
- if (getObjectPropValue(options, 'returnObjects') === true) {
428
- this.objectKeys.add(finalKey)
429
- // Fall through to add the base key itself
430
- }
431
- }
432
-
433
- // 4. Handle selector API as implicit returnObjects
434
- if (isSelectorAPI) {
435
- this.objectKeys.add(finalKey)
436
- // Fall through to add the base key itself
437
- }
438
-
439
- // 5. Default case: Add the simple key
440
- {
441
- // ✅ Use the helper method to find location by searching the code
442
- const location = this.getLocationFromNode(node)
443
-
444
- this.pluginContext.addKey({
445
- key: finalKey,
446
- ns,
447
- defaultValue: dv,
448
- explicitDefault: explicitDefaultForBase,
449
- locations: location
450
- ? [{
451
- file: this.getCurrentFile(),
452
- line: location.line,
453
- column: location.column
454
- }]
455
- : undefined
456
- })
457
- }
458
- }
459
- }
460
-
461
- /**
462
- * Processed a call expression to extract keys from the specified argument.
463
- *
464
- * @param node - The call expression node
465
- * @param argIndex - The index of the argument to process
466
- * @returns An object containing the keys to process and a flag indicating if the selector API is used
467
- */
468
- private handleCallExpressionArgument (
469
- node: CallExpression,
470
- argIndex: number
471
- ): { keysToProcess: string[]; isSelectorAPI: boolean } {
472
- const firstArg = node.arguments[argIndex].expression
473
- const keysToProcess: string[] = []
474
- let isSelectorAPI = false
475
-
476
- if (firstArg.type === 'ArrowFunctionExpression') {
477
- const key = this.extractKeyFromSelector(firstArg)
478
- if (key) {
479
- keysToProcess.push(key)
480
- isSelectorAPI = true
481
- }
482
- } else if (firstArg.type === 'ArrayExpression') {
483
- for (const element of firstArg.elements) {
484
- if (element?.expression) {
485
- keysToProcess.push(...this.expressionResolver.resolvePossibleKeyStringValues(element.expression))
486
- }
487
- }
488
- } else {
489
- keysToProcess.push(...this.expressionResolver.resolvePossibleKeyStringValues(firstArg))
490
- }
491
-
492
- return {
493
- keysToProcess: keysToProcess.filter((key) => !!key),
494
- isSelectorAPI,
495
- }
496
- }
497
-
498
- /**
499
- * Extracts translation key from selector API arrow function.
500
- *
501
- * Processes selector expressions like:
502
- * - `$ => $.path.to.key` → 'path.to.key'
503
- * - `$ => $.app['title'].main` → 'app.title.main'
504
- * - `$ => { return $.nested.key; }` → 'nested.key'
505
- *
506
- * Handles both dot notation and bracket notation, respecting
507
- * the configured key separator or flat key structure.
508
- *
509
- * @param node - Arrow function expression from selector call
510
- * @returns Extracted key path or null if not statically analyzable
511
- */
512
- private extractKeyFromSelector (node: ArrowFunctionExpression): string | null {
513
- let body = node.body
514
-
515
- // Handle block bodies, e.g., $ => { return $.key; }
516
- if (body.type === 'BlockStatement') {
517
- const returnStmt = body.stmts.find(s => s.type === 'ReturnStatement')
518
- if (returnStmt?.type === 'ReturnStatement' && returnStmt.argument) {
519
- body = returnStmt.argument
520
- } else {
521
- return null
522
- }
523
- }
524
-
525
- let current = body
526
- const parts: string[] = []
527
-
528
- // Recursively walk down MemberExpressions
529
- while (current.type === 'MemberExpression') {
530
- const prop = current.property
531
-
532
- if (prop.type === 'Identifier') {
533
- // This handles dot notation: .key
534
- parts.unshift(prop.value)
535
- } else if (prop.type === 'Computed' && prop.expression.type === 'StringLiteral') {
536
- // This handles bracket notation: ['key']
537
- parts.unshift(prop.expression.value)
538
- } else {
539
- // This is a dynamic property like [myVar] or a private name, which we cannot resolve.
540
- return null
541
- }
542
-
543
- current = current.object
544
- }
545
-
546
- if (parts.length > 0) {
547
- const keySeparator = this.config.extract.keySeparator
548
- const joiner = typeof keySeparator === 'string' ? keySeparator : '.'
549
- return parts.join(joiner)
550
- }
551
-
552
- return null
553
- }
554
-
555
- /**
556
- * Generates plural form keys based on the primary language's plural rules.
557
- *
558
- * Uses Intl.PluralRules to determine the correct plural categories
559
- * for the configured primary language and generates suffixed keys
560
- * for each category (e.g., 'item_one', 'item_other').
561
- *
562
- * @param key - Base key name for pluralization
563
- * @param ns - Namespace for the keys
564
- * @param options - object expression options
565
- * @param isOrdinal - isOrdinal flag
566
- */
567
- private handlePluralKeys (key: string, ns: string | false | undefined, options: ObjectExpression, isOrdinal: boolean, defaultValueFromCall?: string, explicitDefaultFromSource?: boolean): void {
568
- try {
569
- const type = isOrdinal ? 'ordinal' : 'cardinal'
570
-
571
- // Generate plural forms for ALL target languages to ensure we have all necessary keys
572
- const allPluralCategories = new Set<string>()
573
-
574
- for (const locale of this.config.locales) {
575
- try {
576
- const pluralRules = new Intl.PluralRules(locale, { type })
577
- const categories = pluralRules.resolvedOptions().pluralCategories
578
- categories.forEach(cat => allPluralCategories.add(cat))
579
- } catch (e) {
580
- // If a locale is invalid, fall back to English rules
581
- const englishRules = new Intl.PluralRules('en', { type })
582
- const categories = englishRules.resolvedOptions().pluralCategories
583
- categories.forEach(cat => allPluralCategories.add(cat))
584
- }
585
- }
586
-
587
- const pluralCategories = Array.from(allPluralCategories).sort()
588
- const pluralSeparator = this.config.extract.pluralSeparator ?? '_'
589
-
590
- // Get all possible default values once at the start
591
- const defaultValue = getObjectPropValue(options, 'defaultValue')
592
- const otherDefault = getObjectPropValue(options, `defaultValue${pluralSeparator}other`)
593
- const ordinalOtherDefault = getObjectPropValue(options, `defaultValue${pluralSeparator}ordinal${pluralSeparator}other`)
594
-
595
- // Handle context - both static and dynamic
596
- const contextPropValue = getObjectPropValueExpression(options, 'context')
597
- const keysToGenerate: Array<{ key: string, context?: string }> = []
598
-
599
- if (contextPropValue) {
600
- // Handle dynamic context by resolving all possible values
601
- const contextValues = this.expressionResolver.resolvePossibleContextStringValues(contextPropValue)
602
-
603
- if (contextValues.length > 0) {
604
- // For static context (string literal), only generate context variants
605
- if (contextPropValue.type === 'StringLiteral') {
606
- // Only generate context-specific plural forms, no base forms
607
- for (const contextValue of contextValues) {
608
- if (contextValue.length > 0) {
609
- keysToGenerate.push({ key, context: contextValue })
610
- }
611
- }
612
- } else {
613
- // For dynamic context, generate context variants AND base forms
614
- for (const contextValue of contextValues) {
615
- if (contextValue.length > 0) {
616
- keysToGenerate.push({ key, context: contextValue })
617
- }
618
- }
619
-
620
- // Only generate base plural forms if generateBasePluralForms is not disabled
621
- const shouldGenerateBaseForms = this.config.extract?.generateBasePluralForms !== false
622
- if (shouldGenerateBaseForms) {
623
- keysToGenerate.push({ key })
624
- }
625
- }
626
- } else {
627
- // Couldn't resolve context, fall back to base key only
628
- keysToGenerate.push({ key })
629
- }
630
- } else {
631
- // No context, always generate base plural forms
632
- keysToGenerate.push({ key })
633
- }
634
-
635
- // If the only plural category across configured locales is "other",
636
- // prefer the base key (no "_other" suffix) as it's more natural for languages
637
- // with no grammatical plural forms (ja/zh/ko).
638
- // Prefer the configured primaryLanguage as signal for single-"other" languages.
639
- // If primaryLanguage indicates single-"other", treat as that case; otherwise
640
- // fall back to earlier union-of-locales check that produced `pluralCategories`.
641
- const primaryLang = this.config.extract?.primaryLanguage || (Array.isArray(this.config.locales) ? this.config.locales[0] : undefined) || 'en'
642
- let primaryIsSingleOther = false
643
- try {
644
- const primaryCats = new Intl.PluralRules(primaryLang, { type }).resolvedOptions().pluralCategories
645
- if (primaryCats.length === 1 && primaryCats[0] === 'other') primaryIsSingleOther = true
646
- } catch {
647
- primaryIsSingleOther = false
648
- }
649
-
650
- if (primaryIsSingleOther || (pluralCategories.length === 1 && pluralCategories[0] === 'other')) {
651
- for (const { key: baseKey, context } of keysToGenerate) {
652
- const specificOther = getObjectPropValue(options, `defaultValue${pluralSeparator}other`)
653
- // Final default resolution:
654
- // 1) plural-specific defaultValue_other
655
- // 2) general defaultValue (from options)
656
- // 3) defaultValueFromCall (string arg)
657
- // 4) fallback to key (or context-key for context variants)
658
- let finalDefaultValue: string | undefined
659
- if (typeof specificOther === 'string') {
660
- finalDefaultValue = specificOther
661
- } else if (typeof defaultValue === 'string') {
662
- finalDefaultValue = defaultValue
663
- } else if (typeof defaultValueFromCall === 'string') {
664
- finalDefaultValue = defaultValueFromCall
665
- } else {
666
- finalDefaultValue = context ? `${baseKey}_${context}` : baseKey
667
- }
668
-
669
- const ctxSep = this.config.extract.contextSeparator ?? '_'
670
- const finalKey = context ? `${baseKey}${ctxSep}${context}` : baseKey
671
-
672
- this.pluginContext.addKey({
673
- key: finalKey,
674
- ns,
675
- defaultValue: finalDefaultValue,
676
- hasCount: true,
677
- isOrdinal,
678
- explicitDefault: Boolean(explicitDefaultFromSource || typeof specificOther === 'string')
679
- })
680
- }
681
- return
682
- }
683
-
684
- // Generate plural forms for each key variant
685
- for (const { key: baseKey, context } of keysToGenerate) {
686
- for (const category of pluralCategories) {
687
- // 1. Look for the most specific default value
688
- const specificDefaultKey = isOrdinal ? `defaultValue${pluralSeparator}ordinal${pluralSeparator}${category}` : `defaultValue${pluralSeparator}${category}`
689
- const specificDefault = getObjectPropValue(options, specificDefaultKey)
690
-
691
- // 2. Determine the final default value using the ORIGINAL fallback chain with corrections
692
- let finalDefaultValue: string | undefined
693
- if (typeof specificDefault === 'string') {
694
- // Most specific: defaultValue_one, defaultValue_ordinal_other, etc.
695
- finalDefaultValue = specificDefault
696
- } else if (category === 'one' && typeof defaultValue === 'string') {
697
- // For "one" category, prefer the general defaultValue
698
- finalDefaultValue = defaultValue
699
- } else if (category === 'one' && typeof defaultValueFromCall === 'string') {
700
- // For "one" category, also consider defaultValueFromCall
701
- finalDefaultValue = defaultValueFromCall
702
- } else if (isOrdinal && typeof ordinalOtherDefault === 'string') {
703
- // For ordinals (non-one categories), fall back to ordinal_other
704
- finalDefaultValue = ordinalOtherDefault
705
- } else if (!isOrdinal && typeof otherDefault === 'string') {
706
- // For cardinals (non-one categories), fall back to _other
707
- finalDefaultValue = otherDefault
708
- } else if (typeof defaultValue === 'string') {
709
- // General defaultValue as fallback
710
- finalDefaultValue = defaultValue
711
- } else if (typeof defaultValueFromCall === 'string') {
712
- // defaultValueFromCall as fallback
713
- finalDefaultValue = defaultValueFromCall
714
- } else {
715
- // Final fallback to the base key itself
716
- finalDefaultValue = baseKey
717
- }
718
-
719
- // 3. Construct the final plural key
720
- let finalKey: string
721
- if (context) {
722
- const contextSeparator = this.config.extract.contextSeparator ?? '_'
723
- finalKey = isOrdinal
724
- ? `${baseKey}${contextSeparator}${context}${pluralSeparator}ordinal${pluralSeparator}${category}`
725
- : `${baseKey}${contextSeparator}${context}${pluralSeparator}${category}`
726
- } else {
727
- finalKey = isOrdinal
728
- ? `${baseKey}${pluralSeparator}ordinal${pluralSeparator}${category}`
729
- : `${baseKey}${pluralSeparator}${category}`
730
- }
731
-
732
- this.pluginContext.addKey({
733
- key: finalKey,
734
- ns,
735
- defaultValue: finalDefaultValue,
736
- hasCount: true,
737
- isOrdinal,
738
- // Only treat plural/context variant as explicit when:
739
- // - the extractor marked the source as explicitly providing plural defaults
740
- // - OR a plural-specific default was provided in the options (specificDefault/otherDefault)
741
- // Do NOT treat the presence of a general base defaultValueFromCall as making variants explicit.
742
- explicitDefault: Boolean(explicitDefaultFromSource || typeof specificDefault === 'string' || typeof otherDefault === 'string'),
743
- // If this is a context variant, track the base key (without context or plural suffixes)
744
- keyAcceptingContext: context !== undefined ? key : undefined
745
- })
746
- }
747
- }
748
- } catch (e) {
749
- this.logger.warn(`Could not determine plural rules for language "${this.config.extract?.primaryLanguage}". Falling back to simple key extraction.`)
750
- // Fallback to a simple key if Intl API fails
751
- const defaultValue = defaultValueFromCall || getObjectPropValue(options, 'defaultValue')
752
- this.pluginContext.addKey({ key, ns, defaultValue: typeof defaultValue === 'string' ? defaultValue : key })
753
- }
754
- }
755
-
756
- /**
757
- * Serializes a callee node (Identifier or MemberExpression) into a string.
758
- *
759
- * Produces a dotted name for simple callees that can be used for scope lookups
760
- * or configuration matching.
761
- *
762
- * @param callee - The CallExpression callee node to serialize
763
- * @returns A dotted string name for supported callees, or null when the callee
764
- * is a computed/unsupported expression.
765
- */
766
- private getFunctionName (callee: CallExpression['callee']): string | null {
767
- if (callee.type === 'Identifier') {
768
- return callee.value
769
- }
770
- if (callee.type === 'MemberExpression') {
771
- const parts: string[] = []
772
- let current: any = callee
773
- while (current.type === 'MemberExpression') {
774
- if (current.property.type === 'Identifier') {
775
- parts.unshift(current.property.value)
776
- } else {
777
- return null // Cannot handle computed properties like i18n['t']
778
- }
779
- current = current.object
780
- }
781
- // Handle `this` as the base of the expression (e.g., this._i18n.t)
782
- if (current.type === 'ThisExpression') {
783
- parts.unshift('this')
784
- } else if (current.type === 'Identifier') {
785
- parts.unshift(current.value)
786
- } else {
787
- return null // Base of the expression is not a simple identifier
788
- }
789
- return parts.join('.')
790
- }
791
- return null
792
- }
793
- }