@dcoder-x/plugin-shared 0.1.15 → 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,6 +12,24 @@ 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
35
  * points to an actual page component, not a layout/guard/wrapper/router file.
@@ -30,6 +48,18 @@ function isPageRoute(route) {
30
48
  if (/(provider|context|guard|layout|redirect|catchall|catch-all)/.test(base) ||
31
49
  /^(router|routes|routerconfig|approuter|routesconfig|routingconfig|routeconfig|approviders|appwrapper)$/i.test(base))
32
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
+ }
33
63
  return true;
34
64
  }
35
65
  class ComponentExtractor {
@@ -20,8 +20,11 @@ const traverse = typeof traverse_1.default === 'function' ? traverse_1.default :
20
20
  * 3 = pages/views/screens directory → definitely a page
21
21
  * 1 = unknown / ambiguous
22
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)
23
26
  */
24
- function scoreFilePath(filePath) {
27
+ function scoreFilePath(filePath, routerFiles) {
25
28
  const p = filePath.replace(/\\/g, '/').toLowerCase();
26
29
  const base = path_1.default.basename(p).replace(/\.(tsx?|jsx?)$/, '');
27
30
  if (p.includes('/pages/') || p.includes('/views/') || p.includes('/screens/'))
@@ -33,8 +36,35 @@ function scoreFilePath(filePath) {
33
36
  if (/(provider|context|guard|layout|redirect|catchall|catch-all)/.test(base) ||
34
37
  /^(router|routes|routerconfig|approuter|routesconfig|routingconfig|routeconfig|approviders|appwrapper)$/i.test(base))
35
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)))
41
+ return 0;
36
42
  return 1;
37
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
+ }
38
68
  // ---------------------------------------------------------------------------
39
69
  // RouteExtractor
40
70
  // ---------------------------------------------------------------------------
@@ -150,6 +180,10 @@ class RouteExtractor {
150
180
  return [];
151
181
  const results = [];
152
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)));
153
187
  for (const filePath of routerFiles) {
154
188
  const source = fs_1.default.readFileSync(filePath, 'utf-8');
155
189
  let ast;
@@ -162,7 +196,7 @@ class RouteExtractor {
162
196
  const importMap = this.buildImportMap(ast, filePath);
163
197
  // Walk the entire AST looking for JSX Route trees and
164
198
  // createBrowserRouter([…]) / createHashRouter([…]) call expressions.
165
- this.extractFromAst(ast, filePath, importMap, results);
199
+ this.extractFromAst(ast, filePath, importMap, results, routerFileSet);
166
200
  }
167
201
  // Deduplicate: same (path + filePath) pair can appear when multiple router
168
202
  // files re-export or re-nest the same routes.
@@ -182,7 +216,7 @@ class RouteExtractor {
182
216
  * 2. createBrowserRouter / createHashRouter call arguments — delegates to
183
217
  * walkObjectRouteConfig which handles the object-literal route config style.
184
218
  */
185
- extractFromAst(ast, filePath, importMap, results) {
219
+ extractFromAst(ast, filePath, importMap, results, routerFileSet) {
186
220
  // Track which JSX Route nodes we have already processed as children of a
187
221
  // parent Route so we don't double-emit them from the flat visitor pass.
188
222
  const processedNodes = new WeakSet();
@@ -202,7 +236,7 @@ class RouteExtractor {
202
236
  const routeObjects = firstArg.type === 'ArrayExpression' ? firstArg.elements : [];
203
237
  for (const el of routeObjects) {
204
238
  if (el)
205
- this.walkObjectRouteConfig(el, '', filePath, importMap, results);
239
+ this.walkObjectRouteConfig(el, '', filePath, importMap, results, routerFileSet);
206
240
  }
207
241
  },
208
242
  // JSX style: <Routes><Route path="…" element={…}>…</Route></Routes>
@@ -221,7 +255,7 @@ class RouteExtractor {
221
255
  const grandparentOpening = parentJsx?.openingElement?.name?.name;
222
256
  if (grandparentOpening === 'Route')
223
257
  return;
224
- this.walkJsxRouteNode(jsxElement, '', filePath, importMap, results, processedNodes);
258
+ this.walkJsxRouteNode(jsxElement, '', filePath, importMap, results, processedNodes, routerFileSet);
225
259
  },
226
260
  });
227
261
  }
@@ -229,7 +263,7 @@ class RouteExtractor {
229
263
  * Recursively walk a JSX <Route> element and its JSX children.
230
264
  * pathPrefix is the accumulated path from ancestor <Route path> segments.
231
265
  */
232
- walkJsxRouteNode(jsxElement, pathPrefix, filePath, importMap, results, processedNodes) {
266
+ walkJsxRouteNode(jsxElement, pathPrefix, filePath, importMap, results, processedNodes, routerFileSet) {
233
267
  if (!jsxElement)
234
268
  return;
235
269
  processedNodes.add(jsxElement);
@@ -251,11 +285,21 @@ class RouteExtractor {
251
285
  // Resolve the component file for this route's element prop.
252
286
  const resolvedFile = this.resolveRouteComponentFile(openingEl, importMap);
253
287
  const effectiveFile = resolvedFile ?? filePath;
254
- 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
+ })();
255
299
  // A route is a layout container when:
256
300
  // - 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);
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);
259
303
  // Only emit routes that have an explicit path segment — index routes with
260
304
  // no path are structural placeholders.
261
305
  if (routeSegment || (!hasChildRoutes && fullPath)) {
@@ -272,14 +316,14 @@ class RouteExtractor {
272
316
  }
273
317
  // Recurse into child <Route> elements, passing the accumulated path.
274
318
  for (const child of childRouteElements) {
275
- this.walkJsxRouteNode(child, fullPath, filePath, importMap, results, processedNodes);
319
+ this.walkJsxRouteNode(child, fullPath, filePath, importMap, results, processedNodes, routerFileSet);
276
320
  }
277
321
  }
278
322
  /**
279
323
  * Walk an object-literal route config node as produced by createBrowserRouter.
280
324
  * Handles: { path, element, Component, children: [...] }
281
325
  */
282
- walkObjectRouteConfig(node, pathPrefix, filePath, importMap, results) {
326
+ walkObjectRouteConfig(node, pathPrefix, filePath, importMap, results, routerFileSet) {
283
327
  if (!node || node.type !== 'ObjectExpression')
284
328
  return;
285
329
  let routeSegment = '';
@@ -316,8 +360,17 @@ class RouteExtractor {
316
360
  ? this.resolveElementNodeToFile(elementNode, importMap)
317
361
  : null;
318
362
  const effectiveFile = resolvedFile ?? filePath;
319
- const fileScore = scoreFilePath(effectiveFile);
320
- 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);
321
374
  if (routeSegment || (!hasChildRoutes && fullPath)) {
322
375
  results.push({
323
376
  path: fullPath,
@@ -332,7 +385,7 @@ class RouteExtractor {
332
385
  }
333
386
  for (const child of childElements) {
334
387
  if (child)
335
- this.walkObjectRouteConfig(child, fullPath, filePath, importMap, results);
388
+ this.walkObjectRouteConfig(child, fullPath, filePath, importMap, results, routerFileSet);
336
389
  }
337
390
  }
338
391
  // ---------------------------------------------------------------------------
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dcoder-x/plugin-shared",
3
- "version": "0.1.15",
3
+ "version": "0.1.16",
4
4
  "private": false,
5
5
  "main": "dist/index.js",
6
6
  "types": "dist/index.d.ts",