@route-intelligence/next 2.1.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/dist/index.d.ts +30 -0
- package/dist/index.js +694 -0
- package/package.json +46 -0
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import { Condition, RouteGraphLike, FrameworkPlugin } from '@route-intelligence/shared';
|
|
2
|
+
import { SourceFile } from 'ts-morph';
|
|
3
|
+
|
|
4
|
+
interface MiddlewareAnalysis {
|
|
5
|
+
matchers: string[];
|
|
6
|
+
redirects: Array<{
|
|
7
|
+
path: string;
|
|
8
|
+
conditions: Condition[];
|
|
9
|
+
}>;
|
|
10
|
+
rewrites: Array<{
|
|
11
|
+
path: string;
|
|
12
|
+
conditions: Condition[];
|
|
13
|
+
}>;
|
|
14
|
+
conditions: Condition[];
|
|
15
|
+
}
|
|
16
|
+
declare function analyzeMiddlewareFile(sourceFile: SourceFile, filePath: string): MiddlewareAnalysis;
|
|
17
|
+
declare function applyMiddlewareToGraph(graph: RouteGraphLike, middlewareId: string, analysis: MiddlewareAnalysis): void;
|
|
18
|
+
|
|
19
|
+
interface NextPluginOptions {
|
|
20
|
+
appDir?: string;
|
|
21
|
+
pagesDir?: string;
|
|
22
|
+
srcDir?: string;
|
|
23
|
+
basePath?: string;
|
|
24
|
+
customNavigationWrappers?: string[];
|
|
25
|
+
}
|
|
26
|
+
declare function createNextAppRouterPlugin(options?: NextPluginOptions): FrameworkPlugin;
|
|
27
|
+
declare function createNextPagesRouterPlugin(options?: NextPluginOptions): FrameworkPlugin;
|
|
28
|
+
declare function NextPlugin(options?: NextPluginOptions): FrameworkPlugin;
|
|
29
|
+
|
|
30
|
+
export { NextPlugin, type NextPluginOptions, type NextPluginOptions as NextPluginOptionsType, analyzeMiddlewareFile, applyMiddlewareToGraph, createNextAppRouterPlugin, createNextPagesRouterPlugin };
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,694 @@
|
|
|
1
|
+
// src/index.ts
|
|
2
|
+
import { existsSync, readdirSync, statSync } from "fs";
|
|
3
|
+
import { basename, dirname, join, relative, sep } from "path";
|
|
4
|
+
import { createDefaultEdgeAttributes as createDefaultEdgeAttributes2 } from "@route-intelligence/shared";
|
|
5
|
+
import { Project } from "ts-morph";
|
|
6
|
+
|
|
7
|
+
// src/middleware.ts
|
|
8
|
+
import { createDefaultEdgeAttributes } from "@route-intelligence/shared";
|
|
9
|
+
import picomatch from "picomatch";
|
|
10
|
+
import { Node } from "ts-morph";
|
|
11
|
+
function analyzeMiddlewareFile(sourceFile, filePath) {
|
|
12
|
+
const matchers = [];
|
|
13
|
+
const redirects = [];
|
|
14
|
+
const rewrites = [];
|
|
15
|
+
const conditions = [];
|
|
16
|
+
sourceFile.forEachDescendant((node) => {
|
|
17
|
+
if (Node.isPropertyAssignment(node) && node.getName() === "matcher") {
|
|
18
|
+
const init = node.getInitializer();
|
|
19
|
+
if (init && Node.isArrayLiteralExpression(init)) {
|
|
20
|
+
for (const el of init.getElements()) {
|
|
21
|
+
if (Node.isStringLiteral(el)) {
|
|
22
|
+
matchers.push(el.getLiteralText());
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
} else if (init && Node.isStringLiteral(init)) {
|
|
26
|
+
matchers.push(init.getLiteralText());
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
if (Node.isCallExpression(node)) {
|
|
30
|
+
const expr = node.getExpression();
|
|
31
|
+
if (Node.isPropertyAccessExpression(expr)) {
|
|
32
|
+
const obj = expr.getExpression().getText();
|
|
33
|
+
const method = expr.getName();
|
|
34
|
+
if (obj === "NextResponse" && (method === "redirect" || method === "rewrite")) {
|
|
35
|
+
const args = node.getArguments();
|
|
36
|
+
const pathArg = args[0];
|
|
37
|
+
if (pathArg && Node.isStringLiteral(pathArg)) {
|
|
38
|
+
const path = pathArg.getLiteralText();
|
|
39
|
+
const conds = extractSurroundingConditions(node);
|
|
40
|
+
if (method === "redirect") {
|
|
41
|
+
redirects.push({ path, conditions: conds });
|
|
42
|
+
} else {
|
|
43
|
+
rewrites.push({ path, conditions: conds });
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
if (Node.isCallExpression(node)) {
|
|
50
|
+
const callee = node.getExpression().getText();
|
|
51
|
+
if (callee.includes("cookies") || callee.includes("headers")) {
|
|
52
|
+
conditions.push({
|
|
53
|
+
kind: callee.includes("cookies") ? "cookie" : "header",
|
|
54
|
+
expression: node.getText(),
|
|
55
|
+
negated: false,
|
|
56
|
+
confidence: "inferred"
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
});
|
|
61
|
+
if (matchers.length === 0) {
|
|
62
|
+
matchers.push("/:path*");
|
|
63
|
+
}
|
|
64
|
+
return { matchers, redirects, rewrites, conditions };
|
|
65
|
+
}
|
|
66
|
+
function extractSurroundingConditions(node) {
|
|
67
|
+
let current = node;
|
|
68
|
+
const conditions = [];
|
|
69
|
+
while (current) {
|
|
70
|
+
const parent = current.getParent();
|
|
71
|
+
if (!parent) break;
|
|
72
|
+
if (Node.isIfStatement(parent)) {
|
|
73
|
+
conditions.push({
|
|
74
|
+
kind: inferKind(parent.getExpression().getText()),
|
|
75
|
+
expression: parent.getExpression().getText(),
|
|
76
|
+
negated: false,
|
|
77
|
+
confidence: "inferred"
|
|
78
|
+
});
|
|
79
|
+
}
|
|
80
|
+
current = parent;
|
|
81
|
+
}
|
|
82
|
+
return conditions;
|
|
83
|
+
}
|
|
84
|
+
function inferKind(expr) {
|
|
85
|
+
const lower = expr.toLowerCase();
|
|
86
|
+
if (lower.includes("jwt") || lower.includes("token")) return "auth";
|
|
87
|
+
if (lower.includes("role")) return "role";
|
|
88
|
+
if (lower.includes("permission")) return "permission";
|
|
89
|
+
return "unknown";
|
|
90
|
+
}
|
|
91
|
+
function applyMiddlewareToGraph(graph, middlewareId, analysis) {
|
|
92
|
+
for (const matcher of analysis.matchers) {
|
|
93
|
+
const isMatch = picomatch(matcher);
|
|
94
|
+
for (const nodeId of graph.getAllNodeIds()) {
|
|
95
|
+
const path = graph.getNodePath(nodeId);
|
|
96
|
+
if (!path || nodeId === middlewareId) continue;
|
|
97
|
+
if (isMatch(path) || matcher === "/:path*") {
|
|
98
|
+
graph.addEdge(
|
|
99
|
+
`middleware-match:${middlewareId}->${nodeId}:${matcher}`,
|
|
100
|
+
middlewareId,
|
|
101
|
+
nodeId,
|
|
102
|
+
createDefaultEdgeAttributes({
|
|
103
|
+
type: "middleware-match",
|
|
104
|
+
source: "unknown",
|
|
105
|
+
conditions: analysis.conditions
|
|
106
|
+
})
|
|
107
|
+
);
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
for (const redirect of analysis.redirects) {
|
|
112
|
+
const targetId = graph.findNodeByPath(redirect.path);
|
|
113
|
+
if (targetId) {
|
|
114
|
+
graph.addEdge(
|
|
115
|
+
`middleware-redirect:${middlewareId}->${targetId}`,
|
|
116
|
+
middlewareId,
|
|
117
|
+
targetId,
|
|
118
|
+
createDefaultEdgeAttributes({
|
|
119
|
+
type: "redirect",
|
|
120
|
+
source: "NextResponse.redirect",
|
|
121
|
+
conditions: redirect.conditions
|
|
122
|
+
})
|
|
123
|
+
);
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
for (const rewrite of analysis.rewrites) {
|
|
127
|
+
const targetId = graph.findNodeByPath(rewrite.path);
|
|
128
|
+
if (targetId) {
|
|
129
|
+
graph.addEdge(
|
|
130
|
+
`middleware-rewrite:${middlewareId}->${targetId}`,
|
|
131
|
+
middlewareId,
|
|
132
|
+
targetId,
|
|
133
|
+
createDefaultEdgeAttributes({
|
|
134
|
+
type: "rewrite",
|
|
135
|
+
source: "NextResponse.rewrite",
|
|
136
|
+
conditions: rewrite.conditions
|
|
137
|
+
})
|
|
138
|
+
);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// src/index.ts
|
|
144
|
+
var APP_FILE_CONVENTIONS = {
|
|
145
|
+
"page.tsx": "route",
|
|
146
|
+
"page.ts": "route",
|
|
147
|
+
"page.jsx": "route",
|
|
148
|
+
"page.js": "route",
|
|
149
|
+
"layout.tsx": "layout",
|
|
150
|
+
"layout.ts": "layout",
|
|
151
|
+
"layout.jsx": "layout",
|
|
152
|
+
"layout.js": "layout",
|
|
153
|
+
"template.tsx": "template",
|
|
154
|
+
"template.ts": "template",
|
|
155
|
+
"loading.tsx": "loading",
|
|
156
|
+
"loading.ts": "loading",
|
|
157
|
+
"error.tsx": "error",
|
|
158
|
+
"error.ts": "error",
|
|
159
|
+
"global-error.tsx": "global-error",
|
|
160
|
+
"global-error.ts": "global-error",
|
|
161
|
+
"not-found.tsx": "not-found",
|
|
162
|
+
"not-found.ts": "not-found",
|
|
163
|
+
"forbidden.tsx": "forbidden",
|
|
164
|
+
"forbidden.ts": "forbidden",
|
|
165
|
+
"unauthorized.tsx": "unauthorized",
|
|
166
|
+
"unauthorized.ts": "unauthorized",
|
|
167
|
+
"route.ts": "api-route",
|
|
168
|
+
"route.js": "api-route"
|
|
169
|
+
};
|
|
170
|
+
var INTERCEPT_PATTERNS = [
|
|
171
|
+
{ prefix: "(.)", level: "." },
|
|
172
|
+
{ prefix: "(..)(..)", level: "(..)(..)" },
|
|
173
|
+
{ prefix: "(..)", level: ".." },
|
|
174
|
+
{ prefix: "(...)", level: "..." }
|
|
175
|
+
];
|
|
176
|
+
function resolveAppDir(root, options) {
|
|
177
|
+
const candidates = [
|
|
178
|
+
options.appDir ? join(root, options.appDir) : null,
|
|
179
|
+
join(root, "src", "app"),
|
|
180
|
+
join(root, "app")
|
|
181
|
+
].filter(Boolean);
|
|
182
|
+
return candidates.find((c) => existsSync(c)) ?? null;
|
|
183
|
+
}
|
|
184
|
+
function resolvePagesDir(root, options) {
|
|
185
|
+
const candidates = [
|
|
186
|
+
options.pagesDir ? join(root, options.pagesDir) : null,
|
|
187
|
+
join(root, "src", "pages"),
|
|
188
|
+
join(root, "pages")
|
|
189
|
+
].filter(Boolean);
|
|
190
|
+
return candidates.find((c) => existsSync(c)) ?? null;
|
|
191
|
+
}
|
|
192
|
+
function parseSegment(segment) {
|
|
193
|
+
if (segment.startsWith("(") && segment.endsWith(")")) {
|
|
194
|
+
return {
|
|
195
|
+
segment,
|
|
196
|
+
isDynamic: false,
|
|
197
|
+
isCatchAll: false,
|
|
198
|
+
isOptionalCatchAll: false,
|
|
199
|
+
isRouteGroup: true,
|
|
200
|
+
groupName: segment.slice(1, -1),
|
|
201
|
+
isParallelSlot: false,
|
|
202
|
+
isIntercepted: false
|
|
203
|
+
};
|
|
204
|
+
}
|
|
205
|
+
if (segment.startsWith("@")) {
|
|
206
|
+
return {
|
|
207
|
+
segment,
|
|
208
|
+
isDynamic: false,
|
|
209
|
+
isCatchAll: false,
|
|
210
|
+
isOptionalCatchAll: false,
|
|
211
|
+
isRouteGroup: false,
|
|
212
|
+
isParallelSlot: true,
|
|
213
|
+
slotName: segment.slice(1),
|
|
214
|
+
isIntercepted: false
|
|
215
|
+
};
|
|
216
|
+
}
|
|
217
|
+
for (const { prefix, level } of INTERCEPT_PATTERNS) {
|
|
218
|
+
if (segment.startsWith(prefix)) {
|
|
219
|
+
return {
|
|
220
|
+
segment,
|
|
221
|
+
isDynamic: false,
|
|
222
|
+
isCatchAll: false,
|
|
223
|
+
isOptionalCatchAll: false,
|
|
224
|
+
isRouteGroup: false,
|
|
225
|
+
isParallelSlot: false,
|
|
226
|
+
isIntercepted: true,
|
|
227
|
+
interceptLevel: level
|
|
228
|
+
};
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
const optionalCatchAll = segment.match(/^\[\[\.\.\.(.+)\]\]$/);
|
|
232
|
+
if (optionalCatchAll) {
|
|
233
|
+
return {
|
|
234
|
+
segment: optionalCatchAll[1] ?? segment,
|
|
235
|
+
isDynamic: true,
|
|
236
|
+
isCatchAll: true,
|
|
237
|
+
isOptionalCatchAll: true,
|
|
238
|
+
isRouteGroup: false,
|
|
239
|
+
isParallelSlot: false,
|
|
240
|
+
isIntercepted: false
|
|
241
|
+
};
|
|
242
|
+
}
|
|
243
|
+
const catchAll = segment.match(/^\[\.\.\.(.+)\]$/);
|
|
244
|
+
if (catchAll) {
|
|
245
|
+
return {
|
|
246
|
+
segment: catchAll[1] ?? segment,
|
|
247
|
+
isDynamic: true,
|
|
248
|
+
isCatchAll: true,
|
|
249
|
+
isOptionalCatchAll: false,
|
|
250
|
+
isRouteGroup: false,
|
|
251
|
+
isParallelSlot: false,
|
|
252
|
+
isIntercepted: false
|
|
253
|
+
};
|
|
254
|
+
}
|
|
255
|
+
const dynamic = segment.match(/^\[(.+)\]$/);
|
|
256
|
+
if (dynamic) {
|
|
257
|
+
return {
|
|
258
|
+
segment: dynamic[1] ?? segment,
|
|
259
|
+
isDynamic: true,
|
|
260
|
+
isCatchAll: false,
|
|
261
|
+
isOptionalCatchAll: false,
|
|
262
|
+
isRouteGroup: false,
|
|
263
|
+
isParallelSlot: false,
|
|
264
|
+
isIntercepted: false
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
return {
|
|
268
|
+
segment,
|
|
269
|
+
isDynamic: false,
|
|
270
|
+
isCatchAll: false,
|
|
271
|
+
isOptionalCatchAll: false,
|
|
272
|
+
isRouteGroup: false,
|
|
273
|
+
isParallelSlot: false,
|
|
274
|
+
isIntercepted: false
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
function segmentsToUrlPath(segments, basePath = "") {
|
|
278
|
+
const urlSegments = [];
|
|
279
|
+
for (const seg of segments) {
|
|
280
|
+
const parsed = parseSegment(seg);
|
|
281
|
+
if (parsed.isRouteGroup) continue;
|
|
282
|
+
if (parsed.isParallelSlot) continue;
|
|
283
|
+
if (parsed.isIntercepted) {
|
|
284
|
+
const name = seg.replace(/^\(\.\)+|\(\.\.\)\(\.\.\)|\(\.\.\)|\(\.\.\.\)/, "");
|
|
285
|
+
urlSegments.push(name);
|
|
286
|
+
continue;
|
|
287
|
+
}
|
|
288
|
+
if (parsed.isOptionalCatchAll) {
|
|
289
|
+
urlSegments.push(`[[...${parsed.segment}]]`);
|
|
290
|
+
} else if (parsed.isCatchAll) {
|
|
291
|
+
urlSegments.push(`[...${parsed.segment}]`);
|
|
292
|
+
} else if (parsed.isDynamic) {
|
|
293
|
+
urlSegments.push(`[${parsed.segment}]`);
|
|
294
|
+
} else {
|
|
295
|
+
urlSegments.push(parsed.segment);
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
const path = `/${urlSegments.join("/")}`;
|
|
299
|
+
return basePath ? `${basePath}${path === "/" ? "" : path}` : path || "/";
|
|
300
|
+
}
|
|
301
|
+
function walkAppDirectory(dir, appRoot, basePath) {
|
|
302
|
+
const routes = [];
|
|
303
|
+
let entries;
|
|
304
|
+
try {
|
|
305
|
+
entries = readdirSync(dir);
|
|
306
|
+
} catch {
|
|
307
|
+
return routes;
|
|
308
|
+
}
|
|
309
|
+
for (const entry of entries) {
|
|
310
|
+
const fullPath = join(dir, entry);
|
|
311
|
+
const isDir = statSync(fullPath).isDirectory();
|
|
312
|
+
if (isDir) {
|
|
313
|
+
routes.push(...walkAppDirectory(fullPath, appRoot, basePath));
|
|
314
|
+
continue;
|
|
315
|
+
}
|
|
316
|
+
const routeType = APP_FILE_CONVENTIONS[entry];
|
|
317
|
+
if (!routeType) continue;
|
|
318
|
+
const relDir = relative(appRoot, dir);
|
|
319
|
+
const segments = relDir ? relDir.split(sep) : [];
|
|
320
|
+
const allSegments = [...segments];
|
|
321
|
+
const urlPath = segmentsToUrlPath(allSegments, basePath);
|
|
322
|
+
const lastSegment = allSegments[allSegments.length - 1] ?? "";
|
|
323
|
+
const parsed = lastSegment ? parseSegment(lastSegment) : parseSegment("");
|
|
324
|
+
const id = `app:${allSegments.join("/")}:${entry}`;
|
|
325
|
+
routes.push({
|
|
326
|
+
id,
|
|
327
|
+
type: routeType,
|
|
328
|
+
filePath: fullPath,
|
|
329
|
+
urlPath: routeType === "route" || routeType === "api-route" ? urlPath : `${urlPath}#${routeType}`,
|
|
330
|
+
segment: parsed.segment,
|
|
331
|
+
isDynamic: parsed.isDynamic,
|
|
332
|
+
isCatchAll: parsed.isCatchAll,
|
|
333
|
+
isOptionalCatchAll: parsed.isOptionalCatchAll,
|
|
334
|
+
isParallelSlot: parsed.isParallelSlot,
|
|
335
|
+
slotName: parsed.slotName,
|
|
336
|
+
isIntercepted: parsed.isIntercepted,
|
|
337
|
+
interceptLevel: parsed.interceptLevel,
|
|
338
|
+
isRouteGroup: parsed.isRouteGroup,
|
|
339
|
+
groupName: parsed.groupName,
|
|
340
|
+
tags: ["next", "app-router"]
|
|
341
|
+
});
|
|
342
|
+
}
|
|
343
|
+
return routes;
|
|
344
|
+
}
|
|
345
|
+
function resolveParentLayout(segments, routes) {
|
|
346
|
+
for (let i = segments.length; i >= 0; i--) {
|
|
347
|
+
const parentSegments = segments.slice(0, i);
|
|
348
|
+
const layoutId = `app:${parentSegments.join("/")}:/layout.tsx`;
|
|
349
|
+
if (routes.some((r) => r.id.startsWith(`app:${parentSegments.join("/")}:`) && r.type === "layout")) {
|
|
350
|
+
const layout = routes.find(
|
|
351
|
+
(r) => r.type === "layout" && r.id === `app:${parentSegments.join("/")}:/layout.tsx` || r.id.includes("layout") && r.filePath.includes(parentSegments.join(sep))
|
|
352
|
+
);
|
|
353
|
+
if (layout) return layout.id;
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
return void 0;
|
|
357
|
+
}
|
|
358
|
+
function createNextAppRouterPlugin(options = {}) {
|
|
359
|
+
const opts = { appDir: "app", basePath: "", ...options };
|
|
360
|
+
return {
|
|
361
|
+
id: "next-app-router",
|
|
362
|
+
name: "Next.js App Router",
|
|
363
|
+
version: "0.1.0",
|
|
364
|
+
capabilities: {
|
|
365
|
+
supportsAppRouter: true,
|
|
366
|
+
supportsPagesRouter: false,
|
|
367
|
+
supportsMiddleware: true,
|
|
368
|
+
supportsApiRoutes: true,
|
|
369
|
+
supportsI18n: true,
|
|
370
|
+
supportsIncrementalAnalysis: true
|
|
371
|
+
},
|
|
372
|
+
async detect(ctx) {
|
|
373
|
+
return resolveAppDir(ctx.root, opts) !== null;
|
|
374
|
+
},
|
|
375
|
+
async configure(ctx) {
|
|
376
|
+
const appDir = resolveAppDir(ctx.root, opts);
|
|
377
|
+
return {
|
|
378
|
+
appDir,
|
|
379
|
+
basePath: opts.basePath,
|
|
380
|
+
customNavigationWrappers: opts.customNavigationWrappers ?? []
|
|
381
|
+
};
|
|
382
|
+
},
|
|
383
|
+
async *discoverRoutes(ctx) {
|
|
384
|
+
const appDir = resolveAppDir(ctx.root, opts);
|
|
385
|
+
if (!appDir) return;
|
|
386
|
+
const routes = walkAppDirectory(appDir, appDir, opts.basePath ?? "");
|
|
387
|
+
const middlewarePath = [
|
|
388
|
+
join(ctx.root, "middleware.ts"),
|
|
389
|
+
join(ctx.root, "middleware.js"),
|
|
390
|
+
join(ctx.root, "src", "middleware.ts")
|
|
391
|
+
].find((p) => existsSync(p));
|
|
392
|
+
if (middlewarePath) {
|
|
393
|
+
routes.push({
|
|
394
|
+
id: "middleware:main",
|
|
395
|
+
type: "middleware",
|
|
396
|
+
filePath: middlewarePath,
|
|
397
|
+
urlPath: "/*",
|
|
398
|
+
segment: "*",
|
|
399
|
+
isDynamic: false,
|
|
400
|
+
isCatchAll: true,
|
|
401
|
+
isOptionalCatchAll: false,
|
|
402
|
+
isParallelSlot: false,
|
|
403
|
+
isIntercepted: false,
|
|
404
|
+
isRouteGroup: false,
|
|
405
|
+
tags: ["next", "middleware"]
|
|
406
|
+
});
|
|
407
|
+
}
|
|
408
|
+
for (const route of routes) {
|
|
409
|
+
const relDir = relative(appDir, dirname(route.filePath));
|
|
410
|
+
const segments = relDir === "." ? [] : relDir.split(sep);
|
|
411
|
+
route.parentLayoutId = resolveParentLayout(segments, routes);
|
|
412
|
+
yield route;
|
|
413
|
+
}
|
|
414
|
+
},
|
|
415
|
+
async analyzeFile(file, ctx) {
|
|
416
|
+
const edges = [];
|
|
417
|
+
const sourceId = findRouteIdForFile(file.path, ctx);
|
|
418
|
+
for (const link of file.jsxLinks) {
|
|
419
|
+
const targetPath = destinationToPath(link.destination);
|
|
420
|
+
edges.push({
|
|
421
|
+
sourceId: sourceId ?? "",
|
|
422
|
+
targetPath,
|
|
423
|
+
type: link.prefetch === false ? "navigation" : "prefetch",
|
|
424
|
+
source: "Link",
|
|
425
|
+
isExternal: link.destination.kind === "external",
|
|
426
|
+
conditions: link.conditions,
|
|
427
|
+
loc: link.loc
|
|
428
|
+
});
|
|
429
|
+
}
|
|
430
|
+
for (const call of file.navigationCalls) {
|
|
431
|
+
const targetPath = destinationToPath(call.destination);
|
|
432
|
+
let edgeType = "navigation";
|
|
433
|
+
let source = "unknown";
|
|
434
|
+
if (call.callee === "redirect") {
|
|
435
|
+
edgeType = "redirect";
|
|
436
|
+
source = "redirect";
|
|
437
|
+
} else if (call.callee === "permanentRedirect") {
|
|
438
|
+
edgeType = "permanent-redirect";
|
|
439
|
+
source = "permanentRedirect";
|
|
440
|
+
} else if (call.callee.includes("NextResponse.redirect")) {
|
|
441
|
+
edgeType = "redirect";
|
|
442
|
+
source = "NextResponse.redirect";
|
|
443
|
+
} else if (call.callee.includes("NextResponse.rewrite")) {
|
|
444
|
+
edgeType = "rewrite";
|
|
445
|
+
source = "NextResponse.rewrite";
|
|
446
|
+
} else if (call.callee.startsWith("router.")) {
|
|
447
|
+
source = call.callee;
|
|
448
|
+
edgeType = call.callee.includes("prefetch") ? "prefetch" : "navigation";
|
|
449
|
+
} else if (call.callee === "window.location") {
|
|
450
|
+
source = "window.location";
|
|
451
|
+
} else if (call.callee === "window.open") {
|
|
452
|
+
source = "window.open";
|
|
453
|
+
} else if (call.callee.startsWith("history.")) {
|
|
454
|
+
source = call.callee;
|
|
455
|
+
}
|
|
456
|
+
edges.push({
|
|
457
|
+
sourceId: sourceId ?? "",
|
|
458
|
+
targetPath,
|
|
459
|
+
type: edgeType,
|
|
460
|
+
source,
|
|
461
|
+
method: call.method,
|
|
462
|
+
isExternal: call.destination.kind === "external",
|
|
463
|
+
conditions: call.conditions,
|
|
464
|
+
loc: call.loc
|
|
465
|
+
});
|
|
466
|
+
}
|
|
467
|
+
return {
|
|
468
|
+
filePath: file.path,
|
|
469
|
+
edges,
|
|
470
|
+
conditions: file.conditionalBlocks.flatMap((b) => b.conditions),
|
|
471
|
+
tags: [],
|
|
472
|
+
diagnostics: []
|
|
473
|
+
};
|
|
474
|
+
},
|
|
475
|
+
async enrichGraph(graph, ctx) {
|
|
476
|
+
await enrichMiddlewareGraph(graph, ctx);
|
|
477
|
+
await enrichLayoutHierarchy(graph, ctx);
|
|
478
|
+
},
|
|
479
|
+
async runDiagnostics(graph) {
|
|
480
|
+
const diags = [];
|
|
481
|
+
for (const nodeId of graph.getAllNodeIds()) {
|
|
482
|
+
const path = graph.getNodePath(nodeId);
|
|
483
|
+
if (!path) continue;
|
|
484
|
+
const dynamicRoutes = graph.getAllNodeIds().filter((id) => {
|
|
485
|
+
const p = graph.getNodePath(id);
|
|
486
|
+
return p?.includes("[") && graph.getNodePath(id) === path;
|
|
487
|
+
});
|
|
488
|
+
if (dynamicRoutes.length > 1) {
|
|
489
|
+
diags.push({
|
|
490
|
+
ruleId: "shadowed-route",
|
|
491
|
+
severity: "warning",
|
|
492
|
+
message: `Route "${path}" may shadow another route`,
|
|
493
|
+
nodeId
|
|
494
|
+
});
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
return diags;
|
|
498
|
+
}
|
|
499
|
+
};
|
|
500
|
+
}
|
|
501
|
+
function destinationToPath(dest) {
|
|
502
|
+
switch (dest.kind) {
|
|
503
|
+
case "static":
|
|
504
|
+
return dest.path;
|
|
505
|
+
case "template-literal":
|
|
506
|
+
return dest.template.replace(/\$\{[^}]+\}/g, "[param]");
|
|
507
|
+
case "external":
|
|
508
|
+
return dest.url;
|
|
509
|
+
case "dynamic":
|
|
510
|
+
return void 0;
|
|
511
|
+
default: {
|
|
512
|
+
const _exhaustive = dest;
|
|
513
|
+
return _exhaustive;
|
|
514
|
+
}
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
function findRouteIdForFile(filePath, ctx) {
|
|
518
|
+
for (const [id, route] of ctx.routes) {
|
|
519
|
+
if (route.filePath === filePath) return id;
|
|
520
|
+
}
|
|
521
|
+
return void 0;
|
|
522
|
+
}
|
|
523
|
+
async function enrichMiddlewareGraph(graph, ctx) {
|
|
524
|
+
const middlewareId = graph.getAllNodeIds().find((id) => id.startsWith("middleware:"));
|
|
525
|
+
if (!middlewareId) return;
|
|
526
|
+
const middlewarePaths = [
|
|
527
|
+
join(ctx.root, "middleware.ts"),
|
|
528
|
+
join(ctx.root, "middleware.js"),
|
|
529
|
+
join(ctx.root, "src", "middleware.ts")
|
|
530
|
+
];
|
|
531
|
+
const middlewarePath = middlewarePaths.find((p) => existsSync(p));
|
|
532
|
+
if (!middlewarePath) return;
|
|
533
|
+
try {
|
|
534
|
+
const project = new Project({ skipAddingFilesFromTsConfig: true });
|
|
535
|
+
const sourceFile = project.addSourceFileAtPath(middlewarePath);
|
|
536
|
+
const analysis = analyzeMiddlewareFile(sourceFile, middlewarePath);
|
|
537
|
+
applyMiddlewareToGraph(graph, middlewareId, analysis);
|
|
538
|
+
} catch {
|
|
539
|
+
for (const nodeId of graph.getAllNodeIds()) {
|
|
540
|
+
const path = graph.getNodePath(nodeId);
|
|
541
|
+
if (!path || nodeId === middlewareId) continue;
|
|
542
|
+
graph.addEdge(
|
|
543
|
+
`middleware-match:${middlewareId}->${nodeId}`,
|
|
544
|
+
middlewareId,
|
|
545
|
+
nodeId,
|
|
546
|
+
createDefaultEdgeAttributes2({
|
|
547
|
+
type: "middleware-match",
|
|
548
|
+
source: "unknown"
|
|
549
|
+
})
|
|
550
|
+
);
|
|
551
|
+
}
|
|
552
|
+
}
|
|
553
|
+
}
|
|
554
|
+
async function enrichLayoutHierarchy(graph, _ctx) {
|
|
555
|
+
}
|
|
556
|
+
function createNextPagesRouterPlugin(options = {}) {
|
|
557
|
+
const opts = { pagesDir: "pages", basePath: "", ...options };
|
|
558
|
+
return {
|
|
559
|
+
id: "next-pages-router",
|
|
560
|
+
name: "Next.js Pages Router",
|
|
561
|
+
version: "0.1.0",
|
|
562
|
+
capabilities: {
|
|
563
|
+
supportsAppRouter: false,
|
|
564
|
+
supportsPagesRouter: true,
|
|
565
|
+
supportsMiddleware: true,
|
|
566
|
+
supportsApiRoutes: true,
|
|
567
|
+
supportsI18n: false,
|
|
568
|
+
supportsIncrementalAnalysis: true
|
|
569
|
+
},
|
|
570
|
+
async detect(ctx) {
|
|
571
|
+
return resolvePagesDir(ctx.root, opts) !== null;
|
|
572
|
+
},
|
|
573
|
+
async configure(ctx) {
|
|
574
|
+
return {
|
|
575
|
+
pagesDir: resolvePagesDir(ctx.root, opts),
|
|
576
|
+
basePath: opts.basePath
|
|
577
|
+
};
|
|
578
|
+
},
|
|
579
|
+
async *discoverRoutes(ctx) {
|
|
580
|
+
const pagesDir = resolvePagesDir(ctx.root, opts);
|
|
581
|
+
if (!pagesDir) return;
|
|
582
|
+
const { readdirSync: readdirSync2, statSync: statSync2 } = await import("fs");
|
|
583
|
+
function walk(dir, segments = []) {
|
|
584
|
+
const routes = [];
|
|
585
|
+
for (const entry of readdirSync2(dir)) {
|
|
586
|
+
const fullPath = join(dir, entry);
|
|
587
|
+
if (statSync2(fullPath).isDirectory()) {
|
|
588
|
+
routes.push(...walk(fullPath, [...segments, entry]));
|
|
589
|
+
continue;
|
|
590
|
+
}
|
|
591
|
+
const ext = entry.replace(/^(index)?\.(tsx|ts|jsx|js)$/, "");
|
|
592
|
+
if (!/\.(tsx|ts|jsx|js)$/.test(entry)) continue;
|
|
593
|
+
if (entry.startsWith("_")) continue;
|
|
594
|
+
const isApi = segments[0] === "api";
|
|
595
|
+
const fileName = basename(entry, entry.slice(entry.lastIndexOf(".")));
|
|
596
|
+
const urlSegments = [...segments];
|
|
597
|
+
if (fileName !== "index") urlSegments.push(fileName);
|
|
598
|
+
const urlPath = pagesPathToUrl(urlSegments, opts.basePath ?? "", isApi);
|
|
599
|
+
routes.push({
|
|
600
|
+
id: `pages:${urlSegments.join("/")}:${entry}`,
|
|
601
|
+
type: isApi ? "api-route" : "route",
|
|
602
|
+
filePath: fullPath,
|
|
603
|
+
urlPath,
|
|
604
|
+
segment: urlSegments[urlSegments.length - 1] ?? "",
|
|
605
|
+
isDynamic: urlSegments.some((s) => s.startsWith("[")),
|
|
606
|
+
isCatchAll: urlSegments.some((s) => s.startsWith("[...")),
|
|
607
|
+
isOptionalCatchAll: false,
|
|
608
|
+
isParallelSlot: false,
|
|
609
|
+
isIntercepted: false,
|
|
610
|
+
isRouteGroup: false,
|
|
611
|
+
tags: ["next", "pages-router"]
|
|
612
|
+
});
|
|
613
|
+
}
|
|
614
|
+
return routes;
|
|
615
|
+
}
|
|
616
|
+
for (const route of walk(pagesDir)) {
|
|
617
|
+
yield route;
|
|
618
|
+
}
|
|
619
|
+
},
|
|
620
|
+
async analyzeFile(file, ctx) {
|
|
621
|
+
const appPlugin = createNextAppRouterPlugin(opts);
|
|
622
|
+
return appPlugin.analyzeFile(file, ctx);
|
|
623
|
+
},
|
|
624
|
+
async enrichGraph(graph, ctx) {
|
|
625
|
+
await enrichMiddlewareGraph(graph, ctx);
|
|
626
|
+
},
|
|
627
|
+
async runDiagnostics() {
|
|
628
|
+
return [];
|
|
629
|
+
}
|
|
630
|
+
};
|
|
631
|
+
}
|
|
632
|
+
function pagesPathToUrl(segments, basePath, isApi) {
|
|
633
|
+
const mapped = segments.map((s) => {
|
|
634
|
+
if (s.startsWith("[...") && s.endsWith("]")) return `[...${s.slice(4, -1)}]`;
|
|
635
|
+
if (s.startsWith("[") && s.endsWith("]")) return s;
|
|
636
|
+
return s;
|
|
637
|
+
});
|
|
638
|
+
const prefix = isApi ? "" : "";
|
|
639
|
+
const path = `/${mapped.join("/")}`;
|
|
640
|
+
const full = `${prefix}${path}`.replace(/\/index$/, "") || "/";
|
|
641
|
+
return basePath ? `${basePath}${full === "/" ? "" : full}` : full;
|
|
642
|
+
}
|
|
643
|
+
function NextPlugin(options = {}) {
|
|
644
|
+
const appPlugin = createNextAppRouterPlugin(options);
|
|
645
|
+
const pagesPlugin = createNextPagesRouterPlugin(options);
|
|
646
|
+
return {
|
|
647
|
+
id: "next",
|
|
648
|
+
name: "Next.js",
|
|
649
|
+
version: "0.1.0",
|
|
650
|
+
capabilities: {
|
|
651
|
+
supportsAppRouter: true,
|
|
652
|
+
supportsPagesRouter: true,
|
|
653
|
+
supportsMiddleware: true,
|
|
654
|
+
supportsApiRoutes: true,
|
|
655
|
+
supportsI18n: true,
|
|
656
|
+
supportsIncrementalAnalysis: true
|
|
657
|
+
},
|
|
658
|
+
async detect(ctx) {
|
|
659
|
+
return await appPlugin.detect(ctx) || await pagesPlugin.detect(ctx);
|
|
660
|
+
},
|
|
661
|
+
async configure(ctx) {
|
|
662
|
+
const appConfig = await appPlugin.configure(ctx);
|
|
663
|
+
const pagesConfig = await pagesPlugin.configure(ctx);
|
|
664
|
+
return { ...appConfig, ...pagesConfig, ...options };
|
|
665
|
+
},
|
|
666
|
+
async *discoverRoutes(ctx) {
|
|
667
|
+
if (await appPlugin.detect(ctx)) {
|
|
668
|
+
yield* appPlugin.discoverRoutes(ctx);
|
|
669
|
+
}
|
|
670
|
+
if (await pagesPlugin.detect(ctx)) {
|
|
671
|
+
yield* pagesPlugin.discoverRoutes(ctx);
|
|
672
|
+
}
|
|
673
|
+
},
|
|
674
|
+
async analyzeFile(file, ctx) {
|
|
675
|
+
return appPlugin.analyzeFile(file, ctx);
|
|
676
|
+
},
|
|
677
|
+
async enrichGraph(graph, ctx) {
|
|
678
|
+
await appPlugin.enrichGraph(graph, ctx);
|
|
679
|
+
await pagesPlugin.enrichGraph(graph, ctx);
|
|
680
|
+
},
|
|
681
|
+
async runDiagnostics(graph) {
|
|
682
|
+
const appDiags = await appPlugin.runDiagnostics(graph);
|
|
683
|
+
const pagesDiags = await pagesPlugin.runDiagnostics(graph);
|
|
684
|
+
return [...appDiags, ...pagesDiags];
|
|
685
|
+
}
|
|
686
|
+
};
|
|
687
|
+
}
|
|
688
|
+
export {
|
|
689
|
+
NextPlugin,
|
|
690
|
+
analyzeMiddlewareFile,
|
|
691
|
+
applyMiddlewareToGraph,
|
|
692
|
+
createNextAppRouterPlugin,
|
|
693
|
+
createNextPagesRouterPlugin
|
|
694
|
+
};
|
package/package.json
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@route-intelligence/next",
|
|
3
|
+
"version": "2.1.0",
|
|
4
|
+
"description": "Next.js plugin for Route Intelligence",
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"types": "./dist/index.d.ts",
|
|
9
|
+
"development": "./src/index.ts",
|
|
10
|
+
"import": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"main": "./dist/index.js",
|
|
14
|
+
"types": "./dist/index.d.ts",
|
|
15
|
+
"files": [
|
|
16
|
+
"dist"
|
|
17
|
+
],
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup src/index.ts --format esm --dts --clean",
|
|
20
|
+
"dev": "tsup src/index.ts --format esm --dts --watch",
|
|
21
|
+
"typecheck": "tsc --noEmit",
|
|
22
|
+
"test": "vitest run --passWithNoTests"
|
|
23
|
+
},
|
|
24
|
+
"dependencies": {
|
|
25
|
+
"@route-intelligence/core": "*",
|
|
26
|
+
"@route-intelligence/shared": "*",
|
|
27
|
+
"fast-glob": "^3.3.3",
|
|
28
|
+
"picomatch": "^4.0.2",
|
|
29
|
+
"ts-morph": "^25.0.1"
|
|
30
|
+
},
|
|
31
|
+
"devDependencies": {
|
|
32
|
+
"@route-intelligence/tsconfig": "*",
|
|
33
|
+
"@types/node": "^22.15.32",
|
|
34
|
+
"@types/picomatch": "^4.0.0",
|
|
35
|
+
"tsup": "^8.4.0",
|
|
36
|
+
"typescript": "^5.8.3",
|
|
37
|
+
"vitest": "^3.2.4"
|
|
38
|
+
},
|
|
39
|
+
"engines": {
|
|
40
|
+
"node": ">=22"
|
|
41
|
+
},
|
|
42
|
+
"license": "MIT",
|
|
43
|
+
"publishConfig": {
|
|
44
|
+
"access": "public"
|
|
45
|
+
}
|
|
46
|
+
}
|