i18next-cli 1.34.0 ā 1.34.1
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/README.md +1 -1
- package/dist/cjs/cli.js +271 -1
- package/dist/cjs/config.js +211 -1
- package/dist/cjs/extractor/core/ast-visitors.js +364 -1
- package/dist/cjs/extractor/core/extractor.js +245 -1
- package/dist/cjs/extractor/core/key-finder.js +132 -1
- package/dist/cjs/extractor/core/translation-manager.js +745 -1
- package/dist/cjs/extractor/parsers/ast-utils.js +85 -1
- package/dist/cjs/extractor/parsers/call-expression-handler.js +941 -1
- package/dist/cjs/extractor/parsers/comment-parser.js +375 -1
- package/dist/cjs/extractor/parsers/expression-resolver.js +362 -1
- package/dist/cjs/extractor/parsers/jsx-handler.js +492 -1
- package/dist/cjs/extractor/parsers/jsx-parser.js +355 -1
- package/dist/cjs/extractor/parsers/scope-manager.js +408 -1
- package/dist/cjs/extractor/plugin-manager.js +106 -1
- package/dist/cjs/heuristic-config.js +99 -1
- package/dist/cjs/index.js +28 -1
- package/dist/cjs/init.js +174 -1
- package/dist/cjs/linter.js +431 -1
- package/dist/cjs/locize.js +269 -1
- package/dist/cjs/migrator.js +196 -1
- package/dist/cjs/rename-key.js +354 -1
- package/dist/cjs/status.js +336 -1
- package/dist/cjs/syncer.js +120 -1
- package/dist/cjs/types-generator.js +165 -1
- package/dist/cjs/utils/default-value.js +43 -1
- package/dist/cjs/utils/file-utils.js +136 -1
- package/dist/cjs/utils/funnel-msg-tracker.js +75 -1
- package/dist/cjs/utils/logger.js +36 -1
- package/dist/cjs/utils/nested-object.js +124 -1
- package/dist/cjs/utils/validation.js +71 -1
- package/dist/esm/cli.js +269 -1
- package/dist/esm/config.js +206 -1
- package/dist/esm/extractor/core/ast-visitors.js +362 -1
- package/dist/esm/extractor/core/extractor.js +241 -1
- package/dist/esm/extractor/core/key-finder.js +130 -1
- package/dist/esm/extractor/core/translation-manager.js +743 -1
- package/dist/esm/extractor/parsers/ast-utils.js +80 -1
- package/dist/esm/extractor/parsers/call-expression-handler.js +939 -1
- package/dist/esm/extractor/parsers/comment-parser.js +373 -1
- package/dist/esm/extractor/parsers/expression-resolver.js +360 -1
- package/dist/esm/extractor/parsers/jsx-handler.js +490 -1
- package/dist/esm/extractor/parsers/jsx-parser.js +334 -1
- package/dist/esm/extractor/parsers/scope-manager.js +406 -1
- package/dist/esm/extractor/plugin-manager.js +103 -1
- package/dist/esm/heuristic-config.js +97 -1
- package/dist/esm/index.js +11 -1
- package/dist/esm/init.js +172 -1
- package/dist/esm/linter.js +425 -1
- package/dist/esm/locize.js +265 -1
- package/dist/esm/migrator.js +194 -1
- package/dist/esm/rename-key.js +352 -1
- package/dist/esm/status.js +334 -1
- package/dist/esm/syncer.js +118 -1
- package/dist/esm/types-generator.js +163 -1
- package/dist/esm/utils/default-value.js +41 -1
- package/dist/esm/utils/file-utils.js +131 -1
- package/dist/esm/utils/funnel-msg-tracker.js +72 -1
- package/dist/esm/utils/logger.js +34 -1
- package/dist/esm/utils/nested-object.js +120 -1
- package/dist/esm/utils/validation.js +68 -1
- package/package.json +2 -2
- package/types/locize.d.ts.map +1 -1
|
@@ -1 +1,364 @@
|
|
|
1
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var scopeManager = require('../parsers/scope-manager.js');
|
|
4
|
+
var expressionResolver = require('../parsers/expression-resolver.js');
|
|
5
|
+
var callExpressionHandler = require('../parsers/call-expression-handler.js');
|
|
6
|
+
var jsxHandler = require('../parsers/jsx-handler.js');
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* AST visitor class that traverses JavaScript/TypeScript syntax trees to extract translation keys.
|
|
10
|
+
*
|
|
11
|
+
* This class implements a manual recursive walker that:
|
|
12
|
+
* - Maintains scope information for tracking useTranslation and getFixedT calls
|
|
13
|
+
* - Extracts keys from t() function calls with various argument patterns
|
|
14
|
+
* - Handles JSX Trans components with complex children serialization
|
|
15
|
+
* - Supports both string literals and selector API for type-safe keys
|
|
16
|
+
* - Processes pluralization and context variants
|
|
17
|
+
* - Manages namespace resolution from multiple sources
|
|
18
|
+
*
|
|
19
|
+
* The visitor respects configuration options for separators, function names,
|
|
20
|
+
* component names, and other extraction settings.
|
|
21
|
+
*
|
|
22
|
+
* @example
|
|
23
|
+
* ```typescript
|
|
24
|
+
* const visitors = new ASTVisitors(config, pluginContext, logger)
|
|
25
|
+
* visitors.visit(parsedAST)
|
|
26
|
+
*
|
|
27
|
+
* // The pluginContext will now contain all extracted keys
|
|
28
|
+
* ```
|
|
29
|
+
*/
|
|
30
|
+
class ASTVisitors {
|
|
31
|
+
pluginContext;
|
|
32
|
+
config;
|
|
33
|
+
logger;
|
|
34
|
+
hooks;
|
|
35
|
+
get objectKeys() {
|
|
36
|
+
return this.callExpressionHandler.objectKeys;
|
|
37
|
+
}
|
|
38
|
+
scopeManager;
|
|
39
|
+
expressionResolver;
|
|
40
|
+
callExpressionHandler;
|
|
41
|
+
jsxHandler;
|
|
42
|
+
currentFile = '';
|
|
43
|
+
currentCode = '';
|
|
44
|
+
/**
|
|
45
|
+
* Creates a new AST visitor instance.
|
|
46
|
+
*
|
|
47
|
+
* @param config - Toolkit configuration with extraction settings
|
|
48
|
+
* @param pluginContext - Context for adding discovered translation keys
|
|
49
|
+
* @param logger - Logger for warnings and debug information
|
|
50
|
+
*/
|
|
51
|
+
constructor(config, pluginContext, logger, hooks, expressionResolver$1) {
|
|
52
|
+
this.pluginContext = pluginContext;
|
|
53
|
+
this.config = config;
|
|
54
|
+
this.logger = logger;
|
|
55
|
+
this.hooks = {
|
|
56
|
+
onBeforeVisitNode: hooks?.onBeforeVisitNode,
|
|
57
|
+
onAfterVisitNode: hooks?.onAfterVisitNode,
|
|
58
|
+
resolvePossibleKeyStringValues: hooks?.resolvePossibleKeyStringValues,
|
|
59
|
+
resolvePossibleContextStringValues: hooks?.resolvePossibleContextStringValues
|
|
60
|
+
};
|
|
61
|
+
this.scopeManager = new scopeManager.ScopeManager(config);
|
|
62
|
+
// use shared resolver when provided so captured enums/objects are visible across files
|
|
63
|
+
this.expressionResolver = expressionResolver$1 ?? new expressionResolver.ExpressionResolver(this.hooks);
|
|
64
|
+
this.callExpressionHandler = new callExpressionHandler.CallExpressionHandler(config, pluginContext, logger, this.expressionResolver, () => this.getCurrentFile(), () => this.getCurrentCode());
|
|
65
|
+
this.jsxHandler = new jsxHandler.JSXHandler(config, pluginContext, this.expressionResolver, () => this.getCurrentFile(), () => this.getCurrentCode());
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* Main entry point for AST traversal.
|
|
69
|
+
* Creates a root scope and begins the recursive walk through the syntax tree.
|
|
70
|
+
*
|
|
71
|
+
* @param node - The root module node to traverse
|
|
72
|
+
*/
|
|
73
|
+
visit(node) {
|
|
74
|
+
// Reset any per-file scope state to avoid leaking scopes between files.
|
|
75
|
+
this.scopeManager.reset();
|
|
76
|
+
// Reset per-file captured variables in the expression resolver so variables from other files don't leak.
|
|
77
|
+
this.expressionResolver.resetFileSymbols();
|
|
78
|
+
this.scopeManager.enterScope(); // Create the root scope for the file
|
|
79
|
+
this.walk(node);
|
|
80
|
+
this.scopeManager.exitScope(); // Clean up the root scope
|
|
81
|
+
}
|
|
82
|
+
/**
|
|
83
|
+
* Recursively walks through AST nodes, handling scoping and visiting logic.
|
|
84
|
+
*
|
|
85
|
+
* This is the core traversal method that:
|
|
86
|
+
* 1. Manages function scopes (enter/exit)
|
|
87
|
+
* 2. Dispatches to specific handlers based on node type
|
|
88
|
+
* 3. Recursively processes child nodes
|
|
89
|
+
* 4. Maintains proper scope cleanup
|
|
90
|
+
*
|
|
91
|
+
* @param node - The current AST node to process
|
|
92
|
+
*
|
|
93
|
+
* @private
|
|
94
|
+
*/
|
|
95
|
+
walk(node) {
|
|
96
|
+
if (!node)
|
|
97
|
+
return;
|
|
98
|
+
let isNewScope = false;
|
|
99
|
+
// ENTER SCOPE for functions
|
|
100
|
+
// Accept many SWC/TS AST variants for function-like nodes (declarations, expressions, arrow functions)
|
|
101
|
+
if (node.type === 'Function' ||
|
|
102
|
+
node.type === 'FunctionDeclaration' ||
|
|
103
|
+
node.type === 'FunctionDecl' ||
|
|
104
|
+
node.type === 'FnDecl' ||
|
|
105
|
+
node.type === 'ArrowFunctionExpression' ||
|
|
106
|
+
node.type === 'FunctionExpression' ||
|
|
107
|
+
node.type === 'MethodDefinition' ||
|
|
108
|
+
node.type === 'ClassMethod' ||
|
|
109
|
+
node.type === 'ObjectMethod') {
|
|
110
|
+
this.scopeManager.enterScope();
|
|
111
|
+
isNewScope = true;
|
|
112
|
+
const params = (node.params && Array.isArray(node.params)) ? node.params : (node.params || []);
|
|
113
|
+
for (const p of params) {
|
|
114
|
+
// handle common param shapes: Identifier, AssignmentPattern (default), RestElement ignored
|
|
115
|
+
let ident;
|
|
116
|
+
if (!p)
|
|
117
|
+
continue;
|
|
118
|
+
// direct identifier (arrow fn params etc)
|
|
119
|
+
if (p.type === 'Identifier')
|
|
120
|
+
ident = p;
|
|
121
|
+
// default params: (x = ...) -> AssignmentPattern.left
|
|
122
|
+
else if (p.type === 'AssignmentPattern' && p.left && p.left.type === 'Identifier')
|
|
123
|
+
ident = p.left;
|
|
124
|
+
// rest: (...args)
|
|
125
|
+
else if (p.type === 'RestElement' && p.argument && p.argument.type === 'Identifier')
|
|
126
|
+
ident = p.argument;
|
|
127
|
+
// SWC/TS often wrap params: { pat: Identifier } or { pattern: Identifier } or FnParam/Param
|
|
128
|
+
else if ((p.type === 'Param' || p.type === 'FnParam' || p.type === 'Arg') && p.pat && p.pat.type === 'Identifier')
|
|
129
|
+
ident = p.pat;
|
|
130
|
+
else if ((p.type === 'Param' || p.type === 'FnParam' || p.type === 'Arg') && p.pattern && p.pattern.type === 'Identifier')
|
|
131
|
+
ident = p.pattern;
|
|
132
|
+
else if (p.pat && p.pat.type === 'Identifier')
|
|
133
|
+
ident = p.pat;
|
|
134
|
+
else if (p.pattern && p.pattern.type === 'Identifier')
|
|
135
|
+
ident = p.pattern;
|
|
136
|
+
// some parsers expose .param or .left.param shapes
|
|
137
|
+
else if ((p.left && p.left.param && p.left.param.type === 'Identifier'))
|
|
138
|
+
ident = p.left.param;
|
|
139
|
+
else if ((p.param && p.param.type === 'Identifier'))
|
|
140
|
+
ident = p.param;
|
|
141
|
+
if (!ident)
|
|
142
|
+
continue;
|
|
143
|
+
const paramKey = (ident.value ?? ident.name);
|
|
144
|
+
if (!paramKey)
|
|
145
|
+
continue;
|
|
146
|
+
// Try to locate TypeScript type node carried on the identifier.
|
|
147
|
+
const rawTypeAnn = (ident.typeAnnotation ?? p.typeAnnotation ?? (p.left && p.left.typeAnnotation));
|
|
148
|
+
let typeAnn;
|
|
149
|
+
if (rawTypeAnn) {
|
|
150
|
+
// SWC may wrap the actual TS type in a wrapper like TsTypeAnn / TsTypeAnnotation
|
|
151
|
+
if (rawTypeAnn.type === 'TsTypeAnn' || rawTypeAnn.type === 'TsTypeAnnotation') {
|
|
152
|
+
typeAnn = rawTypeAnn.typeAnnotation ?? rawTypeAnn;
|
|
153
|
+
}
|
|
154
|
+
else {
|
|
155
|
+
typeAnn = rawTypeAnn;
|
|
156
|
+
}
|
|
157
|
+
}
|
|
158
|
+
else {
|
|
159
|
+
typeAnn = undefined;
|
|
160
|
+
}
|
|
161
|
+
// Small helpers to robustly extract the referenced type name and literal string
|
|
162
|
+
const extractTypeName = (ta) => {
|
|
163
|
+
if (!ta)
|
|
164
|
+
return undefined;
|
|
165
|
+
// Identifier style: { type: 'Identifier', value: 'TFunction' } OR { name: 'TFunction' }
|
|
166
|
+
if (ta.typeName && (ta.typeName.type === 'Identifier'))
|
|
167
|
+
return ta.typeName.value ?? ta.typeName.name;
|
|
168
|
+
if (ta.typeName && ta.typeName.type === 'TsQualifiedName') {
|
|
169
|
+
// Qualified like Foo.TFunction -> try right side
|
|
170
|
+
const right = (ta.typeName.right ?? ta.typeName);
|
|
171
|
+
return right?.value ?? right?.name;
|
|
172
|
+
}
|
|
173
|
+
if (ta.typeName && typeof ta.typeName === 'string')
|
|
174
|
+
return ta.typeName;
|
|
175
|
+
if (ta.type === 'Identifier')
|
|
176
|
+
return ta.value ?? ta.name;
|
|
177
|
+
if (ta.id)
|
|
178
|
+
return ta.id?.value ?? ta.id?.name ?? ta.id;
|
|
179
|
+
return undefined;
|
|
180
|
+
};
|
|
181
|
+
const extractStringLiteralValue = (node) => {
|
|
182
|
+
if (!node)
|
|
183
|
+
return undefined;
|
|
184
|
+
// shapes: TsLiteralType -> { literal: { type: 'StringLiteral', value: 'x' } }
|
|
185
|
+
if (node.type === 'TsLiteralType' && node.literal)
|
|
186
|
+
return node.literal.value ?? node.literal.raw;
|
|
187
|
+
if (node.type === 'StringLiteral' || node.type === 'Str' || node.type === 'Literal')
|
|
188
|
+
return node.value ?? node.raw ?? node.value;
|
|
189
|
+
if (node.literal && (node.literal.type === 'StringLiteral' || node.literal.type === 'Str'))
|
|
190
|
+
return node.literal.value;
|
|
191
|
+
// some SWC builds put the string directly on .value
|
|
192
|
+
if (typeof node.value === 'string')
|
|
193
|
+
return node.value;
|
|
194
|
+
// handle wrapped parameter like { params: [ ... ] } where a literal might be one level deeper
|
|
195
|
+
if (node.params && Array.isArray(node.params) && node.params[0])
|
|
196
|
+
return extractStringLiteralValue(node.params[0]);
|
|
197
|
+
if (node.typeArguments && Array.isArray(node.typeArguments) && node.typeArguments[0])
|
|
198
|
+
return extractStringLiteralValue(node.typeArguments[0]);
|
|
199
|
+
if (node.typeParameters && Array.isArray(node.typeParameters) && node.typeParameters[0])
|
|
200
|
+
return extractStringLiteralValue(node.typeParameters[0]);
|
|
201
|
+
if (node.typeParams && Array.isArray(node.typeParams) && node.typeParams[0])
|
|
202
|
+
return extractStringLiteralValue(node.typeParams[0]);
|
|
203
|
+
return undefined;
|
|
204
|
+
};
|
|
205
|
+
// Detect TsTypeReference like: TFunction<"my-custom-namespace">
|
|
206
|
+
if (typeAnn && (typeAnn.type === 'TsTypeReference' || typeAnn.type === 'TsTypeRef' || typeAnn.type === 'TsTypeReference')) {
|
|
207
|
+
const finalTypeName = extractTypeName(typeAnn);
|
|
208
|
+
if (finalTypeName === 'TFunction') {
|
|
209
|
+
// support multiple AST shapes for type parameters:
|
|
210
|
+
// - typeAnn.typeParameters?.params?.[0]
|
|
211
|
+
// - typeAnn.typeArguments?.params?.[0]
|
|
212
|
+
// - typeAnn.typeParams?.[0] / typeAnn.params?.[0]
|
|
213
|
+
const candidates = [
|
|
214
|
+
typeAnn.typeParameters?.params?.[0],
|
|
215
|
+
typeAnn.typeParameters?.[0],
|
|
216
|
+
typeAnn.typeArguments?.params?.[0],
|
|
217
|
+
typeAnn.typeArguments?.[0],
|
|
218
|
+
typeAnn.typeParams?.params?.[0],
|
|
219
|
+
typeAnn.typeParams?.[0],
|
|
220
|
+
typeAnn.params?.[0],
|
|
221
|
+
typeAnn.args?.[0],
|
|
222
|
+
typeAnn.typeParameters, // fallback if it's directly the literal
|
|
223
|
+
typeAnn.typeArguments,
|
|
224
|
+
typeAnn.typeParams,
|
|
225
|
+
];
|
|
226
|
+
let tp;
|
|
227
|
+
for (const c of candidates) {
|
|
228
|
+
if (c) {
|
|
229
|
+
tp = c;
|
|
230
|
+
break;
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
const ns = extractStringLiteralValue(tp);
|
|
234
|
+
if (ns) {
|
|
235
|
+
this.scopeManager.setVarInScope(paramKey, { defaultNs: ns });
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
}
|
|
241
|
+
this.hooks.onBeforeVisitNode?.(node);
|
|
242
|
+
// --- VISIT LOGIC ---
|
|
243
|
+
// Handle specific node types
|
|
244
|
+
switch (node.type) {
|
|
245
|
+
case 'VariableDeclarator':
|
|
246
|
+
this.scopeManager.handleVariableDeclarator(node);
|
|
247
|
+
// Capture simple variable initializers so the expressionResolver can
|
|
248
|
+
// resolve identifiers / member expressions that reference them.
|
|
249
|
+
this.expressionResolver.captureVariableDeclarator(node);
|
|
250
|
+
break;
|
|
251
|
+
case 'TSEnumDeclaration':
|
|
252
|
+
case 'TsEnumDeclaration':
|
|
253
|
+
case 'TsEnumDecl':
|
|
254
|
+
// capture enums into resolver symbol table
|
|
255
|
+
this.expressionResolver.captureEnumDeclaration(node);
|
|
256
|
+
break;
|
|
257
|
+
case 'CallExpression':
|
|
258
|
+
this.callExpressionHandler.handleCallExpression(node, this.scopeManager.getVarFromScope.bind(this.scopeManager));
|
|
259
|
+
break;
|
|
260
|
+
case 'JSXElement':
|
|
261
|
+
this.jsxHandler.handleJSXElement(node, this.scopeManager.getVarFromScope.bind(this.scopeManager));
|
|
262
|
+
break;
|
|
263
|
+
}
|
|
264
|
+
this.hooks.onAfterVisitNode?.(node);
|
|
265
|
+
// --- END VISIT LOGIC ---
|
|
266
|
+
// --- RECURSION ---
|
|
267
|
+
// Recurse into the children of the current node
|
|
268
|
+
for (const key in node) {
|
|
269
|
+
if (key === 'span')
|
|
270
|
+
continue;
|
|
271
|
+
const child = node[key];
|
|
272
|
+
if (Array.isArray(child)) {
|
|
273
|
+
// Pre-scan array children to register VariableDeclarator-based scopes
|
|
274
|
+
// (e.g., `const { t } = useTranslation(...)`) before walking the rest
|
|
275
|
+
// of the items. This ensures that functions/arrow-functions defined
|
|
276
|
+
// earlier in the same block that reference t will resolve to the
|
|
277
|
+
// correct scope even if the `useTranslation` declarator appears later.
|
|
278
|
+
for (const item of child) {
|
|
279
|
+
if (!item || typeof item !== 'object')
|
|
280
|
+
continue;
|
|
281
|
+
// Direct declarator present in arrays (rare)
|
|
282
|
+
if (item.type === 'VariableDeclarator') {
|
|
283
|
+
this.scopeManager.handleVariableDeclarator(item);
|
|
284
|
+
this.expressionResolver.captureVariableDeclarator(item);
|
|
285
|
+
continue;
|
|
286
|
+
}
|
|
287
|
+
// enum declarations can appear as ExportDeclaration.declaration earlier; be permissive
|
|
288
|
+
if (item && item.id && Array.isArray(item.members)) {
|
|
289
|
+
this.expressionResolver.captureEnumDeclaration(item);
|
|
290
|
+
// continue to allow further traversal
|
|
291
|
+
}
|
|
292
|
+
// Common case: VariableDeclaration which contains .declarations (VariableDeclarator[])
|
|
293
|
+
if (item.type === 'VariableDeclaration' && Array.isArray(item.declarations)) {
|
|
294
|
+
for (const decl of item.declarations) {
|
|
295
|
+
if (decl && typeof decl === 'object' && decl.type === 'VariableDeclarator') {
|
|
296
|
+
this.scopeManager.handleVariableDeclarator(decl);
|
|
297
|
+
this.expressionResolver.captureVariableDeclarator(decl);
|
|
298
|
+
}
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
}
|
|
302
|
+
for (const item of child) {
|
|
303
|
+
// Be less strict: if it's a non-null object, walk it.
|
|
304
|
+
// This allows traversal into nodes that might not have a `.type` property
|
|
305
|
+
// but still contain other valid AST nodes.
|
|
306
|
+
if (item && typeof item === 'object') {
|
|
307
|
+
this.walk(item);
|
|
308
|
+
}
|
|
309
|
+
}
|
|
310
|
+
}
|
|
311
|
+
else if (child && typeof child === 'object') {
|
|
312
|
+
// The condition for single objects should be the same as for array items.
|
|
313
|
+
// Do not require `child.type`. This allows traversal into class method bodies.
|
|
314
|
+
this.walk(child);
|
|
315
|
+
}
|
|
316
|
+
}
|
|
317
|
+
// --- END RECURSION ---
|
|
318
|
+
// LEAVE SCOPE for functions
|
|
319
|
+
if (isNewScope) {
|
|
320
|
+
this.scopeManager.exitScope();
|
|
321
|
+
}
|
|
322
|
+
}
|
|
323
|
+
/**
|
|
324
|
+
* Retrieves variable information from the scope chain.
|
|
325
|
+
* Searches from innermost to outermost scope.
|
|
326
|
+
*
|
|
327
|
+
* @param name - Variable name to look up
|
|
328
|
+
* @returns Scope information if found, undefined otherwise
|
|
329
|
+
*
|
|
330
|
+
* @private
|
|
331
|
+
*/
|
|
332
|
+
getVarFromScope(name) {
|
|
333
|
+
return this.scopeManager.getVarFromScope(name);
|
|
334
|
+
}
|
|
335
|
+
/**
|
|
336
|
+
* Sets the current file path and code used by the extractor.
|
|
337
|
+
* Also resets the search index for location tracking.
|
|
338
|
+
*/
|
|
339
|
+
setCurrentFile(file, code) {
|
|
340
|
+
this.currentFile = file;
|
|
341
|
+
this.currentCode = code;
|
|
342
|
+
// Reset search indexes when processing a new file
|
|
343
|
+
this.callExpressionHandler.resetSearchIndex();
|
|
344
|
+
this.jsxHandler.resetSearchIndex();
|
|
345
|
+
}
|
|
346
|
+
/**
|
|
347
|
+
* Returns the currently set file path.
|
|
348
|
+
*
|
|
349
|
+
* @returns The current file path as a string, or `undefined` if no file has been set.
|
|
350
|
+
* @remarks
|
|
351
|
+
* Use this to retrieve the file context that was previously set via `setCurrentFile`.
|
|
352
|
+
*/
|
|
353
|
+
getCurrentFile() {
|
|
354
|
+
return this.currentFile;
|
|
355
|
+
}
|
|
356
|
+
/**
|
|
357
|
+
* @returns The full source code string for the file currently under processing.
|
|
358
|
+
*/
|
|
359
|
+
getCurrentCode() {
|
|
360
|
+
return this.currentCode;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
exports.ASTVisitors = ASTVisitors;
|
|
@@ -1 +1,245 @@
|
|
|
1
|
-
|
|
1
|
+
'use strict';
|
|
2
|
+
|
|
3
|
+
var ora = require('ora');
|
|
4
|
+
var chalk = require('chalk');
|
|
5
|
+
var core = require('@swc/core');
|
|
6
|
+
var promises = require('node:fs/promises');
|
|
7
|
+
var node_path = require('node:path');
|
|
8
|
+
var keyFinder = require('./key-finder.js');
|
|
9
|
+
var translationManager = require('./translation-manager.js');
|
|
10
|
+
var validation = require('../../utils/validation.js');
|
|
11
|
+
var commentParser = require('../parsers/comment-parser.js');
|
|
12
|
+
var logger = require('../../utils/logger.js');
|
|
13
|
+
var fileUtils = require('../../utils/file-utils.js');
|
|
14
|
+
var funnelMsgTracker = require('../../utils/funnel-msg-tracker.js');
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Main extractor function that runs the complete key extraction and file generation process.
|
|
18
|
+
*
|
|
19
|
+
* This is the primary entry point that:
|
|
20
|
+
* 1. Validates configuration
|
|
21
|
+
* 2. Sets up default sync options
|
|
22
|
+
* 3. Finds all translation keys across source files
|
|
23
|
+
* 4. Generates/updates translation files for all locales
|
|
24
|
+
* 5. Provides progress feedback via spinner
|
|
25
|
+
* 6. Returns whether any files were updated
|
|
26
|
+
*
|
|
27
|
+
* @param config - The i18next toolkit configuration object
|
|
28
|
+
* @param logger - Logger instance for output (defaults to ConsoleLogger)
|
|
29
|
+
* @returns Promise resolving to boolean indicating if any files were updated
|
|
30
|
+
*
|
|
31
|
+
* @throws {ExtractorError} When configuration validation fails or extraction process encounters errors
|
|
32
|
+
*
|
|
33
|
+
* @example
|
|
34
|
+
* ```typescript
|
|
35
|
+
* const config = await loadConfig()
|
|
36
|
+
* const updated = await runExtractor(config)
|
|
37
|
+
* if (updated) {
|
|
38
|
+
* console.log('Translation files were updated')
|
|
39
|
+
* }
|
|
40
|
+
* ```
|
|
41
|
+
*/
|
|
42
|
+
async function runExtractor(config, { isWatchMode = false, isDryRun = false, syncPrimaryWithDefaults = false, syncAll = false } = {}, logger$1 = new logger.ConsoleLogger()) {
|
|
43
|
+
config.extract.primaryLanguage ||= config.locales[0] || 'en';
|
|
44
|
+
config.extract.secondaryLanguages ||= config.locales.filter((l) => l !== config?.extract?.primaryLanguage);
|
|
45
|
+
// Ensure default function and component names are set if not provided.
|
|
46
|
+
config.extract.functions ||= ['t', '*.t'];
|
|
47
|
+
config.extract.transComponents ||= ['Trans'];
|
|
48
|
+
validation.validateExtractorConfig(config);
|
|
49
|
+
const plugins = config.plugins || [];
|
|
50
|
+
const spinner = ora('Running i18next key extractor...\n').start();
|
|
51
|
+
try {
|
|
52
|
+
const { allKeys, objectKeys } = await keyFinder.findKeys(config, logger$1);
|
|
53
|
+
spinner.text = `Found ${allKeys.size} unique keys. Updating translation files...`;
|
|
54
|
+
const results = await translationManager.getTranslations(allKeys, objectKeys, config, { syncPrimaryWithDefaults, syncAll });
|
|
55
|
+
let anyFileUpdated = false;
|
|
56
|
+
for (const result of results) {
|
|
57
|
+
if (result.updated) {
|
|
58
|
+
anyFileUpdated = true;
|
|
59
|
+
if (!isDryRun) {
|
|
60
|
+
// prefer explicit outputFormat; otherwise infer from file extension per-file
|
|
61
|
+
const effectiveFormat = config.extract.outputFormat ?? (result.path.endsWith('.json5') ? 'json5' : 'json');
|
|
62
|
+
const rawContent = effectiveFormat === 'json5'
|
|
63
|
+
? (await fileUtils.loadRawJson5Content(result.path)) ?? undefined
|
|
64
|
+
: undefined;
|
|
65
|
+
const fileContent = fileUtils.serializeTranslationFile(result.newTranslations, effectiveFormat, config.extract.indentation, rawContent);
|
|
66
|
+
await promises.mkdir(node_path.dirname(result.path), { recursive: true });
|
|
67
|
+
await promises.writeFile(result.path, fileContent);
|
|
68
|
+
logger$1.info(chalk.green(`Updated: ${result.path}`));
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
// Run afterSync hooks from plugins
|
|
73
|
+
if (plugins.length > 0) {
|
|
74
|
+
spinner.text = 'Running post-extraction plugins...';
|
|
75
|
+
for (const plugin of plugins) {
|
|
76
|
+
await plugin.afterSync?.(results, config);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
spinner.succeed(chalk.bold('Extraction complete!'));
|
|
80
|
+
// Show the funnel message only if files were actually changed.
|
|
81
|
+
if (anyFileUpdated)
|
|
82
|
+
await printLocizeFunnel();
|
|
83
|
+
return anyFileUpdated;
|
|
84
|
+
}
|
|
85
|
+
catch (error) {
|
|
86
|
+
spinner.fail(chalk.red('Extraction failed.'));
|
|
87
|
+
// Re-throw or handle error
|
|
88
|
+
throw error;
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Processes an individual source file for translation key extraction.
|
|
93
|
+
*
|
|
94
|
+
* This function:
|
|
95
|
+
* 1. Reads the source file
|
|
96
|
+
* 2. Runs plugin onLoad hooks for code transformation
|
|
97
|
+
* 3. Parses the code into an Abstract Syntax Tree (AST) using SWC
|
|
98
|
+
* 4. Extracts keys from comments using regex patterns
|
|
99
|
+
* 5. Traverses the AST using visitors to find translation calls
|
|
100
|
+
* 6. Runs plugin onVisitNode hooks for custom extraction logic
|
|
101
|
+
*
|
|
102
|
+
* @param file - Path to the source file to process
|
|
103
|
+
* @param config - The i18next toolkit configuration object
|
|
104
|
+
* @param logger - Logger instance for output
|
|
105
|
+
* @param allKeys - Map to accumulate found translation keys
|
|
106
|
+
*
|
|
107
|
+
* @throws {ExtractorError} When file processing fails
|
|
108
|
+
*
|
|
109
|
+
* @internal
|
|
110
|
+
*/
|
|
111
|
+
async function processFile(file, plugins, astVisitors, pluginContext, config, logger$1 = new logger.ConsoleLogger()) {
|
|
112
|
+
try {
|
|
113
|
+
let code = await promises.readFile(file, 'utf-8');
|
|
114
|
+
// Run onLoad hooks from plugins with error handling
|
|
115
|
+
for (const plugin of plugins) {
|
|
116
|
+
try {
|
|
117
|
+
const result = await plugin.onLoad?.(code, file);
|
|
118
|
+
if (result !== undefined) {
|
|
119
|
+
code = result;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
catch (err) {
|
|
123
|
+
logger$1.warn(`Plugin ${plugin.name} onLoad failed:`, err);
|
|
124
|
+
// Continue with the original code if the plugin fails
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
// Determine parser options from file extension so .ts is not parsed as TSX
|
|
128
|
+
const fileExt = node_path.extname(file).toLowerCase();
|
|
129
|
+
const isTypeScriptFile = fileExt === '.ts' || fileExt === '.tsx' || fileExt === '.mts' || fileExt === '.cts';
|
|
130
|
+
const isTSX = fileExt === '.tsx';
|
|
131
|
+
const isJSX = fileExt === '.jsx';
|
|
132
|
+
let ast;
|
|
133
|
+
try {
|
|
134
|
+
ast = await core.parse(code, {
|
|
135
|
+
syntax: isTypeScriptFile ? 'typescript' : 'ecmascript',
|
|
136
|
+
tsx: isTSX,
|
|
137
|
+
jsx: isJSX,
|
|
138
|
+
decorators: true,
|
|
139
|
+
dynamicImport: true,
|
|
140
|
+
comments: true,
|
|
141
|
+
});
|
|
142
|
+
}
|
|
143
|
+
catch (err) {
|
|
144
|
+
// Fallback for .ts files with JSX (already present)
|
|
145
|
+
if (fileExt === '.ts' && !isTSX) {
|
|
146
|
+
try {
|
|
147
|
+
ast = await core.parse(code, {
|
|
148
|
+
syntax: 'typescript',
|
|
149
|
+
tsx: true,
|
|
150
|
+
decorators: true,
|
|
151
|
+
dynamicImport: true,
|
|
152
|
+
comments: true,
|
|
153
|
+
});
|
|
154
|
+
logger$1.info?.(`Parsed ${file} using TSX fallback`);
|
|
155
|
+
}
|
|
156
|
+
catch (err2) {
|
|
157
|
+
throw new validation.ExtractorError('Failed to process file', file, err2);
|
|
158
|
+
}
|
|
159
|
+
// Fallback for .js files with JSX
|
|
160
|
+
}
|
|
161
|
+
else if (fileExt === '.js' && !isJSX) {
|
|
162
|
+
try {
|
|
163
|
+
ast = await core.parse(code, {
|
|
164
|
+
syntax: 'ecmascript',
|
|
165
|
+
jsx: true,
|
|
166
|
+
decorators: true,
|
|
167
|
+
dynamicImport: true,
|
|
168
|
+
comments: true,
|
|
169
|
+
});
|
|
170
|
+
logger$1.info?.(`Parsed ${file} using JSX fallback`);
|
|
171
|
+
}
|
|
172
|
+
catch (err2) {
|
|
173
|
+
throw new validation.ExtractorError('Failed to process file', file, err2);
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
else {
|
|
177
|
+
throw new validation.ExtractorError('Failed to process file', file, err);
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
// "Wire up" the visitor's scope method to the context.
|
|
181
|
+
// This avoids a circular dependency while giving plugins access to the scope.
|
|
182
|
+
pluginContext.getVarFromScope = astVisitors.getVarFromScope.bind(astVisitors);
|
|
183
|
+
// Pass BOTH file and code
|
|
184
|
+
astVisitors.setCurrentFile(file, code);
|
|
185
|
+
// 3. FIRST: Visit the AST to build scope information
|
|
186
|
+
astVisitors.visit(ast);
|
|
187
|
+
// 4. THEN: Extract keys from comments with scope resolution (now scope info is available)
|
|
188
|
+
if (config.extract.extractFromComments !== false) {
|
|
189
|
+
commentParser.extractKeysFromComments(code, pluginContext, config, astVisitors.getVarFromScope.bind(astVisitors));
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
catch (error) {
|
|
193
|
+
logger$1.warn(`${chalk.yellow('Skipping file due to error:')} ${file}`);
|
|
194
|
+
const err = error;
|
|
195
|
+
const msg = typeof err?.message === 'string' && err.message.trim().length > 0
|
|
196
|
+
? err.message
|
|
197
|
+
: (typeof err === 'string' ? err : '') || err?.toString?.() || 'Unknown error';
|
|
198
|
+
logger$1.warn(` ${msg}`);
|
|
199
|
+
// If message is missing, stack is often the only useful clue
|
|
200
|
+
if ((!err?.message || String(err.message).trim() === '') && err?.stack) {
|
|
201
|
+
logger$1.warn(` ${String(err.stack)}`);
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
/**
|
|
206
|
+
* Simplified extraction function that returns translation results without file writing.
|
|
207
|
+
* Used primarily for testing and programmatic access.
|
|
208
|
+
*
|
|
209
|
+
* @param config - The i18next toolkit configuration object
|
|
210
|
+
* @returns Promise resolving to array of translation results
|
|
211
|
+
*
|
|
212
|
+
* @example
|
|
213
|
+
* ```typescript
|
|
214
|
+
* const results = await extract(config)
|
|
215
|
+
* for (const result of results) {
|
|
216
|
+
* console.log(`${result.path}: ${result.updated ? 'Updated' : 'No changes'}`)
|
|
217
|
+
* }
|
|
218
|
+
* ```
|
|
219
|
+
*/
|
|
220
|
+
async function extract(config, { syncPrimaryWithDefaults = false } = {}) {
|
|
221
|
+
config.extract.primaryLanguage ||= config.locales[0] || 'en';
|
|
222
|
+
config.extract.secondaryLanguages ||= config.locales.filter((l) => l !== config?.extract?.primaryLanguage);
|
|
223
|
+
config.extract.functions ||= ['t', '*.t'];
|
|
224
|
+
config.extract.transComponents ||= ['Trans'];
|
|
225
|
+
const { allKeys, objectKeys } = await keyFinder.findKeys(config);
|
|
226
|
+
return translationManager.getTranslations(allKeys, objectKeys, config, { syncPrimaryWithDefaults });
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Prints a promotional message for the locize saveMissing workflow.
|
|
230
|
+
* This message is shown after a successful extraction that resulted in changes.
|
|
231
|
+
*/
|
|
232
|
+
async function printLocizeFunnel() {
|
|
233
|
+
if (!(await funnelMsgTracker.shouldShowFunnel('extract')))
|
|
234
|
+
return;
|
|
235
|
+
console.log(chalk.yellow.bold('\nš” Tip: Tired of running the extractor manually?'));
|
|
236
|
+
console.log(' Discover a real-time "push" workflow with `saveMissing` and Locize AI,');
|
|
237
|
+
console.log(' where keys are created and translated automatically as you code.');
|
|
238
|
+
console.log(` Learn more: ${chalk.cyan('https://www.locize.com/blog/i18next-savemissing-ai-automation')}`);
|
|
239
|
+
console.log(` Watch the video: ${chalk.cyan('https://youtu.be/joPsZghT3wM')}`);
|
|
240
|
+
return funnelMsgTracker.recordFunnelShown('extract');
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
exports.extract = extract;
|
|
244
|
+
exports.processFile = processFile;
|
|
245
|
+
exports.runExtractor = runExtractor;
|