blog.git

blog

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

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&gt;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(/&gt;/g, '>').replace(/&lt;/g, '<').replace(/&amp;/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 }