diff --git a/astro.config.mjs b/astro.config.mjs
@@ -9,21 +9,44 @@ import satteriGuard from './src/lib/satteri-guard.mjs';
import satteriProse from './src/lib/satteri-prose.mjs';
import satteriDiagram from './src/lib/satteri-diagram.mjs';
import satteriKorean from './src/lib/satteri-korean.mjs';
+import { diagramCopy, diagramKey, findMermaidFences, readDiagram } from './src/lib/diagrams.mjs';
/**
- * sha256 of the <style> inside each baked diagram, for the Content-Security-Policy below.
+ * sha256 of the <style> inside every diagram copy a page will carry, for the
+ * Content-Security-Policy below.
*
- * Astro hashes the scripts and styles it emits itself, and once style-src-elem carries a
- * hash, browsers ignore 'unsafe-inline' for <style> elements. The diagrams' own <style>
- * arrives inside Markdown HTML that Astro never looks at, so without these three it would
- * be blocked and every diagram would lose its palette. The files hold the text exactly as
- * it is inlined (no entities, satteriDiagram inserts it verbatim), so it is hashed as read.
- * prebuild bakes any new diagram before this file is loaded.
+ * Astro hashes the scripts and styles it emits itself, and once style-src carries a hash,
+ * browsers ignore 'unsafe-inline' for <style> elements. The diagrams' own <style> arrives
+ * inside Markdown HTML that Astro never looks at, so without these it would be blocked and
+ * the diagram would lose its palette. A diagram drawn twice on one page is renamed for the
+ * second copy, <style> included, so each copy is hashed as satteriDiagram will emit it:
+ * through diagramCopy(), up to the most copies of that diagram any one document has. The
+ * text is inlined verbatim with no entities, so it is hashed as it stands. prebuild bakes
+ * any new diagram before this file is loaded, and check-dist fails the build if a page
+ * still carries a <style> or <script> its policy does not list.
*/
-const DIAGRAM_STYLE_HASHES = readdirSync('src/lib/diagrams')
- .filter((f) => f.endsWith('.svg'))
- .flatMap((f) => [...readFileSync(`src/lib/diagrams/${f}`, 'utf8').matchAll(/<style>([\s\S]*?)<\/style>/g)])
- .map(([, css]) => `sha256-${createHash('sha256').update(css).digest('base64')}`);
+function diagramCopies(dir, copies = new Map()) {
+ for (const entry of readdirSync(dir, { withFileTypes: true })) {
+ const file = `${dir}/${entry.name}`;
+ if (entry.isDirectory()) diagramCopies(file, copies);
+ else if (entry.name.endsWith('.md')) {
+ const onPage = new Map();
+ for (const { source } of findMermaidFences(readFileSync(file, 'utf8'))) {
+ const key = diagramKey(source);
+ onPage.set(key, (onPage.get(key) ?? 0) + 1);
+ }
+ for (const [key, n] of onPage) copies.set(key, Math.max(copies.get(key) ?? 0, n));
+ }
+ }
+ return copies;
+}
+const DIAGRAM_STYLE_HASHES = [...diagramCopies('content')].flatMap(([key, n]) => {
+ const svg = readDiagram(key);
+ if (!svg) return [];
+ return Array.from({ length: n }, (_, i) => diagramCopy(svg, key, i))
+ .flatMap((markup) => [...markup.matchAll(/<style>([\s\S]*?)<\/style>/g)])
+ .map(([, css]) => `sha256-${createHash('sha256').update(css).digest('base64')}`);
+});
/**
* Grammars preloaded into the Shiki highlighter: the fence languages the corpus uses.
@@ -127,6 +150,13 @@ export default defineConfig({
* the answer to the "Shiki ... not compatible with Content Security Policy" warning
* Astro prints on every build. What a <meta> cannot carry: frame-ancestors, report-uri
* and sandbox are ignored there.
+ *
+ * Two things in the emitted policy are Astro's and not the site's. script-src always lists
+ * five hashes for Astro's client-directive runtimes (idle, load, media, only, visible;
+ * astro/dist/runtime/client/*.prebuilt.js), added even on a site with no islands, and
+ * style-src lists sha256 of the empty string. Neither matches anything a page carries.
+ * Astro writes the <meta> where it renders the head, after the preload and icon links and
+ * before every stylesheet and script; check-dist holds it to that.
*/
security: {
csp: {
diff --git a/scripts/check-dist.mjs b/scripts/check-dist.mjs
@@ -1,5 +1,7 @@
/**
- * check-dist.mjs — the post-build check that every page meant to carry a body has one.
+ * check-dist.mjs — the post-build checks on what is about to be uploaded: every page meant
+ * to carry a body has one, and every page's Content-Security-Policy allows what the page
+ * actually inlines (see the section near the end).
*
* The pages already refuse to build an entry that came back unrendered (src/lib/rendered.ts).
* This reads the output instead of the inputs, so it still holds for a page that renders
@@ -9,6 +11,7 @@
* Each container must exist as well as be non-empty. A class renamed in a template would
* otherwise turn every check here into a silent pass.
*/
+import { createHash } from 'node:crypto';
import { readdir, readFile } from 'node:fs/promises';
import path from 'node:path';
@@ -51,7 +54,44 @@ let projects = 0;
if (empty) err('code/index.html', `${empty} project description(s) are empty`);
}
-console.log(`check-dist: ${posts} post pages, about, ${projects} projects`);
+/* Content-Security-Policy, on every page. Astro writes the <meta> and hashes what it emits,
+ but a <style> or <script> it did not see (a diagram's, say) is dropped by the browser
+ without a sound: the page ships and simply looks or behaves wrong. So each page must
+ carry exactly one policy, nothing the policy governs may come before it, and every
+ inline <style> and executable inline <script> must be listed in it by hash. */
+const sha = (s) => `'sha256-${createHash('sha256').update(s).digest('base64')}'`;
+async function htmlFiles(dir) {
+ const out = [];
+ for (const e of await readdir(dir, { withFileTypes: true })) {
+ const abs = path.join(dir, e.name);
+ if (e.isDirectory()) out.push(...await htmlFiles(abs));
+ else if (e.name.endsWith('.html')) out.push(abs);
+ }
+ return out;
+}
+let policed = 0;
+for (const abs of await htmlFiles(DIST)) {
+ const rel = path.relative(DIST, abs).split(path.sep).join('/');
+ const html = await readFile(abs, 'utf8');
+ const metas = [...html.matchAll(/<meta http-equiv="content-security-policy" content="([^"]*)">/gi)];
+ if (metas.length !== 1) { err(rel, `expected one Content-Security-Policy <meta>, found ${metas.length}`); continue; }
+ policed++;
+ if (/<(?:script|style|base)\b|<link\b[^>]*\brel="stylesheet"/i.test(html.slice(0, metas[0].index)))
+ err(rel, 'a script, style or <base> comes before the Content-Security-Policy <meta>, which does not govern it');
+ const directives = new Map(
+ metas[0][1].split(';').map((d) => d.trim().split(/\s+/)).filter((t) => t[0]).map(([name, ...values]) => [name, new Set(values)]),
+ );
+ const listed = (kind, hash) =>
+ (directives.get(`${kind}-src-elem`) ?? directives.get(`${kind}-src`) ?? directives.get('default-src') ?? new Set()).has(hash);
+ for (const [, css] of html.matchAll(/<style\b[^>]*>([\s\S]*?)<\/style>/gi))
+ if (!listed('style', sha(css))) err(rel, `an inline <style> (${css.length} chars) is not in the policy; the browser will drop it`);
+ for (const [, attrs, js] of html.matchAll(/<script\b([^>]*)>([\s\S]*?)<\/script>/gi)) {
+ if (/\bsrc=/i.test(attrs) || /\btype="application\/(?:ld\+)?json"/i.test(attrs)) continue;
+ if (!listed('script', sha(js))) err(rel, `an inline <script> (${js.length} chars) is not in the policy; the browser will not run it`);
+ }
+}
+
+console.log(`check-dist: ${posts} post pages, about, ${projects} projects; a complete policy on ${policed} pages`);
for (const e of errors) console.error(` ERROR ${e}`);
if (errors.length) { console.error(`\n${errors.length} error(s) — dist/ is not fit to deploy.`); process.exit(1); }
-console.log(' every body is present — passed');
+console.log(' every body is present and every inline style and script is allowed — passed');
diff --git a/src/lib/diagrams.mjs b/src/lib/diagrams.mjs
@@ -227,6 +227,15 @@ export function diagramKey(source) {
export const diagramFile = (key) => `${DIAGRAM_DIR}/${key}.svg`;
+/**
+ * The markup for the nth appearance of one diagram on a page (n from 0). Every id in a baked
+ * file, the root's and the arrowhead markers', and every selector in its <style>, starts
+ * from `mmd-<key>`, so one replacement keeps a second copy from sharing ids with the first.
+ * It rewrites the <style> too, which is why astro.config.mjs hashes each copy for the
+ * Content-Security-Policy through this same function rather than reading the file alone.
+ */
+export const diagramCopy = (svg, key, n) => (n === 0 ? svg : svg.replaceAll(`mmd-${key}`, `mmd-${key}-${n}`));
+
/**
* The baked SVG, or null when this fence has not been rendered yet. Synchronous by design:
* the plugin that calls it is a cache lookup, with no browser and no lifecycle to manage.
diff --git a/src/lib/satteri-diagram.mjs b/src/lib/satteri-diagram.mjs
@@ -13,7 +13,7 @@
* useMaxWidth note in diagrams.mjs). What the tabindex buys is a keyboard reader landing on
* the figure - the drawing is vector, so a reader who lands on it can zoom it losslessly.
*/
-import { diagramFile, diagramKey, normalizeDiagram, readDiagram } from './diagrams.mjs';
+import { diagramCopy, diagramFile, diagramKey, normalizeDiagram, readDiagram } from './diagrams.mjs';
/**
* A build with a fence that was never rendered is a broken build, not a warning: the
@@ -89,9 +89,7 @@ export default function satteriDiagram() {
const seen = ctx.data.diagramSeen;
const repeat = seen.get(key) ?? 0;
seen.set(key, repeat + 1);
- // Every id inside the file — the root, and the arrowhead markers that reference it —
- // starts from this one string, so one replacement keeps the SVG self-consistent.
- const markup = repeat === 0 ? svg : svg.replaceAll(`mmd-${key}`, `mmd-${key}-${repeat}`);
+ const markup = diagramCopy(svg, key, repeat);
const kind = /aria-roledescription="([^"]+)"/.exec(svg)?.[1] ?? '';
const label = LABELS[kind] ?? 'Diagram';
|