Skip to the content

Capsid menu

render-diagram-draw.mjs

376 lines and 30 functions in DrDustinEdwards/prelum, at commit 547db6a on 2026-10-25. This file on GitHub.

Functions

The functions of render-diagram-draw.mjs
FunctionLinesComplexityCoveredCRAPMutation
mermaidConfig84 to 11234%WatchCRAP 11.1No data: mutation testing has not run on this repo yet
<anonymous:85>85 to 8520%WatchCRAP 6No data: mutation testing has not run on this repo yet
sizeRoot121 to 13670%CriticalCRAP 56No data: mutation testing has not run on this repo yet
drawDiagrams155 to 19963%CriticalCRAP 39.3No data: mutation testing has not run on this repo yet
fileName158 to 15810%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:161>161 to 16210%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:162>162 to 16220%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:162>162 to 1621100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:186>186 to 18610%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:196>196 to 19610%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:196>196 to 1961100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:197>197 to 19720%WatchCRAP 6No data: mutation testing has not run on this repo yet
pruneDiagrams207 to 209233%OkCRAP 3.2No data: mutation testing has not run on this repo yet
normalizeHex229 to 2322100%OkCRAP 2No data: mutation testing has not run on this repo yet
cssRules241 to 2638100%WatchCRAP 8No data: mutation testing has not run on this repo yet
colourDeclarations269 to 2775100%OkCRAP 5No data: mutation testing has not run on this repo yet
colourProblem285 to 293689%WatchCRAP 6.1No data: mutation testing has not run on this repo yet
auditDiagramSvg304 to 40816100%WatchCRAP 16No data: mutation testing has not run on this repo yet
check322 to 3283100%OkCRAP 3No data: mutation testing has not run on this repo yet
auditRules331 to 356694%WatchCRAP 6No data: mutation testing has not run on this repo yet
<anonymous:343>343 to 3431100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:344>344 to 3441100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:345>345 to 352263%OkCRAP 2.2No data: mutation testing has not run on this repo yet
<anonymous:364>364 to 3641100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:364>364 to 3642100%OkCRAP 2No data: mutation testing has not run on this repo yet
<anonymous:364>364 to 3641100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:364>364 to 3641100%OkCRAP 1No data: mutation testing has not run on this repo yet
styledBy382 to 3931100%OkCRAP 1No data: mutation testing has not run on this repo yet
<anonymous:384>384 to 392250%OkCRAP 2.5No data: mutation testing has not run on this repo yet
<anonymous:386>386 to 392214%OkCRAP 4.5No data: mutation testing has not run on this repo yet

Imports

It imports render-diagram.mjs. No module of this repo imports it.

Source

// Draws the assets render-diagram.mjs names: one SVG per theme, ahead of time, in Node. Layout needs real font metrics, so
// this drives Mermaid's own renderer in a headless browser; the site build then only reads the committed files. Each site
// gives its colours by role; the role map, Mermaid's configuration, the drawing and the colour audit are shared here.

import { existsSync } from "node:fs";
import { mkdir, readdir, unlink, writeFile } from "node:fs/promises";
import { basename, join } from "node:path";

import { renderMermaid } from "@mermaid-js/mermaid-cli";
import { parseHTML } from "linkedom";
import puppeteer from "puppeteer";

import { DIAGRAM_THEMES, diagramAssetPath } from "./render-diagram.mjs";

/**
 * Every Mermaid theme variable a diagram paints with, by the role its colour plays. Everything khroma would otherwise
 * derive is supplied; what is not still gets derived, which is why every drawing is audited rather than trusting this
 * list to be complete.
 */
