From e8a86f1590df910b8223bb32a99bf32b177f9031 Mon Sep 17 00:00:00 2001 From: Elliot DeNolf Date: Sat, 8 Aug 2026 15:23:51 -0400 Subject: [PATCH] feat(dot): render dot and graphviz fences as ascii diagrams Translate a subset of DOT into a mermaid flowchart and reuse the existing ascii renderer. Constructs with no flowchart equivalent (records, ports, HTML labels) fall through to a framed code block. --- src/app/lib/dot-to-mermaid.test.ts | 118 ++++++++++ src/app/lib/dot-to-mermaid.ts | 352 +++++++++++++++++++++++++++++ src/app/lib/loadDocument.ts | 4 +- src/app/lib/preprocess.test.ts | 21 +- src/app/lib/preprocess.ts | 30 ++- test/graph-digraph.md | 277 +++++++++++++++++++++++ 6 files changed, 793 insertions(+), 9 deletions(-) create mode 100644 src/app/lib/dot-to-mermaid.test.ts create mode 100644 src/app/lib/dot-to-mermaid.ts create mode 100644 test/graph-digraph.md diff --git a/src/app/lib/dot-to-mermaid.test.ts b/src/app/lib/dot-to-mermaid.test.ts new file mode 100644 index 0000000..6f2738b --- /dev/null +++ b/src/app/lib/dot-to-mermaid.test.ts @@ -0,0 +1,118 @@ +import { describe, expect, test } from 'bun:test' +import { renderMermaidAscii } from 'beautiful-mermaid' +import { UnsupportedDotError, dotToMermaid } from './dot-to-mermaid' + +describe('dotToMermaid', () => { + test('translates nodes, edges, and edge labels', () => { + const out = dotToMermaid('digraph g { a -> b [label="go"]; }') + expect(out).toBe('flowchart TD\n a[a]\n b[b]\n a -->|go| b') + }) + + test('an edge chain expands to one edge per hop', () => { + const out = dotToMermaid('digraph g { a -> b -> c; }') + expect(out).toContain('a --> b') + expect(out).toContain('b --> c') + }) + + test('rankdir maps onto flowchart direction', () => { + expect(dotToMermaid('digraph g { rankdir=LR; a -> b; }')).toStartWith('flowchart LR') + expect(dotToMermaid('digraph g { graph [rankdir=BT]; a -> b; }')).toStartWith('flowchart BT') + }) + + test('quoted labels override the node id', () => { + expect(dotToMermaid('digraph g { a [label="Parse it"]; a -> b; }')).toContain('a[Parse it]') + }) + + test('shapes map onto flowchart node wrappers', () => { + expect(dotToMermaid('digraph g { a [shape=diamond]; a -> b; }')).toContain('a{a}') + expect(dotToMermaid('digraph g { node [shape=circle]; a -> b; }')).toContain('a((a))') + }) + + test('cluster subgraphs become mermaid subgraphs; plain ones only scope', () => { + const clustered = dotToMermaid('digraph g { subgraph cluster_x { label="X"; a; } a -> b; }') + expect(clustered).toContain('subgraph sg0[X]') + expect(clustered).toContain('end') + + expect(dotToMermaid('digraph g { subgraph plain { a; } a -> b; }')).not.toContain('subgraph') + }) + + test('ids that are not bare words are rewritten and kept unique', () => { + const out = dotToMermaid('digraph g { "a b" -> "a-b"; }') + expect(out).toContain('a_b[a b]') + expect(out).toContain('a_b_2[a-b]') + expect(out).toContain('a_b --> a_b_2') + }) + + test('only the wrapper it sits in is stripped from a label', () => { + // `]` would end the node early and swallow the rest of the graph; `|` and `(` are harmless. + expect(dotToMermaid('digraph g { a [label="x|y(z)]w"]; a -> b; }')).toContain('a[x|y(z) w]') + expect(dotToMermaid('digraph g { a [label="p}q", shape=diamond]; a -> b; }')).toContain( + 'a{p q}', + ) + }) + + test('an edge label only drops the pipe that would close it early', () => { + expect(dotToMermaid('digraph g { a -> b [label="x|y (z)"]; }')).toContain('a -->|x y (z)| b') + }) + + test('comments and `strict` are tolerated', () => { + const out = dotToMermaid('strict digraph g {\n // note\n /* block */\n a -> b;\n}') + expect(out).toContain('a --> b') + }) + + test('undirected graphs translate as directed edges', () => { + expect(dotToMermaid('graph g { a -- b; }')).toContain('a --> b') + }) + + test.each([ + ['not DOT at all', 'flowchart TD\n a --> b'], + ['record shapes', 'digraph g { a [shape=record, label="x"]; }'], + ['record ports', 'digraph g { a:f0 -> b; }'], + ['HTML labels', 'digraph g { a [label=<x>]; }'], + ['an empty graph', 'digraph g { }'], + ['an unterminated body', 'digraph g { a -> b;'], + ])('rejects %s', (_name, dot) => { + expect(() => dotToMermaid(dot)).toThrow(UnsupportedDotError) + }) +}) + +/** + * A DOT graph and its hand-written mermaid twin must paint the same ASCII, so + * `dot` fences are not a second-class diagram. Sources mirror `test/mermaid.md`. + */ +describe('dot renders identically to equivalent mermaid', () => { + const render = (src: string) => + renderMermaidAscii(src) + .split('\n') + .map(l => l.trimEnd()) + .join('\n') + .trim() + + test.each([ + [ + 'decision flow', + 'graph TD\n A[Start] --> B{Decision}\n B -->|Yes| C[Process]\n B -->|No| D[End]\n C --> D', + 'digraph g {\n A [label="Start"]; B [label="Decision", shape=diamond];\n C [label="Process"]; D [label="End"];\n A -> B; B -> C [label="Yes"]; B -> D [label="No"]; C -> D;\n}', + ], + ['chain LR', 'graph LR\n A --> B --> C --> D', 'digraph g { rankdir=LR; A -> B -> C -> D; }'], + ['chain BT', 'graph BT\n A --> B --> C', 'digraph g { rankdir=BT; A -> B -> C; }'], + ['chain RL', 'graph RL\n A --> B --> C', 'digraph g { rankdir=RL; A -> B -> C; }'], + [ + 'fan-out and fan-in', + 'graph TD\n ast --> render\n ast --> toc\n render --> viewer\n toc --> viewer', + 'digraph g { ast -> render; ast -> toc; render -> viewer; toc -> viewer; }', + ], + [ + 'edge labels', + 'graph LR\n A -->|go| B\n B -->|go| C', + 'digraph g { rankdir=LR; A -> B -> C [label="go"]; }', + ], + [ + 'subgraphs', + 'graph TD\n subgraph frontend\n a1[UI] --> a2[Router]\n end\n subgraph backend\n b1[API] --> b2[DB]\n end\n a2 --> b1', + 'digraph g {\n subgraph cluster_f { label="frontend"; a1 [label="UI"]; a2 [label="Router"]; a1 -> a2; }\n subgraph cluster_b { label="backend"; b1 [label="API"]; b2 [label="DB"]; b1 -> b2; }\n a2 -> b1;\n}', + ], + ])('%s', (_name, mermaid, dot) => { + expect(render(dotToMermaid(dot))).toBe(render(mermaid)) + }) +}) diff --git a/src/app/lib/dot-to-mermaid.ts b/src/app/lib/dot-to-mermaid.ts new file mode 100644 index 0000000..de80f08 --- /dev/null +++ b/src/app/lib/dot-to-mermaid.ts @@ -0,0 +1,352 @@ +/** + * Translates the DOT subset that maps cleanly onto a Mermaid `flowchart` so + * `dot` fences can reuse the existing Mermaid ASCII renderer. Records, ports, + * and HTML labels have no flowchart equivalent and are rejected, not degraded. + */ + +type NodeAttrs = { + label: string + shape?: string +} + +type Edge = { + from: string + to: string + label?: string +} + +type Cluster = { + label?: string + nodeIds: string[] +} + +export class UnsupportedDotError extends Error {} + +const DIRECTIONS: Record = { TB: 'TD', TD: 'TD', BT: 'BT', LR: 'LR', RL: 'RL' } + +/** + * @throws {UnsupportedDotError} when the source is not DOT, or uses a construct + * with no flowchart equivalent. + */ +export function dotToMermaid(dot: string): string { + const tokens = tokenize(dot) + const graph = parseGraph(tokens) + return emitMermaid(graph) +} + +// --------------------------------------------------------------------------- +// Lexer + +type Token = { kind: 'id' | 'punct'; value: string; quoted: boolean } + +const PUNCT = new Set(['{', '}', '[', ']', ';', ',', '=', ':']) + +function tokenize(src: string): Token[] { + const tokens: Token[] = [] + let i = 0 + while (i < src.length) { + const ch = src[i] + + if (ch === undefined) break + if (/\s/.test(ch)) { + i++ + continue + } + if (ch === '#' || src.startsWith('//', i)) { + const nl = src.indexOf('\n', i) + i = nl === -1 ? src.length : nl + continue + } + if (src.startsWith('/*', i)) { + const end = src.indexOf('*/', i + 2) + i = end === -1 ? src.length : end + 2 + continue + } + if (src.startsWith('->', i) || src.startsWith('--', i)) { + tokens.push({ kind: 'punct', value: '->', quoted: false }) + i += 2 + continue + } + if (ch === '"') { + const { value, next } = readQuoted(src, i) + tokens.push({ kind: 'id', value, quoted: true }) + i = next + continue + } + if (ch === '<') throw new UnsupportedDotError('HTML labels are not supported') + if (PUNCT.has(ch)) { + tokens.push({ kind: 'punct', value: ch, quoted: false }) + i++ + continue + } + + const match = /^[A-Za-z0-9_.\-+]+/.exec(src.slice(i)) + if (!match) throw new UnsupportedDotError(`unexpected character ${JSON.stringify(ch)}`) + tokens.push({ kind: 'id', value: match[0], quoted: false }) + i += match[0].length + } + return tokens +} + +function readQuoted(src: string, start: number): { value: string; next: number } { + let out = '' + let i = start + 1 + while (i < src.length) { + const ch = src[i] + if (ch === '\\') { + const escaped = src[i + 1] + // \l \r \n are DOT line breaks; the ASCII renderer is single-line per node. + out += escaped === 'l' || escaped === 'r' || escaped === 'n' ? ' ' : (escaped ?? '') + i += 2 + continue + } + if (ch === '"') return { value: out, next: i + 1 } + out += ch + i++ + } + throw new UnsupportedDotError('unterminated quoted string') +} + +// --------------------------------------------------------------------------- +// Parser + +type Graph = { + direction: string + nodes: Map + edges: Edge[] + clusters: Cluster[] +} + +type Scope = { shape?: string } + +function parseGraph(tokens: Token[]): Graph { + let pos = 0 + + const peek = (offset = 0): Token | undefined => tokens[pos + offset] + const next = (): Token | undefined => tokens[pos++] + + const graph: Graph = { direction: 'TD', nodes: new Map(), edges: [], clusters: [] } + + if (peek()?.value === 'strict') pos++ + const kind = next() + if (kind?.value !== 'digraph' && kind?.value !== 'graph') { + throw new UnsupportedDotError('not a DOT graph') + } + if (peek()?.kind === 'id') pos++ // optional graph name + if (next()?.value !== '{') throw new UnsupportedDotError('expected `{`') + + parseStatements({}) + return graph + + /** Consumes statements up to the matching `}`, recursing into subgraphs. */ + function parseStatements(scope: Scope, cluster?: Cluster): void { + while (pos < tokens.length) { + const token = peek() + if (!token) break + if (token.value === '}') { + pos++ + return + } + if (token.value === ';' || token.value === ',') { + pos++ + continue + } + parseStatement(scope, cluster) + } + throw new UnsupportedDotError('unterminated graph body') + } + + function parseStatement(scope: Scope, cluster?: Cluster): void { + const token = peek() + if (!token) return + + if (!token.quoted && token.value === 'subgraph') { + pos++ + const name = peek()?.kind === 'id' ? next()?.value : undefined + if (next()?.value !== '{') throw new UnsupportedDotError('expected `{` after subgraph') + // Only `cluster*` subgraphs draw a box in Graphviz; others are scope-only. + const isCluster = name?.startsWith('cluster') ?? false + const child: Cluster | undefined = isCluster ? { nodeIds: [] } : cluster + parseStatements({ ...scope }, child) + if (isCluster && child) graph.clusters.push(child) + return + } + + if ( + !token.quoted && + (token.value === 'node' || token.value === 'edge' || token.value === 'graph') + ) { + pos++ + const attrs = parseAttrList() + if (token.value === 'node' && attrs.shape) scope.shape = attrs.shape + if (token.value === 'graph') applyGraphAttrs(attrs, cluster) + return + } + + // `rankdir = LR` / `label = "…"` at statement level. + if (peek(1)?.value === '=') { + const key = next()?.value ?? '' + pos++ + const value = next()?.value ?? '' + applyGraphAttrs({ [key]: value }, cluster) + return + } + + parseNodeOrEdge(scope, cluster) + } + + function parseNodeOrEdge(scope: Scope, cluster?: Cluster): void { + const chain: string[] = [readNodeId()] + while (peek()?.value === '->') { + pos++ + chain.push(readNodeId()) + } + const attrs = peek()?.value === '[' ? parseAttrList() : {} + + for (const id of chain) { + declareNode({ id, attrs: chain.length === 1 ? attrs : {}, scope, cluster }) + } + for (let i = 0; i < chain.length - 1; i++) { + const from = chain[i] + const to = chain[i + 1] + if (from === undefined || to === undefined) continue + graph.edges.push({ from, to, label: attrs.label }) + } + } + + function readNodeId(): string { + const token = next() + if (!token || token.kind !== 'id') throw new UnsupportedDotError('expected a node id') + if (peek()?.value === ':') throw new UnsupportedDotError('record ports are not supported') + return token.value + } + + function parseAttrList(): Record { + const attrs: Record = {} + while (peek()?.value === '[') { + pos++ + while (peek() && peek()?.value !== ']') { + const token = next() + if (!token) break + if (token.value === ',' || token.value === ';') continue + if (peek()?.value === '=') { + pos++ + const value = next() + if (value) attrs[token.value] = value.value + } + } + pos++ // closing ] + } + return attrs + } + + function applyGraphAttrs(attrs: Record, cluster?: Cluster): void { + const rankdir = attrs.rankdir?.toUpperCase() + const direction = rankdir ? DIRECTIONS[rankdir] : undefined + if (direction) graph.direction = direction + if (cluster && attrs.label !== undefined) cluster.label = attrs.label + } + + function declareNode(params: { + id: string + attrs: Record + scope: Scope + cluster?: Cluster + }): void { + const { id, attrs, scope, cluster } = params + const shape = attrs.shape ?? scope.shape + if (shape === 'record' || shape === 'Mrecord') { + throw new UnsupportedDotError('record shapes are not supported') + } + const existing = graph.nodes.get(id) + graph.nodes.set(id, { + label: attrs.label ?? existing?.label ?? id, + shape: shape ?? existing?.shape, + }) + if (cluster && !cluster.nodeIds.includes(id)) cluster.nodeIds.push(id) + } +} + +// --------------------------------------------------------------------------- +// Emitter + +const SHAPE_WRAPPERS: Record = { + box: ['[', ']'], + rect: ['[', ']'], + rectangle: ['[', ']'], + square: ['[', ']'], + circle: ['((', '))'], + doublecircle: ['((', '))'], + ellipse: ['(', ')'], + oval: ['(', ')'], + diamond: ['{', '}'], + cylinder: ['[(', ')]'], +} + +function emitMermaid(graph: Graph): string { + if (graph.nodes.size === 0) throw new UnsupportedDotError('graph has no nodes') + + const ids = safeIds([...graph.nodes.keys()]) + const lines = [`flowchart ${graph.direction}`] + const clustered = new Set(graph.clusters.flatMap(c => c.nodeIds)) + + for (const [id, attrs] of graph.nodes) { + if (clustered.has(id)) continue + lines.push(` ${declaration({ id: ids.get(id) ?? id, attrs })}`) + } + + graph.clusters.forEach((cluster, index) => { + const title = escapeLabel(cluster.label ?? '', ']') + lines.push(` subgraph sg${index}${title ? `[${title}]` : ''}`) + for (const id of cluster.nodeIds) { + const attrs = graph.nodes.get(id) + if (attrs) lines.push(` ${declaration({ id: ids.get(id) ?? id, attrs })}`) + } + lines.push(' end') + }) + + for (const edge of graph.edges) { + const from = ids.get(edge.from) ?? edge.from + const to = ids.get(edge.to) ?? edge.to + const label = edge.label ? `|${escapeLabel(edge.label, '|')}|` : '' + lines.push(` ${from} -->${label} ${to}`) + } + + return lines.join('\n') +} + +function declaration(params: { id: string; attrs: NodeAttrs }): string { + const { id, attrs } = params + const [open, close] = SHAPE_WRAPPERS[attrs.shape ?? ''] ?? ['[', ']'] + return `${id}${open}${escapeLabel(attrs.label, close)}${close}` +} + +/** Mermaid ids are bare words; DOT ids are not, so map them and keep them unique. */ +function safeIds(dotIds: string[]): Map { + const out = new Map() + const taken = new Set() + for (const id of dotIds) { + let safe = id.replace(/[^A-Za-z0-9_]/g, '_') + if (safe === '' || /^[0-9]/.test(safe)) safe = `n_${safe}` + let candidate = safe + let n = 2 + while (taken.has(candidate)) candidate = `${safe}_${n++}` + taken.add(candidate) + out.set(id, candidate) + } + return out +} + +/** + * Labels are emitted unquoted, because the ASCII renderer prints quotes + * literally. Only the characters that close the surrounding wrapper have to go + * — one of them silently truncates the rest of the graph, not just the label. + */ +function escapeLabel(label: string, closers: string): string { + const forbidden = new Set(closers) + return [...label] + .map(ch => (forbidden.has(ch) ? ' ' : ch)) + .join('') + .replace(/\s+/g, ' ') + .trim() +} diff --git a/src/app/lib/loadDocument.ts b/src/app/lib/loadDocument.ts index ac5f308..da9558b 100644 --- a/src/app/lib/loadDocument.ts +++ b/src/app/lib/loadDocument.ts @@ -4,7 +4,7 @@ import type { Node, TocEntry } from './ast' import { FRONTMATTER_ID, parseFrontmatter, splitFrontmatter } from './frontmatter' import type { FrontmatterRow } from './frontmatter' import { computeHeadingLines, countNewlines } from './headingLines' -import { replaceMermaidBlocks } from './preprocess' +import { replaceDotBlocks, replaceMermaidBlocks } from './preprocess' export type LoadedDocument = { nodes: Node[] @@ -24,7 +24,7 @@ export function buildDocument(md: string, filePath?: string): LoadedDocument { const { frontmatter, body } = splitFrontmatter(md) const offset = countNewlines(md.slice(0, md.length - body.length)) const headingLines = computeHeadingLines({ body, offset }) - const processed = replaceMermaidBlocks(body) + const processed = replaceDotBlocks(replaceMermaidBlocks(body)) const { nodes, toc, headingIds } = buildTree(processed) const rows: FrontmatterRow[] = frontmatter ? parseFrontmatter(frontmatter) : [] const absPath = filePath ? resolve(filePath) : undefined diff --git a/src/app/lib/preprocess.test.ts b/src/app/lib/preprocess.test.ts index d720aac..5a5a7e3 100644 --- a/src/app/lib/preprocess.test.ts +++ b/src/app/lib/preprocess.test.ts @@ -1,5 +1,5 @@ import { describe, expect, test } from 'bun:test' -import { MERMAID_ASCII_LANG, replaceMermaidBlocks } from './preprocess' +import { MERMAID_ASCII_LANG, replaceDotBlocks, replaceMermaidBlocks } from './preprocess' describe('replaceMermaidBlocks', () => { test('converts mermaid fence to ascii art under the rendered-ascii info string', () => { @@ -16,3 +16,22 @@ describe('replaceMermaidBlocks', () => { expect(out).toBe(md) }) }) + +describe('replaceDotBlocks', () => { + test.each(['dot', 'graphviz'])('renders a %s fence as ascii art', lang => { + const md = '```' + lang + '\ndigraph g { a -> b; }\n```' + const out = replaceDotBlocks(md) + expect(out.startsWith('```' + MERMAID_ASCII_LANG + '\n')).toBe(true) + expect(out).not.toContain('digraph') + }) + + test('untranslatable dot is left unchanged', () => { + const md = '```dot\ndigraph g { a [shape=record, label="x"]; }\n```' + expect(replaceDotBlocks(md)).toBe(md) + }) + + test('non-dot fences are untouched', () => { + const md = '```ts\nconst a = 1\n```' + expect(replaceDotBlocks(md)).toBe(md) + }) +}) diff --git a/src/app/lib/preprocess.ts b/src/app/lib/preprocess.ts index 75b1aae..291af7c 100644 --- a/src/app/lib/preprocess.ts +++ b/src/app/lib/preprocess.ts @@ -1,4 +1,5 @@ import { renderMermaidAscii } from 'beautiful-mermaid' +import { dotToMermaid } from './dot-to-mermaid' /** * Fence lang marking a pre-rendered mermaid ASCII block. It carries its own @@ -8,17 +9,34 @@ import { renderMermaidAscii } from 'beautiful-mermaid' export const MERMAID_ASCII_LANG = 'mermaidascii' const MERMAID_BLOCK_REGEX = /```mermaid\s*\n([\s\S]*?)```/g +const DOT_BLOCK_REGEX = /```(?:dot|graphviz)\s*\n([\s\S]*?)```/g export function replaceMermaidBlocks(markdown: string): string { - return markdown.replace(MERMAID_BLOCK_REGEX, (raw, diagram: string) => { + return markdown.replace( + MERMAID_BLOCK_REGEX, + (raw, diagram: string) => renderBlock(diagram) ?? raw, + ) +} + +/** DOT reaches the ASCII renderer by translation; untranslatable DOT stays a code block. */ +export function replaceDotBlocks(markdown: string): string { + return markdown.replace(DOT_BLOCK_REGEX, (raw, diagram: string) => { try { - const ascii = renderMermaidAscii(diagram.trim()) - .split('\n') - .map(l => l.trimEnd()) - .join('\n') - return '```' + MERMAID_ASCII_LANG + '\n' + ascii + '\n```' + return renderBlock(dotToMermaid(diagram)) ?? raw } catch { return raw } }) } + +function renderBlock(diagram: string): string | undefined { + try { + const ascii = renderMermaidAscii(diagram.trim()) + .split('\n') + .map(l => l.trimEnd()) + .join('\n') + return '```' + MERMAID_ASCII_LANG + '\n' + ascii + '\n```' + } catch { + return undefined + } +} diff --git a/test/graph-digraph.md b/test/graph-digraph.md new file mode 100644 index 0000000..c1719d2 --- /dev/null +++ b/test/graph-digraph.md @@ -0,0 +1,277 @@ +# DOT Digraph Rendering Test + +Exercise of the DOT subset `viewmd` renders. `replaceDotBlocks` translates each +`dot` / `graphviz` fence into a mermaid `flowchart` (`dotToMermaid`), then hands +it to `beautiful-mermaid` for the ASCII pass — the same renderer behind +`test/mermaid.md`. + +DOT constructs with no flowchart equivalent (records, ports, HTML labels) are +rejected rather than approximated; those blocks degrade to their raw source — +see the last section. + +## Fence Languages + +### `dot` + +```dot +digraph g { + a -> b; +} +``` + +### `graphviz` + +```graphviz +digraph g { + a -> b; +} +``` + +## Direction (`rankdir`) + +### Default (top-down) + +```dot +digraph g { + a -> b -> c; +} +``` + +### Left-right + +```dot +digraph g { + rankdir=LR; + a -> b -> c; +} +``` + +### Bottom-top + +```dot +digraph g { + rankdir=BT; + a -> b -> c; +} +``` + +### Right-left + +```dot +digraph g { + rankdir=RL; + a -> b -> c; +} +``` + +### Set via a `graph` attribute statement + +```dot +digraph g { + graph [rankdir=LR]; + a -> b; +} +``` + +## Nodes + +### Bare ids become their own label + +```dot +digraph g { + parse -> render; +} +``` + +### Explicit labels + +```dot +digraph g { + a [label="Read file"]; + b [label="Build AST"]; + a -> b; +} +``` + +### Quoted ids with spaces and punctuation + +```dot +digraph g { + "read file" -> "build-ast" -> "paint frame"; +} +``` + +### Shapes + +`beautiful-mermaid` paints every flowchart node as a rectangle, so shape is +carried through the translation but does not change the ASCII output. + +```dot +digraph g { + box [shape=box]; + round [shape=ellipse]; + circle [shape=circle]; + decision [shape=diamond]; + box -> round -> circle -> decision; +} +``` + +### Default shape via a `node` statement + +```dot +digraph g { + node [shape=box]; + a -> b; +} +``` + +## Edges + +### Chains expand to one edge per hop + +```dot +digraph g { + a -> b -> c -> d; +} +``` + +### Edge labels + +```dot +digraph g { + parse -> ast [label="tokens"]; + ast -> render [label="nodes"]; +} +``` + +### Fan-out and fan-in + +```dot +digraph g { + ast -> render; + ast -> toc; + render -> viewer; + toc -> viewer; +} +``` + +### Undirected graphs + +`graph` with `--` edges translates to arrows; the flowchart renderer has no +undirected edge. + +```dot +graph g { + a -- b -- c; +} +``` + +### `strict` prefix + +```dot +strict digraph g { + a -> b; + a -> b; +} +``` + +## Subgraphs + +### `cluster*` subgraphs draw a box + +```dot +digraph g { + rankdir=LR; + subgraph cluster_input { + label="input"; + a; + b; + } + a -> c; + b -> c; +} +``` + +### Plain subgraphs only scope, matching Graphviz + +```dot +digraph g { + subgraph plain { + a; + } + a -> b; +} +``` + +## Comments + +```dot +digraph g { + // line comment + # hash comment + /* block + comment */ + a -> b; +} +``` + +## A Realistic Pipeline + +```dot +digraph viewmd { + rankdir=TB; + node [shape=box]; + + file [label="markdown"]; + file -> preprocess [label="raw"]; + preprocess -> ast [label="fences swapped"]; + ast -> toc; + ast -> viewer; + toc -> viewer [label="jump"]; +} +``` + +## Graceful Degradation (unsupported constructs) + +`dotToMermaid` throws `UnsupportedDotError` for these, and `replaceDotBlocks` +leaves the fence untouched so it renders as a framed code block. + +### Record shapes + +```dot +digraph g { + x [shape=record, label="left|right"]; + x -> y; +} +``` + +### Record ports + +```dot +digraph g { + a:f0 -> b:f1; +} +``` + +### HTML labels + +```dot +digraph g { + a [label=<bold>]; + a -> b; +} +``` + +### Empty graph + +```dot +digraph g { +} +``` + +### Malformed source + +```dot +digraph g { + a -> b; +```