@dcoder-x/plugin-shared 0.1.12 → 0.1.14

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,6 +12,22 @@ 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.
18
+ * Score 0 = structural wrapper → skip as entry point.
19
+ */
20
+ function isPageRoute(route) {
21
+ if (route.isLayoutRoute)
22
+ return false;
23
+ const p = route.filePath.replace(/\\/g, '/').toLowerCase();
24
+ if (p.includes('/pages/') || p.includes('/views/') || p.includes('/screens/'))
25
+ 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)))
28
+ return false;
29
+ return true;
30
+ }
15
31
  class ComponentExtractor {
16
32
  constructor(source, routes) {
17
33
  this.source = source;
@@ -19,19 +35,29 @@ class ComponentExtractor {
19
35
  }
20
36
  async extract() {
21
37
  const elements = [];
22
- // Build a direct filePath → route.path index so that files which are
23
- // themselves route entries get their own route, not whichever layout or
24
- // parent route happened to import them first.
38
+ // Build a filePath → route.path index for ALL routes (including layout routes)
39
+ // so that any file encountered during a walk can be assigned its own route
40
+ // when it is directly registered as a route entry.
25
41
  const fileRouteIndex = new Map();
26
42
  for (const route of this.routes) {
27
- const relFwd = route.filePath.replace(/\\/g, '/');
28
- fileRouteIndex.set(relFwd, route.path);
29
- // Also index by just the basename-less relative path variations
30
43
  fileRouteIndex.set(route.filePath, route.path);
44
+ fileRouteIndex.set(route.filePath.replace(/\\/g, '/'), route.path);
31
45
  }
32
- const seen = new Set(); // deduplicate across routes
33
- for (const route of this.routes) {
34
- const moduleFiles = this.getModuleFilesForRoute(route.filePath);
46
+ // Only use page-scoring routes as module-graph entry points.
47
+ // Layout/guard/wrapper routes are structural — their file imports the entire
48
+ // app and would contaminate every selector with the layout's route path.
49
+ const pageRoutes = this.routes.filter(isPageRoute);
50
+ // The stopAt boundary for each walk = all OTHER page route entry files.
51
+ // This prevents a walk from crossing into a sibling route's components.
52
+ const allPageRouteFiles = new Set();
53
+ for (const route of pageRoutes) {
54
+ allPageRouteFiles.add(route.filePath);
55
+ allPageRouteFiles.add(route.filePath.replace(/\\/g, '/'));
56
+ }
57
+ const seen = new Set(); // deduplicate files across all route walks
58
+ for (const route of pageRoutes) {
59
+ const stopAt = new Set([...allPageRouteFiles].filter((f) => f !== route.filePath && f !== route.filePath.replace(/\\/g, '/')));
60
+ const moduleFiles = this.getModuleFilesForRoute(route.filePath, stopAt);
35
61
  for (const filePath of moduleFiles) {
36
62
  if (seen.has(filePath))
37
63
  continue;
@@ -39,8 +65,8 @@ class ComponentExtractor {
39
65
  const source = this.readSource(filePath);
40
66
  if (!source)
41
67
  continue;
42
- // Prefer the route this file is directly registered as; fall back to
43
- // the route whose module graph we're walking (the layout/parent route).
68
+ // If this file is itself a registered route entry, use that route's
69
+ // path. Otherwise inherit from the walk's originating page route.
44
70
  const assignedRoute = fileRouteIndex.get(filePath) ??
45
71
  fileRouteIndex.get(filePath.replace(/\\/g, '/')) ??
46
72
  route.path;
@@ -62,9 +88,16 @@ class ComponentExtractor {
62
88
  fileRouteIndex.set(route.filePath, route.path);
63
89
  fileRouteIndex.set(route.filePath.replace(/\\/g, '/'), route.path);
64
90
  }
91
+ const pageRoutes = this.routes.filter(isPageRoute);
92
+ const allPageRouteFiles = new Set();
93
+ for (const route of pageRoutes) {
94
+ allPageRouteFiles.add(route.filePath);
95
+ allPageRouteFiles.add(route.filePath.replace(/\\/g, '/'));
96
+ }
65
97
  const seen = new Set();
66
- for (const route of this.routes) {
67
- const moduleFiles = this.getModuleFilesForRoute(route.filePath);
98
+ for (const route of pageRoutes) {
99
+ const stopAt = new Set([...allPageRouteFiles].filter((f) => f !== route.filePath && f !== route.filePath.replace(/\\/g, '/')));
100
+ const moduleFiles = this.getModuleFilesForRoute(route.filePath, stopAt);
68
101
  for (const filePath of moduleFiles) {
69
102
  if (seen.has(filePath))
70
103
  continue;
@@ -121,15 +154,13 @@ class ComponentExtractor {
121
154
  }
122
155
  return components;
123
156
  }
124
- getModuleFilesForRoute(entryFilePath) {
157
+ getModuleFilesForRoute(entryFilePath, stopAt) {
125
158
  if (this.source.type === 'webpack') {
126
- return this.walkWebpackGraph(entryFilePath, this.source.compilation);
159
+ return this.walkWebpackGraph(entryFilePath, this.source.compilation, stopAt);
127
160
  }
128
- return this.walkRollupGraph(entryFilePath, this.source.moduleGraph);
161
+ return this.walkRollupGraph(entryFilePath, this.source.moduleGraph, stopAt);
129
162
  }
130
- walkWebpackGraph(entryFilePath, compilation) {
131
- // Normalise to relative forward-slash paths so visited keys and injectedMap
132
- // keys are consistent across macOS and Windows.
163
+ walkWebpackGraph(entryFilePath, compilation, stopAt) {
133
164
  const toRelFwd = (p) => path_1.default.isAbsolute(p)
134
165
  ? path_1.default.relative(process.cwd(), p).replace(/\\/g, '/')
135
166
  : p.replace(/\\/g, '/');
@@ -139,6 +170,9 @@ class ComponentExtractor {
139
170
  const current = queue.shift();
140
171
  if (visited.has(current))
141
172
  continue;
173
+ // Stop recursing into other route entry files — they own their own walk.
174
+ if (stopAt && current !== toRelFwd(entryFilePath) && (stopAt.has(current) || stopAt.has(path_1.default.resolve(process.cwd(), current))))
175
+ continue;
142
176
  visited.add(current);
143
177
  const mod = this.findWebpackModule(compilation, current);
144
178
  let queuedFromWebpackGraph = false;
@@ -150,10 +184,7 @@ class ComponentExtractor {
150
184
  }
151
185
  }
152
186
  }
153
- // Fallback for webpack variants where moduleGraph helpers are unavailable.
154
187
  if (!queuedFromWebpackGraph) {
155
- // getFileImportPaths resolves to absolute paths via resolveImportFile;
156
- // normalise them before queuing.
157
188
  for (const depPath of this.getFileImportPaths(current)) {
158
189
  if (depPath && !depPath.includes('node_modules')) {
159
190
  queue.push(toRelFwd(depPath));
@@ -266,24 +297,25 @@ class ComponentExtractor {
266
297
  }
267
298
  return resolved;
268
299
  }
269
- walkRollupGraph(entryFilePath, graph) {
270
- // Normalise the entry to a relative forward-slash path so it matches the
271
- // keys stored by ClippyVitePlugin (which also normalises to relative).
300
+ walkRollupGraph(entryFilePath, graph, stopAt) {
272
301
  const toRelFwd = (p) => path_1.default.isAbsolute(p)
273
302
  ? path_1.default.relative(process.cwd(), p).replace(/\\/g, '/')
274
303
  : p.replace(/\\/g, '/');
304
+ const entryKey = toRelFwd(entryFilePath);
275
305
  const visited = new Set();
276
- const queue = [toRelFwd(entryFilePath)];
306
+ const queue = [entryKey];
277
307
  while (queue.length > 0) {
278
308
  const current = queue.shift();
279
309
  if (visited.has(current))
280
310
  continue;
311
+ // Stop recursing into other route entry files — they own their own walk.
312
+ if (stopAt && current !== entryKey && (stopAt.has(current) || stopAt.has(path_1.default.resolve(process.cwd(), current))))
313
+ continue;
281
314
  visited.add(current);
282
315
  const mod = graph.get(current);
283
316
  if (!mod)
284
317
  continue;
285
318
  for (const importId of mod.importedIds) {
286
- // importedIds are already relative keys after the vite plugin normalised them
287
319
  if (importId && !importId.includes('node_modules')) {
288
320
  queue.push(toRelFwd(importId));
289
321
  }
@@ -7,9 +7,33 @@ export declare class RouteExtractor {
7
7
  private walkPagesDir;
8
8
  private walkTanStackDir;
9
9
  private extractReactRouterRoutes;
10
- private buildImportMap;
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,28 @@ 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
+ * 2 = named after a recognisable page pattern but not in a pages dir
22
+ * 1 = unknown / ambiguous
23
+ * 0 = structural wrapper (provider, guard, layout, context, …)
24
+ */
25
+ function scoreFilePath(filePath) {
26
+ const p = filePath.replace(/\\/g, '/').toLowerCase();
27
+ if (p.includes('/pages/') || p.includes('/views/') || p.includes('/screens/'))
28
+ 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)))
31
+ return 0;
32
+ return 1;
33
+ }
34
+ // ---------------------------------------------------------------------------
35
+ // RouteExtractor
36
+ // ---------------------------------------------------------------------------
15
37
  class RouteExtractor {
16
38
  constructor(dir) {
17
39
  this.dir = dir;
@@ -37,6 +59,9 @@ class RouteExtractor {
37
59
  }
38
60
  return routes;
39
61
  }
62
+ // ---------------------------------------------------------------------------
63
+ // App-dir / pages-dir / TanStack (unchanged)
64
+ // ---------------------------------------------------------------------------
40
65
  walkAppDir(dir, routePrefix) {
41
66
  const results = [];
42
67
  const entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
@@ -75,10 +100,6 @@ class RouteExtractor {
75
100
  .relative(path_1.default.join(this.dir, 'pages'), fullPath)
76
101
  .replace(/\.(tsx?|jsx?)$/, '')
77
102
  .replace(/\\/g, '/');
78
- // index.tsx maps to the parent segment path.
79
- // examples:
80
- // pages/index.tsx -> /
81
- // pages/blog/index.tsx -> /blog
82
103
  const routePath = relativePath === 'index'
83
104
  ? '/'
84
105
  : `/${relativePath.replace(/\/index$/, '')}`;
@@ -117,6 +138,9 @@ class RouteExtractor {
117
138
  }
118
139
  return results;
119
140
  }
141
+ // ---------------------------------------------------------------------------
142
+ // React Router extraction — hierarchical walk
143
+ // ---------------------------------------------------------------------------
120
144
  async extractReactRouterRoutes(srcDir) {
121
145
  if (!fs_1.default.existsSync(srcDir))
122
146
  return [];
@@ -132,30 +156,12 @@ class RouteExtractor {
132
156
  continue;
133
157
  }
134
158
  const importMap = this.buildImportMap(ast, filePath);
135
- traverse(ast, {
136
- JSXOpeningElement: (nodePath) => {
137
- const name = nodePath.node.name?.name;
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
- });
159
+ // Walk the entire AST looking for JSX Route trees and
160
+ // createBrowserRouter([…]) / createHashRouter([…]) call expressions.
161
+ this.extractFromAst(ast, filePath, importMap, results);
156
162
  }
157
- // Deduplicate: same path + filePath can appear if multiple router files
158
- // re-export or nest the same routes (e.g. AppProviders wrapping routerConfig).
163
+ // Deduplicate: same (path + filePath) pair can appear when multiple router
164
+ // files re-export or re-nest the same routes.
159
165
  const seen = new Set();
160
166
  return results.filter((r) => {
161
167
  const key = `${r.path}::${r.filePath}`;
@@ -165,45 +171,201 @@ class RouteExtractor {
165
171
  return true;
166
172
  });
167
173
  }
168
- buildImportMap(ast, fromFile) {
169
- const map = new Map();
170
- for (const node of ast.program.body) {
171
- if (node.type !== 'ImportDeclaration')
172
- continue;
173
- const importSource = node.source?.value;
174
- if (!importSource)
175
- continue;
176
- const resolved = this.resolveImportToFile(importSource, fromFile);
177
- if (!resolved)
178
- continue;
179
- for (const specifier of node.specifiers ?? []) {
180
- if (specifier.type === 'ImportDefaultSpecifier' ||
181
- specifier.type === 'ImportSpecifier') {
182
- const localName = specifier.local?.name;
183
- if (localName)
184
- map.set(localName, resolved);
174
+ /**
175
+ * Top-level AST scan. Finds:
176
+ * 1. JSX <Route> elements at any depth — delegates to walkJsxRouteNode which
177
+ * descends the JSX tree hierarchically and accumulates path prefixes.
178
+ * 2. createBrowserRouter / createHashRouter call arguments — delegates to
179
+ * walkObjectRouteConfig which handles the object-literal route config style.
180
+ */
181
+ extractFromAst(ast, filePath, importMap, results) {
182
+ // Track which JSX Route nodes we have already processed as children of a
183
+ // parent Route so we don't double-emit them from the flat visitor pass.
184
+ const processedNodes = new WeakSet();
185
+ traverse(ast, {
186
+ // Object-config style: createBrowserRouter([{ path, element, children }])
187
+ CallExpression: (nodePath) => {
188
+ const calleeName = nodePath.node.callee?.name ||
189
+ nodePath.node.callee?.property?.name;
190
+ if (calleeName !== 'createBrowserRouter' &&
191
+ calleeName !== 'createHashRouter' &&
192
+ calleeName !== 'createMemoryRouter')
193
+ return;
194
+ const firstArg = nodePath.node.arguments?.[0];
195
+ if (!firstArg)
196
+ return;
197
+ // The argument is an ArrayExpression of route config objects.
198
+ const routeObjects = firstArg.type === 'ArrayExpression' ? firstArg.elements : [];
199
+ for (const el of routeObjects) {
200
+ if (el)
201
+ this.walkObjectRouteConfig(el, '', filePath, importMap, results);
185
202
  }
203
+ },
204
+ // JSX style: <Routes><Route path="…" element={…}>…</Route></Routes>
205
+ JSXOpeningElement: (nodePath) => {
206
+ const name = nodePath.node.name?.name;
207
+ if (name !== 'Route')
208
+ return;
209
+ // Only process top-level Route elements here. If this node is already
210
+ // reachable from a parent Route tree we walked, skip it.
211
+ const jsxElement = nodePath.parentPath?.node;
212
+ if (!jsxElement || processedNodes.has(jsxElement))
213
+ return;
214
+ // Check whether the immediate JSX parent is also a Route — if so,
215
+ // this node will be handled by the parent's recursive descent.
216
+ const parentJsx = nodePath.parentPath?.parentPath?.node;
217
+ const grandparentOpening = parentJsx?.openingElement?.name?.name;
218
+ if (grandparentOpening === 'Route')
219
+ return;
220
+ this.walkJsxRouteNode(jsxElement, '', filePath, importMap, results, processedNodes);
221
+ },
222
+ });
223
+ }
224
+ /**
225
+ * Recursively walk a JSX <Route> element and its JSX children.
226
+ * pathPrefix is the accumulated path from ancestor <Route path> segments.
227
+ */
228
+ walkJsxRouteNode(jsxElement, pathPrefix, filePath, importMap, results, processedNodes) {
229
+ if (!jsxElement)
230
+ return;
231
+ processedNodes.add(jsxElement);
232
+ const openingEl = jsxElement.openingElement;
233
+ if (!openingEl || openingEl.name?.name !== 'Route')
234
+ return;
235
+ const pathAttr = openingEl.attributes.find((a) => a.name?.name === 'path');
236
+ const routeSegment = pathAttr?.value?.value ?? '';
237
+ // Build the full path for this route node.
238
+ const fullPath = routeSegment
239
+ ? pathPrefix
240
+ ? `${pathPrefix}/${routeSegment}`.replace(/\/+/g, '/')
241
+ : routeSegment
242
+ : pathPrefix;
243
+ // Collect all child <Route> JSX elements (direct JSX children only).
244
+ const childRouteElements = (jsxElement.children ?? []).filter((child) => child.type === 'JSXElement' &&
245
+ child.openingElement?.name?.name === 'Route');
246
+ const hasChildRoutes = childRouteElements.length > 0;
247
+ // Resolve the component file for this route's element prop.
248
+ const resolvedFile = this.resolveRouteComponentFile(openingEl, importMap);
249
+ const effectiveFile = resolvedFile ?? filePath;
250
+ const fileScore = scoreFilePath(effectiveFile);
251
+ // A route is a layout container when:
252
+ // - 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);
255
+ // Only emit routes that have an explicit path segment — index routes with
256
+ // no path are structural placeholders.
257
+ if (routeSegment || (!hasChildRoutes && fullPath)) {
258
+ results.push({
259
+ path: fullPath,
260
+ filePath: effectiveFile,
261
+ isDynamic: fullPath.includes(':'),
262
+ params: (fullPath.match(/:(\w+)/g) ?? []).map((p) => p.slice(1)),
263
+ layout: null,
264
+ routerType: 'react-router',
265
+ semantic: this.deriveSemanticMeaning(fullPath),
266
+ isLayoutRoute,
267
+ });
268
+ }
269
+ // Recurse into child <Route> elements, passing the accumulated path.
270
+ for (const child of childRouteElements) {
271
+ this.walkJsxRouteNode(child, fullPath, filePath, importMap, results, processedNodes);
272
+ }
273
+ }
274
+ /**
275
+ * Walk an object-literal route config node as produced by createBrowserRouter.
276
+ * Handles: { path, element, Component, children: [...] }
277
+ */
278
+ walkObjectRouteConfig(node, pathPrefix, filePath, importMap, results) {
279
+ if (!node || node.type !== 'ObjectExpression')
280
+ return;
281
+ let routeSegment = '';
282
+ let elementNode = null;
283
+ let childrenNode = null;
284
+ for (const prop of node.properties ?? []) {
285
+ if (prop.type !== 'ObjectProperty' && prop.type !== 'Property')
286
+ continue;
287
+ const key = prop.key?.name || prop.key?.value;
288
+ if (key === 'path') {
289
+ if (prop.value?.type === 'StringLiteral')
290
+ routeSegment = prop.value.value;
291
+ }
292
+ else if (key === 'element') {
293
+ elementNode = prop.value;
294
+ }
295
+ else if (key === 'Component') {
296
+ // createBrowserRouter also supports Component: MyPage directly.
297
+ elementNode = prop.value;
298
+ }
299
+ else if (key === 'children') {
300
+ childrenNode = prop.value;
186
301
  }
187
302
  }
188
- return map;
303
+ const fullPath = routeSegment
304
+ ? pathPrefix
305
+ ? `${pathPrefix}/${routeSegment}`.replace(/\/+/g, '/')
306
+ : routeSegment
307
+ : pathPrefix;
308
+ const childElements = childrenNode?.type === 'ArrayExpression' ? (childrenNode.elements ?? []) : [];
309
+ const hasChildRoutes = childElements.length > 0;
310
+ // Resolve the component from element={<Comp />} or Component={Comp}.
311
+ const resolvedFile = elementNode
312
+ ? this.resolveElementNodeToFile(elementNode, importMap)
313
+ : null;
314
+ const effectiveFile = resolvedFile ?? filePath;
315
+ const fileScore = scoreFilePath(effectiveFile);
316
+ const isLayoutRoute = hasChildRoutes && (fileScore === 0 || resolvedFile === null);
317
+ if (routeSegment || (!hasChildRoutes && fullPath)) {
318
+ results.push({
319
+ path: fullPath,
320
+ filePath: effectiveFile,
321
+ isDynamic: fullPath.includes(':'),
322
+ params: (fullPath.match(/:(\w+)/g) ?? []).map((p) => p.slice(1)),
323
+ layout: null,
324
+ routerType: 'react-router',
325
+ semantic: this.deriveSemanticMeaning(fullPath),
326
+ isLayoutRoute,
327
+ });
328
+ }
329
+ for (const child of childElements) {
330
+ if (child)
331
+ this.walkObjectRouteConfig(child, fullPath, filePath, importMap, results);
332
+ }
189
333
  }
190
- resolveRouteComponentFile(routeOpeningPath, importMap) {
191
- const elementAttr = routeOpeningPath.node.attributes.find((a) => a.name?.name === 'element');
334
+ // ---------------------------------------------------------------------------
335
+ // Component resolution
336
+ // ---------------------------------------------------------------------------
337
+ resolveRouteComponentFile(openingElement, importMap) {
338
+ const elementAttr = openingElement.attributes.find((a) => a.name?.name === 'element');
192
339
  if (!elementAttr)
193
340
  return null;
194
341
  const expr = elementAttr.value?.expression;
195
342
  if (!expr)
196
343
  return null;
197
- // Try each JSX component name in document order; first match in the import map wins.
198
- // Wrapper components like <Can> or <RouteGuard> won't be in the map, so we fall
199
- // through to the actual page component nested inside them.
344
+ return this.resolveElementNodeToFile(expr, importMap);
345
+ }
346
+ /**
347
+ * Given a JSX expression node (either a JSXElement or an Identifier) that
348
+ * represents the value passed to an `element` or `Component` prop, resolve
349
+ * it to the best-scoring file path via the import map.
350
+ */
351
+ resolveElementNodeToFile(expr, importMap) {
352
+ // Component={MyPage} — expr is an Identifier
353
+ if (expr.type === 'Identifier') {
354
+ const resolved = importMap.get(expr.name);
355
+ return resolved ?? null;
356
+ }
357
+ // element={<MyPage />} or element={<Wrapper><MyPage /></Wrapper>}
358
+ // Collect all JSX component names depth-first, score each, pick best.
200
359
  const componentNames = this.collectJSXComponentNames(expr);
201
- for (const componentName of componentNames) {
202
- const resolved = importMap.get(componentName);
360
+ const candidates = [];
361
+ for (const name of componentNames) {
362
+ const resolved = importMap.get(name);
203
363
  if (resolved)
204
- return resolved;
364
+ candidates.push(resolved);
205
365
  }
206
- return null;
366
+ if (candidates.length === 0)
367
+ return null;
368
+ return candidates.reduce((best, c) => (scoreFilePath(c) >= scoreFilePath(best) ? c : best));
207
369
  }
208
370
  collectJSXComponentNames(node) {
209
371
  if (!node)
@@ -220,6 +382,31 @@ class RouteExtractor {
220
382
  }
221
383
  return [];
222
384
  }
385
+ // ---------------------------------------------------------------------------
386
+ // Import map
387
+ // ---------------------------------------------------------------------------
388
+ buildImportMap(ast, fromFile) {
389
+ const map = new Map();
390
+ for (const node of ast.program.body) {
391
+ if (node.type !== 'ImportDeclaration')
392
+ continue;
393
+ const importSource = node.source?.value;
394
+ if (!importSource)
395
+ continue;
396
+ const resolved = this.resolveImportToFile(importSource, fromFile);
397
+ if (!resolved)
398
+ continue;
399
+ for (const specifier of node.specifiers ?? []) {
400
+ if (specifier.type === 'ImportDefaultSpecifier' ||
401
+ specifier.type === 'ImportSpecifier') {
402
+ const localName = specifier.local?.name;
403
+ if (localName)
404
+ map.set(localName, resolved);
405
+ }
406
+ }
407
+ }
408
+ return map;
409
+ }
223
410
  resolveImportToFile(importSource, fromFile) {
224
411
  if (!importSource.startsWith('.') && !importSource.startsWith('/'))
225
412
  return null;
@@ -244,6 +431,9 @@ class RouteExtractor {
244
431
  }
245
432
  return null;
246
433
  }
434
+ // ---------------------------------------------------------------------------
435
+ // File discovery
436
+ // ---------------------------------------------------------------------------
247
437
  findFilesContaining(dir, pattern) {
248
438
  const results = [];
249
439
  const entries = fs_1.default.readdirSync(dir, { withFileTypes: true });
@@ -260,6 +450,9 @@ class RouteExtractor {
260
450
  }
261
451
  return results;
262
452
  }
453
+ // ---------------------------------------------------------------------------
454
+ // Utilities
455
+ // ---------------------------------------------------------------------------
263
456
  extractParams(routePath) {
264
457
  const nextParams = (routePath.match(/\[([^\]]+)\]/g) || []).map((m) => m.replace(/[\[\]\.]/g, ''));
265
458
  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;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcoder-x/plugin-shared",
3
- "version": "0.1.12",
3
+ "version": "0.1.14",
4
4
  "private": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",