export const DIAGRAM_ROLES = {
  background: "paper",
  primaryColor: "paper",
  primaryBorderColor: "borderStrong",
  primaryTextColor: "text",
  secondaryColor: "raised",
  secondaryBorderColor: "border",
  secondaryTextColor: "text",
  tertiaryColor: "tint",
  tertiaryBorderColor: "border",
  tertiaryTextColor: "text",
  lineColor: "borderStrong",
  textColor: "text",
  titleColor: "heading",
  errorBkgColor: "dangerTint",
  errorTextColor: "danger",

  mainBkg: "paper",
  nodeBorder: "borderStrong",
  nodeTextColor: "text",
  clusterBkg: "tint",
  clusterBorder: "border",
  // An edge crosses page background, so the label plate must match it or the line shows through.
  edgeLabelBackground: "paper",
  arrowheadColor: "borderStrong",

  actorBkg: "paper",
  actorBorder: "borderStrong",
  actorTextColor: "text",
  actorLineColor: "border",
  signalColor: "borderStrong",
  signalTextColor: "text",
  labelBoxBkgColor: "paper",
  labelBoxBorderColor: "borderStrong",
  labelTextColor: "text",
  loopTextColor: "text",
  noteBkgColor: "raised",
  noteBorderColor: "border",
  noteTextColor: "text",
  activationBkgColor: "raised",
  activationBorderColor: "borderStrong",
  sequenceNumberColor: "onAccent",
  altBackground: "paper",
};

/** The roles a site must colour, each once per theme. */
export const DIAGRAM_ROLE_NAMES = [...new Set(Object.values(DIAGRAM_ROLES))];

/**
 * An SVG inside an `<img>` may not load external resources, so the viewer's font may differ from the one the drawing was
 * measured with, which is why the padding below is generous.
 */
const FONT_FAMILY = 'ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif';

/** Set explicitly so the bytes do not move if the tool's default does. */
const SVG_ID = "diagram";

/** Fixed, so nothing about the output depends on the machine it was built on. */
const VIEWPORT = { width: 1200, height: 800, deviceScaleFactor: 1 };

/**
 * @param {"light" | "dark"} theme
 * @param {Record<string, string>} colours one hex colour per name in DIAGRAM_ROLE_NAMES
 */
