@dcoder-x/plugin-shared 0.1.13 → 0.1.15
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/extractors/ComponentExtractor.js +59 -24
- package/dist/extractors/FlowInferrer.d.ts +6 -0
- package/dist/extractors/FlowInferrer.js +33 -6
- package/dist/extractors/RouteExtractor.d.ts +25 -1
- package/dist/extractors/RouteExtractor.js +247 -66
- package/dist/types.d.ts +7 -0
- package/package.json +1 -1
|
@@ -12,6 +12,26 @@ const InteractionGraphExtractor_1 = require("./InteractionGraphExtractor");
|
|
|
12
12
|
const ComponentContextResolver_1 = require("./ComponentContextResolver");
|
|
13
13
|
const HtmlTagGuards_1 = require("../injection/HtmlTagGuards");
|
|
14
14
|
const IdStrategy_1 = require("../injection/IdStrategy");
|
|
15
|
+
/**
|
|
16
|
+
* A route is a usable module-graph entry point only when its filePath
|
|
17
|
+
* points to an actual page component, not a layout/guard/wrapper/router file.
|
|
18
|
+
*/
|
|
19
|
+
function isPageRoute(route) {
|
|
20
|
+
if (route.isLayoutRoute)
|
|
21
|
+
return false;
|
|
22
|
+
const p = route.filePath.replace(/\\/g, '/').toLowerCase();
|
|
23
|
+
const base = path_1.default.basename(p).replace(/\.(tsx?|jsx?)$/, '');
|
|
24
|
+
if (p.includes('/pages/') || p.includes('/views/') || p.includes('/screens/'))
|
|
25
|
+
return true;
|
|
26
|
+
// Directory-based structural patterns
|
|
27
|
+
if (/\/(contexts?|providers?|guards?|layouts?|wrappers?|hoc|utils?|helpers?)\//.test(p))
|
|
28
|
+
return false;
|
|
29
|
+
// Basename-based structural patterns: wrapper, router config, and provider files
|
|
30
|
+
if (/(provider|context|guard|layout|redirect|catchall|catch-all)/.test(base) ||
|
|
31
|
+
/^(router|routes|routerconfig|approuter|routesconfig|routingconfig|routeconfig|approviders|appwrapper)$/i.test(base))
|
|
32
|
+
return false;
|
|
33
|
+
return true;
|
|
34
|
+
}
|
|
15
35
|
class ComponentExtractor {
|
|
16
36
|
constructor(source, routes) {
|
|
17
37
|
this.source = source;
|
|
@@ -19,25 +39,28 @@ class ComponentExtractor {
|
|
|
19
39
|
}
|
|
20
40
|
async extract() {
|
|
21
41
|
const elements = [];
|
|
22
|
-
// Build a
|
|
23
|
-
//
|
|
24
|
-
//
|
|
42
|
+
// Build a filePath → route.path index for ALL routes (including layout routes)
|
|
43
|
+
// so that any file encountered during a walk can be assigned its own route
|
|
44
|
+
// when it is directly registered as a route entry.
|
|
25
45
|
const fileRouteIndex = new Map();
|
|
26
46
|
for (const route of this.routes) {
|
|
27
47
|
fileRouteIndex.set(route.filePath, route.path);
|
|
28
48
|
fileRouteIndex.set(route.filePath.replace(/\\/g, '/'), route.path);
|
|
29
49
|
}
|
|
30
|
-
//
|
|
31
|
-
//
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
50
|
+
// Only use page-scoring routes as module-graph entry points.
|
|
51
|
+
// Layout/guard/wrapper routes are structural — their file imports the entire
|
|
52
|
+
// app and would contaminate every selector with the layout's route path.
|
|
53
|
+
const pageRoutes = this.routes.filter(isPageRoute);
|
|
54
|
+
// The stopAt boundary for each walk = all OTHER page route entry files.
|
|
55
|
+
// This prevents a walk from crossing into a sibling route's components.
|
|
56
|
+
const allPageRouteFiles = new Set();
|
|
57
|
+
for (const route of pageRoutes) {
|
|
58
|
+
allPageRouteFiles.add(route.filePath);
|
|
59
|
+
allPageRouteFiles.add(route.filePath.replace(/\\/g, '/'));
|
|
36
60
|
}
|
|
37
|
-
const seen = new Set(); // deduplicate across
|
|
38
|
-
for (const route of
|
|
39
|
-
|
|
40
|
-
const stopAt = new Set([...allRouteFiles].filter((f) => f !== route.filePath && f !== route.filePath.replace(/\\/g, '/')));
|
|
61
|
+
const seen = new Set(); // deduplicate files across all route walks
|
|
62
|
+
for (const route of pageRoutes) {
|
|
63
|
+
const stopAt = new Set([...allPageRouteFiles].filter((f) => f !== route.filePath && f !== route.filePath.replace(/\\/g, '/')));
|
|
41
64
|
const moduleFiles = this.getModuleFilesForRoute(route.filePath, stopAt);
|
|
42
65
|
for (const filePath of moduleFiles) {
|
|
43
66
|
if (seen.has(filePath))
|
|
@@ -46,8 +69,8 @@ class ComponentExtractor {
|
|
|
46
69
|
const source = this.readSource(filePath);
|
|
47
70
|
if (!source)
|
|
48
71
|
continue;
|
|
49
|
-
//
|
|
50
|
-
//
|
|
72
|
+
// If this file is itself a registered route entry, use that route's
|
|
73
|
+
// path. Otherwise inherit from the walk's originating page route.
|
|
51
74
|
const assignedRoute = fileRouteIndex.get(filePath) ??
|
|
52
75
|
fileRouteIndex.get(filePath.replace(/\\/g, '/')) ??
|
|
53
76
|
route.path;
|
|
@@ -69,14 +92,15 @@ class ComponentExtractor {
|
|
|
69
92
|
fileRouteIndex.set(route.filePath, route.path);
|
|
70
93
|
fileRouteIndex.set(route.filePath.replace(/\\/g, '/'), route.path);
|
|
71
94
|
}
|
|
72
|
-
const
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
95
|
+
const pageRoutes = this.routes.filter(isPageRoute);
|
|
96
|
+
const allPageRouteFiles = new Set();
|
|
97
|
+
for (const route of pageRoutes) {
|
|
98
|
+
allPageRouteFiles.add(route.filePath);
|
|
99
|
+
allPageRouteFiles.add(route.filePath.replace(/\\/g, '/'));
|
|
76
100
|
}
|
|
77
101
|
const seen = new Set();
|
|
78
|
-
for (const route of
|
|
79
|
-
const stopAt = new Set([...
|
|
102
|
+
for (const route of pageRoutes) {
|
|
103
|
+
const stopAt = new Set([...allPageRouteFiles].filter((f) => f !== route.filePath && f !== route.filePath.replace(/\\/g, '/')));
|
|
80
104
|
const moduleFiles = this.getModuleFilesForRoute(route.filePath, stopAt);
|
|
81
105
|
for (const filePath of moduleFiles) {
|
|
82
106
|
if (seen.has(filePath))
|
|
@@ -393,10 +417,21 @@ function extractStaticProps(attributes) {
|
|
|
393
417
|
continue;
|
|
394
418
|
if (attr.value?.type === 'StringLiteral') {
|
|
395
419
|
props[key] = attr.value.value;
|
|
420
|
+
continue;
|
|
396
421
|
}
|
|
397
|
-
if (attr.value?.type === 'JSXExpressionContainer'
|
|
398
|
-
attr.value.expression
|
|
399
|
-
|
|
422
|
+
if (attr.value?.type === 'JSXExpressionContainer') {
|
|
423
|
+
const expr = attr.value.expression;
|
|
424
|
+
if (expr?.type === 'StringLiteral') {
|
|
425
|
+
props[key] = expr.value;
|
|
426
|
+
}
|
|
427
|
+
else if (expr?.type === 'TemplateLiteral' &&
|
|
428
|
+
expr.quasis?.length === 1 &&
|
|
429
|
+
expr.expressions?.length === 0) {
|
|
430
|
+
// Pure template literal with no interpolations: `to="/some/path"`
|
|
431
|
+
const raw = expr.quasis[0].value.cooked ?? expr.quasis[0].value.raw;
|
|
432
|
+
if (raw)
|
|
433
|
+
props[key] = raw;
|
|
434
|
+
}
|
|
400
435
|
}
|
|
401
436
|
}
|
|
402
437
|
return props;
|
|
@@ -14,6 +14,12 @@ export declare class FlowInferrer {
|
|
|
14
14
|
*/
|
|
15
15
|
private isCoherentChain;
|
|
16
16
|
private detectInteractionFlows;
|
|
17
|
+
/**
|
|
18
|
+
* Deduplicate interaction flows by (page, trigger event, effect target).
|
|
19
|
+
* Shared components used on multiple routes produce one flow per component
|
|
20
|
+
* interaction. After dedup, each distinct interaction appears once.
|
|
21
|
+
*/
|
|
22
|
+
private deduplicateInteractionFlows;
|
|
17
23
|
private buildChain;
|
|
18
24
|
private generateIntentPatterns;
|
|
19
25
|
/**
|
|
@@ -45,10 +45,9 @@ class FlowInferrer {
|
|
|
45
45
|
infer() {
|
|
46
46
|
const edges = this.buildEdgeGraph();
|
|
47
47
|
const routeFlows = this.detectLinearFlows(edges);
|
|
48
|
-
const interactionFlows = this.detectInteractionFlows();
|
|
48
|
+
const interactionFlows = this.deduplicateInteractionFlows(this.detectInteractionFlows());
|
|
49
49
|
const combined = [...routeFlows, ...interactionFlows];
|
|
50
|
-
//
|
|
51
|
-
// navigation chrome (sidebars, footers) rather than user intent signals
|
|
50
|
+
// Remove patterns that appear in too many flows — structural nav chrome
|
|
52
51
|
return this.deduplicateCrossFlowNoise(combined);
|
|
53
52
|
}
|
|
54
53
|
// ---------------------------------------------------------------------------
|
|
@@ -56,13 +55,21 @@ class FlowInferrer {
|
|
|
56
55
|
// ---------------------------------------------------------------------------
|
|
57
56
|
buildEdgeGraph() {
|
|
58
57
|
const edges = [];
|
|
58
|
+
// Index routes both with and without leading slash to handle mixed conventions
|
|
59
59
|
const routePaths = new Set(this.routes.map((r) => r.path));
|
|
60
|
+
const normalizeHref = (href) => href.replace(/^\//, '');
|
|
60
61
|
for (const el of this.elements) {
|
|
61
62
|
const isNav = el.tag === 'a' || el.isNavigationLink === true;
|
|
62
|
-
const
|
|
63
|
-
if (!isNav || !
|
|
63
|
+
const rawHref = el.staticProps['href'] || el.staticProps['to'];
|
|
64
|
+
if (!isNav || !rawHref)
|
|
64
65
|
continue;
|
|
65
|
-
|
|
66
|
+
// Match against both the raw href and the normalized (no leading slash) form
|
|
67
|
+
const href = routePaths.has(rawHref)
|
|
68
|
+
? rawHref
|
|
69
|
+
: routePaths.has(normalizeHref(rawHref))
|
|
70
|
+
? normalizeHref(rawHref)
|
|
71
|
+
: null;
|
|
72
|
+
if (!href)
|
|
66
73
|
continue;
|
|
67
74
|
const triggerText = el.staticProps['aria-label'] ||
|
|
68
75
|
el.label ||
|
|
@@ -162,6 +169,26 @@ class FlowInferrer {
|
|
|
162
169
|
return flows;
|
|
163
170
|
}
|
|
164
171
|
// ---------------------------------------------------------------------------
|
|
172
|
+
// Interaction flow deduplication
|
|
173
|
+
// ---------------------------------------------------------------------------
|
|
174
|
+
/**
|
|
175
|
+
* Deduplicate interaction flows by (page, trigger event, effect target).
|
|
176
|
+
* Shared components used on multiple routes produce one flow per component
|
|
177
|
+
* interaction. After dedup, each distinct interaction appears once.
|
|
178
|
+
*/
|
|
179
|
+
deduplicateInteractionFlows(flows) {
|
|
180
|
+
const seen = new Set();
|
|
181
|
+
return flows.filter((f) => {
|
|
182
|
+
const trigger = f.edges[0]?.trigger ?? '';
|
|
183
|
+
const step = f.steps[0] ?? '';
|
|
184
|
+
const sig = `${f.page}::${trigger}::${step}`;
|
|
185
|
+
if (seen.has(sig))
|
|
186
|
+
return false;
|
|
187
|
+
seen.add(sig);
|
|
188
|
+
return true;
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
// ---------------------------------------------------------------------------
|
|
165
192
|
// Chain builder
|
|
166
193
|
// ---------------------------------------------------------------------------
|
|
167
194
|
buildChain(start, adjacency, seen) {
|
|
@@ -7,9 +7,33 @@ export declare class RouteExtractor {
|
|
|
7
7
|
private walkPagesDir;
|
|
8
8
|
private walkTanStackDir;
|
|
9
9
|
private extractReactRouterRoutes;
|
|
10
|
-
|
|
10
|
+
/**
|
|
11
|
+
* Top-level AST scan. Finds:
|
|
12
|
+
* 1. JSX <Route> elements at any depth — delegates to walkJsxRouteNode which
|
|
13
|
+
* descends the JSX tree hierarchically and accumulates path prefixes.
|
|
14
|
+
* 2. createBrowserRouter / createHashRouter call arguments — delegates to
|
|
15
|
+
* walkObjectRouteConfig which handles the object-literal route config style.
|
|
16
|
+
*/
|
|
17
|
+
private extractFromAst;
|
|
18
|
+
/**
|
|
19
|
+
* Recursively walk a JSX <Route> element and its JSX children.
|
|
20
|
+
* pathPrefix is the accumulated path from ancestor <Route path> segments.
|
|
21
|
+
*/
|
|
22
|
+
private walkJsxRouteNode;
|
|
23
|
+
/**
|
|
24
|
+
* Walk an object-literal route config node as produced by createBrowserRouter.
|
|
25
|
+
* Handles: { path, element, Component, children: [...] }
|
|
26
|
+
*/
|
|
27
|
+
private walkObjectRouteConfig;
|
|
11
28
|
private resolveRouteComponentFile;
|
|
29
|
+
/**
|
|
30
|
+
* Given a JSX expression node (either a JSXElement or an Identifier) that
|
|
31
|
+
* represents the value passed to an `element` or `Component` prop, resolve
|
|
32
|
+
* it to the best-scoring file path via the import map.
|
|
33
|
+
*/
|
|
34
|
+
private resolveElementNodeToFile;
|
|
12
35
|
private collectJSXComponentNames;
|
|
36
|
+
private buildImportMap;
|
|
13
37
|
private resolveImportToFile;
|
|
14
38
|
private findFilesContaining;
|
|
15
39
|
private extractParams;
|
|
@@ -12,6 +12,32 @@ const traverse_1 = __importDefault(require("@babel/traverse"));
|
|
|
12
12
|
// The CJS-in-ESM import wraps exports in a namespace object, so `.default`
|
|
13
13
|
// may be the module object rather than the function. This guard handles both.
|
|
14
14
|
const traverse = typeof traverse_1.default === 'function' ? traverse_1.default : traverse_1.default.default;
|
|
15
|
+
// ---------------------------------------------------------------------------
|
|
16
|
+
// Scoring helpers — shared between JSX and object-config parsers
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
/**
|
|
19
|
+
* Score a resolved file path: higher = more likely to be an actual page component.
|
|
20
|
+
* 3 = pages/views/screens directory → definitely a page
|
|
21
|
+
* 1 = unknown / ambiguous
|
|
22
|
+
* 0 = structural file (provider, guard, layout, context, router config, …)
|
|
23
|
+
*/
|
|
24
|
+
function scoreFilePath(filePath) {
|
|
25
|
+
const p = filePath.replace(/\\/g, '/').toLowerCase();
|
|
26
|
+
const base = path_1.default.basename(p).replace(/\.(tsx?|jsx?)$/, '');
|
|
27
|
+
if (p.includes('/pages/') || p.includes('/views/') || p.includes('/screens/'))
|
|
28
|
+
return 3;
|
|
29
|
+
// Directory-based structural patterns
|
|
30
|
+
if (/\/(contexts?|providers?|guards?|layouts?|wrappers?|hoc|utils?|helpers?)\//.test(p))
|
|
31
|
+
return 0;
|
|
32
|
+
// Basename-based structural patterns: wrapper/config/router files
|
|
33
|
+
if (/(provider|context|guard|layout|redirect|catchall|catch-all)/.test(base) ||
|
|
34
|
+
/^(router|routes|routerconfig|approuter|routesconfig|routingconfig|routeconfig|approviders|appwrapper)$/i.test(base))
|
|
35
|
+
return 0;
|
|
36
|
+
return 1;
|
|
37
|
+
}
|
|
38
|
+
// ---------------------------------------------------------------------------
|
|
39
|
+
// RouteExtractor
|
|
40
|
+
// ---------------------------------------------------------------------------
|
|
15
41
|
class RouteExtractor {
|
|
16
42
|
constructor(dir) {
|
|
17
43
|
this.dir = dir;
|
|
@@ -37,6 +63,9 @@ class RouteExtractor {
|
|
|
37
63
|
}
|
|
38
64
|
return routes;
|
|
39
65
|
}
|
|
66
|
+
// ---------------------------------------------------------------------------
|
|
67
|
+
// App-dir / pages-dir / TanStack (unchanged)
|
|
68
|
+
// ---------------------------------------------------------------------------
|
|
40
69
|
walkAppDir(dir, routePrefix) {
|
|
41
70
|
const results = [];
|
|
42
71
|
const entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
@@ -75,10 +104,6 @@ class RouteExtractor {
|
|
|
75
104
|
.relative(path_1.default.join(this.dir, 'pages'), fullPath)
|
|
76
105
|
.replace(/\.(tsx?|jsx?)$/, '')
|
|
77
106
|
.replace(/\\/g, '/');
|
|
78
|
-
// index.tsx maps to the parent segment path.
|
|
79
|
-
// examples:
|
|
80
|
-
// pages/index.tsx -> /
|
|
81
|
-
// pages/blog/index.tsx -> /blog
|
|
82
107
|
const routePath = relativePath === 'index'
|
|
83
108
|
? '/'
|
|
84
109
|
: `/${relativePath.replace(/\/index$/, '')}`;
|
|
@@ -117,6 +142,9 @@ class RouteExtractor {
|
|
|
117
142
|
}
|
|
118
143
|
return results;
|
|
119
144
|
}
|
|
145
|
+
// ---------------------------------------------------------------------------
|
|
146
|
+
// React Router extraction — hierarchical walk
|
|
147
|
+
// ---------------------------------------------------------------------------
|
|
120
148
|
async extractReactRouterRoutes(srcDir) {
|
|
121
149
|
if (!fs_1.default.existsSync(srcDir))
|
|
122
150
|
return [];
|
|
@@ -132,30 +160,12 @@ class RouteExtractor {
|
|
|
132
160
|
continue;
|
|
133
161
|
}
|
|
134
162
|
const importMap = this.buildImportMap(ast, filePath);
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
if (name !== 'Route')
|
|
139
|
-
return;
|
|
140
|
-
const pathAttr = nodePath.node.attributes.find((a) => a.name?.name === 'path');
|
|
141
|
-
const routePath = pathAttr?.value?.value;
|
|
142
|
-
if (!routePath)
|
|
143
|
-
return;
|
|
144
|
-
const resolvedFilePath = this.resolveRouteComponentFile(nodePath, importMap) ?? filePath;
|
|
145
|
-
results.push({
|
|
146
|
-
path: routePath,
|
|
147
|
-
filePath: resolvedFilePath,
|
|
148
|
-
isDynamic: routePath.includes(':'),
|
|
149
|
-
params: (routePath.match(/:(\w+)/g) || []).map((p) => p.slice(1)),
|
|
150
|
-
layout: null,
|
|
151
|
-
routerType: 'react-router',
|
|
152
|
-
semantic: this.deriveSemanticMeaning(routePath),
|
|
153
|
-
});
|
|
154
|
-
},
|
|
155
|
-
});
|
|
163
|
+
// Walk the entire AST looking for JSX Route trees and
|
|
164
|
+
// createBrowserRouter([…]) / createHashRouter([…]) call expressions.
|
|
165
|
+
this.extractFromAst(ast, filePath, importMap, results);
|
|
156
166
|
}
|
|
157
|
-
// Deduplicate: same path + filePath can appear
|
|
158
|
-
// re-export or nest the same routes
|
|
167
|
+
// Deduplicate: same (path + filePath) pair can appear when multiple router
|
|
168
|
+
// files re-export or re-nest the same routes.
|
|
159
169
|
const seen = new Set();
|
|
160
170
|
return results.filter((r) => {
|
|
161
171
|
const key = `${r.path}::${r.filePath}`;
|
|
@@ -165,61 +175,201 @@ class RouteExtractor {
|
|
|
165
175
|
return true;
|
|
166
176
|
});
|
|
167
177
|
}
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
178
|
+
/**
|
|
179
|
+
* Top-level AST scan. Finds:
|
|
180
|
+
* 1. JSX <Route> elements at any depth — delegates to walkJsxRouteNode which
|
|
181
|
+
* descends the JSX tree hierarchically and accumulates path prefixes.
|
|
182
|
+
* 2. createBrowserRouter / createHashRouter call arguments — delegates to
|
|
183
|
+
* walkObjectRouteConfig which handles the object-literal route config style.
|
|
184
|
+
*/
|
|
185
|
+
extractFromAst(ast, filePath, importMap, results) {
|
|
186
|
+
// Track which JSX Route nodes we have already processed as children of a
|
|
187
|
+
// parent Route so we don't double-emit them from the flat visitor pass.
|
|
188
|
+
const processedNodes = new WeakSet();
|
|
189
|
+
traverse(ast, {
|
|
190
|
+
// Object-config style: createBrowserRouter([{ path, element, children }])
|
|
191
|
+
CallExpression: (nodePath) => {
|
|
192
|
+
const calleeName = nodePath.node.callee?.name ||
|
|
193
|
+
nodePath.node.callee?.property?.name;
|
|
194
|
+
if (calleeName !== 'createBrowserRouter' &&
|
|
195
|
+
calleeName !== 'createHashRouter' &&
|
|
196
|
+
calleeName !== 'createMemoryRouter')
|
|
197
|
+
return;
|
|
198
|
+
const firstArg = nodePath.node.arguments?.[0];
|
|
199
|
+
if (!firstArg)
|
|
200
|
+
return;
|
|
201
|
+
// The argument is an ArrayExpression of route config objects.
|
|
202
|
+
const routeObjects = firstArg.type === 'ArrayExpression' ? firstArg.elements : [];
|
|
203
|
+
for (const el of routeObjects) {
|
|
204
|
+
if (el)
|
|
205
|
+
this.walkObjectRouteConfig(el, '', filePath, importMap, results);
|
|
185
206
|
}
|
|
207
|
+
},
|
|
208
|
+
// JSX style: <Routes><Route path="…" element={…}>…</Route></Routes>
|
|
209
|
+
JSXOpeningElement: (nodePath) => {
|
|
210
|
+
const name = nodePath.node.name?.name;
|
|
211
|
+
if (name !== 'Route')
|
|
212
|
+
return;
|
|
213
|
+
// Only process top-level Route elements here. If this node is already
|
|
214
|
+
// reachable from a parent Route tree we walked, skip it.
|
|
215
|
+
const jsxElement = nodePath.parentPath?.node;
|
|
216
|
+
if (!jsxElement || processedNodes.has(jsxElement))
|
|
217
|
+
return;
|
|
218
|
+
// Check whether the immediate JSX parent is also a Route — if so,
|
|
219
|
+
// this node will be handled by the parent's recursive descent.
|
|
220
|
+
const parentJsx = nodePath.parentPath?.parentPath?.node;
|
|
221
|
+
const grandparentOpening = parentJsx?.openingElement?.name?.name;
|
|
222
|
+
if (grandparentOpening === 'Route')
|
|
223
|
+
return;
|
|
224
|
+
this.walkJsxRouteNode(jsxElement, '', filePath, importMap, results, processedNodes);
|
|
225
|
+
},
|
|
226
|
+
});
|
|
227
|
+
}
|
|
228
|
+
/**
|
|
229
|
+
* Recursively walk a JSX <Route> element and its JSX children.
|
|
230
|
+
* pathPrefix is the accumulated path from ancestor <Route path> segments.
|
|
231
|
+
*/
|
|
232
|
+
walkJsxRouteNode(jsxElement, pathPrefix, filePath, importMap, results, processedNodes) {
|
|
233
|
+
if (!jsxElement)
|
|
234
|
+
return;
|
|
235
|
+
processedNodes.add(jsxElement);
|
|
236
|
+
const openingEl = jsxElement.openingElement;
|
|
237
|
+
if (!openingEl || openingEl.name?.name !== 'Route')
|
|
238
|
+
return;
|
|
239
|
+
const pathAttr = openingEl.attributes.find((a) => a.name?.name === 'path');
|
|
240
|
+
const routeSegment = pathAttr?.value?.value ?? '';
|
|
241
|
+
// Build the full path for this route node.
|
|
242
|
+
const fullPath = routeSegment
|
|
243
|
+
? pathPrefix
|
|
244
|
+
? `${pathPrefix}/${routeSegment}`.replace(/\/+/g, '/')
|
|
245
|
+
: routeSegment
|
|
246
|
+
: pathPrefix;
|
|
247
|
+
// Collect all child <Route> JSX elements (direct JSX children only).
|
|
248
|
+
const childRouteElements = (jsxElement.children ?? []).filter((child) => child.type === 'JSXElement' &&
|
|
249
|
+
child.openingElement?.name?.name === 'Route');
|
|
250
|
+
const hasChildRoutes = childRouteElements.length > 0;
|
|
251
|
+
// Resolve the component file for this route's element prop.
|
|
252
|
+
const resolvedFile = this.resolveRouteComponentFile(openingEl, importMap);
|
|
253
|
+
const effectiveFile = resolvedFile ?? filePath;
|
|
254
|
+
const fileScore = scoreFilePath(effectiveFile);
|
|
255
|
+
// A route is a layout container when:
|
|
256
|
+
// - it has child routes (it is a branch, not a leaf), AND
|
|
257
|
+
// - its resolved file is a structural wrapper (score 0) or it fell back to the router file
|
|
258
|
+
const isLayoutRoute = hasChildRoutes && (fileScore === 0 || resolvedFile === null);
|
|
259
|
+
// Only emit routes that have an explicit path segment — index routes with
|
|
260
|
+
// no path are structural placeholders.
|
|
261
|
+
if (routeSegment || (!hasChildRoutes && fullPath)) {
|
|
262
|
+
results.push({
|
|
263
|
+
path: fullPath,
|
|
264
|
+
filePath: effectiveFile,
|
|
265
|
+
isDynamic: fullPath.includes(':'),
|
|
266
|
+
params: (fullPath.match(/:(\w+)/g) ?? []).map((p) => p.slice(1)),
|
|
267
|
+
layout: null,
|
|
268
|
+
routerType: 'react-router',
|
|
269
|
+
semantic: this.deriveSemanticMeaning(fullPath),
|
|
270
|
+
isLayoutRoute,
|
|
271
|
+
});
|
|
272
|
+
}
|
|
273
|
+
// Recurse into child <Route> elements, passing the accumulated path.
|
|
274
|
+
for (const child of childRouteElements) {
|
|
275
|
+
this.walkJsxRouteNode(child, fullPath, filePath, importMap, results, processedNodes);
|
|
276
|
+
}
|
|
277
|
+
}
|
|
278
|
+
/**
|
|
279
|
+
* Walk an object-literal route config node as produced by createBrowserRouter.
|
|
280
|
+
* Handles: { path, element, Component, children: [...] }
|
|
281
|
+
*/
|
|
282
|
+
walkObjectRouteConfig(node, pathPrefix, filePath, importMap, results) {
|
|
283
|
+
if (!node || node.type !== 'ObjectExpression')
|
|
284
|
+
return;
|
|
285
|
+
let routeSegment = '';
|
|
286
|
+
let elementNode = null;
|
|
287
|
+
let childrenNode = null;
|
|
288
|
+
for (const prop of node.properties ?? []) {
|
|
289
|
+
if (prop.type !== 'ObjectProperty' && prop.type !== 'Property')
|
|
290
|
+
continue;
|
|
291
|
+
const key = prop.key?.name || prop.key?.value;
|
|
292
|
+
if (key === 'path') {
|
|
293
|
+
if (prop.value?.type === 'StringLiteral')
|
|
294
|
+
routeSegment = prop.value.value;
|
|
295
|
+
}
|
|
296
|
+
else if (key === 'element') {
|
|
297
|
+
elementNode = prop.value;
|
|
298
|
+
}
|
|
299
|
+
else if (key === 'Component') {
|
|
300
|
+
// createBrowserRouter also supports Component: MyPage directly.
|
|
301
|
+
elementNode = prop.value;
|
|
302
|
+
}
|
|
303
|
+
else if (key === 'children') {
|
|
304
|
+
childrenNode = prop.value;
|
|
186
305
|
}
|
|
187
306
|
}
|
|
188
|
-
|
|
307
|
+
const fullPath = routeSegment
|
|
308
|
+
? pathPrefix
|
|
309
|
+
? `${pathPrefix}/${routeSegment}`.replace(/\/+/g, '/')
|
|
310
|
+
: routeSegment
|
|
311
|
+
: pathPrefix;
|
|
312
|
+
const childElements = childrenNode?.type === 'ArrayExpression' ? (childrenNode.elements ?? []) : [];
|
|
313
|
+
const hasChildRoutes = childElements.length > 0;
|
|
314
|
+
// Resolve the component from element={<Comp />} or Component={Comp}.
|
|
315
|
+
const resolvedFile = elementNode
|
|
316
|
+
? this.resolveElementNodeToFile(elementNode, importMap)
|
|
317
|
+
: null;
|
|
318
|
+
const effectiveFile = resolvedFile ?? filePath;
|
|
319
|
+
const fileScore = scoreFilePath(effectiveFile);
|
|
320
|
+
const isLayoutRoute = hasChildRoutes && (fileScore === 0 || resolvedFile === null);
|
|
321
|
+
if (routeSegment || (!hasChildRoutes && fullPath)) {
|
|
322
|
+
results.push({
|
|
323
|
+
path: fullPath,
|
|
324
|
+
filePath: effectiveFile,
|
|
325
|
+
isDynamic: fullPath.includes(':'),
|
|
326
|
+
params: (fullPath.match(/:(\w+)/g) ?? []).map((p) => p.slice(1)),
|
|
327
|
+
layout: null,
|
|
328
|
+
routerType: 'react-router',
|
|
329
|
+
semantic: this.deriveSemanticMeaning(fullPath),
|
|
330
|
+
isLayoutRoute,
|
|
331
|
+
});
|
|
332
|
+
}
|
|
333
|
+
for (const child of childElements) {
|
|
334
|
+
if (child)
|
|
335
|
+
this.walkObjectRouteConfig(child, fullPath, filePath, importMap, results);
|
|
336
|
+
}
|
|
189
337
|
}
|
|
190
|
-
|
|
191
|
-
|
|
338
|
+
// ---------------------------------------------------------------------------
|
|
339
|
+
// Component resolution
|
|
340
|
+
// ---------------------------------------------------------------------------
|
|
341
|
+
resolveRouteComponentFile(openingElement, importMap) {
|
|
342
|
+
const elementAttr = openingElement.attributes.find((a) => a.name?.name === 'element');
|
|
192
343
|
if (!elementAttr)
|
|
193
344
|
return null;
|
|
194
345
|
const expr = elementAttr.value?.expression;
|
|
195
346
|
if (!expr)
|
|
196
347
|
return null;
|
|
197
|
-
|
|
198
|
-
|
|
199
|
-
|
|
348
|
+
return this.resolveElementNodeToFile(expr, importMap);
|
|
349
|
+
}
|
|
350
|
+
/**
|
|
351
|
+
* Given a JSX expression node (either a JSXElement or an Identifier) that
|
|
352
|
+
* represents the value passed to an `element` or `Component` prop, resolve
|
|
353
|
+
* it to the best-scoring file path via the import map.
|
|
354
|
+
*/
|
|
355
|
+
resolveElementNodeToFile(expr, importMap) {
|
|
356
|
+
// Component={MyPage} — expr is an Identifier
|
|
357
|
+
if (expr.type === 'Identifier') {
|
|
358
|
+
const resolved = importMap.get(expr.name);
|
|
359
|
+
return resolved ?? null;
|
|
360
|
+
}
|
|
361
|
+
// element={<MyPage />} or element={<Wrapper><MyPage /></Wrapper>}
|
|
362
|
+
// Collect all JSX component names depth-first, score each, pick best.
|
|
200
363
|
const componentNames = this.collectJSXComponentNames(expr);
|
|
201
364
|
const candidates = [];
|
|
202
|
-
for (const
|
|
203
|
-
const resolved = importMap.get(
|
|
365
|
+
for (const name of componentNames) {
|
|
366
|
+
const resolved = importMap.get(name);
|
|
204
367
|
if (resolved)
|
|
205
368
|
candidates.push(resolved);
|
|
206
369
|
}
|
|
207
370
|
if (candidates.length === 0)
|
|
208
371
|
return null;
|
|
209
|
-
|
|
210
|
-
const score = (filePath) => {
|
|
211
|
-
const p = filePath.replace(/\\/g, '/').toLowerCase();
|
|
212
|
-
if (p.includes('/pages/'))
|
|
213
|
-
return 3;
|
|
214
|
-
if (p.includes('/views/') || p.includes('/screens/'))
|
|
215
|
-
return 2;
|
|
216
|
-
// Wrappers, providers, guards, layouts, contexts are structural — not page components
|
|
217
|
-
if (/\/(contexts?|providers?|guards?|layouts?|wrappers?|hoc|utils?|helpers?)\//.test(p) ||
|
|
218
|
-
/(provider|context|guard|layout|redirect|catchall|catch-all)/.test(p))
|
|
219
|
-
return 0;
|
|
220
|
-
return 1;
|
|
221
|
-
};
|
|
222
|
-
return candidates.reduce((best, c) => (score(c) >= score(best) ? c : best));
|
|
372
|
+
return candidates.reduce((best, c) => (scoreFilePath(c) >= scoreFilePath(best) ? c : best));
|
|
223
373
|
}
|
|
224
374
|
collectJSXComponentNames(node) {
|
|
225
375
|
if (!node)
|
|
@@ -236,6 +386,31 @@ class RouteExtractor {
|
|
|
236
386
|
}
|
|
237
387
|
return [];
|
|
238
388
|
}
|
|
389
|
+
// ---------------------------------------------------------------------------
|
|
390
|
+
// Import map
|
|
391
|
+
// ---------------------------------------------------------------------------
|
|
392
|
+
buildImportMap(ast, fromFile) {
|
|
393
|
+
const map = new Map();
|
|
394
|
+
for (const node of ast.program.body) {
|
|
395
|
+
if (node.type !== 'ImportDeclaration')
|
|
396
|
+
continue;
|
|
397
|
+
const importSource = node.source?.value;
|
|
398
|
+
if (!importSource)
|
|
399
|
+
continue;
|
|
400
|
+
const resolved = this.resolveImportToFile(importSource, fromFile);
|
|
401
|
+
if (!resolved)
|
|
402
|
+
continue;
|
|
403
|
+
for (const specifier of node.specifiers ?? []) {
|
|
404
|
+
if (specifier.type === 'ImportDefaultSpecifier' ||
|
|
405
|
+
specifier.type === 'ImportSpecifier') {
|
|
406
|
+
const localName = specifier.local?.name;
|
|
407
|
+
if (localName)
|
|
408
|
+
map.set(localName, resolved);
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
}
|
|
412
|
+
return map;
|
|
413
|
+
}
|
|
239
414
|
resolveImportToFile(importSource, fromFile) {
|
|
240
415
|
if (!importSource.startsWith('.') && !importSource.startsWith('/'))
|
|
241
416
|
return null;
|
|
@@ -260,6 +435,9 @@ class RouteExtractor {
|
|
|
260
435
|
}
|
|
261
436
|
return null;
|
|
262
437
|
}
|
|
438
|
+
// ---------------------------------------------------------------------------
|
|
439
|
+
// File discovery
|
|
440
|
+
// ---------------------------------------------------------------------------
|
|
263
441
|
findFilesContaining(dir, pattern) {
|
|
264
442
|
const results = [];
|
|
265
443
|
const entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
|
|
@@ -276,6 +454,9 @@ class RouteExtractor {
|
|
|
276
454
|
}
|
|
277
455
|
return results;
|
|
278
456
|
}
|
|
457
|
+
// ---------------------------------------------------------------------------
|
|
458
|
+
// Utilities
|
|
459
|
+
// ---------------------------------------------------------------------------
|
|
279
460
|
extractParams(routePath) {
|
|
280
461
|
const nextParams = (routePath.match(/\[([^\]]+)\]/g) || []).map((m) => m.replace(/[\[\]\.]/g, ''));
|
|
281
462
|
const rrParams = (routePath.match(/:(\w+)/g) || []).map((m) => m.slice(1));
|
package/dist/types.d.ts
CHANGED
|
@@ -29,6 +29,13 @@ export interface DiscoveredRoute {
|
|
|
29
29
|
layout: string | null;
|
|
30
30
|
routerType: 'app' | 'pages' | 'react-router' | 'tanstack';
|
|
31
31
|
semantic: string;
|
|
32
|
+
/**
|
|
33
|
+
* True when this route exists only as a layout/guard/wrapper container.
|
|
34
|
+
* Its filePath points to a structural file (provider, guard, layout) rather
|
|
35
|
+
* than an actual page component. ComponentExtractor skips these as
|
|
36
|
+
* module-graph entry points — their content is reached via child routes.
|
|
37
|
+
*/
|
|
38
|
+
isLayoutRoute?: boolean;
|
|
32
39
|
}
|
|
33
40
|
export interface DiscoveredElement {
|
|
34
41
|
tag: string;
|