@elench/testkit 0.1.61 → 0.1.63

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.
@@ -1,5 +1,6 @@
1
1
  import path from "path";
2
2
  import ts from "typescript";
3
+ import { DYNAMIC_SEGMENT_TOKEN } from "./shared.mjs";
3
4
 
4
5
  const HTTP_WRAPPER_METHODS = {
5
6
  getJson: "GET",
@@ -23,13 +24,29 @@ export function analyzeNextPageFile({
23
24
  isServerActionFile,
24
25
  normalizeRoute,
25
26
  }) {
26
- const sourceFile = createSourceFile(filePath, content);
27
- const imports = collectImports(sourceFile, {
27
+ return analyzeNextRouteTree({
28
+ serviceName,
29
+ serviceRoot,
30
+ route,
31
+ rootFiles: [{ filePath, content }],
28
32
  readSourceFile,
29
- resolveImportPath,
33
+ resolveImportPath: (fromFilePath, specifier) =>
34
+ fromFilePath === filePath ? resolveImportPath(specifier) : resolveImportToSourceFile(serviceRoot, fromFilePath, specifier),
30
35
  isServerActionFile,
36
+ normalizeRoute,
31
37
  });
32
- const localFunctions = collectTopLevelFunctions(sourceFile);
38
+ }
39
+
40
+ export function analyzeNextRouteTree({
41
+ serviceName,
42
+ serviceRoot,
43
+ route,
44
+ rootFiles = [],
45
+ readSourceFile,
46
+ resolveImportPath,
47
+ isServerActionFile,
48
+ normalizeRoute,
49
+ }) {
33
50
  const directRequests = [];
34
51
  const directServerActionRefs = [];
35
52
  const nodes = [];
@@ -37,163 +54,366 @@ export function analyzeNextPageFile({
37
54
  const surfacesByTargetValue = new Map();
38
55
  const actionNodeIds = new Set();
39
56
  const requestNodeIds = new Set();
40
- const defaultPageComponent = findDefaultPageComponent(sourceFile, localFunctions);
41
- const directPageAnalysis = defaultPageComponent
42
- ? analyzeCallableLikeNode(defaultPageComponent, {
43
- imports,
44
- localFunctions,
45
- normalizeRoute,
46
- }, new Set(), { skipNestedFunctions: true })
47
- : emptyActionAnalysis();
48
-
49
- for (const request of directPageAnalysis.requests) {
50
- const node = createClientRequestNode(serviceName, filePath, request, `page:${route}`);
51
- if (!requestNodeIds.has(node.id)) {
52
- requestNodeIds.add(node.id);
53
- nodes.push(node);
57
+ const pageNodeId = `page_view:${serviceName}:${route}`;
58
+ const moduleCache = new Map();
59
+ const visitedExports = new Set();
60
+
61
+ const analyzerContext = {
62
+ serviceName,
63
+ serviceRoot,
64
+ route,
65
+ pageNodeId,
66
+ readSourceFile,
67
+ resolveImportPath,
68
+ isServerActionFile,
69
+ normalizeRoute,
70
+ moduleCache,
71
+ visitedExports,
72
+ state: {
73
+ nodes,
74
+ edges,
75
+ directRequests,
76
+ directServerActionRefs,
77
+ surfacesByTargetValue,
78
+ actionNodeIds,
79
+ requestNodeIds,
80
+ },
81
+ };
82
+
83
+ for (const rootFile of rootFiles) {
84
+ const rootContent =
85
+ typeof rootFile.content === "string"
86
+ ? rootFile.content
87
+ : readSourceFile(rootFile.filePath);
88
+ if (!rootContent) continue;
89
+ const moduleInfo = loadModuleInfo(rootFile.filePath, rootContent, analyzerContext);
90
+ analyzeModuleExport(moduleInfo, "default", analyzerContext);
91
+ }
92
+
93
+ return {
94
+ nodes: dedupeById(nodes),
95
+ edges: dedupeById(edges),
96
+ requests: directRequests,
97
+ serverActionRefs: dedupeServerActionRefs(directServerActionRefs),
98
+ surfacesByTargetValue,
99
+ };
100
+ }
101
+
102
+ export function extractHttpSuiteRequests(content, filePath = "suite.int.testkit.ts") {
103
+ const sourceFile = createSourceFile(filePath, content);
104
+ const requests = [];
105
+
106
+ const visit = (node) => {
107
+ if (ts.isCallExpression(node)) {
108
+ const request = resolveHttpRequestCall(node, { normalizeRoute: defaultNormalizeRoute });
109
+ if (request) requests.push(request);
54
110
  }
55
- directRequests.push({
56
- originNodeId: `page_view:${serviceName}:${route}`,
111
+ ts.forEachChild(node, visit);
112
+ };
113
+
114
+ visit(sourceFile);
115
+ return dedupeRequests(requests);
116
+ }
117
+
118
+ export function extractPlaywrightVisitedRoutes(content, filePath = "suite.pw.testkit.ts") {
119
+ const sourceFile = createSourceFile(filePath, content);
120
+ const routes = [];
121
+
122
+ const visit = (node) => {
123
+ if (ts.isCallExpression(node)) {
124
+ const route = resolveGotoRoute(node);
125
+ if (route) routes.push(route);
126
+ }
127
+ ts.forEachChild(node, visit);
128
+ };
129
+
130
+ visit(sourceFile);
131
+ return [...new Set(routes)];
132
+ }
133
+
134
+ function analyzeModuleExport(moduleInfo, contextExportName, analyzerContext) {
135
+ if (!moduleInfo) return;
136
+ const visitKey = `${moduleInfo.filePath}#${contextExportName}`;
137
+ if (analyzerContext.visitedExports.has(visitKey)) return;
138
+ analyzerContext.visitedExports.add(visitKey);
139
+
140
+ const callable = resolveModuleExportCallable(moduleInfo, contextExportName);
141
+ if (!callable) return;
142
+
143
+ const localFunctions = new Map(moduleInfo.topLevelFunctions);
144
+ for (const [name, node] of collectScopeLocalFunctions(callable)) {
145
+ localFunctions.set(name, node);
146
+ }
147
+
148
+ const actionAnalysis = analyzeCallableLikeNode(
149
+ callable,
150
+ {
151
+ imports: moduleInfo.imports,
152
+ localFunctions,
153
+ normalizeRoute: analyzerContext.normalizeRoute,
154
+ moduleLoader: (filePath) => loadModuleInfoFromFile(filePath, analyzerContext),
155
+ resolveImportedExport: resolveImportedExportCallable,
156
+ analyzeImportedCallable,
157
+ },
158
+ new Set(),
159
+ { skipNestedFunctions: true }
160
+ );
161
+
162
+ for (const request of actionAnalysis.requests) {
163
+ const node = createClientRequestNode(
164
+ analyzerContext.serviceName,
165
+ moduleInfo.filePath,
166
+ request,
167
+ `page:${analyzerContext.route}:${moduleInfo.filePath}`
168
+ );
169
+ if (!analyzerContext.state.requestNodeIds.has(node.id)) {
170
+ analyzerContext.state.requestNodeIds.add(node.id);
171
+ analyzerContext.state.nodes.push(node);
172
+ }
173
+ analyzerContext.state.directRequests.push({
174
+ originNodeId: analyzerContext.pageNodeId,
57
175
  node,
58
176
  method: request.method,
59
177
  path: request.path,
60
178
  });
61
- edges.push({
62
- id: `requests:page_view:${serviceName}:${route}:${node.id}`,
179
+ analyzerContext.state.edges.push({
180
+ id: `requests:${analyzerContext.pageNodeId}:${node.id}`,
63
181
  kind: "requests",
64
- from: `page_view:${serviceName}:${route}`,
182
+ from: analyzerContext.pageNodeId,
65
183
  to: node.id,
66
184
  confidence: request.confidence,
67
185
  });
68
186
  }
69
187
 
70
- for (const serverActionRef of directPageAnalysis.serverActionRefs) {
71
- directServerActionRefs.push({
72
- originNodeId: `page_view:${serviceName}:${route}`,
188
+ for (const serverActionRef of actionAnalysis.serverActionRefs) {
189
+ analyzerContext.state.directServerActionRefs.push({
190
+ originNodeId: analyzerContext.pageNodeId,
73
191
  ...serverActionRef,
74
192
  });
75
193
  }
76
194
 
77
- walkJsx(sourceFile, (element, formContext) => {
195
+ walkJsx(callable, (element, formContext) => {
78
196
  const descriptor = describeSurface(element, formContext);
79
- if (!descriptor) return formContext;
80
-
81
- const surfaceKey = descriptor.target?.value || descriptor.actionName || `${descriptor.tagName}:${descriptor.line}`;
82
- const surfaceId = `ui_surface:${serviceName}:${route}:${surfaceKey}`;
83
- const surfaceNode = {
84
- id: surfaceId,
85
- kind: "ui_surface",
86
- service: serviceName,
87
- label: descriptor.label,
88
- route,
89
- filePath,
90
- ...(descriptor.target ? { target: descriptor.target } : {}),
91
- metadata: {
92
- surfaceKind: descriptor.surfaceKind,
93
- tagName: descriptor.tagName,
94
- },
95
- };
197
+ if (descriptor) {
198
+ const surfaceKey = descriptor.target?.value || descriptor.actionName || `${moduleInfo.filePath}:${descriptor.tagName}:${descriptor.line}`;
199
+ const surfaceId = `ui_surface:${analyzerContext.serviceName}:${analyzerContext.route}:${surfaceKey}`;
200
+ const surfaceNode = {
201
+ id: surfaceId,
202
+ kind: "ui_surface",
203
+ service: analyzerContext.serviceName,
204
+ label: descriptor.label,
205
+ route: analyzerContext.route,
206
+ filePath: moduleInfo.filePath,
207
+ ...(descriptor.target ? { target: descriptor.target } : {}),
208
+ metadata: {
209
+ surfaceKind: descriptor.surfaceKind,
210
+ tagName: descriptor.tagName,
211
+ },
212
+ };
96
213
 
97
- nodes.push(surfaceNode);
98
- edges.push({
99
- id: `contains:page_view:${serviceName}:${route}:${surfaceId}`,
100
- kind: "contains",
101
- from: `page_view:${serviceName}:${route}`,
102
- to: surfaceId,
103
- confidence: "high",
104
- });
214
+ analyzerContext.state.nodes.push(surfaceNode);
215
+ analyzerContext.state.edges.push({
216
+ id: `contains:${analyzerContext.pageNodeId}:${surfaceId}`,
217
+ kind: "contains",
218
+ from: analyzerContext.pageNodeId,
219
+ to: surfaceId,
220
+ confidence: "high",
221
+ });
105
222
 
106
- if (descriptor.target?.kind === "testId") {
107
- surfacesByTargetValue.set(descriptor.target.value, surfaceNode);
108
- }
223
+ if (descriptor.target?.kind === "testId") {
224
+ analyzerContext.state.surfacesByTargetValue.set(descriptor.target.value, surfaceNode);
225
+ }
109
226
 
110
- if (descriptor.actionBinding) {
111
- const actionId = `ui_action:${serviceName}:${route}:${descriptor.actionBinding.key}`;
112
- if (!actionNodeIds.has(actionId)) {
113
- actionNodeIds.add(actionId);
114
- nodes.push({
115
- id: actionId,
116
- kind: "ui_action",
117
- service: serviceName,
118
- label: descriptor.actionBinding.label,
119
- route,
120
- filePath,
121
- metadata: {
122
- bindingKind: descriptor.actionBinding.kind,
123
- actionProp: descriptor.actionBinding.actionProp,
124
- },
227
+ if (descriptor.actionBinding) {
228
+ const actionId = `ui_action:${analyzerContext.serviceName}:${analyzerContext.route}:${descriptor.actionBinding.key}`;
229
+ if (!analyzerContext.state.actionNodeIds.has(actionId)) {
230
+ analyzerContext.state.actionNodeIds.add(actionId);
231
+ analyzerContext.state.nodes.push({
232
+ id: actionId,
233
+ kind: "ui_action",
234
+ service: analyzerContext.serviceName,
235
+ label: descriptor.actionBinding.label,
236
+ route: analyzerContext.route,
237
+ filePath: moduleInfo.filePath,
238
+ metadata: {
239
+ bindingKind: descriptor.actionBinding.kind,
240
+ actionProp: descriptor.actionBinding.actionProp,
241
+ },
242
+ });
243
+ }
244
+
245
+ analyzerContext.state.edges.push({
246
+ id: `triggers:${surfaceId}:${actionId}`,
247
+ kind: "triggers",
248
+ from: surfaceId,
249
+ to: actionId,
250
+ confidence: descriptor.actionBinding.confidence,
125
251
  });
126
- }
127
252
 
128
- edges.push({
129
- id: `triggers:${surfaceId}:${actionId}`,
130
- kind: "triggers",
131
- from: surfaceId,
132
- to: actionId,
133
- confidence: descriptor.actionBinding.confidence,
134
- });
253
+ const bindingAnalysis = analyzeActionBinding(descriptor.actionBinding, {
254
+ imports: moduleInfo.imports,
255
+ localFunctions,
256
+ normalizeRoute: analyzerContext.normalizeRoute,
257
+ moduleLoader: (filePath) => loadModuleInfoFromFile(filePath, analyzerContext),
258
+ resolveImportedExport: resolveImportedExportCallable,
259
+ analyzeImportedCallable,
260
+ });
135
261
 
136
- const actionAnalysis = analyzeActionBinding(descriptor.actionBinding, {
137
- imports,
138
- localFunctions,
139
- normalizeRoute,
140
- });
262
+ for (const request of bindingAnalysis.requests) {
263
+ const node = createClientRequestNode(
264
+ analyzerContext.serviceName,
265
+ moduleInfo.filePath,
266
+ request,
267
+ descriptor.actionBinding.key
268
+ );
269
+ if (!analyzerContext.state.requestNodeIds.has(node.id)) {
270
+ analyzerContext.state.requestNodeIds.add(node.id);
271
+ analyzerContext.state.nodes.push(node);
272
+ }
273
+ analyzerContext.state.directRequests.push({
274
+ originNodeId: actionId,
275
+ node,
276
+ method: request.method,
277
+ path: request.path,
278
+ });
279
+ analyzerContext.state.edges.push({
280
+ id: `requests:${actionId}:${node.id}`,
281
+ kind: "requests",
282
+ from: actionId,
283
+ to: node.id,
284
+ confidence: request.confidence,
285
+ });
286
+ }
141
287
 
142
- for (const request of actionAnalysis.requests) {
143
- const node = createClientRequestNode(serviceName, filePath, request, descriptor.actionBinding.key);
144
- if (!requestNodeIds.has(node.id)) {
145
- requestNodeIds.add(node.id);
146
- nodes.push(node);
288
+ for (const serverActionRef of bindingAnalysis.serverActionRefs) {
289
+ analyzerContext.state.directServerActionRefs.push({
290
+ originNodeId: actionId,
291
+ ...serverActionRef,
292
+ });
147
293
  }
148
- directRequests.push({
149
- originNodeId: actionId,
150
- node,
151
- method: request.method,
152
- path: request.path,
153
- });
154
- edges.push({
155
- id: `requests:${actionId}:${node.id}`,
156
- kind: "requests",
157
- from: actionId,
158
- to: node.id,
159
- confidence: request.confidence,
160
- });
161
294
  }
162
295
 
163
- for (const serverActionRef of actionAnalysis.serverActionRefs) {
164
- directServerActionRefs.push({
165
- originNodeId: actionId,
166
- ...serverActionRef,
167
- });
168
- }
296
+ return descriptor.formContext ?? formContext;
169
297
  }
170
298
 
171
- return descriptor.formContext ?? formContext;
299
+ const componentRef = resolveImportedComponentRef(element, moduleInfo.imports);
300
+ if (componentRef) {
301
+ analyzeImportedComponent(componentRef, analyzerContext);
302
+ }
303
+ return formContext;
172
304
  });
305
+ }
173
306
 
174
- return {
175
- nodes: dedupeById(nodes),
176
- edges: dedupeById(edges),
177
- requests: directRequests,
178
- serverActionRefs: dedupeServerActionRefs(directServerActionRefs),
179
- surfacesByTargetValue,
180
- };
307
+ function analyzeImportedComponent(componentRef, analyzerContext) {
308
+ const moduleInfo = loadModuleInfoFromFile(componentRef.filePath, analyzerContext);
309
+ if (!moduleInfo) return;
310
+ analyzeModuleExport(moduleInfo, componentRef.exportName, analyzerContext);
181
311
  }
182
312
 
183
- export function extractHttpSuiteRequests(content, filePath = "suite.int.testkit.ts") {
313
+ function analyzeImportedCallable(imported, options, visited) {
314
+ if (!imported?.resolvedFilePath || imported.isServerAction || visited.has(imported.resolvedFilePath)) {
315
+ return emptyActionAnalysis();
316
+ }
317
+ const moduleInfo = options.moduleLoader(imported.resolvedFilePath);
318
+ if (!moduleInfo) return emptyActionAnalysis();
319
+ const callable = options.resolveImportedExport(moduleInfo, imported.importedName);
320
+ if (!callable) return emptyActionAnalysis();
321
+
322
+ const localFunctions = new Map(moduleInfo.topLevelFunctions);
323
+ for (const [name, node] of collectScopeLocalFunctions(callable)) {
324
+ localFunctions.set(name, node);
325
+ }
326
+
327
+ const nextVisited = new Set(visited);
328
+ nextVisited.add(imported.resolvedFilePath);
329
+ return analyzeCallableLikeNode(
330
+ callable,
331
+ {
332
+ imports: moduleInfo.imports,
333
+ localFunctions,
334
+ normalizeRoute: options.normalizeRoute,
335
+ moduleLoader: options.moduleLoader,
336
+ resolveImportedExport: options.resolveImportedExport,
337
+ analyzeImportedCallable,
338
+ },
339
+ nextVisited
340
+ );
341
+ }
342
+
343
+ function loadModuleInfoFromFile(filePath, analyzerContext) {
344
+ const cached = analyzerContext.moduleCache.get(filePath);
345
+ if (cached) return cached;
346
+ const content = analyzerContext.readSourceFile(filePath);
347
+ if (!content) return null;
348
+ return loadModuleInfo(filePath, content, analyzerContext);
349
+ }
350
+
351
+ function loadModuleInfo(filePath, content, analyzerContext) {
352
+ const cached = analyzerContext.moduleCache.get(filePath);
353
+ if (cached) return cached;
354
+
184
355
  const sourceFile = createSourceFile(filePath, content);
185
- const requests = [];
356
+ const imports = collectImports(sourceFile, {
357
+ readSourceFile: analyzerContext.readSourceFile,
358
+ resolveImportPath: (specifier) => analyzerContext.resolveImportPath(filePath, specifier),
359
+ isServerActionFile: analyzerContext.isServerActionFile,
360
+ });
361
+ const topLevelFunctions = collectTopLevelFunctions(sourceFile);
362
+ const exports = collectExportedCallables(sourceFile, topLevelFunctions);
363
+ const moduleInfo = {
364
+ filePath,
365
+ sourceFile,
366
+ imports,
367
+ topLevelFunctions,
368
+ exports,
369
+ defaultExport: findDefaultPageComponent(sourceFile, topLevelFunctions),
370
+ };
371
+ analyzerContext.moduleCache.set(filePath, moduleInfo);
372
+ return moduleInfo;
373
+ }
186
374
 
187
- const visit = (node) => {
188
- if (ts.isCallExpression(node)) {
189
- const request = resolveHttpRequestCall(node, { normalizeRoute: defaultNormalizeRoute });
190
- if (request) requests.push(request);
191
- }
192
- ts.forEachChild(node, visit);
375
+ function resolveModuleExportCallable(moduleInfo, exportName) {
376
+ if (!moduleInfo) return null;
377
+ if (exportName === "default") return moduleInfo.defaultExport || null;
378
+ return moduleInfo.exports.get(exportName) || null;
379
+ }
380
+
381
+ function resolveImportedExportCallable(moduleInfo, importedName) {
382
+ if (!moduleInfo) return null;
383
+ if (importedName === "default") return moduleInfo.defaultExport || null;
384
+ return moduleInfo.exports.get(importedName) || null;
385
+ }
386
+
387
+ function resolveImportedComponentRef(node, imports) {
388
+ const opening = ts.isJsxElement(node) ? node.openingElement : node;
389
+ const tagName = opening.tagName.getText();
390
+ if (!/^[A-Z]/u.test(tagName)) return null;
391
+ const imported = imports.get(tagName);
392
+ if (!imported?.resolvedFilePath || imported.isServerAction) return null;
393
+ if (imported.resolvedFilePath.includes("/components/ui/")) return null;
394
+ return {
395
+ filePath: imported.resolvedFilePath,
396
+ exportName: imported.importedName,
193
397
  };
398
+ }
194
399
 
195
- visit(sourceFile);
196
- return dedupeRequests(requests);
400
+ function resolveGotoRoute(callExpression) {
401
+ const callee = callExpression.expression;
402
+ if (!ts.isPropertyAccessExpression(callee)) return null;
403
+ if (callee.name.text !== "goto") return null;
404
+ const pathArg = callExpression.arguments[0];
405
+ const literal = extractStringLiteral(pathArg);
406
+ if (!literal) return null;
407
+ if (/^https?:\/\//u.test(literal)) {
408
+ try {
409
+ const parsed = new URL(literal);
410
+ if (parsed.hostname === "localhost" || parsed.hostname === "127.0.0.1") {
411
+ return defaultNormalizeRoute(parsed.pathname.split("?")[0]);
412
+ }
413
+ } catch { /* ignore malformed */ }
414
+ return null;
415
+ }
416
+ return defaultNormalizeRoute(literal.split("?")[0]);
197
417
  }
198
418
 
199
419
  function walkJsx(sourceFile, visitor) {
@@ -324,6 +544,10 @@ function analyzeActionBinding(binding, options) {
324
544
  };
325
545
  }
326
546
 
547
+ if (imported?.resolvedFilePath) {
548
+ return options.analyzeImportedCallable(imported, options, new Set([imported.resolvedFilePath]));
549
+ }
550
+
327
551
  const localFunction = options.localFunctions.get(identifier);
328
552
  if (!localFunction) return emptyActionAnalysis();
329
553
  return analyzeCallableLikeNode(localFunction, options, new Set([identifier]));
@@ -361,6 +585,17 @@ function analyzeCallableLikeNode(node, options, visited = new Set(), settings =
361
585
  if (localCall) {
362
586
  for (const requestEntry of localCall.requests) requests.push(requestEntry);
363
587
  for (const serverActionRef of localCall.serverActionRefs) serverActionRefs.push(serverActionRef);
588
+ } else {
589
+ const importedCall = resolveImportedLocalCall(child, options, visited);
590
+ if (importedCall) {
591
+ for (const requestEntry of importedCall.requests) requests.push(requestEntry);
592
+ for (const serverActionRef of importedCall.serverActionRefs) serverActionRefs.push(serverActionRef);
593
+ } else {
594
+ for (const callbackAnalysis of resolveExecutedCallbackAnalyses(child, options, visited)) {
595
+ for (const requestEntry of callbackAnalysis.requests) requests.push(requestEntry);
596
+ for (const serverActionRef of callbackAnalysis.serverActionRefs) serverActionRefs.push(serverActionRef);
597
+ }
598
+ }
364
599
  }
365
600
  }
366
601
  }
@@ -393,6 +628,26 @@ function resolveLocalFunctionCall(callExpression, options, visited) {
393
628
  return analyzeCallableLikeNode(localFunction, options, nextVisited);
394
629
  }
395
630
 
631
+ function resolveImportedLocalCall(callExpression, options, visited) {
632
+ const callee = callExpression.expression;
633
+ if (!ts.isIdentifier(callee)) return null;
634
+ const imported = options.imports.get(callee.text);
635
+ if (!imported?.resolvedFilePath || imported.isServerAction) return null;
636
+ return options.analyzeImportedCallable(imported, options, visited);
637
+ }
638
+
639
+ function resolveExecutedCallbackAnalyses(callExpression, options, visited) {
640
+ const callbacks = [];
641
+ const callee = callExpression.expression;
642
+ if (ts.isIdentifier(callee) && (callee.text === "useEffect" || callee.text === "useLayoutEffect")) {
643
+ const callbackArg = callExpression.arguments[0];
644
+ if (callbackArg && (ts.isArrowFunction(callbackArg) || ts.isFunctionExpression(callbackArg))) {
645
+ callbacks.push(analyzeCallableLikeNode(callbackArg, options, new Set(visited)));
646
+ }
647
+ }
648
+ return callbacks;
649
+ }
650
+
396
651
  function resolveServerActionCall(callExpression, options) {
397
652
  const callee = callExpression.expression;
398
653
  if (!ts.isIdentifier(callee)) return null;
@@ -508,6 +763,58 @@ function collectTopLevelFunctions(sourceFile) {
508
763
  return functions;
509
764
  }
510
765
 
766
+ function collectExportedCallables(sourceFile, topLevelFunctions) {
767
+ const exports = new Map();
768
+ for (const statement of sourceFile.statements) {
769
+ if (ts.isFunctionDeclaration(statement) && statement.name && hasExportModifier(statement.modifiers)) {
770
+ exports.set(statement.name.text, statement);
771
+ continue;
772
+ }
773
+
774
+ if (!ts.isVariableStatement(statement) || !hasExportModifier(statement.modifiers)) continue;
775
+ for (const declaration of statement.declarationList.declarations) {
776
+ if (!ts.isIdentifier(declaration.name) || !declaration.initializer) continue;
777
+ if (ts.isArrowFunction(declaration.initializer) || ts.isFunctionExpression(declaration.initializer)) {
778
+ exports.set(declaration.name.text, declaration.initializer);
779
+ } else if (ts.isIdentifier(declaration.initializer)) {
780
+ const referenced = topLevelFunctions.get(declaration.initializer.text);
781
+ if (referenced) exports.set(declaration.name.text, referenced);
782
+ }
783
+ }
784
+ }
785
+ return exports;
786
+ }
787
+
788
+ function collectScopeLocalFunctions(node) {
789
+ const functions = new Map();
790
+ const callableBody = "body" in node ? node.body : null;
791
+ if (!callableBody) return functions;
792
+
793
+ const visit = (child) => {
794
+ if (
795
+ child !== callableBody &&
796
+ (ts.isFunctionDeclaration(child) || ts.isArrowFunction(child) || ts.isFunctionExpression(child))
797
+ ) {
798
+ return;
799
+ }
800
+
801
+ if (ts.isFunctionDeclaration(child) && child.name) {
802
+ functions.set(child.name.text, child);
803
+ }
804
+
805
+ if (ts.isVariableDeclaration(child) && ts.isIdentifier(child.name) && child.initializer) {
806
+ if (ts.isArrowFunction(child.initializer) || ts.isFunctionExpression(child.initializer)) {
807
+ functions.set(child.name.text, child.initializer);
808
+ }
809
+ }
810
+
811
+ ts.forEachChild(child, visit);
812
+ };
813
+
814
+ visit(callableBody);
815
+ return functions;
816
+ }
817
+
511
818
  function findDefaultPageComponent(sourceFile, localFunctions) {
512
819
  for (const statement of sourceFile.statements) {
513
820
  if (ts.isFunctionDeclaration(statement) && hasDefaultModifier(statement.modifiers)) {
@@ -526,6 +833,10 @@ function findDefaultPageComponent(sourceFile, localFunctions) {
526
833
  return null;
527
834
  }
528
835
 
836
+ function hasExportModifier(modifiers) {
837
+ return Boolean(modifiers?.some((modifier) => modifier.kind === ts.SyntaxKind.ExportKeyword));
838
+ }
839
+
529
840
  function collectJsxAttributes(openingElement) {
530
841
  const attributes = {};
531
842
  for (const property of openingElement.attributes.properties) {
@@ -578,7 +889,7 @@ function flattenJsxText(children) {
578
889
  function extractRouteLiteral(node, normalizeRoute) {
579
890
  const literal = extractStringLiteral(node);
580
891
  if (!literal) return null;
581
- return normalizeRoute(literal);
892
+ return normalizeRoute(literal.split("?")[0]);
582
893
  }
583
894
 
584
895
  function extractFetchMethod(node) {
@@ -596,6 +907,14 @@ function extractStringLiteral(node) {
596
907
  if (!node) return null;
597
908
  if (ts.isStringLiteralLike(node)) return node.text;
598
909
  if (ts.isNoSubstitutionTemplateLiteral(node)) return node.text;
910
+ if (ts.isTemplateExpression(node)) {
911
+ let value = node.head.text;
912
+ for (const span of node.templateSpans) {
913
+ value += DYNAMIC_SEGMENT_TOKEN;
914
+ value += span.literal.text;
915
+ }
916
+ return value;
917
+ }
599
918
  return null;
600
919
  }
601
920