feat: add Diagrams Playground web editor (#1240)

A browser-based playground for the diagrams library: write Python, see
the rendered diagram instantly. Runs the real diagrams package
client-side via Pyodide (WASM) — fully static, no backend.

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
pull/1241/head
MinJae Kwon 4 weeks ago committed by GitHub
parent 21151309da
commit a13ce09940
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194

@ -0,0 +1,78 @@
name: Deploy Static Site
# Single source of truth for the gh-pages branch (served at
# diagrams.mingrammer.com). Builds BOTH the Docusaurus docs and the
# playground, assembles them into one tree (docs at the root, playground
# under /playground/), and deploys the whole thing. Because everything is
# rebuilt and published together, the docs and the playground can never
# overwrite each other — this replaces the old manual `website/publish.sh`
# and the separate playground deploy.
#
# Safety: the deploy step only runs if every build step succeeds, so a failed
# build leaves the live gh-pages branch untouched.
on:
push:
branches: [master]
paths:
- "website/**"
- "playground/**"
- "diagrams/**"
- "resources/**"
- ".github/workflows/deploy-site.yml"
workflow_dispatch:
permissions:
contents: write
concurrency:
group: deploy-site
cancel-in-progress: true
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-node@v4
with:
node-version: "20"
- uses: actions/setup-python@v5
with:
python-version: "3.12"
# --- Docs (Docusaurus v1) -> website/build/diagrams/ (includes CNAME) ---
- name: Build docs
working-directory: website
env:
# Docusaurus 1.x uses a legacy webpack/OpenSSL path that needs this
# on Node 17+.
NODE_OPTIONS: --openssl-legacy-provider
run: |
npm install --no-audit --no-fund
npm run build
# --- Playground (Pyodide assets + Vite) -> playground/dist/ ---
- name: Install diagrams (for asset generation)
run: pip install .
- name: Build playground
run: |
cd playground && npm ci && cd ..
python3 playground/scripts/gen_catalog.py --repo-root . --out playground/public
cd playground && npm run build
# --- Assemble the combined site ---
- name: Assemble site
run: |
rm -rf _site
cp -a website/build/diagrams _site
mkdir -p _site/playground
cp -a playground/dist/. _site/playground/
test -f _site/CNAME || echo "diagrams.mingrammer.com" > _site/CNAME
- name: Deploy to gh-pages
uses: peaceiris/actions-gh-pages@v4
with:
github_token: ${{ secrets.GITHUB_TOKEN }}
publish_dir: _site
force_orphan: true

@ -0,0 +1,38 @@
name: Playground
on:
pull_request:
paths:
- "playground/**"
- "diagrams/**"
- "resources/**"
- ".github/workflows/playground.yml"
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-python@v5
with:
python-version: "3.12"
- uses: actions/setup-node@v4
with:
node-version: "20"
- name: Install python deps
run: pip install . pytest
- name: Python tests (catalog + shim)
run: python3 -m pytest playground/scripts/ -v
- name: Generate assets
run: python3 playground/scripts/gen_catalog.py --repo-root . --out playground/public
- name: npm install & unit tests & build
working-directory: playground
run: |
npm ci
npm test
npm run build
- name: E2E smoke
working-directory: playground
run: |
npx playwright install chromium --with-deps
npm run e2e

@ -2,4 +2,4 @@
line_length = 120
multi_line_output = 3
include_trailing_comma = True
known_third_party = graphviz,jinja2
known_third_party = graphviz,jinja2,pytest

@ -41,6 +41,8 @@ Diagrams lets you draw the cloud system architecture **in Python code**. It was
## Getting Started
> Want to try it first? The [**Playground**](https://diagrams.mingrammer.com/playground/) runs **diagrams** right in your browser — no installation required.
It requires **Python 3.9** or higher, check your Python version first.
It uses [Graphviz](https://www.graphviz.org/) to render the diagram, so you need to [install Graphviz](https://graphviz.gitlab.io/download/) to use **diagrams**. After installing graphviz (or already have it), install the **diagrams**.
@ -60,6 +62,12 @@ $ poetry add diagrams
You can start with [quick start](https://diagrams.mingrammer.com/docs/getting-started/installation#quick-start). Check out [guides](https://diagrams.mingrammer.com/docs/guides/diagram) for more details, and you can find all available nodes list in [here](https://diagrams.mingrammer.com/docs/nodes/aws).
## Playground
[**diagrams.mingrammer.com/playground**](https://diagrams.mingrammer.com/playground/)
Write **diagrams** code and see the rendered diagram instantly, without installing anything. It runs the real **diagrams** package in your browser via [Pyodide](https://pyodide.org), and supports node search, autocompletion, PNG/SVG/JPEG export, and shareable links.
## Examples
| Event Processing | Stateful Architecture | Advanced Web Service |

@ -3,6 +3,8 @@ id: installation
title: Installation
---
> Prefer to try it without installing? The [**Playground**](/playground/) runs **diagrams** in your browser.
**diagrams** requires **Python 3.7** or higher, check your Python version first.
**diagrams** uses [Graphviz](https://www.graphviz.org/) to render the diagram, so you need to [install Graphviz](https://graphviz.gitlab.io/download/) to use it.

@ -0,0 +1,11 @@
node_modules/
dist/
public/icons/
public/wheels/
public/catalog.json
test-results/
playwright-report/
*.tsbuildinfo
vite.config.js
vite.config.d.ts
.omc/

@ -0,0 +1,57 @@
import { expect, test } from "@playwright/test";
test.beforeEach(async ({ page }) => {
await page.goto("/");
// wait for Pyodide init + the default example to finish rendering
await expect(page.getByTestId("preview").locator("svg")).toBeVisible({ timeout: 150_000 });
});
test("renders the default example with icons", async ({ page }) => {
const preview = page.getByTestId("preview");
await expect(preview.locator("svg image").first()).toHaveAttribute("xlink:href", /icons\/aws\//);
});
test("autocompletes EC2 from the aws compute module", async ({ page }) => {
const editor = page.getByTestId("editor").locator(".cm-content");
await editor.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.type("from diagrams.aws.compute import EC2A");
await expect(page.locator(".cm-tooltip-autocomplete")).toContainText("EC2AutoScaling", { timeout: 10_000 });
});
test("share link roundtrips code", async ({ page, context }) => {
const editor = page.getByTestId("editor").locator(".cm-content");
await editor.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.type('from diagrams import Diagram\nwith Diagram("Shared", show=False):\n pass');
await page.getByTestId("share-button").click();
await expect(page.getByTestId("share-button")).toContainText("Link copied!");
const url = page.url();
expect(url).toContain("#code=");
const second = await context.newPage();
await second.goto(url);
await expect(second.getByTestId("editor")).toContainText("Shared", { timeout: 150_000 });
});
test("python errors keep the previous preview", async ({ page }) => {
const editor = page.getByTestId("editor").locator(".cm-content");
await editor.click();
await page.keyboard.press("ControlOrMeta+a");
await page.keyboard.type("1/0");
await expect(page.getByTestId("error-panel")).toContainText("ZeroDivisionError", { timeout: 30_000 });
await expect(page.getByTestId("preview").locator("svg")).toBeVisible();
});
test("exports PNG", async ({ page }) => {
const downloadPromise = page.waitForEvent("download");
// Background White is the ExportBar's default; WIDTH/HEIGHT left as
// "auto" fall back to the default-2x output size.
await page.getByRole("button", { name: "PNG" }).click();
const download = await downloadPromise;
expect(download.suggestedFilename()).toMatch(/\.png$/);
});
test("copies image to clipboard", async ({ page }) => {
await page.getByRole("button", { name: "Copy Image" }).click();
await expect(page.getByText("Copied!")).toBeVisible();
});

@ -0,0 +1,31 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Diagrams Playground</title>
<!-- Real diagrams project logo; relative href survives sub-path deploys. -->
<link rel="icon" type="image/png" href="diagrams-logo.png" />
<link rel="preconnect" href="https://fonts.googleapis.com" />
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
<link
href="https://fonts.googleapis.com/css2?family=IBM+Plex+Mono:ital,wght@0,400;0,500;0,600;1,400&family=Inter:wght@400;500;600&display=swap"
rel="stylesheet"
/>
<!-- Google Analytics (GA4) — same property as the docs site, so the
playground shows up alongside diagrams.mingrammer.com traffic. -->
<script async src="https://www.googletagmanager.com/gtag/js?id=G-Y1TWCZ0L77"></script>
<script>
window.dataLayer = window.dataLayer || [];
function gtag() {
dataLayer.push(arguments);
}
gtag("js", new Date());
gtag("config", "G-Y1TWCZ0L77");
</script>
</head>
<body>
<div id="root"></div>
<script type="module" src="./src/main.tsx"></script>
</body>
</html>

File diff suppressed because it is too large Load Diff

@ -0,0 +1,41 @@
{
"name": "diagrams-playground",
"private": true,
"version": "0.1.0",
"type": "module",
"scripts": {
"assets": "cd .. && python3 playground/scripts/gen_catalog.py --repo-root . --out playground/public",
"dev": "vite",
"build": "tsc -b && vite build",
"preview": "vite preview --port 4173",
"test": "vitest run",
"e2e": "playwright test"
},
"dependencies": {
"@codemirror/autocomplete": "^6.18.0",
"@codemirror/commands": "^6.10.4",
"@codemirror/lang-python": "^6.1.6",
"@codemirror/language": "^6.12.4",
"@codemirror/state": "^6.4.1",
"@codemirror/view": "^6.34.0",
"@lezer/highlight": "^1.2.3",
"@hpcc-js/wasm-graphviz": "^1.7.0",
"codemirror": "^6.0.1",
"dompurify": "^3.1.6",
"pako": "^2.1.0",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@playwright/test": "^1.47.0",
"@testing-library/react": "^16.0.1",
"@types/pako": "^2.0.3",
"@types/react": "^18.3.5",
"@types/react-dom": "^18.3.0",
"@vitejs/plugin-react": "^4.3.1",
"jsdom": "^25.0.0",
"typescript": "~5.6.2",
"vite": "^5.4.3",
"vitest": "^2.1.0"
}
}

@ -0,0 +1,27 @@
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "e2e",
timeout: 180_000, // pyodide 초기 로드 포함
// Conservative default: each spec cold-loads Pyodide (runtime + wheel
// install) in its own context, so parallel workers mean simultaneous
// large downloads/WASM boots competing for CPU and network — a known
// flakiness source on constrained CI runners, though parallel runs do
// pass locally. Serial trades wall-clock time for determinism.
fullyParallel: false,
workers: 1,
use: {
baseURL: "http://localhost:4173",
// Headless Chromium does not grant clipboard-write by default, so
// navigator.clipboard.writeText() rejects with NotAllowedError unless
// explicitly granted here (needed for the "share link" test's
// writeText().then(...) success path).
permissions: ["clipboard-read", "clipboard-write"],
},
webServer: {
command: "npm run preview",
port: 4173,
reuseExistingServer: true,
timeout: 30_000,
},
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 5.2 KiB

@ -0,0 +1,120 @@
"""Generate playground build assets from the diagrams package.
Outputs (under --out):
catalog.json node classes / aliases / icons / constructor signatures
icons/** copy of resources/ for the preview <image> tags
wheels/*.whl slim diagrams wheel (resources stripped; not needed at
runtime because Node._load_icon only builds path strings)
wheels/manifest.json {"wheel": "<filename>"} for the worker to locate it
"""
import argparse
import importlib
import inspect
import json
import pkgutil
import shutil
import subprocess
import sys
import tempfile
import zipfile
from pathlib import Path
def build_catalog(repo_root: Path) -> dict:
sys.path.insert(0, str(repo_root))
import diagrams
from diagrams import Cluster, Diagram, Edge, Node
modules = {}
for info in pkgutil.walk_packages([str(repo_root / "diagrams")], prefix="diagrams."):
if any(part.startswith("_") for part in info.name.split(".")):
continue
mod = importlib.import_module(info.name)
classes, aliases = {}, {}
for attr, val in vars(mod).items():
if attr.startswith("_") or not inspect.isclass(val):
continue
if not issubclass(val, Node) or val.__module__ != info.name:
continue
if getattr(val, "_icon", None) is None:
continue
# Check if icon file exists on disk
icon_rel = "/".join([*Path(val._icon_dir).parts[1:], val._icon])
if not (repo_root / "resources" / icon_rel).exists():
print(
f"warning: skipping {info.name}.{val.__name__} — missing icon resources/{icon_rel}", file=sys.stderr
)
continue
if attr == val.__name__:
classes[attr] = val
else: # module-level alias assignment (e.g. ECS = ElasticContainerService)
aliases.setdefault(val.__name__, []).append(attr)
if classes:
modules[info.name] = [
{
"name": name,
"aliases": sorted(aliases.get(name, [])),
# _icon_dir is "resources/aws/compute" — strip leading segment
"icon": "/".join([*Path(cls._icon_dir).parts[1:], cls._icon]),
}
for name, cls in sorted(classes.items())
]
def signature_params(fn) -> list:
return [str(p) for p in list(inspect.signature(fn).parameters.values())[1:]]
return {
"modules": modules,
"signatures": {
"Diagram": signature_params(Diagram.__init__),
"Cluster": signature_params(Cluster.__init__),
"Edge": signature_params(Edge.__init__),
},
}
def build_slim_wheel(repo_root: Path, out_dir: Path) -> Path:
out_dir.mkdir(parents=True, exist_ok=True)
with tempfile.TemporaryDirectory() as tmp:
subprocess.run(
[sys.executable, "-m", "pip", "wheel", "--no-deps", "-w", tmp, str(repo_root)],
check=True,
)
src = next(Path(tmp).glob("diagrams-*.whl"))
dst = out_dir / src.name
with zipfile.ZipFile(src) as zin, zipfile.ZipFile(dst, "w", zipfile.ZIP_DEFLATED) as zout:
for item in zin.infolist():
if item.filename.startswith("resources/"):
continue
data = zin.read(item.filename)
if item.filename.endswith(".dist-info/RECORD"):
lines = [l for l in data.decode().splitlines() if not l.startswith("resources/")]
data = ("\n".join(lines) + "\n").encode()
zout.writestr(item, data)
return dst
def main() -> None:
parser = argparse.ArgumentParser()
parser.add_argument("--repo-root", type=Path, required=True)
parser.add_argument("--out", type=Path, required=True)
args = parser.parse_args()
repo_root, out = args.repo_root.resolve(), args.out.resolve()
catalog = build_catalog(repo_root)
out.mkdir(parents=True, exist_ok=True)
(out / "catalog.json").write_text(json.dumps(catalog))
print(f"catalog.json: {sum(len(v) for v in catalog['modules'].values())} classes")
icons_dir = out / "icons"
shutil.copytree(repo_root / "resources", icons_dir, dirs_exist_ok=True)
print(f"icons: copied to {icons_dir}")
wheel = build_slim_wheel(repo_root, out / "wheels")
(out / "wheels" / "manifest.json").write_text(json.dumps({"wheel": wheel.name}))
print(f"wheel: {wheel.name} ({wheel.stat().st_size // 1024} KiB)")
if __name__ == "__main__":
main()

@ -0,0 +1,60 @@
import importlib.util
import zipfile
from pathlib import Path
# Load the sibling gen_catalog.py by file path rather than a bare
# `from gen_catalog import ...` after a sys.path hack — the latter forces an
# import that isort keeps reordering above the path setup (breaking it) and
# that seed-isort-config misclassifies as third-party.
_spec = importlib.util.spec_from_file_location("gen_catalog", Path(__file__).parent / "gen_catalog.py")
gen_catalog = importlib.util.module_from_spec(_spec)
_spec.loader.exec_module(gen_catalog)
build_catalog = gen_catalog.build_catalog
build_slim_wheel = gen_catalog.build_slim_wheel
REPO_ROOT = Path(__file__).resolve().parents[2]
def test_catalog_contains_ec2_with_icon():
catalog = build_catalog(REPO_ROOT)
compute = catalog["modules"]["diagrams.aws.compute"]
ec2 = next(c for c in compute if c["name"] == "EC2")
assert ec2["icon"] == "aws/compute/ec2.png"
assert (REPO_ROOT / "resources" / ec2["icon"]).exists()
def test_catalog_records_aliases():
catalog = build_catalog(REPO_ROOT)
compute = catalog["modules"]["diagrams.aws.compute"]
ecs = next(c for c in compute if c["name"] == "ElasticContainerService")
assert "ECS" in ecs["aliases"]
def test_catalog_signatures_have_core_classes():
catalog = build_catalog(REPO_ROOT)
assert any(p.startswith("name") for p in catalog["signatures"]["Diagram"])
assert any(p.startswith("label") for p in catalog["signatures"]["Cluster"])
assert any(p.startswith("forward") for p in catalog["signatures"]["Edge"])
def test_all_catalog_icons_exist_on_disk():
catalog = build_catalog(REPO_ROOT)
missing = [
c["icon"]
for classes in catalog["modules"].values()
for c in classes
if not (REPO_ROOT / "resources" / c["icon"]).exists()
]
assert missing == []
def test_slim_wheel_has_no_resources(tmp_path):
wheel = build_slim_wheel(REPO_ROOT, tmp_path)
with zipfile.ZipFile(wheel) as zf:
names = zf.namelist()
assert not [n for n in names if n.startswith("resources/")]
assert [n for n in names if n.startswith("diagrams/")]
record = next(n for n in names if n.endswith(".dist-info/RECORD"))
record_body = zf.read(record).decode()
assert "resources/" not in record_body
assert wheel.stat().st_size < 3_000_000 # confirms the 38MB resources are stripped

@ -0,0 +1,128 @@
import json
import sys
from pathlib import Path
import pytest
REPO_ROOT = Path(__file__).resolve().parents[2]
SHIM = REPO_ROOT / "playground" / "src" / "worker" / "shim.py"
sys.path.insert(0, str(REPO_ROOT))
# Fixture to restore json module after each test
@pytest.fixture(autouse=True)
def restore_json_module():
import json.encoder as je
original_dumps = json.dumps
original_encode_basestring_ascii = je.encode_basestring_ascii
original_encode_basestring = je.encode_basestring
original_c_encode_basestring_ascii = je.c_encode_basestring_ascii
original_c_make_encoder = je.c_make_encoder
yield
json.dumps = original_dumps
je.encode_basestring_ascii = original_encode_basestring_ascii
je.encode_basestring = original_encode_basestring
je.c_encode_basestring_ascii = original_c_encode_basestring_ascii
je.c_make_encoder = original_c_make_encoder
namespace = {}
exec(compile(SHIM.read_text(), str(SHIM), "exec"), namespace)
run_user_code = namespace["run_user_code"]
SAMPLE = """
from diagrams import Diagram
from diagrams.aws.compute import EC2
from diagrams.aws.network import ELB
with Diagram("Web Service", show=False):
ELB("lb") >> EC2("web")
"""
def test_captures_dot_source():
result = json.loads(run_user_code(SAMPLE))
assert result["error"] is None
assert len(result["dots"]) == 1
assert result["dots"][0]["name"] == "Web Service"
assert "elastic-load-balancing.png" in result["dots"][0]["source"]
assert "digraph" in result["dots"][0]["source"]
def test_no_output_files_written(tmp_path, monkeypatch):
monkeypatch.chdir(tmp_path)
json.loads(run_user_code(SAMPLE))
assert list(tmp_path.iterdir()) == []
def test_captures_multiple_diagrams():
code = SAMPLE + '\nwith Diagram("Second", show=False):\n EC2("solo")\n'
result = json.loads(run_user_code(code))
assert [d["name"] for d in result["dots"]] == ["Web Service", "Second"]
def test_explicit_render_call_not_duplicated():
code = """
from diagrams import Diagram
from diagrams.aws.compute import EC2
with Diagram("D", show=False) as d:
EC2("a")
d.render()
"""
result = json.loads(run_user_code(code))
assert len(result["dots"]) == 1
def test_error_returns_clean_traceback():
result = json.loads(run_user_code("from diagrams import Diagram\n1/0\n"))
assert result["dots"] == []
assert "ZeroDivisionError" in result["error"]
assert "line 2" in result["error"]
assert "shim.py" not in result["error"]
def test_stdout_captured():
result = json.loads(run_user_code('print("hello")'))
assert result["stdout"] == "hello\n"
def test_exception_inside_diagram_block_not_captured():
code = """
from diagrams import Diagram
from diagrams.aws.compute import EC2
try:
with Diagram("Broken", show=False):
EC2("a")
raise RuntimeError("boom")
except RuntimeError:
pass
with Diagram("After", show=False):
EC2("b")
"""
result = json.loads(run_user_code(code))
assert [d["name"] for d in result["dots"]] == ["After"]
assert result["error"] is None
def test_json_sabotage_still_returns_json():
code = "import json\njson.dumps = None\nprint('ok')"
result = json.loads(run_user_code(code))
assert result["error"] is None
assert result["stdout"] == "ok\n"
def test_json_encoder_sabotage_still_returns_json():
code = """
import json.encoder as je
def evil(*a, **k):
raise RuntimeError("pwned")
je.encode_basestring_ascii = evil
je.encode_basestring = evil
je.c_encode_basestring_ascii = None
je.c_make_encoder = None
"""
result = json.loads(run_user_code(code))
assert result["dots"] == []
assert "Internal error serializing result" in result["error"]
assert "pwned" in result["error"]

@ -0,0 +1,241 @@
import { autocompletion } from "@codemirror/autocomplete";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import DragHandle from "./components/DragHandle";
import EditorPane from "./components/EditorPane";
import ErrorPanel from "./components/ErrorPanel";
import ExamplesGallery from "./components/ExamplesGallery";
import ExportBar from "./components/ExportBar";
import NodeSearch from "./components/NodeSearch";
import PreviewPane from "./components/PreviewPane";
import Toolbar from "./components/Toolbar";
import { diagramsCompletions } from "./completions/imports";
import { signatureTooltip } from "./completions/signature";
import { DEFAULT_CODE, EXAMPLES } from "./examples";
import { renderDot } from "./renderer/render";
import { decodeShare, encodeShare } from "./share/codec";
import type { Catalog } from "./types";
import { debounce } from "./utils/debounce";
import { clampRatio, clampSidebarWidth, DEFAULT_RATIO, DEFAULT_SIDEBAR_WIDTH } from "./utils/layout";
import { renameDiagramInCode } from "./utils/rename";
import { initTheme } from "./utils/theme";
import { PyClient, SupersededError, TimeoutError } from "./worker/client";
const SPLIT_STORAGE_KEY = "dgp-split";
const SIDEBAR_STORAGE_KEY = "dgp-sidebar";
function loadStoredRatio(): number {
const stored = Number(localStorage.getItem(SPLIT_STORAGE_KEY));
return clampRatio(Number.isFinite(stored) && stored !== 0 ? stored : DEFAULT_RATIO);
}
function loadStoredSidebarWidth(): number {
const stored = Number(localStorage.getItem(SIDEBAR_STORAGE_KEY));
return clampSidebarWidth(Number.isFinite(stored) && stored !== 0 ? stored : DEFAULT_SIDEBAR_WIDTH);
}
const STATUS_BY_STAGE: Record<string, string> = {
pyodide: "Loading Python runtime… (first visit only)",
packages: "Installing diagrams package…",
ready: "Ready",
};
export default function App() {
const clientRef = useRef<PyClient>();
const replaceCodeRef = useRef<(code: string) => void>();
const codeRef = useRef(decodeShare(window.location.hash) ?? DEFAULT_CODE);
const [catalog, setCatalog] = useState<Catalog | null>(null);
const [catalogError, setCatalogError] = useState<string | null>(null);
const [status, setStatus] = useState("Starting…");
const [ready, setReady] = useState(false);
const [svgs, setSvgs] = useState<{ name: string; svg: string }[]>([]);
const [error, setError] = useState<string | null>(null);
const [rendering, setRendering] = useState(false);
const [shared, setShared] = useState(false);
const [splitRatio, setSplitRatio] = useState(loadStoredRatio);
const [sidebarWidth, setSidebarWidth] = useState(loadStoredSidebarWidth);
const [lineCount, setLineCount] = useState(() => codeRef.current.split("\n").length);
// A share-link boot loads its code straight into the editor, so no example
// pill should read as "active" until the user explicitly picks one.
const [activeExample, setActiveExample] = useState<string | null>(() =>
decodeShare(window.location.hash) !== null ? null : EXAMPLES[0].title
);
const [renderMs, setRenderMs] = useState<number | null>(null);
const splitContainerRef = useRef<HTMLDivElement>(null);
const mainRef = useRef<HTMLElement>(null);
useEffect(() => {
initTheme();
}, []);
const handleSplitChange = useCallback((ratio: number) => {
const clamped = clampRatio(ratio);
setSplitRatio(clamped);
localStorage.setItem(SPLIT_STORAGE_KEY, String(clamped));
}, []);
const handleSidebarChange = useCallback((width: number) => {
const clamped = clampSidebarWidth(width);
setSidebarWidth(clamped);
localStorage.setItem(SIDEBAR_STORAGE_KEY, String(clamped));
}, []);
const execute = useCallback(async (code: string) => {
codeRef.current = code;
const client = clientRef.current;
if (!client) return;
setRendering(true);
const startedAt = performance.now();
try {
const result = await client.run(code);
if (result.error) {
setError(result.error); // keep the last successful preview on screen
} else {
const rendered = await Promise.all(
result.dots.map(async (d) => ({ name: d.name, svg: await renderDot(d.source) }))
);
setSvgs(rendered);
setError(null);
setRenderMs(Math.round(performance.now() - startedAt));
}
} catch (err) {
if (err instanceof SupersededError) return;
if (err instanceof TimeoutError) {
setError("Execution timed out after 10s. The Python runtime was restarted (infinite loop?).");
} else {
setError(String(err));
}
} finally {
setRendering(false);
}
}, []);
const debouncedExecute = useMemo(() => debounce(execute, 500), [execute]);
// codeRef must track EVERY keystroke immediately — handleShare/insertImport
// read it synchronously; only the execution is debounced.
const handleEditorChange = useCallback(
(code: string) => {
codeRef.current = code;
setLineCount(code.split("\n").length);
debouncedExecute(code);
},
[debouncedExecute]
);
useEffect(() => {
fetch("catalog.json")
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(setCatalog)
.catch((err) => {
setCatalog(null);
setCatalogError(
`Node catalog failed to load (${err instanceof Error ? err.message : String(err)}) — autocomplete and node search are disabled.`
);
});
const client = new PyClient();
clientRef.current = client;
client
.init((stage) => {
if (clientRef.current === client) setStatus(STATUS_BY_STAGE[stage] ?? stage);
})
.then(() => {
if (clientRef.current !== client) return;
setReady(true);
void execute(codeRef.current);
})
.catch((err) => {
if (String(err).includes("disposed")) return; // our own cleanup
if (clientRef.current === client) setStatus(`Failed to start: ${err}`);
});
return () => client.dispose();
}, [execute]);
const editorExtensions = useMemo(() => {
if (!catalog) return [];
return [
autocompletion({ override: [diagramsCompletions(catalog)] }),
signatureTooltip(catalog.signatures),
];
}, [catalog]);
function handleShare() {
const hash = `#code=${encodeShare(codeRef.current)}`;
window.history.replaceState(null, "", hash);
navigator.clipboard
.writeText(window.location.href)
.then(() => {
setShared(true);
setTimeout(() => setShared(false), 2000);
})
.catch(() => setShared(false));
}
function loadCode(code: string) {
replaceCodeRef.current?.(code);
void execute(code);
}
function insertImport(importStmt: string) {
loadCode(`${importStmt}\n${codeRef.current}`);
}
function handleSelectExample(example: { title: string; code: string }) {
setActiveExample(example.title);
loadCode(example.code);
}
function handleRenameDiagram(index: number, name: string) {
const next = renameDiagramInCode(codeRef.current, index, name);
if (next) loadCode(next);
}
return (
<div className="app">
<Toolbar status={ready ? (rendering ? "Rendering…" : "Ready") : status} onShare={handleShare} shared={shared} />
<ExamplesGallery activeExample={activeExample} onSelect={handleSelectExample} />
<main className="main" ref={mainRef}>
<NodeSearch catalog={catalog} onInsert={insertImport} width={sidebarWidth} />
<DragHandle
containerRef={mainRef}
onChange={handleSidebarChange}
ariaLabel="Resize node list sidebar"
valueFromPointer={(clientX, rect) => clampSidebarWidth(clientX - rect.left)}
resetValue={DEFAULT_SIDEBAR_WIDTH}
/>
<div className="split-container" ref={splitContainerRef}>
<div className="editor-column" style={{ flexBasis: `${splitRatio}%` }}>
<EditorPane
key={catalog ? "with-catalog" : "bare"}
initialCode={codeRef.current}
extensions={editorExtensions}
lineCount={lineCount}
onChange={handleEditorChange}
onReplaceRef={(fn) => (replaceCodeRef.current = fn)}
onRunNow={() => void execute(codeRef.current)}
onShare={handleShare}
/>
<ErrorPanel error={catalogError} testId="catalog-error-panel" />
<ErrorPanel error={error} />
</div>
<DragHandle
containerRef={splitContainerRef}
onChange={handleSplitChange}
ariaLabel="Resize editor and preview panes"
valueFromPointer={(clientX, rect) =>
rect.width === 0 ? DEFAULT_RATIO : clampRatio(((clientX - rect.left) / rect.width) * 100)
}
resetValue={DEFAULT_RATIO}
/>
<PreviewPane svgs={svgs} loading={rendering} renderMs={renderMs} onRenameDiagram={handleRenameDiagram}>
<ExportBar svgs={svgs} />
</PreviewPane>
</div>
</main>
</div>
);
}

File diff suppressed because it is too large Load Diff

@ -0,0 +1,79 @@
import { CompletionContext, CompletionResult } from "@codemirror/autocomplete";
import { EditorState } from "@codemirror/state";
import { describe, expect, it } from "vitest";
import type { Catalog } from "../types";
import { diagramsCompletions, moduleSegments, parseImports } from "./imports";
function ctx(doc: string, pos = doc.length, explicit = false): CompletionContext {
return new CompletionContext(EditorState.create({ doc }), pos, explicit);
}
const CATALOG: Catalog = {
modules: {
"diagrams.aws.compute": [
{ name: "EC2", aliases: [], icon: "aws/compute/ec2.png" },
{ name: "ElasticContainerService", aliases: ["ECS"], icon: "aws/compute/x.png" },
],
"diagrams.aws.database": [{ name: "RDS", aliases: [], icon: "aws/database/rds.png" }],
"diagrams.gcp.compute": [{ name: "GCE", aliases: [], icon: "gcp/compute/gce.png" }],
},
signatures: { Diagram: [], Cluster: [], Edge: [] },
};
describe("parseImports", () => {
it("collects plain and aliased names", () => {
const doc = "from diagrams import Diagram\nfrom diagrams.aws.compute import EC2, ElasticContainerService as ECS\n";
const names = parseImports(doc);
expect(names.get("Diagram")).toBe("diagrams.Diagram");
expect(names.get("EC2")).toBe("diagrams.aws.compute.EC2");
expect(names.get("ECS")).toBe("diagrams.aws.compute.ElasticContainerService");
});
it("ignores non-import lines", () => {
expect(parseImports("x = 1\n# from fake import Y\n").size).toBe(0);
});
});
describe("moduleSegments", () => {
it("lists next segments for a prefix", () => {
expect(moduleSegments(CATALOG, "diagrams.")).toEqual(["aws", "gcp"]);
expect(moduleSegments(CATALOG, "diagrams.aws.")).toEqual(["compute", "database"]);
});
});
describe("diagramsCompletions", () => {
const source = diagramsCompletions(CATALOG);
it("completes next segments after a dotted prefix", () => {
const r = source(ctx("from diagrams.aws.")) as CompletionResult;
expect(r.options.map((o) => o.label)).toEqual(["compute", "database"]);
expect(r.from).toBe("from diagrams.aws.".length);
});
it("offers the root module while typing it (no corruption)", () => {
const r = source(ctx("from diag")) as CompletionResult;
expect(r.options.map((o) => o.label)).toEqual(["diagrams"]);
expect(r.from).toBe("from ".length);
});
it("handles extra whitespace after from", () => {
const r = source(ctx("from diagrams.")) as CompletionResult;
expect(r.from).toBe("from diagrams.".length);
expect(r.options.map((o) => o.label)).toEqual(["aws", "gcp"]);
});
it("completes classes when an earlier name is aliased", () => {
const r = source(ctx("from diagrams.aws.compute import EC2 as E, EC")) as CompletionResult;
expect(r.options.some((o) => o.label === "ElasticContainerService")).toBe(true);
expect(r.from).toBe("from diagrams.aws.compute import EC2 as E, ".length);
});
});
describe("parseImports parenthesized", () => {
it("handles multiline parenthesized imports with aliases", () => {
const doc = "from diagrams.aws.compute import (\n EC2,\n ElasticContainerService as ECS,\n)\n";
const names = parseImports(doc);
expect(names.get("EC2")).toBe("diagrams.aws.compute.EC2");
expect(names.get("ECS")).toBe("diagrams.aws.compute.ElasticContainerService");
});
});

@ -0,0 +1,112 @@
import { Completion, CompletionContext, CompletionResult, CompletionSource } from "@codemirror/autocomplete";
import type { Catalog } from "../types";
const IMPORT_LINE = /^from\s+(diagrams(?:\.\w+)*)\s+import\s+(.+)$/;
const PAREN_IMPORT = /from\s+(diagrams(?:\.\w+)*)\s+import\s*\(([^)]*)\)/g;
function addImportNames(names: Map<string, string>, module: string, imports: string): void {
for (const part of imports.split(",")) {
const [original, alias] = part.split(/\s+as\s+/).map((s) => s.trim());
if (!original || !/^\w+$/.test(original)) continue;
if (alias && !/^\w+$/.test(alias)) continue;
names.set(alias ?? original, `${module}.${original}`);
}
}
export function parseImports(doc: string): Map<string, string> {
const names = new Map<string, string>();
// Handle parenthesized (possibly multiline) imports first, then strip them
// from the doc so the per-line pass below doesn't double-process them.
let remaining = doc;
for (const match of doc.matchAll(PAREN_IMPORT)) {
const [whole, module, imports] = match;
addImportNames(names, module, imports);
remaining = remaining.replace(whole, "");
}
for (const line of remaining.split("\n")) {
const match = line.trim().match(IMPORT_LINE);
if (!match) continue;
const [, module, imports] = match;
addImportNames(names, module, imports);
}
return names;
}
export function moduleSegments(catalog: Catalog, prefix: string): string[] {
const segments = new Set<string>();
for (const moduleName of Object.keys(catalog.modules)) {
const withDot = moduleName + ".";
if (withDot.startsWith(prefix)) {
const rest = moduleName.slice(prefix.length);
if (rest) segments.add(rest.split(".")[0]);
}
}
return [...segments].sort();
}
function iconInfo(icon: string): Completion["info"] {
return () => {
const img = document.createElement("img");
img.src = `icons/${icon}`;
img.width = 48;
img.height = 48;
return img;
};
}
export function diagramsCompletions(catalog: Catalog): CompletionSource {
return (context: CompletionContext): CompletionResult | null => {
// 1) `from diagrams.aws.` — module path segments
const modMatch = context.matchBefore(/from\s+[\w.]*$/);
if (modMatch) {
const typed = modMatch.text.replace(/^from\s+/, "");
const consumed = modMatch.text.length - typed.length; // actual "from<ws>" width
const lastDot = typed.lastIndexOf(".");
const prefix = lastDot === -1 ? "" : typed.slice(0, lastDot + 1);
const options = moduleSegments(catalog, prefix).map((seg) => ({
label: seg,
type: "namespace",
}));
if (!options.length) return null;
return { from: modMatch.from + consumed + prefix.length, options };
}
// 2) `from diagrams.aws.compute import EC` — class names
const clsMatch = context.matchBefore(
/from\s+(diagrams[\w.]+)\s+import\s+(?:\w+(?:\s+as\s+\w+)?\s*,\s*)*\w*$/,
);
if (clsMatch) {
const module = clsMatch.text.match(/from\s+([\w.]+)/)![1];
const classes = catalog.modules[module];
if (!classes) return null;
const word = context.matchBefore(/\w*$/)!;
const options: Completion[] = classes.flatMap((cls) => [
{ label: cls.name, type: "class", info: iconInfo(cls.icon) },
...cls.aliases.map((alias) => ({
label: alias,
type: "class" as const,
detail: cls.name,
info: iconInfo(cls.icon),
})),
]);
return { from: word.from, options };
}
// 3) general position — imported names
const word = context.matchBefore(/\w+$/);
if (!word && !context.explicit) return null;
const imported = parseImports(context.state.doc.toString());
if (!imported.size) return null;
return {
from: word?.from ?? context.pos,
options: [...imported.entries()].map(([name, origin]) => ({
label: name,
type: "class",
detail: origin,
})),
validFor: /^\w*$/,
};
};
}

@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";
import { findCallContext, lookupSignature } from "./signature";
describe("findCallContext", () => {
it("returns the innermost open call", () => {
expect(findCallContext('with Diagram("web", ')).toBe("Diagram");
expect(findCallContext("Edge(color=")).toBe("Edge");
expect(findCallContext('Cluster("db", graph_attr={')).toBe("Cluster");
});
it("ignores completed calls", () => {
expect(findCallContext('EC2("web") >> ')).toBeNull();
expect(findCallContext("x = 1")).toBeNull();
});
it("handles nesting", () => {
expect(findCallContext('Diagram("a", graph_attr=dict(')).toBe("dict");
});
});
describe("lookupSignature", () => {
it("returns params for known functions", () => {
expect(lookupSignature({ Diagram: ["name: str = ''"] }, "Diagram")).toEqual(["name: str = ''"]);
});
it("ignores prototype-chain names", () => {
expect(lookupSignature({}, "constructor")).toBeNull();
expect(lookupSignature({}, "toString")).toBeNull();
});
it("returns null for null or unknown names", () => {
expect(lookupSignature({ Diagram: [] }, null)).toBeNull();
expect(lookupSignature({ Diagram: [] }, "Edge")).toBeNull();
});
});

@ -0,0 +1,69 @@
import { StateField } from "@codemirror/state";
import { EditorView, Tooltip, showTooltip } from "@codemirror/view";
import type { Extension } from "@codemirror/state";
/** Finds the name of the innermost call that is still open (unclosed) in the
* text before the cursor.
*
* v1 known limitations:
* (a) No string-literal awareness unbalanced brackets inside Python strings can
* produce a false function context (worst case: wrong/absent tooltip, never a buffer write).
* (b) buildTooltip is line-scoped multiline calls lose the tooltip.
*/
export function findCallContext(textBeforeCursor: string): string | null {
const stack: string[] = [];
const re = /([A-Za-z_]\w*)?\s*(\(|\)|\[|\]|\{|\})/g;
let match: RegExpExecArray | null;
while ((match = re.exec(textBeforeCursor))) {
const [, name, bracket] = match;
if (bracket === "(") stack.push(name ?? "");
else if (bracket === "[" || bracket === "{") stack.push("");
else stack.pop();
}
for (let i = stack.length - 1; i >= 0; i--) {
if (stack[i]) return stack[i];
}
return null;
}
/** Own-property, array-checked signature lookup guards against
* prototype-chain names like "constructor" or "toString". */
export function lookupSignature(
signatures: Record<string, string[]>,
funcName: string | null
): string[] | null {
if (!funcName || !Object.hasOwn(signatures, funcName)) return null;
const params = signatures[funcName];
return Array.isArray(params) ? params : null;
}
function buildTooltip(signatures: Record<string, string[]>, view: { state: EditorView["state"] }): Tooltip | null {
const { state } = view;
const pos = state.selection.main.head;
const line = state.doc.lineAt(pos);
const funcName = findCallContext(line.text.slice(0, pos - line.from));
const params = lookupSignature(signatures, funcName);
if (!funcName || !params) return null;
return {
pos,
above: true,
create: () => {
const dom = document.createElement("div");
dom.className = "cm-signature-hint";
dom.textContent = `${funcName}(${params.join(", ")})`;
return { dom };
},
};
}
export function signatureTooltip(signatures: Record<string, string[]>): Extension {
const field = StateField.define<Tooltip | null>({
create: (state) => buildTooltip(signatures, { state }),
update(value, tr) {
if (!tr.docChanged && !tr.selection) return value;
return buildTooltip(signatures, { state: tr.state });
},
provide: (f) => showTooltip.from(f),
});
return [field];
}

@ -0,0 +1,59 @@
import { useRef, useState, type PointerEvent, type RefObject } from "react";
interface Props {
containerRef: RefObject<HTMLElement>;
onChange: (value: number) => void;
ariaLabel: string;
/** Derives the next value from the pointer's clientX and containerRef's
* current bounding rect ratio math for the editor/preview split, px
* math for the sidebar. Callers own their own clamping. */
valueFromPointer: (clientX: number, rect: DOMRect) => number;
/** Value reported on double-click, and the fallback reported mid-drag if
* containerRef briefly has no rect (e.g. mid-unmount). */
resetValue: number;
}
// Shared flush 1px vertical divider / drag handle: used both between
// .editor-column and .preview-pane (ratio math, 25-75 clamp) and on the
// node-list sidebar's right edge (px math, clampSidebarWidth). Drags
// compute the next value via the caller-supplied valueFromPointer and
// report it through onChange; double-click resets to resetValue.
export default function DragHandle({ containerRef, onChange, ariaLabel, valueFromPointer, resetValue }: Props) {
const [active, setActive] = useState(false);
const draggingRef = useRef(false);
function handlePointerDown(e: PointerEvent<HTMLDivElement>) {
draggingRef.current = true;
setActive(true);
e.currentTarget.setPointerCapture(e.pointerId);
}
function handlePointerMove(e: PointerEvent<HTMLDivElement>) {
if (!draggingRef.current) return;
const rect = containerRef.current?.getBoundingClientRect();
onChange(rect ? valueFromPointer(e.clientX, rect) : resetValue);
}
function endDrag(e: PointerEvent<HTMLDivElement>) {
if (!draggingRef.current) return;
draggingRef.current = false;
setActive(false);
if (e.currentTarget.hasPointerCapture(e.pointerId)) {
e.currentTarget.releasePointerCapture(e.pointerId);
}
}
return (
<div
className={`split-handle${active ? " is-active" : ""}`}
role="separator"
aria-orientation="vertical"
aria-label={ariaLabel}
onPointerDown={handlePointerDown}
onPointerMove={handlePointerMove}
onPointerUp={endDrag}
onPointerCancel={endDrag}
onDoubleClick={() => onChange(resetValue)}
/>
);
}

@ -0,0 +1,102 @@
import { indentWithTab } from "@codemirror/commands";
import { python } from "@codemirror/lang-python";
import { Extension } from "@codemirror/state";
import { EditorView, keymap } from "@codemirror/view";
import { basicSetup } from "codemirror";
import { useEffect, useRef } from "react";
import { blueprintTheme } from "../editor-theme";
interface Props {
initialCode: string;
extensions?: Extension[];
lineCount: number;
onChange: (code: string) => void;
onReplaceRef?: (replace: (code: string) => void) => void;
/** Mod-Enter: run the current code immediately (skips the debounce). */
onRunNow?: () => void;
/** Mod-S: share (intercepts the browser's save dialog). */
onShare?: () => void;
}
export default function EditorPane({
initialCode,
extensions = [],
lineCount,
onChange,
onReplaceRef,
onRunNow,
onShare,
}: Props) {
const hostRef = useRef<HTMLDivElement>(null);
// Keep the latest callbacks without remounting the view (avoids stale
// closures if prop identities change after mount).
const onChangeRef = useRef(onChange);
onChangeRef.current = onChange;
const onRunNowRef = useRef(onRunNow);
onRunNowRef.current = onRunNow;
const onShareRef = useRef(onShare);
onShareRef.current = onShare;
// NOTE: `extensions` and `initialCode` are intentionally captured once at
// mount — the App mounts EditorPane only after the catalog is loaded, so
// extensions are stable for the lifetime of the view.
useEffect(() => {
const view = new EditorView({
doc: initialCode,
parent: hostRef.current!,
extensions: [
// Playground-level bindings, ahead of basicSetup so they win:
// Mod-Enter = run now (skip debounce), Mod-S = share link instead of
// the browser's useless save dialog.
keymap.of([
{
key: "Mod-Enter",
preventDefault: true,
run: () => {
onRunNowRef.current?.();
return true;
},
},
{
key: "Mod-s",
preventDefault: true,
run: () => {
onShareRef.current?.();
return true;
},
},
]),
basicSetup,
// basicSetup's default keymap doesn't bind Tab (that's deliberate
// upstream, so Tab still moves focus for a11y by default) — add it
// back explicitly so Tab indents code, matching every code editor's
// expected behavior here. autocompletion's own Tab-to-accept binding
// (wired in App.tsx via `extensions`, spread in below) still wins
// while a completion popup is open — CodeMirror gives it higher
// precedence internally — so this only fires when no popup is open.
keymap.of([indentWithTab]),
python(),
blueprintTheme,
EditorView.updateListener.of((update) => {
if (update.docChanged) onChangeRef.current(update.state.doc.toString());
}),
...extensions,
],
});
onReplaceRef?.((code) => {
view.dispatch({ changes: { from: 0, to: view.state.doc.length, insert: code } });
});
return () => view.destroy();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<div className="editor-pane-wrap" data-testid="editor">
<div className="editor-topbar">
<span className="editor-filename">main.py</span>
<span className="editor-linecount">{lineCount} lines</span>
</div>
<div ref={hostRef} className="editor-pane" />
</div>
);
}

@ -0,0 +1,13 @@
interface Props {
error: string | null;
testId?: string;
}
export default function ErrorPanel({ error, testId = "error-panel" }: Props) {
if (!error) return null;
return (
<pre className="error-panel" data-testid={testId}>
{error}
</pre>
);
}

@ -0,0 +1,23 @@
import { EXAMPLES } from "../examples";
interface Props {
activeExample: string | null;
onSelect: (example: { title: string; code: string }) => void;
}
export default function ExamplesGallery({ activeExample, onSelect }: Props) {
return (
<div className="examples">
<span className="examples-label">Examples</span>
{EXAMPLES.map((example) => (
<button
key={example.title}
className={`example-pill${example.title === activeExample ? " is-active" : ""}`}
onClick={() => onSelect(example)}
>
{example.title}
</button>
))}
</div>
);
}

@ -0,0 +1,249 @@
import { useEffect, useState } from "react";
import { download, inlineIcons, svgToPngBlob } from "../export/exporter";
import type { FocusEvent, KeyboardEvent } from "react";
type DownloadFormat = "png" | "svg" | "jpeg";
const MIN_DIM = 16;
const MAX_DIM = 8192;
const COPIED_TIMEOUT_MS = 2000;
interface Props {
svgs: { name: string; svg: string }[];
}
function slug(name: string): string {
return name ? name.toLowerCase().replace(/\W+/g, "_") : "diagram";
}
// Parses a raw SIZE field's text into a positive pixel value. Empty (or
// not-yet-a-number, e.g. mid-typing) means "no override" — resolveSize's
// aspect-ratio-preserving default takes over for that axis. Clamping to
// [MIN_DIM, MAX_DIM] happens separately on blur (see `clampDimText`).
function parseDim(raw: string): number | undefined {
if (raw.trim() === "") return undefined;
const n = Number(raw);
return Number.isFinite(n) && n > 0 ? n : undefined;
}
function clampDimText(raw: string): string {
if (raw.trim() === "") return "";
const n = Math.round(Number(raw));
if (!Number.isFinite(n)) return "";
return String(Math.min(MAX_DIM, Math.max(MIN_DIM, n)));
}
// Small download-arrow icon rendered to the right of each format button's
// label. Purely decorative — the button's accessible name still comes from
// its text content ("PNG"/"SVG"/"JPEG"), which e2e depends on — so this
// stays aria-hidden.
function DownloadIcon() {
return (
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<path
d="M7 1.6v7.1M7 8.7 3.9 5.6M7 8.7l3.1-3.1"
stroke="currentColor"
strokeWidth="1.4"
strokeLinecap="round"
strokeLinejoin="round"
/>
<path d="M2.6 11.4h8.8" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
);
}
// Small copy icon rendered LEFT of "Copy" (mockup order) — decorative and
// aria-hidden for the same reason as `DownloadIcon`.
function CopyIcon() {
return (
<svg width="11" height="11" viewBox="0 0 14 14" fill="none" aria-hidden="true">
<rect x="4.6" y="4.6" width="7.2" height="7.2" rx="1.3" stroke="currentColor" strokeWidth="1.3" />
<path d="M2.6 9V2.9a1 1 0 0 1 1-1H9" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" />
</svg>
);
}
// Permanently-visible export bar docked under the editor (Mermaid-Live
// style). Two full-width rows:
// - fields row: `SIZE [W] × [H] px [Auto toggle]` stretched across the
// full width (+ TARGET select when several diagrams exist). One Auto
// switch governs BOTH dimensions: ON = inputs disabled/dimmed and the
// exporter's aspect-preserving default (2x) applies; OFF = explicit
// pixel inputs (either axis may be left empty for per-axis aspect
// auto). Toggling Auto back on keeps the last typed values.
// - actions row: PNG / SVG / JPEG download buttons (equal width) and a
// compact "Copy Image" (clipboard PNG) button. Exports always use a
// white background (the BACKGROUND option was dropped).
export default function ExportBar({ svgs }: Props) {
const [auto, setAuto] = useState(true);
const [width, setWidth] = useState("");
const [height, setHeight] = useState("");
const [selectedIndex, setSelectedIndex] = useState(0);
const [error, setError] = useState<string | null>(null);
const [copied, setCopied] = useState(false);
// Keep the selection valid as the diagram list changes (e.g. a script edit
// that drops from two `with Diagram(...)` blocks down to one).
useEffect(() => {
if (selectedIndex >= svgs.length && svgs.length > 0) setSelectedIndex(0);
}, [svgs.length, selectedIndex]);
const active = svgs[selectedIndex] ?? null;
const effectiveWidth = auto ? undefined : parseDim(width);
const effectiveHeight = auto ? undefined : parseDim(height);
function handleDimBlur(setter: (v: string) => void) {
return (e: FocusEvent<HTMLInputElement>) => setter(clampDimText(e.currentTarget.value));
}
function handleDimKeyDown(e: KeyboardEvent<HTMLInputElement>) {
// Commit + clamp on Enter rather than waiting for blur, so a keyboard
// user can confirm the value without tabbing away.
if (e.key === "Enter") e.currentTarget.blur();
}
async function handleDownload(format: DownloadFormat) {
if (!active) return;
setError(null);
setCopied(false);
try {
const fileSlug = slug(active.name);
if (format === "svg") {
// SVG is vector — SIZE is meaningless for it.
const inlined = await inlineIcons(active.svg);
download(`${fileSlug}.svg`, new Blob([inlined], { type: "image/svg+xml" }));
} else {
const mime = format === "jpeg" ? "image/jpeg" : "image/png";
const blob = await svgToPngBlob(active.svg, {
width: effectiveWidth,
height: effectiveHeight,
mime,
});
download(`${fileSlug}.${format === "jpeg" ? "jpg" : "png"}`, blob);
}
} catch (err) {
setError(`Export failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
async function handleCopyImage() {
if (!active) return;
setError(null);
try {
const blob = await svgToPngBlob(active.svg, {
width: effectiveWidth,
height: effectiveHeight,
mime: "image/png",
});
await navigator.clipboard.write([new ClipboardItem({ "image/png": blob })]);
setCopied(true);
setTimeout(() => setCopied(false), COPIED_TIMEOUT_MS);
} catch (err) {
setCopied(false);
setError(`Copy failed: ${err instanceof Error ? err.message : String(err)}`);
}
}
const message = error
? { text: error, tone: "error" as const }
: copied
? { text: "Copied!", tone: "success" as const }
: null;
return (
<div className="export-bar">
<div className="export-bar-row export-bar-row-fields">
<div className="export-field">
<span className="export-field-label">Size</span>
<input
type="text"
inputMode="numeric"
className="export-size-input"
aria-label="Export width in pixels"
placeholder="auto"
disabled={auto}
value={width}
onChange={(e) => setWidth(e.target.value.replace(/\D/g, ""))}
onBlur={handleDimBlur(setWidth)}
onKeyDown={handleDimKeyDown}
/>
<span className="export-size-x" aria-hidden="true">×</span>
<input
type="text"
inputMode="numeric"
className="export-size-input"
aria-label="Export height in pixels"
placeholder="auto"
disabled={auto}
value={height}
onChange={(e) => setHeight(e.target.value.replace(/\D/g, ""))}
onBlur={handleDimBlur(setHeight)}
onKeyDown={handleDimKeyDown}
/>
<span className="export-size-px" aria-hidden="true">px</span>
<button
type="button"
role="switch"
aria-checked={auto}
className="export-auto-toggle"
onClick={() => setAuto((a) => !a)}
>
<span className="toggle-track" aria-hidden="true">
<span className="toggle-knob" />
</span>
Auto
</button>
{svgs.length > 1 && (
<select
className="export-bar-select"
aria-label="Diagram to export"
value={selectedIndex}
onChange={(e) => setSelectedIndex(Number(e.target.value))}
>
{svgs.map((s, i) => (
<option key={`${s.name}-${i}`} value={i}>
{s.name || "diagram"}
</option>
))}
</select>
)}
</div>
</div>
<div className="export-bar-row export-bar-row-actions">
<button
type="button"
className="export-download-btn"
disabled={!active}
onClick={() => void handleDownload("png")}
>
PNG
<DownloadIcon />
</button>
<button
type="button"
className="export-download-btn"
disabled={!active}
onClick={() => void handleDownload("svg")}
>
SVG
<DownloadIcon />
</button>
<button
type="button"
className="export-download-btn"
disabled={!active}
onClick={() => void handleDownload("jpeg")}
>
JPEG
<DownloadIcon />
</button>
<button type="button" className="export-copy-btn" disabled={!active} onClick={() => void handleCopyImage()}>
<CopyIcon />
Copy Image
</button>
{message && <span className={`export-bar-message export-bar-message-${message.tone}`}>{message.text}</span>}
</div>
</div>
);
}

@ -0,0 +1,297 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { createPortal } from "react-dom";
import { providerLabel } from "../search/providerLabel";
import { searchCatalog, type SearchHit } from "../search/search";
import { catalogTree } from "../search/tree";
import type { Catalog } from "../types";
interface Props {
catalog: Catalog | null;
onInsert: (importStmt: string) => void;
/** Resizable sidebar width in px (see DragHandle); falls back to CSS. */
width?: number;
}
interface MenuHit {
name: string;
importStmt: string;
}
interface MenuState {
x: number;
y: number;
hit: MenuHit;
}
interface HitRowProps {
icon: string;
name: string;
importStmt: string;
module?: string;
onInsert: (importStmt: string) => void;
onContextMenu: (x: number, y: number, hit: MenuHit) => void;
// Set only when rendered as a class row inside an expanded tree category
// (depth 2); the container carries the indent/rail, this only tweaks the
// row's own padding via the "is-tree-class" CSS class.
inTree?: boolean;
}
// Shared row for a single class: real node icon (bare, no wrapper box),
// name, optional module path. Fixed layout that never shifts on hover —
// used by both the flat search results and the expanded-category tree rows
// so the two views stay visually unified. Left-click inserts the import;
// right-click opens a small custom context menu (Copy/Insert) owned by the
// parent NodeSearch.
function HitRow({ icon, name, importStmt, module, onInsert, onContextMenu, inTree }: HitRowProps) {
return (
<li
className={inTree ? "hit-row is-tree-class" : "hit-row"}
tabIndex={0}
role="button"
onClick={() => onInsert(importStmt)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
onInsert(importStmt);
}
}}
onContextMenu={(e) => {
e.preventDefault();
onContextMenu(e.clientX, e.clientY, { name, importStmt });
}}
>
<span className="hit-icon">
<img src={`icons/${icon}`} width={20} height={20} alt="" loading="lazy" />
</span>
<span className="hit-name" title={importStmt}>{name}</span>
{module !== undefined && <span className="hit-module">{module}</span>}
</li>
);
}
interface ContextMenuProps {
x: number;
y: number;
hit: MenuHit;
onInsert: (importStmt: string) => void;
onClose: () => void;
}
// Small custom right-click menu: Copy import / Insert import. Rendered via a
// portal straight onto <body> — several ancestors (e.g. `.node-search`'s
// fade-slide entrance animation) end their animation with a lingering
// `transform`, which per spec establishes a new containing block for any
// `position: fixed` descendant. Left in place, that made the menu position
// itself relative to the sidebar instead of the viewport (appearing far from
// the click) and clipped it under `.node-search`'s `overflow: hidden` and
// the editor's stacking context (covered instead of on top). Portaling to
// `document.body` sidesteps all of that: `position: fixed` is now always
// viewport-relative, and a high z-index guarantees it paints above the
// editor. Closes on outside pointerdown, Esc, or scroll (capture-phase
// listeners so scrolling inside the results list — which doesn't bubble —
// still closes it); these still work unchanged since `rootRef` points at the
// real portaled DOM node regardless of where in the tree it renders.
function ContextMenu({ x, y, hit, onInsert, onClose }: ContextMenuProps) {
const rootRef = useRef<HTMLDivElement>(null);
useEffect(() => {
function handlePointerDown(e: PointerEvent) {
if (rootRef.current && !rootRef.current.contains(e.target as Node)) onClose();
}
function handleKeyDown(e: KeyboardEvent) {
if (e.key === "Escape") onClose();
}
function handleScroll() {
onClose();
}
document.addEventListener("pointerdown", handlePointerDown);
document.addEventListener("keydown", handleKeyDown);
window.addEventListener("scroll", handleScroll, true);
return () => {
document.removeEventListener("pointerdown", handlePointerDown);
document.removeEventListener("keydown", handleKeyDown);
window.removeEventListener("scroll", handleScroll, true);
};
}, [onClose]);
return createPortal(
<div className="ctx-menu" style={{ left: x, top: y }} ref={rootRef} role="menu">
<button
type="button"
role="menuitem"
onClick={() => {
void navigator.clipboard.writeText(hit.importStmt).catch(() => {});
onClose();
}}
>
Copy import
</button>
<button
type="button"
role="menuitem"
onClick={() => {
onInsert(hit.importStmt);
onClose();
}}
>
Insert import
</button>
</div>,
document.body
);
}
const MENU_WIDTH = 160;
const MENU_HEIGHT = 76;
// Centered SVG chevron shared by both tree levels. Unlike the old text
// glyphs ("▸"/"▾"), the triangle is geometrically centered in its box, so
// the CSS 90° open-rotation spins in place instead of drifting (the glyph's
// off-center metrics were visibly shifting the depth-1 caret mid-turn).
function Chevron() {
return (
<span className="tree-chevron" aria-hidden="true">
<svg width="11" height="11" viewBox="0 0 10 10" fill="currentColor">
<path d="M3 1.6 7.4 5 3 8.4Z" />
</svg>
</span>
);
}
export default function NodeSearch({ catalog, onInsert, width }: Props) {
const [query, setQuery] = useState("");
const [expanded, setExpanded] = useState<Set<string>>(new Set());
const [menu, setMenu] = useState<MenuState | null>(null);
const hits = useMemo(
() => (catalog ? searchCatalog(catalog, query) : []),
[catalog, query]
);
const tree = useMemo(() => (catalog ? catalogTree(catalog) : []), [catalog]);
function toggle(key: string) {
setExpanded((prev) => {
const next = new Set(prev);
if (next.has(key)) next.delete(key);
else next.add(key);
return next;
});
}
// One menu instance at a time: keep it fully on-screen by clamping against
// the viewport rather than letting it render past the right/bottom edge.
function openMenu(x: number, y: number, hit: MenuHit) {
const clampedX = Math.min(x, window.innerWidth - MENU_WIDTH - 8);
const clampedY = Math.min(y, window.innerHeight - MENU_HEIGHT - 8);
setMenu({ x: Math.max(8, clampedX), y: Math.max(8, clampedY), hit });
}
// Flat search-result row (non-empty query).
function hitRow(hit: SearchHit) {
return (
<HitRow
key={`${hit.module}.${hit.name}`}
icon={hit.icon}
name={hit.name}
importStmt={hit.importStmt}
module={hit.module.replace("diagrams.", "")}
onInsert={onInsert}
onContextMenu={openMenu}
/>
);
}
const isBlank = !query.trim();
return (
<div className="node-search" style={width !== undefined ? { width } : undefined}>
<div className="search-input-wrap">
<span className="search-icon" aria-hidden="true">
<svg width="14" height="14" viewBox="0 0 14 14" fill="none">
<circle cx="6" cy="6" r="4.5" stroke="currentColor" strokeWidth="1.4" />
<line x1="9.4" y1="9.4" x2="13" y2="13" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" />
</svg>
</span>
<input
type="search"
placeholder="Search nodes — EC2, Kafka..."
aria-label="Search nodes"
value={query}
onChange={(e) => setQuery(e.target.value)}
data-testid="node-search-input"
/>
</div>
{isBlank ? (
<ul className="node-search-results node-tree">
{tree.map((provider) => {
const providerKey = `provider:${provider.provider}`;
const providerOpen = expanded.has(providerKey);
return (
<li key={provider.provider} className="tree-node">
<button
type="button"
className="tree-row tree-provider-row"
aria-expanded={providerOpen}
onClick={() => toggle(providerKey)}
>
<Chevron />
<span className="tree-provider-name">{providerLabel(provider.provider)}</span>
<span className="tree-badge">{provider.count}</span>
</button>
{providerOpen && (
<ul className="tree-children">
{provider.categories.map((cat) => {
const categoryKey = `category:${cat.module}`;
const categoryOpen = expanded.has(categoryKey);
const label = cat.category || cat.module.split(".").pop() || cat.module;
return (
<li key={cat.module} className="tree-node">
<button
type="button"
className="tree-row tree-category-row"
aria-expanded={categoryOpen}
onClick={() => toggle(categoryKey)}
>
<Chevron />
<span className="tree-category-name">{label}</span>
<span className="tree-badge">{cat.classes.length}</span>
</button>
{categoryOpen && (
<ul className="tree-class-rows">
{cat.classes.map((cls) => (
<HitRow
key={`${cat.module}.${cls.name}`}
icon={cls.icon}
name={cls.name}
importStmt={`from ${cat.module} import ${cls.name}`}
onInsert={onInsert}
onContextMenu={openMenu}
inTree
/>
))}
</ul>
)}
</li>
);
})}
</ul>
)}
</li>
);
})}
</ul>
) : (
<ul className="node-search-results">{hits.map((hit) => hitRow(hit))}</ul>
)}
{menu && (
<ContextMenu
x={menu.x}
y={menu.y}
hit={menu.hit}
onInsert={onInsert}
onClose={() => setMenu(null)}
/>
)}
</div>
);
}

@ -0,0 +1,337 @@
import type { KeyboardEvent, ReactNode } from "react";
import { useCallback, useEffect, useLayoutEffect, useRef, useState } from "react";
import { svgStats } from "../utils/svgStats";
import { fitView, zoomAt, type ViewTransform } from "../utils/zoom";
interface Props {
svgs: { name: string; svg: string }[];
loading: boolean;
renderMs: number | null;
onRenameDiagram?: (index: number, name: string) => void;
/** Rendered docked at the bottom of the pane, below the canvas
* (the ExportBar lives here per the design mockup). */
children?: ReactNode;
}
// True infinite canvas: the container is a fixed, overflow:hidden viewport;
// all pan/zoom state lives in `view` (tx/ty/scale) and is applied as a
// single CSS transform on the absolutely-positioned content layer. Unlike
// the old scrollLeft/scrollTop-based pan, tx/ty are unbounded — content can
// be dragged past the left/top edge (negative translate) with no clamping.
export default function PreviewPane({ svgs, loading, renderMs, onRenameDiagram, children }: Props) {
const [view, setView] = useState<ViewTransform>({ tx: 0, ty: 0, scale: 1 });
const containerRef = useRef<HTMLDivElement>(null);
const contentRef = useRef<HTMLDivElement>(null);
const viewRef = useRef(view);
viewRef.current = view;
// Click-to-edit for the fixed header's diagram name (always index 0 — the
// per-sheet `.sheet-label`s below stay read-only). `titleDraft` is local
// input state so typing doesn't touch `svgs`/App state until commit.
const [isEditingTitle, setIsEditingTitle] = useState(false);
const [titleDraft, setTitleDraft] = useState("");
const titleInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!isEditingTitle) return;
const input = titleInputRef.current;
input?.focus();
input?.select();
}, [isEditingTitle]);
function startEditingTitle() {
if (!onRenameDiagram) return;
setTitleDraft(svgs[0]?.name ?? "");
setIsEditingTitle(true);
}
function commitTitleEdit() {
setIsEditingTitle(false);
const nextName = titleDraft;
const previousName = svgs[0]?.name ?? "";
if (nextName !== previousName) onRenameDiagram?.(0, nextName);
}
function handleTitleKeyDown(e: KeyboardEvent<HTMLInputElement>) {
if (e.key === "Enter") {
e.preventDefault();
commitTitleEdit();
} else if (e.key === "Escape") {
e.preventDefault();
setIsEditingTitle(false);
}
}
// Fits the content layer entirely inside the canvas viewport (scaled down
// for large diagrams, scaled up — capped at 2x — for tiny ones) and
// centers it on both axes. offsetWidth/Height read the content's
// untransformed layout size (CSS transforms don't affect layout), so this
// is correct regardless of the view's current scale. Used both for the
// initial/on-new-svgs sizing and for the "%" fit button.
const fitToView = useCallback(() => {
const container = containerRef.current;
const content = contentRef.current;
if (!container || !content) return;
const rect = container.getBoundingClientRect();
setView(fitView(rect.width, rect.height, content.offsetWidth, content.offsetHeight));
}, []);
useLayoutEffect(() => {
fitToView();
}, [svgs, fitToView]);
// ctrl+wheel (trackpad pinch) zooms at the cursor; a plain wheel pans
// (natural two-finger trackpad scroll). Must be a real (non-passive) DOM
// listener — not React's onWheel — so preventDefault() actually stops the
// browser's own page-zoom/scroll for the gesture.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
function handleWheel(e: WheelEvent) {
e.preventDefault();
const rect = container!.getBoundingClientRect();
if (e.ctrlKey) {
const factor = Math.exp(-e.deltaY * 0.01);
setView((v) => zoomAt(v, e.clientX - rect.left, e.clientY - rect.top, factor));
} else {
setView((v) => ({ ...v, tx: v.tx - e.deltaX, ty: v.ty - e.deltaY }));
}
}
container.addEventListener("wheel", handleWheel, { passive: false });
return () => container.removeEventListener("wheel", handleWheel);
}, []);
// Two-pointer touch pinch: track active pointers' positions, and on each
// move with exactly two active pointers, derive a scale factor from the
// change in distance between them and zoom at their midpoint.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
const pointers = new Map<number, { x: number; y: number }>();
let lastDistance: number | null = null;
function distance(): number {
const [a, b] = [...pointers.values()];
return Math.hypot(a.x - b.x, a.y - b.y);
}
function midpoint(): { x: number; y: number } {
const [a, b] = [...pointers.values()];
return { x: (a.x + b.x) / 2, y: (a.y + b.y) / 2 };
}
function handlePointerDown(e: PointerEvent) {
if (e.pointerType !== "touch") return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
lastDistance = pointers.size === 2 ? distance() : null;
}
function handlePointerMove(e: PointerEvent) {
if (!pointers.has(e.pointerId)) return;
pointers.set(e.pointerId, { x: e.clientX, y: e.clientY });
if (pointers.size !== 2) return;
const dist = distance();
if (lastDistance === null || lastDistance === 0) {
lastDistance = dist;
return;
}
const factor = dist / lastDistance;
const { x, y } = midpoint();
const rect = container!.getBoundingClientRect();
setView((v) => zoomAt(v, x - rect.left, y - rect.top, factor));
lastDistance = dist;
}
function handlePointerEnd(e: PointerEvent) {
pointers.delete(e.pointerId);
lastDistance = pointers.size === 2 ? distance() : null;
}
container.addEventListener("pointerdown", handlePointerDown);
container.addEventListener("pointermove", handlePointerMove);
container.addEventListener("pointerup", handlePointerEnd);
container.addEventListener("pointercancel", handlePointerEnd);
container.addEventListener("pointerleave", handlePointerEnd);
return () => {
container.removeEventListener("pointerdown", handlePointerDown);
container.removeEventListener("pointermove", handlePointerMove);
container.removeEventListener("pointerup", handlePointerEnd);
container.removeEventListener("pointercancel", handlePointerEnd);
container.removeEventListener("pointerleave", handlePointerEnd);
};
}, []);
// One-pointer drag-to-pan: tx/ty move freely with the pointer — no bounds,
// so dragging toward the left/top can carry content into negative
// translate space (this is exactly what fixes "can't pan left/up past the
// edge" from the old scrollLeft/scrollTop approach). A 3px move threshold
// keeps incidental clicks (zoom buttons, etc.) from starting a drag; those
// targets are also excluded outright. If a second pointer comes down
// mid-drag, pan disengages immediately and hands off to the pinch effect.
useEffect(() => {
const container = containerRef.current;
if (!container) return;
let draggingId: number | null = null;
let engaged = false;
let originX = 0;
let originY = 0;
let originTx = 0;
let originTy = 0;
function disengage() {
if (draggingId !== null && container!.hasPointerCapture(draggingId)) {
container!.releasePointerCapture(draggingId);
}
container!.classList.remove("is-panning");
draggingId = null;
engaged = false;
}
function handlePointerDown(e: PointerEvent) {
if (draggingId !== null) {
disengage();
return;
}
if (e.button !== 0) return;
if ((e.target as Element).closest("button, a, input, select, [role=menu]")) return;
draggingId = e.pointerId;
engaged = false;
originX = e.clientX;
originY = e.clientY;
originTx = viewRef.current.tx;
originTy = viewRef.current.ty;
container!.setPointerCapture(draggingId);
}
function handlePointerMove(e: PointerEvent) {
if (draggingId === null || e.pointerId !== draggingId) return;
const dx = e.clientX - originX;
const dy = e.clientY - originY;
if (!engaged) {
if (Math.hypot(dx, dy) < 3) return;
engaged = true;
container!.classList.add("is-panning");
}
setView((v) => ({ ...v, tx: originTx + dx, ty: originTy + dy }));
}
function handlePointerEnd(e: PointerEvent) {
if (draggingId !== e.pointerId) return;
disengage();
}
container.addEventListener("pointerdown", handlePointerDown);
container.addEventListener("pointermove", handlePointerMove);
container.addEventListener("pointerup", handlePointerEnd);
container.addEventListener("pointercancel", handlePointerEnd);
return () => {
container.removeEventListener("pointerdown", handlePointerDown);
container.removeEventListener("pointermove", handlePointerMove);
container.removeEventListener("pointerup", handlePointerEnd);
container.removeEventListener("pointercancel", handlePointerEnd);
};
}, []);
// Zoom segment (/+) buttons zoom around the container's center.
function zoomByFactor(factor: number) {
const container = containerRef.current;
if (!container) return;
const rect = container.getBoundingClientRect();
setView((v) => zoomAt(v, rect.width / 2, rect.height / 2, factor));
}
const pct = Math.round(view.scale * 100);
const first = svgs[0];
const firstStats = first ? svgStats(first.svg) : null;
const showSheetLabels = svgs.length > 1;
return (
<div className="preview-pane">
<div className="preview-header">
<div className="diagram-header-left">
{isEditingTitle ? (
<input
ref={titleInputRef}
className="diagram-name-input"
data-testid="diagram-title"
value={titleDraft}
onChange={(e) => setTitleDraft(e.target.value)}
onBlur={commitTitleEdit}
onKeyDown={handleTitleKeyDown}
/>
) : (
<span
className={onRenameDiagram ? "diagram-name diagram-name--editable" : "diagram-name"}
data-testid="diagram-title"
tabIndex={onRenameDiagram ? 0 : undefined}
role={onRenameDiagram ? "button" : undefined}
aria-label={onRenameDiagram ? "Rename diagram" : undefined}
onClick={onRenameDiagram ? startEditingTitle : undefined}
onKeyDown={
onRenameDiagram
? (e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
startEditingTitle();
}
}
: undefined
}
title={onRenameDiagram ? "Click to rename" : undefined}
>
{first?.name || "diagram"}
{onRenameDiagram && (
<span className="diagram-name-edit-icon" aria-hidden="true">
</span>
)}
</span>
)}
{firstStats && (
<span className="diagram-meta">
· {firstStats.nodes} nodes · {firstStats.edges} edges
</span>
)}
</div>
<div className="diagram-header-right">
<div className="zoom-segment">
<button onClick={() => zoomByFactor(1 / 1.2)} aria-label="Zoom out">
</button>
<button className="zoom-pct" onClick={fitToView} aria-label="Fit">
{pct}%
</button>
<button onClick={() => zoomByFactor(1.2)} aria-label="Zoom in">
+
</button>
</div>
</div>
</div>
{/* testid lives on the canvas (not the pane root) so e2e's
`preview svg` matches only rendered diagram SVGs the ExportBar
docked below carries its own decorative icon <svg>s. */}
<div
className="preview-canvas"
data-testid="preview"
ref={containerRef}
style={{ backgroundPosition: `${view.tx}px ${view.ty}px` }}
>
<div
className="preview-content"
ref={contentRef}
style={{ transform: `translate(${view.tx}px, ${view.ty}px) scale(${view.scale})` }}
>
{svgs.map(({ name, svg }, i) => (
<section key={`${name}-${i}`} className="diagram-block">
{showSheetLabels && <div className="sheet-label">{name || "diagram"}</div>}
<div className="diagram-card">
<div className="preview-svg" dangerouslySetInnerHTML={{ __html: svg }} />
</div>
</section>
))}
</div>
{loading && <span className="preview-overlay-chip">Rendering</span>}
{renderMs != null && <span className="render-ms-chip">rendered in {renderMs}ms</span>}
</div>
{children}
</div>
);
}

@ -0,0 +1,143 @@
import { useEffect, useState } from "react";
import { formatStars } from "../utils/format";
import { toggleTheme } from "../utils/theme";
interface Props {
status: string;
onShare: () => void;
shared: boolean;
}
function statusVariant(status: string): "ready" | "busy" | "error" {
if (status === "Ready") return "ready";
if (status.startsWith("Failed")) return "error";
return "busy";
}
const STARS_CACHE_KEY = "dgp-gh-stars";
const STARS_TTL_MS = 60 * 60 * 1000; // 1h
const STARS_API_URL = "https://api.github.com/repos/mingrammer/diagrams";
// The badge must always render — when the API is unreachable (e.g. rate
// limited) and no cache exists yet, fall back to this approximate count;
// it self-corrects on the next successful fetch.
const FALLBACK_STARS = 42_500;
interface StarsCache {
count: number;
ts: number;
}
function readCachedStars(ignoreTtl = false): number | null {
try {
const raw = localStorage.getItem(STARS_CACHE_KEY);
if (!raw) return null;
const parsed = JSON.parse(raw) as StarsCache;
if (typeof parsed.count !== "number" || typeof parsed.ts !== "number") return null;
if (!ignoreTtl && Date.now() - parsed.ts > STARS_TTL_MS) return null;
return parsed.count;
} catch {
return null;
}
}
// GitHub's official octocat mark, inlined so it renders crisp at 16px with
// no extra request and follows `currentColor` in both themes.
function GitHubMark() {
return (
<svg width="16" height="16" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.01 8.01 0 0 0 16 8c0-4.42-3.58-8-8-8Z" />
</svg>
);
}
// Small filled star, used ahead of the formatted count.
function StarMark() {
return (
<svg width="10" height="10" viewBox="0 0 16 16" fill="currentColor" aria-hidden="true">
<path d="M8 .5l2.2 4.7 5.1.6-3.8 3.6.9 5.1L8 12l-4.4 2.5.9-5.1L.7 5.8l5.1-.6L8 .5Z" />
</svg>
);
}
export default function Toolbar({ status, onShare, shared }: Props) {
const isLoading = status === "Rendering…";
const variant = statusVariant(status);
// Stale-while-error: fresh cache → stale cache (expired TTL) → baked-in
// fallback, so the count is always visible; a successful fetch replaces it.
const [stars, setStars] = useState<number>(
() => readCachedStars() ?? readCachedStars(true) ?? FALLBACK_STARS
);
useEffect(() => {
if (readCachedStars() !== null) return; // fresh cache already applied above
let cancelled = false;
fetch(STARS_API_URL)
.then((res) => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then((data: { stargazers_count?: unknown }) => {
if (cancelled) return;
const count = data.stargazers_count;
if (typeof count !== "number") return;
setStars(count);
localStorage.setItem(STARS_CACHE_KEY, JSON.stringify({ count, ts: Date.now() } satisfies StarsCache));
})
.catch(() => {
// Fetch failed (network, rate limit, bad shape): keep whatever count
// is already displayed — a fresh/stale cached value or the baked-in
// fallback — rather than surfacing an error.
});
return () => {
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
return (
<header className={`toolbar${isLoading ? " is-loading" : ""}`}>
{/* The real diagrams project logo (copied from assets/img/diagrams.png)
on a white chip so its dark strokes stay legible in dark theme. */}
<span className="app-mark" aria-hidden="true">
<img src="diagrams-logo.png" alt="" width={22} height={22} />
</span>
<strong className="toolbar-title">Diagrams Playground</strong>
<div className={`status-pill status-pill--${variant}`}>
<span className="status-dot" aria-hidden="true" />
<span data-testid="status">{status}</span>
</div>
<span className="toolbar-spacer" />
<div className="toolbar-actions">
<button onClick={toggleTheme} aria-label="Toggle theme" className="btn-ghost" data-testid="theme-toggle">
Theme
</button>
<a
href="https://github.com/mingrammer/diagrams"
target="_blank"
rel="noreferrer"
className="btn-ghost"
aria-label={`GitHub repository, ${stars} stars`}
>
<GitHubMark />
GitHub
<span className="gh-stars">
<StarMark />
{formatStars(stars)}
</span>
</a>
<a
href="https://diagrams.mingrammer.com"
target="_blank"
rel="noreferrer"
className="btn-ghost"
aria-label="Documentation"
>
Docs
</a>
<button onClick={onShare} className="btn-share" data-testid="share-button">
{shared ? "Link copied!" : "Share"}
</button>
</div>
</header>
);
}

@ -0,0 +1,98 @@
import type { Extension } from "@codemirror/state";
import { HighlightStyle, syntaxHighlighting } from "@codemirror/language";
import { EditorView } from "@codemirror/view";
import { tags as t } from "@lezer/highlight";
// Blueprint engineering drawing theme for CodeMirror.
// Static module-level constant — imported once into EditorPane's static
// extension list at mount time (see HARD CONSTRAINT #4 in the redesign spec).
//
// Every color below is a var(--cm-*)/var(--syn-*) reference into app.css's
// per-theme token blocks (:root[data-theme="dark"|"light"]). CodeMirror's
// theme values are plain CSS strings, so var() resolves live against
// document.documentElement's data-theme attribute — flipping the theme
// re-paints the editor instantly with no remount required.
const blueprintEditorTheme = EditorView.theme(
{
"&": {
backgroundColor: "var(--cm-bg)",
color: "var(--cm-fg)",
height: "100%",
},
".cm-content": {
caretColor: "var(--cm-caret)",
fontFamily: "'IBM Plex Mono', ui-monospace, monospace",
fontSize: "13px",
},
".cm-cursor, .cm-dropCursor": {
borderLeftColor: "var(--cm-caret)",
borderLeftWidth: "2px",
},
// `!important` is required here: @codemirror/view's own baseTheme (part
// of basicSetup) ships a same-named `&dark.cm-focused > .cm-scroller >
// .cm-selectionLayer .cm-selectionBackground` rule with strictly higher
// selector specificity (it targets the internal DOM chain, not just the
// class) that otherwise wins the cascade and paints an opaque `#233`
// regardless of this token's value or insertion order — that mismatch,
// not the token color itself, was the actual cause of "selection too
// dark/opaque" (confirmed via getComputedStyle: it read `rgb(34,51,51)`
// — CodeMirror's literal default — in both app themes before this fix).
"&.cm-focused .cm-selectionBackground, .cm-selectionBackground, .cm-content ::selection": {
backgroundColor: "var(--cm-selection) !important",
},
".cm-activeLine": {
backgroundColor: "var(--cm-active-line)",
},
".cm-gutters": {
backgroundColor: "var(--cm-gutter-bg)",
color: "var(--cm-gutter-fg)",
border: "none",
borderRight: "1px solid var(--cm-gutter-border)",
},
".cm-activeLineGutter": {
backgroundColor: "var(--cm-active-gutter-bg)",
color: "var(--cm-active-gutter-fg)",
},
".cm-lineNumbers .cm-gutterElement": {
fontFamily: "'IBM Plex Mono', ui-monospace, monospace",
},
".cm-selectionMatch": {
backgroundColor: "var(--cm-selection-match)",
},
".cm-matchingBracket, .cm-nonmatchingBracket": {
backgroundColor: "var(--cm-matching-bracket-bg)",
outline: "1px solid var(--cm-matching-bracket-outline)",
},
".cm-tooltip": {
backgroundColor: "var(--cm-tooltip-bg)",
border: "1px solid var(--cm-tooltip-border)",
color: "var(--cm-tooltip-fg)",
fontFamily: "'IBM Plex Mono', ui-monospace, monospace",
},
".cm-tooltip-autocomplete ul li[aria-selected]": {
backgroundColor: "var(--cm-autocomplete-selected-bg)",
color: "var(--cm-autocomplete-selected-fg)",
},
},
{ dark: true }
);
const blueprintHighlightStyle = HighlightStyle.define([
{ tag: t.keyword, color: "var(--syn-keyword)" },
{ tag: [t.string, t.special(t.string)], color: "var(--syn-string)" },
{ tag: [t.comment, t.lineComment, t.blockComment, t.docComment], color: "var(--syn-comment)", fontStyle: "italic" },
{
tag: [t.function(t.variableName), t.function(t.definition(t.variableName)), t.className, t.definition(t.className)],
color: "var(--syn-function)",
},
{ tag: [t.number, t.integer, t.float], color: "var(--syn-number)" },
{ tag: [t.operator, t.punctuation, t.bracket], color: "var(--syn-operator)" },
{ tag: t.variableName, color: "var(--syn-variable)" },
{ tag: t.propertyName, color: "var(--syn-property)" },
{ tag: [t.bool, t.null], color: "var(--syn-bool)" },
{ tag: t.definition(t.variableName), color: "var(--syn-variable)" },
{ tag: t.atom, color: "var(--syn-atom)" },
]);
export const blueprintTheme: Extension = [blueprintEditorTheme, syntaxHighlighting(blueprintHighlightStyle)];

@ -0,0 +1,80 @@
export const DEFAULT_CODE = `from diagrams import Diagram
from diagrams.aws.compute import EC2
from diagrams.aws.database import RDS
from diagrams.aws.network import ELB
with Diagram("Web Service", show=False):
ELB("lb") >> EC2("web") >> RDS("userdb")
`;
export const EXAMPLES: { title: string; code: string }[] = [
{ title: "Web Service", code: DEFAULT_CODE },
{
title: "Grouped Workers",
code: `from diagrams import Diagram
from diagrams.aws.compute import EC2
from diagrams.aws.database import RDS
from diagrams.aws.network import ELB
with Diagram("Grouped Workers", show=False, direction="TB"):
ELB("lb") >> [EC2("worker1"),
EC2("worker2"),
EC2("worker3"),
EC2("worker4"),
EC2("worker5")] >> RDS("events")
`,
},
{
title: "Clustered Web Services",
code: `from diagrams import Cluster, Diagram
from diagrams.aws.compute import ECS
from diagrams.aws.database import ElastiCache, RDS
from diagrams.aws.network import ELB, Route53
with Diagram("Clustered Web Services", show=False):
dns = Route53("dns")
lb = ELB("lb")
with Cluster("Services"):
svc_group = [ECS("web1"), ECS("web2"), ECS("web3")]
with Cluster("DB Cluster"):
db_primary = RDS("userdb")
db_primary - [RDS("userdb ro")]
memcached = ElastiCache("memcached")
dns >> lb >> svc_group
svc_group >> db_primary
svc_group >> memcached
`,
},
{
title: "Event Processing (K8s + OnPrem)",
code: `from diagrams import Cluster, Diagram
from diagrams.aws.compute import ECS, EKS, Lambda
from diagrams.aws.database import Redshift
from diagrams.aws.integration import SQS
from diagrams.aws.storage import S3
with Diagram("Event Processing", show=False):
source = EKS("k8s source")
with Cluster("Event Flows"):
with Cluster("Event Workers"):
workers = [ECS("worker1"), ECS("worker2"), ECS("worker3")]
queue = SQS("event queue")
with Cluster("Processing"):
handlers = [Lambda("proc1"), Lambda("proc2"), Lambda("proc3")]
store = S3("events store")
dw = Redshift("analytics")
source >> workers >> queue >> handlers
handlers >> store
handlers >> dw
`,
},
];

@ -0,0 +1,77 @@
import { describe, expect, it, vi } from "vitest";
import { inlineIcons, resolveSize } from "./exporter";
const PNG_BYTES = new Uint8Array([137, 80, 78, 71]);
function fakeFetch(): typeof fetch {
return vi.fn(async () => new Response(PNG_BYTES.buffer, { status: 200 })) as unknown as typeof fetch;
}
describe("inlineIcons", () => {
it("replaces icons/ hrefs with data URIs", async () => {
const svg = `<svg><image xlink:href="icons/aws/compute/ec2.png"/></svg>`;
const result = await inlineIcons(svg, fakeFetch());
expect(result).toContain("data:image/png;base64,iVBORw==".slice(0, 30));
expect(result).not.toContain("icons/aws");
});
it("fetches each unique icon once", async () => {
const fetcher = fakeFetch();
const svg = `<svg><image xlink:href="icons/a.png"/><image xlink:href="icons/a.png"/><image xlink:href="icons/b.png"/></svg>`;
await inlineIcons(svg, fetcher);
expect(fetcher).toHaveBeenCalledTimes(2);
});
it("leaves svg without icons untouched", async () => {
const svg = "<svg><text>hi</text></svg>";
expect(await inlineIcons(svg, fakeFetch())).toBe(svg);
});
it("rejects when an icon fetch returns non-ok", async () => {
const fetcher = vi.fn(async () => new Response("nope", { status: 404 })) as unknown as typeof fetch;
const svg = `<svg><image xlink:href="icons/a.png"/></svg>`;
await expect(inlineIcons(svg, fetcher)).rejects.toThrow("HTTP 404");
});
it("uses svg mime for .svg icons", async () => {
const svg = `<svg><image xlink:href="icons/gis/georchestra/datafeeder.svg"/></svg>`;
const result = await inlineIcons(svg, fakeFetch());
expect(result).toContain("data:image/svg+xml;base64,");
});
});
describe("resolveSize", () => {
it("defaults to 2x the natural size when both axes are unset", () => {
expect(resolveSize(100, 50)).toEqual({ w: 200, h: 100 });
});
it("treats invalid (non-finite / non-positive) axes as unset, falling back to the 2x default", () => {
expect(resolveSize(200, 100, 0, Number.NaN)).toEqual({ w: 400, h: 200 });
});
it("derives height from the natural aspect ratio when only width is given", () => {
expect(resolveSize(200, 100, 50)).toEqual({ w: 50, h: 25 });
});
it("derives width from the natural aspect ratio when only height is given", () => {
expect(resolveSize(200, 100, undefined, 25)).toEqual({ w: 50, h: 25 });
});
it("uses both axes exactly, allowing aspect distortion", () => {
expect(resolveSize(200, 100, 300, 50)).toEqual({ w: 300, h: 50 });
});
it("clamps each axis to the 16px minimum independently after computation", () => {
// width=5 -> derived height = round(5 * 100 / 200) = 3, both below 16.
expect(resolveSize(200, 100, 5)).toEqual({ w: 16, h: 16 });
});
it("clamps each axis to the 8192px maximum independently after computation", () => {
expect(resolveSize(200, 100, 20_000)).toEqual({ w: 8192, h: 8192 });
});
it("falls back to the (clamped) 2x default when the natural size is degenerate, ignoring width/height", () => {
expect(resolveSize(0, 100, 300, 150)).toEqual({ w: 16, h: 200 });
expect(resolveSize(-10, -20)).toEqual({ w: 16, h: 16 });
});
});

@ -0,0 +1,116 @@
const ICON_HREF = /(xlink:href|href)="(icons\/[^"]+)"/g;
async function toDataUri(url: string, fetcher: typeof fetch): Promise<string> {
const res = await fetcher(url);
if (!res.ok) throw new Error(`Failed to fetch icon ${url}: HTTP ${res.status}`);
const mime = url.endsWith(".svg") ? "image/svg+xml" : "image/png";
const bytes = new Uint8Array(await res.arrayBuffer());
let binary = "";
for (const byte of bytes) binary += String.fromCharCode(byte);
return `data:${mime};base64,${btoa(binary)}`;
}
export async function inlineIcons(svg: string, fetcher: typeof fetch = fetch): Promise<string> {
const urls = new Set<string>();
for (const match of svg.matchAll(ICON_HREF)) urls.add(match[2]);
if (!urls.size) return svg;
const dataUris = new Map<string, string>();
await Promise.all(
[...urls].map(async (url) => dataUris.set(url, await toDataUri(url, fetcher)))
);
return svg.replace(ICON_HREF, (_full, attr, url) => `${attr}="${dataUris.get(url)}"`);
}
export interface OutputSize {
w: number;
h: number;
}
const MIN_OUTPUT_SIZE = 16;
const MAX_OUTPUT_SIZE = 8192;
function clampAxis(n: number): number {
return Math.min(MAX_OUTPUT_SIZE, Math.max(MIN_OUTPUT_SIZE, Math.round(n)));
}
// Resolves the raster output size for an export from the optional
// user-provided WIDTH/HEIGHT fields (both in px):
// - neither set (or not a positive finite number) -> defaults to 2x the
// diagram's natural size, matching the export bar's previous default
// output.
// - only one axis set -> the other axis is derived from the diagram's
// natural aspect ratio, so the diagram is never stretched by accident.
// - both axes set -> used exactly as given; the user explicitly asked for
// both dimensions, so aspect distortion is allowed.
// A degenerate natural size (<=0 on either axis, e.g. before an <img> has
// finished loading) can't produce a meaningful aspect ratio, so it's guarded
// by always falling back to the default-2x branch, which is itself then
// clamped, so the result is still a valid, positive canvas size.
// Every result is rounded and clamped per-axis to [MIN_OUTPUT_SIZE,
// MAX_OUTPUT_SIZE] so callers can hand it straight to a <canvas>.
export function resolveSize(naturalW: number, naturalH: number, width?: number, height?: number): OutputSize {
const validWidth = width !== undefined && Number.isFinite(width) && width > 0;
const validHeight = height !== undefined && Number.isFinite(height) && height > 0;
const naturalOk = naturalW > 0 && naturalH > 0;
let w: number;
let h: number;
if (naturalOk && validWidth && validHeight) {
w = width as number;
h = height as number;
} else if (naturalOk && validWidth) {
w = width as number;
h = ((width as number) * naturalH) / naturalW;
} else if (naturalOk && validHeight) {
h = height as number;
w = ((height as number) * naturalW) / naturalH;
} else {
w = naturalW * 2;
h = naturalH * 2;
}
return { w: clampAxis(w), h: clampAxis(h) };
}
export interface PngExportOptions {
width?: number;
height?: number;
mime?: "image/png" | "image/jpeg";
}
export async function svgToPngBlob(svg: string, options?: PngExportOptions): Promise<Blob> {
const { mime = "image/png", width, height } = options ?? {};
const inlined = await inlineIcons(svg);
const svgBlob = new Blob([inlined], { type: "image/svg+xml" });
const url = URL.createObjectURL(svgBlob);
try {
const img = new Image();
await new Promise<void>((resolve, reject) => {
img.onload = () => resolve();
img.onerror = () => reject(new Error("Failed to load SVG for export"));
img.src = url;
});
const { w, h } = resolveSize(img.naturalWidth, img.naturalHeight, width, height);
const canvas = document.createElement("canvas");
canvas.width = w;
canvas.height = h;
const ctx = canvas.getContext("2d")!;
ctx.fillStyle = "white";
ctx.fillRect(0, 0, canvas.width, canvas.height);
ctx.scale(w / img.naturalWidth, h / img.naturalHeight);
ctx.drawImage(img, 0, 0);
return await new Promise<Blob>((resolve, reject) =>
canvas.toBlob((blob) => (blob ? resolve(blob) : reject(new Error("toBlob failed"))), mime)
);
} finally {
URL.revokeObjectURL(url);
}
}
export function download(filename: string, blob: Blob): void {
const url = URL.createObjectURL(blob);
const anchor = document.createElement("a");
anchor.href = url;
anchor.download = filename;
anchor.click();
URL.revokeObjectURL(url);
}

@ -0,0 +1,10 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
import "./app.css";
ReactDOM.createRoot(document.getElementById("root")!).render(
<React.StrictMode>
<App />
</React.StrictMode>
);

@ -0,0 +1,33 @@
import { describe, expect, it } from "vitest";
import { renderDot } from "./render";
describe("renderDot", () => {
it("renders plain dot to svg", async () => {
const svg = await renderDot("digraph { a -> b }");
expect(svg).toContain("<svg");
// Graphviz emits the edge title as "a&#45;&gt;b" (hyphen as a numeric
// character reference), but DOMPurify's DOM parse/serialize round-trip
// normalizes "&#45;" to the literal "-" character, since re-encoding a
// plain hyphen is never required for well-formed XML/HTML. This is true
// of any spec-compliant DOM serializer, not just DOMPurify (verified
// directly with jsdom's DOMParser/XMLSerializer). See task-5-report.md.
expect(svg).toContain("a-&gt;b");
}, 30_000);
it("keeps image nodes and rewrites their hrefs", async () => {
const dot = `digraph {
n [label="web" height="1.9" image="/site-packages/resources/aws/compute/ec2.png" shape=none fixedsize=true width="1.4"]
}`;
const svg = await renderDot(dot);
expect(svg).toContain(`icons/aws/compute/ec2.png`);
}, 30_000);
it("strips script elements injected via labels", async () => {
const svg = await renderDot(`digraph { a [label="<<script>alert(1)</script>x>"] }`);
expect(svg).not.toContain("<script");
}, 30_000);
it("throws on invalid dot", async () => {
await expect(renderDot("digraph {")).rejects.toThrow();
}, 30_000);
});

@ -0,0 +1,32 @@
import { Graphviz } from "@hpcc-js/wasm-graphviz";
import DOMPurify from "dompurify";
import { extractImagePaths, rewriteSvgImages } from "./rewrite";
let graphvizPromise: Promise<Graphviz> | null = null;
function getGraphviz(): Promise<Graphviz> {
graphvizPromise ??= Graphviz.load().catch((err) => {
graphvizPromise = null; // allow retry on the next render call
throw err;
});
return graphvizPromise;
}
export async function renderDot(dot: string): Promise<string> {
const graphviz = await getGraphviz();
// Register each referenced icon as a stub file so Graphviz emits the
// <image> element. Node sizes are fixed (fixedsize=true) so the stub
// dimensions do not affect layout.
const images = extractImagePaths(dot).map((path) => ({
path,
width: "256px",
height: "256px",
}));
const svg = graphviz.layout(dot, "svg", "dot", { images });
if (!svg) throw new Error("Graphviz returned empty output");
return DOMPurify.sanitize(rewriteSvgImages(svg), {
USE_PROFILES: { svg: true, svgFilters: true },
ADD_TAGS: ["image"],
ADD_ATTR: ["xlink:href"],
});
}

@ -0,0 +1,59 @@
import { describe, expect, it } from "vitest";
import { extractImagePaths, rewriteSvgImages, toIconUrl } from "./rewrite";
const DOT = `digraph "Web Service" {
a [label=lb image="/lib/python3.12/site-packages/resources/aws/network/elastic-load-balancing.png" shape=none]
b [label=web image="/lib/python3.12/site-packages/resources/aws/compute/ec2.png" shape=none]
c [label=web2 image="/lib/python3.12/site-packages/resources/aws/compute/ec2.png" shape=none]
}`;
describe("extractImagePaths", () => {
it("finds unique image attribute values", () => {
expect(extractImagePaths(DOT)).toEqual([
"/lib/python3.12/site-packages/resources/aws/network/elastic-load-balancing.png",
"/lib/python3.12/site-packages/resources/aws/compute/ec2.png",
]);
});
it("returns empty array when no images", () => {
expect(extractImagePaths("digraph { a -> b }")).toEqual([]);
});
});
describe("toIconUrl", () => {
it("maps resources paths to icons/ urls", () => {
expect(toIconUrl("/x/site-packages/resources/aws/compute/ec2.png")).toBe("icons/aws/compute/ec2.png");
});
it("returns null for non-resources paths", () => {
expect(toIconUrl("/etc/passwd")).toBeNull();
});
});
describe("rewriteSvgImages", () => {
it("rewrites xlink:href to hosted icon urls", () => {
const svg = `<svg><image xlink:href="/a/resources/aws/compute/ec2.png" width="66px"/></svg>`;
expect(rewriteSvgImages(svg)).toContain(`xlink:href="icons/aws/compute/ec2.png"`);
});
it("blanks external image hrefs so no off-origin requests fire", () => {
const svg = `<svg><image xlink:href="https://evil.example/x.png"/></svg>`;
const result = rewriteSvgImages(svg);
expect(result).not.toContain("evil.example");
expect(result).toContain(`xlink:href=""`);
});
it("passes through already-local icons/ and data: hrefs", () => {
const svg = `<svg><image href="icons/aws/compute/ec2.png"/><image href="data:image/png;base64,AAAA"/></svg>`;
const result = rewriteSvgImages(svg);
expect(result).toContain(`href="icons/aws/compute/ec2.png"`);
expect(result).toContain(`href="data:image/png;base64,AAAA"`);
});
it("does not rewrite hrefs outside <image> elements", () => {
const svg = `<svg><a xlink:href="https://example.com/resources/aws/page.png"><text>n</text></a><image xlink:href="/x/resources/aws/compute/ec2.png"/></svg>`;
const result = rewriteSvgImages(svg);
expect(result).toContain(`<a xlink:href="https://example.com/resources/aws/page.png">`);
expect(result).toContain(`<image xlink:href="icons/aws/compute/ec2.png"`);
});
});

@ -0,0 +1,29 @@
const IMAGE_ATTR = /image="([^"]+)"/g;
const RESOURCES_SEGMENT = /\/resources\/(.+)$/;
const IMAGE_TAG = /<image\b[^>]*>/g;
export function extractImagePaths(dot: string): string[] {
const paths = new Set<string>();
for (const match of dot.matchAll(IMAGE_ATTR)) paths.add(match[1]);
return [...paths];
}
export function toIconUrl(absPath: string): string | null {
const match = absPath.match(RESOURCES_SEGMENT);
return match ? `icons/${match[1]}` : null;
}
export function rewriteSvgImages(svg: string): string {
return svg.replace(IMAGE_TAG, (tag) =>
tag.replace(/(xlink:href|href)="([^"]*)"/g, (_full, attr, value) => {
const iconUrl = toIconUrl(value);
if (iconUrl) return `${attr}="${iconUrl}"`;
// Pass through already-safe local/inline references; blank anything
// else. The sanitized SVG is injected into the DOM, so an external
// href from user-controlled DOT could otherwise fire off-origin
// image requests (tracking pixels, etc.).
if (value.startsWith("icons/") || value.startsWith("data:")) return `${attr}="${value}"`;
return `${attr}=""`;
})
);
}

@ -0,0 +1,38 @@
import { describe, expect, it } from "vitest";
import { providerLabel } from "./providerLabel";
describe("providerLabel", () => {
it("maps known provider keys to their display names", () => {
expect(providerLabel("aws")).toBe("AWS");
expect(providerLabel("gcp")).toBe("GCP");
expect(providerLabel("k8s")).toBe("K8s");
expect(providerLabel("onprem")).toBe("OnPrem");
expect(providerLabel("alibabacloud")).toBe("AlibabaCloud");
expect(providerLabel("digitalocean")).toBe("DigitalOcean");
expect(providerLabel("ibm")).toBe("IBM");
expect(providerLabel("oci")).toBe("OCI");
expect(providerLabel("openstack")).toBe("OpenStack");
expect(providerLabel("saas")).toBe("SaaS");
expect(providerLabel("elastic")).toBe("Elastic");
expect(providerLabel("firebase")).toBe("Firebase");
expect(providerLabel("azure")).toBe("Azure");
expect(providerLabel("generic")).toBe("Generic");
expect(providerLabel("programming")).toBe("Programming");
expect(providerLabel("outscale")).toBe("Outscale");
expect(providerLabel("gis")).toBe("GIS");
expect(providerLabel("c4")).toBe("C4");
});
it("falls back to capitalizing unknown providers", () => {
expect(providerLabel("unknownvendor")).toBe("Unknownvendor");
expect(providerLabel("foo")).toBe("Foo");
});
it("capitalize fallback lowercases the remainder of the string", () => {
expect(providerLabel("FOObar")).toBe("Foobar");
});
it("handles an empty string without throwing", () => {
expect(providerLabel("")).toBe("");
});
});

@ -0,0 +1,32 @@
// Maps a catalog module's provider segment (e.g. "aws", "k8s") to the
// display name shown as a tree row in the sidebar. Known providers use their
// canonical casing; anything else falls back to a simple capitalize.
const KNOWN_LABELS: Record<string, string> = {
aws: "AWS",
gcp: "GCP",
k8s: "K8s",
onprem: "OnPrem",
alibabacloud: "AlibabaCloud",
digitalocean: "DigitalOcean",
ibm: "IBM",
oci: "OCI",
openstack: "OpenStack",
saas: "SaaS",
elastic: "Elastic",
firebase: "Firebase",
azure: "Azure",
generic: "Generic",
programming: "Programming",
outscale: "Outscale",
gis: "GIS",
c4: "C4",
};
function capitalize(value: string): string {
if (!value) return value;
return value.charAt(0).toUpperCase() + value.slice(1).toLowerCase();
}
export function providerLabel(provider: string): string {
return KNOWN_LABELS[provider.toLowerCase()] ?? capitalize(provider);
}

@ -0,0 +1,49 @@
import { describe, expect, it } from "vitest";
import type { Catalog } from "../types";
import { searchCatalog } from "./search";
const CATALOG: Catalog = {
modules: {
"diagrams.aws.compute": [
{ name: "EC2", aliases: [], icon: "aws/compute/ec2.png" },
{ name: "EC2AutoScaling", aliases: [], icon: "aws/compute/ec2-auto-scaling.png" },
{ name: "ElasticContainerService", aliases: ["ECS"], icon: "aws/compute/ecs.png" },
],
"diagrams.onprem.database": [{ name: "PostgreSQL", aliases: ["Postgresql"], icon: "onprem/database/postgresql.png" }],
},
signatures: { Diagram: [], Cluster: [], Edge: [] },
};
describe("searchCatalog", () => {
it("ranks exact match first, then prefix, then substring", () => {
const hits = searchCatalog(CATALOG, "ec2");
expect(hits[0].name).toBe("EC2");
expect(hits[1].name).toBe("EC2AutoScaling");
});
it("matches aliases and reports the alias as name", () => {
const hits = searchCatalog(CATALOG, "ecs");
expect(hits.some((h) => h.name === "ECS")).toBe(true);
});
it("builds a ready-to-paste import statement", () => {
const [hit] = searchCatalog(CATALOG, "postgresql");
expect(hit.importStmt).toBe("from diagrams.onprem.database import PostgreSQL");
});
it("returns empty for blank query", () => {
expect(searchCatalog(CATALOG, " ")).toEqual([]);
});
it("builds importStmt from the matched alias so insert binds the shown name", () => {
const hit = searchCatalog(CATALOG, "ecs").find((h) => h.name === "ECS");
expect(hit?.importStmt).toBe("from diagrams.aws.compute import ECS");
});
it("dedupes case-insensitively identical alias/name pairs", () => {
const hits = searchCatalog(CATALOG, "postgresql");
expect(hits).toHaveLength(1);
expect(hits[0].name).toBe("PostgreSQL");
expect(hits[0].importStmt).toBe("from diagrams.onprem.database import PostgreSQL");
});
});

@ -0,0 +1,44 @@
import type { Catalog } from "../types";
export interface SearchHit {
module: string;
name: string;
icon: string;
importStmt: string;
}
function rank(candidate: string, query: string): number {
const lower = candidate.toLowerCase();
if (lower === query) return 0;
if (lower.startsWith(query)) return 1;
if (lower.includes(query)) return 2;
return -1;
}
export function searchCatalog(catalog: Catalog, query: string, limit = 50): SearchHit[] {
const q = query.trim().toLowerCase();
if (!q) return [];
const scored: (SearchHit & { score: number })[] = [];
for (const [module, classes] of Object.entries(catalog.modules)) {
for (const cls of classes) {
const candidates = [
cls.name,
...cls.aliases.filter((a) => a.toLowerCase() !== cls.name.toLowerCase()),
];
for (const name of candidates) {
const score = rank(name, q);
if (score >= 0) {
scored.push({
module,
name,
icon: cls.icon,
importStmt: `from ${module} import ${name}`,
score,
});
}
}
}
}
scored.sort((a, b) => a.score - b.score || a.name.length - b.name.length || a.name.localeCompare(b.name));
return scored.slice(0, limit).map(({ score: _score, ...hit }) => hit);
}

@ -0,0 +1,76 @@
import { describe, expect, it } from "vitest";
import type { Catalog } from "../types";
import { catalogTree } from "./tree";
const CATALOG: Catalog = {
modules: {
"diagrams.aws.compute": [
{ name: "EC2", aliases: [], icon: "aws/compute/ec2.png" },
{ name: "EC2AutoScaling", aliases: [], icon: "aws/compute/ec2-auto-scaling.png" },
],
"diagrams.aws.database": [{ name: "RDS", aliases: [], icon: "aws/database/rds.png" }],
// Two-segment module: diagrams.<provider> with no category segment.
"diagrams.aws": [{ name: "AWS", aliases: [], icon: "aws/aws.png" }],
"diagrams.gcp.compute": [{ name: "GCE", aliases: [], icon: "gcp/compute/gce.png" }],
// Provider that only ever appears as a two-segment module.
"diagrams.c4": [
{ name: "Person", aliases: [], icon: "c4/person.png" },
{ name: "System", aliases: [], icon: "c4/system.png" },
],
},
signatures: {},
};
describe("catalogTree", () => {
const tree = catalogTree(CATALOG);
it("groups modules by provider (the segment after 'diagrams')", () => {
expect(tree.map((p) => p.provider)).toEqual(["aws", "c4", "gcp"]);
});
it("sorts providers alphabetically", () => {
const providers = tree.map((p) => p.provider);
expect(providers).toEqual([...providers].sort());
});
it("sorts categories alphabetically within a provider", () => {
const aws = tree.find((p) => p.provider === "aws")!;
const categories = aws.categories.map((c) => c.category);
expect(categories).toEqual([...categories].sort());
});
it("counts total classes under each provider, across all its modules", () => {
const aws = tree.find((p) => p.provider === "aws")!;
// 2 (compute) + 1 (database) + 1 (bare diagrams.aws) = 4
expect(aws.count).toBe(4);
const gcp = tree.find((p) => p.provider === "gcp")!;
expect(gcp.count).toBe(1);
const c4 = tree.find((p) => p.provider === "c4")!;
expect(c4.count).toBe(2);
});
it("handles the two-segment module case (diagrams.<provider> with no category) gracefully", () => {
const aws = tree.find((p) => p.provider === "aws")!;
const bareCategory = aws.categories.find((c) => c.module === "diagrams.aws")!;
expect(bareCategory).toBeDefined();
expect(bareCategory.classes.map((c) => c.name)).toEqual(["AWS"]);
const c4 = tree.find((p) => p.provider === "c4")!;
expect(c4.categories).toHaveLength(1);
expect(c4.categories[0].module).toBe("diagrams.c4");
expect(c4.categories[0].classes.map((c) => c.name)).toEqual(["Person", "System"]);
});
it("preserves each category's module key and classes", () => {
const aws = tree.find((p) => p.provider === "aws")!;
const compute = aws.categories.find((c) => c.module === "diagrams.aws.compute")!;
expect(compute.category).toBe("compute");
expect(compute.classes.map((c) => c.name)).toEqual(["EC2", "EC2AutoScaling"]);
});
it("returns an empty array for an empty catalog", () => {
expect(catalogTree({ modules: {}, signatures: {} })).toEqual([]);
});
});

@ -0,0 +1,42 @@
import type { Catalog, CatalogClass } from "../types";
export interface TreeCategory {
module: string;
category: string;
classes: CatalogClass[];
}
export interface TreeProvider {
provider: string;
count: number;
categories: TreeCategory[];
}
// Groups catalog module keys ("diagrams.<provider>.<category>") into a
// provider -> category -> classes tree for the sidebar's collapsed browse view.
//
// Two-segment modules ("diagrams.<provider>", e.g. a provider with no
// sub-category — diagrams.c4 in some catalogs) have no category segment;
// they're grouped under the provider with category "" so they still surface
// as a (single) expandable row instead of being dropped.
export function catalogTree(catalog: Catalog): TreeProvider[] {
const byProvider = new Map<string, TreeCategory[]>();
for (const [module, classes] of Object.entries(catalog.modules)) {
const parts = module.split(".");
const provider = parts[1] ?? module;
const category = parts.slice(2).join(".");
const categories = byProvider.get(provider);
const entry: TreeCategory = { module, category, classes };
if (categories) categories.push(entry);
else byProvider.set(provider, [entry]);
}
return [...byProvider.entries()]
.map(([provider, categories]) => ({
provider,
count: categories.reduce((sum, c) => sum + c.classes.length, 0),
categories: [...categories].sort((a, b) => a.category.localeCompare(b.category)),
}))
.sort((a, b) => a.provider.localeCompare(b.provider));
}

@ -0,0 +1,24 @@
import { describe, expect, it } from "vitest";
import { decodeShare, encodeShare } from "./codec";
describe("share codec", () => {
it("roundtrips code through encode/decode", () => {
const code = 'from diagrams import Diagram\nwith Diagram("웹", show=False):\n pass\n';
expect(decodeShare(encodeShare(code))).toBe(code);
});
it("produces URL-safe output (no +, /, =)", () => {
const encoded = encodeShare("a".repeat(500));
expect(encoded).not.toMatch(/[+/=]/);
});
it("decodes a full '#code=...' fragment", () => {
const encoded = encodeShare("x = 1");
expect(decodeShare(`#code=${encoded}`)).toBe("x = 1");
});
it("returns null for garbage input", () => {
expect(decodeShare("#code=!!notbase64!!")).toBeNull();
expect(decodeShare("")).toBeNull();
});
});

@ -0,0 +1,27 @@
import { deflate, inflate } from "pako";
const PREFIX = "#code=";
export function encodeShare(code: string): string {
const compressed = deflate(new TextEncoder().encode(code), { level: 9 });
let binary = "";
for (const byte of compressed) binary += String.fromCharCode(byte);
return btoa(binary).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, "");
}
export function decodeShare(fragment: string): string | null {
let value = fragment.startsWith(PREFIX) ? fragment.slice(PREFIX.length) : fragment;
value = value.replace(/^#/, "");
if (!value) return null;
try {
const base64 = value.replace(/-/g, "+").replace(/_/g, "/");
// encodeShare strips base64 padding; atob() requires it in some engines,
// so restore it to a multiple of 4 before decoding.
const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
const binary = atob(padded);
const bytes = Uint8Array.from(binary, (c) => c.charCodeAt(0));
return new TextDecoder().decode(inflate(bytes));
} catch {
return null;
}
}

@ -0,0 +1,23 @@
export interface DotResult {
name: string;
source: string;
}
export interface RunResult {
dots: DotResult[];
stdout: string;
error: string | null;
}
export type ProgressStage = "pyodide" | "packages" | "ready";
export interface CatalogClass {
name: string;
aliases: string[];
icon: string;
}
export interface Catalog {
modules: Record<string, CatalogClass[]>;
signatures: Record<string, string[]>;
}

@ -0,0 +1,31 @@
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { debounce } from "./debounce";
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
describe("debounce", () => {
it("fires once with the latest args after the wait", () => {
const spy = vi.fn();
const fn = debounce(spy, 500);
fn("a");
fn("b");
vi.advanceTimersByTime(499);
expect(spy).not.toHaveBeenCalled();
vi.advanceTimersByTime(1);
expect(spy).toHaveBeenCalledOnce();
expect(spy).toHaveBeenCalledWith("b");
});
it("restarts the wait on each call", () => {
const spy = vi.fn();
const fn = debounce(spy, 500);
fn("a");
vi.advanceTimersByTime(400);
fn("b");
vi.advanceTimersByTime(400);
expect(spy).not.toHaveBeenCalled();
vi.advanceTimersByTime(100);
expect(spy).toHaveBeenCalledWith("b");
});
});

@ -0,0 +1,10 @@
export function debounce<A extends unknown[]>(
fn: (...args: A) => void,
ms: number
): (...args: A) => void {
let timer: ReturnType<typeof setTimeout> | undefined;
return (...args: A) => {
clearTimeout(timer);
timer = setTimeout(() => fn(...args), ms);
};
}

@ -0,0 +1,20 @@
import { describe, expect, it } from "vitest";
import { formatStars } from "./format";
describe("formatStars", () => {
it("returns the plain integer below 1000", () => {
expect(formatStars(512)).toBe("512");
});
it("formats exactly 1000 as 1k (trailing .0 stripped)", () => {
expect(formatStars(1000)).toBe("1k");
});
it("formats with one decimal when non-zero", () => {
expect(formatStars(24340)).toBe("24.3k");
});
it("strips a trailing .0", () => {
expect(formatStars(24040)).toBe("24k");
});
});

@ -0,0 +1,12 @@
// Pure display-formatting helpers. `formatStars` renders a GitHub-style
// star count: plain below 1000, one decimal "k" above it with a trailing
// ".0" stripped (24340 -> "24.3k", 24040 -> "24k"). Rounds to the nearest
// hundred before dividing by 1000 (rather than rounding the divided value)
// to avoid float-precision artifacts like 24.299999999999997.
export function formatStars(n: number): string {
if (n < 1000) return String(n);
const tenths = Math.round(n / 100);
const k = tenths / 10;
const str = Number.isInteger(k) ? String(k) : k.toFixed(1);
return `${str}k`;
}

@ -0,0 +1,45 @@
import { describe, expect, it } from "vitest";
import { clampRatio, clampSidebarWidth, DEFAULT_SIDEBAR_WIDTH } from "./layout";
describe("clampRatio", () => {
it("passes through values within the 25-75 range", () => {
expect(clampRatio(50)).toBe(50);
expect(clampRatio(30)).toBe(30);
expect(clampRatio(70)).toBe(70);
});
it("clamps values below 25 up to 25", () => {
expect(clampRatio(10)).toBe(25);
expect(clampRatio(-100)).toBe(25);
});
it("clamps values above 75 down to 75", () => {
expect(clampRatio(90)).toBe(75);
expect(clampRatio(1000)).toBe(75);
});
it("clamps exactly at the boundaries", () => {
expect(clampRatio(25)).toBe(25);
expect(clampRatio(75)).toBe(75);
});
it("defaults to 50 for NaN input", () => {
expect(clampRatio(NaN)).toBe(50);
});
});
describe("clampSidebarWidth", () => {
it("passes through in-range widths", () => {
expect(clampSidebarWidth(260)).toBe(260);
expect(clampSidebarWidth(300)).toBe(300);
});
it("clamps to the 180-420 bounds", () => {
expect(clampSidebarWidth(50)).toBe(180);
expect(clampSidebarWidth(9999)).toBe(420);
});
it("falls back to the default for NaN", () => {
expect(clampSidebarWidth(Number.NaN)).toBe(DEFAULT_SIDEBAR_WIDTH);
});
});

@ -0,0 +1,21 @@
// Pure helper for the editor/preview split-pane ratio (percent, 0-100).
// Keeps both panes usable — never let a drag collapse one side entirely.
const MIN_RATIO = 25;
const MAX_RATIO = 75;
export const DEFAULT_RATIO = 50;
export function clampRatio(r: number): number {
if (Number.isNaN(r)) return DEFAULT_RATIO;
return Math.min(MAX_RATIO, Math.max(MIN_RATIO, r));
}
// Node-list sidebar width (px). Same idea: resizable but never collapsed
// into uselessness on either extreme.
const MIN_SIDEBAR_WIDTH = 180;
const MAX_SIDEBAR_WIDTH = 420;
export const DEFAULT_SIDEBAR_WIDTH = 260;
export function clampSidebarWidth(w: number): number {
if (Number.isNaN(w)) return DEFAULT_SIDEBAR_WIDTH;
return Math.min(MAX_SIDEBAR_WIDTH, Math.max(MIN_SIDEBAR_WIDTH, w));
}

@ -0,0 +1,62 @@
import { describe, expect, it } from "vitest";
import { renameDiagramInCode } from "./rename";
describe("renameDiagramInCode", () => {
it("renames a double-quoted Diagram name", () => {
const code = 'with Diagram("Web Service", show=False):\n pass\n';
expect(renameDiagramInCode(code, 0, "Renamed Arch")).toBe(
'with Diagram("Renamed Arch", show=False):\n pass\n'
);
});
it("renames a single-quoted Diagram name", () => {
const code = "with Diagram('Web Service', show=False):\n pass\n";
expect(renameDiagramInCode(code, 0, "Renamed Arch")).toBe(
"with Diagram('Renamed Arch', show=False):\n pass\n"
);
});
it("renames the second occurrence (index 1), leaving the first untouched", () => {
const code =
'with Diagram("First", show=False):\n pass\n\nwith Diagram("Second", show=False):\n pass\n';
expect(renameDiagramInCode(code, 1, "Renamed")).toBe(
'with Diagram("First", show=False):\n pass\n\nwith Diagram("Renamed", show=False):\n pass\n'
);
});
it("returns null when the argument is a variable, not a string literal", () => {
const code = "name = \"Web Service\"\nwith Diagram(name, show=False):\n pass\n";
expect(renameDiagramInCode(code, 0, "Renamed")).toBeNull();
});
it("returns null when the requested occurrence doesn't exist", () => {
const code = 'with Diagram("Only One", show=False):\n pass\n';
expect(renameDiagramInCode(code, 1, "Renamed")).toBeNull();
});
it("escapes double quotes and backslashes when rewriting a double-quoted literal", () => {
const code = 'with Diagram("Web Service", show=False):\n pass\n';
expect(renameDiagramInCode(code, 0, 'He said "hi" \\ there')).toBe(
'with Diagram("He said \\"hi\\" \\\\ there", show=False):\n pass\n'
);
});
it("escapes single quotes and backslashes when rewriting a single-quoted literal", () => {
const code = "with Diagram('Web Service', show=False):\n pass\n";
expect(renameDiagramInCode(code, 0, "It's a \\ test")).toBe(
"with Diagram('It\\'s a \\\\ test', show=False):\n pass\n"
);
});
it("allows an empty new name", () => {
const code = 'with Diagram("Web Service", show=False):\n pass\n';
expect(renameDiagramInCode(code, 0, "")).toBe('with Diagram("", show=False):\n pass\n');
});
it("tolerates whitespace between Diagram( and the string literal", () => {
const code = 'with Diagram( "Web Service", show=False):\n pass\n';
expect(renameDiagramInCode(code, 0, "Renamed")).toBe(
'with Diagram( "Renamed", show=False):\n pass\n'
);
});
});

@ -0,0 +1,46 @@
// Pure text-rewrite helper for the preview header's click-to-edit diagram
// title. Deliberately does not parse Python — it locates the (index+1)-th
// `Diagram(` call and, only when its first argument is a plain quoted string
// literal, replaces the literal's contents. Anything it can't confidently
// rewrite (missing occurrence, non-literal argument such as `Diagram(name)`)
// returns null so the caller can silently revert the UI edit rather than risk
// corrupting the user's code.
const DIAGRAM_CALL_RE = /\bDiagram\(\s*/g;
function escapeForQuote(value: string, quote: string): string {
return value.replace(/\\/g, "\\\\").split(quote).join(`\\${quote}`);
}
export function renameDiagramInCode(code: string, index: number, newName: string): string | null {
DIAGRAM_CALL_RE.lastIndex = 0;
let match: RegExpExecArray | null;
let occurrence = -1;
while ((match = DIAGRAM_CALL_RE.exec(code)) !== null) {
occurrence++;
if (occurrence !== index) continue;
const literalStart = match.index + match[0].length;
const quote = code[literalStart];
if (quote !== '"' && quote !== "'") return null;
let i = literalStart + 1;
let closed = false;
while (i < code.length) {
const ch = code[i];
if (ch === "\\" && i + 1 < code.length) {
i += 2;
continue;
}
if (ch === quote) {
closed = true;
break;
}
i++;
}
if (!closed) return null;
const escaped = escapeForQuote(newName, quote);
return code.slice(0, literalStart + 1) + escaped + code.slice(i);
}
return null;
}

@ -0,0 +1,36 @@
import { describe, expect, it } from "vitest";
import { svgStats } from "./svgStats";
const SAMPLE_SVG = `<svg>
<g id="graph0" class="graph">
<g id="node1" class="node"><title>a</title></g>
<g id="node2" class="node"><title>b</title></g>
<g id="node3" class="node"><title>c</title></g>
<g id="edge1" class="edge"><title>a-&gt;b</title></g>
<g id="edge2" class="edge"><title>b-&gt;c</title></g>
</g>
</svg>`;
describe("svgStats", () => {
it("counts node and edge groups in a rendered graphviz svg", () => {
expect(svgStats(SAMPLE_SVG)).toEqual({ nodes: 3, edges: 2 });
});
it("returns zeros for an empty string", () => {
expect(svgStats("")).toEqual({ nodes: 0, edges: 0 });
});
it("returns zeros when there are no node/edge groups", () => {
expect(svgStats(`<svg><g id="graph0" class="graph"></g></svg>`)).toEqual({ nodes: 0, edges: 0 });
});
it("does not count unrelated classes like graph or cluster", () => {
const svg = `<g class="graph"></g><g class="cluster"></g><g class="node"></g>`;
expect(svgStats(svg)).toEqual({ nodes: 1, edges: 0 });
});
it("counts single node and single edge", () => {
const svg = `<g class="node"></g><g class="edge"></g>`;
expect(svgStats(svg)).toEqual({ nodes: 1, edges: 1 });
});
});

@ -0,0 +1,27 @@
// Pure helper for the preview pane's header meta line. Graphviz marks every
// node and edge group with an exact class="node"/class="edge" attribute
// (verified against @hpcc-js/wasm-graphviz output — see render.test.ts for
// the same SVG shape), so a literal substring count is enough and avoids
// pulling in a DOM/XML parser just to count groups.
export interface SvgStats {
nodes: number;
edges: number;
}
function countOccurrences(haystack: string, needle: string): number {
if (!haystack) return 0;
let count = 0;
let index = haystack.indexOf(needle);
while (index !== -1) {
count++;
index = haystack.indexOf(needle, index + needle.length);
}
return count;
}
export function svgStats(svg: string): SvgStats {
return {
nodes: countOccurrences(svg, 'class="node"'),
edges: countOccurrences(svg, 'class="edge"'),
};
}

@ -0,0 +1,30 @@
// DOM-only theme mechanism: data-theme="light"|"dark" on <html>.
// Not pure logic (reads/writes localStorage + matchMedia + the DOM), so it's
// exercised via the app + e2e/manual verification rather than unit tests.
const STORAGE_KEY = "dgp-theme";
type Theme = "light" | "dark";
function isTheme(value: string | null): value is Theme {
return value === "light" || value === "dark";
}
function systemTheme(): Theme {
return window.matchMedia && window.matchMedia("(prefers-color-scheme: light)").matches ? "light" : "dark";
}
function apply(theme: Theme): void {
document.documentElement.setAttribute("data-theme", theme);
}
export function initTheme(): void {
const stored = localStorage.getItem(STORAGE_KEY);
apply(isTheme(stored) ? stored : systemTheme());
}
export function toggleTheme(): void {
const current = document.documentElement.getAttribute("data-theme");
const next: Theme = current === "dark" ? "light" : "dark";
apply(next);
localStorage.setItem(STORAGE_KEY, next);
}

@ -0,0 +1,123 @@
import { describe, expect, it } from "vitest";
import { clampZoom, fitView, zoomAt, type ViewTransform } from "./zoom";
describe("clampZoom", () => {
it("passes through values within the 0.2-4 range", () => {
expect(clampZoom(1)).toBe(1);
expect(clampZoom(0.5)).toBe(0.5);
expect(clampZoom(3)).toBe(3);
});
it("clamps values below 0.2 up to 0.2", () => {
expect(clampZoom(0.01)).toBe(0.2);
expect(clampZoom(-5)).toBe(0.2);
});
it("clamps values above 4 down to 4", () => {
expect(clampZoom(10)).toBe(4);
});
it("clamps exactly at the boundaries", () => {
expect(clampZoom(0.2)).toBe(0.2);
expect(clampZoom(4)).toBe(4);
});
});
describe("zoomAt", () => {
it("(a) keeps the world point under the cursor fixed across a zoom-in", () => {
const view: ViewTransform = { tx: 10, ty: 20, scale: 1 };
const cx = 100;
const cy = 50;
const worldBefore = { x: (cx - view.tx) / view.scale, y: (cy - view.ty) / view.scale };
const next = zoomAt(view, cx, cy, 2);
const worldAfter = { x: (cx - next.tx) / next.scale, y: (cy - next.ty) / next.scale };
expect(worldAfter.x).toBeCloseTo(worldBefore.x, 10);
expect(worldAfter.y).toBeCloseTo(worldBefore.y, 10);
});
it("(a) keeps the world point under the cursor fixed across a zoom-out", () => {
const view: ViewTransform = { tx: -35, ty: 60, scale: 2 };
const cx = 320;
const cy = 140;
const worldBefore = { x: (cx - view.tx) / view.scale, y: (cy - view.ty) / view.scale };
const next = zoomAt(view, cx, cy, 0.5);
const worldAfter = { x: (cx - next.tx) / next.scale, y: (cy - next.ty) / next.scale };
expect(worldAfter.x).toBeCloseTo(worldBefore.x, 10);
expect(worldAfter.y).toBeCloseTo(worldBefore.y, 10);
});
it("(b) clamps at the MAX bound and stops tx/ty from drifting further", () => {
const view: ViewTransform = { tx: 0, ty: 0, scale: 4 };
const next = zoomAt(view, 100, 100, 2);
expect(next.scale).toBe(4);
expect(next.tx).toBe(view.tx);
expect(next.ty).toBe(view.ty);
});
it("(b) clamps at the MIN bound and stops tx/ty from drifting further", () => {
const view: ViewTransform = { tx: 12, ty: -7, scale: 0.2 };
const next = zoomAt(view, 50, 50, 0.01);
expect(next.scale).toBe(0.2);
expect(next.tx).toBe(view.tx);
expect(next.ty).toBe(view.ty);
});
it("(c) factor 1 is a no-op (identity)", () => {
const view: ViewTransform = { tx: 15, ty: -8, scale: 1.5 };
const next = zoomAt(view, 123, 45, 1);
expect(next).toEqual(view);
});
});
describe("fitView", () => {
it("fits-wide: a canvas-relatively-wide content is width-constrained", () => {
// canvas 1000x1000, margin 48 -> available 904x904. Content 2000x500:
// scaleW = 904/2000 = 0.452, scaleH = 904/500 = 1.808 -> min is scaleW.
const view = fitView(1000, 1000, 2000, 500, 48);
expect(view.scale).toBeCloseTo(0.452, 10);
expect(view.tx).toBeCloseTo((1000 - 2000 * 0.452) / 2, 10);
expect(view.ty).toBeCloseTo((1000 - 500 * 0.452) / 2, 10);
});
it("fits-tall: a canvas-relatively-tall content is height-constrained", () => {
// Same canvas/margin. Content 500x2000:
// scaleW = 904/500 = 1.808, scaleH = 904/2000 = 0.452 -> min is scaleH.
const view = fitView(1000, 1000, 500, 2000, 48);
expect(view.scale).toBeCloseTo(0.452, 10);
expect(view.tx).toBeCloseTo((1000 - 500 * 0.452) / 2, 10);
expect(view.ty).toBeCloseTo((1000 - 2000 * 0.452) / 2, 10);
});
it("upscale-cap: tiny content is capped at scale 2, not blown up further", () => {
// canvas 1000x1000, margin 48 -> available 904x904. Content 10x10:
// raw min ratio is 90.4, but the cap clamps it to 2.
const view = fitView(1000, 1000, 10, 10, 48);
expect(view.scale).toBe(2);
expect(view.tx).toBeCloseTo((1000 - 10 * 2) / 2, 10);
expect(view.ty).toBeCloseTo((1000 - 10 * 2) / 2, 10);
});
it("centering math: content is centered on both axes at the computed scale", () => {
const view = fitView(1440, 900, 800, 400, 48);
// available: 1344x804. scaleW = 1344/800 = 1.68, scaleH = 804/400 = 2.01
// -> min is scaleW = 1.68, within [0.2, 2].
expect(view.scale).toBeCloseTo(1.68, 10);
expect(view.tx).toBeCloseTo((1440 - 800 * 1.68) / 2, 10);
expect(view.ty).toBeCloseTo((900 - 400 * 1.68) / 2, 10);
});
it("defaults margin to 48 when omitted", () => {
const withDefault = fitView(1000, 1000, 2000, 500);
const withExplicit = fitView(1000, 1000, 2000, 500, 48);
expect(withDefault).toEqual(withExplicit);
});
it("clamps the scale down to 0.2 for extremely oversized content", () => {
const view = fitView(1000, 1000, 100000, 100000, 48);
expect(view.scale).toBe(0.2);
});
});

@ -0,0 +1,57 @@
// Pure helpers for the preview pane's infinite-canvas pan/zoom transform.
const MIN_ZOOM = 0.2;
const MAX_ZOOM = 4;
export function clampZoom(z: number): number {
return Math.min(MAX_ZOOM, Math.max(MIN_ZOOM, z));
}
export interface ViewTransform {
tx: number;
ty: number;
scale: number;
}
// Zooms `view` by `factor`, keeping the world point currently under
// (cx, cy) — container-local coordinates — fixed on screen. Standard
// "zoom toward a point" formula for a translate-then-scale transform
// (`transform: translate(tx,ty) scale(s)` with `transform-origin: 0 0`):
// worldX = (cx - tx) / scale must be equal before and after, which solves to
// tx' = cx - (cx - tx) * (scale'/scale) (same for ty). `factor` is clamped
// via clampZoom first, so at the zoom bounds the ratio collapses to 1 and
// tx/ty are left untouched (no drift once zoom is maxed/minned out).
export function zoomAt(view: ViewTransform, cx: number, cy: number, factor: number): ViewTransform {
const scale = clampZoom(view.scale * factor);
const ratio = scale / view.scale;
return {
scale,
tx: cx - (cx - view.tx) * ratio,
ty: cy - (cy - view.ty) * ratio,
};
}
const FIT_MIN_SCALE = 0.2;
const FIT_MAX_SCALE = 2; // capped below zoomAt's MAX_ZOOM so small diagrams don't blow up blurry
const FIT_DEFAULT_MARGIN = 48;
// Computes the view transform that fits `contentW x contentH` inside
// `canvasW x canvasH` with `margin` px of breathing room on every side,
// centered on both axes. Used for the preview pane's initial "fit to view"
// size and its "%" button (now a re-fit rather than a reset-to-100%).
export function fitView(
canvasW: number,
canvasH: number,
contentW: number,
contentH: number,
margin: number = FIT_DEFAULT_MARGIN
): ViewTransform {
const availW = canvasW - 2 * margin;
const availH = canvasH - 2 * margin;
const rawScale = Math.min(availW / contentW, availH / contentH);
const scale = Math.min(FIT_MAX_SCALE, Math.max(FIT_MIN_SCALE, rawScale));
return {
scale,
tx: (canvasW - contentW * scale) / 2,
ty: (canvasH - contentH * scale) / 2,
};
}

@ -0,0 +1 @@
/// <reference types="vite/client" />

@ -0,0 +1,120 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import type { RunResult } from "../types";
import { PyClient, SupersededError, TimeoutError } from "./client";
const OK: RunResult = { dots: [{ name: "D", source: "digraph {}" }], stdout: "", error: null };
/** Fake Worker that intercepts postMessage; tests drive its responses. */
class FakeWorker {
static instances: FakeWorker[] = [];
onmessage: ((e: MessageEvent) => void) | null = null;
posted: unknown[] = [];
terminated = false;
constructor() {
FakeWorker.instances.push(this);
}
postMessage(msg: { type: string; id?: number }) {
this.posted.push(msg);
if (msg.type === "init") this.emit({ type: "ready" });
}
terminate() {
this.terminated = true;
}
emit(data: unknown) {
this.onmessage?.({ data } as MessageEvent);
}
lastRun() {
return this.posted.filter((m) => (m as { type: string }).type === "run").at(-1) as
| { type: "run"; id: number; code: string }
| undefined;
}
}
function makeClient() {
return new PyClient(() => new FakeWorker() as unknown as Worker, { fetchWheelUrl: async () => "wheels/x.whl" });
}
beforeEach(() => {
FakeWorker.instances = [];
vi.useRealTimers();
});
describe("PyClient", () => {
it("init resolves when worker reports ready", async () => {
const client = makeClient();
await expect(client.init()).resolves.toBeUndefined();
});
it("run resolves with the worker result", async () => {
const client = makeClient();
await client.init();
const worker = FakeWorker.instances[0];
const promise = client.run("code");
const run = worker.lastRun()!;
worker.emit({ type: "result", id: run.id, result: OK });
await expect(promise).resolves.toEqual(OK);
});
it("supersedes a queued run when a newer one arrives", async () => {
const client = makeClient();
await client.init();
const worker = FakeWorker.instances[0];
const first = client.run("first"); // in flight
const second = client.run("second"); // queued
const third = client.run("third"); // replaces second
await expect(second).rejects.toBeInstanceOf(SupersededError);
worker.emit({ type: "result", id: worker.lastRun()!.id, result: OK });
await first;
// after `first` resolves, the third run dispatches automatically
const thirdRun = worker.lastRun()!;
expect(thirdRun.code).toBe("third");
worker.emit({ type: "result", id: thirdRun.id, result: OK });
await expect(third).resolves.toEqual(OK);
});
it("rejects with TimeoutError and restarts the worker on timeout", async () => {
vi.useFakeTimers();
const client = makeClient();
await client.init();
const first = FakeWorker.instances[0];
const promise = client.run("while True: pass");
const rejection = expect(promise).rejects.toBeInstanceOf(TimeoutError);
await vi.advanceTimersByTimeAsync(10_000);
await rejection;
expect(first.terminated).toBe(true);
expect(FakeWorker.instances.length).toBe(2); // a fresh worker was spawned
});
it("rejects queued and future runs when respawn fails", async () => {
vi.useFakeTimers();
let calls = 0;
const client = new PyClient(() => new FakeWorker() as unknown as Worker, {
fetchWheelUrl: async () => {
calls += 1;
if (calls > 1) throw new Error("manifest gone");
return "wheels/x.whl";
},
});
await client.init();
const first = client.run("while True: pass");
const firstRejection = expect(first).rejects.toBeInstanceOf(TimeoutError);
const queued = client.run("queued");
const queuedRejection = expect(queued).rejects.toThrow("manifest gone");
await vi.advanceTimersByTimeAsync(10_000);
await firstRejection;
await queuedRejection;
await expect(client.run("later")).rejects.toThrow("manifest gone");
});
it("dispose rejects in-flight and future runs and terminates the worker", async () => {
const client = makeClient();
await client.init();
const worker = FakeWorker.instances[0];
const inFlight = client.run("code");
const rejection = expect(inFlight).rejects.toThrow("disposed");
client.dispose();
await rejection;
expect(worker.terminated).toBe(true);
await expect(client.run("later")).rejects.toThrow("disposed");
});
});

@ -0,0 +1,181 @@
import type { ProgressStage, RunResult } from "../types";
export const RUN_TIMEOUT_MS = 10_000;
export const INIT_TIMEOUT_MS = 120_000;
export class TimeoutError extends Error {
constructor() {
super("Execution timed out");
}
}
export class SupersededError extends Error {
constructor() {
super("Superseded by a newer run");
}
}
interface PendingRun {
code: string;
resolve: (r: RunResult) => void;
reject: (e: Error) => void;
}
interface ClientOptions {
fetchWheelUrl?: () => Promise<string>;
}
async function defaultFetchWheelUrl(): Promise<string> {
const res = await fetch("wheels/manifest.json");
const manifest = (await res.json()) as { wheel: string };
return `wheels/${manifest.wheel}`;
}
function defaultMakeWorker(): Worker {
return new Worker(new URL("./py.worker.ts", import.meta.url), { type: "module" });
}
export class PyClient {
private worker: Worker | null = null;
private ready = false;
private nextId = 1;
private inFlight: (PendingRun & { id: number; timer: ReturnType<typeof setTimeout> }) | null = null;
private queued: PendingRun | null = null;
private onProgress?: (s: ProgressStage) => void;
private dead: Error | null = null;
constructor(
private makeWorker: () => Worker = defaultMakeWorker,
private options: ClientOptions = {}
) {}
async init(onProgress?: (s: ProgressStage) => void): Promise<void> {
this.onProgress = onProgress;
await this.spawn();
}
private async spawn(): Promise<void> {
const rawWheelUrl = await (this.options.fetchWheelUrl ?? defaultFetchWheelUrl)();
// Resolve to an absolute URL against the *page's* location before handing it
// to the worker: the worker script has its own module URL (e.g. under
// /assets/ in a production build), so a relative path would otherwise
// resolve against the worker's location instead of the page's, sending
// micropip.install() to the wrong path (see task-14 e2e report).
const wheelUrl = new URL(rawWheelUrl, location.href).href;
this.worker = this.makeWorker();
this.worker.onmessage = (e) => this.handleMessage(e.data);
await new Promise<void>((resolve, reject) => {
const timer = setTimeout(() => reject(new TimeoutError()), INIT_TIMEOUT_MS);
this.resolveReady = () => {
clearTimeout(timer);
this.ready = true;
resolve();
};
this.rejectReady = (err) => {
clearTimeout(timer);
reject(err);
};
// Wire resolveReady/rejectReady before posting: some worker
// implementations (and the FakeWorker used in tests) may respond
// synchronously within postMessage, which would otherwise race
// ahead of these callbacks being assigned.
this.worker!.postMessage({ type: "init", wheelUrl });
});
}
private resolveReady: (() => void) | null = null;
private rejectReady: ((e: Error) => void) | null = null;
private handleMessage(msg: {
type: string;
stage?: ProgressStage;
id?: number;
result?: RunResult;
error?: string;
}) {
switch (msg.type) {
case "progress":
this.onProgress?.(msg.stage!);
break;
case "ready":
this.onProgress?.("ready");
this.resolveReady?.();
this.dispatchQueued();
break;
case "init-error":
this.rejectReady?.(new Error(msg.error));
break;
case "result":
case "run-error": {
if (!this.inFlight || this.inFlight.id !== msg.id) return;
const { resolve, reject, timer } = this.inFlight;
clearTimeout(timer);
this.inFlight = null;
if (msg.type === "result") resolve(msg.result!);
else reject(new Error(msg.error));
this.dispatchQueued();
break;
}
}
}
run(code: string): Promise<RunResult> {
return new Promise<RunResult>((resolve, reject) => {
if (this.dead) {
reject(this.dead);
return;
}
const pending: PendingRun = { code, resolve, reject };
if (this.inFlight || !this.ready) {
this.queued?.reject(new SupersededError());
this.queued = pending;
} else {
this.dispatch(pending);
}
});
}
private dispatchQueued() {
if (this.queued && !this.inFlight && this.ready) {
const next = this.queued;
this.queued = null;
this.dispatch(next);
}
}
private dispatch(pending: PendingRun) {
const id = this.nextId++;
const timer = setTimeout(() => this.handleTimeout(), RUN_TIMEOUT_MS);
this.inFlight = { ...pending, id, timer };
this.worker!.postMessage({ type: "run", id, code: pending.code });
}
private handleTimeout() {
const stalled = this.inFlight;
this.inFlight = null;
this.ready = false;
this.worker?.terminate();
stalled?.reject(new TimeoutError());
// Re-initialize a fresh worker in the background (any queued code runs next).
void this.spawn().catch((err) => {
this.dead = err instanceof Error ? err : new Error(String(err));
this.queued?.reject(this.dead);
this.queued = null;
});
}
/** Terminate the worker and reject all pending/future runs (unmount cleanup). */
dispose(): void {
this.dead = new Error("PyClient disposed");
this.rejectReady?.(this.dead);
this.queued?.reject(this.dead);
this.queued = null;
if (this.inFlight) {
clearTimeout(this.inFlight.timer);
this.inFlight.reject(this.dead);
this.inFlight = null;
}
this.worker?.terminate();
this.ready = false;
}
}

@ -0,0 +1,48 @@
/// <reference lib="webworker" />
import shimSource from "./shim.py?raw";
const PYODIDE_URL = "https://cdn.jsdelivr.net/pyodide/v0.27.7/full/pyodide.mjs";
// pyodide has no bundled types; the surface we use is tiny.
interface Pyodide {
loadPackage(name: string): Promise<void>;
pyimport(name: string): { install(reqs: string[]): Promise<void> };
runPython(code: string): void;
globals: { get(name: string): (arg: string) => string };
}
let pyodide: Pyodide | null = null;
function post(msg: unknown) {
(self as unknown as Worker).postMessage(msg);
}
async function init(wheelUrl: string) {
post({ type: "progress", stage: "pyodide" });
const mod = await import(/* @vite-ignore */ PYODIDE_URL);
pyodide = (await mod.loadPyodide()) as Pyodide;
post({ type: "progress", stage: "packages" });
await pyodide.loadPackage("micropip");
const micropip = pyodide.pyimport("micropip");
await micropip.install(["jinja2", "graphviz", wheelUrl]);
pyodide.runPython(shimSource);
post({ type: "ready" });
}
self.onmessage = async (e: MessageEvent) => {
const msg = e.data;
try {
if (msg.type === "init") {
await init(msg.wheelUrl);
} else if (msg.type === "run") {
const json = pyodide!.globals.get("run_user_code")(msg.code);
post({ type: "result", id: msg.id, result: JSON.parse(json) });
}
} catch (err) {
post({
type: msg.type === "init" ? "init-error" : "run-error",
id: msg.id,
error: String(err),
});
}
};

@ -0,0 +1,72 @@
"""Runs inside Pyodide. Patches diagrams so that no dot binary or filesystem
writes are needed: Diagram.render only captures DOT source, and
Diagram.__exit__ skips the os.remove of the .gv file."""
import io
import json
import sys
import traceback
_json_dumps = json.dumps # captured before user code can monkeypatch json
def _install_patches(dots, state):
import diagrams
def patched_render(self):
# Dedup a diagram rendered twice back-to-back (an explicit d.render()
# immediately followed by __exit__) by comparing object identity to
# the last-rendered diagram. `state["last"]` keeps that object alive,
# so `is` stays reliable — unlike id(self), which CPython can reuse
# once the previous diagram is garbage-collected, silently
# overwriting an earlier capture.
if state["last"] is self:
dots[-1] = {"name": self.name, "source": self.dot.source}
return
state["last"] = self
dots.append({"name": self.name, "source": self.dot.source})
def patched_exit(self, exc_type, exc_value, tb):
if exc_type is None:
patched_render(self)
diagrams.setdiagram(None)
diagrams.Diagram.render = patched_render
diagrams.Diagram.__exit__ = patched_exit
def _format_user_traceback(exc):
# Drop the first frame (our exec call below) so the trace starts at
# the user's <playground> code.
tb = exc.__traceback__
if tb is not None:
tb = tb.tb_next
return "".join(traceback.format_exception(type(exc), exc, tb))
def run_user_code(code):
dots = []
state = {"last": None}
_install_patches(dots, state)
stdout = io.StringIO()
original_stdout, error = sys.stdout, None
sys.stdout = stdout
try:
exec(compile(code, "<playground>", "exec"), {"__name__": "__main__"})
except BaseException as exc: # noqa: BLE001 - report everything to the UI
error = _format_user_traceback(exc)
finally:
sys.stdout = original_stdout
payload = {"dots": dots, "stdout": stdout.getvalue(), "error": error}
try:
return _json_dumps(payload)
except Exception as exc: # json machinery sabotaged or payload non-serializable
message = "Internal error serializing result: " + repr(exc)
escaped = (
message.replace("\\", "\\\\")
.replace('"', '\\"')
.replace("\n", "\\n")
.replace("\r", "\\r")
.replace("\t", "\\t")
)
return '{"dots": [], "stdout": "", "error": "' + escaped + '"}'

@ -0,0 +1,20 @@
{
"compilerOptions": {
"target": "ES2020",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable", "WebWorker"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "bundler",
"allowImportingTsExtensions": true,
"isolatedModules": true,
"moduleDetection": "force",
"noEmit": true,
"jsx": "react-jsx",
"strict": true,
"noUnusedLocals": true,
"noUnusedParameters": true,
"types": ["vitest/globals"]
},
"include": ["src", "e2e", "vite.config.ts", "playwright.config.ts"]
}

@ -0,0 +1,22 @@
import react from "@vitejs/plugin-react";
import { defineConfig } from "vitest/config";
// Deployment note: base "./" means all asset URLs are resolved relative to
// the current document path, so this app must be served from a path with a
// trailing slash (e.g. /playground/, not /playground). GitHub Pages
// auto-redirects directory URLs to add the trailing slash, but other static
// hosts do not — without a redirect, /playground resolves assets relative to
// the parent path and catalog.json, wheels/, and icons/ 404 (often into the
// SPA's own index.html fallback instead of a clean 404). If deploying
// elsewhere, configure the host to redirect /playground -> /playground/.
export default defineConfig({
plugins: [react()],
base: "./",
test: {
globals: true,
environment: "jsdom",
// e2e/*.spec.ts uses @playwright/test, not vitest — keep vitest's
// default *.spec.ts glob from picking it up.
exclude: ["**/node_modules/**", "**/dist/**", "e2e/**"],
},
});

@ -85,6 +85,7 @@
"Docs": "Docs",
"Guides": "Guides",
"Nodes": "Nodes",
"Playground": "Playground",
"GitHub": "GitHub",
"Sponsoring": "Sponsoring"
},

@ -64,7 +64,8 @@ class HomeSplash extends React.Component {
<div className="inner">
<ProjectTitle tagline={siteConfig.tagline} title={siteConfig.title} />
<PromoSection>
<Button href={docUrl('getting-started/installation')}>Try It Out</Button>
<Button href={`${baseUrl}playground/`}>Try It Out</Button>
<Button href={docUrl('getting-started/installation')}>Explore</Button>
<Button href={docUrl('getting-started/examples')}>Show Examples</Button>
</PromoSection>
</div>

@ -21,6 +21,7 @@ const siteConfig = {
{doc: 'getting-started/installation', label: 'Docs'},
{doc: 'guides/diagram', label: 'Guides'},
{doc: 'nodes/aws', label: 'Nodes'},
{href: '/playground/', label: 'Playground'},
{href: 'https://github.com/mingrammer/diagrams', label: 'GitHub'},
{href: 'https://www.buymeacoffee.com/mingrammer', label: 'Sponsoring'},
],
@ -57,7 +58,10 @@ const siteConfig = {
enableUpdateTime: true,
gaTrackingId: 'UA-84081627-3',
// GA4 measurement ID — requires gaGtag so Docusaurus emits gtag.js
// instead of the legacy (and now shut down) analytics.js.
gaTrackingId: 'G-Y1TWCZ0L77',
gaGtag: true,
};
module.exports = siteConfig;

Loading…
Cancel
Save