export function mermaidConfig(theme, colours) {
  const missing = DIAGRAM_ROLE_NAMES.filter((role) => !/^#[0-9a-f]{3}(?:[0-9a-f]{3})?$/i.test(colours[role] ?? ""));
  if (missing.length > 0) throw new Error(`the ${theme} diagram colours lack a hex colour for ${missing.join(", ")}`);
  /** @type {Record<string, string | boolean>} */
  const themeVariables = { darkMode: theme === "dark", fontFamily: FONT_FAMILY };
  for (const [variable, role] of Object.entries(DIAGRAM_ROLES)) themeVariables[variable] = /** @type {string} */ (colours[role]);
  return {
    // "base" is the only built-in theme whose variables are fully overridable; every other one hardcodes part of its palette.
    theme: "base",
    themeVariables,
    // Labels as real text, never `<foreignObject>`: a foreignObject is not rendered at all through `<img>`, so every node
    // would come out blank on the page while looking correct standalone.
    htmlLabels: false,
    flowchart: { htmlLabels: false, padding: 12, useMaxWidth: false },
    // Tightened from the defaults, for layout rather than taste: a wider drawing is scaled down and takes its type with
    // it, and narrowing the gaps shrinks the drawing without shrinking the text.
    sequence: {
      useMaxWidth: false,
      wrap: false,
      actorMargin: 24,
      width: 120,
      boxMargin: 8,
      noteMargin: 8,
      messageMargin: 28,
      // The mirrored row of actors at the foot repeats what the head already said and costs height a figure cannot spare.
      mirrorActors: false,
    },
  };
}

/**
 * Inside an `<img>` a percentage width means no intrinsic width, so the viewBox size is copied onto the root. A targeted
 * rewrite rather than re-serializing, because an `.svg` is parsed strictly.
 *
 * @param {string} svg
 * @param {string} label
 */
function sizeRoot(svg, label) {
  const openTag = svg.match(/<svg\b[^>]*>/)?.[0];
  if (!openTag) throw new Error(`${label}: no <svg> root tag`);
  const viewBox = openTag.match(/viewBox="([\d.\-\s]+)"/)?.[1];
  if (!viewBox) throw new Error(`${label}: root <svg> has no viewBox to size from`);
  const [, , width = 0, height = 0] = viewBox.trim().split(/\s+/).map(Number);
  if (!Number.isFinite(width) || !Number.isFinite(height) || width <= 0 || height <= 0) {
    throw new Error(`${label}: viewBox "${viewBox}" has no usable size`);
  }
  const rewritten = openTag
    .replace(/\swidth="[^"]*"/, "")
    .replace(/\sheight="[^"]*"/, "")
    .replace(/max-width:\s*[^;"]+;?\s*/, "")
    .replace(/<svg\b/, `<svg width="${Math.ceil(width)}" height="${Math.ceil(height)}"`);
  return svg.replace(openTag, rewritten);
}

/**
 * Draws what is missing (or everything, with `force`), audits each drawing before writing it, and deletes nothing: it
 * returns the files no diagram names, for the caller to prune by its own rules. The browser is only launched when there
 * is something to draw, so a build with every asset current needs no Chromium. PUPPETEER_EXECUTABLE_PATH points
 * Puppeteer at an installed Chromium; MERMAID_PUPPETEER_CONFIG is JSON launch options, for example
 * {"args":["--no-sandbox"]} in a container that runs as root.
 *
 * @param {{
 *   diagrams: Array<{ key: string, source: string, label?: string }>,
 *   dir: string,
 *   assetPrefix: string,
 *   colours: Record<"light" | "dark", Record<string, string>>,
 *   force?: boolean,
 *   log?: (line: string) => void,
 * }} options
 * @returns {Promise<{ written: string[], current: number, stale: string[] }>}
 */
export async function drawDiagrams({ diagrams, dir, assetPrefix, colours, force = false, log = console.log }) {
  const themes = /** @type {Array<"light" | "dark">} */ (DIAGRAM_THEMES);
  const configs = { light: mermaidConfig("light", colours.light), dark: mermaidConfig("dark", colours.dark) };
  const fileName = (/** @type {string} */ key, /** @type {string} */ theme) => basename(diagramAssetPath(key, theme, assetPrefix));

  await mkdir(dir, { recursive: true });
  const outstanding = diagrams.flatMap((diagram) =>
    themes.filter((theme) => force || !existsSync(join(dir, fileName(diagram.key, theme)))).map((theme) => ({ diagram, theme })),
  );

  /** @type {string[]} */
  const written = [];
  const launchOptions = process.env.MERMAID_PUPPETEER_CONFIG ? JSON.parse(process.env.MERMAID_PUPPETEER_CONFIG) : {};
  const browser = outstanding.length > 0 ? await puppeteer.launch(launchOptions) : null;
  try {
    for (const { diagram, theme } of outstanding) {
      const name = fileName(diagram.key, theme);
      const label = `${name}${diagram.label ? ` (${diagram.label})` : ""}`;
      // The renderer's API rather than its command line: Node will not spawn the shim without a shell, and a shell joins
      // an argument array without quoting.
      const { data } = await renderMermaid(/** @type {import("puppeteer").Browser} */ (browser), diagram.source, "svg", {
        backgroundColor: "transparent",
        mermaidConfig: /** @type {any} */ (configs[theme]),
        svgId: SVG_ID,
        viewport: VIEWPORT,
      });
      const svg = sizeRoot(Buffer.from(data).toString("utf8"), label);
      // Audited before it is written, so a colour Mermaid derived rather than took from the palette stops the build
      // instead of shipping.
      const audit = auditDiagramSvg(svg, Object.values(colours[theme]));
      if (audit.problems.length > 0) {
        throw new Error(`${label} uses colours that are not the site's:\n${audit.problems.map((p) => `    ${p}`).join("\n")}`);
      }
      await writeFile(join(dir, name), svg, "utf8");
      written.push(name);
      log(`  wrote ${name} (${Math.round(svg.length / 1024)} kB, ${audit.checked} colours checked)`);
    }
  } finally {
    await browser?.close();
  }

  const live = new Set(diagrams.flatMap((d) => themes.map((t) => fileName(d.key, t))));
  const stale = (await readdir(dir)).filter((name) => name.endsWith(".svg") && !live.has(name));
  return { written, current: diagrams.length * themes.length - outstanding.length, stale };
}

/**
 * Deletes the named files from `dir`.
 *
 * @param {string} dir
 * @param {string[]} names
 */
export async function pruneDiagrams(dir, names) {
  for (const name of names) await unlink(join(dir, name));
}

const COLOUR_PROPERTIES = new Set([
  "fill",
  "stroke",
  "color",
  "background",
  "background-color",
  "stop-color",
  "flood-color",
  "border",
  "border-color",
  "outline",
  "outline-color",
]);

// `currentColor` is safe because it resolves to `color`, which is itself audited.
const NON_COLOUR_VALUES = new Set(["none", "transparent", "currentcolor", "inherit", "initial", "unset", "revert"]);

/** @param {string} hex */
function normalizeHex(hex) {
  const h = hex.toLowerCase();
  return h.length === 4 ? `#${h[1]}${h[1]}${h[2]}${h[2]}${h[3]}${h[3]}` : h;
}

/**
 * By brace depth, not splitting on `}`: Mermaid's stylesheet opens with nested at-rules. Unbalanced braces throw: a stray
 * `}` drove the depth negative and every later rule was silently lost.
 *
 * @param {string} text
 * @returns {Array<{ selector: string, body: string }>}
 */
function cssRules(text) {
  /** @type {Array<{ selector: string, body: string }>} */
  const out = [];
  let depth = 0;
  let start = 0;
  let selectorEnd = -1;
  for (let i = 0; i < text.length; i += 1) {
    const c = text[i];
    if (c === "{") {
      if (depth === 0) selectorEnd = i;
      depth += 1;
    } else if (c === "}") {
      depth -= 1;
      if (depth < 0) throw new Error(`unbalanced stylesheet: a "}" at offset ${i} closes nothing`);
      if (depth === 0) {
        out.push({ selector: text.slice(start, selectorEnd).trim(), body: text.slice(selectorEnd + 1, i) });
        start = i + 1;
      }
    }
  }
  if (depth !== 0) throw new Error(`unbalanced stylesheet: ${depth} "{" never closed`);
  return out;
}

/** At-rules whose body is more rules, applied when a condition holds, so their rules are audited. */
const GROUPING_AT_RULES = /^@(media|supports|layer|container|document|scope)\b/i;

/** @param {string} body */
function colourDeclarations(body) {
  /** @type {Array<{ property: string, value: string }>} */
  const out = [];
  for (const m of body.matchAll(/([a-zA-Z-]+)\s*:\s*([^;{}]+)/g)) {
    const property = (m[1] ?? "").toLowerCase();
    if (COLOUR_PROPERTIES.has(property)) out.push({ property, value: (m[2] ?? "").trim() });
  }
  return out;
}

/**
 * Allowlist, not a hex hunt, so rgb(), hsl() and named colours are caught too.
 *
 * @param {string} value
 * @param {Set<string>} palette
 */
function colourProblem(value, palette) {
  const cleaned = value.replace(/!important/gi, "").trim();
  const lower = cleaned.toLowerCase();
  if (cleaned === "" || NON_COLOUR_VALUES.has(lower) || lower.startsWith("url(")) return null;
  if (/^#[0-9a-fA-F]{3}(?:[0-9a-fA-F]{3})?$/.test(cleaned)) {
    return palette.has(normalizeHex(cleaned)) ? null : `${cleaned} is not one of the palette's colours`;
  }
  return `${cleaned} is not a palette colour`;
}

/**
 * Every colour a drawing can paint must be one of the palette's: in its `<style>` (rules inside conditional group rules
 * included, `@keyframes` audited whole, a rule no element matches skipped and counted), in `style` attributes, and in
 * presentation attributes no stylesheet rule overrides, on every element except unreferenced `<defs>` children.
 *
 * @param {string} svg
 * @param {string[]} paletteHexes
 * @returns {{ checked: number, skippedRules: number, overridden: number, problems: string[] }}
 */
export function auditDiagramSvg(svg, paletteHexes) {
  const palette = new Set(paletteHexes.map(normalizeHex));
  const { document } = parseHTML(`<!DOCTYPE html><html><body>${svg}</body></html>`);
  const root = document.querySelector("svg");
  if (!root) return { checked: 0, skippedRules: 0, overridden: 0, problems: ["no <svg> element"] };

  /** @type {string[]} */
  const problems = [];
  let checked = 0;
  let skippedRules = 0;
  let overridden = 0;
  /** @type {Array<{ selectors: string[], properties: Set<string> }>} */
  const styling = [];

  /**
   * @param {string} where
   * @param {Array<{ property: string, value: string }>} declarations
   */
  const check = (where, declarations) => {
    for (const { property, value } of declarations) {
      checked += 1;
      const problem = colourProblem(value, palette);
      if (problem) problems.push(`${where} ${property}: ${problem}`);
    }
  };

  /** @param {string} text */
  const auditRules = (text) => {
    for (const rule of cssRules(text)) {
      if (GROUPING_AT_RULES.test(rule.selector)) {
        auditRules(rule.body);
        continue;
      }
      const declarations = colourDeclarations(rule.body);
      if (declarations.length === 0) continue;
      if (rule.selector.startsWith("@")) {
        check(rule.selector, declarations);
        continue;
      }
      const selectors = rule.selector.split(",").map((s) => s.trim()).filter(Boolean);
      styling.push({ selectors, properties: new Set(declarations.map((d) => d.property)) });
      const reachable = selectors.some((one) => {
        try {
          return Boolean(document.querySelector(one));
        } catch {
          // Unparseable selectors count as reachable, so the rule is audited rather than skipped.
          return true;
        }
      });
      if (reachable) check(rule.selector, declarations);
      else skippedRules += 1;
    }
  };
  for (const style of document.querySelectorAll("style")) auditRules(style.textContent ?? "");

  // Unreferenced <defs> children, and everything under them, are never painted. A reference is url(#id) (markers,
  // gradients) or an href to #id (<use>), and both keep the target audited.
  const serialized = root.toString();
  const all = [root, ...root.querySelectorAll("*")];
  const hrefs = new Set(
    all.flatMap((el) => ["href", "xlink:href"].map((n) => el.getAttribute(n) ?? "")).filter((v) => v.startsWith("#")).map((v) => v.slice(1)),
  );
  const dead = new Set();
  for (const defs of document.querySelectorAll("defs")) {
    for (const child of defs.children) {
      const id = child.getAttribute("id");
      if (!id || (!serialized.includes(`url(#${id})`) && !hrefs.has(id))) {
        for (const el of [child, ...child.querySelectorAll("*")]) dead.add(el);
      }
    }
  }

  /**
   * A presentation attribute a stylesheet rule overrides is never painted.
   *
   * @param {any} element
   * @param {string} property
   */
  const styledBy = (element, property) =>
    styling.some(
      (rule) =>
        rule.properties.has(property) &&
        rule.selectors.some((selector) => {
          try {
            return element.matches(selector);
          } catch {
            return false;
          }
        }),
    );

  // The root carries attributes too: Mermaid writes its background into `style`.
  for (const element of all) {
    if (dead.has(element)) continue;
    for (const attribute of element.attributes) {
      const name = attribute.name.toLowerCase();
      if (name === "style") check(`<${element.localName} style>`, colourDeclarations(attribute.value));
      else if (!COLOUR_PROPERTIES.has(name)) continue;
      else if (styledBy(element, name)) overridden += 1;
      else check(`<${element.localName}>`, [{ property: name, value: attribute.value }]);
    }
  }

  return { checked, skippedRules, overridden, problems };
}