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