src/lib/diagrams.mjs (14258B)
1 /**
2 * diagrams — the contract between the step that bakes mermaid fences into SVG
3 * (scripts/render-diagrams.mjs) and the plugin that puts the result back into the page
4 * (src/lib/satteri-diagram.mjs). Both sides import this file, so a diagram is keyed the
5 * same way from either end.
6 *
7 * Why bake at all. Mermaid lays a diagram out by *measuring text*, so the geometry it
8 * emits belongs to whatever font did the measuring. Measured in a browser, the string
9 * "클라이언트 검열 프로브 위장 핸드셰이크" at 16px is 251.1px in this site's own KoBody,
10 * 266.3px in a default serif and 294.5px in Malgun Gothic — a 17.3% spread, and every box
11 * in the picture is sized around that number. Loading public/fonts before rendering is
12 * therefore not a nicety: it is what makes a Windows dev box and an Ubuntu runner produce
13 * identical SVG, because no system font is ever consulted.
14 *
15 * The cache key is a hash over the fence source *and* everything else that could move a
16 * pixel — schema version, mermaid config, palette, and the content-hashed font filenames.
17 * Nothing here needs a manifest: the key *is* the filename.
18 *
19 * Re-subsetting a face changes its filename, and with it every key: scripts/subset-fonts.py
20 * rewrites the name in DIAGRAM_FONTS below as well as in fonts.css, Base.astro and
21 * tokens.css, so the next `npm run diagrams` re-bakes every diagram against the new file.
22 */
23 import { createHash } from 'node:crypto';
24 import { readFileSync } from 'node:fs';
25
26 /** Committed, because a build must never need a browser. See scripts/render-diagrams.mjs. */
27 export const DIAGRAM_DIR = 'src/lib/diagrams';
28
29 /** Bump when the baked SVG's own markup changes shape (ids, root attributes, wrapper). */
30 export const DIAGRAM_SCHEMA = 2;
31
32 /**
33 * The renderer is part of the geometry, so it is part of the key: a mermaid upgrade
34 * re-renders every diagram rather than leaving new ones drawn by a different version than
35 * their neighbours. Stated here rather than read from the package so the plugin side never
36 * has to resolve a devDependency; scripts/render-diagrams.mjs asserts the two agree.
37 */
38 export const MERMAID_VERSION = '12.0.0';
39
40 /**
41 * The faces to load into the render browser, matching the @font-face rules in
42 * src/styles/fonts.css exactly — including KoBody's size-adjust, without which every
43 * Hangul label is measured 6% too wide and the boxes are drawn to match.
44 */
45 export const DIAGRAM_FONTS = [
46 { family: 'Lora', file: 'public/fonts/latin-9bba8f25.woff2', descriptors: { weight: '400 700' } },
47 {
48 family: 'KoBody',
49 file: 'public/fonts/ko-02f8cebd.woff2',
50 descriptors: {
51 weight: '400 700',
52 sizeAdjust: '94.29%',
53 unicodeRange: 'U+1100-11FF, U+3130-318F, U+A960-A97F, U+AC00-D7A3, U+D7B0-D7FF',
54 },
55 },
56 ];
57
58 /**
59 * --font-body, spelled out. The stack is baked into the SVG's own style block, so the
60 * fallback names have to be here too: a reader whose webfont has not landed yet gets
61 * KoFallback, whose metric overrides in fonts.css are tuned to KoBody, rather than a raw
62 * Malgun Gothic that would not fit the boxes this file measured.
63 */
64 const FONT_STACK = "'Lora', 'KoBody', 'KoFallback', 'LatinFallback', Georgia, 'Times New Roman', serif";
65
66 /* tokens.css, resolved. SVG cannot carry a var(), so these are the literals it computes to. */
67 const INK = '#000000'; /* --page-fg */
68 const PLATE = '#faedda'; /* --sand */
69 const PAGE = '#fffbf4'; /* --sand-light */
70 const BORDER = '#eed6aa'; /* --sand-dark */
71 const HAIRLINE = '#b9b8ad'; /* --grey */
72 const RULE = '#605a4e'; /* --slate */
73 const WASH = '#f5eee0'; /* --slate-wash */
74 const ACCENT = '#a51c30'; /* --crimson */
75 const ACCENT_WASH = '#f7ebeb'; /* --crimson-wash */
76
77 /**
78 * The config every fence is rendered with.
79 *
80 * htmlLabels: false is the load-bearing line. With HTML labels mermaid puts a <p> inside a
81 * <foreignObject>, which is live HTML sitting inside .prose: every `.prose p` rule would
82 * apply to it, and the reader's browser would re-wrap that text at read time inside a box
83 * whose size was frozen at build time. SVG text is measured once, here, and cannot reflow.
84 * A <br/> inside a label still breaks the line.
85 *
86 * useMaxWidth: false keeps the width/height attributes mermaid measured on the root, which
87 * is what gives the baked SVG an intrinsic ratio. The fitting itself is CSS's - base.css
88 * scales the picture into the column with `max-inline-size: 100%` - so mermaid's own
89 * `width="100%" style="max-width:Npx"` variant would only be a second, weaker way of saying
90 * the same thing, and it would take the measurement off the file on its way out.
91 *
92 * sequence.wrap: true is what makes that fitting affordable, and it is the one setting here
93 * that changes a drawing rather than its colours. A sequence diagram's width is set by its
94 * widest message label: unwrapped, "FakeIP 198.18.x.x (실제 해석 안 함 → 유출 0)" alone
95 * holds two lifelines ~380px apart, and five actors of that turn into 1,540px that has to be
96 * scaled 0.58 to reach a 928px column - 16px labels at 9.3px. Wrapped, the same two diagrams
97 * come out 660x780 (fits with no scaling at all) and 1,194x958 (scales 0.75, labels at 12px).
98 * Height is the price, and it is the right one: a page scrolls down already.
99 *
100 * It does re-break text the author did not break, which is the thing flowchart.wrappingWidth
101 * is set to 400 to avoid. The two are not the same call. A <br/> in a flowchart label
102 * is the author drawing a box; a long sentence on an arrow is prose, and mermaid breaks it on
103 * spaces, so the Korean labels here wrap where their words already end.
104 */
105 export const MERMAID_CONFIG = {
106 startOnLoad: false,
107 // The SVG is injected as markup, so mermaid's own DOMPurify pass stays in the loop.
108 securityLevel: 'strict',
109 theme: 'base',
110 // mermaid 12 defaults to look: 'neo', which hangs a drop shadow off notes and label
111 // boxes. This page's whole graphic vocabulary is a 4px rule and a 1px hairline.
112 look: 'classic',
113 fontFamily: FONT_STACK,
114 htmlLabels: false,
115 // wrappingWidth is 120px by default, which re-breaks lines the author already broke with
116 // <br/>: "VLESS + Reality :443" came back as two lines. Raised past the longest explicit
117 // line in the corpus (~270px), so a break in the picture is a break in the source.
118 flowchart: { htmlLabels: false, useMaxWidth: false, wrappingWidth: 400 },
119 sequence: { useMaxWidth: false, wrap: true },
120 er: { useMaxWidth: false },
121 gantt: { useMaxWidth: false },
122 class: { useMaxWidth: false },
123 state: { useMaxWidth: false },
124 journey: { useMaxWidth: false },
125 pie: { useMaxWidth: false },
126 themeVariables: {
127 fontFamily: FONT_STACK,
128 fontSize: '15px',
129 background: PAGE,
130 // Nodes take the surface token — the same tint as inline code and the figure plate.
131 primaryColor: PLATE,
132 primaryTextColor: INK,
133 primaryBorderColor: BORDER,
134 secondaryColor: WASH,
135 secondaryTextColor: INK,
136 secondaryBorderColor: BORDER,
137 // Clusters sit on the page ground behind a hairline, so a subgraph reads as a frame
138 // around its nodes rather than a second filled box behind them.
139 tertiaryColor: PAGE,
140 tertiaryTextColor: INK,
141 tertiaryBorderColor: HAIRLINE,
142 mainBkg: PLATE,
143 nodeBorder: BORDER,
144 clusterBkg: PAGE,
145 clusterBorder: HAIRLINE,
146 titleColor: INK,
147 textColor: INK,
148 lineColor: RULE,
149 edgeLabelBackground: PAGE,
150 nodeTextColor: INK,
151 /* Sequence. */
152 actorBkg: PLATE,
153 actorBorder: BORDER,
154 actorTextColor: INK,
155 actorLineColor: RULE,
156 signalColor: INK,
157 signalTextColor: INK,
158 labelBoxBkgColor: PLATE,
159 labelBoxBorderColor: BORDER,
160 labelTextColor: INK,
161 loopTextColor: INK,
162 altBackground: WASH,
163 activationBkgColor: WASH,
164 activationBorderColor: BORDER,
165 sequenceNumberColor: PAGE,
166 // A note is an aside, so it borrows the accent the way a blockquote's bar does.
167 noteBkgColor: ACCENT_WASH,
168 noteTextColor: INK,
169 noteBorderColor: ACCENT,
170 },
171 };
172
173 /**
174 * Rules appended to each baked SVG's own style block, scoped to that diagram's id.
175 *
176 * With SVG text labels mermaid paints the rect behind an edge label at opacity 0.5, so the
177 * edge line shows straight through the words — visible on the flowchart, where a three-line
178 * label sits on top of the arrow between two subgraphs. The HTML-label path gets an opaque
179 * box; this gives the SVG path the same one. Part of the cache key, so editing this line
180 * re-bakes every diagram.
181 */
182 export const DIAGRAM_CSS = ['.edgeLabel rect{opacity:1;}'];
183
184 /**
185 * Make one rendered SVG fit to be *inlined into HTML*, which is not the same document type
186 * mermaid serialised it for, and append the rules above.
187 *
188 * Mermaid serialises as XML, so a child combinator inside its stylesheet comes out escaped:
189 * `.noteText>tspan`. Inside an inline <svg> the HTML parser decodes that again, so the
190 * browser reads the selector correctly either way. The decode here is for the
191 * Content-Security-Policy: astro.config.mjs hashes each diagram's <style> text as it stands
192 * in the file, the browser hashes the text it parsed, and the two agree only once the file
193 * holds the decoded text. A mismatch drops the whole stylesheet without a sound.
194 * Five rules per sequence diagram use the combinator: actor, label, loop, section-title and
195 * note text. They are not decoration: without `text.actor>tspan` the participant names
196 * inherit the actor box's sand fill and vanish into it.
197 */
198 export function finishDiagram(svg, id) {
199 const rules = DIAGRAM_CSS.map((rule) => `#${id} ${rule}`).join('');
200 if (!svg.includes('</style>')) return svg.replace(/^(<svg[^>]*>)/, `$1<style>${rules}</style>`);
201 let appended = false;
202 // Every style block is decoded, not just the first: today mermaid emits one per diagram,
203 // and a version that emits two would otherwise leave the second one's selectors broken.
204 return svg.replace(/(<style>)([\s\S]*?)(<\/style>)/g, (_all, open, css, close) => {
205 const decoded = css.replace(/>/g, '>').replace(/</g, '<').replace(/&/g, '&');
206 // A decoded `</style` would end the element early and spill CSS into the page as text.
207 if (/<\/style/i.test(decoded)) throw new Error('diagram stylesheet contains a closing style tag');
208 const tail = appended ? '' : rules;
209 appended = true;
210 return `${open}${decoded}${tail}${close}`;
211 });
212 }
213
214 /**
215 * Both ends have to hash the same bytes. The highlight plugin in @astrojs/markdown-satteri
216 * strips one trailing newline from a fence's text; this strips every trailing newline and
217 * normalises CRLF, so a checkout with other line endings still finds its cached diagram.
218 */
219 // `(?<!\n)` anchors the match at the start of a newline run, so a long run in the middle of
220 // a fence is not rescanned from every position (80,000 newlines took 3.7 s without it).
221 export const normalizeDiagram = (source) => String(source).replace(/\r\n?/g, '\n').replace(/(?<!\n)\n+$/, '');
222
223 /** 16 hex characters = 64 bits. A collision needs billions of diagrams; the corpus has 3. */
224 export function diagramKey(source) {
225 const material = JSON.stringify({
226 v: DIAGRAM_SCHEMA,
227 mermaid: MERMAID_VERSION,
228 config: MERMAID_CONFIG,
229 css: DIAGRAM_CSS,
230 fonts: DIAGRAM_FONTS,
231 source: normalizeDiagram(source),
232 });
233 return createHash('sha256').update(material, 'utf8').digest('hex').slice(0, 16);
234 }
235
236 export const diagramFile = (key) => `${DIAGRAM_DIR}/${key}.svg`;
237
238 /**
239 * The markup for the nth appearance of one diagram on a page (n from 0). Every id in a baked
240 * file, the root's and the arrowhead markers', and every selector in its <style>, starts
241 * from `mmd-<key>`, so one replacement keeps a second copy from sharing ids with the first.
242 * It rewrites the <style> too, which is why astro.config.mjs hashes each copy for the
243 * Content-Security-Policy through this same function rather than reading the file alone.
244 */
245 export const diagramCopy = (svg, key, n) => (n === 0 ? svg : svg.replaceAll(`mmd-${key}`, `mmd-${key}-${n}`));
246
247 /**
248 * The baked SVG, or null when this fence has not been rendered yet. Synchronous by design:
249 * the plugin that calls it is a cache lookup, with no browser and no lifecycle to manage.
250 */
251 export function readDiagram(key) {
252 try {
253 return readFileSync(diagramFile(key), 'utf8');
254 } catch {
255 return null;
256 }
257 }
258
259 /**
260 * Every mermaid fence in a markdown document, in source order.
261 *
262 * A line-by-line CommonMark scan rather than one regex: a fence body can contain anything,
263 * including shorter backtick runs, and a regex that matched the first closing run it saw
264 * would end a mermaid block on a nested one. Tildes, indented openers (up to three spaces)
265 * and longer closing runs are all part of the format, so they are all handled.
266 */
267 export function findMermaidFences(markdown) {
268 const lines = String(markdown).replace(/\r\n?/g, '\n').split('\n');
269 const out = [];
270 let open = null;
271 for (let i = 0; i < lines.length; i++) {
272 const line = lines[i];
273 const fence = /^( {0,3})(`{3,}|~{3,})(.*)$/.exec(line);
274 if (open) {
275 // A closing fence is the same character, at least as long, and carries nothing else.
276 if (fence && fence[2][0] === open.char && fence[2].length >= open.length && fence[3].trim() === '') {
277 if (open.mermaid) out.push({ source: open.body.join('\n'), line: open.line });
278 open = null;
279 continue;
280 }
281 // The opener's indent comes off every body line, as CommonMark requires.
282 if (open.mermaid) open.body.push(line.replace(new RegExp(`^ {0,${open.indent}}`), ''));
283 continue;
284 }
285 if (!fence) continue;
286 const info = fence[3].trim();
287 // A backtick fence cannot carry a backtick in its info string.
288 if (fence[2][0] === '`' && info.includes('`')) continue;
289 open = {
290 char: fence[2][0],
291 length: fence[2].length,
292 indent: fence[1].length,
293 mermaid: info.split(/\s+/)[0].toLowerCase() === 'mermaid',
294 line: i + 1,
295 body: [],
296 };
297 }
298 // An unclosed fence still runs to the end of the document.
299 if (open?.mermaid) out.push({ source: open.body.join('\n'), line: open.line });
300 return out;
301 }