crawlemon 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js ADDED
@@ -0,0 +1,1986 @@
1
+ // src/index.ts
2
+ import fs3 from "node:fs";
3
+ import path4 from "node:path";
4
+ import { spawnSync } from "node:child_process";
5
+
6
+ // ../next-adapter/src/parser.ts
7
+ function parsePageSource(content) {
8
+ const metadata = {};
9
+ const headings = [];
10
+ const images = [];
11
+ const links = [];
12
+ const lineAt = (index) => content.slice(0, index).split("\n").length;
13
+ const titleMatch = content.match(/title\s*:\s*["'`]([^"'`]+)["'`]/) || content.match(/title\s*:\s*\{[\s\S]*?default\s*:\s*["'`]([^"'`]+)["'`]/) || content.match(/<title[^>]*>([^<]+)<\/title>/i);
14
+ if (titleMatch) {
15
+ metadata.title = titleMatch[1].trim();
16
+ }
17
+ const descMatch = content.match(/description\s*:\s*["'`]([^"'`]+)["'`]/) || content.match(/<meta[^>]*name=["']description["'][^>]*content=["']([^"']+)["']/i) || content.match(/<meta[^>]*content=["']([^"']+)["'][^>]*name=["']description["']/i);
18
+ if (descMatch) {
19
+ metadata.description = descMatch[1].trim();
20
+ }
21
+ const canonicalMatch = content.match(/canonical\s*:\s*["'`]([^"'`]+)["'`]/) || content.match(/<link[^>]*rel=["']canonical["'][^>]*href=["']([^"']+)["']/i);
22
+ if (canonicalMatch) {
23
+ metadata.canonical = canonicalMatch[1].trim();
24
+ }
25
+ const robotsMatch = content.match(/robots\s*:\s*["'`]([^"'`]+)["'`]/) || content.match(/<meta[^>]*name=["']robots["'][^>]*content=["']([^"']+)["']/i);
26
+ if (robotsMatch) {
27
+ metadata.robots = robotsMatch[1].trim();
28
+ }
29
+ const jsonLdMatches = content.matchAll(/<script[^>]*type=["']application\/ld\+json["'][^>]*>([\s\S]*?)<\/script>/gi);
30
+ const jsonLd = [];
31
+ for (const match of jsonLdMatches) {
32
+ try {
33
+ const parsed = JSON.parse(match[1].trim());
34
+ jsonLd.push(parsed);
35
+ } catch {
36
+ jsonLd.push({ invalid: true });
37
+ }
38
+ }
39
+ if (jsonLd.length > 0) {
40
+ metadata.jsonLd = jsonLd;
41
+ }
42
+ const declarationCount = [
43
+ /export\s+const\s+metadata\b/.test(content),
44
+ /export\s+(?:async\s+)?function\s+generateMetadata\b/.test(content),
45
+ /<Head\b/.test(content)
46
+ ].filter(Boolean).length;
47
+ metadata.hasConflictingDeclarations = declarationCount > 1;
48
+ for (const match of content.matchAll(/<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1>/gi)) {
49
+ headings.push({
50
+ level: parseInt(match[1], 10),
51
+ text: match[2].replace(/<[^>]+>/g, "").replace(/[{}]/g, "").trim(),
52
+ line: lineAt(match.index || 0)
53
+ });
54
+ }
55
+ for (const match of content.matchAll(/<(img|Image)\b([^>]*?)\/?>/g)) {
56
+ const attrs = match[2];
57
+ const srcMatch = attrs.match(/src\s*=\s*["'`]([^"'`]+)["'`]/);
58
+ const altMatch = attrs.match(/alt\s*=\s*["'`]([^"'`]*?)["'`]/);
59
+ images.push({
60
+ src: srcMatch ? srcMatch[1] : "unknown-image",
61
+ alt: altMatch ? altMatch[1] : void 0,
62
+ isNextImage: match[1] === "Image",
63
+ line: lineAt(match.index || 0)
64
+ });
65
+ }
66
+ for (const match of content.matchAll(/<(a|Link)\b[^>]*?href\s*=\s*["']([^"']+)["'][^>]*>([\s\S]*?)<\/\1>/g)) {
67
+ const href = match[2].trim();
68
+ const anchorRaw = match[3].replace(/<[^>]+>/g, "").replace(/[{}]/g, "").trim();
69
+ const isInternal = href.startsWith("/") || !/^(https?:|mailto:|tel:|javascript:)/i.test(href);
70
+ links.push({
71
+ href,
72
+ anchorText: anchorRaw || "(empty anchor)",
73
+ line: lineAt(match.index || 0),
74
+ isInternal
75
+ });
76
+ }
77
+ const cleanBodyText = content.replace(/<script[\s\S]*?<\/script>/gi, "").replace(/<style[\s\S]*?<\/style>/gi, "").replace(/<[^>]+>/g, " ").replace(/\s+/g, " ").trim();
78
+ const words = cleanBodyText.split(/\s+/).filter((w) => w.length > 2);
79
+ const hasLittleContent = words.length < 20;
80
+ return {
81
+ metadata,
82
+ headings,
83
+ images,
84
+ links,
85
+ textContent: cleanBodyText,
86
+ hasLittleContent
87
+ };
88
+ }
89
+
90
+ // ../next-adapter/src/scanner.ts
91
+ import fs from "node:fs";
92
+ import path from "node:path";
93
+ function normalizeRouteGroup(segment) {
94
+ return segment.startsWith("(") && segment.endsWith(")") ? "" : segment;
95
+ }
96
+ var NextJsAdapter = class {
97
+ /**
98
+ * Detects if the given directory contains a Next.js application.
99
+ */
100
+ static async detect(projectRoot) {
101
+ const pkgPath = path.join(projectRoot, "package.json");
102
+ if (fs.existsSync(pkgPath)) {
103
+ try {
104
+ const pkg = JSON.parse(fs.readFileSync(pkgPath, "utf8"));
105
+ const deps = { ...pkg.dependencies || {}, ...pkg.devDependencies || {} };
106
+ if (deps.next) return true;
107
+ } catch {
108
+ }
109
+ }
110
+ return fs.existsSync(path.join(projectRoot, "app")) || fs.existsSync(path.join(projectRoot, "src", "app")) || fs.existsSync(path.join(projectRoot, "pages")) || fs.existsSync(path.join(projectRoot, "src", "pages")) || fs.existsSync(path.join(projectRoot, "next.config.js")) || fs.existsSync(path.join(projectRoot, "next.config.mjs"));
111
+ }
112
+ /**
113
+ * Loads optional seo.config.ts or returns default configuration.
114
+ */
115
+ static async loadConfig(projectRoot) {
116
+ const configCandidates = [
117
+ path.join(projectRoot, "seo.config.ts"),
118
+ path.join(projectRoot, "seo.config.js"),
119
+ path.join(projectRoot, "seo.config.json")
120
+ ];
121
+ for (const candidate of configCandidates) {
122
+ if (fs.existsSync(candidate)) {
123
+ try {
124
+ if (candidate.endsWith(".json")) {
125
+ return JSON.parse(fs.readFileSync(candidate, "utf8"));
126
+ }
127
+ const content = fs.readFileSync(candidate, "utf8");
128
+ const siteUrlMatch = content.match(/siteUrl\s*:\s*["']([^"']+)["']/);
129
+ const ignoreMatch = content.match(/ignore\s*:\s*\[([\s\S]*?)\]/);
130
+ const ignore = ignoreMatch ? Array.from(ignoreMatch[1].matchAll(/["']([^"']+)["']/g), (match) => match[1]) : void 0;
131
+ const rulesMatch = content.match(/rules\s*:\s*\{([\s\S]*?)\}/);
132
+ const rules = {};
133
+ if (rulesMatch) {
134
+ for (const match of rulesMatch[1].matchAll(/([A-Za-z][A-Za-z0-9_-]*)\s*:\s*(true|false)/g)) {
135
+ rules[match[1]] = match[2] === "true";
136
+ }
137
+ }
138
+ return {
139
+ siteUrl: siteUrlMatch ? siteUrlMatch[1] : void 0,
140
+ ignore,
141
+ rules
142
+ };
143
+ } catch {
144
+ }
145
+ }
146
+ }
147
+ return {
148
+ siteUrl: void 0,
149
+ ignore: ["/admin/**", "/api/**"]
150
+ };
151
+ }
152
+ /**
153
+ * Scans project directory and builds complete route inventory.
154
+ */
155
+ static async scan(projectRoot) {
156
+ const isNext = await this.detect(projectRoot);
157
+ const config = await this.loadConfig(projectRoot);
158
+ let appDir = path.join(projectRoot, "app");
159
+ if (!fs.existsSync(appDir) && fs.existsSync(path.join(projectRoot, "src", "app"))) {
160
+ appDir = path.join(projectRoot, "src", "app");
161
+ }
162
+ let pagesDir = path.join(projectRoot, "pages");
163
+ if (!fs.existsSync(pagesDir) && fs.existsSync(path.join(projectRoot, "src", "pages"))) {
164
+ pagesDir = path.join(projectRoot, "src", "pages");
165
+ }
166
+ const isAppRouter = fs.existsSync(appDir);
167
+ const routes = [];
168
+ const redirects = [];
169
+ for (const configName of ["next.config.js", "next.config.mjs", "next.config.ts"]) {
170
+ const configPath = path.join(projectRoot, configName);
171
+ if (!fs.existsSync(configPath)) continue;
172
+ const content = fs.readFileSync(configPath, "utf8");
173
+ for (const match of content.matchAll(/source\s*:\s*["'`]([^"'`]+)["'`][\s\S]{0,500}?destination\s*:\s*["'`]([^"'`]+)["'`][\s\S]{0,200}?permanent\s*:\s*(true|false)/g)) {
174
+ redirects.push({ source: match[1], destination: match[2], permanent: match[3] === "true" });
175
+ }
176
+ break;
177
+ }
178
+ let rootLayoutMetadata = {};
179
+ if (isAppRouter) {
180
+ const layoutFileCandidates = [
181
+ path.join(appDir, "layout.tsx"),
182
+ path.join(appDir, "layout.jsx"),
183
+ path.join(appDir, "layout.js")
184
+ ];
185
+ for (const layoutFile of layoutFileCandidates) {
186
+ if (fs.existsSync(layoutFile)) {
187
+ const content = fs.readFileSync(layoutFile, "utf8");
188
+ const parsed = parsePageSource(content);
189
+ rootLayoutMetadata = parsed.metadata;
190
+ break;
191
+ }
192
+ }
193
+ }
194
+ if (isAppRouter) {
195
+ const scanAppDir = (currentDir, relativePath = "") => {
196
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
197
+ for (const entry of entries) {
198
+ const fullPath = path.join(currentDir, entry.name);
199
+ if (entry.isDirectory()) {
200
+ if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "api") {
201
+ continue;
202
+ }
203
+ const normalized = normalizeRouteGroup(entry.name);
204
+ const nextRelative = normalized ? path.join(relativePath, normalized) : relativePath;
205
+ scanAppDir(fullPath, nextRelative);
206
+ } else if (entry.isFile()) {
207
+ if (/^page\.(tsx|jsx|js|ts)$/.test(entry.name)) {
208
+ const routePath = relativePath === "" ? "/" : `/${relativePath.replace(/\\/g, "/")}`;
209
+ const content = fs.readFileSync(fullPath, "utf8");
210
+ const parsed = parsePageSource(content);
211
+ routes.push({
212
+ route: routePath,
213
+ filePath: fullPath,
214
+ metadata: {
215
+ ...rootLayoutMetadata,
216
+ ...parsed.metadata
217
+ },
218
+ headings: parsed.headings,
219
+ images: parsed.images,
220
+ links: parsed.links,
221
+ textContent: parsed.textContent,
222
+ hasLittleContent: parsed.hasLittleContent,
223
+ hasDynamicSegments: routePath.includes("[")
224
+ });
225
+ }
226
+ }
227
+ }
228
+ };
229
+ scanAppDir(appDir);
230
+ }
231
+ if (fs.existsSync(pagesDir)) {
232
+ const scanPagesDir = (currentDir, relativePath = "") => {
233
+ const entries = fs.readdirSync(currentDir, { withFileTypes: true });
234
+ for (const entry of entries) {
235
+ const fullPath = path.join(currentDir, entry.name);
236
+ if (entry.isDirectory()) {
237
+ if (entry.name.startsWith(".") || entry.name === "node_modules" || entry.name === "api") {
238
+ continue;
239
+ }
240
+ scanPagesDir(fullPath, path.join(relativePath, entry.name));
241
+ } else if (entry.isFile()) {
242
+ if (/\.(tsx|jsx|js)$/.test(entry.name)) {
243
+ const baseName = entry.name.replace(/\.(tsx|jsx|js)$/, "");
244
+ if (baseName.startsWith("_") || baseName === "api") {
245
+ continue;
246
+ }
247
+ let routePath = `/${path.join(relativePath, baseName === "index" ? "" : baseName).replace(/\\/g, "/")}`;
248
+ if (routePath === "//" || routePath === "") routePath = "/";
249
+ const content = fs.readFileSync(fullPath, "utf8");
250
+ const parsed = parsePageSource(content);
251
+ routes.push({
252
+ route: routePath,
253
+ filePath: fullPath,
254
+ metadata: parsed.metadata,
255
+ headings: parsed.headings,
256
+ images: parsed.images,
257
+ links: parsed.links,
258
+ textContent: parsed.textContent,
259
+ hasLittleContent: parsed.hasLittleContent,
260
+ hasDynamicSegments: routePath.includes("[")
261
+ });
262
+ }
263
+ }
264
+ }
265
+ };
266
+ scanPagesDir(pagesDir);
267
+ }
268
+ const sitemapCandidates = [
269
+ path.join(appDir, "sitemap.ts"),
270
+ path.join(appDir, "sitemap.js"),
271
+ path.join(projectRoot, "public", "sitemap.xml")
272
+ ];
273
+ let sitemapFound = false;
274
+ let sitemapMalformed = false;
275
+ const sitemapUrls = [];
276
+ for (const candidate of sitemapCandidates) {
277
+ if (fs.existsSync(candidate)) {
278
+ sitemapFound = true;
279
+ const content = fs.readFileSync(candidate, "utf8");
280
+ if (candidate.endsWith(".xml") && (!/<urlset\b/i.test(content) || !/<loc>[^<]+<\/loc>/i.test(content))) {
281
+ sitemapMalformed = true;
282
+ }
283
+ const urlMatches = content.matchAll(/url\s*:\s*['"`]([^'"`]+)['"`]|<loc>([^<]+)<\/loc>/gi);
284
+ for (const m of urlMatches) {
285
+ const url = m[1] || m[2];
286
+ if (url) sitemapUrls.push(url.trim());
287
+ }
288
+ break;
289
+ }
290
+ }
291
+ const robotsCandidates = [
292
+ path.join(appDir, "robots.ts"),
293
+ path.join(appDir, "robots.js"),
294
+ path.join(projectRoot, "public", "robots.txt")
295
+ ];
296
+ const robotsFile = robotsCandidates.find((candidate) => fs.existsSync(candidate));
297
+ const robotsFound = Boolean(robotsFile);
298
+ const robotsContent = robotsFile ? fs.readFileSync(robotsFile, "utf8") : "";
299
+ routes.sort((a, b) => a.route.localeCompare(b.route));
300
+ return {
301
+ isNextJs: isNext,
302
+ isAppRouter,
303
+ routes,
304
+ sitemapFound,
305
+ sitemapUrls,
306
+ sitemapMalformed,
307
+ robotsFound,
308
+ robotsContent,
309
+ redirects,
310
+ config,
311
+ projectRoot
312
+ };
313
+ }
314
+ };
315
+
316
+ // ../core/src/rules/metadata-title.ts
317
+ var metadataTitleRule = {
318
+ id: "metadata-title",
319
+ name: "Page Title Validation",
320
+ category: "metadata",
321
+ analyze(context) {
322
+ const findings = [];
323
+ const titleMap = /* @__PURE__ */ new Map();
324
+ for (const route of context.routes) {
325
+ const title = route.metadata.title;
326
+ if (!title || title.trim() === "") {
327
+ findings.push({
328
+ id: `title-missing-${route.route}`,
329
+ rule: "metadata-title",
330
+ severity: "error",
331
+ category: "metadata",
332
+ message: `Missing or empty <title> metadata for route "${route.route}".`,
333
+ file: route.filePath,
334
+ route: route.route,
335
+ fixable: false,
336
+ explanation: "Search engines rely on page titles to understand context and display search snippets. Missing titles critically harm click-through rates."
337
+ });
338
+ continue;
339
+ }
340
+ const trimmed = title.trim();
341
+ const existing = titleMap.get(trimmed) || [];
342
+ existing.push(route.route);
343
+ titleMap.set(trimmed, existing);
344
+ if (trimmed.length < 10) {
345
+ findings.push({
346
+ id: `title-short-${route.route}`,
347
+ rule: "metadata-title",
348
+ severity: "warning",
349
+ category: "metadata",
350
+ message: `Page title is suspiciously short (${trimmed.length} chars) on "${route.route}". Recommended: 30-60 characters.`,
351
+ file: route.filePath,
352
+ route: route.route,
353
+ fixable: false,
354
+ explanation: "Titles shorter than 10 characters lack sufficient topical context for ranking."
355
+ });
356
+ } else if (trimmed.length > 65) {
357
+ findings.push({
358
+ id: `title-long-${route.route}`,
359
+ rule: "metadata-title",
360
+ severity: "warning",
361
+ category: "metadata",
362
+ message: `Page title is suspiciously long (${trimmed.length} chars) on "${route.route}". Recommended: under 65 characters to prevent truncation.`,
363
+ file: route.filePath,
364
+ route: route.route,
365
+ fixable: false,
366
+ explanation: "Search engine result pages (SERPs) typically truncate titles exceeding ~60-65 characters."
367
+ });
368
+ }
369
+ }
370
+ for (const [title, routes] of titleMap.entries()) {
371
+ if (routes.length > 1) {
372
+ for (const r of routes) {
373
+ findings.push({
374
+ id: `title-duplicate-${r}`,
375
+ rule: "metadata-title",
376
+ severity: "error",
377
+ category: "metadata",
378
+ message: `Duplicate title "${title}" shared across multiple routes (${routes.join(", ")}).`,
379
+ route: r,
380
+ fixable: false,
381
+ explanation: "Every indexable page should have a unique, descriptive title to avoid keyword cannibalization."
382
+ });
383
+ }
384
+ }
385
+ }
386
+ return findings;
387
+ }
388
+ };
389
+
390
+ // ../core/src/rules/metadata-description.ts
391
+ var metadataDescriptionRule = {
392
+ id: "metadata-description",
393
+ name: "Meta Description Validation",
394
+ category: "metadata",
395
+ analyze(context) {
396
+ const findings = [];
397
+ const descMap = /* @__PURE__ */ new Map();
398
+ for (const route of context.routes) {
399
+ const desc = route.metadata.description;
400
+ if (!desc || desc.trim() === "") {
401
+ findings.push({
402
+ id: `desc-missing-${route.route}`,
403
+ rule: "metadata-description",
404
+ severity: "warning",
405
+ category: "metadata",
406
+ message: `Missing meta description on route "${route.route}".`,
407
+ file: route.filePath,
408
+ route: route.route,
409
+ fixable: false,
410
+ explanation: "Meta descriptions provide the summary text displayed under your title in search results, directly influencing organic CTR."
411
+ });
412
+ continue;
413
+ }
414
+ const trimmed = desc.trim();
415
+ const existing = descMap.get(trimmed) || [];
416
+ existing.push(route.route);
417
+ descMap.set(trimmed, existing);
418
+ if (trimmed.length < 50) {
419
+ findings.push({
420
+ id: `desc-short-${route.route}`,
421
+ rule: "metadata-description",
422
+ severity: "info",
423
+ category: "metadata",
424
+ message: `Meta description is very short (${trimmed.length} chars) on "${route.route}". Ideal length: 120-160 characters.`,
425
+ file: route.filePath,
426
+ route: route.route,
427
+ fixable: false,
428
+ explanation: "Descriptions under 50 characters rarely provide enough context to attract user clicks."
429
+ });
430
+ } else if (trimmed.length > 165) {
431
+ findings.push({
432
+ id: `desc-long-${route.route}`,
433
+ rule: "metadata-description",
434
+ severity: "info",
435
+ category: "metadata",
436
+ message: `Meta description is long (${trimmed.length} chars) on "${route.route}" and will likely be truncated on SERPs.`,
437
+ file: route.filePath,
438
+ route: route.route,
439
+ fixable: false,
440
+ explanation: "Google usually truncates descriptions beyond 155-160 characters on desktop and mobile."
441
+ });
442
+ }
443
+ }
444
+ for (const [desc, routes] of descMap.entries()) {
445
+ if (routes.length > 1) {
446
+ for (const r of routes) {
447
+ findings.push({
448
+ id: `desc-duplicate-${r}`,
449
+ rule: "metadata-description",
450
+ severity: "warning",
451
+ category: "metadata",
452
+ message: `Duplicate meta description shared across routes (${routes.join(", ")}).`,
453
+ route: r,
454
+ fixable: false,
455
+ explanation: "Each page should feature a unique description summarizing its specific content."
456
+ });
457
+ }
458
+ }
459
+ }
460
+ return findings;
461
+ }
462
+ };
463
+
464
+ // ../core/src/rules/canonical.ts
465
+ var canonicalRule = {
466
+ id: "canonical",
467
+ name: "Canonical URL Validation",
468
+ category: "technical",
469
+ analyze(context) {
470
+ const findings = [];
471
+ let expectedDomain = null;
472
+ let configuredSiteUrl = null;
473
+ const canonicalRoutes = /* @__PURE__ */ new Map();
474
+ if (context.config.siteUrl) {
475
+ try {
476
+ configuredSiteUrl = new URL(context.config.siteUrl);
477
+ if (!["http:", "https:"].includes(configuredSiteUrl.protocol)) throw new Error("Unsupported siteUrl protocol");
478
+ expectedDomain = configuredSiteUrl.hostname;
479
+ } catch {
480
+ findings.push({
481
+ id: "canonical-config-site-url-invalid",
482
+ rule: "canonical",
483
+ severity: "error",
484
+ category: "technical",
485
+ message: `Configured siteUrl "${context.config.siteUrl}" is not a valid absolute URL.`,
486
+ fixable: false
487
+ });
488
+ }
489
+ }
490
+ for (const route of context.routes) {
491
+ const canonical = route.metadata.canonical;
492
+ if (!canonical || canonical.trim() === "") {
493
+ findings.push({
494
+ id: `canonical-missing-${route.route}`,
495
+ rule: "canonical",
496
+ severity: "error",
497
+ category: "technical",
498
+ message: `Missing canonical URL on route "${route.route}".`,
499
+ file: route.filePath,
500
+ route: route.route,
501
+ fixable: Boolean(configuredSiteUrl),
502
+ explanation: "A canonical tag tells search engines which URL represents the master copy of a page, preventing duplicate content penalties."
503
+ });
504
+ continue;
505
+ }
506
+ try {
507
+ const parsed = new URL(canonical);
508
+ const canonicalKey = `${parsed.origin}${parsed.pathname.replace(/\/$/, "") || "/"}`;
509
+ canonicalRoutes.set(canonicalKey, [...canonicalRoutes.get(canonicalKey) || [], route.route]);
510
+ if (expectedDomain && parsed.hostname !== expectedDomain) {
511
+ findings.push({
512
+ id: `canonical-domain-mismatch-${route.route}`,
513
+ rule: "canonical",
514
+ severity: "warning",
515
+ category: "technical",
516
+ message: `Canonical URL on "${route.route}" points to external domain "${parsed.hostname}" instead of configured "${expectedDomain}".`,
517
+ file: route.filePath,
518
+ route: route.route,
519
+ fixable: false,
520
+ explanation: "Cross-domain canonicals signal to search engines that another site owns this content. Ensure this is intentional."
521
+ });
522
+ }
523
+ if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
524
+ findings.push({
525
+ id: `canonical-invalid-protocol-${route.route}`,
526
+ rule: "canonical",
527
+ severity: "error",
528
+ category: "technical",
529
+ message: `Canonical URL "${canonical}" on "${route.route}" must use http or https protocol.`,
530
+ file: route.filePath,
531
+ route: route.route,
532
+ fixable: false
533
+ });
534
+ }
535
+ if (configuredSiteUrl && parsed.protocol === "http:" && configuredSiteUrl.protocol === "https:") {
536
+ findings.push({
537
+ id: `canonical-insecure-protocol-${route.route}`,
538
+ rule: "canonical",
539
+ severity: "warning",
540
+ category: "technical",
541
+ message: `Canonical URL on "${route.route}" uses HTTP while siteUrl uses HTTPS.`,
542
+ file: route.filePath,
543
+ route: route.route,
544
+ fixable: false
545
+ });
546
+ }
547
+ if (configuredSiteUrl && parsed.hostname === configuredSiteUrl.hostname) {
548
+ const basePath = configuredSiteUrl.pathname.replace(/\/$/, "");
549
+ const expectedPath = `${basePath}${route.route === "/" ? "" : route.route}` || "/";
550
+ const actualPath = parsed.pathname.replace(/\/$/, "") || "/";
551
+ const normalizedExpected = expectedPath.replace(/\/$/, "") || "/";
552
+ if (actualPath !== normalizedExpected) {
553
+ findings.push({
554
+ id: `canonical-route-mismatch-${route.route}`,
555
+ rule: "canonical",
556
+ severity: "warning",
557
+ category: "technical",
558
+ message: `Canonical URL on "${route.route}" points to path "${parsed.pathname}" instead of "${expectedPath}".`,
559
+ file: route.filePath,
560
+ route: route.route,
561
+ fixable: false
562
+ });
563
+ }
564
+ }
565
+ } catch {
566
+ findings.push({
567
+ id: `canonical-malformed-${route.route}`,
568
+ rule: "canonical",
569
+ severity: "error",
570
+ category: "technical",
571
+ message: `Malformed canonical URL "${canonical}" on route "${route.route}". Must be an absolute valid URL.`,
572
+ file: route.filePath,
573
+ route: route.route,
574
+ fixable: false
575
+ });
576
+ }
577
+ }
578
+ for (const [canonical, routes] of canonicalRoutes) {
579
+ if (routes.length < 2) continue;
580
+ for (const route of routes) {
581
+ findings.push({
582
+ id: `canonical-duplicate-${route}`,
583
+ rule: "canonical",
584
+ severity: "error",
585
+ category: "technical",
586
+ message: `Canonical URL "${canonical}" is shared by routes ${routes.join(", ")}.`,
587
+ route,
588
+ fixable: false
589
+ });
590
+ }
591
+ }
592
+ return findings;
593
+ }
594
+ };
595
+
596
+ // ../core/src/rules/headings.ts
597
+ var headingsRule = {
598
+ id: "headings",
599
+ name: "Heading Hierarchy & Structure",
600
+ category: "content",
601
+ analyze(context) {
602
+ const findings = [];
603
+ for (const route of context.routes) {
604
+ const headings = route.headings;
605
+ const h1s = headings.filter((h) => h.level === 1);
606
+ if (h1s.length === 0) {
607
+ findings.push({
608
+ id: `heading-missing-h1-${route.route}`,
609
+ rule: "headings",
610
+ severity: "error",
611
+ category: "content",
612
+ message: `Missing <h1> heading on route "${route.route}".`,
613
+ file: route.filePath,
614
+ route: route.route,
615
+ fixable: false,
616
+ explanation: "Every page should have exactly one <h1> element defining its primary topic for search engines and accessibility."
617
+ });
618
+ } else if (h1s.length > 1) {
619
+ findings.push({
620
+ id: `heading-multiple-h1-${route.route}`,
621
+ rule: "headings",
622
+ severity: "warning",
623
+ category: "content",
624
+ message: `Found ${h1s.length} <h1> tags on route "${route.route}". Best practice is a single prominent <h1>.`,
625
+ file: route.filePath,
626
+ line: h1s[1].line,
627
+ route: route.route,
628
+ fixable: false,
629
+ explanation: "Multiple <h1> tags can dilute topical hierarchy and confuse screen readers."
630
+ });
631
+ }
632
+ let prevLevel = 1;
633
+ for (const h of headings) {
634
+ if (h.level > prevLevel + 1 && prevLevel > 0) {
635
+ findings.push({
636
+ id: `heading-skipped-level-${route.route}-${h.line ?? h.text}`,
637
+ rule: "headings",
638
+ severity: "info",
639
+ category: "content",
640
+ message: `Heading hierarchy skip on "${route.route}": jumped from <h${prevLevel}> to <h${h.level}> ("${h.text.slice(0, 30)}...").`,
641
+ file: route.filePath,
642
+ line: h.line,
643
+ route: route.route,
644
+ fixable: false,
645
+ explanation: "Maintain sequential heading levels (h1 -> h2 -> h3) for clear structural semantics."
646
+ });
647
+ break;
648
+ }
649
+ prevLevel = h.level;
650
+ }
651
+ if (route.hasLittleContent) {
652
+ findings.push({
653
+ id: `content-thin-${route.route}`,
654
+ rule: "headings",
655
+ severity: "warning",
656
+ category: "content",
657
+ message: `Route "${route.route}" has thin or minimal text content.`,
658
+ file: route.filePath,
659
+ route: route.route,
660
+ fixable: false,
661
+ explanation: "Thin pages risk being classified as low-value by search engine indexers."
662
+ });
663
+ }
664
+ }
665
+ return findings;
666
+ }
667
+ };
668
+
669
+ // ../core/src/rules/images.ts
670
+ var imagesRule = {
671
+ id: "images",
672
+ name: "Image Accessibility & Alt Attributes",
673
+ category: "content",
674
+ analyze(context) {
675
+ const findings = [];
676
+ for (const route of context.routes) {
677
+ for (const img of route.images) {
678
+ if (img.alt === void 0 || img.alt === null) {
679
+ findings.push({
680
+ id: `img-missing-alt-${route.route}-${img.src}`,
681
+ rule: "images",
682
+ severity: "error",
683
+ category: "content",
684
+ message: `Image "${img.src}" on route "${route.route}" is missing an alt attribute.`,
685
+ file: route.filePath,
686
+ line: img.line,
687
+ route: route.route,
688
+ fixable: false,
689
+ // Never invent alt descriptions without AI
690
+ explanation: "Missing alt attributes harm accessibility and prevent images from ranking in Google Image Search."
691
+ });
692
+ } else if (img.alt.trim() === "" && !img.src.includes("icon") && !img.src.includes("decorative")) {
693
+ findings.push({
694
+ id: `img-empty-alt-${route.route}-${img.src}`,
695
+ rule: "images",
696
+ severity: "warning",
697
+ category: "content",
698
+ message: `Image "${img.src}" has an empty alt attribute on route "${route.route}".`,
699
+ file: route.filePath,
700
+ line: img.line,
701
+ route: route.route,
702
+ fixable: false,
703
+ explanation: 'Empty alt="" marks images as decorative. If this image conveys meaning, provide a descriptive alt attribute.'
704
+ });
705
+ }
706
+ }
707
+ }
708
+ return findings;
709
+ }
710
+ };
711
+
712
+ // ../core/src/rules/links.ts
713
+ var linksRule = {
714
+ id: "links",
715
+ name: "Internal Link Validation",
716
+ category: "links",
717
+ analyze(context) {
718
+ const findings = [];
719
+ const validRoutes = new Set(context.routes.map((r) => r.route));
720
+ const normalizeRoute = (r) => r.endsWith("/") && r.length > 1 ? r.slice(0, -1) : r;
721
+ const normalizedValidRoutes = new Set([...validRoutes].map(normalizeRoute));
722
+ const redirects = new Map((context.redirects || []).map((redirect) => [normalizeRoute(redirect.source), redirect.destination]));
723
+ for (const route of context.routes) {
724
+ for (const link of route.links) {
725
+ if (!link.isInternal) continue;
726
+ const href = link.href.trim();
727
+ if (href === "" || href === "#") {
728
+ findings.push({
729
+ id: `link-empty-${route.route}-${link.line ?? href}`,
730
+ rule: "links",
731
+ severity: "warning",
732
+ category: "links",
733
+ message: `Empty or placeholder href ("${href}") found on route "${route.route}".`,
734
+ file: route.filePath,
735
+ line: link.line,
736
+ route: route.route,
737
+ fixable: false,
738
+ explanation: "Placeholder links degrade crawl efficiency and waste link equity."
739
+ });
740
+ continue;
741
+ }
742
+ const cleanHref = normalizeRoute(href.split("?")[0].split("#")[0]);
743
+ if (cleanHref.startsWith("/")) {
744
+ const redirectTarget = redirects.get(cleanHref);
745
+ if (redirectTarget) {
746
+ findings.push({
747
+ id: `link-internal-redirect-${route.route}-${cleanHref}`,
748
+ rule: "links",
749
+ severity: "warning",
750
+ category: "links",
751
+ message: `Internal link "${href}" on "${route.route}" redirects to "${redirectTarget}".`,
752
+ file: route.filePath,
753
+ line: link.line,
754
+ route: route.route,
755
+ fixable: false,
756
+ explanation: "Link directly to the final internal route to avoid unnecessary crawl hops."
757
+ });
758
+ }
759
+ if (!normalizedValidRoutes.has(cleanHref) && !redirectTarget) {
760
+ let isTypoFixable = false;
761
+ for (const valid of normalizedValidRoutes) {
762
+ if (valid.toLowerCase() === cleanHref.toLowerCase()) {
763
+ isTypoFixable = true;
764
+ break;
765
+ }
766
+ }
767
+ findings.push({
768
+ id: `link-broken-${route.route}-${cleanHref}`,
769
+ rule: "links",
770
+ severity: "error",
771
+ category: "links",
772
+ message: `Broken internal link to nonexistent route "${href}" on "${route.route}".`,
773
+ file: route.filePath,
774
+ line: link.line,
775
+ route: route.route,
776
+ fixable: isTypoFixable,
777
+ explanation: "Broken internal links return 404s to search bots and users, impairing crawl discovery."
778
+ });
779
+ }
780
+ }
781
+ }
782
+ }
783
+ return findings;
784
+ }
785
+ };
786
+
787
+ // ../core/src/rules/crawlability.ts
788
+ var crawlabilityRule = {
789
+ id: "crawlability",
790
+ name: "Crawlability & Indexing (Robots & Sitemap)",
791
+ category: "technical",
792
+ analyze(context) {
793
+ const findings = [];
794
+ if (!context.robotsFound) {
795
+ findings.push({
796
+ id: "robots-missing",
797
+ rule: "crawlability",
798
+ severity: "warning",
799
+ category: "technical",
800
+ message: "Missing robots.txt or app/robots.ts file.",
801
+ fixable: true,
802
+ explanation: "Robots.txt directs search crawler access to public sections and points crawlers to your sitemap."
803
+ });
804
+ } else if (context.robotsContent) {
805
+ const directives = context.robotsContent.split(/\r?\n/).map((line) => line.replace(/#.*/, "").trim()).filter(Boolean);
806
+ const disallowed = directives.filter((line) => /^disallow\s*:/i.test(line)).map((line) => line.replace(/^disallow\s*:/i, "").trim()).filter(Boolean);
807
+ const allowed = new Set(
808
+ directives.filter((line) => /^allow\s*:/i.test(line)).map((line) => line.replace(/^allow\s*:/i, "").trim())
809
+ );
810
+ for (const blocked of disallowed) {
811
+ if (allowed.has(blocked)) {
812
+ findings.push({
813
+ id: `robots-contradictory-${blocked}`,
814
+ rule: "crawlability",
815
+ severity: "warning",
816
+ category: "technical",
817
+ message: `robots rules both allow and disallow "${blocked}".`,
818
+ fixable: false
819
+ });
820
+ }
821
+ for (const route of context.routes) {
822
+ if (blocked === "/" || route.route === blocked || route.route.startsWith(`${blocked.replace(/\/$/, "")}/`)) {
823
+ findings.push({
824
+ id: `robots-route-blocked-${route.route}`,
825
+ rule: "crawlability",
826
+ severity: route.route === "/" ? "error" : "warning",
827
+ category: "technical",
828
+ message: `Public route "${route.route}" appears blocked by robots rule "${blocked}".`,
829
+ route: route.route,
830
+ file: route.filePath,
831
+ fixable: false
832
+ });
833
+ }
834
+ }
835
+ }
836
+ }
837
+ if (!context.sitemapFound) {
838
+ findings.push({
839
+ id: "sitemap-missing",
840
+ rule: "crawlability",
841
+ severity: "error",
842
+ category: "technical",
843
+ message: "Missing sitemap.xml or app/sitemap.ts file.",
844
+ fixable: Boolean(context.config.siteUrl),
845
+ explanation: "An XML sitemap communicates your full URL catalog to Google, ensuring newly published routes are indexed quickly."
846
+ });
847
+ } else {
848
+ if (context.sitemapMalformed) {
849
+ findings.push({
850
+ id: "sitemap-malformed",
851
+ rule: "crawlability",
852
+ severity: "error",
853
+ category: "technical",
854
+ message: "sitemap.xml is malformed or contains no URL entries.",
855
+ fixable: Boolean(context.config.siteUrl)
856
+ });
857
+ }
858
+ const validRoutes = new Set(context.routes.map((r) => r.route));
859
+ for (const url of context.sitemapUrls) {
860
+ try {
861
+ const pathname = url.startsWith("http") ? new URL(url).pathname : url;
862
+ const clean = pathname.endsWith("/") && pathname.length > 1 ? pathname.slice(0, -1) : pathname;
863
+ if (!validRoutes.has(clean) && clean !== "") {
864
+ findings.push({
865
+ id: `sitemap-orphan-url-${clean}`,
866
+ rule: "crawlability",
867
+ severity: "warning",
868
+ category: "technical",
869
+ message: `Sitemap contains URL "${url}" which does not correspond to any known route.`,
870
+ fixable: false,
871
+ explanation: "Listing nonexistent routes in sitemaps wastes search engine crawl budgets."
872
+ });
873
+ }
874
+ } catch {
875
+ }
876
+ }
877
+ }
878
+ for (const route of context.routes) {
879
+ const robots = route.metadata.robots?.toLowerCase() || "";
880
+ if (robots.includes("noindex")) {
881
+ const listedInSitemap = context.sitemapUrls.some((url) => {
882
+ try {
883
+ return (url.startsWith("http") ? new URL(url).pathname : url).replace(/\/$/, "") === route.route.replace(/\/$/, "");
884
+ } catch {
885
+ return false;
886
+ }
887
+ });
888
+ if (listedInSitemap) {
889
+ findings.push({
890
+ id: `robots-noindex-sitemap-${route.route}`,
891
+ rule: "crawlability",
892
+ severity: "warning",
893
+ category: "technical",
894
+ message: `Route "${route.route}" is marked noindex but is also listed in the sitemap.`,
895
+ file: route.filePath,
896
+ route: route.route,
897
+ fixable: false
898
+ });
899
+ }
900
+ if (route.route === "/") {
901
+ findings.push({
902
+ id: "robots-noindex-homepage",
903
+ rule: "crawlability",
904
+ severity: "error",
905
+ category: "technical",
906
+ message: 'Homepage ("/") is configured with "noindex", blocking entire domain from search engines.',
907
+ file: route.filePath,
908
+ route: route.route,
909
+ fixable: false,
910
+ explanation: "Applying noindex to your homepage prevents Google from indexing your primary domain."
911
+ });
912
+ }
913
+ }
914
+ }
915
+ return findings;
916
+ }
917
+ };
918
+
919
+ // ../core/src/rules/structured-data.ts
920
+ var SUPPORTED_SCHEMAS = /* @__PURE__ */ new Set(["Organization", "WebSite", "Article", "BreadcrumbList", "Product", "SoftwareApplication", "FAQPage"]);
921
+ var structuredDataRule = {
922
+ id: "structured-data",
923
+ name: "JSON-LD Structured Data Validation",
924
+ category: "technical",
925
+ analyze(context) {
926
+ const findings = [];
927
+ for (const route of context.routes) {
928
+ const jsonLdList = route.metadata.jsonLd || [];
929
+ for (const item of jsonLdList) {
930
+ if (item.invalid === true) {
931
+ findings.push({
932
+ id: `jsonld-malformed-${route.route}`,
933
+ rule: "structured-data",
934
+ severity: "error",
935
+ category: "technical",
936
+ message: `JSON-LD block on route "${route.route}" is not valid JSON.`,
937
+ file: route.filePath,
938
+ route: route.route,
939
+ fixable: false
940
+ });
941
+ continue;
942
+ }
943
+ const contextVal = item["@context"];
944
+ if (!contextVal || !contextVal.includes("schema.org") && !contextVal.includes("https://schema.org")) {
945
+ findings.push({
946
+ id: `jsonld-context-missing-${route.route}`,
947
+ rule: "structured-data",
948
+ severity: "warning",
949
+ category: "technical",
950
+ message: `JSON-LD block on route "${route.route}" has missing or invalid @context. Expected "https://schema.org".`,
951
+ file: route.filePath,
952
+ route: route.route,
953
+ fixable: false
954
+ });
955
+ }
956
+ const typeVal = item["@type"];
957
+ if (!typeVal) {
958
+ findings.push({
959
+ id: `jsonld-type-missing-${route.route}`,
960
+ rule: "structured-data",
961
+ severity: "error",
962
+ category: "technical",
963
+ message: `JSON-LD structured data on route "${route.route}" is missing the @type field.`,
964
+ file: route.filePath,
965
+ route: route.route,
966
+ fixable: false
967
+ });
968
+ } else if (SUPPORTED_SCHEMAS.has(typeVal) && typeVal === "Article" && !item.headline) {
969
+ findings.push({
970
+ id: `jsonld-article-missing-headline-${route.route}`,
971
+ rule: "structured-data",
972
+ severity: "warning",
973
+ category: "technical",
974
+ message: `Article structured data on route "${route.route}" is missing recommended "headline" property.`,
975
+ file: route.filePath,
976
+ route: route.route,
977
+ fixable: false
978
+ });
979
+ } else if (SUPPORTED_SCHEMAS.has(typeVal) && ["Organization", "WebSite", "Product"].includes(typeVal) && !item.name) {
980
+ findings.push({
981
+ id: `jsonld-${typeVal.toLowerCase()}-missing-name-${route.route}`,
982
+ rule: "structured-data",
983
+ severity: "warning",
984
+ category: "technical",
985
+ message: `${typeVal} structured data on route "${route.route}" is missing the required "name" property.`,
986
+ file: route.filePath,
987
+ route: route.route,
988
+ fixable: false
989
+ });
990
+ } else if (SUPPORTED_SCHEMAS.has(typeVal) && typeVal === "BreadcrumbList" && !item.itemListElement) {
991
+ findings.push({
992
+ id: `jsonld-breadcrumb-missing-items-${route.route}`,
993
+ rule: "structured-data",
994
+ severity: "warning",
995
+ category: "technical",
996
+ message: `BreadcrumbList structured data on route "${route.route}" has no itemListElement entries.`,
997
+ file: route.filePath,
998
+ route: route.route,
999
+ fixable: false
1000
+ });
1001
+ }
1002
+ }
1003
+ }
1004
+ return findings;
1005
+ }
1006
+ };
1007
+
1008
+ // ../core/src/rules/routes.ts
1009
+ var routesRule = {
1010
+ id: "routes",
1011
+ name: "Route Consistency",
1012
+ category: "technical",
1013
+ analyze(context) {
1014
+ const findings = [];
1015
+ const seen = /* @__PURE__ */ new Map();
1016
+ for (const route of context.routes) {
1017
+ const existing = seen.get(route.route);
1018
+ if (existing) {
1019
+ findings.push({
1020
+ id: `route-duplicate-${route.route}-${route.filePath}`,
1021
+ rule: "routes",
1022
+ severity: "error",
1023
+ category: "technical",
1024
+ message: `Route "${route.route}" is defined by both "${existing}" and "${route.filePath}".`,
1025
+ file: route.filePath,
1026
+ route: route.route,
1027
+ fixable: false
1028
+ });
1029
+ } else {
1030
+ seen.set(route.route, route.filePath);
1031
+ }
1032
+ }
1033
+ return findings;
1034
+ }
1035
+ };
1036
+
1037
+ // ../core/src/rules/metadata-conflicts.ts
1038
+ var metadataConflictsRule = {
1039
+ id: "metadata-conflicts",
1040
+ name: "Conflicting Metadata Declarations",
1041
+ category: "metadata",
1042
+ analyze(context) {
1043
+ return context.routes.filter((route) => route.metadata.hasConflictingDeclarations).map((route) => ({
1044
+ id: `metadata-conflict-${route.route}`,
1045
+ rule: "metadata-conflicts",
1046
+ severity: "warning",
1047
+ category: "metadata",
1048
+ message: `Route "${route.route}" contains multiple metadata declaration mechanisms.`,
1049
+ file: route.filePath,
1050
+ route: route.route,
1051
+ fixable: false,
1052
+ explanation: "Mixing Metadata exports, generateMetadata, and next/head can produce conflicting search metadata."
1053
+ }));
1054
+ }
1055
+ };
1056
+
1057
+ // ../core/src/rules/redirects.ts
1058
+ var redirectsRule = {
1059
+ id: "redirects",
1060
+ name: "Redirect Consistency",
1061
+ category: "technical",
1062
+ analyze(context) {
1063
+ const findings = [];
1064
+ const redirects = new Map((context.redirects || []).map((redirect) => [redirect.source, redirect.destination]));
1065
+ for (const [source, destination] of redirects) {
1066
+ if (source === destination) {
1067
+ findings.push({
1068
+ id: `redirect-self-loop-${source}`,
1069
+ rule: "redirects",
1070
+ severity: "error",
1071
+ category: "technical",
1072
+ message: `Redirect "${source}" points to itself.`,
1073
+ route: source,
1074
+ fixable: false
1075
+ });
1076
+ continue;
1077
+ }
1078
+ const visited = /* @__PURE__ */ new Set([source]);
1079
+ let current = destination;
1080
+ while (current && redirects.has(current)) {
1081
+ if (visited.has(current)) {
1082
+ findings.push({
1083
+ id: `redirect-loop-${source}`,
1084
+ rule: "redirects",
1085
+ severity: "error",
1086
+ category: "technical",
1087
+ message: `Redirect starting at "${source}" forms a loop.`,
1088
+ route: source,
1089
+ fixable: false
1090
+ });
1091
+ break;
1092
+ }
1093
+ visited.add(current);
1094
+ current = redirects.get(current);
1095
+ }
1096
+ }
1097
+ return findings;
1098
+ }
1099
+ };
1100
+
1101
+ // ../core/src/rules/index.ts
1102
+ var allRules = [
1103
+ metadataTitleRule,
1104
+ metadataDescriptionRule,
1105
+ metadataConflictsRule,
1106
+ canonicalRule,
1107
+ headingsRule,
1108
+ imagesRule,
1109
+ linksRule,
1110
+ crawlabilityRule,
1111
+ structuredDataRule,
1112
+ routesRule,
1113
+ redirectsRule
1114
+ ];
1115
+
1116
+ // ../core/src/scoring.ts
1117
+ var PENALTIES = {
1118
+ error: 15,
1119
+ warning: 6,
1120
+ info: 2
1121
+ };
1122
+ var CATEGORY_WEIGHTS = {
1123
+ technical: 0.3,
1124
+ metadata: 0.3,
1125
+ content: 0.2,
1126
+ links: 0.2
1127
+ };
1128
+ var MAX_PENALTY_PER_RULE_AND_CATEGORY = 30;
1129
+ function calculateScore(findings) {
1130
+ const categoryPenalties = {
1131
+ technical: 0,
1132
+ metadata: 0,
1133
+ content: 0,
1134
+ links: 0
1135
+ };
1136
+ const groupedPenalties = /* @__PURE__ */ new Map();
1137
+ for (const finding of findings) {
1138
+ const key = `${finding.category}:${finding.rule}`;
1139
+ groupedPenalties.set(key, (groupedPenalties.get(key) || 0) + (PENALTIES[finding.severity] || 0));
1140
+ }
1141
+ for (const [key, penalty] of groupedPenalties) {
1142
+ const category = key.split(":", 1)[0];
1143
+ categoryPenalties[category] += Math.min(MAX_PENALTY_PER_RULE_AND_CATEGORY, penalty);
1144
+ }
1145
+ const breakdown = {
1146
+ technical: Math.max(0, 100 - categoryPenalties.technical),
1147
+ metadata: Math.max(0, 100 - categoryPenalties.metadata),
1148
+ content: Math.max(0, 100 - categoryPenalties.content),
1149
+ links: Math.max(0, 100 - categoryPenalties.links)
1150
+ };
1151
+ const weightedOverall = breakdown.technical * CATEGORY_WEIGHTS.technical + breakdown.metadata * CATEGORY_WEIGHTS.metadata + breakdown.content * CATEGORY_WEIGHTS.content + breakdown.links * CATEGORY_WEIGHTS.links;
1152
+ const overall = Math.round(Math.max(0, Math.min(100, weightedOverall)));
1153
+ return {
1154
+ overall,
1155
+ breakdown
1156
+ };
1157
+ }
1158
+
1159
+ // ../core/src/graph.ts
1160
+ function buildLinkGraph(routes, redirects = []) {
1161
+ const nodeSet = new Set(routes.map((r) => r.route));
1162
+ const nodes = Array.from(nodeSet);
1163
+ const edges = [];
1164
+ const incomingCount = {};
1165
+ const outgoingCount = {};
1166
+ const adjacencyList = {};
1167
+ for (const node of nodes) {
1168
+ incomingCount[node] = 0;
1169
+ outgoingCount[node] = 0;
1170
+ adjacencyList[node] = [];
1171
+ }
1172
+ const normalize = (path5) => path5.endsWith("/") && path5.length > 1 ? path5.slice(0, -1) : path5;
1173
+ const redirectMap = new Map(redirects.map((redirect) => [normalize(redirect.source), normalize(redirect.destination)]));
1174
+ for (const route of routes) {
1175
+ const source = route.route;
1176
+ for (const link of route.links) {
1177
+ if (!link.isInternal) continue;
1178
+ const cleanHref = normalize(link.href.split("?")[0].split("#")[0]);
1179
+ const resolvedTarget = redirectMap.get(cleanHref) || cleanHref;
1180
+ const targetExists = nodeSet.has(resolvedTarget);
1181
+ edges.push({
1182
+ source,
1183
+ target: resolvedTarget,
1184
+ anchorText: link.anchorText,
1185
+ isBroken: !targetExists
1186
+ });
1187
+ if (targetExists) {
1188
+ outgoingCount[source] = (outgoingCount[source] || 0) + 1;
1189
+ incomingCount[resolvedTarget] = (incomingCount[resolvedTarget] || 0) + 1;
1190
+ adjacencyList[source].push(resolvedTarget);
1191
+ }
1192
+ }
1193
+ }
1194
+ const depths = {};
1195
+ for (const node of nodes) {
1196
+ depths[node] = node === "/" ? 0 : -1;
1197
+ }
1198
+ if (nodeSet.has("/")) {
1199
+ const queue = ["/"];
1200
+ const visited = /* @__PURE__ */ new Set(["/"]);
1201
+ while (queue.length > 0) {
1202
+ const current = queue.shift();
1203
+ const currDepth = depths[current];
1204
+ for (const neighbor of adjacencyList[current] || []) {
1205
+ if (!visited.has(neighbor)) {
1206
+ visited.add(neighbor);
1207
+ depths[neighbor] = currDepth + 1;
1208
+ queue.push(neighbor);
1209
+ }
1210
+ }
1211
+ }
1212
+ }
1213
+ const metrics = {};
1214
+ const orphans = [];
1215
+ const deadEnds = [];
1216
+ const brokenEdges = edges.filter((e) => e.isBroken);
1217
+ const lowConnectivity = [];
1218
+ const highlyLinked = [];
1219
+ const highLinkThreshold = Math.max(10, Math.ceil(nodes.length * 0.8));
1220
+ for (const node of nodes) {
1221
+ const inc = incomingCount[node] || 0;
1222
+ const out = outgoingCount[node] || 0;
1223
+ const isOrphan = node !== "/" && inc === 0;
1224
+ const isDeadEnd = out === 0;
1225
+ if (isOrphan) orphans.push(node);
1226
+ if (isDeadEnd) deadEnds.push(node);
1227
+ if (node !== "/" && inc + out <= 1) lowConnectivity.push(node);
1228
+ if (inc >= highLinkThreshold) highlyLinked.push(node);
1229
+ metrics[node] = {
1230
+ route: node,
1231
+ incomingLinks: inc,
1232
+ outgoingLinks: out,
1233
+ depthFromHome: depths[node] ?? -1,
1234
+ isOrphan,
1235
+ isDeadEnd,
1236
+ centrality: nodes.length > 1 ? Number((inc / (nodes.length - 1)).toFixed(3)) : 0
1237
+ };
1238
+ }
1239
+ return {
1240
+ nodes,
1241
+ edges,
1242
+ metrics,
1243
+ orphans,
1244
+ deadEnds,
1245
+ brokenEdges,
1246
+ lowConnectivity,
1247
+ highlyLinked
1248
+ };
1249
+ }
1250
+
1251
+ // ../core/src/recommender.ts
1252
+ var STOP_WORDS = /* @__PURE__ */ new Set([
1253
+ "a",
1254
+ "an",
1255
+ "and",
1256
+ "are",
1257
+ "as",
1258
+ "at",
1259
+ "be",
1260
+ "by",
1261
+ "for",
1262
+ "from",
1263
+ "has",
1264
+ "he",
1265
+ "in",
1266
+ "is",
1267
+ "it",
1268
+ "its",
1269
+ "of",
1270
+ "on",
1271
+ "that",
1272
+ "the",
1273
+ "to",
1274
+ "was",
1275
+ "were",
1276
+ "will",
1277
+ "with",
1278
+ "the",
1279
+ "this",
1280
+ "but",
1281
+ "or",
1282
+ "yang",
1283
+ "dan",
1284
+ "di",
1285
+ "ke",
1286
+ "dari",
1287
+ "ini",
1288
+ "itu",
1289
+ "untuk",
1290
+ "pada",
1291
+ "adalah",
1292
+ "dengan",
1293
+ "page",
1294
+ "home",
1295
+ "about",
1296
+ "contact",
1297
+ "index"
1298
+ ]);
1299
+ function tokenize(text) {
1300
+ return text.toLowerCase().replace(/[^a-z0-9\s-]/g, " ").replace(/[-_]/g, " ").split(/\s+/).filter((word) => word.length >= 3 && !STOP_WORDS.has(word));
1301
+ }
1302
+ function extractRouteTokens(route) {
1303
+ const weights = /* @__PURE__ */ new Map();
1304
+ const addTokens = (words, weightMultiplier) => {
1305
+ for (const w of words) {
1306
+ weights.set(w, (weights.get(w) || 0) + weightMultiplier);
1307
+ }
1308
+ };
1309
+ const slugTokens = tokenize(route.route);
1310
+ addTokens(slugTokens, 4);
1311
+ if (route.metadata.title) {
1312
+ const titleTokens = tokenize(route.metadata.title);
1313
+ addTokens(titleTokens, 3);
1314
+ }
1315
+ for (const h of route.headings.filter((h2) => h2.level === 1)) {
1316
+ addTokens(tokenize(h.text), 2.5);
1317
+ }
1318
+ for (const h of route.headings.filter((h2) => h2.level > 1)) {
1319
+ addTokens(tokenize(h.text), 1.5);
1320
+ }
1321
+ if (route.textContent) {
1322
+ addTokens(tokenize(route.textContent), 1);
1323
+ }
1324
+ const tokens = Array.from(weights.keys());
1325
+ return { tokens, weights };
1326
+ }
1327
+ function generateLinkRecommendations(routes, minScore = 35) {
1328
+ const recommendations = [];
1329
+ const eligibleRoutes = routes.filter(
1330
+ (r) => !r.route.startsWith("/api") && !r.route.startsWith("/admin") && !r.route.startsWith("/_")
1331
+ );
1332
+ const routeProfiles = eligibleRoutes.map((route) => ({
1333
+ route,
1334
+ ...extractRouteTokens(route)
1335
+ }));
1336
+ const existingLinksMap = /* @__PURE__ */ new Map();
1337
+ for (const r of eligibleRoutes) {
1338
+ const set = /* @__PURE__ */ new Set();
1339
+ for (const link of r.links) {
1340
+ const clean = link.href.split("?")[0].split("#")[0];
1341
+ set.add(clean.endsWith("/") && clean.length > 1 ? clean.slice(0, -1) : clean);
1342
+ }
1343
+ existingLinksMap.set(r.route, set);
1344
+ }
1345
+ for (let i = 0; i < routeProfiles.length; i++) {
1346
+ const source = routeProfiles[i];
1347
+ const existing = existingLinksMap.get(source.route.route) || /* @__PURE__ */ new Set();
1348
+ for (let j = 0; j < routeProfiles.length; j++) {
1349
+ if (i === j) continue;
1350
+ const target = routeProfiles[j];
1351
+ if (existing.has(target.route.route)) continue;
1352
+ let intersectionScore = 0;
1353
+ const sharedKeywords = [];
1354
+ for (const [token, weight] of source.weights.entries()) {
1355
+ const targetWeight = target.weights.get(token);
1356
+ if (targetWeight !== void 0) {
1357
+ const overlap = Math.min(weight, targetWeight);
1358
+ intersectionScore += overlap;
1359
+ sharedKeywords.push(token);
1360
+ }
1361
+ }
1362
+ if (sharedKeywords.length === 0) continue;
1363
+ const maxPossible = Math.max(1, (source.tokens.length + target.tokens.length) * 1.5);
1364
+ const rawRatio = intersectionScore / maxPossible;
1365
+ const percentage = Math.min(96, Math.max(15, Math.round(rawRatio * 100)));
1366
+ if (percentage >= minScore) {
1367
+ sharedKeywords.sort((a, b) => {
1368
+ const scoreA = (source.weights.get(a) || 0) + (target.weights.get(a) || 0);
1369
+ const scoreB = (source.weights.get(b) || 0) + (target.weights.get(b) || 0);
1370
+ return scoreB - scoreA;
1371
+ });
1372
+ const topKeywords = sharedKeywords.slice(0, 3);
1373
+ const reason = `"${topKeywords.join('", "')}" appears prominently in route path and headings on both pages.`;
1374
+ recommendations.push({
1375
+ sourceRoute: source.route.route,
1376
+ targetRoute: target.route.route,
1377
+ score: percentage,
1378
+ reason,
1379
+ prominentKeywords: topKeywords
1380
+ });
1381
+ }
1382
+ }
1383
+ }
1384
+ return recommendations.sort((a, b) => b.score - a.score);
1385
+ }
1386
+
1387
+ // ../core/src/opportunities.ts
1388
+ var COMMON_NICHES = {
1389
+ invoice: ["freelancer", "agency", "consultant", "umkm", "startup", "contractor", "software-house", "creator"],
1390
+ tools: ["invoice-generator", "utm-builder", "meta-tag-generator", "qr-generator", "salary-calculator", "pdf-compressor"],
1391
+ templates: ["invoice", "quotation", "contract", "receipt", "proposal", "resume"],
1392
+ blog: ["tutorial", "case-study", "comparison", "guide", "best-practices", "review"],
1393
+ integrations: ["stripe", "midtrans", "xendit", "github", "slack", "notion"]
1394
+ };
1395
+ function detectContentOpportunities(routes, providedKeywords = {}) {
1396
+ const clusters = [];
1397
+ const prefixGroups = /* @__PURE__ */ new Map();
1398
+ for (const route of routes) {
1399
+ if (route.route === "/" || route.route.startsWith("/api") || route.route.startsWith("/admin")) {
1400
+ continue;
1401
+ }
1402
+ const segments = route.route.split("/").filter(Boolean);
1403
+ if (segments.length >= 2) {
1404
+ const prefix = `/${segments[0]}`;
1405
+ const subSegment = segments[1];
1406
+ const group = prefixGroups.get(prefix) || { fullRoutes: [], subSegments: [] };
1407
+ group.fullRoutes.push(route.route);
1408
+ group.subSegments.push(subSegment);
1409
+ prefixGroups.set(prefix, group);
1410
+ }
1411
+ }
1412
+ for (const [prefix, group] of prefixGroups.entries()) {
1413
+ const cleanPrefix = prefix.replace("/", "");
1414
+ const existingSubSegments = new Set(group.subSegments);
1415
+ const nicheKeywords = providedKeywords[cleanPrefix] || COMMON_NICHES[cleanPrefix] || [
1416
+ "starter-guide",
1417
+ "tips",
1418
+ "best-practices",
1419
+ "faq",
1420
+ "pricing-plans",
1421
+ "case-studies"
1422
+ ];
1423
+ const opportunities = nicheKeywords.filter((niche) => !existingSubSegments.has(niche)).map((niche) => `${prefix}/${niche}`);
1424
+ if (group.fullRoutes.length >= 2 || group.fullRoutes.length >= 1 && opportunities.length > 0) {
1425
+ const topicName = cleanPrefix.charAt(0).toUpperCase() + cleanPrefix.slice(1) + " Expansion Cluster";
1426
+ clusters.push({
1427
+ pattern: `${prefix}/{topic}`,
1428
+ topic: topicName,
1429
+ existingPages: group.fullRoutes,
1430
+ potentialOpportunities: opportunities.slice(0, 5)
1431
+ });
1432
+ }
1433
+ }
1434
+ return clusters;
1435
+ }
1436
+
1437
+ // ../core/src/fixer.ts
1438
+ import path2 from "node:path";
1439
+ import fs2 from "node:fs";
1440
+ function createUnifiedDiff(filename, oldText, newText) {
1441
+ const oldLines = oldText ? oldText.split("\n") : [];
1442
+ const newLines = newText ? newText.split("\n") : [];
1443
+ let prefix = 0;
1444
+ while (prefix < oldLines.length && prefix < newLines.length && oldLines[prefix] === newLines[prefix]) prefix += 1;
1445
+ let suffix = 0;
1446
+ while (suffix < oldLines.length - prefix && suffix < newLines.length - prefix && oldLines[oldLines.length - 1 - suffix] === newLines[newLines.length - 1 - suffix]) suffix += 1;
1447
+ const contextStart = Math.max(0, prefix - 3);
1448
+ const oldChangeEnd = oldLines.length - suffix;
1449
+ const newChangeEnd = newLines.length - suffix;
1450
+ const oldContextEnd = Math.min(oldLines.length, oldChangeEnd + 3);
1451
+ const newContextEnd = Math.min(newLines.length, newChangeEnd + 3);
1452
+ const oldCount = oldContextEnd - contextStart;
1453
+ const newCount = newContextEnd - contextStart;
1454
+ let diff = `--- a/${filename}
1455
+ +++ b/${filename}
1456
+ @@ -${contextStart + 1},${oldCount} +${contextStart + 1},${newCount} @@
1457
+ `;
1458
+ for (let index = contextStart; index < prefix; index += 1) diff += ` ${oldLines[index]}
1459
+ `;
1460
+ for (let index = prefix; index < oldChangeEnd; index += 1) diff += `-${oldLines[index]}
1461
+ `;
1462
+ for (let index = prefix; index < newChangeEnd; index += 1) diff += `+${newLines[index]}
1463
+ `;
1464
+ const suffixStart = oldLines.length - suffix;
1465
+ for (let index = suffixStart; index < Math.min(oldLines.length, suffixStart + 3); index += 1) {
1466
+ diff += ` ${oldLines[index]}
1467
+ `;
1468
+ }
1469
+ return diff;
1470
+ }
1471
+ async function applySafeFixes(options) {
1472
+ const { projectRoot, routes, findings, config, dryRun = false, isAppRouter = true } = options;
1473
+ const appliedChanges = [];
1474
+ const skippedFindings = [];
1475
+ const scoreBefore = calculateScore(findings);
1476
+ let siteUrl;
1477
+ if (config.siteUrl) {
1478
+ try {
1479
+ const parsedSiteUrl = new URL(config.siteUrl);
1480
+ if (parsedSiteUrl.protocol === "http:" || parsedSiteUrl.protocol === "https:") {
1481
+ siteUrl = config.siteUrl.replace(/\/$/, "");
1482
+ }
1483
+ } catch {
1484
+ siteUrl = void 0;
1485
+ }
1486
+ }
1487
+ const validRoutes = routes.filter((r) => !r.hasDynamicSegments).map((r) => r.route);
1488
+ const fixedFindingIds = /* @__PURE__ */ new Set();
1489
+ const virtualFiles = /* @__PURE__ */ new Map();
1490
+ const appDirectory = fs2.existsSync(path2.join(projectRoot, "app")) ? path2.join(projectRoot, "app") : path2.join(projectRoot, "src", "app");
1491
+ const safeFile = (candidate) => {
1492
+ const root = path2.resolve(projectRoot);
1493
+ const resolved = path2.resolve(candidate);
1494
+ return resolved === root || resolved.startsWith(`${root}${path2.sep}`) ? resolved : null;
1495
+ };
1496
+ const readCurrent = (candidate) => {
1497
+ if (virtualFiles.has(candidate)) return virtualFiles.get(candidate);
1498
+ return fs2.existsSync(candidate) ? fs2.readFileSync(candidate, "utf8") : "";
1499
+ };
1500
+ const recordChange = (change, findingId) => {
1501
+ appliedChanges.push(change);
1502
+ fixedFindingIds.add(findingId);
1503
+ virtualFiles.set(change.filePath, change.newContent);
1504
+ if (!dryRun) {
1505
+ fs2.mkdirSync(path2.dirname(change.filePath), { recursive: true });
1506
+ fs2.writeFileSync(change.filePath, change.newContent, "utf8");
1507
+ }
1508
+ };
1509
+ const robotsFinding = findings.find((f) => f.rule === "crawlability" && f.id === "robots-missing");
1510
+ if (robotsFinding) {
1511
+ if (isAppRouter) {
1512
+ const robotsFilePath = path2.join(appDirectory, "robots.ts");
1513
+ const relativePath = path2.relative(projectRoot, robotsFilePath);
1514
+ const oldContent = fs2.existsSync(robotsFilePath) ? fs2.readFileSync(robotsFilePath, "utf8") : "";
1515
+ const sitemapLine = siteUrl ? `
1516
+ sitemap: '${siteUrl}/sitemap.xml',` : "";
1517
+ const newContent = `import { MetadataRoute } from 'next';
1518
+
1519
+ export default function robots(): MetadataRoute.Robots {
1520
+ return {
1521
+ rules: {
1522
+ userAgent: '*',
1523
+ allow: '/',
1524
+ disallow: ['/api/', '/admin/'],
1525
+ },
1526
+ ${sitemapLine.trimStart()}
1527
+ };
1528
+ }
1529
+ `;
1530
+ recordChange({
1531
+ filePath: robotsFilePath,
1532
+ originalContent: oldContent,
1533
+ newContent,
1534
+ diff: createUnifiedDiff(relativePath, oldContent, newContent),
1535
+ description: "Generated app/robots.ts with standard crawler rules and sitemap reference."
1536
+ }, robotsFinding.id);
1537
+ } else {
1538
+ const robotsFilePath = path2.join(projectRoot, "public", "robots.txt");
1539
+ const relativePath = "public/robots.txt";
1540
+ const oldContent = fs2.existsSync(robotsFilePath) ? fs2.readFileSync(robotsFilePath, "utf8") : "";
1541
+ const newContent = `User-agent: *
1542
+ Allow: /
1543
+ Disallow: /api/
1544
+ Disallow: /admin/
1545
+ ${siteUrl ? `
1546
+ Sitemap: ${siteUrl}/sitemap.xml
1547
+ ` : ""}`;
1548
+ recordChange({
1549
+ filePath: robotsFilePath,
1550
+ originalContent: oldContent,
1551
+ newContent,
1552
+ diff: createUnifiedDiff(relativePath, oldContent, newContent),
1553
+ description: "Generated public/robots.txt with standard crawler rules and sitemap pointer."
1554
+ }, robotsFinding.id);
1555
+ }
1556
+ }
1557
+ const sitemapFinding = findings.find(
1558
+ (f) => f.rule === "crawlability" && (f.id === "sitemap-missing" || f.id === "sitemap-malformed")
1559
+ );
1560
+ if (sitemapFinding && siteUrl) {
1561
+ if (isAppRouter) {
1562
+ const sitemapFilePath = path2.join(appDirectory, "sitemap.ts");
1563
+ const relativePath = path2.relative(projectRoot, sitemapFilePath);
1564
+ const oldContent = fs2.existsSync(sitemapFilePath) ? fs2.readFileSync(sitemapFilePath, "utf8") : "";
1565
+ const routeEntries = validRoutes.map(
1566
+ (r) => ` {
1567
+ url: '${siteUrl}${r === "/" ? "" : r}',
1568
+ lastModified: new Date(),
1569
+ changeFrequency: '${r === "/" ? "daily" : "weekly"}',
1570
+ priority: ${r === "/" ? 1 : 0.8},
1571
+ }`
1572
+ ).join(",\n");
1573
+ const newContent = `import { MetadataRoute } from 'next';
1574
+
1575
+ export default function sitemap(): MetadataRoute.Sitemap {
1576
+ return [
1577
+ ${routeEntries}
1578
+ ];
1579
+ }
1580
+ `;
1581
+ recordChange({
1582
+ filePath: sitemapFilePath,
1583
+ originalContent: oldContent,
1584
+ newContent,
1585
+ diff: createUnifiedDiff(relativePath, oldContent, newContent),
1586
+ description: `Generated app/sitemap.ts containing ${validRoutes.length} discovered routes.`
1587
+ }, sitemapFinding.id);
1588
+ } else {
1589
+ const sitemapFilePath = path2.join(projectRoot, "public", "sitemap.xml");
1590
+ const relativePath = "public/sitemap.xml";
1591
+ const oldContent = fs2.existsSync(sitemapFilePath) ? fs2.readFileSync(sitemapFilePath, "utf8") : "";
1592
+ const xmlEntries = validRoutes.map(
1593
+ (r) => ` <url>
1594
+ <loc>${siteUrl}${r === "/" ? "" : r}</loc>
1595
+ <changefreq>${r === "/" ? "daily" : "weekly"}</changefreq>
1596
+ <priority>${r === "/" ? "1.0" : "0.8"}</priority>
1597
+ </url>`
1598
+ ).join("\n");
1599
+ const newContent = `<?xml version="1.0" encoding="UTF-8"?>
1600
+ <urlset xmlns="http://www.sitemaps.org/schemas/sitemap/0.9">
1601
+ ${xmlEntries}
1602
+ </urlset>
1603
+ `;
1604
+ recordChange({
1605
+ filePath: sitemapFilePath,
1606
+ originalContent: oldContent,
1607
+ newContent,
1608
+ diff: createUnifiedDiff(relativePath, oldContent, newContent),
1609
+ description: `Generated public/sitemap.xml containing ${validRoutes.length} discovered routes.`
1610
+ }, sitemapFinding.id);
1611
+ }
1612
+ }
1613
+ if (siteUrl) {
1614
+ const canonicalFindings = findings.filter((f) => f.rule === "canonical" && f.fixable && f.file);
1615
+ for (const finding of canonicalFindings) {
1616
+ const findingFile = finding.file ? safeFile(finding.file) : null;
1617
+ if (!findingFile || !fs2.existsSync(findingFile)) continue;
1618
+ const fileContent = readCurrent(findingFile);
1619
+ const route = finding.route || "/";
1620
+ const canonicalUrl = `${siteUrl}${route === "/" ? "" : route}`;
1621
+ if (fileContent.includes("export const metadata")) {
1622
+ if (!fileContent.includes("alternates")) {
1623
+ const updatedContent = fileContent.replace(
1624
+ /(export\s+const\s+metadata(?:\s*:\s*Metadata)?\s*=\s*\{)/,
1625
+ `$1
1626
+ alternates: {
1627
+ canonical: '${canonicalUrl}',
1628
+ },`
1629
+ );
1630
+ if (updatedContent !== fileContent) {
1631
+ recordChange({
1632
+ filePath: findingFile,
1633
+ originalContent: fileContent,
1634
+ newContent: updatedContent,
1635
+ diff: createUnifiedDiff(path2.relative(projectRoot, findingFile), fileContent, updatedContent),
1636
+ description: `Added canonical alternate "${canonicalUrl}" to metadata in ${path2.basename(findingFile)}.`
1637
+ }, finding.id);
1638
+ }
1639
+ }
1640
+ } else if (fileContent.includes("export default function")) {
1641
+ const metadataBoilerplate = `import type { Metadata } from 'next';
1642
+
1643
+ export const metadata: Metadata = {
1644
+ alternates: {
1645
+ canonical: '${canonicalUrl}',
1646
+ },
1647
+ };
1648
+
1649
+ `;
1650
+ const updatedContent = metadataBoilerplate + fileContent;
1651
+ recordChange({
1652
+ filePath: findingFile,
1653
+ originalContent: fileContent,
1654
+ newContent: updatedContent,
1655
+ diff: createUnifiedDiff(path2.relative(projectRoot, findingFile), fileContent, updatedContent),
1656
+ description: `Injected canonical metadata export in ${path2.basename(findingFile)}.`
1657
+ }, finding.id);
1658
+ }
1659
+ }
1660
+ }
1661
+ const fixableLinkFindings = findings.filter((f) => f.rule === "links" && f.fixable && f.file);
1662
+ for (const lf of fixableLinkFindings) {
1663
+ const linkFile = lf.file ? safeFile(lf.file) : null;
1664
+ if (!linkFile || !fs2.existsSync(linkFile)) continue;
1665
+ const fileContent = readCurrent(linkFile);
1666
+ const match = lf.message.match(/nonexistent route "([^"]+)"/);
1667
+ if (match) {
1668
+ const brokenHref = match[1];
1669
+ const normalizedTarget = validRoutes.find(
1670
+ (r) => r.toLowerCase() === brokenHref.toLowerCase()
1671
+ );
1672
+ if (normalizedTarget) {
1673
+ const escapedHref = brokenHref.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
1674
+ const updatedContent = fileContent.replace(
1675
+ new RegExp(`href=["']${escapedHref}["']`, "g"),
1676
+ `href="${normalizedTarget}"`
1677
+ );
1678
+ if (updatedContent !== fileContent) {
1679
+ recordChange({
1680
+ filePath: linkFile,
1681
+ originalContent: fileContent,
1682
+ newContent: updatedContent,
1683
+ diff: createUnifiedDiff(path2.relative(projectRoot, linkFile), fileContent, updatedContent),
1684
+ description: `Fixed casing of internal link: "${brokenHref}" -> "${normalizedTarget}".`
1685
+ }, lf.id);
1686
+ }
1687
+ }
1688
+ }
1689
+ }
1690
+ for (const f of findings) {
1691
+ if (!fixedFindingIds.has(f.id)) {
1692
+ skippedFindings.push(f);
1693
+ }
1694
+ }
1695
+ const remainingFindings = findings.filter((f) => !fixedFindingIds.has(f.id));
1696
+ const scoreAfter = calculateScore(remainingFindings);
1697
+ return {
1698
+ appliedChanges,
1699
+ skippedFindings,
1700
+ scoreBefore,
1701
+ scoreAfter,
1702
+ dryRun
1703
+ };
1704
+ }
1705
+
1706
+ // ../core/src/engine.ts
1707
+ function runSEOAudit(options) {
1708
+ const {
1709
+ routes,
1710
+ config = {},
1711
+ sitemapFound = false,
1712
+ robotsFound = false,
1713
+ robotsContent = "",
1714
+ sitemapUrls = [],
1715
+ sitemapMalformed = false,
1716
+ redirects = [],
1717
+ projectRoot = "",
1718
+ isAppRouter = true
1719
+ } = options;
1720
+ const ignored = config.ignore || ["/admin/**", "/api/**"];
1721
+ const matchesIgnore = (route, pattern) => {
1722
+ const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, ".*").replace(/\*/g, "[^/]*");
1723
+ return new RegExp(`^${escaped}$`).test(route);
1724
+ };
1725
+ const analyzedRoutes = routes.filter((route) => !ignored.some((pattern) => matchesIgnore(route.route, pattern))).sort((a, b) => a.route.localeCompare(b.route));
1726
+ const ruleContext = {
1727
+ routes: analyzedRoutes,
1728
+ config,
1729
+ sitemapFound,
1730
+ robotsFound,
1731
+ robotsContent,
1732
+ sitemapUrls,
1733
+ sitemapMalformed,
1734
+ redirects,
1735
+ projectRoot,
1736
+ isAppRouter
1737
+ };
1738
+ const ruleAliases = {
1739
+ canonical: "requireCanonical",
1740
+ "metadata-description": "requireDescription"
1741
+ };
1742
+ const ruleSetting = (ruleId) => config.rules?.[ruleId] ?? (ruleAliases[ruleId] ? config.rules?.[ruleAliases[ruleId]] : void 0);
1743
+ const findings = allRules.filter((rule) => ruleSetting(rule.id) !== false).flatMap((rule) => {
1744
+ const results = rule.analyze(ruleContext);
1745
+ const configured = ruleSetting(rule.id);
1746
+ if (configured && typeof configured === "object" && configured.severity) {
1747
+ return results.map((finding) => ({ ...finding, severity: configured.severity }));
1748
+ }
1749
+ return results;
1750
+ }).sort((a, b) => a.id.localeCompare(b.id));
1751
+ const score = calculateScore(findings);
1752
+ const linkGraph = buildLinkGraph(analyzedRoutes, redirects);
1753
+ const recommendations = generateLinkRecommendations(analyzedRoutes);
1754
+ const opportunities = detectContentOpportunities(analyzedRoutes, config.opportunityKeywords);
1755
+ return {
1756
+ score,
1757
+ findings,
1758
+ routes: analyzedRoutes,
1759
+ linkGraph,
1760
+ recommendations,
1761
+ opportunities,
1762
+ timestamp: (/* @__PURE__ */ new Date()).toISOString(),
1763
+ engineVersion: "0.1.0"
1764
+ };
1765
+ }
1766
+
1767
+ // src/formatter.ts
1768
+ import path3 from "node:path";
1769
+ var colors = {
1770
+ reset: "\x1B[0m",
1771
+ bold: "\x1B[1m",
1772
+ dim: "\x1B[2m",
1773
+ red: "\x1B[31m",
1774
+ green: "\x1B[32m",
1775
+ yellow: "\x1B[33m",
1776
+ blue: "\x1B[34m",
1777
+ cyan: "\x1B[36m",
1778
+ bgRed: "\x1B[41m",
1779
+ bgGreen: "\x1B[42m"
1780
+ };
1781
+ function formatScoreBadge(score) {
1782
+ if (score >= 85) return `${colors.green}${colors.bold}${score}/100${colors.reset}`;
1783
+ if (score >= 70) return `${colors.yellow}${colors.bold}${score}/100${colors.reset}`;
1784
+ return `${colors.red}${colors.bold}${score}/100${colors.reset}`;
1785
+ }
1786
+ function printAuditReport(result, projectRoot) {
1787
+ const { score, findings, routes } = result;
1788
+ console.log("\n" + colors.cyan + colors.bold + "\u{1F50D} Crawlemon Audit" + colors.reset + ` (${routes.length} routes scanned)`);
1789
+ console.log(colors.dim + "\u2500".repeat(50) + colors.reset);
1790
+ console.log(`
1791
+ SEO Score: ${formatScoreBadge(score.overall)}`);
1792
+ console.log(` ${colors.dim}Technical: ${score.breakdown.technical.toString().padEnd(4)} Metadata: ${score.breakdown.metadata}`);
1793
+ console.log(` Content: ${score.breakdown.content.toString().padEnd(4)} Internal Links: ${score.breakdown.links}${colors.reset}
1794
+ `);
1795
+ if (findings.length === 0) {
1796
+ console.log(`${colors.green}\u2713 All checks passed! No SEO regressions or issues found.${colors.reset}
1797
+ `);
1798
+ return;
1799
+ }
1800
+ const errors = findings.filter((f) => f.severity === "error");
1801
+ const warnings = findings.filter((f) => f.severity === "warning");
1802
+ const infos = findings.filter((f) => f.severity === "info");
1803
+ const fixable = findings.filter((f) => f.fixable);
1804
+ console.log(
1805
+ `Found ${colors.red}${errors.length} errors${colors.reset}, ${colors.yellow}${warnings.length} warnings${colors.reset}, ${colors.blue}${infos.length} notices${colors.reset} (${colors.green}${fixable.length} safe auto-fixes available${colors.reset})
1806
+ `
1807
+ );
1808
+ for (const f of findings) {
1809
+ let icon = colors.blue + "\u2139" + colors.reset;
1810
+ if (f.severity === "error") icon = colors.red + "\u2716" + colors.reset;
1811
+ if (f.severity === "warning") icon = colors.yellow + "\u26A0" + colors.reset;
1812
+ const fixTag = f.fixable ? ` ${colors.green}[fixable]${colors.reset}` : "";
1813
+ const routeTag = f.route ? ` ${colors.cyan}(${f.route})${colors.reset}` : "";
1814
+ const relativeFile = f.file ? path3.relative(projectRoot, f.file) : "";
1815
+ const fileTag = relativeFile ? `
1816
+ ${colors.dim}${relativeFile}${f.line ? `:${f.line}` : ""}${colors.reset}` : "";
1817
+ console.log(` ${icon} ${f.message}${fixTag}${routeTag}${fileTag}`);
1818
+ }
1819
+ console.log("\n" + colors.dim + "\u2500".repeat(50) + colors.reset);
1820
+ if (fixable.length > 0) {
1821
+ console.log(`Run ${colors.bold}npx crawlemon fix${colors.reset} to automatically apply safe deterministic fixes.
1822
+ `);
1823
+ }
1824
+ }
1825
+ function printFixReport(result) {
1826
+ const { appliedChanges, skippedFindings, scoreBefore, scoreAfter, dryRun } = result;
1827
+ console.log(
1828
+ "\n" + (dryRun ? colors.yellow + "\u{1F9EA} Crawlemon Fix (Dry Run)" : colors.green + "\u2728 Crawlemon Fix") + colors.reset
1829
+ );
1830
+ console.log(colors.dim + "\u2500".repeat(50) + colors.reset);
1831
+ console.log(`
1832
+ Score Before: ${formatScoreBadge(scoreBefore.overall)}`);
1833
+ console.log(` Score After: ${formatScoreBadge(scoreAfter.overall)}
1834
+ `);
1835
+ if (appliedChanges.length === 0) {
1836
+ console.log("No safe automatic fixes were applicable.\n");
1837
+ } else {
1838
+ console.log(`${colors.green}\u2713 ${appliedChanges.length} safe fixes ${dryRun ? "would be applied" : "applied"}:${colors.reset}
1839
+ `);
1840
+ for (const change of appliedChanges) {
1841
+ console.log(` \u2022 ${colors.bold}${change.description}${colors.reset}`);
1842
+ if (change.diff) {
1843
+ console.log(colors.dim + change.diff.split("\n").map((l) => " " + l).join("\n") + colors.reset);
1844
+ }
1845
+ }
1846
+ }
1847
+ if (skippedFindings.length > 0) {
1848
+ console.log(`
1849
+ ${colors.yellow}\u26A0 ${skippedFindings.length} issues require manual review.${colors.reset}
1850
+ `);
1851
+ }
1852
+ }
1853
+ function printLinkGraphReport(graph, recommendations) {
1854
+ console.log("\n" + colors.cyan + colors.bold + "\u{1F310} Internal Link Graph & Orphans" + colors.reset);
1855
+ console.log(colors.dim + "\u2500".repeat(50) + colors.reset);
1856
+ console.log(`
1857
+ Routes Analyzed: ${graph.nodes.length}`);
1858
+ console.log(` Internal Edges: ${graph.edges.length}`);
1859
+ console.log(` Orphan Routes: ${graph.orphans.length > 0 ? colors.red + graph.orphans.length : colors.green + "0"}${colors.reset}`);
1860
+ console.log(` Broken Edges: ${graph.brokenEdges.length > 0 ? colors.red + graph.brokenEdges.length : colors.green + "0"}${colors.reset}
1861
+ `);
1862
+ console.log(` Dead-end Routes: ${graph.deadEnds.length}`);
1863
+ console.log(` Low Connectivity: ${graph.lowConnectivity.length}`);
1864
+ console.log(` Highly Linked: ${graph.highlyLinked.length}
1865
+ `);
1866
+ if (graph.orphans.length > 0) {
1867
+ console.log(`${colors.red}\u26A0 Orphan Pages (No incoming links):${colors.reset}`);
1868
+ for (const orphan of graph.orphans) {
1869
+ console.log(` \u2022 ${orphan}`);
1870
+ }
1871
+ console.log("");
1872
+ }
1873
+ if (recommendations.length > 0) {
1874
+ console.log(`${colors.green}\u{1F4A1} Recommended Internal Links (Non-AI, TF-IDF Term Overlap):${colors.reset}
1875
+ `);
1876
+ for (const rec of recommendations.slice(0, 10)) {
1877
+ console.log(` ${colors.bold}${rec.sourceRoute}${colors.reset} \u2500\u2500(${rec.score}%)\u2500\u2500> ${colors.cyan}${rec.targetRoute}${colors.reset}`);
1878
+ console.log(` ${colors.dim}Reason: ${rec.reason}${colors.reset}
1879
+ `);
1880
+ }
1881
+ }
1882
+ }
1883
+ function printOpportunitiesReport(clusters) {
1884
+ console.log("\n" + colors.cyan + colors.bold + "\u{1F680} Content Clusters & Expansion Opportunities" + colors.reset);
1885
+ console.log(colors.dim + "\u2500".repeat(50) + colors.reset);
1886
+ if (clusters.length === 0) {
1887
+ console.log("\nNo repeated topic/slug patterns detected yet. Add more topic pages to generate expansion clusters.\n");
1888
+ return;
1889
+ }
1890
+ for (const c of clusters) {
1891
+ console.log(`
1892
+ ${colors.bold}Cluster: ${c.topic}${colors.reset} (${colors.dim}${c.pattern}${colors.reset})`);
1893
+ console.log(` Existing Pages (${c.existingPages.length}):`);
1894
+ for (const p of c.existingPages) {
1895
+ console.log(` \u2713 ${p}`);
1896
+ }
1897
+ console.log(` Suggested Expansion Opportunities (${c.potentialOpportunities.length}):`);
1898
+ for (const opp of c.potentialOpportunities) {
1899
+ console.log(` + ${colors.green}${opp}${colors.reset}`);
1900
+ }
1901
+ }
1902
+ console.log("");
1903
+ }
1904
+
1905
+ // src/index.ts
1906
+ function checkSecretRisk(projectRoot) {
1907
+ const envCandidates = fs3.readdirSync(projectRoot).filter((name) => name !== ".env.example" && (name === ".env" || name.startsWith(".env.")));
1908
+ const gitignorePath = path4.join(projectRoot, ".gitignore");
1909
+ let gitignoreContent = "";
1910
+ if (fs3.existsSync(gitignorePath)) {
1911
+ gitignoreContent = fs3.readFileSync(gitignorePath, "utf8");
1912
+ }
1913
+ for (const env of envCandidates) {
1914
+ const envPath = path4.join(projectRoot, env);
1915
+ if (fs3.existsSync(envPath)) {
1916
+ const tracked = spawnSync("git", ["ls-files", "--error-unmatch", "--", env], {
1917
+ cwd: projectRoot,
1918
+ stdio: "ignore"
1919
+ }).status === 0;
1920
+ if (tracked || !gitignoreContent.includes(env) && !gitignoreContent.includes(".env*")) {
1921
+ console.warn(`\x1B[33m\u26A0 Potential secret risk: "${env}" exists but may not be ignored in .gitignore.\x1B[0m`);
1922
+ }
1923
+ }
1924
+ }
1925
+ }
1926
+ async function runCli(args) {
1927
+ const command = args[0] || "audit";
1928
+ const projectRoot = process.cwd();
1929
+ if (command === "--help" || command === "help") {
1930
+ console.log("Crawlemon \u2014 deterministic SEO CI for Next.js\n\nCommands:\n audit\n fix [--dry-run]\n links\n opportunities");
1931
+ return;
1932
+ }
1933
+ if (command === "--version" || command === "-v") {
1934
+ console.log("0.1.0");
1935
+ return;
1936
+ }
1937
+ checkSecretRisk(projectRoot);
1938
+ const isNext = await NextJsAdapter.detect(projectRoot);
1939
+ if (!isNext) {
1940
+ console.error(`\x1B[31m\u2716 Error: No Next.js application detected in "${projectRoot}".\x1B[0m`);
1941
+ process.exit(1);
1942
+ }
1943
+ const scanData = await NextJsAdapter.scan(projectRoot);
1944
+ const auditResult = runSEOAudit({
1945
+ routes: scanData.routes,
1946
+ config: scanData.config,
1947
+ sitemapFound: scanData.sitemapFound,
1948
+ robotsFound: scanData.robotsFound,
1949
+ robotsContent: scanData.robotsContent,
1950
+ sitemapUrls: scanData.sitemapUrls,
1951
+ sitemapMalformed: scanData.sitemapMalformed,
1952
+ redirects: scanData.redirects,
1953
+ projectRoot: scanData.projectRoot,
1954
+ isAppRouter: scanData.isAppRouter
1955
+ });
1956
+ if (command === "audit") {
1957
+ printAuditReport(auditResult, projectRoot);
1958
+ const hasErrors = auditResult.findings.some((f) => f.severity === "error");
1959
+ if (hasErrors && process.env.CI) {
1960
+ process.exit(1);
1961
+ }
1962
+ } else if (command === "fix") {
1963
+ const dryRun = args.includes("--dry-run");
1964
+ const fixResult = await applySafeFixes({
1965
+ projectRoot,
1966
+ routes: scanData.routes,
1967
+ findings: auditResult.findings,
1968
+ config: scanData.config,
1969
+ dryRun,
1970
+ isAppRouter: scanData.isAppRouter
1971
+ });
1972
+ printFixReport(fixResult);
1973
+ } else if (command === "links") {
1974
+ printLinkGraphReport(auditResult.linkGraph, auditResult.recommendations);
1975
+ } else if (command === "opportunities") {
1976
+ printOpportunitiesReport(auditResult.opportunities);
1977
+ } else {
1978
+ console.log(`
1979
+ Unknown command: "${command}"`);
1980
+ console.log("Available commands: audit, fix [--dry-run], links, opportunities\n");
1981
+ process.exit(1);
1982
+ }
1983
+ }
1984
+ export {
1985
+ runCli
1986
+ };