next-route-graph 1.1.0 → 1.2.0

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.
Files changed (47) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/CONTRIBUTING.md +74 -0
  3. package/README.md +113 -189
  4. package/SECURITY.md +19 -0
  5. package/dist/cli.js +107 -80
  6. package/dist/core/analyzer.d.ts +5 -1
  7. package/dist/core/analyzer.d.ts.map +1 -1
  8. package/dist/core/analyzer.js +21 -14
  9. package/dist/core/inspect.d.ts +6 -0
  10. package/dist/core/inspect.d.ts.map +1 -0
  11. package/dist/core/inspect.js +21 -0
  12. package/dist/core/linter.d.ts.map +1 -1
  13. package/dist/core/linter.js +43 -23
  14. package/dist/core/parser.d.ts.map +1 -1
  15. package/dist/core/parser.js +31 -16
  16. package/dist/errors/index.d.ts +4 -0
  17. package/dist/errors/index.d.ts.map +1 -1
  18. package/dist/errors/index.js +8 -1
  19. package/dist/index.d.ts +4 -2
  20. package/dist/index.d.ts.map +1 -1
  21. package/dist/index.js +4 -2
  22. package/dist/preview.d.ts +4 -0
  23. package/dist/preview.d.ts.map +1 -0
  24. package/dist/preview.js +25 -0
  25. package/dist/render/dot.d.ts.map +1 -1
  26. package/dist/render/dot.js +25 -47
  27. package/dist/render/graph.d.ts +13 -0
  28. package/dist/render/graph.d.ts.map +1 -0
  29. package/dist/render/graph.js +53 -0
  30. package/dist/render/html.d.ts +8 -0
  31. package/dist/render/html.d.ts.map +1 -0
  32. package/dist/render/html.js +718 -0
  33. package/dist/render/index.d.ts +4 -2
  34. package/dist/render/index.d.ts.map +1 -1
  35. package/dist/render/index.js +10 -3
  36. package/dist/render/mermaid.d.ts.map +1 -1
  37. package/dist/render/mermaid.js +19 -43
  38. package/dist/render/svg.d.ts +8 -0
  39. package/dist/render/svg.d.ts.map +1 -0
  40. package/dist/render/svg.js +76 -0
  41. package/dist/types/index.d.ts +5 -1
  42. package/dist/types/index.d.ts.map +1 -1
  43. package/docs/ARCHITECTURE.md +71 -0
  44. package/docs/NEW_FEATURES.md +16 -0
  45. package/docs/adr/0001-offline-reports-and-shared-route-graph.md +29 -0
  46. package/docs/route-report.png +0 -0
  47. package/package.json +12 -4
package/dist/cli.js CHANGED
@@ -1,113 +1,140 @@
1
1
  #!/usr/bin/env node
2
- import { Command } from "commander";
2
+ import fs from "node:fs/promises";
3
+ import path from "node:path";
4
+ import { stripVTControlCharacters } from "node:util";
5
+ import { Command, Option } from "commander";
3
6
  import pc from "picocolors";
4
- import { analyzeRoutes } from "./core/analyzer.js";
5
- import { detectAppRouter } from "./core/detector.js";
6
- import { scanAppDir } from "./core/scanner.js";
7
+ import { filterRoutes, inspectProject } from "./core/inspect.js";
7
8
  import { NextRouteGraphError, RouteLintError } from "./errors/index.js";
9
+ import { startPreview } from "./preview.js";
8
10
  import { getRenderer } from "./render/index.js";
9
- /**
10
- * Determines the target output format based on CLI flags.
11
- */
12
11
  function resolveFormat(options) {
13
- if (options.json)
14
- return "json";
15
- if (options.flat)
16
- return "flat";
17
- if (options.mermaid)
18
- return "mermaid";
19
- if (options.dot)
20
- return "dot";
21
- if (options.markdown)
22
- return "markdown";
23
- if (options.format)
24
- return options.format;
25
- return "tree";
12
+ const aliases = [
13
+ "json",
14
+ "flat",
15
+ "mermaid",
16
+ "dot",
17
+ "markdown",
18
+ "html",
19
+ "svg",
20
+ ];
21
+ const selected = aliases.filter((format) => options[format]);
22
+ if (selected.length + Number(Boolean(options.format)) > 1) {
23
+ throw new Error("Choose one output format: --format <type> or one format flag.");
24
+ }
25
+ const format = selected[0] ??
26
+ options.format ??
27
+ (options.serve !== undefined ? "html" : "tree");
28
+ if (options.html && options.output)
29
+ throw new Error("Use --html [file] or --output <file>, not both.");
30
+ if (options.serve !== undefined &&
31
+ (format !== "html" || options.output || options.html)) {
32
+ throw new Error("Use --serve on its own or with --format html; file output is separate.");
33
+ }
34
+ return format;
26
35
  }
