@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.
- package/README.md +192 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +3780 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/crawler/index.d.ts +88 -0
- package/dist/crawler/index.mjs +610 -0
- package/dist/crawler/index.mjs.map +1 -0
- package/dist/google-console/index.d.ts +95 -0
- package/dist/google-console/index.mjs +539 -0
- package/dist/google-console/index.mjs.map +1 -0
- package/dist/index.d.ts +285 -0
- package/dist/index.mjs +3236 -0
- package/dist/index.mjs.map +1 -0
- package/dist/link-checker/index.d.ts +76 -0
- package/dist/link-checker/index.mjs +326 -0
- package/dist/link-checker/index.mjs.map +1 -0
- package/dist/markdown-report-B3QdDzxE.d.ts +193 -0
- package/dist/reports/index.d.ts +24 -0
- package/dist/reports/index.mjs +836 -0
- package/dist/reports/index.mjs.map +1 -0
- package/dist/routes/index.d.ts +69 -0
- package/dist/routes/index.mjs +372 -0
- package/dist/routes/index.mjs.map +1 -0
- package/dist/scanner-Cz4Th2Pt.d.ts +60 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/index.mjs +3 -0
- package/dist/types/index.mjs.map +1 -0
- package/package.json +114 -0
- package/src/analyzer.ts +256 -0
- package/src/cli/commands/audit.ts +260 -0
- package/src/cli/commands/content.ts +180 -0
- package/src/cli/commands/crawl.ts +32 -0
- package/src/cli/commands/index.ts +12 -0
- package/src/cli/commands/inspect.ts +60 -0
- package/src/cli/commands/links.ts +41 -0
- package/src/cli/commands/robots.ts +36 -0
- package/src/cli/commands/routes.ts +126 -0
- package/src/cli/commands/sitemap.ts +48 -0
- package/src/cli/index.ts +149 -0
- package/src/cli/types.ts +40 -0
- package/src/config.ts +207 -0
- package/src/content/index.ts +51 -0
- package/src/content/link-checker.ts +182 -0
- package/src/content/link-fixer.ts +188 -0
- package/src/content/scanner.ts +200 -0
- package/src/content/sitemap-generator.ts +321 -0
- package/src/content/types.ts +140 -0
- package/src/crawler/crawler.ts +425 -0
- package/src/crawler/index.ts +10 -0
- package/src/crawler/robots-parser.ts +171 -0
- package/src/crawler/sitemap-validator.ts +204 -0
- package/src/google-console/analyzer.ts +317 -0
- package/src/google-console/auth.ts +100 -0
- package/src/google-console/client.ts +281 -0
- package/src/google-console/index.ts +9 -0
- package/src/index.ts +144 -0
- package/src/link-checker/index.ts +461 -0
- package/src/reports/claude-context.ts +149 -0
- package/src/reports/generator.ts +244 -0
- package/src/reports/index.ts +27 -0
- package/src/reports/json-report.ts +320 -0
- package/src/reports/markdown-report.ts +246 -0
- package/src/reports/split-report.ts +252 -0
- package/src/routes/analyzer.ts +324 -0
- package/src/routes/index.ts +25 -0
- package/src/routes/scanner.ts +298 -0
- package/src/types/index.ts +222 -0
- 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
|
+
}
|