SLOPSHOPPER

mermaid-render

Draws mermaid fenced code blocks in assistant replies as diagrams: SVG on desktop, VS Code and mobile, box-drawing text in the terminal.

newrowsprocess
v0.1.1MITupdated 2026-10-25ceyeoh/mermaid-render
A shopper browsing a rack in a slop shop
README

mermaid-render

A Claude Code mod that draws mermaid diagrams in assistant replies. When a reply holds a closed fenced code block whose language is mermaid, the block is drawn as a picture instead of as code.

  • In the desktop app, VS Code and the mobile app, the diagram is an SVG. Its colors follow the light or dark color scheme.
  • In the terminal, the diagram is drawn with Unicode box-drawing characters.

Supported diagrams

Flowcharts (graph / flowchart), sequence diagrams, state diagrams, class diagrams and ER diagrams. Rendering comes from beautiful-mermaid 1.1.3, vendored in renderer/.

When a diagram is not drawn

The block stays the code block it is today when any of these holds:

  • the diagram type is one beautiful-mermaid does not draw (pie, gantt, mindmap, gitGraph and others), or the source does not parse;
  • the fence is still streaming and has not closed yet;
  • in the terminal, the drawing is wider than the window;
  • the SVG would be over 131072 characters, or would reference anything outside itself;
  • no JavaScript runtime could start.

Text written between two tool calls is often drawn as a summary of itself, and a summary holds no fence, so a diagram shows reliably only in the text that ends a reply.

Install

This repository is its own plugin marketplace.

claude plugin marketplace add ceyeoh/mermaid-render
claude plugin install mermaid-render@mermaid-render --scope user

A user-scope install loads in every new terminal session and in the desktop app's Code tab.

Requirements

Mods (function-hooks plugins) need a recent Claude Code. This mod was built and tested on Claude Code 2.1.310.

The renderer runs as a separate process, because its bundle is larger than a hooks module may import. It needs node (or bun) on the machine. The mod tries node, /opt/homebrew/bin/node, /usr/local/bin/node, bun and /opt/homebrew/bin/bun, in that order, and uses the first that starts. When none starts, every diagram stays code and one line goes to the debug log (claude --debug).

What it runs and sends

  • When a finished reply holds a closed mermaid block, the mod starts node (or bun) on your machine with this plugin's renderer/render.mjs. It writes the diagram sources to the process as JSON on stdin and reads SVG or text back from stdout. One process draws every new diagram in a reply.
  • To find a runtime, it runs each candidate with --version until one starts. It does this once per session.
  • A diagram already drawn comes from an in-memory cache of up to 256 entries. The cache is gone when Claude Code exits.
  • The renderer makes no network requests, reads no files besides its own code, and writes nothing to disk. Nothing leaves your machine.
  • beautiful-mermaid's SVG asks for Google Fonts through a CSS @import. The renderer strips that line, and it throws away any SVG that still references a URL other than the SVG namespace, so a drawn diagram never fetches anything.

Vendored code

renderer/beautiful-mermaid.mjs holds beautiful-mermaid 1.1.3 and its dependencies elkjs 0.11.1 and entities 7.0.1, all from npm, bundled unminified into one file by scripts/vendor.sh. It is not edited by hand. With bun 1.4.4, scripts/vendor.sh rebuilds it byte for byte, so git status stays clean afterwards.

Most of the file reads as plain JavaScript. The elkjs part is elkjs's own published build, which is compiled from Java and is hard to read in any form. elkjs can start a Web Worker when it is given a worker URL. beautiful-mermaid never gives one, so layout runs inside the renderer process.

Development

  • claude plugin test . runs the hook and parser tests.
  • node renderer/render.check.mjs runs the real renderer over a batch of diagrams.
  • scripts/vendor.sh rebuilds renderer/beautiful-mermaid.mjs from the pinned npm release, with its license files.

License

MIT. The vendored bundle includes beautiful-mermaid (MIT, renderer/LICENSE-beautiful-mermaid), elkjs (EPL-2.0, renderer/LICENSE-elkjs) and entities (BSD-2-Clause, renderer/LICENSE-entities).