27
36
  async function runAnalysis(projectPath, options) {
28
37
  try {
29
- const { appDir, displayRoot } = detectAppRouter(projectPath);
30
- const scannedFiles = await scanAppDir(appDir);
31
- let analysisResult = await analyzeRoutes(scannedFiles, projectPath, displayRoot);
32
- if (options.search) {
33
- const search = options.search;
34
- analysisResult.routes = analysisResult.routes.filter((route) => route.path.includes(search));
35
- analysisResult.summary = {
36
- ...analysisResult.summary,
37
- routes: analysisResult.routes.length,
38
- dynamic: analysisResult.routes.filter((route) => route.segmentType !== "static").length,
39
- client: analysisResult.routes.filter((route) => route.rendering === "client").length,
40
- server: analysisResult.routes.filter((route) => route.rendering === "server").length,
41
- };
42
- }
43
38
  const format = resolveFormat(options);
44
- const renderer = getRenderer(format);
45
- const output = renderer.render(analysisResult);
46
- if (output) {
47
- console.log(output);
39
+ const portText = options.serve === true ? "3000" : String(options.serve);
40
+ if (options.serve !== undefined &&
41
+ (!/^\d+$/.test(portText) || Number(portText) > 65535)) {
42
+ throw new Error("Preview port must be an integer between 0 and 65535 (0 chooses a free port).");
48
43
  }
49
- // Determine exit code policy based on check mode and strict flag
50
- const lint = analysisResult.lint;
51
- if (lint) {
52
- if (options.check) {
53
- // In check mode: errors always fail; warnings fail if --strict is enabled
54
- if (lint.hasErrors) {
55
- throw new RouteLintError(lint.errorCount, lint.warningCount);
56
- }
57
- if (options.strict && lint.warningCount > 0) {
58
- throw new RouteLintError(0, lint.warningCount);
59
- }
60
- }
61
- else if (options.strict) {
62
- // In default mode: fail if strict and either errors or warnings exist
63
- if (lint.hasErrors || lint.warningCount > 0) {
64
- throw new RouteLintError(lint.errorCount, lint.warningCount);
65
- }
44
+ const fullResult = await inspectProject(projectPath);
45
+ const result = options.search
46
+ ? filterRoutes(fullResult, options.search)
47
+ : fullResult;
48
+ let output = getRenderer(format).render(result);
49
+ const lint = fullResult.lint;
50
+ const failed = lint &&
51
+ ((options.check && lint.hasErrors) ||
52
+ (options.strict && (lint.hasErrors || lint.warningCount > 0)));
53
+ if (options.serve !== undefined) {
54
+ // A failed CI check must terminate instead of leaving a preview process alive.
55
+ if (failed)
56
+ throw new RouteLintError(lint.errorCount, lint.warningCount);
57
+ const server = await startPreview(output, Number(portText));
58
+ const address = server.address();
59
+ if (address && typeof address !== "string") {
60
+ console.error(pc.green(`✓ Route Graph server running at http://127.0.0.1:${address.port}`));
61
+ console.error("Static snapshot · Press Ctrl+C to stop");
66
62
  }
63
+ return;
64
+ }
65
+ const target = options.html
66
+ ? typeof options.html === "string"
67
+ ? options.html
68
+ : "route-graph.html"
69
+ : options.output;
70
+ if (target && target !== "-") {
71
+ const absolute = path.resolve(target);
72
+ await fs.mkdir(path.dirname(absolute), { recursive: true });
73
+ if (format === "tree")
74
+ output = stripVTControlCharacters(output);
75
+ await fs.writeFile(absolute, output + "\n", "utf-8");
76
+ console.error(pc.green(`✓ ${format === "html" ? "HTML report" : format.toUpperCase() + " export"} written to ${target}`));
67
77
  }
78
+ else if (output) {
79
+ process.stdout.write(output + "\n");
80
+ }
81
+ // Write the report before applying CI exit policy so failing checks leave artifacts.
82
+ if (failed)
83
+ throw new RouteLintError(lint.errorCount, lint.warningCount);
68
84
  }
69
85
  catch (error) {
70
- if (options.json) {
71
- console.error(JSON.stringify({ error: error.message, code: error.code }));
86
+ const message = error instanceof Error ? error.message : String(error);
87
+ if (options.json || options.format === "json") {
88
+ console.error(JSON.stringify({
89
+ error: message,
90
+ code: error instanceof NextRouteGraphError ? error.code : "ERR_CLI",
91
+ }));
72
92
  }
73
93
  else {
74
- console.error(pc.red(`✗ ${error.message}`));
75
- if (error instanceof NextRouteGraphError && error.hint) {
94
+ console.error(pc.red(`✗ ${message}`));
95
+ if (error instanceof NextRouteGraphError && error.hint)
76
96
  console.error(pc.dim(` Hint: ${error.hint}`));
77
- }
78
97
  }
79
- process.exit(1);
98
+ process.exitCode = 1;
80
99
  }
81
100
  }
82
101
  const program = new Command();
102
+ const { version } = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf-8"));
83
103
  program
84
104
  .name("next-route-graph")
85
105
  .description("Inspect, lint, and visualize Next.js App Router project routes")
86
- .version("1.1.0")
106
+ .version(version)
87
107
  .argument("[projectPath]", "Path to the Next.js project root", ".")
88
- .option("--flat", "Output only flat route paths")
108
+ .option("--flat", "Output flat route paths")
89
109
  .option("--json", "Output machine-readable JSON")
90
- .option("--mermaid", "Output Mermaid flowchart diagram")
91
- .option("--dot", "Output Graphviz DOT graph")
92
- .option("--markdown", "Output GitHub-flavored Markdown table")
93
- .option("--format <type>", "Output format: tree, flat, json, mermaid, dot, markdown", "tree")
94
- .option("--search <query>", "Filter routes by search string")
95
- .option("--check", "Run route validation and lint checks")
96
- .option("--strict", "Exit with non-zero code if route issues are detected")
97
- .action(async (projectPath, options) => {
98
- await runAnalysis(projectPath, options);
99
- });
110
+ .option("--mermaid", "Output Mermaid diagram source")
111
+ .option("--dot", "Output Graphviz DOT source")
112
+ .option("--markdown", "Output a Markdown table")
113
+ .option("--svg", "Output a rendered SVG route diagram")
114
+ .option("--html [file]", "Write an offline interactive HTML report (default: route-graph.html)")
115
+ .option("--serve [port]", "Preview an HTML snapshot on 127.0.0.1 (default: 3000)")
116
+ .addOption(new Option("--format <type>", "Output format (default: tree)").choices([
117
+ "tree",
118
+ "flat",
119
+ "json",
120
+ "mermaid",
121
+ "dot",
122
+ "markdown",
123
+ "html",
124
+ "svg",
125
+ ]))
126
+ .option("-o, --output <file>", "Write any format to a file; '-' writes to stdout")
127
+ .option("--search <query>", "Filter path, source file, or HTTP method (case-insensitive)")
128
+ .option("--check", "Fail on route validation errors")
129
+ .option("--strict", "Fail on errors or warnings")
130
+ .action(runAnalysis);
100
131
  program
101
132
  .command("check")
102
- .description("Lint Next.js App Router for collisions, missing roots, and slot errors")
133
+ .description("Check route collisions, root layouts, and slot fallbacks")
103
134
  .argument("[projectPath]", "Path to the Next.js project root", ".")
104
135
  .option("--strict", "Fail on warnings in addition to errors")
105
136
  .option("--json", "Output check report as JSON")
106
137
  .action(async (projectPath, options) => {
107
- await runAnalysis(projectPath, {
108
- check: true,
109
- strict: options.strict ?? false,
110
- json: options.json,
111
- });
138
+ await runAnalysis(projectPath, { ...options, check: true });
112
139
  });
113
- program.parse(process.argv);
140
+ await program.parseAsync(process.argv);
@@ -1,4 +1,8 @@
1
- import { AnalysisResult, DiscoveredFile } from "../types/index.js";
1
+ import { AnalysisResult, DiscoveredFile, RouteEntry, SummaryStats } from "../types/index.js";
2
+ /**
3
+ * Aggregates summary statistics for the analyzed routes and convention files.
4
+ */
5
+ export declare function getRoutesSummary(routes: RouteEntry[], layouts: number, loading: number, errors: number): SummaryStats;
2
6
  /**
3
7
  * Analyzes discovered files in a Next.js App Router to construct route entries,
4
8
  * evaluate rendering types, HTTP methods, layouts, and linter rules.
@@ -1 +1 @@
1
- {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../../src/core/analyzer.ts"],"names":[],"mappings":"AAGA,OAAO,EACL,cAAc,EACd,cAAc,EAIf,MAAM,mBAAmB,CAAC;AA+M3B;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CACjC,KAAK,EAAE,cAAc,EAAE,EACvB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,cAAc,CAAC,CA8CzB"}
1
+ {"version":3,"file":"analyzer.d.ts","sourceRoot":"","sources":["../../src/core/analyzer.ts"],"names":[],"mappings":"AAIA,OAAO,EACL,cAAc,EACd,cAAc,EACd,UAAU,EAEV,YAAY,EACb,MAAM,mBAAmB,CAAC;AAyK3B;;GAEG;AACH,wBAAgB,gBAAgB,CAC9B,MAAM,EAAE,UAAU,EAAE,EACpB,OAAO,EAAE,MAAM,EACf,OAAO,EAAE,MAAM,EACf,MAAM,EAAE,MAAM,GACb,YAAY,CA4Bd;AAED;;;;;;;;GAQG;AACH,wBAAsB,aAAa,CACjC,KAAK,EAAE,cAAc,EAAE,EACvB,WAAW,EAAE,MAAM,EACnB,WAAW,EAAE,MAAM,GAClB,OAAO,CAAC,cAAc,CAAC,CAqDzB"}
@@ -1,7 +1,8 @@
1
1
  import fs from "node:fs/promises";
2
2
  import path from "node:path";
3
+ import { RouteFileReadError } from "../errors/index.js";
3
4
  import { lintRoutes } from "./linter.js";
4
- import { detectHttpMethods, detectRenderingType, detectRouteConfig, detectServerActions, extractRouteMetadata, getSegmentType, parseRoutePath, parseSpecialFileType, } from "./parser.js";
5
+ import { detectHttpMethods, getPrologueDirectives, detectRouteConfig, detectServerActions, extractRouteMetadata, getSegmentType, parseRoutePath, parseSpecialFileType, } from "./parser.js";
5
6
  /**
6
7
  * Groups discovered files by their directory relative to the App Router root.
7
8
  */
@@ -38,8 +39,12 @@ async function processDirectoryFiles(dirFiles) {
38
39
  try {
39
40
  content = await fs.readFile(file.fullPath, "utf-8");
40
41
  }
41
- catch { }
42
- const rendering = await detectRenderingType(content);
42
+ catch (cause) {
43
+ throw new RouteFileReadError(file.relativePath, cause);
44
+ }
45
+ const rendering = getPrologueDirectives(content).has("use client")
46
+ ? "client"
47
+ : "server";
43
48
  const methods = specialType === "route" ? detectHttpMethods(content) : undefined;
44
49
  const hasServerActions = detectServerActions(content);
45
50
  const config = detectRouteConfig(content);
@@ -73,7 +78,7 @@ async function processDirectoryFiles(dirFiles) {
73
78
  /**
74
79
  * Finds all layout files in parent directories that wrap a specific directory.
75
80
  */
76
- function findLayoutChain(targetDir, allFiles) {
81
+ function findLayoutChain(targetDir, layoutsByDirectory) {
77
82
  const layoutChain = [];
78
83
  const normalizedTarget = targetDir === "." ? "" : targetDir;
79
84
  const segments = normalizedTarget ? normalizedTarget.split("/") : [];
@@ -81,13 +86,9 @@ function findLayoutChain(targetDir, allFiles) {
81
86
  for (let i = 0; i <= segments.length; i++) {
82
87
  const currentPath = segments.slice(0, i).join("/");
83
88
  const checkDir = currentPath === "" ? "." : currentPath;
84
- const layout = allFiles.find((f) => {
85
- const dir = path.dirname(f.relativePath).replace(/\\/g, "/");
86
- const isDir = dir === checkDir || (checkDir === "." && dir === "");
87
- return isDir && parseSpecialFileType(f.fileName) === "layout";
88
- });
89
+ const layout = layoutsByDirectory.get(checkDir);
89
90
  if (layout) {
90
- layoutChain.push(layout.relativePath);
91
+ layoutChain.push(layout);
91
92
  }
92
93
  }
93
94
  return layoutChain;
@@ -96,7 +97,7 @@ function findLayoutChain(targetDir, allFiles) {
96
97
  * Creates a RouteEntry from the convention files in a directory.
97
98
  * Uses cached metadata from the in-memory RouteFile to eliminate duplicate disk reads.
98
99
  */
99
- function createRouteEntry(routeFiles, allFiles) {
100
+ function createRouteEntry(routeFiles, layoutsByDirectory) {
100
101
  const pageFile = routeFiles.find((file) => file.type === "page");
101
102
  const routeHandlerFile = routeFiles.find((file) => file.type === "route");
102
103
  const primaryFile = pageFile ?? routeHandlerFile;
@@ -106,7 +107,7 @@ function createRouteEntry(routeFiles, allFiles) {
106
107
  const computedPath = parseRoutePath(primaryFile.relativePath);
107
108
  const metadata = extractRouteMetadata(primaryFile.relativePath);
108
109
  const dir = path.dirname(primaryFile.relativePath).replace(/\\/g, "/");
109
- const layoutChain = findLayoutChain(dir, allFiles);
110
+ const layoutChain = findLayoutChain(dir, layoutsByDirectory);
110
111
  return {
111
112
  path: computedPath,
112
113
  type: pageFile ? "page" : "route",
@@ -126,7 +127,7 @@ function createRouteEntry(routeFiles, allFiles) {
126
127
  /**
127
128
  * Aggregates summary statistics for the analyzed routes and convention files.
128
129
  */
129
- function getRoutesSummary(routes, layouts, loading, errors) {
130
+ export function getRoutesSummary(routes, layouts, loading, errors) {
130
131
  let dynamic = 0;
131
132
  let client = 0;
132
133
  let apiRoutes = 0;
@@ -165,6 +166,12 @@ function getRoutesSummary(routes, layouts, loading, errors) {
165
166
  export async function analyzeRoutes(files, projectPath, displayRoot) {
166
167
  const startTime = Date.now();
167
168
  const dirMap = groupFilesByDirectory(files);
169
+ const layoutsByDirectory = new Map();
170
+ for (const [dir, dirFiles] of dirMap) {
171
+ const layout = dirFiles.find((file) => parseSpecialFileType(file.fileName) === "layout");
172
+ if (layout)
173
+ layoutsByDirectory.set(dir, layout.relativePath);
174
+ }
168
175
  const discoveredRoutes = [];
169
176
  let totalLayouts = 0;
170
177
  let totalLoading = 0;
@@ -174,7 +181,7 @@ export async function analyzeRoutes(files, projectPath, displayRoot) {
174
181
  totalLayouts += result.layouts;
175
182
  totalLoading += result.loading;
176
183
  totalErrors += result.errors;
177
- const route = createRouteEntry(result.routeFiles, files);
184
+ const route = createRouteEntry(result.routeFiles, layoutsByDirectory);
178
185
  if (route) {
179
186
  discoveredRoutes.push(route);
180
187
  }
@@ -0,0 +1,6 @@
1
+ import type { AnalysisResult } from "../types/index.js";
2
+ /** Scan a project without starting Next.js or executing project code. */
3
+ export declare function inspectProject(projectPath?: string): Promise<AnalysisResult>;
4
+ /** Filter endpoint counts; convention-file totals and diagnostics remain project-wide. */
5
+ export declare function filterRoutes(result: AnalysisResult, query: string): AnalysisResult;
6
+ //# sourceMappingURL=inspect.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"inspect.d.ts","sourceRoot":"","sources":["../../src/core/inspect.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAExD,yEAAyE;AACzE,wBAAsB,cAAc,CAClC,WAAW,SAAM,GAChB,OAAO,CAAC,cAAc,CAAC,CAMzB;AAED,0FAA0F;AAC1F,wBAAgB,YAAY,CAC1B,MAAM,EAAE,cAAc,EACtB,KAAK,EAAE,MAAM,GACZ,cAAc,CAiBhB"}
@@ -0,0 +1,21 @@
1
+ import { analyzeRoutes, getRoutesSummary } from "./analyzer.js";
2
+ import { detectAppRouter } from "./detector.js";
3
+ import { scanAppDir } from "./scanner.js";
4
+ /** Scan a project without starting Next.js or executing project code. */
5
+ export async function inspectProject(projectPath = ".") {
6
+ const start = performance.now();
7
+ const { appDir, displayRoot } = detectAppRouter(projectPath);
8
+ const files = await scanAppDir(appDir);
9
+ const result = await analyzeRoutes(files, projectPath, displayRoot);
10
+ return { ...result, executionTimeMs: Math.round(performance.now() - start) };
11
+ }
12
+ /** Filter endpoint counts; convention-file totals and diagnostics remain project-wide. */
13
+ export function filterRoutes(result, query) {
14
+ const search = query.toLowerCase();
15
+ const routes = result.routes.filter((route) => [route.path, route.file, ...(route.methods ?? [])].some((value) => value.toLowerCase().includes(search)));
16
+ return {
17
+ ...result,
18
+ routes,
19
+ summary: getRoutesSummary(routes, result.summary.layouts, result.summary.loading, result.summary.errors),
20
+ };
21
+ }
@@ -1 +1 @@
1
- {"version":3,"file":"linter.d.ts","sourceRoot":"","sources":["../../src/core/linter.ts"],"names":[],"mappings":"AACA,OAAO,EACL,cAAc,EAEd,UAAU,EACV,UAAU,EACX,MAAM,mBAAmB,CAAC;AAkB3B;;;;;;;GAOG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,UAAU,EAAE,EACpB,KAAK,EAAE,cAAc,EAAE,GACtB,UAAU,CA2KZ"}
1
+ {"version":3,"file":"linter.d.ts","sourceRoot":"","sources":["../../src/core/linter.ts"],"names":[],"mappings":"AACA,OAAO,EACL,cAAc,EAEd,UAAU,EACV,UAAU,EACX,MAAM,mBAAmB,CAAC;AAkB3B;;;;;;;GAOG;AACH,wBAAgB,UAAU,CACxB,MAAM,EAAE,UAAU,EAAE,EACpB,KAAK,EAAE,cAAc,EAAE,GACtB,UAAU,CA8LZ"}
@@ -13,7 +13,7 @@ function createPatternKey(routePath) {
13
13
  return ":catchAll";
14
14
  if (seg.startsWith("[") && seg.endsWith("]"))
15
15
  return ":dynamic";
16
- return seg.toLowerCase();
16
+ return seg;
17
17
  })
18
18
  .join("/");
19
19
  }
@@ -47,7 +47,7 @@ export function lintRoutes(routes, files) {
47
47
  });
48
48
  if (!hasTopLevelRootLayout && routes.length > 0) {
49
49
  // Check if every route has at least one layout in its layoutChain
50
- const routesWithoutLayout = routes.filter((r) => !r.layoutChain || r.layoutChain.length === 0);
50
+ const routesWithoutLayout = routes.filter((r) => r.type === "page" && (!r.layoutChain || r.layoutChain.length === 0));
51
51
  if (routesWithoutLayout.length > 0) {
52
52
  issues.push({
53
53
  rule: "require-root-layout",
@@ -77,13 +77,28 @@ export function lintRoutes(routes, files) {
77
77
  // -------------------------------------------------------------
78
78
  // Rule 3: Exact URL Collision across Route Groups (no-duplicate-paths)
79
79
  // -------------------------------------------------------------
80
+ // Slots render alongside children, and intercepted pages intentionally share URLs.
81
+ // Collisions only apply within the same parallel-slot branch.
82
+ const branchKey = (route) => {
83
+ const parts = route.file.replace(/\\/g, "/").split("/");
84
+ let slot = -1;
85
+ parts.forEach((part, index) => {
86
+ if (part.startsWith("@"))
87
+ slot = index;
88
+ });
89
+ return slot < 0 ? "" : parts.slice(0, slot + 1).join("/");
90
+ };
91
+ const publicRoutes = routes.filter((route) => !route.intercepting);
80
92
  const pathToFileMap = new Map();
81
- for (const route of routes) {
82
- const existing = pathToFileMap.get(route.path) || [];
83
- existing.push(route.file);
84
- pathToFileMap.set(route.path, existing);
93
+ for (const route of publicRoutes) {
94
+ const key = JSON.stringify([branchKey(route), route.path]);
95
+ const existing = pathToFileMap.get(key) || [];
96
+ existing.push(route);
97
+ pathToFileMap.set(key, existing);
85
98
  }
86
- for (const [routePath, fileList] of pathToFileMap.entries()) {
99
+ for (const entries of pathToFileMap.values()) {
100
+ const routePath = entries[0].path;
101
+ const fileList = entries.map((route) => route.file);
87
102
  if (fileList.length > 1) {
88
103
  issues.push({
89
104
  rule: "no-duplicate-paths",
@@ -98,23 +113,28 @@ export function lintRoutes(routes, files) {
98
113
  // Rule 4: Dynamic Segment Collisions (no-route-collisions)
99
114
  // -------------------------------------------------------------
100
115
  const patternMap = new Map();
101
- for (const route of routes) {
102
- const key = createPatternKey(route.path);
103
- const existing = patternMap.get(key) || [];
104
- existing.push(route);
105
- patternMap.set(key, existing);
116
+ for (const route of publicRoutes) {
117
+ const segments = route.path.split("/").filter(Boolean);
118
+ for (let depth = 1; depth <= segments.length; depth++) {
119
+ if (!segments[depth - 1].startsWith("["))
120
+ continue;
121
+ const prefix = "/" + segments.slice(0, depth).join("/");
122
+ const key = JSON.stringify([branchKey(route), createPatternKey(prefix)]);
123
+ const existing = patternMap.get(key) ?? new Set();
124
+ existing.add(prefix);
125
+ patternMap.set(key, existing);
126
+ }
106
127
  }
107
- for (const [pattern, patternRoutes] of patternMap.entries()) {
108
- if (patternRoutes.length > 1) {
109
- const uniquePaths = Array.from(new Set(patternRoutes.map((r) => r.path)));
110
- if (uniquePaths.length > 1) {
111
- issues.push({
112
- rule: "no-route-collisions",
113
- severity: "error",
114
- message: `Dynamic route collision detected matching pattern "/${pattern}": ${uniquePaths.join(", ")}`,
115
- hint: "Next.js cannot disambiguate multiple dynamic segments at the same level. Unify the dynamic parameter names.",
116
- });
117
- }
128
+ for (const patternPaths of patternMap.values()) {
129
+ const uniquePaths = [...patternPaths];
130
+ const pattern = createPatternKey(uniquePaths[0]);
131
+ if (uniquePaths.length > 1) {
132
+ issues.push({
133
+ rule: "no-route-collisions",
134
+ severity: "error",
135
+ message: `Dynamic route collision detected matching pattern "/${pattern}": ${uniquePaths.join(", ")}`,
136
+ hint: "Next.js cannot disambiguate multiple dynamic segments at the same level. Unify the dynamic parameter names.",
137
+ });
118
138
  }
119
139
  }
120
140
  // -------------------------------------------------------------
@@ -1 +1 @@
1
- {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/core/parser.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAE/E;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,GAAG,CAAC,eAAe,CAU7C,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAQpC,CAAC;AAEX;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CAQ7E;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAkElE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAIrD;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,iBAAiB,EAAE,MAAM,GACxB,OAAO,CAAC,aAAa,CAAC,CAoBxB;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,EAAE,CAmB/D;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAW5D;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG;IAClD,OAAO,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC5B,aAAa,CAAC,EAAE,MAAM,GAAG,eAAe,GAAG,OAAO,GAAG,cAAc,CAAC;CACrE,CA2BA;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG;IAC1D,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAmBA;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CAwC3D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,GAChB,QAAQ,GAAG,SAAS,GAAG,WAAW,GAAG,oBAAoB,CAc3D"}
1
+ {"version":3,"file":"parser.d.ts","sourceRoot":"","sources":["../../src/core/parser.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,UAAU,EAAE,aAAa,EAAE,eAAe,EAAE,MAAM,mBAAmB,CAAC;AAE/E;;GAEG;AACH,eAAO,MAAM,aAAa,EAAE,GAAG,CAAC,eAAe,CAU7C,CAAC;AAEH;;GAEG;AACH,eAAO,MAAM,YAAY,EAAE,SAAS,UAAU,EAQpC,CAAC;AAEX;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,QAAQ,EAAE,MAAM,GAAG,eAAe,GAAG,IAAI,CAQ7E;AAED;;;;;;;;;GASG;AACH,wBAAgB,qBAAqB,CAAC,OAAO,EAAE,MAAM,GAAG,GAAG,CAAC,MAAM,CAAC,CAsElE;AAED;;;GAGG;AACH,wBAAgB,aAAa,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,CAErD;AAED;;;;;;GAMG;AACH,wBAAsB,mBAAmB,CACvC,iBAAiB,EAAE,MAAM,GACxB,OAAO,CAAC,aAAa,CAAC,CAoBxB;AAED;;;;;;;GAOG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG,UAAU,EAAE,CAuB/D;AAED;;;;;;GAMG;AACH,wBAAgB,mBAAmB,CAAC,OAAO,EAAE,MAAM,GAAG,OAAO,CAW5D;AAED;;;;;;GAMG;AACH,wBAAgB,iBAAiB,CAAC,OAAO,EAAE,MAAM,GAAG;IAClD,OAAO,CAAC,EAAE,QAAQ,GAAG,MAAM,CAAC;IAC5B,aAAa,CAAC,EAAE,MAAM,GAAG,eAAe,GAAG,OAAO,GAAG,cAAc,CAAC;CACrE,CA2BA;AAED;;;;;GAKG;AACH,wBAAgB,oBAAoB,CAAC,YAAY,EAAE,MAAM,GAAG;IAC1D,KAAK,EAAE,MAAM,EAAE,CAAC;IAChB,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB,CAmBA;AAED;;;;;;;GAOG;AACH,wBAAgB,cAAc,CAAC,YAAY,EAAE,MAAM,GAAG,MAAM,CA+C3D;AAED;;;;;GAKG;AACH,wBAAgB,cAAc,CAC5B,SAAS,EAAE,MAAM,GAChB,QAAQ,GAAG,SAAS,GAAG,WAAW,GAAG,oBAAoB,CAc3D"}
@@ -85,7 +85,9 @@ export function getPrologueDirectives(content) {
85
85
  if (char === '"' || char === "'") {
86
86
  const quote = char;
87
87
  let endIndex = index + 1;
88
- while (endIndex < len && code[endIndex] !== quote && code[endIndex] !== "\n") {
88
+ while (endIndex < len &&
89
+ code[endIndex] !== quote &&
90
+ code[endIndex] !== "\n") {
89
91
  endIndex++;
90
92
  }
91
93
  if (endIndex < len && code[endIndex] === quote) {
@@ -114,9 +116,7 @@ export function getPrologueDirectives(content) {
114
116
  * when matching exported declarations.
115
117
  */
116
118
  export function stripComments(content) {
117
- return content
118
- .replace(/\/\*[\s\S]*?\*\//g, "")
119
- .replace(/\/\/[^\n]*/g, "");
119
+ return content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
120
120
  }
121
121
  /**
122
122
  * Analyzes the source content or file on disk to detect if a "use client" directive is present.
@@ -152,19 +152,25 @@ export async function detectRenderingType(fileContentOrPath) {
152
152
  * @returns Array of discovered HTTP methods
153
153
  */
154
154
  export function detectHttpMethods(content) {
155
- const discovered = [];
156
155
  const cleanCode = stripComments(content);
157
- for (const method of HTTP_METHODS) {
158
- // Matches:
159
- // 1. export [async] function GET(...)
160
- // 2. export const/let/var GET = ... or export const GET: Handler = ...
161
- // 3. export { GET } or export { handle as GET }
162
- const pattern = new RegExp(`export\\s+(?:async\\s+)?(?:function\\s+${method}\\b|(?:const|let|var)\\s+${method}(?:\\s*:[^=]+)?\\s*=|\\{[^}]*\\b(?:as\\s+)?${method}\\b[^}]*\\})`, "m");
163
- if (pattern.test(cleanCode)) {
164
- discovered.push(method);
156
+ const namedExports = new Set();
157
+ for (const match of cleanCode.matchAll(/\bexport\s*\{([^}]*)\}/g)) {
158
+ for (const entry of match[1].split(",")) {
159
+ const specifier = entry.trim();
160
+ if (!specifier || specifier.startsWith("type "))
161
+ continue;
162
+ const name = specifier
163
+ .split(/\s+as\s+/)
164
+ .pop()
165
+ .trim();
166
+ namedExports.add(name);
165
167
  }
166
168
  }
167
- return discovered;
169
+ return HTTP_METHODS.filter((method) => {
170
+ // Direct declarations and named exports use the exported name, not the local alias.
171
+ const pattern = new RegExp(`export\\s+(?:async\\s+)?(?:function\\s+${method}\\b|(?:const|let|var)\\s+${method}(?:\\s*:[^=]+)?\\s*=)`, "m");
172
+ return namedExports.has(method) || pattern.test(cleanCode);
173
+ });
168
174
  }
169
175
  /**
170
176
  * Detects if a file defines Server Actions via the "use server" directive.
@@ -256,8 +262,17 @@ export function parseRoutePath(relativePath) {
256
262
  if (cleanedPart.startsWith("%5F") || cleanedPart.startsWith("%5f")) {
257
263
  cleanedPart = "_" + cleanedPart.slice(3);
258
264
  }
259
- // Strip intercepting route prefixes for the public URL mapping (match (..)(..) before (..))
260
- cleanedPart = cleanedPart.replace(/^(?:\(\.\.\)\(\.\.\)|\(\.\.\.\)|\(\.\.\)|\(\.\))/, "");
265
+ // Match URL segments, not filesystem levels: groups and slots never count.
266
+ const interception = cleanedPart.match(/^(\(\.\.\.\)|(?:\(\.\.\))+|\(\.\))/)?.[0];
267
+ if (interception) {
268
+ if (interception === "(...)")
269
+ urlSegments.length = 0;
270
+ else if (interception !== "(.)") {
271
+ const levels = interception.length / "(..)".length;
272
+ urlSegments.splice(Math.max(0, urlSegments.length - levels));
273
+ }
274
+ cleanedPart = cleanedPart.slice(interception.length);
275
+ }
261
276
  urlSegments.push(cleanedPart);
262
277
  }
263
278
  const routePath = "/" + urlSegments.join("/");
@@ -22,6 +22,10 @@ export declare class RouteDirectoryReadError extends NextRouteGraphError {
22
22
  readonly code = "ERR_DIRECTORY_READ";
23
23
  constructor(dirPath?: string, cause?: unknown);
24
24
  }
25
+ export declare class RouteFileReadError extends NextRouteGraphError {
26
+ readonly code = "ERR_FILE_READ";
27
+ constructor(filePath: string, cause: unknown);
28
+ }
25
29
  export declare class RouteLintError extends NextRouteGraphError {
26
30
  readonly code = "ERR_ROUTE_LINT";
27
31
  readonly errorCount: number;
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/errors/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,8BAAsB,mBAAoB,SAAQ,KAAK;IACrD,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;gBAEX,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM;CAM3C;AAED,qBAAa,oBAAqB,SAAQ,mBAAmB;IAC3D,QAAQ,CAAC,IAAI,2BAA2B;gBAE5B,WAAW,CAAC,EAAE,MAAM;CAQjC;AAED,qBAAa,oBAAqB,SAAQ,mBAAmB;IAC3D,QAAQ,CAAC,IAAI,0BAA0B;;CAQxC;AAED,qBAAa,gBAAiB,SAAQ,mBAAmB;IACvD,QAAQ,CAAC,IAAI,uBAAuB;;CAQrC;AAED,qBAAa,uBAAwB,SAAQ,mBAAmB;IAC9D,QAAQ,CAAC,IAAI,wBAAwB;gBAEzB,OAAO,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO;CAW9C;AAED,qBAAa,cAAe,SAAQ,mBAAmB;IACrD,QAAQ,CAAC,IAAI,oBAAoB;IACjC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;gBAElB,UAAU,EAAE,MAAM,EAAE,YAAY,GAAE,MAAU;CAWzD"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/errors/index.ts"],"names":[],"mappings":"AAAA;;GAEG;AAEH,8BAAsB,mBAAoB,SAAQ,KAAK;IACrD,QAAQ,CAAC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAC;IAC/B,QAAQ,CAAC,IAAI,CAAC,EAAE,MAAM,CAAC;gBAEX,OAAO,EAAE,MAAM,EAAE,IAAI,CAAC,EAAE,MAAM;CAM3C;AAED,qBAAa,oBAAqB,SAAQ,mBAAmB;IAC3D,QAAQ,CAAC,IAAI,2BAA2B;gBAE5B,WAAW,CAAC,EAAE,MAAM;CAQjC;AAED,qBAAa,oBAAqB,SAAQ,mBAAmB;IAC3D,QAAQ,CAAC,IAAI,0BAA0B;;CAQxC;AAED,qBAAa,gBAAiB,SAAQ,mBAAmB;IACvD,QAAQ,CAAC,IAAI,uBAAuB;;CAQrC;AAED,qBAAa,uBAAwB,SAAQ,mBAAmB;IAC9D,QAAQ,CAAC,IAAI,wBAAwB;gBAEzB,OAAO,CAAC,EAAE,MAAM,EAAE,KAAK,CAAC,EAAE,OAAO;CAW9C;AAED,qBAAa,kBAAmB,SAAQ,mBAAmB;IACzD,QAAQ,CAAC,IAAI,mBAAmB;gBAEpB,QAAQ,EAAE,MAAM,EAAE,KAAK,EAAE,OAAO;CAO7C;AAED,qBAAa,cAAe,SAAQ,mBAAmB;IACrD,QAAQ,CAAC,IAAI,oBAAoB;IACjC,QAAQ,CAAC,UAAU,EAAE,MAAM,CAAC;IAC5B,QAAQ,CAAC,YAAY,EAAE,MAAM,CAAC;gBAElB,UAAU,EAAE,MAAM,EAAE,YAAY,GAAE,MAAU;CAWzD"}
@@ -41,6 +41,13 @@ export class RouteDirectoryReadError extends NextRouteGraphError {
41
41
  }
42
42
  }
43
43
  }
44
+ export class RouteFileReadError extends NextRouteGraphError {
45
+ code = "ERR_FILE_READ";
46
+ constructor(filePath, cause) {
47
+ super(`Unable to read route file: "${filePath}"`, "Check that the file still exists and is readable, then scan again.");
48
+ this.cause = cause;
49
+ }
50
+ }
44
51
  export class RouteLintError extends NextRouteGraphError {
45
52
  code = "ERR_ROUTE_LINT";
46
53
  errorCount;
@@ -51,7 +58,7 @@ export class RouteLintError extends NextRouteGraphError {
51
58
  details.push(`${errorCount} error(s)`);
52
59
  if (warningCount > 0)
53
60
  details.push(`${warningCount} warning(s)`);
54
- super(`Route validation failed with ${details.join(" and ") || "issues"}.`, "Fix the reported route issues or run without --strict.");
61
+ super(`Route validation failed with ${details.join(" and ") || "issues"}.`, "Fix the reported route issues. Use a normal scan without --check or --strict to inspect without failing validation.");
55
62
  this.errorCount = errorCount;
56
63
  this.warningCount = warningCount;
57
64
  }
package/dist/index.d.ts CHANGED
@@ -5,12 +5,14 @@
5
5
  * Programmatic SDK entry point.
6
6
  */
7
7
  export { analyzeRoutes } from "./core/analyzer.js";
8
+ export { inspectProject, filterRoutes } from "./core/inspect.js";
9
+ export { buildRouteGraph, type RouteGraphNode } from "./render/graph.js";
8
10
  export { detectAppRouter, type DetectionResult } from "./core/detector.js";
9
11
  export { lintRoutes } from "./core/linter.js";
10
12
  export { detectHttpMethods, detectRenderingType, detectRouteConfig, detectServerActions, extractRouteMetadata, getPrologueDirectives, getSegmentType, HTTP_METHODS, parseRoutePath, parseSpecialFileType, SPECIAL_FILES, stripComments, } from "./core/parser.js";
11
13
  export { scanAppDir } from "./core/scanner.js";
12
- export { AmbiguousRouterError, NextRouteGraphError, NoAppRouterError, ProjectNotFoundError, RouteDirectoryReadError, RouteLintError, } from "./errors/index.js";
13
- export { DotRenderer, FlatRenderer, getRenderer, JsonRenderer, MarkdownRenderer, MermaidRenderer, renderDot, renderFlat, renderJson, renderMarkdown, renderMermaid, renderTree, TreeRenderer, } from "./render/index.js";
14
+ export { AmbiguousRouterError, NextRouteGraphError, NoAppRouterError, ProjectNotFoundError, RouteDirectoryReadError, RouteFileReadError, RouteLintError, } from "./errors/index.js";
15
+ export { DotRenderer, FlatRenderer, getRenderer, HtmlRenderer, JsonRenderer, MarkdownRenderer, MermaidRenderer, renderDot, renderFlat, renderHtml, renderJson, renderMarkdown, renderMermaid, renderTree, TreeRenderer, SvgRenderer, renderSvg, } from "./render/index.js";
14
16
  export type { OutputRenderer } from "./render/index.js";
15
17
  export type { AnalysisResult, CLIOptions, DiscoveredFile, HttpMethod, LintIssue, LintResult, LintSeverity, OutputFormat, RenderingType, RouteEntry, RouteFile, SegmentType, SpecialFileType, SummaryStats, } from "./types/index.js";
16
18
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC3E,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,iBAAiB,EACjB,mBAAmB,EACnB,oBAAoB,EACpB,qBAAqB,EACrB,cAAc,EACd,YAAY,EACZ,cAAc,EACd,oBAAoB,EACpB,aAAa,EACb,aAAa,GACd,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAG/C,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,EACvB,cAAc,GACf,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EACL,WAAW,EACX,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,gBAAgB,EAChB,eAAe,EACf,SAAS,EACT,UAAU,EACV,UAAU,EACV,cAAc,EACd,aAAa,EACb,UAAU,EACV,YAAY,GACb,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAGxD,YAAY,EACV,cAAc,EACd,UAAU,EACV,cAAc,EACd,UAAU,EACV,SAAS,EACT,UAAU,EACV,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,UAAU,EACV,SAAS,EACT,WAAW,EACX,eAAe,EACf,YAAY,GACb,MAAM,kBAAkB,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;GAKG;AAEH,OAAO,EAAE,aAAa,EAAE,MAAM,oBAAoB,CAAC;AACnD,OAAO,EAAE,cAAc,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AACjE,OAAO,EAAE,eAAe,EAAE,KAAK,cAAc,EAAE,MAAM,mBAAmB,CAAC;AACzE,OAAO,EAAE,eAAe,EAAE,KAAK,eAAe,EAAE,MAAM,oBAAoB,CAAC;AAC3E,OAAO,EAAE,UAAU,EAAE,MAAM,kBAAkB,CAAC;AAC9C,OAAO,EACL,iBAAiB,EACjB,mBAAmB,EACnB,iBAAiB,EACjB,mBAAmB,EACnB,oBAAoB,EACpB,qBAAqB,EACrB,cAAc,EACd,YAAY,EACZ,cAAc,EACd,oBAAoB,EACpB,aAAa,EACb,aAAa,GACd,MAAM,kBAAkB,CAAC;AAC1B,OAAO,EAAE,UAAU,EAAE,MAAM,mBAAmB,CAAC;AAG/C,OAAO,EACL,oBAAoB,EACpB,mBAAmB,EACnB,gBAAgB,EAChB,oBAAoB,EACpB,uBAAuB,EACvB,kBAAkB,EAClB,cAAc,GACf,MAAM,mBAAmB,CAAC;AAG3B,OAAO,EACL,WAAW,EACX,YAAY,EACZ,WAAW,EACX,YAAY,EACZ,YAAY,EACZ,gBAAgB,EAChB,eAAe,EACf,SAAS,EACT,UAAU,EACV,UAAU,EACV,UAAU,EACV,cAAc,EACd,aAAa,EACb,UAAU,EACV,YAAY,EACZ,WAAW,EACX,SAAS,GACV,MAAM,mBAAmB,CAAC;AAE3B,YAAY,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AAGxD,YAAY,EACV,cAAc,EACd,UAAU,EACV,cAAc,EACd,UAAU,EACV,SAAS,EACT,UAAU,EACV,YAAY,EACZ,YAAY,EACZ,aAAa,EACb,UAAU,EACV,SAAS,EACT,WAAW,EACX,eAAe,EACf,YAAY,GACb,MAAM,kBAAkB,CAAC"}