@djangocfg/seo 2.1.50

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 (68) hide show
  1. package/README.md +192 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.mjs +3780 -0
  4. package/dist/cli.mjs.map +1 -0
  5. package/dist/crawler/index.d.ts +88 -0
  6. package/dist/crawler/index.mjs +610 -0
  7. package/dist/crawler/index.mjs.map +1 -0
  8. package/dist/google-console/index.d.ts +95 -0
  9. package/dist/google-console/index.mjs +539 -0
  10. package/dist/google-console/index.mjs.map +1 -0
  11. package/dist/index.d.ts +285 -0
  12. package/dist/index.mjs +3236 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/link-checker/index.d.ts +76 -0
  15. package/dist/link-checker/index.mjs +326 -0
  16. package/dist/link-checker/index.mjs.map +1 -0
  17. package/dist/markdown-report-B3QdDzxE.d.ts +193 -0
  18. package/dist/reports/index.d.ts +24 -0
  19. package/dist/reports/index.mjs +836 -0
  20. package/dist/reports/index.mjs.map +1 -0
  21. package/dist/routes/index.d.ts +69 -0
  22. package/dist/routes/index.mjs +372 -0
  23. package/dist/routes/index.mjs.map +1 -0
  24. package/dist/scanner-Cz4Th2Pt.d.ts +60 -0
  25. package/dist/types/index.d.ts +144 -0
  26. package/dist/types/index.mjs +3 -0
  27. package/dist/types/index.mjs.map +1 -0
  28. package/package.json +114 -0
  29. package/src/analyzer.ts +256 -0
  30. package/src/cli/commands/audit.ts +260 -0
  31. package/src/cli/commands/content.ts +180 -0
  32. package/src/cli/commands/crawl.ts +32 -0
  33. package/src/cli/commands/index.ts +12 -0
  34. package/src/cli/commands/inspect.ts +60 -0
  35. package/src/cli/commands/links.ts +41 -0
  36. package/src/cli/commands/robots.ts +36 -0
  37. package/src/cli/commands/routes.ts +126 -0
  38. package/src/cli/commands/sitemap.ts +48 -0
  39. package/src/cli/index.ts +149 -0
  40. package/src/cli/types.ts +40 -0
  41. package/src/config.ts +207 -0
  42. package/src/content/index.ts +51 -0
  43. package/src/content/link-checker.ts +182 -0
  44. package/src/content/link-fixer.ts +188 -0
  45. package/src/content/scanner.ts +200 -0
  46. package/src/content/sitemap-generator.ts +321 -0
  47. package/src/content/types.ts +140 -0
  48. package/src/crawler/crawler.ts +425 -0
  49. package/src/crawler/index.ts +10 -0
  50. package/src/crawler/robots-parser.ts +171 -0
  51. package/src/crawler/sitemap-validator.ts +204 -0
  52. package/src/google-console/analyzer.ts +317 -0
  53. package/src/google-console/auth.ts +100 -0
  54. package/src/google-console/client.ts +281 -0
  55. package/src/google-console/index.ts +9 -0
  56. package/src/index.ts +144 -0
  57. package/src/link-checker/index.ts +461 -0
  58. package/src/reports/claude-context.ts +149 -0
  59. package/src/reports/generator.ts +244 -0
  60. package/src/reports/index.ts +27 -0
  61. package/src/reports/json-report.ts +320 -0
  62. package/src/reports/markdown-report.ts +246 -0
  63. package/src/reports/split-report.ts +252 -0
  64. package/src/routes/analyzer.ts +324 -0
  65. package/src/routes/index.ts +25 -0
  66. package/src/routes/scanner.ts +298 -0
  67. package/src/types/index.ts +222 -0
  68. package/src/utils/index.ts +154 -0
