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.
Files changed (74) hide show
  1. package/CHANGELOG.md +95 -0
  2. package/CONTRIBUTING.md +74 -0
  3. package/README.md +169 -134
  4. package/SECURITY.md +19 -0
  5. package/dist/cli.d.ts +3 -0
  6. package/dist/cli.d.ts.map +1 -0
  7. package/dist/cli.js +126 -44
  8. package/dist/core/analyzer.d.ts +16 -0
  9. package/dist/core/analyzer.d.ts.map +1 -0
  10. package/dist/core/analyzer.js +113 -29
  11. package/dist/core/detector.d.ts +19 -0
  12. package/dist/core/detector.d.ts.map +1 -0
  13. package/dist/core/detector.js +31 -3
  14. package/dist/core/inspect.d.ts +6 -0
  15. package/dist/core/inspect.d.ts.map +1 -0
  16. package/dist/core/inspect.js +21 -0
  17. package/dist/core/linter.d.ts +11 -0
  18. package/dist/core/linter.d.ts.map +1 -0
  19. package/dist/core/linter.js +194 -0
  20. package/dist/core/parser.d.ts +95 -0
  21. package/dist/core/parser.d.ts.map +1 -0
  22. package/dist/core/parser.js +242 -15
  23. package/dist/core/scanner.d.ts +12 -0
  24. package/dist/core/scanner.d.ts.map +1 -0
  25. package/dist/core/scanner.js +32 -6
  26. package/dist/errors/index.d.ts +35 -0
  27. package/dist/errors/index.d.ts.map +1 -0
  28. package/dist/errors/index.js +65 -0
  29. package/dist/index.d.ts +18 -0
  30. package/dist/index.d.ts.map +1 -0
  31. package/dist/index.js +17 -0
  32. package/dist/preview.d.ts +4 -0
  33. package/dist/preview.d.ts.map +1 -0
  34. package/dist/preview.js +25 -0
  35. package/dist/render/dot.d.ts +13 -0
  36. package/dist/render/dot.d.ts.map +1 -0
  37. package/dist/render/dot.js +66 -0
  38. package/dist/render/flat.d.ts +13 -0
  39. package/dist/render/flat.d.ts.map +1 -0
  40. package/dist/render/flat.js +14 -2
  41. package/dist/render/graph.d.ts +13 -0
  42. package/dist/render/graph.d.ts.map +1 -0
  43. package/dist/render/graph.js +53 -0
  44. package/dist/render/html.d.ts +8 -0
  45. package/dist/render/html.d.ts.map +1 -0
  46. package/dist/render/html.js +718 -0
  47. package/dist/render/index.d.ts +20 -0
  48. package/dist/render/index.d.ts.map +1 -0
  49. package/dist/render/index.js +37 -0
  50. package/dist/render/json.d.ts +13 -0
  51. package/dist/render/json.d.ts.map +1 -0
  52. package/dist/render/json.js +12 -14
  53. package/dist/render/markdown.d.ts +13 -0
  54. package/dist/render/markdown.d.ts.map +1 -0
  55. package/dist/render/markdown.js +59 -0
  56. package/dist/render/mermaid.d.ts +13 -0
  57. package/dist/render/mermaid.d.ts.map +1 -0
  58. package/dist/render/mermaid.js +64 -0
  59. package/dist/render/renderer.interface.d.ts +8 -0
  60. package/dist/render/renderer.interface.d.ts.map +1 -0
  61. package/dist/render/renderer.interface.js +1 -0
  62. package/dist/render/svg.d.ts +8 -0
  63. package/dist/render/svg.d.ts.map +1 -0
  64. package/dist/render/svg.js +76 -0
  65. package/dist/render/tree.d.ts +13 -0
  66. package/dist/render/tree.d.ts.map +1 -0
  67. package/dist/render/tree.js +57 -7
  68. package/dist/types/index.d.ts +132 -0
  69. package/dist/types/index.d.ts.map +1 -0
  70. package/docs/ARCHITECTURE.md +71 -0
  71. package/docs/NEW_FEATURES.md +16 -0
  72. package/docs/adr/0001-offline-reports-and-shared-route-graph.md +29 -0
  73. package/docs/route-report.png +0 -0
  74. 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"}
@@ -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
- export async function detectRenderingType(filePath) {
21
- try {
22
- const content = await fs.readFile(filePath, "utf-8");
23
- const firstLines = content.slice(0, 500);
24
- const clientDirectiveRegex = /^(?:(?:\s*\/\/.*?\n)|(?:\s*\/\*[\s\S]*?\*\/)|(?:\s*))["']use client["']/m;
25
- if (clientDirectiveRegex.test(firstLines)) {
26
- return "client";
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
- catch { }
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
- if (cleanedPart.startsWith("(.)") ||
50
- cleanedPart.startsWith("(..)") ||
51
- cleanedPart.startsWith("(...)") ||
52
- cleanedPart.startsWith("(..)(..)")) {
53
- cleanedPart = cleanedPart.replace(/^(?:\(\.\.\.\)|\(\.\.\)|\(\.\))/, "");
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"}
@@ -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 IGNORE_DIRS = new Set([
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 Error("Unable to read the App Router directory.");
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 (!IGNORE_DIRS.has(entry.name)) {
50
+ if (!shouldIgnoreDirectory(entry.name)) {
25
51
  const subFiles = await scanAppDir(rootDir, fullPath);
26
52
  files = files.concat(subFiles);
27
53
  }