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