maestro/scripts/validate-help-docs.mjs
clade 7049a874f3 feat: initial public release (MAESTRO v0.1.0)
Open-source release of MAESTRO, an agent orchestration platform that runs
LLM-driven tasks through sandboxed tools, with a web UI. Apache-2.0.
See README.md and docs/ (getting-started, configuration, architecture).
2026-06-03 04:01:14 +00:00

92 lines
4.0 KiB
JavaScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// scripts/validate-help-docs.mjs
// Validates ui/src/content/help/*.md frontmatter + structural invariants.
// Exits non-zero on any error. Run before `vite build`.
import { readFileSync, readdirSync, existsSync } from 'node:fs';
import { join, dirname, resolve } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
import { createRequire } from 'node:module';
const __dirname = dirname(fileURLToPath(import.meta.url));
const HELP_DIR = resolve(__dirname, '../ui/src/content/help');
// `yaml` lives in ui/node_modules (the build runs `node ../scripts/...` from ui/).
// Resolve it relative to ui/ so this script works regardless of cwd.
let parseYaml;
try {
const require = createRequire(pathToFileURL(resolve(__dirname, '../ui/package.json')));
({ parse: parseYaml } = await import(pathToFileURL(require.resolve('yaml')).href));
} catch {
console.error("help-docs validation: cannot load 'yaml' from ui/node_modules — run `npm --prefix ui install`");
process.exit(1);
}
const CATEGORIES = ['basic', 'advanced', 'admin'];
// KEEP IN SYNC with ui/src/lib/help.ts slugify()
function slugify(text) {
const s = text.replace(/<[^>]*>/g, '').trim().toLowerCase()
.replace(/[^\p{L}\p{N}]+/gu, '-').replace(/^-+|-+$/g, '');
return s || 'section';
}
function splitFrontmatter(raw) {
const n = raw.replace(/^/, '').replace(/\r\n/g, '\n');
const m = /^---\n([\s\S]*?)\n---\n?/.exec(n);
if (!m) return { frontmatter: '', body: n };
return { frontmatter: m[1], body: n.slice(m[0].length) };
}
const errors = [];
const ids = new Map();
const orders = new Map();
const files = readdirSync(HELP_DIR).filter((f) => f.endsWith('.md')).sort();
if (files.length === 0) errors.push('no .md files found in help dir');
for (const file of files) {
const raw = readFileSync(join(HELP_DIR, file), 'utf8');
const { frontmatter, body } = splitFrontmatter(raw);
if (!frontmatter) { errors.push(`${file}: missing frontmatter block`); continue; }
let data;
try { data = parseYaml(frontmatter); } catch (e) { errors.push(`${file}: YAML error ${e.message}`); continue; }
if (typeof data?.id !== 'string' || !data.id.trim()) errors.push(`${file}: 'id' required`);
if (typeof data?.title !== 'string' || !data.title.trim()) errors.push(`${file}: 'title' required`);
if (!CATEGORIES.includes(data?.category)) errors.push(`${file}: 'category' must be ${CATEGORIES.join('|')}`);
if (typeof data?.order !== 'number' || !Number.isFinite(data.order)) errors.push(`${file}: 'order' must be a number`);
if (data?.keywords != null && (!Array.isArray(data.keywords) || data.keywords.some((k) => typeof k !== 'string')))
errors.push(`${file}: 'keywords' must be a string array`);
if (typeof data?.id === 'string') {
if (ids.has(data.id)) errors.push(`${file}: duplicate id '${data.id}' (also ${ids.get(data.id)})`);
else ids.set(data.id, file);
}
if (typeof data?.order === 'number') {
if (orders.has(data.order)) errors.push(`${file}: duplicate order ${data.order} (also ${orders.get(data.order)})`);
else orders.set(data.order, file);
}
// Duplicate heading slugs within a file (anchor links would be ambiguous).
const slugCounts = new Map();
for (const line of body.split('\n')) {
const h = /^(#{2,3})\s+(.*)$/.exec(line);
if (!h) continue;
const slug = slugify(h[2]);
slugCounts.set(slug, (slugCounts.get(slug) ?? 0) + 1);
}
for (const [slug, count] of slugCounts) {
if (count > 1) errors.push(`${file}: ${count} headings collide on slug '${slug}' (anchor links ambiguous)`);
}
// Relative markdown links that point at local files must resolve.
for (const m of body.matchAll(/\]\((\.{1,2}\/[^)]+)\)/g)) {
const target = resolve(HELP_DIR, m[1].split('#')[0]);
if (!existsSync(target)) errors.push(`${file}: broken relative link ${m[1]}`);
}
}
if (errors.length) {
console.error('help-docs validation FAILED:');
for (const e of errors) console.error(' - ' + e);
process.exit(1);
}
console.log(`help-docs validation OK (${files.length} files)`);