Draws the ```mermaid blocks of Claude's replies as images in the terminal, in light or dark, and opens them in Excalidraw

A Claude Code mod that draws the ```mermaid blocks of Claude's replies as images in the terminal, and opens them in Excalidraw.

brew install resvg
Then, in Claude Code:
/plugin install mermaid-render --marketplace maxp-riot/mermaid-render
"env": { "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1" } to ~/.claude/settings.json. Other terminals get box characters.auto gets a dark card).Open in Excalidraw under an image, or /excalidraw for the last diagram, opens excalidraw.com with an editable copy. Excalidraw asks before replacing a canvas that holds a drawing. Nothing is uploaded.MIT. Bundles beautiful-mermaid (MIT), elkjs (EPL-2.0) and entities (BSD-2-Clause), with their licenses.
hooks/register.tsx 234 lines1import type { EngineInterface, Register } from 'claude-code'
2
3import { lastMermaidSource } from './excalidraw'
4import { drawMermaidFences, splitMermaidFences } from './fences'
5import { flattenSvg } from './flatten'
6import {
7 CACHE_DIR,
8 PALETTES,
9 RESVG,
10 appearanceOf,
11 diagramKey,
12 imagesHint,
13 imageCells,
14 svgSize,
15 terminalShowsImages,
16 type Appearance,
17 type DiagramImage,
18 type TerminalEnv,
19} from './image'
20import { canConvertToScene, excalidrawUrl, svgToScene } from './scene'
21
22const OSASCRIPT = '/usr/bin/osascript'
23const OPEN = '/usr/bin/open'
24
25// Margin for the reply's bullet and indent.
26const REPLY_INDENT = 4
27
28// What the text after a diagram is shifted by, to line up with the reply's text
29// Claude Code draws after its bullet. An estimate, to calibrate on screen.
30const TEXT_INDENT = 2
31
32const DIAGRAMS_SECTION = {
33 id: 'mermaid-render:diagrams',
34 scope: 'session',
35 text: [
36 'This terminal draws each ```mermaid code block of your replies as a diagram.',
37 'When a diagram explains a flow, a call sequence, states or a data model better than prose, add one.',
38 'Drawn kinds: flowchart or graph (TD, LR), sequenceDiagram, stateDiagram-v2, classDiagram, erDiagram, xychart-beta.',
39 'Any other kind, or a block that does not parse, is shown as code.',
40 'Start the fence at the beginning of a line, outside lists and quotes.',
41 'Keep labels short and prefer top-down layouts so the diagram fits.',
42 ].join(' '),
43} as const
44
45/**
46 * The appearance for the person's Claude Code theme, a custom theme's `base`
47 * read from ~/.claude/themes (`dark` when it names none, as Claude Code does).
48 */
49async function currentAppearance($: EngineInterface): Promise<Appearance> {
50 const theme = (await $.config.list()).find(row => row.key === 'theme')?.value
51 if (typeof theme !== 'string' || !theme.startsWith('custom:')) {
52 return appearanceOf(typeof theme === 'string' ? theme : undefined)
53 }
54
55 try {
56 const home = await $.env.get('HOME')
57 const file = JSON.parse(await $.fs.read(`${home}/.claude/themes/${theme.slice('custom:'.length)}.json`))
58 return appearanceOf(theme, typeof file.base === 'string' ? file.base : 'dark')
59 } catch {
60 // A plugin's theme, or a file that is gone or not JSON.
61 return appearanceOf(theme)
62 }
63}
64
65async function terminalEnv($: EngineInterface): Promise<TerminalEnv> {
66 return {
67 force: await $.env.get('CLAUDE_CODE_FORCE_TERMINAL_IMAGES'),
68 tmux: await $.env.get('TMUX'),
69 termProgram: await $.env.get('TERM_PROGRAM'),
70 term: await $.env.get('TERM'),
71 kittyWindowId: await $.env.get('KITTY_WINDOW_ID'),
72 }
73}
74
75/**
76 * Renders a Mermaid source to a PNG in CACHE_DIR: layout and SVG by
77 * beautiful-mermaid under JavaScriptCore (osascript, so no Node), PNG by resvg
78 * at twice the SVG's size. Resolves undefined when a step fails.
79 */
80async function renderDiagramImage($: EngineInterface, source: string, appearance: Appearance): Promise<DiagramImage | undefined> {
81 const renderer = `${$.plugin.root}/renderer`
82 const palette = PALETTES[appearance]
83 const fail = (step: string, detail: string) => {
84 $.ui.log(`mermaid-render: ${step} failed, the diagram stays box art: ${detail.slice(0, 500)}`)
85 return undefined
86 }
87
88 try {
89 const key = await diagramKey(source, appearance)
90 const hasResvg = await $.fs.stat(RESVG).then(() => true, () => false)
91 if (!hasResvg) return fail('resvg', `not found at ${RESVG}: brew install resvg`)
92
93 const laidOut = await $.process.run(
94 [OSASCRIPT, '-l', 'JavaScript', `${renderer}/render.js`, `${renderer}/beautiful-mermaid.iife.js`, palette.bg, palette.fg],
95 { stdin: source, timeoutMs: 10_000 },
96 )
97 if (laidOut.exitCode !== 0) return fail('osascript', laidOut.stderr)
98
99 const svg = flattenSvg(laidOut.stdout)
100 const size = svgSize(svg)
101 if (size === undefined) return fail('svg size', laidOut.stdout.slice(0, 200))
102
103 await $.fs.write(`${CACHE_DIR}/${key}.svg`, svg)
104 const file = `${CACHE_DIR}/${key}.png`
105 const background = palette.isOpaque ? ['--background', palette.bg] : []
106 const rasterized = await $.process.run([RESVG, '--zoom', '2', ...background, `${CACHE_DIR}/${key}.svg`, file], { timeoutMs: 10_000 })
107 if (rasterized.exitCode !== 0) return fail('resvg', rasterized.stderr)
108
109 return { file, ...size, svg }
110 } catch (error) {
111 // osascript or resvg missing, a timeout, a refused call, or an SVG
112 // flattenSvg cannot read.
113 return fail('render', String(error))
114 }
115}
116
117/**
118 * Opens excalidraw.com on the diagram as an editable scene. Excalidraw loads a
119 * #url= link straight onto an empty canvas, and asks before replacing a canvas
120 * that holds a drawing ("Replace my content", with backups offered first).
121 */
122async function openInExcalidraw($: EngineInterface, svg: string): Promise<string> {
123 const scene = svgToScene(svg)
124 if (scene === undefined) return 'This kind of diagram has no Excalidraw export.'
125
126 await $.process.run([OPEN, excalidrawUrl(scene)])
127 $.ui.toast('Excalidraw opened on the diagram')
128
129 return 'Excalidraw opened on the diagram. If your canvas holds a drawing, Excalidraw asks before replacing it.'
130}
131
132// Whether the iTerm2 hint was shown while the module lives.
133let isHintShown = false
134
135// One render per source and appearance while the module lives; a reload
136// starts over.
137const images = new Map<string, Promise<DiagramImage | undefined>>()
138
139function imageOf($: EngineInterface, source: string, appearance: Appearance): Promise<DiagramImage | undefined> {
140 const key = `${appearance}\n${source}`
141 let image = images.get(key)
142 if (image === undefined) {
143 image = renderDiagramImage($, source, appearance)
144 images.set(key, image)
145 }
146 return image
147}
148
149export const register: Register = on => {
150 on('session.start', async ($, e, next) => {
151 await $.command.register({ name: 'excalidraw', description: 'Open the last Mermaid diagram in Excalidraw' })
152
153 return next(e)
154 })
155
156 on('command.run', { command: 'excalidraw' }, async $ => {
157 const source = lastMermaidSource(await $.session.messages())
158 if (source === undefined) return { text: 'No Mermaid diagram in this session.' }
159
160 const image = await imageOf($, source, await currentAppearance($))
161 if (image === undefined) return { text: 'The last diagram could not be rendered.' }
162
163 return { text: await openInExcalidraw($, image.svg) }
164 })
165
166 // Only the screen changes: the stored reply keeps its mermaid source (ctrl+o).
167 on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
168 if (e.surface !== 'terminal' || !e.props.text.includes('```mermaid')) {
169 return next(e)
170 }
171
172 const maxColumns = (e.viewport?.columns ?? 80) - REPLY_INDENT
173 const segments = splitMermaidFences(e.props.text)
174 const hasDiagram = segments.some(segment => segment.kind === 'diagram')
175 // Reading the terminal or the theme can fail; the diagram then stays box art.
176 const env = await terminalEnv($).catch((): TerminalEnv => ({}))
177 const canDraw = hasDiagram && terminalShowsImages(env)
178 const hint = hasDiagram ? imagesHint(env) : undefined
179 if (hint !== undefined && !isHintShown) {
180 isHintShown = true
181 $.ui.log(hint)
182 }
183 const appearance = canDraw ? await currentAppearance($).catch((): Appearance => 'card') : 'card'
184 const drawn = canDraw
185 ? await Promise.all(segments.map(segment => (segment.kind === 'diagram' ? imageOf($, segment.source, appearance) : undefined)))
186 : []
187 const isAllDrawn = segments.every((segment, i) => segment.kind === 'text' || drawn[i] !== undefined)
188
189 if (!canDraw || !isAllDrawn) {
190 return next({ ...e, props: { ...e.props, text: drawMermaidFences(e.props.text, maxColumns) } })
191 }
192
193 const { Box, Button, Image, Markdown } = $.ui.resolve(e)
194 const opening = segments[0]?.kind === 'text' ? segments[0].text : ''
195
196 const body = segments.map((segment, i) => {
197 if (segment.kind === 'text') {
198 return i === 0 ? null : <Markdown key={`text-${i}`} text={segment.text} />
199 }
200
201 const image = drawn[i]!
202 const cells = imageCells(image, maxColumns)
203
204 return (
205 <Box key={`diagram-${i}`} flexDirection="column" marginTop={1}>
206 <Image source={{ file: image.file, format: 'png' }} columns={cells.columns} rows={cells.rows} alt="Mermaid diagram" />
207 {canConvertToScene(image.svg) ? (
208 <Button key={`excalidraw-${i}`} label="Open in Excalidraw" onPress={() => openInExcalidraw($, image.svg)} />
209 ) : null}
210 </Box>
211 )
212 })
213
214 return (
215 <Box flexDirection="column">
216 {await next({ ...e, props: { ...e.props, text: opening } })}
217 <Box flexDirection="column" paddingLeft={TEXT_INDENT}>
218 {body}
219 </Box>
220 </Box>
221 )
222 })
223
224 on('prompt.compose', async ($, e, next) => {
225 const composed = await next(e)
226
227 if (!e.surfaces.includes('terminal')) {
228 return composed
229 }
230
231 return { sections: [...composed.sections, DIAGRAMS_SECTION] }
232 })
233}
234hooks/excalidraw.ts 19 lines1import type { SessionMessage } from 'claude-code'
2
3import { splitMermaidFences } from './fences'
4
5/**
6 * The Mermaid source of the last top-level ```mermaid fence Claude wrote in
7 * these messages, or undefined when it wrote none.
8 */
9export function lastMermaidSource(messages: readonly SessionMessage[]): string | undefined {
10 for (const message of [...messages].reverse()) {
11 if (message.role !== 'assistant') continue
12
13 const diagrams = splitMermaidFences(message.text).filter(segment => segment.kind === 'diagram')
14 const last = diagrams.at(-1)
15 if (last) return last.source
16 }
17 return undefined
18}
19hooks/fences.ts 62 lines1import { renderMermaidASCII } from './vendor/beautiful-mermaid-ascii'
2
3// Only a closed fence that starts a line: one still streaming has no closing
4// line yet, and one indented inside a list item or a quote stays code.
5const MERMAID_FENCE = /^```mermaid[^\S\n]*\n([\s\S]*?)\n```[^\S\n]*$/gm
6
7const COMPACT = { colorMode: 'none', paddingX: 2, paddingY: 2, boxBorderPadding: 0 } as const
8
9export type Segment = { kind: 'text'; text: string } | { kind: 'diagram'; source: string }
10
11/**
12 * Cuts a markdown reply into its top-level ```mermaid fences and the text
13 * around them, in order; text segments are trimmed and empty ones left out.
14 */
15export function splitMermaidFences(text: string): Segment[] {
16 const segments: Segment[] = []
17 const pushText = (part: string) => {
18 if (part.trim() !== '') segments.push({ kind: 'text', text: part.trim() })
19 }
20
21 let last = 0
22 for (const match of text.matchAll(MERMAID_FENCE)) {
23 pushText(text.slice(last, match.index))
24 segments.push({ kind: 'diagram', source: match[1]! })
25 last = match.index + match[0].length
26 }
27 pushText(text.slice(last))
28
29 return segments
30}
31
32/**
33 * Replaces each top-level ```mermaid fence of a markdown reply with a ```text
34 * fence holding its box-drawing diagram. A fence keeps its source when the
35 * renderer refuses it or the drawing is wider than `columns`.
36 */
37export function drawMermaidFences(text: string, columns: number): string {
38 return text.replace(MERMAID_FENCE, (fence, source: string) => {
39 const art = drawDiagram(source, columns)
40
41 return art === undefined ? fence : '```text\n' + art + '\n```'
42 })
43}
44
45function drawDiagram(source: string, columns: number): string | undefined {
46 let art: string
47
48 try {
49 art = renderMermaidASCII(source, COMPACT)
50 } catch {
51 // Kinds it does not draw (pie, gantt, mindmap) throw.
52 return undefined
53 }
54
55 const lines = art.split('\n').map(line => line.trimEnd())
56 const widest = Math.max(...lines.map(line => [...line].length))
57
58 // A malformed sequence diagram draws as nothing rather than throwing. Box
59 // drawing cannot shrink to fit a narrower terminal.
60 return widest > 0 && widest <= columns ? lines.join('\n').trimEnd() : undefined
61}
62hooks/flatten.ts 119 lines1// beautiful-mermaid colors its SVG with CSS custom properties and color-mix().
2// resvg 0.48 refuses the custom property declarations and draws nothing, so
3// every var() and color-mix() is resolved here into a plain color first.
4
5type Props = Record<string, string>
6
7/**
8 * Returns the SVG with its custom properties, var() and color-mix() resolved
9 * into hex colors, its font @imports dropped and the root style (the
10 * background) removed. Throws on a construct it does not know how to resolve.
11 */
12export function flattenSvg(svg: string): string {
13 const props: Props = {}
14 const declare = (css: string) => {
15 for (const match of css.matchAll(/(--[\w-]+)\s*:\s*([^;]+);?/g)) {
16 props[match[1]!] = match[2]!.trim()
17 }
18 }
19
20 const rootStyle = svg.match(/<svg[^>]*\sstyle="([^"]*)"/)
21 if (rootStyle) declare(rootStyle[1]!)
22 for (const block of svg.matchAll(/<style>([\s\S]*?)<\/style>/g)) declare(block[1]!)
23
24 const stripped = svg
25 .replace(/<style>([\s\S]*?)<\/style>/g, (block, css: string) => {
26 const kept = css
27 .replace(/\/\*[\s\S]*?\*\//g, '')
28 .replace(/@import url\([^)]*\);/g, '')
29 .replace(/--[\w-]+\s*:[^;{}]+;/g, '')
30
31 return `<style>${kept}</style>`
32 })
33 .replace(/(<svg[^>]*\sstyle=")[^"]*"/, '$1"')
34
35 return resolve(stripped, props, 0)
36}
37
38function resolve(value: string, props: Props, depth: number): string {
39 if (depth > 20) throw new Error(`custom property cycle in ${value}`)
40
41 let out = value
42 for (const fn of ['var(', 'color-mix('] as const) {
43 let at: number
44 while ((at = out.indexOf(fn)) !== -1) {
45 const open = at + fn.length - 1
46 const close = closingParen(out, open)
47 const inner = out.slice(open + 1, close)
48 const replacement = fn === 'var(' ? resolveVar(inner, props, depth) : colorMix(resolve(inner, props, depth + 1))
49 out = out.slice(0, at) + replacement + out.slice(close + 1)
50 }
51 }
52 return out
53}
54
55function resolveVar(inner: string, props: Props, depth: number): string {
56 const [name, ...fallback] = splitTopLevel(inner)
57 const raw = props[name!] ?? fallback.join(', ')
58 if (raw === '') throw new Error(`undefined custom property ${name}`)
59
60 return resolve(raw, props, depth + 1)
61}
62
63// color-mix(in srgb, A p%, B q%): a missing percentage is 100 minus the other.
64function colorMix(args: string): string {
65 const [space, first, second] = splitTopLevel(args)
66 if (space !== 'in srgb' || first === undefined || second === undefined) {
67 throw new Error(`unsupported color-mix(${args})`)
68 }
69
70 const a = mixOperand(first)
71 const b = mixOperand(second)
72 const pa = a.pct ?? (b.pct === undefined ? 50 : 100 - b.pct)
73 const pb = b.pct ?? 100 - pa
74
75 return rgbToHex(a.rgb.map((c, i) => (c * pa + b.rgb[i]! * pb) / (pa + pb)))
76}
77
78function mixOperand(part: string): { rgb: number[]; pct: number | undefined } {
79 const match = part.match(/^(#[0-9a-fA-F]{3}|#[0-9a-fA-F]{6})(?:\s+([\d.]+)%)?$/)
80 if (!match) throw new Error(`unsupported color-mix operand ${part}`)
81
82 return { rgb: hexToRgb(match[1]!), pct: match[2] === undefined ? undefined : Number(match[2]) }
83}
84
85function hexToRgb(hex: string): number[] {
86 const full = hex.length === 4 ? `#${[...hex.slice(1)].map(c => c + c).join('')}` : hex
87
88 return [1, 3, 5].map(i => parseInt(full.slice(i, i + 2), 16))
89}
90
91function rgbToHex(rgb: number[]): string {
92 return `#${rgb.map(c => Math.round(c).toString(16).padStart(2, '0')).join('')}`
93}
94
95function closingParen(text: string, open: number): number {
96 let depth = 0
97 for (let i = open; i < text.length; i++) {
98 if (text[i] === '(') depth++
99 if (text[i] === ')' && --depth === 0) return i
100 }
101 throw new Error(`unbalanced parentheses in ${text.slice(open, open + 80)}`)
102}
103
104function splitTopLevel(text: string): string[] {
105 const parts: string[] = []
106 let depth = 0
107 let start = 0
108 for (let i = 0; i < text.length; i++) {
109 if (text[i] === '(') depth++
110 if (text[i] === ')') depth--
111 if (text[i] === ',' && depth === 0) {
112 parts.push(text.slice(start, i).trim())
113 start = i + 1
114 }
115 }
116 parts.push(text.slice(start).trim())
117 return parts
118}
119hooks/image.ts 102 lines1export type Appearance = 'dark' | 'light' | 'card'
2
3// The colors a diagram is drawn in, by appearance. `dark` is Ghostty's default
4// background and white; `light` is beautiful-mermaid's github-light pair. A
5// `card` keeps the dark colors on an opaque background, for a terminal whose
6// background the mod cannot know.
7export const PALETTES = {
8 dark: { bg: '#282c34', fg: '#ffffff', isOpaque: false },
9 light: { bg: '#ffffff', fg: '#1f2328', isOpaque: false },
10 card: { bg: '#282c34', fg: '#ffffff', isOpaque: true },
11} as const
12
13// Bump when the rendering changes, so cached PNGs are not reused.
14const RENDER_VERSION = 2
15
16export const CACHE_DIR = '/tmp/mermaid-render'
17
18// Where Homebrew installs resvg on Apple Silicon.
19export const RESVG = '/opt/homebrew/bin/resvg'
20
21// Pixels of a terminal cell at 1x, so a diagram's 13px labels come out about the
22// size of the terminal's text. An estimate for Ghostty's default 13pt font, to
23// calibrate on screen.
24const CELL = { width: 8, height: 17 } as const
25
26const MAX_ROWS = 40
27
28export type DiagramImage = { file: string; width: number; height: number; svg: string }
29
30/**
31 * The appearance for Claude Code's `theme` setting: its dark and light presets
32 * name themselves, a custom theme goes by its `base` preset, and `auto` (the
33 * terminal's own background, which a mod cannot read) gets a card.
34 */
35export function appearanceOf(theme: string | undefined, customBase?: string): Appearance {
36 const preset = theme?.startsWith('custom:') ? customBase : theme
37 if (preset?.startsWith('dark')) return 'dark'
38 if (preset?.startsWith('light')) return 'light'
39 return 'card'
40}
41
42export type TerminalEnv = {
43 force?: string
44 tmux?: string
45 termProgram?: string
46 term?: string
47 kittyWindowId?: string
48}
49
50/**
51 * Whether Claude Code draws pictures in this terminal: kitty and Ghostty
52 * (cmux included) outside tmux, or any terminal when
53 * CLAUDE_CODE_FORCE_TERMINAL_IMAGES is set. Elsewhere an Image shows its alt.
54 */
55export function terminalShowsImages(env: TerminalEnv): boolean {
56 if (env.force === '1') return true
57 if (env.tmux) return false
58
59 return env.termProgram === 'ghostty' || env.term === 'xterm-ghostty' || env.term === 'xterm-kitty' || env.kittyWindowId !== undefined
60}
61
62/**
63 * What to tell an iTerm2 user whose diagrams stay box art: iTerm2 3.7 reads
64 * the kitty graphics Claude Code sends, once Claude Code is told to send them.
65 */
66export function imagesHint(env: TerminalEnv): string | undefined {
67 if (env.termProgram !== 'iTerm.app' || terminalShowsImages(env)) return undefined
68
69 return 'mermaid-render: iTerm2 3.7 or later can show the diagrams as images. Add "env": { "CLAUDE_CODE_FORCE_TERMINAL_IMAGES": "1" } to ~/.claude/settings.json, then start a new session.'
70}
71
72/**
73 * The name a diagram's files take in CACHE_DIR: a hash of its source, its
74 * appearance and the render version.
75 */
76export async function diagramKey(source: string, appearance: Appearance): Promise<string> {
77 const text = `${RENDER_VERSION}\n${appearance}\n${source}`
78 const digest = await crypto.subtle.digest('SHA-256', new TextEncoder().encode(text))
79
80 return [...new Uint8Array(digest)].map(byte => byte.toString(16).padStart(2, '0')).join('')
81}
82
83/**
84 * The width and height an SVG's root element declares, in pixels.
85 */
86export function svgSize(svg: string): { width: number; height: number } | undefined {
87 const size = svg.match(/<svg[^>]*\swidth="([\d.]+)"\s+height="([\d.]+)"/)
88
89 return size ? { width: Number(size[1]), height: Number(size[2]) } : undefined
90}
91
92/**
93 * The cells an image takes: its labels at about the terminal's text size,
94 * narrowed to `maxColumns` and to MAX_ROWS rows, keeping its proportions.
95 */
96export function imageCells(image: { width: number; height: number }, maxColumns: number): { columns: number; rows: number } {
97 const rowsPerColumn = (image.height / image.width) * (CELL.width / CELL.height)
98 const columns = Math.max(1, Math.min(maxColumns, Math.ceil(image.width / CELL.width), Math.floor(MAX_ROWS / rowsPerColumn)))
99
100 return { columns, rows: Math.max(1, Math.round(columns * rowsPerColumn)) }
101}
102hooks/scene.ts 353 lines1// Turns a beautiful-mermaid SVG into an Excalidraw scene: the same layout,
2// drawn with Excalidraw's own shapes, bound text and arrows bound to the boxes
3// they join, so the diagram stays editable. Excalidraw's own Mermaid converter
4// needs a browser, which a mod does not have.
5//
6// Fields left out are filled by Excalidraw's restoreElements when it loads the
7// scene (packages/excalidraw/data/restore.ts).
8
9type Attrs = Record<string, string>
10type SvgItem = { tag: string; attrs: Attrs; text: string; group: Attrs | undefined }
11type Element = Record<string, unknown> & { id: string; type: string; boundElements?: { type: string; id: string }[] }
12
13export type ExcalidrawScene = {
14 type: 'excalidraw'
15 version: 2
16 source: string
17 elements: Element[]
18 appState: { viewBackgroundColor: string }
19 files: Record<string, never>
20}
21
22const INK = '#1e1e1e'
23const EXCALIFONT = 5
24
25// beautiful-mermaid's ER cardinalities (src/er/parser.ts) as Excalidraw arrowheads.
26const CARDINALITY: Record<string, string> = {
27 one: 'cardinality_exactly_one',
28 'zero-one': 'cardinality_zero_or_one',
29 many: 'cardinality_one_or_many',
30 'zero-many': 'cardinality_zero_or_many',
31}
32
33// Class relationship kinds whose marker reads best as an outlined head.
34const CLASS_ARROWHEAD: Record<string, string> = {
35 inheritance: 'triangle_outline',
36 realization: 'triangle_outline',
37 composition: 'diamond',
38 aggregation: 'diamond_outline',
39}
40
41/**
42 * Whether svgToScene converts this SVG: every kind but xychart, whose dotted
43 * grid alone is hundreds of circles.
44 */
45export function canConvertToScene(svg: string): boolean {
46 return !svg.includes('xychart')
47}
48
49/**
50 * The Excalidraw scene for a beautiful-mermaid SVG, or undefined for a kind it
51 * does not convert.
52 */
53export function svgToScene(svg: string): ExcalidrawScene | undefined {
54 if (!canConvertToScene(svg)) return undefined
55 const items = parseSvg(svg)
56
57 const elements: Element[] = []
58 const byId = new Map<string, Element>()
59 const add = (element: Element) => {
60 elements.push(element)
61 byId.set(element.id, element)
62 return element
63 }
64 const bind = (container: Element, child: Element, type: string) => {
65 container.boundElements = [...(container.boundElements ?? []), { type, id: child.id }]
66 }
67
68 // Boxes first, so arrows can bind to them whatever the SVG order.
69 const groups = new Map<Attrs, SvgItem[]>()
70 for (const item of items) {
71 if (item.group?.['data-id'] === undefined) continue
72 groups.set(item.group, [...(groups.get(item.group) ?? []), item])
73 }
74 for (const [group, members] of groups) {
75 const shapes = members.filter(item => ['rect', 'polygon', 'circle'].includes(item.tag))
76 const texts = members.filter(item => item.tag === 'text')
77 const [outer] = shapes
78 if (outer === undefined) continue
79
80 const box = add(shapeElement(`box-${group['data-id']}`, outer, elements.length))
81 // A flowchart or state node and a sequence actor hold one centered label;
82 // a class or an entity lays out its own lines.
83 const isSimpleBox = texts.length === 1 && (group.class === 'node' || group.class === 'actor')
84 if (isSimpleBox) {
85 const label = add(textElement(`${box.id}-label`, texts[0]!, elements.length))
86 Object.assign(label, centeredIn(box, label), { containerId: box.id, textAlign: 'center', verticalAlign: 'middle' })
87 bind(box, label, 'text')
88 }
89
90 for (const item of members) {
91 if (item === outer || (isSimpleBox && item.tag === 'text')) continue
92 // A class or entity header repeats the box's own outline.
93 if (item.tag === 'rect' && item.attrs.x === outer.attrs.x && item.attrs.y === outer.attrs.y) continue
94 const extra = convertItem(item, `${box.id}-${elements.length}`, elements.length)
95 if (extra) add(extra)
96 }
97 }
98
99 const arrows: Element[] = []
100 for (const item of items) {
101 if (item.group?.['data-id'] !== undefined) continue
102 const id = `item-${elements.length}`
103 const groupClass = item.group?.class
104
105 if (item.tag === 'polyline' && /\b(edge|class-relationship|er-relationship)\b/.test(item.attrs.class ?? '')) {
106 const arrow = add(linkElement(id, item, elements.length))
107 bindEnds(arrow, item.attrs, byId, bind)
108 arrows.push(arrow)
109 continue
110 }
111 if (groupClass === 'edge-label') {
112 if (item.tag !== 'text') continue
113 const label = add(textElement(id, item, elements.length))
114 const arrow = arrows.find(candidate => candidate.edge === `${item.group!['data-from']}->${item.group!['data-to']}` && !candidate.boundElements?.length)
115 if (arrow) {
116 Object.assign(label, { containerId: arrow.id, textAlign: 'center', verticalAlign: 'middle' })
117 bind(arrow, label, 'text')
118 }
119 continue
120 }
121 if (groupClass === 'message' && (item.tag === 'line' || item.tag === 'polyline')) {
122 const arrow = add(linkElement(id, item, elements.length))
123 arrow.endArrowhead = item.group!['data-arrow-head'] === 'filled' ? 'triangle' : 'arrow'
124 if (item.group!['data-line-style'] === 'dashed') arrow.strokeStyle = 'dashed'
125 continue
126 }
127 if (groupClass === 'note' && item.tag === 'polygon') {
128 // The second polygon is the folded corner.
129 if (elements.some(element => element.note === item.group)) continue
130 add({ ...shapeElement(id, { ...item, tag: 'rect', attrs: boundingRect(item.attrs.points ?? '') }, elements.length), note: item.group })
131 continue
132 }
133 // ER cardinality glyphs and label backgrounds outside any group: the
134 // relationship's arrowheads and its text already say it.
135 if (item.group === undefined && svg.includes('er-relationship') && item.tag !== 'text') continue
136
137 const element = convertItem(item, id, elements.length)
138 if (element) add(element)
139 }
140
141 // A note's text belongs in the note.
142 for (const element of elements) {
143 if (element.note === undefined) continue
144 const text = elements.find(candidate => candidate.type === 'text' && candidate.group === element.note)
145 if (text) {
146 Object.assign(text, centeredIn(element, text), { containerId: element.id, textAlign: 'center', verticalAlign: 'middle' })
147 bind(element, text, 'text')
148 }
149 }
150
151 return {
152 type: 'excalidraw',
153 version: 2,
154 source: 'mermaid-render',
155 elements: elements.map(({ edge, note, group, ...element }) => element as Element),
156 appState: { viewBackgroundColor: '#ffffff' },
157 files: {},
158 }
159}
160
161/**
162 * The link that opens excalidraw.com on a scene: the scene travels in the
163 * fragment as a data: URL, which the browser never sends to the server.
164 */
165export function excalidrawUrl(scene: ExcalidrawScene): string {
166 const bytes = new TextEncoder().encode(JSON.stringify(scene))
167 let binary = ''
168 for (const byte of bytes) binary += String.fromCharCode(byte)
169
170 return `https://excalidraw.com/#url=${encodeURIComponent(`data:application/json;base64,${btoa(binary)}`)}`
171}
172
173function bindEnds(arrow: Element, attrs: Attrs, byId: Map<string, Element>, bind: (container: Element, child: Element, type: string) => void) {
174 const from = attrs['data-from'] ?? attrs['data-entity1']
175 const to = attrs['data-to'] ?? attrs['data-entity2']
176 arrow.edge = `${from}->${to}`
177
178 for (const [end, nodeId] of [['startBinding', from], ['endBinding', to]] as const) {
179 const box = byId.get(`box-${nodeId}`)
180 if (box === undefined) continue
181 arrow[end] = { elementId: box.id, focus: 0, gap: 4 }
182 bind(box, arrow, 'arrow')
183 }
184}
185
186function linkElement(id: string, item: SvgItem, seed: number): Element {
187 const points = item.tag === 'line'
188 ? [[num(item.attrs.x1), num(item.attrs.y1)], [num(item.attrs.x2), num(item.attrs.y2)]]
189 : parsePoints(item.attrs.points ?? '')
190 const [x0, y0] = points[0] ?? [0, 0]
191 const attrs = item.attrs
192 const marker = (side: 'start' | 'end') => {
193 if (attrs['data-cardinality1'] !== undefined) {
194 return CARDINALITY[attrs[side === 'start' ? 'data-cardinality1' : 'data-cardinality2'] ?? ''] ?? null
195 }
196 if (attrs[`marker-${side}`] === undefined) return null
197 return CLASS_ARROWHEAD[attrs['data-type'] ?? ''] ?? 'arrow'
198 }
199
200 return {
201 ...base(id, 'arrow', x0!, y0!, seed),
202 points: points.map(([x, y]) => [x! - x0!, y! - y0!]),
203 startArrowhead: marker('start'),
204 endArrowhead: marker('end'),
205 strokeStyle: attrs['stroke-dasharray'] !== undefined || attrs['data-identifying'] === 'false' ? 'dashed' : 'solid',
206 group: item.group,
207 }
208}
209
210function convertItem(item: SvgItem, id: string, seed: number): Element | undefined {
211 switch (item.tag) {
212 case 'rect':
213 case 'circle':
214 case 'polygon':
215 return shapeElement(id, item, seed)
216 case 'line':
217 case 'polyline':
218 return { ...linkElement(id, item, seed), type: 'line', startArrowhead: null, endArrowhead: null }
219 case 'text':
220 return item.text === '' ? undefined : textElement(id, item, seed)
221 default:
222 return undefined
223 }
224}
225
226function shapeElement(id: string, item: SvgItem, seed: number): Element {
227 const { attrs } = item
228 const isFilled = attrs.fill !== undefined && attrs.fill !== 'none' && item.tag === 'circle' && attrs.stroke === 'none'
229 const fill = isFilled ? { backgroundColor: INK, fillStyle: 'solid' } : {}
230
231 if (item.tag === 'circle') {
232 const r = num(attrs.r)
233 return { ...base(id, 'ellipse', num(attrs.cx) - r, num(attrs.cy) - r, seed), width: 2 * r, height: 2 * r, ...fill }
234 }
235 if (item.tag === 'polygon') {
236 const points = parsePoints(attrs.points ?? '')
237 const box = boundingRect(attrs.points ?? '')
238 if (points.length === 4) return { ...base(id, 'diamond', num(box.x), num(box.y), seed), width: num(box.width), height: num(box.height) }
239
240 const [x0, y0] = points[0] ?? [0, 0]
241 return {
242 ...base(id, 'line', x0!, y0!, seed),
243 points: [...points, points[0]!].map(([x, y]) => [x! - x0!, y! - y0!]),
244 polygon: true,
245 }
246 }
247
248 return {
249 ...base(id, 'rectangle', num(attrs.x), num(attrs.y), seed),
250 width: num(attrs.width),
251 height: num(attrs.height),
252 roundness: num(attrs.rx) > 0 ? { type: 3 } : null,
253 }
254}
255
256function textElement(id: string, item: SvgItem, seed: number): Element {
257 const fontSize = num(item.attrs['font-size']) || 13
258 const lines = item.text.split('\n')
259 const width = Math.max(...lines.map(line => [...line].length)) * fontSize * 0.6
260 const height = lines.length * fontSize * 1.25
261 const isCentered = item.attrs['text-anchor'] === 'middle'
262 const x = num(item.attrs.x)
263
264 return {
265 ...base(id, 'text', isCentered ? x - width / 2 : x, num(item.attrs.y) - height / 2, seed),
266 width,
267 height,
268 text: item.text,
269 originalText: item.text,
270 fontSize,
271 fontFamily: EXCALIFONT,
272 textAlign: isCentered ? 'center' : 'left',
273 verticalAlign: 'top',
274 containerId: null,
275 group: item.group,
276 }
277}
278
279function base(id: string, type: string, x: number, y: number, seed: number): Element {
280 return { id, type, x, y, strokeColor: INK, backgroundColor: 'transparent', strokeWidth: 1, roughness: 1, seed: seed + 1 }
281}
282
283function centeredIn(box: Element, text: Element): { x: number; y: number } {
284 return {
285 x: (box.x as number) + ((box.width as number) - (text.width as number)) / 2,
286 y: (box.y as number) + ((box.height as number) - (text.height as number)) / 2,
287 }
288}
289
290function boundingRect(points: string): Attrs {
291 const xs = parsePoints(points).map(([x]) => x!)
292 const ys = parsePoints(points).map(([, y]) => y!)
293 const [x, y] = [Math.min(...xs), Math.min(...ys)]
294
295 return { x: String(x), y: String(y), width: String(Math.max(...xs) - x), height: String(Math.max(...ys) - y) }
296}
297
298function parsePoints(points: string): number[][] {
299 return points.trim().split(/\s+/).filter(Boolean).map(pair => pair.split(',').map(Number))
300}
301
302function num(value: string | undefined): number {
303 return value === undefined ? 0 : Number(value)
304}
305
306function parseAttrs(source: string): Attrs {
307 const attrs: Attrs = {}
308 for (const match of source.matchAll(/([\w:-]+)="([^"]*)"/g)) attrs[match[1]!] = decodeEntities(match[2]!)
309 return attrs
310}
311
312function decodeEntities(text: string): string {
313 return text
314 .replace(/&#x([0-9a-fA-F]+);/g, (all, hex: string) => String.fromCodePoint(parseInt(hex, 16)))
315 .replace(/&#(\d+);/g, (all, dec: string) => String.fromCodePoint(Number(dec)))
316 .replace(/"/g, '"')
317 .replace(/'|'/g, "'")
318 .replace(/</g, '<')
319 .replace(/>/g, '>')
320 .replace(/&/g, '&')
321}
322
323// beautiful-mermaid's SVG is flat: drawing elements, at most one <g> deep,
324// <text> holding plain text or <tspan>s.
325function parseSvg(svg: string): SvgItem[] {
326 const body = svg.replace(/<style>[\s\S]*?<\/style>/g, '').replace(/<defs>[\s\S]*?<\/defs>/g, '')
327 const items: SvgItem[] = []
328 let group: Attrs | undefined
329 const tag = /<(\/?)([a-zA-Z]+)([^>]*?)(\/?)>/g
330
331 let match: RegExpExecArray | null
332 while ((match = tag.exec(body)) !== null) {
333 const [, closing, name, rawAttrs, selfClosing] = match
334 if (name === 'svg' || name === 'tspan') continue
335 if (name === 'g') {
336 group = closing ? undefined : parseAttrs(rawAttrs!)
337 continue
338 }
339 if (closing) continue
340
341 const attrs = parseAttrs(rawAttrs!)
342 if (name === 'text' && !selfClosing) {
343 const end = body.indexOf('</text>', tag.lastIndex)
344 const inner = body.slice(tag.lastIndex, end).replace(/<[^>]+>/g, '')
345 tag.lastIndex = end + '</text>'.length
346 items.push({ tag: name, attrs, text: decodeEntities(inner).trim(), group })
347 continue
348 }
349 items.push({ tag: name!, attrs, text: '', group })
350 }
351 return items
352}
353hooks/vendor/beautiful-mermaid-ascii.js 5566 lines1// beautiful-mermaid 1.1.3 (MIT, Craft Docs, see LICENSE-beautiful-mermaid): its
2// ASCII renderer alone, bundled from the package's src/ascii so elkjs, which
3// only the SVG renderer needs, stays out. Rebuilt with:
4// npm install beautiful-mermaid@1.1.3 esbuild
5// echo "export { renderMermaidASCII } from './node_modules/beautiful-mermaid/src/ascii/index.ts'" > entry.ts
6// npx esbuild entry.ts --bundle --format=esm --platform=neutral --external:elkjs --outfile=beautiful-mermaid-ascii.js
7// node_modules/beautiful-mermaid/src/multiline-utils.ts
8function normalizeBrTags(label) {
9 const unquoted = label.startsWith('"') && label.endsWith('"') ? label.slice(1, -1) : label;
10 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>");
11}
12
13// node_modules/beautiful-mermaid/src/parser.ts
14function parseMermaid(text) {
15 const lines = text.split("\n").map((l) => l.trim()).filter((l) => l.length > 0 && !l.startsWith("%%"));
16 if (lines.length === 0) {
17 throw new Error("Empty mermaid diagram");
18 }
19 const header = lines[0];
20 if (/^stateDiagram(-v2)?\s*$/i.test(header)) {
21 return parseStateDiagram(lines);
22 }
23 return parseFlowchart(lines);
24}
25function parseFlowchart(lines) {
26 const headerMatch = lines[0].match(/^(?:graph|flowchart)\s+(TD|TB|LR|BT|RL)\s*$/i);
27 if (!headerMatch) {
28 throw new Error(`Invalid mermaid header: "${lines[0]}". Expected "graph TD", "flowchart LR", "stateDiagram-v2", etc.`);
29 }
30 const direction = headerMatch[1].toUpperCase();
31 const graph = {
32 direction,
33 nodes: /* @__PURE__ */ new Map(),
34 edges: [],
35 subgraphs: [],
36 classDefs: /* @__PURE__ */ new Map(),
37 classAssignments: /* @__PURE__ */ new Map(),
38 nodeStyles: /* @__PURE__ */ new Map(),
39 linkStyles: /* @__PURE__ */ new Map()
40 };
41 const subgraphStack = [];
42 for (let i = 1; i < lines.length; i++) {
43 const line = lines[i];
44 const classDefMatch = line.match(/^classDef\s+(\w+)\s+(.+)$/);
45 if (classDefMatch) {
46 const name = classDefMatch[1];
47 const propsStr = classDefMatch[2];
48 const props = parseStyleProps(propsStr);
49 graph.classDefs.set(name, props);
50 continue;
51 }
52 const classAssignMatch = line.match(/^class\s+([\w,-]+)\s+(\w+)$/);
53 if (classAssignMatch) {
54 const nodeIds = classAssignMatch[1].split(",").map((s) => s.trim());
55 const className = classAssignMatch[2];
56 for (const id of nodeIds) {
57 graph.classAssignments.set(id, className);
58 }
59 continue;
60 }
61 const styleMatch = line.match(/^style\s+([\w,-]+)\s+(.+)$/);
62 if (styleMatch) {
63 const nodeIds = styleMatch[1].split(",").map((s) => s.trim());
64 const props = parseStyleProps(styleMatch[2]);
65 for (const id of nodeIds) {
66 graph.nodeStyles.set(id, { ...graph.nodeStyles.get(id), ...props });
67 }
68 continue;
69 }
70 const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/);
71 if (linkStyleMatch) {
72 const target = linkStyleMatch[1].trim();
73 const props = parseStyleProps(linkStyleMatch[2]);
74 if (target === "default") {
75 graph.linkStyles.set("default", { ...graph.linkStyles.get("default"), ...props });
76 } else {
77 const indices = target.split(",").map((s) => parseInt(s.trim(), 10));
78 for (const idx of indices) {
79 if (!isNaN(idx)) {
80 graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props });
81 }
82 }
83 }
84 continue;
85 }
86 const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i);
87 if (dirMatch && subgraphStack.length > 0) {
88 subgraphStack[subgraphStack.length - 1].direction = dirMatch[1].toUpperCase();
89 continue;
90 }
91 const subgraphMatch = line.match(/^subgraph\s+(.+)$/);
92 if (subgraphMatch) {
93 const rest = subgraphMatch[1].trim();
94 const bracketMatch = rest.match(/^([\w-]+)\s*\[(.+)\]$/);
95 let id;
96 let label;
97 if (bracketMatch) {
98 id = bracketMatch[1];
99 label = normalizeBrTags(bracketMatch[2]);
100 } else {
101 label = normalizeBrTags(rest);
102 id = rest.replace(/\s+/g, "_").replace(/[^\w]/g, "");
103 }
104 const sg = { id, label, nodeIds: [], children: [] };
105 subgraphStack.push(sg);
106 continue;
107 }
108 if (line === "end") {
109 const completed = subgraphStack.pop();
110 if (completed) {
111 if (subgraphStack.length > 0) {
112 subgraphStack[subgraphStack.length - 1].children.push(completed);
113 } else {
114 graph.subgraphs.push(completed);
115 }
116 }
117 continue;
118 }
119 parseEdgeLine(line, graph, subgraphStack);
120 }
121 return graph;
122}
123function parseStateDiagram(lines) {
124 const graph = {
125 direction: "TD",
126 nodes: /* @__PURE__ */ new Map(),
127 edges: [],
128 subgraphs: [],
129 classDefs: /* @__PURE__ */ new Map(),
130 classAssignments: /* @__PURE__ */ new Map(),
131 nodeStyles: /* @__PURE__ */ new Map(),
132 linkStyles: /* @__PURE__ */ new Map()
133 };
134 const compositeStack = [];
135 const compositeStateIds = /* @__PURE__ */ new Set();
136 let startCount = 0;
137 let endCount = 0;
138 for (let i = 1; i < lines.length; i++) {
139 const line = lines[i];
140 const dirMatch = line.match(/^direction\s+(TD|TB|LR|BT|RL)\s*$/i);
141 if (dirMatch) {
142 if (compositeStack.length > 0) {
143 compositeStack[compositeStack.length - 1].direction = dirMatch[1].toUpperCase();
144 } else {
145 graph.direction = dirMatch[1].toUpperCase();
146 }
147 continue;
148 }
149 const linkStyleMatch = line.match(/^linkStyle\s+(default|[\d,\s]+)\s+(.+)$/);
150 if (linkStyleMatch) {
151 const target = linkStyleMatch[1].trim();
152 const props = parseStyleProps(linkStyleMatch[2]);
153 if (target === "default") {
154 graph.linkStyles.set("default", { ...graph.linkStyles.get("default"), ...props });
155 } else {
156 const indices = target.split(",").map((s) => parseInt(s.trim(), 10));
157 for (const idx of indices) {
158 if (!isNaN(idx)) {
159 graph.linkStyles.set(idx, { ...graph.linkStyles.get(idx), ...props });
160 }
161 }
162 }
163 continue;
164 }
165 const compositeMatch = line.match(/^state\s+(?:"([^"]+)"\s+as\s+)?([\w\p{L}]+)\s*\{$/u);
166 if (compositeMatch) {
167 const label = compositeMatch[1] ?? compositeMatch[2];
168 const id = compositeMatch[2];
169 const sg = { id, label, nodeIds: [], children: [] };
170 compositeStack.push(sg);
171 compositeStateIds.add(id);
172 graph.nodes.delete(id);
173 continue;
174 }
175 if (line === "}") {
176 const completed = compositeStack.pop();
177 if (completed) {
178 if (compositeStack.length > 0) {
179 compositeStack[compositeStack.length - 1].children.push(completed);
180 } else {
181 graph.subgraphs.push(completed);
182 }
183 }
184 continue;
185 }
186 const stateAliasMatch = line.match(/^state\s+"([^"]+)"\s+as\s+([\w\p{L}]+)\s*$/u);
187 if (stateAliasMatch) {
188 const label = normalizeBrTags(stateAliasMatch[1]);
189 const id = stateAliasMatch[2];
190 registerStateNode(graph, compositeStack, { id, label, shape: "rounded" });
191 continue;
192 }
193 const transitionMatch = line.match(/^(\[\*\]|[\w\p{L}-]+)\s*(-->)\s*(\[\*\]|[\w\p{L}-]+)(?:\s*:\s*(.+))?$/u);
194 if (transitionMatch) {
195 let sourceId = transitionMatch[1];
196 let targetId = transitionMatch[3];
197 const rawTransitionLabel = transitionMatch[4]?.trim();
198 const edgeLabel = rawTransitionLabel ? normalizeBrTags(rawTransitionLabel) : void 0;
199 if (sourceId === "[*]") {
200 startCount++;
201 sourceId = `_start${startCount > 1 ? startCount : ""}`;
202 registerStateNode(graph, compositeStack, { id: sourceId, label: "", shape: "state-start" });
203 } else if (!compositeStateIds.has(sourceId)) {
204 ensureStateNode(graph, compositeStack, sourceId);
205 }
206 if (targetId === "[*]") {
207 endCount++;
208 targetId = `_end${endCount > 1 ? endCount : ""}`;
209 registerStateNode(graph, compositeStack, { id: targetId, label: "", shape: "state-end" });
210 } else if (!compositeStateIds.has(targetId)) {
211 ensureStateNode(graph, compositeStack, targetId);
212 }
213 graph.edges.push({
214 source: sourceId,
215 target: targetId,
216 label: edgeLabel,
217 style: "solid",
218 hasArrowStart: false,
219 hasArrowEnd: true
220 });
221 continue;
222 }
223 const stateDescMatch = line.match(/^([\w\p{L}-]+)\s*:\s*(.+)$/u);
224 if (stateDescMatch) {
225 const id = stateDescMatch[1];
226 const label = normalizeBrTags(stateDescMatch[2].trim());
227 registerStateNode(graph, compositeStack, { id, label, shape: "rounded" });
228 continue;
229 }
230 }
231 return graph;
232}
233function registerStateNode(graph, compositeStack, node) {
234 const isNew = !graph.nodes.has(node.id);
235 if (isNew) {
236 graph.nodes.set(node.id, node);
237 }
238 if (compositeStack.length > 0) {
239 const current = compositeStack[compositeStack.length - 1];
240 if (!current.nodeIds.includes(node.id)) {
241 current.nodeIds.push(node.id);
242 }
243 }
244}
245function ensureStateNode(graph, compositeStack, id) {
246 if (!graph.nodes.has(id)) {
247 registerStateNode(graph, compositeStack, { id, label: id, shape: "rounded" });
248 } else {
249 if (compositeStack.length > 0) {
250 const current = compositeStack[compositeStack.length - 1];
251 if (!current.nodeIds.includes(id)) {
252 current.nodeIds.push(id);
253 }
254 }
255 }
256}
257function parseStyleProps(propsStr) {
258 const cleaned = propsStr.replace(/;\s*$/, "");
259 const props = {};
260 for (const pair of cleaned.split(",")) {
261 const colonIdx = pair.indexOf(":");
262 if (colonIdx > 0) {
263 const key = pair.slice(0, colonIdx).trim();
264 const val = pair.slice(colonIdx + 1).trim();
265 if (key && val) {
266 props[key] = val;
267 }
268 }
269 }
270 return props;
271}
272var ARROW_REGEX = /^(<)?(-->|-.->|==>|---|-\.-|===)(?:\|([^|]*)\|)?/;
273var TEXT_ARROW_REGEX = /^(<)?(--|-\.|==)\s+(.+?)\s+(-->|---|\.\->|-\.\-|==>|===)/;
274var NODE_PATTERNS = [
275 // Triple delimiters (must be first)
276 { regex: /^([\w-]+)\(\(\((.+?)\)\)\)/, shape: "doublecircle" },
277 // A(((text)))
278 // Double delimiters with mixed brackets
279 { regex: /^([\w-]+)\(\[(.+?)\]\)/, shape: "stadium" },
280 // A([text])
281 { regex: /^([\w-]+)\(\((.+?)\)\)/, shape: "circle" },
282 // A((text))
283 { regex: /^([\w-]+)\[\[(.+?)\]\]/, shape: "subroutine" },
284 // A[[text]]
285 { regex: /^([\w-]+)\[\((.+?)\)\]/, shape: "cylinder" },
286 // A[(text)]
287 // Trapezoid variants — must come before plain [text]
288 { regex: /^([\w-]+)\[\/(.+?)\\\]/, shape: "trapezoid" },
289 // A[/text\]
290 { regex: /^([\w-]+)\[\\(.+?)\/\]/, shape: "trapezoid-alt" },
291 // A[\text/]
292 // Asymmetric flag shape
293 { regex: /^([\w-]+)>(.+?)\]/, shape: "asymmetric" },
294 // A>text]
295 // Double curly braces (hexagon) — must come before single {text}
296 { regex: /^([\w-]+)\{\{(.+?)\}\}/, shape: "hexagon" },
297 // A{{text}}
298 // Single-char delimiters (last — most common, least specific)
299 { regex: /^([\w-]+)\[(.+?)\]/, shape: "rectangle" },
300 // A[text]
301 { regex: /^([\w-]+)\((.+?)\)/, shape: "rounded" },
302 // A(text)
303 { regex: /^([\w-]+)\{(.+?)\}/, shape: "diamond" }
304 // A{text}
305];
306var BARE_NODE_REGEX = /^([\w-]+)/;
307var CLASS_SHORTHAND_REGEX = /^:::([\w][\w-]*)/;
308function parseEdgeLine(line, graph, subgraphStack) {
309 let remaining = line.trim();
310 const firstGroup = consumeNodeGroup(remaining, graph, subgraphStack);
311 if (!firstGroup || firstGroup.ids.length === 0) return;
312 remaining = firstGroup.remaining.trim();
313 let prevGroupIds = firstGroup.ids;
314 while (remaining.length > 0) {
315 let hasArrowStart;
316 let style;
317 let hasArrowEnd;
318 let edgeLabel;
319 const arrowMatch = remaining.match(ARROW_REGEX);
320 if (arrowMatch) {
321 hasArrowStart = Boolean(arrowMatch[1]);
322 const arrowOp = arrowMatch[2];
323 const rawEdgeLabel = arrowMatch[3]?.trim();
324 edgeLabel = rawEdgeLabel ? normalizeBrTags(rawEdgeLabel) : void 0;
325 remaining = remaining.slice(arrowMatch[0].length).trim();
326 style = arrowStyleFromOp(arrowOp);
327 hasArrowEnd = arrowOp.endsWith(">");
328 } else {
329 const textMatch = remaining.match(TEXT_ARROW_REGEX);
330 if (!textMatch) break;
331 hasArrowStart = Boolean(textMatch[1]);
332 const rawLabel = textMatch[3].trim();
333 edgeLabel = rawLabel ? normalizeBrTags(rawLabel) : void 0;
334 const openOp = textMatch[2];
335 const closeOp = textMatch[4];
336 remaining = remaining.slice(textMatch[0].length).trim();
337 style = textArrowStyleFromOps(openOp, closeOp);
338 hasArrowEnd = closeOp.endsWith(">");
339 }
340 const nextGroup = consumeNodeGroup(remaining, graph, subgraphStack);
341 if (!nextGroup || nextGroup.ids.length === 0) break;
342 remaining = nextGroup.remaining.trim();
343 for (const sourceId of prevGroupIds) {
344 for (const targetId of nextGroup.ids) {
345 graph.edges.push({
346 source: sourceId,
347 target: targetId,
348 label: edgeLabel,
349 style,
350 hasArrowStart,
351 hasArrowEnd
352 });
353 }
354 }
355 prevGroupIds = nextGroup.ids;
356 }
357}
358function consumeNodeGroup(text, graph, subgraphStack) {
359 const first = consumeNode(text, graph, subgraphStack);
360 if (!first) return null;
361 const ids = [first.id];
362 let remaining = first.remaining.trim();
363 while (remaining.startsWith("&")) {
364 remaining = remaining.slice(1).trim();
365 const next = consumeNode(remaining, graph, subgraphStack);
366 if (!next) break;
367 ids.push(next.id);
368 remaining = next.remaining.trim();
369 }
370 return { ids, remaining };
371}
372function consumeNode(text, graph, subgraphStack) {
373 let id = null;
374 let remaining = text;
375 for (const { regex, shape } of NODE_PATTERNS) {
376 const match = text.match(regex);
377 if (match) {
378 id = match[1];
379 const label = normalizeBrTags(match[2]);
380 registerNode(graph, subgraphStack, { id, label, shape });
381 remaining = text.slice(match[0].length);
382 break;
383 }
384 }
385 if (id === null) {
386 const bareMatch = text.match(BARE_NODE_REGEX);
387 if (bareMatch) {
388 id = bareMatch[1];
389 if (!graph.nodes.has(id)) {
390 registerNode(graph, subgraphStack, { id, label: id, shape: "rectangle" });
391 }
392 remaining = text.slice(bareMatch[0].length);
393 }
394 }
395 if (id === null) return null;
396 const classMatch = remaining.match(CLASS_SHORTHAND_REGEX);
397 if (classMatch) {
398 graph.classAssignments.set(id, classMatch[1]);
399 remaining = remaining.slice(classMatch[0].length);
400 }
401 return { id, remaining };
402}
403function registerNode(graph, subgraphStack, node) {
404 const isNew = !graph.nodes.has(node.id);
405 if (isNew) {
406 graph.nodes.set(node.id, node);
407 }
408 trackInSubgraph(subgraphStack, node.id);
409}
410function trackInSubgraph(subgraphStack, nodeId) {
411 if (subgraphStack.length > 0) {
412 const current = subgraphStack[subgraphStack.length - 1];
413 if (!current.nodeIds.includes(nodeId)) {
414 current.nodeIds.push(nodeId);
415 }
416 }
417}
418function arrowStyleFromOp(op) {
419 if (op === "-.->") return "dotted";
420 if (op === "-.-") return "dotted";
421 if (op === "==>") return "thick";
422 if (op === "===") return "thick";
423 return "solid";
424}
425function textArrowStyleFromOps(openOp, closeOp) {
426 if (openOp === "-." || closeOp === ".->" || closeOp === "-.-") return "dotted";
427 if (openOp === "==" || closeOp === "==>" || closeOp === "===") return "thick";
428 return "solid";
429}
430
431// node_modules/beautiful-mermaid/src/ascii/types.ts
432var Up = { x: 1, y: 0 };
433var Down = { x: 1, y: 2 };
434var Left = { x: 0, y: 1 };
435var Right = { x: 2, y: 1 };
436var UpperRight = { x: 2, y: 0 };
437var UpperLeft = { x: 0, y: 0 };
438var LowerRight = { x: 2, y: 2 };
439var LowerLeft = { x: 0, y: 2 };
440var Middle = { x: 1, y: 1 };
441function gridCoordEquals(a, b) {
442 return a.x === b.x && a.y === b.y;
443}
444function drawingCoordEquals(a, b) {
445 return a.x === b.x && a.y === b.y;
446}
447function gridCoordDirection(c, dir) {
448 return { x: c.x + dir.x, y: c.y + dir.y };
449}
450function gridKey(c) {
451 return `${c.x},${c.y}`;
452}
453var EMPTY_STYLE = { name: "", styles: {} };
454
455// node_modules/beautiful-mermaid/src/ascii/ansi.ts
456var DEFAULT_ASCII_THEME = {
457 fg: "#27272a",
458 // zinc-800 — primary text
459 border: "#a1a1aa",
460 // zinc-400 — node borders (12% mix)
461 line: "#71717a",
462 // zinc-500 — edge lines (35% mix)
463 arrow: "#52525b",
464 // zinc-600 — arrowheads (60% mix)
465 corner: "#71717a",
466 // same as line
467 junction: "#a1a1aa"
468 // same as border
469};
470function detectColorMode() {
471 const proc = globalThis.process;
472 if (proc) {
473 if (!proc.stdout?.isTTY) {
474 return "none";
475 }
476 const colorTerm = proc.env?.COLORTERM?.toLowerCase() ?? "";
477 const term = proc.env?.TERM?.toLowerCase() ?? "";
478 if (colorTerm === "truecolor" || colorTerm === "24bit") {
479 return "truecolor";
480 }
481 if (term.includes("256color") || term.includes("256")) {
482 return "ansi256";
483 }
484 if (term && term !== "dumb") {
485 return "ansi16";
486 }
487 return "none";
488 }
489 if (typeof document !== "undefined") {
490 return "html";
491 }
492 return "none";
493}
494function parseHex(hex) {
495 const h = hex.replace("#", "");
496 if (h.length === 3) {
497 return {
498 r: parseInt(h[0] + h[0], 16),
499 g: parseInt(h[1] + h[1], 16),
500 b: parseInt(h[2] + h[2], 16)
501 };
502 }
503 return {
504 r: parseInt(h.substring(0, 2), 16),
505 g: parseInt(h.substring(2, 4), 16),
506 b: parseInt(h.substring(4, 6), 16)
507 };
508}
509var ESC = "\x1B[";
510var RESET = `${ESC}0m`;
511function truecolorFg(hex) {
512 const { r, g, b } = parseHex(hex);
513 return `${ESC}38;2;${r};${g};${b}m`;
514}
515function rgbTo256(r, g, b) {
516 const avg = (r + g + b) / 3;
517 const maxDiff = Math.max(Math.abs(r - avg), Math.abs(g - avg), Math.abs(b - avg));
518 if (maxDiff < 10) {
519 const gray = Math.round(avg / 255 * 23);
520 return 232 + Math.min(23, Math.max(0, gray));
521 }
522 const toIndex = (v) => {
523 if (v < 48) return 0;
524 if (v < 115) return 1;
525 return Math.min(5, Math.floor((v - 35) / 40));
526 };
527 const ri = toIndex(r);
528 const gi = toIndex(g);
529 const bi = toIndex(b);
530 return 16 + 36 * ri + 6 * gi + bi;
531}
532function ansi256Fg(hex) {
533 const { r, g, b } = parseHex(hex);
534 const index = rgbTo256(r, g, b);
535 return `${ESC}38;5;${index}m`;
536}
537function ansi16Fg(hex) {
538 const { r, g, b } = parseHex(hex);
539 const luma = 0.299 * r + 0.587 * g + 0.114 * b;
540 const bright = luma > 100 ? 0 : 60;
541 let code;
542 if (r > 180 && g < 100 && b < 100) code = 31;
543 else if (g > 180 && r < 100 && b < 100) code = 32;
544 else if (r > 150 && g > 150 && b < 100) code = 33;
545 else if (b > 180 && r < 100 && g < 100) code = 34;
546 else if (r > 150 && b > 150 && g < 100) code = 35;
547 else if (g > 150 && b > 150 && r < 100) code = 36;
548 else if (luma > 200) code = 37;
549 else if (luma < 50) code = 30;
550 else code = 37;
551 return `${ESC}${code + bright}m`;
552}
553function escapeHtml(text) {
554 return text.replace(/&/g, "&").replace(/</g, "<").replace(/>/g, ">");
555}
556function htmlSpan(hex, text) {
557 return `<span style="color:${hex}">${escapeHtml(text)}</span>`;
558}
559function getRoleColor(role, theme) {
560 switch (role) {
561 case "text":
562 return theme.fg;
563 case "border":
564 return theme.border;
565 case "line":
566 return theme.line;
567 case "arrow":
568 return theme.arrow;
569 case "corner":
570 return theme.corner ?? theme.line;
571 case "junction":
572 return theme.junction ?? theme.border;
573 default:
574 return theme.fg;
575 }
576}
577function getAnsiColor(role, theme, mode) {
578 if (mode === "none") return "";
579 const hex = getRoleColor(role, theme);
580 switch (mode) {
581 case "truecolor":
582 return truecolorFg(hex);
583 case "ansi256":
584 return ansi256Fg(hex);
585 case "ansi16":
586 return ansi16Fg(hex);
587 default:
588 return "";
589 }
590}
591function colorizeLine(chars, roles, theme, mode) {
592 if (mode === "none") {
593 return chars.join("");
594 }
595 if (mode === "html") {
596 return colorizeLineHtml(chars, roles, theme);
597 }
598 let result = "";
599 let currentRole = null;
600 let buffer = "";
601 for (let i = 0; i < chars.length; i++) {
602 const char = chars[i];
603 const role = roles[i] ?? null;
604 if (char === " ") {
605 if (buffer.length > 0) {
606 if (currentRole !== null) {
607 result += getAnsiColor(currentRole, theme, mode) + buffer + RESET;
608 } else {
609 result += buffer;
610 }
611 buffer = "";
612 currentRole = null;
613 }
614 result += char;
615 continue;
616 }
617 if (role === currentRole) {
618 buffer += char;
619 continue;
620 }
621 if (buffer.length > 0) {
622 if (currentRole !== null) {
623 result += getAnsiColor(currentRole, theme, mode) + buffer + RESET;
624 } else {
625 result += buffer;
626 }
627 }
628 buffer = char;
629 currentRole = role;
630 }
631 if (buffer.length > 0 && currentRole !== null) {
632 result += getAnsiColor(currentRole, theme, mode) + buffer + RESET;
633 } else if (buffer.length > 0) {
634 result += buffer;
635 }
636 return result;
637}
638function colorizeLineHtml(chars, roles, theme) {
639 let result = "";
640 let currentRole = null;
641 let buffer = "";
642 const flush = () => {
643 if (buffer.length === 0) return;
644 if (currentRole !== null) {
645 result += htmlSpan(getRoleColor(currentRole, theme), buffer);
646 } else {
647 result += escapeHtml(buffer);
648 }
649 buffer = "";
650 currentRole = null;
651 };
652 for (let i = 0; i < chars.length; i++) {
653 const char = chars[i];
654 const role = roles[i] ?? null;
655 if (char === " ") {
656 flush();
657 result += " ";
658 continue;
659 }
660 if (role === currentRole) {
661 buffer += char;
662 continue;
663 }
664 flush();
665 buffer = char;
666 currentRole = role;
667 }
668 flush();
669 return result;
670}
671function colorizeText(text, hex, mode) {
672 if (mode === "none" || text.length === 0) return text;
673 if (mode === "html") return htmlSpan(hex, text);
674 let code;
675 switch (mode) {
676 case "truecolor":
677 code = truecolorFg(hex);
678 break;
679 case "ansi256":
680 code = ansi256Fg(hex);
681 break;
682 case "ansi16":
683 code = ansi16Fg(hex);
684 break;
685 default:
686 return text;
687 }
688 return `${code}${text}${RESET}`;
689}
690
691// node_modules/beautiful-mermaid/src/ascii/canvas.ts
692function mkCanvas(x, y) {
693 const canvas = [];
694 for (let i = 0; i <= x; i++) {
695 const col = [];
696 for (let j = 0; j <= y; j++) {
697 col.push(" ");
698 }
699 canvas.push(col);
700 }
701 return canvas;
702}
703function copyCanvas(source) {
704 const [maxX, maxY] = getCanvasSize(source);
705 return mkCanvas(maxX, maxY);
706}
707function mkRoleCanvas(x, y) {
708 const roleCanvas = [];
709 for (let i = 0; i <= x; i++) {
710 const col = [];
711 for (let j = 0; j <= y; j++) {
712 col.push(null);
713 }
714 roleCanvas.push(col);
715 }
716 return roleCanvas;
717}
718function increaseRoleCanvasSize(roleCanvas, newX, newY) {
719 const currX = roleCanvas.length - 1;
720 const currY = (roleCanvas[0]?.length ?? 1) - 1;
721 const targetX = Math.max(newX, currX);
722 const targetY = Math.max(newY, currY);
723 const grown = mkRoleCanvas(targetX, targetY);
724 for (let x = 0; x < grown.length; x++) {
725 for (let y = 0; y < grown[0].length; y++) {
726 if (x < roleCanvas.length && y < roleCanvas[0].length) {
727 grown[x][y] = roleCanvas[x][y];
728 }
729 }
730 }
731 roleCanvas.length = 0;
732 roleCanvas.push(...grown);
733 return roleCanvas;
734}
735function setRole(roleCanvas, x, y, role) {
736 if (x >= roleCanvas.length || y >= (roleCanvas[0]?.length ?? 0)) {
737 increaseRoleCanvasSize(roleCanvas, x, y);
738 }
739 roleCanvas[x][y] = role;
740}
741function getCanvasSize(canvas) {
742 return [canvas.length - 1, (canvas[0]?.length ?? 1) - 1];
743}
744function increaseSize(canvas, newX, newY) {
745 const [currX, currY] = getCanvasSize(canvas);
746 const targetX = Math.max(newX, currX);
747 const targetY = Math.max(newY, currY);
748 const grown = mkCanvas(targetX, targetY);
749 for (let x = 0; x < grown.length; x++) {
750 for (let y = 0; y < grown[0].length; y++) {
751 if (x < canvas.length && y < canvas[0].length) {
752 grown[x][y] = canvas[x][y];
753 }
754 }
755 }
756 canvas.length = 0;
757 canvas.push(...grown);
758 return canvas;
759}
760var JUNCTION_CHARS = /* @__PURE__ */ new Set([
761 "\u2500",
762 "\u2502",
763 "\u250C",
764 "\u2510",
765 "\u2514",
766 "\u2518",
767 "\u251C",
768 "\u2524",
769 "\u252C",
770 "\u2534",
771 "\u253C",
772 "\u2574",
773 "\u2575",
774 "\u2576",
775 "\u2577"
776]);
777function isJunctionChar(c) {
778 return JUNCTION_CHARS.has(c);
779}
780function isAlphanumeric(c) {
781 return /^[a-zA-Z0-9]$/.test(c);
782}
783var JUNCTION_MAP = {
784 "\u2500": { "\u2502": "\u253C", "\u250C": "\u252C", "\u2510": "\u252C", "\u2514": "\u2534", "\u2518": "\u2534", "\u251C": "\u253C", "\u2524": "\u253C", "\u252C": "\u252C", "\u2534": "\u2534" },
785 "\u2502": { "\u2500": "\u253C", "\u250C": "\u251C", "\u2510": "\u2524", "\u2514": "\u251C", "\u2518": "\u2524", "\u251C": "\u251C", "\u2524": "\u2524", "\u252C": "\u253C", "\u2534": "\u253C" },
786 "\u250C": { "\u2500": "\u252C", "\u2502": "\u251C", "\u2510": "\u252C", "\u2514": "\u251C", "\u2518": "\u253C", "\u251C": "\u251C", "\u2524": "\u253C", "\u252C": "\u252C", "\u2534": "\u253C" },
787 "\u2510": { "\u2500": "\u252C", "\u2502": "\u2524", "\u250C": "\u252C", "\u2514": "\u253C", "\u2518": "\u2524", "\u251C": "\u253C", "\u2524": "\u2524", "\u252C": "\u252C", "\u2534": "\u253C" },
788 "\u2514": { "\u2500": "\u2534", "\u2502": "\u251C", "\u250C": "\u251C", "\u2510": "\u253C", "\u2518": "\u2534", "\u251C": "\u251C", "\u2524": "\u253C", "\u252C": "\u253C", "\u2534": "\u2534" },
789 "\u2518": { "\u2500": "\u2534", "\u2502": "\u2524", "\u250C": "\u253C", "\u2510": "\u2524", "\u2514": "\u2534", "\u251C": "\u253C", "\u2524": "\u2524", "\u252C": "\u253C", "\u2534": "\u2534" },
790 "\u251C": { "\u2500": "\u253C", "\u2502": "\u251C", "\u250C": "\u251C", "\u2510": "\u253C", "\u2514": "\u251C", "\u2518": "\u253C", "\u2524": "\u253C", "\u252C": "\u253C", "\u2534": "\u253C" },
791 "\u2524": { "\u2500": "\u253C", "\u2502": "\u2524", "\u250C": "\u253C", "\u2510": "\u2524", "\u2514": "\u253C", "\u2518": "\u2524", "\u251C": "\u253C", "\u252C": "\u253C", "\u2534": "\u253C" },
792 "\u252C": { "\u2500": "\u252C", "\u2502": "\u253C", "\u250C": "\u252C", "\u2510": "\u252C", "\u2514": "\u253C", "\u2518": "\u253C", "\u251C": "\u253C", "\u2524": "\u253C", "\u2534": "\u253C" },
793 "\u2534": { "\u2500": "\u2534", "\u2502": "\u253C", "\u250C": "\u253C", "\u2510": "\u253C", "\u2514": "\u2534", "\u2518": "\u2534", "\u251C": "\u253C", "\u2524": "\u253C", "\u252C": "\u253C" }
794};
795function mergeJunctions(c1, c2) {
796 return JUNCTION_MAP[c1]?.[c2] ?? c1;
797}
798function mergeCanvases(base, offset, useAscii, ...overlays) {
799 let [maxX, maxY] = getCanvasSize(base);
800 for (const overlay of overlays) {
801 const [oX, oY] = getCanvasSize(overlay);
802 maxX = Math.max(maxX, oX + offset.x);
803 maxY = Math.max(maxY, oY + offset.y);
804 }
805 const merged = mkCanvas(maxX, maxY);
806 for (let x = 0; x <= maxX; x++) {
807 for (let y = 0; y <= maxY; y++) {
808 if (x < base.length && y < base[0].length) {
809 merged[x][y] = base[x][y];
810 }
811 }
812 }
813 for (const overlay of overlays) {
814 for (let x = 0; x < overlay.length; x++) {
815 for (let y = 0; y < overlay[0].length; y++) {
816 const c = overlay[x][y];
817 if (c !== " ") {
818 const mx = x + offset.x;
819 const my = y + offset.y;
820 const current = merged[mx][my];
821 if (!useAscii && isJunctionChar(c) && isJunctionChar(current)) {
822 merged[mx][my] = mergeJunctions(current, c);
823 } else if (isAlphanumeric(current) && isAlphanumeric(c)) {
824 } else {
825 merged[mx][my] = c;
826 }
827 }
828 }
829 }
830 }
831 return merged;
832}
833function canvasToString(canvas, options) {
834 const [maxX, maxY] = getCanvasSize(canvas);
835 const lines = [];
836 const roleCanvas = options?.roleCanvas;
837 const colorMode = options?.colorMode ?? "none";
838 const theme = options?.theme ?? DEFAULT_ASCII_THEME;
839 for (let y = 0; y <= maxY; y++) {
840 if (colorMode === "none" || !roleCanvas) {
841 let line = "";
842 for (let x = 0; x <= maxX; x++) {
843 line += canvas[x][y];
844 }
845 lines.push(line);
846 } else {
847 const chars = [];
848 const roles = [];
849 for (let x = 0; x <= maxX; x++) {
850 chars.push(canvas[x][y]);
851 roles.push(roleCanvas[x]?.[y] ?? null);
852 }
853 lines.push(colorizeLine(chars, roles, theme, colorMode));
854 }
855 }
856 return lines.join("\n");
857}
858var VERTICAL_FLIP_MAP = {
859 // Unicode arrows
860 "\u25B2": "\u25BC",
861 "\u25BC": "\u25B2",
862 "\u25E4": "\u25E3",
863 "\u25E3": "\u25E4",
864 "\u25E5": "\u25E2",
865 "\u25E2": "\u25E5",
866 // ASCII arrows
867 "^": "v",
868 "v": "^",
869 // Unicode corners
870 "\u250C": "\u2514",
871 "\u2514": "\u250C",
872 "\u2510": "\u2518",
873 "\u2518": "\u2510",
874 // Unicode junctions (T-pieces flip vertically)
875 "\u252C": "\u2534",
876 "\u2534": "\u252C",
877 // Box-start junctions (exit points from node boxes)
878 "\u2575": "\u2577",
879 "\u2577": "\u2575"
880};
881function flipCanvasVertically(canvas) {
882 for (const col of canvas) {
883 col.reverse();
884 }
885 for (const col of canvas) {
886 for (let y = 0; y < col.length; y++) {
887 const flipped = VERTICAL_FLIP_MAP[col[y]];
888 if (flipped) col[y] = flipped;
889 }
890 }
891 return canvas;
892}
893function flipRoleCanvasVertically(roleCanvas) {
894 for (const col of roleCanvas) {
895 col.reverse();
896 }
897 return roleCanvas;
898}
899function drawText(canvas, start, text, forceOverwrite = false) {
900 increaseSize(canvas, start.x + text.length, start.y);
901 for (let i = 0; i < text.length; i++) {
902 const x = start.x + i;
903 const current = canvas[x][start.y];
904 if (forceOverwrite || current === " ") {
905 canvas[x][start.y] = text[i];
906 }
907 }
908}
909function setCanvasSizeToGrid(canvas, columnWidth, rowHeight) {
910 let maxX = 0;
911 let maxY = 0;
912 for (const w of columnWidth.values()) maxX += w;
913 for (const h of rowHeight.values()) maxY += h;
914 increaseSize(canvas, maxX - 1, maxY - 1);
915}
916function setRoleCanvasSizeToGrid(roleCanvas, columnWidth, rowHeight) {
917 let maxX = 0;
918 let maxY = 0;
919 for (const w of columnWidth.values()) maxX += w;
920 for (const h of rowHeight.values()) maxY += h;
921 increaseRoleCanvasSize(roleCanvas, maxX - 1, maxY - 1);
922}
923
924// node_modules/beautiful-mermaid/src/ascii/converter.ts
925function convertToAsciiGraph(parsed, config) {
926 const nodeMap = /* @__PURE__ */ new Map();
927 let index = 0;
928 for (const [id, mNode] of parsed.nodes) {
929 const asciiNode = {
930 // Use the parser ID as the unique identity key to avoid collisions
931 // when multiple nodes share the same label (e.g. A[Web Server], C[Web Server]).
932 name: id,
933 // The label is used for rendering inside the box.
934 displayLabel: mNode.label,
935 // Preserve shape from parser for shape-aware rendering
936 shape: mNode.shape,
937 index,
938 gridCoord: null,
939 drawingCoord: null,
940 drawing: null,
941 drawn: false,
942 styleClassName: "",
943 styleClass: EMPTY_STYLE
944 };
945 nodeMap.set(id, asciiNode);
946 index++;
947 }
948 const nodes = [...nodeMap.values()];
949 const edges = [];
950 for (const mEdge of parsed.edges) {
951 const from = nodeMap.get(mEdge.source);
952 const to = nodeMap.get(mEdge.target);
953 if (!from || !to) continue;
954 edges.push({
955 from,
956 to,
957 text: mEdge.label ?? "",
958 path: [],
959 labelLine: [],
960 startDir: { x: 0, y: 0 },
961 endDir: { x: 0, y: 0 },
962 style: mEdge.style,
963 hasArrowStart: mEdge.hasArrowStart,
964 hasArrowEnd: mEdge.hasArrowEnd
965 });
966 }
967 const subgraphs = [];
968 for (const mSg of parsed.subgraphs) {
969 convertSubgraph(mSg, null, nodeMap, subgraphs);
970 }
971 deduplicateSubgraphNodes(parsed.subgraphs, subgraphs, nodeMap, parsed);
972 for (const [nodeId, className] of parsed.classAssignments) {
973 const node = nodeMap.get(nodeId);
974 const classDef = parsed.classDefs.get(className);
975 if (node && classDef) {
976 node.styleClassName = className;
977 node.styleClass = { name: className, styles: classDef };
978 }
979 }
980 return {
981 nodes,
982 edges,
983 canvas: mkCanvas(0, 0),
984 roleCanvas: mkRoleCanvas(0, 0),
985 grid: /* @__PURE__ */ new Map(),
986 columnWidth: /* @__PURE__ */ new Map(),
987 rowHeight: /* @__PURE__ */ new Map(),
988 subgraphs,
989 config,
990 offsetX: 0,
991 offsetY: 0,
992 bundles: []
993 // Populated by analyzeEdgeBundles() during layout
994 };
995}
996function convertSubgraph(mSg, parent, nodeMap, allSubgraphs) {
997 let normalizedDirection;
998 if (mSg.direction) {
999 normalizedDirection = mSg.direction === "LR" || mSg.direction === "RL" ? "LR" : "TD";
1000 }
1001 const sg = {
1002 name: mSg.label,
1003 nodes: [],
1004 parent,
1005 children: [],
1006 minX: 0,
1007 minY: 0,
1008 maxX: 0,
1009 maxY: 0,
1010 direction: normalizedDirection
1011 };
1012 for (const nodeId of mSg.nodeIds) {
1013 const node = nodeMap.get(nodeId);
1014 if (node) sg.nodes.push(node);
1015 }
1016 allSubgraphs.push(sg);
1017 for (const childMSg of mSg.children) {
1018 const child = convertSubgraph(childMSg, sg, nodeMap, allSubgraphs);
1019 sg.children.push(child);
1020 for (const childNode of child.nodes) {
1021 if (!sg.nodes.includes(childNode)) {
1022 sg.nodes.push(childNode);
1023 }
1024 }
1025 }
1026 return sg;
1027}
1028function deduplicateSubgraphNodes(mermaidSubgraphs, asciiSubgraphs, nodeMap, parsed) {
1029 const sgMap = /* @__PURE__ */ new Map();
1030 buildSgMap(mermaidSubgraphs, asciiSubgraphs, sgMap);
1031 const nodeOwner = /* @__PURE__ */ new Map();
1032 function claimNodes(mSg) {
1033 const asciiSg = sgMap.get(mSg);
1034 if (!asciiSg) return;
1035 for (const child of mSg.children) {
1036 claimNodes(child);
1037 }
1038 for (const nodeId of mSg.nodeIds) {
1039 if (!nodeOwner.has(nodeId)) {
1040 nodeOwner.set(nodeId, asciiSg);
1041 }
1042 }
1043 }
1044 for (const mSg of mermaidSubgraphs) {
1045 claimNodes(mSg);
1046 }
1047 for (const asciiSg of asciiSubgraphs) {
1048 asciiSg.nodes = asciiSg.nodes.filter((node) => {
1049 let nodeId;
1050 for (const [id, n] of nodeMap) {
1051 if (n === node) {
1052 nodeId = id;
1053 break;
1054 }
1055 }
1056 if (!nodeId) return false;
1057 const owner = nodeOwner.get(nodeId);
1058 if (!owner) return true;
1059 return isAncestorOrSelf(asciiSg, owner);
1060 });
1061 }
1062}
1063function isAncestorOrSelf(candidate, target) {
1064 let current = target;
1065 while (current !== null) {
1066 if (current === candidate) return true;
1067 current = current.parent;
1068 }
1069 return false;
1070}
1071function buildSgMap(mSgs, aSgs, result) {
1072 const flatMermaid = [];
1073 function flatten(sgs) {
1074 for (const sg of sgs) {
1075 flatMermaid.push(sg);
1076 flatten(sg.children);
1077 }
1078 }
1079 flatten(mSgs);
1080 for (let i = 0; i < flatMermaid.length && i < aSgs.length; i++) {
1081 result.set(flatMermaid[i], aSgs[i]);
1082 }
1083}
1084
1085// node_modules/beautiful-mermaid/src/ascii/pathfinder.ts
1086var MinHeap = class {
1087 items = [];
1088 get length() {
1089 return this.items.length;
1090 }
1091 push(item) {
1092 this.items.push(item);
1093 this.bubbleUp(this.items.length - 1);
1094 }
1095 pop() {
1096 if (this.items.length === 0) return void 0;
1097 const top = this.items[0];
1098 const last = this.items.pop();
1099 if (this.items.length > 0) {
1100 this.items[0] = last;
1101 this.sinkDown(0);
1102 }
1103 return top;
1104 }
1105 bubbleUp(i) {
1106 while (i > 0) {
1107 const parent = i - 1 >> 1;
1108 if (this.items[i].priority < this.items[parent].priority) {
1109 ;
1110 [this.items[i], this.items[parent]] = [this.items[parent], this.items[i]];
1111 i = parent;
1112 } else {
1113 break;
1114 }
1115 }
1116 }
1117 sinkDown(i) {
1118 const n = this.items.length;
1119 while (true) {
1120 let smallest = i;
1121 const left = 2 * i + 1;
1122 const right = 2 * i + 2;
1123 if (left < n && this.items[left].priority < this.items[smallest].priority) {
1124 smallest = left;
1125 }
1126 if (right < n && this.items[right].priority < this.items[smallest].priority) {
1127 smallest = right;
1128 }
1129 if (smallest !== i) {
1130 ;
1131 [this.items[i], this.items[smallest]] = [this.items[smallest], this.items[i]];
1132 i = smallest;
1133 } else {
1134 break;
1135 }
1136 }
1137 }
1138};
1139function heuristic(a, b) {
1140 const absX = Math.abs(a.x - b.x);
1141 const absY = Math.abs(a.y - b.y);
1142 if (absX === 0 || absY === 0) {
1143 return absX + absY;
1144 }
1145 return absX + absY + 1;
1146}
1147var MOVE_DIRS = [
1148 { x: 1, y: 0 },
1149 { x: -1, y: 0 },
1150 { x: 0, y: 1 },
1151 { x: 0, y: -1 }
1152];
1153function isFreeInGrid(grid, c) {
1154 if (c.x < 0 || c.y < 0) return false;
1155 return !grid.has(gridKey(c));
1156}
1157function getPath(grid, from, to) {
1158 const pq = new MinHeap();
1159 pq.push({ coord: from, priority: 0 });
1160 const costSoFar = /* @__PURE__ */ new Map();
1161 costSoFar.set(gridKey(from), 0);
1162 const cameFrom = /* @__PURE__ */ new Map();
1163 cameFrom.set(gridKey(from), null);
1164 while (pq.length > 0) {
1165 const current = pq.pop().coord;
1166 if (gridCoordEquals(current, to)) {
1167 const path = [];
1168 let c = current;
1169 while (c !== null) {
1170 path.unshift(c);
1171 c = cameFrom.get(gridKey(c)) ?? null;
1172 }
1173 return path;
1174 }
1175 const currentCost = costSoFar.get(gridKey(current));
1176 for (const dir of MOVE_DIRS) {
1177 const next = { x: current.x + dir.x, y: current.y + dir.y };
1178 if (!isFreeInGrid(grid, next) && !gridCoordEquals(next, to)) {
1179 continue;
1180 }
1181 const newCost = currentCost + 1;
1182 const nextKey = gridKey(next);
1183 const existingCost = costSoFar.get(nextKey);
1184 if (existingCost === void 0 || newCost < existingCost) {
1185 costSoFar.set(nextKey, newCost);
1186 const priority = newCost + heuristic(next, to);
1187 pq.push({ coord: next, priority });
1188 cameFrom.set(nextKey, current);
1189 }
1190 }
1191 }
1192 return null;
1193}
1194function mergePath(path) {
1195 if (path.length <= 2) return path;
1196 const toRemove = /* @__PURE__ */ new Set();
1197 let step0 = path[0];
1198 let step1 = path[1];
1199 for (let idx = 2; idx < path.length; idx++) {
1200 const step2 = path[idx];