@litodocs/cli 1.2.0 → 1.3.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/package.json +1 -1
- package/src/cli.js +14 -0
- package/src/commands/check-links.js +75 -0
- package/src/commands/validate.js +111 -4
- package/src/core/config.js +88 -0
- package/src/core/content-linter.js +172 -0
- package/src/core/doc-utils.js +123 -0
- package/src/core/landing-sync.js +128 -15
- package/src/core/link-checker.js +172 -0
package/package.json
CHANGED
package/src/cli.js
CHANGED
|
@@ -5,6 +5,7 @@ import { devCommand } from "./commands/dev.js";
|
|
|
5
5
|
import { ejectCommand } from "./commands/eject.js";
|
|
6
6
|
import { initCommand } from "./commands/init.js";
|
|
7
7
|
import { validateCommand } from "./commands/validate.js";
|
|
8
|
+
import { checkLinksCommand } from "./commands/check-links.js";
|
|
8
9
|
import { previewCommand } from "./commands/preview.js";
|
|
9
10
|
import { doctorCommand } from "./commands/doctor.js";
|
|
10
11
|
import { infoCommand } from "./commands/info.js";
|
|
@@ -116,8 +117,21 @@ export async function cli() {
|
|
|
116
117
|
.description("Validate docs-config.json configuration")
|
|
117
118
|
.option("-i, --input <path>", "Path to the docs folder")
|
|
118
119
|
.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)")
|
|
119
124
|
.action(validateCommand);
|
|
120
125
|
|
|
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
|
+
|
|
121
135
|
// Preview production build
|
|
122
136
|
program
|
|
123
137
|
.command("preview")
|
|
@@ -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
|
+
}
|
package/src/commands/validate.js
CHANGED
|
@@ -3,6 +3,8 @@ import { resolve, join } from 'path';
|
|
|
3
3
|
import { intro, outro, log, spinner } from '@clack/prompts';
|
|
4
4
|
import pc from 'picocolors';
|
|
5
5
|
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';
|
|
6
8
|
|
|
7
9
|
/**
|
|
8
10
|
* Validate command - Validate docs-config.json
|
|
@@ -12,18 +14,41 @@ export async function validateCommand(options) {
|
|
|
12
14
|
const inputPath = options.input ? resolve(options.input) : process.cwd();
|
|
13
15
|
const configPath = join(inputPath, 'docs-config.json');
|
|
14
16
|
|
|
17
|
+
const runContent = options.content || options.all;
|
|
18
|
+
const runLinks = options.links || options.all;
|
|
19
|
+
const strict = options.strict || false;
|
|
20
|
+
|
|
15
21
|
// Quick mode for CI - just exit with code
|
|
16
22
|
if (options.quiet) {
|
|
23
|
+
let hasErrors = false;
|
|
24
|
+
|
|
25
|
+
// Config validation
|
|
17
26
|
if (!existsSync(configPath)) {
|
|
18
27
|
process.exit(1);
|
|
19
28
|
}
|
|
20
29
|
try {
|
|
21
30
|
const config = JSON.parse(readFileSync(configPath, 'utf-8'));
|
|
22
31
|
const result = validateConfig(config, inputPath, { silent: true });
|
|
23
|
-
|
|
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
|
+
}
|
|
24
47
|
} catch (e) {
|
|
25
|
-
|
|
48
|
+
hasErrors = true;
|
|
26
49
|
}
|
|
50
|
+
|
|
51
|
+
process.exit(hasErrors ? 1 : 0);
|
|
27
52
|
}
|
|
28
53
|
|
|
29
54
|
console.clear();
|
|
@@ -31,7 +56,7 @@ export async function validateCommand(options) {
|
|
|
31
56
|
|
|
32
57
|
const s = spinner();
|
|
33
58
|
|
|
34
|
-
//
|
|
59
|
+
// ── Config validation ──
|
|
35
60
|
s.start('Looking for docs-config.json...');
|
|
36
61
|
|
|
37
62
|
if (!existsSync(configPath)) {
|
|
@@ -113,7 +138,89 @@ export async function validateCommand(options) {
|
|
|
113
138
|
}
|
|
114
139
|
|
|
115
140
|
log.message('');
|
|
116
|
-
|
|
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
|
+
}
|
|
117
224
|
} catch (error) {
|
|
118
225
|
log.error(pc.red(error.message));
|
|
119
226
|
if (error.stack && !options.quiet) {
|
package/src/core/config.js
CHANGED
|
@@ -102,6 +102,94 @@ export async function generateConfig(projectDir, options, frameworkConfig = null
|
|
|
102
102
|
}
|
|
103
103
|
}
|
|
104
104
|
|
|
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
|
+
|
|
105
193
|
// Update vite.config.js for React/Vue frameworks
|
|
106
194
|
if (['react', 'vue'].includes(frameworkName) && baseUrl && baseUrl !== "/") {
|
|
107
195
|
const viteConfigPath = join(projectDir, "vite.config.js");
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { existsSync, readFileSync } from 'fs';
|
|
2
|
+
import { join, dirname } from 'path';
|
|
3
|
+
import { collectMarkdownFiles, parseFrontmatter, deriveSlug } from './doc-utils.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {'error' | 'warning'} Severity
|
|
7
|
+
* @typedef {{ rule: string, severity: Severity, file: string, message: string }} LintIssue
|
|
8
|
+
* @typedef {{ issues: LintIssue[], totalFiles: number }} LintResult
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Run content quality checks on all markdown files in a docs folder.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} docsPath - Root docs directory
|
|
15
|
+
* @param {object} [options]
|
|
16
|
+
* @param {object} [options.config] - Parsed docs-config.json (for orphan detection)
|
|
17
|
+
* @returns {Promise<LintResult>}
|
|
18
|
+
*/
|
|
19
|
+
export async function lintContent(docsPath, options = {}) {
|
|
20
|
+
const files = await collectMarkdownFiles(docsPath);
|
|
21
|
+
/** @type {LintIssue[]} */
|
|
22
|
+
const issues = [];
|
|
23
|
+
|
|
24
|
+
// Pre-compute title map for duplicate detection
|
|
25
|
+
/** @type {Map<string, string[]>} title → list of relative paths */
|
|
26
|
+
const titleMap = new Map();
|
|
27
|
+
|
|
28
|
+
// Pre-compute sidebar slugs for orphan detection
|
|
29
|
+
const sidebarSlugs = new Set();
|
|
30
|
+
if (options.config?.navigation?.sidebar) {
|
|
31
|
+
collectSidebarSlugs(options.config.navigation.sidebar, sidebarSlugs);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
for (const file of files) {
|
|
35
|
+
const content = readFileSync(file.absolutePath, 'utf-8');
|
|
36
|
+
const { data, body } = parseFrontmatter(content);
|
|
37
|
+
const slug = deriveSlug(file.relativePath);
|
|
38
|
+
|
|
39
|
+
// ── missing-title ──
|
|
40
|
+
if (!data.title) {
|
|
41
|
+
issues.push({
|
|
42
|
+
rule: 'missing-title',
|
|
43
|
+
severity: 'warning',
|
|
44
|
+
file: file.relativePath,
|
|
45
|
+
message: 'No title in frontmatter',
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
// ── missing-description ──
|
|
50
|
+
if (!data.description) {
|
|
51
|
+
issues.push({
|
|
52
|
+
rule: 'missing-description',
|
|
53
|
+
severity: 'warning',
|
|
54
|
+
file: file.relativePath,
|
|
55
|
+
message: 'No description in frontmatter',
|
|
56
|
+
});
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// ── empty-page ──
|
|
60
|
+
if (!body || body.trim().length === 0) {
|
|
61
|
+
issues.push({
|
|
62
|
+
rule: 'empty-page',
|
|
63
|
+
severity: 'error',
|
|
64
|
+
file: file.relativePath,
|
|
65
|
+
message: 'Page has no content body',
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── long-title ──
|
|
70
|
+
if (data.title && data.title.length > 70) {
|
|
71
|
+
issues.push({
|
|
72
|
+
rule: 'long-title',
|
|
73
|
+
severity: 'warning',
|
|
74
|
+
file: file.relativePath,
|
|
75
|
+
message: `Title is ${data.title.length} characters (recommended max: 70 for SEO)`,
|
|
76
|
+
});
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// ── missing-image ──
|
|
80
|
+
// Check markdown image references: 
|
|
81
|
+
const imageRegex = /!\[[^\]]*\]\(([^)]+)\)/g;
|
|
82
|
+
let imgMatch;
|
|
83
|
+
while ((imgMatch = imageRegex.exec(content)) !== null) {
|
|
84
|
+
const imgPath = imgMatch[1].split(/[?#]/)[0]; // strip query/fragment
|
|
85
|
+
if (!imgPath || /^(https?:|data:)/.test(imgPath)) continue;
|
|
86
|
+
|
|
87
|
+
// Resolve relative to file's directory or to docs root for absolute paths
|
|
88
|
+
let resolvedPath;
|
|
89
|
+
if (imgPath.startsWith('/')) {
|
|
90
|
+
// Could be in _images, _assets, or public
|
|
91
|
+
const candidates = [
|
|
92
|
+
join(docsPath, '_images', imgPath.slice(1)),
|
|
93
|
+
join(docsPath, '_assets', imgPath.slice(1)),
|
|
94
|
+
join(docsPath, 'public', imgPath.slice(1)),
|
|
95
|
+
];
|
|
96
|
+
if (!candidates.some(c => existsSync(c))) {
|
|
97
|
+
issues.push({
|
|
98
|
+
rule: 'missing-image',
|
|
99
|
+
severity: 'error',
|
|
100
|
+
file: file.relativePath,
|
|
101
|
+
message: `Referenced image not found: ${imgPath}`,
|
|
102
|
+
});
|
|
103
|
+
}
|
|
104
|
+
} else {
|
|
105
|
+
resolvedPath = join(dirname(file.absolutePath), imgPath);
|
|
106
|
+
if (!existsSync(resolvedPath)) {
|
|
107
|
+
issues.push({
|
|
108
|
+
rule: 'missing-image',
|
|
109
|
+
severity: 'error',
|
|
110
|
+
file: file.relativePath,
|
|
111
|
+
message: `Referenced image not found: ${imgPath}`,
|
|
112
|
+
});
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// ── Collect titles for duplicate check ──
|
|
118
|
+
if (data.title) {
|
|
119
|
+
const normalizedTitle = data.title.toLowerCase().trim();
|
|
120
|
+
if (!titleMap.has(normalizedTitle)) {
|
|
121
|
+
titleMap.set(normalizedTitle, []);
|
|
122
|
+
}
|
|
123
|
+
titleMap.get(normalizedTitle).push(file.relativePath);
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// ── orphaned-page ──
|
|
127
|
+
if (sidebarSlugs.size > 0) {
|
|
128
|
+
// Only check content pages, not index pages or API pages
|
|
129
|
+
if (!sidebarSlugs.has(slug)) {
|
|
130
|
+
issues.push({
|
|
131
|
+
rule: 'orphaned-page',
|
|
132
|
+
severity: 'warning',
|
|
133
|
+
file: file.relativePath,
|
|
134
|
+
message: `Page is not linked in sidebar navigation (slug: ${slug})`,
|
|
135
|
+
});
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
// ── duplicate-title (post-pass) ──
|
|
141
|
+
for (const [title, paths] of titleMap) {
|
|
142
|
+
if (paths.length > 1) {
|
|
143
|
+
for (const p of paths) {
|
|
144
|
+
issues.push({
|
|
145
|
+
rule: 'duplicate-title',
|
|
146
|
+
severity: 'warning',
|
|
147
|
+
file: p,
|
|
148
|
+
message: `Duplicate title "${title}" also in: ${paths.filter(x => x !== p).join(', ')}`,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return { issues, totalFiles: files.length };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Recursively extract all href slugs from sidebar config.
|
|
159
|
+
*/
|
|
160
|
+
function collectSidebarSlugs(groups, slugSet) {
|
|
161
|
+
for (const group of groups) {
|
|
162
|
+
if (!group.items) continue;
|
|
163
|
+
for (const item of group.items) {
|
|
164
|
+
if (item.href) {
|
|
165
|
+
slugSet.add(item.href);
|
|
166
|
+
}
|
|
167
|
+
if (item.items) {
|
|
168
|
+
collectSidebarSlugs([{ items: item.items }], slugSet);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
}
|
|
172
|
+
}
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { readdir, readFile, stat } from 'fs/promises';
|
|
2
|
+
import { join, relative, extname, sep } from 'path';
|
|
3
|
+
|
|
4
|
+
/** Folders that contain non-content files (assets, custom landing, etc.) */
|
|
5
|
+
const EXCLUDED_FOLDERS = ['_assets', '_css', '_images', '_static', '_landing', '_navbar', '_footer', 'public', 'node_modules'];
|
|
6
|
+
|
|
7
|
+
/** Files that are not doc pages */
|
|
8
|
+
const EXCLUDED_FILES = ['docs-config.json', 'vercel.json', 'netlify.toml', 'README.md'];
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Recursively collect all .md and .mdx files under docsPath,
|
|
12
|
+
* respecting the same exclusion rules as sync.js.
|
|
13
|
+
*
|
|
14
|
+
* @param {string} docsPath - Root docs directory
|
|
15
|
+
* @returns {Promise<Array<{ absolutePath: string, relativePath: string }>>}
|
|
16
|
+
*/
|
|
17
|
+
export async function collectMarkdownFiles(docsPath) {
|
|
18
|
+
const results = [];
|
|
19
|
+
|
|
20
|
+
async function walk(dir) {
|
|
21
|
+
const entries = await readdir(dir, { withFileTypes: true });
|
|
22
|
+
|
|
23
|
+
for (const entry of entries) {
|
|
24
|
+
const fullPath = join(dir, entry.name);
|
|
25
|
+
const rel = relative(docsPath, fullPath);
|
|
26
|
+
const topSegment = rel.split(sep)[0];
|
|
27
|
+
|
|
28
|
+
if (entry.isDirectory()) {
|
|
29
|
+
if (EXCLUDED_FOLDERS.includes(topSegment) || EXCLUDED_FOLDERS.includes(entry.name)) {
|
|
30
|
+
continue;
|
|
31
|
+
}
|
|
32
|
+
await walk(fullPath);
|
|
33
|
+
} else if (entry.isFile()) {
|
|
34
|
+
if (EXCLUDED_FILES.includes(entry.name)) continue;
|
|
35
|
+
const ext = extname(entry.name).toLowerCase();
|
|
36
|
+
if (ext === '.md' || ext === '.mdx') {
|
|
37
|
+
results.push({ absolutePath: fullPath, relativePath: rel });
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
await walk(docsPath);
|
|
44
|
+
return results;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* Parse YAML-style frontmatter from markdown content.
|
|
49
|
+
* Returns { data: Record<string, string>, body: string }.
|
|
50
|
+
* Simple key: value parsing — handles title, description, etc.
|
|
51
|
+
*
|
|
52
|
+
* @param {string} content - Raw file content
|
|
53
|
+
* @returns {{ data: Record<string, any>, body: string }}
|
|
54
|
+
*/
|
|
55
|
+
export function parseFrontmatter(content) {
|
|
56
|
+
if (!content.startsWith('---')) {
|
|
57
|
+
return { data: {}, body: content };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const endIdx = content.indexOf('---', 3);
|
|
61
|
+
if (endIdx === -1) {
|
|
62
|
+
return { data: {}, body: content };
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
const fmBlock = content.substring(3, endIdx).trim();
|
|
66
|
+
const body = content.substring(endIdx + 3).trim();
|
|
67
|
+
const data = {};
|
|
68
|
+
|
|
69
|
+
for (const line of fmBlock.split('\n')) {
|
|
70
|
+
const colonIdx = line.indexOf(':');
|
|
71
|
+
if (colonIdx === -1) continue;
|
|
72
|
+
const key = line.substring(0, colonIdx).trim();
|
|
73
|
+
let value = line.substring(colonIdx + 1).trim();
|
|
74
|
+
|
|
75
|
+
// Strip surrounding quotes
|
|
76
|
+
if ((value.startsWith('"') && value.endsWith('"')) ||
|
|
77
|
+
(value.startsWith("'") && value.endsWith("'"))) {
|
|
78
|
+
value = value.slice(1, -1);
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
if (key) data[key] = value;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
return { data, body };
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* Convert a relative file path to a URL slug.
|
|
89
|
+
*
|
|
90
|
+
* Examples:
|
|
91
|
+
* "getting-started/installation.md" → "/getting-started/installation"
|
|
92
|
+
* "introduction/index.mdx" → "/introduction"
|
|
93
|
+
* "index.md" → "/"
|
|
94
|
+
*
|
|
95
|
+
* @param {string} relativePath - Path relative to docsPath
|
|
96
|
+
* @returns {string}
|
|
97
|
+
*/
|
|
98
|
+
export function deriveSlug(relativePath) {
|
|
99
|
+
let slug = relativePath
|
|
100
|
+
.replace(/\.(md|mdx)$/, '')
|
|
101
|
+
.split(sep)
|
|
102
|
+
.join('/');
|
|
103
|
+
|
|
104
|
+
// Remove trailing /index
|
|
105
|
+
if (slug.endsWith('/index')) {
|
|
106
|
+
slug = slug.slice(0, -6);
|
|
107
|
+
}
|
|
108
|
+
if (slug === 'index') {
|
|
109
|
+
slug = '';
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return '/' + slug;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Check if a URL is external (http/https, mailto, tel, etc.)
|
|
117
|
+
*
|
|
118
|
+
* @param {string} url
|
|
119
|
+
* @returns {boolean}
|
|
120
|
+
*/
|
|
121
|
+
export function isExternalUrl(url) {
|
|
122
|
+
return /^(https?:|mailto:|tel:|ftp:)/.test(url);
|
|
123
|
+
}
|
package/src/core/landing-sync.js
CHANGED
|
@@ -13,6 +13,46 @@ const { copy, ensureDir, readFile, writeFile, pathExists, readJson } = pkg;
|
|
|
13
13
|
import { join, relative, basename, extname } from 'path';
|
|
14
14
|
import { readdir } from 'fs/promises';
|
|
15
15
|
|
|
16
|
+
/**
|
|
17
|
+
* Add is:inline to all <script> and <style> tags in HTML so Astro ships
|
|
18
|
+
* them as-is. Without this, Astro treats scripts as ES modules (scoping
|
|
19
|
+
* declarations, breaking onclick handlers) and scopes styles (breaking
|
|
20
|
+
* global CSS like :root variables, animations, etc.).
|
|
21
|
+
*/
|
|
22
|
+
function inlineForAstro(html) {
|
|
23
|
+
// Add is:inline to <script> tags that don't already have it
|
|
24
|
+
html = html.replace(/<script(?![^>]*is:inline)([^>]*>)/gi, '<script is:inline$1');
|
|
25
|
+
// Add is:inline to <style> tags that don't already have is:inline or is:global
|
|
26
|
+
html = html.replace(/<style(?![^>]*is:(?:inline|global))([^>]*>)/gi, '<style is:inline$1');
|
|
27
|
+
return html;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Check if HTML is a full document (has <html> or <!doctype>).
|
|
32
|
+
* If so, extract head content, body content, and html/body attributes
|
|
33
|
+
* so we can merge them into the Astro template properly.
|
|
34
|
+
*/
|
|
35
|
+
function parseFullHtmlDocument(html) {
|
|
36
|
+
const isFullDoc = /<!doctype\s+html|<html[\s>]/i.test(html);
|
|
37
|
+
if (!isFullDoc) return null;
|
|
38
|
+
|
|
39
|
+
// Extract <html> tag attributes
|
|
40
|
+
const htmlTagMatch = html.match(/<html([^>]*)>/i);
|
|
41
|
+
const htmlAttrs = htmlTagMatch ? htmlTagMatch[1].trim() : '';
|
|
42
|
+
|
|
43
|
+
// Extract <head> inner content
|
|
44
|
+
const headMatch = html.match(/<head[^>]*>([\s\S]*)<\/head>/i);
|
|
45
|
+
const headContent = headMatch ? headMatch[1].trim() : '';
|
|
46
|
+
|
|
47
|
+
// Extract <body> tag attributes and inner content
|
|
48
|
+
const bodyTagMatch = html.match(/<body([^>]*)>/i);
|
|
49
|
+
const bodyAttrs = bodyTagMatch ? bodyTagMatch[1].trim() : '';
|
|
50
|
+
const bodyMatch = html.match(/<body[^>]*>([\s\S]*)<\/body>/i);
|
|
51
|
+
const bodyContent = bodyMatch ? bodyMatch[1].trim() : '';
|
|
52
|
+
|
|
53
|
+
return { htmlAttrs, headContent, bodyContent, bodyAttrs };
|
|
54
|
+
}
|
|
55
|
+
|
|
16
56
|
/**
|
|
17
57
|
* Landing page types
|
|
18
58
|
*/
|
|
@@ -309,6 +349,9 @@ async function generateAstroLanding(projectDir, landingData) {
|
|
|
309
349
|
|
|
310
350
|
let htmlContent = await readFile(join(sourcePath, mainHtml), 'utf-8');
|
|
311
351
|
|
|
352
|
+
// Make all <script> tags in the user's HTML pass through Astro untouched
|
|
353
|
+
htmlContent = inlineForAstro(htmlContent);
|
|
354
|
+
|
|
312
355
|
// Read CSS files and write to a separate file
|
|
313
356
|
let cssContent = '';
|
|
314
357
|
for (const cssFile of cssFiles) {
|
|
@@ -327,7 +370,36 @@ async function generateAstroLanding(projectDir, landingData) {
|
|
|
327
370
|
jsContent += `// ${jsFile}\n${js}\n\n`;
|
|
328
371
|
}
|
|
329
372
|
|
|
330
|
-
//
|
|
373
|
+
// Check if the user's HTML is a full document (has <html>, <head>, <body>)
|
|
374
|
+
const parsed = parseFullHtmlDocument(htmlContent);
|
|
375
|
+
|
|
376
|
+
let astroContent;
|
|
377
|
+
|
|
378
|
+
if (parsed) {
|
|
379
|
+
// Full HTML document: merge the user's head/body into the Astro page
|
|
380
|
+
// instead of nesting an entire HTML document inside another one.
|
|
381
|
+
astroContent = generateAstroFromFullDoc(parsed, { cssFiles, jsContent, navbarContent, footerContent });
|
|
382
|
+
} else {
|
|
383
|
+
// HTML fragment: wrap it in a full Astro page
|
|
384
|
+
astroContent = generateAstroFromFragment(htmlContent, { jsContent, navbarContent, footerContent });
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// Write to index.astro
|
|
388
|
+
const indexPath = join(projectDir, 'src', 'pages', 'index.astro');
|
|
389
|
+
await writeFile(indexPath, astroContent, 'utf-8');
|
|
390
|
+
|
|
391
|
+
// Copy assets if they exist
|
|
392
|
+
await copyLandingAssets(sourcePath, projectDir);
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
/**
|
|
396
|
+
* Generate Astro page from a full HTML document.
|
|
397
|
+
* Extracts <head> and <body> content, preserves the user's structure.
|
|
398
|
+
*/
|
|
399
|
+
function generateAstroFromFullDoc(parsed, { cssFiles, jsContent, navbarContent, footerContent }) {
|
|
400
|
+
const { htmlAttrs, headContent, bodyContent, bodyAttrs } = parsed;
|
|
401
|
+
|
|
402
|
+
// Determine header/footer rendering
|
|
331
403
|
const navbarIsHidden = navbarContent === '__hidden__';
|
|
332
404
|
const footerIsHidden = footerContent === '__hidden__';
|
|
333
405
|
const hasCustomNavbar = !navbarIsHidden && !!navbarContent;
|
|
@@ -338,16 +410,64 @@ async function generateAstroLanding(projectDir, landingData) {
|
|
|
338
410
|
const headerRender = navbarIsHidden
|
|
339
411
|
? ''
|
|
340
412
|
: hasCustomNavbar
|
|
341
|
-
? `<header class="landing-custom-navbar">\n ${navbarContent}\n </header>`
|
|
413
|
+
? `<header class="landing-custom-navbar">\n ${inlineForAstro(navbarContent)}\n </header>`
|
|
342
414
|
: '<Header />';
|
|
343
415
|
const footerRender = footerIsHidden
|
|
344
416
|
? ''
|
|
345
417
|
: hasCustomFooter
|
|
346
|
-
? `<footer class="landing-custom-footer">\n ${footerContent}\n </footer>`
|
|
418
|
+
? `<footer class="landing-custom-footer">\n ${inlineForAstro(footerContent)}\n </footer>`
|
|
347
419
|
: '<Footer />';
|
|
348
420
|
|
|
349
|
-
|
|
350
|
-
|
|
421
|
+
return `---
|
|
422
|
+
// Custom landing page - auto-generated by Lito CLI
|
|
423
|
+
// Source: _landing/ folder (full HTML document)
|
|
424
|
+
import '../styles/landing.css';
|
|
425
|
+
${headerImport}
|
|
426
|
+
${footerImport}
|
|
427
|
+
---
|
|
428
|
+
|
|
429
|
+
<!doctype html>
|
|
430
|
+
<html ${htmlAttrs}>
|
|
431
|
+
<head>
|
|
432
|
+
${headContent}
|
|
433
|
+
</head>
|
|
434
|
+
<body ${bodyAttrs}>
|
|
435
|
+
${headerRender}
|
|
436
|
+
|
|
437
|
+
${bodyContent}
|
|
438
|
+
|
|
439
|
+
${footerRender}
|
|
440
|
+
|
|
441
|
+
${jsContent ? `<script is:inline>\n${jsContent}\n</script>` : ''}
|
|
442
|
+
</body>
|
|
443
|
+
</html>
|
|
444
|
+
`;
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
/**
|
|
448
|
+
* Generate Astro page from an HTML fragment.
|
|
449
|
+
* Wraps it in a full Astro page with Lito's defaults.
|
|
450
|
+
*/
|
|
451
|
+
function generateAstroFromFragment(htmlContent, { jsContent, navbarContent, footerContent }) {
|
|
452
|
+
const navbarIsHidden = navbarContent === '__hidden__';
|
|
453
|
+
const footerIsHidden = footerContent === '__hidden__';
|
|
454
|
+
const hasCustomNavbar = !navbarIsHidden && !!navbarContent;
|
|
455
|
+
const hasCustomFooter = !footerIsHidden && !!footerContent;
|
|
456
|
+
|
|
457
|
+
const headerImport = navbarIsHidden || hasCustomNavbar ? '' : "import Header from '../components/Header.astro';";
|
|
458
|
+
const footerImport = footerIsHidden || hasCustomFooter ? '' : "import Footer from '../components/Footer.astro';";
|
|
459
|
+
const headerRender = navbarIsHidden
|
|
460
|
+
? ''
|
|
461
|
+
: hasCustomNavbar
|
|
462
|
+
? `<header class="landing-custom-navbar">\n ${inlineForAstro(navbarContent)}\n </header>`
|
|
463
|
+
: '<Header />';
|
|
464
|
+
const footerRender = footerIsHidden
|
|
465
|
+
? ''
|
|
466
|
+
: hasCustomFooter
|
|
467
|
+
? `<footer class="landing-custom-footer">\n ${inlineForAstro(footerContent)}\n </footer>`
|
|
468
|
+
: '<Footer />';
|
|
469
|
+
|
|
470
|
+
return `---
|
|
351
471
|
// Custom landing page - auto-generated by Lito CLI
|
|
352
472
|
// Source: _landing/ folder
|
|
353
473
|
import '../styles/global.css';
|
|
@@ -388,17 +508,10 @@ const config = await getConfigFile();
|
|
|
388
508
|
|
|
389
509
|
${footerRender}
|
|
390
510
|
|
|
391
|
-
${jsContent ? `<script>\n${jsContent}\n</script>` : ''}
|
|
511
|
+
${jsContent ? `<script is:inline>\n${jsContent}\n</script>` : ''}
|
|
392
512
|
</body>
|
|
393
513
|
</html>
|
|
394
514
|
`;
|
|
395
|
-
|
|
396
|
-
// Write to index.astro
|
|
397
|
-
const indexPath = join(projectDir, 'src', 'pages', 'index.astro');
|
|
398
|
-
await writeFile(indexPath, astroContent, 'utf-8');
|
|
399
|
-
|
|
400
|
-
// Copy assets if they exist
|
|
401
|
-
await copyLandingAssets(sourcePath, projectDir);
|
|
402
515
|
}
|
|
403
516
|
|
|
404
517
|
/**
|
|
@@ -708,12 +821,12 @@ async function generateAstroSectionsLanding(projectDir, landingData) {
|
|
|
708
821
|
const headerRender = navbarIsHidden
|
|
709
822
|
? ''
|
|
710
823
|
: hasCustomNavbar
|
|
711
|
-
? `<header class="landing-custom-navbar">\n ${navbarContent}\n </header>`
|
|
824
|
+
? `<header class="landing-custom-navbar">\n ${inlineForAstro(navbarContent)}\n </header>`
|
|
712
825
|
: '<Header />';
|
|
713
826
|
const footerRender = footerIsHidden
|
|
714
827
|
? ''
|
|
715
828
|
: hasCustomFooter
|
|
716
|
-
? `<footer class="landing-custom-footer">\n ${footerContent}\n </footer>`
|
|
829
|
+
? `<footer class="landing-custom-footer">\n ${inlineForAstro(footerContent)}\n </footer>`
|
|
717
830
|
: '<Footer />';
|
|
718
831
|
|
|
719
832
|
const astroContent = `---
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
import { readFileSync, existsSync } from 'fs';
|
|
2
|
+
import { join } from 'path';
|
|
3
|
+
import { collectMarkdownFiles, deriveSlug, isExternalUrl } from './doc-utils.js';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* @typedef {{ file: string, link: string, text: string, suggestion?: string }} BrokenLink
|
|
7
|
+
* @typedef {{ brokenLinks: BrokenLink[], totalLinks: number, checkedFiles: number }} CheckResult
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Scan all markdown files for broken internal links.
|
|
12
|
+
*
|
|
13
|
+
* @param {string} docsPath - Root docs directory
|
|
14
|
+
* @param {object} [options]
|
|
15
|
+
* @returns {Promise<CheckResult>}
|
|
16
|
+
*/
|
|
17
|
+
export async function checkLinks(docsPath, options = {}) {
|
|
18
|
+
const files = await collectMarkdownFiles(docsPath);
|
|
19
|
+
|
|
20
|
+
// Build a set of all known slugs for resolution
|
|
21
|
+
const knownSlugs = new Set();
|
|
22
|
+
for (const file of files) {
|
|
23
|
+
knownSlugs.add(deriveSlug(file.relativePath));
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** @type {BrokenLink[]} */
|
|
27
|
+
const brokenLinks = [];
|
|
28
|
+
let totalLinks = 0;
|
|
29
|
+
|
|
30
|
+
// Regex patterns for extracting links
|
|
31
|
+
// Markdown links (excluding images which start with !)
|
|
32
|
+
const mdLinkRegex = /(?<!!)\[([^\]]*)\]\(([^)]+)\)/g;
|
|
33
|
+
// HTML/JSX href attributes
|
|
34
|
+
const hrefRegex = /href=["']([^"']+)["']/g;
|
|
35
|
+
|
|
36
|
+
for (const file of files) {
|
|
37
|
+
const content = readFileSync(file.absolutePath, 'utf-8');
|
|
38
|
+
const links = [];
|
|
39
|
+
|
|
40
|
+
// Extract markdown links
|
|
41
|
+
let match;
|
|
42
|
+
while ((match = mdLinkRegex.exec(content)) !== null) {
|
|
43
|
+
links.push({ text: match[1], url: match[2] });
|
|
44
|
+
}
|
|
45
|
+
mdLinkRegex.lastIndex = 0;
|
|
46
|
+
|
|
47
|
+
// Extract href links
|
|
48
|
+
while ((match = hrefRegex.exec(content)) !== null) {
|
|
49
|
+
links.push({ text: '', url: match[1] });
|
|
50
|
+
}
|
|
51
|
+
hrefRegex.lastIndex = 0;
|
|
52
|
+
|
|
53
|
+
for (const { text, url } of links) {
|
|
54
|
+
// Skip non-checkable links
|
|
55
|
+
if (!url) continue;
|
|
56
|
+
if (isExternalUrl(url)) continue;
|
|
57
|
+
if (url.startsWith('#')) continue; // anchor-only
|
|
58
|
+
if (url.startsWith('{') || url.includes('${')) continue; // template vars
|
|
59
|
+
if (url.startsWith('data:')) continue;
|
|
60
|
+
|
|
61
|
+
totalLinks++;
|
|
62
|
+
|
|
63
|
+
// Strip anchor fragment and query string
|
|
64
|
+
const cleanUrl = url.split(/[?#]/)[0];
|
|
65
|
+
if (!cleanUrl) continue;
|
|
66
|
+
|
|
67
|
+
// Normalize: ensure leading slash
|
|
68
|
+
const slug = cleanUrl.startsWith('/') ? cleanUrl : '/' + cleanUrl;
|
|
69
|
+
|
|
70
|
+
// Check if slug resolves to a known page
|
|
71
|
+
if (resolveSlug(slug, knownSlugs, docsPath)) continue;
|
|
72
|
+
|
|
73
|
+
// Check if it's a static asset (in _assets, _images, public)
|
|
74
|
+
if (resolveStaticAsset(slug, docsPath)) continue;
|
|
75
|
+
|
|
76
|
+
// Broken link — try to suggest a fix
|
|
77
|
+
const suggestion = findClosestSlug(slug, knownSlugs);
|
|
78
|
+
|
|
79
|
+
brokenLinks.push({
|
|
80
|
+
file: file.relativePath,
|
|
81
|
+
link: url,
|
|
82
|
+
text,
|
|
83
|
+
...(suggestion ? { suggestion } : {}),
|
|
84
|
+
});
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
return { brokenLinks, totalLinks, checkedFiles: files.length };
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* Try to resolve a slug against known slugs.
|
|
93
|
+
* Checks exact match, with/without trailing slash, and index variants.
|
|
94
|
+
*/
|
|
95
|
+
function resolveSlug(slug, knownSlugs, docsPath) {
|
|
96
|
+
// Normalize trailing slash
|
|
97
|
+
const normalized = slug.endsWith('/') ? slug.slice(0, -1) : slug;
|
|
98
|
+
if (!normalized) return knownSlugs.has('/'); // root
|
|
99
|
+
|
|
100
|
+
if (knownSlugs.has(normalized)) return true;
|
|
101
|
+
|
|
102
|
+
// Maybe it's linking to a directory with an index file
|
|
103
|
+
if (knownSlugs.has(normalized + '/index')) return true;
|
|
104
|
+
|
|
105
|
+
// Check if the raw file exists (.md/.mdx)
|
|
106
|
+
const withoutSlash = normalized.startsWith('/') ? normalized.slice(1) : normalized;
|
|
107
|
+
const candidates = [
|
|
108
|
+
join(docsPath, withoutSlash + '.md'),
|
|
109
|
+
join(docsPath, withoutSlash + '.mdx'),
|
|
110
|
+
join(docsPath, withoutSlash, 'index.md'),
|
|
111
|
+
join(docsPath, withoutSlash, 'index.mdx'),
|
|
112
|
+
];
|
|
113
|
+
|
|
114
|
+
return candidates.some(c => existsSync(c));
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
/**
|
|
118
|
+
* Check if a path resolves to a static asset.
|
|
119
|
+
*/
|
|
120
|
+
function resolveStaticAsset(slug, docsPath) {
|
|
121
|
+
const withoutSlash = slug.startsWith('/') ? slug.slice(1) : slug;
|
|
122
|
+
const candidates = [
|
|
123
|
+
join(docsPath, 'public', withoutSlash),
|
|
124
|
+
join(docsPath, '_assets', withoutSlash),
|
|
125
|
+
join(docsPath, '_images', withoutSlash),
|
|
126
|
+
];
|
|
127
|
+
return candidates.some(c => existsSync(c));
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Find the closest matching slug using simple string similarity (Levenshtein-like).
|
|
132
|
+
*
|
|
133
|
+
* @param {string} target
|
|
134
|
+
* @param {Set<string>} knownSlugs
|
|
135
|
+
* @returns {string|null}
|
|
136
|
+
*/
|
|
137
|
+
function findClosestSlug(target, knownSlugs) {
|
|
138
|
+
let bestMatch = null;
|
|
139
|
+
let bestScore = Infinity;
|
|
140
|
+
|
|
141
|
+
for (const slug of knownSlugs) {
|
|
142
|
+
const dist = levenshtein(target, slug);
|
|
143
|
+
if (dist < bestScore && dist <= Math.max(target.length, slug.length) * 0.4) {
|
|
144
|
+
bestScore = dist;
|
|
145
|
+
bestMatch = slug;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
return bestMatch;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* Simple Levenshtein distance.
|
|
154
|
+
*/
|
|
155
|
+
function levenshtein(a, b) {
|
|
156
|
+
const m = a.length;
|
|
157
|
+
const n = b.length;
|
|
158
|
+
const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0));
|
|
159
|
+
|
|
160
|
+
for (let i = 0; i <= m; i++) dp[i][0] = i;
|
|
161
|
+
for (let j = 0; j <= n; j++) dp[0][j] = j;
|
|
162
|
+
|
|
163
|
+
for (let i = 1; i <= m; i++) {
|
|
164
|
+
for (let j = 1; j <= n; j++) {
|
|
165
|
+
dp[i][j] = a[i - 1] === b[j - 1]
|
|
166
|
+
? dp[i - 1][j - 1]
|
|
167
|
+
: 1 + Math.min(dp[i - 1][j], dp[i][j - 1], dp[i - 1][j - 1]);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
return dp[m][n];
|
|
172
|
+
}
|