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).
This commit is contained in:
Emmanuel Atsé 2026-05-22 03:45:50 +02:00
parent 42121ca70c
commit 5cafcd46c8
No known key found for this signature in database
1 changed files with 265 additions and 194 deletions

View File

@ -3,14 +3,14 @@
* *
* Validates sidebar.order values in YAML frontmatter of markdown doc files. * Validates sidebar.order values in YAML frontmatter of markdown doc files.
* *
* What it checks (English strict, errors): * English docs strict (errors):
* - Duplicate sidebar.order values within the same directory * - Duplicate sidebar.order values within the same directory
* - Gaps in the ordering sequence (e.g., 1, 2, 4 missing 3) * - Gaps in the ordering sequence
* - sidebar: block present but missing order: field * - sidebar: block present but missing or invalid order: field
* *
* What it checks (translations errors + warnings): * Translations errors + warnings:
* - Same structural rules as English (duplicates, gaps) errors * - Same structural rules as English (duplicates, gaps) errors
* - Order drift from English counterpart warnings (non-blocking) * - Order drift from English counterpart warnings (non-blocking)
* *
* Usage: * Usage:
* node tools/validate-sidebar-order.js * node tools/validate-sidebar-order.js
@ -20,113 +20,157 @@ const fs = require('node:fs');
const path = require('node:path'); const path = require('node:path');
const DOCS_ROOT = path.resolve(__dirname, '../docs'); const DOCS_ROOT = path.resolve(__dirname, '../docs');
const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---/; const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---[ \t]*(?:\r?\n|$)/;
const SIDEBAR_ORDER_REGEX = /sidebar:\s*\r?\n(?:(?:[ \t]+.*\r?\n)|(?:\r?\n))*?[ \t]+order:\s*(\d+)/; const LOCALE_RE = /^[a-z]{2}(?:-[a-zA-Z0-9]+)*$/;
const HAS_SIDEBAR_REGEX = /^sidebar:/m; const MAX_GAPS = 50;
const LOCALE_PATTERN = /^[a-z]{2}(?:-[a-zA-Z]{2})?$/;
// ── Main ─────────────────────────────────────────────────────────────────
/** /**
* Extract sidebar.order from YAML frontmatter. * Scan all docs, validate sidebar orders, and report errors/warnings.
* @param {string} content - Full file contents of a markdown file. * Exits 0 on success, 1 if any errors found.
* @returns {{ hasSidebar: boolean, order?: number|null }} Whether a sidebar block exists and its order value.
*/ */
function extractSidebarOrder(content) { function main() {
const match = content.match(FRONTMATTER_REGEX); if (!fs.existsSync(DOCS_ROOT)) {
if (!match) return { hasSidebar: false }; console.error(`Error: docs directory not found at ${DOCS_ROOT}`);
process.exit(1);
const frontmatter = match[1];
if (!HAS_SIDEBAR_REGEX.test(frontmatter)) {
return { hasSidebar: false };
} }
const orderMatch = frontmatter.match(SIDEBAR_ORDER_REGEX); const { languageDirs, englishSections } = classifyDocsDirs();
if (!orderMatch) { console.log(`\nValidating sidebar ordering in: ${DOCS_ROOT}\n`);
return { hasSidebar: true, order: null }; 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`);
}
} }
return { hasSidebar: true, order: parseInt(orderMatch[1], 10) }; 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 ─────────────────────────────────────────────
* Detect translation language directories under docs/ by matching locale-code names.
* @returns {string[]} Directory names matching locale patterns (e.g. "cs", "zh-cn").
*/
function detectLanguageDirs() {
const entries = fs.readdirSync(DOCS_ROOT, { withFileTypes: true });
return entries
.filter((e) => e.isDirectory() && !e.name.startsWith('_'))
.map((e) => e.name)
.filter((name) => LOCALE_PATTERN.test(name));
}
/** /**
* List all top-level content directories under docs/ (excludes _-prefixed dirs). * Classify top-level docs/ subdirectories as language dirs or English sections.
* @returns {string[]} Directory names for English content sections. * Language dirs match BCP 47 locale pattern; everything else is English.
* @returns {{ languageDirs: string[], englishSections: string[] }}
*/ */
function getEnglishSections() { function classifyDocsDirs() {
const entries = fs.readdirSync(DOCS_ROOT, { withFileTypes: true }); const dirs = fs.readdirSync(DOCS_ROOT, { withFileTypes: true }).filter((e) => e.isDirectory() && !e.name.startsWith('_'));
return entries.filter((e) => e.isDirectory() && !e.name.startsWith('_')).map((e) => e.name);
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. * 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. * @param {string} dirPath - Absolute path to the directory to scan.
* @param {Array<{level:string,type:string,file?:string,order?:number,directory?:string,missing?:number,message:string}>} issues - Array to push errors into. * @returns {{ orderMap: Map<number, string[]>, issues: object[] }}
* @returns {Map<number,string[]>} Map of order values to the files that hold them.
*/ */
function checkDirectory(dirPath, issues) { function checkDirectory(dirPath) {
const entries = fs.readdirSync(dirPath, { withFileTypes: true }); const issues = [];
const mdFiles = entries.filter((e) => e.isFile() && (e.name.endsWith('.md') || e.name.endsWith('.mdx')));
const orderMap = new Map(); const orderMap = new Map();
const missingOrder = []; const missingOrder = [];
const invalidOrder = [];
for (const entry of mdFiles) { for (const entry of listMdEntries(dirPath)) {
const fullPath = path.join(dirPath, entry.name); const fullPath = path.join(dirPath, entry.name);
const content = fs.readFileSync(fullPath, 'utf-8'); const result = extractSidebarOrder(fs.readFileSync(fullPath, 'utf-8'));
const { hasSidebar, order } = extractSidebarOrder(content);
if (!hasSidebar) continue; if (!result.hasSidebar) continue;
if (result.order === null) {
if (order === null) { if (result.orderInvalid) {
missingOrder.push(fullPath); invalidOrder.push(fullPath);
} else {
missingOrder.push(fullPath);
}
continue; continue;
} }
if (!orderMap.has(order)) { if (!orderMap.has(result.order)) orderMap.set(result.order, []);
orderMap.set(order, []); orderMap.get(result.order).push(fullPath);
}
orderMap.get(order).push(fullPath);
} }
for (const file of missingOrder) { for (const file of missingOrder) {
issues.push({ issues.push({ level: 'error', type: 'missing-order', file, message: 'Has sidebar: block but no order: field' });
level: 'error', }
type: 'missing-order',
file, for (const file of invalidOrder) {
message: `Has sidebar: block but no order: field`, issues.push({ level: 'error', type: 'invalid-order', file, message: 'Invalid sidebar.order: must be a positive integer' });
});
} }
for (const [order, files] of orderMap) { for (const [order, files] of orderMap) {
if (files.length > 1) { if (files.length > 1) {
for (const file of files) { for (const file of files) {
issues.push({ issues.push({ level: 'error', type: 'duplicate-order', file, order, message: `Duplicate sidebar.order: ${order}` });
level: 'error',
type: 'duplicate-order',
file,
order,
message: `Duplicate sidebar.order: ${order}`,
});
} }
} }
} }
if (orderMap.size > 0) { if (orderMap.size > 0) {
const orders = [...orderMap.keys()].sort((a, b) => a - b); let max = -Infinity;
const max = orders.at(-1); for (const k of orderMap.keys()) if (k > max) max = k;
let gapCount = 0;
for (let i = 1; i <= max; i++) { for (let i = 1; i <= max; i++) {
if (!orderMap.has(i)) { if (!orderMap.has(i)) {
issues.push({ issues.push({
@ -136,21 +180,37 @@ function checkDirectory(dirPath, issues) {
missing: i, missing: i,
message: `Gap in sidebar order: missing position ${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; return { orderMap, issues };
} }
// ── Cross-language drift ─────────────────────────────────────────────────
/** /**
* Compare translated sidebar orders against English counterparts and warn on drift. * Compare translated sidebar orders against English counterparts and warn on drift.
* @param {string} lang - Language directory name (e.g. "cs"). * 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 {string[]} langSections - Section subdirectories within the language folder.
* @param {Map<string,Map<number,string[]>>} englishOrderMaps - English order maps keyed by section name. * @param {Map<string, Map<number, string[]>>} englishOrderMaps - English order maps keyed by section name.
* @param {Array<{level:string,type:string,file:string,englishFile:string,langOrder:number,englishOrder:number,message:string}>} warnings - Array to push drift warnings into. * @returns {object[]} Drift warnings.
*/ */
function checkTranslationDrift(lang, langSections, englishOrderMaps, warnings) { function checkTranslationDrift(lang, langSections, englishOrderMaps) {
const warnings = [];
for (const section of langSections) { for (const section of langSections) {
const sectionDir = path.join(DOCS_ROOT, lang, section); const sectionDir = path.join(DOCS_ROOT, lang, section);
if (!fs.existsSync(sectionDir)) continue; if (!fs.existsSync(sectionDir)) continue;
@ -158,22 +218,18 @@ function checkTranslationDrift(lang, langSections, englishOrderMaps, warnings) {
const englishMap = englishOrderMaps.get(section); const englishMap = englishOrderMaps.get(section);
if (!englishMap) continue; if (!englishMap) continue;
const entries = fs.readdirSync(sectionDir, { withFileTypes: true }); for (const entry of listMdEntries(sectionDir)) {
const mdFiles = entries.filter((e) => e.isFile() && (e.name.endsWith('.md') || e.name.endsWith('.mdx')));
for (const entry of mdFiles) {
const langFile = path.join(sectionDir, entry.name); const langFile = path.join(sectionDir, entry.name);
const englishFile = path.join(DOCS_ROOT, section, entry.name); const englishFile = path.join(DOCS_ROOT, section, entry.name);
if (!fs.existsSync(englishFile)) continue; if (!fs.existsSync(englishFile)) continue;
const langContent = fs.readFileSync(langFile, 'utf-8'); const langResult = extractSidebarOrder(fs.readFileSync(langFile, 'utf-8'));
const engContent = fs.readFileSync(englishFile, 'utf-8'); const engResult = extractSidebarOrder(fs.readFileSync(englishFile, 'utf-8'));
const langResult = extractSidebarOrder(langContent); const langHasOrder = typeof langResult.order === 'number';
const engResult = extractSidebarOrder(engContent); const engHasOrder = typeof engResult.order === 'number';
if (typeof langResult.order === 'number' && typeof engResult.order === 'number' && langResult.order !== engResult.order) { if (langHasOrder && engHasOrder && langResult.order !== engResult.order) {
warnings.push({ warnings.push({
level: 'warning', level: 'warning',
type: 'order-drift', type: 'order-drift',
@ -181,137 +237,152 @@ function checkTranslationDrift(lang, langSections, englishOrderMaps, warnings) {
englishFile, englishFile,
langOrder: langResult.order, langOrder: langResult.order,
englishOrder: engResult.order, englishOrder: engResult.order,
message: `Order drift: ${lang} has order ${langResult.order}, English has ${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. * Convert an absolute path to one relative to DOCS_ROOT.
* @param {string} filePath - Absolute file path. * @param {string} filePath - Absolute file path.
* @returns {string} Relative path from docs root. * @returns {string} Relative path from docs root.
*/ */
function relativePath(filePath) { function rel(filePath) {
return path.relative(DOCS_ROOT, filePath); return path.relative(DOCS_ROOT, filePath);
} }
// Main execution /**
console.log(`\nValidating sidebar ordering in: ${DOCS_ROOT}\n`); * 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 languageDirs = detectLanguageDirs(); const frontmatter = match[1];
const englishSections = getEnglishSections().filter((s) => !languageDirs.includes(s));
console.log(`English sections: ${englishSections.join(', ')}`); // Flow mapping: sidebar: { order: 5 }
console.log(`Translation languages: ${languageDirs.join(', ')}\n`); const inline = frontmatter.match(/^sidebar:[ \t]*\{[^}]*\border:[ \t]*(\d+)/m);
if (inline) return validateOrder(inline[1]);
const allErrors = []; // Block mapping: sidebar:\n order: 5
const allWarnings = []; if (!/^sidebar:[ \t]*$/m.test(frontmatter)) return { hasSidebar: false };
const englishOrderMaps = new Map(); const lines = frontmatter.split(/\r?\n/);
const start = lines.findIndex((l) => /^sidebar:[ \t]*$/.test(l));
let baseIndent = null;
for (const section of englishSections) { for (let i = start + 1; i < lines.length; i++) {
const sectionDir = path.join(DOCS_ROOT, section); const line = lines[i];
if (!fs.existsSync(sectionDir) || !fs.statSync(sectionDir).isDirectory()) continue; if (/^\s*$/.test(line)) continue;
console.log(`\nChecking English docs/${section}/`); const indent = line.search(/\S/);
const issues = []; if (indent === 0) break;
const orderMap = checkDirectory(sectionDir, issues); if (baseIndent === null) baseIndent = indent;
englishOrderMaps.set(section, orderMap); if (indent < baseIndent) break;
if (indent > baseIndent) continue;
for (const issue of issues) { const m = line.match(/^\s+order:[ \t]*(\d+)/);
if (issue.level === 'error') { if (m) return validateOrder(m[1]);
allErrors.push(issue);
switch (issue.type) {
case 'duplicate-order': {
console.log(` [ERROR] Duplicate order ${issue.order}: ${relativePath(issue.file)}`);
break;
}
case 'gap': {
console.log(` [ERROR] ${issue.message} in docs/${section}/`);
break;
}
case 'missing-order': {
console.log(` [ERROR] ${issue.message}: ${relativePath(issue.file)}`);
break;
}
}
}
} }
if (issues.length === 0) { return { hasSidebar: true, order: null };
console.log(` [OK] docs/${section}/ — all orders valid`);
}
} }
for (const lang of languageDirs) { /**
const langDir = path.join(DOCS_ROOT, lang); * Validate a parsed order value and return a result object.
const langSections = fs * Rejects non-finite values (Infinity, NaN) and non-positive values (0, negative).
.readdirSync(langDir, { withFileTypes: true }) * @param {string} raw - Raw digit string from frontmatter.
.filter((e) => e.isDirectory() && !e.name.startsWith('_')) * @returns {{ hasSidebar: boolean, order?: number|null, orderInvalid?: boolean }}
.map((e) => e.name); */
function validateOrder(raw) {
console.log(`\nChecking ${lang}/ docs`); const n = parseInt(raw, 10);
if (!Number.isFinite(n) || n < 1) return { hasSidebar: true, order: null, orderInvalid: true };
for (const section of langSections) { return { hasSidebar: true, order: n };
const sectionDir = path.join(langDir, section);
if (!fs.existsSync(sectionDir)) continue;
console.log(` ${lang}/${section}/`);
const issues = [];
checkDirectory(sectionDir, issues);
for (const issue of issues) {
allErrors.push(issue);
switch (issue.type) {
case 'duplicate-order': {
console.log(` [ERROR] Duplicate order ${issue.order}: ${relativePath(issue.file)}`);
break;
}
case 'gap': {
console.log(` [ERROR] ${issue.message} in ${lang}/${section}/`);
break;
}
case 'missing-order': {
console.log(` [ERROR] ${issue.message}: ${relativePath(issue.file)}`);
break;
}
}
}
if (issues.length === 0) {
console.log(` [OK] ${lang}/${section}/ — all orders valid`);
}
}
const driftWarnings = [];
checkTranslationDrift(lang, langSections, englishOrderMaps, driftWarnings);
for (const w of driftWarnings) {
allWarnings.push(w);
console.log(` [WARN] ${relativePath(w.file)}: order ${w.langOrder} (English: ${w.englishOrder})`);
}
} }
console.log(`\n${'─'.repeat(60)}`); /**
console.log(`\nSummary:`); * List markdown files (.md/.mdx) in a directory, excluding subdirectories.
console.log(` Errors: ${allErrors.length}`); * @param {string} dirPath - Absolute path to the directory.
console.log(` Warnings: ${allWarnings.length}`); * @returns {fs.Dirent[]} Dirent entries for markdown files.
*/
if (allErrors.length > 0) { function listMdEntries(dirPath) {
const types = {}; return fs.readdirSync(dirPath, { withFileTypes: true }).filter((e) => e.isFile() && (e.name.endsWith('.md') || e.name.endsWith('.mdx')));
for (const e of allErrors) {
types[e.type] = (types[e.type] || 0) + 1;
}
console.log(`\n Error breakdown:`);
for (const [type, count] of Object.entries(types)) {
console.log(` ${type}: ${count}`);
}
} }
if (allErrors.length === 0 && allWarnings.length === 0) { main();
console.log(`\n All sidebar orders valid!`);
}
console.log('');
process.exit(allErrors.length > 0 ? 1 : 0);