Repository: lukilabs/beautiful-mermaid Branch: main Commit: 65f4e0ab8c28 Files: 196 Total size: 1.5 MB Directory structure: gitextract_riz8wh68/ ├── .github/ │ └── workflows/ │ ├── ci.yml │ └── publish.yml ├── .gitignore ├── LICENSE ├── README.md ├── bench.ts ├── dev.ts ├── index.ts ├── package.json ├── samples-data.ts ├── src/ │ ├── __tests__/ │ │ ├── ascii-edge-styles.test.ts │ │ ├── ascii-multiline.test.ts │ │ ├── ascii.test.ts │ │ ├── class-arrow-directions.test.ts │ │ ├── class-integration.test.ts │ │ ├── class-parser.test.ts │ │ ├── edge-approach-direction.test.ts │ │ ├── er-integration.test.ts │ │ ├── er-parser.test.ts │ │ ├── integration.test.ts │ │ ├── layout-disconnected.test.ts │ │ ├── linkstyle.test.ts │ │ ├── multiline-labels.test.ts │ │ ├── parser.test.ts │ │ ├── renderer.test.ts │ │ ├── sequence-integration.test.ts │ │ ├── sequence-layout.test.ts │ │ ├── sequence-parser.test.ts │ │ ├── styles.test.ts │ │ ├── testdata/ │ │ │ ├── ascii/ │ │ │ │ ├── ampersand_lhs.txt │ │ │ │ ├── ampersand_lhs_and_rhs.txt │ │ │ │ ├── ampersand_rhs.txt │ │ │ │ ├── ampersand_td_fanin.txt │ │ │ │ ├── ampersand_td_fanout.txt │ │ │ │ ├── ampersand_without_edge.txt │ │ │ │ ├── back_reference_from_child.txt │ │ │ │ ├── backlink_from_bottom.txt │ │ │ │ ├── backlink_from_top.txt │ │ │ │ ├── backlink_with_short_y_padding.txt │ │ │ │ ├── cls_all_relationships.txt │ │ │ │ ├── cls_annotation.txt │ │ │ │ ├── cls_association.txt │ │ │ │ ├── cls_basic.txt │ │ │ │ ├── cls_dependency.txt │ │ │ │ ├── cls_inheritance.txt │ │ │ │ ├── cls_methods.txt │ │ │ │ ├── comments.txt │ │ │ │ ├── custom_padding.txt │ │ │ │ ├── duplicate_labels.txt │ │ │ │ ├── er_attributes.txt │ │ │ │ ├── er_basic.txt │ │ │ │ ├── er_identifying.txt │ │ │ │ ├── flowchart_tb_simple.txt │ │ │ │ ├── graph_bt_direction.txt │ │ │ │ ├── graph_tb_direction.txt │ │ │ │ ├── nested_subgraphs_with_labels.txt │ │ │ │ ├── preserve_order_of_definition.txt │ │ │ │ ├── self_reference.txt │ │ │ │ ├── self_reference_with_edge.txt │ │ │ │ ├── seq_basic.txt │ │ │ │ ├── seq_multiple_messages.txt │ │ │ │ ├── seq_self_message.txt │ │ │ │ ├── single_node.txt │ │ │ │ ├── single_node_longer_name.txt │ │ │ │ ├── subgraph_complex_mixed.txt │ │ │ │ ├── subgraph_complex_nested.txt │ │ │ │ ├── subgraph_direction_override.txt │ │ │ │ ├── subgraph_empty.txt │ │ │ │ ├── subgraph_mixed_nodes.txt │ │ │ │ ├── subgraph_mixed_nodes_td.txt │ │ │ │ ├── subgraph_multiple_edges.txt │ │ │ │ ├── subgraph_multiple_nodes.txt │ │ │ │ ├── subgraph_nested.txt │ │ │ │ ├── subgraph_nested_with_external.txt │ │ │ │ ├── subgraph_node_outside_lr.txt │ │ │ │ ├── subgraph_single_node.txt │ │ │ │ ├── subgraph_td_direction.txt │ │ │ │ ├── subgraph_td_multiple.txt │ │ │ │ ├── subgraph_td_multiple_paddingy.txt │ │ │ │ ├── subgraph_three_levels_nested.txt │ │ │ │ ├── subgraph_three_separate.txt │ │ │ │ ├── subgraph_two_separate.txt │ │ │ │ ├── subgraph_with_labels.txt │ │ │ │ ├── three_nodes.txt │ │ │ │ ├── three_nodes_single_line.txt │ │ │ │ ├── two_layer_single_graph.txt │ │ │ │ ├── two_layer_single_graph_longer_names.txt │ │ │ │ ├── two_nodes_linked.txt │ │ │ │ ├── two_nodes_longer_names.txt │ │ │ │ ├── two_root_nodes.txt │ │ │ │ ├── two_root_nodes_longer_names.txt │ │ │ │ └── two_single_root_nodes.txt │ │ │ └── unicode/ │ │ │ ├── ampersand_lhs.txt │ │ │ ├── ampersand_lhs_and_rhs.txt │ │ │ ├── ampersand_rhs.txt │ │ │ ├── ampersand_without_edge.txt │ │ │ ├── back_reference_from_child.txt │ │ │ ├── backlink_from_bottom.txt │ │ │ ├── backlink_from_top.txt │ │ │ ├── cls_all_relationships.txt │ │ │ ├── cls_annotation.txt │ │ │ ├── cls_association.txt │ │ │ ├── cls_basic.txt │ │ │ ├── cls_dependency.txt │ │ │ ├── cls_inheritance.txt │ │ │ ├── cls_methods.txt │ │ │ ├── comments.txt │ │ │ ├── duplicate_labels.txt │ │ │ ├── er_attributes.txt │ │ │ ├── er_basic.txt │ │ │ ├── er_identifying.txt │ │ │ ├── graph_bt_direction.txt │ │ │ ├── preserve_order_of_definition.txt │ │ │ ├── self_reference.txt │ │ │ ├── self_reference_with_edge.txt │ │ │ ├── seq_basic.txt │ │ │ ├── seq_multiple_messages.txt │ │ │ ├── seq_self_message.txt │ │ │ ├── single_node.txt │ │ │ ├── single_node_longer_name.txt │ │ │ ├── three_nodes.txt │ │ │ ├── three_nodes_single_line.txt │ │ │ ├── two_layer_single_graph.txt │ │ │ ├── two_layer_single_graph_longer_names.txt │ │ │ ├── two_nodes_linked.txt │ │ │ ├── two_nodes_longer_names.txt │ │ │ ├── two_root_nodes.txt │ │ │ ├── two_root_nodes_longer_names.txt │ │ │ └── two_single_root_nodes.txt │ │ ├── text-metrics.test.ts │ │ ├── xychart-ascii.test.ts │ │ └── xychart-integration.test.ts │ ├── ascii/ │ │ ├── ansi.ts │ │ ├── canvas.ts │ │ ├── class-diagram.ts │ │ ├── converter.ts │ │ ├── draw.ts │ │ ├── edge-bundling.ts │ │ ├── edge-routing.ts │ │ ├── er-diagram.ts │ │ ├── grid.ts │ │ ├── index.ts │ │ ├── multiline-utils.ts │ │ ├── pathfinder.ts │ │ ├── sequence.ts │ │ ├── shapes/ │ │ │ ├── circle.ts │ │ │ ├── corners.ts │ │ │ ├── diamond.ts │ │ │ ├── hexagon.ts │ │ │ ├── index.ts │ │ │ ├── rectangle.ts │ │ │ ├── rounded.ts │ │ │ ├── special.ts │ │ │ ├── stadium.ts │ │ │ ├── state.ts │ │ │ └── types.ts │ │ ├── types.ts │ │ ├── validate.ts │ │ └── xychart.ts │ ├── browser.ts │ ├── class/ │ │ ├── layout.ts │ │ ├── parser.ts │ │ ├── renderer.ts │ │ └── types.ts │ ├── elk-instance.ts │ ├── er/ │ │ ├── layout.ts │ │ ├── parser.ts │ │ ├── renderer.ts │ │ └── types.ts │ ├── index.ts │ ├── layout-engine.ts │ ├── layout.ts │ ├── multiline-utils.ts │ ├── parser.ts │ ├── renderer.ts │ ├── sequence/ │ │ ├── layout.ts │ │ ├── parser.ts │ │ ├── renderer.ts │ │ └── types.ts │ ├── shape-clipping.ts │ ├── styles.ts │ ├── text-metrics.ts │ ├── theme.ts │ ├── types.ts │ └── xychart/ │ ├── colors.ts │ ├── layout.ts │ ├── parser.ts │ ├── renderer.ts │ └── types.ts ├── tsconfig.json ├── tsup.config.ts ├── wrangler.toml ├── xychart-design.md ├── xychart-samples-data.ts ├── xychart-test.html └── xychart-test.ts ================================================ FILE CONTENTS ================================================ ================================================ FILE: .github/workflows/ci.yml ================================================ name: CI on: push: branches: [main] pull_request: branches: [main] jobs: test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v1 with: bun-version: latest - name: Install dependencies run: bun install - name: Run tests run: bun test - name: Type check run: bun x tsc --noEmit ================================================ FILE: .github/workflows/publish.yml ================================================ name: Publish to npm on: release: types: [published] jobs: publish: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Setup Bun uses: oven-sh/setup-bun@v1 with: bun-version: latest - name: Setup Node.js (for npm publish) uses: actions/setup-node@v4 with: node-version: '20' registry-url: 'https://registry.npmjs.org' - name: Install dependencies run: bun install - name: Run tests run: bun test - name: Publish to npm run: npm publish --access public env: NODE_AUTH_TOKEN: ${{ secrets.NPM_TOKEN }} ================================================ FILE: .gitignore ================================================ # Dependencies node_modules/ # Build output dist/ site/ *.tsbuildinfo # Generated files index.html # IDE .idea/ .vscode/ *.swp *.swo .DS_Store # Logs *.log npm-debug.log* # Environment .env .env.local .env.*.local # Test coverage coverage/ ================================================ FILE: LICENSE ================================================ MIT License Copyright (c) 2026 Craft Docs Permission is hereby granted, free of charge, to any person obtaining a copy of this software and associated documentation files (the "Software"), to deal in the Software without restriction, including without limitation the rights to use, copy, modify, merge, publish, distribute, sublicense, and/or sell copies of the Software, and to permit persons to whom the Software is furnished to do so, subject to the following conditions: The above copyright notice and this permission notice shall be included in all copies or substantial portions of the Software. THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ================================================ FILE: README.md ================================================
# beautiful-mermaid **Render Mermaid diagrams as beautiful SVGs or ASCII art** Ultra-fast, fully themeable, zero DOM dependencies. Built for the AI era. ![beautiful-mermaid sequence diagram example](hero.png) [![npm version](https://img.shields.io/npm/v/beautiful-mermaid.svg)](https://www.npmjs.com/package/beautiful-mermaid) [![License](https://img.shields.io/badge/license-MIT-blue.svg)](LICENSE) [**Live Demo & Samples**](https://agents.craft.do/mermaid) **[→ Use it live in Craft Agents](https://agents.craft.do)**
--- ## Why We Built This Diagrams are essential for AI-assisted programming. When you're working with an AI coding assistant, being able to visualize data flows, state machines, and system architecture—directly in your terminal or chat interface—makes complex concepts instantly graspable. [Mermaid](https://mermaid.js.org/) is the de facto standard for text-based diagrams. It's brilliant. But the default renderer has problems: - **Aesthetics** — Might be personal preference, but wished they looked more professional - **Complex theming** — Customizing colors requires wrestling with CSS classes - **No terminal output** — Can't render to ASCII for CLI tools - **Heavy dependencies** — Pulls in a lot of code for simple diagrams We built `beautiful-mermaid` at [Craft](https://craft.do) to power diagrams in [Craft Agents](https://agents.craft.do). It's fast, beautiful, and works everywhere—from rich UIs to plain terminals. The ASCII rendering engine is based on [mermaid-ascii](https://github.com/AlexanderGrooff/mermaid-ascii) by Alexander Grooff. We ported it from Go to TypeScript and extended it. Thank you Alexander for the excellent foundation! (And inspiration that this was possible.) ## Features - **6 diagram types** — Flowcharts, State, Sequence, Class, ER, and XY Charts (bar, line, combined) - **Dual output** — SVG for rich UIs, ASCII/Unicode for terminals - **Synchronous rendering** — No async, no flash. Works with React `useMemo()` - **15 built-in themes** — And dead simple to add your own - **Full Shiki compatibility** — Use any VS Code theme directly - **Live theme switching** — CSS custom properties, no re-render needed - **Mono mode** — Beautiful diagrams from just 2 colors - **Zero DOM dependencies** — Pure TypeScript, works everywhere - **Ultra-fast** — Renders 100+ diagrams in under 500ms ## Installation ```bash npm install beautiful-mermaid # or bun add beautiful-mermaid # or pnpm add beautiful-mermaid ``` ## Quick Start ### SVG Output ```typescript import { renderMermaidSVG } from 'beautiful-mermaid' const svg = renderMermaidSVG(` graph TD A[Start] --> B{Decision} B -->|Yes| C[Action] B -->|No| D[End] `) ``` Rendering is **fully synchronous** — no `await`, no promises. The ELK.js layout engine runs synchronously via a FakeWorker bypass, so you get your SVG string instantly. Need async? Use `renderMermaidSVGAsync()` — same output, returns a `Promise`. ### ASCII Output ```typescript import { renderMermaidASCII } from 'beautiful-mermaid' const ascii = renderMermaidASCII(`graph LR; A --> B --> C`) ``` ``` ┌───┐ ┌───┐ ┌───┐ │ │ │ │ │ │ │ A │────►│ B │────►│ C │ │ │ │ │ │ │ └───┘ └───┘ └───┘ ``` --- ## React Integration Because rendering is synchronous, you can use `useMemo()` for zero-flash diagram rendering: ```tsx import { renderMermaidSVG } from 'beautiful-mermaid' function MermaidDiagram({ code }: { code: string }) { const { svg, error } = React.useMemo(() => { try { return { svg: renderMermaidSVG(code, { bg: 'var(--background)', fg: 'var(--foreground)', transparent: true, }), error: null, } } catch (err) { return { svg: null, error: err instanceof Error ? err : new Error(String(err)) } } }, [code]) if (error) return
{error.message}
return
} ``` **Why this works well:** - **No flash** — SVG is computed synchronously during render, not in a useEffect - **CSS variables** — Pass `var(--background)` etc. instead of hex colors. The SVG inherits from your app's CSS, so theme switches apply instantly without re-rendering - **Memoized** — Only re-renders when `code` changes --- ## Theming The theming system is the heart of `beautiful-mermaid`. It's designed to be both powerful and dead simple. ### The Two-Color Foundation Every diagram needs just two colors: **background** (`bg`) and **foreground** (`fg`). That's it. From these two colors, the entire diagram is derived using `color-mix()`: ```typescript const svg = renderMermaidSVG(diagram, { bg: '#1a1b26', // Background fg: '#a9b1d6', // Foreground }) ``` This is **Mono Mode**—a coherent, beautiful diagram from just two colors. The system automatically derives: | Element | Derivation | |---------|------------| | Text | `--fg` at 100% | | Secondary text | `--fg` at 60% into `--bg` | | Edge labels | `--fg` at 40% into `--bg` | | Faint text | `--fg` at 25% into `--bg` | | Connectors | `--fg` at 50% into `--bg` | | Arrow heads | `--fg` at 85% into `--bg` | | Node fill | `--fg` at 3% into `--bg` | | Group header | `--fg` at 5% into `--bg` | | Inner strokes | `--fg` at 12% into `--bg` | | Node stroke | `--fg` at 20% into `--bg` | ### Enriched Mode For richer themes, you can provide optional "enrichment" colors that override specific derivations: ```typescript const svg = renderMermaidSVG(diagram, { bg: '#1a1b26', fg: '#a9b1d6', // Optional enrichment: line: '#3d59a1', // Edge/connector color accent: '#7aa2f7', // Arrow heads, highlights muted: '#565f89', // Secondary text, labels surface: '#292e42', // Node fill tint border: '#3d59a1', // Node stroke }) ``` If an enrichment color isn't provided, it falls back to the `color-mix()` derivation. This means you can provide just the colors you care about. ### CSS Custom Properties = Live Switching All colors are CSS custom properties on the `` element. This means you can switch themes instantly without re-rendering: ```javascript // Switch theme by updating CSS variables svg.style.setProperty('--bg', '#282a36') svg.style.setProperty('--fg', '#f8f8f2') // The entire diagram updates immediately ``` For React apps, pass CSS variable references instead of hex values: ```typescript const svg = renderMermaidSVG(diagram, { bg: 'var(--background)', fg: 'var(--foreground)', accent: 'var(--accent)', transparent: true, }) // Theme switches apply automatically via CSS cascade — no re-render needed ``` ### Built-in Themes 15 carefully curated themes ship out of the box: | Theme | Type | Background | Accent | |-------|------|------------|--------| | `zinc-light` | Light | `#FFFFFF` | Derived | | `zinc-dark` | Dark | `#18181B` | Derived | | `tokyo-night` | Dark | `#1a1b26` | `#7aa2f7` | | `tokyo-night-storm` | Dark | `#24283b` | `#7aa2f7` | | `tokyo-night-light` | Light | `#d5d6db` | `#34548a` | | `catppuccin-mocha` | Dark | `#1e1e2e` | `#cba6f7` | | `catppuccin-latte` | Light | `#eff1f5` | `#8839ef` | | `nord` | Dark | `#2e3440` | `#88c0d0` | | `nord-light` | Light | `#eceff4` | `#5e81ac` | | `dracula` | Dark | `#282a36` | `#bd93f9` | | `github-light` | Light | `#ffffff` | `#0969da` | | `github-dark` | Dark | `#0d1117` | `#4493f8` | | `solarized-light` | Light | `#fdf6e3` | `#268bd2` | | `solarized-dark` | Dark | `#002b36` | `#268bd2` | | `one-dark` | Dark | `#282c34` | `#c678dd` | ```typescript import { renderMermaidSVG, THEMES } from 'beautiful-mermaid' const svg = renderMermaidSVG(diagram, THEMES['tokyo-night']) ``` ### Adding Your Own Theme Creating a theme is trivial. At minimum, just provide `bg` and `fg`: ```typescript const myTheme = { bg: '#0f0f0f', fg: '#e0e0e0', } const svg = renderMermaidSVG(diagram, myTheme) ``` Want richer colors? Add any of the optional enrichments: ```typescript const myRichTheme = { bg: '#0f0f0f', fg: '#e0e0e0', accent: '#ff6b6b', // Pop of color for arrows muted: '#666666', // Subdued labels } ``` ### Full Shiki Compatibility Use **any VS Code theme** directly via Shiki integration. This gives you access to hundreds of community themes: ```typescript import { getSingletonHighlighter } from 'shiki' import { renderMermaidSVG, fromShikiTheme } from 'beautiful-mermaid' // Load any theme from Shiki's registry const highlighter = await getSingletonHighlighter({ themes: ['vitesse-dark', 'rose-pine', 'material-theme-darker'] }) // Extract diagram colors from the theme const colors = fromShikiTheme(highlighter.getTheme('vitesse-dark')) const svg = renderMermaidSVG(diagram, colors) ``` The `fromShikiTheme()` function intelligently maps VS Code editor colors to diagram roles: | Editor Color | Diagram Role | |--------------|--------------| | `editor.background` | `bg` | | `editor.foreground` | `fg` | | `editorLineNumber.foreground` | `line` | | `focusBorder` / keyword token | `accent` | | comment token | `muted` | | `editor.selectionBackground` | `surface` | | `editorWidget.border` | `border` | --- ## Supported Diagrams ### Flowcharts ``` graph TD A[Start] --> B{Decision} B -->|Yes| C[Process] B -->|No| D[End] C --> D ``` All directions supported: `TD` (top-down), `LR` (left-right), `BT` (bottom-top), `RL` (right-left). ### State Diagrams ``` stateDiagram-v2 [*] --> Idle Idle --> Processing: start Processing --> Complete: done Complete --> [*] ``` ### Sequence Diagrams ``` sequenceDiagram Alice->>Bob: Hello Bob! Bob-->>Alice: Hi Alice! Alice->>Bob: How are you? Bob-->>Alice: Great, thanks! ``` ### Class Diagrams ``` classDiagram Animal <|-- Duck Animal <|-- Fish Animal: +int age Animal: +String gender Animal: +isMammal() bool Duck: +String beakColor Duck: +swim() Duck: +quack() ``` ### ER Diagrams ``` erDiagram CUSTOMER ||--o{ ORDER : places ORDER ||--|{ LINE_ITEM : contains PRODUCT ||--o{ LINE_ITEM : "is in" ``` ### Inline Edge Styling Use `linkStyle` to override edge colors and stroke widths — just like [Mermaid's linkStyle](https://mermaid.js.org/syntax/flowchart.html#styling-links): ``` graph TD A --> B --> C linkStyle 0 stroke:#ff0000,stroke-width:2px linkStyle default stroke:#888888 ``` | Syntax | Effect | | ------------------------------- | -------------------------------------- | | `linkStyle 0 stroke:#f00` | Style a single edge by index (0-based) | | `linkStyle 0,2 stroke:#f00` | Style multiple edges at once | | `linkStyle default stroke:#888` | Default style applied to all edges | Index-specific styles override the default. Supported properties: `stroke`, `stroke-width`. Works in both flowcharts and state diagrams. ### XY Charts Bar charts, line charts, and combinations — using Mermaid's `xychart-beta` syntax. **Bar chart:** ``` xychart-beta title "Monthly Revenue" x-axis [Jan, Feb, Mar, Apr, May, Jun] y-axis "Revenue ($K)" 0 --> 500 bar [180, 250, 310, 280, 350, 420] ``` **Line chart:** ``` xychart-beta title "User Growth" x-axis [Jan, Feb, Mar, Apr, May, Jun] line [1200, 1800, 2500, 3100, 3800, 4500] ``` **Combined bar + line:** ``` xychart-beta title "Sales with Trend" x-axis [Jan, Feb, Mar, Apr, May, Jun] bar [300, 380, 280, 450, 350, 520] line [300, 330, 320, 353, 352, 395] ``` **Horizontal orientation:** ``` xychart-beta horizontal title "Language Popularity" x-axis [Python, JavaScript, Java, Go, Rust] bar [30, 25, 20, 12, 8] ``` **Axis configuration:** - Categorical x-axis: `x-axis [A, B, C]` - Numeric x-axis range: `x-axis 0 --> 100` - Axis titles: `x-axis "Category" [A, B, C]` - Y-axis range: `y-axis "Score" 0 --> 100` **Multi-series:** Add multiple `bar` and/or `line` declarations. Each series gets a distinct color from a monochromatic palette derived from the theme's accent color. ### XY Chart Styling The chart renderer follows a clean, minimal design philosophy inspired by Apple and Craft: - **Dot grid** — A subtle dot pattern fills the plot area instead of traditional solid grid lines - **Rounded bars** — All bar corners are rounded for a modern, polished look - **Smooth curves** — Line series use natural cubic spline interpolation, producing mathematically smooth curves through all data points (not straight segments or staircase steps) - **Floating labels** — No visible axis lines or tick marks; labels float freely for a clutter-free aesthetic - **Drop-shadow lines** — Each line series has a subtle shadow beneath it for depth - **Monochromatic palette** — Series 0 uses the theme's accent color; additional series get darker/lighter shades of the same hue with subtle hue drift, adapting automatically to light or dark backgrounds - **Interactive tooltips** — When rendered with `interactive: true`, hovering over bars or data points shows value tooltips. Multi-line tooltips appear when multiple series share an x-position - **Sparse line dots** — Lines with 12 or fewer data points show data point dots by default for readability - **Full theme support** — All 15 built-in themes (and custom themes) apply to charts. The accent color drives the entire series color palette - **Live theme switching** — Chart series colors are CSS custom properties (`--xychart-color-N`), so theme changes apply instantly without re-rendering --- ## ASCII Output For terminal environments, CLI tools, or anywhere you need plain text, render to ASCII or Unicode box-drawing characters: ```typescript import { renderMermaidASCII } from 'beautiful-mermaid' // Unicode mode (default) — prettier box drawing const unicode = renderMermaidASCII(`graph LR; A --> B`) // Pure ASCII mode — maximum compatibility const ascii = renderMermaidASCII(`graph LR; A --> B`, { useAscii: true }) ``` **Unicode output:** ``` ┌───┐ ┌───┐ │ │ │ │ │ A │────►│ B │ │ │ │ │ └───┘ └───┘ ``` **ASCII output:** ``` +---+ +---+ | | | | | A |---->| B | | | | | +---+ +---+ ``` ### ASCII Options ```typescript renderMermaidASCII(diagram, { useAscii: false, // true = ASCII, false = Unicode (default) paddingX: 5, // Horizontal spacing between nodes paddingY: 5, // Vertical spacing between nodes boxBorderPadding: 1, // Padding inside node boxes colorMode: 'auto', // 'none' | 'auto' | 'ansi16' | 'ansi256' | 'truecolor' | 'html' theme: { ... }, // Partial — override default colors }) ``` ### ASCII XY Charts XY charts render to ASCII with dedicated chart-drawing characters: - **Bar charts** — `█` blocks (Unicode) or `#` (ASCII mode) - **Line charts** — Staircase routing with rounded corners: `╭╮╰╯│─` (Unicode) or `+|-` (ASCII) - **Multi-series** — Each series gets a distinct ANSI color from the theme's accent palette - **Legends** — Automatically shown when multiple series are present - **Horizontal charts** — Fully supported with categories on the y-axis --- ## API Reference ### `renderMermaidSVG(text, options?): string` Render a Mermaid diagram to SVG. Synchronous. Auto-detects diagram type. **Parameters:** - `text` — Mermaid source code - `options` — Optional `RenderOptions` object **RenderOptions:** | Option | Type | Default | Description | |--------|------|---------|-------------| | `bg` | `string` | `#FFFFFF` | Background color (or CSS variable) | | `fg` | `string` | `#27272A` | Foreground color (or CSS variable) | | `line` | `string?` | — | Edge/connector color | | `accent` | `string?` | — | Arrow heads, highlights | | `muted` | `string?` | — | Secondary text, labels | | `surface` | `string?` | — | Node fill tint | | `border` | `string?` | — | Node stroke color | | `font` | `string` | `Inter` | Font family | | `transparent` | `boolean` | `false` | Render with transparent background | | `padding` | `number` | `40` | Canvas padding in px | | `nodeSpacing` | `number` | `24` | Horizontal spacing between sibling nodes | | `layerSpacing` | `number` | `40` | Vertical spacing between layers | | `componentSpacing` | `number` | `24` | Spacing between disconnected components | | `thoroughness` | `number` | `3` | Crossing minimization trials (1-7, higher = better but slower) | | `interactive` | `boolean` | `false` | Enable hover tooltips on XY chart bars and data points | **XY Charts:** Diagrams starting with `xychart-beta` are auto-detected — no separate function needed. The `accent` color option drives the chart series color palette. ### `renderMermaidSVGAsync(text, options?): Promise` Async version of `renderMermaidSVG()`. Same output, returns a `Promise`. Useful in async server handlers or data loaders. ### `renderMermaidASCII(text, options?): string` Render a Mermaid diagram to ASCII/Unicode text. Synchronous. **AsciiRenderOptions:** | Option | Type | Default | Description | |--------|------|---------|-------------| | `useAscii` | `boolean` | `false` | Use ASCII instead of Unicode | | `paddingX` | `number` | `5` | Horizontal node spacing | | `paddingY` | `number` | `5` | Vertical node spacing | | `boxBorderPadding` | `number` | `1` | Inner box padding | | `colorMode` | `string` | `'auto'` | `'none'`, `'auto'`, `'ansi16'`, `'ansi256'`, `'truecolor'`, or `'html'` | | `theme` | `Partial` | — | Override default colors for ASCII output | ### `parseMermaid(text): MermaidGraph` Parse Mermaid source into a structured graph object (for custom processing). ### `fromShikiTheme(theme): DiagramColors` Extract diagram colors from a Shiki theme object. ### `THEMES: Record` Object containing all 15 built-in themes. ### `DEFAULTS: { bg: string, fg: string }` Default colors (`#FFFFFF` / `#27272A`). --- ## Attribution The ASCII rendering engine is based on [mermaid-ascii](https://github.com/AlexanderGrooff/mermaid-ascii) by Alexander Grooff. We ported it from Go to TypeScript and extended it with: - Sequence diagram support - Class diagram support - ER diagram support - Unicode box-drawing characters - Configurable spacing and padding Thank you Alexander for the excellent foundation! --- ## License MIT — see [LICENSE](LICENSE) for details. ---
Built with care by the team at [Craft](https://craft.do)
================================================ FILE: bench.ts ================================================ /** * Performance benchmark for beautiful-mermaid. * * Runs all sample definitions through both renderers (SVG + ASCII) in Bun * and prints a table with per-sample timing and aggregate stats. * * Usage: bun run packages/mermaid/bench.ts */ import { samples } from './samples-data.ts' import { renderMermaid } from './src/index.ts' import { renderMermaidAscii } from './src/ascii/index.ts' // ============================================================================ // Types // ============================================================================ interface Result { index: number title: string category: string svgMs: number asciiMs: number svgError: string | null asciiError: string | null } // ============================================================================ // Helpers // ============================================================================ /** Pad/truncate a string to exactly `width` characters, right-aligned if numeric. */ function col(value: string, width: number, align: 'left' | 'right' = 'left'): string { const truncated = value.length > width ? value.slice(0, width - 1) + '\u2026' : value return align === 'right' ? truncated.padStart(width) : truncated.padEnd(width) } function fmtMs(ms: number): string { return ms.toFixed(1) } // ============================================================================ // Main // ============================================================================ const results: Result[] = [] const totalStart = performance.now() console.log(`\nbeautiful-mermaid — Benchmark (${samples.length} samples)`) console.log('═'.repeat(90)) console.log( `${col('#', 4, 'right')} ${col('Title', 38)} ${col('Category', 15)} ${col('SVG (ms)', 10, 'right')} ${col('ASCII (ms)', 10, 'right')} ${col('Total', 10, 'right')}`, ) console.log('─'.repeat(90)) for (let i = 0; i < samples.length; i++) { const sample = samples[i]! const category = sample.category ?? 'Other' let svgMs = 0 let asciiMs = 0 let svgError: string | null = null let asciiError: string | null = null // Render SVG (async — uses dagre layout for flowcharts/state/class/ER) try { const t0 = performance.now() await renderMermaid(sample.source, sample.options) svgMs = performance.now() - t0 } catch (err) { svgError = String(err) svgMs = -1 } // Render ASCII (sync — custom text layout, no dagre) try { const t0 = performance.now() renderMermaidAscii(sample.source) asciiMs = performance.now() - t0 } catch (err) { asciiError = String(err) asciiMs = -1 } const totalMs = (svgMs >= 0 ? svgMs : 0) + (asciiMs >= 0 ? asciiMs : 0) results.push({ index: i, title: sample.title, category, svgMs, asciiMs, svgError, asciiError }) // Print row const svgStr = svgMs >= 0 ? fmtMs(svgMs) : 'ERR' const asciiStr = asciiMs >= 0 ? fmtMs(asciiMs) : 'N/A' console.log( `${col(String(i + 1), 4, 'right')} ${col(sample.title, 38)} ${col(category, 15)} ${col(svgStr, 10, 'right')} ${col(asciiStr, 10, 'right')} ${col(fmtMs(totalMs), 10, 'right')}`, ) } const totalElapsed = performance.now() - totalStart // ============================================================================ // Aggregates // ============================================================================ console.log('═'.repeat(90)) const svgTimes = results.filter(r => r.svgMs >= 0).map(r => r.svgMs) const asciiTimes = results.filter(r => r.asciiMs >= 0).map(r => r.asciiMs) const svgTotal = svgTimes.reduce((a, b) => a + b, 0) const asciiTotal = asciiTimes.reduce((a, b) => a + b, 0) console.log(`Total: ${fmtMs(totalElapsed)}ms (SVG: ${fmtMs(svgTotal)}ms, ASCII: ${fmtMs(asciiTotal)}ms)`) console.log(`Average: ${fmtMs((svgTotal + asciiTotal) / results.length)}ms per sample`) // Find slowest SVG and ASCII if (svgTimes.length > 0) { const slowestSvg = results.filter(r => r.svgMs >= 0).sort((a, b) => b.svgMs - a.svgMs)[0]! console.log(`Slowest SVG: #${slowestSvg.index + 1} ${slowestSvg.title} (${fmtMs(slowestSvg.svgMs)}ms)`) } if (asciiTimes.length > 0) { const slowestAscii = results.filter(r => r.asciiMs >= 0).sort((a, b) => b.asciiMs - a.asciiMs)[0]! console.log(`Slowest ASCII: #${slowestAscii.index + 1} ${slowestAscii.title} (${fmtMs(slowestAscii.asciiMs)}ms)`) } // Report errors const svgErrors = results.filter(r => r.svgError) const asciiErrors = results.filter(r => r.asciiError) if (svgErrors.length > 0) { console.log(`\nSVG errors (${svgErrors.length}):`) for (const r of svgErrors) { console.log(` #${r.index + 1} ${r.title}: ${r.svgError}`) } } if (asciiErrors.length > 0) { console.log(`\nASCII unsupported (${asciiErrors.length}):`) for (const r of asciiErrors) { console.log(` #${r.index + 1} ${r.title}`) } } // Category breakdown console.log('\n── By Category ──') const catMap = new Map() for (const r of results) { if (!catMap.has(r.category)) catMap.set(r.category, []) catMap.get(r.category)!.push(r) } for (const [cat, catResults] of catMap) { const catSvg = catResults.filter(r => r.svgMs >= 0).reduce((a, r) => a + r.svgMs, 0) const catAscii = catResults.filter(r => r.asciiMs >= 0).reduce((a, r) => a + r.asciiMs, 0) console.log(` ${col(cat, 16)} ${col(String(catResults.length), 3, 'right')} samples SVG: ${col(fmtMs(catSvg), 8, 'right')}ms ASCII: ${col(fmtMs(catAscii), 8, 'right')}ms Total: ${col(fmtMs(catSvg + catAscii), 8, 'right')}ms`) } console.log() ================================================ FILE: dev.ts ================================================ /** * Development server with live reload for mermaid samples. * * Usage: bun run packages/mermaid/dev.ts * * - Runs `index.ts` to generate index.html on startup * - Watches `src/` and `index.ts` for file changes * - On change, rebuilds index.html and notifies browsers via SSE * - Serves index.html with an injected live-reload script * * This avoids manually re-running the build and refreshing the browser — * just save a file and the page updates automatically. */ import { watch } from 'fs' import { join } from 'path' const PORT = 3456 const ROOT = import.meta.dir // ============================================================================ // Build management // ============================================================================ let building = false const sseClients = new Set() async function rebuild(): Promise { if (building) return building = true console.log('\x1b[36m[dev]\x1b[0m Rebuilding samples...') const t0 = performance.now() const proc = Bun.spawn(['bun', 'run', join(ROOT, 'index.ts')], { cwd: ROOT, stdout: 'inherit', stderr: 'inherit', }) await proc.exited const ms = (performance.now() - t0).toFixed(0) if (proc.exitCode === 0) { console.log(`\x1b[32m[dev]\x1b[0m Rebuilt in ${ms}ms`) // Notify all connected browsers to reload for (const client of sseClients) { try { client.enqueue('data: reload\n\n') } catch { sseClients.delete(client) } } } else { console.error(`\x1b[31m[dev]\x1b[0m Build failed (exit ${proc.exitCode})`) } building = false } // ============================================================================ // File watching — debounced to coalesce rapid saves // ============================================================================ let debounce: Timer | null = null function onFileChange(_event: string, filename: string | null): void { // Ignore index.html itself (it's the output, not a source) if (filename === 'index.html') return if (debounce) clearTimeout(debounce) debounce = setTimeout(() => { console.log(`\x1b[90m[dev]\x1b[0m Change detected${filename ? `: ${filename}` : ''}`) rebuild() }, 150) } // Watch the entire mermaid package for changes (excludes index.html output) watch(ROOT, { recursive: true }, onFileChange) // ============================================================================ // HTTP server // ============================================================================ // Initial build before starting the server await rebuild() console.log(`\x1b[36m[dev]\x1b[0m Server running at \x1b[1mhttp://localhost:${PORT}\x1b[0m`) console.log(`\x1b[36m[dev]\x1b[0m Watching for changes in src/ and index.ts\n`) Bun.serve({ port: PORT, async fetch(req) { const url = new URL(req.url) // SSE endpoint — browsers connect here to receive reload signals if (url.pathname === '/__dev_events') { let controller!: ReadableStreamDefaultController const stream = new ReadableStream({ start(c) { controller = c sseClients.add(controller) }, cancel() { sseClients.delete(controller) }, }) return new Response(stream, { headers: { 'Content-Type': 'text/event-stream', 'Cache-Control': 'no-cache', Connection: 'keep-alive', }, }) } // Serve index.html with injected live-reload script const file = Bun.file(join(ROOT, 'index.html')) if (!(await file.exists())) { return new Response('index.html not found — build may have failed', { status: 404 }) } let html = await file.text() // Inject live-reload client before html = html.replace( '', ` `, ) return new Response(html, { headers: { 'Content-Type': 'text/html' }, }) }, }) ================================================ FILE: index.ts ================================================ /** * Generates index.html showcasing all beautiful-mermaid rendering capabilities. * * Usage: bun run index.ts * * This file doubles as a **visual test suite** — every supported feature, * shape, edge type, block construct, and theme variant is exercised by at * least one sample. If a rendering change causes regressions, it will be * visible in the generated HTML. * * The generated HTML is **dynamic** — it includes a bundled copy of the * mermaid renderer and renders all diagrams client-side in real time, * showing progressive loading and per-diagram render timing. * * Sample definitions live in samples-data.ts (shared with bench.ts). */ import { samples } from './samples-data.ts' import { THEMES } from './src/theme.ts' import { createHighlighter } from 'shiki' // ============================================================================ // HTML generation — dynamic version // // Instead of pre-rendering SVGs at build time, we: // 1. Bundle the mermaid renderer for the browser via Bun.build() // 2. Embed sample definitions as inline JSON // 3. Emit client-side JS that renders each diagram on page load // ============================================================================ function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } /** Convert markdown-style backtick spans to tags in description text. */ function formatDescription(text: string): string { return text.replace(/`([^`]+)`/g, '$1') } /** Human-readable labels for theme keys */ const THEME_LABELS: Record = { 'zinc-dark': 'Zinc Dark', 'tokyo-night': 'Tokyo Night', 'tokyo-night-storm': 'Tokyo Storm', 'tokyo-night-light': 'Tokyo Light', 'catppuccin-mocha': 'Catppuccin', 'catppuccin-latte': 'Latte', 'nord': 'Nord', 'nord-light': 'Nord Light', 'dracula': 'Dracula', 'github-light': 'GitHub', 'github-dark': 'GitHub Dark', 'solarized-light': 'Solarized', 'solarized-dark': 'Solar Dark', 'one-dark': 'One Dark', } async function generateHtml(): Promise { // Step 0: Create Shiki highlighter for mermaid syntax highlighting in source panels. // We use 'github-light' as the base theme — its hex colors get overridden by CSS // color-mix() rules derived from --t-fg / --t-bg so tokens adapt to any theme. const highlighter = await createHighlighter({ langs: ['mermaid'], themes: ['github-light'], }) // Step 1: Bundle the mermaid renderer for the browser const buildResult = await Bun.build({ entrypoints: [new URL('./src/browser.ts', import.meta.url).pathname], target: 'browser', format: 'esm', minify: true, }) if (!buildResult.success) { console.error('Bundle build failed:', buildResult.logs) process.exit(1) } const bundleJs = await buildResult.outputs[0]!.text() console.log(`Browser bundle: ${(bundleJs.length / 1024).toFixed(1)} KB`) // Step 2: Build sample JSON (only serializable fields needed by client) const samplesJson = JSON.stringify(samples.map(s => ({ title: s.title, description: s.description, source: s.source, category: s.category ?? 'Other', options: s.options ?? {}, }))) // Step 3: Group samples by category for TOC (done at build time since it's static) const categories = new Map() samples.forEach((sample, i) => { const cat = sample.category ?? 'Other' if (!categories.has(cat)) categories.set(cat, []) categories.get(cat)!.push(i) }) const categoryBadgeColors: Record = { Flowchart: '#3b82f6', State: '#8b5cf6', Sequence: '#10b981', Class: '#f59e0b', ER: '#ef4444', 'XY Chart': '#f97316', 'Theme Showcase': '#06b6d4', } // Map category names to the title prefixes they use, so we can strip duplicates in the ToC const categoryPrefixes: Record = { 'State': 'State: ', 'Sequence': 'Sequence: ', 'Class': 'Class: ', 'ER': 'ER: ', 'XY Chart': 'XY: ', 'Theme Showcase': 'Theme: ', } // Build mapping from original index to display number (excluding Hero samples) const heroCount = samples.filter(s => s.category === 'Hero').length const displayNum = (i: number) => i + 1 - heroCount const tocSections = [...categories.entries()] .filter(([cat]) => cat !== 'Hero') // Skip Hero from TOC .map(([cat, indices]) => { const badgeColor = categoryBadgeColors[cat] ?? '#71717a' const prefix = categoryPrefixes[cat] const items = indices.map(i => { let title = samples[i]!.title // Strip the category prefix from the title since it's already under the category heading if (prefix && title.startsWith(prefix)) title = title.slice(prefix.length) return `
  • ${displayNum(i)}. ${escapeHtml(title)}
  • ` }).join('\n ') return `

    ${escapeHtml(cat)} (${indices.length} samples)

      ${items}
    ` }).join('\n') // Step 3b: Build theme selector pills (build-time so we include swatches) // Only show Default, Dracula, and Solarized inline; rest go in "More" dropdown const VISIBLE_THEMES = new Set(['dracula', 'solarized-light']) function buildThemePill(key: string, colors: { bg: string; fg: string }, active = false): string { const isDark = parseInt(colors.bg.replace('#', '').slice(0, 2), 16) < 0x80 const shadow = isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)' const label = key === '' ? 'Default' : (THEME_LABELS[key] ?? key) const activeClass = active ? ' active' : '' return `` } const themeEntries = Object.entries(THEMES) // Visible inline pills: Default + Dracula + Solarized const visiblePills = [ '', ...themeEntries .filter(([key]) => VISIBLE_THEMES.has(key)) .map(([key, colors]) => buildThemePill(key, colors)), ] // All themes go in the dropdown (including Default, Dracula, Solarized) const allDropdownPills = [ buildThemePill('', { bg: '#FFFFFF', fg: '#27272A' }, true), ...themeEntries.map(([key, colors]) => buildThemePill(key, colors)), ] const totalThemes = allDropdownPills.length const themePillsHtml = `
    ${visiblePills.join('\n ')}
    ${allDropdownPills.join('\n ')}
    ` // Step 4: Pre-highlight all sample sources with Shiki (build-time only, zero runtime cost). // The mermaid TextMate grammar requires a fenced code block prefix to tokenize properly // (see https://github.com/shikijs/shiki/issues/973), so we wrap each source with // ```mermaid ... ``` and then strip those fence lines from the output HTML. // Source panels always use github-dark — Shiki's inline colors are used directly. const highlightedSources = samples.map(sample => { const fenced = '```mermaid\n' + sample.source.trim() + '\n```' const html = highlighter.codeToHtml(fenced, { lang: 'mermaid', theme: 'github-light', }) // Strip the first line (```mermaid) and last line (```) from the output return html.replace( /().*?<\/span>\n/, // first line '$1' ).replace( /\n.*?<\/span>(<\/code>)/, // last line '$1' ) }) // Step 5: Build sample card HTML shells (SVG + ASCII are empty, filled client-side) // data-sample-bg stores the per-sample background for "Default" mode restoration. // Hero samples get special full-width SVG-only treatment and are placed before "Samples" heading. const heroCards: string[] = [] const regularCards: string[] = [] samples.forEach((sample, i) => { const bg = sample.options?.bg ?? '' const isHero = sample.category === 'Hero' if (isHero) { // Hero sample: full-width SVG only, no header/source/ASCII panels heroCards.push(`
    `) } else { regularCards.push(`

    ${escapeHtml(sample.title)}

    ${formatDescription(sample.description)}

    ${highlightedSources[i]} ${sample.options ? `
    Options: ${escapeHtml(JSON.stringify(sample.options))}
    ` : ''}
    Rendering\u2026
    `) } }) const heroCardsHtml = heroCards.join('\n') const regularCardsHtml = regularCards.join('\n') // ============================================================================ // Step 5: Assemble full HTML // ============================================================================ return ` Beautiful Mermaid — Mermaid Rendering, Made Beautiful
    ${themePillsHtml}
    ${tocSections}

    Beautiful Mermaid

    Mermaid Rendering, made beautiful.

    An open source library for rendering diagrams, designed for the age of AI: beautiful-mermaid. Ultra-fast, fully themeable, and outputs to both SVG and ASCII.
    Built by the team at Craft — because diagrams deserve great design too.

    Rendering ${samples.length * 2} samples\u2026

    ASCII rendering based on Mermaid-ASCII
    Early preview — actively evolving
    ${heroCardsHtml}

    Samples

    ${regularCardsHtml}
    Edit Diagram
    © 2026 Craft Docs Limited, Inc. All rights reserved.
    ` } // ============================================================================ // Main // ============================================================================ const html = await generateHtml() const outPath = new URL('./index.html', import.meta.url).pathname await Bun.write(outPath, html) console.log(`Written to ${outPath} (${(html.length / 1024).toFixed(1)} KB)`) ================================================ FILE: package.json ================================================ { "name": "beautiful-mermaid", "version": "1.1.3", "license": "MIT", "description": "Render Mermaid diagrams as beautiful SVGs or ASCII art. Ultra-fast, fully themeable, zero DOM dependencies.", "type": "module", "main": "dist/index.js", "types": "dist/index.d.ts", "exports": { ".": { "bun": "./src/index.ts", "import": "./dist/index.js", "types": "./dist/index.d.ts" } }, "repository": { "type": "git", "url": "https://github.com/lukilabs/beautiful-mermaid" }, "homepage": "https://github.com/lukilabs/beautiful-mermaid", "bugs": { "url": "https://github.com/lukilabs/beautiful-mermaid/issues" }, "keywords": [ "mermaid", "diagram", "svg", "ascii", "flowchart", "sequence-diagram", "class-diagram", "er-diagram", "xychart", "state-diagram", "visualization", "theming" ], "author": "Craft Docs", "files": ["src/", "dist/", "LICENSE", "README.md"], "scripts": { "test": "bun test src/__tests__/", "samples": "bun run index.ts", "dev": "bun run dev.ts", "bench": "bun run bench.ts", "build": "tsup", "build:samples": "bun run index.ts && mkdir -p site && mv index.html site/ && cp -r public/* site/", "deploy": "bun run build:samples && wrangler pages deploy site --project-name craft-agents-mermaid", "xychart-test": "bun run xychart-test.ts", "prepublishOnly": "npm run build" }, "dependencies": { "elkjs": "^0.11.0", "entities": "^7.0.1" }, "devDependencies": { "@types/bun": "^1.3.9", "shiki": "^3.19.0", "tsup": "^8.5.1", "typescript": "^5.9.3" } } ================================================ FILE: samples-data.ts ================================================ /** * Sample definitions for the beautiful-mermaid visual test suite. * * Shared by: * - index.ts — generates the HTML visual test page * - bench.ts — runs performance benchmarks in Bun (no browser) * - dev.ts — dev server with live reload * * Every supported feature, shape, edge type, block construct, and theme * variant is exercised by at least one sample. */ export interface Sample { title: string description: string source: string /** Optional category tag for grouping in the Table of Contents */ category?: string options?: { bg?: string; fg?: string; line?: string; accent?: string; muted?: string; surface?: string; border?: string; font?: string; padding?: number; transparent?: boolean; interactive?: boolean } } export const samples: Sample[] = [ // ══════════════════════════════════════════════════════════════════════════ // HERO — Showcase diagram // ══════════════════════════════════════════════════════════════════════════ { title: 'Beautiful Mermaid', category: 'Hero', description: 'Mermaid rendering, made beautiful.', source: `stateDiagram-v2 direction LR [*] --> Input Input --> Parse: DSL Parse --> Layout: AST Layout --> SVG: Vector Layout --> ASCII: Text SVG --> Theme ASCII --> Theme Theme --> Output Output --> [*]`, options: { transparent: true }, }, // ══════════════════════════════════════════════════════════════════════════ // FLOWCHART — Shapes // ══════════════════════════════════════════════════════════════════════════ { title: 'Simple Flow', category: 'Flowchart', description: 'Basic linear flow with three nodes connected by solid arrows.', source: `graph TD A[Start] --> B[Process] --> C[End]`, }, { title: 'Original Node Shapes', category: 'Flowchart', description: 'Rectangle, rounded, diamond, stadium, and circle.', source: `graph LR A[Rectangle] --> B(Rounded) B --> C{Diamond} C --> D([Stadium]) D --> E((Circle))`, }, { title: 'Batch 1 Shapes', category: 'Flowchart', description: 'Subroutine `[[text]]`, double circle `(((text)))`, and hexagon `{{text}}`.', source: `graph LR A[[Subroutine]] --> B(((Double Circle))) B --> C{{Hexagon}}`, }, { title: 'Batch 2 Shapes', category: 'Flowchart', description: 'Cylinder `[(text)]`, asymmetric `>text]`, trapezoid `[/text\\]`, and inverse trapezoid `[\\text/]`.', source: `graph LR A[(Database)] --> B>Flag Shape] B --> C[/Wider Bottom\\] C --> D[\\Wider Top/]`, }, { title: 'All 12 Flowchart Shapes', category: 'Flowchart', description: 'Every supported flowchart shape in a single diagram.', source: `graph LR A[Rectangle] --> B(Rounded) B --> C{Diamond} C --> D([Stadium]) D --> E((Circle)) E --> F[[Subroutine]] F --> G(((Double Circle))) G --> H{{Hexagon}} H --> I[(Database)] I --> J>Flag] J --> K[/Trapezoid\\] K --> L[\\Inverse Trap/]`, }, // ══════════════════════════════════════════════════════════════════════════ // FLOWCHART — Edges // ══════════════════════════════════════════════════════════════════════════ { title: 'All Edge Styles', category: 'Flowchart', description: 'Solid, dotted, and thick arrows with labels.', source: `graph TD A[Source] -->|solid| B[Target 1] A -.->|dotted| C[Target 2] A ==>|thick| D[Target 3]`, }, { title: 'No-Arrow Edges', category: 'Flowchart', description: 'Lines without arrowheads: solid `---`, dotted `-.-`, thick `===`.', source: `graph TD A[Node 1] ---|related| B[Node 2] B -.- C[Node 3] C === D[Node 4]`, }, { title: 'Text-Embedded Labels', category: 'Flowchart', description: 'Using `-- label -->` syntax instead of `-->|label|` for edge labels.', source: `flowchart TD A(Start) --> B{Is it sunny?} B -- Yes --> C[Go to the park] B -- No --> D[Stay indoors] C --> E[Finish] D --> E`, }, { title: 'Bidirectional Arrows', category: 'Flowchart', description: 'Arrows in both directions: `<-->`, `<-.->`, `<==>`.', source: `graph LR A[Client] <-->|sync| B[Server] B <-.->|heartbeat| C[Monitor] C <==>|data| D[Storage]`, }, { title: 'Parallel Links (&)', category: 'Flowchart', description: 'Using `&` to create multiple edges from/to groups of nodes.', source: `graph TD A[Input] & B[Config] --> C[Processor] C --> D[Output] & E[Log]`, }, { title: 'Chained Edges', category: 'Flowchart', description: 'A long chain of nodes demonstrating edge chaining syntax.', source: `graph LR A[Step 1] --> B[Step 2] --> C[Step 3] --> D[Step 4] --> E[Step 5]`, }, // ══════════════════════════════════════════════════════════════════════════ // FLOWCHART — Edge Styling (linkStyle) // ══════════════════════════════════════════════════════════════════════════ { title: 'linkStyle: Color-Coded Edges', category: 'Flowchart', description: 'Using `linkStyle` to color specific edges by index (0-based).', source: `graph TD A[Start] --> B{Decision} B -->|Yes| C[Accept] B -->|No| D[Reject] C --> E[Done] D --> E linkStyle 0 stroke:#7aa2f7,stroke-width:3px linkStyle 1 stroke:#9ece6a,stroke-width:2px linkStyle 2 stroke:#f7768e,stroke-width:2px linkStyle default stroke:#565f89`, }, { title: 'linkStyle: Default + Override', category: 'Flowchart', description: 'Default edge style with index-specific overrides for critical paths.', source: `graph LR A[Request] --> B[Auth] B --> C[Process] C --> D[Response] B --> E[Reject] linkStyle default stroke:#6b7280,stroke-width:1px linkStyle 0,1,2 stroke:#22c55e,stroke-width:2px linkStyle 3 stroke:#ef4444,stroke-width:3px`, }, // ══════════════════════════════════════════════════════════════════════════ // FLOWCHART — Directions // ══════════════════════════════════════════════════════════════════════════ { title: 'Direction: Left-Right (LR)', category: 'Flowchart', description: 'Horizontal layout flowing left to right.', source: `graph LR A[Input] --> B[Transform] --> C[Output]`, }, { title: 'Direction: Bottom-Top (BT)', category: 'Flowchart', description: 'Vertical layout flowing from bottom to top.', source: `graph BT A[Foundation] --> B[Layer 2] --> C[Top]`, }, // ══════════════════════════════════════════════════════════════════════════ // FLOWCHART — Subgraphs // ══════════════════════════════════════════════════════════════════════════ { title: 'Subgraphs', category: 'Flowchart', description: 'Grouped nodes inside labeled subgraph containers.', source: `graph TD subgraph Frontend A[React App] --> B[State Manager] end subgraph Backend C[API Server] --> D[Database] end B --> C`, }, { title: 'Nested Subgraphs', category: 'Flowchart', description: 'Subgraphs inside subgraphs for hierarchical grouping.', source: `graph TD subgraph Cloud subgraph us-east [US East Region] A[Web Server] --> B[App Server] end subgraph us-west [US West Region] C[Web Server] --> D[App Server] end end E[Load Balancer] --> A E --> C`, }, { title: 'Subgraph Direction Override', category: 'Flowchart', description: 'Using `direction LR` inside a subgraph while the outer graph flows TD.', source: `graph TD subgraph pipeline [Processing Pipeline] direction LR A[Input] --> B[Parse] --> C[Transform] --> D[Output] end E[Source] --> A D --> F[Sink]`, }, // ══════════════════════════════════════════════════════════════════════════ // FLOWCHART — Styling // ══════════════════════════════════════════════════════════════════════════ { title: '::: Class Shorthand', category: 'Flowchart', description: 'Assigning classes with `:::` syntax directly on node definitions.', source: `graph TD A[Normal]:::default --> B[Highlighted]:::highlight --> C[Error]:::error classDef default fill:#f4f4f5,stroke:#a1a1aa classDef highlight fill:#fbbf24,stroke:#d97706 classDef error fill:#ef4444,stroke:#dc2626`, }, { title: 'Inline Style Overrides', category: 'Flowchart', description: 'Using `style` statements to override node fill and stroke colors.', source: `graph TD A[Default] --> B[Custom Colors] --> C[Another Custom] style B fill:#3b82f6,stroke:#1d4ed8,color:#ffffff style C fill:#10b981,stroke:#059669`, }, // ══════════════════════════════════════════════════════════════════════════ // FLOWCHART — Real-World Diagrams // ══════════════════════════════════════════════════════════════════════════ { title: 'CI/CD Pipeline', category: 'Flowchart', description: 'A realistic CI/CD pipeline with decision points, feedback loops, and deployment stages.', source: `graph TD subgraph ci [CI Pipeline] A[Push Code] --> B{Tests Pass?} B -->|Yes| C[Build Image] B -->|No| D[Fix & Retry] D -.-> A end C --> E([Deploy Staging]) E --> F{QA Approved?} F -->|Yes| G((Production)) F -->|No| D`, }, { title: 'System Architecture', category: 'Flowchart', description: 'A microservices architecture with multiple services and data stores.', source: `graph LR subgraph clients [Client Layer] A([Web App]) --> B[API Gateway] C([Mobile App]) --> B end subgraph services [Service Layer] B --> D[Auth Service] B --> E[User Service] B --> F[Order Service] end subgraph data [Data Layer] D --> G[(Auth DB)] E --> H[(User DB)] F --> I[(Order DB)] F --> J([Message Queue]) end`, }, { title: 'Decision Tree', category: 'Flowchart', description: 'A branching decision flowchart with multiple outcomes.', source: `graph TD A{Is it raining?} -->|Yes| B{Have umbrella?} A -->|No| C([Go outside]) B -->|Yes| D([Go with umbrella]) B -->|No| E{Is it heavy?} E -->|Yes| F([Stay inside]) E -->|No| G([Run for it])`, }, { title: 'Git Branching Workflow', category: 'Flowchart', description: 'A git flow showing feature branches, PRs, and release cycle.', source: `graph LR A[main] --> B[develop] B --> C[feature/auth] B --> D[feature/ui] C --> E{PR Review} D --> E E -->|approved| B B --> F[release/1.0] F --> G{Tests?} G -->|pass| A G -->|fail| F`, }, // ══════════════════════════════════════════════════════════════════════════ // STATE DIAGRAMS // ══════════════════════════════════════════════════════════════════════════ { title: 'Basic State Diagram', category: 'State', description: 'A simple `stateDiagram-v2` with start/end pseudostates and transitions.', source: `stateDiagram-v2 [*] --> Idle Idle --> Active : start Active --> Idle : cancel Active --> Done : complete Done --> [*]`, }, { title: 'State: Composite States', category: 'State', description: 'Nested composite states with inner transitions.', source: `stateDiagram-v2 [*] --> Idle Idle --> Processing : submit state Processing { parse --> validate validate --> execute } Processing --> Complete : done Processing --> Error : fail Error --> Idle : retry Complete --> [*]`, }, { title: 'State: Connection Lifecycle', category: 'State', description: 'TCP-like connection state machine with multiple states.', source: `stateDiagram-v2 [*] --> Closed Closed --> Connecting : connect Connecting --> Connected : success Connecting --> Closed : timeout Connected --> Disconnecting : close Connected --> Reconnecting : error Reconnecting --> Connected : success Reconnecting --> Closed : max_retries Disconnecting --> Closed : done Closed --> [*]`, }, { title: 'State: CJK State Names', category: 'State', description: 'State diagram using Chinese characters for state names.', source: `stateDiagram-v2 [*] --> 空闲 空闲 --> 处理中 : 提交 处理中 --> 完成 : 成功 处理中 --> 错误 : 失败 错误 --> 空闲 : 重试 完成 --> [*]`, }, // ══════════════════════════════════════════════════════════════════════════ // SEQUENCE DIAGRAMS — Core Features // ══════════════════════════════════════════════════════════════════════════ { title: 'Sequence: Basic Messages', category: 'Sequence', description: 'Simple request/response between two participants.', source: `sequenceDiagram Alice->>Bob: Hello Bob! Bob-->>Alice: Hi Alice!`, }, { title: 'Sequence: Participant Aliases', category: 'Sequence', description: 'Using `participant ... as ...` for compact diagram IDs with readable labels.', source: `sequenceDiagram participant A as Alice participant B as Bob participant C as Charlie A->>B: Hello B->>C: Forward C-->>A: Reply`, }, { title: 'Sequence: Actor Stick Figures', category: 'Sequence', description: 'Using `actor` instead of `participant` renders stick figures instead of boxes.', source: `sequenceDiagram actor U as User participant S as System participant DB as Database U->>S: Click button S->>DB: Query DB-->>S: Results S-->>U: Display`, }, { title: 'Sequence: Arrow Types', category: 'Sequence', description: 'All arrow types: solid `->>` and dashed `-->>` with filled arrowheads, open arrows `-)` .', source: `sequenceDiagram A->>B: Solid arrow (sync) B-->>A: Dashed arrow (return) A-)B: Open arrow (async) B--)A: Open dashed arrow`, }, { title: 'Sequence: Activation Boxes', category: 'Sequence', description: 'Using `+` and `-` to show when participants are active.', source: `sequenceDiagram participant C as Client participant S as Server C->>+S: Request S->>+S: Process S->>-S: Done S-->>-C: Response`, }, { title: 'Sequence: Self-Messages', category: 'Sequence', description: 'A participant sending a message to itself (displayed as a loop arrow).', source: `sequenceDiagram participant S as Server S->>S: Internal process S->>S: Validate S-->>S: Log`, }, // ══════════════════════════════════════════════════════════════════════════ // SEQUENCE DIAGRAMS — Blocks // ══════════════════════════════════════════════════════════════════════════ { title: 'Sequence: Loop Block', category: 'Sequence', description: 'A `loop` construct wrapping repeated message exchanges.', source: `sequenceDiagram participant C as Client participant S as Server C->>S: Connect loop Every 30s C->>S: Heartbeat S-->>C: Ack end C->>S: Disconnect`, }, { title: 'Sequence: Alt/Else Block', category: 'Sequence', description: 'Conditional branching with `alt` (if) and `else` blocks.', source: `sequenceDiagram participant C as Client participant S as Server C->>S: Login alt Valid credentials S-->>C: 200 OK else Invalid S-->>C: 401 Unauthorized else Account locked S-->>C: 403 Forbidden end`, }, { title: 'Sequence: Opt Block', category: 'Sequence', description: 'Optional block — executes only if condition is met.', source: `sequenceDiagram participant A as App participant C as Cache participant DB as Database A->>C: Get data C-->>A: Cache miss opt Cache miss A->>DB: Query DB-->>A: Results A->>C: Store in cache end`, }, { title: 'Sequence: Par Block', category: 'Sequence', description: 'Parallel execution with `par`/`and` constructs.', source: `sequenceDiagram participant C as Client participant A as AuthService participant U as UserService participant O as OrderService C->>A: Authenticate par Fetch user data A->>U: Get profile and Fetch orders A->>O: Get orders end A-->>C: Combined response`, }, { title: 'Sequence: Critical Block', category: 'Sequence', description: 'Critical section that must complete atomically.', source: `sequenceDiagram participant A as App participant DB as Database A->>DB: BEGIN critical Transaction A->>DB: UPDATE accounts A->>DB: INSERT log end A->>DB: COMMIT`, }, // ══════════════════════════════════════════════════════════════════════════ // SEQUENCE DIAGRAMS — Notes // ══════════════════════════════════════════════════════════════════════════ { title: 'Sequence: Notes (Right/Left/Over)', category: 'Sequence', description: 'Notes positioned to the right, left, or over participants.', source: `sequenceDiagram participant A as Alice participant B as Bob Note left of A: Alice prepares A->>B: Hello Note right of B: Bob thinks B-->>A: Reply Note over A,B: Conversation complete`, }, // ══════════════════════════════════════════════════════════════════════════ // SEQUENCE DIAGRAMS — Complex / Real-World // ══════════════════════════════════════════════════════════════════════════ { title: 'Sequence: OAuth 2.0 Flow', category: 'Sequence', description: 'Full OAuth 2.0 authorization code flow with token exchange.', source: `sequenceDiagram actor U as User participant App as Client App participant Auth as Auth Server participant API as Resource API U->>App: Click Login App->>Auth: Authorization request Auth->>U: Login page U->>Auth: Credentials Auth-->>App: Authorization code App->>Auth: Exchange code for token Auth-->>App: Access token App->>API: Request + token API-->>App: Protected resource App-->>U: Display data`, }, { title: 'Sequence: Database Transaction', category: 'Sequence', description: 'Multi-step database transaction with rollback handling.', source: `sequenceDiagram participant C as Client participant S as Server participant DB as Database C->>S: POST /transfer S->>DB: BEGIN S->>DB: Debit account A alt Success S->>DB: Credit account B S->>DB: INSERT audit_log S->>DB: COMMIT S-->>C: 200 OK else Insufficient funds S->>DB: ROLLBACK S-->>C: 400 Bad Request end`, }, { title: 'Sequence: Microservice Orchestration', category: 'Sequence', description: 'Complex multi-service flow with parallel calls and error handling.', source: `sequenceDiagram participant G as Gateway participant A as Auth participant U as Users participant O as Orders participant N as Notify G->>A: Validate token A-->>G: Valid par Fetch data G->>U: Get user U-->>G: User data and G->>O: Get orders O-->>G: Order list end G->>N: Send notification N-->>G: Queued Note over G: Aggregate response`, }, { title: 'Sequence: Self-Messages with Notes', category: 'Sequence', description: 'Self-referencing messages inside alt blocks with notes — tests that notes clear self-message loops and stack without overlapping.', source: `sequenceDiagram participant User participant Main as Main Process participant Renderer participant Timer as 3s Fallback Timer User->>Main: CMD+W Main->>Main: event.preventDefault() Main->>Renderer: WINDOW_CLOSE_REQUESTED Main->>Timer: Start 3s timer alt Multiple panels Renderer->>Renderer: closePanel(focusedId) Note over Renderer: Panel removed Note over Renderer: No confirmCloseWindow! Timer-->>Main: 3s elapsed → window.destroy() else Single panel Renderer->>Renderer: closePanel(lastId) Note over Renderer: Stack becomes [] Renderer->>Renderer: Auto-select fires → new panel created! Note over Renderer: Panel reopens Timer-->>Main: 3s elapsed → window.destroy() end`, }, // ══════════════════════════════════════════════════════════════════════════ // CLASS DIAGRAMS — Core Features // ══════════════════════════════════════════════════════════════════════════ { title: 'Class: Basic Class', category: 'Class', description: 'A single class with attributes and methods, rendered as a 3-compartment box.', source: `classDiagram class Animal { +String name +int age +eat() void +sleep() void }`, }, { title: 'Class: Visibility Markers', category: 'Class', description: 'All four visibility levels: `+` (public), `-` (private), `#` (protected), `~` (package).', source: `classDiagram class User { +String name -String password #int internalId ~String packageField +login() bool -hashPassword() String #validate() void ~notify() void }`, }, { title: 'Class: Interface Annotation', category: 'Class', description: 'Using `<>` annotation above the class name.', source: `classDiagram class Serializable { <> +serialize() String +deserialize(data) void }`, }, { title: 'Class: Abstract Annotation', category: 'Class', description: 'Using `<>` annotation for abstract classes.', source: `classDiagram class Shape { <> +String color +area() double +draw() void }`, }, { title: 'Class: Enum Annotation', category: 'Class', description: 'Using `<>` annotation for enum types.', source: `classDiagram class Status { <> ACTIVE INACTIVE PENDING DELETED }`, }, // ══════════════════════════════════════════════════════════════════════════ // CLASS DIAGRAMS — Relationships // ══════════════════════════════════════════════════════════════════════════ { title: 'Class: Inheritance (<|--)', category: 'Class', description: 'Inheritance relationship rendered with a hollow triangle marker.', source: `classDiagram class Animal { +String name +eat() void } class Dog { +String breed +bark() void } class Cat { +bool isIndoor +meow() void } Animal <|-- Dog Animal <|-- Cat`, }, { title: 'Class: Composition (*--)', category: 'Class', description: 'Composition — "owns" relationship with filled diamond marker.', source: `classDiagram class Car { +String model +start() void } class Engine { +int horsepower +rev() void } Car *-- Engine`, }, { title: 'Class: Aggregation (o--)', category: 'Class', description: 'Aggregation — "has" relationship with hollow diamond marker.', source: `classDiagram class University { +String name } class Department { +String faculty } University o-- Department`, }, { title: 'Class: Association (-->)', category: 'Class', description: 'Basic association — simple directed arrow.', source: `classDiagram class Customer { +String name } class Order { +int orderId } Customer --> Order`, }, { title: 'Class: Dependency (..>)', category: 'Class', description: 'Dependency — dashed line with open arrow.', source: `classDiagram class Service { +process() void } class Repository { +find() Object } Service ..> Repository`, }, { title: 'Class: Realization (..|>)', category: 'Class', description: 'Realization — dashed line with hollow triangle (implements interface).', source: `classDiagram class Flyable { <> +fly() void } class Bird { +fly() void +sing() void } Bird ..|> Flyable`, }, { title: 'Class: All 6 Relationship Types', category: 'Class', description: 'Every relationship type in a single diagram for comparison.', source: `classDiagram A <|-- B : inheritance C *-- D : composition E o-- F : aggregation G --> H : association I ..> J : dependency K ..|> L : realization`, }, { title: 'Class: Relationship Labels', category: 'Class', description: 'Labeled relationships between classes with descriptive text.', source: `classDiagram class Teacher { +String name } class Student { +String name } class Course { +String title } Teacher --> Course : teaches Student --> Course : enrolled in`, }, // ══════════════════════════════════════════════════════════════════════════ // CLASS DIAGRAMS — Complex / Real-World // ══════════════════════════════════════════════════════════════════════════ { title: 'Class: Design Pattern — Observer', category: 'Class', description: 'The Observer (publish-subscribe) design pattern with interface + concrete implementations.', source: `classDiagram class Subject { <> +attach(Observer) void +detach(Observer) void +notify() void } class Observer { <> +update() void } class EventEmitter { -List~Observer~ observers +attach(Observer) void +detach(Observer) void +notify() void } class Logger { +update() void } class Alerter { +update() void } Subject <|.. EventEmitter Observer <|.. Logger Observer <|.. Alerter EventEmitter --> Observer`, }, { title: 'Class: MVC Architecture', category: 'Class', description: 'Model-View-Controller pattern showing relationships between layers.', source: `classDiagram class Model { -data Map +getData() Map +setData(key, val) void +notify() void } class View { -model Model +render() void +update() void } class Controller { -model Model -view View +handleInput(event) void +updateModel(data) void } Controller --> Model : updates Controller --> View : refreshes View --> Model : reads Model ..> View : notifies`, }, { title: 'Class: Full Hierarchy', category: 'Class', description: 'A complete class hierarchy with abstract base, interfaces, and concrete classes.', source: `classDiagram class Animal { <> +String name +int age +eat() void +sleep() void } class Mammal { +bool warmBlooded +nurse() void } class Bird { +bool canFly +layEggs() void } class Dog { +String breed +bark() void } class Cat { +bool isIndoor +purr() void } class Parrot { +String vocabulary +speak() void } Animal <|-- Mammal Animal <|-- Bird Mammal <|-- Dog Mammal <|-- Cat Bird <|-- Parrot`, }, // ══════════════════════════════════════════════════════════════════════════ // ER DIAGRAMS — Core Features // ══════════════════════════════════════════════════════════════════════════ { title: 'ER: Basic Relationship', category: 'ER', description: 'A simple one-to-many relationship between two entities.', source: `erDiagram CUSTOMER ||--o{ ORDER : places`, }, { title: 'ER: Entity with Attributes', category: 'ER', description: 'An entity with typed attributes and `PK`/`FK`/`UK` key badges.', source: `erDiagram CUSTOMER { int id PK string name string email UK date created_at }`, }, { title: 'ER: Attribute Keys (PK, FK, UK)', category: 'ER', description: 'All three key constraint types rendered as badges.', source: `erDiagram ORDER { int id PK int customer_id FK string invoice_number UK decimal total date order_date string status }`, }, // ══════════════════════════════════════════════════════════════════════════ // ER DIAGRAMS — Cardinality Types // ══════════════════════════════════════════════════════════════════════════ { title: 'ER: Exactly One to Exactly One (||--||)', category: 'ER', description: 'One-to-one mandatory relationship.', source: `erDiagram PERSON ||--|| PASSPORT : has`, }, { title: 'ER: Exactly One to Zero-or-Many (||--o{)', category: 'ER', description: 'Classic one-to-many optional relationship (crow\'s foot).', source: `erDiagram CUSTOMER ||--o{ ORDER : places`, }, { title: 'ER: Zero-or-One to One-or-Many (|o--|{)', category: 'ER', description: 'Optional on one side, at-least-one on the other.', source: `erDiagram SUPERVISOR |o--|{ EMPLOYEE : manages`, }, { title: 'ER: One-or-More to Zero-or-Many (}|--o{)', category: 'ER', description: 'At-least-one to zero-or-many relationship.', source: `erDiagram TEACHER }|--o{ COURSE : teaches`, }, { title: 'ER: All Cardinality Types', category: 'ER', description: 'Every cardinality combination in one diagram.', source: `erDiagram A ||--|| B : one-to-one C ||--o{ D : one-to-many E |o--|{ F : opt-to-many G }|--o{ H : many-to-many`, }, // ══════════════════════════════════════════════════════════════════════════ // ER DIAGRAMS — Line Styles // ══════════════════════════════════════════════════════════════════════════ { title: 'ER: Identifying (Solid) Relationship', category: 'ER', description: 'Solid line indicating an identifying relationship (child depends on parent for identity).', source: `erDiagram ORDER ||--|{ LINE_ITEM : contains`, }, { title: 'ER: Non-Identifying (Dashed) Relationship', category: 'ER', description: 'Dashed line indicating a non-identifying relationship.', source: `erDiagram USER ||..o{ LOG_ENTRY : generates USER ||..o{ SESSION : opens`, }, { title: 'ER: Mixed Identifying & Non-Identifying', category: 'ER', description: 'Both solid and dashed lines in the same diagram.', source: `erDiagram ORDER ||--|{ LINE_ITEM : contains ORDER ||..o{ SHIPMENT : ships-via PRODUCT ||--o{ LINE_ITEM : includes PRODUCT ||..o{ REVIEW : receives`, }, // ══════════════════════════════════════════════════════════════════════════ // ER DIAGRAMS — Complex / Real-World // ══════════════════════════════════════════════════════════════════════════ { title: 'ER: E-Commerce Schema', category: 'ER', description: 'Full e-commerce database schema with customers, orders, products, and line items.', source: `erDiagram CUSTOMER { int id PK string name string email UK } ORDER { int id PK date created int customer_id FK } PRODUCT { int id PK string name float price } LINE_ITEM { int id PK int order_id FK int product_id FK int quantity } CUSTOMER ||--o{ ORDER : places ORDER ||--|{ LINE_ITEM : contains PRODUCT ||--o{ LINE_ITEM : includes`, }, { title: 'ER: Blog Platform Schema', category: 'ER', description: 'Blog system with users, posts, comments, and tags.', source: `erDiagram USER { int id PK string username UK string email UK date joined } POST { int id PK string title text content int author_id FK date published } COMMENT { int id PK text body int post_id FK int user_id FK date created } TAG { int id PK string name UK } USER ||--o{ POST : writes USER ||--o{ COMMENT : authors POST ||--o{ COMMENT : has POST }|--o{ TAG : tagged-with`, }, { title: 'ER: School Management Schema', category: 'ER', description: 'School system with students, teachers, courses, and enrollments.', source: `erDiagram STUDENT { int id PK string name date dob string grade } TEACHER { int id PK string name string department } COURSE { int id PK string title int teacher_id FK int credits } ENROLLMENT { int id PK int student_id FK int course_id FK string semester float grade } TEACHER ||--o{ COURSE : teaches STUDENT ||--o{ ENROLLMENT : enrolled COURSE ||--o{ ENROLLMENT : has`, }, // ══════════════════════════════════════════════════════════════════════════ // XY CHARTS (xychart-beta) // ══════════════════════════════════════════════════════════════════════════ { title: 'XY: Simple Bar Chart', category: 'XY Chart', description: 'Basic bar chart with categorical x-axis.', source: `xychart-beta title "Product Sales" x-axis [Widgets, Gadgets, Gizmos, Doodads, Thingamajigs] bar [150, 230, 180, 95, 310]`, options: { interactive: true }, }, { title: 'XY: Line Chart', category: 'XY Chart', description: 'Line chart showing revenue growth over years.', source: `xychart-beta title "Revenue Growth" x-axis [2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025] line [320, 420, 540, 680, 820, 950, 1080, 1200]`, options: { interactive: true }, }, { title: 'XY: Bar and Line Overlay', category: 'XY Chart', description: 'Bars with a line overlay and both axis titles.', source: `xychart-beta title "Monthly Revenue" x-axis "Month" [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec] y-axis "Revenue (USD)" 0 --> 10000 bar [4200, 5000, 5800, 6200, 5500, 7000, 7800, 7200, 8400, 8100, 9000, 9200] line [4200, 5000, 5800, 6200, 5500, 7000, 7800, 7200, 8400, 8100, 9000, 9200]`, options: { interactive: true }, }, { title: 'XY: Horizontal Bars', category: 'XY Chart', description: 'Horizontal bar chart showing language popularity.', source: `xychart-beta horizontal title "Language Popularity" x-axis [Python, JavaScript, Java, Go, Rust] bar [30, 25, 20, 12, 8]`, options: { interactive: true }, }, { title: 'XY: Multiple Bar Series', category: 'XY Chart', description: 'Two bar series comparing years side by side.', source: `xychart-beta title "2023 vs 2024 Sales" x-axis [Q1, Q2, Q3, Q4] bar [200, 250, 300, 280] bar [230, 280, 320, 350]`, options: { interactive: true }, }, { title: 'XY: Dual Lines', category: 'XY Chart', description: 'Two lines comparing planned vs actual values.', source: `xychart-beta title "Planned vs Actual" x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug] line [100, 145, 190, 240, 280, 320, 360, 400] line [90, 130, 185, 235, 275, 340, 380, 420]`, options: { interactive: true }, }, { title: 'XY: Numeric X-Axis', category: 'XY Chart', description: 'Line chart using a numeric x-axis range.', source: `xychart-beta title "Distribution Curve" x-axis 0 --> 100 line [4, 7, 13, 21, 31, 43, 58, 71, 84, 91, 95, 91, 84, 71, 58, 43, 31, 21, 13, 7, 4]`, options: { interactive: true }, }, { title: 'XY: 12-Month Dataset', category: 'XY Chart', description: 'Full year monthly data with bar and trend line.', source: `xychart-beta title "Monthly Active Users (2024)" x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec] y-axis "Users" 0 --> 30000 bar [12000, 13500, 15200, 16800, 18500, 20100, 19800, 21500, 23000, 24200, 25800, 28000] line [12000, 13500, 15200, 16800, 18500, 20100, 19800, 21500, 23000, 24200, 25800, 28000]`, options: { interactive: true }, }, { title: 'XY: Horizontal Combined', category: 'XY Chart', description: 'Horizontal chart with both bars and a trend line.', source: `xychart-beta horizontal title "Budget vs Actual" x-axis [Eng, Sales, Marketing, Product, Ops, HR, Finance, Legal] bar [500, 350, 250, 200, 150, 120, 100, 80] line [480, 380, 230, 180, 160, 110, 95, 75]`, options: { interactive: true }, }, { title: 'XY: Sprint Burndown', category: 'XY Chart', description: 'Sprint burndown chart with actual and ideal lines.', source: `xychart-beta title "Sprint Burndown" x-axis [D1, D2, D3, D4, D5, D6, D7, D8, D9, D10] y-axis "Story Points" 0 --> 80 line [72, 65, 58, 50, 45, 38, 30, 22, 12, 0] line [72, 65, 58, 50, 43, 36, 29, 22, 14, 0]`, options: { interactive: true }, }, ] ================================================ FILE: src/__tests__/ascii-edge-styles.test.ts ================================================ // ============================================================================ // ASCII edge style tests — dotted and thick line rendering // ============================================================================ import { describe, it, expect } from 'bun:test' import { renderMermaidAscii } from '../ascii/index.ts' describe('ASCII edge styles', () => { describe('solid edges (default)', () => { it('renders solid edges with ─ in unicode mode', () => { const result = renderMermaidAscii(` graph LR A --> B `) expect(result).toContain('─') expect(result).not.toContain('┄') expect(result).not.toContain('━') }) it('renders solid edges with - in ascii mode', () => { const result = renderMermaidAscii(` graph LR A --> B `, { useAscii: true }) expect(result).toContain('-') }) }) describe('dotted edges (-.->)', () => { it('renders dotted edges with ┄ in unicode mode', () => { const result = renderMermaidAscii(` graph LR A -.-> B `) // Should contain dotted horizontal line character expect(result).toContain('┄') }) it('renders dotted edges with . in ascii mode', () => { const result = renderMermaidAscii(` graph LR A -.-> B `, { useAscii: true }) // Should contain dots for dotted lines expect(result).toContain('.') }) it('renders dotted vertical edges with ┆ in unicode mode', () => { const result = renderMermaidAscii(` graph TD A -.-> B `) // Should contain dotted vertical line character expect(result).toContain('┆') }) it('renders dotted vertical edges with : in ascii mode', () => { const result = renderMermaidAscii(` graph TD A -.-> B `, { useAscii: true }) // Should contain colons for dotted vertical lines expect(result).toContain(':') }) it('renders dotted edges with labels', () => { const result = renderMermaidAscii(` graph LR A -.->|optional| B `) expect(result).toContain('┄') expect(result).toContain('optional') }) }) describe('thick edges (==>)', () => { it('renders thick edges with ━ in unicode mode', () => { const result = renderMermaidAscii(` graph LR A ==> B `) // Should contain thick horizontal line character expect(result).toContain('━') }) it('renders thick edges with = in ascii mode', () => { const result = renderMermaidAscii(` graph LR A ==> B `, { useAscii: true }) // Should contain equals for thick lines expect(result).toContain('=') }) it('renders thick vertical edges with ┃ in unicode mode', () => { const result = renderMermaidAscii(` graph TD A ==> B `) // Should contain thick vertical line character expect(result).toContain('┃') }) }) describe('mixed edge styles', () => { it('renders different styles in the same diagram', () => { const result = renderMermaidAscii(` graph LR A --> B B -.-> C C ==> D `) // Should have all three line types expect(result).toContain('─') // solid expect(result).toContain('┄') // dotted expect(result).toContain('━') // thick }) it('renders mixed styles in ascii mode', () => { const result = renderMermaidAscii(` graph LR A --> B B -.-> C C ==> D `, { useAscii: true }) // Note: ASCII mode uses - for solid, . for dotted, = for thick // We just check that the diagram renders without error expect(result).toContain('A') expect(result).toContain('B') expect(result).toContain('C') expect(result).toContain('D') }) }) }) ================================================ FILE: src/__tests__/ascii-multiline.test.ts ================================================ import { describe, it, expect } from 'bun:test' import { renderMermaidAscii } from '../ascii/index.ts' describe('ASCII multi-line labels', () => { describe('flowchart nodes', () => { it('renders multi-line node labels', () => { const ascii = renderMermaidAscii('graph TD\n A[Line1
    Line2]', { useAscii: false }) expect(ascii).toContain('Line1') expect(ascii).toContain('Line2') // Lines should be on different rows const lines = ascii.split('\n') const line1Row = lines.findIndex(l => l.includes('Line1')) const line2Row = lines.findIndex(l => l.includes('Line2')) expect(line2Row).toBeGreaterThan(line1Row) }) it('handles 3+ line labels', () => { const ascii = renderMermaidAscii('graph TD\n A[A
    B
    C]', { useAscii: false }) expect(ascii).toContain('A') expect(ascii).toContain('B') expect(ascii).toContain('C') // Verify vertical ordering const lines = ascii.split('\n') const aRow = lines.findIndex(l => l.includes('A') && !l.includes('─') && !l.includes('-')) const bRow = lines.findIndex(l => l.includes('B')) const cRow = lines.findIndex(l => l.includes('C')) expect(bRow).toBeGreaterThan(aRow) expect(cRow).toBeGreaterThan(bRow) }) it('renders in ASCII mode (not Unicode)', () => { const ascii = renderMermaidAscii('graph TD\n A[Line1
    Line2]', { useAscii: true }) expect(ascii).toContain('Line1') expect(ascii).toContain('Line2') // Should use ASCII box characters expect(ascii).toContain('+') expect(ascii).toContain('-') }) }) describe('flowchart edge labels', () => { it('renders multi-line edge labels', () => { const ascii = renderMermaidAscii('graph TD\n A --> B\n A -->|Line1
    Line2| C', { useAscii: false }) expect(ascii).toContain('Line1') expect(ascii).toContain('Line2') }) }) describe('flowchart subgraph labels', () => { it('renders multi-line subgraph labels', () => { const ascii = renderMermaidAscii(`graph TD subgraph sg [Group
    Header] A[Node] end `, { useAscii: false }) expect(ascii).toContain('Group') expect(ascii).toContain('Header') }) }) describe('sequence diagram', () => { it('renders multi-line actor labels', () => { const ascii = renderMermaidAscii(`sequenceDiagram participant A as Actor
    One A->>A: msg `, { useAscii: false }) expect(ascii).toContain('Actor') expect(ascii).toContain('One') }) it('renders multi-line message labels', () => { const ascii = renderMermaidAscii(`sequenceDiagram participant A participant B A->>B: Line1
    Line2 `, { useAscii: false }) expect(ascii).toContain('Line1') expect(ascii).toContain('Line2') }) it('preserves existing note multi-line support', () => { const ascii = renderMermaidAscii(`sequenceDiagram participant A A->>A: self Note over A: Note line 1
    Note line 2 `, { useAscii: false }) expect(ascii).toContain('Note line 1') expect(ascii).toContain('Note line 2') }) }) describe('class diagram', () => { it('renders multi-line class names', () => { const ascii = renderMermaidAscii(`classDiagram class MyClass["Long
    Name"] `, { useAscii: false }) expect(ascii).toContain('Long') expect(ascii).toContain('Name') }) it('renders multi-line relationship labels', () => { const ascii = renderMermaidAscii(`classDiagram A --> B : uses
    implements `, { useAscii: false }) expect(ascii).toContain('uses') expect(ascii).toContain('implements') }) }) describe('ER diagram', () => { it('renders multi-line entity names', () => { const ascii = renderMermaidAscii(`erDiagram "Entity
    Name" { string id } `, { useAscii: false }) expect(ascii).toContain('Entity') expect(ascii).toContain('Name') }) it('renders multi-line relationship labels', () => { const ascii = renderMermaidAscii(`erDiagram A ||--o{ B : "has
    many" `, { useAscii: false }) expect(ascii).toContain('has') expect(ascii).toContain('many') }) }) describe('edge cases', () => { it('handles empty lines from consecutive
    ', () => { const ascii = renderMermaidAscii('graph TD\n A[Line1

    Line3]', { useAscii: false }) expect(ascii).toContain('Line1') expect(ascii).toContain('Line3') }) it('handles single-line labels (no
    )', () => { const ascii = renderMermaidAscii('graph TD\n A[SingleLine]', { useAscii: false }) expect(ascii).toContain('SingleLine') }) it('handles very long lines', () => { const long = 'A'.repeat(30) const ascii = renderMermaidAscii(`graph TD\n A[${long}
    Short]`, { useAscii: false }) expect(ascii).toContain(long) expect(ascii).toContain('Short') }) it('handles mixed short and long lines', () => { const ascii = renderMermaidAscii('graph TD\n A[Short
    VeryLongSecondLine
    Med]', { useAscii: false }) expect(ascii).toContain('Short') expect(ascii).toContain('VeryLongSecondLine') expect(ascii).toContain('Med') }) }) describe('multiline-utils functions', () => { it('splitLines splits on newlines', () => { // Test through the rendering pipeline const ascii = renderMermaidAscii('graph TD\n A[One
    Two
    Three]', { useAscii: false }) const lines = ascii.split('\n') // All three words should appear on separate lines expect(lines.some(l => l.includes('One'))).toBe(true) expect(lines.some(l => l.includes('Two'))).toBe(true) expect(lines.some(l => l.includes('Three'))).toBe(true) }) it('maxLineWidth uses longest line for box sizing', () => { // Box should be wide enough for the longest line const ascii = renderMermaidAscii('graph TD\n A[X
    LongLine
    Y]', { useAscii: false }) // The box should contain LongLine without truncation expect(ascii).toContain('LongLine') }) }) }) ================================================ FILE: src/__tests__/ascii.test.ts ================================================ /** * Golden-file tests for the ASCII/Unicode renderer. * * Ported from AlexanderGrooff/mermaid-ascii cmd/graph_test.go. * Each .txt file contains mermaid input above a `---` separator * and the expected ASCII/Unicode output below it. * * Test data: 44 ASCII files + 22 Unicode files = 66 total. */ import { describe, it, expect } from 'bun:test' import { renderMermaidAscii } from '../ascii/index.ts' import { hasDiagonalLines, DIAGONAL_CHARS } from '../ascii/validate.ts' import { readdirSync, readFileSync } from 'node:fs' import { join } from 'node:path' // ============================================================================ // Test case parser — matches Go's testutil.ReadTestCase format // ============================================================================ interface TestCase { mermaid: string expected: string paddingX: number paddingY: number } /** * Parse a golden test file into its components. * Format: * [paddingX=N] (optional) * [paddingY=N] (optional) * * --- * */ function parseTestCase(content: string): TestCase { const tc: TestCase = { mermaid: '', expected: '', paddingX: 5, paddingY: 5 } const lines = content.split('\n') const paddingRegex = /^(?:padding([xy]))\s*=\s*(\d+)\s*$/i let inMermaid = true let mermaidStarted = false const mermaidLines: string[] = [] const expectedLines: string[] = [] for (const line of lines) { if (line === '---') { inMermaid = false continue } if (inMermaid) { const trimmed = line.trim() // Before mermaid code starts, parse padding directives and skip blanks if (!mermaidStarted) { if (trimmed === '') continue const match = trimmed.match(paddingRegex) if (match) { const value = parseInt(match[2]!, 10) if (match[1]!.toLowerCase() === 'x') { tc.paddingX = value } else { tc.paddingY = value } continue } } mermaidStarted = true mermaidLines.push(line) } else { expectedLines.push(line) } } tc.mermaid = mermaidLines.join('\n') + '\n' // Strip final trailing newline (matches Go's strings.TrimSuffix(expected, "\n")) let expected = expectedLines.join('\n') if (expected.endsWith('\n')) { expected = expected.slice(0, -1) } tc.expected = expected return tc } // ============================================================================ // Whitespace normalization — matches Go's testutil.NormalizeWhitespace // ============================================================================ /** * Normalize whitespace for comparison: * - Trim trailing spaces from each line * - Remove leading/trailing blank lines */ function normalizeWhitespace(s: string): string { const lines = s.split('\n') let normalized = lines.map(l => l.trimEnd()) // Remove leading blank lines while (normalized.length > 0 && normalized[0] === '') { normalized.shift() } // Remove trailing blank lines while (normalized.length > 0 && normalized[normalized.length - 1] === '') { normalized.pop() } return normalized.join('\n') } /** Replace spaces with middle dots for clearer diff output. */ function visualizeWhitespace(s: string): string { return s.replaceAll(' ', '·') } // ============================================================================ // Test runner — dynamically loads all golden files from testdata directories // ============================================================================ function runGoldenTests(dir: string, useAscii: boolean): void { const files = readdirSync(dir).filter(f => f.endsWith('.txt')).sort() for (const file of files) { const testName = file.replace('.txt', '') it(testName, () => { const content = readFileSync(join(dir, file), 'utf-8') const tc = parseTestCase(content) const actual = renderMermaidAscii(tc.mermaid, { useAscii, paddingX: tc.paddingX, paddingY: tc.paddingY, }) const normalizedExpected = normalizeWhitespace(tc.expected) const normalizedActual = normalizeWhitespace(actual) if (normalizedExpected !== normalizedActual) { const expectedVis = visualizeWhitespace(normalizedExpected) const actualVis = visualizeWhitespace(normalizedActual) expect(actualVis).toBe(expectedVis) } }) } } // ============================================================================ // Test suites // ============================================================================ const testdataDir = join(import.meta.dir, 'testdata') describe('ASCII rendering', () => { runGoldenTests(join(testdataDir, 'ascii'), true) }) describe('Unicode rendering', () => { runGoldenTests(join(testdataDir, 'unicode'), false) }) // ============================================================================ // Config behavior tests — ported from Go's TestGraphUseAsciiConfig // ============================================================================ describe('Config behavior', () => { const mermaidInput = 'graph LR\nA --> B' it('ASCII and Unicode outputs should differ', () => { const asciiOutput = renderMermaidAscii(mermaidInput, { useAscii: true }) const unicodeOutput = renderMermaidAscii(mermaidInput, { useAscii: false }) expect(asciiOutput).not.toBe(unicodeOutput) }) it('ASCII output should not contain Unicode box-drawing characters', () => { const output = renderMermaidAscii(mermaidInput, { useAscii: true }) expect(output).not.toContain('┌') expect(output).not.toContain('─') expect(output).not.toContain('│') }) it('Unicode output should contain Unicode box-drawing characters', () => { const output = renderMermaidAscii(mermaidInput, { useAscii: false }) const hasUnicode = output.includes('┌') || output.includes('─') || output.includes('│') expect(hasUnicode).toBe(true) }) }) // ============================================================================ // Diagonal validation — ensures all edges use orthogonal Manhattan routing // ============================================================================ describe('Diagonal validation', () => { const asciiDir = join(testdataDir, 'ascii') const unicodeDir = join(testdataDir, 'unicode') it('ASCII output should never contain diagonal characters', () => { // Test all ASCII golden files const files = readdirSync(asciiDir).filter((f) => f.endsWith('.txt')) for (const file of files) { const content = readFileSync(join(asciiDir, file), 'utf-8') const { mermaid, paddingX, paddingY } = parseTestCase(content) const output = renderMermaidAscii(mermaid, { useAscii: true, boxBorderPadding: paddingX, paddingY: paddingY, }) // Check for diagonal characters for (const char of DIAGONAL_CHARS.ascii) { expect(output).not.toContain(char) } } }) it('Unicode output should never contain diagonal characters', () => { // Test all Unicode golden files const files = readdirSync(unicodeDir).filter((f) => f.endsWith('.txt')) for (const file of files) { const content = readFileSync(join(unicodeDir, file), 'utf-8') const { mermaid, paddingX, paddingY } = parseTestCase(content) const output = renderMermaidAscii(mermaid, { useAscii: false, boxBorderPadding: paddingX, paddingY: paddingY, }) // Check for diagonal characters for (const char of DIAGONAL_CHARS.unicode) { expect(output).not.toContain(char) } } }) it('hasDiagonalLines utility correctly detects diagonal characters', () => { // Should detect ASCII diagonals expect(hasDiagonalLines('A / B')).toBe(true) expect(hasDiagonalLines('A \\ B')).toBe(true) // Should detect Unicode diagonals expect(hasDiagonalLines('A ╱ B')).toBe(true) expect(hasDiagonalLines('A ╲ B')).toBe(true) // Should not flag clean output expect(hasDiagonalLines('┌───┐\n│ A │\n└───┘')).toBe(false) expect(hasDiagonalLines('+---+\n| A |\n+---+')).toBe(false) }) }) ================================================ FILE: src/__tests__/class-arrow-directions.test.ts ================================================ /** * Comprehensive tests for class diagram arrow directions. * * Ensures all relationship types have correctly oriented arrows: * - Inheritance/Realization: hollow triangles point toward parent/interface * - Association/Dependency: filled arrows point from source to target * - Composition/Aggregation: diamonds are omnidirectional */ import { describe, test, expect } from 'bun:test' import { renderMermaidAscii } from '../ascii/index.ts' describe('Class Diagram Arrow Directions', () => { // ============================================================================ // INHERITANCE (<|--) // ============================================================================ describe('Inheritance (<|--)', () => { test('parent above child - triangle points UP toward parent', () => { const diagram = `classDiagram Animal <|-- Dog` const result = renderMermaidAscii(diagram) // Should contain upward triangle expect(result).toContain('△') expect(result).not.toContain('▽') // Parent should be above child const lines = result.split('\n') const animalLine = lines.findIndex(l => l.includes('Animal')) const dogLine = lines.findIndex(l => l.includes('Dog')) expect(animalLine).toBeLessThan(dogLine) }) test('multiple inheritance creates separate arrows', () => { const diagram = `classDiagram Animal <|-- Dog Animal <|-- Cat Dog <|-- Puppy` const result = renderMermaidAscii(diagram) // Animal should be at top, then Dog/Cat, then Puppy const lines = result.split('\n') const animalLine = lines.findIndex(l => l.includes('Animal')) const dogLine = lines.findIndex(l => l.includes('Dog')) const catLine = lines.findIndex(l => l.includes('Cat')) const puppyLine = lines.findIndex(l => l.includes('Puppy')) expect(animalLine).toBeLessThan(dogLine) expect(animalLine).toBeLessThan(catLine) expect(dogLine).toBeLessThan(puppyLine) }) test('multi-level inheritance - all triangles point UP', () => { const diagram = `classDiagram Animal <|-- Mammal Mammal <|-- Dog` const result = renderMermaidAscii(diagram) // Verify ordering: Animal > Mammal > Dog (top to bottom) const lines = result.split('\n') const animalLine = lines.findIndex(l => l.includes('Animal')) const mammalLine = lines.findIndex(l => l.includes('Mammal')) const dogLine = lines.findIndex(l => l.includes('Dog')) expect(animalLine).toBeLessThan(mammalLine) expect(mammalLine).toBeLessThan(dogLine) // All triangles should point up expect(result.match(/△/g)?.length).toBe(2) }) test('multiple inheritance from same parent', () => { const diagram = `classDiagram Animal <|-- Dog Animal <|-- Cat` const result = renderMermaidAscii(diagram) // Animal should be above both children const lines = result.split('\n') const animalLine = lines.findIndex(l => l.includes('Animal')) const dogLine = lines.findIndex(l => l.includes('Dog')) const catLine = lines.findIndex(l => l.includes('Cat')) expect(animalLine).toBeLessThan(dogLine) expect(animalLine).toBeLessThan(catLine) // Should have at least one triangle pointing up (may merge visually) expect(result).toContain('△') }) test('ASCII mode uses ^ for upward triangle', () => { const diagram = `classDiagram Animal <|-- Dog` const result = renderMermaidAscii(diagram, { useAscii: true }) expect(result).toContain('^') expect(result).not.toContain('v') }) }) // ============================================================================ // ASSOCIATION (-->) // ============================================================================ describe('Association (-->)', () => { test('source above target - arrow points DOWN', () => { const diagram = `classDiagram Person --> Address` const result = renderMermaidAscii(diagram) // Should contain downward arrow expect(result).toContain('▼') expect(result).not.toContain('▲') // Person should be above Address const lines = result.split('\n') const personLine = lines.findIndex(l => l.includes('Person')) const addressLine = lines.findIndex(l => l.includes('Address')) expect(personLine).toBeLessThan(addressLine) }) test('multiple associations from same source', () => { const diagram = `classDiagram Person --> Address Person --> Phone` const result = renderMermaidAscii(diagram) // Person should be above both targets const lines = result.split('\n') const personLine = lines.findIndex(l => l.includes('Person')) const addressLine = lines.findIndex(l => l.includes('Address')) const phoneLine = lines.findIndex(l => l.includes('Phone')) expect(personLine).toBeLessThan(addressLine) expect(personLine).toBeLessThan(phoneLine) }) test('chain of associations', () => { const diagram = `classDiagram A --> B B --> C` const result = renderMermaidAscii(diagram) // A > B > C ordering const lines = result.split('\n') const aLine = lines.findIndex(l => l.includes('│ A │')) const bLine = lines.findIndex(l => l.includes('│ B │')) const cLine = lines.findIndex(l => l.includes('│ C │')) expect(aLine).toBeLessThan(bLine) expect(bLine).toBeLessThan(cLine) // Both arrows point down expect(result.match(/▼/g)?.length).toBe(2) }) test('ASCII mode uses v for downward arrow', () => { const diagram = `classDiagram Person --> Address` const result = renderMermaidAscii(diagram, { useAscii: true }) expect(result).toContain('v') expect(result).not.toContain('^') }) }) // ============================================================================ // DEPENDENCY (..>) // ============================================================================ describe('Dependency (..>)', () => { test('source above target - arrow points DOWN', () => { const diagram = `classDiagram Client ..> Server` const result = renderMermaidAscii(diagram) expect(result).toContain('▼') expect(result).not.toContain('▲') const lines = result.split('\n') const clientLine = lines.findIndex(l => l.includes('Client')) const serverLine = lines.findIndex(l => l.includes('Server')) expect(clientLine).toBeLessThan(serverLine) }) test('multiple dependencies', () => { const diagram = `classDiagram Client ..> Server Client ..> Database` const result = renderMermaidAscii(diagram) const lines = result.split('\n') const clientLine = lines.findIndex(l => l.includes('Client')) const serverLine = lines.findIndex(l => l.includes('Server')) const dbLine = lines.findIndex(l => l.includes('Database')) expect(clientLine).toBeLessThan(serverLine) expect(clientLine).toBeLessThan(dbLine) }) test('ASCII mode uses v for downward arrow', () => { const diagram = `classDiagram Client ..> Server` const result = renderMermaidAscii(diagram, { useAscii: true }) expect(result).toContain('v') }) }) // ============================================================================ // REALIZATION (..|>) // ============================================================================ describe('Realization (..|>)', () => { test('interface above implementation - triangle points UP', () => { // Circle ..|> Shape means "Circle implements Shape" // Shape (interface/parent) should be placed ABOVE Circle (implementation/child) const diagram = `classDiagram Circle ..|> Shape` const result = renderMermaidAscii(diagram) // Shape (interface) should be above Circle (implementation) const lines = result.split('\n') const shapeLine = lines.findIndex(l => l.includes('Shape')) const circleLine = lines.findIndex(l => l.includes('Circle')) expect(shapeLine).toBeLessThan(circleLine) expect(result).toContain('△') }) test('realization with <|.. syntax (marker at from end)', () => { // Shape <|.. Circle means "Circle implements Shape" (same as Circle ..|> Shape) const diagram = `classDiagram Shape <|.. Circle` const result = renderMermaidAscii(diagram) // Shape (interface) should be above Circle (implementation) const lines = result.split('\n') const shapeLine = lines.findIndex(l => l.includes('Shape')) const circleLine = lines.findIndex(l => l.includes('Circle')) expect(shapeLine).toBeLessThan(circleLine) expect(result).toContain('△') }) test('multiple implementations', () => { // Circle and Square both implement Shape const diagram = `classDiagram Circle ..|> Shape Square ..|> Shape` const result = renderMermaidAscii(diagram) // Shape (interface) above both implementations const lines = result.split('\n') const shapeLine = lines.findIndex(l => l.includes('Shape')) const circleLine = lines.findIndex(l => l.includes('Circle')) const squareLine = lines.findIndex(l => l.includes('Square')) expect(shapeLine).toBeLessThan(circleLine) expect(shapeLine).toBeLessThan(squareLine) // At least one triangle (may merge visually if same connection point) expect(result).toContain('△') }) }) // ============================================================================ // COMPOSITION & AGGREGATION (omnidirectional diamonds) // ============================================================================ describe('Composition (*--) and Aggregation (o--)', () => { test('composition - diamond is omnidirectional', () => { const diagram = `classDiagram Car *-- Engine` const result = renderMermaidAscii(diagram) // Should contain filled diamond expect(result).toContain('◆') }) test('aggregation - hollow diamond is omnidirectional', () => { const diagram = `classDiagram Team o-- Player` const result = renderMermaidAscii(diagram) // Should contain hollow diamond expect(result).toContain('◇') }) }) // ============================================================================ // MIXED SCENARIOS // ============================================================================ describe('Mixed Relationship Scenarios', () => { test('all 6 relationship types together', () => { const diagram = `classDiagram A <|-- B : inheritance C *-- D : composition E o-- F : aggregation G --> H : association I ..> J : dependency K ..|> L : realization` const result = renderMermaidAscii(diagram) // Upward triangles for inheritance and realization expect(result.match(/△/g)?.length).toBe(2) // Downward arrows for association and dependency expect(result.match(/▼/g)?.length).toBe(2) // Diamonds for composition and aggregation expect(result).toContain('◆') expect(result).toContain('◇') }) test('inheritance with association - different arrow directions', () => { const diagram = `classDiagram Animal <|-- Dog Dog --> Food` const result = renderMermaidAscii(diagram) // Should have both up triangle (inheritance) and down arrow (association) expect(result).toContain('△') expect(result).toContain('▼') }) test('circular reference creates valid layout', () => { const diagram = `classDiagram A --> B B --> C C ..> A` const result = renderMermaidAscii(diagram) // Cycles may create mixed arrow directions (up and down) to avoid overlaps // Just verify arrows are present and classes are rendered const hasUpArrow = result.includes('▲') const hasDownArrow = result.includes('▼') expect(hasUpArrow || hasDownArrow).toBe(true) expect(result).toContain('│ A │') expect(result).toContain('│ B │') expect(result).toContain('│ C │') }) }) // ============================================================================ // ASCII vs UNICODE CONSISTENCY // ============================================================================ describe('ASCII and Unicode Mode Consistency', () => { test('same diagram produces consistent layouts in both modes', () => { const diagram = `classDiagram Animal <|-- Dog Person --> Address` const unicode = renderMermaidAscii(diagram) const ascii = renderMermaidAscii(diagram, { useAscii: true }) // Both should have same node ordering const unicodeLines = unicode.split('\n') const asciiLines = ascii.split('\n') const uAnimal = unicodeLines.findIndex(l => l.includes('Animal')) const uDog = unicodeLines.findIndex(l => l.includes('Dog')) const aPerson = asciiLines.findIndex(l => l.includes('Person')) const aAddress = asciiLines.findIndex(l => l.includes('Address')) expect(uAnimal).toBeLessThan(uDog) expect(aPerson).toBeLessThan(aAddress) // Unicode has △ and ▼, ASCII has ^ and v expect(unicode).toContain('△') expect(unicode).toContain('▼') expect(ascii).toContain('^') expect(ascii).toContain('v') }) }) // ============================================================================ // EDGE CASES // ============================================================================ describe('Edge Cases', () => { test('single inheritance relationship', () => { const diagram = `classDiagram A <|-- B` const result = renderMermaidAscii(diagram) expect(result).toContain('△') const lines = result.split('\n') const aLine = lines.findIndex(l => l.includes('│ A │')) const bLine = lines.findIndex(l => l.includes('│ B │')) expect(aLine).toBeLessThan(bLine) }) test('classes with members maintain arrow directions', () => { const diagram = `classDiagram class Animal { +String name +eat() void } class Dog { +bark() void } Animal <|-- Dog` const result = renderMermaidAscii(diagram) expect(result).toContain('△') const lines = result.split('\n') const animalLine = lines.findIndex(l => l.includes('Animal')) const dogLine = lines.findIndex(l => l.includes('Dog')) expect(animalLine).toBeLessThan(dogLine) }) }) }) ================================================ FILE: src/__tests__/class-integration.test.ts ================================================ /** * Integration tests for class diagrams — end-to-end parse → layout → render. */ import { describe, it, expect } from 'bun:test' import { renderMermaidSVG } from '../index.ts' describe('renderMermaidSVG – class diagrams', () => { it('renders a basic class diagram to valid SVG', () => { const svg = renderMermaidSVG(`classDiagram class Animal { +String name +eat() void }`) expect(svg).toContain('') expect(svg).toContain('Animal') expect(svg).toContain('name') expect(svg).toContain('eat') }) it('renders class with annotation', () => { const svg = renderMermaidSVG(`classDiagram class Flyable { <> +fly() void }`) expect(svg).toContain('interface') expect(svg).toContain('Flyable') expect(svg).toContain('fly') }) it('renders inheritance relationship with triangle marker', () => { const svg = renderMermaidSVG(`classDiagram Animal <|-- Dog`) expect(svg).toContain('Animal') expect(svg).toContain('Dog') // Inheritance uses a hollow triangle marker expect(svg).toContain('cls-inherit') }) it('renders composition with filled diamond', () => { const svg = renderMermaidSVG(`classDiagram Car *-- Engine`) expect(svg).toContain('cls-composition') }) it('renders aggregation with hollow diamond', () => { const svg = renderMermaidSVG(`classDiagram University o-- Department`) expect(svg).toContain('cls-aggregation') }) it('renders dependency with dashed line', () => { const svg = renderMermaidSVG(`classDiagram Service ..> Repository`) expect(svg).toContain('stroke-dasharray') expect(svg).toContain('cls-arrow') }) it('renders realization with dashed line and triangle', () => { const svg = renderMermaidSVG(`classDiagram Bird ..|> Flyable`) expect(svg).toContain('stroke-dasharray') expect(svg).toContain('cls-inherit') }) it('renders relationship labels', () => { const svg = renderMermaidSVG(`classDiagram Customer --> Order : places`) expect(svg).toContain('places') }) it('renders class compartments with divider lines', () => { const svg = renderMermaidSVG(`classDiagram class Animal { +String name +eat() void }`) // Should have horizontal divider lines between compartments const lines = svg.match(/ { const svg = renderMermaidSVG(`classDiagram class A { +x int }`, { bg: '#18181B', fg: '#FAFAFA' }) expect(svg).toContain('--bg:#18181B') }) it('renders a complete class hierarchy', () => { const svg = renderMermaidSVG(`classDiagram class Animal { <> +String name +eat() void } class Dog { +String breed +bark() void } class Cat { +bool isIndoor +meow() void } Animal <|-- Dog Animal <|-- Cat`) expect(svg).toContain('Animal') expect(svg).toContain('Dog') expect(svg).toContain('Cat') expect(svg).toContain('abstract') }) }) ================================================ FILE: src/__tests__/class-parser.test.ts ================================================ /** * Tests for the class diagram parser. * * Covers: class blocks, attributes, methods, visibility, annotations, * relationships (all 6 types), cardinality, labels, inline attributes. */ import { describe, it, expect } from 'bun:test' import { parseClassDiagram } from '../class/parser.ts' /** Helper to parse — preprocesses text the same way index.ts does */ function parse(text: string) { const lines = text.split('\n').map(l => l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) return parseClassDiagram(lines) } // ============================================================================ // Class definitions // ============================================================================ describe('parseClassDiagram – class definitions', () => { it('parses a class block with attributes and methods', () => { const d = parse(`classDiagram class Animal { +String name +int age +eat() void +sleep() }`) expect(d.classes).toHaveLength(1) expect(d.classes[0]!.id).toBe('Animal') expect(d.classes[0]!.attributes).toHaveLength(2) expect(d.classes[0]!.methods).toHaveLength(2) }) it('parses attribute visibility (+ - # ~)', () => { const d = parse(`classDiagram class MyClass { +String publicField -int privateField #double protectedField ~bool packageField }`) expect(d.classes[0]!.attributes[0]!.visibility).toBe('+') expect(d.classes[0]!.attributes[1]!.visibility).toBe('-') expect(d.classes[0]!.attributes[2]!.visibility).toBe('#') expect(d.classes[0]!.attributes[3]!.visibility).toBe('~') }) it('parses method with return type', () => { const d = parse(`classDiagram class Calc { +add(a, b) int }`) expect(d.classes[0]!.methods[0]!.name).toBe('add') expect(d.classes[0]!.methods[0]!.type).toBe('int') }) it('parses annotation <>', () => { const d = parse(`classDiagram class Flyable { <> +fly() void }`) expect(d.classes[0]!.annotation).toBe('interface') expect(d.classes[0]!.methods).toHaveLength(1) }) it('parses inline annotation syntax', () => { const d = parse(`classDiagram class Shape { <> }`) expect(d.classes[0]!.annotation).toBe('abstract') }) it('parses standalone class declaration', () => { const d = parse(`classDiagram class EmptyClass`) expect(d.classes).toHaveLength(1) expect(d.classes[0]!.id).toBe('EmptyClass') }) it('auto-creates classes from relationships', () => { const d = parse(`classDiagram Animal <|-- Dog`) expect(d.classes).toHaveLength(2) expect(d.classes.find(c => c.id === 'Animal')).toBeDefined() expect(d.classes.find(c => c.id === 'Dog')).toBeDefined() }) }) // ============================================================================ // Inline attributes // ============================================================================ describe('parseClassDiagram – inline attributes', () => { it('parses inline attribute: ClassName : +Type name', () => { const d = parse(`classDiagram class Animal Animal : +String name Animal : +int age`) const cls = d.classes.find(c => c.id === 'Animal')! expect(cls.attributes).toHaveLength(2) expect(cls.attributes[0]!.name).toBe('name') }) }) // ============================================================================ // Relationships // ============================================================================ describe('parseClassDiagram – relationships', () => { it('parses inheritance: <|-- (marker at from)', () => { const d = parse(`classDiagram Animal <|-- Dog`) expect(d.relationships).toHaveLength(1) expect(d.relationships[0]!.type).toBe('inheritance') expect(d.relationships[0]!.from).toBe('Animal') expect(d.relationships[0]!.to).toBe('Dog') expect(d.relationships[0]!.markerAt).toBe('from') }) it('parses composition: *-- (marker at from)', () => { const d = parse(`classDiagram Car *-- Engine`) expect(d.relationships[0]!.type).toBe('composition') expect(d.relationships[0]!.markerAt).toBe('from') }) it('parses aggregation: o-- (marker at from)', () => { const d = parse(`classDiagram University o-- Department`) expect(d.relationships[0]!.type).toBe('aggregation') expect(d.relationships[0]!.markerAt).toBe('from') }) it('parses association: --> (marker at to)', () => { const d = parse(`classDiagram Customer --> Order`) expect(d.relationships[0]!.type).toBe('association') expect(d.relationships[0]!.markerAt).toBe('to') }) it('parses dependency: ..> (marker at to)', () => { const d = parse(`classDiagram Service ..> Repository`) expect(d.relationships[0]!.type).toBe('dependency') expect(d.relationships[0]!.markerAt).toBe('to') }) it('parses realization: ..|> (marker at to)', () => { const d = parse(`classDiagram Bird ..|> Flyable`) expect(d.relationships[0]!.type).toBe('realization') expect(d.relationships[0]!.markerAt).toBe('to') }) // --- Reversed arrow variants --- it('parses reversed realization: <|.. (marker at from)', () => { const d = parse(`classDiagram Flyable <|.. Bird`) expect(d.relationships[0]!.type).toBe('realization') expect(d.relationships[0]!.from).toBe('Flyable') expect(d.relationships[0]!.to).toBe('Bird') expect(d.relationships[0]!.markerAt).toBe('from') }) it('parses reversed composition: --* (marker at to)', () => { const d = parse(`classDiagram Engine --* Car`) expect(d.relationships[0]!.type).toBe('composition') expect(d.relationships[0]!.from).toBe('Engine') expect(d.relationships[0]!.to).toBe('Car') expect(d.relationships[0]!.markerAt).toBe('to') }) it('parses reversed aggregation: --o (marker at to)', () => { const d = parse(`classDiagram Department --o University`) expect(d.relationships[0]!.type).toBe('aggregation') expect(d.relationships[0]!.from).toBe('Department') expect(d.relationships[0]!.to).toBe('University') expect(d.relationships[0]!.markerAt).toBe('to') }) it('parses relationship with label', () => { const d = parse(`classDiagram Customer --> Order : places`) expect(d.relationships[0]!.label).toBe('places') }) it('parses relationship with cardinality', () => { const d = parse(`classDiagram Customer "1" --> "*" Order : places`) expect(d.relationships[0]!.fromCardinality).toBe('1') expect(d.relationships[0]!.toCardinality).toBe('*') }) it('handles multiple relationships', () => { const d = parse(`classDiagram Animal <|-- Dog Animal <|-- Cat Dog *-- Leg`) expect(d.relationships).toHaveLength(3) }) }) // ============================================================================ // Full diagram // ============================================================================ describe('parseClassDiagram – full diagram', () => { it('parses a complete class hierarchy', () => { const d = parse(`classDiagram class Animal { <> +String name +eat() void +sleep() void } class Dog { +String breed +bark() void } class Cat { +bool isIndoor +meow() void } Animal <|-- Dog Animal <|-- Cat`) expect(d.classes).toHaveLength(3) expect(d.relationships).toHaveLength(2) const animal = d.classes.find(c => c.id === 'Animal')! expect(animal.annotation).toBe('abstract') expect(animal.attributes).toHaveLength(1) expect(animal.methods).toHaveLength(2) }) }) ================================================ FILE: src/__tests__/edge-approach-direction.test.ts ================================================ /** * Edge Approach Direction Tests * * Verifies that edges approach target nodes from the correct direction: * - Edges entering from TOP should have a vertical final segment (coming from above) * - Edges entering from BOTTOM should have a vertical final segment (coming from below) * - Edges entering from LEFT should have a horizontal final segment (coming from left) * - Edges entering from RIGHT should have a horizontal final segment (coming from right) * * This prevents visual artifacts where an arrow points to the top of a node * but approaches horizontally, creating an awkward bend at the arrowhead. */ import { describe, it, expect } from 'bun:test' import { parseMermaid } from '../parser.ts' import { layoutGraphSync } from '../layout.ts' interface Point { x: number y: number } /** * Determine if a segment is primarily vertical (dy > dx). */ function isVerticalSegment(p1: Point, p2: Point, tolerance = 1): boolean { const dx = Math.abs(p2.x - p1.x) const dy = Math.abs(p2.y - p1.y) return dy > dx || dx < tolerance } /** * Determine if a segment is primarily horizontal (dx > dy). */ function isHorizontalSegment(p1: Point, p2: Point, tolerance = 1): boolean { const dx = Math.abs(p2.x - p1.x) const dy = Math.abs(p2.y - p1.y) return dx > dy || dy < tolerance } /** * Get the final segment of an edge (last two points). */ function getFinalSegment(points: Point[]): { p1: Point; p2: Point } | null { if (points.length < 2) return null return { p1: points[points.length - 2]!, p2: points[points.length - 1]!, } } /** * Get the first segment of an edge (first two points). */ function getFirstSegment(points: Point[]): { p1: Point; p2: Point } | null { if (points.length < 2) return null return { p1: points[0]!, p2: points[1]!, } } /** * Determine which side of a node a point is on. */ function getApproachSide( point: Point, nodeX: number, nodeY: number, nodeWidth: number, nodeHeight: number ): 'top' | 'bottom' | 'left' | 'right' { const cx = nodeX + nodeWidth / 2 const cy = nodeY + nodeHeight / 2 const dx = point.x - cx const dy = point.y - cy // Check which edge the point is closest to const distTop = Math.abs(point.y - nodeY) const distBottom = Math.abs(point.y - (nodeY + nodeHeight)) const distLeft = Math.abs(point.x - nodeX) const distRight = Math.abs(point.x - (nodeX + nodeWidth)) const minDist = Math.min(distTop, distBottom, distLeft, distRight) if (minDist === distTop) return 'top' if (minDist === distBottom) return 'bottom' if (minDist === distLeft) return 'left' return 'right' } // ============================================================================ // Test Cases // ============================================================================ describe('Edge Approach Direction', () => { describe('TD layout - edges should approach targets vertically from top', () => { it('simple two-node vertical: final segment should be vertical', () => { const parsed = parseMermaid(`graph TD A --> B`) const positioned = layoutGraphSync(parsed, {}) const edge = positioned.edges.find( (e) => e.source === 'A' && e.target === 'B' ) expect(edge).toBeDefined() const finalSeg = getFinalSegment(edge!.points) expect(finalSeg).not.toBeNull() // Edge enters B from top, so final segment should be vertical expect(isVerticalSegment(finalSeg!.p1, finalSeg!.p2)).toBe(true) }) it('fan-out pattern: multiple edges to one target should all be vertical', () => { // This is the exact pattern from the screenshot const parsed = parseMermaid(`graph TD Input --> Processor Config --> Processor`) const positioned = layoutGraphSync(parsed, {}) const processorNode = positioned.nodes.find((n) => n.id === 'Processor') expect(processorNode).toBeDefined() // Both edges should approach Processor with vertical final segments for (const edge of positioned.edges) { if (edge.target === 'Processor') { const finalSeg = getFinalSegment(edge.points) expect(finalSeg).not.toBeNull() // The final segment should be vertical (approaching top edge) const isVertical = isVerticalSegment(finalSeg!.p1, finalSeg!.p2) expect(isVertical).toBe(true) } } }) it('fan-in and fan-out pattern: all vertical approaches', () => { // Full pattern from the screenshot const parsed = parseMermaid(`graph TD Input --> Processor Config --> Processor Processor --> Output Processor --> Log`) const positioned = layoutGraphSync(parsed, {}) // Check all edges for (const edge of positioned.edges) { const targetNode = positioned.nodes.find((n) => n.id === edge.target) expect(targetNode).toBeDefined() const finalSeg = getFinalSegment(edge.points) expect(finalSeg).not.toBeNull() // Determine which side the edge approaches const approachSide = getApproachSide( finalSeg!.p2, targetNode!.x, targetNode!.y, targetNode!.width, targetNode!.height ) // For top/bottom approach, final segment must be vertical // For left/right approach, final segment must be horizontal if (approachSide === 'top' || approachSide === 'bottom') { expect(isVerticalSegment(finalSeg!.p1, finalSeg!.p2)).toBe(true) } else { expect(isHorizontalSegment(finalSeg!.p1, finalSeg!.p2)).toBe(true) } } }) }) describe('LR layout - edges should approach targets horizontally', () => { it('simple two-node horizontal: final segment should be horizontal', () => { const parsed = parseMermaid(`graph LR A --> B`) const positioned = layoutGraphSync(parsed, {}) const edge = positioned.edges.find( (e) => e.source === 'A' && e.target === 'B' ) expect(edge).toBeDefined() const finalSeg = getFinalSegment(edge!.points) expect(finalSeg).not.toBeNull() // Edge enters B from left, so final segment should be horizontal expect(isHorizontalSegment(finalSeg!.p1, finalSeg!.p2)).toBe(true) }) it('fan-out pattern in LR: final segments should be horizontal', () => { const parsed = parseMermaid(`graph LR Input --> Processor Config --> Processor`) const positioned = layoutGraphSync(parsed, {}) for (const edge of positioned.edges) { if (edge.target === 'Processor') { const finalSeg = getFinalSegment(edge.points) expect(finalSeg).not.toBeNull() expect(isHorizontalSegment(finalSeg!.p1, finalSeg!.p2)).toBe(true) } } }) }) describe('Source exit direction matches side', () => { it('TD layout: edges should exit source vertically from bottom', () => { const parsed = parseMermaid(`graph TD A --> B A --> C`) const positioned = layoutGraphSync(parsed, {}) for (const edge of positioned.edges) { if (edge.source === 'A') { const firstSeg = getFirstSegment(edge.points) expect(firstSeg).not.toBeNull() // Edge exits A from bottom, so first segment should be vertical expect(isVerticalSegment(firstSeg!.p1, firstSeg!.p2)).toBe(true) } } }) it('LR layout: edges should exit source horizontally from right', () => { const parsed = parseMermaid(`graph LR A --> B A --> C`) const positioned = layoutGraphSync(parsed, {}) for (const edge of positioned.edges) { if (edge.source === 'A') { const firstSeg = getFirstSegment(edge.points) expect(firstSeg).not.toBeNull() // Edge exits A from right, so first segment should be horizontal expect(isHorizontalSegment(firstSeg!.p1, firstSeg!.p2)).toBe(true) } } }) }) describe('Diamond shapes - approach direction preserved', () => { it('edges to diamond should approach with correct direction', () => { const parsed = parseMermaid(`graph TD A --> B{Decision} B -->|Yes| C B -->|No| D`) const positioned = layoutGraphSync(parsed, {}) // Edge A → B should approach B's top vertically const edgeAB = positioned.edges.find( (e) => e.source === 'A' && e.target === 'B' ) expect(edgeAB).toBeDefined() const finalSegAB = getFinalSegment(edgeAB!.points) expect(finalSegAB).not.toBeNull() expect(isVerticalSegment(finalSegAB!.p1, finalSegAB!.p2)).toBe(true) }) it('edges should terminate at diamond vertices, not bounding box', () => { // In TD layout, edges approaching from above should hit the top vertex, // and edges leaving downward should start from the bottom vertex. const parsed = parseMermaid(`graph TD A[Start] --> B{Decision} B --> C[End]`) const positioned = layoutGraphSync(parsed, {}) const diamond = positioned.nodes.find(n => n.id === 'B') expect(diamond).toBeDefined() expect(diamond!.shape).toBe('diamond') // Calculate diamond vertices const cx = diamond!.x + diamond!.width / 2 const topY = diamond!.y const bottomY = diamond!.y + diamond!.height // Edge A → B should end at the diamond's top vertex const edgeAB = positioned.edges.find(e => e.source === 'A' && e.target === 'B') expect(edgeAB).toBeDefined() const endPointAB = edgeAB!.points[edgeAB!.points.length - 1]! expect(endPointAB.x).toBeCloseTo(cx, 0) expect(endPointAB.y).toBeCloseTo(topY, 0) // Edge B → C should start from the diamond's bottom vertex const edgeBC = positioned.edges.find(e => e.source === 'B' && e.target === 'C') expect(edgeBC).toBeDefined() const startPointBC = edgeBC!.points[0]! expect(startPointBC.x).toBeCloseTo(cx, 0) expect(startPointBC.y).toBeCloseTo(bottomY, 0) }) }) }) ================================================ FILE: src/__tests__/er-integration.test.ts ================================================ /** * Integration tests for ER diagrams — end-to-end parse → layout → render. */ import { describe, it, expect } from 'bun:test' import { renderMermaidSVG } from '../index.ts' describe('renderMermaidSVG – ER diagrams', () => { it('renders a basic ER diagram to valid SVG', () => { const svg = renderMermaidSVG(`erDiagram CUSTOMER ||--o{ ORDER : places`) expect(svg).toContain('') expect(svg).toContain('CUSTOMER') expect(svg).toContain('ORDER') expect(svg).toContain('places') }) it('renders entity with attributes', () => { const svg = renderMermaidSVG(`erDiagram CUSTOMER { int id PK string name string email UK }`) expect(svg).toContain('CUSTOMER') expect(svg).toContain('id') expect(svg).toContain('name') expect(svg).toContain('email') // PK/UK key badges expect(svg).toContain('PK') expect(svg).toContain('UK') }) it('renders relationship lines between entities', () => { const svg = renderMermaidSVG(`erDiagram A ||--o{ B : has`) // Should have polyline for the relationship expect(svg).toContain(' { const svg = renderMermaidSVG(`erDiagram CUSTOMER ||--o{ ORDER : places`) // Crow's foot markers are rendered as lines const lineCount = (svg.match(/ { const svg = renderMermaidSVG(`erDiagram USER ||..o{ LOG : generates`) expect(svg).toContain('stroke-dasharray') }) it('renders relationship labels with background pills', () => { const svg = renderMermaidSVG(`erDiagram A ||--o{ B : places`) expect(svg).toContain('places') // Background pill behind label expect(svg).toContain('rx="2"') }) it('renders with dark colors', () => { const svg = renderMermaidSVG(`erDiagram A ||--|| B : links`, { bg: '#18181B', fg: '#FAFAFA' }) expect(svg).toContain('--bg:#18181B') }) it('renders entity boxes with header and attribute rows', () => { const svg = renderMermaidSVG(`erDiagram USER { int id PK string name string email }`) // Should have rectangles for entity box and header const rectCount = (svg.match(/ { const svg = renderMermaidSVG(`erDiagram CUSTOMER { int id PK string name string email UK } ORDER { int id PK date created int customer_id FK } PRODUCT { int id PK string name float price } CUSTOMER ||--o{ ORDER : places ORDER ||--|{ LINE_ITEM : contains PRODUCT ||--o{ LINE_ITEM : includes`) expect(svg).toContain('CUSTOMER') expect(svg).toContain('ORDER') expect(svg).toContain('PRODUCT') expect(svg).toContain('LINE_ITEM') expect(svg).toContain('places') expect(svg).toContain('contains') expect(svg).toContain('includes') }) }) // ============================================================================ // Label positioning tests — verify that relationship labels sit ON the // polyline path, not floating in space. The renderer's midpoint() computes // the arc-length midpoint of the relationship polyline. These tests parse // the SVG output to verify positioning for both straight and multi-segment // (orthogonal, bent) paths. // ============================================================================ /** Extract entity box rects from SVG: returns Map */ function extractEntityBoxes(svg: string): Map { const boxes = new Map() // Entity header text: LABEL const headerPattern = /]*font-weight="700"[^>]*>([^<]+)<\/text>/g let match while ((match = headerPattern.exec(svg)) !== null) { const centerX = parseFloat(match[1]!) const label = match[3]! // Find the corresponding outer rect that contains this text. const rectPattern = /= rx && centerX <= rx + rw) { boxes.set(label, { x: rx, y: ry, width: rw, height: rh, rightEdge: rx + rw }) break } } } return boxes } /** Extract relationship label positions from SVG: returns Map */ function extractLabelPositions(svg: string): Map { const labels = new Map() // Relationship labels use font-size="11" font-weight="400" — match flexibly // regardless of attribute order const labelPattern = /]*font-size="11"[^>]*font-weight="400"[^>]*>([^<]+)<\/text>/g let match while ((match = labelPattern.exec(svg)) !== null) { labels.set(match[3]!, { x: parseFloat(match[1]!), y: parseFloat(match[2]!) }) } return labels } /** Extract polyline paths from SVG: returns array of point arrays */ function extractPolylines(svg: string): Array> { const polylines: Array> = [] // Match polylines with points attribute anywhere in the tag const pattern = /]*points="([^"]+)"[^>]*>/g let match while ((match = pattern.exec(svg)) !== null) { const points = match[1]!.split(' ').map(p => { const [x, y] = p.split(',') return { x: parseFloat(x!), y: parseFloat(y!) } }) polylines.push(points) } return polylines } /** * Check if a point lies on (or very near) a polyline path. * Computes the minimum distance from the point to any segment of the polyline. * Returns the minimum distance in pixels. */ function distanceToPolyline(point: { x: number; y: number }, polyline: Array<{ x: number; y: number }>): number { let minDist = Infinity for (let i = 1; i < polyline.length; i++) { const a = polyline[i - 1]! const b = polyline[i]! const dist = pointToSegmentDist(point, a, b) if (dist < minDist) minDist = dist } return minDist } /** Distance from point P to line segment AB */ function pointToSegmentDist(p: { x: number; y: number }, a: { x: number; y: number }, b: { x: number; y: number }): number { const dx = b.x - a.x const dy = b.y - a.y const lenSq = dx * dx + dy * dy if (lenSq === 0) return Math.sqrt((p.x - a.x) ** 2 + (p.y - a.y) ** 2) // Project P onto AB, clamped to [0,1] const t = Math.max(0, Math.min(1, ((p.x - a.x) * dx + (p.y - a.y) * dy) / lenSq)) const projX = a.x + t * dx const projY = a.y + t * dy return Math.sqrt((p.x - projX) ** 2 + (p.y - projY) ** 2) } /** * Find the polyline closest to a label position. * Returns the minimum distance from the label to any polyline. */ function closestPolylineDistance(label: { x: number; y: number }, polylines: Array>): number { let minDist = Infinity for (const pl of polylines) { const dist = distanceToPolyline(label, pl) if (dist < minDist) minDist = dist } return minDist } // ─── Straight-line label positioning ──────────────────────────────────────── describe('renderMermaidSVG – ER label positioning (straight lines)', () => { it('label is between the two entity boxes horizontally', () => { const svg = renderMermaidSVG(`erDiagram TEACHER }|--o{ COURSE : teaches`) const boxes = extractEntityBoxes(svg) const labels = extractLabelPositions(svg) const teacher = boxes.get('TEACHER')! const course = boxes.get('COURSE')! const label = labels.get('teaches')! // Label x should be between the two entity box edges const leftEdge = Math.min(teacher.rightEdge, course.rightEdge) const rightEdge = Math.max(teacher.x, course.x) expect(label.x).toBeGreaterThan(leftEdge) expect(label.x).toBeLessThan(rightEdge) }) it('label has minimum clearance from entity box edges', () => { const svg = renderMermaidSVG(`erDiagram A ||--o{ B : links`) const boxes = extractEntityBoxes(svg) const labels = extractLabelPositions(svg) const boxA = boxes.get('A')! const boxB = boxes.get('B')! const label = labels.get('links')! const minClearance = 10 const leftBox = boxA.x < boxB.x ? boxA : boxB const rightBox = boxA.x < boxB.x ? boxB : boxA expect(label.x - leftBox.rightEdge).toBeGreaterThanOrEqual(minClearance) expect(rightBox.x - label.x).toBeGreaterThanOrEqual(minClearance) }) it('label is approximately at the horizontal midpoint of the gap', () => { const svg = renderMermaidSVG(`erDiagram CUSTOMER ||--o{ ORDER : places`) const boxes = extractEntityBoxes(svg) const labels = extractLabelPositions(svg) const customer = boxes.get('CUSTOMER')! const order = boxes.get('ORDER')! const label = labels.get('places')! const leftBox = customer.x < order.x ? customer : order const rightBox = customer.x < order.x ? order : customer const gapMidpoint = (leftBox.rightEdge + rightBox.x) / 2 expect(Math.abs(label.x - gapMidpoint)).toBeLessThan(15) }) it('label sits on (or very near) its relationship polyline', () => { const svg = renderMermaidSVG(`erDiagram A ||--o{ B : connects`) const labels = extractLabelPositions(svg) const polylines = extractPolylines(svg) const label = labels.get('connects')! // Label should be within 2px of its closest polyline segment const dist = closestPolylineDistance(label, polylines) expect(dist).toBeLessThan(2) }) }) // ─── Multi-entity diagrams with orthogonal routing ────────────────────────── describe('renderMermaidSVG – ER label positioning (multi-segment paths)', () => { it('all labels in a multi-relationship diagram sit near a polyline', () => { const svg = renderMermaidSVG(`erDiagram ORDER ||--|{ LINE_ITEM : contains ORDER ||..o{ SHIPMENT : ships-via PRODUCT ||--o{ LINE_ITEM : includes PRODUCT ||..o{ REVIEW : receives`) const labels = extractLabelPositions(svg) const polylines = extractPolylines(svg) // Every relationship label should be found for (const name of ['contains', 'ships-via', 'includes', 'receives']) { expect(labels.has(name)).toBe(true) } // Every label should be within 2px of a polyline segment for (const [, pos] of labels) { const dist = closestPolylineDistance(pos, polylines) expect(dist).toBeLessThan(2) } }) it('non-identifying relationship labels also sit on their dashed polylines', () => { const svg = renderMermaidSVG(`erDiagram USER ||..o{ LOG_ENTRY : generates USER ||..o{ SESSION : opens`) const labels = extractLabelPositions(svg) const polylines = extractPolylines(svg) expect(labels.has('generates')).toBe(true) expect(labels.has('opens')).toBe(true) for (const [, pos] of labels) { const dist = closestPolylineDistance(pos, polylines) expect(dist).toBeLessThan(2) } }) it('label on vertical segment has x matching the segment x', () => { const svg = renderMermaidSVG(`erDiagram ORDER ||--|{ LINE_ITEM : contains ORDER ||..o{ SHIPMENT : ships-via PRODUCT ||--o{ LINE_ITEM : includes PRODUCT ||..o{ REVIEW : receives`) const labels = extractLabelPositions(svg) const polylines = extractPolylines(svg) // For each label, find the closest polyline and verify it's near a segment for (const [, pos] of labels) { const dist = closestPolylineDistance(pos, polylines) expect(dist).toBeLessThan(2) } }) it('labels in e-commerce schema all sit on their polylines', () => { const svg = renderMermaidSVG(`erDiagram CUSTOMER ||--o{ ORDER : places ORDER ||--|{ LINE_ITEM : contains PRODUCT ||--o{ LINE_ITEM : includes`) const labels = extractLabelPositions(svg) const polylines = extractPolylines(svg) expect(labels.size).toBe(3) for (const [, pos] of labels) { const dist = closestPolylineDistance(pos, polylines) expect(dist).toBeLessThan(2) } }) it('label is not at the endpoint of any polyline', () => { const svg = renderMermaidSVG(`erDiagram A ||--o{ B : links`) const labels = extractLabelPositions(svg) const polylines = extractPolylines(svg) const label = labels.get('links')! for (const pl of polylines) { const start = pl[0]! const end = pl[pl.length - 1]! const distToStart = Math.sqrt((label.x - start.x) ** 2 + (label.y - start.y) ** 2) const distToEnd = Math.sqrt((label.x - end.x) ** 2 + (label.y - end.y) ** 2) // At least one endpoint should be far from the label (>5px) expect(Math.min(distToStart, distToEnd)).toBeGreaterThan(5) } }) it('multiple labels in same diagram have distinct positions', () => { const svg = renderMermaidSVG(`erDiagram CUSTOMER ||--o{ ORDER : places ORDER ||--|{ LINE_ITEM : contains PRODUCT ||--o{ LINE_ITEM : includes`) const labels = extractLabelPositions(svg) const positions = [...labels.values()] // Each label should have a unique position (no two labels at same x,y) for (let i = 0; i < positions.length; i++) { for (let j = i + 1; j < positions.length; j++) { const dx = positions[i]!.x - positions[j]!.x const dy = positions[i]!.y - positions[j]!.y const dist = Math.sqrt(dx * dx + dy * dy) expect(dist).toBeGreaterThan(10) // at least 10px apart } } }) it('label background pill also sits on the polyline', () => { const svg = renderMermaidSVG(`erDiagram A ||--o{ B : test`) const labels = extractLabelPositions(svg) const polylines = extractPolylines(svg) const label = labels.get('test')! // Find the background pill rect (rx="2" ry="2" near the label position) const pillPattern = / l.trim()).filter(l => l.length > 0 && !l.startsWith('%%')) return parseErDiagram(lines) } // ============================================================================ // Entity definitions // ============================================================================ describe('parseErDiagram – entity definitions', () => { it('parses an entity with attributes', () => { const d = parse(`erDiagram CUSTOMER { string name int age string email }`) expect(d.entities).toHaveLength(1) expect(d.entities[0]!.id).toBe('CUSTOMER') expect(d.entities[0]!.attributes).toHaveLength(3) expect(d.entities[0]!.attributes[0]!.type).toBe('string') expect(d.entities[0]!.attributes[0]!.name).toBe('name') }) it('parses attributes with PK key', () => { const d = parse(`erDiagram USER { int id PK string name }`) expect(d.entities[0]!.attributes[0]!.keys).toContain('PK') }) it('parses attributes with FK key', () => { const d = parse(`erDiagram ORDER { int id PK int customer_id FK }`) expect(d.entities[0]!.attributes[1]!.keys).toContain('FK') }) it('parses attributes with UK key', () => { const d = parse(`erDiagram USER { string email UK }`) expect(d.entities[0]!.attributes[0]!.keys).toContain('UK') }) it('parses attributes with comment', () => { const d = parse(`erDiagram USER { string email UK "user email address" }`) expect(d.entities[0]!.attributes[0]!.comment).toBe('user email address') }) it('parses multiple entities', () => { const d = parse(`erDiagram CUSTOMER { int id PK string name } ORDER { int id PK date created }`) expect(d.entities).toHaveLength(2) }) it('auto-creates entities from relationships', () => { const d = parse(`erDiagram CUSTOMER ||--o{ ORDER : places`) expect(d.entities).toHaveLength(2) expect(d.entities.find(e => e.id === 'CUSTOMER')).toBeDefined() expect(d.entities.find(e => e.id === 'ORDER')).toBeDefined() }) }) // ============================================================================ // Relationships // ============================================================================ describe('parseErDiagram – relationships', () => { it('parses exactly-one to zero-or-many: ||--o{', () => { const d = parse(`erDiagram CUSTOMER ||--o{ ORDER : places`) expect(d.relationships).toHaveLength(1) expect(d.relationships[0]!.entity1).toBe('CUSTOMER') expect(d.relationships[0]!.entity2).toBe('ORDER') expect(d.relationships[0]!.cardinality1).toBe('one') expect(d.relationships[0]!.cardinality2).toBe('zero-many') expect(d.relationships[0]!.label).toBe('places') expect(d.relationships[0]!.identifying).toBe(true) }) it('parses zero-or-one to one-or-more: |o--|{', () => { const d = parse(`erDiagram A |o--|{ B : connects`) expect(d.relationships[0]!.cardinality1).toBe('zero-one') expect(d.relationships[0]!.cardinality2).toBe('many') }) it('parses exactly-one to exactly-one: ||--||', () => { const d = parse(`erDiagram PERSON ||--|| PASSPORT : has`) expect(d.relationships[0]!.cardinality1).toBe('one') expect(d.relationships[0]!.cardinality2).toBe('one') }) it('parses non-identifying relationship (dotted): ||..o{', () => { const d = parse(`erDiagram USER ||..o{ LOG : generates`) expect(d.relationships[0]!.identifying).toBe(false) }) it('parses one-or-more to zero-or-many: }|--o{', () => { const d = parse(`erDiagram PRODUCT }|--o{ TAG : has`) expect(d.relationships[0]!.cardinality1).toBe('many') expect(d.relationships[0]!.cardinality2).toBe('zero-many') }) it('handles multiple relationships', () => { const d = parse(`erDiagram CUSTOMER ||--o{ ORDER : places ORDER ||--|{ LINE_ITEM : contains PRODUCT ||--o{ LINE_ITEM : appears_in`) expect(d.relationships).toHaveLength(3) }) }) // ============================================================================ // Full diagram // ============================================================================ describe('parseErDiagram – full diagram', () => { it('parses a complete e-commerce schema', () => { const d = parse(`erDiagram CUSTOMER { int id PK string name string email UK } ORDER { int id PK date created int customer_id FK } LINE_ITEM { int id PK int quantity int order_id FK int product_id FK } PRODUCT { int id PK string name float price } CUSTOMER ||--o{ ORDER : places ORDER ||--|{ LINE_ITEM : contains PRODUCT ||--o{ LINE_ITEM : includes`) expect(d.entities).toHaveLength(4) expect(d.relationships).toHaveLength(3) const customer = d.entities.find(e => e.id === 'CUSTOMER')! expect(customer.attributes).toHaveLength(3) expect(customer.attributes[0]!.keys).toContain('PK') expect(customer.attributes[2]!.keys).toContain('UK') const lineItem = d.entities.find(e => e.id === 'LINE_ITEM')! expect(lineItem.attributes.filter(a => a.keys.includes('FK'))).toHaveLength(2) }) }) ================================================ FILE: src/__tests__/integration.test.ts ================================================ /** * Integration tests for the full renderMermaidSVG pipeline. * * These tests exercise parse → layout → render end-to-end. * They use the synchronous ELK.js-based rendering pipeline. * * Covers: original features, Batch 1 (new shapes), Batch 2 (edges, styles), * and Batch 3 (state diagrams). */ import { describe, it, expect } from 'bun:test' import { renderMermaidSVG } from '../index.ts' // ============================================================================ // Basic rendering // ============================================================================ describe('renderMermaidSVG – basic', () => { it('renders a simple graph to valid SVG', () => { const svg = renderMermaidSVG('graph TD\n A --> B') expect(svg).toContain('') // Should contain both nodes expect(svg).toContain('>A') expect(svg).toContain('>B') }) it('renders a graph with labeled nodes', () => { const svg = renderMermaidSVG('graph TD\n A[Start] --> B[End]') expect(svg).toContain('>Start') expect(svg).toContain('>End') }) it('renders edges with labels', () => { const svg = renderMermaidSVG('graph TD\n A -->|Yes| B') expect(svg).toContain('>Yes') }) }) // ============================================================================ // Options // ============================================================================ describe('renderMermaidSVG – options', () => { it('applies dark colors', () => { const svg = renderMermaidSVG('graph TD\n A --> B', { bg: '#18181B', fg: '#FAFAFA' }) expect(svg).toContain('--bg:#18181B') }) it('applies default light colors', () => { const svg = renderMermaidSVG('graph TD\n A --> B') expect(svg).toContain('--bg:#FFFFFF') }) it('applies custom font', () => { const svg = renderMermaidSVG('graph TD\n A --> B', { font: 'JetBrains Mono' }) expect(svg).toContain("'JetBrains Mono'") }) it('respects padding option', () => { const small = renderMermaidSVG('graph TD\n A --> B', { padding: 10 }) const large = renderMermaidSVG('graph TD\n A --> B', { padding: 80 }) const getWidth = (svg: string) => { const match = svg.match(/width="([\d.]+)"/) return match ? Number(match[1]) : 0 } expect(getWidth(large)).toBeGreaterThan(getWidth(small)) }) }) // ============================================================================ // Complex diagrams // ============================================================================ describe('renderMermaidSVG – complex diagrams', () => { it('renders all original node shapes', () => { const svg = renderMermaidSVG(`graph TD A[Rectangle] --> B(Rounded) B --> C{Diamond} C --> D([Stadium]) D --> E((Circle))`) expect(svg).toContain('>Rectangle') expect(svg).toContain('>Rounded') expect(svg).toContain('>Diamond') expect(svg).toContain('>Stadium') expect(svg).toContain('>Circle') expect(svg).toContain(' { const svg = renderMermaidSVG(`graph TD A -->|solid| B B -.->|dotted| C C ==>|thick| D`) expect(svg).toContain('>solid') expect(svg).toContain('>dotted') expect(svg).toContain('>thick') expect(svg).toContain('stroke-dasharray="4 4"') }) it('renders subgraphs', () => { const svg = renderMermaidSVG(`graph TD subgraph Backend A[API] --> B[DB] end C[Client] --> A`) expect(svg).toContain('>Backend') expect(svg).toContain('>API') expect(svg).toContain('>DB') expect(svg).toContain('>Client') }) it('renders a complex real-world diagram', () => { const svg = renderMermaidSVG(`graph TD subgraph ci [CI Pipeline] A[Push Code] --> B{Tests Pass?} B -->|Yes| C[Build Docker] B -->|No| D[Fix & Retry] D --> A end C --> E([Deploy to Staging]) E --> F{QA Approved?} F -->|Yes| G((Production)) F -->|No| D`) expect(svg).toContain('') expect(svg).toContain('>CI Pipeline') expect(svg).toContain('>Push Code') expect(svg).toContain('>Tests Pass?') expect(svg).toContain('>Yes') expect(svg).toContain('>No') expect(svg).toContain('>Production') }) it('renders different directions', () => { const lr = renderMermaidSVG('graph LR\n A --> B --> C') const td = renderMermaidSVG('graph TD\n A --> B --> C') const getDimensions = (svg: string) => { const w = svg.match(/width="([\d.]+)"/) const h = svg.match(/height="([\d.]+)"/) return { width: Number(w?.[1] ?? 0), height: Number(h?.[1] ?? 0) } } const lrDims = getDimensions(lr) const tdDims = getDimensions(td) expect(lrDims.width).toBeGreaterThan(tdDims.width) expect(tdDims.height).toBeGreaterThan(lrDims.height) }) }) // ============================================================================ // Batch 1: New shapes (end-to-end) // ============================================================================ describe('renderMermaidSVG – Batch 1 shapes', () => { it('renders subroutine shape with inner vertical lines', () => { const svg = renderMermaidSVG('graph TD\n A[[Subroutine]] --> B') expect(svg).toContain('>Subroutine') expect(svg).toContain(' elements', () => { const svg = renderMermaidSVG('graph TD\n A(((Important))) --> B') expect(svg).toContain('>Important') const circleCount = (svg.match(/ { const svg = renderMermaidSVG('graph TD\n A{{Decision}} --> B') expect(svg).toContain('>Decision') expect(svg).toContain(' { it('renders cylinder / database', () => { const svg = renderMermaidSVG('graph TD\n A[(Database)] --> B') expect(svg).toContain('>Database') expect(svg).toContain(' { const svg = renderMermaidSVG('graph TD\n A>Flag Shape] --> B') expect(svg).toContain('>Flag Shape') expect(svg).toContain(' { const svg = renderMermaidSVG('graph TD\n A[/Wider Bottom\\] --> B[\\Wider Top/]') expect(svg).toContain('>Wider Bottom') expect(svg).toContain('>Wider Top') }) }) describe('renderMermaidSVG – Batch 2 edge features', () => { it('renders no-arrow edges', () => { const svg = renderMermaidSVG('graph TD\n A --- B') expect(svg).toContain(' { const svg = renderMermaidSVG('graph TD\n A <--> B') expect(svg).toContain('marker-end="url(#arrowhead)"') expect(svg).toContain('marker-start="url(#arrowhead-start)"') }) it('renders parallel links with &', () => { const svg = renderMermaidSVG('graph TD\n A & B --> C') // Should have node labels for A, B, and C expect(svg).toContain('>A') expect(svg).toContain('>B') expect(svg).toContain('>C') // Should have 2 edges (A→C and B→C) const polylines = (svg.match(/ { const svg = renderMermaidSVG(`graph TD A[Red Node] --> B style A fill:#ff0000,stroke:#cc0000`) expect(svg).toContain('fill="#ff0000"') expect(svg).toContain('stroke="#cc0000"') }) }) // ============================================================================ // Batch 3: State diagrams (end-to-end) // ============================================================================ describe('renderMermaidSVG – state diagrams', () => { it('renders a basic state diagram', () => { const svg = renderMermaidSVG(`stateDiagram-v2 [*] --> Idle Idle --> Active : start Active --> Done`) expect(svg).toContain('') expect(svg).toContain('>Idle') expect(svg).toContain('>Active') expect(svg).toContain('>Done') expect(svg).toContain('>start') }) it('renders start pseudostate as filled circle', () => { const svg = renderMermaidSVG(`stateDiagram-v2 [*] --> Ready`) // Start pseudostate: filled circle with stroke="none" expect(svg).toContain('stroke="none"') expect(svg).toContain(' { const svg = renderMermaidSVG(`stateDiagram-v2 Done --> [*]`) // End pseudostate: two circles (outer ring + inner filled) const circleCount = (svg.match(/ { const svg = renderMermaidSVG(`stateDiagram-v2 state Processing { parse --> validate validate --> execute } [*] --> Processing`) expect(svg).toContain('>Processing') expect(svg).toContain('>parse') expect(svg).toContain('>validate') expect(svg).toContain('>execute') }) it('renders full state diagram lifecycle', () => { const svg = renderMermaidSVG(`stateDiagram-v2 [*] --> Idle Idle --> Processing : submit state Processing { parse --> validate validate --> execute } Processing --> Complete : done Complete --> [*]`) expect(svg).toContain('') expect(svg).toContain('>Idle') expect(svg).toContain('>Complete') expect(svg).toContain('>Processing') expect(svg).toContain('>submit') expect(svg).toContain('>done') }) it('cycle edge labels do not overlap (Running ↔ Paused)', () => { const svg = renderMermaidSVG(`stateDiagram-v2 [*] --> Ready Ready --> Running : start Running --> Paused : pause Paused --> Running : resume Running --> Stopped : stop Stopped --> [*]`) // Extract all label pill elements (rx="2" distinguishes them from node rects) const pillPattern = / b.x const overlapY = a.y < b.y + b.h && a.y + a.h > b.y expect( overlapX && overlapY, `Label pills ${i} (x=${a.x},y=${a.y},w=${a.w},h=${a.h}) and ${j} (x=${b.x},y=${b.y},w=${b.w},h=${b.h}) overlap` ).toBe(false) } } }) }) // ============================================================================ // Source order and deduplication // ============================================================================ describe('renderMermaidSVG – source order', () => { it('does not duplicate composite state nodes in SVG', () => { const svg = renderMermaidSVG(`stateDiagram-v2 [*] --> Idle Idle --> Processing : submit state Processing { parse --> validate validate --> execute } Processing --> Complete : done Complete --> [*]`) // "Processing" should appear exactly once as a group label, not also as a standalone node. const processingLabels = (svg.match(/>Processing<\/text>/g) ?? []).length expect(processingLabels).toBe(1) }) it('renders subgraph-first diagrams with subgraph at top in TD layout', () => { const svg = renderMermaidSVG(`graph TD subgraph ci [CI Pipeline] A[Push Code] --> B{Tests Pass?} B -->|Yes| C[Build Image] end C --> D([Deploy]) D --> E{QA?} E -->|Yes| F((Production))`) // Verify all elements render (no crashes from source order changes) expect(svg).toContain('>CI Pipeline') expect(svg).toContain('>Push Code') expect(svg).toContain('>Deploy') expect(svg).toContain('>Production') }) }) // ============================================================================ // Edge cases: self-loops, empty subgraphs, nesting depth // ============================================================================ describe('renderMermaidSVG – edge cases', () => { it('renders a self-loop (source === target)', () => { const svg = renderMermaidSVG(`graph TD A[Node] --> A`) expect(svg).toContain('Node') // Should have at least one edge polyline expect(svg).toContain(' { const svg = renderMermaidSVG(`graph TD A[Retry] -->|again| A`) expect(svg).toContain('>Retry') expect(svg).toContain('>again') }) it('renders an empty subgraph without crashing', () => { const svg = renderMermaidSVG(`graph TD subgraph Empty end A --> B`) expect(svg).toContain('Empty') expect(svg).toContain('>A') expect(svg).toContain('>B') }) it('renders edges targeting an empty subgraph', () => { const svg = renderMermaidSVG(`graph TD subgraph S [Empty Group] end A --> S S --> B`) expect(svg).toContain('Empty Group') expect(svg).toContain('>A') expect(svg).toContain('>B') }) it('renders a single-node subgraph', () => { const svg = renderMermaidSVG(`graph TD subgraph Single A[Only Node] end B --> A`) expect(svg).toContain('>Single') expect(svg).toContain('>Only Node') expect(svg).toContain('>B') }) it('renders 3-level nested subgraphs', () => { const svg = renderMermaidSVG(`graph TD subgraph Level1 [Outer] subgraph Level2 [Middle] subgraph Level3 [Inner] A[Deep Node] --> B[Also Deep] end end end C[Outside] --> A`) expect(svg).toContain('>Outer') expect(svg).toContain('>Middle') expect(svg).toContain('>Inner') expect(svg).toContain('>Deep Node') expect(svg).toContain('>Also Deep') expect(svg).toContain('>Outside') }) it('renders 3-level nested composite states', () => { const svg = renderMermaidSVG(`stateDiagram-v2 [*] --> Active state Active { state Processing { state Validating { check --> verify } } } Active --> [*]`) expect(svg).toContain('Active') expect(svg).toContain('>Processing') expect(svg).toContain('>Validating') expect(svg).toContain('>check') expect(svg).toContain('>verify') }) }) // ============================================================================ // All new shapes in one diagram (end-to-end stress test) // ============================================================================ describe('renderMermaidSVG – all shapes combined', () => { it('renders a diagram with all 12 flowchart shapes', () => { const svg = renderMermaidSVG(`graph LR A[Rectangle] --> B(Rounded) B --> C{Diamond} C --> D([Stadium]) D --> E((Circle)) E --> F[[Subroutine]] F --> G(((DoubleCircle))) G --> H{{Hexagon}} H --> I[(Cylinder)] I --> J>Flag] J --> K[/Trapezoid\\] K --> L[\\TrapAlt/]`) // Verify every label renders for (const label of ['Rectangle', 'Rounded', 'Diamond', 'Stadium', 'Circle', 'Subroutine', 'DoubleCircle', 'Hexagon', 'Cylinder', 'Flag', 'Trapezoid', 'TrapAlt']) { expect(svg).toContain(`>${label}`) } // Verify SVG validity expect(svg).toContain('') }) }) ================================================ FILE: src/__tests__/layout-disconnected.test.ts ================================================ /** * Integration tests for disconnected component layout. * * These tests verify that the full layout pipeline correctly handles * graphs with multiple disconnected components (subgraphs or nodes * with no edges connecting them). * * The key invariant: disconnected components should NEVER overlap. */ import { describe, it, expect } from 'bun:test' import { renderMermaidSync, parseMermaid } from '../index.ts' import { layoutGraphSync } from '../layout.ts' // ============================================================================ // Test helpers // ============================================================================ /** Check if two rectangles overlap */ function rectanglesOverlap( r1: { x: number; y: number; width: number; height: number }, r2: { x: number; y: number; width: number; height: number } ): boolean { return !( r1.x + r1.width <= r2.x || // r1 is left of r2 r2.x + r2.width <= r1.x || // r2 is left of r1 r1.y + r1.height <= r2.y || // r1 is above r2 r2.y + r2.height <= r1.y // r2 is above r1 ) } /** Get bounding box from positioned elements */ function getBoundingBox(items: Array<{ x: number; y: number; width: number; height: number }>) { if (items.length === 0) return { x: 0, y: 0, width: 0, height: 0 } let minX = Infinity, minY = Infinity, maxX = -Infinity, maxY = -Infinity for (const item of items) { minX = Math.min(minX, item.x) minY = Math.min(minY, item.y) maxX = Math.max(maxX, item.x + item.width) maxY = Math.max(maxY, item.y + item.height) } return { x: minX, y: minY, width: maxX - minX, height: maxY - minY } } // ============================================================================ // Two disconnected subgraphs (the original bug) // ============================================================================ describe('layoutGraph – two disconnected subgraphs', () => { it('renders without overlap in LR direction', () => { const source = `graph LR subgraph Today [Today] A[AI Response] --> B[Markdown] B --> C[User reads] C --> D[User acts] end subgraph Tomorrow [Next Wave] E[AI Response] --> F[Widget] F --> G[User acts] end` const parsed = parseMermaid(source) const result = layoutGraphSync(parsed) // Find the two top-level groups const today = result.groups.find(g => g.label === 'Today') const tomorrow = result.groups.find(g => g.label === 'Next Wave') expect(today).toBeDefined() expect(tomorrow).toBeDefined() // They should NOT overlap expect(rectanglesOverlap(today!, tomorrow!)).toBe(false) }) it('renders without overlap in TD direction', () => { const source = `graph TD subgraph Today [Today] A --> B --> C end subgraph Tomorrow [Tomorrow] D --> E --> F end` const parsed = parseMermaid(source) const result = layoutGraphSync(parsed) const today = result.groups.find(g => g.label === 'Today') const tomorrow = result.groups.find(g => g.label === 'Tomorrow') expect(today).toBeDefined() expect(tomorrow).toBeDefined() expect(rectanglesOverlap(today!, tomorrow!)).toBe(false) }) it('respects direction for stacking (LR = vertical)', () => { // Perpendicular stacking: LR flows horizontally → stack vertically const source = `graph LR subgraph S1 [First] A --> B end subgraph S2 [Second] C --> D end` const parsed = parseMermaid(source) const result = layoutGraphSync(parsed) const s1 = result.groups.find(g => g.label === 'First')! const s2 = result.groups.find(g => g.label === 'Second')! // In LR mode, subgraphs should be stacked vertically (perpendicular to flow) // One should be above the other const isVerticallyArranged = (s1.y + s1.height <= s2.y) || (s2.y + s2.height <= s1.y) expect(isVerticallyArranged).toBe(true) }) it('respects direction for stacking (TD = horizontal)', () => { // Perpendicular stacking: TD flows vertically → stack horizontally const source = `graph TD subgraph S1 [First] A --> B end subgraph S2 [Second] C --> D end` const parsed = parseMermaid(source) const result = layoutGraphSync(parsed) const s1 = result.groups.find(g => g.label === 'First')! const s2 = result.groups.find(g => g.label === 'Second')! // ELK may arrange disconnected components in various ways // The key requirement is that they don't overlap const noOverlap = (s1.x + s1.width <= s2.x) || (s2.x + s2.width <= s1.x) || (s1.y + s1.height <= s2.y) || (s2.y + s2.height <= s1.y) expect(noOverlap).toBe(true) }) }) // ============================================================================ // Three+ disconnected components // ============================================================================ describe('layoutGraph – multiple disconnected components', () => { it('renders three disconnected subgraphs without overlap', () => { const source = `graph LR subgraph A [Alpha] A1 --> A2 end subgraph B [Beta] B1 --> B2 end subgraph C [Gamma] C1 --> C2 end` const parsed = parseMermaid(source) const result = layoutGraphSync(parsed) const alpha = result.groups.find(g => g.label === 'Alpha')! const beta = result.groups.find(g => g.label === 'Beta')! const gamma = result.groups.find(g => g.label === 'Gamma')! // No pair should overlap expect(rectanglesOverlap(alpha, beta)).toBe(false) expect(rectanglesOverlap(beta, gamma)).toBe(false) expect(rectanglesOverlap(alpha, gamma)).toBe(false) }) it('renders five disconnected nodes without overlap', () => { // Five completely isolated nodes const source = `graph LR A[Node A] B[Node B] C[Node C] D[Node D] E[Node E]` const parsed = parseMermaid(source) const result = layoutGraphSync(parsed) // All nodes should exist expect(result.nodes.length).toBe(5) // No pair of nodes should overlap for (let i = 0; i < result.nodes.length; i++) { for (let j = i + 1; j < result.nodes.length; j++) { const overlap = rectanglesOverlap(result.nodes[i]!, result.nodes[j]!) expect( overlap, `Nodes ${result.nodes[i]!.id} and ${result.nodes[j]!.id} overlap` ).toBe(false) } } }) }) // ============================================================================ // Mixed: connected + disconnected // ============================================================================ describe('layoutGraph – mixed connected and disconnected', () => { it('renders two connected subgraphs + one disconnected', () => { const source = `graph LR subgraph Frontend [Frontend] FE1 --> FE2 end subgraph Backend [Backend] BE1 --> BE2 end subgraph Isolated [Isolated] I1 --> I2 end FE2 --> BE1` const parsed = parseMermaid(source) const result = layoutGraphSync(parsed) const frontend = result.groups.find(g => g.label === 'Frontend')! const backend = result.groups.find(g => g.label === 'Backend')! const isolated = result.groups.find(g => g.label === 'Isolated')! // None should overlap expect(rectanglesOverlap(frontend, backend)).toBe(false) expect(rectanglesOverlap(backend, isolated)).toBe(false) expect(rectanglesOverlap(frontend, isolated)).toBe(false) }) it('renders connected nodes + isolated node', () => { const source = `graph LR A --> B --> C D[Isolated]` const parsed = parseMermaid(source) const result = layoutGraphSync(parsed) const nodeD = result.nodes.find(n => n.id === 'D')! const connectedNodes = result.nodes.filter(n => n.id !== 'D') // Isolated node should not overlap with any connected node for (const node of connectedNodes) { expect( rectanglesOverlap(nodeD, node), `Node D overlaps with node ${node.id}` ).toBe(false) } }) }) // ============================================================================ // Layout quality preservation // ============================================================================ describe('layoutGraph – quality preservation', () => { it('each component looks identical to standalone rendering', () => { // Render a single subgraph standalone const standalone = `graph LR subgraph S [Section] A --> B --> C end` const standaloneParsed = parseMermaid(standalone) const standaloneResult = layoutGraphSync(standaloneParsed) // Render the same subgraph as part of a disconnected graph const combined = `graph LR subgraph S [Section] A --> B --> C end subgraph Other [Other] X --> Y end` const combinedParsed = parseMermaid(combined) const combinedResult = layoutGraphSync(combinedParsed) // The "Section" group should have the same dimensions const standaloneGroup = standaloneResult.groups.find(g => g.label === 'Section')! const combinedGroup = combinedResult.groups.find(g => g.label === 'Section')! expect(combinedGroup.width).toBe(standaloneGroup.width) expect(combinedGroup.height).toBe(standaloneGroup.height) }) }) // ============================================================================ // Edge cases // ============================================================================ describe('layoutGraph – disconnected edge cases', () => { it('handles single node as its own component', () => { const source = `graph LR A --> B C[Isolated]` const parsed = parseMermaid(source) const result = layoutGraphSync(parsed) expect(result.nodes.length).toBe(3) // All nodes positioned without overlap for (let i = 0; i < result.nodes.length; i++) { for (let j = i + 1; j < result.nodes.length; j++) { expect(rectanglesOverlap(result.nodes[i]!, result.nodes[j]!)).toBe(false) } } }) it('handles empty subgraph with disconnected nodes', () => { const source = `graph LR subgraph Empty end A --> B` const parsed = parseMermaid(source) const result = layoutGraphSync(parsed) expect(result.groups.length).toBe(1) expect(result.nodes.length).toBe(2) }) it('handles subgraph containing entire component', () => { const source = `graph LR subgraph Component1 A --> B --> C end D --> E` const parsed = parseMermaid(source) const result = layoutGraphSync(parsed) const group = result.groups.find(g => g.label === 'Component1')! const nodeD = result.nodes.find(n => n.id === 'D')! const nodeE = result.nodes.find(n => n.id === 'E')! // Group and D/E nodes should not overlap expect(rectanglesOverlap(group, nodeD)).toBe(false) expect(rectanglesOverlap(group, nodeE)).toBe(false) }) }) // ============================================================================ // Full render tests (SVG output) // ============================================================================ describe('renderMermaid – disconnected components', () => { it('renders two disconnected subgraphs to valid SVG', () => { const source = `graph LR subgraph Today [Today] A --> B end subgraph Tomorrow [Tomorrow] C --> D end` const svg = renderMermaidSync(source) expect(svg).toContain('') expect(svg).toContain('>Today') expect(svg).toContain('>Tomorrow') expect(svg).toContain('>A') expect(svg).toContain('>B') expect(svg).toContain('>C') expect(svg).toContain('>D') }) it('renders isolated nodes to valid SVG', () => { const source = `graph LR A[First] B[Second] C[Third]` const svg = renderMermaidSync(source) expect(svg).toContain('') expect(svg).toContain('>First') expect(svg).toContain('>Second') expect(svg).toContain('>Third') }) }) ================================================ FILE: src/__tests__/linkstyle.test.ts ================================================ import { describe, it, expect } from 'bun:test' import { parseMermaid } from '../parser.ts' import { renderMermaidSVG } from '../index.ts' describe('linkStyle – parser', () => { it('parses linkStyle with single index', () => { const g = parseMermaid('graph TD\n A --> B\n linkStyle 0 stroke:#ff0000,stroke-width:2px') expect(g.linkStyles.get(0)).toEqual({ stroke: '#ff0000', 'stroke-width': '2px' }) }) it('parses linkStyle with comma-separated indices', () => { const g = parseMermaid('graph TD\n A --> B\n B --> C\n linkStyle 0,1 stroke:#00ff00') expect(g.linkStyles.get(0)).toEqual({ stroke: '#00ff00' }) expect(g.linkStyles.get(1)).toEqual({ stroke: '#00ff00' }) }) it('parses linkStyle default', () => { const g = parseMermaid('graph TD\n A --> B\n linkStyle default stroke:#888888,stroke-width:3px') expect(g.linkStyles.get('default')).toEqual({ stroke: '#888888', 'stroke-width': '3px' }) }) it('later linkStyle overrides earlier for same index', () => { const g = parseMermaid('graph TD\n A --> B\n linkStyle 0 stroke:#ff0000\n linkStyle 0 stroke:#00ff00') expect(g.linkStyles.get(0)).toEqual({ stroke: '#00ff00' }) }) it('ignores linkStyle lines silently (no crash) when index out of range', () => { const g = parseMermaid('graph TD\n A --> B\n linkStyle 99 stroke:#ff0000') expect(g.linkStyles.get(99)).toEqual({ stroke: '#ff0000' }) expect(g.edges).toHaveLength(1) }) it('strips trailing semicolons from style values', () => { const g = parseMermaid('graph TD\n A --> B\n linkStyle 0 stroke:#ff0000,stroke-width:4px;') expect(g.linkStyles.get(0)).toEqual({ stroke: '#ff0000', 'stroke-width': '4px' }) }) }) describe('linkStyle – state diagram parser', () => { it('parses linkStyle in state diagrams', () => { const g = parseMermaid('stateDiagram-v2\n A --> B\n linkStyle 0 stroke:#ff0000') expect(g.linkStyles.get(0)).toEqual({ stroke: '#ff0000' }) }) it('parses linkStyle default in state diagrams', () => { const g = parseMermaid('stateDiagram-v2\n A --> B\n B --> C\n linkStyle default stroke:#888') expect(g.linkStyles.get('default')).toEqual({ stroke: '#888' }) }) }) describe('linkStyle – SVG integration', () => { it('applies linkStyle stroke color to SVG edge', () => { const svg = renderMermaidSVG('graph TD\n A --> B\n linkStyle 0 stroke:#ff0000') expect(svg).toContain('stroke="#ff0000"') }) it('applies linkStyle stroke-width to SVG edge', () => { const svg = renderMermaidSVG('graph TD\n A --> B\n linkStyle 0 stroke-width:3px') expect(svg).toContain('stroke-width="3px"') }) it('applies linkStyle default to all edges', () => { const svg = renderMermaidSVG('graph TD\n A --> B\n B --> C\n linkStyle default stroke:#00ff00') const matches = svg.match(/stroke="#00ff00"/g) expect(matches).not.toBeNull() expect(matches!.length).toBeGreaterThanOrEqual(2) }) it('index-specific linkStyle overrides default', () => { const svg = renderMermaidSVG( 'graph TD\n A --> B\n B --> C\n linkStyle default stroke:#888\n linkStyle 0 stroke:#ff0000' ) expect(svg).toContain('stroke="#ff0000"') expect(svg).toContain('stroke="#888"') }) it('arrowhead color matches custom stroke color', () => { const svg = renderMermaidSVG('graph TD\n A --> B\n linkStyle 0 stroke:#ff0000') // Should have a color-specific marker def (# is hex-encoded to "23") expect(svg).toContain('id="arrowhead-23ff0000"') expect(svg).toContain('fill="#ff0000"') // Edge should reference the colored marker expect(svg).toContain('marker-end="url(#arrowhead-23ff0000)"') }) it('escapes XSS injection in stroke value', () => { const svg = renderMermaidSVG('graph TD\n A --> B\n linkStyle 0 stroke:red" onmouseover="alert(1)') // Quotes must be escaped — no attribute breakout expect(svg).not.toContain('stroke="red" onmouseover') expect(svg).toContain('stroke="red" onmouseover="alert(1)"') }) it('trailing semicolons do not leak into SVG attributes', () => { const svg = renderMermaidSVG('graph TD\n A --> B\n linkStyle 0 stroke:#ff0000,stroke-width:4px;') expect(svg).toContain('stroke-width="4px"') expect(svg).not.toContain('stroke-width="4px;"') }) }) ================================================ FILE: src/__tests__/multiline-labels.test.ts ================================================ /** * Tests for multi-line label support via
    tags. * * Covers: * - Parser: normalization of
    ,
    ,
    to \n * - Text metrics: measureMultilineText() for width/height calculation * - Layout: estimateNodeSize() with multi-line labels * - Renderer: generation for node and edge labels * - Integration: full SVG output with multi-line labels */ import { describe, it, expect } from 'bun:test' import { parseMermaid } from '../parser.ts' import { parseSequenceDiagram } from '../sequence/parser.ts' import { parseClassDiagram } from '../class/parser.ts' import { parseErDiagram } from '../er/parser.ts' import { measureMultilineText, LINE_HEIGHT_RATIO, measureTextWidth } from '../text-metrics.ts' import { renderMermaid } from '../index.ts' import { normalizeBrTags, stripFormattingTags } from '../multiline-utils.ts' // ============================================================================ // Parser:
    tag normalization // ============================================================================ describe('parseMermaid –
    tag normalization', () => { describe('node labels', () => { it('normalizes
    to newline', () => { const g = parseMermaid('graph TD\n A[Line1
    Line2]') expect(g.nodes.get('A')!.label).toBe('Line1\nLine2') }) it('normalizes
    to newline', () => { const g = parseMermaid('graph TD\n A[Line1
    Line2]') expect(g.nodes.get('A')!.label).toBe('Line1\nLine2') }) it('normalizes
    (with space) to newline', () => { const g = parseMermaid('graph TD\n A[Line1
    Line2]') expect(g.nodes.get('A')!.label).toBe('Line1\nLine2') }) it('is case-insensitive (
    ,
    ,
    )', () => { const g1 = parseMermaid('graph TD\n A[Line1
    Line2]') const g2 = parseMermaid('graph TD\n B[Line1
    Line2]') const g3 = parseMermaid('graph TD\n C[Line1
    Line2]') expect(g1.nodes.get('A')!.label).toBe('Line1\nLine2') expect(g2.nodes.get('B')!.label).toBe('Line1\nLine2') expect(g3.nodes.get('C')!.label).toBe('Line1\nLine2') }) it('handles multiple
    tags', () => { const g = parseMermaid('graph TD\n A[One
    Two
    Three
    Four]') expect(g.nodes.get('A')!.label).toBe('One\nTwo\nThree\nFour') }) it('handles
    with various node shapes', () => { const g = parseMermaid(`graph TD A[Rect
    Label] B(Round
    Label) C{Diamond
    Label} D([Stadium
    Label]) `) expect(g.nodes.get('A')!.label).toBe('Rect\nLabel') expect(g.nodes.get('B')!.label).toBe('Round\nLabel') expect(g.nodes.get('C')!.label).toBe('Diamond\nLabel') expect(g.nodes.get('D')!.label).toBe('Stadium\nLabel') }) }) describe('edge labels', () => { it('normalizes
    in edge labels', () => { const g = parseMermaid('graph TD\n A -->|First
    Second| B') expect(g.edges[0]!.label).toBe('First\nSecond') }) it('normalizes
    in edge labels', () => { const g = parseMermaid('graph TD\n A -->|Line1
    Line2| B') expect(g.edges[0]!.label).toBe('Line1\nLine2') }) }) describe('subgraph labels', () => { it('normalizes
    in subgraph labels (bracket syntax)', () => { const g = parseMermaid(`graph TD subgraph sg1 [Group
    Header] A[Node] end `) expect(g.subgraphs[0]!.label).toBe('Group\nHeader') }) it('normalizes
    in subgraph labels (plain syntax)', () => { const g = parseMermaid(`graph TD subgraph Line1
    Line2 A[Node] end `) expect(g.subgraphs[0]!.label).toBe('Line1\nLine2') }) }) describe('state diagram labels', () => { it('normalizes
    in state alias labels', () => { const g = parseMermaid(`stateDiagram-v2 state "First
    Second" as s1 [*] --> s1 `) expect(g.nodes.get('s1')!.label).toBe('First\nSecond') }) it('normalizes
    in state description labels', () => { const g = parseMermaid(`stateDiagram-v2 s1 : First
    Second [*] --> s1 `) expect(g.nodes.get('s1')!.label).toBe('First\nSecond') }) it('normalizes
    in transition labels', () => { const g = parseMermaid(`stateDiagram-v2 s1 --> s2 : Event
    Action `) expect(g.edges[0]!.label).toBe('Event\nAction') }) }) describe('sequence diagram labels', () => { it('normalizes
    in participant alias labels', () => { const lines = ['sequenceDiagram', 'participant A as First
    Line', 'A->>A: test'] const diagram = parseSequenceDiagram(lines) expect(diagram.actors[0]!.label).toBe('First\nLine') }) it('normalizes
    in message labels', () => { const lines = ['sequenceDiagram', 'A->>B: Hello
    World'] const diagram = parseSequenceDiagram(lines) expect(diagram.messages[0]!.label).toBe('Hello\nWorld') }) it('normalizes
    in note text', () => { const lines = ['sequenceDiagram', 'A->>B: Hello', 'Note over A,B: First
    Second'] const diagram = parseSequenceDiagram(lines) expect(diagram.notes[0]!.text).toBe('First\nSecond') }) it('normalizes
    in block labels', () => { const lines = ['sequenceDiagram', 'A->>B: Hello', 'loop Every
    30s', 'A->>B: Ping', 'end'] const diagram = parseSequenceDiagram(lines) expect(diagram.blocks[0]!.label).toBe('Every\n30s') }) it('normalizes
    in divider labels', () => { const lines = ['sequenceDiagram', 'A->>B: Hello', 'alt First
    case', 'A->>B: a', 'else Second
    case', 'A->>B: b', 'end'] const diagram = parseSequenceDiagram(lines) expect(diagram.blocks[0]!.dividers[0]!.label).toBe('Second\ncase') }) }) describe('class diagram labels', () => { it('normalizes
    in relationship labels', () => { const lines = ['classDiagram', 'A --> B : uses
    internally'] const diagram = parseClassDiagram(lines) expect(diagram.relationships[0]!.label).toBe('uses\ninternally') }) it('normalizes
    in fromCardinality labels', () => { const lines = ['classDiagram', 'A "one
    to" --> B'] const diagram = parseClassDiagram(lines) expect(diagram.relationships[0]!.fromCardinality).toBe('one\nto') }) it('normalizes
    in toCardinality labels', () => { const lines = ['classDiagram', 'A --> "many
    items" B'] const diagram = parseClassDiagram(lines) expect(diagram.relationships[0]!.toCardinality).toBe('many\nitems') }) }) describe('ER diagram labels', () => { it('normalizes
    in relationship labels', () => { const lines = ['erDiagram', 'CUSTOMER ||--o{ ORDER : places
    orders'] const diagram = parseErDiagram(lines) expect(diagram.relationships[0]!.label).toBe('places\norders') }) it('normalizes
    in attribute comments', () => { const lines = ['erDiagram', 'CUSTOMER {', 'int id PK "primary
    key"', '}'] const diagram = parseErDiagram(lines) expect(diagram.entities[0]!.attributes[0]!.comment).toBe('primary\nkey') }) }) }) // ============================================================================ // Text metrics: multi-line measurement // ============================================================================ describe('measureMultilineText', () => { const fontSize = 13 const fontWeight = 500 it('returns single line metrics for text without newlines', () => { const metrics = measureMultilineText('Hello', fontSize, fontWeight) expect(metrics.lines).toEqual(['Hello']) expect(metrics.lineHeight).toBe(fontSize * LINE_HEIGHT_RATIO) expect(metrics.height).toBe(fontSize * LINE_HEIGHT_RATIO) expect(metrics.width).toBe(measureTextWidth('Hello', fontSize, fontWeight)) }) it('splits text on newlines', () => { const metrics = measureMultilineText('Line1\nLine2\nLine3', fontSize, fontWeight) expect(metrics.lines).toEqual(['Line1', 'Line2', 'Line3']) }) it('calculates height based on number of lines', () => { const lineHeight = fontSize * LINE_HEIGHT_RATIO const one = measureMultilineText('One', fontSize, fontWeight) const two = measureMultilineText('One\nTwo', fontSize, fontWeight) const three = measureMultilineText('One\nTwo\nThree', fontSize, fontWeight) expect(one.height).toBeCloseTo(lineHeight, 1) expect(two.height).toBeCloseTo(lineHeight * 2, 1) expect(three.height).toBeCloseTo(lineHeight * 3, 1) }) it('uses maximum line width for overall width', () => { const metrics = measureMultilineText('Short\nMuch Longer Line\nMedium', fontSize, fontWeight) const shortWidth = measureTextWidth('Short', fontSize, fontWeight) const longWidth = measureTextWidth('Much Longer Line', fontSize, fontWeight) const mediumWidth = measureTextWidth('Medium', fontSize, fontWeight) expect(metrics.width).toBe(longWidth) expect(metrics.width).toBeGreaterThan(shortWidth) expect(metrics.width).toBeGreaterThan(mediumWidth) }) it('handles empty lines', () => { const metrics = measureMultilineText('Line1\n\nLine3', fontSize, fontWeight) expect(metrics.lines).toEqual(['Line1', '', 'Line3']) expect(metrics.height).toBeCloseTo(fontSize * LINE_HEIGHT_RATIO * 3, 1) }) it('exports LINE_HEIGHT_RATIO constant', () => { expect(LINE_HEIGHT_RATIO).toBe(1.3) }) }) // ============================================================================ // Renderer: element generation // ============================================================================ describe('renderMermaid – multi-line labels', () => { it('renders single-line node label without tspan', async () => { const svg = await renderMermaid('graph TD\n A[Single Line]') // Should have text element with direct content expect(svg).toContain('Single Line') // Should NOT have tspan for single line expect(svg).not.toMatch(/]*>Single Line<\/tspan>/) }) it('renders multi-line node label with tspan elements', async () => { const svg = await renderMermaid('graph TD\n A[Line1
    Line2]') // Should have tspan elements expect(svg).toContain('Line1
    ') expect(svg).toContain('>Line2
    ') }) it('renders 3-line node label with 3 tspan elements', async () => { const svg = await renderMermaid('graph TD\n A[One
    Two
    Three]') // Count tspan occurrences const tspanMatches = svg.match(/One') expect(svg).toContain('>Two') expect(svg).toContain('>Three') }) it('renders multi-line edge label with tspan elements', async () => { const svg = await renderMermaid('graph TD\n A -->|First
    Second| B') expect(svg).toContain('First') expect(svg).toContain('>Second') }) it('includes x attribute on each tspan for horizontal reset', async () => { const svg = await renderMermaid('graph TD\n A[Line1
    Line2]') // Each tspan should have an x attribute const tspanRegex = / { const svg = await renderMermaid('graph TD\n A[Line1
    Line2]') // Each tspan should have a dy attribute const tspanRegex = /]*dy="[^"]+"/g const matches = svg.match(tspanRegex) expect(matches).toHaveLength(2) }) it('escapes XML characters in multi-line labels', async () => { const svg = await renderMermaid('graph TD\n A[First &
    Second >]') expect(svg).toContain('&') expect(svg).toContain('>') expect(svg).not.toContain('First &
    ') expect(svg).not.toContain('Second >') }) }) // ============================================================================ // Integration: layout sizing // ============================================================================ describe('renderMermaid – multi-line layout sizing', () => { it('multi-line node is taller than single-line node', async () => { const singleSvg = await renderMermaid('graph TD\n A[Single]') const multiSvg = await renderMermaid('graph TD\n A[Line1
    Line2]') // Extract node rect heights const singleHeight = extractFirstRectHeight(singleSvg) const multiHeight = extractFirstRectHeight(multiSvg) expect(multiHeight).toBeGreaterThan(singleHeight) }) it('3-line node is taller than 2-line node', async () => { const twoLineSvg = await renderMermaid('graph TD\n A[One
    Two]') const threeLineSvg = await renderMermaid('graph TD\n A[One
    Two
    Three]') const twoLineHeight = extractFirstRectHeight(twoLineSvg) const threeLineHeight = extractFirstRectHeight(threeLineSvg) expect(threeLineHeight).toBeGreaterThan(twoLineHeight) }) it('node width matches longest line', async () => { // "Much Longer" is wider than "Short" const svg = await renderMermaid('graph TD\n A[Short
    Much Longer Line]') const width = extractFirstRectWidth(svg) // Compare to single-line with long text const longSvg = await renderMermaid('graph TD\n A[Much Longer Line]') const longWidth = extractFirstRectWidth(longSvg) // Widths should be approximately equal (multi-line uses max line width) expect(Math.abs(width - longWidth)).toBeLessThan(5) }) }) // ============================================================================ // Integration: sequence diagram multi-line rendering // ============================================================================ describe('renderMermaid – sequence diagram multi-line', () => { it('renders multi-line message labels with tspan elements', async () => { const svg = await renderMermaid(`sequenceDiagram A->>B: Hello
    World `) expect(svg).toContain('Hello') expect(svg).toContain('>World') }) it('renders multi-line actor labels with tspan elements', async () => { const svg = await renderMermaid(`sequenceDiagram participant A as First
    Line A->>A: test `) expect(svg).toContain('First') expect(svg).toContain('>Line') }) it('renders multi-line note text with tspan elements', async () => { const svg = await renderMermaid(`sequenceDiagram A->>B: msg Note over A,B: Note
    Text `) expect(svg).toContain('Note') expect(svg).toContain('>Text') }) }) // ============================================================================ // Integration: class diagram multi-line rendering // ============================================================================ describe('renderMermaid – class diagram multi-line', () => { it('renders multi-line relationship labels with tspan elements', async () => { const svg = await renderMermaid(`classDiagram A --> B : uses
    data `) expect(svg).toContain('uses') expect(svg).toContain('>data') }) it('renders multi-line cardinality with tspan elements', async () => { const svg = await renderMermaid(`classDiagram A "one
    to" --> B `) expect(svg).toContain('one') expect(svg).toContain('>to') }) }) // ============================================================================ // Integration: ER diagram multi-line rendering // ============================================================================ describe('renderMermaid – ER diagram multi-line', () => { it('renders multi-line relationship labels with tspan elements', async () => { const svg = await renderMermaid(`erDiagram CUSTOMER ||--o{ ORDER : places
    orders `) expect(svg).toContain('places') expect(svg).toContain('>orders') }) }) // ============================================================================ // Edge cases // ============================================================================ describe('renderMermaid – edge cases', () => { it('handles consecutive

    tags (empty lines)', async () => { const svg = await renderMermaid('graph TD\n A[Line1

    Line3]') const tspanMatches = svg.match(/', async () => { const longLine = 'VeryLongTextHere' const svg = await renderMermaid(`graph TD\n A[${longLine}
    Short]`) expect(svg).toContain(`>${longLine}
    `) expect(svg).toContain('>Short') }) it('handles single character lines', async () => { const svg = await renderMermaid('graph TD\n A[X
    Y
    Z]') expect(svg).toContain('>X') expect(svg).toContain('>Y') expect(svg).toContain('>Z') }) it('handles unicode with
    ', async () => { const svg = await renderMermaid('graph TD\n A[日本
    語]') expect(svg).toContain('>日本') expect(svg).toContain('>語') }) it('handles many lines (5+)', async () => { const svg = await renderMermaid('graph TD\n A[1
    2
    3
    4
    5
    6]') const tspanMatches = svg.match(/ { const svg = await renderMermaid(`graph TD A[Single] --> B[Multi
    Line] B --> C[Also Single] `) expect(svg).toContain('>Single') expect(svg).toContain('>Multi
    ') expect(svg).toContain('>Also Single') }) }) // ============================================================================ // Subgraph multi-line // ============================================================================ describe('renderMermaid – subgraph multi-line', () => { it('renders multi-line group headers with tspan', async () => { const svg = await renderMermaid(`graph TD subgraph sg [Group
    Header] A[Node] end `) expect(svg).toContain('>Group') expect(svg).toContain('>Header') }) }) // ============================================================================ // All flowchart shapes with multi-line // ============================================================================ describe('renderMermaid – all flowchart shapes with multi-line', () => { const shapes: [string, string][] = [ ['rectangle', 'A[Line1
    Line2]'], ['rounded', 'A(Line1
    Line2)'], ['diamond', 'A{Line1
    Line2}'], ['stadium', 'A([Line1
    Line2])'], ['circle', 'A((Line1
    Line2))'], ['subroutine', 'A[[Line1
    Line2]]'], ['double-circle', 'A(((Line1
    Line2)))'], ['hexagon', 'A{{Line1
    Line2}}'], ['cylinder', 'A[(Line1
    Line2)]'], ['flag', 'A>Line1
    Line2]'], ['trapezoid', 'A[/Line1
    Line2\\]'], ['inv-trapezoid', 'A[\\Line1
    Line2/]'], ] shapes.forEach(([name, syntax]) => { it(`renders multi-line in ${name} shape`, async () => { const svg = await renderMermaid(`graph TD\n ${syntax}`) expect(svg).toContain('Line1') expect(svg).toContain('>Line2') }) }) }) // ============================================================================ // Inline formatting: , , , → SVG tspan attributes // ============================================================================ describe('renderMermaid – inline formatting', () => { it('renders as font-weight="bold"', async () => { const svg = await renderMermaid('graph TD\n A[Hello bold text]') expect(svg).toContain('font-weight="bold"') expect(svg).toContain('>bold') }) it('renders as font-weight="bold"', async () => { const svg = await renderMermaid('graph TD\n A[Hello bold]') expect(svg).toContain('font-weight="bold"') }) it('renders as font-style="italic"', async () => { const svg = await renderMermaid('graph TD\n A[Hello italic text]') expect(svg).toContain('font-style="italic"') expect(svg).toContain('>italic') }) it('renders as font-style="italic"', async () => { const svg = await renderMermaid('graph TD\n A[Hello italic]') expect(svg).toContain('font-style="italic"') }) it('renders as text-decoration="underline"', async () => { const svg = await renderMermaid('graph TD\n A[Hello underline text]') expect(svg).toContain('text-decoration="underline"') expect(svg).toContain('>underline') }) it('renders as text-decoration="line-through"', async () => { const svg = await renderMermaid('graph TD\n A[Hello strike text]') expect(svg).toContain('text-decoration="line-through"') expect(svg).toContain('>strike') }) it('renders as text-decoration="line-through"', async () => { const svg = await renderMermaid('graph TD\n A[Hello deleted]') expect(svg).toContain('text-decoration="line-through"') }) it('renders nested with both attributes', async () => { const svg = await renderMermaid('graph TD\n A[bold italic]') expect(svg).toContain('font-weight="bold"') expect(svg).toContain('font-style="italic"') expect(svg).toContain('>bold italic') }) it('renders formatting combined with
    multiline', async () => { const svg = await renderMermaid('graph TD\n A[Line1
    Bold Line2]') expect(svg).toContain('font-weight="bold"') expect(svg).toContain('>Bold Line2') }) it('does not include raw tag text in rendered text', async () => { const svg = await renderMermaid('graph TD\n A[bold]') // Tags should not appear as escaped text content inside elements expect(svg).toMatch(/bold<\/tspan>/) expect(svg).not.toMatch(/]*><b>/) }) it('renders plain text without formatting tspan wrappers', async () => { const svg = await renderMermaid('graph TD\n A[Plain text]') // Should not have bold/italic formatting tspans (font-weight="500" on the text element is fine) expect(svg).not.toContain('font-weight="bold"') expect(svg).not.toContain('font-style="italic"') expect(svg).not.toContain('text-decoration=') }) }) // ============================================================================ // Tag stripping: unsupported tags removed, formatting tags preserved // ============================================================================ describe('normalizeBrTags – tag handling', () => { it('strips tags', () => { expect(normalizeBrTags('H2O')).toBe('H2O') }) it('strips tags', () => { expect(normalizeBrTags('x2')).toBe('x2') }) it('strips tags', () => { expect(normalizeBrTags('big small')).toBe('big small') }) it('strips tags', () => { expect(normalizeBrTags('some highlighted text')).toBe('some highlighted text') }) it('preserves tags for rendering', () => { expect(normalizeBrTags('Hello bold')).toContain('') }) it('preserves tags for rendering', () => { expect(normalizeBrTags('Hello italic')).toContain('') }) it('preserves tags for rendering', () => { expect(normalizeBrTags('Hello under')).toContain('') }) it('preserves tags for rendering', () => { expect(normalizeBrTags('Hello strike')).toContain('') }) }) describe('stripFormattingTags', () => { it('strips all formatting tags', () => { expect(stripFormattingTags('bold and italic')).toBe('bold and italic') }) it('strips and ', () => { expect(stripFormattingTags('bold italic')).toBe('bold italic') }) it('strips , , ', () => { expect(stripFormattingTags('under strike del')).toBe('under strike del') }) it('handles nested tags', () => { expect(stripFormattingTags('nested')).toBe('nested') }) it('returns plain text unchanged', () => { expect(stripFormattingTags('no tags here')).toBe('no tags here') }) }) // ============================================================================ // Text metrics: formatting tags excluded from width // ============================================================================ describe('measureMultilineText – formatting tag exclusion', () => { const fontSize = 13 const fontWeight = 500 it('measures width of plain text, not tag text', () => { const withTags = measureMultilineText('bold', fontSize, fontWeight) const plain = measureMultilineText('bold', fontSize, fontWeight) expect(withTags.width).toBe(plain.width) }) it('excludes nested tags from width', () => { const withTags = measureMultilineText('text', fontSize, fontWeight) const plain = measureMultilineText('text', fontSize, fontWeight) expect(withTags.width).toBe(plain.width) }) }) // ============================================================================ // HTML entity decoding — prevents double-escaping in SVG output // ============================================================================ describe('renderMermaid – HTML entity decoding', () => { it('decodes < and > in node labels (prevents double-escaping)', async () => { // Input has pre-encoded entities (as delivered by react-markdown + rehype-raw) const svg = await renderMermaid('graph LR\n A[AsyncGenerator<AgentEvent>]') // SVG should contain single-encoded < (correct XML), NOT double-encoded &lt; expect(svg).toContain('AsyncGenerator<AgentEvent>') expect(svg).not.toContain('&lt;') expect(svg).not.toContain('&gt;') }) it('decodes & in node labels', async () => { const svg = await renderMermaid('graph LR\n A[Tom & Jerry]') expect(svg).toContain('Tom & Jerry') expect(svg).not.toContain('&amp;') }) it('decodes numeric entity references (decimal)', async () => { // < = <, > = > const svg = await renderMermaid('graph LR\n A[List<Item>]') expect(svg).toContain('List<Item>') expect(svg).not.toContain('<') expect(svg).not.toContain('>') }) it('decodes numeric entity references (hex)', async () => { // < = <, > = > const svg = await renderMermaid('graph LR\n A[Map<K, V>]') expect(svg).toContain('Map<K, V>') expect(svg).not.toContain('<') expect(svg).not.toContain('>') }) it('decodes entities in edge labels', async () => { const svg = await renderMermaid('graph LR\n A -->|returns <T>| B') expect(svg).toContain('returns <T>') expect(svg).not.toContain('&lt;') }) it('decodes entities in class diagram generics', async () => { const svg = await renderMermaid(`classDiagram class MyService~T~ MyService --> Handler : uses `) // Class parser converts ~T~ to in the label, then escapeXml encodes it expect(svg).toContain('MyService<T>') }) it('handles raw angle brackets the same as decoded entities', async () => { // Raw < and decoded < should produce identical SVG output const svgRaw = await renderMermaid('graph LR\n A[List]') const svgEncoded = await renderMermaid('graph LR\n A[List<Item>]') // Both should contain the same single-encoded entity in SVG expect(svgRaw).toContain('List<Item>') expect(svgEncoded).toContain('List<Item>') }) }) // ============================================================================ // Markdown formatting: **bold**, *italic*, ~~strike~~ → HTML tags // ============================================================================ describe('normalizeBrTags – markdown formatting', () => { it('converts **bold** to bold', () => { expect(normalizeBrTags('Hello **World**')).toBe('Hello World') }) it('converts *italic* to italic', () => { expect(normalizeBrTags('Hello *World*')).toBe('Hello World') }) it('converts ~~strikethrough~~ to strikethrough', () => { expect(normalizeBrTags('Hello ~~World~~')).toBe('Hello World') }) it('handles bold and italic together', () => { expect(normalizeBrTags('**bold** and *italic*')).toBe('bold and italic') }) it('does not match single * surrounded by spaces (multiplication)', () => { expect(normalizeBrTags('a * b * c')).toBe('a * b * c') }) it('handles ***bold italic*** (bold outer, italic inner)', () => { const result = normalizeBrTags('***text***') // ** matches first → *text, then * italic wraps across tag boundary // Functionally correct: parseInlineFormatting() uses boolean state, not tag nesting expect(result).toBe('text') }) it('handles multiple bold segments', () => { expect(normalizeBrTags('**one** and **two**')).toBe('one and two') }) it('handles bold with
    multiline', () => { expect(normalizeBrTags('Line1
    **Bold Line2**')).toBe('Line1\nBold Line2') }) it('preserves existing HTML tags alongside markdown', () => { expect(normalizeBrTags('html and **md**')).toBe('html and md') }) it('does not affect text without markdown formatting', () => { expect(normalizeBrTags('plain text')).toBe('plain text') }) }) describe('renderMermaid – markdown formatting in labels', () => { it('renders **bold** as font-weight="bold"', async () => { const svg = await renderMermaid('graph TD\n A[Hello **bold** text]') expect(svg).toContain('font-weight="bold"') expect(svg).toContain('>bold
    ') }) it('renders *italic* as font-style="italic"', async () => { const svg = await renderMermaid('graph TD\n A[Hello *italic* text]') expect(svg).toContain('font-style="italic"') expect(svg).toContain('>italic') }) it('renders ~~strike~~ as text-decoration="line-through"', async () => { const svg = await renderMermaid('graph TD\n A[Hello ~~strike~~ text]') expect(svg).toContain('text-decoration="line-through"') expect(svg).toContain('>strike') }) it('renders **bold** in edge labels', async () => { const svg = await renderMermaid('graph TD\n A -->|**important**| B') expect(svg).toContain('font-weight="bold"') expect(svg).toContain('>important') }) }) // ============================================================================ // Helper functions // ============================================================================ function extractFirstRectHeight(svg: string): number { const match = svg.match(/]*height="(\d+(?:\.\d+)?)"/) return match ? parseFloat(match[1]!) : 0 } function extractFirstRectWidth(svg: string): number { const match = svg.match(/]*width="(\d+(?:\.\d+)?)"/) return match ? parseFloat(match[1]!) : 0 } ================================================ FILE: src/__tests__/parser.test.ts ================================================ /** * Tests for the Mermaid parser. * * Covers: * - Flowcharts: graph headers, node shapes (all 13), edge styles, chained edges, * subgraphs (basic + nested), classDef/class, ::: shorthand, style statements, * direction override, & parallel links, no-arrow edges, bidirectional arrows * - State diagrams: transitions, [*] pseudostates, composite states, * state aliases, direction override * - Comments and error cases */ import { describe, it, expect } from 'bun:test' import { parseMermaid } from '../parser.ts' // ============================================================================ // Graph header parsing // ============================================================================ describe('parseMermaid – graph header', () => { it('parses "graph TD" header', () => { const g = parseMermaid('graph TD\n A --> B') expect(g.direction).toBe('TD') }) it('parses "flowchart LR" header', () => { const g = parseMermaid('flowchart LR\n A --> B') expect(g.direction).toBe('LR') }) it.each(['TD', 'TB', 'LR', 'BT', 'RL'] as const)('accepts direction %s', (dir) => { const g = parseMermaid(`graph ${dir}\n A --> B`) expect(g.direction).toBe(dir) }) it('is case-insensitive for the keyword', () => { const g = parseMermaid('graph td\n A --> B') expect(g.direction).toBe('TD') }) it('throws on empty input', () => { expect(() => parseMermaid('')).toThrow('Empty mermaid diagram') }) it('throws on invalid header', () => { expect(() => parseMermaid('sequenceDiagram\n A ->> B')).toThrow('Invalid mermaid header') }) it('throws on header without direction', () => { expect(() => parseMermaid('graph\n A --> B')).toThrow('Invalid mermaid header') }) }) // ============================================================================ // Original node shapes // ============================================================================ describe('parseMermaid – node shapes (original)', () => { it('parses rectangle nodes: A[Label]', () => { const g = parseMermaid('graph TD\n A[Hello World]') const node = g.nodes.get('A') expect(node).toBeDefined() expect(node!.shape).toBe('rectangle') expect(node!.label).toBe('Hello World') }) it('parses rounded nodes: A(Label)', () => { const g = parseMermaid('graph TD\n A(Rounded)') expect(g.nodes.get('A')!.shape).toBe('rounded') expect(g.nodes.get('A')!.label).toBe('Rounded') }) it('parses diamond nodes: A{Label}', () => { const g = parseMermaid('graph TD\n A{Decision}') expect(g.nodes.get('A')!.shape).toBe('diamond') expect(g.nodes.get('A')!.label).toBe('Decision') }) it('parses stadium nodes: A([Label])', () => { const g = parseMermaid('graph TD\n A([Stadium])') expect(g.nodes.get('A')!.shape).toBe('stadium') expect(g.nodes.get('A')!.label).toBe('Stadium') }) it('parses circle nodes: A((Label))', () => { const g = parseMermaid('graph TD\n A((Circle))') expect(g.nodes.get('A')!.shape).toBe('circle') expect(g.nodes.get('A')!.label).toBe('Circle') }) it('creates a default rectangle for bare node references', () => { const g = parseMermaid('graph TD\n A --> B') expect(g.nodes.get('A')!.shape).toBe('rectangle') expect(g.nodes.get('A')!.label).toBe('A') expect(g.nodes.get('B')!.shape).toBe('rectangle') expect(g.nodes.get('B')!.label).toBe('B') }) it('supports hyphenated node IDs', () => { const g = parseMermaid('graph TD\n my-node[My Node]') expect(g.nodes.get('my-node')).toBeDefined() expect(g.nodes.get('my-node')!.label).toBe('My Node') }) it('first definition wins for shape and label', () => { const g = parseMermaid('graph TD\n A[Start] --> B\n A --> B') expect(g.nodes.get('A')!.shape).toBe('rectangle') expect(g.nodes.get('A')!.label).toBe('Start') }) }) // ============================================================================ // Batch 1 node shapes // ============================================================================ describe('parseMermaid – node shapes (Batch 1)', () => { it('parses subroutine nodes: A[[Label]]', () => { const g = parseMermaid('graph TD\n A[[Subroutine]]') expect(g.nodes.get('A')!.shape).toBe('subroutine') expect(g.nodes.get('A')!.label).toBe('Subroutine') }) it('parses double circle nodes: A(((Label)))', () => { const g = parseMermaid('graph TD\n A(((Double)))') expect(g.nodes.get('A')!.shape).toBe('doublecircle') expect(g.nodes.get('A')!.label).toBe('Double') }) it('parses hexagon nodes: A{{Label}}', () => { const g = parseMermaid('graph TD\n A{{Hexagon}}') expect(g.nodes.get('A')!.shape).toBe('hexagon') expect(g.nodes.get('A')!.label).toBe('Hexagon') }) }) // ============================================================================ // Batch 2 node shapes // ============================================================================ describe('parseMermaid – node shapes (Batch 2)', () => { it('parses cylinder / database nodes: A[(Label)]', () => { const g = parseMermaid('graph TD\n A[(Database)]') expect(g.nodes.get('A')!.shape).toBe('cylinder') expect(g.nodes.get('A')!.label).toBe('Database') }) it('parses asymmetric / flag nodes: A>Label]', () => { const g = parseMermaid('graph TD\n A>Flag Shape]') expect(g.nodes.get('A')!.shape).toBe('asymmetric') expect(g.nodes.get('A')!.label).toBe('Flag Shape') }) it('parses trapezoid nodes: A[/Label\\]', () => { const g = parseMermaid('graph TD\n A[/Trapezoid\\]') expect(g.nodes.get('A')!.shape).toBe('trapezoid') expect(g.nodes.get('A')!.label).toBe('Trapezoid') }) it('parses trapezoid-alt nodes: A[\\Label/]', () => { const g = parseMermaid('graph TD\n A[\\Alt Trapezoid/]') expect(g.nodes.get('A')!.shape).toBe('trapezoid-alt') expect(g.nodes.get('A')!.label).toBe('Alt Trapezoid') }) }) // ============================================================================ // All shapes in one diagram — ensures no regex conflicts // ============================================================================ describe('parseMermaid – all shapes combined', () => { it('parses all 13 shapes correctly in one diagram', () => { const g = parseMermaid(`graph TD A[Rectangle] B(Rounded) C{Diamond} D([Stadium]) E((Circle)) F[[Subroutine]] G(((DoubleCircle))) H{{Hexagon}} I[(Cylinder)] J>Asymmetric] K[/Trapezoid\\] L[\\TrapAlt/]`) expect(g.nodes.get('A')!.shape).toBe('rectangle') expect(g.nodes.get('B')!.shape).toBe('rounded') expect(g.nodes.get('C')!.shape).toBe('diamond') expect(g.nodes.get('D')!.shape).toBe('stadium') expect(g.nodes.get('E')!.shape).toBe('circle') expect(g.nodes.get('F')!.shape).toBe('subroutine') expect(g.nodes.get('G')!.shape).toBe('doublecircle') expect(g.nodes.get('H')!.shape).toBe('hexagon') expect(g.nodes.get('I')!.shape).toBe('cylinder') expect(g.nodes.get('J')!.shape).toBe('asymmetric') expect(g.nodes.get('K')!.shape).toBe('trapezoid') expect(g.nodes.get('L')!.shape).toBe('trapezoid-alt') }) }) // ============================================================================ // Edge parsing — original arrows // ============================================================================ describe('parseMermaid – edges (original)', () => { it('parses a solid edge: -->', () => { const g = parseMermaid('graph TD\n A --> B') expect(g.edges).toHaveLength(1) expect(g.edges[0]!.source).toBe('A') expect(g.edges[0]!.target).toBe('B') expect(g.edges[0]!.style).toBe('solid') expect(g.edges[0]!.label).toBeUndefined() }) it('parses a dotted edge: -.->', () => { const g = parseMermaid('graph TD\n A -.-> B') expect(g.edges[0]!.style).toBe('dotted') }) it('parses a thick edge: ==>', () => { const g = parseMermaid('graph TD\n A ==> B') expect(g.edges[0]!.style).toBe('thick') }) it('parses edge label: -->|label|', () => { const g = parseMermaid('graph TD\n A -->|Yes| B') expect(g.edges[0]!.label).toBe('Yes') }) it('parses edge label on dotted edges', () => { const g = parseMermaid('graph TD\n A -.->|Maybe| B') expect(g.edges[0]!.label).toBe('Maybe') expect(g.edges[0]!.style).toBe('dotted') }) it('parses chained edges: A --> B --> C', () => { const g = parseMermaid('graph TD\n A --> B --> C') expect(g.edges).toHaveLength(2) expect(g.edges[0]!.source).toBe('A') expect(g.edges[0]!.target).toBe('B') expect(g.edges[1]!.source).toBe('B') expect(g.edges[1]!.target).toBe('C') }) it('parses chained edges with shapes: A[Start] --> B{Check} --> C(End)', () => { const g = parseMermaid('graph TD\n A[Start] --> B{Check} --> C(End)') expect(g.edges).toHaveLength(2) expect(g.nodes.get('A')!.shape).toBe('rectangle') expect(g.nodes.get('B')!.shape).toBe('diamond') expect(g.nodes.get('C')!.shape).toBe('rounded') }) it('handles multiple edge lines', () => { const g = parseMermaid('graph TD\n A --> B\n B --> C\n C --> D') expect(g.edges).toHaveLength(3) }) it('sets hasArrowEnd=true for arrow operators (-->)', () => { const g = parseMermaid('graph TD\n A --> B') expect(g.edges[0]!.hasArrowEnd).toBe(true) expect(g.edges[0]!.hasArrowStart).toBe(false) }) }) // ============================================================================ // No-arrow edges (Batch 1.1) // ============================================================================ describe('parseMermaid – no-arrow edges', () => { it('parses solid line without arrow: ---', () => { const g = parseMermaid('graph TD\n A --- B') expect(g.edges).toHaveLength(1) expect(g.edges[0]!.style).toBe('solid') expect(g.edges[0]!.hasArrowEnd).toBe(false) expect(g.edges[0]!.hasArrowStart).toBe(false) }) it('parses dotted line without arrow: -.-', () => { const g = parseMermaid('graph TD\n A -.- B') expect(g.edges[0]!.style).toBe('dotted') expect(g.edges[0]!.hasArrowEnd).toBe(false) }) it('parses thick line without arrow: ===', () => { const g = parseMermaid('graph TD\n A === B') expect(g.edges[0]!.style).toBe('thick') expect(g.edges[0]!.hasArrowEnd).toBe(false) }) it('parses no-arrow with label: ---|text|', () => { const g = parseMermaid('graph TD\n A ---|connects| B') expect(g.edges[0]!.label).toBe('connects') expect(g.edges[0]!.hasArrowEnd).toBe(false) }) }) // ============================================================================ // Bidirectional arrows (Batch 2.4) // ============================================================================ describe('parseMermaid – bidirectional arrows', () => { it('parses solid bidirectional: <-->', () => { const g = parseMermaid('graph TD\n A <--> B') expect(g.edges).toHaveLength(1) expect(g.edges[0]!.style).toBe('solid') expect(g.edges[0]!.hasArrowStart).toBe(true) expect(g.edges[0]!.hasArrowEnd).toBe(true) }) it('parses dotted bidirectional: <-.->', () => { const g = parseMermaid('graph TD\n A <-.-> B') expect(g.edges[0]!.style).toBe('dotted') expect(g.edges[0]!.hasArrowStart).toBe(true) expect(g.edges[0]!.hasArrowEnd).toBe(true) }) it('parses thick bidirectional: <==>', () => { const g = parseMermaid('graph TD\n A <==> B') expect(g.edges[0]!.style).toBe('thick') expect(g.edges[0]!.hasArrowStart).toBe(true) expect(g.edges[0]!.hasArrowEnd).toBe(true) }) it('parses bidirectional with label: <-->|text|', () => { const g = parseMermaid('graph TD\n A <-->|sync| B') expect(g.edges[0]!.label).toBe('sync') expect(g.edges[0]!.hasArrowStart).toBe(true) expect(g.edges[0]!.hasArrowEnd).toBe(true) }) }) // ============================================================================ // Text-embedded edge labels (fixes #32) // Based on PR #36 by @liuxiaopai-ai // ============================================================================ describe('parseMermaid – text-embedded edge labels', () => { it('parses solid arrow with text label: -- Yes -->', () => { const g = parseMermaid('graph TD\n A -- Yes --> B') expect(g.edges).toHaveLength(1) expect(g.edges[0]!.label).toBe('Yes') expect(g.edges[0]!.style).toBe('solid') expect(g.edges[0]!.hasArrowEnd).toBe(true) }) it('parses solid line with text label: -- text ---', () => { const g = parseMermaid('graph TD\n A -- related --- B') expect(g.edges[0]!.label).toBe('related') expect(g.edges[0]!.style).toBe('solid') expect(g.edges[0]!.hasArrowEnd).toBe(false) }) it('parses dotted arrow with text label: -. Maybe .->', () => { const g = parseMermaid('graph TD\n A -. Maybe .-> B') expect(g.edges[0]!.label).toBe('Maybe') expect(g.edges[0]!.style).toBe('dotted') expect(g.edges[0]!.hasArrowEnd).toBe(true) }) it('parses thick arrow with text label: == Sure ==>', () => { const g = parseMermaid('graph TD\n A == Sure ==> B') expect(g.edges[0]!.label).toBe('Sure') expect(g.edges[0]!.style).toBe('thick') expect(g.edges[0]!.hasArrowEnd).toBe(true) }) it('parses multi-word text labels', () => { const g = parseMermaid('graph TD\n A -- This is a label --> B') expect(g.edges[0]!.label).toBe('This is a label') }) it('parses shaped nodes with text-embedded labels', () => { const g = parseMermaid('graph TD\n A[Start] -- Yes --> B(End)') expect(g.edges[0]!.label).toBe('Yes') expect(g.nodes.get('A')!.shape).toBe('rectangle') expect(g.nodes.get('B')!.shape).toBe('rounded') }) it('produces same result as pipe syntax (issue #32)', () => { const pipe = parseMermaid(`graph TD A --> B B -->|Yes| C`) const text = parseMermaid(`graph TD A --> B B -- Yes --> C`) expect(pipe.edges[1]!.label).toBe(text.edges[1]!.label) expect(pipe.edges[1]!.style).toBe(text.edges[1]!.style) expect(pipe.edges[1]!.hasArrowEnd).toBe(text.edges[1]!.hasArrowEnd) }) it('handles the exact issue #32 scenario', () => { const g = parseMermaid(`flowchart TD A(Start) --> B{Is it sunny?} B -- Yes --> C[Go to the park] B -- No --> D[Stay indoors] C --> E[Finish] D --> E`) expect(g.edges).toHaveLength(5) expect(g.edges[1]!.label).toBe('Yes') expect(g.edges[2]!.label).toBe('No') }) }) // ============================================================================ // Parallel links with & (Batch 2.6) // ============================================================================ describe('parseMermaid – parallel links (&)', () => { it('expands A & B --> C to two edges', () => { const g = parseMermaid('graph TD\n A & B --> C') expect(g.edges).toHaveLength(2) expect(g.edges[0]!.source).toBe('A') expect(g.edges[0]!.target).toBe('C') expect(g.edges[1]!.source).toBe('B') expect(g.edges[1]!.target).toBe('C') }) it('expands A --> C & D to two edges', () => { const g = parseMermaid('graph TD\n A --> C & D') expect(g.edges).toHaveLength(2) expect(g.edges[0]!.source).toBe('A') expect(g.edges[0]!.target).toBe('C') expect(g.edges[1]!.source).toBe('A') expect(g.edges[1]!.target).toBe('D') }) it('expands A & B --> C & D to four edges (Cartesian product)', () => { const g = parseMermaid('graph TD\n A & B --> C & D') expect(g.edges).toHaveLength(4) const edgePairs = g.edges.map(e => `${e.source}->${e.target}`) expect(edgePairs).toContain('A->C') expect(edgePairs).toContain('A->D') expect(edgePairs).toContain('B->C') expect(edgePairs).toContain('B->D') }) }) // ============================================================================ // ::: class shorthand (Batch 1.2) // ============================================================================ describe('parseMermaid – ::: class shorthand', () => { it('assigns class via ::: on shaped nodes', () => { const g = parseMermaid('graph TD\n A[Start]:::highlight --> B') expect(g.classAssignments.get('A')).toBe('highlight') }) it('assigns class via ::: on bare nodes', () => { const g = parseMermaid('graph TD\n A:::important --> B') expect(g.classAssignments.get('A')).toBe('important') }) it('works in chained edges', () => { const g = parseMermaid('graph TD\n A:::start --> B:::mid --> C:::end') expect(g.classAssignments.get('A')).toBe('start') expect(g.classAssignments.get('B')).toBe('mid') expect(g.classAssignments.get('C')).toBe('end') }) }) // ============================================================================ // Inline style statements (Batch 2.5) // ============================================================================ describe('parseMermaid – style statements', () => { it('parses style for a single node', () => { const g = parseMermaid('graph TD\n A --> B\n style A fill:#ff0000,stroke:#333') expect(g.nodeStyles.get('A')).toEqual({ fill: '#ff0000', stroke: '#333' }) }) it('parses style for multiple nodes', () => { const g = parseMermaid('graph TD\n A --> B\n style A,B fill:#0f0') expect(g.nodeStyles.get('A')).toEqual({ fill: '#0f0' }) expect(g.nodeStyles.get('B')).toEqual({ fill: '#0f0' }) }) it('merges multiple style statements for same node', () => { const g = parseMermaid('graph TD\n A --> B\n style A fill:#f00\n style A stroke:#333') expect(g.nodeStyles.get('A')).toEqual({ fill: '#f00', stroke: '#333' }) }) }) // ============================================================================ // Subgraph direction override (Batch 2.7) // ============================================================================ describe('parseMermaid – subgraph direction override', () => { it('parses direction override inside subgraph', () => { const g = parseMermaid(`graph TD subgraph sub1 [Left-Right Group] direction LR A --> B end`) expect(g.subgraphs[0]!.direction).toBe('LR') }) it('does not apply direction outside subgraph', () => { // "direction LR" at root level without subgraph context should not change graph direction const g = parseMermaid('graph TD\n A --> B') expect(g.direction).toBe('TD') }) }) // ============================================================================ // Subgraphs // ============================================================================ describe('parseMermaid – subgraphs', () => { it('parses a basic subgraph', () => { const g = parseMermaid(`graph TD subgraph Backend A --> B end`) expect(g.subgraphs).toHaveLength(1) expect(g.subgraphs[0]!.label).toBe('Backend') expect(g.subgraphs[0]!.nodeIds).toContain('A') expect(g.subgraphs[0]!.nodeIds).toContain('B') }) it('parses subgraph with bracket ID syntax: subgraph id [Label]', () => { const g = parseMermaid(`graph TD subgraph be [Backend Services] A --> B end`) expect(g.subgraphs[0]!.id).toBe('be') expect(g.subgraphs[0]!.label).toBe('Backend Services') }) it('parses subgraph bracket syntax with hyphenated ID: subgraph us-east [US East]', () => { const g = parseMermaid(`graph TD subgraph us-east [US East Region] A --> B end`) expect(g.subgraphs[0]!.id).toBe('us-east') expect(g.subgraphs[0]!.label).toBe('US East Region') }) it('slugifies label as id when bracket syntax is not used', () => { const g = parseMermaid(`graph TD subgraph My Group A --> B end`) expect(g.subgraphs[0]!.id).toBe('My_Group') expect(g.subgraphs[0]!.label).toBe('My Group') }) it('parses nested subgraphs', () => { const g = parseMermaid(`graph TD subgraph Outer subgraph Inner A --> B end C --> D end`) expect(g.subgraphs).toHaveLength(1) // only top-level const outer = g.subgraphs[0]! expect(outer.label).toBe('Outer') expect(outer.children).toHaveLength(1) expect(outer.children[0]!.label).toBe('Inner') expect(outer.children[0]!.nodeIds).toContain('A') expect(outer.children[0]!.nodeIds).toContain('B') expect(outer.nodeIds).toContain('C') expect(outer.nodeIds).toContain('D') }) it('does NOT track nodes in subgraphs where they are merely referenced (regression)', () => { // This diagram has cross-subgraph edges: // - B is defined in "clients" but referenced in "services" // - D, E, F are defined in "services" but referenced in "data" // Nodes should only belong to the subgraph where they are FIRST DEFINED. const g = parseMermaid(`graph LR subgraph clients [Client Layer] A([Web App]) --> B[API Gateway] C([Mobile App]) --> B end subgraph services [Service Layer] B --> D[Auth Service] B --> E[User Service] B --> F[Order Service] end subgraph data [Data Layer] D --> G[(Auth DB)] E --> H[(User DB)] F --> I[(Order DB)] F --> J([Message Queue]) end`) const clients = g.subgraphs.find(sg => sg.id === 'clients')! const services = g.subgraphs.find(sg => sg.id === 'services')! const data = g.subgraphs.find(sg => sg.id === 'data')! // B should ONLY be in clients (where it's defined), NOT in services expect(clients.nodeIds).toContain('B') expect(services.nodeIds).not.toContain('B') // D, E, F should ONLY be in services, NOT in data expect(services.nodeIds).toContain('D') expect(services.nodeIds).toContain('E') expect(services.nodeIds).toContain('F') expect(data.nodeIds).not.toContain('D') expect(data.nodeIds).not.toContain('E') expect(data.nodeIds).not.toContain('F') // Data layer should only have its own nodes expect(data.nodeIds).toContain('G') expect(data.nodeIds).toContain('H') expect(data.nodeIds).toContain('I') expect(data.nodeIds).toContain('J') }) }) // ============================================================================ // classDef and class assignments // ============================================================================ describe('parseMermaid – classDef and class', () => { it('parses classDef with properties', () => { const g = parseMermaid(`graph TD classDef highlight fill:#f96,stroke:#333 A --> B`) expect(g.classDefs.has('highlight')).toBe(true) const props = g.classDefs.get('highlight')! expect(props['fill']).toBe('#f96') expect(props['stroke']).toBe('#333') }) it('parses class assignments to single node', () => { const g = parseMermaid(`graph TD A --> B class A highlight`) expect(g.classAssignments.get('A')).toBe('highlight') }) it('parses class assignments to multiple nodes', () => { const g = parseMermaid(`graph TD A --> B --> C class A,B highlight`) expect(g.classAssignments.get('A')).toBe('highlight') expect(g.classAssignments.get('B')).toBe('highlight') }) }) // ============================================================================ // Comments // ============================================================================ describe('parseMermaid – comments', () => { it('ignores lines starting with %%', () => { const g = parseMermaid(`graph TD %% This is a comment A --> B %% Another comment`) expect(g.nodes.size).toBe(2) expect(g.edges).toHaveLength(1) }) }) // ============================================================================ // Edge cases // ============================================================================ describe('parseMermaid – edge cases', () => { it('handles extra whitespace', () => { const g = parseMermaid(' graph TD \n A --> B ') expect(g.edges).toHaveLength(1) expect(g.nodes.size).toBe(2) }) it('handles empty lines between definitions', () => { const g = parseMermaid('graph TD\n\n A --> B\n\n B --> C') expect(g.edges).toHaveLength(2) }) it('handles diagram with only nodes (no edges)', () => { const g = parseMermaid('graph TD\n A[Only Node]') expect(g.nodes.size).toBe(1) expect(g.edges).toHaveLength(0) }) it('preserves node order in the map', () => { const g = parseMermaid('graph TD\n Z[Last] --> A[First]') const ids = [...g.nodes.keys()] expect(ids[0]).toBe('Z') expect(ids[1]).toBe('A') }) }) // ============================================================================ // State diagram parsing (Batch 3) // ============================================================================ describe('parseMermaid – state diagrams', () => { it('detects stateDiagram-v2 header', () => { const g = parseMermaid('stateDiagram-v2\n s1 --> s2') expect(g.direction).toBe('TD') }) it('detects stateDiagram header (without -v2)', () => { const g = parseMermaid('stateDiagram\n s1 --> s2') expect(g.direction).toBe('TD') }) it('parses basic state transitions', () => { const g = parseMermaid(`stateDiagram-v2 Idle --> Active Active --> Done`) expect(g.edges).toHaveLength(2) expect(g.edges[0]!.source).toBe('Idle') expect(g.edges[0]!.target).toBe('Active') expect(g.edges[1]!.source).toBe('Active') expect(g.edges[1]!.target).toBe('Done') // State nodes default to rounded shape expect(g.nodes.get('Idle')!.shape).toBe('rounded') }) it('parses transition labels', () => { const g = parseMermaid(`stateDiagram-v2 Idle --> Active : start`) expect(g.edges[0]!.label).toBe('start') }) it('parses [*] start pseudostate', () => { const g = parseMermaid(`stateDiagram-v2 [*] --> Idle`) // [*] as source becomes _start with state-start shape const startNode = g.nodes.get('_start') expect(startNode).toBeDefined() expect(startNode!.shape).toBe('state-start') expect(g.edges[0]!.source).toBe('_start') }) it('parses [*] end pseudostate', () => { const g = parseMermaid(`stateDiagram-v2 Done --> [*]`) const endNode = g.nodes.get('_end') expect(endNode).toBeDefined() expect(endNode!.shape).toBe('state-end') expect(g.edges[0]!.target).toBe('_end') }) it('assigns unique IDs to multiple [*] pseudostates', () => { const g = parseMermaid(`stateDiagram-v2 [*] --> A [*] --> B`) // First [*] source → _start, second → _start2 expect(g.nodes.has('_start')).toBe(true) expect(g.nodes.has('_start2')).toBe(true) }) it('parses state description: s1 : Description', () => { const g = parseMermaid(`stateDiagram-v2 s1 : Idle State s1 --> s2`) expect(g.nodes.get('s1')!.label).toBe('Idle State') expect(g.nodes.get('s1')!.shape).toBe('rounded') }) it('parses state alias: state "Description" as s1', () => { const g = parseMermaid(`stateDiagram-v2 state "Waiting for input" as waiting waiting --> active`) expect(g.nodes.get('waiting')!.label).toBe('Waiting for input') }) it('parses composite states', () => { const g = parseMermaid(`stateDiagram-v2 state Processing { parse --> validate validate --> execute }`) expect(g.subgraphs).toHaveLength(1) expect(g.subgraphs[0]!.id).toBe('Processing') expect(g.subgraphs[0]!.label).toBe('Processing') expect(g.subgraphs[0]!.nodeIds).toContain('parse') expect(g.subgraphs[0]!.nodeIds).toContain('validate') expect(g.subgraphs[0]!.nodeIds).toContain('execute') }) it('parses composite states with alias', () => { const g = parseMermaid(`stateDiagram-v2 state "Active Processing" as AP { inner1 --> inner2 }`) expect(g.subgraphs[0]!.id).toBe('AP') expect(g.subgraphs[0]!.label).toBe('Active Processing') }) it('parses direction override in state diagrams', () => { const g = parseMermaid(`stateDiagram-v2 direction LR s1 --> s2`) expect(g.direction).toBe('LR') }) it('parses direction override inside composite state', () => { const g = parseMermaid(`stateDiagram-v2 state Processing { direction LR parse --> validate }`) expect(g.subgraphs[0]!.direction).toBe('LR') }) it('parses CJK (Chinese) state names in transitions', () => { const g = parseMermaid(`stateDiagram-v2 [*] --> 空闲 空闲 --> 完成`) expect(g.edges).toHaveLength(2) expect(g.edges[0]!.target).toBe('空闲') expect(g.edges[1]!.source).toBe('空闲') expect(g.edges[1]!.target).toBe('完成') expect(g.nodes.get('空闲')!.shape).toBe('rounded') }) it('parses CJK state names with transition labels', () => { const g = parseMermaid(`stateDiagram-v2 空闲 --> 处理中 : 提交`) expect(g.edges[0]!.source).toBe('空闲') expect(g.edges[0]!.target).toBe('处理中') expect(g.edges[0]!.label).toBe('提交') }) it('parses CJK state descriptions', () => { const g = parseMermaid(`stateDiagram-v2 空闲 : 等待输入 空闲 --> 完成`) expect(g.nodes.get('空闲')!.label).toBe('等待输入') }) it('parses Japanese state names', () => { const g = parseMermaid(`stateDiagram-v2 [*] --> 待機 待機 --> 処理中 : 開始 処理中 --> 完了`) expect(g.edges).toHaveLength(3) expect(g.nodes.has('待機')).toBe(true) expect(g.nodes.has('処理中')).toBe(true) expect(g.nodes.has('完了')).toBe(true) }) it('handles full state diagram with start/end and composites', () => { const g = parseMermaid(`stateDiagram-v2 [*] --> Idle Idle --> Processing : submit state Processing { parse --> validate validate --> execute } Processing --> Complete : done Complete --> [*]`) expect(g.nodes.has('_start')).toBe(true) expect(g.nodes.has('_end')).toBe(true) expect(g.nodes.has('Idle')).toBe(true) expect(g.nodes.has('Complete')).toBe(true) expect(g.subgraphs).toHaveLength(1) expect(g.subgraphs[0]!.id).toBe('Processing') // Should have transitions for: [*]→Idle, Idle→Processing, parse→validate, // validate→execute, Processing→Complete, Complete→[*] expect(g.edges).toHaveLength(6) }) }) ================================================ FILE: src/__tests__/renderer.test.ts ================================================ /** * Tests for the SVG renderer. * * Uses hand-crafted PositionedGraph data to test SVG output without * depending on the layout engine. */ import { describe, it, expect } from 'bun:test' import { renderSvg } from '../renderer.ts' import type { DiagramColors } from '../theme.ts' import type { PositionedGraph, PositionedNode, PositionedEdge, PositionedGroup } from '../types.ts' /** Minimal positioned graph for testing */ function makeGraph(overrides: Partial = {}): PositionedGraph { return { width: 400, height: 300, nodes: [], edges: [], groups: [], ...overrides, } } /** Helper to build a positioned node */ function makeNode(overrides: Partial = {}): PositionedNode { return { id: 'A', label: 'Test', shape: 'rectangle', x: 100, y: 100, width: 80, height: 40, ...overrides, } } /** Helper to build a positioned edge with arrow defaults */ function makeEdge(overrides: Partial = {}): PositionedEdge { return { source: 'A', target: 'B', style: 'solid', hasArrowStart: false, hasArrowEnd: true, points: [{ x: 100, y: 120 }, { x: 100, y: 200 }], ...overrides, } } /** Default light colors — CSS custom properties handle actual styling */ const lightColors: DiagramColors = { bg: '#FFFFFF', fg: '#27272A' } const darkColors: DiagramColors = { bg: '#18181B', fg: '#FAFAFA' } // ============================================================================ // SVG structure // ============================================================================ describe('renderSvg – SVG structure', () => { it('produces a valid SVG root element', () => { const svg = renderSvg(makeGraph(), lightColors) expect(svg).toContain('') }) it('includes with arrow markers', () => { const svg = renderSvg(makeGraph(), lightColors) expect(svg).toContain('') expect(svg).toContain('') }) it('includes embedded Google Fonts import', () => { const svg = renderSvg(makeGraph(), lightColors, 'Inter') expect(svg).toContain('fonts.googleapis.com') expect(svg).toContain('Inter') }) it('uses custom font name when specified', () => { const svg = renderSvg(makeGraph(), lightColors, 'Roboto Mono') // encodeURIComponent turns spaces into %20 expect(svg).toContain('Roboto%20Mono') expect(svg).toContain("'Roboto Mono'") }) it('sets CSS color variables in inline style', () => { const light = renderSvg(makeGraph(), lightColors) expect(light).toContain('--bg:#FFFFFF') expect(light).toContain('--fg:#27272A') const dark = renderSvg(makeGraph(), darkColors) expect(dark).toContain('--bg:#18181B') expect(dark).toContain('--fg:#FAFAFA') }) }) // ============================================================================ // Original node shapes // ============================================================================ describe('renderSvg – node shapes', () => { it('renders rectangle with rx=0', () => { const graph = makeGraph({ nodes: [makeNode({ shape: 'rectangle' })] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('rx="0" ry="0"') }) it('renders rounded rectangle with rx=6', () => { const graph = makeGraph({ nodes: [makeNode({ shape: 'rounded' })] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('rx="6" ry="6"') }) it('renders stadium with rx=height/2', () => { const node = makeNode({ shape: 'stadium', height: 40 }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('rx="20" ry="20"') }) it('renders circle with element', () => { const node = makeNode({ shape: 'circle', width: 60, height: 60 }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('', () => { const node = makeNode({ shape: 'diamond', width: 80, height: 80 }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain(' elements', () => { const graph = makeGraph({ nodes: [makeNode({ label: 'My Node' })] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('>My Node
    ') }) }) // ============================================================================ // New Batch 1 shapes // ============================================================================ describe('renderSvg – new shapes (Batch 1)', () => { it('renders subroutine with outer rect and inset vertical lines', () => { const node = makeNode({ shape: 'subroutine', width: 100, height: 40 }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) // Outer rect expect(svg).toContain(' elements', () => { const node = makeNode({ shape: 'doublecircle', width: 80, height: 80 }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) const circleMatches = svg.match(/', () => { const node = makeNode({ shape: 'hexagon', width: 100, height: 40 }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain(' { it('renders cylinder with ellipses and body rect', () => { const node = makeNode({ shape: 'cylinder', width: 80, height: 50 }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) // Should contain ellipses for top and bottom caps const ellipseMatches = svg.match(/', () => { const node = makeNode({ shape: 'asymmetric', width: 100, height: 40 }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('', () => { const node = makeNode({ shape: 'trapezoid', width: 100, height: 40 }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('', () => { const node = makeNode({ shape: 'trapezoid-alt', width: 100, height: 40 }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain(' { it('renders state-start as a filled circle', () => { const node = makeNode({ shape: 'state-start', label: '', width: 28, height: 28 }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain(' { const node = makeNode({ shape: 'state-end', label: '', width: 28, height: 28 }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) const circleMatches = svg.match(/ { it('renders a solid edge as with end arrow', () => { const edge = makeEdge({ style: 'solid', hasArrowEnd: true }) const graph = makeGraph({ edges: [edge] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain(' { const edge = makeEdge({ style: 'dotted' }) const graph = makeGraph({ edges: [edge] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('stroke-dasharray="4 4"') }) it('renders thick edges with doubled stroke width', () => { const edge = makeEdge({ style: 'thick' }) const graph = makeGraph({ edges: [edge] }) const svg = renderSvg(graph, lightColors) // Base connector stroke is 1px, thick is doubled to 2px expect(svg).toContain('stroke-width="2"') }) it('does not add dasharray to solid edges', () => { const edge = makeEdge({ style: 'solid' }) const graph = makeGraph({ edges: [edge] }) const svg = renderSvg(graph, lightColors) expect(svg).not.toContain('dasharray') }) it('skips edges with fewer than 2 points', () => { const edge = makeEdge({ points: [{ x: 0, y: 0 }] }) const graph = makeGraph({ edges: [edge] }) const svg = renderSvg(graph, lightColors) expect(svg).not.toContain(' { const edge = makeEdge({ hasArrowEnd: false, hasArrowStart: false }) const graph = makeGraph({ edges: [edge] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain(' { const edge = makeEdge({ hasArrowStart: true, hasArrowEnd: true }) const graph = makeGraph({ edges: [edge] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('marker-end="url(#arrowhead)"') expect(svg).toContain('marker-start="url(#arrowhead-start)"') }) }) // ============================================================================ // Edge labels // ============================================================================ describe('renderSvg – edge labels', () => { it('renders edge label with background pill', () => { const edge = makeEdge({ label: 'Yes' }) const graph = makeGraph({ edges: [edge] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('>Yes') expect(svg).toContain('rx="2" ry="2"') }) it('does not render label elements for edges without labels', () => { const edge = makeEdge({ label: undefined }) const graph = makeGraph({ edges: [edge] }) const svg = renderSvg(graph, lightColors) const textMatches = svg.match(/]*>.*?<\/text>/g) ?? [] expect(textMatches).toHaveLength(0) }) it('uses labelPosition when provided instead of edge midpoint', () => { // Edge midpoint would be at (100, 160) given these points. // labelPosition overrides to (50, 80) — verify the SVG uses that coordinate. const edge = makeEdge({ label: 'Go', points: [{ x: 100, y: 120 }, { x: 100, y: 200 }], labelPosition: { x: 50, y: 80 }, }) const graph = makeGraph({ edges: [edge] }) const svg = renderSvg(graph, lightColors) // The label text should be centered at the labelPosition (x=50, y=80) expect(svg).toContain('x="50" y="80"') // The midpoint y=160 should NOT appear in any label-related element expect(svg).not.toContain('y="160"') }) }) // ============================================================================ // Group rendering (subgraphs) // ============================================================================ describe('renderSvg – groups', () => { it('renders group with outer rectangle and header band', () => { const group: PositionedGroup = { id: 'sg1', label: 'Backend', x: 20, y: 20, width: 200, height: 150, children: [], } const graph = makeGraph({ groups: [group] }) const svg = renderSvg(graph, lightColors) const rectCount = (svg.match(/x="20" y="20"/g) ?? []).length expect(rectCount).toBeGreaterThanOrEqual(2) expect(svg).toContain('>Backend') }) it('renders nested groups recursively', () => { const inner: PositionedGroup = { id: 'inner', label: 'Inner', x: 40, y: 60, width: 120, height: 80, children: [], } const outer: PositionedGroup = { id: 'outer', label: 'Outer', x: 20, y: 20, width: 200, height: 150, children: [inner], } const graph = makeGraph({ groups: [outer] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('>Outer') expect(svg).toContain('>Inner') }) }) // ============================================================================ // Inline style support // ============================================================================ describe('renderSvg – inline styles', () => { it('applies inline fill override', () => { const node = makeNode({ inlineStyle: { fill: '#ff0000' } }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('fill="#ff0000"') }) it('applies inline stroke override', () => { const node = makeNode({ inlineStyle: { stroke: '#00ff00' } }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('stroke="#00ff00"') }) it('applies inline text color override', () => { const node = makeNode({ inlineStyle: { color: '#0000ff' } }) const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('fill="#0000ff"') }) it('falls back to theme when no inline style', () => { const node = makeNode() const graph = makeGraph({ nodes: [node] }) const svg = renderSvg(graph, lightColors) expect(svg).toContain('fill="var(--_node-fill)"') }) }) // ============================================================================ // XML escaping // ============================================================================ describe('renderSvg – XML escaping', () => { it('escapes special characters in node labels', () => { const node = makeNode({ label: '
    XY Chart Test

    XY Chart Test

    xychart-beta rendering comparison: Chart.js vs beautiful-mermaid

    Column 1 shows the Mermaid source, Column 2 renders a Chart.js reference, and Column 3 shows the beautiful-mermaid SVG rendering with theme support.

    100 xychart-beta examples across 9 categories

    Chart.js reference + beautiful-mermaid SVG comparison

    Basic Bar Charts

    Simple Bar Chart

    A minimal bar chart with three data points.

    xychart-beta
        title "Simple Bar Chart"
        x-axis [A, B, C]
        bar [10, 20, 30]
    Rendering…

    Five-Category Bars

    Bar chart with five categories and varied values.

    xychart-beta
        title "Product Sales"
        x-axis [Widgets, Gadgets, Gizmos, Doodads, Thingamajigs]
        bar [150, 230, 180, 95, 310]
    Rendering…

    Descending Values

    Bars sorted in descending order to show ranking.

    xychart-beta
        title "Browser Market Share"
        x-axis [Chrome, Safari, Firefox, Edge, Other]
        bar [65, 19, 8, 5, 3]
    Rendering…

    Single Bar

    A bar chart with only two data points.

    xychart-beta
        title "Q1 vs Q2"
        x-axis [Q1, Q2]
        bar [4500, 5200]
    Rendering…

    Eight-Category Bars

    Bar chart with eight categories and varied heights.

    xychart-beta
        title "Department Headcount"
        x-axis [Eng, Sales, Marketing, Support, HR, Finance, Legal, Ops]
        bar [45, 32, 18, 25, 8, 12, 6, 15]
    Rendering…

    Small Values

    Bar chart with small integer values under 10.

    xychart-beta
        title "Daily Bugs Found"
        x-axis [Mon, Tue, Wed, Thu, Fri]
        bar [3, 7, 2, 5, 1]
    Rendering…

    Uniform Values

    Bars with nearly identical heights.

    xychart-beta
        title "Consistent Output"
        x-axis [Week 1, Week 2, Week 3, Week 4]
        bar [100, 102, 99, 101]
    Rendering…

    Wide Range

    Bars with a wide range of values from small to large.

    xychart-beta
        title "City Population (thousands)"
        x-axis [Town A, Town B, City C, Metro D, Mega E]
        bar [5, 25, 150, 800, 3500]
    Rendering…

    Ten-Category Bars

    Bar chart with ten data points showing monthly figures.

    xychart-beta
        title "Monthly Signups"
        x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct]
        bar [120, 145, 190, 210, 250, 280, 310, 295, 340, 380]
    Rendering…

    Bars With Y-Axis Range

    Bar chart with an explicit y-axis range.

    xychart-beta
        title "Test Scores"
        x-axis [Alice, Bob, Carol, Dave, Eve]
        y-axis "Score" 0 --> 100
        bar [85, 72, 91, 68, 95]
    Rendering…

    Basic Line Charts

    Simple Line Chart

    A minimal line chart with four data points.

    xychart-beta
        title "Simple Trend"
        x-axis [Q1, Q2, Q3, Q4]
        line [100, 150, 130, 180]
    Rendering…

    Upward Trend

    Line chart showing a steady upward trend.

    xychart-beta
        title "Revenue Growth"
        x-axis [2019, 2020, 2021, 2022, 2023]
        line [500, 620, 780, 950, 1200]
    Rendering…

    Downward Trend

    Line chart showing a declining trend.

    xychart-beta
        title "Declining Defect Rate"
        x-axis [Sprint 1, Sprint 2, Sprint 3, Sprint 4, Sprint 5, Sprint 6]
        line [25, 20, 15, 12, 8, 5]
    Rendering…

    Oscillating Values

    Line chart with values that rise and fall repeatedly.

    xychart-beta
        title "Daily Temperature Variation"
        x-axis [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
        line [18, 22, 17, 24, 19, 26, 20]
    Rendering…

    Large Scale Line

    Line chart with values in the thousands.

    xychart-beta
        title "Website Visitors"
        x-axis [Jan, Feb, Mar, Apr, May, Jun]
        y-axis "Visitors" 0 --> 50000
        line [12000, 18000, 25000, 31000, 38000, 45000]
    Rendering…

    Flat Line

    A line chart where values remain approximately constant.

    xychart-beta
        title "Stable Metric"
        x-axis [W1, W2, W3, W4, W5, W6]
        line [50, 51, 49, 50, 52, 50]
    Rendering…

    Spike Pattern

    Line chart with a dramatic spike in the middle.

    xychart-beta
        title "Traffic Spike"
        x-axis [6am, 8am, 10am, 12pm, 2pm, 4pm, 6pm]
        line [200, 350, 1200, 4500, 2800, 900, 300]
    Rendering…

    V-Shape Recovery

    Line chart showing a sharp decline followed by recovery.

    xychart-beta
        title "Stock Price Recovery"
        x-axis [Jan, Feb, Mar, Apr, May, Jun]
        line [100, 72, 45, 38, 65, 95]
    Rendering…

    Step Pattern

    Line chart with staircase-like jumps between plateaus.

    xychart-beta
        title "Pricing Tiers"
        x-axis [Free, Basic, Pro, Team, Enterprise]
        line [0, 10, 25, 50, 100]
    Rendering…

    Two Lines

    Two line series plotted on the same chart.

    xychart-beta
        title "Planned vs Actual"
        x-axis [Q1, Q2, Q3, Q4]
        line [100, 200, 300, 400]
        line [90, 210, 280, 420]
    Rendering…

    Combined Bar + Line

    Bar and Line Overlay

    Bars with a line overlaid showing the same data as a trend.

    xychart-beta
        title "Monthly Revenue"
        x-axis [Jan, Feb, Mar, Apr, May, Jun]
        y-axis "Revenue (USD)" 0 --> 10000
        bar [5000, 6200, 7800, 4500, 9200, 8100]
        line [5000, 6200, 7800, 4500, 9200, 8100]
    Rendering…

    Bar with Trend Line

    Bars showing actual values with a line showing the moving average trend.

    xychart-beta
        title "Sales with Trend"
        x-axis [Jan, Feb, Mar, Apr, May, Jun]
        bar [300, 450, 280, 520, 390, 610]
        line [300, 375, 343, 388, 388, 425]
    Rendering…

    Bar with Target Line

    Bars showing actual performance with a flat target line.

    xychart-beta
        title "Performance vs Target"
        x-axis [Jan, Feb, Mar, Apr, May, Jun]
        y-axis "Units" 0 --> 600
        bar [320, 410, 380, 520, 290, 480]
        line [400, 400, 400, 400, 400, 400]
    Rendering…

    Revenue and Profit

    Bars for revenue with a line for profit margin.

    xychart-beta
        title "Revenue and Profit"
        x-axis [Q1, Q2, Q3, Q4]
        bar [5000, 6500, 7200, 8100]
        line [1200, 1800, 2100, 2600]
    Rendering…

    Dual Dataset Overlay

    Two bars and one line series for multi-metric comparison.

    xychart-beta
        title "Orders, Returns, and Net"
        x-axis [Jan, Feb, Mar, Apr, May]
        bar [200, 250, 300, 280, 350]
        bar [20, 30, 25, 35, 28]
        line [180, 220, 275, 245, 322]
    Rendering…

    Costs vs Revenue

    Bars for costs with a line showing revenue growth.

    xychart-beta
        title "Costs vs Revenue"
        x-axis [2020, 2021, 2022, 2023, 2024]
        bar [400, 420, 450, 440, 460]
        line [350, 480, 620, 780, 950]
    Rendering…

    Bar with Two Lines

    Bars with two overlaid line series for comparison.

    xychart-beta
        title "Actual, Forecast, Target"
        x-axis [Jan, Feb, Mar, Apr, May, Jun]
        bar [100, 120, 115, 140, 135, 160]
        line [95, 110, 120, 130, 140, 150]
        line [120, 120, 120, 120, 120, 120]
    Rendering…

    Stacked Context

    Multiple bar series with a line showing the total.

    xychart-beta
        title "Channel Performance"
        x-axis [Jan, Feb, Mar, Apr]
        bar [100, 120, 140, 160]
        bar [80, 90, 100, 110]
        line [180, 210, 240, 270]
    Rendering…

    Cumulative Line Over Bars

    Monthly bars with a cumulative line showing running total.

    xychart-beta
        title "Monthly and Cumulative Sales"
        x-axis [Jan, Feb, Mar, Apr, May, Jun]
        bar [100, 150, 120, 180, 200, 170]
        line [100, 250, 370, 550, 750, 920]
    Rendering…

    Conversion Funnel

    Bars for stage counts with a line showing conversion rate.

    xychart-beta
        title "Funnel Analysis"
        x-axis [Visitors, Signups, Activated, Paid, Retained]
        bar [10000, 3000, 1500, 800, 500]
        line [10000, 3000, 1500, 800, 500]
    Rendering…

    Axis Configurations

    Categorical X-Axis

    Standard categorical x-axis with string labels.

    xychart-beta
        title "Fruit Preferences"
        x-axis [Apple, Banana, Cherry, Date, Elderberry]
        bar [45, 32, 28, 15, 8]
    Rendering…

    Numeric X-Axis Range

    Numeric x-axis using the range syntax.

    xychart-beta
        title "Distribution Curve"
        x-axis 0 --> 100
        line [5, 15, 35, 60, 80, 95, 80, 60, 35, 15, 5]
    Rendering…

    Y-Axis with Label and Range

    Y-axis with a title and explicit min/max range.

    xychart-beta
        title "Temperature Log"
        x-axis [6am, 9am, 12pm, 3pm, 6pm, 9pm]
        y-axis "Temp (F)" 50 --> 100
        line [58, 65, 78, 85, 76, 62]
    Rendering…

    Y-Axis Range Without Label

    Y-axis with range but no title.

    xychart-beta
        title "Sensor Readings"
        x-axis [T1, T2, T3, T4, T5]
        y-axis 0 --> 500
        line [120, 250, 380, 310, 190]
    Rendering…

    X-Axis with Title

    Categorical x-axis with an axis title.

    xychart-beta
        title "Quarterly Results"
        x-axis "Quarter" [Q1, Q2, Q3, Q4]
        y-axis "Revenue ($K)" 0 --> 1000
        bar [420, 580, 710, 890]
    Rendering…

    Both Axes Titled

    Both x-axis and y-axis have descriptive titles.

    xychart-beta
        title "Experiment Results"
        x-axis "Trial Number" [1, 2, 3, 4, 5, 6]
        y-axis "Measurement (mm)" 0 --> 50
        line [12, 18, 25, 22, 31, 28]
    Rendering…

    Long Category Labels

    X-axis with long multi-word category labels.

    xychart-beta
        title "Department Budget"
        x-axis [Engineering, Product Management, Customer Success, Human Resources]
        bar [850, 420, 310, 180]
    Rendering…

    Many Short Labels

    X-axis with many single-character labels.

    xychart-beta
        title "Letter Frequency"
        x-axis [A, B, C, D, E, F, G, H, I, J, K, L, M]
        bar [82, 15, 28, 43, 127, 22, 20, 61, 70, 2, 8, 40, 24]
    Rendering…

    Wide Y-Axis Range

    Y-axis spanning a large range from 0 to 100000.

    xychart-beta
        title "Annual Revenue"
        x-axis [2020, 2021, 2022, 2023, 2024]
        y-axis "USD" 0 --> 100000
        bar [15000, 28000, 45000, 67000, 92000]
    Rendering…

    Narrow Y-Axis Range

    Y-axis with a tight range to emphasize small differences.

    xychart-beta
        title "CPU Temperature"
        x-axis [10s, 20s, 30s, 40s, 50s, 60s]
        y-axis "Celsius" 60 --> 80
        line [65, 68, 72, 75, 73, 70]
    Rendering…

    Auto-Range Y-Axis

    No y-axis declaration; auto-ranged from data.

    xychart-beta
        title "Auto Range"
        x-axis [A, B, C, D, E]
        bar [42, 87, 63, 29, 75]
    Rendering…

    Year Labels

    X-axis with year labels as categories.

    xychart-beta
        title "Company Growth"
        x-axis [2018, 2019, 2020, 2021, 2022, 2023, 2024, 2025]
        line [10, 15, 12, 22, 35, 48, 62, 80]
    Rendering…

    Percentage Y-Axis

    Y-axis configured as percentage from 0 to 100.

    xychart-beta
        title "Completion Rate"
        x-axis [Week 1, Week 2, Week 3, Week 4, Week 5]
        y-axis "Percent" 0 --> 100
        line [15, 35, 58, 78, 95]
    Rendering…

    Numeric X-Axis with Bars

    Numeric x-axis range combined with bar data.

    xychart-beta
        title "Histogram"
        x-axis 0 --> 50
        bar [5, 12, 25, 38, 30, 18, 8]
    Rendering…

    Mixed Short and Long Labels

    X-axis with a mix of short and longer labels.

    xychart-beta
        title "Regional Sales"
        x-axis [US, EU, Asia Pacific, LATAM, MEA, ANZ]
        bar [450, 380, 520, 180, 95, 60]
    Rendering…

    Horizontal Orientation

    Horizontal Bar Chart

    Simple horizontal bar chart.

    xychart-beta horizontal
        title "Language Popularity"
        x-axis [Python, JavaScript, Java, Go, Rust]
        bar [30, 25, 20, 12, 8]
    Rendering…

    Horizontal with Y-Axis

    Horizontal bars with explicit y-axis range.

    xychart-beta horizontal
        title "Sprint Velocity"
        x-axis [Sprint 1, Sprint 2, Sprint 3, Sprint 4, Sprint 5]
        y-axis "Story Points" 0 --> 100
        bar [45, 52, 68, 72, 80]
    Rendering…

    Horizontal Line Chart

    Line chart in horizontal orientation.

    xychart-beta horizontal
        title "Response Time Trend"
        x-axis [v1.0, v1.1, v1.2, v1.3, v1.4]
        line [450, 380, 320, 280, 210]
    Rendering…

    Horizontal Combined

    Horizontal chart with both bars and a line.

    xychart-beta horizontal
        title "Budget vs Actual"
        x-axis [Eng, Sales, Marketing, Ops, HR]
        bar [500, 350, 200, 150, 100]
        line [480, 380, 180, 160, 95]
    Rendering…

    Horizontal Ranking

    Horizontal bars showing a ranked list.

    xychart-beta horizontal
        title "Top Features Requested"
        x-axis [Dark Mode, API Access, Mobile App, SSO, Webhooks, CSV Export]
        bar [245, 198, 176, 152, 134, 112]
    Rendering…

    Horizontal Small Values

    Horizontal bars with small single-digit values.

    xychart-beta horizontal
        title "Team Satisfaction Survey"
        x-axis [Culture, Compensation, Growth, Balance, Tools]
        y-axis "Rating" 0 --> 5
        bar [4, 3, 4, 5, 3]
    Rendering…

    Horizontal Two Bars

    Horizontal chart with two bar series.

    xychart-beta horizontal
        title "This Year vs Last Year"
        x-axis [Q1, Q2, Q3, Q4]
        bar [200, 250, 300, 280]
        bar [180, 220, 270, 310]
    Rendering…

    Horizontal Wide Range

    Horizontal bars with a wide value range.

    xychart-beta horizontal
        title "GitHub Stars"
        x-axis [React, Vue, Angular, Svelte, Solid]
        bar [220000, 210000, 95000, 78000, 32000]
    Rendering…

    Horizontal with Two Lines

    Horizontal chart with two line series.

    xychart-beta horizontal
        title "Planned vs Actual Delivery"
        x-axis [Feature A, Feature B, Feature C, Feature D]
        line [10, 15, 20, 25]
        line [12, 14, 22, 23]
    Rendering…

    Horizontal Long Labels

    Horizontal orientation with long category labels.

    xychart-beta horizontal
        title "Error Categories"
        x-axis [Authentication Failure, Database Timeout, Rate Limit Exceeded, Invalid Input]
        bar [342, 128, 89, 456]
    Rendering…

    Titles & Formatting

    No Title

    Chart without a title declaration.

    xychart-beta
        x-axis [A, B, C, D]
        bar [10, 20, 30, 40]
    Rendering…

    Short Title

    Chart with a very short one-word title.

    xychart-beta
        title "Sales"
        x-axis [Jan, Feb, Mar]
        bar [100, 200, 150]
    Rendering…

    Long Title

    Chart with a long descriptive title.

    xychart-beta
        title "Quarterly Revenue Comparison Across All Regional Offices 2024"
        x-axis [Q1, Q2, Q3, Q4]
        bar [1200, 1500, 1800, 2100]
    Rendering…

    Title with Numbers

    Title containing numeric values.

    xychart-beta
        title "FY2024 Q3 Results"
        x-axis [Product A, Product B, Product C]
        bar [340, 520, 180]
    Rendering…

    Title with Special Characters

    Title containing parentheses and symbols.

    xychart-beta
        title "Growth Rate (%)"
        x-axis [2020, 2021, 2022, 2023]
        line [5, 12, 8, 15]
    Rendering…

    Title with Ampersand

    Title using the ampersand character.

    xychart-beta
        title "R&D Investment"
        x-axis [2021, 2022, 2023, 2024]
        bar [200, 280, 350, 420]
    Rendering…

    Title with Hyphen

    Title containing hyphens.

    xychart-beta
        title "Year-Over-Year Comparison"
        x-axis [Jan, Feb, Mar, Apr, May]
        bar [100, 120, 90, 140, 130]
        line [110, 115, 100, 125, 135]
    Rendering…

    Minimal Chart

    The most minimal possible xychart with just axis and data.

    xychart-beta
        x-axis [A, B]
        bar [1, 2]
    Rendering…

    Full Specification

    Chart with every optional element specified.

    xychart-beta
        title "Complete Chart"
        x-axis "Category" [Alpha, Beta, Gamma, Delta]
        y-axis "Value" 0 --> 500
        bar [120, 340, 250, 410]
        line [120, 340, 250, 410]
    Rendering…

    Title with Colon

    Title containing a colon separator.

    xychart-beta
        title "Metrics: Daily Active Users"
        x-axis [Mon, Tue, Wed, Thu, Fri]
        line [1500, 1800, 2200, 2000, 1700]
    Rendering…

    Large Datasets

    12-Month Dataset

    Full year of monthly data points.

    xychart-beta
        title "Monthly Active Users (2024)"
        x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
        line [12000, 13500, 15200, 16800, 18500, 20100, 19800, 21500, 23000, 24200, 25800, 28000]
    Rendering…

    26-Point Alphabet

    Bar chart with 26 data points, one per letter.

    xychart-beta
        title "Letter Distribution"
        x-axis [A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z]
        bar [82, 15, 28, 43, 127, 22, 20, 61, 70, 2, 8, 40, 24, 67, 75, 19, 1, 60, 63, 91, 28, 10, 24, 2, 20, 1]
    Rendering…

    Dense Weekly Data

    Line chart with data for many weeks.

    xychart-beta
        title "Weekly Downloads"
        x-axis [W1, W2, W3, W4, W5, W6, W7, W8, W9, W10, W11, W12, W13, W14, W15, W16, W17, W18, W19, W20]
        line [500, 520, 480, 550, 600, 580, 620, 650, 700, 680, 720, 750, 800, 780, 820, 850, 900, 880, 920, 950]
    Rendering…

    Multiple Series Large

    Three data series across 12 months.

    xychart-beta
        title "Product Lines Revenue"
        x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
        bar [500, 520, 540, 580, 600, 620, 650, 640, 680, 700, 720, 750]
        bar [300, 320, 310, 350, 370, 380, 400, 390, 420, 440, 450, 470]
        line [800, 840, 850, 930, 970, 1000, 1050, 1030, 1100, 1140, 1170, 1220]
    Rendering…

    Two Bars Twelve Months

    Two bar series over 12 months for year comparison.

    xychart-beta
        title "2023 vs 2024 Monthly Sales"
        x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
        bar [180, 200, 220, 240, 260, 250, 270, 290, 310, 330, 350, 380]
        bar [210, 230, 250, 270, 300, 290, 310, 330, 360, 380, 400, 430]
    Rendering…

    High Frequency Data

    Hourly data points across a full day.

    xychart-beta
        title "Hourly Server Load"
        x-axis [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23]
        line [15, 12, 10, 8, 7, 9, 18, 45, 72, 85, 88, 90, 82, 78, 80, 85, 88, 75, 60, 45, 35, 28, 22, 18]
    Rendering…

    Large Values Dataset

    Dataset with values in the millions.

    xychart-beta
        title "Cloud Spending by Month"
        x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
        y-axis "USD" 0 --> 500000
        bar [120000, 135000, 148000, 162000, 178000, 195000, 210000, 228000, 245000, 268000, 290000, 315000]
    Rendering…

    Three Lines Large

    Three overlapping line series over 10 data points.

    xychart-beta
        title "Multi-Region Latency"
        x-axis [T1, T2, T3, T4, T5, T6, T7, T8, T9, T10]
        line [45, 48, 42, 50, 55, 52, 47, 44, 49, 46]
        line [120, 115, 125, 118, 122, 130, 128, 135, 132, 127]
        line [200, 210, 195, 205, 215, 220, 208, 212, 218, 225]
    Rendering…

    Quarterly Four Years

    Quarterly data spanning four years.

    xychart-beta
        title "Quarterly Earnings (2021-2024)"
        x-axis [21Q1, 21Q2, 21Q3, 21Q4, 22Q1, 22Q2, 22Q3, 22Q4, 23Q1, 23Q2, 23Q3, 23Q4, 24Q1, 24Q2, 24Q3, 24Q4]
        bar [120, 140, 135, 160, 155, 175, 170, 190, 185, 210, 205, 230, 225, 250, 245, 270]
    Rendering…

    Dense Bars and Line

    Dense bar chart with 15 categories and an overlay line.

    xychart-beta
        title "Daily Active Users (First 15 Days)"
        x-axis [D1, D2, D3, D4, D5, D6, D7, D8, D9, D10, D11, D12, D13, D14, D15]
        bar [500, 520, 510, 530, 540, 480, 450, 520, 550, 540, 560, 570, 510, 490, 530]
        line [500, 510, 510, 520, 530, 520, 510, 515, 525, 530, 535, 540, 535, 530, 530]
    Rendering…

    Edge Cases

    Single Data Point

    Chart with only one data point.

    xychart-beta
        title "Single Value"
        x-axis [Only]
        bar [42]
    Rendering…

    All Zeros

    Bar chart where every value is zero.

    xychart-beta
        title "No Activity"
        x-axis [Mon, Tue, Wed, Thu, Fri]
        bar [0, 0, 0, 0, 0]
    Rendering…

    Very Large Numbers

    Chart with values in the millions.

    xychart-beta
        title "National GDP (millions)"
        x-axis [Country A, Country B, Country C]
        bar [5200000, 3800000, 2900000]
    Rendering…

    Very Small Numbers

    Chart with very small decimal-like integer values.

    xychart-beta
        title "Trace Amounts"
        x-axis [Sample A, Sample B, Sample C, Sample D]
        bar [1, 2, 1, 3]
    Rendering…

    Two Data Points

    Minimal chart with exactly two data points.

    xychart-beta
        title "Before and After"
        x-axis [Before, After]
        bar [25, 75]
    Rendering…

    Single Value Repeated

    All bars have the identical value.

    xychart-beta
        title "Equal Distribution"
        x-axis [A, B, C, D, E]
        bar [50, 50, 50, 50, 50]
    Rendering…

    Extreme Outlier

    One value is dramatically larger than the rest.

    xychart-beta
        title "Revenue by Product"
        x-axis [Niche A, Niche B, Flagship, Niche C, Niche D]
        bar [50, 30, 5000, 40, 20]
    Rendering…

    Descending to Zero

    Values that decrease to zero.

    xychart-beta
        title "Declining Interest"
        x-axis [Week 1, Week 2, Week 3, Week 4, Week 5]
        line [100, 60, 25, 8, 0]
    Rendering…

    Ascending from Zero

    Values that start at zero and increase.

    xychart-beta
        title "Ramp Up"
        x-axis [Day 1, Day 2, Day 3, Day 4, Day 5]
        bar [0, 10, 50, 150, 400]
    Rendering…

    Alternating High Low

    Values alternating between high and low.

    xychart-beta
        title "Alternating Pattern"
        x-axis [A, B, C, D, E, F, G, H]
        bar [100, 10, 100, 10, 100, 10, 100, 10]
    Rendering…

    Real-World Scenarios

    Monthly Revenue Report

    Typical monthly revenue bar chart for a SaaS company.

    xychart-beta
        title "Monthly Revenue (2024)"
        x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
        y-axis "Revenue ($K)" 0 --> 500
        bar [180, 195, 210, 225, 250, 268, 285, 275, 300, 320, 340, 380]
    Rendering…

    Cumulative Registered Users

    Cumulative user growth over months showing accelerating adoption.

    xychart-beta
        title "Cumulative Registered Users"
        x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
        y-axis "Users" 0 --> 50000
        line [2500, 5200, 8800, 13100, 18000, 23500, 29200, 34800, 39500, 43200, 46500, 50000]
    Rendering…

    Temperature Over the Year

    Average monthly temperature showing seasonal variation.

    xychart-beta
        title "Average Monthly Temperature"
        x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
        y-axis "Temp (F)" 20 --> 100
        line [32, 35, 45, 58, 68, 78, 85, 83, 74, 60, 48, 36]
    Rendering…

    Stock Price Trend

    Weekly stock price movement over a quarter.

    xychart-beta
        title "ACME Corp Stock Price"
        x-axis [W1, W2, W3, W4, W5, W6, W7, W8, W9, W10, W11, W12, W13]
        y-axis "Price ($)" 80 --> 160
        line [100, 105, 98, 112, 108, 120, 115, 125, 135, 128, 140, 148, 155]
    Rendering…

    Survey Results

    Customer satisfaction survey scores by category.

    xychart-beta
        title "Customer Satisfaction Survey"
        x-axis [Ease of Use, Performance, Support, Pricing, Features, Reliability]
        y-axis "Score" 0 --> 5
        bar [4, 3, 5, 3, 4, 4]
    Rendering…

    API Response Time

    P95 API response times across endpoints.

    xychart-beta
        title "P95 Response Time by Endpoint"
        x-axis [/users, /orders, /products, /search, /auth, /reports]
        y-axis "ms" 0 --> 1000
        bar [120, 250, 180, 450, 80, 850]
    Rendering…

    Website Analytics

    Daily page views and unique visitors for a week.

    xychart-beta
        title "Website Traffic"
        x-axis [Mon, Tue, Wed, Thu, Fri, Sat, Sun]
        bar [8500, 9200, 9800, 9500, 8800, 5200, 4100]
        line [3200, 3500, 3800, 3600, 3400, 2100, 1800]
    Rendering…

    Sprint Burndown

    Remaining story points over a two-week sprint.

    xychart-beta
        title "Sprint Burndown"
        x-axis [D1, D2, D3, D4, D5, D6, D7, D8, D9, D10]
        y-axis "Story Points" 0 --> 80
        line [72, 65, 58, 50, 45, 38, 30, 22, 12, 0]
        line [72, 65, 58, 50, 43, 36, 29, 22, 14, 0]
    Rendering…

    Marketing Funnel

    Marketing conversion funnel from impressions to purchases.

    xychart-beta
        title "Marketing Funnel"
        x-axis [Impressions, Clicks, Signups, Trials, Purchases]
        bar [50000, 5000, 1200, 400, 150]
    Rendering…

    Server Error Rates

    HTTP error rates by hour during an incident.

    xychart-beta
        title "Error Rate During Incident"
        x-axis [10am, 11am, 12pm, 1pm, 2pm, 3pm, 4pm, 5pm]
        y-axis "Errors/min" 0 --> 500
        bar [5, 12, 45, 380, 420, 250, 35, 8]
        line [5, 12, 45, 380, 420, 250, 35, 8]
    Rendering…

    Employee Growth

    Headcount growth at a startup over years.

    xychart-beta
        title "Company Headcount"
        x-axis [2018, 2019, 2020, 2021, 2022, 2023, 2024]
        bar [8, 15, 25, 45, 80, 120, 185]
    Rendering…

    Monthly Churn Rate

    Customer churn rate percentage over months.

    xychart-beta
        title "Monthly Churn Rate"
        x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
        y-axis "Churn %" 0 --> 10
        line [5, 4, 5, 4, 3, 3, 4, 3, 3, 2, 2, 2]
    Rendering…

    A/B Test Results

    Conversion rates for control vs variant across segments.

    xychart-beta
        title "A/B Test Conversion Rates"
        x-axis [Mobile, Desktop, Tablet, New Users, Returning]
        bar [3, 5, 4, 2, 6]
        bar [4, 7, 5, 3, 8]
    Rendering…

    Cloud Cost Breakdown

    Monthly cloud infrastructure costs by service.

    xychart-beta
        title "Monthly Cloud Costs"
        x-axis [Compute, Storage, Database, Network, CDN, Monitoring, Other]
        y-axis "USD" 0 --> 20000
        bar [15000, 8000, 6500, 3200, 2800, 1500, 900]
    Rendering…

    Release Frequency

    Number of production deployments per month.

    xychart-beta
        title "Production Deployments per Month"
        x-axis [Jan, Feb, Mar, Apr, May, Jun, Jul, Aug, Sep, Oct, Nov, Dec]
        bar [12, 15, 18, 22, 20, 25, 28, 24, 30, 32, 35, 38]
        line [12, 15, 18, 22, 20, 25, 28, 24, 30, 32, 35, 38]
    Rendering…
    © 2026 Luki Labs. MIT License.
    ================================================ FILE: xychart-test.ts ================================================ /** * Generates xychart-test.html showcasing xychart-beta Mermaid examples. * * Usage: bun run xychart-test.ts * * For each example, renders a 3-column grid: * 1. Shiki-highlighted mermaid source * 2. Chart.js reference rendering * 3. beautiful-mermaid SVG rendering (client-side via bundled renderer) */ import { xychartSamples } from './xychart-samples-data.ts' import { THEMES } from './src/theme.ts' import { createHighlighter } from 'shiki' function escapeHtml(text: string): string { return text .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } function formatDescription(text: string): string { return text.replace(/`([^`]+)`/g, '$1') } const THEME_LABELS: Record = { 'zinc-dark': 'Zinc Dark', 'tokyo-night': 'Tokyo Night', 'tokyo-night-storm': 'Tokyo Storm', 'tokyo-night-light': 'Tokyo Light', 'catppuccin-mocha': 'Catppuccin', 'catppuccin-latte': 'Latte', 'nord': 'Nord', 'nord-light': 'Nord Light', 'dracula': 'Dracula', 'github-light': 'GitHub', 'github-dark': 'GitHub Dark', 'solarized-light': 'Solarized', 'solarized-dark': 'Solar Dark', 'one-dark': 'One Dark', } async function generateHtml(): Promise { const highlighter = await createHighlighter({ langs: ['mermaid'], themes: ['github-light'], }) // Bundle the mermaid renderer for client-side SVG rendering const buildResult = await Bun.build({ entrypoints: [new URL('./src/browser.ts', import.meta.url).pathname], target: 'browser', format: 'esm', minify: true, }) const bundleJs = await buildResult.outputs[0].text() // Group samples by category for TOC const categories = new Map() xychartSamples.forEach((sample, i) => { const cat = sample.category ?? 'Other' if (!categories.has(cat)) categories.set(cat, []) categories.get(cat)!.push(i) }) const tocSections = [...categories.entries()].map(([cat, indices]) => { const items = indices.map(i => { const title = xychartSamples[i]!.title return `
  • ${i + 1}. ${escapeHtml(title)}
  • ` }).join('\n ') return `

    ${escapeHtml(cat)} (${indices.length})

      ${items}
    ` }).join('\n') // Theme pills const VISIBLE_THEMES = new Set(['dracula', 'solarized-light']) function buildThemePill(key: string, colors: { bg: string; fg: string }, active = false): string { const isDark = parseInt(colors.bg.replace('#', '').slice(0, 2), 16) < 0x80 const shadow = isDark ? 'rgba(255,255,255,0.15)' : 'rgba(0,0,0,0.1)' const label = key === '' ? 'Default' : (THEME_LABELS[key] ?? key) const activeClass = active ? ' active' : '' return `` } const themeEntries = Object.entries(THEMES) const visiblePills = [ '', ...themeEntries .filter(([key]) => VISIBLE_THEMES.has(key)) .map(([key, colors]) => buildThemePill(key, colors)), ] const allDropdownPills = [ buildThemePill('', { bg: '#FFFFFF', fg: '#27272A' }, true), ...themeEntries.map(([key, colors]) => buildThemePill(key, colors)), ] const totalThemes = allDropdownPills.length const themePillsHtml = `
    ${visiblePills.join('\n ')}
    ${allDropdownPills.join('\n ')}
    ` // Pre-highlight sources with Shiki const highlightedSources = xychartSamples.map(sample => { const fenced = '```mermaid\n' + sample.source.trim() + '\n```' const html = highlighter.codeToHtml(fenced, { lang: 'mermaid', theme: 'github-light', }) return html.replace( /().*?<\/span>\n/, '$1' ).replace( /\n.*?<\/span>(<\/code>)/, '$1' ) }) // Build sample cards grouped by category let currentCategory = '' const sampleCards = xychartSamples.map((sample, i) => { const cat = sample.category ?? 'Other' let sectionHeader = '' if (cat !== currentCategory) { currentCategory = cat sectionHeader = `\n

    ${escapeHtml(cat)}

    \n` } return `${sectionHeader}

    ${escapeHtml(sample.title)}

    ${formatDescription(sample.description)}

    ${highlightedSources[i]}
    Rendering…
    ` }).join('\n') // Build THEMES JSON for client-side use const themesJson = JSON.stringify(THEMES) // Build sources JSON for the parser const sourcesJson = JSON.stringify(xychartSamples.map(s => s.source)) return ` XY Chart Test — Beautiful Mermaid
    XY Chart Test
    ${themePillsHtml}
    ${tocSections}

    XY Chart Test

    xychart-beta rendering comparison: Chart.js vs beautiful-mermaid

    Column 1 shows the Mermaid source, Column 2 renders a Chart.js reference, and Column 3 shows the beautiful-mermaid SVG rendering with theme support.

    ${xychartSamples.length} xychart-beta examples across ${categories.size} categories

    Chart.js reference + beautiful-mermaid SVG comparison

    ${sampleCards}
    © 2026 Luki Labs. MIT License.
    ` } const html = await generateHtml() const outPath = new URL('./xychart-test.html', import.meta.url).pathname await Bun.write(outPath, html) console.log(`Written to ${outPath} (${(html.length / 1024).toFixed(1)} KB)`)