mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-08-29 03:44:18 +08:00
717a84f2e8
* docs(fr): translation of install-custom-modules Reference commit97d32405* docs(fr): refinement of forensic-investigation * docs(fr): translation of customize-bmad TOML customization rewrite Reference commits0dbfae67,4405b817,ffdd9bc6, b63086f2" * fix(docs): handle non-ASCII anchors in link validator Anchor validation failed for links containing accented characters (e.g. ./customize-bmad.md#dépannage) because the raw anchor didn't match the slugified version produced by extractAnchors. Normalize anchors through decodeURIComponent + headingToAnchor before comparing, and guard against malformed URI components. * docs(fr): translation of install-bmad channel and config rewrite Reference commits3d824d4c,91a57499,0f852a38* docs(fr): translation of expand-bmad-for-your-org organizational customization patterns Reference commitsc52c9b5b,b63086f2,4405b817,0dbfae67* docs(fr): update install-custom-modules Reference commit231a2036* docs(fr): consolidate non-interactive installation into unified install-bmad guide Replace standalone non-interactive-installation.md with a redirect stub pointing to the Installations CI non interactives section in install-bmad.md. * docs(fr): translation of named-agents Reference commits0dbfae67,4405b817,b63086f2* docs(fr): refinement of upgrade-to-v6 * docs(fr): refine agents.md * docs(fr): refine commands.md rename bmad-create-prd to bmad-prd and update skill descriptions * docs(fr): refine workflow-map-diagram Reference commitc52c9b5brename create-prd to prd, create-product-brief to product-brief add prfaq workflow, update agent labels and output names refine French wording throughout * docs(fr): update and refine workflow-map Reference commits:380590ac52c9b5* docs(fr): update and refine getting-started Reference commitsc52c9b5b,0f852a38rename bmad-create-prd to bmad-prd, add PRD intents section update Quick Reference table, refine French wording throughout * docs(fr): refine index.md Reference commit0dbfae67refine French wording throughout, improve phrasing and table formatting * docs(fr): apply French typographic conventions across all docs regex-based pass followed by AI + manual review of all 34 source files Rules applied: - Apostrophe: ASCII ' → curly ’ (U+2019) in all French prose - Guillemets: ASCII "..." → « … » with narrow no-break space (U+202F) on both sides - Narrow no-break space (U+202F): before ; ? ! and after « / before » - No-break space (U+00A0): before : in French prose - Thousands separator: narrow no-break space (U+202F) in 4+ digit numbers Additional review fixes: remaining ASCII quotes in _STYLE_GUIDE.md checklist items, testing.md, and party-mode.md numbering. Preserved exclusions: YAML frontmatter delimiters, code blocks, backtick inline code, URLs, footnote syntax, and English UI text. * docs(fr): align sidebar ordering with current English docs Update sidebar order values across all French explanation and how-to pages to match the live English documentation structure. * docs(fr): fix omission in quick-dev from english * docs(fr): style guide formatting * docs(fr): use quick-dev wording in workflow-map-diagram-fr * docs(fr): fix typos * docs(fr): add bmad-investigate / IN trigger to agent tables The forensic investigation feature added the IN menu trigger and bmad-investigate skill, but the French docs that enumerate triggers and agent capabilities were not updated. - agents.md: add IN trigger and Enquête de code to Amelia's row - named-agents.md: add Enquête de code to Amelia's capabilities * docs(fr): fix agent skill identifiers to use bmad-agent-* prefix The agent skill identifiers in agents.md and commands.md were missing the -agent- segment of the namespace (e.g. bmad-pm instead of bmad-agent-pm). All agent launchers use the bmad-agent-* naming convention since the installer generates skill directories under that prefix. - agents.md: fix bmad-dev, bmad-analyst, bmad-pm, bmad-architect, bmad-ux-designer, bmad-tech-writer - commands.md: fix bmad-pm, bmad-architect * docs(fr): rename bmad-create-ux-design to bmad-ux (#2413) Applyee47e30c(refactor(bmad-ux): spine-based UX skill) to French docs. Rename skill bmad-create-ux-design → bmad-ux and update outputs from ux-spec.md to DESIGN.md + EXPERIENCE.md. * docs(fr): translate bmad-spec section French translation of the bmad-spec section introduced inaa6dece(feat(bmad-spec): introduce Spec kernel distiller skill (#2417)). * docs(fr): improve core-tools locution, phrasing and typography Broader pass across all sections of core-tools.md for more idiomatic French: consistent section headers (À utiliser quand, Fonctionnement), natural verb choices, fluid sentence construction and corrected punctuation. * docs(fr): apply French typography and table formatting pass Continuation of27002100. Systematic pass across all French documentation assisted by an automated French typography linter: - Replace regular space with NBSP (U+00A0) before colons per French typographic convention - Align table separator rows to match column widths - Fix thousands separator in install-bmad.md (5000 → 5 000) - Correct glossary example code block rendering in _STYLE_GUIDE.md * docs(fr): fix missing french typography on roadmap.mdx * docs(fr): translate web-bundles explanation and how-to French translation of: - docs/explanation/web-bundles.md - docs/how-to/use-web-bundles.md Reference commits:7729ad46,d659a03d,3bc2ad30* docs(fr): refresh skill metadata references Fixes #2437 for French. - agents.md: update PM triggers CP/VP/EP → PRD, remove stale US trigger from Technical Writer, align PRD description to create/update/validate - commands.md: fix Cursor/Windsurf skill paths to .agents/skills/, update core tools count to 12, align PRD description - core-tools.md: add missing bmad-customize tool entry and section with link to customize-bmad how-to - party-mode.md: replace stale "BMad Master orchestre" with "Le Party Mode orchestre la discussion" --------- Co-authored-by: Brian <bmadcode@gmail.com>
414 lines
13 KiB
JavaScript
414 lines
13 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 or bare .md references
|
|
const LINK_REGEX = /\[([^\]]*)\]\(((?:\.{1,2}\/|\/)[^)]+|[\w][^)\s]*\.md(?:[?#][^)]*)?)\)/g;
|
|
|
|
// File extensions that are static assets, not markdown docs
|
|
const STATIC_ASSET_EXTENSIONS = ['.zip', '.txt', '.pdf', '.png', '.jpg', '.jpeg', '.gif', '.svg', '.webp', '.ico'];
|
|
|
|
// Custom Astro page routes (not part of the docs content collection)
|
|
const CUSTOM_PAGE_ROUTES = new Set([]);
|
|
|
|
// 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') || entry.name.endsWith('.mdx'))) {
|
|
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, sourceFile) {
|
|
// Strip anchor and query
|
|
let checkPath = siteRelativePath.split('#')[0].split('?')[0];
|
|
|
|
// Handle relative paths (including bare .md): resolve from source file's directory
|
|
if (checkPath.startsWith('./') || checkPath.startsWith('../') || (!checkPath.startsWith('/') && checkPath.endsWith('.md'))) {
|
|
const sourceDir = path.dirname(sourceFile);
|
|
const resolved = path.resolve(sourceDir, checkPath);
|
|
// Ensure the resolved path stays within DOCS_ROOT
|
|
if (!resolved.startsWith(DOCS_ROOT + path.sep) && resolved !== DOCS_ROOT) return null;
|
|
if (fs.existsSync(resolved) && fs.statSync(resolved).isFile()) return resolved;
|
|
if (fs.existsSync(resolved + '.md')) return resolved + '.md';
|
|
if (fs.existsSync(resolved + '.mdx')) return resolved + '.mdx';
|
|
// Directory: check for index.md or index.mdx
|
|
if (fs.existsSync(resolved) && fs.statSync(resolved).isDirectory()) {
|
|
const indexFile = path.join(resolved, 'index.md');
|
|
const indexMdxFile = path.join(resolved, 'index.mdx');
|
|
if (fs.existsSync(indexFile)) return indexFile;
|
|
if (fs.existsSync(indexMdxFile)) return indexMdxFile;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
// Strip /docs/ prefix if present (legacy absolute links)
|
|
if (checkPath.startsWith('/docs/')) {
|
|
checkPath = checkPath.slice(5); // Remove '/docs' but keep leading '/'
|
|
}
|
|
|
|
if (checkPath.endsWith('/')) {
|
|
// Could be file.md, file.mdx, or directory/index.md/mdx
|
|
const baseName = checkPath.slice(0, -1);
|
|
const asMd = path.join(DOCS_ROOT, baseName + '.md');
|
|
const asMdx = path.join(DOCS_ROOT, baseName + '.mdx');
|
|
const asIndex = path.join(DOCS_ROOT, checkPath, 'index.md');
|
|
const asIndexMdx = path.join(DOCS_ROOT, checkPath, 'index.mdx');
|
|
|
|
if (fs.existsSync(asMd)) return asMd;
|
|
if (fs.existsSync(asMdx)) return asMdx;
|
|
if (fs.existsSync(asIndex)) return asIndex;
|
|
if (fs.existsSync(asIndexMdx)) return asIndexMdx;
|
|
return null;
|
|
}
|
|
|
|
// Direct path (e.g., /path/file.md)
|
|
const direct = path.join(DOCS_ROOT, checkPath);
|
|
if (fs.existsSync(direct) && fs.statSync(direct).isFile()) return direct;
|
|
|
|
// Try with .md extension
|
|
const withMd = direct + '.md';
|
|
if (fs.existsSync(withMd)) return withMd;
|
|
|
|
// Try with .mdx extension
|
|
const withMdx = direct + '.mdx';
|
|
if (fs.existsSync(withMdx)) return withMdx;
|
|
|
|
// Directory without trailing slash: check for index.md or index.mdx
|
|
if (fs.existsSync(direct) && fs.statSync(direct).isDirectory()) {
|
|
const indexFile = path.join(direct, 'index.md');
|
|
const indexMdxFile = path.join(direct, 'index.mdx');
|
|
if (fs.existsSync(indexFile)) return indexFile;
|
|
if (fs.existsSync(indexMdxFile)) return indexMdxFile;
|
|
}
|
|
|
|
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;
|
|
}
|
|
|
|
// Skip custom Astro page routes
|
|
if (CUSTOM_PAGE_ROUTES.has(linkPath)) {
|
|
continue;
|
|
}
|
|
|
|
// Validate the link target exists
|
|
const targetFile = resolveLink(linkPath, filePath);
|
|
|
|
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);
|
|
let normalizedAnchor;
|
|
try {
|
|
normalizedAnchor = headingToAnchor(decodeURIComponent(anchor));
|
|
} catch {
|
|
normalizedAnchor = headingToAnchor(anchor);
|
|
}
|
|
|
|
if (!anchors.has(anchor) && !anchors.has(normalizedAnchor)) {
|
|
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);
|