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 }