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.
This commit is contained in:
Emmanuel Atsé 2026-05-22 01:32:17 +02:00
parent 1da6bf80df
commit 98bf8a3a9f
No known key found for this signature in database
2 changed files with 296 additions and 1 deletions

View File

@ -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",

View File

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