mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-01-30 04:32:02 +00:00
* 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>
289 lines
9.2 KiB
JavaScript
289 lines
9.2 KiB
JavaScript
/**
|
|
* Fix Documentation Links
|
|
*
|
|
* Converts relative markdown links to repo-relative paths with .md extension.
|
|
* This ensures links work both in GitHub and on the Astro/Starlight site
|
|
* (the rehype plugin transforms /docs/path/file.md → /path/file/ at build time).
|
|
*
|
|
* - ./file.md → /docs/current/path/file.md
|
|
* - ../other/file.md → /docs/resolved/path/file.md
|
|
* - /path/file/ → /docs/path/file.md (or /docs/path/file/index.md if it's a directory)
|
|
*
|
|
* Usage:
|
|
* node tools/fix-doc-links.js # Dry run (shows what would change)
|
|
* node tools/fix-doc-links.js --write # Actually write changes
|
|
*/
|
|
|
|
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:
|
|
// - [text](path.md) or [text](path.md#anchor) - existing .md links
|
|
// - [text](/path/to/page/) or [text](/path/to/page/#anchor) - site-relative links to convert
|
|
const MARKDOWN_LINK_REGEX = /\[([^\]]*)\]\(([^)]+(?:\.md|\/))(?:#[^)]*)?(?:\?[^)]*)?\)/g;
|
|
// Simpler approach: match all markdown links and filter in the handler
|
|
const ALL_MARKDOWN_LINKS_REGEX = /\[([^\]]*)\]\(([^)]+)\)/g;
|
|
|
|
/**
|
|
* 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);
|
|
|
|
// Skip underscore-prefixed entries
|
|
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;
|
|
}
|
|
|
|
/**
|
|
* Convert a markdown link href to repo-relative path with .md extension
|
|
*
|
|
* @param {string} href - The original href (e.g., "./file.md", "/path/to/page/", "/path/to/page/#anchor")
|
|
* @param {string} currentFilePath - Absolute path to the file containing this link
|
|
* @returns {string|null} - Repo-relative path (e.g., "/docs/path/to/file.md"), or null if shouldn't be converted
|
|
*/
|
|
function convertToRepoRelative(href, currentFilePath) {
|
|
// Skip external links
|
|
if (href.includes('://') || href.startsWith('mailto:') || href.startsWith('tel:')) {
|
|
return null;
|
|
}
|
|
|
|
// Skip anchor-only links
|
|
if (href.startsWith('#')) {
|
|
return null;
|
|
}
|
|
|
|
// Extract anchor and query string if present
|
|
let anchor = '';
|
|
let query = '';
|
|
let pathPortion = href;
|
|
|
|
const hashIndex = href.indexOf('#');
|
|
const queryIndex = href.indexOf('?');
|
|
|
|
if (hashIndex !== -1 || queryIndex !== -1) {
|
|
const firstDelimiter = Math.min(hashIndex === -1 ? Infinity : hashIndex, queryIndex === -1 ? Infinity : queryIndex);
|
|
pathPortion = href.slice(0, Math.max(0, firstDelimiter));
|
|
|
|
const suffix = href.slice(Math.max(0, firstDelimiter));
|
|
const anchorInSuffix = suffix.indexOf('#');
|
|
|
|
if (suffix.startsWith('?')) {
|
|
if (anchorInSuffix === -1) {
|
|
query = suffix;
|
|
} else {
|
|
query = suffix.slice(0, Math.max(0, anchorInSuffix));
|
|
anchor = suffix.slice(Math.max(0, anchorInSuffix));
|
|
}
|
|
} else {
|
|
anchor = suffix;
|
|
}
|
|
}
|
|
|
|
// Skip non-documentation links (images, external assets, etc.)
|
|
const ext = path.extname(pathPortion).toLowerCase();
|
|
if (
|
|
ext &&
|
|
ext !== '.md' &&
|
|
!['.md'].includes(ext) && // Has an extension that's not .md - skip unless it's a trailing slash path
|
|
!pathPortion.endsWith('/')
|
|
) {
|
|
return null;
|
|
}
|
|
|
|
// Check if original path ends with / (directory reference) BEFORE path.join normalizes it
|
|
const isDirectoryPath = pathPortion.endsWith('/');
|
|
|
|
let absolutePath;
|
|
|
|
if (pathPortion.startsWith('/docs/')) {
|
|
// Already repo-relative with /docs/ prefix
|
|
absolutePath = path.join(path.dirname(DOCS_ROOT), pathPortion);
|
|
} else if (pathPortion.startsWith('/')) {
|
|
// Site-relative (e.g., /tutorials/getting-started/) - resolve from docs root
|
|
absolutePath = path.join(DOCS_ROOT, pathPortion);
|
|
} else {
|
|
// Relative path (./, ../, or bare filename) - resolve from current file's directory
|
|
const currentDir = path.dirname(currentFilePath);
|
|
absolutePath = path.resolve(currentDir, pathPortion);
|
|
}
|
|
|
|
// Convert to repo-relative path (with /docs/ prefix)
|
|
let repoRelative = '/docs/' + path.relative(DOCS_ROOT, absolutePath);
|
|
|
|
// Normalize path separators for Windows
|
|
repoRelative = repoRelative.split(path.sep).join('/');
|
|
|
|
// If original path was a directory reference (ended with /), check for index.md or file.md
|
|
if (isDirectoryPath) {
|
|
const relativeDir = repoRelative.slice(6); // Remove '/docs/'
|
|
|
|
// Handle root path case (relativeDir is empty or just '.')
|
|
const normalizedDir = relativeDir === '' || relativeDir === '.' ? '' : relativeDir;
|
|
const indexPath = path.join(DOCS_ROOT, normalizedDir, 'index.md');
|
|
const filePath = normalizedDir ? path.join(DOCS_ROOT, normalizedDir + '.md') : null;
|
|
|
|
if (fs.existsSync(indexPath)) {
|
|
// Avoid double slash when repoRelative is '/docs/' (root case)
|
|
repoRelative = repoRelative.endsWith('/') ? repoRelative + 'index.md' : repoRelative + '/index.md';
|
|
} else if (filePath && fs.existsSync(filePath)) {
|
|
repoRelative = repoRelative + '.md';
|
|
} else {
|
|
// Neither exists - default to index.md and let validation catch it
|
|
repoRelative = repoRelative.endsWith('/') ? repoRelative + 'index.md' : repoRelative + '/index.md';
|
|
}
|
|
} else if (!repoRelative.endsWith('.md')) {
|
|
// Path doesn't end with .md - add .md
|
|
repoRelative = repoRelative + '.md';
|
|
}
|
|
|
|
return repoRelative + query + anchor;
|
|
}
|
|
|
|
/**
|
|
* Process a single markdown file, skipping links inside fenced code blocks
|
|
*
|
|
* @param {string} filePath - Absolute path to the file
|
|
* @returns {Object} - { changed: boolean, original: string, updated: string, changes: Array }
|
|
*/
|
|
function processFile(filePath) {
|
|
const original = fs.readFileSync(filePath, 'utf-8');
|
|
const changes = [];
|
|
|
|
// Extract fenced code blocks and replace with placeholders
|
|
const codeBlocks = [];
|
|
const CODE_PLACEHOLDER = '\u0000CODE_BLOCK_';
|
|
|
|
let contentWithPlaceholders = original.replaceAll(/```[\s\S]*?```/g, (match) => {
|
|
const index = codeBlocks.length;
|
|
codeBlocks.push(match);
|
|
return `${CODE_PLACEHOLDER}${index}\u0000`;
|
|
});
|
|
|
|
// Process links only in non-code-block content
|
|
contentWithPlaceholders = contentWithPlaceholders.replaceAll(ALL_MARKDOWN_LINKS_REGEX, (match, linkText, href) => {
|
|
const newHref = convertToRepoRelative(href, filePath);
|
|
|
|
// Skip if conversion returned null (external link, anchor, etc.)
|
|
if (newHref === null) {
|
|
return match;
|
|
}
|
|
|
|
// Only record as change if actually different
|
|
if (newHref !== href) {
|
|
changes.push({ from: href, to: newHref });
|
|
return `[${linkText}](${newHref})`;
|
|
}
|
|
|
|
return match;
|
|
});
|
|
|
|
// Restore code blocks
|
|
const updated = contentWithPlaceholders.replaceAll(
|
|
new RegExp(`${CODE_PLACEHOLDER}(\\d+)\u0000`, 'g'),
|
|
(match, index) => codeBlocks[parseInt(index, 10)],
|
|
);
|
|
|
|
return {
|
|
changed: changes.length > 0,
|
|
original,
|
|
updated,
|
|
changes,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Validate that a repo-relative link points to an existing file
|
|
*/
|
|
function validateLink(repoRelativePath) {
|
|
// Strip anchor/query
|
|
const checkPath = repoRelativePath.split('#')[0].split('?')[0];
|
|
|
|
// Remove /docs/ prefix to get path relative to DOCS_ROOT
|
|
const relativePath = checkPath.startsWith('/docs/') ? checkPath.slice(6) : checkPath.slice(1);
|
|
|
|
return fs.existsSync(path.join(DOCS_ROOT, relativePath));
|
|
}
|
|
|
|
// Main execution
|
|
console.log(`\nScanning docs in: ${DOCS_ROOT}`);
|
|
console.log(`Mode: ${DRY_RUN ? 'DRY RUN (use --write to apply changes)' : 'WRITE MODE'}\n`);
|
|
|
|
const files = getMarkdownFiles(DOCS_ROOT);
|
|
console.log(`Found ${files.length} markdown files (excluding _* paths)\n`);
|
|
|
|
let totalChanges = 0;
|
|
let filesChanged = 0;
|
|
const brokenLinks = [];
|
|
|
|
for (const filePath of files) {
|
|
const relativePath = path.relative(DOCS_ROOT, filePath);
|
|
const result = processFile(filePath);
|
|
|
|
if (result.changed) {
|
|
filesChanged++;
|
|
totalChanges += result.changes.length;
|
|
|
|
console.log(`\n${relativePath}`);
|
|
for (const change of result.changes) {
|
|
const isValid = validateLink(change.to);
|
|
const status = isValid ? ' ' : '! ';
|
|
|
|
console.log(`${status} ${change.from}`);
|
|
console.log(` -> ${change.to}`);
|
|
|
|
if (!isValid) {
|
|
brokenLinks.push({
|
|
file: relativePath,
|
|
link: change.to,
|
|
original: change.from,
|
|
});
|
|
}
|
|
}
|
|
|
|
if (!DRY_RUN) {
|
|
fs.writeFileSync(filePath, result.updated, 'utf-8');
|
|
}
|
|
}
|
|
}
|
|
|
|
console.log(`\n${'─'.repeat(60)}`);
|
|
console.log(`\nSummary:`);
|
|
console.log(` Files scanned: ${files.length}`);
|
|
console.log(` Files with changes: ${filesChanged}`);
|
|
console.log(` Total link updates: ${totalChanges}`);
|
|
|
|
if (brokenLinks.length > 0) {
|
|
console.log(`\n! Potential broken links (${brokenLinks.length}):`);
|
|
for (const bl of brokenLinks) {
|
|
console.log(` ${bl.file}: ${bl.link}`);
|
|
}
|
|
}
|
|
|
|
if (DRY_RUN && totalChanges > 0) {
|
|
console.log(`\nRun with --write to apply these changes`);
|
|
}
|
|
|
|
console.log('');
|