@zauso-ai/capstan-router 0.2.0 → 0.3.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 +3 -2
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +2 -1
- package/dist/index.js.map +1 -1
- package/dist/manifest.d.ts.map +1 -1
- package/dist/manifest.js +0 -7
- package/dist/manifest.js.map +1 -1
- package/dist/matcher.d.ts.map +1 -1
- package/dist/matcher.js +65 -3
- package/dist/matcher.js.map +1 -1
- package/dist/runtime.d.ts +3 -0
- package/dist/runtime.d.ts.map +1 -0
- package/dist/runtime.js +2 -0
- package/dist/runtime.js.map +1 -0
- package/dist/scanner.d.ts +33 -2
- package/dist/scanner.d.ts.map +1 -1
- package/dist/scanner.js +422 -116
- package/dist/scanner.js.map +1 -1
- package/dist/static-analysis.d.ts +7 -0
- package/dist/static-analysis.d.ts.map +1 -0
- package/dist/static-analysis.js +158 -0
- package/dist/static-analysis.js.map +1 -0
- package/dist/types.d.ts +48 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/validation.d.ts +9 -0
- package/dist/validation.d.ts.map +1 -0
- package/dist/validation.js +334 -0
- package/dist/validation.js.map +1 -0
- package/package.json +11 -2
package/dist/scanner.js
CHANGED
|
@@ -1,5 +1,89 @@
|
|
|
1
|
+
import { openSync, readSync, closeSync, statSync } from "node:fs";
|
|
1
2
|
import { readdir, stat } from "node:fs/promises";
|
|
2
3
|
import path from "node:path";
|
|
4
|
+
import { canonicalizeRouteManifest, createRouteConflictError as createValidationRouteConflictError, } from "./validation.js";
|
|
5
|
+
import { analyzeRouteFileStaticInfo } from "./static-analysis.js";
|
|
6
|
+
function isRouteGroupSegment(segment) {
|
|
7
|
+
return /^\([^()/]+\)$/.test(segment);
|
|
8
|
+
}
|
|
9
|
+
function isNotFoundFile(filename) {
|
|
10
|
+
return filename === "not-found.tsx" || filename === "not-found.page.tsx";
|
|
11
|
+
}
|
|
12
|
+
const componentTypeCache = new Map();
|
|
13
|
+
export class RouteScanCache {
|
|
14
|
+
states = new Map();
|
|
15
|
+
get(rootDir) {
|
|
16
|
+
return this.states.get(rootDir);
|
|
17
|
+
}
|
|
18
|
+
set(rootDir, state) {
|
|
19
|
+
this.states.set(rootDir, state);
|
|
20
|
+
}
|
|
21
|
+
clear(rootDir) {
|
|
22
|
+
if (rootDir) {
|
|
23
|
+
this.states.delete(path.resolve(rootDir));
|
|
24
|
+
return;
|
|
25
|
+
}
|
|
26
|
+
this.states.clear();
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
export function createRouteScanCache() {
|
|
30
|
+
return new RouteScanCache();
|
|
31
|
+
}
|
|
32
|
+
/**
|
|
33
|
+
* Detect whether a page file is a server or client component by checking
|
|
34
|
+
* for a "use client" directive at the top of the file.
|
|
35
|
+
*
|
|
36
|
+
* Accepts an optional pre-computed signature (mtime:size) to avoid a
|
|
37
|
+
* redundant statSync when the caller already has file stats.
|
|
38
|
+
*/
|
|
39
|
+
function detectComponentType(filePath, knownSignature) {
|
|
40
|
+
let signature = knownSignature;
|
|
41
|
+
if (!signature) {
|
|
42
|
+
try {
|
|
43
|
+
const stats = statSync(filePath);
|
|
44
|
+
signature = `${stats.mtimeMs}:${stats.size}`;
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
componentTypeCache.delete(filePath);
|
|
48
|
+
return "server";
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
const cached = componentTypeCache.get(filePath);
|
|
52
|
+
if (cached?.signature === signature) {
|
|
53
|
+
return cached.componentType;
|
|
54
|
+
}
|
|
55
|
+
// Read only the first 128 bytes — enough to detect "use client" directive
|
|
56
|
+
// without pulling the entire file into memory.
|
|
57
|
+
let fd;
|
|
58
|
+
try {
|
|
59
|
+
fd = openSync(filePath, "r");
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return "server";
|
|
63
|
+
}
|
|
64
|
+
try {
|
|
65
|
+
const buf = Buffer.alloc(128);
|
|
66
|
+
const bytesRead = readSync(fd, buf, 0, 128, 0);
|
|
67
|
+
const head = buf.toString("utf-8", 0, bytesRead);
|
|
68
|
+
const firstLine = head.split(/\r?\n/)[0]?.trim() ?? "";
|
|
69
|
+
const componentType = (firstLine === '"use client"' ||
|
|
70
|
+
firstLine === "'use client'" ||
|
|
71
|
+
firstLine === '"use client";' ||
|
|
72
|
+
firstLine === "'use client';")
|
|
73
|
+
? "client"
|
|
74
|
+
: "server";
|
|
75
|
+
// Reuse the signature from above — no second statSync needed.
|
|
76
|
+
componentTypeCache.set(filePath, { signature, componentType });
|
|
77
|
+
return componentType;
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
componentTypeCache.delete(filePath);
|
|
81
|
+
return "server";
|
|
82
|
+
}
|
|
83
|
+
finally {
|
|
84
|
+
closeSync(fd);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
3
87
|
/**
|
|
4
88
|
* Determine the route type from a filename.
|
|
5
89
|
* Returns null if the file is not a recognized route file.
|
|
@@ -9,6 +93,12 @@ function classifyFile(filename) {
|
|
|
9
93
|
return "layout";
|
|
10
94
|
if (filename === "_middleware.ts")
|
|
11
95
|
return "middleware";
|
|
96
|
+
if (filename === "_loading.tsx")
|
|
97
|
+
return "loading";
|
|
98
|
+
if (filename === "_error.tsx")
|
|
99
|
+
return "error";
|
|
100
|
+
if (isNotFoundFile(filename))
|
|
101
|
+
return "not-found";
|
|
12
102
|
if (filename.endsWith(".page.tsx"))
|
|
13
103
|
return "page";
|
|
14
104
|
if (filename.endsWith(".api.ts"))
|
|
@@ -54,101 +144,218 @@ function fileToSegment(filename) {
|
|
|
54
144
|
return { segment: base, params: [], isCatchAll: false };
|
|
55
145
|
}
|
|
56
146
|
/**
|
|
57
|
-
*
|
|
58
|
-
*
|
|
147
|
+
* Recursively walk a directory, returning all files as paths relative to the root.
|
|
148
|
+
*
|
|
149
|
+
* Uses async readdir with Dirent to classify entries without extra syscalls,
|
|
150
|
+
* then statSync per route file for signature. statSync is deliberately
|
|
151
|
+
* synchronous: on hot filesystem cache (common during dev), sync stat
|
|
152
|
+
* avoids Promise/microtask overhead that exceeds the sub-microsecond I/O.
|
|
59
153
|
*/
|
|
60
|
-
function
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
const catchAllMatch = part.match(/^\[\.\.\.(\w+)\]$/);
|
|
69
|
-
if (catchAllMatch) {
|
|
70
|
-
segments.push("*");
|
|
71
|
-
params.push(catchAllMatch[1]);
|
|
154
|
+
async function walkDir(dir) {
|
|
155
|
+
const files = [];
|
|
156
|
+
const stack = [
|
|
157
|
+
{ absoluteDir: dir, relativeDir: "." },
|
|
158
|
+
];
|
|
159
|
+
while (stack.length > 0) {
|
|
160
|
+
const current = stack.pop();
|
|
161
|
+
if (!current) {
|
|
72
162
|
continue;
|
|
73
163
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
164
|
+
let entries;
|
|
165
|
+
try {
|
|
166
|
+
entries = await readdir(current.absoluteDir, { withFileTypes: true });
|
|
167
|
+
}
|
|
168
|
+
catch {
|
|
78
169
|
continue;
|
|
79
170
|
}
|
|
80
|
-
|
|
171
|
+
for (const entry of entries) {
|
|
172
|
+
const absolutePath = path.join(current.absoluteDir, entry.name);
|
|
173
|
+
const relativePath = current.relativeDir === "."
|
|
174
|
+
? entry.name
|
|
175
|
+
: path.join(current.relativeDir, entry.name);
|
|
176
|
+
if (entry.isDirectory()) {
|
|
177
|
+
stack.push({
|
|
178
|
+
absoluteDir: absolutePath,
|
|
179
|
+
relativeDir: relativePath,
|
|
180
|
+
});
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
if (entry.isFile()) {
|
|
184
|
+
const routeType = classifyFile(entry.name);
|
|
185
|
+
if (!routeType) {
|
|
186
|
+
continue;
|
|
187
|
+
}
|
|
188
|
+
try {
|
|
189
|
+
const stats = statSync(absolutePath);
|
|
190
|
+
files.push({
|
|
191
|
+
relativePath,
|
|
192
|
+
absolutePath,
|
|
193
|
+
filename: entry.name,
|
|
194
|
+
routeType,
|
|
195
|
+
signature: `${stats.mtimeMs}:${stats.size}`,
|
|
196
|
+
});
|
|
197
|
+
}
|
|
198
|
+
catch {
|
|
199
|
+
continue;
|
|
200
|
+
}
|
|
201
|
+
}
|
|
202
|
+
}
|
|
81
203
|
}
|
|
82
|
-
|
|
204
|
+
files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
|
|
205
|
+
return files;
|
|
83
206
|
}
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
* ordered from outermost to innermost.
|
|
87
|
-
*/
|
|
88
|
-
function collectLayouts(routesDir, relativeDir) {
|
|
89
|
-
const layouts = [];
|
|
90
|
-
const parts = relativeDir === "" || relativeDir === "." ? [] : relativeDir.split(path.sep);
|
|
91
|
-
// Check the root directory first
|
|
92
|
-
layouts.push(path.join(routesDir, "_layout.tsx"));
|
|
93
|
-
// Then each nested directory
|
|
94
|
-
let current = routesDir;
|
|
95
|
-
for (const part of parts) {
|
|
96
|
-
current = path.join(current, part);
|
|
97
|
-
layouts.push(path.join(current, "_layout.tsx"));
|
|
98
|
-
}
|
|
99
|
-
return layouts;
|
|
207
|
+
function normalizeRelativeDir(relativeDir) {
|
|
208
|
+
return relativeDir === "" ? "." : relativeDir;
|
|
100
209
|
}
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
*/
|
|
105
|
-
function collectMiddlewares(routesDir, relativeDir) {
|
|
106
|
-
const middlewares = [];
|
|
107
|
-
const parts = relativeDir === "" || relativeDir === "." ? [] : relativeDir.split(path.sep);
|
|
108
|
-
middlewares.push(path.join(routesDir, "_middleware.ts"));
|
|
109
|
-
let current = routesDir;
|
|
110
|
-
for (const part of parts) {
|
|
111
|
-
current = path.join(current, part);
|
|
112
|
-
middlewares.push(path.join(current, "_middleware.ts"));
|
|
210
|
+
function getDirectoryDepth(relativeDir) {
|
|
211
|
+
if (relativeDir === "." || relativeDir === "") {
|
|
212
|
+
return 0;
|
|
113
213
|
}
|
|
114
|
-
return
|
|
214
|
+
return relativeDir.split(path.sep).filter(Boolean).length;
|
|
115
215
|
}
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
async function walkDir(dir, root) {
|
|
120
|
-
const files = [];
|
|
121
|
-
let entries;
|
|
122
|
-
try {
|
|
123
|
-
entries = await readdir(dir, { withFileTypes: true });
|
|
216
|
+
function describeDirectorySegment(segment) {
|
|
217
|
+
if (isRouteGroupSegment(segment)) {
|
|
218
|
+
return { segments: [], params: [] };
|
|
124
219
|
}
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
return
|
|
220
|
+
const catchAllMatch = segment.match(/^\[\.\.\.(\w+)\]$/);
|
|
221
|
+
if (catchAllMatch) {
|
|
222
|
+
return { segments: ["*"], params: [catchAllMatch[1]] };
|
|
223
|
+
}
|
|
224
|
+
const dynamicMatch = segment.match(/^\[(\w+)\]$/);
|
|
225
|
+
if (dynamicMatch) {
|
|
226
|
+
return { segments: [`:${dynamicMatch[1]}`], params: [dynamicMatch[1]] };
|
|
128
227
|
}
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
|
|
228
|
+
return { segments: [segment], params: [] };
|
|
229
|
+
}
|
|
230
|
+
function resolveDirectoryFile(rootDir, relativeDir, filename, existingAbsolute) {
|
|
231
|
+
const absolutePath = path.join(rootDir, relativeDir === "." ? "" : relativeDir, filename);
|
|
232
|
+
return existingAbsolute.has(absolutePath) ? absolutePath : undefined;
|
|
233
|
+
}
|
|
234
|
+
function buildDirectoryContexts(rootDir, routeFiles, existingAbsolute) {
|
|
235
|
+
const uniqueDirs = new Set(["."]);
|
|
236
|
+
const signatureByPath = new Map(routeFiles.map((file) => [file.relativePath, file.signature]));
|
|
237
|
+
for (const { relativePath } of routeFiles) {
|
|
238
|
+
let currentDir = normalizeRelativeDir(path.dirname(relativePath));
|
|
239
|
+
for (;;) {
|
|
240
|
+
uniqueDirs.add(currentDir);
|
|
241
|
+
if (currentDir === ".") {
|
|
242
|
+
break;
|
|
243
|
+
}
|
|
244
|
+
currentDir = normalizeRelativeDir(path.dirname(currentDir));
|
|
134
245
|
}
|
|
135
|
-
|
|
136
|
-
|
|
246
|
+
}
|
|
247
|
+
const orderedDirs = [...uniqueDirs].sort((left, right) => {
|
|
248
|
+
const depthCmp = getDirectoryDepth(left) - getDirectoryDepth(right);
|
|
249
|
+
if (depthCmp !== 0) {
|
|
250
|
+
return depthCmp;
|
|
137
251
|
}
|
|
252
|
+
return left.localeCompare(right);
|
|
253
|
+
});
|
|
254
|
+
const contexts = new Map();
|
|
255
|
+
for (const relativeDir of orderedDirs) {
|
|
256
|
+
const parentDir = relativeDir === "."
|
|
257
|
+
? null
|
|
258
|
+
: normalizeRelativeDir(path.dirname(relativeDir));
|
|
259
|
+
const parent = parentDir ? contexts.get(parentDir) : undefined;
|
|
260
|
+
const dirInfo = (() => {
|
|
261
|
+
if (!parent || relativeDir === ".") {
|
|
262
|
+
return { segments: [], params: [] };
|
|
263
|
+
}
|
|
264
|
+
const segmentInfo = describeDirectorySegment(path.basename(relativeDir));
|
|
265
|
+
return {
|
|
266
|
+
segments: [...parent.dirInfo.segments, ...segmentInfo.segments],
|
|
267
|
+
params: [...parent.dirInfo.params, ...segmentInfo.params],
|
|
268
|
+
};
|
|
269
|
+
})();
|
|
270
|
+
const layoutPath = resolveDirectoryFile(rootDir, relativeDir, "_layout.tsx", existingAbsolute);
|
|
271
|
+
const middlewarePath = resolveDirectoryFile(rootDir, relativeDir, "_middleware.ts", existingAbsolute);
|
|
272
|
+
const loadingPath = resolveDirectoryFile(rootDir, relativeDir, "_loading.tsx", existingAbsolute);
|
|
273
|
+
const errorPath = resolveDirectoryFile(rootDir, relativeDir, "_error.tsx", existingAbsolute);
|
|
274
|
+
const notFoundPath = resolveDirectoryFile(rootDir, relativeDir, "not-found.tsx", existingAbsolute)
|
|
275
|
+
?? resolveDirectoryFile(rootDir, relativeDir, "not-found.page.tsx", existingAbsolute);
|
|
276
|
+
const contextSignatureParts = [
|
|
277
|
+
parent?.contextSignature ?? "root",
|
|
278
|
+
relativeDir,
|
|
279
|
+
dirInfo.segments.join(","),
|
|
280
|
+
dirInfo.params.join(","),
|
|
281
|
+
layoutPath ? `${normalizeRelativeDir(path.relative(rootDir, layoutPath))}@${signatureByPath.get(normalizeRelativeDir(path.relative(rootDir, layoutPath))) ?? "missing"}` : "layout:none",
|
|
282
|
+
middlewarePath ? `${normalizeRelativeDir(path.relative(rootDir, middlewarePath))}@${signatureByPath.get(normalizeRelativeDir(path.relative(rootDir, middlewarePath))) ?? "missing"}` : "middleware:none",
|
|
283
|
+
loadingPath ? `${normalizeRelativeDir(path.relative(rootDir, loadingPath))}@${signatureByPath.get(normalizeRelativeDir(path.relative(rootDir, loadingPath))) ?? "missing"}` : "loading:none",
|
|
284
|
+
errorPath ? `${normalizeRelativeDir(path.relative(rootDir, errorPath))}@${signatureByPath.get(normalizeRelativeDir(path.relative(rootDir, errorPath))) ?? "missing"}` : "error:none",
|
|
285
|
+
notFoundPath ? `${normalizeRelativeDir(path.relative(rootDir, notFoundPath))}@${signatureByPath.get(normalizeRelativeDir(path.relative(rootDir, notFoundPath))) ?? "missing"}` : "not-found:none",
|
|
286
|
+
];
|
|
287
|
+
contexts.set(relativeDir, {
|
|
288
|
+
dirInfo,
|
|
289
|
+
contextSignature: contextSignatureParts.join("|"),
|
|
290
|
+
layouts: layoutPath ? [...(parent?.layouts ?? []), layoutPath] : [...(parent?.layouts ?? [])],
|
|
291
|
+
middlewares: middlewarePath
|
|
292
|
+
? [...(parent?.middlewares ?? []), middlewarePath]
|
|
293
|
+
: [...(parent?.middlewares ?? [])],
|
|
294
|
+
...(loadingPath
|
|
295
|
+
? { nearestLoading: loadingPath }
|
|
296
|
+
: parent?.nearestLoading
|
|
297
|
+
? { nearestLoading: parent.nearestLoading }
|
|
298
|
+
: {}),
|
|
299
|
+
...(errorPath
|
|
300
|
+
? { nearestError: errorPath }
|
|
301
|
+
: parent?.nearestError
|
|
302
|
+
? { nearestError: parent.nearestError }
|
|
303
|
+
: {}),
|
|
304
|
+
...(notFoundPath
|
|
305
|
+
? { nearestNotFound: notFoundPath }
|
|
306
|
+
: parent?.nearestNotFound
|
|
307
|
+
? { nearestNotFound: parent.nearestNotFound }
|
|
308
|
+
: {}),
|
|
309
|
+
});
|
|
138
310
|
}
|
|
139
|
-
return
|
|
311
|
+
return contexts;
|
|
140
312
|
}
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
313
|
+
function buildCachedManifest(manifest, routeFiles, scannedFiles, validationSignature, validationRouteOrder, validationDiagnostics) {
|
|
314
|
+
return {
|
|
315
|
+
fileSignatures: new Map(routeFiles.map((file) => [file.relativePath, file.signature])),
|
|
316
|
+
scannedFiles,
|
|
317
|
+
validationDiagnostics,
|
|
318
|
+
validationRouteOrder,
|
|
319
|
+
validationSignature,
|
|
320
|
+
manifest,
|
|
321
|
+
};
|
|
322
|
+
}
|
|
323
|
+
function snapshotsMatchCacheState(routeFiles, cachedState) {
|
|
324
|
+
if (routeFiles.length !== cachedState.fileSignatures.size) {
|
|
325
|
+
return false;
|
|
326
|
+
}
|
|
327
|
+
for (const file of routeFiles) {
|
|
328
|
+
if (cachedState.fileSignatures.get(file.relativePath) !== file.signature) {
|
|
329
|
+
return false;
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
return true;
|
|
333
|
+
}
|
|
334
|
+
function buildStaticDiagnostics(filePath, routeType, urlPattern, params) {
|
|
335
|
+
return analyzeRouteFileStaticInfo(filePath, routeType, urlPattern, params.length > 0);
|
|
336
|
+
}
|
|
337
|
+
function createRouteValidationKey(route) {
|
|
338
|
+
return `${route.type}:${route.filePath}`;
|
|
339
|
+
}
|
|
340
|
+
function createRouteValidationSignature(route) {
|
|
341
|
+
return [
|
|
342
|
+
route.type,
|
|
343
|
+
route.filePath,
|
|
344
|
+
route.urlPattern,
|
|
345
|
+
route.params.join(","),
|
|
346
|
+
route.isCatchAll ? "1" : "0",
|
|
347
|
+
route.methods?.join(",") ?? "",
|
|
348
|
+
route.layouts.join(","),
|
|
349
|
+
route.middlewares.join(","),
|
|
350
|
+
route.loading ?? "",
|
|
351
|
+
route.error ?? "",
|
|
352
|
+
route.notFound ?? "",
|
|
353
|
+
].join("|");
|
|
147
354
|
}
|
|
148
355
|
/**
|
|
149
356
|
* Scan a routes directory and produce a RouteManifest describing every route file found.
|
|
150
357
|
*/
|
|
151
|
-
export async function scanRoutes(routesDir) {
|
|
358
|
+
export async function scanRoutes(routesDir, options = {}) {
|
|
152
359
|
const resolvedRoot = path.resolve(routesDir);
|
|
153
360
|
// Verify the directory exists
|
|
154
361
|
try {
|
|
@@ -156,6 +363,7 @@ export async function scanRoutes(routesDir) {
|
|
|
156
363
|
if (!s.isDirectory()) {
|
|
157
364
|
return {
|
|
158
365
|
routes: [],
|
|
366
|
+
diagnostics: [],
|
|
159
367
|
scannedAt: new Date().toISOString(),
|
|
160
368
|
rootDir: resolvedRoot,
|
|
161
369
|
};
|
|
@@ -164,32 +372,68 @@ export async function scanRoutes(routesDir) {
|
|
|
164
372
|
catch {
|
|
165
373
|
return {
|
|
166
374
|
routes: [],
|
|
375
|
+
diagnostics: [],
|
|
167
376
|
scannedAt: new Date().toISOString(),
|
|
168
377
|
rootDir: resolvedRoot,
|
|
169
378
|
};
|
|
170
379
|
}
|
|
171
|
-
const
|
|
380
|
+
const cachedState = options.cache?.get(resolvedRoot);
|
|
381
|
+
// Quick pre-check: if a single changedFile is known and its signature
|
|
382
|
+
// matches the cache, the reported change was a no-op (e.g. editor
|
|
383
|
+
// touch without content change). Return cached manifest immediately
|
|
384
|
+
// without walking the directory tree.
|
|
385
|
+
if (options.changedFile && cachedState) {
|
|
386
|
+
const changedRelative = path.relative(resolvedRoot, path.resolve(options.changedFile));
|
|
387
|
+
const previousSignature = cachedState.fileSignatures.get(changedRelative);
|
|
388
|
+
if (previousSignature !== undefined) {
|
|
389
|
+
try {
|
|
390
|
+
const stats = statSync(path.resolve(options.changedFile));
|
|
391
|
+
if (`${stats.mtimeMs}:${stats.size}` === previousSignature) {
|
|
392
|
+
return cachedState.manifest;
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
catch {
|
|
396
|
+
// File deleted — fall through to full scan.
|
|
397
|
+
}
|
|
398
|
+
}
|
|
399
|
+
}
|
|
400
|
+
const routeFiles = await walkDir(resolvedRoot);
|
|
401
|
+
if (cachedState && snapshotsMatchCacheState(routeFiles, cachedState)) {
|
|
402
|
+
return cachedState.manifest;
|
|
403
|
+
}
|
|
172
404
|
// Build a set of all absolute paths for existence checks
|
|
173
|
-
const absolutePathSet = new Set(
|
|
405
|
+
const absolutePathSet = new Set(routeFiles.map((file) => file.absolutePath));
|
|
406
|
+
const directoryContexts = buildDirectoryContexts(resolvedRoot, routeFiles, absolutePathSet);
|
|
174
407
|
const routes = [];
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
|
|
408
|
+
const staticDiagnostics = [];
|
|
409
|
+
const scannedFiles = new Map();
|
|
410
|
+
for (const routeFile of routeFiles) {
|
|
411
|
+
const relativeDir = path.dirname(routeFile.relativePath);
|
|
412
|
+
const absoluteFilePath = routeFile.absolutePath;
|
|
413
|
+
const directoryContext = directoryContexts.get(normalizeRelativeDir(relativeDir));
|
|
414
|
+
if (!directoryContext) {
|
|
415
|
+
throw new Error(`Missing scanner directory context for ${relativeDir}`);
|
|
416
|
+
}
|
|
417
|
+
const cached = cachedState?.scannedFiles.get(routeFile.relativePath);
|
|
418
|
+
if (cached && cached.signature === routeFile.signature && cached.contextSignature === directoryContext.contextSignature) {
|
|
419
|
+
routes.push(cached.entry);
|
|
420
|
+
staticDiagnostics.push(...cached.diagnostics);
|
|
421
|
+
scannedFiles.set(routeFile.relativePath, cached);
|
|
180
422
|
continue;
|
|
181
423
|
}
|
|
182
|
-
const relativeDir = path.dirname(relPath);
|
|
183
|
-
const absoluteFilePath = path.join(resolvedRoot, relPath);
|
|
184
424
|
// Build URL pattern
|
|
185
|
-
const dirInfo =
|
|
186
|
-
|
|
425
|
+
const { dirInfo } = directoryContext;
|
|
426
|
+
const routeType = routeFile.routeType;
|
|
427
|
+
if (routeType === "layout" ||
|
|
428
|
+
routeType === "middleware" ||
|
|
429
|
+
routeType === "loading" ||
|
|
430
|
+
routeType === "error") {
|
|
187
431
|
// Layouts and middlewares don't get their own URL pattern —
|
|
188
432
|
// they are referenced by other routes. But we still include them
|
|
189
433
|
// in the manifest so they can be discovered.
|
|
190
434
|
const urlParts = dirInfo.segments;
|
|
191
435
|
const urlPattern = "/" + urlParts.join("/");
|
|
192
|
-
|
|
436
|
+
const entry = {
|
|
193
437
|
filePath: absoluteFilePath,
|
|
194
438
|
type: routeType,
|
|
195
439
|
urlPattern: urlPattern === "/" ? "/" : urlPattern.replace(/\/$/, ""),
|
|
@@ -197,28 +441,66 @@ export async function scanRoutes(routesDir) {
|
|
|
197
441
|
middlewares: [],
|
|
198
442
|
params: dirInfo.params,
|
|
199
443
|
isCatchAll: false,
|
|
444
|
+
};
|
|
445
|
+
const staticInfo = buildStaticDiagnostics(absoluteFilePath, routeType, entry.urlPattern, entry.params);
|
|
446
|
+
if (staticInfo.staticInfo) {
|
|
447
|
+
entry.staticInfo = staticInfo.staticInfo;
|
|
448
|
+
}
|
|
449
|
+
staticDiagnostics.push(...staticInfo.diagnostics);
|
|
450
|
+
routes.push(entry);
|
|
451
|
+
scannedFiles.set(routeFile.relativePath, {
|
|
452
|
+
signature: routeFile.signature,
|
|
453
|
+
contextSignature: directoryContext.contextSignature,
|
|
454
|
+
entry,
|
|
455
|
+
diagnostics: staticInfo.diagnostics,
|
|
456
|
+
});
|
|
457
|
+
continue;
|
|
458
|
+
}
|
|
459
|
+
if (routeType === "not-found") {
|
|
460
|
+
const urlPattern = "/" + dirInfo.segments.join("/");
|
|
461
|
+
const entry = {
|
|
462
|
+
filePath: absoluteFilePath,
|
|
463
|
+
type: routeType,
|
|
464
|
+
urlPattern: urlPattern === "/" ? "/" : urlPattern.replace(/\/$/, ""),
|
|
465
|
+
layouts: directoryContext.layouts,
|
|
466
|
+
middlewares: directoryContext.middlewares,
|
|
467
|
+
params: dirInfo.params,
|
|
468
|
+
isCatchAll: false,
|
|
469
|
+
componentType: detectComponentType(absoluteFilePath, routeFile.signature),
|
|
470
|
+
};
|
|
471
|
+
const staticInfo = buildStaticDiagnostics(absoluteFilePath, routeType, entry.urlPattern, entry.params);
|
|
472
|
+
if (staticInfo.staticInfo)
|
|
473
|
+
entry.staticInfo = staticInfo.staticInfo;
|
|
474
|
+
staticDiagnostics.push(...staticInfo.diagnostics);
|
|
475
|
+
if (directoryContext.nearestLoading)
|
|
476
|
+
entry.loading = directoryContext.nearestLoading;
|
|
477
|
+
if (directoryContext.nearestError)
|
|
478
|
+
entry.error = directoryContext.nearestError;
|
|
479
|
+
if (directoryContext.nearestNotFound)
|
|
480
|
+
entry.notFound = directoryContext.nearestNotFound;
|
|
481
|
+
routes.push(entry);
|
|
482
|
+
scannedFiles.set(routeFile.relativePath, {
|
|
483
|
+
signature: routeFile.signature,
|
|
484
|
+
contextSignature: directoryContext.contextSignature,
|
|
485
|
+
entry,
|
|
486
|
+
diagnostics: staticInfo.diagnostics,
|
|
200
487
|
});
|
|
201
488
|
continue;
|
|
202
489
|
}
|
|
203
490
|
// Page or API route
|
|
204
|
-
const fileInfo = fileToSegment(filename);
|
|
491
|
+
const fileInfo = fileToSegment(routeFile.filename);
|
|
205
492
|
const allParams = [...dirInfo.params, ...fileInfo.params];
|
|
206
493
|
const urlParts = [...dirInfo.segments];
|
|
207
494
|
if (fileInfo.segment !== "") {
|
|
208
495
|
urlParts.push(fileInfo.segment);
|
|
209
496
|
}
|
|
210
497
|
const urlPattern = "/" + urlParts.join("/");
|
|
211
|
-
// Collect parent layouts and middlewares (only those that actually exist on disk)
|
|
212
|
-
const layoutCandidates = collectLayouts(resolvedRoot, relativeDir);
|
|
213
|
-
const middlewareCandidates = collectMiddlewares(resolvedRoot, relativeDir);
|
|
214
|
-
const layouts = filterExisting(layoutCandidates, absolutePathSet);
|
|
215
|
-
const middlewares = filterExisting(middlewareCandidates, absolutePathSet);
|
|
216
498
|
const entry = {
|
|
217
499
|
filePath: absoluteFilePath,
|
|
218
500
|
type: routeType,
|
|
219
501
|
urlPattern: urlPattern === "/" ? "/" : urlPattern.replace(/\/$/, ""),
|
|
220
|
-
layouts,
|
|
221
|
-
middlewares,
|
|
502
|
+
layouts: directoryContext.layouts,
|
|
503
|
+
middlewares: directoryContext.middlewares,
|
|
222
504
|
params: allParams,
|
|
223
505
|
isCatchAll: fileInfo.isCatchAll,
|
|
224
506
|
};
|
|
@@ -227,31 +509,55 @@ export async function scanRoutes(routesDir) {
|
|
|
227
509
|
// The actual exported methods are determined at runtime.
|
|
228
510
|
entry.methods = ["GET", "POST", "PUT", "DELETE", "PATCH"];
|
|
229
511
|
}
|
|
512
|
+
if (routeType === "page") {
|
|
513
|
+
entry.componentType = detectComponentType(absoluteFilePath, routeFile.signature);
|
|
514
|
+
if (directoryContext.nearestLoading)
|
|
515
|
+
entry.loading = directoryContext.nearestLoading;
|
|
516
|
+
if (directoryContext.nearestError)
|
|
517
|
+
entry.error = directoryContext.nearestError;
|
|
518
|
+
if (directoryContext.nearestNotFound)
|
|
519
|
+
entry.notFound = directoryContext.nearestNotFound;
|
|
520
|
+
}
|
|
521
|
+
const staticInfo = buildStaticDiagnostics(absoluteFilePath, routeType, entry.urlPattern, entry.params);
|
|
522
|
+
if (staticInfo.staticInfo) {
|
|
523
|
+
entry.staticInfo = staticInfo.staticInfo;
|
|
524
|
+
}
|
|
525
|
+
staticDiagnostics.push(...staticInfo.diagnostics);
|
|
230
526
|
routes.push(entry);
|
|
527
|
+
scannedFiles.set(routeFile.relativePath, {
|
|
528
|
+
signature: routeFile.signature,
|
|
529
|
+
contextSignature: directoryContext.contextSignature,
|
|
530
|
+
entry,
|
|
531
|
+
diagnostics: staticInfo.diagnostics,
|
|
532
|
+
});
|
|
231
533
|
}
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
const
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
534
|
+
const validationSignature = routes
|
|
535
|
+
.map((route) => createRouteValidationSignature(route))
|
|
536
|
+
.join("\n");
|
|
537
|
+
const routeByValidationKey = new Map(routes.map((route) => [createRouteValidationKey(route), route]));
|
|
538
|
+
const reusedValidatedRoutes = cachedState && cachedState.validationSignature === validationSignature
|
|
539
|
+
? cachedState.validationRouteOrder
|
|
540
|
+
.map((key) => routeByValidationKey.get(key))
|
|
541
|
+
.filter((route) => route !== undefined)
|
|
542
|
+
: undefined;
|
|
543
|
+
const validated = reusedValidatedRoutes && reusedValidatedRoutes.length === routes.length
|
|
544
|
+
? {
|
|
545
|
+
routes: reusedValidatedRoutes,
|
|
546
|
+
diagnostics: cachedState.validationDiagnostics,
|
|
547
|
+
}
|
|
548
|
+
: canonicalizeRouteManifest(routes, resolvedRoot);
|
|
549
|
+
const diagnostics = [...validated.diagnostics, ...staticDiagnostics];
|
|
550
|
+
const errorDiagnostics = diagnostics.filter((diagnostic) => diagnostic.severity === "error");
|
|
551
|
+
if (errorDiagnostics.length > 0) {
|
|
552
|
+
throw createValidationRouteConflictError(errorDiagnostics);
|
|
553
|
+
}
|
|
554
|
+
const manifest = {
|
|
555
|
+
routes: validated.routes,
|
|
556
|
+
diagnostics,
|
|
253
557
|
scannedAt: new Date().toISOString(),
|
|
254
558
|
rootDir: resolvedRoot,
|
|
255
559
|
};
|
|
560
|
+
options.cache?.set(resolvedRoot, buildCachedManifest(manifest, routeFiles, scannedFiles, validationSignature, validated.routes.map((route) => createRouteValidationKey(route)), validated.diagnostics));
|
|
561
|
+
return manifest;
|
|
256
562
|
}
|
|
257
563
|
//# sourceMappingURL=scanner.js.map
|