@djangocfg/seo 2.1.50
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +192 -0
- package/dist/cli.d.ts +1 -0
- package/dist/cli.mjs +3780 -0
- package/dist/cli.mjs.map +1 -0
- package/dist/crawler/index.d.ts +88 -0
- package/dist/crawler/index.mjs +610 -0
- package/dist/crawler/index.mjs.map +1 -0
- package/dist/google-console/index.d.ts +95 -0
- package/dist/google-console/index.mjs +539 -0
- package/dist/google-console/index.mjs.map +1 -0
- package/dist/index.d.ts +285 -0
- package/dist/index.mjs +3236 -0
- package/dist/index.mjs.map +1 -0
- package/dist/link-checker/index.d.ts +76 -0
- package/dist/link-checker/index.mjs +326 -0
- package/dist/link-checker/index.mjs.map +1 -0
- package/dist/markdown-report-B3QdDzxE.d.ts +193 -0
- package/dist/reports/index.d.ts +24 -0
- package/dist/reports/index.mjs +836 -0
- package/dist/reports/index.mjs.map +1 -0
- package/dist/routes/index.d.ts +69 -0
- package/dist/routes/index.mjs +372 -0
- package/dist/routes/index.mjs.map +1 -0
- package/dist/scanner-Cz4Th2Pt.d.ts +60 -0
- package/dist/types/index.d.ts +144 -0
- package/dist/types/index.mjs +3 -0
- package/dist/types/index.mjs.map +1 -0
- package/package.json +114 -0
- package/src/analyzer.ts +256 -0
- package/src/cli/commands/audit.ts +260 -0
- package/src/cli/commands/content.ts +180 -0
- package/src/cli/commands/crawl.ts +32 -0
- package/src/cli/commands/index.ts +12 -0
- package/src/cli/commands/inspect.ts +60 -0
- package/src/cli/commands/links.ts +41 -0
- package/src/cli/commands/robots.ts +36 -0
- package/src/cli/commands/routes.ts +126 -0
- package/src/cli/commands/sitemap.ts +48 -0
- package/src/cli/index.ts +149 -0
- package/src/cli/types.ts +40 -0
- package/src/config.ts +207 -0
- package/src/content/index.ts +51 -0
- package/src/content/link-checker.ts +182 -0
- package/src/content/link-fixer.ts +188 -0
- package/src/content/scanner.ts +200 -0
- package/src/content/sitemap-generator.ts +321 -0
- package/src/content/types.ts +140 -0
- package/src/crawler/crawler.ts +425 -0
- package/src/crawler/index.ts +10 -0
- package/src/crawler/robots-parser.ts +171 -0
- package/src/crawler/sitemap-validator.ts +204 -0
- package/src/google-console/analyzer.ts +317 -0
- package/src/google-console/auth.ts +100 -0
- package/src/google-console/client.ts +281 -0
- package/src/google-console/index.ts +9 -0
- package/src/index.ts +144 -0
- package/src/link-checker/index.ts +461 -0
- package/src/reports/claude-context.ts +149 -0
- package/src/reports/generator.ts +244 -0
- package/src/reports/index.ts +27 -0
- package/src/reports/json-report.ts +320 -0
- package/src/reports/markdown-report.ts +246 -0
- package/src/reports/split-report.ts +252 -0
- package/src/routes/analyzer.ts +324 -0
- package/src/routes/index.ts +25 -0
- package/src/routes/scanner.ts +298 -0
- package/src/types/index.ts +222 -0
- package/src/utils/index.ts +154 -0
|
@@ -0,0 +1,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
|
+
}
|