clone

a personal forge on plain git: static pages, stacked-diff reviews, signed local ci, repo sites and releases

main
forge / lib / diagrams.ts
diagrams.ts 8.6K
1// Mermaid diagrams, drawn at build time: a ```mermaid fence in markdown2// becomes inline SVG (Pele, https://pele.run) that the stylesheet colours3// through its --pele-* variables, so it follows light and dark with no4// script. A diagram that doesn't parse stays its source, with a note.5//6// The markdown is anyone's who can push a repo. Pele escapes the text in a7// diagram and filters its links and styles; then the SVG goes through a8// sanitizer of its own that keeps only what Pele draws with, apart from9// the markdown's, whose raw HTML still can't have SVG.10import { createHash } from 'node:crypto';11import { PeleError, render, type TextMeasurer } from 'pele';12import sanitize from 'sanitize-html';1314// Text widths of the sans (Open Sans), regular and bold (--fw-strong,15// which style.css sets diagrams' bold in), in thousandths of an em:16// U+0020-007E then U+00A0-017F, two base-36 digits each, made by17// lib/text-widths.sh. Pele measures labels to size nodes, with a canvas in18// a browser; at build time there is none, and its own table is Helvetica's.19const REGULAR = '787cb2hyfwmzk9638787fbfw778y7ba7fwfwfwfwfwfwfwfwfwfw7b7bfwfwfwc0owhkhyhik6fgeck7kh7r7hh0eiozkxlmgqlmh5f8fbk9gkpng2fjfw93a793fwc67pfgh0dbh0fm9cf3h17070el70pqh1gqh0h0bdd99wh1dwljejdxd1aff9affw787cfwfwfwfwf9eag4n49tdsfw8yn4dwbwfw9o9o7ph6i77b669oaedskklclmc0hkhkhkhkhkhko4hifgfgfgfg7r7r7r7rk6kxlmlmlmlmlmfwlmk9k9k9k9fjgqhbfgfgfgfgfgfgnydbfmfmfmfm70707070goh1gqgqgqgqgqfwgqh1h1h1h1dxh0dxhkfghkfghkfghidbhidbhidbhidbk6h0k6h1fgfmfgfmfgfmfgfmfgfmk7f3k7f3k7f3k7f3khh1khh17r707r707r707r707r70f8e17h70h0elelei70ei70ei70ei79ei70kxh1kxh1kxh1iskxh1lmgqlmgqlmgqppqch5bdh5bdh5bdf8d9f8d9f8d9f8d9fb9wfb9wfb9wk9h1k9h1k9h1k9h1k9h1k9h1pnljfjdxfjfwd1fwd1fwd18z';20const BOLD = '787tcmhyfwoikp729494f6fw7r8y7sb5fwfwfwfwfwfwfwfwfwfw7s7sfwfwfwcyoxisiihnkhfjf1k5l28u8ri3fepwm6m0ham0i1fafwkthpqkhxgwg296b596fwbm9hgghfe1hfg7affkhy8484gl84qwhyh3hfhfcbdobjhyfcn8fofcdfatfaatfw787tfwfwfwfwfadpgon4afg9fw8yn4dwbwfwabab9hi2i77s5tabapg9mgnkn1cyisisisisisispvhnfjfjfjfj8u8u8u8ukhm6m0m0m0m0m0fwm0ktktktktgwhaj5ggggggggggggp3e1g7g7g7g784848484h2hyh3h3h3h3h3fwh3hyhyhyhyfchffcisggisggisgghne1hne1hne1hne1khhfkhhsfjg7fjg7fjg7fjg7fjg7k5fkk5fkk5fkk5fkl2hyl2hy8u848u848u848u848u84hmg78r84i3glglfe84fe84fe84fe9dfe84m6hym6hym6hykrm6hym0h3m0h3m0h3qpqzi1cbi1cbi1cbfadofadofadofadofwbjfwbjfwbjkthykthykthykthykthykthyqkn8gwfcgwg2dfg2dfg2dfa8';21const table = (s: string): Map<number, number> => {22 const codes = [...Array(0x7f - 0x20).keys()].map((i) => i + 0x20).concat([...Array(0x180 - 0xa0).keys()].map((i) => i + 0xa0));23 return new Map(codes.map((c, i) => [c, parseInt(s.slice(i * 2, i * 2 + 2), 36)]));24};25const widths = [table(REGULAR), table(BOLD)];26// Characters the sans lacks come from other fonts, as in Pele's own table:27// marks and zero-width spaces take no room, East Asian wide ones an em,28// the rest a guess. The mono (Noto Sans Mono) is 0.6em throughout.29const ZERO = /[̀-ͯ​-‏︀-️]/;30const WIDE = /[ᄀ-ᅟ⺀-꓏가-힣豈-﫿︰-﹏＀-⦆¢-₩]|[^\0-￿]/u;31const measurer: TextMeasurer = {32 width(text, size, style) {33 const mono = (style & 4) !== 0, w = widths[style & 1];34 let sum = 0;35 for (const ch of text) {36 const c = ch.codePointAt(0)!;37 if (c < 0x20 || ZERO.test(ch)) continue;38 sum += WIDE.test(ch) ? (mono ? 1200 : 1000) : mono ? 600 : (w.get(c) ?? 580);39 }40 return (sum * size) / 1000;41 },42};4344// What Pele draws with, and no more: shapes, text, links. No images, no45// foreign objects, no <style>, no use or xlink references; inline styles only46// with these properties, and no url() in them or in any attribute.47const PRESENTATION = ['class', 'style', 'transform', 'fill', 'fill-opacity', 'stroke', 'stroke-width', 'stroke-dasharray', 'stroke-linecap', 'stroke-linejoin', 'opacity'];48const SHAPE = [...PRESENTATION, 'x', 'y', 'width', 'height', 'rx', 'ry', 'cx', 'cy', 'r', 'd', 'points'];49const TEXT = [...PRESENTATION, 'x', 'y', 'font-size', 'font-weight', 'font-style', 'font-family', 'text-anchor', 'xml:space'];50const SAFE = /^(?![\s\S]*url\s*\()[-\w\s#%.,()+*/"']*$/i;51const STYLES = [52 '--_a', '--_b', '--_bg', '--_c', '--_fg', '--_fm', '--_hw', '--_l', '--_m', '--_r', '--_s', '--_tw', '--_w',53 'max-width', 'height', 'color', 'direction', 'font', 'font-family', 'font-size', 'font-style', 'font-weight',54 'letter-spacing', 'word-spacing', 'text-transform', 'text-decoration', 'fill', 'stroke', 'stroke-width',55 'stroke-dasharray', 'opacity', 'rx',56];57const SVG: sanitize.IOptions = {58 allowedTags: ['svg', 'g', 'path', 'rect', 'circle', 'ellipse', 'polygon', 'text', 'tspan', 'title', 'desc', 'a'],59 allowedAttributes: {60 svg: [...SHAPE, 'xmlns', 'viewBox', 'preserveAspectRatio', 'role', 'aria-roledescription', 'aria-labelledby', 'aria-describedby', 'aria-hidden'],61 g: [...TEXT],62 path: SHAPE, rect: SHAPE, circle: SHAPE, ellipse: SHAPE, polygon: SHAPE,63 text: TEXT, tspan: TEXT,64 title: ['id'], desc: ['id'],65 a: ['href', 'rel', 'target'],66 },67 // Pele's own classes; a diagram's classDef names aren't kept, so they68 // can't pick up the page's styles.69 allowedClasses: { '*': [/^pele(-[\w-]+)?$/, 'clickable'] },70 allowedStyles: { '*': Object.fromEntries(STYLES.map((p) => [p, [SAFE]])) },71 allowedSchemes: ['http', 'https', 'mailto'],72 allowProtocolRelative: false,73 // SVG's attribute names have capitals (viewBox); keep them as Pele wrote them.74 parser: { lowerCaseAttributeNames: false },75};7677// Which line of the source a syntax error is on. Pele 1.6.0 counts lines in78// the text it parses, which is the source less its front matter and %%79// comment lines, plus a newline, as Mermaid does: an error after either is80// reported lines early, and one at the end a line past the last. The line81// its snippet quotes is the source's, so find it, at or after the line82// Pele counted; at the end, it is the last line with anything on it. Drop83// this when Pele reports lines in the source (upstream: see the commit that84// added it).85function errorLine(src: string, e: PeleError): number {86 const lines = src.split(/\r\n?|\n/);87 const quoted = e.snippet.split('\n')[0];88 if (quoted.trim() === '') {89 const last = lines.findLastIndex((l) => l.trim() !== '') + 1;90 return last > 0 && e.line > last ? last : e.line;91 }92 const at = lines.findIndex((l, i) => i >= e.line - 1 && l === quoted);93 return at === -1 ? e.line : at + 1;94}9596const escape = (s: string): string => s.replace(/[&<>"]/g, (c) => `&${{ '&': 'amp', '<': 'lt', '>': 'gt', '"': 'quot' }[c]};`);9798// The width a phone gives a diagram, about. One drawn wider than this gets99// a second drawing laid out for it, often turned top to bottom, which the100// stylesheet shows instead in a narrow column (Pele's mount() would draw it101// again for the width; a page here has no script for that).102const PHONE = 340;103104// One diagram's HTML. `href` rewrites a link in it, as the markdown's own105// links are (relative ones resolve to forge pages).106export function diagram(src: string, href: (url: string) => string = (u) => u): string {107 // Ids (for the title and description) from the source, so a page that108 // didn't change builds the same.109 const idPrefix = 'diagram-' + createHash('sha256').update(src).digest('hex').slice(0, 8);110 const options = { measurer, images: false, linkSchemes: ['http', 'https', 'mailto'] };111 let svg;112 try {113 const wide = render(src, { ...options, idPrefix });114 svg = wide.svg;115 if (wide.width > PHONE) {116 try {117 const narrow = render(src, { ...options, idPrefix: idPrefix + '-narrow', maxWidth: PHONE });118 if (narrow.width < wide.width) svg += narrow.svg;119 } catch {120 // The wide one will do.121 }122 }123 } catch (e) {124 // Pele's message names the line, and can quote it: shown as text.125 let why = 'Pele failed on it';126 if (e instanceof PeleError) {127 why = e.message.split('\n')[0].replace(/[.:]$/, '').slice(0, 160);128 if (e.code === 'syntax' && e.line) why = why.replace(/ on line \d+/, ` on line ${errorLine(src, e)}`);129 }130 return `<figure class="diagram-source"><pre><code class="language-mermaid">${escape(src)}</code></pre><figcaption>Not drawn: ${escape(why)}.</figcaption></figure>\n`;131 }132 const clean = sanitize(svg, {133 ...SVG,134 // Every tag: no url() in any attribute; a link's href rewritten. (A135 // transform for '*' replaces one for the tag, so it is all here.)136 transformTags: {137 '*': (tag, attribs) => ({138 tagName: tag,139 attribs: Object.fromEntries(140 Object.entries(attribs)141 .filter(([, v]) => !/url\s*\(/i.test(v))142 .map(([k, v]) => [k, tag === 'a' && k === 'href' ? href(v) : v]),143 ),144 }),145 },146 });147 return `<figure class="diagram">${clean}</figure>\n`;148}