mirror of
https://github.com/bmad-code-org/BMAD-METHOD.git
synced 2026-08-30 17:14:59 +08:00
cede485217
* feat(docs): add sidebar order validator
Adds tools/validate-sidebar-order.js to validate sidebar.order values
in YAML frontmatter across English and translated docs.
Checks for duplicate orders, gaps in sequence, and missing order fields.
For translations, also warns on order drift from English counterparts.
Wired into the quality script as docs:validate-sidebar.
* fix(validate-sidebar): tighten language detection and drift guard, add docstrings
* fix(validate-sidebar): replace subdirectory heuristic with locale pattern matching
detectLanguageDirs() previously classified any top-level docs/ directory
containing subdirectories as a translation language. This was too broad —
if an English section ever gained nested subfolders it would be silently
excluded from validation.
Replaced with a BCP 47 locale-code regex (/^[a-z]{2}(?:-[a-zA-Z]{2})?$/)
that matches known patterns (cs, fr, vi-vn, zh-cn) and won't falsely
classify content sections like explanation/ or reference/.
* fix(validate-sidebar): guard drift check against undefined order values
extractSidebarOrder() returns { hasSidebar: false } when no sidebar block
exists, leaving order as undefined rather than null. The drift check only
guarded against null, allowing undefined values to emit noisy warnings
like "Order drift: ... order undefined".
Changed the guard to typeof === 'number' which correctly excludes both
undefined and null without relying on a specific sentinel value.
* chore(validate-sidebar): add JSDoc docstrings to all functions
Adds @param and @returns annotations to extractSidebarOrder,
detectLanguageDirs, getEnglishSections, checkDirectory,
checkTranslationDrift, and relativePath.
* fix(validate-sidebar): add to pre-commit hook
* refactor(validate-sidebar): harden parsing and edge-case handling
Refactor to main() wrapper with pure return-based APIs, single directory
scan, and shared reporting. Harden frontmatter parsing (anchored delimiter,
direct-child-only order extraction, flow mapping support) and validation
(Infinity/zero guard, gap flood cap, multi-segment locales, graceful ENOENT).
* docs: fix sidebar.order duplicates and gaps across all locales
Resolves all validator errors flagged by the new
tools/validate-sidebar-order.js check.
English (docs/{explanation,how-to,reference}/):
- Renumbered to remove duplicates; established reading order
for new explanation pages added since orders were last set.
Translations (cs, fr, vi-vn, zh-cn):
- Mirrored English structural ordering where files exist, then
compacted to 1..N within each directory to eliminate gaps
caused by missing translation files.
Non-blocking drift warnings remain where translation directories
have fewer files than English; these are expected per the
validator's design.
---------
Co-authored-by: Brian Madison <bmadcode@gmail.com>
389 lines
13 KiB
JavaScript
389 lines
13 KiB
JavaScript
/**
|
|
* Sidebar Order Validator
|
|
*
|
|
* Validates sidebar.order values in YAML frontmatter of markdown doc files.
|
|
*
|
|
* English docs — strict (errors):
|
|
* - Duplicate sidebar.order values within the same directory
|
|
* - Gaps in the ordering sequence
|
|
* - sidebar: block present but missing or invalid order: field
|
|
*
|
|
* Translations — errors + warnings:
|
|
* - Same structural rules as English (duplicates, gaps) — errors
|
|
* - Order drift from English counterpart — warnings (non-blocking)
|
|
*
|
|
* Usage:
|
|
* node tools/validate-sidebar-order.js
|
|
*/
|
|
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
|
|
const DOCS_ROOT = path.resolve(__dirname, '../docs');
|
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
|
|
const LOCALE_RE = /^[a-z]{2}(?:-[a-zA-Z0-9]+)*$/;
|
|
const MAX_GAPS = 50;
|
|
|
|
// ── Main ─────────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Scan all docs, validate sidebar orders, and report errors/warnings.
|
|
* Exits 0 on success, 1 if any errors found.
|
|
*/
|
|
function main() {
|
|
if (!fs.existsSync(DOCS_ROOT)) {
|
|
console.error(`Error: docs directory not found at ${DOCS_ROOT}`);
|
|
process.exit(1);
|
|
}
|
|
|
|
const { languageDirs, englishSections } = classifyDocsDirs();
|
|
console.log(`\nValidating sidebar ordering in: ${DOCS_ROOT}\n`);
|
|
console.log(`English sections: ${englishSections.join(', ')}`);
|
|
console.log(`Translation languages: ${languageDirs.join(', ')}\n`);
|
|
|
|
const allErrors = [];
|
|
const allWarnings = [];
|
|
const englishOrderMaps = new Map();
|
|
|
|
for (const section of englishSections) {
|
|
const sectionDir = path.join(DOCS_ROOT, section);
|
|
if (!fs.existsSync(sectionDir)) continue;
|
|
|
|
console.log(`\nChecking English docs/${section}/`);
|
|
const { orderMap, issues } = checkDirectory(sectionDir);
|
|
englishOrderMaps.set(section, orderMap);
|
|
|
|
for (const issue of issues) {
|
|
allErrors.push(issue);
|
|
reportIssue(issue, ' ', `docs/${section}`);
|
|
}
|
|
if (issues.length === 0) {
|
|
console.log(` [OK] docs/${section}/ — all orders valid`);
|
|
}
|
|
}
|
|
|
|
for (const lang of languageDirs) {
|
|
const langDir = path.join(DOCS_ROOT, lang);
|
|
const langSections = fs
|
|
.readdirSync(langDir, { withFileTypes: true })
|
|
.filter((e) => e.isDirectory() && !e.name.startsWith('_'))
|
|
.map((e) => e.name);
|
|
|
|
console.log(`\nChecking ${lang}/ docs`);
|
|
|
|
for (const section of langSections) {
|
|
const sectionDir = path.join(langDir, section);
|
|
if (!fs.existsSync(sectionDir)) continue;
|
|
|
|
console.log(` ${lang}/${section}/`);
|
|
const { issues } = checkDirectory(sectionDir);
|
|
|
|
for (const issue of issues) {
|
|
allErrors.push(issue);
|
|
reportIssue(issue, ' ', `${lang}/${section}`);
|
|
}
|
|
if (issues.length === 0) {
|
|
console.log(` [OK] ${lang}/${section}/ — all orders valid`);
|
|
}
|
|
}
|
|
|
|
for (const w of checkTranslationDrift(lang, langSections, englishOrderMaps)) {
|
|
allWarnings.push(w);
|
|
const langDisplay = w.langOrder === null ? 'no order' : `order ${w.langOrder}`;
|
|
console.log(` [WARN] ${rel(w.file)}: ${langDisplay} (English: ${w.englishOrder})`);
|
|
}
|
|
}
|
|
|
|
printSummary(allErrors, allWarnings);
|
|
process.exit(allErrors.length > 0 ? 1 : 0);
|
|
}
|
|
|
|
// ── Directory classification ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* Classify top-level docs/ subdirectories as language dirs or English sections.
|
|
* Language dirs match BCP 47 locale pattern; everything else is English.
|
|
* @returns {{ languageDirs: string[], englishSections: string[] }}
|
|
*/
|
|
function classifyDocsDirs() {
|
|
const dirs = fs.readdirSync(DOCS_ROOT, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith('_'));
|
|
|
|
const languageDirs = [];
|
|
const englishSections = [];
|
|
|
|
for (const d of dirs) {
|
|
(LOCALE_RE.test(d.name) ? languageDirs : englishSections).push(d.name);
|
|
}
|
|
|
|
return { languageDirs, englishSections };
|
|
}
|
|
|
|
// ── Per-directory validation ─────────────────────────────────────────────
|
|
|
|
/**
|
|
* Validate sidebar.order values for all markdown files in a directory.
|
|
* Detects duplicates, gaps in sequence, missing-order, and invalid-order fields.
|
|
* @param {string} dirPath - Absolute path to the directory to scan.
|
|
* @returns {{ orderMap: Map<number, string[]>, issues: object[] }}
|
|
*/
|
|
function checkDirectory(dirPath) {
|
|
const issues = [];
|
|
const orderMap = new Map();
|
|
const missingOrder = [];
|
|
const invalidOrder = [];
|
|
|
|
for (const entry of listMdEntries(dirPath)) {
|
|
const fullPath = path.join(dirPath, entry.name);
|
|
const result = extractSidebarOrder(fs.readFileSync(fullPath, 'utf-8'));
|
|
|
|
if (!result.hasSidebar) continue;
|
|
if (result.order === null) {
|
|
if (result.orderInvalid) {
|
|
invalidOrder.push(fullPath);
|
|
} else {
|
|
missingOrder.push(fullPath);
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (!orderMap.has(result.order)) orderMap.set(result.order, []);
|
|
orderMap.get(result.order).push(fullPath);
|
|
}
|
|
|
|
for (const file of missingOrder) {
|
|
issues.push({ level: 'error', type: 'missing-order', file, message: 'Has sidebar: block but no order: field' });
|
|
}
|
|
|
|
for (const file of invalidOrder) {
|
|
issues.push({ level: 'error', type: 'invalid-order', file, message: 'Invalid sidebar.order: must be a positive integer' });
|
|
}
|
|
|
|
for (const [order, files] of orderMap) {
|
|
if (files.length > 1) {
|
|
for (const file of files) {
|
|
issues.push({ level: 'error', type: 'duplicate-order', file, order, message: `Duplicate sidebar.order: ${order}` });
|
|
}
|
|
}
|
|
}
|
|
|
|
if (orderMap.size > 0) {
|
|
let max = -Infinity;
|
|
for (const k of orderMap.keys()) if (k > max) max = k;
|
|
|
|
let gapCount = 0;
|
|
for (let i = 1; i <= max; i++) {
|
|
if (!orderMap.has(i)) {
|
|
issues.push({
|
|
level: 'error',
|
|
type: 'gap',
|
|
directory: dirPath,
|
|
missing: i,
|
|
message: `Gap in sidebar order: missing position ${i}`,
|
|
});
|
|
gapCount++;
|
|
if (gapCount >= MAX_GAPS) {
|
|
issues.push({
|
|
level: 'error',
|
|
type: 'gap-truncated',
|
|
directory: dirPath,
|
|
message: `Too many gaps (stopped after ${MAX_GAPS}) — check for typos in sidebar.order values`,
|
|
});
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return { orderMap, issues };
|
|
}
|
|
|
|
// ── Cross-language drift ─────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Compare translated sidebar orders against English counterparts and warn on drift.
|
|
* Warns on numeric drift and on translation having sidebar but missing order.
|
|
* Files without an English counterpart are skipped silently.
|
|
* @param {string} lang - Language directory name (e.g. "cs", "zh-cn").
|
|
* @param {string[]} langSections - Section subdirectories within the language folder.
|
|
* @param {Map<string, Map<number, string[]>>} englishOrderMaps - English order maps keyed by section name.
|
|
* @returns {object[]} Drift warnings.
|
|
*/
|
|
function checkTranslationDrift(lang, langSections, englishOrderMaps) {
|
|
const warnings = [];
|
|
|
|
for (const section of langSections) {
|
|
const sectionDir = path.join(DOCS_ROOT, lang, section);
|
|
if (!fs.existsSync(sectionDir)) continue;
|
|
|
|
const englishMap = englishOrderMaps.get(section);
|
|
if (!englishMap) continue;
|
|
|
|
for (const entry of listMdEntries(sectionDir)) {
|
|
const langFile = path.join(sectionDir, entry.name);
|
|
const englishFile = path.join(DOCS_ROOT, section, entry.name);
|
|
if (!fs.existsSync(englishFile)) continue;
|
|
|
|
const langResult = extractSidebarOrder(fs.readFileSync(langFile, 'utf-8'));
|
|
const engResult = extractSidebarOrder(fs.readFileSync(englishFile, 'utf-8'));
|
|
|
|
const langHasOrder = typeof langResult.order === 'number';
|
|
const engHasOrder = typeof engResult.order === 'number';
|
|
|
|
if (langHasOrder && engHasOrder && langResult.order !== engResult.order) {
|
|
warnings.push({
|
|
level: 'warning',
|
|
type: 'order-drift',
|
|
file: langFile,
|
|
englishFile,
|
|
langOrder: langResult.order,
|
|
englishOrder: engResult.order,
|
|
});
|
|
} else if (engHasOrder && langResult.hasSidebar && !langHasOrder) {
|
|
warnings.push({
|
|
level: 'warning',
|
|
type: 'order-drift',
|
|
file: langFile,
|
|
englishFile,
|
|
langOrder: null,
|
|
englishOrder: engResult.order,
|
|
});
|
|
}
|
|
}
|
|
}
|
|
|
|
return warnings;
|
|
}
|
|
|
|
// ── Output ───────────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Print a single validation issue to stdout.
|
|
* @param {object} issue - Issue object with type, file/order/message fields.
|
|
* @param {string} indent - Whitespace prefix for indentation.
|
|
* @param {string} ctxPath - Display path for gap issues (e.g. "docs/explanation").
|
|
*/
|
|
function reportIssue(issue, indent, ctxPath) {
|
|
switch (issue.type) {
|
|
case 'duplicate-order': {
|
|
console.log(`${indent}[ERROR] Duplicate order ${issue.order}: ${rel(issue.file)}`);
|
|
break;
|
|
}
|
|
case 'gap': {
|
|
console.log(`${indent}[ERROR] ${issue.message} in ${ctxPath}/`);
|
|
break;
|
|
}
|
|
case 'gap-truncated': {
|
|
console.log(`${indent}[ERROR] ${issue.message}`);
|
|
break;
|
|
}
|
|
case 'missing-order': {
|
|
console.log(`${indent}[ERROR] ${issue.message}: ${rel(issue.file)}`);
|
|
break;
|
|
}
|
|
case 'invalid-order': {
|
|
console.log(`${indent}[ERROR] ${issue.message}: ${rel(issue.file)}`);
|
|
break;
|
|
}
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Print summary with error/warning counts and error type breakdown.
|
|
* @param {object[]} errors - All collected errors.
|
|
* @param {object[]} warnings - All collected warnings.
|
|
*/
|
|
function printSummary(errors, warnings) {
|
|
console.log(`\n${'─'.repeat(60)}`);
|
|
console.log('\nSummary:');
|
|
console.log(` Errors: ${errors.length}`);
|
|
console.log(` Warnings: ${warnings.length}`);
|
|
|
|
if (errors.length > 0) {
|
|
const breakdown = {};
|
|
for (const e of errors) breakdown[e.type] = (breakdown[e.type] || 0) + 1;
|
|
console.log('\n Error breakdown:');
|
|
for (const [type, count] of Object.entries(breakdown)) console.log(` ${type}: ${count}`);
|
|
}
|
|
|
|
if (errors.length === 0 && warnings.length === 0) {
|
|
console.log('\n All sidebar orders valid!');
|
|
}
|
|
|
|
console.log('');
|
|
}
|
|
|
|
// ── Leaf helpers ─────────────────────────────────────────────────────────
|
|
|
|
/**
|
|
* Convert an absolute path to one relative to DOCS_ROOT.
|
|
* @param {string} filePath - Absolute file path.
|
|
* @returns {string} Relative path from docs root.
|
|
*/
|
|
function rel(filePath) {
|
|
return path.relative(DOCS_ROOT, filePath);
|
|
}
|
|
|
|
/**
|
|
* Extract sidebar.order from YAML frontmatter.
|
|
* Handles block mapping (sidebar:\n order: 5) and flow mapping (sidebar: { order: 5 }).
|
|
* Only matches order: as a direct child of sidebar:, not from nested blocks.
|
|
* @param {string} content - Full file contents of a markdown file.
|
|
* @returns {{ hasSidebar: boolean, order?: number|null, orderInvalid?: boolean }}
|
|
*/
|
|
function extractSidebarOrder(content) {
|
|
const match = content.match(FRONTMATTER_RE);
|
|
if (!match) return { hasSidebar: false };
|
|
|
|
const frontmatter = match[1];
|
|
|
|
// Flow mapping: sidebar: { order: 5 }
|
|
const inline = frontmatter.match(/^sidebar:[ \t]*\{[^}]*\border:[ \t]*(\d+)/m);
|
|
if (inline) return validateOrder(inline[1]);
|
|
|
|
// Block mapping: sidebar:\n order: 5
|
|
if (!/^sidebar:[ \t]*$/m.test(frontmatter)) return { hasSidebar: false };
|
|
|
|
const lines = frontmatter.split(/\r?\n/);
|
|
const start = lines.findIndex((l) => /^sidebar:[ \t]*$/.test(l));
|
|
let baseIndent = null;
|
|
|
|
for (let i = start + 1; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
if (/^\s*$/.test(line)) continue;
|
|
|
|
const indent = line.search(/\S/);
|
|
if (indent === 0) break;
|
|
if (baseIndent === null) baseIndent = indent;
|
|
if (indent < baseIndent) break;
|
|
if (indent > baseIndent) continue;
|
|
|
|
const m = line.match(/^\s+order:[ \t]*(\d+)/);
|
|
if (m) return validateOrder(m[1]);
|
|
}
|
|
|
|
return { hasSidebar: true, order: null };
|
|
}
|
|
|
|
/**
|
|
* Validate a parsed order value and return a result object.
|
|
* Rejects non-finite values (Infinity, NaN) and non-positive values (0, negative).
|
|
* @param {string} raw - Raw digit string from frontmatter.
|
|
* @returns {{ hasSidebar: boolean, order?: number|null, orderInvalid?: boolean }}
|
|
*/
|
|
function validateOrder(raw) {
|
|
const n = parseInt(raw, 10);
|
|
if (!Number.isFinite(n) || n < 1) return { hasSidebar: true, order: null, orderInvalid: true };
|
|
return { hasSidebar: true, order: n };
|
|
}
|
|
|
|
/**
|
|
* List markdown files (.md/.mdx) in a directory, excluding subdirectories.
|
|
* @param {string} dirPath - Absolute path to the directory.
|
|
* @returns {fs.Dirent[]} Dirent entries for markdown files.
|
|
*/
|
|
function listMdEntries(dirPath) {
|
|
return fs.readdirSync(dirPath, { withFileTypes: true }).filter((e) => e.isFile() && (e.name.endsWith('.md') || e.name.endsWith('.mdx')));
|
|
}
|
|
|
|
main();
|