Skip to content

Commit 6986933

Browse files
DevRohit06claude
andcommitted
feat: add check-links command, content linter, and enhanced validate
- New `check-links` command for broken link detection - Content linter for documentation quality checks - Enhanced `validate` with --content, --links, --all, --strict flags - Inject llms.txt integration and redirects into Astro config Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 1cbe207 commit 6986933

7 files changed

Lines changed: 755 additions & 4 deletions

File tree

src/cli.js

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import { devCommand } from "./commands/dev.js";
55
import { ejectCommand } from "./commands/eject.js";
66
import { initCommand } from "./commands/init.js";
77
import { validateCommand } from "./commands/validate.js";
8+
import { checkLinksCommand } from "./commands/check-links.js";
89
import { previewCommand } from "./commands/preview.js";
910
import { doctorCommand } from "./commands/doctor.js";
1011
import { infoCommand } from "./commands/info.js";
@@ -116,8 +117,21 @@ export async function cli() {
116117
.description("Validate docs-config.json configuration")
117118
.option("-i, --input <path>", "Path to the docs folder")
118119
.option("-q, --quiet", "Quiet mode for CI (exit code only)")
120+
.option("--content", "Run content quality checks")
121+
.option("--links", "Run broken link detection")
122+
.option("--all", "Run all checks (config + content + links)")
123+
.option("--strict", "Fail on warnings too (for CI)")
119124
.action(validateCommand);
120125

126+
// Check links
127+
program
128+
.command("check-links")
129+
.description("Scan documentation for broken internal links")
130+
.requiredOption("-i, --input <path>", "Path to the docs folder")
131+
.option("--strict", "Exit with code 1 on broken links (CI)")
132+
.option("-q, --quiet", "Quiet mode for CI (exit code only)")
133+
.action(checkLinksCommand);
134+
121135
// Preview production build
122136
program
123137
.command("preview")

src/commands/check-links.js

Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
import { resolve } from 'path';
2+
import { intro, outro, log, spinner } from '@clack/prompts';
3+
import pc from 'picocolors';
4+
import { checkLinks } from '../core/link-checker.js';
5+
6+
/**
7+
* Check links command — scan docs for broken internal links.
8+
*/
9+
export async function checkLinksCommand(options) {
10+
try {
11+
const inputPath = options.input ? resolve(options.input) : process.cwd();
12+
13+
// Quiet mode for CI
14+
if (options.quiet) {
15+
const result = await checkLinks(inputPath);
16+
process.exit(result.brokenLinks.length > 0 ? 1 : 0);
17+
}
18+
19+
console.clear();
20+
intro(pc.inverse(pc.cyan(' Lito - Check Links ')));
21+
22+
const s = spinner();
23+
s.start('Scanning documentation for broken links...');
24+
25+
const result = await checkLinks(inputPath);
26+
27+
if (result.brokenLinks.length === 0) {
28+
s.stop(pc.green('All links are valid!'));
29+
log.message('');
30+
log.success(`Checked ${pc.bold(result.totalLinks)} links across ${pc.bold(result.checkedFiles)} files`);
31+
log.message('');
32+
outro(pc.green('No broken links found!'));
33+
return;
34+
}
35+
36+
s.stop(pc.yellow(`Found ${result.brokenLinks.length} broken link(s)`));
37+
log.message('');
38+
39+
// Group broken links by file
40+
const byFile = new Map();
41+
for (const bl of result.brokenLinks) {
42+
if (!byFile.has(bl.file)) byFile.set(bl.file, []);
43+
byFile.get(bl.file).push(bl);
44+
}
45+
46+
for (const [file, links] of byFile) {
47+
log.message(pc.bold(pc.cyan(file)));
48+
for (const bl of links) {
49+
const label = bl.text ? ` (${pc.dim(bl.text)})` : '';
50+
log.message(` ${pc.red('✗')} ${bl.link}${label}`);
51+
if (bl.suggestion) {
52+
log.message(` ${pc.dim('Did you mean:')} ${pc.green(bl.suggestion)}`);
53+
}
54+
}
55+
log.message('');
56+
}
57+
58+
log.message(pc.dim('─'.repeat(50)));
59+
log.message(`${pc.bold('Summary:')} ${pc.red(result.brokenLinks.length + ' broken')} out of ${result.totalLinks} links in ${result.checkedFiles} files`);
60+
log.message('');
61+
62+
if (options.strict) {
63+
outro(pc.red('Link check failed (strict mode)'));
64+
process.exit(1);
65+
} else {
66+
outro(pc.yellow('Link check complete with warnings'));
67+
}
68+
} catch (error) {
69+
log.error(pc.red(error.message));
70+
if (error.stack && !options.quiet) {
71+
log.error(pc.gray(error.stack));
72+
}
73+
process.exit(1);
74+
}
75+
}

src/commands/validate.js

Lines changed: 111 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@ import { resolve, join } from 'path';
33
import { intro, outro, log, spinner } from '@clack/prompts';
44
import pc from 'picocolors';
55
import { validateConfig, isPortableConfig, getCoreConfigKeys, getExtensionKeys } from '../core/config-validator.js';
6+
import { lintContent } from '../core/content-linter.js';
7+
import { checkLinks } from '../core/link-checker.js';
68

79
/**
810
* Validate command - Validate docs-config.json
@@ -12,26 +14,49 @@ export async function validateCommand(options) {
1214
const inputPath = options.input ? resolve(options.input) : process.cwd();
1315
const configPath = join(inputPath, 'docs-config.json');
1416

17+
const runContent = options.content || options.all;
18+
const runLinks = options.links || options.all;
19+
const strict = options.strict || false;
20+
1521
// Quick mode for CI - just exit with code
1622
if (options.quiet) {
23+
let hasErrors = false;
24+
25+
// Config validation
1726
if (!existsSync(configPath)) {
1827
process.exit(1);
1928
}
2029
try {
2130
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
2231
const result = validateConfig(config, inputPath, { silent: true });
23-
process.exit(result.valid ? 0 : 1);
32+
if (!result.valid) hasErrors = true;
33+
34+
// Content linting
35+
if (runContent) {
36+
const lint = await lintContent(inputPath, { config });
37+
const hasLintErrors = lint.issues.some(i => i.severity === 'error');
38+
const hasLintWarnings = lint.issues.some(i => i.severity === 'warning');
39+
if (hasLintErrors || (strict && hasLintWarnings)) hasErrors = true;
40+
}
41+
42+
// Link checking
43+
if (runLinks) {
44+
const linkResult = await checkLinks(inputPath);
45+
if (linkResult.brokenLinks.length > 0) hasErrors = true;
46+
}
2447
} catch (e) {
25-
process.exit(1);
48+
hasErrors = true;
2649
}
50+
51+
process.exit(hasErrors ? 1 : 0);
2752
}
2853

2954
console.clear();
3055
intro(pc.inverse(pc.cyan(' Lito - Validate Configuration ')));
3156

3257
const s = spinner();
3358

34-
// Check if config file exists
59+
// ── Config validation ──
3560
s.start('Looking for docs-config.json...');
3661

3762
if (!existsSync(configPath)) {
@@ -113,7 +138,89 @@ export async function validateCommand(options) {
113138
}
114139

115140
log.message('');
116-
outro(pc.green('Validation complete!'));
141+
142+
// Track if any checks failed
143+
let hasFailure = false;
144+
145+
// ── Content linting ──
146+
if (runContent) {
147+
log.message(pc.dim('─'.repeat(50)));
148+
log.message('');
149+
s.start('Linting documentation content...');
150+
151+
const lint = await lintContent(inputPath, { config });
152+
153+
const errors = lint.issues.filter(i => i.severity === 'error');
154+
const warnings = lint.issues.filter(i => i.severity === 'warning');
155+
156+
if (lint.issues.length === 0) {
157+
s.stop(pc.green(`Content is clean (${lint.totalFiles} files checked)`));
158+
} else {
159+
s.stop(pc.yellow(`Found ${lint.issues.length} issue(s) in ${lint.totalFiles} files`));
160+
log.message('');
161+
162+
if (errors.length > 0) {
163+
log.error(pc.bold(`Errors (${errors.length}):`));
164+
for (const issue of errors) {
165+
log.error(` ${pc.red('✗')} ${pc.cyan(issue.file)}: ${issue.message} ${pc.dim(`[${issue.rule}]`)}`);
166+
}
167+
log.message('');
168+
hasFailure = true;
169+
}
170+
171+
if (warnings.length > 0) {
172+
log.warn(pc.bold(`Warnings (${warnings.length}):`));
173+
for (const issue of warnings) {
174+
log.warn(` ${pc.yellow('!')} ${pc.cyan(issue.file)}: ${issue.message} ${pc.dim(`[${issue.rule}]`)}`);
175+
}
176+
log.message('');
177+
if (strict) hasFailure = true;
178+
}
179+
}
180+
}
181+
182+
// ── Link checking ──
183+
if (runLinks) {
184+
log.message(pc.dim('─'.repeat(50)));
185+
log.message('');
186+
s.start('Checking for broken links...');
187+
188+
const linkResult = await checkLinks(inputPath);
189+
190+
if (linkResult.brokenLinks.length === 0) {
191+
s.stop(pc.green(`All ${linkResult.totalLinks} links are valid (${linkResult.checkedFiles} files)`));
192+
} else {
193+
s.stop(pc.yellow(`Found ${linkResult.brokenLinks.length} broken link(s)`));
194+
log.message('');
195+
196+
// Group by file
197+
const byFile = new Map();
198+
for (const bl of linkResult.brokenLinks) {
199+
if (!byFile.has(bl.file)) byFile.set(bl.file, []);
200+
byFile.get(bl.file).push(bl);
201+
}
202+
203+
for (const [file, links] of byFile) {
204+
log.message(` ${pc.bold(pc.cyan(file))}`);
205+
for (const bl of links) {
206+
const label = bl.text ? ` (${pc.dim(bl.text)})` : '';
207+
log.message(` ${pc.red('✗')} ${bl.link}${label}`);
208+
if (bl.suggestion) {
209+
log.message(` ${pc.dim('Did you mean:')} ${pc.green(bl.suggestion)}`);
210+
}
211+
}
212+
}
213+
log.message('');
214+
hasFailure = true;
215+
}
216+
}
217+
218+
if (hasFailure) {
219+
outro(pc.red('Validation complete with errors'));
220+
process.exit(1);
221+
} else {
222+
outro(pc.green('Validation complete!'));
223+
}
117224
} catch (error) {
118225
log.error(pc.red(error.message));
119226
if (error.stack && !options.quiet) {

src/core/config.js

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,94 @@ export async function generateConfig(projectDir, options, frameworkConfig = null
102102
}
103103
}
104104

105+
// Inject llms.txt integration into astro.config.mjs
106+
if (frameworkName === 'astro' && config.integrations?.llmsTxt?.enabled && config.metadata?.url) {
107+
const astroConfigPath = join(projectDir, "astro.config.mjs");
108+
if (await pathExists(astroConfigPath)) {
109+
let content = await readFile(astroConfigPath, "utf-8");
110+
111+
// Add import after the last existing import line
112+
const importLine = `import astroLlmsTxt from '@4hse/astro-llms-txt';`;
113+
if (!content.includes(importLine)) {
114+
const lines = content.split('\n');
115+
let lastImportIdx = 0;
116+
for (let i = 0; i < lines.length; i++) {
117+
if (lines[i].startsWith('import ')) lastImportIdx = i;
118+
}
119+
lines.splice(lastImportIdx + 1, 0, importLine);
120+
content = lines.join('\n');
121+
}
122+
123+
// Build the integration call
124+
const llmsTitle = config.integrations.llmsTxt.title || config.metadata.name || 'Documentation';
125+
const llmsDesc = config.integrations.llmsTxt.description || config.metadata.description || '';
126+
const llmsConfig = ` astroLlmsTxt({
127+
title: ${JSON.stringify(llmsTitle)},
128+
description: ${JSON.stringify(llmsDesc)},
129+
docSet: [
130+
{
131+
title: ${JSON.stringify(llmsTitle + ' - Full Documentation')},
132+
description: ${JSON.stringify('Complete documentation content')},
133+
url: '/llms-full.txt',
134+
include: ['**'],
135+
mainSelector: 'article',
136+
ignoreSelectors: ['nav', '.sidebar', '.toc', 'footer', '.breadcrumbs'],
137+
},
138+
{
139+
title: ${JSON.stringify(llmsTitle + ' - Structure')},
140+
description: ${JSON.stringify('Documentation structure overview')},
141+
url: '/llms-small.txt',
142+
include: ['**'],
143+
onlyStructure: true,
144+
mainSelector: 'article',
145+
ignoreSelectors: ['nav', '.sidebar', '.toc', 'footer', '.breadcrumbs'],
146+
},
147+
],
148+
}),`;
149+
150+
// Insert after sitemap() in integrations array
151+
if (content.includes('sitemap(),')) {
152+
content = content.replace(
153+
'sitemap(),',
154+
`sitemap(),\n${llmsConfig}`
155+
);
156+
} else {
157+
// Fallback: insert at start of integrations array
158+
content = content.replace(
159+
'integrations: [',
160+
`integrations: [\n${llmsConfig}`
161+
);
162+
}
163+
164+
await writeFile(astroConfigPath, content, "utf-8");
165+
}
166+
}
167+
168+
// Inject redirects into astro.config.mjs
169+
if (frameworkName === 'astro' && config.redirects && Object.keys(config.redirects).length > 0) {
170+
const astroConfigPath = join(projectDir, "astro.config.mjs");
171+
if (await pathExists(astroConfigPath)) {
172+
let content = await readFile(astroConfigPath, "utf-8");
173+
174+
// Build redirects object for Astro config
175+
const redirectEntries = Object.entries(config.redirects).map(([source, dest]) => {
176+
if (typeof dest === 'string') {
177+
return ` '${source}': '${dest}'`;
178+
}
179+
return ` '${source}': { status: ${dest.status || 301}, destination: '${dest.destination}' }`;
180+
});
181+
182+
const redirectsBlock = ` redirects: {\n${redirectEntries.join(',\n')}\n },`;
183+
184+
content = content.replace(
185+
"export default defineConfig({",
186+
`export default defineConfig({\n${redirectsBlock}`
187+
);
188+
189+
await writeFile(astroConfigPath, content, "utf-8");
190+
}
191+
}
192+
105193
// Update vite.config.js for React/Vue frameworks
106194
if (['react', 'vue'].includes(frameworkName) && baseUrl && baseUrl !== "/") {
107195
const viteConfigPath = join(projectDir, "vite.config.js");

0 commit comments

Comments
 (0)