SLOPSHOPPER

loom-mermaid

Draw Mermaid diagrams in replies as colored character art.

newbandrowsprompt
★ 10v0.10.0MITupdated 2026-10-09Yassimba/loom/plugins/pi-loom-mermaid
A shopper browsing a rack in a slop shop
README

pi-loom-mermaid

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.

Install

Requires Pi and Node.js 22.6 or newer.

  1. Install the package:
   pi install npm:@yassimba/pi-loom-mermaid
  1. Set 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.

  1. Run /reload in Pi.

Claude Code

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.

Usage

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.

Plannotator document export

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.

The same diagram in Pi and GitHub

All three views below use the same Mermaid code.

Pi built-in

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">

pi-loom-mermaid

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 built-in

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 or remove

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.

Contributing

From the Loom repository root, run npm ci, npm run check, and npm run audit before opening a pull request.

License

MIT. Adapted from pi-lovely-mermaid.

Source 36 files
hooks/register.tsx 166 lines
1import 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};
166
src/loom-mermaid/class-style.ts 66 lines
1/**
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}
66
src/loom-mermaid/types.ts 65 lines
1/**
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}
65
src/shared.ts 109 lines
1/** 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}
109
src/loom-mermaid/css-colors.ts 154 lines
1// 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}
154
src/fences.ts 105 lines
1export 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}
105
src/loom-mermaid/index.ts 98 lines
1import 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}
98
src/streaming.ts 33 lines
1import { 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}
33
src/loom-mermaid/canvas.ts 481 lines
1import 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'
481
src/loom-mermaid/labels.ts 361 lines
1import { 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      // `&amp;lt;` decodes to the literal `&lt;` 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 * `&lt;b&gt;` 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}
361
src/loom-mermaid/registry.ts 94 lines
1/**
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}
94
src/loom-mermaid/statements.ts 229 lines
1/**
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