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.
- package/dist/cjs/cli.js +1 -1
- package/dist/cjs/extractor/parsers/expression-resolver.js +1 -1
- package/dist/esm/cli.js +1 -1
- package/dist/esm/extractor/parsers/expression-resolver.js +1 -1
- package/package.json +6 -6
- package/types/cli.d.ts +3 -1
- package/types/cli.d.ts.map +1 -1
- package/types/extractor/parsers/expression-resolver.d.ts.map +1 -1
- package/CHANGELOG.md +0 -595
- package/src/cli.ts +0 -283
- package/src/config.ts +0 -215
- package/src/extractor/core/ast-visitors.ts +0 -259
- package/src/extractor/core/extractor.ts +0 -250
- package/src/extractor/core/key-finder.ts +0 -142
- package/src/extractor/core/translation-manager.ts +0 -750
- package/src/extractor/index.ts +0 -7
- package/src/extractor/parsers/ast-utils.ts +0 -87
- package/src/extractor/parsers/call-expression-handler.ts +0 -793
- package/src/extractor/parsers/comment-parser.ts +0 -424
- package/src/extractor/parsers/expression-resolver.ts +0 -353
- package/src/extractor/parsers/jsx-handler.ts +0 -488
- package/src/extractor/parsers/jsx-parser.ts +0 -1463
- package/src/extractor/parsers/scope-manager.ts +0 -445
- package/src/extractor/plugin-manager.ts +0 -116
- package/src/extractor.ts +0 -15
- package/src/heuristic-config.ts +0 -92
- package/src/index.ts +0 -22
- package/src/init.ts +0 -175
- package/src/linter.ts +0 -345
- package/src/locize.ts +0 -263
- package/src/migrator.ts +0 -208
- package/src/rename-key.ts +0 -398
- package/src/status.ts +0 -380
- package/src/syncer.ts +0 -133
- package/src/types-generator.ts +0 -139
- package/src/types.ts +0 -577
- package/src/utils/default-value.ts +0 -45
- package/src/utils/file-utils.ts +0 -167
- package/src/utils/funnel-msg-tracker.ts +0 -84
- package/src/utils/logger.ts +0 -36
- package/src/utils/nested-object.ts +0 -135
- 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
|
-
}
|