tui-mermaid
v0.1.0
Published
Render mermaid diagram text as Unicode box-drawing art for terminals. TypeScript port of xAI grok-build's mermaid renderer.
Readme
mermaid-tui
Render mermaid diagram text as Unicode box-drawing art for terminals. Flowcharts, state diagrams, class diagrams, ER diagrams, and sequence diagrams become plain-text (or ANSI-colored) box art with a semantic role tagged on every span, so any terminal UI framework can style the output itself. Zero runtime dependencies, ESM-only, Node >= 24.
What this is
A TypeScript port of the mermaid renderer from
xai-org/grok-build
(crates/codegen/xai-grok-markdown/src/mermaid.rs), licensed Apache-2.0.
Test fixtures and Unicode-width oracle data are vendored from the Go port,
spacedock-dev/mermaidtext,
also Apache-2.0 — its fixture corpus and porting discipline (golden-fixture
parity, exhaustive width-oracle tests, documented deviations) shaped how this
port was built and verified.
See NOTICE and THIRD_PARTY_NOTICES.md for full attribution, and PORTING.md for the pinned upstream commits, the file-by-file Rust-to-TypeScript map, and every documented behavioral deviation.
Install
npm install mermaid-tuiCLI usage
npm run build
printf 'graph TD\n A[Start] --> B{OK?}\n B --> C[Done]\n' | node dist/cliMain.jsOutput:
┌───────┐
│ Start │
└───┬───┘
│
▼
╭─────╮
│ OK? │
╰──┬──╯
│
▼
┌──────┐
│ Done │
└──────┘(Once installed as a dependency, the same binary is available as mermaid-tui
via the package's bin entry, or npx mermaid-tui.)
CLI flags
| Flag | Description |
| ------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| -w, --width <n> | Max render width in columns. Decimal digits only; 0 or omitted means unbounded. Diagrams that don't fit fall back to a framed raw-source box with a wrap hint. |
| --color | Force ANSI-colored output, even when stdout is not a TTY. |
| --no-color | Force plain-text output, even when stdout is a TTY. |
| <file> | Optional positional file argument. Reads from stdin when omitted. |
Exit codes: 0 on success (including blank input, which prints nothing);
1 with a message on stderr for an unreadable file or an invalid --width.
Library usage
import { render, toAnsi } from 'mermaid-tui';
const art = render('graph TD\nA-->B\n', { maxWidth: 80 });
// ^ Art | undefined — undefined only for blank/whitespace-only input
if (art !== undefined) {
console.log(art.plainLines.join('\n')); // plain text
console.log(toAnsi(art)); // ANSI-colored for a TTY
}Art shape:
interface Art {
lines: Span[][]; // per-line spans: { text: string; role: Role }
plainLines: string[]; // same content, flattened to plain strings
diagramType: string; // e.g. 'flowchart', 'sequenceDiagram', or the
// fallback source's first word
fallback: boolean; // true if this is the raw-source fallback box
fallbackReason?: 'unsupported' | 'tooWide';
}
type Role = 'plain' | 'border' | 'nodeText' | 'edge' | 'edgeLabel' | 'title';Consumers that want their own styling read art.lines directly and map each
span's role to a color/attribute; toAnsi is provided for callers who just
want ANSI escapes for a plain terminal.
Integration examples
These are illustrative role→style maps for popular terminal-UI frameworks. They are untested — none of them are exercised by this repo's test suite — and are included to show the shape of the integration, not as verified code.
Ink
import { Text } from 'ink';
import { render } from 'mermaid-tui';
const COLOR: Record<string, string> = {
border: 'gray',
edge: 'gray',
edgeLabel: 'cyan',
title: 'white',
nodeText: 'white',
plain: 'white',
};
function Diagram({ source }: { source: string }) {
const art = render(source);
if (!art) return null;
return (
<>
{art.lines.map((line, i) => (
<Text key={i}>
{line.map((s, j) => (
<Text
key={j}
color={COLOR[s.role]}
bold={s.role === 'title'}
>
{s.text}
</Text>
))}
</Text>
))}
</>
);
}OpenTUI
import { render } from 'mermaid-tui';
import type { TextChunk } from '@opentui/core';
const FG: Record<string, string> = {
border: '#808080',
edge: '#808080',
edgeLabel: '#00ffff',
title: '#ffffff',
nodeText: '#ffffff',
plain: '#ffffff',
};
function toChunks(source: string): TextChunk[][] {
const art = render(source);
if (!art) return [];
return art.lines.map((line) => line.map((s) => ({ text: s.text, fg: FG[s.role] }) as TextChunk));
// Each inner array of TextChunks maps 1:1 to a <span fg="..."> run per line.
}Rezi
import { render } from 'mermaid-tui';
import { ui } from 'rezi';
const STYLE: Record<string, string> = {
border: 'gray',
edge: 'gray',
edgeLabel: 'cyan',
title: 'bold white',
nodeText: 'white',
plain: 'white',
};
function diagramRichText(source: string) {
const art = render(source);
if (!art) return ui.richText([]);
return ui.richText(
art.lines.flatMap((line) => [
...line.map((s) => ({ text: s.text, style: STYLE[s.role] })),
{ text: '\n', style: 'plain' },
]),
);
}Supported diagrams
| Family | Header(s) | Notes |
| ---------------- | --------------------------------- | -------------------------------------------------------------------------------- |
| Flowchart | graph, flowchart | All directions (TD/BT/LR/RL), shapes, arrow/line kinds, nested subgraph frames |
| State diagram | stateDiagram, stateDiagram-v2 | Reuses flowchart layout; composite states flatten |
| Class diagram | classDiagram | Members, relations, generics, annotations |
| ER diagram | erDiagram | Entities, relationships, cardinalities, attributes |
| Sequence diagram | sequenceDiagram | Participants, messages, notes, self-loops |
Anything else — an unrecognized header, or a diagram that fails to parse — renders as a framed raw-source fallback box instead of an error.
Limits
Diagrams exceeding these caps render as the fallback box rather than failing:
| Constant | Value |
| ------------------ | -------------------------------------- |
| MAX_NODES | 128 |
| MAX_EDGES | 512 |
| MAX_GROUPS | 24 |
| MAX_GROUP_DEPTH | 6 |
| MAX_CANVAS_CELLS | 2^21 |
| MAX_LABEL | 28 (label wrap width) |
| MAX_LINES | 4 (label wrap lines before truncation) |
Parity
Plain-text output (plainLines) is byte-identical to the pinned upstream Rust
renderer for every vendored golden fixture (38/38) — the styled lines are
role-tagged spans with no upstream byte representation — and every one of
upstream's 145 tests is ported and passing. See PORTING.md for the full parity
statement, the pinned commits, and every documented deviation from upstream
behavior.
