Files

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}).`);
}