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
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
import path from "node:path";
|
|
2
|
+
/**
|
|
3
|
+
* Normalizes dynamic segments (e.g. [id], [slug], [...catch]) to a wildcard token
|
|
4
|
+
* to detect overlapping routing patterns.
|
|
5
|
+
*/
|
|
6
|
+
function createPatternKey(routePath) {
|
|
7
|
+
const segments = routePath.split("/").filter(Boolean);
|
|
8
|
+
return segments
|
|
9
|
+
.map((seg) => {
|
|
10
|
+
if (seg.startsWith("[[...") && seg.endsWith("]]"))
|
|
11
|
+
return ":optCatchAll";
|
|
12
|
+
if (seg.startsWith("[...") && seg.endsWith("]"))
|
|
13
|
+
return ":catchAll";
|
|
14
|
+
if (seg.startsWith("[") && seg.endsWith("]"))
|
|
15
|
+
return ":dynamic";
|
|
16
|
+
return seg;
|
|
17
|
+
})
|
|
18
|
+
.join("/");
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Validates Next.js App Router routes and file structures against best practices
|
|
22
|
+
* and official framework rules.
|
|
23
|
+
*
|
|
24
|
+
* @param routes - Array of all analyzed route entries
|
|
25
|
+
* @param files - All discovered files in the App Router directory
|
|
26
|
+
* @returns LintResult containing discovered errors, warnings, and info diagnostics
|
|
27
|
+
*/
|
|
28
|
+
export function lintRoutes(routes, files) {
|
|
29
|
+
const issues = [];
|
|
30
|
+
// Group files by relative directory
|
|
31
|
+
const dirGroup = new Map();
|
|
32
|
+
for (const file of files) {
|
|
33
|
+
const dir = path.dirname(file.relativePath).replace(/\\/g, "/");
|
|
34
|
+
const existing = dirGroup.get(dir) || [];
|
|
35
|
+
existing.push(file);
|
|
36
|
+
dirGroup.set(dir, existing);
|
|
37
|
+
}
|
|
38
|
+
// -------------------------------------------------------------
|
|
39
|
+
// Rule 1: Root Layout Check (require-root-layout)
|
|
40
|
+
// -------------------------------------------------------------
|
|
41
|
+
// Next.js requires a root layout. If top-level layout is absent,
|
|
42
|
+
// Next.js supports Multiple Root Layouts inside top-level route groups (e.g. app/(shop)/layout.tsx).
|
|
43
|
+
const hasTopLevelRootLayout = files.some((f) => {
|
|
44
|
+
const dir = path.dirname(f.relativePath).replace(/\\/g, "/");
|
|
45
|
+
const isRoot = dir === "." || dir === "";
|
|
46
|
+
return isRoot && /^layout\.(tsx|ts|jsx|js)$/.test(f.fileName);
|
|
47
|
+
});
|
|
48
|
+
if (!hasTopLevelRootLayout && routes.length > 0) {
|
|
49
|
+
// Check if every route has at least one layout in its layoutChain
|
|
50
|
+
const routesWithoutLayout = routes.filter((r) => r.type === "page" && (!r.layoutChain || r.layoutChain.length === 0));
|
|
51
|
+
if (routesWithoutLayout.length > 0) {
|
|
52
|
+
issues.push({
|
|
53
|
+
rule: "require-root-layout",
|
|
54
|
+
severity: "error",
|
|
55
|
+
message: "Root layout is missing from the App Router root, and one or more routes lack a route group root layout.",
|
|
56
|
+
hint: "Create an 'app/layout.tsx' file, or add a 'layout.tsx' inside each top-level route group.",
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
// -------------------------------------------------------------
|
|
61
|
+
// Rule 2: Page and Route Handler Conflict (no-page-route-conflict)
|
|
62
|
+
// -------------------------------------------------------------
|
|
63
|
+
for (const [dir, dirFiles] of dirGroup.entries()) {
|
|
64
|
+
const hasPage = dirFiles.some((f) => /^page\.(tsx|ts|jsx|js)$/.test(f.fileName));
|
|
65
|
+
const hasRoute = dirFiles.some((f) => /^route\.(tsx|ts|jsx|js)$/.test(f.fileName));
|
|
66
|
+
if (hasPage && hasRoute) {
|
|
67
|
+
const displayDir = dir === "." ? "root (app/)" : dir;
|
|
68
|
+
issues.push({
|
|
69
|
+
rule: "no-page-route-conflict",
|
|
70
|
+
severity: "error",
|
|
71
|
+
filePath: dir,
|
|
72
|
+
message: `Segment "${displayDir}" contains both page and route handler files.`,
|
|
73
|
+
hint: "Next.js does not allow page.(tsx|js) and route.(ts|js) in the same directory segment.",
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
}
|
|
77
|
+
// -------------------------------------------------------------
|
|
78
|
+
// Rule 3: Exact URL Collision across Route Groups (no-duplicate-paths)
|
|
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);
|
|
92
|
+
const pathToFileMap = new Map();
|
|
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);
|
|
98
|
+
}
|
|
99
|
+
for (const entries of pathToFileMap.values()) {
|
|
100
|
+
const routePath = entries[0].path;
|
|
101
|
+
const fileList = entries.map((route) => route.file);
|
|
102
|
+
if (fileList.length > 1) {
|
|
103
|
+
issues.push({
|
|
104
|
+
rule: "no-duplicate-paths",
|
|
105
|
+
severity: "error",
|
|
106
|
+
routePath,
|
|
107
|
+
message: `Multiple route files resolve to the same public URL path "${routePath}":\n - ${fileList.join("\n - ")}`,
|
|
108
|
+
hint: "Next.js cannot serve multiple pages at the exact same URL. Remove or rename one of the conflicting routes.",
|
|
109
|
+
});
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// -------------------------------------------------------------
|
|
113
|
+
// Rule 4: Dynamic Segment Collisions (no-route-collisions)
|
|
114
|
+
// -------------------------------------------------------------
|
|
115
|
+
const patternMap = new Map();
|
|
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
|
+
}
|
|
127
|
+
}
|
|
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
|
+
});
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
// -------------------------------------------------------------
|
|
141
|
+
// Rule 5: Parallel Route Slot Fallback (parallel-missing-default)
|
|
142
|
+
// -------------------------------------------------------------
|
|
143
|
+
const slotDirs = new Set();
|
|
144
|
+
for (const file of files) {
|
|
145
|
+
const parts = file.relativePath.replace(/\\/g, "/").split("/");
|
|
146
|
+
for (let i = 0; i < parts.length - 1; i++) {
|
|
147
|
+
if (parts[i]?.startsWith("@")) {
|
|
148
|
+
const slotPath = parts.slice(0, i + 1).join("/");
|
|
149
|
+
slotDirs.add(slotPath);
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
for (const slotDir of slotDirs) {
|
|
154
|
+
const slotFiles = dirGroup.get(slotDir) || [];
|
|
155
|
+
const hasDefault = slotFiles.some((f) => /^default\.(tsx|ts|jsx|js)$/.test(f.fileName));
|
|
156
|
+
if (!hasDefault) {
|
|
157
|
+
issues.push({
|
|
158
|
+
rule: "parallel-missing-default",
|
|
159
|
+
severity: "warning",
|
|
160
|
+
filePath: slotDir,
|
|
161
|
+
message: `Parallel route slot "${slotDir}" does not define a default.(tsx|js) fallback.`,
|
|
162
|
+
hint: "Next.js requires default.js in parallel slots to prevent 404s when navigating between unrelated pages.",
|
|
163
|
+
});
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
// -------------------------------------------------------------
|
|
167
|
+
// Rule 6: Global Error Boundary Location (global-error-location)
|
|
168
|
+
// -------------------------------------------------------------
|
|
169
|
+
for (const file of files) {
|
|
170
|
+
if (/^global-error\.(tsx|ts|jsx|js)$/.test(file.fileName)) {
|
|
171
|
+
const dir = path.dirname(file.relativePath).replace(/\\/g, "/");
|
|
172
|
+
const isRoot = dir === "." || dir === "";
|
|
173
|
+
if (!isRoot) {
|
|
174
|
+
issues.push({
|
|
175
|
+
rule: "global-error-location",
|
|
176
|
+
severity: "warning",
|
|
177
|
+
filePath: file.relativePath,
|
|
178
|
+
message: `global-error must be defined at the root App Router directory, but was found in "${dir}".`,
|
|
179
|
+
hint: "Move global-error.(tsx|js) to the root app directory. Subdirectories should use error.(tsx|js).",
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
const errorCount = issues.filter((i) => i.severity === "error").length;
|
|
185
|
+
const warningCount = issues.filter((i) => i.severity === "warning").length;
|
|
186
|
+
const infoCount = issues.filter((i) => i.severity === "info").length;
|
|
187
|
+
return {
|
|
188
|
+
issues,
|
|
189
|
+
hasErrors: errorCount > 0,
|
|
190
|
+
errorCount,
|
|
191
|
+
warningCount,
|
|
192
|
+
infoCount,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import { HttpMethod, RenderingType, SpecialFileType } from "../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Recognizable Next.js App Router special file names without extensions
|
|
4
|
+
*/
|
|
5
|
+
export declare const SPECIAL_FILES: Set<SpecialFileType>;
|
|
6
|
+
/**
|
|
7
|
+
* Standard HTTP methods handled by Next.js route handlers
|
|
8
|
+
*/
|
|
9
|
+
export declare const HTTP_METHODS: readonly HttpMethod[];
|
|
10
|
+
/**
|
|
11
|
+
* Determines whether a file name matches a Next.js App Router convention file.
|
|
12
|
+
*
|
|
13
|
+
* @param fileName - File name with extension (e.g., 'page.tsx', 'layout.js', 'global-error.tsx')
|
|
14
|
+
* @returns SpecialFileType or null if it's not a recognized convention file
|
|
15
|
+
*/
|
|
16
|
+
export declare function parseSpecialFileType(fileName: string): SpecialFileType | null;
|
|
17
|
+
/**
|
|
18
|
+
* Extracts prologue directives (e.g., "use client", "use server") declared at the
|
|
19
|
+
* top of a JavaScript/TypeScript source file.
|
|
20
|
+
*
|
|
21
|
+
* Accurately skips shebangs, whitespace, single-line comments, and multi-line comments.
|
|
22
|
+
* Avoids false positives from comments, template strings, or inner variables.
|
|
23
|
+
*
|
|
24
|
+
* @param content - Source file content
|
|
25
|
+
* @returns Set of discovered directive strings
|
|
26
|
+
*/
|
|
27
|
+
export declare function getPrologueDirectives(content: string): Set<string>;
|
|
28
|
+
/**
|
|
29
|
+
* Strips comments from JavaScript/TypeScript source code to avoid false positives
|
|
30
|
+
* when matching exported declarations.
|
|
31
|
+
*/
|
|
32
|
+
export declare function stripComments(content: string): string;
|
|
33
|
+
/**
|
|
34
|
+
* Analyzes the source content or file on disk to detect if a "use client" directive is present.
|
|
35
|
+
* Defaults to 'server' per Next.js App Router defaults.
|
|
36
|
+
*
|
|
37
|
+
* @param fileContentOrPath - Source code string or disk path to the file
|
|
38
|
+
* @returns 'client' if "use client" directive is declared, otherwise 'server'
|
|
39
|
+
*/
|
|
40
|
+
export declare function detectRenderingType(fileContentOrPath: string): Promise<RenderingType>;
|
|
41
|
+
/**
|
|
42
|
+
* Inspects a route file's source content to detect exported HTTP methods.
|
|
43
|
+
* Used for App Router route.ts handlers.
|
|
44
|
+
* Ignores commented-out declarations and matches multiline or typed exports.
|
|
45
|
+
*
|
|
46
|
+
* @param content - Source code content of the file
|
|
47
|
+
* @returns Array of discovered HTTP methods
|
|
48
|
+
*/
|
|
49
|
+
export declare function detectHttpMethods(content: string): HttpMethod[];
|
|
50
|
+
/**
|
|
51
|
+
* Detects if a file defines Server Actions via the "use server" directive.
|
|
52
|
+
* Checks file-level directive as well as inline function-level Server Actions.
|
|
53
|
+
*
|
|
54
|
+
* @param content - Source code content of the file
|
|
55
|
+
* @returns True if the file contains a "use server" directive
|
|
56
|
+
*/
|
|
57
|
+
export declare function detectServerActions(content: string): boolean;
|
|
58
|
+
/**
|
|
59
|
+
* Extracts route segment configuration options like runtime and dynamic mode.
|
|
60
|
+
* Supports TypeScript type annotations and ignores commented-out code.
|
|
61
|
+
*
|
|
62
|
+
* @param content - Source code content
|
|
63
|
+
* @returns Extracted configuration object
|
|
64
|
+
*/
|
|
65
|
+
export declare function detectRouteConfig(content: string): {
|
|
66
|
+
runtime?: "nodejs" | "edge";
|
|
67
|
+
dynamicConfig?: "auto" | "force-dynamic" | "error" | "force-static";
|
|
68
|
+
};
|
|
69
|
+
/**
|
|
70
|
+
* Extracts parallel slots (e.g., '@modal') and intercepting markers from a path.
|
|
71
|
+
*
|
|
72
|
+
* @param relativePath - Relative file path
|
|
73
|
+
* @returns Discovered metadata about slots and intercepting patterns
|
|
74
|
+
*/
|
|
75
|
+
export declare function extractRouteMetadata(relativePath: string): {
|
|
76
|
+
slots: string[];
|
|
77
|
+
intercepting?: string;
|
|
78
|
+
};
|
|
79
|
+
/**
|
|
80
|
+
* Normalizes a relative filesystem path within App Router into a public URL route.
|
|
81
|
+
* Handles Route Groups (folder), Parallel Routes (@slot), and Intercepting Routes.
|
|
82
|
+
* Decodes Next.js %5F prefixes to underscores for public URLs.
|
|
83
|
+
*
|
|
84
|
+
* @param relativePath - App-relative path to a convention file (e.g. 'blog/[slug]/page.tsx')
|
|
85
|
+
* @returns Normalized URL path (e.g. '/blog/[slug]')
|
|
86
|
+
*/
|
|
87
|
+
export declare function parseRoutePath(relativePath: string): string;
|
|
88
|
+
/**
|
|
89
|
+
* Classifies a route path into its segment dynamic type.
|
|
90
|
+
*
|
|
91
|
+
* @param routePath - Normalized route path
|
|
92
|
+
* @returns 'optional-catch-all' | 'catch-all' | 'dynamic' | 'static'
|
|
93
|
+
*/
|
|
94
|
+
export declare function getSegmentType(routePath: string): "static" | "dynamic" | "catch-all" | "optional-catch-all";
|
|
95
|
+
//# sourceMappingURL=parser.d.ts.map
|
|
@@ -0,0 +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,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"}
|
package/dist/core/parser.js
CHANGED
|
@@ -1,15 +1,37 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
/**
|
|
4
|
+
* Recognizable Next.js App Router special file names without extensions
|
|
5
|
+
*/
|
|
3
6
|
export const SPECIAL_FILES = new Set([
|
|
4
7
|
"page",
|
|
5
8
|
"layout",
|
|
6
9
|
"loading",
|
|
7
10
|
"error",
|
|
11
|
+
"global-error",
|
|
8
12
|
"not-found",
|
|
9
13
|
"template",
|
|
10
14
|
"default",
|
|
11
15
|
"route",
|
|
12
16
|
]);
|
|
17
|
+
/**
|
|
18
|
+
* Standard HTTP methods handled by Next.js route handlers
|
|
19
|
+
*/
|
|
20
|
+
export const HTTP_METHODS = [
|
|
21
|
+
"GET",
|
|
22
|
+
"POST",
|
|
23
|
+
"PUT",
|
|
24
|
+
"PATCH",
|
|
25
|
+
"DELETE",
|
|
26
|
+
"HEAD",
|
|
27
|
+
"OPTIONS",
|
|
28
|
+
];
|
|
29
|
+
/**
|
|
30
|
+
* Determines whether a file name matches a Next.js App Router convention file.
|
|
31
|
+
*
|
|
32
|
+
* @param fileName - File name with extension (e.g., 'page.tsx', 'layout.js', 'global-error.tsx')
|
|
33
|
+
* @returns SpecialFileType or null if it's not a recognized convention file
|
|
34
|
+
*/
|
|
13
35
|
export function parseSpecialFileType(fileName) {
|
|
14
36
|
const baseName = path.parse(fileName).name;
|
|
15
37
|
if (SPECIAL_FILES.has(baseName)) {
|
|
@@ -17,46 +39,251 @@ export function parseSpecialFileType(fileName) {
|
|
|
17
39
|
}
|
|
18
40
|
return null;
|
|
19
41
|
}
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
42
|
+
/**
|
|
43
|
+
* Extracts prologue directives (e.g., "use client", "use server") declared at the
|
|
44
|
+
* top of a JavaScript/TypeScript source file.
|
|
45
|
+
*
|
|
46
|
+
* Accurately skips shebangs, whitespace, single-line comments, and multi-line comments.
|
|
47
|
+
* Avoids false positives from comments, template strings, or inner variables.
|
|
48
|
+
*
|
|
49
|
+
* @param content - Source file content
|
|
50
|
+
* @returns Set of discovered directive strings
|
|
51
|
+
*/
|
|
52
|
+
export function getPrologueDirectives(content) {
|
|
53
|
+
const directives = new Set();
|
|
54
|
+
let code = content.replace(/^#![^\n]*\n/, ""); // Strip shebang if present
|
|
55
|
+
let index = 0;
|
|
56
|
+
const len = code.length;
|
|
57
|
+
while (index < len) {
|
|
58
|
+
// Skip whitespace
|
|
59
|
+
while (index < len && /\s/.test(code[index])) {
|
|
60
|
+
index++;
|
|
61
|
+
}
|
|
62
|
+
if (index >= len)
|
|
63
|
+
break;
|
|
64
|
+
// Skip single-line comments
|
|
65
|
+
if (code[index] === "/" && code[index + 1] === "/") {
|
|
66
|
+
index += 2;
|
|
67
|
+
while (index < len && code[index] !== "\n") {
|
|
68
|
+
index++;
|
|
69
|
+
}
|
|
70
|
+
continue;
|
|
71
|
+
}
|
|
72
|
+
// Skip multi-line comments
|
|
73
|
+
if (code[index] === "/" && code[index + 1] === "*") {
|
|
74
|
+
index += 2;
|
|
75
|
+
while (index < len && !(code[index] === "*" && code[index + 1] === "/")) {
|
|
76
|
+
index++;
|
|
77
|
+
}
|
|
78
|
+
if (index < len) {
|
|
79
|
+
index += 2; // skip */
|
|
80
|
+
}
|
|
81
|
+
continue;
|
|
82
|
+
}
|
|
83
|
+
// Check string literal directive: 'use ...' or "use ..."
|
|
84
|
+
const char = code[index];
|
|
85
|
+
if (char === '"' || char === "'") {
|
|
86
|
+
const quote = char;
|
|
87
|
+
let endIndex = index + 1;
|
|
88
|
+
while (endIndex < len &&
|
|
89
|
+
code[endIndex] !== quote &&
|
|
90
|
+
code[endIndex] !== "\n") {
|
|
91
|
+
endIndex++;
|
|
92
|
+
}
|
|
93
|
+
if (endIndex < len && code[endIndex] === quote) {
|
|
94
|
+
const strVal = code.slice(index + 1, endIndex);
|
|
95
|
+
let after = endIndex + 1;
|
|
96
|
+
while (after < len && (code[after] === " " || code[after] === "\t")) {
|
|
97
|
+
after++;
|
|
98
|
+
}
|
|
99
|
+
if (after >= len ||
|
|
100
|
+
code[after] === ";" ||
|
|
101
|
+
code[after] === "\n" ||
|
|
102
|
+
code[after] === "\r") {
|
|
103
|
+
directives.add(strVal);
|
|
104
|
+
index = code[after] === ";" ? after + 1 : after;
|
|
105
|
+
continue;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
// Any non-directive token ends the directive prologue
|
|
110
|
+
break;
|
|
111
|
+
}
|
|
112
|
+
return directives;
|
|
113
|
+
}
|
|
114
|
+
/**
|
|
115
|
+
* Strips comments from JavaScript/TypeScript source code to avoid false positives
|
|
116
|
+
* when matching exported declarations.
|
|
117
|
+
*/
|
|
118
|
+
export function stripComments(content) {
|
|
119
|
+
return content.replace(/\/\*[\s\S]*?\*\//g, "").replace(/\/\/[^\n]*/g, "");
|
|
120
|
+
}
|
|
121
|
+
/**
|
|
122
|
+
* Analyzes the source content or file on disk to detect if a "use client" directive is present.
|
|
123
|
+
* Defaults to 'server' per Next.js App Router defaults.
|
|
124
|
+
*
|
|
125
|
+
* @param fileContentOrPath - Source code string or disk path to the file
|
|
126
|
+
* @returns 'client' if "use client" directive is declared, otherwise 'server'
|
|
127
|
+
*/
|
|
128
|
+
export async function detectRenderingType(fileContentOrPath) {
|
|
129
|
+
let content = fileContentOrPath;
|
|
130
|
+
// If input looks like a potential file path, attempt reading from disk
|
|
131
|
+
if (!fileContentOrPath.includes("\n") &&
|
|
132
|
+
!fileContentOrPath.includes("\r") &&
|
|
133
|
+
!fileContentOrPath.includes(";") &&
|
|
134
|
+
!fileContentOrPath.includes("{")) {
|
|
135
|
+
try {
|
|
136
|
+
content = await fs.readFile(fileContentOrPath, "utf-8");
|
|
137
|
+
}
|
|
138
|
+
catch {
|
|
139
|
+
// If reading fails, treat input string directly as content
|
|
140
|
+
content = fileContentOrPath;
|
|
27
141
|
}
|
|
28
142
|
}
|
|
29
|
-
|
|
30
|
-
return "server";
|
|
143
|
+
const directives = getPrologueDirectives(content);
|
|
144
|
+
return directives.has("use client") ? "client" : "server";
|
|
31
145
|
}
|
|
146
|
+
/**
|
|
147
|
+
* Inspects a route file's source content to detect exported HTTP methods.
|
|
148
|
+
* Used for App Router route.ts handlers.
|
|
149
|
+
* Ignores commented-out declarations and matches multiline or typed exports.
|
|
150
|
+
*
|
|
151
|
+
* @param content - Source code content of the file
|
|
152
|
+
* @returns Array of discovered HTTP methods
|
|
153
|
+
*/
|
|
154
|
+
export function detectHttpMethods(content) {
|
|
155
|
+
const cleanCode = stripComments(content);
|
|
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);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
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
|
+
});
|
|
174
|
+
}
|
|
175
|
+
/**
|
|
176
|
+
* Detects if a file defines Server Actions via the "use server" directive.
|
|
177
|
+
* Checks file-level directive as well as inline function-level Server Actions.
|
|
178
|
+
*
|
|
179
|
+
* @param content - Source code content of the file
|
|
180
|
+
* @returns True if the file contains a "use server" directive
|
|
181
|
+
*/
|
|
182
|
+
export function detectServerActions(content) {
|
|
183
|
+
const directives = getPrologueDirectives(content);
|
|
184
|
+
if (directives.has("use server")) {
|
|
185
|
+
return true;
|
|
186
|
+
}
|
|
187
|
+
const cleanCode = stripComments(content);
|
|
188
|
+
// Matches inline server actions: async function / function / arrow function body starting with 'use server'
|
|
189
|
+
const inlinePattern = /(?:async\s+function|function|\=>)\s*[^{]*\{[\s\r\n]*['"]use server['"]/m;
|
|
190
|
+
return inlinePattern.test(cleanCode);
|
|
191
|
+
}
|
|
192
|
+
/**
|
|
193
|
+
* Extracts route segment configuration options like runtime and dynamic mode.
|
|
194
|
+
* Supports TypeScript type annotations and ignores commented-out code.
|
|
195
|
+
*
|
|
196
|
+
* @param content - Source code content
|
|
197
|
+
* @returns Extracted configuration object
|
|
198
|
+
*/
|
|
199
|
+
export function detectRouteConfig(content) {
|
|
200
|
+
const result = {};
|
|
201
|
+
const cleanCode = stripComments(content);
|
|
202
|
+
const runtimeMatch = cleanCode.match(/export\s+(?:const|let)\s+runtime(?:\s*:\s*[^=]+)?\s*=\s*['"](nodejs|edge)['"]/);
|
|
203
|
+
if (runtimeMatch?.[1]) {
|
|
204
|
+
result.runtime = runtimeMatch[1];
|
|
205
|
+
}
|
|
206
|
+
const dynamicMatch = cleanCode.match(/export\s+(?:const|let)\s+dynamic(?:\s*:\s*[^=]+)?\s*=\s*['"](auto|force-dynamic|error|force-static)['"]/);
|
|
207
|
+
if (dynamicMatch?.[1]) {
|
|
208
|
+
result.dynamicConfig = dynamicMatch[1];
|
|
209
|
+
}
|
|
210
|
+
return result;
|
|
211
|
+
}
|
|
212
|
+
/**
|
|
213
|
+
* Extracts parallel slots (e.g., '@modal') and intercepting markers from a path.
|
|
214
|
+
*
|
|
215
|
+
* @param relativePath - Relative file path
|
|
216
|
+
* @returns Discovered metadata about slots and intercepting patterns
|
|
217
|
+
*/
|
|
218
|
+
export function extractRouteMetadata(relativePath) {
|
|
219
|
+
const parts = relativePath.split("/");
|
|
220
|
+
const slots = [];
|
|
221
|
+
let intercepting;
|
|
222
|
+
for (const part of parts) {
|
|
223
|
+
if (part.startsWith("@")) {
|
|
224
|
+
slots.push(part);
|
|
225
|
+
}
|
|
226
|
+
// Match (..)(..) before (..)
|
|
227
|
+
const interceptMatch = part.match(/^(\(\.\.\)\(\.\.\)|\(\.\.\.\)|\(\.\.\)|\(\.\))/);
|
|
228
|
+
if (interceptMatch && !intercepting) {
|
|
229
|
+
intercepting = interceptMatch[1];
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
return { slots, intercepting };
|
|
233
|
+
}
|
|
234
|
+
/**
|
|
235
|
+
* Normalizes a relative filesystem path within App Router into a public URL route.
|
|
236
|
+
* Handles Route Groups (folder), Parallel Routes (@slot), and Intercepting Routes.
|
|
237
|
+
* Decodes Next.js %5F prefixes to underscores for public URLs.
|
|
238
|
+
*
|
|
239
|
+
* @param relativePath - App-relative path to a convention file (e.g. 'blog/[slug]/page.tsx')
|
|
240
|
+
* @returns Normalized URL path (e.g. '/blog/[slug]')
|
|
241
|
+
*/
|
|
32
242
|
export function parseRoutePath(relativePath) {
|
|
33
243
|
const parts = relativePath.split("/");
|
|
34
|
-
parts.pop();
|
|
244
|
+
parts.pop(); // Remove file name
|
|
35
245
|
const urlSegments = [];
|
|
36
246
|
for (const part of parts) {
|
|
37
247
|
if (!part)
|
|
38
248
|
continue;
|
|
249
|
+
// Ignore Route Groups like (marketing) or (shop)
|
|
39
250
|
if (part.startsWith("(") && part.endsWith(")")) {
|
|
40
251
|
const inner = part.slice(1, -1);
|
|
41
252
|
if (!inner.startsWith(".") && inner !== "...") {
|
|
42
253
|
continue;
|
|
43
254
|
}
|
|
44
255
|
}
|
|
256
|
+
// Ignore parallel route slot directories like @analytics or @auth
|
|
45
257
|
if (part.startsWith("@")) {
|
|
46
258
|
continue;
|
|
47
259
|
}
|
|
48
260
|
let cleanedPart = part;
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
cleanedPart.
|
|
52
|
-
|
|
53
|
-
|
|
261
|
+
// Decode escaped underscore %5F per Next.js spec
|
|
262
|
+
if (cleanedPart.startsWith("%5F") || cleanedPart.startsWith("%5f")) {
|
|
263
|
+
cleanedPart = "_" + cleanedPart.slice(3);
|
|
264
|
+
}
|
|
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);
|
|
54
275
|
}
|
|
55
276
|
urlSegments.push(cleanedPart);
|
|
56
277
|
}
|
|
57
278
|
const routePath = "/" + urlSegments.join("/");
|
|
58
279
|
return routePath === "/" ? "/" : routePath.replace(/\/+/g, "/");
|
|
59
280
|
}
|
|
281
|
+
/**
|
|
282
|
+
* Classifies a route path into its segment dynamic type.
|
|
283
|
+
*
|
|
284
|
+
* @param routePath - Normalized route path
|
|
285
|
+
* @returns 'optional-catch-all' | 'catch-all' | 'dynamic' | 'static'
|
|
286
|
+
*/
|
|
60
287
|
export function getSegmentType(routePath) {
|
|
61
288
|
if (routePath.includes("[[...")) {
|
|
62
289
|
return "optional-catch-all";
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import { DiscoveredFile } from "../types/index.js";
|
|
2
|
+
/**
|
|
3
|
+
* Recursively scans a Next.js App Router directory to discover route-related source files.
|
|
4
|
+
* Correctly ignores private folders (_folder), hidden directories, and build artifacts.
|
|
5
|
+
*
|
|
6
|
+
* @param rootDir - Root App Router directory path
|
|
7
|
+
* @param currentDir - Current directory during recursive traversal
|
|
8
|
+
* @returns Array of discovered App Router files
|
|
9
|
+
* @throws {RouteDirectoryReadError} If directory reading fails
|
|
10
|
+
*/
|
|
11
|
+
export declare function scanAppDir(rootDir: string, currentDir?: string): Promise<DiscoveredFile[]>;
|
|
12
|
+
//# sourceMappingURL=scanner.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"scanner.d.ts","sourceRoot":"","sources":["../../src/core/scanner.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,cAAc,EAAE,MAAM,mBAAmB,CAAC;AA6BnD;;;;;;;;GAQG;AACH,wBAAsB,UAAU,CAC9B,OAAO,EAAE,MAAM,EACf,UAAU,GAAE,MAAgB,GAC3B,OAAO,CAAC,cAAc,EAAE,CAAC,CAmC3B"}
|
package/dist/core/scanner.js
CHANGED
|
@@ -1,27 +1,53 @@
|
|
|
1
1
|
import fs from "node:fs/promises";
|
|
2
2
|
import path from "node:path";
|
|
3
|
+
import { RouteDirectoryReadError } from "../errors/index.js";
|
|
3
4
|
const ALLOWED_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx"]);
|
|
4
|
-
const
|
|
5
|
+
const IGNORE_DIR_NAMES = new Set([
|
|
5
6
|
"node_modules",
|
|
6
|
-
".git",
|
|
7
|
-
".next",
|
|
8
7
|
"dist",
|
|
9
8
|
"build",
|
|
10
9
|
"coverage",
|
|
10
|
+
"out",
|
|
11
|
+
".next",
|
|
12
|
+
".git",
|
|
13
|
+
".turbo",
|
|
14
|
+
".vercel",
|
|
11
15
|
]);
|
|
16
|
+
/**
|
|
17
|
+
* Checks whether a directory should be excluded from App Router route discovery.
|
|
18
|
+
* Follows Next.js conventions:
|
|
19
|
+
* - Folders starting with '_' are Private Folders (opted out of routing).
|
|
20
|
+
* - Folders starting with '.' are hidden / tooling directories.
|
|
21
|
+
* - Build output and dependency folders are excluded.
|
|
22
|
+
*/
|
|
23
|
+
function shouldIgnoreDirectory(dirName) {
|
|
24
|
+
if (dirName.startsWith("_") || dirName.startsWith(".")) {
|
|
25
|
+
return true;
|
|
26
|
+
}
|
|
27
|
+
return IGNORE_DIR_NAMES.has(dirName);
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* Recursively scans a Next.js App Router directory to discover route-related source files.
|
|
31
|
+
* Correctly ignores private folders (_folder), hidden directories, and build artifacts.
|
|
32
|
+
*
|
|
33
|
+
* @param rootDir - Root App Router directory path
|
|
34
|
+
* @param currentDir - Current directory during recursive traversal
|
|
35
|
+
* @returns Array of discovered App Router files
|
|
36
|
+
* @throws {RouteDirectoryReadError} If directory reading fails
|
|
37
|
+
*/
|
|
12
38
|
export async function scanAppDir(rootDir, currentDir = rootDir) {
|
|
13
39
|
let files = [];
|
|
14
40
|
let entries;
|
|
15
41
|
try {
|
|
16
42
|
entries = await fs.readdir(currentDir, { withFileTypes: true });
|
|
17
43
|
}
|
|
18
|
-
catch {
|
|
19
|
-
throw new
|
|
44
|
+
catch (err) {
|
|
45
|
+
throw new RouteDirectoryReadError(currentDir, err);
|
|
20
46
|
}
|
|
21
47
|
for (const entry of entries) {
|
|
22
48
|
const fullPath = path.join(currentDir, entry.name);
|
|
23
49
|
if (entry.isDirectory()) {
|
|
24
|
-
if (!
|
|
50
|
+
if (!shouldIgnoreDirectory(entry.name)) {
|
|
25
51
|
const subFiles = await scanAppDir(rootDir, fullPath);
|
|
26
52
|
files = files.concat(subFiles);
|
|
27
53
|
}
|