Implementation
Sister docs: PRD (intent), Architecture (system view), Notes (decision log).
TL;DR
A production-ready text-to-diagram engine built over 11 phases (~6 weeks part-time):
- Hand-rolled TypeScript parser + custom layout engine + SVG renderer, shipped as one client-side bundle (~80 KB gzipped)
- 37 layout rules across structural / spatial / semantic / visual categories
- 12 base shapes (rect, round-rect, cylinder, queue, cloud, actor, lifeline, diamond, hex, ellipse, doc, comment)
- Custom inline-SVG icon support (P10) — drop an
<svg>block, reference it as a shape - 4-section docs (syntax / examples / layout rules / API) hosted on a public Astro playground
- Deployed to Cloudflare Pages from a private git repo; preview branches via Cloudflare Tunnel
- 94% first-shot LLM render success / 180 median tokens / 120 ms p50 render / byte-deterministic output
Stack
| Layer | Component | Version | Notes |
|---|---|---|---|
| Language | TypeScript | 5.x | strict mode, no any in the engine modules |
| Parser | hand-written recursive descent | — | ~700 LOC; emits AST with source positions for every node |
| Validator | hand-written | — | structured errors {kind, token, line, col, expected, example} |
| Layout | custom (integer-coord) | — | ~1,400 LOC across R1–R37 |
| Renderer | hand-written SVG emitter | — | ~600 LOC; one function per shape |
| Site | Astro | 4.x | static export, no SSR |
| Hosting | Cloudflare Pages | — | free tier; auto-deploy on push to main |
| Preview | Cloudflare Tunnel | 2026.x | named tunnel for ephemeral preview URLs |
| Tests | Vitest + snapshot SVG | — | byte-comparison snapshots gate determinism |
| Eval | held-out 50-prompt LLM set | — | runs locally on demand, not in CI |
Directory layout
packages/
├── core/ # engine bundle (parser + validator + layout + renderer)
│ ├── lexer.ts
│ ├── parser.ts # ~700 LOC recursive descent
│ ├── ast.ts # AST type definitions
│ ├── validator.ts # named-token error catalog
│ ├── layout/
│ │ ├── index.ts # orchestrator
│ │ ├── structural.ts # R1–R10
│ │ ├── spatial.ts # R11–R20
│ │ ├── semantic.ts # R21–R30
│ │ └── visual.ts # R31–R37
│ ├── renderer/
│ │ ├── index.ts
│ │ ├── shapes.ts # 12 base shapes
│ │ ├── edges.ts # edge routing + label placement
│ │ └── icons.ts # custom inline-SVG icon support (P10)
│ └── index.ts # public API: parse(), validate(), render()
│
├── playground/ # Astro site
│ ├── src/
│ │ ├── pages/
│ │ │ ├── index.astro # playground (textarea + live preview)
│ │ │ └── docs/
│ │ │ ├── syntax.astro
│ │ │ ├── examples.astro
│ │ │ ├── layout-rules.astro
│ │ │ └── api.astro
│ │ └── components/
│ │ ├── Editor.tsx # textarea + URL-fragment sync
│ │ └── Preview.tsx # live SVG render
│ └── astro.config.mjs
│
└── tests/
├── parser.test.ts
├── validator.test.ts
├── layout.test.ts
├── renderer.snapshot.test.ts # byte-comparison snapshots
└── eval/
├── prompts.json # 50 held-out LLM eval prompts
└── run-eval.ts # local eval runner
DSL grammar (informal)
graph := directive* statement* EOF
directive := "direction" ("LR" | "TB" | "RL" | "BT")
| "title" STRING
statement := node_decl | edge | cluster | icon_decl
node_decl := IDENT ("[" shape "]")? (":" label)?
shape := "rect" | "round" | "cyl" | "queue" | "cloud"
| "actor" | "lifeline" | "diamond" | "hex"
| "ellipse" | "doc" | "comment"
| IDENT // custom icon ref (from icon_decl)
edge := IDENT edge_op IDENT (":" label)?
edge_op := "->" | "<-" | "--" | "==>" | "-.->"
cluster := "group" IDENT "{" statement* "}"
icon_decl := "icon" IDENT "{" SVG_BLOB "}"
Compact by design:
- Default direction is
LR— no need to declare for 80% of diagrams. - Implicit node declaration —
web -> apiauto-declares both as defaultrect. - Single edge operator (
->) covers the most common case; specialized operators added only where the visual distinction matters.
Example: 4-service architecture
direction: LR
web -> api -> postgres
api -> cache
Rendered: 4 nodes, 3 edges, auto-layout. 21 source bytes / 8 tokens.
For comparison, the Mermaid equivalent:
graph LR
A[Web] --> B[API]
B --> C[(Postgres)]
B --> D[Cache]
67 bytes / 21 tokens. Same diagram, ~2.6× the tokens.
Example: OAuth PKCE sequence
direction: TB
title "OAuth 2.0 PKCE"
client [actor] -> auth_server: GET /authorize?code_challenge=...
auth_server -> user [actor]: login prompt
user -> auth_server: credentials
auth_server -> client: 302 redirect_uri?code=...
client -> auth_server: POST /token (code + verifier)
auth_server -> client: { access_token, refresh_token }
The 37 layout rules
The hard part wasn’t the parser. It was producing a good layout on the first try, every time, because the LLM author can’t see the output to iterate.
| Range | Category | Examples |
|---|---|---|
| R1–R10 | Structural | R1: respect declared direction · R7: nodes connected by ≥3 edges form an implicit cluster · R10: cycles are broken by reversing the lowest-weight edge for layout, marked dashed in render |
| R11–R20 | Spatial | R12: vertical lanes inherit width from widest child + 24px padding · R15: minimum 16px gap between sibling nodes · R19: edge labels never overlap nodes — route around or shorten |
| R21–R30 | Semantic | R23: nodes with the same shape align on the cross-axis · R27: edges into the same node fan in symmetrically · R30: cluster headers sit centered above the cluster bounds |
| R31–R37 | Visual | R31: rect corner radius = 6px · R32: stroke weight = 1.5px · R33: icon padding = 12px on all sides · R34: label font-size 14px / line-height 1.4 · R35–R37: color tokens, no theme overrides |
Determinism is enforced rule-by-rule: every rule operates on integer coordinates only. No Math.round() at render time, no float accumulation. This is what guarantees byte-identical output across browsers and runs.
Validator error model
Every validator error has four fields the LLM can act on:
type ValidationError = {
kind:
| "undeclared_reference"
| "duplicate_node_conflicting_shape"
| "unknown_shape"
| "malformed_edge"
| "cluster_unclosed"
| "icon_undefined"
| "icon_invalid_svg"
| "direction_unknown";
token: string; // the offending source token
line: number;
col: number;
expected: string; // human/LLM-readable expectation
example: string; // a minimal working snippet that demonstrates the fix
};
Example for an undeclared reference:
{
"kind": "undeclared_reference",
"token": "ap1",
"line": 1,
"col": 8,
"expected": "an identifier matching a previously seen node, or a new node declaration",
"example": "web -> api # if you meant 'api', spell it consistently"
}
Why this shape: the LLM’s repair prompt becomes deterministic — the error already contains the candidate fix. On the held-out eval, 88% of errors are repaired in a single retry.
Custom-icon support (P10)
Late add. Users wanted to drop a brand logo or a specific SVG shape as a node. The constraint: don’t break determinism, don’t introduce a network fetch, don’t add a plugin surface.
icon postgres_logo {
<svg viewBox="0 0 24 24"><path d="..."/></svg>
}
api [round] -> db [postgres_logo]
The renderer inlines the user’s SVG into a <defs> block and references it via <use>. The icon participates in layout via R33 (12 px padding all sides). No theming, no fetch, no plugin.
Performance numbers
Measured in a modern browser on commodity hardware:
| Operation | Time |
|---|---|
| Parse 200-token source | ~1 ms |
| Validate AST | <1 ms |
| Layout 10-node diagram | ~20 ms |
| Render SVG (10 nodes, 12 edges) | ~5 ms |
| End-to-end p50 (textarea keypress → SVG in DOM) | 120 ms |
| End-to-end p95 | ~210 ms |
| Engine bundle size (gzipped) | ~80 KB |
For larger diagrams (50+ nodes), layout dominates and scales roughly linearly with edge count.
Eval methodology
A 50-prompt held-out set covers the three diagram families:
- 20 architecture (services, queues, databases, caches)
- 15 sequence (auth flows, request lifecycles, error handling)
- 15 state (form wizards, deployment pipelines, finite state machines)
Each prompt is given to a frontier LLM with the Diagram-Engine syntax cheatsheet in the system prompt. The emitted source is run through the engine. Two outcomes are recorded:
- First-shot render success = parses, validates, and produces a visually correct SVG (judged by holdout).
- Repair-on-one-retry = if first attempt fails validation, the LLM is given the structured error and asked to retry once.
Results:
- First-shot render success: 94%
- Repair on one retry: 88% of the remaining 6%
Reliability features
| Feature | How |
|---|---|
| Byte-deterministic output | Integer coordinates everywhere; snapshot tests gate every commit |
| One-shot repairable errors | Structured ValidationError with named token + expected shape + working example |
| Zero backend on hot path | Client-side bundle; no cold start, no per-render cost |
| No runtime dependencies | Output SVG references nothing external (no fonts, no remote icons) |
| Locked grammar | No plugins, no dialects; backward-compat policy required before any addition |
| URL-fragment sharing | Playground encodes source into URL fragment; no DB needed |
Security model
| Threat | Mitigation |
|---|---|
| XSS via user-pasted custom-icon SVG | Render only in the pasting user’s own browser session (no shared host); M1 only. M2 web component will need a proper SVG sanitizer. |
| Source exfil via URL | URL-fragment-only sharing; never sent to server |
| Bundle tampering on CDN | CF Pages serves over TLS; SRI tags on script imports |
| Validator denial-of-service (pathological input) | Parser has a hard cap on token count (5,000); layout caps node count (500) |
Reproducibility — quickstart for a forker
# 1. Clone the repo (private; M2 will open-source under MIT)
git clone <repo-url> diagram-engine
cd diagram-engine
# 2. Install
pnpm install
# 3. Run the engine tests (parser + validator + layout + snapshot)
pnpm test
# 4. Run the local LLM eval (requires an API key for your LLM of choice)
ANTHROPIC_API_KEY=... pnpm run eval
# 5. Boot the playground locally
pnpm --filter playground dev
# 6. Build for deploy
pnpm --filter playground build # → packages/playground/dist
# deploy ./dist to Cloudflare Pages, or any static host
Total: 5 minutes to a local playground.
Future work (M2+)
- npm publish as
@diagram-engine/coreonce a downstream consumer exists. - Embeddable web component
<diagram-engine>for blog posts and docs sites. - VS Code extension with inline preview on markdown code blocks.
- Claude skill that auto-emits Diagram-Engine source for architecture/sequence prompts.
- ERD shape family (entities, attributes, FK lines) — would reopen the parser; needs a backward-compat policy first.
- Diff mode — render the visual diff between two source versions, for design reviews.
License & attribution
Personal project. Built on:
- TypeScript / Astro / Cloudflare Pages
- No third-party layout libraries (custom engine)
- No third-party parser generators (hand-written recursive descent)
License decision deferred to M2 (likely MIT once the npm package ships).