@djangocfg/seo 2.1.50

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (68) hide show
  1. package/README.md +192 -0
  2. package/dist/cli.d.ts +1 -0
  3. package/dist/cli.mjs +3780 -0
  4. package/dist/cli.mjs.map +1 -0
  5. package/dist/crawler/index.d.ts +88 -0
  6. package/dist/crawler/index.mjs +610 -0
  7. package/dist/crawler/index.mjs.map +1 -0
  8. package/dist/google-console/index.d.ts +95 -0
  9. package/dist/google-console/index.mjs +539 -0
  10. package/dist/google-console/index.mjs.map +1 -0
  11. package/dist/index.d.ts +285 -0
  12. package/dist/index.mjs +3236 -0
  13. package/dist/index.mjs.map +1 -0
  14. package/dist/link-checker/index.d.ts +76 -0
  15. package/dist/link-checker/index.mjs +326 -0
  16. package/dist/link-checker/index.mjs.map +1 -0
  17. package/dist/markdown-report-B3QdDzxE.d.ts +193 -0
  18. package/dist/reports/index.d.ts +24 -0
  19. package/dist/reports/index.mjs +836 -0
  20. package/dist/reports/index.mjs.map +1 -0
  21. package/dist/routes/index.d.ts +69 -0
  22. package/dist/routes/index.mjs +372 -0
  23. package/dist/routes/index.mjs.map +1 -0
  24. package/dist/scanner-Cz4Th2Pt.d.ts +60 -0
  25. package/dist/types/index.d.ts +144 -0
  26. package/dist/types/index.mjs +3 -0
  27. package/dist/types/index.mjs.map +1 -0
  28. package/package.json +114 -0
  29. package/src/analyzer.ts +256 -0
  30. package/src/cli/commands/audit.ts +260 -0
  31. package/src/cli/commands/content.ts +180 -0
  32. package/src/cli/commands/crawl.ts +32 -0
  33. package/src/cli/commands/index.ts +12 -0
  34. package/src/cli/commands/inspect.ts +60 -0
  35. package/src/cli/commands/links.ts +41 -0
  36. package/src/cli/commands/robots.ts +36 -0
  37. package/src/cli/commands/routes.ts +126 -0
  38. package/src/cli/commands/sitemap.ts +48 -0
  39. package/src/cli/index.ts +149 -0
  40. package/src/cli/types.ts +40 -0
  41. package/src/config.ts +207 -0
  42. package/src/content/index.ts +51 -0
  43. package/src/content/link-checker.ts +182 -0
  44. package/src/content/link-fixer.ts +188 -0
  45. package/src/content/scanner.ts +200 -0
  46. package/src/content/sitemap-generator.ts +321 -0
  47. package/src/content/types.ts +140 -0
  48. package/src/crawler/crawler.ts +425 -0
  49. package/src/crawler/index.ts +10 -0
  50. package/src/crawler/robots-parser.ts +171 -0
  51. package/src/crawler/sitemap-validator.ts +204 -0
  52. package/src/google-console/analyzer.ts +317 -0
  53. package/src/google-console/auth.ts +100 -0
  54. package/src/google-console/client.ts +281 -0
  55. package/src/google-console/index.ts +9 -0
  56. package/src/index.ts +144 -0
  57. package/src/link-checker/index.ts +461 -0
  58. package/src/reports/claude-context.ts +149 -0
  59. package/src/reports/generator.ts +244 -0
  60. package/src/reports/index.ts +27 -0
  61. package/src/reports/json-report.ts +320 -0
  62. package/src/reports/markdown-report.ts +246 -0
  63. package/src/reports/split-report.ts +252 -0
  64. package/src/routes/analyzer.ts +324 -0
  65. package/src/routes/index.ts +25 -0
  66. package/src/routes/scanner.ts +298 -0
  67. package/src/types/index.ts +222 -0
  68. package/src/utils/index.ts +154 -0
