Mermaid diagrams in Claude Code: every ```mermaid block Claude writes is drawn as box art, in colour, right where the fence was in the transcript. Needs…

Mermaid diagrams in Claude Code. Every ``` `mermaid ``` block Claude writes is drawn as box art, in colour, right where the fence was in the transcript. Nothing costs a token: the plugin draws locally, and nothing else appears on screen — no pane, no buttons.
❯ how does a prompt become a reply?
⏺ A prompt flows through understanding and a decision point before a reply.
┌────────┐ ┌────────────┐ ◇────────────◇ ┌───────┐
│ │ │ │ │ │ │ │
│ Prompt ├────►│ Understand ├────►│ Use Tools? ├─Yes►│ Reply │
│ │ │ │ │ │ │ │
└────────┘ └────────────┘ ◇────────────◇ └───────┘
Flowcharts, state, sequence, class and ER diagrams and xy charts are drawn; other kinds (gantt, pie, mindmap, …) keep their fence. A top-down flowchart or state diagram is laid out left to right when that fits and loses nothing (rows are dear in a terminal: 5 rows instead of 30), and a state diagram's [*] start and end, which the renderer draws as empty boxes, are left out.
A mod: a plugin built on Claude Code function hooks, TypeScript that runs inside Claude Code's own process. Early access, so it needs the environment variable below and the API can change between releases.
CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1./mermaid ascii on for one that does not). Nothing draws in claude -p.~/.claude/settings.json: { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }
Or for one session: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.
.claude/settings.json sets the variable for sessions started inside it): git clone https://github.com/galElmalah/claude-mods
cd claude-mods
claude --plugin-dir claude-mermaid
Or install it from the repo's marketplace, claude-mods:
claude plugin marketplace add galElmalah/claude-mods
claude plugin install claude-mermaid@claude-mods
| command | what it does | ||
|---|---|---|---|
/mermaid | shows the current settings | ||
| `/mermaid ascii on\ | off` | plain `+-- | ` art instead of box-drawing glyphs (default off) |
| `/mermaid color on\ | off` | borders cyan, arrows yellow, lines dim (default on) | |
| `/mermaid lr on\ | off` | lay top-down flowcharts and state diagrams out left to right when nothing is lost (default on) | |
/mermaid reset | the defaults again |
A setting redraws the diagrams already on screen. Settings are kept in the plugin's store across sessions.
hooks/register.ts is the hooks module. It hooks ui.render of AssistantMessage to swap each closed mermaid fence for a text fence of its art (ANSI-coloured, so the transcript's code block draws the colours), and command.run for /mermaid.hooks/diagrams.ts is the pure part: finding fences, rendering, choosing the sideways layout (kept only when every word of the top-down render survives, since the renderer can overwrite the label of an edge that runs back the other way), fitting to a width, serializing. bun test covers it.hooks/vendor/mermaid-ascii.js is beautiful-mermaid's ASCII renderer bundled by scripts/build-vendor.mjs (hooks modules run with no Node and cannot import packages). The renderer only emits ANSI, so it is run in truecolor against a theme of sentinel colours and the escapes are decoded back into roles (border, line, arrow, text), then coloured for the terminal's own 16-colour palette so light and dark themes both read.Map maximum size exceeded, which would burn the hook's whole budget. The vendored copy caps the search and falls back to a straight segment, so the same graph draws in ~60 ms. Rendered art is cached per source and glyph set; a hook settles in a few ms after the first draw.npm install # esbuild, beautiful-mermaid, aimock, bun types
npm run build:vendor # rebuilds hooks/vendor/mermaid-ascii.js
npm test # unit tests of hooks/diagrams.ts
npm run test:e2e # the plugin inside a real Claude Code (below)
npm run typecheck # against ../.claude/types (run /plugin-types first)
npm run validate # what the engine sees the module hook and call
Typechecking needs the declarations of your Claude Code build: open a session in the repository root with function hooks on and run /plugin-types, which writes the git-ignored .claude/types/.
tests/e2e drives a real interactive Claude Code in a tmux pane, its replies scripted by aimock (an Anthropic-API mock on a local port, ANTHROPIC_BASE_URL pointed at it), the plugin loaded from the checkout with --plugin-dir. Each test sends a prompt, waits for the screen to show a thing, and asserts on the captured text, with -e where colour matters. It covers every drawn kind, colours, two diagrams in one reply, an undrawn kind, the dense graph that used to hang, a fence inside a list item, the sideways layout, every /mermaid form, and a terminal too narrow for the art. Needs tmux and claude on PATH, and the checkout to be a folder Claude Code trusts; skipped otherwise. About 10 s.
Edits to hooks/ hot-reload into a running --plugin-dir session. Start Claude with --debug-file /tmp/mermaid.log to see what the engine refused.
… and a line saying by how much; make the terminal wider.MIT.
hooks/register.ts 114 lines1import type { Register } from 'claude-code'
2import {
3 ANSI_PALETTE,
4 ansiLineOf,
5 fitLines,
6 inlineTextOf,
7 leftToRightOf,
8 mermaidBlocksOf,
9 pickLayout,
10 renderOf,
11 withoutPseudoStates,
12 type Rendered,
13} from './diagrams.ts'
14
15// Every ```mermaid block Claude writes is drawn as box art where the fence
16// was, in the transcript. /mermaid sets the glyph set, the colours and
17// whether top-down diagrams may be laid out sideways.
18
19const COMMAND = 'mermaid'
20const PREFS_KEY = 'prefs'
21// the transcript's code block has a gutter and margins the art must clear
22const INLINE_MARGIN = 6
23
24type Prefs = { ascii: boolean; color: boolean; lr: boolean }
25const DEFAULT_PREFS: Prefs = { ascii: false, color: true, lr: true }
26
27let prefs: Prefs = DEFAULT_PREFS
28const cache = new Map<string, Rendered>()
29
30const isPrefs = (value: unknown): value is Partial<Prefs> => typeof value === 'object' && value !== null
31
32const rendered = (source: string): Rendered => {
33 const key = `${prefs.ascii ? 'a' : 'u'}:${source}`
34 let out = cache.get(key)
35 if (!out) {
36 out = renderOf(source, prefs.ascii)
37 cache.set(key, out)
38 }
39 return out
40}
41
42const drawn = (source: string, columns: number): Rendered => {
43 const prepared = withoutPseudoStates(source)
44 const base = rendered(prepared)
45 const sideways = prefs.lr ? leftToRightOf(prepared) : null
46 return sideways ? pickLayout(base, rendered(sideways), columns) : base
47}
48
49const onOff = (word: string): boolean | undefined =>
50 word === 'on' || word === 'true' ? true : word === 'off' || word === 'false' ? false : undefined
51
52const status = () => `mermaid: ascii ${prefs.ascii ? 'on' : 'off'} · color ${prefs.color ? 'on' : 'off'} · lr ${prefs.lr ? 'on' : 'off'}`
53
54export const register: Register = on => {
55 on('session.start', async ($, e, next) => {
56 const r = await next(e)
57 const saved = await $.store.get(PREFS_KEY).catch(() => undefined)
58 if (isPrefs(saved)) prefs = { ...DEFAULT_PREFS, ...saved }
59 await $.command
60 .register({
61 name: COMMAND,
62 description: 'Mermaid diagrams drawn in the transcript: ascii|color|lr on|off, reset (claude-mermaid)',
63 argumentHint: '[ascii|color|lr on|off | reset]',
64 immediate: true,
65 })
66 .catch(err => $.ui.log(`mermaid: /${COMMAND} not registered: ${err}`))
67 return r
68 })
69
70 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
71 const blocks = mermaidBlocksOf(e.props.text)
72 if (blocks.length === 0) return next(e)
73 const columns = (e.viewport?.columns ?? 80) - INLINE_MARGIN
74 // colours ride on ANSI escapes in the drawn code block; only the terminal reads them
75 const palette = prefs.color && e.surface === 'terminal' ? ANSI_PALETTE : null
76 const text = inlineTextOf(e.props.text, blocks, block => {
77 const room = columns - block.indent.length
78 const art = drawn(block.source, room)
79 if (!('lines' in art)) return null
80 const fit = fitLines(art.lines, room)
81 const lines = fit.lines.map(line => ansiLineOf(line, palette))
82 if (fit.overflow > 0) lines.push(ansiLineOf([{ text: `… ${fit.overflow} columns cut · widen the terminal`, role: 'line' }], palette))
83 return lines
84 })
85 return next({ ...e, props: { ...e.props, text } })
86 })
87
88 on('command.run', { command: COMMAND }, async ($, e, next) => {
89 const [word = '', value = ''] = e.args.trim().toLowerCase().split(/\s+/)
90 const save = async () => {
91 await $.store.set(PREFS_KEY, prefs).catch(err => $.ui.log(`mermaid: store write failed: ${err}`))
92 cache.clear()
93 $.ui.invalidate('ui.render')
94 }
95 if (word === 'reset') {
96 prefs = DEFAULT_PREFS
97 await save()
98 return { text: status() }
99 }
100 if (word === 'ascii' || word === 'color' || word === 'lr') {
101 const flag = onOff(value)
102 prefs = { ...prefs, [word]: flag ?? !prefs[word] }
103 await save()
104 const why = {
105 ascii: 'plain ASCII art',
106 color: 'borders, lines and arrows coloured',
107 lr: 'top-down flowcharts and state diagrams laid out left to right when nothing is lost',
108 }[word]
109 return { text: `mermaid ${word} ${prefs[word] ? 'on' : 'off'} · ${why}` }
110 }
111 return { text: `${status()} · /mermaid ascii|color|lr on|off · reset` }
112 })
113}
114hooks/diagrams.ts 250 lines1import { renderMermaidAscii } from './vendor/mermaid-ascii.js'
2
3// Pure functions over mermaid blocks: finding them in a message's markdown,
4// drawing them as role-tagged box art, fitting the art to a width, and
5// serializing it for the transcript. No `$`, so bun tests these.
6
7export type MermaidBlock = {
8 /** the whole fence, opening line through closing line, its indentation included */
9 start: number
10 end: number
11 /** what the fence's lines are indented by (a fence inside a list item) */
12 indent: string
13 source: string
14}
15
16/** what a run of characters is, as the renderer knows it */
17export type Role = 'text' | 'border' | 'line' | 'arrow' | 'corner' | 'junction' | 'accent'
18
19export type Segment = { text: string; role: Role | null }
20
21export type Rendered = { lines: Segment[][] } | { error: string }
22
23export type Fitted = { lines: Segment[][]; width: number; overflow: number }
24
25// closing fence must sit alone on its line; an unclosed fence (mid-stream)
26// is not a block yet. The info string is `mermaid` as a whole word, so
27// `mermaidjs` is some other language
28const FENCE = /^([ \t]*)(`{3,}|~{3,})[ \t]*mermaid(?![\w-])[^\n]*\n([\s\S]*?)\n[ \t]*\2[ \t]*$/gim
29
30export const mermaidBlocksOf = (text: string): MermaidBlock[] => {
31 const blocks: MermaidBlock[] = []
32 for (const match of text.matchAll(FENCE)) {
33 const source = (match[3] ?? '').replace(/\r\n?/g, '\n').trim()
34 if (source === '') continue
35 const start = match.index ?? 0
36 blocks.push({ start, end: start + match[0].length, indent: match[1] ?? '', source })
37 }
38 return blocks
39}
40
41const KINDS: [RegExp, string][] = [
42 [/^(flowchart|graph)\b/i, 'flowchart'],
43 [/^sequenceDiagram/i, 'sequence'],
44 [/^classDiagram/i, 'class'],
45 [/^stateDiagram/i, 'state'],
46 [/^erDiagram/i, 'er'],
47 [/^xychart/i, 'chart'],
48 [/^gantt/i, 'gantt'],
49 [/^pie\b/i, 'pie'],
50 [/^mindmap/i, 'mindmap'],
51 [/^gitGraph/i, 'gitgraph'],
52 [/^journey/i, 'journey'],
53 [/^timeline/i, 'timeline'],
54 [/^quadrantChart/i, 'quadrant'],
55 [/^requirementDiagram/i, 'requirement'],
56 [/^C4/, 'c4'],
57 [/^sankey/i, 'sankey'],
58 [/^block/i, 'block'],
59 [/^packet/i, 'packet'],
60 [/^kanban/i, 'kanban'],
61 [/^architecture/i, 'architecture'],
62]
63
64// what the renderer draws; the rest keep their fence
65export const DRAWN_KINDS: ReadonlySet<string> = new Set(['flowchart', 'sequence', 'class', 'state', 'er', 'chart'])
66
67// front matter and %% comments precede the header
68const headerOf = (source: string): string => {
69 const lines = source.split('\n').map(line => line.trim())
70 let i = 0
71 if (lines[0] === '---') {
72 i = lines.indexOf('---', 1) + 1
73 if (i === 0) i = lines.length
74 }
75 return lines.slice(i).find(line => line !== '' && !line.startsWith('%%')) ?? ''
76}
77
78export const kindOf = (source: string): string => {
79 const head = headerOf(source)
80 return KINDS.find(([pattern]) => pattern.test(head))?.[1] ?? 'diagram'
81}
82
83// a source past this is not drawn: the layout is quadratic in places
84export const MAX_SOURCE_CHARS = 12_000
85
86// the renderer draws a state diagram's `[*]` start and end as an empty
87// corner-dotted box; a diagram with other transitions reads better without
88export const withoutPseudoStates = (source: string): string => {
89 if (kindOf(source) !== 'state') return source
90 const kept = source.split('\n').filter(line => !line.includes('[*]'))
91 return kept.some(line => line.includes('-->')) ? kept.join('\n') : source
92}
93
94// Rows are dear in a terminal and columns are cheap: a top-down flowchart or
95// state diagram laid out left to right is a fraction of the height. Null
96// when the source already picks a sideways direction, or the kind ignores one
97export const leftToRightOf = (source: string): string | null => {
98 const kind = kindOf(source)
99 if (kind === 'flowchart') {
100 const header = /^(\s*(?:flowchart|graph))(?:\s+(TD|TB|BT|LR|RL))?\b([^\n]*)$/im.exec(source)
101 if (!header || header[2] === 'LR' || header[2] === 'RL') return null
102 return source.replace(header[0], `${header[1]} LR${header[3]}`)
103 }
104 if (kind === 'state') {
105 if (/^\s*direction\s+/im.test(source)) return null
106 return source.replace(/^([^\n]*stateDiagram[^\n]*)$/im, '$1\n direction LR')
107 }
108 return null
109}
110
111const tokensOf = (lines: readonly (readonly Segment[])[]): Map<string, number> => {
112 const out = new Map<string, number>()
113 for (const line of lines) for (const token of plainOf(line).match(/[\p{L}\p{N}_+#-]+/gu) ?? []) out.set(token, (out.get(token) ?? 0) + 1)
114 return out
115}
116
117// the sideways layout wins when every word of the original survives (the
118// renderer can overwrite the label of an edge that runs back the other way)
119// and it fits, or is at least no wider than the original
120export const pickLayout = (base: Rendered, sideways: Rendered, columns: number): Rendered => {
121 if (!('lines' in base) || !('lines' in sideways)) return base
122 const have = tokensOf(sideways.lines)
123 for (const [token, count] of tokensOf(base.lines)) if ((have.get(token) ?? 0) < count) return base
124 const width = widthOf(sideways.lines)
125 return width <= columns || width <= widthOf(base.lines) ? sideways : base
126}
127
128// The renderer colours by role but only emits ANSI. Rendering in truecolor
129// against a theme of sentinel colours (#00000N, N the role's index) turns
130// each escape back into the role it stood for, with no fork of the renderer.
131const ROLES: readonly Role[] = ['text', 'border', 'line', 'arrow', 'corner', 'junction', 'accent']
132const sentinel = (role: Role) => `#00000${ROLES.indexOf(role) + 1}`
133const ROLE_THEME = {
134 fg: sentinel('text'),
135 border: sentinel('border'),
136 line: sentinel('line'),
137 arrow: sentinel('arrow'),
138 corner: sentinel('corner'),
139 junction: sentinel('junction'),
140 accent: sentinel('accent'),
141 bg: '#000000',
142}
143
144const SGR = /\x1b\[([0-9;]*)m/g
145const SENTINEL = /^38;2;0;0;(\d)$/
146
147const segmentsOf = (line: string): Segment[] => {
148 const out: Segment[] = []
149 const push = (text: string, role: Role | null) => {
150 if (text === '') return
151 const last = out[out.length - 1]
152 if (last && last.role === role) last.text += text
153 else out.push({ text, role })
154 }
155 let role: Role | null = null
156 let cursor = 0
157 for (const match of line.matchAll(SGR)) {
158 const at = match.index ?? 0
159 push(line.slice(cursor, at), role)
160 const params = match[1] ?? ''
161 const found = SENTINEL.exec(params)
162 role = found ? (ROLES[Number(found[1]) - 1] ?? null) : params === '0' || params === '' ? null : role
163 cursor = at + match[0].length
164 }
165 push(line.slice(cursor), role)
166 return out
167}
168
169export const plainOf = (line: readonly Segment[]): string => line.map(s => s.text).join('')
170
171export const renderOf = (source: string, useAscii: boolean): Rendered => {
172 const kind = kindOf(source)
173 if (!DRAWN_KINDS.has(kind)) return { error: `${kind} diagrams are not drawn yet` }
174 if (source.length > MAX_SOURCE_CHARS) return { error: `too big to draw (${source.length} characters)` }
175 try {
176 const art = renderMermaidAscii(source, { useAscii, colorMode: 'truecolor', theme: ROLE_THEME })
177 const lines = art.split('\n').map(line => segmentsOf(line.replace(/ +$/, '')))
178 while (lines.length > 0 && lines[lines.length - 1]?.length === 0) lines.pop()
179 while (lines.length > 0 && lines[0]?.length === 0) lines.shift()
180 return lines.length === 0 ? { error: 'nothing to draw' } : { lines }
181 } catch (err) {
182 return { error: err instanceof Error ? err.message : String(err) }
183 }
184}
185
186export const widthOf = (lines: readonly (readonly Segment[])[]): number =>
187 lines.reduce((max, line) => Math.max(max, [...plainOf(line)].length), 0)
188
189// lines wider than `columns` are cut with an ellipsis; `overflow` says by how much
190export const fitLines = (lines: readonly (readonly Segment[])[], columns: number): Fitted => {
191 const width = widthOf(lines)
192 const room = Math.max(1, columns)
193 if (width <= room) return { lines: lines.map(line => [...line]), width, overflow: 0 }
194 const fitted = lines.map(line => {
195 if ([...plainOf(line)].length <= room) return [...line]
196 const out: Segment[] = []
197 let left = room - 1
198 for (const segment of line) {
199 if (left <= 0) break
200 const chars = [...segment.text]
201 out.push({ text: chars.slice(0, left).join(''), role: segment.role })
202 left -= chars.length
203 }
204 out.push({ text: '…', role: null })
205 return out
206 })
207 return { lines: fitted, width, overflow: width - room }
208}
209
210/** SGR parameters per role: the 16-colour palette, so the terminal's theme decides the shades */
211export const ANSI_PALETTE: Readonly<Record<Role, string>> = {
212 text: '',
213 border: '36',
214 line: '2',
215 arrow: '33',
216 corner: '2',
217 junction: '36',
218 accent: '35',
219}
220
221export const ansiLineOf = (line: readonly Segment[], palette: Readonly<Record<Role, string>> | null): string =>
222 line
223 .map(({ text, role }) => {
224 const code = role && palette ? palette[role] : ''
225 return code ? `\x1b[${code}m${text}\x1b[0m` : text
226 })
227 .join('')
228
229// the message's markdown with each mermaid fence swapped for a text fence of its
230// art, so the transcript draws the diagram in place; a block that fails to
231// render keeps its fence. The art keeps the fence's indentation, so a diagram
232// inside a list item stays in it
233export const inlineTextOf = (
234 text: string,
235 blocks: readonly MermaidBlock[],
236 artOf: (block: MermaidBlock) => string[] | null,
237): string => {
238 let out = ''
239 let cursor = 0
240 for (const block of blocks) {
241 const art = artOf(block)
242 out += text.slice(cursor, block.start)
243 out += art
244 ? [block.indent + '```text', ...art.map(line => block.indent + line), block.indent + '```'].join('\n')
245 : text.slice(block.start, block.end)
246 cursor = block.end
247 }
248 return out + text.slice(cursor)
249}
250hooks/vendor/mermaid-ascii.js 5566 lines1// Generated by scripts/build-vendor.mjs from beautiful-mermaid (MIT), A* bounded. Do not edit.
2
3// node_modules/beautiful-mermaid/src/multiline-utils.ts
4function normalizeBrTags(label) {
5 const unquoted = label.startsWith('"') && label.endsWith('"') ? label.slice(1, -1) : label;
6 return unquoted.replace(/<br\s*\/?>/gi, "\n").replace(/\\n/g, "\n").replace(/<\/?(?:sub|sup|small|mark)\s*>/gi, "").replace(/\*\*(.+?)\*\*/g, "<b>$1</b>").replace(/(?<!\*)\*([^\s*](?:[^*]*[^\s*])?)\*(?!\*)/g, "<i>$1</i>").replace(/~~(.+?)~~/g, "<s>$1</s>");
7}
8
9// node_modules/beautiful-mermaid/src/parser.ts
10function parseMermaid(text) {
11 const lines = text.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("%%"));
12 if (lines.length === 0) {
13 throw new Error("Empty mermaid diagram");
14 }
15 const header = lines[0];
16 if (/^stateDiagram(-v2)?\s*$/i.test(header)) {
17 return parseStateDiagram(lines);
18 }
19 return parseFlowchart(lines);
20}
21function parseFlowchart(lines) {
22 const headerMatch = lines[0].match(/^(?:graph|flowchart)\s+(TD|TB|LR|BT|RL)\s*$/i);
23 if (!headerMatch) {
24 throw new Error(`Invalid mermaid header: "${lines[0]}". Expected "graph TD", "flowchart LR", "stateDiagram-v2", etc.`);
25 }
26 const direction = headerMatch[1].toUpperCase();
27 const graph = {
28 direction,
29 nodes: /* @__PURE__ */ new Map(),
30 edges: [],
31 subgraphs: [],
32 classDefs: /* @__PURE__ */ new Map(),
33 classAssignments: /* @__PURE__ */ new Map(),
34 nodeStyles: /* @__PURE__ */ new Map(),
35 linkStyles: /* @__PURE__ */ new Map()
36 };
37 const subgraphStack = [];
38 for (let i = 1; i < lines.length; i++) {
39 const line = lines[i];
40 const classDefMatch = line.match(/^classDef\s+(\w+)\s+(.+)$/);
41 if (classDefMatch) {
42 const name = classDefMatch[1];
43 const propsStr = classDefMatch[2];
44 const props = parseStyleProps(propsStr);
45 graph.classDefs.set(name, props);
46 continue;
47 }
48 const classAssignMatch = line.match(/^class\s+([\w,-]+)\s+(\w+)$/);
49 if (classAssignMatch) {
50 const nodeIds = classAssignMatch[1].split(",").map((s) => s.trim());
51 const className = classAssignMatch[2];
52 for (const id of nodeIds) {
53 graph.classAssignments.set(id, className);
54 }
55 continue;
56 }
57 const styleMatch = line.match(/^style\s+([\w,-]+)\s+(.+)$/);
58 if (styleMatch) {
59 const nodeIds = styleMatch[1].split(",").map((s) => s.trim());
60 const props = parseStyleProps(styleMatch[2]);
61 for (const id of nodeIds) {
62 graph.nodeStyles.set(id, { ...graph.nodeStyles.get(id), ...props });
63 }
64 continue;
65 }
66 const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/);
67 if (linkStyleMatch) {
68 const target = linkStyleMatch[1].trim();
69 const props = parseStyleProps(linkStyleMatch[2]);
70 if (target === "default") {
71 graph.linkStyles.set("default", { ...graph.linkStyles.get("default"), ...props });
72 } else {
73 const indices = target.split(",").map((s) => parseInt(s.trim(), 10));
74 for (const idx of indices) {
75 if (!isNaN(idx)) {
76 graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props });
77 }
78 }
79 }
80 continue;
81 }
82 const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i);
83 if (dirMatch && subgraphStack.length > 0) {
84 subgraphStack[subgraphStack.length - 1].direction = dirMatch[1].toUpperCase();
85 continue;
86 }
87 const subgraphMatch = line.match(/^subgraph\s+(.+)$/);
88 if (subgraphMatch) {
89 const rest = subgraphMatch[1].trim();
90 const bracketMatch = rest.match(/^([\w-]+)\s*\[(.+)\]$/);
91 let id;
92 let label;
93 if (bracketMatch) {
94 id = bracketMatch[1];
95 label = normalizeBrTags(bracketMatch[2]);
96 } else {
97 label = normalizeBrTags(rest);
98 id = rest.replace(/\s+/g, "_").replace(/[^\w]/g, "");
99 }
100 const sg = { id, label, nodeIds: [], children: [] };
101 subgraphStack.push(sg);
102 continue;
103 }
104 if (line === "end") {
105 const completed = subgraphStack.pop();
106 if (completed) {
107 if (subgraphStack.length > 0) {
108 subgraphStack[subgraphStack.length - 1].children.push(completed);
109 } else {
110 graph.subgraphs.push(completed);
111 }
112 }
113 continue;
114 }
115 parseEdgeLine(line, graph, subgraphStack);
116 }
117 return graph;
118}
119function parseStateDiagram(lines) {
120 const graph = {
121 direction: "TD",
122 nodes: /* @__PURE__ */ new Map(),
123 edges: [],
124 subgraphs: [],
125 classDefs: /* @__PURE__ */ new Map(),
126 classAssignments: /* @__PURE__ */ new Map(),
127 nodeStyles: /* @__PURE__ */ new Map(),
128 linkStyles: /* @__PURE__ */ new Map()
129 };
130 const compositeStack = [];
131 const compositeStateIds = /* @__PURE__ */ new Set();
132 let startCount = 0;
133 let endCount = 0;
134 for (let i = 1; i < lines.length; i++) {
135 const line = lines[i];
136 const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i);
137 if (dirMatch) {
138 if (compositeStack.length > 0) {
139 compositeStack[compositeStack.length - 1].direction = dirMatch[1].toUpperCase();
140 } else {
141 graph.direction = dirMatch[1].toUpperCase();
142 }
143 continue;
144 }
145 const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/);
146 if (linkStyleMatch) {
147 const target = linkStyleMatch[1].trim();
148 const props = parseStyleProps(linkStyleMatch[2]);
149 if (target === "default") {
150 graph.linkStyles.set("default", { ...graph.linkStyles.get("default"), ...props });
151 } else {
152 const indices = target.split(",").map((s) => parseInt(s.trim(), 10));
153 for (const idx of indices) {
154 if (!isNaN(idx)) {
155 graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props });
156 }
157 }
158 }
159 continue;
160 }
161 const compositeMatch = line.match(/^state\s+(?:"([^"]+)"\s+as\s+)?([\w\p{L}]+)\s*\{$/u);
162 if (compositeMatch) {
163 const label = compositeMatch[1] ?? compositeMatch[2];
164 const id = compositeMatch[2];
165 const sg = { id, label, nodeIds: [], children: [] };
166 compositeStack.push(sg);
167 compositeStateIds.add(id);
168 graph.nodes.delete(id);
169 continue;
170 }
171 if (line === "}") {
172 const completed = compositeStack.pop();
173 if (completed) {
174 if (compositeStack.length > 0) {
175 compositeStack[compositeStack.length - 1].children.push(completed);
176 } else {
177 graph.subgraphs.push(completed);
178 }
179 }
180 continue;
181 }
182 const stateAliasMatch = line.match(/^state\s+"([^"]+)"\s+as\s+([\w\p{L}]+)\s*$/u);
183 if (stateAliasMatch) {
184 const label = normalizeBrTags(stateAliasMatch[1]);
185 const id = stateAliasMatch[2];
186 registerStateNode(graph, compositeStack, { id, label, shape: "rounded" });
187 continue;
188 }
189 const transitionMatch = line.match(/^(\[\*\]|[\w\p{L}-]+)\s*(-->)\s*(\[\*\]|[\w\p{L}-]+)(?:\s*:\s*(.+))?$/u);
190 if (transitionMatch) {
191 let sourceId = transitionMatch[1];
192 let targetId = transitionMatch[3];
193 const rawTransitionLabel = transitionMatch[4]?.trim();
194 const edgeLabel = rawTransitionLabel ? normalizeBrTags(rawTransitionLabel) : void 0;
195 if (sourceId === "[*]") {
196 startCount++;
197 sourceId = `_start${startCount > 1 ? startCount : ""}`;
198 registerStateNode(graph, compositeStack, { id: sourceId, label: "", shape: "state-start" });
199 } else if (!compositeStateIds.has(sourceId)) {
200 ensureStateNode(graph, compositeStack, sourceId);
201 }
202 if (targetId === "[*]") {
203 endCount++;
204 targetId = `_end${endCount > 1 ? endCount : ""}`;
205 registerStateNode(graph, compositeStack, { id: targetId, label: "", shape: "state-end" });
206 } else if (!compositeStateIds.has(targetId)) {
207 ensureStateNode(graph, compositeStack, targetId);
208 }
209 graph.edges.push({
210 source: sourceId,
211 target: targetId,
212 label: edgeLabel,
213 style: "solid",
214 hasArrowStart: false,
215 hasArrowEnd: true
216 });
217 continue;
218 }
219 const stateDescMatch = line.match(/^([\w\p{L}-]+)\s*:\s*(.+)$/u);
220 if (stateDescMatch) {
221 const id = stateDescMatch[1];
222 const label = normalizeBrTags(stateDescMatch[2].trim());
223 registerStateNode(graph, compositeStack, { id, label, shape: "rounded" });
224 continue;
225 }
226 }
227 return graph;
228}
229function registerStateNode(graph, compositeStack, node) {
230 const isNew = !graph.nodes.has(node.id);
231 if (isNew) {
232 graph.nodes.set(node.id, node);
233 }
234 if (compositeStack.length > 0) {
235 const current = compositeStack[compositeStack.length - 1];
236 if (!current.nodeIds.includes(node.id)) {
237 current.nodeIds.push(node.id);
238 }
239 }
240}
241function ensureStateNode(graph, compositeStack, id) {
242 if (!graph.nodes.has(id)) {
243 registerStateNode(graph, compositeStack, { id, label: id, shape: "rounded" });
244 } else {
245 if (compositeStack.length > 0) {
246 const current = compositeStack[compositeStack.length - 1];
247 if (!current.nodeIds.includes(id)) {
248 current.nodeIds.push(id);
249 }
250 }
251 }
252}
253function parseStyleProps(propsStr) {
254 const cleaned = propsStr.replace(/;\s*$/, "");
255 const props = {};
256 for (const pair of cleaned.split(",")) {
257 const colonIdx = pair.indexOf(":");
258 if (colonIdx > 0) {
259 const key = pair.slice(0, colonIdx).trim();
260 const val = pair.slice(colonIdx + 1).trim();
261 if (key && val) {
262 props[key] = val;
263 }
264 }
265 }
266 return props;
267}
268var ARROW_REGEX = /^(<)?(-->|-.->|==>|---|-\.-|===)(?:\|([^|]*)\|)?/;
269var TEXT_ARROW_REGEX = /^(<)?(--|-\.|==)\s+(.+?)\s+(-->|---|\.\->|-\.\-|==>|===)/;
270var NODE_PATTERNS = [
271 // Triple delimiters (must be first)
272 { regex: /^([\w-]+)\(\(\((.+?)\)\)\)/, shape: "doublecircle" },
273 // A(((text)))
274 // Double delimiters with mixed brackets
275 { regex: /^([\w-]+)\(\[(.+?)\]\)/, shape: "stadium" },
276 // A([text])
277 { regex: /^([\w-]+)\(\((.+?)\)\)/, shape: "circle" },
278 // A((text))
279 { regex: /^([\w-]+)\[\[(.+?)\]\]/, shape: "subroutine" },
280 // A[[text]]
281 { regex: /^([\w-]+)\[\((.+?)\)\]/, shape: "cylinder" },
282 // A[(text)]
283 // Trapezoid variants — must come before plain [text]
284 { regex: /^([\w-]+)\[\/(.+?)\\\]/, shape: "trapezoid" },
285 // A[/text\]
286 { regex: /^([\w-]+)\[\\(.+?)\/\]/, shape: "trapezoid-alt" },
287 // A[\text/]
288 // Asymmetric flag shape
289 { regex: /^([\w-]+)>(.+?)\]/, shape: "asymmetric" },
290 // A>text]
291 // Double curly braces (hexagon) — must come before single {text}
292 { regex: /^([\w-]+)\{\{(.+?)\}\}/, shape: "hexagon" },
293 // A{{text}}
294 // Single-char delimiters (last — most common, least specific)
295 { regex: /^([\w-]+)\[(.+?)\]/, shape: "rectangle" },
296 // A[text]
297 { regex: /^([\w-]+)\((.+?)\)/, shape: "rounded" },
298 // A(text)
299 { regex: /^([\w-]+)\{(.+?)\}/, shape: "diamond" }
300 // A{text}
301];
302var BARE_NODE_REGEX = /^([\w-]+)/;
303var CLASS_SHORTHAND_REGEX = /^:::([\w][\w-]*)/;
304function parseEdgeLine(line, graph, subgraphStack) {
305 let remaining = line.trim();
306 const firstGroup = consumeNodeGroup(remaining, graph, subgraphStack);
307 if (!firstGroup || firstGroup.ids.length === 0) return;
308 remaining = firstGroup.remaining.trim();
309 let prevGroupIds = firstGroup.ids;
310 while (remaining.length > 0) {
311 let hasArrowStart;
312 let style;
313 let hasArrowEnd;
314 let edgeLabel;
315 const arrowMatch = remaining.match(ARROW_REGEX);
316 if (arrowMatch) {
317 hasArrowStart = Boolean(arrowMatch[1]);
318 const arrowOp = arrowMatch[2];
319 const rawEdgeLabel = arrowMatch[3]?.trim();
320 edgeLabel = rawEdgeLabel ? normalizeBrTags(rawEdgeLabel) : void 0;
321 remaining = remaining.slice(arrowMatch[0].length).trim();
322 style = arrowStyleFromOp(arrowOp);
323 hasArrowEnd = arrowOp.endsWith(">");
324 } else {
325 const textMatch = remaining.match(TEXT_ARROW_REGEX);
326 if (!textMatch) break;
327 hasArrowStart = Boolean(textMatch[1]);
328 const rawLabel = textMatch[3].trim();
329 edgeLabel = rawLabel ? normalizeBrTags(rawLabel) : void 0;
330 const openOp = textMatch[2];
331 const closeOp = textMatch[4];
332 remaining = remaining.slice(textMatch[0].length).trim();
333 style = textArrowStyleFromOps(openOp, closeOp);
334 hasArrowEnd = closeOp.endsWith(">");
335 }
336 const nextGroup = consumeNodeGroup(remaining, graph, subgraphStack);
337 if (!nextGroup || nextGroup.ids.length === 0) break;
338 remaining = nextGroup.remaining.trim();
339 for (const sourceId of prevGroupIds) {
340 for (const targetId of nextGroup.ids) {
341 graph.edges.push({
342 source: sourceId,
343 target: targetId,
344 label: edgeLabel,
345 style,
346 hasArrowStart,
347 hasArrowEnd
348 });
349 }
350 }
351 prevGroupIds = nextGroup.ids;
352 }
353}
354function consumeNodeGroup(text, graph, subgraphStack) {
355 const first = consumeNode(text, graph, subgraphStack);
356 if (!first) return null;
357 const ids = [first.id];
358 let remaining = first.remaining.trim();
359 while (remaining.startsWith("&")) {
360 remaining = remaining.slice(1).trim();
361 const next = consumeNode(remaining, graph, subgraphStack);
362 if (!next) break;
363 ids.push(next.id);
364 remaining = next.remaining.trim();
365 }
366 return { ids, remaining };
367}
368function consumeNode(text, graph, subgraphStack) {
369 let id = null;
370 let remaining = text;
371 for (const { regex, shape } of NODE_PATTERNS) {
372 const match = text.match(regex);
373 if (match) {
374 id = match[1];
375 const label = normalizeBrTags(match[2]);
376 registerNode(graph, subgraphStack, { id, label, shape });
377 remaining = text.slice(match[0].length);
378 break;
379 }
380 }
381 if (id === null) {
382 const bareMatch = text.match(BARE_NODE_REGEX);
383 if (bareMatch) {
384 id = bareMatch[1];
385 if (!graph.nodes.has(id)) {
386 registerNode(graph, subgraphStack, { id, label: id, shape: "rectangle" });
387 }
388 remaining = text.slice(bareMatch[0].length);
389 }
390 }
391 if (id === null) return null;
392 const classMatch = remaining.match(CLASS_SHORTHAND_REGEX);
393 if (classMatch) {
394 graph.classAssignments.set(id, classMatch[1]);
395 remaining = remaining.slice(classMatch[0].length);
396 }
397 return { id, remaining };
398}
399function registerNode(graph, subgraphStack, node) {
400 const isNew = !graph.nodes.has(node.id);
401 if (isNew) {
402 graph.nodes.set(node.id, node);
403 }
404 trackInSubgraph(subgraphStack, node.id);
405}
406function trackInSubgraph(subgraphStack, nodeId) {
407 if (subgraphStack.length > 0) {
408 const current = subgraphStack[subgraphStack.length - 1];
409 if (!current.nodeIds.includes(nodeId)) {
410 current.nodeIds.push(nodeId);
411 }
412 }
413}
414function arrowStyleFromOp(op) {
415 if (op === "-.->") return "dotted";
416 if (op === "-.-") return "dotted";
417 if (op === "==>") return "thick";
418 if (op === "===") return "thick";
419 return "solid";
420}
421function textArrowStyleFromOps(openOp, closeOp) {
422 if (openOp === "-." || closeOp === ".->" || closeOp === "-.-") return "dotted";
423 if (openOp === "==" || closeOp === "==>" || closeOp === "===") return "thick";
424 return "solid";
425}
426
427// node_modules/beautiful-mermaid/src/ascii/types.ts
428var Up = { x: 1, y: 0 };
429var Down = { x: 1, y: 2 };
430var Left = { x: 0, y: 1 };
431var Right = { x: 2, y: 1 };
432var UpperRight = { x: 2, y: 0 };
433var UpperLeft = { x: 0, y: 0 };
434var LowerRight = { x: 2, y: 2 };
435var LowerLeft = { x: 0, y: 2 };
436var Middle = { x: 1, y: 1 };
437function gridCoordEquals(a, b) {
438 return a.x === b.x && a.y === b.y;
439}
440function drawingCoordEquals(a, b) {
441 return a.x === b.x && a.y === b.y;
442}
443function gridCoordDirection(c, dir) {
444 return { x: c.x + dir.x, y: c.y + dir.y };
445}
446function gridKey(c) {
447 return `${c.x},${c.y}`;
448}
449var EMPTY_STYLE = { name: "", styles: {} };
450
451// node_modules/beautiful-mermaid/src/ascii/ansi.ts
452var DEFAULT_ASCII_THEME = {
453 fg: "#27272a",
454 // zinc-800 — primary text
455 border: "#a1a1aa",
456 // zinc-400 — node borders (12% mix)
457 line: "#71717a",
458 // zinc-500 — edge lines (35% mix)
459 arrow: "#52525b",
460 // zinc-600 — arrowheads (60% mix)
461 corner: "#71717a",
462 // same as line
463 junction: "#a1a1aa"
464 // same as border
465};
466function detectColorMode() {
467 const proc = globalThis.process;
468 if (proc) {
469 if (!proc.stdout?.isTTY) {
470 return "none";
471 }
472 const colorTerm = proc.env?.COLORTERM?.toLowerCase() ?? "";
473 const term = proc.env?.TERM?.toLowerCase() ?? "";
474 if (colorTerm === "truecolor" || colorTerm === "24bit") {
475 return "truecolor";
476 }
477 if (term.includes("256color") || term.includes("256")) {
478 return "ansi256";
479 }
480 if (term && term !== "dumb") {
481 return "ansi16";
482 }
483 return "none";
484 }
485 if (typeof document !== "undefined") {
486 return "html";
487 }
488 return "none";
489}
490function parseHex(hex) {
491 const h = hex.replace("#", "");
492 if (h.length === 3) {
493 return {
494 r: parseInt(h[0] + h[0], 16),
495 g: parseInt(h[1] + h[1], 16),
496 b: parseInt(h[2] + h[2], 16)
497 };
498 }
499 return {
500 r: parseInt(h.substring(0, 2), 16),
501 g: parseInt(h.substring(2, 4), 16),
502 b: parseInt(h.substring(4, 6), 16)
503 };
504}
505var ESC = "\x1B[";
506var RESET = `${ESC}0m`;
507function truecolorFg(hex) {
508 const { r, g, b } = parseHex(hex);
509 return `${ESC}38;2;${r};${g};${b}m`;
510}
511function rgbTo256(r, g, b) {
512 const avg = (r + g + b) / 3;
513 const maxDiff = Math.max(Math.abs(r - avg), Math.abs(g - avg), Math.abs(b - avg));
514 if (maxDiff < 10) {
515 const gray = Math.round(avg / 255 * 23);
516 return 232 + Math.min(23, Math.max(0, gray));
517 }
518 const toIndex = (v) => {
519 if (v < 48) return 0;
520 if (v < 115) return 1;
521 return Math.min(5, Math.floor((v - 35) / 40));
522 };
523 const ri = toIndex(r);
524 const gi = toIndex(g);
525 const bi = toIndex(b);
526 return 16 + 36 * ri + 6 * gi + bi;
527}
528function ansi256Fg(hex) {
529 const { r, g, b } = parseHex(hex);
530 const index = rgbTo256(r, g, b);
531 return `${ESC}38;5;${index}m`;
532}
533function ansi16Fg(hex) {
534 const { r, g, b } = parseHex(hex);
535 const luma = 0.299 * r + 0.587 * g + 0.114 * b;
536 const bright = luma > 100 ? 0 : 60;
537 let code;
538 if (r > 180 && g < 100 && b < 100) code = 31;
539 else if (g > 180 && r < 100 && b < 100) code = 32;
540 else if (r > 150 && g > 150 && b < 100) code = 33;
541 else if (b > 180 && r < 100 && g < 100) code = 34;
542 else if (r > 150 && b > 150 && g < 100) code = 35;
543 else if (g > 150 && b > 150 && r < 100) code = 36;
544 else if (luma > 200) code = 37;
545 else if (luma < 50) code = 30;
546 else code = 37;
547 return `${ESC}${code + bright}m`;
548}
549function escapeHtml(text) {
550 return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
551}
552function htmlSpan(hex, text) {
553 return `<span style="color:${hex}">${escapeHtml(text)}</span>`;
554}
555function getRoleColor(role, theme) {
556 switch (role) {
557 case "text":
558 return theme.fg;
559 case "border":
560 return theme.border;
561 case "line":
562 return theme.line;
563 case "arrow":
564 return theme.arrow;
565 case "corner":
566 return theme.corner ?? theme.line;
567 case "junction":
568 return theme.junction ?? theme.border;
569 default:
570 return theme.fg;
571 }
572}
573function getAnsiColor(role, theme, mode) {
574 if (mode === "none") return "";
575 const hex = getRoleColor(role, theme);
576 switch (mode) {
577 case "truecolor":
578 return truecolorFg(hex);
579 case "ansi256":
580 return ansi256Fg(hex);
581 case "ansi16":
582 return ansi16Fg(hex);
583 default:
584 return "";
585 }
586}
587function colorizeLine(chars, roles, theme, mode) {
588 if (mode === "none") {
589 return chars.join("");
590 }
591 if (mode === "html") {
592 return colorizeLineHtml(chars, roles, theme);
593 }
594 let result = "";
595 let currentRole = null;
596 let buffer = "";
597 for (let i = 0; i < chars.length; i++) {
598 const char = chars[i];
599 const role = roles[i] ?? null;
600 if (char === " ") {
601 if (buffer.length > 0) {
602 if (currentRole !== null) {
603 result += getAnsiColor(currentRole, theme, mode) + buffer + RESET;
604 } else {
605 result += buffer;
606 }
607 buffer = "";
608 currentRole = null;
609 }
610 result += char;
611 continue;
612 }
613 if (role === currentRole) {
614 buffer += char;
615 continue;
616 }
617 if (buffer.length > 0) {
618 if (currentRole !== null) {
619 result += getAnsiColor(currentRole, theme, mode) + buffer + RESET;
620 } else {
621 result += buffer;
622 }
623 }
624 buffer = char;
625 currentRole = role;
626 }
627 if (buffer.length > 0 && currentRole !== null) {
628 result += getAnsiColor(currentRole, theme, mode) + buffer + RESET;
629 } else if (buffer.length > 0) {
630 result += buffer;
631 }
632 return result;
633}
634function colorizeLineHtml(chars, roles, theme) {
635 let result = "";
636 let currentRole = null;
637 let buffer = "";
638 const flush = () => {
639 if (buffer.length === 0) return;
640 if (currentRole !== null) {
641 result += htmlSpan(getRoleColor(currentRole, theme), buffer);
642 } else {
643 result += escapeHtml(buffer);
644 }
645 buffer = "";
646 currentRole = null;
647 };
648 for (let i = 0; i < chars.length; i++) {
649 const char = chars[i];
650 const role = roles[i] ?? null;
651 if (char === " ") {
652 flush();
653 result += " ";
654 continue;
655 }
656 if (role === currentRole) {
657 buffer += char;
658 continue;
659 }
660 flush();
661 buffer = char;
662 currentRole = role;
663 }
664 flush();
665 return result;
666}
667function colorizeText(text, hex, mode) {
668 if (mode === "none" || text.length === 0) return text;
669 if (mode === "html") return htmlSpan(hex, text);
670 let code;
671 switch (mode) {
672 case "truecolor":
673 code = truecolorFg(hex);
674 break;
675 case "ansi256":
676 code = ansi256Fg(hex);
677 break;
678 case "ansi16":
679 code = ansi16Fg(hex);
680 break;
681 default:
682 return text;
683 }
684 return `${code}${text}${RESET}`;
685}
686
687// node_modules/beautiful-mermaid/src/ascii/canvas.ts
688function mkCanvas(x, y) {
689 const canvas = [];
690 for (let i = 0; i <= x; i++) {
691 const col = [];
692 for (let j = 0; j <= y; j++) {
693 col.push(" ");
694 }
695 canvas.push(col);
696 }
697 return canvas;
698}
699function copyCanvas(source) {
700 const [maxX, maxY] = getCanvasSize(source);
701 return mkCanvas(maxX, maxY);
702}
703function mkRoleCanvas(x, y) {
704 const roleCanvas = [];
705 for (let i = 0; i <= x; i++) {
706 const col = [];
707 for (let j = 0; j <= y; j++) {
708 col.push(null);
709 }
710 roleCanvas.push(col);
711 }
712 return roleCanvas;
713}
714function increaseRoleCanvasSize(roleCanvas, newX, newY) {
715 const currX = roleCanvas.length - 1;
716 const currY = (roleCanvas[0]?.length ?? 1) - 1;
717 const targetX = Math.max(newX, currX);
718 const targetY = Math.max(newY, currY);
719 const grown = mkRoleCanvas(targetX, targetY);
720 for (let x = 0; x < grown.length; x++) {
721 for (let y = 0; y < grown[0].length; y++) {
722 if (x < roleCanvas.length && y < roleCanvas[0].length) {
723 grown[x][y] = roleCanvas[x][y];
724 }
725 }
726 }
727 roleCanvas.length = 0;
728 roleCanvas.push(...grown);
729 return roleCanvas;
730}
731function setRole(roleCanvas, x, y, role) {
732 if (x >= roleCanvas.length || y >= (roleCanvas[0]?.length ?? 0)) {
733 increaseRoleCanvasSize(roleCanvas, x, y);
734 }
735 roleCanvas[x][y] = role;
736}
737function getCanvasSize(canvas) {
738 return [canvas.length - 1, (canvas[0]?.length ?? 1) - 1];
739}
740function increaseSize(canvas, newX, newY) {
741 const [currX, currY] = getCanvasSize(canvas);
742 const targetX = Math.max(newX, currX);
743 const targetY = Math.max(newY, currY);
744 const grown = mkCanvas(targetX, targetY);
745 for (let x = 0; x < grown.length; x++) {
746 for (let y = 0; y < grown[0].length; y++) {
747 if (x < canvas.length && y < canvas[0].length) {
748 grown[x][y] = canvas[x][y];
749 }
750 }
751 }
752 canvas.length = 0;
753 canvas.push(...grown);
754 return canvas;
755}
756var JUNCTION_CHARS = /* @__PURE__ */ new Set([
757 "\u2500",
758 "\u2502",
759 "\u250C",
760 "\u2510",
761 "\u2514",
762 "\u2518",
763 "\u251C",
764 "\u2524",
765 "\u252C",
766 "\u2534",
767 "\u253C",
768 "\u2574",
769 "\u2575",
770 "\u2576",
771 "\u2577"
772]);
773function isJunctionChar(c) {
774 return JUNCTION_CHARS.has(c);
775}
776function isAlphanumeric(c) {
777 return /^[a-zA-Z0-9]$/.test(c);
778}
779var JUNCTION_MAP = {
780 "\u2500": { "\u2502": "\u253C", "\u250C": "\u252C", "\u2510": "\u252C", "\u2514": "\u2534", "\u2518": "\u2534", "\u251C": "\u253C", "\u2524": "\u253C", "\u252C": "\u252C", "\u2534": "\u2534" },
781 "\u2502": { "\u2500": "\u253C", "\u250C": "\u251C", "\u2510": "\u2524", "\u2514": "\u251C", "\u2518": "\u2524", "\u251C": "\u251C", "\u2524": "\u2524", "\u252C": "\u253C", "\u2534": "\u253C" },
782 "\u250C": { "\u2500": "\u252C", "\u2502": "\u251C", "\u2510": "\u252C", "\u2514": "\u251C", "\u2518": "\u253C", "\u251C": "\u251C", "\u2524": "\u253C", "\u252C": "\u252C", "\u2534": "\u253C" },
783 "\u2510": { "\u2500": "\u252C", "\u2502": "\u2524", "\u250C": "\u252C", "\u2514": "\u253C", "\u2518": "\u2524", "\u251C": "\u253C", "\u2524": "\u2524", "\u252C": "\u252C", "\u2534": "\u253C" },
784 "\u2514": { "\u2500": "\u2534", "\u2502": "\u251C", "\u250C": "\u251C", "\u2510": "\u253C", "\u2518": "\u2534", "\u251C": "\u251C", "\u2524": "\u253C", "\u252C": "\u253C", "\u2534": "\u2534" },
785 "\u2518": { "\u2500": "\u2534", "\u2502": "\u2524", "\u250C": "\u253C", "\u2510": "\u2524", "\u2514": "\u2534", "\u251C": "\u253C", "\u2524": "\u2524", "\u252C": "\u253C", "\u2534": "\u2534" },
786 "\u251C": { "\u2500": "\u253C", "\u2502": "\u251C", "\u250C": "\u251C", "\u2510": "\u253C", "\u2514": "\u251C", "\u2518": "\u253C", "\u2524": "\u253C", "\u252C": "\u253C", "\u2534": "\u253C" },
787 "\u2524": { "\u2500": "\u253C", "\u2502": "\u2524", "\u250C": "\u253C", "\u2510": "\u2524", "\u2514": "\u253C", "\u2518": "\u2524", "\u251C": "\u253C", "\u252C": "\u253C", "\u2534": "\u253C" },
788 "\u252C": { "\u2500": "\u252C", "\u2502": "\u253C", "\u250C": "\u252C", "\u2510": "\u252C", "\u2514": "\u253C", "\u2518": "\u253C", "\u251C": "\u253C", "\u2524": "\u253C", "\u2534": "\u253C" },
789 "\u2534": { "\u2500": "\u2534", "\u2502": "\u253C", "\u250C": "\u253C", "\u2510": "\u253C", "\u2514": "\u2534", "\u2518": "\u2534", "\u251C": "\u253C", "\u2524": "\u253C", "\u252C": "\u253C" }
790};
791function mergeJunctions(c1, c2) {
792 return JUNCTION_MAP[c1]?.[c2] ?? c1;
793}
794function mergeCanvases(base, offset, useAscii, ...overlays) {
795 let [maxX, maxY] = getCanvasSize(base);
796 for (const overlay of overlays) {
797 const [oX, oY] = getCanvasSize(overlay);
798 maxX = Math.max(maxX, oX + offset.x);
799 maxY = Math.max(maxY, oY + offset.y);
800 }
801 const merged = mkCanvas(maxX, maxY);
802 for (let x = 0; x <= maxX; x++) {
803 for (let y = 0; y <= maxY; y++) {
804 if (x < base.length && y < base[0].length) {
805 merged[x][y] = base[x][y];
806 }
807 }
808 }
809 for (const overlay of overlays) {
810 for (let x = 0; x < overlay.length; x++) {
811 for (let y = 0; y < overlay[0].length; y++) {
812 const c = overlay[x][y];
813 if (c !== " ") {
814 const mx = x + offset.x;
815 const my = y + offset.y;
816 const current = merged[mx][my];
817 if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) {
818 merged[mx][my] = mergeJunctions(current, c);
819 } else if (isAlphanumeric(current) && isAlphanumeric(c)) {
820 } else {
821 merged[mx][my] = c;
822 }
823 }
824 }
825 }
826 }
827 return merged;
828}
829function canvasToString(canvas, options) {
830 const [maxX, maxY] = getCanvasSize(canvas);
831 const lines = [];
832 const roleCanvas = options?.roleCanvas;
833 const colorMode = options?.colorMode ?? "none";
834 const theme = options?.theme ?? DEFAULT_ASCII_THEME;
835 for (let y = 0; y <= maxY; y++) {
836 if (colorMode === "none" || !roleCanvas) {
837 let line = "";
838 for (let x = 0; x <= maxX; x++) {
839 line += canvas[x][y];
840 }
841 lines.push(line);
842 } else {
843 const chars = [];
844 const roles = [];
845 for (let x = 0; x <= maxX; x++) {
846 chars.push(canvas[x][y]);
847 roles.push(roleCanvas[x]?.[y] ?? null);
848 }
849 lines.push(colorizeLine(chars, roles, theme, colorMode));
850 }
851 }
852 return lines.join("\n");
853}
854var VERTICAL_FLIP_MAP = {
855 // Unicode arrows
856 "\u25B2": "\u25BC",
857 "\u25BC": "\u25B2",
858 "\u25E4": "\u25E3",
859 "\u25E3": "\u25E4",
860 "\u25E5": "\u25E2",
861 "\u25E2": "\u25E5",
862 // ASCII arrows
863 "^": "v",
864 "v": "^",
865 // Unicode corners
866 "\u250C": "\u2514",
867 "\u2514": "\u250C",
868 "\u2510": "\u2518",
869 "\u2518": "\u2510",
870 // Unicode junctions (T-pieces flip vertically)
871 "\u252C": "\u2534",
872 "\u2534": "\u252C",
873 // Box-start junctions (exit points from node boxes)
874 "\u2575": "\u2577",
875 "\u2577": "\u2575"
876};
877function flipCanvasVertically(canvas) {
878 for (const col of canvas) {
879 col.reverse();
880 }
881 for (const col of canvas) {
882 for (let y = 0; y < col.length; y++) {
883 const flipped = VERTICAL_FLIP_MAP[col[y]];
884 if (flipped) col[y] = flipped;
885 }
886 }
887 return canvas;
888}
889function flipRoleCanvasVertically(roleCanvas) {
890 for (const col of roleCanvas) {
891 col.reverse();
892 }
893 return roleCanvas;
894}
895function drawText(canvas, start, text, forceOverwrite = false) {
896 increaseSize(canvas, start.x + text.length, start.y);
897 for (let i = 0; i < text.length; i++) {
898 const x = start.x + i;
899 const current = canvas[x][start.y];
900 if (forceOverwrite || current === " ") {
901 canvas[x][start.y] = text[i];
902 }
903 }
904}
905function setCanvasSizeToGrid(canvas, columnWidth, rowHeight) {
906 let maxX = 0;
907 let maxY = 0;
908 for (const w of columnWidth.values()) maxX += w;
909 for (const h of rowHeight.values()) maxY += h;
910 increaseSize(canvas, maxX - 1, maxY - 1);
911}
912function setRoleCanvasSizeToGrid(roleCanvas, columnWidth, rowHeight) {
913 let maxX = 0;
914 let maxY = 0;
915 for (const w of columnWidth.values()) maxX += w;
916 for (const h of rowHeight.values()) maxY += h;
917 increaseRoleCanvasSize(roleCanvas, maxX - 1, maxY - 1);
918}
919
920// node_modules/beautiful-mermaid/src/ascii/converter.ts
921function convertToAsciiGraph(parsed, config) {
922 const nodeMap = /* @__PURE__ */ new Map();
923 let index = 0;
924 for (const [id, mNode] of parsed.nodes) {
925 const asciiNode = {
926 // Use the parser ID as the unique identity key to avoid collisions
927 // when multiple nodes share the same label (e.g. A[Web Server], C[Web Server]).
928 name: id,
929 // The label is used for rendering inside the box.
930 displayLabel: mNode.label,
931 // Preserve shape from parser for shape-aware rendering
932 shape: mNode.shape,
933 index,
934 gridCoord: null,
935 drawingCoord: null,
936 drawing: null,
937 drawn: false,
938 styleClassName: "",
939 styleClass: EMPTY_STYLE
940 };
941 nodeMap.set(id, asciiNode);
942 index++;
943 }
944 const nodes = [...nodeMap.values()];
945 const edges = [];
946 for (const mEdge of parsed.edges) {
947 const from = nodeMap.get(mEdge.source);
948 const to = nodeMap.get(mEdge.target);
949 if (!from || !to) continue;
950 edges.push({
951 from,
952 to,
953 text: mEdge.label ?? "",
954 path: [],
955 labelLine: [],
956 startDir: { x: 0, y: 0 },
957 endDir: { x: 0, y: 0 },
958 style: mEdge.style,
959 hasArrowStart: mEdge.hasArrowStart,
960 hasArrowEnd: mEdge.hasArrowEnd
961 });
962 }
963 const subgraphs = [];
964 for (const mSg of parsed.subgraphs) {
965 convertSubgraph(mSg, null, nodeMap, subgraphs);
966 }
967 deduplicateSubgraphNodes(parsed.subgraphs, subgraphs, nodeMap, parsed);
968 for (const [nodeId, className] of parsed.classAssignments) {
969 const node = nodeMap.get(nodeId);
970 const classDef = parsed.classDefs.get(className);
971 if (node && classDef) {
972 node.styleClassName = className;
973 node.styleClass = { name: className, styles: classDef };
974 }
975 }
976 return {
977 nodes,
978 edges,
979 canvas: mkCanvas(0, 0),
980 roleCanvas: mkRoleCanvas(0, 0),
981 grid: /* @__PURE__ */ new Map(),
982 columnWidth: /* @__PURE__ */ new Map(),
983 rowHeight: /* @__PURE__ */ new Map(),
984 subgraphs,
985 config,
986 offsetX: 0,
987 offsetY: 0,
988 bundles: []
989 // Populated by analyzeEdgeBundles() during layout
990 };
991}
992function convertSubgraph(mSg, parent, nodeMap, allSubgraphs) {
993 let normalizedDirection;
994 if (mSg.direction) {
995 normalizedDirection = mSg.direction === "LR" || mSg.direction === "RL" ? "LR" : "TD";
996 }
997 const sg = {
998 name: mSg.label,
999 nodes: [],
1000 parent,
1001 children: [],
1002 minX: 0,
1003 minY: 0,
1004 maxX: 0,
1005 maxY: 0,
1006 direction: normalizedDirection
1007 };
1008 for (const nodeId of mSg.nodeIds) {
1009 const node = nodeMap.get(nodeId);
1010 if (node) sg.nodes.push(node);
1011 }
1012 allSubgraphs.push(sg);
1013 for (const childMSg of mSg.children) {
1014 const child = convertSubgraph(childMSg, sg, nodeMap, allSubgraphs);
1015 sg.children.push(child);
1016 for (const childNode of child.nodes) {
1017 if (!sg.nodes.includes(childNode)) {
1018 sg.nodes.push(childNode);
1019 }
1020 }
1021 }
1022 return sg;
1023}
1024function deduplicateSubgraphNodes(mermaidSubgraphs, asciiSubgraphs, nodeMap, parsed) {
1025 const sgMap = /* @__PURE__ */ new Map();
1026 buildSgMap(mermaidSubgraphs, asciiSubgraphs, sgMap);
1027 const nodeOwner = /* @__PURE__ */ new Map();
1028 function claimNodes(mSg) {
1029 const asciiSg = sgMap.get(mSg);
1030 if (!asciiSg) return;
1031 for (const child of mSg.children) {
1032 claimNodes(child);
1033 }
1034 for (const nodeId of mSg.nodeIds) {
1035 if (!nodeOwner.has(nodeId)) {
1036 nodeOwner.set(nodeId, asciiSg);
1037 }
1038 }
1039 }
1040 for (const mSg of mermaidSubgraphs) {
1041 claimNodes(mSg);
1042 }
1043 for (const asciiSg of asciiSubgraphs) {
1044 asciiSg.nodes = asciiSg.nodes.filter((node) => {
1045 let nodeId;
1046 for (const [id, n] of nodeMap) {
1047 if (n === node) {
1048 nodeId = id;
1049 break;
1050 }
1051 }
1052 if (!nodeId) return false;
1053 const owner = nodeOwner.get(nodeId);
1054 if (!owner) return true;
1055 return isAncestorOrSelf(asciiSg, owner);
1056 });
1057 }
1058}
1059function isAncestorOrSelf(candidate, target) {
1060 let current = target;
1061 while (current !== null) {
1062 if (current === candidate) return true;
1063 current = current.parent;
1064 }
1065 return false;
1066}
1067function buildSgMap(mSgs, aSgs, result) {
1068 const flatMermaid = [];
1069 function flatten(sgs) {
1070 for (const sg of sgs) {
1071 flatMermaid.push(sg);
1072 flatten(sg.children);
1073 }
1074 }
1075 flatten(mSgs);
1076 for (let i = 0; i < flatMermaid.length && i < aSgs.length; i++) {
1077 result.set(flatMermaid[i], aSgs[i]);
1078 }
1079}
1080
1081// node_modules/beautiful-mermaid/src/ascii/pathfinder.ts
1082var MinHeap = class {
1083 items = [];
1084 get length() {
1085 return this.items.length;
1086 }
1087 push(item) {
1088 this.items.push(item);
1089 this.bubbleUp(this.items.length - 1);
1090 }
1091 pop() {
1092 if (this.items.length === 0) return void 0;
1093 const top = this.items[0];
1094 const last = this.items.pop();
1095 if (this.items.length > 0) {
1096 this.items[0] = last;
1097 this.sinkDown(0);
1098 }
1099 return top;
1100 }
1101 bubbleUp(i) {
1102 while (i > 0) {
1103 const parent = i - 1 >> 1;
1104 if (this.items[i].priority < this.items[parent].priority) {
1105 ;
1106 [this.items[i], this.items[parent]] = [this.items[parent], this.items[i]];
1107 i = parent;
1108 } else {
1109 break;
1110 }
1111 }
1112 }
1113 sinkDown(i) {
1114 const n = this.items.length;
1115 while (true) {
1116 let smallest = i;
1117 const left = 2 * i + 1;
1118 const right = 2 * i + 2;
1119 if (left < n && this.items[left].priority < this.items[smallest].priority) {
1120 smallest = left;
1121 }
1122 if (right < n && this.items[right].priority < this.items[smallest].priority) {
1123 smallest = right;
1124 }
1125 if (smallest !== i) {
1126 ;
1127 [this.items[i], this.items[smallest]] = [this.items[smallest], this.items[i]];
1128 i = smallest;
1129 } else {
1130 break;
1131 }
1132 }
1133 }
1134};
1135function heuristic(a, b) {
1136 const absX = Math.abs(a.x - b.x);
1137 const absY = Math.abs(a.y - b.y);
1138 if (absX === 0 || absY === 0) {
1139 return absX + absY;
1140 }
1141 return absX + absY + 1;
1142}
1143var MOVE_DIRS = [
1144 { x: 1, y: 0 },
1145 { x: -1, y: 0 },
1146 { x: 0, y: 1 },
1147 { x: 0, y: -1 }
1148];
1149function isFreeInGrid(grid, c) {
1150 if (c.x < 0 || c.y < 0) return false;
1151 return !grid.has(gridKey(c));
1152}
1153function getPath(grid, from, to) {
1154 const pq = new MinHeap();
1155 pq.push({ coord: from, priority: 0 });
1156 const costSoFar = /* @__PURE__ */ new Map();
1157 costSoFar.set(gridKey(from), 0);
1158 const cameFrom = /* @__PURE__ */ new Map();
1159 cameFrom.set(gridKey(from), null);
1160 const MAX_EXPANSIONS = 4e4;
1161 let expansions = 0;
1162 while (pq.length > 0) {
1163 if (++expansions > MAX_EXPANSIONS) return null;
1164 const current = pq.pop().coord;
1165 if (gridCoordEquals(current, to)) {
1166 const path = [];
1167 let c = current;
1168 while (c !== null) {
1169 path.unshift(c);
1170 c = cameFrom.get(gridKey(c)) ?? null;
1171 }
1172 return path;
1173 }
1174 const currentCost = costSoFar.get(gridKey(current));
1175 for (const dir of MOVE_DIRS) {
1176 const next = { x: current.x + dir.x, y: current.y + dir.y };
1177 if (!isFreeInGrid(grid, next) && !gridCoordEquals(next, to)) {
1178 continue;
1179 }
1180 const newCost = currentCost + 1;
1181 const nextKey = gridKey(next);
1182 const existingCost = costSoFar.get(nextKey);
1183 if (existingCost === void 0 || newCost < existingCost) {
1184 costSoFar.set(nextKey, newCost);
1185 const priority = newCost + heuristic(next, to);
1186 pq.push({ coord: next, priority });
1187 cameFrom.set(nextKey, current);
1188 }
1189 }
1190 }
1191 return null;
1192}
1193function mergePath(path) {
1194 if (path.length <= 2) return path;
1195 const toRemove = /* @__PURE__ */ new Set();
1196 let step0 = path[0];
1197 let step1 = path[1];
1198 for (let idx = 2; idx < path.length; idx++) {
1199 const step2 = path[idx];
1200 const prevDx = step1.x - step0.x;