SLOPSHOPPER

claude-mermaid

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…

newrowscommand
★ 3v0.3.1MITupdated 2026-09-15galElmalah/claude-mods/claude-mermaid
A shopper browsing a rack in a slop shop
Preview · a replayed session in a sandbox
claude · ~/work/app · claude-mermaid
› fix the failing auth test and add an audit log call ⏺ Read(src/auth.ts) ⎿ Read 6 lines ⏺ Update(src/auth.ts) ⎿ Added 2 lines, removed 1 line ⏺ Bash(bun test) ⎿ 3 pass, 1 fail ● Done. refresh now rejects expired claims and logs an audit event. ✻ Worked for 42s · done 4:20 PM › /mermaid ⎿ claude-mermaid: mermaid: ascii off · color on · lr on · /mermaid ascii|color|lr on|off · reset ────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── › ? for shortcuts
README

claude-mermaid

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.

Requirements

  • Claude Code 2.1.270 or later with CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1.
  • An interactive terminal session with a font that has box-drawing characters (/mermaid ascii on for one that does not). Nothing draws in claude -p.

Quick start

  1. Turn function hooks on, in ~/.claude/settings.json:
   { "env": { "CLAUDE_CODE_ENABLE_FUNCTION_HOOKS": "1" } }

Or for one session: CLAUDE_CODE_ENABLE_FUNCTION_HOOKS=1 claude.

  1. Load the plugin from a clone (the repo's own .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
  1. Ask Claude for a diagram. It is drawn in the reply.

Use

commandwhat it does
/mermaidshows 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 resetthe defaults again

A setting redraws the diagrams already on screen. Settings are kept in the plugin's store across sessions.

How it works

  • 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.
  • The bundle patches one thing: upstream's A* edge router searches an unbounded grid, so a graph that fences an edge's target in ran for ~25 s and died on 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.

Develop

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/.

End-to-end tests

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.

Limits

  • Kinds beautiful-mermaid does not draw (gantt, pie, mindmap, gitGraph, journey, timeline, quadrant, C4, …) keep their fence.
  • Art wider than the transcript is cut at the right with … and a line saying by how much; make the terminal wider.
  • Colours ride on ANSI escapes inside the drawn code block; a surface that strips them (desktop, mobile) shows plain art.

License

MIT.

Source 3 files
hooks/register.ts 114 lines
1import 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}
114
hooks/diagrams.ts 250 lines
1import { 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}
250
hooks/vendor/mermaid-ascii.js 5566 lines
1// 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, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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;