From 98bf8a3a9f0d70522cb26869ec018f4f62e4f290 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 01:32:17 +0200 Subject: [PATCH 1/4] 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. --- package.json | 3 +- tools/validate-sidebar-order.js | 294 ++++++++++++++++++++++++++++++++ 2 files changed, 296 insertions(+), 1 deletion(-) create mode 100644 tools/validate-sidebar-order.js diff --git a/package.json b/package.json index cb88efa64..add3f829c 100644 --- a/package.json +++ b/package.json @@ -31,6 +31,7 @@ "docs:fix-links": "node tools/fix-doc-links.js", "docs:preview": "astro preview --root website", "docs:validate-links": "node tools/validate-doc-links.js", + "docs:validate-sidebar": "node tools/validate-sidebar-order.js", "format:check": "prettier --check \"**/*.{js,cjs,mjs,json,yaml}\"", "format:fix": "prettier --write \"**/*.{js,cjs,mjs,json,yaml}\"", "format:fix:staged": "prettier --write", @@ -39,7 +40,7 @@ "lint:fix": "eslint . --ext .js,.cjs,.mjs,.yaml --fix", "lint:md": "markdownlint-cli2 \"**/*.md\"", "prepare": "command -v husky >/dev/null 2>&1 && husky || exit 0", - "quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:urls && npm run validate:refs && npm run validate:skills", + "quality": "npm run format:check && npm run lint && npm run lint:md && npm run docs:build && npm run test:install && npm run test:urls && npm run validate:refs && npm run validate:skills && npm run docs:validate-sidebar", "rebundle": "node tools/installer/bundlers/bundle-web.js rebundle", "test": "npm run test:refs && npm run test:install && npm run test:urls && npm run test:channels && npm run lint && npm run lint:md && npm run format:check", "test:channels": "node test/test-installer-channels.js", diff --git a/tools/validate-sidebar-order.js b/tools/validate-sidebar-order.js new file mode 100644 index 000000000..fe3950459 --- /dev/null +++ b/tools/validate-sidebar-order.js @@ -0,0 +1,294 @@ +/** + * Sidebar Order Validator + * + * 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 + * + * What it checks (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_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; +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 }; + } + + const orderMatch = frontmatter.match(SIDEBAR_ORDER_REGEX); + if (!orderMatch) { + return { hasSidebar: true, order: null }; + } + + return { hasSidebar: true, order: parseInt(orderMatch[1], 10) }; +} + +function detectLanguageDirs() { + const dirs = []; + const entries = fs.readdirSync(DOCS_ROOT, { withFileTypes: true }); + + for (const entry of entries) { + if (!entry.isDirectory() || entry.name.startsWith('_')) continue; + + const subPath = path.join(DOCS_ROOT, entry.name); + const subEntries = fs.readdirSync(subPath, { withFileTypes: true }); + const hasSubdirs = subEntries.some((e) => e.isDirectory() && !e.name.startsWith('_')); + + if (hasSubdirs) { + dirs.push(entry.name); + } + } + return dirs; +} + +function getEnglishSections() { + const entries = fs.readdirSync(DOCS_ROOT, { withFileTypes: true }); + return entries.filter((e) => e.isDirectory() && !e.name.startsWith('_')).map((e) => e.name); +} + +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'))); + + const orderMap = new Map(); + const missingOrder = []; + + for (const entry of mdFiles) { + const fullPath = path.join(dirPath, entry.name); + const content = fs.readFileSync(fullPath, 'utf-8'); + const { hasSidebar, order } = extractSidebarOrder(content); + + if (!hasSidebar) continue; + + if (order === null) { + missingOrder.push(fullPath); + continue; + } + + if (!orderMap.has(order)) { + orderMap.set(order, []); + } + orderMap.get(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 [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) { + const orders = [...orderMap.keys()].sort((a, b) => a - b); + const max = orders.at(-1); + + 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}`, + }); + } + } + } + + return orderMap; +} + +function checkTranslationDrift(lang, langSections, englishOrderMaps, 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; + + 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) { + 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(langContent); + const engResult = extractSidebarOrder(engContent); + + if (langResult.order !== null && engResult.order !== null && langResult.order !== engResult.order) { + warnings.push({ + level: 'warning', + type: 'order-drift', + file: langFile, + englishFile, + langOrder: langResult.order, + englishOrder: engResult.order, + message: `Order drift: ${lang} has order ${langResult.order}, English has ${engResult.order}`, + }); + } + } + } +} + +function relativePath(filePath) { + return path.relative(DOCS_ROOT, filePath); +} + +// Main execution +console.log(`\nValidating sidebar ordering in: ${DOCS_ROOT}\n`); + +const languageDirs = detectLanguageDirs(); +const englishSections = getEnglishSections().filter((s) => !languageDirs.includes(s)); + +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) || !fs.statSync(sectionDir).isDirectory()) continue; + + console.log(`\nChecking English docs/${section}/`); + const issues = []; + const orderMap = checkDirectory(sectionDir, issues); + englishOrderMaps.set(section, orderMap); + + 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; + } + } + } + } + + 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, 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:`); +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}`); + } +} + +if (allErrors.length === 0 && allWarnings.length === 0) { + console.log(`\n All sidebar orders valid!`); +} + +console.log(''); +process.exit(allErrors.length > 0 ? 1 : 0); From 7ef3a66dca3dce93c2fb8121f23c80f4f1415059 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 02:29:49 +0200 Subject: [PATCH 2/4] fix(validate-sidebar): tighten language detection and drift guard, add docstrings MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit * 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. --- tools/validate-sidebar-order.js | 53 +++++++++++++++++++++++---------- 1 file changed, 38 insertions(+), 15 deletions(-) diff --git a/tools/validate-sidebar-order.js b/tools/validate-sidebar-order.js index fe3950459..c08b57e2e 100644 --- a/tools/validate-sidebar-order.js +++ b/tools/validate-sidebar-order.js @@ -23,6 +23,13 @@ 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})?$/; + +/** + * 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. + */ function extractSidebarOrder(content) { const match = content.match(FRONTMATTER_REGEX); if (!match) return { hasSidebar: false }; @@ -41,29 +48,33 @@ function extractSidebarOrder(content) { return { hasSidebar: true, order: parseInt(orderMatch[1], 10) }; } +/** + * 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 dirs = []; const entries = fs.readdirSync(DOCS_ROOT, { withFileTypes: true }); - - for (const entry of entries) { - if (!entry.isDirectory() || entry.name.startsWith('_')) continue; - - const subPath = path.join(DOCS_ROOT, entry.name); - const subEntries = fs.readdirSync(subPath, { withFileTypes: true }); - const hasSubdirs = subEntries.some((e) => e.isDirectory() && !e.name.startsWith('_')); - - if (hasSubdirs) { - dirs.push(entry.name); - } - } - return dirs; + 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). + * @returns {string[]} Directory names for English content sections. + */ function getEnglishSections() { const entries = fs.readdirSync(DOCS_ROOT, { withFileTypes: true }); return entries.filter((e) => e.isDirectory() && !e.name.startsWith('_')).map((e) => e.name); } +/** + * Validate sidebar.order values for all markdown files in a directory. + * @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. + */ 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'))); @@ -132,6 +143,13 @@ function checkDirectory(dirPath, issues) { return orderMap; } +/** + * Compare translated sidebar orders against English counterparts and warn on drift. + * @param {string} lang - Language directory name (e.g. "cs"). + * @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. + */ function checkTranslationDrift(lang, langSections, englishOrderMaps, warnings) { for (const section of langSections) { const sectionDir = path.join(DOCS_ROOT, lang, section); @@ -155,7 +173,7 @@ function checkTranslationDrift(lang, langSections, englishOrderMaps, warnings) { const langResult = extractSidebarOrder(langContent); const engResult = extractSidebarOrder(engContent); - if (langResult.order !== null && engResult.order !== null && langResult.order !== engResult.order) { + if (typeof langResult.order === 'number' && typeof engResult.order === 'number' && langResult.order !== engResult.order) { warnings.push({ level: 'warning', type: 'order-drift', @@ -170,6 +188,11 @@ function checkTranslationDrift(lang, langSections, englishOrderMaps, warnings) { } } +/** + * 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) { return path.relative(DOCS_ROOT, filePath); } From 42121ca70c203bb7742fe28fbd8956ca3804505b 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 02:43:19 +0200 Subject: [PATCH 3/4] fix(validate-sidebar): add to pre-commit hook --- .husky/pre-commit | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.husky/pre-commit b/.husky/pre-commit index ae9e0c44f..9d7c37791 100755 --- a/.husky/pre-commit +++ b/.husky/pre-commit @@ -10,11 +10,13 @@ npm test if command -v rg >/dev/null 2>&1; then if git diff --cached --name-only | rg -q '^docs/'; then npm run docs:validate-links + npm run docs:validate-sidebar npm run docs:build fi else if git diff --cached --name-only | grep -Eq '^docs/'; then npm run docs:validate-links + npm run docs:validate-sidebar npm run docs:build fi fi 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 4/4] 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();