Files
simba_docs/scripts/check-build.mjs
T
2026-09-25 01:45:31 +08:00

76 lines
3.6 KiB
JavaScript

// Check generated HTML, so Markdown links and MDX components are both covered.
import { existsSync, readFileSync, readdirSync, statSync } from 'node:fs';
import { join, relative, sep } from 'node:path';
import { fileURLToPath } from 'node:url';
const root = fileURLToPath(new URL('../dist/', import.meta.url));
if (!existsSync(root)) throw new Error('Missing dist/. Run npm run build first.');
const origin = 'https://docs.simba-robotics.com';
const files = [];
function walk(dir) {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) walk(path);
else files.push(path);
}
}
walk(root);
const pages = new Map(files.filter((path) => path.endsWith('.html'))
.map((path) => [path, readFileSync(path, 'utf8')]));
const errors = [];
let checked = 0;
let diagrams = 0;
for (const [path, html] of pages) {
const page = relative(root, path).split(sep).join('/');
const base = new URL(page.replace(/index\.html$/, ''), origin);
if (html.includes('class="katex-error"')) errors.push(`${page}: invalid math`);
if (/<style\b[^>]*\sset:html=/.test(html)) errors.push(`${page}: MDX emitted inert SVG styles`);
const diagramCount = [...html.matchAll(/class="mermaid-diagram"/g)].length;
if (diagramCount) {
diagrams += diagramCount;
const staticCount = [...html.matchAll(/data-rendered="static"/g)].length;
const lightCount = [...html.matchAll(/class="mermaid-variant mermaid-light"/g)].length;
const darkCount = [...html.matchAll(/class="mermaid-variant mermaid-dark"/g)].length;
const svgCount = [...html.matchAll(/<svg\b[^>]*\bid="mermaid-[^"]+"/g)].length;
if ([staticCount, lightCount, darkCount].some((count) => count !== diagramCount) || svgCount !== diagramCount * 2) {
errors.push(`${page}: Mermaid must contain both pre-rendered SVG themes`);
}
const ids = [...html.matchAll(/\sid="([^"]+)"/g)].map((match) => match[1]);
if (ids.length !== new Set(ids).size) errors.push(`${page}: duplicate IDs may break inline SVG references`);
}
// Astro emits quoted, escaped attributes. Code examples escape '<', so they
// are not mistaken for real links here. External URLs are not fetched.
for (const tag of html.matchAll(/<(?:a|link|script|img)\b[^>]*>/g)) {
// Canonical/hreflang are SEO metadata, not navigation or loaded assets.
if (/^<link\b/.test(tag[0]) && /\srel="(?:canonical|alternate)"/.test(tag[0])) continue;
for (const attribute of tag[0].matchAll(/\s(?:href|src)="([^"]*)"/g)) {
const ref = attribute[1].replaceAll('&amp;', '&');
const url = new URL(ref, base);
if (url.origin !== base.origin) continue;
let target = join(root, decodeURIComponent(url.pathname));
if (existsSync(target) && statSync(target).isDirectory()) target = join(target, 'index.html');
if (!existsSync(target)) {
errors.push(`${page}: missing ${ref}`);
continue;
}
checked++;
if (url.hash && pages.has(target)) {
const id = decodeURIComponent(url.hash.slice(1));
if (!pages.get(target).includes(`id="${id}"`)) errors.push(`${page}: missing anchor ${ref}`);
}
}
}
}
if (!existsSync(join(root, '404.html'))) errors.push('Missing 404.html');
for (const path of files) {
if (/\.(?:woff2?|ttf|otf|eot)$/i.test(path)) errors.push(`Unexpected bundled font: ${relative(root, path)}`);
}
if (errors.length) {
console.error(errors.join('\n'));
process.exitCode = 1;
} else {
console.log(`Checked ${pages.size} HTML pages, ${checked} local links/assets, and ${diagrams} static Mermaid diagrams; no broken anchors, math errors, or bundled fonts.`);
}