scripts/check-dist.mjs (5415B)
1 /**
2 * check-dist.mjs — the post-build checks on what is about to be uploaded: every page meant
3 * to carry a body has one, and every page's Content-Security-Policy allows what the page
4 * actually inlines (see the Content-Security-Policy section).
5 *
6 * The pages already refuse to build an entry that came back unrendered (src/lib/rendered.ts).
7 * This reads the output instead of the inputs, so it still holds for a page that renders
8 * content without going through that guard: it is the last look at what is about to be
9 * uploaded. npm runs it after `npm run build`, which is what CI calls.
10 *
11 * Each container must exist as well as be non-empty. A class renamed in a template would
12 * otherwise turn every check here into a silent pass.
13 */
14 import { createHash } from 'node:crypto';
15 import { readdir, readFile } from 'node:fs/promises';
16 import path from 'node:path';
17
18 const DIST = 'dist';
19 const errors = [];
20 const err = (f, m) => errors.push(`${f}: ${m}`);
21 const count = (s, re) => (s.match(re) ?? []).length;
22
23 // Posts: dist/writing/<year>/<slug>/index.html, one <div class="prose"> each.
24 const writing = path.join(DIST, 'writing');
25 const years = (await readdir(writing, { withFileTypes: true })).filter((d) => d.isDirectory() && /^\d{4}$/.test(d.name));
26 let posts = 0;
27 for (const y of years) {
28 for (const s of await readdir(path.join(writing, y.name), { withFileTypes: true })) {
29 if (!s.isDirectory()) continue;
30 const rel = path.posix.join('writing', y.name, s.name, 'index.html');
31 const html = await readFile(path.join(DIST, rel), 'utf8');
32 posts++;
33 const n = count(html, /<div class="prose">/g);
34 if (n !== 1) err(rel, `expected one <div class="prose">, found ${n}`);
35 else if (/<div class="prose">\s*<\/div>/.test(html)) err(rel, 'the post body is empty');
36 }
37 }
38
39 // About: the whole of about.md goes into <article class="about">.
40 {
41 const html = await readFile(path.join(DIST, 'index.html'), 'utf8');
42 if (!html.includes('<article class="about">')) err('index.html', 'no <article class="about">');
43 else if (/<article class="about">\s*<\/article>/.test(html)) err('index.html', 'the about body is empty');
44 }
45
46 // Code: one .project__about per project, each holding that file's body.
47 let projects = 0;
48 {
49 const html = await readFile(path.join(DIST, 'code', 'index.html'), 'utf8');
50 projects = count(html, /<li class="project"/g);
51 const bodies = count(html, /<div class="project__about"[^>]*>/g);
52 const empty = count(html, /<div class="project__about"[^>]*>\s*<\/div>/g);
53 if (bodies !== projects) err('code/index.html', `${projects} projects but ${bodies} .project__about blocks`);
54 if (empty) err('code/index.html', `${empty} project description(s) are empty`);
55 }
56
57 /* Content-Security-Policy, on every page. Astro writes the <meta> and hashes what it emits,
58 but a <style> or <script> it did not see (a diagram's, say) is dropped by the browser
59 without a sound: the page ships and simply looks or behaves wrong. So each page must
60 carry exactly one policy, nothing the policy governs may come before it, and every
61 inline <style> and executable inline <script> must be listed in it by hash. */
62 const sha = (s) => `'sha256-${createHash('sha256').update(s).digest('base64')}'`;
63 async function htmlFiles(dir) {
64 const out = [];
65 for (const e of await readdir(dir, { withFileTypes: true })) {
66 const abs = path.join(dir, e.name);
67 if (e.isDirectory()) out.push(...await htmlFiles(abs));
68 else if (e.name.endsWith('.html')) out.push(abs);
69 }
70 return out;
71 }
72 let policed = 0;
73 for (const abs of await htmlFiles(DIST)) {
74 const rel = path.relative(DIST, abs).split(path.sep).join('/');
75 const html = await readFile(abs, 'utf8');
76 const metas = [...html.matchAll(/<meta http-equiv="content-security-policy" content="([^"]*)">/gi)];
77 if (metas.length !== 1) { err(rel, `expected one Content-Security-Policy <meta>, found ${metas.length}`); continue; }
78 policed++;
79 if (/<(?:script|style|base)\b|<link\b[^>]*\brel="stylesheet"/i.test(html.slice(0, metas[0].index)))
80 err(rel, 'a script, style or <base> comes before the Content-Security-Policy <meta>, which does not govern it');
81 const directives = new Map(
82 metas[0][1].split(';').map((d) => d.trim().split(/\s+/)).filter((t) => t[0]).map(([name, ...values]) => [name, new Set(values)]),
83 );
84 const listed = (kind, hash) =>
85 (directives.get(`${kind}-src-elem`) ?? directives.get(`${kind}-src`) ?? directives.get('default-src') ?? new Set()).has(hash);
86 for (const [, css] of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi))
87 if (!listed('style', sha(css))) err(rel, `an inline <style> (${css.length} chars) is not in the policy; the browser will drop it`);
88 for (const [, attrs, js] of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
89 if (/\bsrc=/i.test(attrs) || /\btype="application\/(?:ld\+)?json"/i.test(attrs)) continue;
90 if (!listed('script', sha(js))) err(rel, `an inline <script> (${js.length} chars) is not in the policy; the browser will not run it`);
91 }
92 }
93
94 console.log(`check-dist: ${posts} post pages, about, ${projects} projects; a complete policy on ${policed} pages`);
95 for (const e of errors) console.error(` ERROR ${e}`);
96 if (errors.length) { console.error(`\n${errors.length} error(s) — dist/ is not fit to deploy.`); process.exit(1); }
97 console.log(' every body is present and every inline style and script is allowed — passed');