mirror of https://github.com/mingrammer/diagrams
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
parent
21151309da
commit
a13ce09940
@ -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
|
||||
@ -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,
|
||||
},
|
||||
});
|
||||
|
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,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,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->b" (hyphen as a numeric
|
||||
// character reference), but DOMPurify's DOM parse/serialize round-trip
|
||||
// normalizes "-" 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->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->b</title></g>
|
||||
<g id="edge2" class="edge"><title>b->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/**"],
|
||||
},
|
||||
});
|
||||
Loading…
Reference in new issue