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.
- package/CHANGELOG.md +11 -0
- package/README.md +108 -46
- package/dist/cjs/cli.js +1 -1
- package/dist/cjs/extractor/core/extractor.js +1 -1
- package/dist/cjs/extractor/core/key-finder.js +1 -1
- package/dist/cjs/extractor/parsers/ast-visitors.js +1 -1
- package/dist/cjs/extractor/parsers/comment-parser.js +1 -1
- package/dist/cjs/extractor/plugin-manager.js +1 -1
- package/dist/esm/cli.js +1 -1
- package/dist/esm/extractor/core/extractor.js +1 -1
- package/dist/esm/extractor/core/key-finder.js +1 -1
- package/dist/esm/extractor/parsers/ast-visitors.js +1 -1
- package/dist/esm/extractor/parsers/comment-parser.js +1 -1
- package/dist/esm/extractor/plugin-manager.js +1 -1
- package/package.json +1 -1
- package/src/cli.ts +1 -1
- package/src/extractor/core/extractor.ts +13 -51
- package/src/extractor/core/key-finder.ts +48 -9
- package/src/extractor/parsers/ast-visitors.ts +171 -20
- package/src/extractor/parsers/comment-parser.ts +116 -2
- package/src/extractor/plugin-manager.ts +8 -3
- package/src/types.ts +26 -0
- package/types/extractor/core/extractor.d.ts +2 -2
- package/types/extractor/core/extractor.d.ts.map +1 -1
- package/types/extractor/core/key-finder.d.ts.map +1 -1
- package/types/extractor/parsers/ast-visitors.d.ts +48 -3
- package/types/extractor/parsers/ast-visitors.d.ts.map +1 -1
- package/types/extractor/parsers/comment-parser.d.ts.map +1 -1
- package/types/extractor/plugin-manager.d.ts +2 -2
- package/types/extractor/plugin-manager.d.ts.map +1 -1
- package/types/types.d.ts +24 -0
- 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,
|
|
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 (
|
|
91
|
+
if (plugins.length > 0) {
|
|
91
92
|
spinner.text = 'Running post-extraction plugins...'
|
|
92
|
-
for (const plugin of
|
|
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
|
-
|
|
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
|
|
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
|
-
//
|
|
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
|
-
//
|
|
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
|
-
//
|
|
173
|
-
|
|
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,
|
|
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
|
-
//
|
|
47
|
-
const astVisitors = new ASTVisitors(
|
|
83
|
+
// 3. Create the visitor instance, passing it the context.
|
|
84
|
+
const astVisitors = new ASTVisitors(otherConfig, pluginContext, logger, hooks)
|
|
48
85
|
|
|
49
|
-
//
|
|
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
|
-
|
|
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,
|
|
95
|
+
await processFile(file, plugins, astVisitors, pluginContext, otherConfig, logger)
|
|
57
96
|
}
|
|
58
97
|
|
|
59
|
-
// Run onEnd hooks
|
|
60
|
-
for (const plugin of
|
|
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
|
-
|
|
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 === '
|
|
460
|
-
|
|
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.
|
|
659
|
+
keysToProcess.push(...this.resolvePossibleKeyStringValues(element.expression))
|
|
544
660
|
}
|
|
545
661
|
}
|
|
546
662
|
} else {
|
|
547
|
-
keysToProcess.push(...this.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|
|
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.
|
|
991
|
-
const alternateValues = this.
|
|
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.
|
|
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.
|
|
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
|
-
|
|
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
|
+
}
|