Files
BMAD-METHOD/tools/validate-doc-links.js
Emmanuel Atsé 717a84f2e8 docs(fr): sync French docs with latest English source + fix non-ASCII anchor validation (#2408)
* docs(fr): translation of install-custom-modules

Reference commit 97d32405

* docs(fr): refinement of forensic-investigation

* docs(fr): translation of customize-bmad TOML customization rewrite

Reference commits 0dbfae67, 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 commits 3d824d4c, 91a57499, 0f852a38

* docs(fr): translation of expand-bmad-for-your-org organizational customization patterns

Reference commits c52c9b5b, b63086f2, 4405b817, 0dbfae67

* docs(fr): update install-custom-modules

Reference commit 231a2036

* 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 commits 0dbfae67, 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 commit c52c9b5b

rename 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: 380590a c52c9b5

* docs(fr): update and refine getting-started

Reference commits c52c9b5b, 0f852a38

rename bmad-create-prd to bmad-prd, add PRD intents section
update Quick Reference table, refine French wording throughout

* docs(fr): refine index.md

Reference commit 0dbfae67

refine 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)

Apply ee47e30c (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 in aa6dece
(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 of 27002100. 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>
2026-06-06 20:34:42 -05:00

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);