← Back to project
● Shipped P1 Size M Developer tool

Diagram-Engine — Implementation

DSL grammar, layout rule catalog, token budgeting, performance numbers, and reproducibility steps.

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

LayerComponentVersionNotes
LanguageTypeScript5.xstrict mode, no any in the engine modules
Parserhand-written recursive descent~700 LOC; emits AST with source positions for every node
Validatorhand-writtenstructured errors {kind, token, line, col, expected, example}
Layoutcustom (integer-coord)~1,400 LOC across R1–R37
Rendererhand-written SVG emitter~600 LOC; one function per shape
SiteAstro4.xstatic export, no SSR
HostingCloudflare Pagesfree tier; auto-deploy on push to main
PreviewCloudflare Tunnel2026.xnamed tunnel for ephemeral preview URLs
TestsVitest + snapshot SVGbyte-comparison snapshots gate determinism
Evalheld-out 50-prompt LLM setruns 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 -> api auto-declares both as default rect.
  • 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.

RangeCategoryExamples
R1–R10StructuralR1: 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–R20SpatialR12: 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–R30SemanticR23: 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–R37VisualR31: 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:

OperationTime
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

FeatureHow
Byte-deterministic outputInteger coordinates everywhere; snapshot tests gate every commit
One-shot repairable errorsStructured ValidationError with named token + expected shape + working example
Zero backend on hot pathClient-side bundle; no cold start, no per-render cost
No runtime dependenciesOutput SVG references nothing external (no fonts, no remote icons)
Locked grammarNo plugins, no dialects; backward-compat policy required before any addition
URL-fragment sharingPlayground encodes source into URL fragment; no DB needed

Security model

ThreatMitigation
XSS via user-pasted custom-icon SVGRender 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 URLURL-fragment-only sharing; never sent to server
Bundle tampering on CDNCF 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/core once 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).