Files
BMAD-METHOD/tools/validate-doc-links.js
forcetrainer 12d3492e0c Add link auditor, reorganize documentation, and README update (#1277)
* feat: add link auditor tools and fix broken docs links

- Add audit-doc-links.js to scan docs for broken links with auto-resolution
- Add fix-doc-links.js to apply suggested fixes (dry-run by default)
- Remove stale "Back to Core Concepts" breadcrumb links
- Update BMad acronym to "Breakthrough Method of Agile AI Driven Development"
- Update README links to docs.bmad-method.org
- Simplify upgrade callout in getting-started tutorial

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: reorganize docs structure and archive v4 tutorial

- Remove unused section index files (tutorials, how-to, explanation, reference)
- Move getting-started-bmadv4.md to _archive
- Update quick-start-bmgd.md to remove archived file reference
- Update upgrade-to-v6.md
- Update astro.config.mjs for new structure

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: ignore underscore directories in link checker

Update check-doc-links.js to skip _archive, _planning, and other
underscore-prefixed directories when validating links.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: add v4 users section to README

Add links to v4 documentation archive and upgrade guide for users
migrating from previous versions.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* feat: convert docs to site-relative links and add validation tools

- Convert all relative links (./  ../) to site-relative paths (/path/)
- Strip .md extensions and use trailing slashes for Astro/Starlight
- Add fix-doc-links.js to convert relative links to site-relative
- Add validate-doc-links.js to check links point to existing files
- Remove old audit-doc-links.js and check-doc-links.js
- Update build-docs.js to use new validation script
- Add npm scripts: docs:fix-links, docs:validate-links
- Update style guide with validation steps

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: standardize acronym to BMad across documentation

Replace incorrect "BMAD" with correct "BMad" in text and frontmatter
while preserving "BMAD-METHOD" in GitHub URLs.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: fix BMad acronym and remove draft README

- Correct acronym to "Breakthrough Method of Agile AI Driven Development"
- Remove unused README-draft.md

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: standardize BMad acronym in README

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: standardize FAQ format across all FAQ pages

- Add TOC with jump links under "## Questions"
- Use ### headers for questions (no Q: prefix)
- Direct answers without **A:** prefix
- Remove horizontal rules and "Related Documentation" sections
- End each FAQ with issue/Discord CTA
- Update style guide with new FAQ guidelines
- Delete redundant faq/index.md (sidebar handles navigation)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: use repo-relative links with .md for GitHub compatibility

Convert all documentation links to repo-relative format (/docs/path/file.md)
so they work when browsing on GitHub. The rehype plugin strips /docs/ prefix
and converts .md to trailing slash at build time for Astro/Starlight.

- Update rehype-markdown-links.js to strip /docs/ prefix from absolute paths
- Update fix-doc-links.js to generate /docs/ prefixed paths with .md extension
- Convert 217 links across 64 files to new format

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* fix: handle /docs/ prefix in link validator

Update resolveLink to strip /docs/ prefix from repo-relative links
before checking if files exist.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

* docs: restore FAQ index page

Re-add the FAQ index page that was accidentally deleted, with
updated repo-relative link format.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.5 <noreply@anthropic.com>
Co-authored-by: Alex Verkhovsky <alexey.verkhovsky@gmail.com>
2026-01-10 02:55:33 +08:00

364 lines
10 KiB
JavaScript

/**
* Documentation Link Validator
*
* Validates site-relative links in markdown files and attempts to fix broken ones.
*
* What it checks:
* - All site-relative links (starting with /) point to existing .md files
* - Anchor links (#section) point to valid headings
*
* What it fixes:
* - Broken links where the target file can be found elsewhere in /docs
*
* Usage:
* node tools/validate-doc-links.js # Dry run (validate and show issues)
* node tools/validate-doc-links.js --write # Fix auto-fixable issues
*/
const fs = require('node:fs');
const path = require('node:path');
const DOCS_ROOT = path.resolve(__dirname, '../docs');
const DRY_RUN = !process.argv.includes('--write');
// Regex to match markdown links with site-relative paths
const LINK_REGEX = /\[([^\]]*)\]\((\/[^)]+)\)/g;
// File extensions that are static assets, not markdown docs
const STATIC_ASSET_EXTENSIONS = ['.zip', '.txt', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico'];
// Regex to extract headings for anchor validation
const HEADING_PATTERN = /^#{1,6}\s+(.+)$/gm;
/**
* Get all markdown files in docs directory, excluding _* directories/files
*/
function getMarkdownFiles(dir) {
const files = [];
function walk(currentDir) {
const entries = fs.readdirSync(currentDir, { withFileTypes: true });
for (const entry of entries) {
const fullPath = path.join(currentDir, entry.name);
if (entry.name.startsWith('_')) {
continue;
}
if (entry.isDirectory()) {
walk(fullPath);
} else if (entry.isFile() && entry.name.endsWith('.md')) {
files.push(fullPath);
}
}
}
walk(dir);
return files;
}
/**
* Strip fenced code blocks from content
*/
function stripCodeBlocks(content) {
return content.replaceAll(/```[\s\S]*?```/g, '');
}
/**
* Convert a heading to its anchor slug
*/
function headingToAnchor(heading) {
return heading
.toLowerCase()
.replaceAll(/[\u{1F300}-\u{1F9FF}]/gu, '') // Remove emojis
.replaceAll(/[^\w\s-]/g, '') // Remove special chars
.replaceAll(/\s+/g, '-') // Spaces to hyphens
.replaceAll(/-+/g, '-') // Collapse hyphens
.replaceAll(/^-+|-+$/g, ''); // Trim hyphens
}
/**
* Extract anchor slugs from a markdown file
*/
function extractAnchors(content) {
const anchors = new Set();
let match;
HEADING_PATTERN.lastIndex = 0;
while ((match = HEADING_PATTERN.exec(content)) !== null) {
const headingText = match[1]
.trim()
.replaceAll(/`[^`]+`/g, '')
.replaceAll(/\*\*([^*]+)\*\*/g, '$1')
.replaceAll(/\*([^*]+)\*/g, '$1')
.replaceAll(/\[([^\]]+)\]\([^)]+\)/g, '$1')
.trim();
anchors.add(headingToAnchor(headingText));
}
return anchors;
}
/**
* Resolve a site-relative link to a file path
* /docs/how-to/installation/install-bmad.md -> docs/how-to/installation/install-bmad.md
* /how-to/installation/install-bmad/ -> docs/how-to/installation/install-bmad.md or .../index.md
*/
function resolveLink(siteRelativePath) {
// Strip anchor and query
let checkPath = siteRelativePath.split('#')[0].split('?')[0];
// Strip /docs/ prefix if present (repo-relative links)
if (checkPath.startsWith('/docs/')) {
checkPath = checkPath.slice(5); // Remove '/docs' but keep leading '/'
}
if (checkPath.endsWith('/')) {
// Could be file.md or directory/index.md
const asFile = path.join(DOCS_ROOT, checkPath.slice(0, -1) + '.md');
const asIndex = path.join(DOCS_ROOT, checkPath, 'index.md');
if (fs.existsSync(asFile)) return asFile;
if (fs.existsSync(asIndex)) return asIndex;
return null;
}
// Direct path (e.g., /path/file.md)
const direct = path.join(DOCS_ROOT, checkPath);
if (fs.existsSync(direct)) return direct;
// Try with .md extension
const withMd = direct + '.md';
if (fs.existsSync(withMd)) return withMd;
return null;
}
/**
* Search for a file with directory context
*/
function findFileWithContext(brokenPath) {
// Extract filename and parent directory from the broken path
// e.g., /tutorials/getting-started/foo/ -> parent: getting-started, file: foo.md
const cleanPath = brokenPath.replace(/\/$/, '').replace(/^\//, '');
const parts = cleanPath.split('/');
const fileName = parts.at(-1) + '.md';
const parentDir = parts.length > 1 ? parts.at(-2) : null;
const allFiles = getMarkdownFiles(DOCS_ROOT);
const matches = [];
for (const file of allFiles) {
const fileBaseName = path.basename(file);
const fileParentDir = path.basename(path.dirname(file));
// Exact filename match with parent directory context
if (fileBaseName === fileName) {
if (parentDir && fileParentDir === parentDir) {
// Strong match: both filename and parent dir match
return [file];
}
matches.push(file);
}
// Also check for index.md in a matching directory
if (fileBaseName === 'index.md' && fileParentDir === fileName.replace('.md', '')) {
matches.push(file);
}
}
return matches;
}
/**
* Convert absolute file path to site-relative URL
*/
function fileToSiteRelative(filePath) {
let relative = '/' + path.relative(DOCS_ROOT, filePath);
relative = relative.split(path.sep).join('/');
if (relative.endsWith('/index.md')) {
return relative.replace(/\/index\.md$/, '/');
}
return relative.replace(/\.md$/, '/');
}
/**
* Process a single file and find issues
*/
function processFile(filePath) {
const content = fs.readFileSync(filePath, 'utf-8');
const strippedContent = stripCodeBlocks(content);
const issues = [];
let match;
LINK_REGEX.lastIndex = 0;
while ((match = LINK_REGEX.exec(strippedContent)) !== null) {
const linkText = match[1];
const href = match[2];
// Extract path and anchor
const hashIndex = href.indexOf('#');
const linkPath = hashIndex === -1 ? href : href.slice(0, hashIndex);
const anchor = hashIndex === -1 ? null : href.slice(hashIndex + 1);
// Skip static asset links (zip, txt, images, etc.)
const linkLower = linkPath.toLowerCase();
if (STATIC_ASSET_EXTENSIONS.some((ext) => linkLower.endsWith(ext))) {
continue;
}
// Validate the link target exists
const targetFile = resolveLink(linkPath);
if (!targetFile) {
// Link is broken - try to find the file
const candidates = findFileWithContext(linkPath);
const issue = {
type: 'broken-link',
linkText,
href,
linkPath,
fullMatch: match[0],
};
if (candidates.length === 1) {
issue.status = 'auto-fixable';
issue.suggestedFix = fileToSiteRelative(candidates[0]) + (anchor ? '#' + anchor : '');
issue.foundAt = path.relative(DOCS_ROOT, candidates[0]);
} else if (candidates.length > 1) {
issue.status = 'needs-review';
issue.candidates = candidates.map((c) => path.relative(DOCS_ROOT, c));
} else {
issue.status = 'manual-check';
}
issues.push(issue);
continue;
}
// Validate anchor if present
if (anchor) {
const targetContent = fs.readFileSync(targetFile, 'utf-8');
const anchors = extractAnchors(targetContent);
if (!anchors.has(anchor)) {
issues.push({
type: 'broken-anchor',
linkText,
href,
anchor,
status: 'manual-check',
message: `Anchor "#${anchor}" not found`,
});
}
}
}
return { content, issues };
}
/**
* Apply fixes to file content
*/
function applyFixes(content, issues) {
let updated = content;
for (const issue of issues) {
if (issue.status === 'auto-fixable' && issue.suggestedFix) {
const oldLink = `[${issue.linkText}](${issue.href})`;
const newLink = `[${issue.linkText}](${issue.suggestedFix})`;
updated = updated.replace(oldLink, newLink);
}
}
return updated;
}
// Main execution
console.log(`\nValidating docs in: ${DOCS_ROOT}`);
console.log(`Mode: ${DRY_RUN ? 'DRY RUN (use --write to fix)' : 'WRITE MODE'}\n`);
const files = getMarkdownFiles(DOCS_ROOT);
console.log(`Found ${files.length} markdown files\n`);
let totalIssues = 0;
let autoFixable = 0;
let needsReview = 0;
let manualCheck = 0;
let filesWithIssues = 0;
const allIssues = [];
for (const filePath of files) {
const relativePath = path.relative(DOCS_ROOT, filePath);
const { content, issues } = processFile(filePath);
if (issues.length > 0) {
filesWithIssues++;
totalIssues += issues.length;
console.log(`\n${relativePath}`);
for (const issue of issues) {
if (issue.status === 'auto-fixable') {
autoFixable++;
console.log(` [FIX] ${issue.href}`);
console.log(` -> ${issue.suggestedFix}`);
} else if (issue.status === 'needs-review') {
needsReview++;
console.log(` [REVIEW] ${issue.href}`);
console.log(` Multiple matches found:`);
for (const candidate of issue.candidates) {
console.log(` - ${candidate}`);
}
} else if (issue.type === 'broken-anchor') {
manualCheck++;
console.log(` [MANUAL] ${issue.href}`);
console.log(` ${issue.message}`);
} else {
manualCheck++;
console.log(` [MANUAL] ${issue.href}`);
console.log(` File not found anywhere - may need to remove link`);
}
allIssues.push({ file: relativePath, ...issue });
}
// Apply fixes if not dry run
if (!DRY_RUN) {
const fixableIssues = issues.filter((i) => i.status === 'auto-fixable');
if (fixableIssues.length > 0) {
const updated = applyFixes(content, fixableIssues);
fs.writeFileSync(filePath, updated, 'utf-8');
}
}
}
}
console.log(`\n${'─'.repeat(60)}`);
console.log(`\nSummary:`);
console.log(` Files scanned: ${files.length}`);
console.log(` Files with issues: ${filesWithIssues}`);
console.log(` Total issues: ${totalIssues}`);
if (totalIssues > 0) {
console.log(`\n Breakdown:`);
console.log(` Auto-fixable: ${autoFixable}`);
console.log(` Needs review: ${needsReview}`);
console.log(` Manual check: ${manualCheck}`);
}
if (totalIssues === 0) {
console.log(`\n All links valid!`);
} else if (DRY_RUN && autoFixable > 0) {
console.log(`\nRun with --write to auto-fix ${autoFixable} issue(s)`);
}
console.log('');
process.exit(totalIssues > 0 ? 1 : 0);