@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
- if (/\/(contexts?|providers?|guards?|layouts?|wrappers?|hoc|utils?|helpers?)\//.test(p) ||
27
- /(provider|context|guard|layout|redirect|catchall|catch-all)/.test(path_1.default.basename(p)))
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?.type === 'StringLiteral') {
419
- props[key] = attr.value.expression.value;
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
- // Fix 4: remove patterns that appear in too many flows — they are structural
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 href = el.staticProps['href'] || el.staticProps['to'];
63
- if (!isNav || !href)
63
+ const rawHref = el.staticProps['href'] || el.staticProps['to'];
64
+ if (!isNav || !rawHref)
64
65
  continue;
65
- if (!routePaths.has(href))
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 wrapper (provider, guard, layout, context, …)
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
- if (/\/(contexts?|providers?|guards?|layouts?|wrappers?|hoc|utils?|helpers?)\//.test(p) ||
30
- /(provider|context|guard|layout|redirect|catchall|catch-all)/.test(path_1.default.basename(p)))
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
- const fileScore = scoreFilePath(effectiveFile);
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 a structural wrapper (score 0) or it fell back to the router file
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
- const fileScore = scoreFilePath(effectiveFile);
316
- const isLayoutRoute = hasChildRoutes && (fileScore === 0 || resolvedFile === null);
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
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcoder-x/plugin-shared",
3
- "version": "0.1.14",
3
+ "version": "0.1.16",
4
4
  "private": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",