From 542405c2d280696d93fd2656c27b817a3210370b Mon Sep 17 00:00:00 2001 From: Yuval Kogman Date: Sat, 24 Oct 2026 01:14:31 +0200 Subject: [PATCH] Render each mermaid diagram in its own derivation Every diagram was rendered twice, by a chromium of its own, on every build of the site, and any change to a tracked file rebuilt the site. nix/mermaid.nix now finds the mermaid blocks at evaluation time and renders each in a derivation whose only input is the diagram's text, with a placeholder for the svg id, so an edit elsewhere in the book renders nothing. mermaid_ssr.py takes the rendered svgs from MERMAID_SVG_DIR by the sha256 of their text and fills in the id, and fails on a block that nix did not find. Outside the nix build it still runs mmdc. The site is built from a file set limited to what mdbook reads, so edits to the nix files, the tests or the dictionaries leave it cached. Co-Authored-By: Claude Opus 5.5 --- contrib/mermaid_ssr.py | 18 +++++++- contrib/test_mermaid_ssr.py | 27 ++++++++++++ cspell/tooling.txt | 5 +++ nix/mermaid.nix | 82 ++++++++++++++++++++++++++++++++++--- nix/site.nix | 38 +++++++++++++---- 5 files changed, 154 insertions(+), 16 deletions(-) create mode 100644 contrib/test_mermaid_ssr.py diff --git a/contrib/mermaid_ssr.py b/contrib/mermaid_ssr.py index 82f2bbd..239502e 100644 --- a/contrib/mermaid_ssr.py +++ b/contrib/mermaid_ssr.py @@ -2,8 +2,12 @@ """mdbook preprocessor: render ```mermaid blocks to inline SVG with mmdc. Expects the `mmdc` from nix/mermaid.nix, which comes wrapped with the -headless chromium it drives. +headless chromium it drives. If MERMAID_SVG_DIR is set, it renders nothing +itself: it takes each diagram from MERMAID_SVG_DIR//.svg, as nix/mermaid.nix renders them, with PLACEHOLDER +standing for the svg id. """ +import hashlib import json import os import re @@ -17,9 +21,21 @@ r"^(?P`{3,})mermaid[^\n]*\n(?P.*?)^(?P=fence)[ \t]*$", re.S | re.M, ) +PLACEHOLDER = "MERMAID_SSR_ID" def render(source, theme, svg_id): + rendered = os.environ.get("MERMAID_SVG_DIR") + if rendered: + digest = hashlib.sha256(source.encode()).hexdigest() + try: + with open(os.path.join(rendered, digest, theme + ".svg")) as f: + return f.read().replace(PLACEHOLDER, svg_id) + except FileNotFoundError: + sys.stderr.write( + "mermaid_ssr: %s has no diagram %s; nix/mermaid.nix did not " + "find this block:\n%s" % (rendered, digest, source)) + sys.exit(1) with tempfile.TemporaryDirectory() as tmp: mmd = os.path.join(tmp, "in.mmd") svg = os.path.join(tmp, "out.svg") diff --git a/contrib/test_mermaid_ssr.py b/contrib/test_mermaid_ssr.py new file mode 100644 index 0000000..18149cb --- /dev/null +++ b/contrib/test_mermaid_ssr.py @@ -0,0 +1,27 @@ +import hashlib + +import mermaid_ssr +import pytest + +SOURCE = "flowchart LR\n a --> b\n" + + +def prerender(tmp_path, source, theme, svg): + diagram = tmp_path / hashlib.sha256(source.encode()).hexdigest() + diagram.mkdir(exist_ok=True) + (diagram / (theme + ".svg")).write_text(svg) + + +def test_prerendered_diagram_gets_its_own_id(tmp_path, monkeypatch): + prerender(tmp_path, SOURCE, "dark", + '') + monkeypatch.setenv("MERMAID_SVG_DIR", str(tmp_path)) + assert mermaid_ssr.render(SOURCE, "dark", "mermaid-3-dark") == ( + '') + + +def test_diagram_missing_from_the_prerendered_set_fails(tmp_path, monkeypatch): + prerender(tmp_path, SOURCE, "default", "") + monkeypatch.setenv("MERMAID_SVG_DIR", str(tmp_path)) + with pytest.raises(SystemExit): + mermaid_ssr.render(SOURCE + " b --> c\n", "default", "mermaid-1-light") diff --git a/cspell/tooling.txt b/cspell/tooling.txt index 4275994..32ceda8 100644 --- a/cspell/tooling.txt +++ b/cspell/tooling.txt @@ -5,6 +5,8 @@ devshell DNSSEC dontInstall dylanowen +fileset +foldl fontconfig Graphviz KaTeX @@ -14,8 +16,11 @@ mmdc NixOS nixpkgs pkgs +prerender +prerendered pytest PyYAML stdenv +svgs toplevel unfetched diff --git a/nix/mermaid.nix b/nix/mermaid.nix index 861eb2c..4a80412 100644 --- a/nix/mermaid.nix +++ b/nix/mermaid.nix @@ -1,12 +1,13 @@ { ... }: { - # The only chromium nixpkgs has for darwin sits inside playwright's prebuilt - # browsers, under a directory whose name changes with every bump. Resolve it - # once and bake it in, so no caller has to export a path. perSystem = - { pkgs, ... }: - { - _module.args.mmdc = pkgs.symlinkJoin { + { pkgs, lib, ... }: + let + # The only chromium nixpkgs has for darwin sits inside playwright's + # prebuilt browsers, under a directory whose name changes with every + # bump. Resolve it once and bake it in, so no caller has to export a + # path. + mmdc = pkgs.symlinkJoin { name = "mmdc-with-browser"; paths = [ pkgs.mermaid-cli ]; nativeBuildInputs = [ pkgs.makeWrapper ]; @@ -25,5 +26,74 @@ --add-flags "--puppeteerConfigFile $out/puppeteer.json" ''; }; + + # mermaid measures text with getBBox and rejects a zero-sized box, so + # the build sandbox has to carry a font of its own. + fontconfig = pkgs.makeFontsConf { fontDirectories = [ pkgs.dejavu_fonts ]; }; + + # The bodies of the ```mermaid blocks in a markdown text, as FENCE in + # contrib/mermaid_ssr.py finds them. + blocks = + text: + let + step = + state: line: + if state.fence == null then + let + open = builtins.match "(`{3,})mermaid.*" line; + in + if open == null then state else state // { fence = builtins.head open; } + else if builtins.match "${state.fence}[ \t]*" line != null then + { + fence = null; + lines = [ ]; + found = state.found ++ [ (lib.concatMapStrings (l: l + "\n") state.lines) ]; + } + else + state // { lines = state.lines ++ [ line ]; }; + in + (builtins.foldl' step { + fence = null; + lines = [ ]; + found = [ ]; + } (lib.splitString "\n" text)).found; + + # One derivation per diagram, whose only input is the diagram's text, + # so edits elsewhere in the book leave it cached. mermaid_ssr.py finds + # it by the sha256 of that text, and replaces the placeholder svg ids. + render = + body: + let + hash = builtins.hashString "sha256" body; + in + { + name = hash; + path = + pkgs.runCommand "mermaid-${builtins.substring 0 12 hash}" + { nativeBuildInputs = [ mmdc ]; } + '' + # mmdc writes a chromium profile under $HOME. + export HOME=$(mktemp -d) + export FONTCONFIG_FILE=${fontconfig} + mkdir $out + for theme in default dark; do + mmdc -i ${builtins.toFile "diagram.mmd" body} -o $out/$theme.svg \ + -b transparent -t $theme -I MERMAID_SSR_ID + done + ''; + }; + in + { + _module.args = { + inherit mmdc fontconfig; + + # The diagrams of these markdown files, rendered, as mermaid_ssr.py + # expects to find them in MERMAID_SVG_DIR. + mermaidSvgs = + files: + pkgs.linkFarm "mermaid-svgs" ( + map render (lib.unique (lib.concatMap (f: blocks (builtins.readFile f)) files)) + ); + }; }; } diff --git a/nix/site.nix b/nix/site.nix index 0490dcd..a8d0f35 100644 --- a/nix/site.nix +++ b/nix/site.nix @@ -1,26 +1,46 @@ { ... }: { perSystem = - { pkgs, mmdc, ... }: + { + pkgs, + lib, + fontconfig, + mermaidSvgs, + ... + }: let + # Only what the book is built from, so that changes to the nix files, + # the tests or the spelling dictionaries do not rebuild it. + src = lib.fileset.toSource { + root = ../.; + fileset = lib.fileset.unions [ + ../README.md + ../book.toml + ../docs + ../theme + (lib.fileset.fileFilter (f: f.hasExt "py" && !lib.hasPrefix "test_" f.name) ../contrib) + ]; + }; + site = pkgs.stdenvNoCC.mkDerivation { name = "fungi-docs"; - src = ../.; + inherit src; - nativeBuildInputs = [ mmdc ] ++ (with pkgs; [ + nativeBuildInputs = with pkgs; [ mdbook mdbook-graphviz mdbook-katex graphviz (python3.withPackages (ps: [ ps.pyyaml ])) - ]); + ]; + + MERMAID_SVG_DIR = mermaidSvgs ( + [ ../README.md ] + ++ lib.filter (f: lib.hasSuffix ".md" (toString f)) (lib.filesystem.listFilesRecursive ../docs) + ); buildPhase = '' - # mmdc writes a chromium profile under $HOME. - export HOME=$(mktemp -d) - # mermaid measures text with getBBox and rejects a zero-sized box, so - # the build sandbox has to carry a font of its own. - export FONTCONFIG_FILE=${pkgs.makeFontsConf { fontDirectories = [ pkgs.dejavu_fonts ]; }} + export FONTCONFIG_FILE=${fontconfig} python3 contrib/book.py . mdbook build -d $out '';