next-route-graph 1.0.2 → 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.
- package/CHANGELOG.md +95 -0
- package/CONTRIBUTING.md +74 -0
- package/README.md +169 -134
- package/SECURITY.md +19 -0
- package/dist/cli.d.ts +3 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +126 -44
- package/dist/core/analyzer.d.ts +16 -0
- package/dist/core/analyzer.d.ts.map +1 -0
- package/dist/core/analyzer.js +113 -29
- package/dist/core/detector.d.ts +19 -0
- package/dist/core/detector.d.ts.map +1 -0
- package/dist/core/detector.js +31 -3
- package/dist/core/inspect.d.ts +6 -0
- package/dist/core/inspect.d.ts.map +1 -0
- package/dist/core/inspect.js +21 -0
- package/dist/core/linter.d.ts +11 -0
- package/dist/core/linter.d.ts.map +1 -0
- package/dist/core/linter.js +194 -0
- package/dist/core/parser.d.ts +95 -0
- package/dist/core/parser.d.ts.map +1 -0
- package/dist/core/parser.js +242 -15
- package/dist/core/scanner.d.ts +12 -0
- package/dist/core/scanner.d.ts.map +1 -0
- package/dist/core/scanner.js +32 -6
- package/dist/errors/index.d.ts +35 -0
- package/dist/errors/index.d.ts.map +1 -0
- package/dist/errors/index.js +65 -0
- package/dist/index.d.ts +18 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +17 -0
- package/dist/preview.d.ts +4 -0
- package/dist/preview.d.ts.map +1 -0
- package/dist/preview.js +25 -0
- package/dist/render/dot.d.ts +13 -0
- package/dist/render/dot.d.ts.map +1 -0
- package/dist/render/dot.js +66 -0
- package/dist/render/flat.d.ts +13 -0
- package/dist/render/flat.d.ts.map +1 -0
- package/dist/render/flat.js +14 -2
- package/dist/render/graph.d.ts +13 -0
- package/dist/render/graph.d.ts.map +1 -0
- package/dist/render/graph.js +53 -0
- package/dist/render/html.d.ts +8 -0
- package/dist/render/html.d.ts.map +1 -0
- package/dist/render/html.js +718 -0
- package/dist/render/index.d.ts +20 -0
- package/dist/render/index.d.ts.map +1 -0
- package/dist/render/index.js +37 -0
- package/dist/render/json.d.ts +13 -0
- package/dist/render/json.d.ts.map +1 -0
- package/dist/render/json.js +12 -14
- package/dist/render/markdown.d.ts +13 -0
- package/dist/render/markdown.d.ts.map +1 -0
- package/dist/render/markdown.js +59 -0
- package/dist/render/mermaid.d.ts +13 -0
- package/dist/render/mermaid.d.ts.map +1 -0
- package/dist/render/mermaid.js +64 -0
- package/dist/render/renderer.interface.d.ts +8 -0
- package/dist/render/renderer.interface.d.ts.map +1 -0
- package/dist/render/renderer.interface.js +1 -0
- package/dist/render/svg.d.ts +8 -0
- package/dist/render/svg.d.ts.map +1 -0
- package/dist/render/svg.js +76 -0
- package/dist/render/tree.d.ts +13 -0
- package/dist/render/tree.d.ts.map +1 -0
- package/dist/render/tree.js +57 -7
- package/dist/types/index.d.ts +132 -0
- package/dist/types/index.d.ts.map +1 -0
- package/docs/ARCHITECTURE.md +71 -0
- package/docs/NEW_FEATURES.md +16 -0
- package/docs/adr/0001-offline-reports-and-shared-route-graph.md +29 -0
- package/docs/route-report.png +0 -0
- package/package.json +71 -52
package/dist/cli.js
CHANGED
|
@@ -1,58 +1,140 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
|
-
import
|
|
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 {
|
|
5
|
-
import {
|
|
6
|
-
import {
|
|
7
|
-
import {
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
.
|
|
7
|
+
import { filterRoutes, inspectProject } from "./core/inspect.js";
|
|
8
|
+
import { NextRouteGraphError, RouteLintError } from "./errors/index.js";
|
|
9
|
+
import { startPreview } from "./preview.js";
|
|
10
|
+
import { getRenderer } from "./render/index.js";
|
|
11
|
+
function resolveFormat(options) {
|
|
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;
|
|
35
|
+
}
|
|
36
|
+
async function runAnalysis(projectPath, options) {
|
|
20
37
|
try {
|
|
21
|
-
const
|
|
22
|
-
const
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
analysisResult.routes = analysisResult.routes.filter((route) => route.path.includes(search));
|
|
27
|
-
analysisResult.summary = {
|
|
28
|
-
...analysisResult.summary,
|
|
29
|
-
routes: analysisResult.routes.length,
|
|
30
|
-
dynamic: analysisResult.routes.filter((route) => route.segmentType === "dynamic").length,
|
|
31
|
-
client: analysisResult.routes.filter((route) => route.rendering === "client").length,
|
|
32
|
-
server: analysisResult.routes.filter((route) => route.rendering === "server").length,
|
|
33
|
-
};
|
|
38
|
+
const format = resolveFormat(options);
|
|
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).");
|
|
34
43
|
}
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
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");
|
|
42
62
|
}
|
|
63
|
+
return;
|
|
43
64
|
}
|
|
44
|
-
|
|
45
|
-
|
|
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}`));
|
|
46
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);
|
|
47
84
|
}
|
|
48
85
|
catch (error) {
|
|
49
|
-
|
|
50
|
-
|
|
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
|
+
}));
|
|
51
92
|
}
|
|
52
93
|
else {
|
|
53
|
-
console.error(pc.red(`✗ ${
|
|
94
|
+
console.error(pc.red(`✗ ${message}`));
|
|
95
|
+
if (error instanceof NextRouteGraphError && error.hint)
|
|
96
|
+
console.error(pc.dim(` Hint: ${error.hint}`));
|
|
54
97
|
}
|
|
55
|
-
process.
|
|
98
|
+
process.exitCode = 1;
|
|
56
99
|
}
|
|
100
|
+
}
|
|
101
|
+
const program = new Command();
|
|
102
|
+
const { version } = JSON.parse(await fs.readFile(new URL("../package.json", import.meta.url), "utf-8"));
|
|
103
|
+
program
|
|
104
|
+
.name("next-route-graph")
|
|
105
|
+
.description("Inspect, lint, and visualize Next.js App Router project routes")
|
|
106
|
+
.version(version)
|
|
107
|
+
.argument("[projectPath]", "Path to the Next.js project root", ".")
|
|
108
|
+
.option("--flat", "Output flat route paths")
|
|
109
|
+
.option("--json", "Output machine-readable JSON")
|
|
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);
|
|
131
|
+
program
|
|
132
|
+
.command("check")
|
|
133
|
+
.description("Check route collisions, root layouts, and slot fallbacks")
|
|
134
|
+
.argument("[projectPath]", "Path to the Next.js project root", ".")
|
|
135
|
+
.option("--strict", "Fail on warnings in addition to errors")
|
|
136
|
+
.option("--json", "Output check report as JSON")
|
|
137
|
+
.action(async (projectPath, options) => {
|
|
138
|
+
await runAnalysis(projectPath, { ...options, check: true });
|
|
57
139
|
});
|
|
58
|
-
program.
|
|
140
|
+
await program.parseAsync(process.argv);
|
|
@@ -0,0 +1,16 @@
|
|
|
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;
|
|
6
|
+
/**
|
|
7
|
+
* Analyzes discovered files in a Next.js App Router to construct route entries,
|
|
8
|
+
* evaluate rendering types, HTTP methods, layouts, and linter rules.
|
|
9
|
+
*
|
|
10
|
+
* @param files - All discovered files within the App Router directory
|
|
11
|
+
* @param projectPath - Base directory path of the project
|
|
12
|
+
* @param displayRoot - Display name of the App Router root ('app' or 'src/app')
|
|
13
|
+
* @returns Fully populated AnalysisResult with route metadata and lint report
|
|
14
|
+
*/
|
|
15
|
+
export declare function analyzeRoutes(files: DiscoveredFile[], projectPath: string, displayRoot: string): Promise<AnalysisResult>;
|
|
16
|
+
//# sourceMappingURL=analyzer.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
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"}
|
package/dist/core/analyzer.js
CHANGED
|
@@ -1,5 +1,11 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
1
2
|
import path from "node:path";
|
|
2
|
-
import {
|
|
3
|
+
import { RouteFileReadError } from "../errors/index.js";
|
|
4
|
+
import { lintRoutes } from "./linter.js";
|
|
5
|
+
import { detectHttpMethods, getPrologueDirectives, detectRouteConfig, detectServerActions, extractRouteMetadata, getSegmentType, parseRoutePath, parseSpecialFileType, } from "./parser.js";
|
|
6
|
+
/**
|
|
7
|
+
* Groups discovered files by their directory relative to the App Router root.
|
|
8
|
+
*/
|
|
3
9
|
function groupFilesByDirectory(files) {
|
|
4
10
|
const dirMap = new Map();
|
|
5
11
|
for (const file of files) {
|
|
@@ -14,6 +20,11 @@ function groupFilesByDirectory(files) {
|
|
|
14
20
|
}
|
|
15
21
|
return dirMap;
|
|
16
22
|
}
|
|
23
|
+
/**
|
|
24
|
+
* Processes all special convention files in a single directory.
|
|
25
|
+
* Performs a single disk read per file to extract rendering directives,
|
|
26
|
+
* HTTP methods, server actions, and segment configs.
|
|
27
|
+
*/
|
|
17
28
|
async function processDirectoryFiles(dirFiles) {
|
|
18
29
|
const routeFiles = [];
|
|
19
30
|
let layouts = 0;
|
|
@@ -24,12 +35,28 @@ async function processDirectoryFiles(dirFiles) {
|
|
|
24
35
|
if (!specialType) {
|
|
25
36
|
continue;
|
|
26
37
|
}
|
|
27
|
-
|
|
38
|
+
let content = "";
|
|
39
|
+
try {
|
|
40
|
+
content = await fs.readFile(file.fullPath, "utf-8");
|
|
41
|
+
}
|
|
42
|
+
catch (cause) {
|
|
43
|
+
throw new RouteFileReadError(file.relativePath, cause);
|
|
44
|
+
}
|
|
45
|
+
const rendering = getPrologueDirectives(content).has("use client")
|
|
46
|
+
? "client"
|
|
47
|
+
: "server";
|
|
48
|
+
const methods = specialType === "route" ? detectHttpMethods(content) : undefined;
|
|
49
|
+
const hasServerActions = detectServerActions(content);
|
|
50
|
+
const config = detectRouteConfig(content);
|
|
28
51
|
routeFiles.push({
|
|
29
52
|
type: specialType,
|
|
30
53
|
fileName: file.fileName,
|
|
31
54
|
relativePath: file.relativePath,
|
|
32
55
|
rendering,
|
|
56
|
+
methods,
|
|
57
|
+
hasServerActions,
|
|
58
|
+
runtime: config.runtime,
|
|
59
|
+
dynamicConfig: config.dynamicConfig,
|
|
33
60
|
});
|
|
34
61
|
if (specialType === "layout") {
|
|
35
62
|
layouts++;
|
|
@@ -37,7 +64,7 @@ async function processDirectoryFiles(dirFiles) {
|
|
|
37
64
|
if (specialType === "loading") {
|
|
38
65
|
loading++;
|
|
39
66
|
}
|
|
40
|
-
if (specialType === "error") {
|
|
67
|
+
if (specialType === "error" || specialType === "global-error") {
|
|
41
68
|
errors++;
|
|
42
69
|
}
|
|
43
70
|
}
|
|
@@ -48,7 +75,29 @@ async function processDirectoryFiles(dirFiles) {
|
|
|
48
75
|
errors,
|
|
49
76
|
};
|
|
50
77
|
}
|
|
51
|
-
|
|
78
|
+
/**
|
|
79
|
+
* Finds all layout files in parent directories that wrap a specific directory.
|
|
80
|
+
*/
|
|
81
|
+
function findLayoutChain(targetDir, layoutsByDirectory) {
|
|
82
|
+
const layoutChain = [];
|
|
83
|
+
const normalizedTarget = targetDir === "." ? "" : targetDir;
|
|
84
|
+
const segments = normalizedTarget ? normalizedTarget.split("/") : [];
|
|
85
|
+
// Check from root downwards
|
|
86
|
+
for (let i = 0; i <= segments.length; i++) {
|
|
87
|
+
const currentPath = segments.slice(0, i).join("/");
|
|
88
|
+
const checkDir = currentPath === "" ? "." : currentPath;
|
|
89
|
+
const layout = layoutsByDirectory.get(checkDir);
|
|
90
|
+
if (layout) {
|
|
91
|
+
layoutChain.push(layout);
|
|
92
|
+
}
|
|
93
|
+
}
|
|
94
|
+
return layoutChain;
|
|
95
|
+
}
|
|
96
|
+
/**
|
|
97
|
+
* Creates a RouteEntry from the convention files in a directory.
|
|
98
|
+
* Uses cached metadata from the in-memory RouteFile to eliminate duplicate disk reads.
|
|
99
|
+
*/
|
|
100
|
+
function createRouteEntry(routeFiles, layoutsByDirectory) {
|
|
52
101
|
const pageFile = routeFiles.find((file) => file.type === "page");
|
|
53
102
|
const routeHandlerFile = routeFiles.find((file) => file.type === "route");
|
|
54
103
|
const primaryFile = pageFile ?? routeHandlerFile;
|
|
@@ -56,43 +105,41 @@ function createRouteEntry(routeFiles) {
|
|
|
56
105
|
return null;
|
|
57
106
|
}
|
|
58
107
|
const computedPath = parseRoutePath(primaryFile.relativePath);
|
|
108
|
+
const metadata = extractRouteMetadata(primaryFile.relativePath);
|
|
109
|
+
const dir = path.dirname(primaryFile.relativePath).replace(/\\/g, "/");
|
|
110
|
+
const layoutChain = findLayoutChain(dir, layoutsByDirectory);
|
|
59
111
|
return {
|
|
60
112
|
path: computedPath,
|
|
61
113
|
type: pageFile ? "page" : "route",
|
|
62
114
|
file: primaryFile.relativePath,
|
|
63
115
|
segmentType: getSegmentType(computedPath),
|
|
64
116
|
rendering: primaryFile.rendering,
|
|
117
|
+
methods: primaryFile.methods,
|
|
118
|
+
hasServerActions: primaryFile.hasServerActions,
|
|
119
|
+
runtime: primaryFile.runtime,
|
|
120
|
+
dynamicConfig: primaryFile.dynamicConfig,
|
|
65
121
|
files: routeFiles,
|
|
122
|
+
parallelSlots: metadata.slots.length > 0 ? metadata.slots : undefined,
|
|
123
|
+
intercepting: metadata.intercepting,
|
|
124
|
+
layoutChain: layoutChain.length > 0 ? layoutChain : undefined,
|
|
66
125
|
};
|
|
67
126
|
}
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
let totalErrors = 0;
|
|
73
|
-
for (const dirFiles of dirMap.values()) {
|
|
74
|
-
const result = await processDirectoryFiles(dirFiles);
|
|
75
|
-
totalLayouts += result.layouts;
|
|
76
|
-
totalLoading += result.loading;
|
|
77
|
-
totalErrors += result.errors;
|
|
78
|
-
const route = createRouteEntry(result.routeFiles);
|
|
79
|
-
if (route) {
|
|
80
|
-
routesMap.set(route.path, route);
|
|
81
|
-
}
|
|
82
|
-
}
|
|
83
|
-
return {
|
|
84
|
-
routesMap,
|
|
85
|
-
totalLayouts,
|
|
86
|
-
totalLoading,
|
|
87
|
-
totalErrors,
|
|
88
|
-
};
|
|
89
|
-
}
|
|
90
|
-
function getRoutesSummary(routes, layouts, loading, errors) {
|
|
127
|
+
/**
|
|
128
|
+
* Aggregates summary statistics for the analyzed routes and convention files.
|
|
129
|
+
*/
|
|
130
|
+
export function getRoutesSummary(routes, layouts, loading, errors) {
|
|
91
131
|
let dynamic = 0;
|
|
92
132
|
let client = 0;
|
|
133
|
+
let apiRoutes = 0;
|
|
134
|
+
let parallelRoutes = 0;
|
|
135
|
+
let interceptedRoutes = 0;
|
|
93
136
|
for (const route of routes) {
|
|
94
137
|
dynamic += route.segmentType !== "static" ? 1 : 0;
|
|
95
138
|
client += route.rendering === "client" ? 1 : 0;
|
|
139
|
+
apiRoutes += route.type === "route" ? 1 : 0;
|
|
140
|
+
parallelRoutes +=
|
|
141
|
+
route.parallelSlots && route.parallelSlots.length > 0 ? 1 : 0;
|
|
142
|
+
interceptedRoutes += route.intercepting ? 1 : 0;
|
|
96
143
|
}
|
|
97
144
|
return {
|
|
98
145
|
routes: routes.length,
|
|
@@ -102,20 +149,57 @@ function getRoutesSummary(routes, layouts, loading, errors) {
|
|
|
102
149
|
layouts,
|
|
103
150
|
loading,
|
|
104
151
|
errors,
|
|
152
|
+
apiRoutes,
|
|
153
|
+
parallelRoutes,
|
|
154
|
+
interceptedRoutes,
|
|
105
155
|
};
|
|
106
156
|
}
|
|
157
|
+
/**
|
|
158
|
+
* Analyzes discovered files in a Next.js App Router to construct route entries,
|
|
159
|
+
* evaluate rendering types, HTTP methods, layouts, and linter rules.
|
|
160
|
+
*
|
|
161
|
+
* @param files - All discovered files within the App Router directory
|
|
162
|
+
* @param projectPath - Base directory path of the project
|
|
163
|
+
* @param displayRoot - Display name of the App Router root ('app' or 'src/app')
|
|
164
|
+
* @returns Fully populated AnalysisResult with route metadata and lint report
|
|
165
|
+
*/
|
|
107
166
|
export async function analyzeRoutes(files, projectPath, displayRoot) {
|
|
108
167
|
const startTime = Date.now();
|
|
109
168
|
const dirMap = groupFilesByDirectory(files);
|
|
110
|
-
const
|
|
111
|
-
const
|
|
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
|
+
}
|
|
175
|
+
const discoveredRoutes = [];
|
|
176
|
+
let totalLayouts = 0;
|
|
177
|
+
let totalLoading = 0;
|
|
178
|
+
let totalErrors = 0;
|
|
179
|
+
for (const dirFiles of dirMap.values()) {
|
|
180
|
+
const result = await processDirectoryFiles(dirFiles);
|
|
181
|
+
totalLayouts += result.layouts;
|
|
182
|
+
totalLoading += result.loading;
|
|
183
|
+
totalErrors += result.errors;
|
|
184
|
+
const route = createRouteEntry(result.routeFiles, layoutsByDirectory);
|
|
185
|
+
if (route) {
|
|
186
|
+
discoveredRoutes.push(route);
|
|
187
|
+
}
|
|
188
|
+
}
|
|
189
|
+
// Sort routes deterministically by URL path, then by file path for duplicate paths
|
|
190
|
+
const routes = discoveredRoutes.sort((a, b) => {
|
|
191
|
+
const cmp = a.path.localeCompare(b.path);
|
|
192
|
+
return cmp !== 0 ? cmp : a.file.localeCompare(b.file);
|
|
193
|
+
});
|
|
112
194
|
const summary = getRoutesSummary(routes, totalLayouts, totalLoading, totalErrors);
|
|
195
|
+
const lint = lintRoutes(routes, files);
|
|
113
196
|
return {
|
|
114
197
|
project: path.basename(path.resolve(projectPath)),
|
|
115
198
|
router: "App Router",
|
|
116
199
|
root: displayRoot,
|
|
117
200
|
routes,
|
|
118
201
|
summary,
|
|
202
|
+
lint,
|
|
119
203
|
executionTimeMs: Date.now() - startTime,
|
|
120
204
|
};
|
|
121
205
|
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Result of App Router detection
|
|
3
|
+
*/
|
|
4
|
+
export interface DetectionResult {
|
|
5
|
+
appDir: string;
|
|
6
|
+
displayRoot: string;
|
|
7
|
+
}
|
|
8
|
+
/**
|
|
9
|
+
* Detects the Next.js App Router root directory within a project path.
|
|
10
|
+
* Supports standard `./app`, `./src/app`, and direct App Router folder execution.
|
|
11
|
+
*
|
|
12
|
+
* @param projectDir - Path to the project root or App Router directory
|
|
13
|
+
* @returns DetectionResult containing absolute app directory and relative display path
|
|
14
|
+
* @throws {ProjectNotFoundError} If the target project directory does not exist
|
|
15
|
+
* @throws {AmbiguousRouterError} If both ./app and ./src/app are present
|
|
16
|
+
* @throws {NoAppRouterError} If neither ./app nor ./src/app are found
|
|
17
|
+
*/
|
|
18
|
+
export declare function detectAppRouter(projectDir: string): DetectionResult;
|
|
19
|
+
//# sourceMappingURL=detector.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"detector.d.ts","sourceRoot":"","sources":["../../src/core/detector.ts"],"names":[],"mappings":"AAQA;;GAEG;AACH,MAAM,WAAW,eAAe;IAC9B,MAAM,EAAE,MAAM,CAAC;IACf,WAAW,EAAE,MAAM,CAAC;CACrB;AAED;;;;;;;;;GASG;AACH,wBAAgB,eAAe,CAAC,UAAU,EAAE,MAAM,GAAG,eAAe,CAsDnE"}
|
package/dist/core/detector.js
CHANGED
|
@@ -1,16 +1,27 @@
|
|
|
1
1
|
import fs from "node:fs";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { AmbiguousRouterError, NoAppRouterError, ProjectNotFoundError, } from "../errors/index.js";
|
|
4
|
+
/**
|
|
5
|
+
* Detects the Next.js App Router root directory within a project path.
|
|
6
|
+
* Supports standard `./app`, `./src/app`, and direct App Router folder execution.
|
|
7
|
+
*
|
|
8
|
+
* @param projectDir - Path to the project root or App Router directory
|
|
9
|
+
* @returns DetectionResult containing absolute app directory and relative display path
|
|
10
|
+
* @throws {ProjectNotFoundError} If the target project directory does not exist
|
|
11
|
+
* @throws {AmbiguousRouterError} If both ./app and ./src/app are present
|
|
12
|
+
* @throws {NoAppRouterError} If neither ./app nor ./src/app are found
|
|
13
|
+
*/
|
|
3
14
|
export function detectAppRouter(projectDir) {
|
|
4
15
|
const absoluteDir = path.resolve(projectDir);
|
|
5
16
|
if (!fs.existsSync(absoluteDir)) {
|
|
6
|
-
throw new
|
|
17
|
+
throw new ProjectNotFoundError(projectDir);
|
|
7
18
|
}
|
|
8
19
|
const srcAppPath = path.join(absoluteDir, "src", "app");
|
|
9
20
|
const appPath = path.join(absoluteDir, "app");
|
|
10
21
|
const hasSrcApp = fs.existsSync(srcAppPath) && fs.statSync(srcAppPath).isDirectory();
|
|
11
22
|
const hasApp = fs.existsSync(appPath) && fs.statSync(appPath).isDirectory();
|
|
12
23
|
if (hasSrcApp && hasApp) {
|
|
13
|
-
throw new
|
|
24
|
+
throw new AmbiguousRouterError();
|
|
14
25
|
}
|
|
15
26
|
if (hasSrcApp) {
|
|
16
27
|
return {
|
|
@@ -24,5 +35,22 @@ export function detectAppRouter(projectDir) {
|
|
|
24
35
|
displayRoot: "app",
|
|
25
36
|
};
|
|
26
37
|
}
|
|
27
|
-
|
|
38
|
+
// Fallback: Check if the user pointed directly to the app directory itself
|
|
39
|
+
const isDirectAppDir = fs.statSync(absoluteDir).isDirectory() &&
|
|
40
|
+
(path.basename(absoluteDir) === "app" ||
|
|
41
|
+
fs.existsSync(path.join(absoluteDir, "layout.tsx")) ||
|
|
42
|
+
fs.existsSync(path.join(absoluteDir, "layout.jsx")) ||
|
|
43
|
+
fs.existsSync(path.join(absoluteDir, "layout.js")) ||
|
|
44
|
+
fs.existsSync(path.join(absoluteDir, "page.tsx")) ||
|
|
45
|
+
fs.existsSync(path.join(absoluteDir, "page.jsx")) ||
|
|
46
|
+
fs.existsSync(path.join(absoluteDir, "page.js")));
|
|
47
|
+
if (isDirectAppDir) {
|
|
48
|
+
const parentDirName = path.basename(path.dirname(absoluteDir));
|
|
49
|
+
const displayRoot = parentDirName === "src" ? "src/app" : path.basename(absoluteDir);
|
|
50
|
+
return {
|
|
51
|
+
appDir: absoluteDir,
|
|
52
|
+
displayRoot,
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
throw new NoAppRouterError();
|
|
28
56
|
}
|
|
@@ -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
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
import { DiscoveredFile, LintResult, RouteEntry } from "../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Validates Next.js App Router routes and file structures against best practices
|
|
4
|
+
* and official framework rules.
|
|
5
|
+
*
|
|
6
|
+
* @param routes - Array of all analyzed route entries
|
|
7
|
+
* @param files - All discovered files in the App Router directory
|
|
8
|
+
* @returns LintResult containing discovered errors, warnings, and info diagnostics
|
|
9
|
+
*/
|
|
10
|
+
export declare function lintRoutes(routes: RouteEntry[], files: DiscoveredFile[]): LintResult;
|
|
11
|
+
//# sourceMappingURL=linter.d.ts.map
|
|
@@ -0,0 +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,CA8LZ"}
|