Draw Mermaid diagrams in replies as colored character art.

Show diagrams directly in Pi, with colored boxes, a clearer layout, and connecting lines that are easier to follow.
This extension draws diagrams written in Mermaid, a text format for describing boxes, arrows, and other shapes. It also asks the agent to use diagrams when they explain something more clearly than text.
Requires Pi and Node.js 22.6 or newer.
pi install npm:@yassimba/pi-loom-mermaid
markdown.mermaid to "off" in ~/.pi/agent/settings.json. Merge this into your existing settings; do not replace the file: {
"markdown": {
"mermaid": "off"
}
}
This turns off Pi’s own diagram drawing so this extension can draw instead. If you have another Mermaid extension installed, disable it with pi config.
/reload in Pi.The same renderer ships as a Claude Code plugin named loom-mermaid. In Claude Code, run:
/plugin marketplace add Yassimba/loom
/plugin install loom-mermaid@loom
Diagrams then draw in replies on every surface, and the system prompt gains the same note about when to use Mermaid. While a reply streams, a band above the prompt shows the diagram growing; the diagram lands in the transcript when its block closes.
Ask Pi: “Explain this code with a Mermaid diagram.” Pi draws the diagram in the conversation.
You can also paste the example below. Keep the opening line of three backticks followed by mermaid, and the closing line of three backticks.
To show changes, add :::red after a box for removed code, :::orange for changed code, or :::green for added code. These labels give boxes muted colored borders. Text and backgrounds keep your Pi theme’s colors. The classDef lines in the example set custom border colors.
If a diagram is too wide, Pi shows its code instead. Widen the terminal or ask Pi to split it into smaller diagrams.
loom-mermaid-render requires Bun on PATH (also required by Herdr Annotate). The CLI uses Bun because Node cannot strip TypeScript inside installed node_modules; the Pi extension runtime is unchanged. It reads Markdown on stdin and writes pre-rendered diagrams in loom-mermaid fences. Set LOOM_MERMAID_WIDTH to change the default 100-column limit. This format requires the Loom-patched Plannotator TUI: it hides the fences and maps SGR colors, bold, and dim into terminal spans. Hyperlinks are omitted; unsupported or oversized diagrams keep their original Mermaid source. Ordinary Markdown viewers do not understand this colored interchange format.
All three views below use the same Mermaid code.
Gray boxes. Connecting lines take long paths around the outside.
<img src="https://raw.githubusercontent.com/Yassimba/loom/main/assets/mermaid-pi-builtin.png" alt="Pi built-in Mermaid: gray boxes with long connecting lines around the outside">
Colored boxes and shorter connecting lines. Small bends mark where lines cross.
<img src="https://raw.githubusercontent.com/Yassimba/loom/main/assets/mermaid-pi-loom.png" alt="pi-loom-mermaid: colored boxes, shorter connecting lines, and bends at crossings">
GitHub draws the code below as a diagram. Copy the code into Pi to compare how it looks.
flowchart TD
CLI["turbine CLI<br/>shell.main"]:::orange
LSP["Editor<br/>turbine-lsp"]:::orange
HTTP["HTTP client"]:::red
CLI --> SELECT["Select ProjectLayout"]:::orange
LSP --> WORKSPACE["Discover Projects<br/>EditorWorkspace.open"]:::orange
SELECT --> RUNTIME["ProjectRuntime.create"]:::orange
WORKSPACE --> RUNTIME
ENTRY["Installed turbine.extension<br/>entry points"]:::red --> EXT["Discover, order, and admit<br/>Extensions"]:::orange
RUNTIME --> EXT
EXT --> CATALOG["ExtensionCatalog"]:::green
CATALOG --> FORMATS["InstalledFormats"]:::green
CATALOG --> LINT["CachedProjectLint"]:::green
RUNTIME --> SNAPSHOT["ProjectSnapshotCache"]:::green
RUNTIME --> RUN["CheckRun"]:::green
RUNTIME --> HISTORY["RunHistoryReader"]:::green
SNAPSHOT --> COMMANDS["CLI commands"]:::orange
SNAPSHOT --> SESSION["EditorSession"]:::orange
SNAPSHOT --> API["Management API"]:::orange
CLI --> COMMANDS
LSP --> SESSION
HTTP --> API
classDef red stroke:#9f5555
classDef orange stroke:#9a7438
classDef green stroke:#4f8560
Update the package, then run /reload:
pi update npm:@yassimba/pi-loom-mermaid
To uninstall:
pi remove npm:@yassimba/pi-loom-mermaid
Delete the "mermaid": "off" setting you added to restore Pi’s own diagram drawing, then run /reload.
From the Loom repository root, run npm ci, npm run check, and npm run audit before opening a pull request.
MIT. Adapted from pi-lovely-mermaid.
hooks/register.tsx 166 lines1import type { Elements, Register, RenderSurface, TextProps } from "claude-code";
2import { atom, read, update } from "claude-code";
3import { resolveClassStyle } from "../src/loom-mermaid/class-style.ts";
4import type { Role } from "../src/loom-mermaid/types.ts";
5import { ansiLines, type Drawn, drawMessage, fenced, GUIDANCE } from "../src/shared.ts";
6
7/** A surface that has not measured draws at the document transformer's width. */
8const DEFAULT_COLUMNS = 100;
9/** The columns the transcript keeps for a reply's bullet. */
10const GUTTER = 2;
11
12/** Dim frame, plain labels, cyan connectors: the renderer's ANSI theme as Text props. */
13const THEME: Partial<Record<Role, TextProps>> = {
14 border: { dimColor: true },
15 edge: { color: "cyan" },
16 edgeLabel: { color: "cyan", dimColor: true },
17 title: { bold: true },
18};
19
20function runs({ art }: Drawn) {
21 return art.styled.map((row) =>
22 row.map((span) => {
23 const themed = THEME[span.role] ?? {};
24 const cls = resolveClassStyle(span.classes, art.classDefs);
25 // Only `stroke` colors a border; fills and text colors stay with the theme.
26 // It is never drawn dim: `dimColor` replaces a Text's color with the theme's grey.
27 const stroke = span.role === "border" ? cls?.stroke : undefined;
28 const style =
29 stroke === undefined
30 ? { ...themed, ...(cls?.bold === true ? { bold: true } : {}) }
31 : { color: stroke, bold: cls?.bold === true };
32 return { text: span.text, href: span.href, style };
33 }),
34 );
35}
36
37const PENDING = "Drawing Mermaid…";
38
39/** The diagram as one Text per row, each run in its own style. */
40function diagram(
41 { Box, Link, Text }: Pick<Elements[RenderSurface], "Box" | "Link" | "Text">,
42 drawn: Drawn,
43 indent: string,
44 key: string,
45) {
46 return (
47 <Box key={key} flexDirection="column">
48 {runs(drawn).map((row, y) => (
49 <Text key={`row-${y}`} wrap="truncate">
50 {indent}
51 {row.map((run, x) => (
52 <Text key={`run-${x}`} {...run.style}>
53 {run.href === undefined || run.text.trim() === "" ? (
54 run.text
55 ) : (
56 <Link href={run.href}>{run.text}</Link>
57 )}
58 </Text>
59 ))}
60 </Text>
61 ))}
62 </Box>
63 );
64}
65
66/**
67 * A streaming reply as its lines are shown: a Mermaid fence is withheld while
68 * open and shows as its drawing once it closes. Lines only ever add to the end
69 * of it, so what a new batch shows is what it adds; the band above the prompt
70 * shows the fence still open, and the finished reply is drawn again by the
71 * `AssistantMessage` site.
72 */
73function shown(text: string, columns: number): string {
74 return drawMessage(text, columns, false)
75 .map(({ raw, indent, drawing, fence }) => {
76 if (fence === "open") return "";
77 if (drawing === null || drawing === "pending") return raw;
78 return fenced(ansiLines(drawing).join("\n")).replace(/^(?=.)/gm, indent);
79 })
80 .join("");
81}
82
83const arrivingFence = atom({ plugin: "loom-mermaid", key: "arriving" } as const, null);
84
85export const register: Register = (on) => {
86 // The streaming event carries no width, so it draws at the last one a site was drawn at.
87 let columns = DEFAULT_COLUMNS;
88 // ponytail: a reply that never sends its final batch keeps its text here; cap it if sessions leak.
89 const arriving = new Map<string, string>();
90
91 on("classic.MessageDisplay", async ($, e, next) => {
92 const result = await next(e);
93 const before = arriving.get(e.message_id) ?? "";
94 const text = before + e.delta;
95 if (e.final) arriving.delete(e.message_id);
96 else arriving.set(e.message_id, text);
97
98 const last = drawMessage(text, columns, true).at(-1);
99 const open = !e.final && last?.fence === "open" ? last.raw : null;
100 if ((await read($, arrivingFence)) !== open) await update($, arrivingFence, () => open);
101
102 const width = columns - GUTTER;
103 const displayContent = shown(text, width).slice(shown(before, width).length);
104 return displayContent === e.delta ? result : { ...result, displayContent };
105 });
106
107 // The open fence of a streaming reply, growing statement by statement above the prompt.
108 on("ui.render", { component: "AbovePrompt" }, async ($, e, next) => {
109 columns = e.viewport?.columns ?? columns;
110 const fence = await read($, arrivingFence);
111 if (fence === null || e.props.hasSurvey) return next(e);
112 const drawing = drawMessage(fence, e.props.bodyColumns, true)[0]?.drawing ?? null;
113 if (drawing === null) return next(e);
114 const ui = $.ui.resolve(e);
115 return drawing === "pending" ? (
116 <ui.Text italic>{PENDING}</ui.Text>
117 ) : (
118 diagram(ui, drawing, "", "preview")
119 );
120 });
121
122 on("prompt.compose", async (_$, e, next) => {
123 const { sections } = await next(e);
124 return {
125 sections: [...sections, { id: "loom-mermaid:guidance", text: GUIDANCE, scope: "session" }],
126 };
127 });
128
129 on("ui.render", { component: "AssistantMessage" }, ($, e, next) => {
130 // The site carries no streaming flag, so an unclosed fence is one still arriving.
131 columns = e.viewport?.columns ?? DEFAULT_COLUMNS;
132 const gutter = e.surface === "terminal" ? GUTTER : 0;
133 const parts = drawMessage(e.props.text, columns - gutter, true);
134 if (parts.every((part) => part.drawing === null)) return next(e);
135
136 const ui = $.ui.resolve(e);
137 const { Box, Markdown, Text } = ui;
138 const body = (
139 <Box flexDirection="column" gap={1}>
140 {parts.map(({ raw, indent, drawing }, at) => {
141 if (drawing === null) {
142 return raw.trim() === "" ? null : <Markdown key={`text-${at}`} text={raw.trimEnd()} />;
143 }
144 if (drawing === "pending") {
145 return (
146 <Text key={`pending-${at}`} italic>
147 {PENDING}
148 </Text>
149 );
150 }
151 return diagram(ui, drawing, indent, `diagram-${at}`);
152 })}
153 </Box>
154 );
155 if (e.surface !== "terminal") return body;
156 // On the terminal a hook that draws the reply draws its chrome too: the
157 // blank row above it, the bullet and the gutter.
158 return (
159 <Box flexDirection="row" marginTop={1}>
160 <Text>{e.props.isFirstOfReply ? "⏺ " : " "}</Text>
161 {body}
162 </Box>
163 );
164 });
165};
166src/loom-mermaid/class-style.ts 66 lines1/**
2 * Best-effort interpretation of `classDef` styles for a cell grid.
3 *
4 * A terminal cell can express a foreground, a background and boldness —
5 * nothing else. `fill` is the node background, `stroke` its border,
6 * `color` its text; every other property is silently ignored.
7 */
8
9import { NAMED_COLORS } from './css-colors.ts'
10
11/** The terminal-expressible subset of a classDef; colors as `#rrggbb`. */
12export interface ClassStyle {
13 fill?: string
14 stroke?: string
15 color?: string
16 bold?: boolean
17}
18
19/** `#rgb`, `#rrggbb`, `rgb(r,g,b)` or a CSS color name → `#rrggbb`; else null. */
20function normalizeColor(v: string): string | null {
21 const s = v.trim().toLowerCase()
22 if (/^#[0-9a-f]{6}$/.test(s)) return s
23 if (/^#[0-9a-f]{3}$/.test(s)) return `#${s[1]}${s[1]}${s[2]}${s[2]}${s[3]}${s[3]}`
24 const rgb = s.match(/^rgba?\(\s*(\d+)\s*,\s*(\d+)\s*,\s*(\d+)/)
25 if (rgb !== null) {
26 const hex = (n: string) => Math.min(255, Number(n)).toString(16).padStart(2, '0')
27 return `#${hex(rgb[1])}${hex(rgb[2])}${hex(rgb[3])}`
28 }
29 return NAMED_COLORS[s] ?? null
30}
31
32/**
33 * The merged style of a span's classes (later classes win), or null when
34 * nothing terminal-expressible was declared.
35 */
36export function resolveClassStyle(
37 classes: string[] | undefined,
38 classDefs: Record<string, Record<string, string>>,
39): ClassStyle | null {
40 if (classes === undefined) return null
41 const out: ClassStyle = {}
42 for (const name of classes) {
43 const props = classDefs[name]
44 if (props === undefined) continue
45 for (const [k, v] of Object.entries(props)) {
46 if (k === 'fill' || k === 'stroke' || k === 'color') {
47 const c = normalizeColor(v)
48 if (c !== null) out[k] = c
49 } else if (k === 'font-weight') {
50 out.bold = v.trim() === 'bold' || v.trim() === 'bolder'
51 }
52 }
53 }
54 return Object.keys(out).length > 0 ? out : null
55}
56
57/**
58 * Black or white, whichever reads on the given `#rrggbb` background — the
59 * guard that keeps `fill:#eee` legible on a dark terminal theme.
60 */
61export function contrastOn(fill: string): '#000000' | '#ffffff' {
62 const ch = (i: number) => Number.parseInt(fill.slice(i, i + 2), 16)
63 const yiq = (ch(1) * 299 + ch(3) * 587 + ch(5) * 114) / 1000
64 return yiq >= 128 ? '#000000' : '#ffffff'
65}
66src/loom-mermaid/types.ts 65 lines1/**
2 * Semantic role of a run of cells — what a cell *is*, decided by the
3 * renderer. The renderer never knows about colour; consumers map roles to
4 * their own theme (see `toAnsi` for the common case).
5 *
6 * - `border` box outlines, subgraph frames, compartment rules
7 * - `text` node / participant / compartment labels
8 * - `edge` connector lines and arrowheads
9 * - `edgeLabel` text sitting on an edge
10 * - `title` the `mermaid: <kind>` header of a source box
11 * - `none` blank filler
12 *
13 * Distinct from `classes`, which is what the *author* assigned.
14 */
15export type Role = 'border' | 'text' | 'edge' | 'edgeLabel' | 'title' | 'none'
16
17/** A run of adjacent cells sharing one role and one set of author classes. */
18export interface Span {
19 text: string
20 role: Role
21 /**
22 * Author-assigned class names of the node these cells belong to, from a
23 * `:::name` shorthand or a `class A,B name` statement. The renderer never
24 * interprets them — pair with `MermaidArt.classDefs` to style. Absent on
25 * cells that belong to no classed node.
26 */
27 classes?: string[]
28 /**
29 * Link target of the node these cells belong to, from a `click A "url"`
30 * (flowchart) or `link A "url"` (class diagram) statement. `toAnsi` emits
31 * it as an OSC 8 hyperlink; other consumers map it to their own linking.
32 */
33 href?: string
34}
35
36/**
37 * A rendered diagram. `plain[i]` and `styled[i]` describe the same row:
38 * `plain` is right-trimmed for display width and copy/paste, `styled` keeps
39 * the run structure needed to colour it.
40 *
41 * `width` is the display columns the widest row needs — the number to compare
42 * against the space you have. It cannot be recovered from `plain`, whose rows
43 * are strings of code points, not columns.
44 *
45 * `classDefs` are the diagram's `classDef` declarations, parsed:
46 * `classDef warning fill:#f96,stroke:#333` becomes
47 * `{ warning: { fill: '#f96', stroke: '#333' } }`. The renderer ignores them;
48 * a consumer can map them onto its own styling of the classes spans carry.
49 *
50 * `warnings` lists source the grammar could not read and dropped, and any
51 * size-cap truncation. Non-empty means the art is real but incomplete — some
52 * of what was written is not in it.
53 *
54 * They are advisory. Do not gate rendering on them: the art is the best drawing
55 * of the source either way, and a diagram being typed or streamed warns at
56 * nearly every intermediate state. Show them alongside, or once it settles.
57 */
58export interface MermaidArt {
59 plain: string[]
60 styled: Span[][]
61 width: number
62 classDefs: Record<string, Record<string, string>>
63 warnings: string[]
64}
65src/shared.ts 109 lines1/** What Pi and the Claude Code mod both do around the renderer. */
2
3import { type Fence, scanFences } from "./fences.ts";
4import { render, toAnsi } from "./loom-mermaid/index.ts";
5import type { MermaidArt } from "./loom-mermaid/types.ts";
6import { drawArriving } from "./streaming.ts";
7
8/** The system prompt note that makes the model reach for Mermaid and the diff markers. */
9export const GUIDANCE = `Use fenced \`mermaid\` blocks; they render automatically in the user’s session. Always use Mermaid when it is easier to read than prose. Choose by subject: architecture for deployed services, flowchart for dependencies/decisions, sequence for interactions, state for lifecycles, ER/class for models, mindmap for hierarchies, timeline/git graph for history, pie for proportions. Use complementary diagrams when explaining multiple aspects. Keep diagram labels short. Mark changes by appending :::red (removed), :::green (added), or :::orange (changed) after the node, outside its label brackets (A[Added]:::green, never A[Added :::green]); their default colors are automatic—do not add classDef for these diff markers. In general prefer colored outlines to logically group things (if there are no changes involved).`;
10
11/** The default stroke of each diff marker. */
12const diffStrokes = { red: "#9f5555", orange: "#9a7438", green: "#4f8560" };
13
14/** Give the diff markers a source uses but does not define their default strokes. */
15function withDiffClasses(source: string): { source: string; dimStrokes: string[] } {
16 const defaults = Object.entries(diffStrokes).filter(([name]) => {
17 const used = new RegExp(`:::\\s*${name}\\b|\\bclass\\s+[^\\n]+\\s+${name}\\b`).test(source);
18 return used && !new RegExp(`\\bclassDef\\s+${name}\\b`).test(source);
19 });
20 return {
21 source: [source, ...defaults.map(([name, stroke]) => `classDef ${name} stroke:${stroke}`)].join(
22 "\n",
23 ),
24 dimStrokes: defaults.map(([, stroke]) => stroke),
25 };
26}
27
28/** A diagram's art, and the default diff strokes it draws dim. */
29export type Drawn = { art: MermaidArt; dimStrokes: string[] };
30
31/**
32 * Drawings by source and width. Both hosts draw a whole message again for
33 * every streamed chunk and every redraw, and layout is deterministic, so the
34 * first one is the only one needed.
35 */
36const drawn = new Map<string, Drawn | null>();
37const CACHE_SIZE = 64;
38
39/** The diagram drawn within `columns`, or null when the source does not draw or fit. */
40function drawDiagram(source: string, columns: number): Drawn | null {
41 const key = `${columns}\0${source.trimEnd()}`;
42 const hit = drawn.get(key);
43 if (hit !== undefined) {
44 drawn.delete(key);
45 drawn.set(key, hit);
46 return hit;
47 }
48 const styled = withDiffClasses(source);
49 const art = render(styled.source, { maxWidth: columns });
50 const out = !art || art.width > columns ? null : { art, dimStrokes: styled.dimStrokes };
51 drawn.set(key, out);
52 if (drawn.size > CACHE_SIZE) drawn.delete(drawn.keys().next().value as string);
53 return out;
54}
55
56/**
57 * One run of a message: text to show as written (`drawing` null), a diagram,
58 * or "pending" for a fence that has arrived too little to draw. A fence in a
59 * list item carries the item's `indent`.
60 */
61export type Part = {
62 raw: string;
63 indent: string;
64 drawing: Drawn | "pending" | null;
65 /** Whether the run is a Mermaid fence, and whether its closing line has arrived. */
66 fence: "open" | "closed" | null;
67};
68
69function draw(fence: Fence, columns: number, arriving: boolean): Part["drawing"] {
70 if (fence.closed || !(arriving || fence.nested)) return drawDiagram(fence.source, columns);
71 // A list item's fence draws only once closed.
72 return fence.nested ? null : drawArriving(fence.source, (source) => drawDiagram(source, columns));
73}
74
75/**
76 * Split a message at its Mermaid fences and draw each within `columns`.
77 * `arriving` says the message is still streaming, so an unclosed fence draws
78 * its newest complete statements; otherwise it draws whole. A fence that does
79 * not draw or fit stays text.
80 */
81export function drawMessage(markdown: string, columns: number, arriving: boolean): Part[] {
82 return scanFences(markdown).map(({ raw, mermaid }) => ({
83 raw,
84 indent: mermaid?.indent ?? "",
85 drawing: mermaid ? draw(mermaid, columns - mermaid.indent.length, arriving) : null,
86 fence: mermaid ? (mermaid.closed ? "closed" : "open") : null,
87 }));
88}
89
90/** `text` as a code fence long enough to hold any backtick run inside it. */
91export function fenced(text: string, info = ""): string {
92 const longestRun = Math.max(0, ...Array.from(text.matchAll(/`+/g), (match) => match[0].length));
93 const fence = "`".repeat(Math.max(3, longestRun + 1));
94 return `${fence}${info}\n${text}\n${fence}\n`;
95}
96
97/** The art as ANSI lines, its default diff borders dim. */
98export function ansiLines({ art, dimStrokes }: Drawn): string[] {
99 const sgrValues = dimStrokes.map(
100 (hex) => `38;2;${[1, 3, 5].map((i) => Number.parseInt(hex.slice(i, i + 2), 16)).join(";")}`,
101 );
102 return toAnsi(art).map((line) =>
103 sgrValues.reduce(
104 (result, sgr) => result.replaceAll(`\u001b[${sgr}m`, `\u001b[2;${sgr}m`),
105 line,
106 ),
107 );
108}
109src/loom-mermaid/css-colors.ts 154 lines1// Generated by scripts/gen-css-colors.ts from https://drafts.csswg.org/css-color-4/ — do not edit.
2
3/** The CSS Color 4 named-color table: name → `#rrggbb`. */
4export const NAMED_COLORS: Record<string, string> = {
5 aliceblue: '#f0f8ff',
6 antiquewhite: '#faebd7',
7 aqua: '#00ffff',
8 aquamarine: '#7fffd4',
9 azure: '#f0ffff',
10 beige: '#f5f5dc',
11 bisque: '#ffe4c4',
12 black: '#000000',
13 blanchedalmond: '#ffebcd',
14 blue: '#0000ff',
15 blueviolet: '#8a2be2',
16 brown: '#a52a2a',
17 burlywood: '#deb887',
18 cadetblue: '#5f9ea0',
19 chartreuse: '#7fff00',
20 chocolate: '#d2691e',
21 coral: '#ff7f50',
22 cornflowerblue: '#6495ed',
23 cornsilk: '#fff8dc',
24 crimson: '#dc143c',
25 cyan: '#00ffff',
26 darkblue: '#00008b',
27 darkcyan: '#008b8b',
28 darkgoldenrod: '#b8860b',
29 darkgray: '#a9a9a9',
30 darkgreen: '#006400',
31 darkgrey: '#a9a9a9',
32 darkkhaki: '#bdb76b',
33 darkmagenta: '#8b008b',
34 darkolivegreen: '#556b2f',
35 darkorange: '#ff8c00',
36 darkorchid: '#9932cc',
37 darkred: '#8b0000',
38 darksalmon: '#e9967a',
39 darkseagreen: '#8fbc8f',
40 darkslateblue: '#483d8b',
41 darkslategray: '#2f4f4f',
42 darkslategrey: '#2f4f4f',
43 darkturquoise: '#00ced1',
44 darkviolet: '#9400d3',
45 deeppink: '#ff1493',
46 deepskyblue: '#00bfff',
47 dimgray: '#696969',
48 dimgrey: '#696969',
49 dodgerblue: '#1e90ff',
50 firebrick: '#b22222',
51 floralwhite: '#fffaf0',
52 forestgreen: '#228b22',
53 fuchsia: '#ff00ff',
54 gainsboro: '#dcdcdc',
55 ghostwhite: '#f8f8ff',
56 gold: '#ffd700',
57 goldenrod: '#daa520',
58 gray: '#808080',
59 green: '#008000',
60 greenyellow: '#adff2f',
61 grey: '#808080',
62 honeydew: '#f0fff0',
63 hotpink: '#ff69b4',
64 indianred: '#cd5c5c',
65 indigo: '#4b0082',
66 ivory: '#fffff0',
67 khaki: '#f0e68c',
68 lavender: '#e6e6fa',
69 lavenderblush: '#fff0f5',
70 lawngreen: '#7cfc00',
71 lemonchiffon: '#fffacd',
72 lightblue: '#add8e6',
73 lightcoral: '#f08080',
74 lightcyan: '#e0ffff',
75 lightgoldenrodyellow: '#fafad2',
76 lightgray: '#d3d3d3',
77 lightgreen: '#90ee90',
78 lightgrey: '#d3d3d3',
79 lightpink: '#ffb6c1',
80 lightsalmon: '#ffa07a',
81 lightseagreen: '#20b2aa',
82 lightskyblue: '#87cefa',
83 lightslategray: '#778899',
84 lightslategrey: '#778899',
85 lightsteelblue: '#b0c4de',
86 lightyellow: '#ffffe0',
87 lime: '#00ff00',
88 limegreen: '#32cd32',
89 linen: '#faf0e6',
90 magenta: '#ff00ff',
91 maroon: '#800000',
92 mediumaquamarine: '#66cdaa',
93 mediumblue: '#0000cd',
94 mediumorchid: '#ba55d3',
95 mediumpurple: '#9370db',
96 mediumseagreen: '#3cb371',
97 mediumslateblue: '#7b68ee',
98 mediumspringgreen: '#00fa9a',
99 mediumturquoise: '#48d1cc',
100 mediumvioletred: '#c71585',
101 midnightblue: '#191970',
102 mintcream: '#f5fffa',
103 mistyrose: '#ffe4e1',
104 moccasin: '#ffe4b5',
105 navajowhite: '#ffdead',
106 navy: '#000080',
107 oldlace: '#fdf5e6',
108 olive: '#808000',
109 olivedrab: '#6b8e23',
110 orange: '#ffa500',
111 orangered: '#ff4500',
112 orchid: '#da70d6',
113 palegoldenrod: '#eee8aa',
114 palegreen: '#98fb98',
115 paleturquoise: '#afeeee',
116 palevioletred: '#db7093',
117 papayawhip: '#ffefd5',
118 peachpuff: '#ffdab9',
119 peru: '#cd853f',
120 pink: '#ffc0cb',
121 plum: '#dda0dd',
122 powderblue: '#b0e0e6',
123 purple: '#800080',
124 rebeccapurple: '#663399',
125 red: '#ff0000',
126 rosybrown: '#bc8f8f',
127 royalblue: '#4169e1',
128 saddlebrown: '#8b4513',
129 salmon: '#fa8072',
130 sandybrown: '#f4a460',
131 seagreen: '#2e8b57',
132 seashell: '#fff5ee',
133 sienna: '#a0522d',
134 silver: '#c0c0c0',
135 skyblue: '#87ceeb',
136 slateblue: '#6a5acd',
137 slategray: '#708090',
138 slategrey: '#708090',
139 snow: '#fffafa',
140 springgreen: '#00ff7f',
141 steelblue: '#4682b4',
142 tan: '#d2b48c',
143 teal: '#008080',
144 thistle: '#d8bfd8',
145 tomato: '#ff6347',
146 turquoise: '#40e0d0',
147 violet: '#ee82ee',
148 wheat: '#f5deb3',
149 white: '#ffffff',
150 whitesmoke: '#f5f5f5',
151 yellow: '#ffff00',
152 yellowgreen: '#9acd32',
153}
154src/fences.ts 105 lines1export type Fence = {
2 /** The diagram source, the fence's own indentation removed. */
3 source: string;
4 indent: string;
5 closed: boolean;
6 /** Inside a list item, where the drawing keeps the item's indentation. */
7 nested: boolean;
8};
9
10/** A run of a markdown document: text passed through as written, or one Mermaid fence. */
11export type Segment = { raw: string; mermaid?: Fence };
12
13const OPENER = /^([ \t]*)(`{3,}|~{3,})[ \t]*(.*)$/;
14const LIST_ITEM = /^ {0,3}(?:[-*+]|\d{1,9}[.)])(?:[ \t]|$)/;
15
16type Opener = { indent: string; fence: string; info: string; nested: boolean };
17
18/**
19 * The fence a line opens, if any. Outside a list four columns of indent make
20 * an indented code block, and a backtick fence's info string holds no backtick.
21 */
22function opener(body: string, inList: boolean): Opener | null {
23 const open = body.match(OPENER);
24 if (open === null) return null;
25 const [, indent = "", fence = "", info = ""] = open;
26 const nested = inList && indent.length > 0;
27 if (!nested && indent.replaceAll("\t", " ").length > 3) return null;
28 if (fence.startsWith("`") && info.includes("`")) return null;
29 return { indent, fence, info, nested };
30}
31
32/** The line closing the fence opened at `start`, or `lines.length` when none does. */
33function closerAt(lines: string[], start: number, { fence, nested }: Opener): number {
34 const closer = new RegExp(
35 `^${nested ? "[ \\t]*" : " {0,3}"}${fence[0]}{${fence.length},}[ \\t]*\\r?\\n?$`,
36 );
37 let end = start + 1;
38 while (end < lines.length && !closer.test(lines[end] as string)) end++;
39 return end;
40}
41
42/** A list stays open until an unindented line follows a blank one. */
43function isListOpen(body: string, inList: boolean, afterBlank: boolean): boolean {
44 if (LIST_ITEM.test(body)) return true;
45 return inList && !(afterBlank && !/^[ \t]/.test(body));
46}
47
48function mermaidFence(content: string[], open: Opener, closed: boolean): Fence | undefined {
49 if (open.info.trim().split(/\s+/, 1)[0]?.toLowerCase() !== "mermaid") return undefined;
50 const ownIndent = new RegExp(`^[ \\t]{0,${open.indent.length}}`);
51 const source = content.map((line) => line.replace(ownIndent, "")).join("");
52 return {
53 source: closed ? source.replace(/\r?\n$/, "") : source,
54 indent: open.indent,
55 closed,
56 nested: open.nested,
57 };
58}
59
60/**
61 * Split markdown at its Mermaid fences. Every fence is tracked, so a Mermaid
62 * example quoted inside another code block stays text; joining each `raw`
63 * gives the document back.
64 */
65export function scanFences(markdown: string): Segment[] {
66 const lines = markdown.match(/[^\n]*\n|[^\n]+/g) ?? [];
67 const segments: Segment[] = [];
68 let text = "";
69 let inList = false;
70 let afterBlank = false;
71
72 for (let i = 0; i < lines.length; ) {
73 const line = lines[i] as string;
74 const body = line.replace(/\r?\n$/, "");
75 const open = opener(body, inList);
76 if (open === null) {
77 const isBlank = body.trim() === "";
78 if (!isBlank) inList = isListOpen(body, inList, afterBlank);
79 afterBlank = isBlank;
80 text += line;
81 i++;
82 continue;
83 }
84
85 const end = closerAt(lines, i, open);
86 const closed = end < lines.length;
87 const next = closed ? end + 1 : end;
88 const raw = lines.slice(i, next).join("");
89 const mermaid = mermaidFence(lines.slice(i + 1, end), open, closed);
90 i = next;
91 inList = inList && open.nested;
92 afterBlank = false;
93
94 if (mermaid === undefined) {
95 text += raw;
96 continue;
97 }
98 if (text) segments.push({ raw: text });
99 text = "";
100 segments.push({ raw, mermaid });
101 }
102 if (text) segments.push({ raw: text });
103 return segments;
104}
105src/loom-mermaid/index.ts 98 lines1import type { Canvas } from './canvas.ts'
2import { LIMITS, stripControls } from './labels.ts'
3import { type Diagram, diagramFor } from './registry.ts'
4import { frontmatterTitle } from './statements.ts'
5import type { MermaidArt } from './types.ts'
6import { stringWidth } from './width.ts'
7
8export { type AnsiTheme, classSgr, DEFAULT_THEME, toAnsi } from './ansi.ts'
9export { type ClassStyle, contrastOn, resolveClassStyle } from './class-style.ts'
10export { type DiagramKind, diagramKind } from './registry.ts'
11export { sourceBox } from './source-box.ts'
12export type { MermaidArt, Role, Span } from './types.ts'
13
14/**
15 * Render a Mermaid source block as Unicode box-drawing art.
16 *
17 * Supported: `architecture-beta`, `graph`/`flowchart` (including `subgraph`),
18 * `stateDiagram`, `classDiagram`, `erDiagram`, `sequenceDiagram`, `pie`,
19 * `mindmap`, `timeline` and `gitGraph`.
20 *
21 * The diagram is laid out at whatever size it needs; `art.width` reports the
22 * columns that turned out to be. Given `maxWidth`, a diagram wider than that
23 * is laid out again with progressively tighter label limits. LR flowcharts
24 * without explicit group directions or cross-scope member edges then retry
25 * top-down before collapsing subgraphs. The first fit is returned; the source
26 * is never rewritten.
27 * Deciding what to do when even the final fallback exceeds the
28 * space at hand is the caller's — `sourceBox` is the usual answer:
29 *
30 * ```ts
31 * const art = render(src, { maxWidth: cols })
32 * show(art && art.width <= cols ? art : sourceBox(src, cols))
33 * ```
34 *
35 * `null` means there is no art to show: blank input, a diagram type this
36 * renderer does not draw, a source in which not one statement parsed, or a
37 * diagram large enough that laying it out is refused. `diagramKind` separates
38 * the middle two.
39 *
40 * Rendering is best-effort in every grammar: a statement either contributes
41 * what parsed or is dropped, and a diagram over a size cap renders its prefix.
42 * Everything given up on is listed in `art.warnings` — advisory only, never a
43 * reason to withhold the art.
44 */
45export function render(src: string, options: { maxWidth?: number } = {}): MermaidArt | null {
46 src = stripControls(src)
47 if (src.trim() === '') return null
48 const diagram = diagramFor(src)
49 if (diagram === null) return null
50 // Preserve the requested direction while tightening labels, then try TD
51 // before the existing collapsed fallback. Each attempt parses fresh source.
52 let drawn: ReturnType<Diagram['render']> = null
53 let art: ReturnType<Canvas['toLines']> = { plain: [], styled: [], width: 0 }
54 let collapsed = false
55 fitting: for (const [draw, collapse] of [
56 [diagram.render, false],
57 [diagram.renderDown, false],
58 [diagram.render, true],
59 ] as const) {
60 if (draw === undefined) continue
61 for (const limits of LIMITS) {
62 if ((limits.collapse === true) !== collapse) continue
63 const candidate = draw(src, limits)
64 if (candidate === null) {
65 if (draw === diagram.renderDown) break
66 return null
67 }
68 drawn = candidate
69 collapsed = collapse
70 art = drawn.canvas.toLines()
71 if (options.maxWidth === undefined || art.width <= options.maxWidth) break fitting
72 }
73 }
74 if (drawn === null) return null
75 if (collapsed && /^\s*subgraph\b|^\s*state\s+\S+\s*\{/m.test(src)) {
76 drawn.warnings.push('too wide for the space: subgraphs drawn collapsed, one box each')
77 }
78
79 // A frontmatter `title:` is centred above the art, in the `title` role.
80 const title = frontmatterTitle(src)
81 if (title !== null) {
82 const tw = stringWidth(title)
83 art.width = Math.max(art.width, tw)
84 const pad = ' '.repeat(Math.floor((art.width - tw) / 2))
85 art.plain.unshift(pad + title, '')
86 art.styled.unshift(
87 pad === ''
88 ? [{ text: title, role: 'title' }]
89 : [
90 { text: pad, role: 'none' },
91 { text: title, role: 'title' },
92 ],
93 [],
94 )
95 }
96 return { ...art, classDefs: drawn.classDefs, warnings: drawn.warnings }
97}
98src/streaming.ts 33 lines1import { diagramKind } from "./loom-mermaid/index.ts";
2
3/** Newest complete prefix first; never expose a half-written label or comment. */
4function* streamingPrefixes(text: string): Generator<string> {
5 const ends: number[] = [];
6 let depth = 0;
7 // Consume quotes (including unfinished ones) and comments as opaque spans.
8 const tokens = /%%[^\n]*|"(?:\\[\s\S]?|[^"\\])*(?:"|$)|[[\]()\n;]/g;
9 for (const match of text.matchAll(tokens)) {
10 const c = match[0];
11 if (c === "[" || c === "(") depth++;
12 else if (c === "]" || c === ")") depth = Math.max(0, depth - 1);
13 else if (depth === 0 && (c === "\n" || c === ";")) ends.push(match.index + 1);
14 }
15 for (let i = ends.length - 1; i >= 0; i--) yield text.slice(0, ends[i]);
16}
17
18/**
19 * What a fence still arriving draws: its newest complete statements,
20 * "pending" before any draws, null when it names no diagram.
21 */
22export function drawArriving<T>(
23 source: string,
24 drawSource: (source: string) => T | null,
25): T | "pending" | null {
26 if (diagramKind(source) === null) return null;
27 for (const prefix of streamingPrefixes(source)) {
28 const out = drawSource(prefix);
29 if (out !== null) return out;
30 }
31 return "pending";
32}
33src/loom-mermaid/canvas.ts 481 lines1import type { Role, Span } from './types.ts'
2import { measured } from './width.ts'
3
4/**
5 * Sentinel occupying the trailing column of a wide glyph. Never emitted: the
6 * line builder skips it so a CJK character claims two cells of layout but
7 * contributes one character of output.
8 */
9export const CONT = String.fromCharCode(0)
10
11/** Connection direction bits, combined into a box-drawing glyph by `maskChar`. */
12export const U = 1
13export const D = 2
14export const L = 4
15export const R = 8
16
17/** Line styles, tracked per cell so crossing edges keep their own stroke. */
18export const STY_DOT = 1
19export const STY_THICK = 2
20export const STY_SOLID = 4
21
22/**
23 * A grid of cells. Edges accumulate as direction bits rather than glyphs so
24 * that crossings and junctions resolve correctly whatever order they are drawn
25 * in; `finalizeMask` turns the accumulated bits into characters at the end.
26 *
27 * `occupied` marks cells claimed by a box, which edge bits must not overwrite.
28 *
29 * `pass` remembers how each cell was reached: a vertical run passing
30 * through, a horizontal run passing through, or a turn, end or junction.
31 * A cell crossed by one vertical and one horizontal run and nothing else
32 * is two edges crossing, drawn as a hop (`╫`) rather than a junction
33 * (`┼`), so an edge can be followed through a dense band.
34 */
35const PASS_V = 1
36const PASS_H = 2
37const JOINED = 4
38const HOP = '╫'
39export class Canvas {
40 readonly w: number
41 readonly h: number
42 ch: string[]
43 role: Role[]
44 /** Space-joined author classes per cell, or undefined; see `Span.classes`. */
45 tag: (string | undefined)[]
46 /** Link target per cell, or undefined; see `Span.href`. */
47 href: (string | undefined)[]
48 mask: Uint8Array
49 style: Uint8Array
50 occupied: Uint8Array
51 pass: Uint8Array
52 /** Direction each edge cell's flow travels in (drawing order), so a
53 * junction can point a head at the arm that feeds it. */
54 flow: Uint8Array
55 /** Edge labels queued by the layout, written after every line. */
56 labels: { label: string; row: number; x: number }[] = []
57 curStyle: number = STY_SOLID
58 /** Author classes stamped on cells painted while set, like `curStyle`. */
59 curTag: string | undefined
60 /** Link target stamped on cells painted while set, like `curTag`. */
61 curHref: string | undefined
62
63 constructor(w: number, h: number) {
64 const n = w * h
65 this.w = w
66 this.h = h
67 this.ch = new Array(n).fill(' ')
68 this.role = new Array(n).fill('none')
69 this.tag = new Array(n).fill(undefined)
70 this.href = new Array(n).fill(undefined)
71 this.mask = new Uint8Array(n)
72 this.style = new Uint8Array(n)
73 this.occupied = new Uint8Array(n)
74 this.pass = new Uint8Array(n)
75 this.flow = new Uint8Array(n)
76 }
77
78 idx(x: number, y: number): number {
79 return y * this.w + x
80 }
81
82 set(x: number, y: number, c: string, role: Role): void {
83 if (x >= this.w || y >= this.h) return
84 const i = this.idx(x, y)
85 // A literal tab measures one cell here but jumps to the terminal's tab
86 // stop there, desyncing every column after it (the source box expands
87 // tabs for the same reason). Same width, safe glyph.
88 this.ch[i] = c === '\t' ? ' ' : c
89 this.role[i] = role
90 if (this.curTag !== undefined) this.tag[i] = this.curTag
91 if (this.curHref !== undefined) this.href[i] = this.curHref
92 }
93
94 /**
95 * Accumulate direction bits on a free cell.
96 *
97 * `role` is the role to claim the cell for; `border` cells are never
98 * reclassified, so a connector meeting a box keeps the box's styling.
99 */
100 addBits(x: number, y: number, bits: number, role: Role = 'edge'): void {
101 if (x >= this.w || y >= this.h) return
102 const i = this.idx(x, y)
103 if (this.occupied[i]) return
104 this.mask[i] |= bits
105 this.pass[i] |= bits === (U | D) ? PASS_V : bits === (L | R) ? PASS_H : JOINED
106 this.style[i] |= this.curStyle
107 if (this.role[i] !== 'border') this.role[i] = role
108 if (this.curTag !== undefined) this.tag[i] = this.curTag
109 if (this.curHref !== undefined) this.href[i] = this.curHref
110 }
111
112 /** Stamp a finished sub-canvas (a subgraph frame's contents) at an offset. */
113 blit(sub: Canvas, ox: number, oy: number): void {
114 for (let sy = 0; sy < sub.h; sy++) {
115 for (let sx = 0; sx < sub.w; sx++) {
116 const x = ox + sx
117 const y = oy + sy
118 if (x >= this.w || y >= this.h) continue
119 const si = sub.idx(sx, sy)
120 const di = this.idx(x, y)
121 this.ch[di] = sub.ch[si]
122 this.role[di] = sub.role[si]
123 this.tag[di] = sub.tag[si]
124 this.href[di] = sub.href[si]
125 this.style[di] = sub.style[si]
126 this.pass[di] = sub.pass[si]
127 // Blank padding inside the frame stays free: a cross-frame route
128 // may run a stub through it to the inner node it joins.
129 this.occupied[di] = sub.occupied[si] || sub.ch[si] !== ' ' ? 1 : 0
130 }
131 }
132 }
133
134 /** Add direction bits even to an occupied cell, so an edge can meet a border. */
135 junction(x: number, y: number, bits: number): void {
136 if (x >= this.w || y >= this.h) return
137 const i = this.idx(x, y)
138 // A plain border glyph stamped from a sub-canvas goes back to bits so
139 // the tee resolves with the rest.
140 if (this.ch[i] === '│' || this.ch[i] === '─') {
141 this.mask[i] |= this.ch[i] === '│' ? U | D : L | R
142 this.ch[i] = ' '
143 }
144 this.mask[i] |= bits
145 this.pass[i] |= JOINED
146 if (this.role[i] !== 'border') this.role[i] = 'edge'
147 }
148
149 segV(x: number, y0: number, y1: number): void {
150 const a = Math.min(y0, y1)
151 const b = Math.max(y0, y1)
152 for (let y = a; y <= b; y++) {
153 let bits = 0
154 if (y > a) bits |= U
155 if (y < b) bits |= D
156 this.addBits(x, y, bits)
157 if (y > a && y < b) this.flow[this.idx(x, y)] |= y1 > y0 ? D : U
158 }
159 }
160
161 segH(y: number, x0: number, x1: number): void {
162 const a = Math.min(x0, x1)
163 const b = Math.max(x0, x1)
164 for (let x = a; x <= b; x++) {
165 let bits = 0
166 if (x > a) bits |= L
167 if (x < b) bits |= R
168 this.addBits(x, y, bits)
169 if (x > a && x < b) this.flow[this.idx(x, y)] |= x1 > x0 ? R : L
170 }
171 }
172
173 /** Resolve accumulated direction bits into glyphs, honouring line style. */
174 finalizeMask(): void {
175 for (let i = 0; i < this.ch.length; i++) {
176 if (this.mask[i] === 0) continue
177 if (this.ch[i] === ' ') {
178 const c = this.pass[i] === (PASS_V | PASS_H) ? HOP : maskChar(this.mask[i])
179 this.ch[i] =
180 this.style[i] === STY_DOT ? dottedChar(c) : this.style[i] === STY_THICK ? thickChar(c) : c
181 } else if (this.ch[i] === '═' || this.ch[i] === '║') {
182 this.ch[i] = doubleTee(this.ch[i], this.mask[i])
183 }
184 }
185 }
186
187 /**
188 * Mirror top-to-bottom for `BT`. Rows reorder but within-row text does not,
189 * so labels stay readable; box-drawing glyphs flip to match.
190 */
191 flipVertical(): void {
192 for (let y = 0; y < Math.floor(this.h / 2); y++) {
193 const y2 = this.h - 1 - y
194 for (let x = 0; x < this.w; x++) {
195 const i = this.idx(x, y)
196 const j = this.idx(x, y2)
197 ;[this.ch[i], this.ch[j]] = [this.ch[j], this.ch[i]]
198 ;[this.role[i], this.role[j]] = [this.role[j], this.role[i]]
199 ;[this.tag[i], this.tag[j]] = [this.tag[j], this.tag[i]]
200 ;[this.href[i], this.href[j]] = [this.href[j], this.href[i]]
201 }
202 }
203 for (let i = 0; i < this.ch.length; i++) {
204 if (!textRole(this.role[i])) this.ch[i] = flipGlyphV(this.ch[i])
205 }
206 }
207
208 /**
209 * Mirror left-to-right for `RL`. Mirroring reverses each row, so after
210 * flipping glyphs each text/label run is reversed back to reading order.
211 */
212 flipHorizontal(): void {
213 for (let y = 0; y < this.h; y++) {
214 for (let x = 0; x < Math.floor(this.w / 2); x++) {
215 const x2 = this.w - 1 - x
216 const i = this.idx(x, y)
217 const j = this.idx(x2, y)
218 ;[this.ch[i], this.ch[j]] = [this.ch[j], this.ch[i]]
219 ;[this.role[i], this.role[j]] = [this.role[j], this.role[i]]
220 ;[this.tag[i], this.tag[j]] = [this.tag[j], this.tag[i]]
221 ;[this.href[i], this.href[j]] = [this.href[j], this.href[i]]
222 }
223 }
224 for (let i = 0; i < this.ch.length; i++) {
225 if (!textRole(this.role[i])) this.ch[i] = flipGlyphH(this.ch[i])
226 }
227 for (let y = 0; y < this.h; y++) {
228 let x = 0
229 while (x < this.w) {
230 const role = this.role[this.idx(x, y)]
231 if (role === 'text' || role === 'edgeLabel') {
232 const start = this.idx(x, y)
233 while (x < this.w && this.role[this.idx(x, y)] === role) x++
234 const end = this.idx(x, y)
235 reverseSlice(this.ch, start, end)
236 } else {
237 x++
238 }
239 }
240 }
241 }
242
243 /** Group each row into runs of one role and tag, dropping continuations. */
244 toLines(): { plain: string[]; styled: Span[][]; width: number } {
245 const plain: string[] = []
246 const styled: Span[][] = []
247 let width = 0
248 for (let y = 0; y < this.h; y++) {
249 // A trailing CONT counts as painted: it is the second cell of a wide
250 // glyph, so the row really does reach that column.
251 let last = 0
252 for (let x = this.w - 1; x >= 0; x--) {
253 if (this.ch[this.idx(x, y)] !== ' ') {
254 last = x + 1
255 break
256 }
257 }
258 width = Math.max(width, last)
259 const spans: Span[] = []
260 const push = (text: string, role: Role, tag: string | undefined, href?: string): void => {
261 if (text === '') return
262 const span: Span = { text, role }
263 if (tag !== undefined) span.classes = tag.split(' ')
264 if (href !== undefined) span.href = href
265 spans.push(span)
266 }
267 let plainRow = ''
268 let run = ''
269 let runRole: Role = 'none'
270 let runTag: string | undefined
271 let runHref: string | undefined
272 for (let x = 0; x < last; x++) {
273 const i = this.idx(x, y)
274 const c = this.ch[i]
275 if (c === CONT) continue
276 plainRow += c
277 if (
278 (this.role[i] !== runRole || this.tag[i] !== runTag || this.href[i] !== runHref) &&
279 run !== ''
280 ) {
281 push(run, runRole, runTag, runHref)
282 run = ''
283 }
284 runRole = this.role[i]
285 runTag = this.tag[i]
286 runHref = this.href[i]
287 run += c
288 }
289 push(run, runRole, runTag, runHref)
290 styled.push(spans)
291 // Only ASCII spaces, which is all a blank cell ever holds. Trimming `\s`
292 // would eat a trailing NBSP that `styled` keeps, desyncing the two.
293 // (A ` +$` regex backtracks quadratically on a row of mostly spaces:
294 // it was more than half the render time of a 500-edge diagram.)
295 let cut = plainRow.length
296 while (cut > 0 && plainRow[cut - 1] === ' ') cut--
297 plain.push(plainRow.slice(0, cut))
298 }
299 let first = 0
300 while (first < plain.length && plain[first] === '') first++
301 let end = plain.length
302 while (end > first && plain[end - 1] === '') end--
303 return { plain: plain.slice(first, end), styled: styled.slice(first, end), width }
304 }
305}
306
307function reverseSlice(arr: string[], start: number, end: number): void {
308 for (let i = start, j = end - 1; i < j; i++, j--) {
309 ;[arr[i], arr[j]] = [arr[j], arr[i]]
310 }
311}
312
313/**
314 * Paint `text` at `x, y`, one grapheme cluster per cell.
315 *
316 * A wide cluster claims a second cell, marked with `CONT` so the line builder
317 * emits one character for it rather than a stray space.
318 */
319export function drawText(canvas: Canvas, text: string, x: number, y: number, role: Role): void {
320 let cur = x
321 for (const [cluster, cw] of measured(text)) {
322 if (cw === 0) continue
323 canvas.set(cur, y, cluster, role)
324 for (let k = 1; k < cw; k++) canvas.set(cur + k, y, CONT, role)
325 cur += cw
326 }
327}
328
329/**
330 * Paint `text` at `x, y`, clearing any edge bits underneath first.
331 *
332 * Used where text sits on top of a drawn line (sequence messages, dividers,
333 * compartment rows) and must win over it.
334 */
335export function drawTextOverEdges(
336 canvas: Canvas,
337 text: string,
338 x: number,
339 y: number,
340 role: Role,
341): void {
342 let cur = x
343 for (const [cluster, cw] of measured(text)) {
344 if (cw === 0) continue
345 for (let k = 0; k < cw; k++) {
346 if (cur + k < canvas.w && y < canvas.h) canvas.mask[canvas.idx(cur + k, y)] = 0
347 canvas.set(cur + k, y, k === 0 ? cluster : CONT, role)
348 }
349 cur += cw
350 }
351}
352
353function maskChar(mask: number): string {
354 switch (mask) {
355 case 0:
356 return ' '
357 case U:
358 case D:
359 case U | D:
360 return '│'
361 case L:
362 case R:
363 case L | R:
364 return '─'
365 case D | R:
366 return '┌'
367 case D | L:
368 return '┐'
369 case U | R:
370 return '└'
371 case U | L:
372 return '┘'
373 case U | D | R:
374 return '├'
375 case U | D | L:
376 return '┤'
377 case D | L | R:
378 return '┬'
379 case U | L | R:
380 return '┴'
381 default:
382 return '┼'
383 }
384}
385
386/** An edge teeing into a double-line border: the mixed single/double glyphs. */
387function doubleTee(c: string, mask: number): string {
388 if (c === '═') {
389 if (mask & U && mask & D) return '╪'
390 if (mask & D) return '╤'
391 if (mask & U) return '╧'
392 } else {
393 if (mask & L && mask & R) return '╫'
394 if (mask & R) return '╟'
395 if (mask & L) return '╢'
396 }
397 return c
398}
399
400const DOTTED: Record<string, string> = { '─': '╌', '│': '╎' }
401
402const THICK: Record<string, string> = {
403 '─': '━',
404 '│': '┃',
405 '┌': '┏',
406 '┐': '┓',
407 '└': '┗',
408 '┘': '┛',
409 '├': '┣',
410 '┤': '┫',
411 '┬': '┳',
412 '┴': '┻',
413 '┼': '╋',
414}
415
416const FLIP_V: Record<string, string> = {
417 '╔': '╚',
418 '╚': '╔',
419 '╗': '╝',
420 '╝': '╗',
421 '╤': '╧',
422 '╧': '╤',
423 '┌': '└',
424 '└': '┌',
425 '┐': '┘',
426 '┘': '┐',
427 '┏': '┗',
428 '┗': '┏',
429 '┓': '┛',
430 '┛': '┓',
431 '╭': '╰',
432 '╰': '╭',
433 '╮': '╯',
434 '╯': '╮',
435 '┬': '┴',
436 '┴': '┬',
437 '┳': '┻',
438 '┻': '┳',
439 '▼': '▲',
440 '▲': '▼',
441 '▽': '△',
442 '△': '▽',
443}
444
445const FLIP_H: Record<string, string> = {
446 '╔': '╗',
447 '╗': '╔',
448 '╚': '╝',
449 '╝': '╚',
450 '╟': '╢',
451 '╢': '╟',
452 '┌': '┐',
453 '┐': '┌',
454 '└': '┘',
455 '┘': '└',
456 '┏': '┓',
457 '┓': '┏',
458 '┗': '┛',
459 '┛': '┗',
460 '╭': '╮',
461 '╮': '╭',
462 '╰': '╯',
463 '╯': '╰',
464 '├': '┤',
465 '┤': '├',
466 '┣': '┫',
467 '┫': '┣',
468 '▶': '◄',
469 '◄': '▶',
470 '▷': '◁',
471 '◁': '▷',
472}
473
474const dottedChar = (c: string): string => DOTTED[c] ?? c
475const thickChar = (c: string): string => THICK[c] ?? c
476const flipGlyphV = (c: string): string => FLIP_V[c] ?? c
477const flipGlyphH = (c: string): string => FLIP_H[c] ?? c
478
479/** User-authored cells: flips reorder them but must not remap their glyphs. */
480const textRole = (r: Role): boolean => r === 'text' || r === 'edgeLabel' || r === 'title'
481src/loom-mermaid/labels.ts 361 lines1import { measured, stringWidth } from './width.ts'
2
3/** How much text a diagram shows before wrapping or truncating. */
4export interface Limits {
5 /** Node labels wrap to at most this many display columns per line ... */
6 wrap: number
7 /** ... and at most this many lines; overflow is truncated with an ellipsis. */
8 lines: number
9 /** Edge labels are truncated to this many columns. */
10 label: number
11 /**
12 * Draw each top-level subgraph as one box (its title and member count),
13 * with the edges between subgraphs merged: the overview a wide diagram
14 * falls back to before the source box.
15 */
16 collapse?: boolean
17}
18
19/** The default limits, and the tighter ones `render` falls back through
20 * when a diagram is wider than the space it was given. */
21export const LIMITS: Limits[] = [
22 { wrap: 24, lines: 4, label: 28 },
23 { wrap: 16, lines: 3, label: 16 },
24 { wrap: 12, lines: 2, label: 10 },
25 { wrap: 16, lines: 3, label: 16, collapse: true },
26]
27export const DEFAULT_LIMITS: Limits = LIMITS[0]
28
29/**
30 * Identifier-boundary characters preferred as break points when a single word
31 * is too wide to fit, so it is not sliced mid-segment.
32 *
33 * Mirrors `TOKEN_BREAK_CHARS` in grok-build's
34 * `third_party/mermaid-to-svg/src/text_wrap.rs`; the two renderers are
35 * deliberately independent, so keep these in sync.
36 */
37const LABEL_BREAK_CHARS = ['_', '-', '.', '/']
38
39/**
40 * ASCII-only case folding, matching Rust's `to_ascii_lowercase`.
41 *
42 * `String.prototype.toLowerCase` can change a string's length (`İ` becomes two
43 * code points), which would desync the byte offsets some parsers slice with.
44 */
45export const asciiLower = (s: string): string => s.replace(/[A-Z]/g, (c) => c.toLowerCase())
46export const asciiUpper = (s: string): string => s.replace(/[a-z]/g, (c) => c.toUpperCase())
47
48/**
49 * C0 and C1 controls, less the `\t\n\r` the parsers and `srcLines` read.
50 *
51 * They measure one column and paint none, so a box sized around one is drawn a
52 * column short of its own border; NUL also collides with the `CONT` sentinel
53 * and is dropped after layout has already paid for its cell; ESC would inject
54 * ANSI into the caller's scrollback. `decodeEntityBody` refuses to decode an
55 * entity into one — this closes the same hole for literals.
56 */
57// biome-ignore lint/suspicious/noControlCharactersInRegex: the point is to match them
58const CONTROLS = /[\0-\x08\x0b\x0c\x0e-\x1f\x7f-\x9f]/g
59
60/** Applied by every public entry point that takes untrusted source. */
61export const stripControls = (src: string): string => src.replace(CONTROLS, '')
62
63/**
64 * Split source into lines the way Rust's `str::lines()` does: on `\n`, with a
65 * trailing `\r` stripped, and *without* a final empty line when the input ends
66 * in a newline. `String.split` yields that extra element, which would show up
67 * as a spurious blank row inside a source box.
68 */
69export function srcLines(src: string): string[] {
70 const out = src.split('\n').map((l) => (l.endsWith('\r') ? l.slice(0, -1) : l))
71 if (out.length > 0 && out[out.length - 1] === '') out.pop()
72 return out
73}
74
75const ALNUM = /[\p{Alphabetic}\p{N}]/u
76
77/** Matches Rust's `char::is_alphanumeric`. */
78const isAlphanumeric = (c: string): boolean => ALNUM.test(c)
79
80/** Characters allowed in a bare node/state/class identifier. */
81export const isIdChar = (c: string): boolean => isAlphanumeric(c) || c === '_'
82
83const ENTITY_LOOKAHEAD = 10
84
85const NAMED_ENTITIES: Record<string, string> = {
86 lt: '<',
87 gt: '>',
88 amp: '&',
89 quot: '"',
90 apos: "'",
91}
92
93function decodeEntityBody(body: string): string | null {
94 const named = NAMED_ENTITIES[body]
95 if (named !== undefined) return named
96 if (!body.startsWith('#')) return null
97 const num = body.slice(1)
98 const hex = /^[xX]/.test(num)
99 const digits = hex ? num.slice(1) : num
100 if (!(hex ? /^[0-9a-fA-F]+$/ : /^[0-9]+$/).test(digits)) return null
101 const code = Number.parseInt(digits, hex ? 16 : 10)
102 // Surrogates and out-of-range values are not characters at all.
103 if (code > 0x10ffff || (code >= 0xd800 && code <= 0xdfff)) return null
104 // Reject control chars: NUL collides with the CONT sentinel and ESC would
105 // inject ANSI into scrollback.
106 if (code < 0x20 || (code >= 0x7f && code <= 0x9f)) return null
107 return String.fromCodePoint(code)
108}
109
110/**
111 * Decode HTML entities in label text. Called once per label: via `cleanLabel`
112 * for bracketed labels, or explicitly at each direct-push sink.
113 */
114export function decodeHtmlEntities(s: string): string {
115 if (!s.includes('&')) return s
116 const chars = [...s]
117 let out = ''
118 let i = 0
119 while (i < chars.length) {
120 if (chars[i] !== '&') {
121 out += chars[i]
122 i++
123 continue
124 }
125 // Scan a bounded window including the terminating `;`, so a stray `&` or an
126 // over-long run stays literal.
127 const hi = Math.min(i + 1 + ENTITY_LOOKAHEAD, chars.length)
128 let semi = -1
129 for (let j = i + 1; j < hi; j++) {
130 if (chars[j] === ';') {
131 semi = j
132 break
133 }
134 }
135 const decoded = semi === -1 ? null : decodeEntityBody(chars.slice(i + 1, semi).join(''))
136 if (decoded === null) {
137 out += '&'
138 i++
139 } else {
140 // Resume past the `;`. The single pass never re-scans emitted text, so
141 // `&lt;` decodes to the literal `<` rather than to `<`.
142 out += decoded
143 i = semi + 1
144 }
145 }
146 return out
147}
148
149/** Strip markdown emphasis from a `` `backtick` `` label string. */
150function stripMarkdown(s: string): string {
151 const noCode = [...s].filter((c) => c !== '`').join('')
152 const noStrong = noCode.replaceAll('**', '').replaceAll('__', '')
153 const chars = [...noStrong]
154 let out = ''
155 for (let i = 0; i < chars.length; i++) {
156 const c = chars[i]
157 // Keep `*`/`_` only when they sit inside a word, so snake_case survives.
158 const inWord =
159 i > 0 &&
160 isAlphanumeric(chars[i - 1]) &&
161 chars[i + 1] !== undefined &&
162 isAlphanumeric(chars[i + 1])
163 if ((c === '*' || c === '_') && !inWord) continue
164 out += c
165 }
166 return out.trim()
167}
168
169/**
170 * Inline formatting tags that carry no meaning in a terminal. Anything else
171 * that looks like a tag — `Vec<String>`, `<id>` — is left alone.
172 */
173const HTML_FORMAT_TAGS = new Set([
174 'b',
175 'strong',
176 'i',
177 'em',
178 'u',
179 's',
180 'strike',
181 'del',
182 'ins',
183 'mark',
184 'small',
185 'big',
186 'sub',
187 'sup',
188 'code',
189 'kbd',
190 'samp',
191 'var',
192 'tt',
193 'span',
194 'font',
195 'q',
196 'abbr',
197 'cite',
198 'pre',
199])
200
201/** Read a tag starting at `start`, returning its name and the index after `>`. */
202function htmlTagAt(chars: string[], start: number): { name: string; end: number } | null {
203 let i = start + 1
204 if (chars[i] === '/') i++
205 const nameStart = i
206 while (i < chars.length && /^[0-9A-Za-z]$/.test(chars[i])) i++
207 if (i === nameStart) return null
208 const name = chars.slice(nameStart, i).join('')
209 while (i < chars.length && chars[i] !== '>') {
210 if (chars[i] === '<') return null
211 i++
212 }
213 return chars[i] === '>' ? { name, end: i + 1 } : null
214}
215
216function stripHtmlTags(s: string): string {
217 const chars = [...s]
218 let out = ''
219 let i = 0
220 while (i < chars.length) {
221 if (chars[i] === '<') {
222 const tag = htmlTagAt(chars, i)
223 if (tag) {
224 const lower = tag.name.toLowerCase()
225 if (lower === 'br') {
226 out += ' '
227 i = tag.end
228 continue
229 }
230 if (HTML_FORMAT_TAGS.has(lower)) {
231 i = tag.end
232 continue
233 }
234 }
235 }
236 out += chars[i]
237 i++
238 }
239 return out
240}
241
242/** Strip one matching pair of wrapping delimiters, if present. */
243function unwrap(s: string, open: string, close: string): string | null {
244 return s.length >= open.length + close.length && s.startsWith(open) && s.endsWith(close)
245 ? s.slice(open.length, s.length - close.length)
246 : null
247}
248
249/**
250 * Normalise raw label text: strip markup, unquote, and decode entities.
251 *
252 * Decoding happens after tag-stripping so `<b>` is removed as markup while
253 * `<b>` survives as the literal text `<b>`.
254 */
255export function cleanLabel(raw: string): string {
256 const trimmed = stripHtmlTags(raw.trim()).trim()
257 const unquoted = (unwrap(trimmed, '"', '"') ?? unwrap(trimmed, "'", "'") ?? trimmed).trim()
258 const md = unwrap(unquoted, '`', '`')
259 return decodeHtmlEntities(md === null ? unquoted : stripMarkdown(md.trim()))
260}
261
262/** Mermaid writes generics as `List~T~`; show them as `List<T>`. */
263export function displayGenerics(s: string): string {
264 let out = ''
265 let open = false
266 for (const c of s) {
267 if (c === '~') {
268 out += open ? '>' : '<'
269 open = !open
270 } else {
271 out += c
272 }
273 }
274 return out
275}
276
277/** Index of the last identifier-boundary character, or -1. */
278function lastBreak(s: string): number {
279 let best = -1
280 for (const c of LABEL_BREAK_CHARS) best = Math.max(best, s.lastIndexOf(c))
281 return best
282}
283
284/**
285 * Wrap a label to `width` columns over at most `maxLines` lines, truncating the
286 * last line with an ellipsis if it overflows.
287 *
288 * A word too wide to fit is broken after the last identifier boundary
289 * (`_-./`) that fits, falling back to a per-character break when it has none.
290 */
291export function wrapLabel(label: string, width: number, maxLines: number): string[] {
292 width = Math.max(1, width)
293 const lines: string[] = []
294 let cur = ''
295 let curW = 0
296
297 for (const word of label.split(/\s+/).filter((w) => w !== '')) {
298 const ww = stringWidth(word)
299 if (ww > width) {
300 if (cur !== '') {
301 lines.push(cur)
302 cur = ''
303 }
304 let chunk = ''
305 let chunkW = 0
306 for (const [ch, cw] of measured(word)) {
307 if (chunkW + cw > width && chunk !== '') {
308 const p = lastBreak(chunk)
309 const carry = p === -1 ? '' : chunk.slice(p + 1)
310 lines.push(p === -1 ? chunk : chunk.slice(0, p + 1))
311 chunk = carry
312 chunkW = stringWidth(carry)
313 }
314 chunk += ch
315 chunkW += cw
316 }
317 cur = chunk
318 curW = chunkW
319 } else if (cur === '') {
320 cur = word
321 curW = ww
322 } else if (curW + 1 + ww <= width) {
323 cur += ` ${word}`
324 curW += 1 + ww
325 } else {
326 lines.push(cur)
327 cur = word
328 curW = ww
329 }
330 }
331 if (cur !== '') lines.push(cur)
332 if (lines.length === 0) lines.push('')
333
334 if (lines.length > maxLines) {
335 lines.length = maxLines
336 const target = Math.max(1, width - 1)
337 let s = ''
338 let sw = 0
339 for (const [ch, cw] of measured(lines[lines.length - 1])) {
340 if (sw + cw > target) break
341 s += ch
342 sw += cw
343 }
344 lines[lines.length - 1] = `${s}…`
345 }
346 return lines
347}
348
349/** Truncate to `inner` columns, leaving room for the ellipsis. */
350export function fitLabel(label: string, inner: number): string {
351 if (stringWidth(label) <= inner) return label
352 let out = ''
353 let used = 0
354 for (const [c, cw] of measured(label)) {
355 if (used + cw + 1 > inner) break
356 out += c
357 used += cw
358 }
359 return `${out}…`
360}
361src/loom-mermaid/registry.ts 94 lines1/**
2 * The diagram registry: one entry per supported diagram type.
3 *
4 * `diagramKind` and `render` both resolve through this table, so the header
5 * test each parser gates on and the one `diagramKind` reports are the same
6 * function by construction. Adding a diagram type is one module in
7 * `diagrams/` plus one entry here.
8 */
9
10import type { Canvas } from './canvas.ts'
11import { architecture } from './diagrams/architecture.ts'
12import { classDiagram } from './diagrams/class.ts'
13import { er } from './diagrams/er.ts'
14import { flowchart } from './diagrams/flowchart.ts'
15import { gitgraph } from './diagrams/gitgraph.ts'
16import { mindmap } from './diagrams/mindmap.ts'
17import { pie } from './diagrams/pie.ts'
18import { sequence } from './diagrams/sequence.ts'
19import { state } from './diagrams/state.ts'
20import { timeline } from './diagrams/timeline.ts'
21import { type Limits, stripControls } from './labels.ts'
22import { headerKind, statementsOf } from './statements.ts'
23
24/** A diagram type this renderer draws. */
25export type DiagramKind =
26 | 'architecture'
27 | 'flowchart'
28 | 'state'
29 | 'class'
30 | 'er'
31 | 'sequence'
32 | 'pie'
33 | 'mindmap'
34 | 'timeline'
35 | 'gitgraph'
36
37export interface Diagram {
38 kind: DiagramKind
39 /**
40 * Header keywords declaring this diagram type, lowercased. Matched exactly:
41 * upstream's prefix tests accept junk like `stateDiagramFoo` that mermaid
42 * proper rejects at the grammar stage.
43 */
44 headers: string[]
45 /** Parse and lay out within `limits`; `null` means nothing was drawn. */
46 render(
47 src: string,
48 limits: Limits,
49 ): {
50 canvas: Canvas
51 warnings: string[]
52 classDefs: Record<string, Record<string, string>>
53 } | null
54 /** Optional top-down retry; `null` means this source cannot change direction. */
55 renderDown?: Diagram['render']
56}
57
58const DIAGRAMS: Diagram[] = [
59 architecture,
60 flowchart,
61 state,
62 classDiagram,
63 er,
64 sequence,
65 pie,
66 mindmap,
67 timeline,
68 gitgraph,
69]
70
71/** The registry entry `src`'s header declares, or `null`. */
72export function diagramFor(src: string): Diagram | null {
73 const header = headerKind(statementsOf(src))
74 if (header === null) return null
75 return DIAGRAMS.find((d) => d.headers.includes(header)) ?? null
76}
77
78/**
79 * The kind of diagram `src` declares, or `null` if its header names no type
80 * this renderer draws.
81 *
82 * Reads the header only — it says nothing about whether the body parses. Pair
83 * it with `render` to tell a source this renderer will never draw from one that
84 * is merely malformed:
85 *
86 * ```ts
87 * render(src) === null && diagramKind(src) !== null // syntax error
88 * ```
89 */
90export function diagramKind(src: string): DiagramKind | null {
91 // The same strip `render` applies, so the two entry points agree on any src.
92 return diagramFor(stripControls(src))?.kind ?? null
93}
94src/loom-mermaid/statements.ts 229 lines1/**
2 * The shared statement layer: source text to statements, plus the small
3 * string-reading helpers every grammar leans on.
4 */
5
6import { asciiLower, srcLines } from './labels.ts'
7
8function flushStatement(cur: string, out: string[]): string {
9 const trimmed = cur.trim()
10 if (trimmed !== '') out.push(trimmed)
11 return ''
12}
13
14/**
15 * Split one source line into statements on `;`, stopping at a `%%` comment.
16 *
17 * Quoted spans are opaque, so a label may contain `;` and `%%`.
18 */
19function splitStatements(line: string, out: string[]): void {
20 const chars = [...line]
21 let cur = ''
22 let inQuotes = false
23 for (let i = 0; i < chars.length; i++) {
24 const c = chars[i]
25 if (inQuotes) {
26 if (c === '"') inQuotes = false
27 cur += c
28 } else if (c === '"') {
29 inQuotes = true
30 cur += c
31 } else if (c === '%' && chars[i + 1] === '%') {
32 break
33 } else if (c === ';') {
34 cur = flushStatement(cur, out)
35 } else {
36 cur += c
37 }
38 }
39 flushStatement(cur, out)
40}
41
42/**
43 * Index just past a leading YAML frontmatter block (`---` … `---`), or 0 when
44 * there is none. While the block is still unterminated everything is
45 * frontmatter, so a streamed diagram stays blank until it closes.
46 */
47export function frontmatterEnd(lines: string[]): number {
48 let i = 0
49 while (i < lines.length && lines[i].trim() === '') i++
50 if (lines[i]?.trim() !== '---') return 0
51 i++
52 while (i < lines.length && lines[i].trim() !== '---') i++
53 return i + 1
54}
55
56/**
57 * All statements in a source block, in order. A leading YAML frontmatter
58 * block, part of the mermaid grammar since v10, is skipped.
59 */
60export function statementsOf(src: string): string[] {
61 const lines = srcLines(src)
62 const out: string[] = []
63 for (const line of lines.slice(frontmatterEnd(lines))) splitStatements(line, out)
64 return out
65}
66
67/**
68 * The `title:` of a leading frontmatter block, or null. The one frontmatter
69 * key with terminal meaning — `config` and friends style mermaid's own
70 * renderers and are deliberately ignored.
71 */
72export function frontmatterTitle(src: string): string | null {
73 const lines = srcLines(src)
74 const end = frontmatterEnd(lines)
75 for (const line of lines.slice(0, end)) {
76 const kv = splitOnce(line, ':')
77 // Untrimmed on the left: an indented `title:` is nested under some other
78 // key, not the diagram's.
79 if (kv === null || kv[0].trimEnd() !== 'title') continue
80 const t = kv[1].trim()
81 const quoted =
82 t.length > 1 &&
83 ((t.startsWith('"') && t.endsWith('"')) || (t.startsWith("'") && t.endsWith("'")))
84 const title = (quoted ? t.slice(1, -1) : t).trim()
85 return title === '' ? null : title
86 }
87 return null
88}
89
90/** Per-char flags: 1 where the char lies inside a double-quoted span (quotes included). */
91export function quoteMask(chars: string[]): Uint8Array {
92 const mask = new Uint8Array(chars.length)
93 let inQuotes = false
94 for (let i = 0; i < chars.length; i++) {
95 if (chars[i] === '"') {
96 mask[i] = 1
97 inQuotes = !inQuotes
98 } else if (inQuotes) {
99 mask[i] = 1
100 }
101 }
102 return mask
103}
104
105/**
106 * Split on separator chars sitting outside double quotes and parentheses,
107 * dropping empty segments — the one splitter every grammar shares, so quote
108 * rules cannot drift between them.
109 */
110export function splitTop(s: string, isSep: (c: string) => boolean): string[] {
111 const out: string[] = []
112 let cur = ''
113 let inQuotes = false
114 let depth = 0
115 for (const c of s) {
116 if (c === '"') inQuotes = !inQuotes
117 else if (!inQuotes && c === '(') depth++
118 else if (!inQuotes && c === ')' && depth > 0) depth--
119 if (!inQuotes && depth === 0 && isSep(c)) {
120 if (cur !== '') out.push(cur)
121 cur = ''
122 } else {
123 cur += c
124 }
125 }
126 if (cur !== '') out.push(cur)
127 return out
128}
129
130/**
131 * Split `head : rest` at the first label colon, skipping `:::` tag runs so
132 * `A:::hot : desc` keeps its tag with the id. `null` when there is no colon.
133 */
134export function splitColon(s: string): [string, string] | null {
135 const chars = [...s]
136 for (let i = 0; i < chars.length; i++) {
137 if (chars[i] !== ':') continue
138 let run = i
139 while (run < chars.length && chars[run] === ':') run++
140 if (run - i >= 3) {
141 i = run - 1
142 continue
143 }
144 return [chars.slice(0, i).join(''), chars.slice(i + 1).join('')]
145 }
146 return null
147}
148
149/** Strip trailing `:::name` tags from an id token: `A:::hot` → id `A`, classes `[hot]`. */
150export function takeTags(token: string): { id: string; classes: string[] } {
151 const parts = token.split(':::')
152 if (parts.length === 1 || parts[0] === '') return { id: token, classes: [] }
153 return { id: parts[0], classes: parts.slice(1).filter((c) => c !== '') }
154}
155
156/**
157 * The body of a `class A,B name` statement → `[ids, names]`. The last
158 * whitespace-separated token is the name list, everything before it the ids —
159 * so a space after a comma (`class A, B warn`) still reads as two ids.
160 */
161export function parseClassAssign(rest: string): [string[], string[]] | null {
162 const body = rest.trim()
163 const ws = body.search(/\s\S*$/)
164 if (ws === -1) return null
165 const split = (s: string): string[] =>
166 s
167 .split(',')
168 .map((t) => t.trim())
169 .filter((t) => t !== '')
170 return [split(body.slice(0, ws)), split(body.slice(ws))]
171}
172
173/**
174 * `A "url" [tooltip]` / `A href "url" …` → `[id, url]`. The callback forms
175 * (`call`/`callback`) return null — their quoted string is a tooltip.
176 */
177export function parseHref(rest: string): [string, string] | null {
178 const [id, second] = words(rest)
179 if (id === undefined || second === 'call' || second === 'callback') return null
180 const url = rest.match(/"([^"]+)"/)
181 return url === null ? null : [id, url[1]]
182}
183
184export const firstWord = (s: string): string => s.split(/\s+/).filter((w) => w !== '')[0] ?? ''
185export const words = (s: string): string[] => s.split(/\s+/).filter((w) => w !== '')
186
187/** Split on the first occurrence of `sep`, Rust's `split_once`. */
188export function splitOnce(s: string, sep: string): [string, string] | null {
189 const i = s.indexOf(sep)
190 return i === -1 ? null : [s.slice(0, i), s.slice(i + sep.length)]
191}
192
193export const nonEmpty = (s: string): string | null => (s === '' ? null : s)
194
195/** Diagram kind from the header statement, lowercased. */
196export function headerKind(statements: string[]): string | null {
197 const header = statements[0]
198 if (header === undefined) return null
199 const kind = firstWord(header)
200 return kind === '' ? null : asciiLower(kind)
201}
202
203/**
204 * Parse the body of a `classDef` statement: `name[,name2] k1:v1,k2:v2`.
205 * Values are kept verbatim; malformed pairs are skipped.
206 */
207export function parseClassDef(
208 rest: string,
209): { names: string[]; props: Record<string, string> } | null {
210 const body = rest.trim()
211 const ws = body.search(/\s/)
212 if (ws === -1) return null
213 const names = body
214 .slice(0, ws)
215 .split(',')
216 .map((s) => s.trim())
217 .filter((s) => s !== '')
218 const props: Record<string, string> = {}
219 // splitTop keeps `rgb(255,0,0)` whole; a bare comma still separates pairs.
220 for (const pair of splitTop(body.slice(ws).trim(), (c) => c === ',')) {
221 const kv = splitOnce(pair, ':')
222 if (kv === null) continue
223 const k = kv[0].trim()
224 const v = kv[1].trim()
225 if (k !== '' && v !== '') props[k] = v
226 }
227 return names.length === 0 ? null : { names, props }
228}
229