@@ -0,0 +1,324 @@
1
+ /**
2
+ * @djangocfg/seo - Routes Analyzer
3
+ * Compare routes with sitemap and verify accessibility
4
+ */
5
+
6
+ import pLimit from 'p-limit';
7
+ import type { ScanResult, RouteInfo } from './scanner.js';
8
+ import type { SeoIssue } from '../types/index.js';
9
+
10
+ export interface SitemapUrl {
11
+ loc: string;
12
+ lastmod?: string;
13
+ changefreq?: string;
14
+ priority?: string;
15
+ }
16
+
17
+ export interface RouteComparisonResult {
18
+ /** Routes found in app/ */
19
+ appRoutes: RouteInfo[];
20
+ /** URLs found in sitemap */
21
+ sitemapUrls: string[];
22
+ /** Static routes missing from sitemap */
23
+ missingFromSitemap: RouteInfo[];
24
+ /** Sitemap URLs that don't match any route */
25
+ extraInSitemap: string[];
26
+ /** Matching routes */
27
+ matching: RouteInfo[];
28
+ }
29
+
30
+ export interface RouteVerificationResult {
31
+ /** Route being verified */
32
+ route: RouteInfo;
33
+ /** Full URL */
34
+ url: string;
35
+ /** HTTP status code */
36
+ statusCode: number;
37
+ /** Is accessible (2xx) */
38
+ isAccessible: boolean;
39
+ /** Error message if failed */
40
+ error?: string;
41
+ /** Response time in ms */
42
+ responseTime: number;
43
+ }
44
+
45
+ export interface VerifyOptions {
46
+ /** Base URL (e.g., http://localhost:3000) */
47
+ baseUrl: string;
48
+ /** Request timeout in ms */
49
+ timeout?: number;
50
+ /** Max concurrent requests */
51
+ concurrency?: number;
52
+ /** Only verify static routes */
53
+ staticOnly?: boolean;
54
+ }
55
+
56
+ /**
57
+ * Compare routes with sitemap URLs
58
+ */
59
+ export function compareWithSitemap(
60
+ scanResult: ScanResult,
61
+ sitemapUrls: string[],
62
+ baseUrl: string
63
+ ): RouteComparisonResult {
64
+ const appRoutes = scanResult.routes.filter(r => r.type === 'page');
65
+
66
+ // Normalize sitemap URLs to paths
67
+ const sitemapPaths = new Set(
68
+ sitemapUrls.map(url => {
69
+ try {
70
+ return new URL(url).pathname;
71
+ } catch {
72
+ return url;
73
+ }
74
+ })
75
+ );
76
+
77
+ // Find static routes missing from sitemap
78
+ const missingFromSitemap: RouteInfo[] = [];
79
+ const matching: RouteInfo[] = [];
80
+
81
+ for (const route of scanResult.staticRoutes) {
82
+ const path = route.path === '/' ? '/' : route.path;
83
+ // Check with and without trailing slash
84
+ if (sitemapPaths.has(path) || sitemapPaths.has(path + '/') || sitemapPaths.has(path.replace(/\/$/, ''))) {
85
+ matching.push(route);
86
+ } else {
87
+ missingFromSitemap.push(route);
88
+ }
89
+ }
90
+
91
+ // Find sitemap URLs that don't match any static route
92
+ const staticPaths = new Set(scanResult.staticRoutes.map(r => r.path));
93
+ const dynamicPatterns = scanResult.dynamicRoutes.map(r => routeToRegex(r.path));
94
+
95
+ const extraInSitemap: string[] = [];
96
+ for (const path of sitemapPaths) {
97
+ // Skip if matches static route
98
+ if (staticPaths.has(path) || staticPaths.has(path + '/') || staticPaths.has(path.replace(/\/$/, ''))) {
99
+ continue;
100
+ }
101
+
102
+ // Skip if matches dynamic route pattern
103
+ const matchesDynamic = dynamicPatterns.some(regex => regex.test(path));
104
+ if (matchesDynamic) continue;
105
+
106
+ extraInSitemap.push(path);
107
+ }
108
+
109
+ return {
110
+ appRoutes,
111
+ sitemapUrls,
112
+ missingFromSitemap,
113
+ extraInSitemap,
114
+ matching,
115
+ };
116
+ }
117
+
118
+ /**
119
+ * Convert route path to regex for matching
120
+ */
121
+ function routeToRegex(routePath: string): RegExp {
122
+ let pattern = routePath
123
+ // Escape special regex chars except [ ]
124
+ .replace(/[.+?^${}()|\\]/g, '\\$&')
125
+ // Optional catch-all: [[...slug]] matches zero or more segments
126
+ .replace(/\[\[\.\.\.([^\]]+)\]\]/g, '(?:/.*)?')
127
+ // Catch-all: [...slug] matches one or more segments
128
+ .replace(/\[\.\.\.([^\]]+)\]/g, '/.+')
129
+ // Dynamic: [slug] matches one segment
130
+ .replace(/\[([^\]]+)\]/g, '/[^/]+');
131
+
132
+ // Handle trailing slash optionally
133
+ pattern = `^${pattern}/?$`;
134
+
135
+ return new RegExp(pattern);
136
+ }
137
+
138
+ /**
139
+ * Verify routes are accessible
140
+ */
141
+ export async function verifyRoutes(
142
+ scanResult: ScanResult,
143
+ options: VerifyOptions
144
+ ): Promise<RouteVerificationResult[]> {
145
+ const {
146
+ baseUrl,
147
+ timeout = 10000,
148
+ concurrency = 5,
149
+ staticOnly = true,
150
+ } = options;
151
+
152
+ const routes = staticOnly ? scanResult.staticRoutes : scanResult.routes.filter(r => r.type === 'page');
153
+ const limit = pLimit(concurrency);
154
+
155
+ const results = await Promise.all(
156
+ routes.map(route =>
157
+ limit(async () => {
158
+ const url = new URL(route.path, baseUrl).href;
159
+ const startTime = Date.now();
160
+
161
+ try {
162
+ const controller = new AbortController();
163
+ const timeoutId = setTimeout(() => controller.abort(), timeout);
164
+
165
+ const response = await fetch(url, {
166
+ method: 'HEAD',
167
+ signal: controller.signal,
168
+ redirect: 'follow',
169
+ });
170
+
171
+ clearTimeout(timeoutId);
172
+
173
+ return {
174
+ route,
175
+ url,
176
+ statusCode: response.status,
177
+ isAccessible: response.status >= 200 && response.status < 400,
178
+ responseTime: Date.now() - startTime,
179
+ };
180
+ } catch (error) {
181
+ return {
182
+ route,
183
+ url,
184
+ statusCode: 0,
185
+ isAccessible: false,
186
+ error: (error as Error).message,
187
+ responseTime: Date.now() - startTime,
188
+ };
189
+ }
190
+ })
191
+ )
192
+ );
193
+
194
+ return results;
195
+ }
196
+
197
+ /**
198
+ * Analyze routes and generate SEO issues
199
+ */
200
+ export function analyzeRoutes(
201
+ scanResult: ScanResult,
202
+ comparison?: RouteComparisonResult,
203
+ verification?: RouteVerificationResult[]
204
+ ): SeoIssue[] {
205
+ const issues: SeoIssue[] = [];
206
+ const now = new Date().toISOString();
207
+
208
+ // Issue: Static routes missing from sitemap
209
+ if (comparison && comparison.missingFromSitemap.length > 0) {
210
+ for (const route of comparison.missingFromSitemap) {
211
+ issues.push({
212
+ id: `route-missing-sitemap-${route.path}`,
213
+ category: 'indexing',
214
+ severity: 'warning',
215
+ title: 'Route missing from sitemap',
216
+ description: `Static route ${route.path} is not in sitemap.xml`,
217
+ url: route.path,
218
+ recommendation: 'Add this route to your sitemap for better indexing',
219
+ detectedAt: now,
220
+ });
221
+ }
222
+ }
223
+
224
+ // Issue: Sitemap URLs that don't match routes (potential dead links)
225
+ if (comparison && comparison.extraInSitemap.length > 0) {
226
+ for (const path of comparison.extraInSitemap.slice(0, 20)) {
227
+ issues.push({
228
+ id: `sitemap-orphan-${path}`,
229
+ category: 'indexing',
230
+ severity: 'info',
231
+ title: 'Sitemap URL without matching route',
232
+ description: `URL ${path} in sitemap doesn't match any app/ route`,
233
+ url: path,
234
+ recommendation: 'Verify this URL is still valid or remove from sitemap',
235
+ detectedAt: now,
236
+ });
237
+ }
238
+ }
239
+
240
+ // Issue: Inaccessible routes
241
+ if (verification) {
242
+ for (const result of verification) {
243
+ if (!result.isAccessible) {
244
+ issues.push({
245
+ id: `route-inaccessible-${result.route.path}`,
246
+ category: 'technical',
247
+ severity: result.statusCode === 404 ? 'error' : 'warning',
248
+ title: `Route returns ${result.statusCode || 'error'}`,
249
+ description: result.error || `Route ${result.route.path} returned status ${result.statusCode}`,
250
+ url: result.url,
251
+ recommendation: 'Fix the route or remove it from app/',
252
+ detectedAt: now,
253
+ });
254
+ }
255
+ }
256
+ }
257
+
258
+ // Issue: Too many dynamic routes (SEO concern)
259
+ if (scanResult.dynamicRoutes.length > scanResult.staticRoutes.length * 2) {
260
+ issues.push({
261
+ id: 'too-many-dynamic-routes',
262
+ category: 'content',
263
+ severity: 'info',
264
+ title: 'High ratio of dynamic routes',
265
+ description: `${scanResult.dynamicRoutes.length} dynamic vs ${scanResult.staticRoutes.length} static routes`,
266
+ url: '/',
267
+ recommendation: 'Ensure dynamic routes have proper generateStaticParams for SSG',
268
+ detectedAt: now,
269
+ });
270
+ }
271
+
272
+ return issues;
273
+ }
274
+
275
+ /**
276
+ * Generate routes summary
277
+ */
278
+ export function generateRoutesSummary(
279
+ scanResult: ScanResult,
280
+ comparison?: RouteComparisonResult,
281
+ verification?: RouteVerificationResult[]
282
+ ): string {
283
+ const lines: string[] = [];
284
+
285
+ lines.push('## Routes Summary');
286
+ lines.push('');
287
+ lines.push(`Total routes: ${scanResult.routes.length}`);
288
+ lines.push(`├── Static: ${scanResult.staticRoutes.length}`);
289
+ lines.push(`├── Dynamic: ${scanResult.dynamicRoutes.length}`);
290
+ lines.push(`└── API: ${scanResult.apiRoutes.length}`);
291
+ lines.push('');
292
+
293
+ if (comparison) {
294
+ lines.push('### Sitemap Comparison');
295
+ lines.push('');
296
+ lines.push(`Sitemap URLs: ${comparison.sitemapUrls.length}`);
297
+ lines.push(`├── Matching: ${comparison.matching.length}`);
298
+ lines.push(`├── Missing from sitemap: ${comparison.missingFromSitemap.length}`);
299
+ lines.push(`└── Extra in sitemap: ${comparison.extraInSitemap.length}`);
300
+ lines.push('');
301
+ }
302
+
303
+ if (verification) {
304
+ const accessible = verification.filter(r => r.isAccessible).length;
305
+ const broken = verification.filter(r => !r.isAccessible);
306
+
307
+ lines.push('### Route Verification');
308
+ lines.push('');
309
+ lines.push(`Accessible: ${accessible}/${verification.length}`);
310
+
311
+ if (broken.length > 0) {
312
+ lines.push('');
313
+ lines.push('Broken routes:');
314
+ for (const r of broken.slice(0, 10)) {
315
+ lines.push(` - ${r.route.path} → ${r.statusCode || r.error}`);
316
+ }
317
+ if (broken.length > 10) {
318
+ lines.push(` - ... +${broken.length - 10} more`);
319
+ }
320
+ }
321
+ }
322
+
323
+ return lines.join('\n');
324
+ }
@@ -0,0 +1,25 @@
1
+ /**
2
+ * @djangocfg/seo - Routes Module
3
+ * Next.js App Router route scanning and analysis
4
+ */
5
+
6
+ export {
7
+ scanRoutes,
8
+ findAppDir,
9
+ routeToUrl,
10
+ getStaticUrls,
11
+ type RouteInfo,
12
+ type ScanResult,
13
+ type ScanOptions,
14
+ } from './scanner.js';
15
+
16
+ export {
17
+ compareWithSitemap,
18
+ verifyRoutes,
19
+ analyzeRoutes,
20
+ generateRoutesSummary,
21
+ type RouteComparisonResult,
22
+ type RouteVerificationResult,
23
+ type VerifyOptions,
24
+ type SitemapUrl,
25
+ } from './analyzer.js';
@@ -0,0 +1,298 @@
1
+ /**
2
+ * @djangocfg/seo - Next.js App Router Scanner
3
+ * Scans app/ directory to extract all routes
4
+ */
5
+
6
+ import { readdirSync, statSync, existsSync } from 'node:fs';
7
+ import { join, relative } from 'node:path';
8
+
9
+ export interface RouteInfo {
10
+ /** Route path (e.g., /blog/[slug]) */
11
+ path: string;
12
+ /** File path relative to app/ */
13
+ filePath: string;
14
+ /** Route type */
15
+ type: 'page' | 'api' | 'layout' | 'loading' | 'error';
16
+ /** Is dynamic route */
17
+ isDynamic: boolean;
18
+ /** Dynamic segments (e.g., ['slug', '...path']) */
19
+ dynamicSegments: string[];
20
+ /** Is catch-all route */
21
+ isCatchAll: boolean;
22
+ /** Is optional catch-all route */
23
+ isOptionalCatchAll: boolean;
24
+ /** Route group (if any) */
25
+ routeGroup?: string;
26
+ }
27
+
28
+ export interface ScanResult {
29
+ /** All discovered routes */
30
+ routes: RouteInfo[];
31
+ /** Static routes (no dynamic segments) */
32
+ staticRoutes: RouteInfo[];
33
+ /** Dynamic routes (with [param]) */
34
+ dynamicRoutes: RouteInfo[];
35
+ /** API routes */
36
+ apiRoutes: RouteInfo[];
37
+ /** App directory path */
38
+ appDir: string;
39
+ }
40
+
41
+ export interface ScanOptions {
42
+ /** Path to app directory */
43
+ appDir?: string;
44
+ /** Include API routes */
45
+ includeApi?: boolean;
46
+ /** Include layouts, loading, error pages */
47
+ includeSpecial?: boolean;
48
+ }
49
+
50
+ const PAGE_FILES = ['page.tsx', 'page.ts', 'page.jsx', 'page.js'];
51
+ const ROUTE_FILES = ['route.tsx', 'route.ts', 'route.jsx', 'route.js'];
52
+ const SPECIAL_FILES = ['layout', 'loading', 'error', 'not-found', 'template'];
53
+
54
+ /**
55
+ * Find app directory by searching common locations
56
+ */
57
+ export function findAppDir(startDir: string = process.cwd()): string | null {
58
+ const candidates = [
59
+ join(startDir, 'app'),
60
+ join(startDir, 'src', 'app'),
61
+ ];
62
+
63
+ for (const dir of candidates) {
64
+ if (existsSync(dir) && statSync(dir).isDirectory()) {
65
+ return dir;
66
+ }
67
+ }
68
+
69
+ return null;
70
+ }
71
+
72
+ /**
73
+ * Scan Next.js app directory for routes
74
+ */
75
+ export function scanRoutes(options: ScanOptions = {}): ScanResult {
76
+ const {
77
+ appDir = findAppDir() || './app',
78
+ includeApi = true,
79
+ includeSpecial = false,
80
+ } = options;
81
+
82
+ if (!existsSync(appDir)) {
83
+ throw new Error(`App directory not found: ${appDir}`);
84
+ }
85
+
86
+ const routes: RouteInfo[] = [];
87
+
88
+ scanDirectory(appDir, '', routes, { includeApi, includeSpecial });
89
+
90
+ return {
91
+ routes,
92
+ staticRoutes: routes.filter(r => !r.isDynamic && r.type === 'page'),
93
+ dynamicRoutes: routes.filter(r => r.isDynamic && r.type === 'page'),
94
+ apiRoutes: routes.filter(r => r.type === 'api'),
95
+ appDir,
96
+ };
97
+ }
98
+
99
+ function scanDirectory(
100
+ dir: string,
101
+ routePath: string,
102
+ routes: RouteInfo[],
103
+ options: { includeApi: boolean; includeSpecial: boolean }
104
+ ): void {
105
+ let entries: string[];
106
+
107
+ try {
108
+ entries = readdirSync(dir);
109
+ } catch {
110
+ return;
111
+ }
112
+
113
+ for (const entry of entries) {
114
+ const fullPath = join(dir, entry);
115
+
116
+ let stat;
117
+ try {
118
+ stat = statSync(fullPath);
119
+ } catch {
120
+ continue;
121
+ }
122
+
123
+ if (stat.isDirectory()) {
124
+ // Skip private folders
125
+ if (entry.startsWith('_') || entry.startsWith('.')) continue;
126
+
127
+ // Handle route groups (folder)
128
+ if (entry.startsWith('(') && entry.endsWith(')')) {
129
+ // Route groups don't add to URL path
130
+ scanDirectory(fullPath, routePath, routes, options);
131
+ continue;
132
+ }
133
+
134
+ // Handle parallel routes @folder
135
+ if (entry.startsWith('@')) {
136
+ // Parallel routes don't add to URL path
137
+ scanDirectory(fullPath, routePath, routes, options);
138
+ continue;
139
+ }
140
+
141
+ // Handle intercepting routes (.)folder, (..)folder, (...)folder
142
+ if (entry.startsWith('(') && !entry.endsWith(')')) {
143
+ continue; // Skip intercepting routes for now
144
+ }
145
+
146
+ // Build new route path
147
+ const segment = processSegment(entry);
148
+ const newRoutePath = routePath + '/' + segment.urlSegment;
149
+
150
+ scanDirectory(fullPath, newRoutePath, routes, options);
151
+ } else if (stat.isFile()) {
152
+ // Check for page files
153
+ if (PAGE_FILES.includes(entry)) {
154
+ const route = createRouteInfo(routePath || '/', dir, 'page');
155
+ routes.push(route);
156
+ }
157
+
158
+ // Check for API route files
159
+ if (options.includeApi && ROUTE_FILES.includes(entry)) {
160
+ const route = createRouteInfo(routePath || '/', dir, 'api');
161
+ routes.push(route);
162
+ }
163
+
164
+ // Check for special files
165
+ if (options.includeSpecial) {
166
+ const baseName = entry.replace(/\.(tsx?|jsx?|js)$/, '');
167
+ if (SPECIAL_FILES.includes(baseName)) {
168
+ const route = createRouteInfo(routePath || '/', dir, baseName as RouteInfo['type']);
169
+ routes.push(route);
170
+ }
171
+ }
172
+ }
173
+ }
174
+ }
175
+
176
+ interface SegmentInfo {
177
+ urlSegment: string;
178
+ isDynamic: boolean;
179
+ paramName?: string;
180
+ isCatchAll: boolean;
181
+ isOptionalCatchAll: boolean;
182
+ }
183
+
184
+ function processSegment(segment: string): SegmentInfo {
185
+ // Optional catch-all: [[...slug]]
186
+ if (segment.startsWith('[[...') && segment.endsWith(']]')) {
187
+ const paramName = segment.slice(5, -2);
188
+ return {
189
+ urlSegment: segment,
190
+ isDynamic: true,
191
+ paramName,
192
+ isCatchAll: true,
193
+ isOptionalCatchAll: true,
194
+ };
195
+ }
196
+
197
+ // Catch-all: [...slug]
198
+ if (segment.startsWith('[...') && segment.endsWith(']')) {
199
+ const paramName = segment.slice(4, -1);
200
+ return {
201
+ urlSegment: segment,
202
+ isDynamic: true,
203
+ paramName,
204
+ isCatchAll: true,
205
+ isOptionalCatchAll: false,
206
+ };
207
+ }
208
+
209
+ // Dynamic: [slug]
210
+ if (segment.startsWith('[') && segment.endsWith(']')) {
211
+ const paramName = segment.slice(1, -1);
212
+ return {
213
+ urlSegment: segment,
214
+ isDynamic: true,
215
+ paramName,
216
+ isCatchAll: false,
217
+ isOptionalCatchAll: false,
218
+ };
219
+ }
220
+
221
+ // Static segment
222
+ return {
223
+ urlSegment: segment,
224
+ isDynamic: false,
225
+ isCatchAll: false,
226
+ isOptionalCatchAll: false,
227
+ };
228
+ }
229
+
230
+ function createRouteInfo(path: string, filePath: string, type: RouteInfo['type']): RouteInfo {
231
+ const segments = path.split('/').filter(Boolean);
232
+ const dynamicSegments: string[] = [];
233
+ let isDynamic = false;
234
+ let isCatchAll = false;
235
+ let isOptionalCatchAll = false;
236
+
237
+ for (const segment of segments) {
238
+ const info = processSegment(segment);
239
+ if (info.isDynamic) {
240
+ isDynamic = true;
241
+ if (info.paramName) {
242
+ dynamicSegments.push(info.paramName);
243
+ }
244
+ if (info.isCatchAll) isCatchAll = true;
245
+ if (info.isOptionalCatchAll) isOptionalCatchAll = true;
246
+ }
247
+ }
248
+
249
+ // Extract route group from path
250
+ let routeGroup: string | undefined;
251
+ const groupMatch = filePath.match(/\(([^)]+)\)/);
252
+ if (groupMatch) {
253
+ routeGroup = groupMatch[1];
254
+ }
255
+
256
+ return {
257
+ path: path || '/',
258
+ filePath,
259
+ type,
260
+ isDynamic,
261
+ dynamicSegments,
262
+ isCatchAll,
263
+ isOptionalCatchAll,
264
+ routeGroup,
265
+ };
266
+ }
267
+
268
+ /**
269
+ * Convert route path to URL (replace dynamic segments with example values)
270
+ */
271
+ export function routeToUrl(route: RouteInfo, params?: Record<string, string>): string {
272
+ let url = route.path;
273
+
274
+ for (const segment of route.dynamicSegments) {
275
+ const value = params?.[segment] || `{${segment}}`;
276
+
277
+ // Handle different dynamic segment types
278
+ if (route.isOptionalCatchAll) {
279
+ url = url.replace(`[[...${segment}]]`, value);
280
+ } else if (route.isCatchAll) {
281
+ url = url.replace(`[...${segment}]`, value);
282
+ } else {
283
+ url = url.replace(`[${segment}]`, value);
284
+ }
285
+ }
286
+
287
+ return url;
288
+ }
289
+
290
+ /**
291
+ * Get static routes as URLs (ready to check)
292
+ */
293
+ export function getStaticUrls(scanResult: ScanResult, baseUrl: string): string[] {
294
+ return scanResult.staticRoutes.map(route => {
295
+ const url = new URL(route.path, baseUrl);
296
+ return url.href;
297
+ });
298
+ }