@@ -0,0 +1,461 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * @djangocfg/seo - Link Checker
5
+ *
6
+ * Smart link checker using linkinator with proper error handling,
7
+ * timeout management, and filtering of problematic URLs.
8
+ *
9
+ * @example
10
+ * ```typescript
11
+ * import { checkLinks } from '@djangocfg/seo/link-checker';
12
+ *
13
+ * const result = await checkLinks({
14
+ * url: 'https://example.com',
15
+ * timeout: 60000,
16
+ * });
17
+ * ```
18
+ *
19
+ * @example CLI
20
+ * ```bash
21
+ * djangocfg-seo links --site https://example.com
22
+ * # or
23
+ * djangocfg-seo links # Interactive mode
24
+ * ```
25
+ */
26
+
27
+ import { mkdir, writeFile } from 'node:fs/promises';
28
+ import * as linkinator from 'linkinator';
29
+ import { dirname } from 'node:path';
30
+ import chalk from 'chalk';
31
+ import type { CheckOptions } from 'linkinator';
32
+ import type { SeoIssue } from '../types/index.js';
33
+
34
+ export interface CheckLinksOptions {
35
+ /** Base URL to check (defaults to NEXT_PUBLIC_SITE_URL env variable) */
36
+ url?: string;
37
+ /** Timeout in milliseconds (default: 60000) */
38
+ timeout?: number;
39
+ /** URLs to skip (regex pattern) */
40
+ skipPattern?: string;
41
+ /** Show only broken links (default: true) */
42
+ showOnlyBroken?: boolean;
43
+ /** Maximum number of concurrent requests (default: 50) */
44
+ concurrency?: number;
45
+ /** Output file path for report (optional) */
46
+ outputFile?: string;
47
+ /** Report format: 'json', 'markdown', 'text' (default: 'text') */
48
+ reportFormat?: 'json' | 'markdown' | 'text';
49
+ /** Verbose logging (default: true) */
50
+ verbose?: boolean;
51
+ }
52
+
53
+ const DEFAULT_SKIP_PATTERN = [
54
+ 'github.com',
55
+ 'twitter.com',
56
+ 'linkedin.com',
57
+ 'x.com',
58
+ '127.0.0.1',
59
+ 'localhost:[0-9]+',
60
+ 'api\\.localhost',
61
+ 'demo\\.localhost',
62
+ 'cdn-cgi', // Cloudflare email protection
63
+ 'mailto:', // Email links
64
+ 'tel:', // Phone links
65
+ 'javascript:', // JavaScript links
66
+ ].join('|');
67
+
68
+ export interface BrokenLink {
69
+ url: string;
70
+ status: number | string;
71
+ reason?: string;
72
+ isExternal: boolean;
73
+ sourceUrl?: string;
74
+ }
75
+
76
+ export interface CheckLinksResult {
77
+ success: boolean;
78
+ broken: number;
79
+ total: number;
80
+ errors: BrokenLink[];
81
+ /** Internal broken links (same domain) */
82
+ internalErrors: BrokenLink[];
83
+ /** External broken links (different domain) */
84
+ externalErrors: BrokenLink[];
85
+ url: string;
86
+ timestamp: string;
87
+ duration?: number;
88
+ }
89
+
90
+ /**
91
+ * Get site URL from options or environment variable
92
+ */
93
+ function getSiteUrl(options: CheckLinksOptions): string {
94
+ if (options.url) {
95
+ return options.url;
96
+ }
97
+
98
+ // Try environment variables
99
+ const envUrl =
100
+ process.env.NEXT_PUBLIC_SITE_URL ||
101
+ process.env.SITE_URL ||
102
+ process.env.BASE_URL;
103
+
104
+ if (envUrl) {
105
+ return envUrl;
106
+ }
107
+
108
+ throw new Error(
109
+ 'URL is required. Provide it via options.url or set NEXT_PUBLIC_SITE_URL environment variable.'
110
+ );
111
+ }
112
+
113
+ /**
114
+ * Check if URL is external (different domain)
115
+ */
116
+ function isExternalUrl(linkUrl: string, baseUrl: string): boolean {
117
+ try {
118
+ const link = new URL(linkUrl);
119
+ const base = new URL(baseUrl);
120
+ return link.hostname !== base.hostname;
121
+ } catch {
122
+ return true;
123
+ }
124
+ }
125
+
126
+ /**
127
+ * Check all links on a website
128
+ */
129
+ export async function checkLinks(options: CheckLinksOptions): Promise<CheckLinksResult> {
130
+ const url = getSiteUrl(options);
131
+ const {
132
+ timeout = 60000,
133
+ skipPattern = DEFAULT_SKIP_PATTERN,
134
+ showOnlyBroken = true,
135
+ concurrency = 50,
136
+ outputFile,
137
+ reportFormat = 'text',
138
+ verbose = true,
139
+ } = options;
140
+
141
+ const startTime = Date.now();
142
+
143
+ if (verbose) {
144
+ console.log(chalk.cyan(`\n🔍 Starting link check for: ${chalk.bold(url)}`));
145
+ console.log(chalk.dim(` Timeout: ${timeout}ms | Concurrency: ${concurrency}`));
146
+ console.log('');
147
+ }
148
+
149
+ const skipRegex = new RegExp(skipPattern);
150
+
151
+ const checkOptions: CheckOptions = {
152
+ path: url,
153
+ recurse: true,
154
+ timeout,
155
+ concurrency,
156
+ linksToSkip: (link: string) => {
157
+ return Promise.resolve(skipRegex.test(link));
158
+ },
159
+ };
160
+
161
+ const broken: BrokenLink[] = [];
162
+ const internalErrors: BrokenLink[] = [];
163
+ const externalErrors: BrokenLink[] = [];
164
+ let total = 0;
165
+
166
+ try {
167
+ const results = await linkinator.check(checkOptions);
168
+
169
+ for (const result of results.links) {
170
+ total++;
171
+ const status = result.status || 0;
172
+ const isExternal = isExternalUrl(result.url, url);
173
+
174
+ if (status < 200 || status >= 400 || result.state === 'BROKEN') {
175
+ const statusValue = status || 'TIMEOUT';
176
+
177
+ // Skip TIMEOUT errors on external URLs (rate limiting, slow servers)
178
+ if (statusValue === 'TIMEOUT' && isExternal) {
179
+ continue;
180
+ }
181
+
182
+ const brokenLink: BrokenLink = {
183
+ url: result.url,
184
+ status: statusValue,
185
+ reason: result.state === 'BROKEN' ? 'BROKEN' : undefined,
186
+ isExternal,
187
+ sourceUrl: result.parent,
188
+ };
189
+
190
+ broken.push(brokenLink);
191
+
192
+ if (isExternal) {
193
+ externalErrors.push(brokenLink);
194
+ } else {
195
+ internalErrors.push(brokenLink);
196
+ }
197
+ }
198
+ }
199
+
200
+ const success = internalErrors.length === 0;
201
+
202
+ if (!showOnlyBroken || broken.length > 0) {
203
+ if (success && externalErrors.length === 0) {
204
+ console.log(`✅ All links are valid!`);
205
+ console.log(` Checked ${total} links.`);
206
+ } else {
207
+ // Show internal errors first (more important)
208
+ if (internalErrors.length > 0) {
209
+ console.log(chalk.red(`❌ Found ${internalErrors.length} broken internal links:`));
210
+ for (const { url: linkUrl, status, reason } of internalErrors.slice(0, 20)) {
211
+ console.log(` [${status}] ${linkUrl}${reason ? ` (${reason})` : ''}`);
212
+ }
213
+ if (internalErrors.length > 20) {
214
+ console.log(chalk.dim(` ... and ${internalErrors.length - 20} more`));
215
+ }
216
+ }
217
+
218
+ // Show external errors (less critical)
219
+ if (externalErrors.length > 0) {
220
+ console.log('');
221
+ console.log(chalk.yellow(`⚠️ Found ${externalErrors.length} broken external links:`));
222
+ for (const { url: linkUrl, status } of externalErrors.slice(0, 10)) {
223
+ console.log(` [${status}] ${linkUrl}`);
224
+ }
225
+ if (externalErrors.length > 10) {
226
+ console.log(chalk.dim(` ... and ${externalErrors.length - 10} more`));
227
+ }
228
+ }
229
+ }
230
+ }
231
+
232
+ const duration = Date.now() - startTime;
233
+ const result: CheckLinksResult = {
234
+ success,
235
+ broken: broken.length,
236
+ total,
237
+ errors: broken,
238
+ internalErrors,
239
+ externalErrors,
240
+ url,
241
+ timestamp: new Date().toISOString(),
242
+ duration,
243
+ };
244
+
245
+ if (outputFile) {
246
+ await saveReport(result, outputFile, reportFormat);
247
+ console.log(chalk.green(`\n📄 Report saved to: ${chalk.cyan(outputFile)}`));
248
+ }
249
+
250
+ return result;
251
+ } catch (error) {
252
+ const errorMessage = error instanceof Error ? error.message : String(error);
253
+ const errorName = error instanceof Error ? error.name : 'UnknownError';
254
+
255
+ if (
256
+ errorMessage.includes('timeout') ||
257
+ errorMessage.includes('TimeoutError') ||
258
+ errorName === 'TimeoutError' ||
259
+ errorMessage.includes('aborted')
260
+ ) {
261
+ console.warn(chalk.yellow(`⚠️ Some links timed out after ${timeout}ms`));
262
+ console.warn(chalk.dim(` This is normal for slow or protected URLs.`));
263
+ if (total > 0) {
264
+ console.warn(chalk.dim(` Checked ${total} links before timeout.`));
265
+ }
266
+
267
+ if (broken.length > 0) {
268
+ console.log(chalk.red(`\n❌ Found ${broken.length} broken links:`));
269
+ for (const { url, status, reason } of broken) {
270
+ const statusColor =
271
+ typeof status === 'number' && status >= 500 ? chalk.red : chalk.yellow;
272
+ console.log(
273
+ ` ${statusColor(`[${status}]`)} ${chalk.cyan(url)}${reason ? chalk.dim(` (${reason})`) : ''}`
274
+ );
275
+ }
276
+ }
277
+ } else {
278
+ console.error(chalk.red(`❌ Error checking links: ${errorMessage}`));
279
+ }
280
+
281
+ const duration = Date.now() - startTime;
282
+ const result: CheckLinksResult = {
283
+ success: internalErrors.length === 0 && total > 0,
284
+ broken: broken.length,
285
+ total,
286
+ errors: broken,
287
+ internalErrors,
288
+ externalErrors,
289
+ url,
290
+ timestamp: new Date().toISOString(),
291
+ duration,
292
+ };
293
+
294
+ if (outputFile) {
295
+ try {
296
+ await saveReport(result, outputFile, reportFormat);
297
+ console.log(chalk.green(`\n📄 Report saved to: ${chalk.cyan(outputFile)}`));
298
+ } catch (saveError) {
299
+ console.warn(
300
+ chalk.yellow(
301
+ `\n⚠️ Failed to save report: ${saveError instanceof Error ? saveError.message : String(saveError)}`
302
+ )
303
+ );
304
+ }
305
+ }
306
+
307
+ return result;
308
+ }
309
+ }
310
+
311
+ /**
312
+ * Convert link check results to SEO issues
313
+ * Separates internal (critical) from external (warning) issues
314
+ */
315
+ export function linkResultsToSeoIssues(result: CheckLinksResult): SeoIssue[] {
316
+ const issues: SeoIssue[] = [];
317
+
318
+ // Internal broken links are more critical
319
+ for (const error of result.internalErrors) {
320
+ issues.push({
321
+ id: `broken-internal-link-${hash(error.url)}`,
322
+ url: error.url,
323
+ category: 'technical' as const,
324
+ severity: typeof error.status === 'number' && error.status >= 500 ? 'critical' as const : 'error' as const,
325
+ title: `Broken internal link: ${error.status}`,
326
+ description: `Internal link returned ${error.status} status${error.reason ? ` (${error.reason})` : ''}.`,
327
+ recommendation: 'Fix the internal link. This affects user experience and SEO.',
328
+ detectedAt: result.timestamp,
329
+ metadata: {
330
+ status: error.status,
331
+ reason: error.reason,
332
+ sourceUrl: error.sourceUrl || result.url,
333
+ isExternal: false,
334
+ },
335
+ });
336
+ }
337
+
338
+ // External broken links are warnings
339
+ for (const error of result.externalErrors) {
340
+ issues.push({
341
+ id: `broken-external-link-${hash(error.url)}`,
342
+ url: error.url,
343
+ category: 'technical' as const,
344
+ severity: 'warning' as const,
345
+ title: `Broken external link: ${error.status}`,
346
+ description: `External link returned ${error.status} status.`,
347
+ recommendation: 'Consider removing or updating the external link.',
348
+ detectedAt: result.timestamp,
349
+ metadata: {
350
+ status: error.status,
351
+ reason: error.reason,
352
+ sourceUrl: error.sourceUrl || result.url,
353
+ isExternal: true,
354
+ },
355
+ });
356
+ }
357
+
358
+ return issues;
359
+ }
360
+
361
+ async function saveReport(
362
+ result: CheckLinksResult,
363
+ filePath: string,
364
+ format: 'json' | 'markdown' | 'text'
365
+ ): Promise<void> {
366
+ const dir = dirname(filePath);
367
+ if (dir !== '.') {
368
+ await mkdir(dir, { recursive: true });
369
+ }
370
+
371
+ let content: string;
372
+
373
+ switch (format) {
374
+ case 'json':
375
+ content = JSON.stringify(result, null, 2);
376
+ break;
377
+
378
+ case 'markdown':
379
+ content = generateMarkdownReport(result);
380
+ break;
381
+
382
+ case 'text':
383
+ default:
384
+ content = generateTextReport(result);
385
+ break;
386
+ }
387
+
388
+ await writeFile(filePath, content, 'utf-8');
389
+ }
390
+
391
+ function generateMarkdownReport(result: CheckLinksResult): string {
392
+ const lines: string[] = [];
393
+
394
+ lines.push('# Link Check Report');
395
+ lines.push('');
396
+ lines.push(`**URL:** ${result.url}`);
397
+ lines.push(`**Timestamp:** ${result.timestamp}`);
398
+ if (result.duration) {
399
+ lines.push(`**Duration:** ${(result.duration / 1000).toFixed(2)}s`);
400
+ }
401
+ lines.push('');
402
+ lines.push(
403
+ `**Status:** ${result.success ? '✅ All links valid' : '❌ Broken links found'}`
404
+ );
405
+ lines.push(`**Total links:** ${result.total}`);
406
+ lines.push(`**Broken links:** ${result.broken}`);
407
+ lines.push('');
408
+
409
+ if (result.errors.length > 0) {
410
+ lines.push('## Broken Links');
411
+ lines.push('');
412
+ lines.push('| Status | URL | Reason |');
413
+ lines.push('|--------|-----|--------|');
414
+ for (const { url, status, reason } of result.errors) {
415
+ lines.push(`| ${status} | ${url} | ${reason || '-'} |`);
416
+ }
417
+ lines.push('');
418
+ }
419
+
420
+ return lines.join('\n');
421
+ }
422
+
423
+ function generateTextReport(result: CheckLinksResult): string {
424
+ const lines: string[] = [];
425
+
426
+ lines.push('Link Check Report');
427
+ lines.push('='.repeat(50));
428
+ lines.push(`URL: ${result.url}`);
429
+ lines.push(`Timestamp: ${result.timestamp}`);
430
+ if (result.duration) {
431
+ lines.push(`Duration: ${(result.duration / 1000).toFixed(2)}s`);
432
+ }
433
+ lines.push('');
434
+ lines.push(
435
+ `Status: ${result.success ? '✅ All links valid' : '❌ Broken links found'}`
436
+ );
437
+ lines.push(`Total links: ${result.total}`);
438
+ lines.push(`Broken links: ${result.broken}`);
439
+ lines.push('');
440
+
441
+ if (result.errors.length > 0) {
442
+ lines.push('Broken Links:');
443
+ lines.push('-'.repeat(50));
444
+ for (const { url, status, reason } of result.errors) {
445
+ lines.push(`[${status}] ${url}${reason ? ` (${reason})` : ''}`);
446
+ }
447
+ lines.push('');
448
+ }
449
+
450
+ return lines.join('\n');
451
+ }
452
+
453
+ function hash(str: string): string {
454
+ let h = 0;
455
+ for (let i = 0; i < str.length; i++) {
456
+ const char = str.charCodeAt(i);
457
+ h = (h << 5) - h + char;
458
+ h = h & h;
459
+ }
460
+ return Math.abs(h).toString(36);
461
+ }
@@ -0,0 +1,149 @@
1
+ /**
2
+ * @djangocfg/seo - CLAUDE.md Generator
3
+ * Generates AI context file with package docs and current audit state
4
+ */
5
+
6
+ import type { SeoReport } from '../types/index.js';
7
+
8
+ /**
9
+ * Generate CLAUDE.md content for AI context
10
+ */
11
+ export function generateClaudeContext(report: SeoReport): string {
12
+ const lines: string[] = [];
13
+
14
+ // Package docs (static)
15
+ lines.push('# @djangocfg/seo');
16
+ lines.push('');
17
+ lines.push('SEO audit toolkit. Generates AI-optimized split reports (max 1000 lines each).');
18
+ lines.push('');
19
+ lines.push('## Commands');
20
+ lines.push('');
21
+ lines.push('```bash');
22
+ lines.push('# Audit (HTTP-based, crawls live site)');
23
+ lines.push('pnpm seo:audit # Full audit (split reports)');
24
+ lines.push('pnpm seo:audit --env dev # Audit local dev');
25
+ lines.push('pnpm seo:audit --format all # All formats');
26
+ lines.push('');
27
+ lines.push('# Content (file-based, scans MDX/content/)');
28
+ lines.push('pnpm exec djangocfg-seo content check # Check MDX links');
29
+ lines.push('pnpm exec djangocfg-seo content fix # Show fixable links');
30
+ lines.push('pnpm exec djangocfg-seo content fix --fix # Apply fixes');
31
+ lines.push('pnpm exec djangocfg-seo content sitemap # Generate sitemap.ts');
32
+ lines.push('```');
33
+ lines.push('');
34
+ lines.push('## Options');
35
+ lines.push('');
36
+ lines.push('- `--env, -e` - prod (default) or dev');
37
+ lines.push('- `--site, -s` - Site URL (overrides env)');
38
+ lines.push('- `--output, -o` - Output directory');
39
+ lines.push('- `--format, -f` - split (default), json, markdown, ai-summary, all');
40
+ lines.push('- `--max-pages` - Max pages (default: 100)');
41
+ lines.push('- `--service-account` - Google service account JSON path');
42
+ lines.push('- `--content-dir` - Content directory (default: content/)');
43
+ lines.push('- `--base-path` - Base URL path for docs (default: /docs)');
44
+ lines.push('');
45
+ lines.push('## Reports');
46
+ lines.push('');
47
+ lines.push('- `seo-*-index.md` - Summary + links to categories');
48
+ lines.push('- `seo-*-technical.md` - Broken links, sitemap issues');
49
+ lines.push('- `seo-*-content.md` - H1, meta, title issues');
50
+ lines.push('- `seo-*-performance.md` - Load time, TTFB issues');
51
+ lines.push('- `seo-ai-summary-*.md` - Quick overview');
52
+ lines.push('');
53
+
54
+ // Issue types - important for AI to understand priorities
55
+ lines.push('## Issue Severity');
56
+ lines.push('');
57
+ lines.push('- **critical** - Blocks indexing (fix immediately)');
58
+ lines.push('- **error** - SEO problems (high priority)');
59
+ lines.push('- **warning** - Recommendations (medium priority)');
60
+ lines.push('- **info** - Best practices (low priority)');
61
+ lines.push('');
62
+ lines.push('## Issue Categories');
63
+ lines.push('');
64
+ lines.push('- **technical** - Broken links, sitemap, robots.txt');
65
+ lines.push('- **content** - Missing H1, meta description, title');
66
+ lines.push('- **indexing** - Not indexed, crawl errors from GSC');
67
+ lines.push('- **performance** - Slow load time (>3s), high TTFB (>800ms)');
68
+ lines.push('');
69
+
70
+ // Routes info
71
+ lines.push('## Routes Scanner');
72
+ lines.push('');
73
+ lines.push('Scans Next.js App Router `app/` directory. Handles:');
74
+ lines.push('- Route groups `(group)` - ignored in URL');
75
+ lines.push('- Dynamic `[slug]` - shown as `:slug`');
76
+ lines.push('- Catch-all `[...slug]` - shown as `:...slug`');
77
+ lines.push('- Parallel `@folder` - skipped');
78
+ lines.push('- Private `_folder` - skipped');
79
+ lines.push('');
80
+
81
+ // Link guidelines
82
+ lines.push('## Link Guidelines');
83
+ lines.push('');
84
+ lines.push('### Nextra/MDX Projects (content/)');
85
+ lines.push('');
86
+ lines.push('For non-index files (e.g., `overview.mdx`):');
87
+ lines.push('- **Sibling file**: `../sibling` (one level up)');
88
+ lines.push('- **Other section**: `/docs/full/path` (absolute)');
89
+ lines.push('- **AVOID**: `./sibling` (browser adds filename to path!)');
90
+ lines.push('- **AVOID**: `../../deep/path` (hard to maintain)');
91
+ lines.push('');
92
+ lines.push('For index files (e.g., `index.mdx`):');
93
+ lines.push('- **Child file**: `./child` works correctly');
94
+ lines.push('- **Sibling folder**: `../sibling/` or absolute');
95
+ lines.push('');
96
+ lines.push('### Next.js App Router Projects');
97
+ lines.push('');
98
+ lines.push('Use declarative routes from `_routes/`:');
99
+ lines.push('```typescript');
100
+ lines.push('import { routes } from "@/app/_routes";');
101
+ lines.push('<Link href={routes.dashboard.machines}>Machines</Link>');
102
+ lines.push('```');
103
+ lines.push('');
104
+ lines.push('Benefits: type-safe, refactor-friendly, centralized.');
105
+ lines.push('');
106
+
107
+ // Current audit state (dynamic)
108
+ lines.push('---');
109
+ lines.push('');
110
+ lines.push('## Current Audit');
111
+ lines.push('');
112
+ lines.push(`Site: ${report.siteUrl}`);
113
+ lines.push(`Score: ${report.summary.healthScore}/100`);
114
+ lines.push(`Date: ${report.generatedAt.slice(0, 10)}`);
115
+ lines.push('');
116
+
117
+ // Issue summary
118
+ lines.push('### Issues');
119
+ lines.push('');
120
+ const { critical = 0, error = 0, warning = 0, info = 0 } = report.summary.issuesBySeverity;
121
+ if (critical > 0) lines.push(`- Critical: ${critical}`);
122
+ if (error > 0) lines.push(`- Error: ${error}`);
123
+ if (warning > 0) lines.push(`- Warning: ${warning}`);
124
+ if (info > 0) lines.push(`- Info: ${info}`);
125
+ lines.push('');
126
+
127
+ // Top 5 actions
128
+ lines.push('### Top Actions');
129
+ lines.push('');
130
+ const topRecs = report.recommendations.slice(0, 5);
131
+ for (let i = 0; i < topRecs.length; i++) {
132
+ const rec = topRecs[i];
133
+ if (!rec) continue;
134
+ lines.push(`${i + 1}. **${rec.title}** (${rec.affectedUrls.length} URLs)`);
135
+ }
136
+ lines.push('');
137
+
138
+ // Report files in this directory
139
+ lines.push('### Report Files');
140
+ lines.push('');
141
+ lines.push('See split reports in this directory:');
142
+ lines.push('- `seo-*-index.md` - Start here');
143
+ lines.push('- `seo-*-technical.md` - Technical issues');
144
+ lines.push('- `seo-*-content.md` - Content issues');
145
+ lines.push('- `seo-*-performance.md` - Performance issues');
146
+ lines.push('');
147
+
148
+ return lines.join('\n');
149
+ }