79 lines
3.7 KiB
JavaScript
79 lines
3.7 KiB
JavaScript
import '../plugins/environment.mjs';
|
|
import { readdir, readFile, stat } from 'node:fs/promises';
|
|
import path from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { load } from 'cheerio';
|
|
import { normalizeBase } from '../plugins/document-links.mjs';
|
|
|
|
const root = path.resolve(fileURLToPath(new URL('../dist/', import.meta.url)));
|
|
const base = normalizeBase(process.env.SITE_BASE || '/');
|
|
const origin = 'https://local.invalid';
|
|
const failures = [];
|
|
const cache = new Map();
|
|
async function files(directory) {
|
|
const entries = await readdir(directory, { withFileTypes: true });
|
|
return (await Promise.all(entries.map((entry) => entry.isDirectory() ? files(path.join(directory, entry.name)) : path.join(directory, entry.name)))).flat();
|
|
}
|
|
async function document(filename) {
|
|
if (!cache.has(filename)) cache.set(filename, load(await readFile(filename, 'utf8')));
|
|
return cache.get(filename);
|
|
}
|
|
const outputFiles = await files(root);
|
|
for (const filename of outputFiles) {
|
|
const relative = path.relative(root, filename).split(path.sep).join('/');
|
|
if (/^(?:book\/)?draft(?:\/|$)/.test(relative)) failures.push(`Unpublished draft in output: ${relative}`);
|
|
}
|
|
const htmlFiles = outputFiles.filter((file) => file.endsWith('.html'));
|
|
for (const filename of htmlFiles) {
|
|
const relative = path.relative(root, filename).split(path.sep).join('/');
|
|
const pagePath = base + relative.replace(/index\.html$/, '');
|
|
const $ = await document(filename);
|
|
const ids = new Set();
|
|
$('[id]').each((_, element) => {
|
|
const id = $(element).attr('id');
|
|
if (ids.has(id)) failures.push(`${relative}: duplicate id ${id}`);
|
|
ids.add(id);
|
|
});
|
|
if ($('h1').length !== 1) failures.push(`${relative}: expected one h1`);
|
|
if ($('main#main').length !== 1) failures.push(`${relative}: missing main landmark`);
|
|
for (const element of $('a[href], link[href], script[src], img[src]').toArray()) {
|
|
const raw = $(element).attr('href') ?? $(element).attr('src');
|
|
if (!raw || /^(mailto:|tel:|data:)/.test(raw)) continue;
|
|
const url = new URL(raw, origin + pagePath);
|
|
if (url.origin !== origin) continue;
|
|
if (!url.pathname.startsWith(base)) {
|
|
failures.push(`${relative}: link escapes site base: ${raw}`);
|
|
continue;
|
|
}
|
|
let target = path.resolve(root, decodeURIComponent(url.pathname.slice(base.length)));
|
|
if (target !== root && !target.startsWith(root + path.sep)) throw new Error(`Path escape: ${raw}`);
|
|
try {
|
|
if ((await stat(target)).isDirectory()) target = path.join(target, 'index.html');
|
|
await stat(target);
|
|
if (url.hash && target.endsWith('.html')) {
|
|
const dest = await document(target);
|
|
const fragment = decodeURIComponent(url.hash.slice(1));
|
|
if (!dest('[id]').toArray().some((node) => dest(node).attr('id') === fragment)) {
|
|
failures.push(`${relative}: missing anchor ${raw}`);
|
|
}
|
|
}
|
|
} catch (error) {
|
|
failures.push(`${relative}: broken link ${raw} (${error.code ?? error.message})`);
|
|
}
|
|
}
|
|
}
|
|
// Every published source has a page; draft sources must never be emitted.
|
|
const contents = fileURLToPath(new URL('../../contents/', import.meta.url));
|
|
const markdown = (await files(contents)).filter((file) => file.endsWith('.md') && path.relative(contents, file).split(path.sep)[0] !== 'draft');
|
|
for (const file of markdown) {
|
|
const slug = path.relative(contents, file).replace(/\.md$/, '');
|
|
const output = path.join(root, 'book', slug, 'index.html');
|
|
try { await stat(output); } catch { failures.push(`Missing generated document: ${slug}`); }
|
|
}
|
|
if (failures.length) {
|
|
console.error(failures.join('\n'));
|
|
process.exitCode = 1;
|
|
} else {
|
|
console.log(`Checked ${htmlFiles.length} HTML pages, ${markdown.length} source documents, local links/assets and fragment targets (base ${base}).`);
|
|
}
|