Merge 5cafcd46c8 into ee47e30cf6
This commit is contained in:
commit
0f6370b011
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -0,0 +1,388 @@
|
|||
/**
|
||||
* 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();
|
||||
Loading…
Reference in New Issue