From 5cafcd46c8fb2e6662f76599a059da4f3f2da04c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Emmanuel=20Ats=C3=A9?= <4688434+eatse21@users.noreply.github.com> Date: Fri, 22 May 2026 03:45:50 +0200 Subject: [PATCH] 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). --- tools/validate-sidebar-order.js | 459 ++++++++++++++++++-------------- 1 file changed, 265 insertions(+), 194 deletions(-) diff --git a/tools/validate-sidebar-order.js b/tools/validate-sidebar-order.js index c08b57e2e..9183d380e 100644 --- a/tools/validate-sidebar-order.js +++ b/tools/validate-sidebar-order.js @@ -3,14 +3,14 @@ * * Validates sidebar.order values in YAML frontmatter of markdown doc files. * - * What it checks (English — strict, errors): - * - Duplicate sidebar.order values within the same directory - * - Gaps in the ordering sequence (e.g., 1, 2, 4 missing 3) - * - sidebar: block present but missing order: field + * 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 * - * What it checks (translations — errors + warnings): - * - Same structural rules as English (duplicates, gaps) — errors - * - Order drift from English counterpart — warnings (non-blocking) + * 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 @@ -20,113 +20,157 @@ const fs = require('node:fs'); const path = require('node:path'); const DOCS_ROOT = path.resolve(__dirname, '../docs'); -const FRONTMATTER_REGEX = /^---\r?\n([\s\S]*?)\r?\n---/; -const SIDEBAR_ORDER_REGEX = /sidebar:\s*\r?\n(?:(?:[ \t]+.*\r?\n)|(?:\r?\n))*?[ \t]+order:\s*(\d+)/; -const HAS_SIDEBAR_REGEX = /^sidebar:/m; -const LOCALE_PATTERN = /^[a-z]{2}(?:-[a-zA-Z]{2})?$/; +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 ───────────────────────────────────────────────────────────────── /** - * Extract sidebar.order from YAML frontmatter. - * @param {string} content - Full file contents of a markdown file. - * @returns {{ hasSidebar: boolean, order?: number|null }} Whether a sidebar block exists and its order value. + * Scan all docs, validate sidebar orders, and report errors/warnings. + * Exits 0 on success, 1 if any errors found. */ -function extractSidebarOrder(content) { - const match = content.match(FRONTMATTER_REGEX); - if (!match) return { hasSidebar: false }; - - const frontmatter = match[1]; - - if (!HAS_SIDEBAR_REGEX.test(frontmatter)) { - return { hasSidebar: false }; +function main() { + if (!fs.existsSync(DOCS_ROOT)) { + console.error(`Error: docs directory not found at ${DOCS_ROOT}`); + process.exit(1); } - const orderMatch = frontmatter.match(SIDEBAR_ORDER_REGEX); - if (!orderMatch) { - return { hasSidebar: true, order: null }; + 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`); + } } - 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); } -/** - * 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)); -} +// ── Directory classification ───────────────────────────────────────────── /** - * List all top-level content directories under docs/ (excludes _-prefixed dirs). - * @returns {string[]} Directory names for English content sections. + * 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 getEnglishSections() { - const entries = fs.readdirSync(DOCS_ROOT, { withFileTypes: true }); - return entries.filter((e) => e.isDirectory() && !e.name.startsWith('_')).map((e) => e.name); +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. - * @param {Array<{level:string,type:string,file?:string,order?:number,directory?:string,missing?:number,message:string}>} issues - Array to push errors into. - * @returns {Map} Map of order values to the files that hold them. + * @returns {{ orderMap: Map, issues: object[] }} */ -function checkDirectory(dirPath, issues) { - const entries = fs.readdirSync(dirPath, { withFileTypes: true }); - const mdFiles = entries.filter((e) => e.isFile() && (e.name.endsWith('.md') || e.name.endsWith('.mdx'))); - +function checkDirectory(dirPath) { + const issues = []; const orderMap = new Map(); const missingOrder = []; + const invalidOrder = []; - for (const entry of mdFiles) { + for (const entry of listMdEntries(dirPath)) { const fullPath = path.join(dirPath, entry.name); - const content = fs.readFileSync(fullPath, 'utf-8'); - const { hasSidebar, order } = extractSidebarOrder(content); + const result = extractSidebarOrder(fs.readFileSync(fullPath, 'utf-8')); - if (!hasSidebar) continue; - - if (order === null) { - missingOrder.push(fullPath); + if (!result.hasSidebar) continue; + if (result.order === null) { + if (result.orderInvalid) { + invalidOrder.push(fullPath); + } else { + missingOrder.push(fullPath); + } continue; } - if (!orderMap.has(order)) { - orderMap.set(order, []); - } - orderMap.get(order).push(fullPath); + 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`, - }); + 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}`, - }); + issues.push({ level: 'error', type: 'duplicate-order', file, order, message: `Duplicate sidebar.order: ${order}` }); } } } if (orderMap.size > 0) { - const orders = [...orderMap.keys()].sort((a, b) => a - b); - const max = orders.at(-1); + 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({ @@ -136,21 +180,37 @@ function checkDirectory(dirPath, issues) { 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; + return { orderMap, issues }; } +// ── Cross-language 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 {Map>} 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. + * @param {Map>} englishOrderMaps - English order maps keyed by section name. + * @returns {object[]} Drift warnings. */ -function checkTranslationDrift(lang, langSections, englishOrderMaps, 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; @@ -158,22 +218,18 @@ function checkTranslationDrift(lang, langSections, englishOrderMaps, warnings) { const englishMap = englishOrderMaps.get(section); if (!englishMap) continue; - const entries = fs.readdirSync(sectionDir, { withFileTypes: true }); - const mdFiles = entries.filter((e) => e.isFile() && (e.name.endsWith('.md') || e.name.endsWith('.mdx'))); - - for (const entry of mdFiles) { + 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 langContent = fs.readFileSync(langFile, 'utf-8'); - const engContent = fs.readFileSync(englishFile, 'utf-8'); + const langResult = extractSidebarOrder(fs.readFileSync(langFile, 'utf-8')); + const engResult = extractSidebarOrder(fs.readFileSync(englishFile, 'utf-8')); - const langResult = extractSidebarOrder(langContent); - const engResult = extractSidebarOrder(engContent); + const langHasOrder = typeof langResult.order === 'number'; + 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({ level: 'warning', type: 'order-drift', @@ -181,137 +237,152 @@ function checkTranslationDrift(lang, langSections, englishOrderMaps, warnings) { englishFile, langOrder: langResult.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. * @param {string} filePath - Absolute file path. * @returns {string} Relative path from docs root. */ -function relativePath(filePath) { +function rel(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 englishSections = getEnglishSections().filter((s) => !languageDirs.includes(s)); + const frontmatter = match[1]; -console.log(`English sections: ${englishSections.join(', ')}`); -console.log(`Translation languages: ${languageDirs.join(', ')}\n`); + // Flow mapping: sidebar: { order: 5 } + const inline = frontmatter.match(/^sidebar:[ \t]*\{[^}]*\border:[ \t]*(\d+)/m); + if (inline) return validateOrder(inline[1]); -const allErrors = []; -const allWarnings = []; + // Block mapping: sidebar:\n order: 5 + 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) { - const sectionDir = path.join(DOCS_ROOT, section); - if (!fs.existsSync(sectionDir) || !fs.statSync(sectionDir).isDirectory()) continue; + for (let i = start + 1; i < lines.length; i++) { + const line = lines[i]; + if (/^\s*$/.test(line)) continue; - console.log(`\nChecking English docs/${section}/`); - const issues = []; - const orderMap = checkDirectory(sectionDir, issues); - englishOrderMaps.set(section, orderMap); + const indent = line.search(/\S/); + if (indent === 0) break; + if (baseIndent === null) baseIndent = indent; + if (indent < baseIndent) break; + if (indent > baseIndent) continue; - for (const issue of issues) { - if (issue.level === 'error') { - 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; - } - } - } + const m = line.match(/^\s+order:[ \t]*(\d+)/); + if (m) return validateOrder(m[1]); } - if (issues.length === 0) { - console.log(` [OK] docs/${section}/ — all orders valid`); - } + return { hasSidebar: true, order: null }; } -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, 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})`); - } +/** + * 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 }; } -console.log(`\n${'─'.repeat(60)}`); -console.log(`\nSummary:`); -console.log(` Errors: ${allErrors.length}`); -console.log(` Warnings: ${allWarnings.length}`); - -if (allErrors.length > 0) { - const types = {}; - 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}`); - } +/** + * 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'))); } -if (allErrors.length === 0 && allWarnings.length === 0) { - console.log(`\n All sidebar orders valid!`); -} - -console.log(''); -process.exit(allErrors.length > 0 ? 1 : 0); +main();