Build and publish Eldorado documentation / build (pull_request) Successful in 41s
117 lines
6.5 KiB
JavaScript
117 lines
6.5 KiB
JavaScript
import assert from 'node:assert/strict';
|
|
import {readFileSync, readdirSync, mkdirSync, writeFileSync} from 'node:fs';
|
|
import {join} from 'node:path';
|
|
|
|
const normalize = path => path.replace(/\/$/, '');
|
|
const read = path => JSON.parse(readFileSync(path, 'utf8'));
|
|
|
|
// Use Docusaurus's resolved IDs/routes and sidebar trees, not filename guesses.
|
|
export function auditSidebars(instances, entrySidebars) {
|
|
const docs = new Map();
|
|
const menus = new Map();
|
|
for (const {plugin, version, documents, sidebars} of instances) {
|
|
const ids = new Set();
|
|
for (const doc of documents) {
|
|
assert(!ids.has(doc.id), `Duplicate document ID: ${plugin}:${version}:${doc.id}`);
|
|
ids.add(doc.id);
|
|
const route = normalize(doc.path);
|
|
assert(!docs.has(route), `Duplicate document route: ${route}`);
|
|
docs.set(route, {...doc, plugin, version, menu: `${plugin}:${version}:${doc.sidebar}`});
|
|
}
|
|
for (const [name, items] of Object.entries(sidebars)) menus.set(`${plugin}:${version}:${name}`, items);
|
|
}
|
|
const links = new Map();
|
|
for (const [menu, items] of menus) {
|
|
const found = new Set();
|
|
const walk = items => {
|
|
for (const item of items) {
|
|
assert(['category', 'link', 'html'].includes(item.type), `Unresolved sidebar type: ${item.type}`);
|
|
assert(!item.unlisted, `Hidden sidebar item: ${item.label}`);
|
|
if (item.href && !/^(https?:|mailto:)/.test(item.href)) {
|
|
const route = normalize(item.href);
|
|
if (item.docId) assert.equal(docs.get(route)?.id, item.docId, `Sidebar ID/route mismatch: ${item.docId}`);
|
|
assert(!found.has(route), `Duplicate sidebar route in ${menu}: ${route}`);
|
|
found.add(route);
|
|
}
|
|
if (item.type === 'category') walk(item.items);
|
|
}
|
|
};
|
|
walk(items);
|
|
links.set(menu, found);
|
|
}
|
|
const reachable = new Set(entrySidebars);
|
|
for (const menu of reachable) {
|
|
assert(menus.has(menu), `Unknown navigation sidebar: ${menu}`);
|
|
for (const route of links.get(menu)) {
|
|
const doc = docs.get(route);
|
|
if (doc) reachable.add(doc.menu);
|
|
}
|
|
}
|
|
for (const [route, doc] of docs) {
|
|
assert(reachable.has(doc.menu), `Unreachable sidebar for ${doc.plugin}:${doc.id}`);
|
|
assert(links.get(doc.menu)?.has(route), `Missing sidebar document: ${doc.plugin}:${doc.id}`);
|
|
}
|
|
return [...docs.entries()].map(([route, doc]) => ({route, id:doc.id, plugin:doc.plugin, sidebar:doc.sidebar}));
|
|
}
|
|
|
|
export async function verifySidebarCoverage() {
|
|
const global = read('.docusaurus/globalData.json')['docusaurus-plugin-content-docs'];
|
|
assert(global && Object.keys(global).length, 'No generated document inventory');
|
|
const instances = [];
|
|
for (const [plugin, data] of Object.entries(global)) {
|
|
const folder = `.docusaurus/docusaurus-plugin-content-docs/${plugin}/p`;
|
|
const resolved = readdirSync(folder).filter(f => f.endsWith('.json')).map(f => read(join(folder, f)).version);
|
|
for (const version of data.versions) {
|
|
const matches = resolved.filter(v => v.version === version.name);
|
|
assert.equal(matches.length, 1, `Missing/duplicate resolved version: ${plugin}:${version.name}`);
|
|
instances.push({plugin, version:version.name, documents:version.docs, sidebars:matches[0].docsSidebars});
|
|
}
|
|
}
|
|
const {default:config} = await import('../.docusaurus/docusaurus.config.mjs');
|
|
const entries = config.themeConfig.navbar.items.filter(i => i.type === 'docSidebar').map(i => `${i.docsPluginId || 'default'}:current:${i.sidebarId}`);
|
|
const documents = auditSidebars(instances, entries);
|
|
const routes = new Set(documents.map(d => `${d.route.replace(config.baseUrl, '')}/index.html`));
|
|
const exclusions = [];
|
|
const html = readdirSync('build', {recursive:true}).filter(f => f.endsWith('.html')).sort();
|
|
for (const file of html) {
|
|
if (routes.delete(file)) continue;
|
|
let reason;
|
|
if (file === 'index.html') reason = 'Dedicated React homepage, not a documentation page';
|
|
else if (file === '404.html') reason = 'Docusaurus not-found system page';
|
|
else if (file === 'search/index.html') reason = 'Local-search results UI, not a documentation page';
|
|
else if (file === 'strategy/index.html') {
|
|
assert(readFileSync(join('build',file),'utf8').includes('http-equiv="refresh"'), 'Strategy alias must remain a redirect');
|
|
reason = 'Legacy Strategy redirect alias to Target';
|
|
} else if (file === 'strategy/reference/measurement-contracts/index.html') {
|
|
const body = readFileSync(join('build',file),'utf8');
|
|
assert(body.includes('window.location.replace') && body.includes('name="robots" content="noindex"') && !body.includes('<article'), 'Metric alias must remain a non-indexed redirect');
|
|
assert.equal(body, readFileSync(join('static',file),'utf8'));
|
|
reason = 'Legacy measurement-contracts hash-aware redirect to canonical Measurement pages';
|
|
} else if (file.startsWith('diagrams/') && readFileSync(join('build',file),'utf8').includes('<svg')) {
|
|
assert.equal(readFileSync(join('build',file),'utf8'), readFileSync(join('static',file),'utf8'), `Viewer must match generated static asset: ${file}`);
|
|
reason = 'Standalone diagram viewer asset, not documentation';
|
|
}
|
|
assert(reason, `Unclassified published HTML route: ${file}`);
|
|
exclusions.push({file, reason});
|
|
}
|
|
assert.equal(routes.size, 0, `Missing published docs: ${[...routes]}`);
|
|
// Check all local links and anchors on every document, not only selected Strategy pages.
|
|
for (const {route} of documents) {
|
|
const file = `build/${route.replace(config.baseUrl, '')}/index.html`;
|
|
const body = readFileSync(file, 'utf8');
|
|
for (const [, href] of body.matchAll(/href="([^" ]+)"/g)) {
|
|
if (!href.startsWith(config.baseUrl) && !href.startsWith('#')) continue;
|
|
const url = new URL(href, `https://docs.invalid${route}/`);
|
|
let target = url.pathname.slice(config.baseUrl.length);
|
|
if (!target || target.endsWith('/')) target += 'index.html';
|
|
else if (!target.split('/').at(-1).includes('.')) target += '/index.html';
|
|
const targetBody = readFileSync(join('build', decodeURIComponent(target)), 'utf8');
|
|
if (url.hash) assert(targetBody.includes(`id="${decodeURIComponent(url.hash.slice(1))}"`), `Broken anchor: ${route} -> ${href}`);
|
|
}
|
|
}
|
|
mkdirSync('.cache', {recursive:true});
|
|
writeFileSync('.cache/sidebar-coverage.json', JSON.stringify({documents, exclusions}, null, 2));
|
|
console.log(`PASS: ${documents.length} published docs reachable in resolved sidebars; ${exclusions.length} classified non-doc HTML routes; all doc links/anchors checked`);
|
|
return {documents, exclusions};
|
|
}
|