blog.git

blog

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

src/lib/satteri-diagram.mjs (5318B)

      1 /**
      2  * satteri-diagram — swaps a mermaid fence for the SVG that was baked from it.
      3  *
      4  * This is a cache lookup and nothing else. `mermaid` is in syntaxHighlight.excludeLangs, so
      5  * the highlight plugin returns before Shiki ever sees the block and the original
      6  * <pre><code class="language-mermaid"> arrives here intact; the picture itself was drawn
      7  * once, in a browser, by scripts/render-diagrams.mjs, and committed under a hash of its
      8  * source. No browser, no async, no mermaid import, and nothing at all for the reader to
      9  * download.
     10  *
     11  * The output is one <figure class="diagram"> holding the SVG. It is focusable but not a
     12  * scroll region: base.css scales the picture into the column, and the baked SVG keeps the
     13  * width/height attributes that give it a ratio to scale by (see the useMaxWidth note in
     14  * diagrams.mjs). What the tabindex buys is a keyboard reader landing on the figure - the
     15  * drawing is vector, so a reader who lands on it can zoom it losslessly.
     16  */
     17 import { diagramCopy, diagramFile, diagramKey, normalizeDiagram, readDiagram } from './diagrams.mjs';
     18 
     19 /**
     20  * A build with a fence that was never rendered is a broken build, not a warning: the
     21  * prebuild step renders every fence its scan finds, so a miss here means it did not run or
     22  * did not see that fence.
     23  * `astro dev` skips prebuild, though, and an author who has just typed a new diagram wants
     24  * to keep typing — so the dev server shows the source with a note instead of dying.
     25  */
     26 const DEV = process.env.NODE_ENV !== 'production';
     27 
     28 /** mermaid's own word for what it drew, from aria-roledescription, turned into a label. */
     29 const LABELS = {
     30   'flowchart-v2': 'Flowchart',
     31   flowchart: 'Flowchart',
     32   sequence: 'Sequence diagram',
     33   class: 'Class diagram',
     34   state: 'State diagram',
     35   er: 'Entity relationship diagram',
     36   gantt: 'Gantt chart',
     37   journey: 'User journey',
     38   pie: 'Pie chart',
     39   mindmap: 'Mind map',
     40   timeline: 'Timeline',
     41   quadrantChart: 'Quadrant chart',
     42   gitGraph: 'Git graph',
     43 };
     44 
     45 export default function satteriDiagram() {
     46   return {
     47     name: 'diagram',
     48     before(root, ctx) {
     49       // Per document: the id inside a baked SVG is its hash, so the same diagram twice on
     50       // one page would be the same id twice. Only the repeats are renamed.
     51       ctx.data.diagramSeen = new Map();
     52     },
     53     element: {
     54       filter: ['pre'],
     55       visit(node, ctx) {
     56         const code = node.children?.find((c) => c.type === 'element' && c.tagName === 'code');
     57         if (!code || code.type !== 'element') return;
     58         // data.lang is what the highlight plugin keys on, so it is the same contract; the
     59         // class is the fallback for a tree that arrived some other way.
     60         const lang = code.data?.lang
     61           ?? (Array.isArray(code.properties?.className)
     62             ? String(code.properties.className.find((c) => String(c).startsWith('language-')) ?? '').slice(9)
     63             : '');
     64         if (lang !== 'mermaid') return;
     65 
     66         const source = normalizeDiagram(ctx.textContent(code));
     67         const key = diagramKey(source);
     68         const svg = readDiagram(key);
     69 
     70         if (!svg) {
     71           const message =
     72             `No baked diagram for this mermaid fence (${diagramFile(key)}). Run: npm run diagrams`;
     73           // Measured, not assumed: Astro's glob loader catches a render error, logs it and
     74           // stores the entry with no body, and `process.exitCode = 1` here does not survive
     75           // the CLI. What turns this throw into a failed build is the page's render guard
     76           // (src/lib/rendered.ts). The first gate is `npm run prebuild`, which exits 1 when
     77           // a fence its scan finds has no SVG and no browser to make one, and npm then never
     78           // reaches `astro build`. That is why the workflow calls `npm run build`. A fence
     79           // the scan does not see, such as one inside a blockquote, reaches this throw.
     80           if (!DEV) throw new Error(`satteri-diagram: ${message}`);
     81           return {
     82             type: 'element',
     83             tagName: 'figure',
     84             properties: { className: ['diagram', 'diagram--unbaked'] },
     85             children: [
     86               { type: 'element', tagName: 'p', properties: {}, children: [{ type: 'text', value: message }] },
     87               node,
     88             ],
     89           };
     90         }
     91 
     92         const seen = ctx.data.diagramSeen;
     93         const repeat = seen.get(key) ?? 0;
     94         seen.set(key, repeat + 1);
     95         const markup = diagramCopy(svg, key, repeat);
     96 
     97         const kind = /aria-roledescription="([^"]+)"/.exec(svg)?.[1] ?? '';
     98         const label = LABELS[kind] ?? 'Diagram';
     99 
    100         return {
    101           type: 'element',
    102           tagName: 'figure',
    103           properties: {
    104             className: ['diagram'],
    105             'data-diagram': kind || 'diagram',
    106             // Not for scrolling - see the header. A labelled group a reader can reach, and
    107             // stop on, before zooming into the one thing on the page that rewards it.
    108             // A group, not a region: every region is a landmark, and a post with two
    109             // sequence diagrams would list two landmarks with the same name.
    110             tabindex: '0',
    111             role: 'group',
    112             'aria-label': label,
    113           },
    114           children: [{ type: 'raw', value: markup.trim() }],
    115         };
    116       },
    117     },
    118   };
    119 }