@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/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
- * Convert a directory path relative to the routes root into URL segments.
58
- * Each directory named as a dynamic segment (e.g. `[orgId]`) is converted.
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 dirToSegments(relativeDir) {
61
- if (relativeDir === "" || relativeDir === ".") {
62
- return { segments: [], params: [] };
63
- }
64
- const parts = relativeDir.split(path.sep);
65
- const segments = [];
66
- const params = [];
67
- for (const part of parts) {
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
- const dynamicMatch = part.match(/^\[(\w+)\]$/);
75
- if (dynamicMatch) {
76
- segments.push(`:${dynamicMatch[1]}`);
77
- params.push(dynamicMatch[1]);
164
+ let entries;
165
+ try {
166
+ entries = await readdir(current.absoluteDir, { withFileTypes: true });
167
+ }
168
+ catch {
78
169
  continue;
79
170
  }
80
- segments.push(part);
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
- return { segments, params };
204
+ files.sort((left, right) => left.relativePath.localeCompare(right.relativePath));
205
+ return files;
83
206
  }
84
- /**
85
- * Collect all _layout.tsx files from the routes root down to the given directory,
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
- * Collect all _middleware.ts files from the routes root down to the given directory,
103
- * ordered from outermost to innermost.
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 middlewares;
214
+ return relativeDir.split(path.sep).filter(Boolean).length;
115
215
  }
116
- /**
117
- * Recursively walk a directory, returning all files as paths relative to the root.
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
- catch {
126
- // Directory doesn't exist or can't be read
127
- return files;
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
- for (const entry of entries) {
130
- const fullPath = path.join(dir, entry.name);
131
- if (entry.isDirectory()) {
132
- const nested = await walkDir(fullPath, root);
133
- files.push(...nested);
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
- else if (entry.isFile()) {
136
- files.push(path.relative(root, fullPath));
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 files;
311
+ return contexts;
140
312
  }
141
- /**
142
- * Filter layout/middleware candidate paths to only those that actually exist as
143
- * files discovered during the scan.
144
- */
145
- function filterExisting(candidates, existingAbsolute) {
146
- return candidates.filter((p) => existingAbsolute.has(p));
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 relativePaths = await walkDir(resolvedRoot, resolvedRoot);
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(relativePaths.map((rp) => path.join(resolvedRoot, rp)));
405
+ const absolutePathSet = new Set(routeFiles.map((file) => file.absolutePath));
406
+ const directoryContexts = buildDirectoryContexts(resolvedRoot, routeFiles, absolutePathSet);
174
407
  const routes = [];
175
- for (const relPath of relativePaths) {
176
- const filename = path.basename(relPath);
177
- const routeType = classifyFile(filename);
178
- if (routeType === null) {
179
- // Not a recognized route file — skip
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 = dirToSegments(relativeDir);
186
- if (routeType === "layout" || routeType === "middleware") {
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
- routes.push({
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
- // Sort for deterministic output:
233
- // 1. By URL pattern alphabetically
234
- // 2. By type (layout < middleware < page < api)
235
- // 3. By file path as tiebreaker
236
- const typeOrder = {
237
- layout: 0,
238
- middleware: 1,
239
- page: 2,
240
- api: 3,
241
- };
242
- routes.sort((a, b) => {
243
- const patternCmp = a.urlPattern.localeCompare(b.urlPattern);
244
- if (patternCmp !== 0)
245
- return patternCmp;
246
- const typeCmp = typeOrder[a.type] - typeOrder[b.type];
247
- if (typeCmp !== 0)
248
- return typeCmp;
249
- return a.filePath.localeCompare(b.filePath);
250
- });
251
- return {
252
- routes,
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