blog.git

blog

~ Personal research notes. blog.sqzer.com
git clone https://github.com/debuman/blog.git

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');