Source 2 files
hooks/register.tsx 178 lines
1import type { EngineInterface, Register, RenderElement, RenderInput, RenderSurface, RenderViewport } from 'claude-code'
2import { parseSegments, type Segment } from './segments'
3
4type Format = 'svg' | 'ascii'
5type Rendered = { ok: true; output: string } | { ok: false; reason: string }
6type Diagram = { kind: 'diagram'; output: string; alt: string }
7type Block = { kind: 'markdown'; text: string } | Diagram
8
9const FORMAT_BY_SURFACE = {
10  desktop: 'svg',
11  vscode: 'svg',
12  mobile: 'svg',
13  terminal: 'ascii',
14} as const satisfies Record<RenderSurface, Format>
15
16const widestLine = (text: string) => Math.max(...text.split('\n').map(line => [...line].length))
17
18const FITS: Record<Format, (output: string, viewport: RenderViewport | undefined) => boolean> = {
19  svg: () => true,
20  ascii: (output, viewport) => viewport === undefined || widestLine(output) <= viewport.columns,
21}
22
23// The renderer runs out of process: its bundle (beautiful-mermaid with elkjs) is 3.4 MB,
24// and the hooks module may not import a file over 1 MiB.
25const RENDERER = 'renderer/render.mjs'
26const RUNTIMES = ['node', '/opt/homebrew/bin/node', '/usr/local/bin/node', 'bun', '/opt/homebrew/bin/bun']
27const RENDER_TIMEOUT_MS = 20_000
28const CACHE_LIMIT = 256
29
30const cache = new Map<string, Promise<Rendered>>()
31let runtime: Promise<string | undefined> | undefined
32let hasLoggedNoRuntime = false
33
34function findRuntime($: EngineInterface): Promise<string | undefined> {
35  runtime ??= (async () => {
36    for (const candidate of RUNTIMES) {
37      try {
38        await $.process.run([candidate, '--version'], { timeoutMs: 5_000 })
39        return candidate
40      } catch {}
41    }
42    // A run can also reject because its dispatch was abandoned, so a miss is retried on the next draw.
43    runtime = undefined
44    if (!hasLoggedNoRuntime) {
45      hasLoggedNoRuntime = true
46      $.ui.log(`no node or bun could start (tried ${RUNTIMES.join(', ')}); mermaid fences stay code`, { to: 'debug' })
47    }
48    return undefined
49  })()
50  return runtime
51}
52
53function parseRendered(stdout: string, count: number): Rendered[] {
54  const parsed: unknown = JSON.parse(stdout)
55  const isRendered = (value: unknown): value is Rendered =>
56    typeof value === 'object' &&
57    value !== null &&
58    (('ok' in value && value.ok === true && 'output' in value && typeof value.output === 'string') ||
59      ('ok' in value && value.ok === false && 'reason' in value && typeof value.reason === 'string'))
60  if (!Array.isArray(parsed) || parsed.length !== count || !parsed.every(isRendered)) {
61    throw new Error(`renderer answered ${stdout.slice(0, 80)}`)
62  }
63  return parsed
64}
65
66async function runRenderer($: EngineInterface, format: Format, sources: string[]): Promise<Rendered[]> {
67  const argv0 = await findRuntime($)
68  if (argv0 === undefined) throw new Error('no runtime')
69  const { exitCode, stdout, stderr } = await $.process.run([argv0, `${$.plugin.root}/${RENDERER}`], {
70    stdin: JSON.stringify({ format, sources }),
71    timeoutMs: RENDER_TIMEOUT_MS,
72  })
73  if (exitCode !== 0) throw new Error(`renderer exited ${exitCode}: ${stderr.slice(0, 200)}`)
74  return parseRendered(stdout, sources.length)
75}
76
77function remember(key: string, rendered: Promise<Rendered>): void {
78  cache.set(key, rendered)
79  for (const oldest of cache.keys()) {
80    if (cache.size <= CACHE_LIMIT) break
81    cache.delete(oldest)
82  }
83}
84
85function renderAll($: EngineInterface, format: Format, sources: string[]): Promise<Rendered[]> {
86  const keyOf = (source: string) => `${format}\0${source}`
87  const missing = [...new Set(sources)].filter(source => !cache.has(keyOf(source)))
88  if (missing.length > 0) {
89    const batch = runRenderer($, format, missing).then(
90      results => ({ isTransportFailure: false, results }),
91      (error: unknown) => ({
92        isTransportFailure: true,
93        results: missing.map((): Rendered => ({ ok: false, reason: String(error) })),
94      }),
95    )
96    missing.forEach((source, index) => {
97      const key = keyOf(source)
98      const rendered = batch.then(({ isTransportFailure, results }) => {
99        if (isTransportFailure && cache.get(key) === rendered) cache.delete(key)
100        return results[index] ?? { ok: false as const, reason: 'missing result' }
101      })
102      remember(key, rendered)
103    })
104  }
105  return Promise.all(
106    sources.map(source => cache.get(keyOf(source)) ?? Promise.resolve({ ok: false as const, reason: 'evicted' })),
107  )
108}
109
110function toBlocks(segments: Segment[], rendered: Rendered[]): Block[] {
111  const blocks: Block[] = []
112  let diagramIndex = 0
113  const pushMarkdown = (text: string) => {
114    const last = blocks.at(-1)
115    if (last?.kind !== 'markdown') blocks.push({ kind: 'markdown', text })
116    else last.text += (last.text.endsWith('\n') || text.startsWith('\n') ? '' : '\n') + text
117  }
118  for (const segment of segments) {
119    if (segment.kind === 'markdown') {
120      pushMarkdown(segment.text)
121      continue
122    }
123    const result = rendered[diagramIndex++]
124    if (result?.ok === true) {
125      const alt = segment.source.trim().split('\n')[0]?.trim() || 'mermaid diagram'
126      blocks.push({ kind: 'diagram', output: result.output, alt })
127    } else {
128      pushMarkdown(segment.fence)
129    }
130  }
131  return blocks
132}
133
134export const register: Register = on => {
135  on('ui.render', { component: 'AssistantMessage' }, async ($, e, next) => {
136    if (e.props.isSummary === true) return next(e)
137    const segments = parseSegments(e.props.text)
138    const sources = segments.flatMap(segment => (segment.kind === 'mermaid' ? [segment.source] : []))
139    if (sources.length === 0) return next(e)
140
141    const format = FORMAT_BY_SURFACE[e.surface]
142    const fitting = (result: Rendered): Rendered =>
143      result.ok && !FITS[format](result.output, e.viewport) ? { ok: false, reason: 'too wide' } : result
144    const blocks = toBlocks(segments, (await renderAll($, format, sources)).map(fitting))
145    if (blocks.every(block => block.kind === 'markdown')) return next(e)
146
147    return drawBlocks($, e, blocks)
148  }).catch(($, e, next) => next(e))
149}
150
151function diagramDrawer($: EngineInterface, e: RenderInput<'AssistantMessage'>): (diagram: Diagram) => RenderElement {
152  if (e.surface === 'terminal') {
153    const { Code } = $.ui.resolve(e)
154    return ({ output }) => <Code source={output} wrap="truncate-end" />
155  }
156  const { Svg } = $.ui.resolve(e)
157  return ({ output, alt }) => <Svg source={output} alt={alt} />
158}
159
160function drawBlocks($: EngineInterface, e: RenderInput<'AssistantMessage'>, blocks: Block[]): RenderElement {
161  const { Box, Markdown } = $.ui.resolve(e)
162  const drawDiagram = diagramDrawer($, e)
163  const body = (
164    <Box flexDirection="column" gap={1}>
165      {blocks.map(block => (block.kind === 'markdown' ? <Markdown text={block.text} /> : drawDiagram(block)))}
166    </Box>
167  )
168  if (e.surface !== 'terminal') return body
169  // The terminal's reply bullet and gutter belong to the component this tree replaces.
170  const { Text } = $.ui.resolve(e)
171  return (
172    <Box flexDirection="row">
173      <Text>{e.props.isFirstOfReply ? '⏺ ' : '  '}</Text>
174      {body}
175    </Box>
176  )
177}
178
hooks/segments.ts 61 lines
1export type Segment =
2  | { kind: 'markdown'; text: string }
3  | { kind: 'mermaid'; source: string; fence: string }
4
5const OPENER = /^( {0,3})(`{3,}|~{3,})(.*)$/
6
7type Fence = { char: string; length: number; indent: number; isMermaid: boolean; start: number; body: string[] }
8
9function openFence(line: string, start: number): Fence | undefined {
10  const match = OPENER.exec(line)
11  if (match === null) return undefined
12  const [, indent = '', marker = '', info = ''] = match
13  if (marker[0] === '`' && info.includes('`')) return undefined
14  const language = info.trim().split(/\s+/)[0]?.toLowerCase()
15  return { char: marker[0] ?? '`', length: marker.length, indent: indent.length, isMermaid: language === 'mermaid', start, body: [] }
16}
17
18function closes(fence: Fence, line: string): boolean {
19  const match = /^ {0,3}(`+|~+)[ \t]*$/.exec(line)
20  const marker = match?.[1]
21  return marker !== undefined && marker[0] === fence.char && marker.length >= fence.length
22}
23
24function dedent(line: string, indent: number): string {
25  let cut = 0
26  while (cut < indent && line[cut] === ' ') cut++
27  return line.slice(cut)
28}
29
30export function parseSegments(text: string): Segment[] {
31  const lines = text.split(/(?<=\n)/)
32  const segments: Segment[] = []
33  let fence: Fence | undefined
34  let offset = 0
35  let markdownStart = 0
36
37  for (const raw of lines) {
38    const line = raw.replace(/\r?\n$/, '')
39    if (fence === undefined) {
40      fence = openFence(line, offset)
41    } else if (closes(fence, line)) {
42      if (fence.isMermaid) {
43        segments.push({ kind: 'markdown', text: text.slice(markdownStart, fence.start) })
44        segments.push({
45          kind: 'mermaid',
46          source: fence.body.join('\n'),
47          fence: text.slice(fence.start, offset + line.length),
48        })
49        markdownStart = offset + line.length
50      }
51      fence = undefined
52    } else {
53      fence.body.push(dedent(line, fence.indent))
54    }
55    offset += raw.length
56  }
57  segments.push({ kind: 'markdown', text: text.slice(markdownStart) })
58
59  return segments.filter(segment => segment.kind === 'mermaid' || segment.text.trim() !== '')
60}
61