@dcoder-x/plugin-shared 0.1.14 → 0.1.16
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.
|
@@ -12,20 +12,54 @@ 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
|
+
// Content-based structural file detection (Options A and B)
|
|
17
|
+
// These mirror the helpers in RouteExtractor but live here so ComponentExtractor
|
|
18
|
+
// can run them independently without importing across extractor boundaries.
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
function looksLikeRouterConfigContent(content) {
|
|
21
|
+
return /createBrowserRouter|createHashRouter|createMemoryRouter/.test(content) ||
|
|
22
|
+
/<Routes[\s>]/.test(content);
|
|
23
|
+
}
|
|
24
|
+
function looksLikeOutletOnlyWrapper(content) {
|
|
25
|
+
if (!/<Outlet[\s/>]/.test(content))
|
|
26
|
+
return false;
|
|
27
|
+
const stripped = content.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
28
|
+
const componentTags = stripped.match(/<([A-Z][A-Za-z0-9]*)/g) ?? [];
|
|
29
|
+
const uniqueTags = new Set(componentTags.map((t) => t.slice(1)));
|
|
30
|
+
const structural = new Set(['Outlet', 'Suspense', 'Fragment', 'ErrorBoundary', 'StrictMode']);
|
|
31
|
+
return [...uniqueTags].filter((t) => !structural.has(t)).length === 0;
|
|
32
|
+
}
|
|
15
33
|
/**
|
|
16
34
|
* 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.
|
|
18
|
-
* Score 0 = structural wrapper → skip as entry point.
|
|
35
|
+
* points to an actual page component, not a layout/guard/wrapper/router file.
|
|
19
36
|
*/
|
|
20
37
|
function isPageRoute(route) {
|
|
21
38
|
if (route.isLayoutRoute)
|
|
22
39
|
return false;
|
|
23
40
|
const p = route.filePath.replace(/\\/g, '/').toLowerCase();
|
|
41
|
+
const base = path_1.default.basename(p).replace(/\.(tsx?|jsx?)$/, '');
|
|
24
42
|
if (p.includes('/pages/') || p.includes('/views/') || p.includes('/screens/'))
|
|
25
43
|
return true;
|
|
26
|
-
|
|
27
|
-
|
|
44
|
+
// Directory-based structural patterns
|
|
45
|
+
if (/\/(contexts?|providers?|guards?|layouts?|wrappers?|hoc|utils?|helpers?)\//.test(p))
|
|
28
46
|
return false;
|
|
47
|
+
// Basename-based structural patterns: wrapper, router config, and provider files
|
|
48
|
+
if (/(provider|context|guard|layout|redirect|catchall|catch-all)/.test(base) ||
|
|
49
|
+
/^(router|routes|routerconfig|approuter|routesconfig|routingconfig|routeconfig|approviders|appwrapper)$/i.test(base))
|
|
50
|
+
return false;
|
|
51
|
+
// Option A: content-based — file defines routes, it is not a page
|
|
52
|
+
// Option B: file only renders <Outlet>, it is a pure layout wrapper
|
|
53
|
+
try {
|
|
54
|
+
const content = fs_1.default.readFileSync(route.filePath, 'utf-8');
|
|
55
|
+
if (looksLikeRouterConfigContent(content))
|
|
56
|
+
return false;
|
|
57
|
+
if (looksLikeOutletOnlyWrapper(content))
|
|
58
|
+
return false;
|
|
59
|
+
}
|
|
60
|
+
catch {
|
|
61
|
+
// file unreadable — fall through to default true
|
|
62
|
+
}
|
|
29
63
|
return true;
|
|
30
64
|
}
|
|
31
65
|
class ComponentExtractor {
|
|
@@ -413,10 +447,21 @@ function extractStaticProps(attributes) {
|
|
|
413
447
|
continue;
|
|
414
448
|
if (attr.value?.type === 'StringLiteral') {
|
|
415
449
|
props[key] = attr.value.value;
|
|
450
|
+
continue;
|
|
416
451
|
}
|
|
417
|
-
if (attr.value?.type === 'JSXExpressionContainer'
|
|
418
|
-
attr.value.expression
|
|
419
|
-
|
|
452
|
+
if (attr.value?.type === 'JSXExpressionContainer') {
|
|
453
|
+
const expr = attr.value.expression;
|
|
454
|
+
if (expr?.type === 'StringLiteral') {
|
|
455
|
+
props[key] = expr.value;
|
|
456
|
+
}
|
|
457
|
+
else if (expr?.type === 'TemplateLiteral' &&
|
|
458
|
+
expr.quasis?.length === 1 &&
|
|
459
|
+
expr.expressions?.length === 0) {
|
|
460
|
+
// Pure template literal with no interpolations: `to="/some/path"`
|
|
461
|
+
const raw = expr.quasis[0].value.cooked ?? expr.quasis[0].value.raw;
|
|
462
|
+
if (raw)
|
|
463
|
+
props[key] = raw;
|
|
464
|
+
}
|
|
420
465
|
}
|
|
421
466
|
}
|
|
422
467
|
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) {
|
|
@@ -18,19 +18,53 @@ const traverse = typeof traverse_1.default === 'function' ? traverse_1.default :
|
|
|
18
18
|
/**
|
|
19
19
|
* Score a resolved file path: higher = more likely to be an actual page component.
|
|
20
20
|
* 3 = pages/views/screens directory → definitely a page
|
|
21
|
-
* 2 = named after a recognisable page pattern but not in a pages dir
|
|
22
21
|
* 1 = unknown / ambiguous
|
|
23
|
-
* 0 = structural
|
|
22
|
+
* 0 = structural file (provider, guard, layout, context, router config, …)
|
|
23
|
+
*
|
|
24
|
+
* routerFiles: optional set of files known to contain createBrowserRouter/<Route
|
|
25
|
+
* (Option A — content-based router config detection)
|
|
24
26
|
*/
|
|
25
|
-
function scoreFilePath(filePath) {
|
|
27
|
+
function scoreFilePath(filePath, routerFiles) {
|
|
26
28
|
const p = filePath.replace(/\\/g, '/').toLowerCase();
|
|
29
|
+
const base = path_1.default.basename(p).replace(/\.(tsx?|jsx?)$/, '');
|
|
27
30
|
if (p.includes('/pages/') || p.includes('/views/') || p.includes('/screens/'))
|
|
28
31
|
return 3;
|
|
29
|
-
|
|
30
|
-
|
|
32
|
+
// Directory-based structural patterns
|
|
33
|
+
if (/\/(contexts?|providers?|guards?|layouts?|wrappers?|hoc|utils?|helpers?)\//.test(p))
|
|
34
|
+
return 0;
|
|
35
|
+
// Basename-based structural patterns: wrapper/config/router files
|
|
36
|
+
if (/(provider|context|guard|layout|redirect|catchall|catch-all)/.test(base) ||
|
|
37
|
+
/^(router|routes|routerconfig|approuter|routesconfig|routingconfig|routeconfig|approviders|appwrapper)$/i.test(base))
|
|
38
|
+
return 0;
|
|
39
|
+
// Option A: content-based — this file defines routes, it is not a page
|
|
40
|
+
if (routerFiles?.has(path_1.default.normalize(filePath)))
|
|
31
41
|
return 0;
|
|
32
42
|
return 1;
|
|
33
43
|
}
|
|
44
|
+
/**
|
|
45
|
+
* Option A: returns true if the file content contains router definition patterns.
|
|
46
|
+
* Used to populate the routerFiles set passed to scoreFilePath().
|
|
47
|
+
*/
|
|
48
|
+
function looksLikeRouterConfigContent(content) {
|
|
49
|
+
return /createBrowserRouter|createHashRouter|createMemoryRouter/.test(content) ||
|
|
50
|
+
/<Routes[\s>]/.test(content);
|
|
51
|
+
}
|
|
52
|
+
/**
|
|
53
|
+
* Option B: returns true if the file renders only <Outlet> with no other
|
|
54
|
+
* substantive JSX component tags. These are pure layout wrappers.
|
|
55
|
+
*/
|
|
56
|
+
function looksLikeOutletOnlyWrapper(content) {
|
|
57
|
+
if (!/<Outlet[\s/>]/.test(content))
|
|
58
|
+
return false;
|
|
59
|
+
// Strip comments and string literals (rough), then count distinct JSX component tags
|
|
60
|
+
const stripped = content.replace(/\/\/.*$/gm, '').replace(/\/\*[\s\S]*?\*\//g, '');
|
|
61
|
+
const componentTags = stripped.match(/<([A-Z][A-Za-z0-9]*)/g) ?? [];
|
|
62
|
+
const uniqueTags = new Set(componentTags.map((t) => t.slice(1)));
|
|
63
|
+
// Allow Outlet plus a small set of structural primitives (Suspense, Fragment, ErrorBoundary)
|
|
64
|
+
const structural = new Set(['Outlet', 'Suspense', 'Fragment', 'ErrorBoundary', 'StrictMode']);
|
|
65
|
+
const nonStructural = [...uniqueTags].filter((t) => !structural.has(t));
|
|
66
|
+
return nonStructural.length === 0;
|
|
67
|
+
}
|
|
34
68
|
// ---------------------------------------------------------------------------
|
|
35
69
|
// RouteExtractor
|
|
36
70
|
// ---------------------------------------------------------------------------
|
|
@@ -146,6 +180,10 @@ class RouteExtractor {
|
|
|
146
180
|
return [];
|
|
147
181
|
const results = [];
|
|
148
182
|
const routerFiles = this.findFilesContaining(srcDir, /createBrowserRouter|<Route/);
|
|
183
|
+
// Option A: build a normalised set of all files that contain router-definition
|
|
184
|
+
// patterns. scoreFilePath() uses this to score any such file as 0 regardless
|
|
185
|
+
// of its filename, so arbitrarily-named router config files are excluded.
|
|
186
|
+
const routerFileSet = new Set(routerFiles.map((f) => path_1.default.normalize(f)));
|
|
149
187
|
for (const filePath of routerFiles) {
|
|
150
188
|
const source = fs_1.default.readFileSync(filePath, 'utf-8');
|
|
151
189
|
let ast;
|
|
@@ -158,7 +196,7 @@ class RouteExtractor {
|
|
|
158
196
|
const importMap = this.buildImportMap(ast, filePath);
|
|
159
197
|
// Walk the entire AST looking for JSX Route trees and
|
|
160
198
|
// createBrowserRouter([…]) / createHashRouter([…]) call expressions.
|
|
161
|
-
this.extractFromAst(ast, filePath, importMap, results);
|
|
199
|
+
this.extractFromAst(ast, filePath, importMap, results, routerFileSet);
|
|
162
200
|
}
|
|
163
201
|
// Deduplicate: same (path + filePath) pair can appear when multiple router
|
|
164
202
|
// files re-export or re-nest the same routes.
|
|
@@ -178,7 +216,7 @@ class RouteExtractor {
|
|
|
178
216
|
* 2. createBrowserRouter / createHashRouter call arguments — delegates to
|
|
179
217
|
* walkObjectRouteConfig which handles the object-literal route config style.
|
|
180
218
|
*/
|
|
181
|
-
extractFromAst(ast, filePath, importMap, results) {
|
|
219
|
+
extractFromAst(ast, filePath, importMap, results, routerFileSet) {
|
|
182
220
|
// Track which JSX Route nodes we have already processed as children of a
|
|
183
221
|
// parent Route so we don't double-emit them from the flat visitor pass.
|
|
184
222
|
const processedNodes = new WeakSet();
|
|
@@ -198,7 +236,7 @@ class RouteExtractor {
|
|
|
198
236
|
const routeObjects = firstArg.type === 'ArrayExpression' ? firstArg.elements : [];
|
|
199
237
|
for (const el of routeObjects) {
|
|
200
238
|
if (el)
|
|
201
|
-
this.walkObjectRouteConfig(el, '', filePath, importMap, results);
|
|
239
|
+
this.walkObjectRouteConfig(el, '', filePath, importMap, results, routerFileSet);
|
|
202
240
|
}
|
|
203
241
|
},
|
|
204
242
|
// JSX style: <Routes><Route path="…" element={…}>…</Route></Routes>
|
|
@@ -217,7 +255,7 @@ class RouteExtractor {
|
|
|
217
255
|
const grandparentOpening = parentJsx?.openingElement?.name?.name;
|
|
218
256
|
if (grandparentOpening === 'Route')
|
|
219
257
|
return;
|
|
220
|
-
this.walkJsxRouteNode(jsxElement, '', filePath, importMap, results, processedNodes);
|
|
258
|
+
this.walkJsxRouteNode(jsxElement, '', filePath, importMap, results, processedNodes, routerFileSet);
|
|
221
259
|
},
|
|
222
260
|
});
|
|
223
261
|
}
|
|
@@ -225,7 +263,7 @@ class RouteExtractor {
|
|
|
225
263
|
* Recursively walk a JSX <Route> element and its JSX children.
|
|
226
264
|
* pathPrefix is the accumulated path from ancestor <Route path> segments.
|
|
227
265
|
*/
|
|
228
|
-
walkJsxRouteNode(jsxElement, pathPrefix, filePath, importMap, results, processedNodes) {
|
|
266
|
+
walkJsxRouteNode(jsxElement, pathPrefix, filePath, importMap, results, processedNodes, routerFileSet) {
|
|
229
267
|
if (!jsxElement)
|
|
230
268
|
return;
|
|
231
269
|
processedNodes.add(jsxElement);
|
|
@@ -247,11 +285,21 @@ class RouteExtractor {
|
|
|
247
285
|
// Resolve the component file for this route's element prop.
|
|
248
286
|
const resolvedFile = this.resolveRouteComponentFile(openingEl, importMap);
|
|
249
287
|
const effectiveFile = resolvedFile ?? filePath;
|
|
250
|
-
|
|
288
|
+
// Option A+B: pass routerFileSet for content-based router config detection;
|
|
289
|
+
// also check if the resolved file is an outlet-only wrapper (Option B).
|
|
290
|
+
const fileScore = scoreFilePath(effectiveFile, routerFileSet);
|
|
291
|
+
const isOutletOnly = resolvedFile !== null && (() => {
|
|
292
|
+
try {
|
|
293
|
+
return looksLikeOutletOnlyWrapper(fs_1.default.readFileSync(resolvedFile, 'utf-8'));
|
|
294
|
+
}
|
|
295
|
+
catch {
|
|
296
|
+
return false;
|
|
297
|
+
}
|
|
298
|
+
})();
|
|
251
299
|
// A route is a layout container when:
|
|
252
300
|
// - it has child routes (it is a branch, not a leaf), AND
|
|
253
|
-
// - its resolved file is
|
|
254
|
-
const isLayoutRoute = hasChildRoutes && (fileScore === 0 || resolvedFile === null);
|
|
301
|
+
// - its resolved file scores 0, is an outlet-only wrapper, or fell back to the router file
|
|
302
|
+
const isLayoutRoute = hasChildRoutes && (fileScore === 0 || isOutletOnly || resolvedFile === null);
|
|
255
303
|
// Only emit routes that have an explicit path segment — index routes with
|
|
256
304
|
// no path are structural placeholders.
|
|
257
305
|
if (routeSegment || (!hasChildRoutes && fullPath)) {
|
|
@@ -268,14 +316,14 @@ class RouteExtractor {
|
|
|
268
316
|
}
|
|
269
317
|
// Recurse into child <Route> elements, passing the accumulated path.
|
|
270
318
|
for (const child of childRouteElements) {
|
|
271
|
-
this.walkJsxRouteNode(child, fullPath, filePath, importMap, results, processedNodes);
|
|
319
|
+
this.walkJsxRouteNode(child, fullPath, filePath, importMap, results, processedNodes, routerFileSet);
|
|
272
320
|
}
|
|
273
321
|
}
|
|
274
322
|
/**
|
|
275
323
|
* Walk an object-literal route config node as produced by createBrowserRouter.
|
|
276
324
|
* Handles: { path, element, Component, children: [...] }
|
|
277
325
|
*/
|
|
278
|
-
walkObjectRouteConfig(node, pathPrefix, filePath, importMap, results) {
|
|
326
|
+
walkObjectRouteConfig(node, pathPrefix, filePath, importMap, results, routerFileSet) {
|
|
279
327
|
if (!node || node.type !== 'ObjectExpression')
|
|
280
328
|
return;
|
|
281
329
|
let routeSegment = '';
|
|
@@ -312,8 +360,17 @@ class RouteExtractor {
|
|
|
312
360
|
? this.resolveElementNodeToFile(elementNode, importMap)
|
|
313
361
|
: null;
|
|
314
362
|
const effectiveFile = resolvedFile ?? filePath;
|
|
315
|
-
|
|
316
|
-
const
|
|
363
|
+
// Option A+B: content-based router config detection + outlet-only wrapper check.
|
|
364
|
+
const fileScore = scoreFilePath(effectiveFile, routerFileSet);
|
|
365
|
+
const isOutletOnly = resolvedFile !== null && (() => {
|
|
366
|
+
try {
|
|
367
|
+
return looksLikeOutletOnlyWrapper(fs_1.default.readFileSync(resolvedFile, 'utf-8'));
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
return false;
|
|
371
|
+
}
|
|
372
|
+
})();
|
|
373
|
+
const isLayoutRoute = hasChildRoutes && (fileScore === 0 || isOutletOnly || resolvedFile === null);
|
|
317
374
|
if (routeSegment || (!hasChildRoutes && fullPath)) {
|
|
318
375
|
results.push({
|
|
319
376
|
path: fullPath,
|
|
@@ -328,7 +385,7 @@ class RouteExtractor {
|
|
|
328
385
|
}
|
|
329
386
|
for (const child of childElements) {
|
|
330
387
|
if (child)
|
|
331
|
-
this.walkObjectRouteConfig(child, fullPath, filePath, importMap, results);
|
|
388
|
+
this.walkObjectRouteConfig(child, fullPath, filePath, importMap, results, routerFileSet);
|
|
332
389
|
}
|
|
333
390
|
}
|
|
334
391
|
// ---------------------------------------------------------------------------
|