Release Diagram Tools 0.2.0
This commit is contained in:
@@ -0,0 +1,39 @@
|
||||
name: Verify
|
||||
|
||||
on:
|
||||
push:
|
||||
branches: [main]
|
||||
pull_request:
|
||||
workflow_dispatch:
|
||||
|
||||
concurrency:
|
||||
group: verify-${{ gitea.repository }}-${{ gitea.ref }}
|
||||
cancel-in-progress: true
|
||||
|
||||
permissions:
|
||||
contents: read
|
||||
|
||||
jobs:
|
||||
verify:
|
||||
runs-on: ubuntu-latest
|
||||
timeout-minutes: 45
|
||||
env:
|
||||
CI: "true"
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
- uses: actions/setup-node@v4
|
||||
with:
|
||||
node-version: "22"
|
||||
cache: npm
|
||||
- name: Select declared npm version
|
||||
run: npm install --global npm@11.17.0
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
- name: Audit runtime dependencies
|
||||
run: npm audit --omit=dev --audit-level=moderate
|
||||
- name: Check, test, and build
|
||||
run: npm run check
|
||||
- name: Install browser engines
|
||||
run: npx playwright install --with-deps chromium firefox webkit
|
||||
- name: Browser tests
|
||||
run: npm run test:browser
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 - 2026-09-02
|
||||
|
||||
- Add safe deterministic entity-relationship, mind-map and timeline grammars,
|
||||
richer diagnostics and accessible outline/export coverage.
|
||||
|
||||
## 0.1.0 - 2026-09-01
|
||||
|
||||
- Add bounded flowchart, sequence, class and state parser/layout.
|
||||
|
||||
@@ -1,7 +1,19 @@
|
||||
# Diagram Tools
|
||||
|
||||
Diagram Tools is a local, script-free editor for bounded flowchart, sequence,
|
||||
class, state, entity-relationship, mind-map and timeline sources. It provides
|
||||
line diagnostics, reusable examples, accessible outlines, pan/zoom, local
|
||||
source import/export, normalized JSON, inert SVG and local PNG export. Mermaid
|
||||
directives, styling, click handlers and external resources are never executed.
|
||||
|
||||
Diagram Tools is a GPL-3.0-or-later local text/visual editor with an independent safe Mermaid-like subset for flowchart/graph, sequence, class and state diagrams. It produces deterministic SVG, a local 2× PNG, syntax diagnostics, scroll/drag pan, 25–300% zoom and an accessible textual outline.
|
||||
|
||||
Supported syntax is deliberately small: node declarations/shapes and four edge styles; participants/messages; class declarations/members/relations; state nodes/transitions. Directives, clicks, styles, scripts, HTML and remote assets are rejected. Caps: 50,000 characters, 2,000 lines, 200 nodes, 500 edges, 2,400 px layout edge and 20 megapixel PNG.
|
||||
Supported syntax is deliberately small: node declarations/shapes and four edge
|
||||
styles; participants/messages/notes; class declarations, bounded member blocks
|
||||
and relations; labelled state nodes, start/end boundaries and transitions; ER
|
||||
attributes/relations; hierarchical mind maps; and timeline sections/events.
|
||||
Directives, clicks, styles, scripts, HTML and remote assets are rejected. Caps:
|
||||
50,000 characters, 2,000 lines, 200 nodes, 500 edges and a 20 megapixel PNG;
|
||||
large vector canvases remain scrollable instead of clipping model content.
|
||||
|
||||
Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`.
|
||||
|
||||
@@ -1,3 +1,3 @@
|
||||
# Source identity
|
||||
|
||||
Diagram Tools 0.1.0 · `de.add-ideas.diagram-tools` · https://git.add-ideas.de/lotobo/diagram-tools · GPL-3.0-or-later. Deterministic releases normalize file order, time and permissions and add SHA-256.
|
||||
Diagram Tools 0.2.0 · `de.add-ideas.diagram-tools` · https://git.add-ideas.de/lotobo/diagram-tools · GPL-3.0-or-later. Deterministic releases normalize file order, time and permissions and add SHA-256.
|
||||
|
||||
+12
-1
@@ -1,3 +1,14 @@
|
||||
# Architecture
|
||||
|
||||
`core/diagram.ts` parses one fixed grammar into nodes, edges and line diagnostics, then lays it out deterministically as escaped SVG. Sequence diagrams use lifelines; other modes use a bounded grid. React shows the last successful SVG after errors and delegates PNG rasterization to a local canvas.
|
||||
`core/diagram.ts` parses fixed, bounded flowchart, sequence, class, state, ER,
|
||||
mind-map and timeline grammars into nodes, edges and line diagnostics, then lays
|
||||
them out deterministically as escaped SVG. ER attribute blocks and sequence
|
||||
notes, class member blocks and labelled state transitions/boundaries become
|
||||
inert model data; directives, click handlers, remote resources and styling
|
||||
commands are rejected rather than interpreted. Layout dimensions are derived
|
||||
from the bounded model rather than clipping nodes to a fixed viewport.
|
||||
|
||||
React retains the last successful SVG after errors, imports/exports source and
|
||||
the normalized JSON model, and delegates optional PNG rasterization to a local
|
||||
canvas. Generated SVG contains only application-owned geometry and escaped
|
||||
text.
|
||||
|
||||
Generated
+20
-20
@@ -1,22 +1,22 @@
|
||||
{
|
||||
"name": "diagram-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "diagram-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
@@ -42,24 +42,24 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-contract": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz",
|
||||
"integrity": "sha512-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.3.0/toolbox-contract-0.3.0.tgz",
|
||||
"integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
|
||||
"license": "Apache-2.0"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-helpers": {
|
||||
"version": "0.1.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz",
|
||||
"integrity": "sha512-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==",
|
||||
"version": "0.2.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.2.0/toolbox-helpers-0.2.0.tgz",
|
||||
"integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
|
||||
"license": "GPL-3.0-or-later"
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-shell-react": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.2.3/toolbox-shell-react-0.2.3.tgz",
|
||||
"integrity": "sha512-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-shell-react/-/0.3.0/toolbox-shell-react-0.3.0.tgz",
|
||||
"integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=18 <20",
|
||||
@@ -67,13 +67,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@add-ideas/toolbox-testkit": {
|
||||
"version": "0.2.3",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz",
|
||||
"integrity": "sha512-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==",
|
||||
"version": "0.3.0",
|
||||
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.3.0/toolbox-testkit-0.3.0.tgz",
|
||||
"integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3"
|
||||
"@add-ideas/toolbox-contract": "0.3.0"
|
||||
},
|
||||
"bin": {
|
||||
"toolbox-check": "dist/cli.js"
|
||||
|
||||
+8
-5
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "diagram-tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Create safe deterministic diagrams locally.",
|
||||
"license": "GPL-3.0-or-later",
|
||||
"author": "Albrecht Degering",
|
||||
@@ -9,6 +9,9 @@
|
||||
"url": "git+https://git.add-ideas.de/lotobo/diagram-tools.git"
|
||||
},
|
||||
"homepage": "https://git.add-ideas.de/lotobo/diagram-tools",
|
||||
"bugs": {
|
||||
"url": "https://git.add-ideas.de/lotobo/diagram-tools/issues"
|
||||
},
|
||||
"private": true,
|
||||
"type": "module",
|
||||
"engines": {
|
||||
@@ -34,14 +37,14 @@
|
||||
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
|
||||
},
|
||||
"dependencies": {
|
||||
"@add-ideas/toolbox-contract": "0.2.3",
|
||||
"@add-ideas/toolbox-helpers": "0.1.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.2.3",
|
||||
"@add-ideas/toolbox-contract": "0.3.0",
|
||||
"@add-ideas/toolbox-helpers": "0.2.0",
|
||||
"@add-ideas/toolbox-shell-react": "0.3.0",
|
||||
"react": "19.2.8",
|
||||
"react-dom": "19.2.8"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@add-ideas/toolbox-testkit": "0.2.3",
|
||||
"@add-ideas/toolbox-testkit": "0.3.0",
|
||||
"@eslint/js": "10.0.1",
|
||||
"@playwright/test": "1.62.1",
|
||||
"@testing-library/jest-dom": "6.9.1",
|
||||
|
||||
+20
-2
@@ -12,7 +12,25 @@ export default defineConfig({
|
||||
timeout: 180000,
|
||||
},
|
||||
projects: [
|
||||
{ name: "chromium", use: { ...devices["Desktop Chrome"] } },
|
||||
{ name: "firefox", use: { ...devices["Desktop Firefox"] } },
|
||||
{
|
||||
name: "chromium",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Chrome"] },
|
||||
},
|
||||
{
|
||||
name: "firefox",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Firefox"] },
|
||||
},
|
||||
{
|
||||
name: "webkit",
|
||||
testIgnore: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Desktop Safari"] },
|
||||
},
|
||||
{
|
||||
name: "mobile-chromium",
|
||||
testMatch: /responsive\.spec\.ts/,
|
||||
use: { ...devices["Pixel 5"] },
|
||||
},
|
||||
],
|
||||
});
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
# Changelog
|
||||
|
||||
## 0.2.0 - 2026-09-02
|
||||
|
||||
- Add safe deterministic entity-relationship, mind-map and timeline grammars,
|
||||
richer diagnostics and accessible outline/export coverage.
|
||||
|
||||
## 0.1.0 - 2026-09-01
|
||||
|
||||
- Add bounded flowchart, sequence, class and state parser/layout.
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-contract@0.2.3
|
||||
@add-ideas/toolbox-contract@0.3.0
|
||||
Declared licence: Apache-2.0
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
@@ -198,7 +198,7 @@ Declared licence: Apache-2.0
|
||||
|
||||
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-helpers@0.1.0
|
||||
@add-ideas/toolbox-helpers@0.2.0
|
||||
Declared licence: GPL-3.0-or-later
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
@@ -879,7 +879,7 @@ Public License instead of this License. But first, please read
|
||||
|
||||
|
||||
==============================================================================
|
||||
@add-ideas/toolbox-shell-react@0.2.3
|
||||
@add-ideas/toolbox-shell-react@0.3.0
|
||||
Declared licence: Apache-2.0
|
||||
==============================================================================
|
||||
--- LICENSE ---
|
||||
|
||||
+13
-1
@@ -1,7 +1,19 @@
|
||||
# Diagram Tools
|
||||
|
||||
Diagram Tools is a local, script-free editor for bounded flowchart, sequence,
|
||||
class, state, entity-relationship, mind-map and timeline sources. It provides
|
||||
line diagnostics, reusable examples, accessible outlines, pan/zoom, local
|
||||
source import/export, normalized JSON, inert SVG and local PNG export. Mermaid
|
||||
directives, styling, click handlers and external resources are never executed.
|
||||
|
||||
Diagram Tools is a GPL-3.0-or-later local text/visual editor with an independent safe Mermaid-like subset for flowchart/graph, sequence, class and state diagrams. It produces deterministic SVG, a local 2× PNG, syntax diagnostics, scroll/drag pan, 25–300% zoom and an accessible textual outline.
|
||||
|
||||
Supported syntax is deliberately small: node declarations/shapes and four edge styles; participants/messages; class declarations/members/relations; state nodes/transitions. Directives, clicks, styles, scripts, HTML and remote assets are rejected. Caps: 50,000 characters, 2,000 lines, 200 nodes, 500 edges, 2,400 px layout edge and 20 megapixel PNG.
|
||||
Supported syntax is deliberately small: node declarations/shapes and four edge
|
||||
styles; participants/messages/notes; class declarations, bounded member blocks
|
||||
and relations; labelled state nodes, start/end boundaries and transitions; ER
|
||||
attributes/relations; hierarchical mind maps; and timeline sections/events.
|
||||
Directives, clicks, styles, scripts, HTML and remote assets are rejected. Caps:
|
||||
50,000 characters, 2,000 lines, 200 nodes, 500 edges and a 20 megapixel PNG;
|
||||
large vector canvases remain scrollable instead of clipping model content.
|
||||
|
||||
Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`.
|
||||
|
||||
+1
-1
@@ -1,3 +1,3 @@
|
||||
# Source identity
|
||||
|
||||
Diagram Tools 0.1.0 · `de.add-ideas.diagram-tools` · https://git.add-ideas.de/lotobo/diagram-tools · GPL-3.0-or-later. Deterministic releases normalize file order, time and permissions and add SHA-256.
|
||||
Diagram Tools 0.2.0 · `de.add-ideas.diagram-tools` · https://git.add-ideas.de/lotobo/diagram-tools · GPL-3.0-or-later. Deterministic releases normalize file order, time and permissions and add SHA-256.
|
||||
|
||||
@@ -1,3 +1,14 @@
|
||||
# Architecture
|
||||
|
||||
`core/diagram.ts` parses one fixed grammar into nodes, edges and line diagnostics, then lays it out deterministically as escaped SVG. Sequence diagrams use lifelines; other modes use a bounded grid. React shows the last successful SVG after errors and delegates PNG rasterization to a local canvas.
|
||||
`core/diagram.ts` parses fixed, bounded flowchart, sequence, class, state, ER,
|
||||
mind-map and timeline grammars into nodes, edges and line diagnostics, then lays
|
||||
them out deterministically as escaped SVG. ER attribute blocks and sequence
|
||||
notes, class member blocks and labelled state transitions/boundaries become
|
||||
inert model data; directives, click handlers, remote resources and styling
|
||||
commands are rejected rather than interpreted. Layout dimensions are derived
|
||||
from the bounded model rather than clipping nodes to a fixed viewport.
|
||||
|
||||
React retains the last successful SVG after errors, imports/exports source and
|
||||
the normalized JSON model, and delegates optional PNG rasterization to a local
|
||||
canvas. Generated SVG contains only application-owned geometry and escaped
|
||||
text.
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
const CACHE = "diagram-tools-v0.1.0",
|
||||
const CACHE = "diagram-tools-v0.2.0",
|
||||
APP = [
|
||||
"./",
|
||||
"./index.html",
|
||||
|
||||
+15
-2
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.diagram-tools",
|
||||
"name": "Diagram Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Create safe diagrams locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
@@ -21,9 +21,22 @@
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{ "mediaType": "text/plain", "extensions": [".txt", ".mmd", ".mermaid"] },
|
||||
{ "mediaType": "application/json", "extensions": [".json"] }
|
||||
],
|
||||
"produces": [
|
||||
{ "mediaType": "image/svg+xml", "extensions": [".svg"] },
|
||||
{ "mediaType": "image/png", "extensions": [".png"] },
|
||||
{ "mediaType": "application/json", "extensions": [".json"] },
|
||||
{ "mediaType": "text/plain", "extensions": [".txt", ".mmd"] }
|
||||
]
|
||||
},
|
||||
"capabilities": { "required": [], "optional": [] },
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": false,
|
||||
"fileUploads": true,
|
||||
"telemetry": false,
|
||||
"label": "Diagram source and exports remain local."
|
||||
},
|
||||
|
||||
@@ -18,7 +18,9 @@ export function HelpDialog({
|
||||
onCancel={onClose}
|
||||
aria-label="About Diagram Tools"
|
||||
>
|
||||
<button onClick={onClose}>×</button>
|
||||
<button type="button" aria-label="Close help" onClick={onClose}>
|
||||
×
|
||||
</button>
|
||||
<h2>Diagram Tools</h2>
|
||||
<p>
|
||||
The syntax resembles a small familiar Mermaid subset but uses an
|
||||
|
||||
@@ -26,6 +26,31 @@ Idle --> Reading : open
|
||||
Reading --> Ready : parsed
|
||||
Reading -.-> Error : invalid
|
||||
Error --> Idle : reset`,
|
||||
er: `erDiagram
|
||||
CUSTOMER {
|
||||
string id PK
|
||||
string name
|
||||
}
|
||||
ORDER {
|
||||
string id PK
|
||||
number total
|
||||
}
|
||||
CUSTOMER ||--o{ ORDER : places`,
|
||||
mindmap: `mindmap
|
||||
((Local tools))
|
||||
Author
|
||||
Diagrams
|
||||
Documents
|
||||
Export
|
||||
SVG
|
||||
PNG`,
|
||||
timeline: `timeline
|
||||
title Local release
|
||||
section Author
|
||||
2026-08 : Draft
|
||||
2026-09 : Verify
|
||||
section Publish
|
||||
2026-10 : Release`,
|
||||
} as const;
|
||||
export function Workbench() {
|
||||
const [source, setSource] = useState<string>(EXAMPLES.flowchart),
|
||||
@@ -60,6 +85,23 @@ export function Workbench() {
|
||||
setStatus(e instanceof Error ? e.message : String(e));
|
||||
}
|
||||
}
|
||||
async function openSource(file: File | undefined) {
|
||||
if (!file) return;
|
||||
try {
|
||||
if (file.size > 50_000)
|
||||
throw new Error("Diagram source exceeds 50,000 characters.");
|
||||
const text = await file.text(),
|
||||
next = parseDiagram(text);
|
||||
setSource(text);
|
||||
setDiagram(next);
|
||||
setSvg(renderSvg(next));
|
||||
setStatus(`Opened and rendered ${file.name} locally.`);
|
||||
} catch (error) {
|
||||
setStatus(
|
||||
`${error instanceof Error ? error.message : String(error)} Last valid diagram remains visible.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
return (
|
||||
<main className="workbench">
|
||||
<section className="hero panel">
|
||||
@@ -111,6 +153,36 @@ export function Workbench() {
|
||||
SVG
|
||||
</button>
|
||||
<button onClick={() => void png()}>PNG 2×</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
triggerBlobDownload(
|
||||
new Blob([source], { type: "text/plain" }),
|
||||
"diagram.mmd",
|
||||
)
|
||||
}
|
||||
>
|
||||
Source
|
||||
</button>
|
||||
<button
|
||||
onClick={() =>
|
||||
triggerBlobDownload(
|
||||
new Blob([JSON.stringify(diagram, null, 2)], {
|
||||
type: "application/json",
|
||||
}),
|
||||
"diagram-model.json",
|
||||
)
|
||||
}
|
||||
>
|
||||
Model JSON
|
||||
</button>
|
||||
<label className="file-button">
|
||||
Open source
|
||||
<input
|
||||
type="file"
|
||||
accept=".mmd,.mermaid,.txt,text/plain"
|
||||
onChange={(event) => void openSource(event.target.files?.[0])}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<h2>Syntax diagnostics</h2>
|
||||
{diagram.diagnostics.length ? (
|
||||
@@ -126,9 +198,11 @@ export function Workbench() {
|
||||
<p className="success">No syntax diagnostics.</p>
|
||||
)}
|
||||
<p className="boundary">
|
||||
Caps: 50,000 characters, 2,000 lines, 200 nodes, 500 edges and a
|
||||
2,400 px layout edge. Unsupported lines produce diagnostics;
|
||||
directives never execute.
|
||||
Caps: 50,000 characters, 2,000 lines, 200 nodes, 500 edges and a 20
|
||||
megapixel PNG export. Large vector canvases remain scrollable.
|
||||
Flowchart, sequence, class, state, ER, mind-map and timeline
|
||||
families share the inert model. Unsupported lines produce
|
||||
diagnostics; directives never execute.
|
||||
</p>
|
||||
</section>
|
||||
<section className="panel canvas-panel">
|
||||
|
||||
+237
-28
@@ -1,8 +1,17 @@
|
||||
export type DiagramMode = "flowchart" | "sequence" | "class" | "state";
|
||||
export type DiagramMode =
|
||||
"flowchart" | "sequence" | "class" | "state" | "er" | "mindmap" | "timeline";
|
||||
export interface Node {
|
||||
id: string;
|
||||
label: string;
|
||||
shape: "rect" | "round" | "diamond" | "actor" | "class" | "state";
|
||||
shape:
|
||||
| "rect"
|
||||
| "round"
|
||||
| "diamond"
|
||||
| "actor"
|
||||
| "class"
|
||||
| "state"
|
||||
| "circle"
|
||||
| "note";
|
||||
members: string[];
|
||||
}
|
||||
export interface Edge {
|
||||
@@ -47,9 +56,12 @@ export function parseDiagram(source: string): Diagram {
|
||||
} else if (/^sequenceDiagram$/iu.test(header)) mode = "sequence";
|
||||
else if (/^classDiagram$/iu.test(header)) mode = "class";
|
||||
else if (/^stateDiagram(?:-v2)?$/iu.test(header)) mode = "state";
|
||||
else if (/^erDiagram$/iu.test(header)) mode = "er";
|
||||
else if (/^mindmap$/iu.test(header)) mode = "mindmap";
|
||||
else if (/^timeline$/iu.test(header)) mode = "timeline";
|
||||
else
|
||||
throw new Error(
|
||||
"First statement must be flowchart/graph, sequenceDiagram, classDiagram or stateDiagram-v2.",
|
||||
"First statement must be flowchart/graph, sequenceDiagram, classDiagram, stateDiagram-v2, erDiagram, mindmap or timeline.",
|
||||
);
|
||||
const nodes = new Map<string, Node>(),
|
||||
edges: Edge[] = [],
|
||||
@@ -65,6 +77,11 @@ export function parseDiagram(source: string): Diagram {
|
||||
}
|
||||
return node;
|
||||
};
|
||||
let erEntity: Node | undefined,
|
||||
classEntity: Node | undefined,
|
||||
generated = 0,
|
||||
mindStack: { indent: number; id: string }[] = [],
|
||||
timelinePrevious: string | undefined;
|
||||
for (let index = first + 1; index < lines.length; index++) {
|
||||
const raw = lines[index]!,
|
||||
line = raw.trim();
|
||||
@@ -81,8 +98,85 @@ export function parseDiagram(source: string): Diagram {
|
||||
continue;
|
||||
}
|
||||
try {
|
||||
if (mode === "sequence") parseSequence(line, ensure, edges);
|
||||
else if (mode === "class") parseClass(line, ensure, edges);
|
||||
if (mode === "er") {
|
||||
if (line === "}") {
|
||||
if (!erEntity) throw new Error("Unexpected ER attribute block end.");
|
||||
erEntity = undefined;
|
||||
} else if (erEntity) {
|
||||
if (line.includes("{") || line.includes("}"))
|
||||
throw new Error("Nested ER attribute blocks are not supported.");
|
||||
if (erEntity.members.length >= 100)
|
||||
throw new Error("ER entity exceeds 100 attributes.");
|
||||
erEntity.members.push(line);
|
||||
} else {
|
||||
const opening = /^([A-Za-z_][\w-]*)\s*\{$/u.exec(line);
|
||||
if (opening) erEntity = ensure(opening[1]!, opening[1], "class");
|
||||
else parseEr(line, ensure, edges);
|
||||
}
|
||||
} else if (mode === "class") {
|
||||
if (line === "}") {
|
||||
if (!classEntity)
|
||||
throw new Error("Unexpected class member block end.");
|
||||
classEntity = undefined;
|
||||
} else if (classEntity) {
|
||||
if (line.includes("{") || line.includes("}"))
|
||||
throw new Error("Nested class member blocks are not supported.");
|
||||
if (classEntity.members.length >= 100)
|
||||
throw new Error("Class exceeds 100 members.");
|
||||
classEntity.members.push(line);
|
||||
} else {
|
||||
const opening =
|
||||
/^class\s+([A-Za-z_][\w-]*)(?:\s*\[([^\]]{1,200})\])?\s*\{$/iu.exec(
|
||||
line,
|
||||
);
|
||||
if (opening)
|
||||
classEntity = ensure(
|
||||
opening[1]!,
|
||||
opening[2]?.trim() ?? opening[1],
|
||||
"class",
|
||||
);
|
||||
else parseClass(line, ensure, edges);
|
||||
}
|
||||
} else if (mode === "mindmap") {
|
||||
const indent = raw.length - raw.trimStart().length,
|
||||
label = line.replace(/^[-*+]\s*/u, "").trim();
|
||||
if (!label) continue;
|
||||
const id = `mind-${++generated}`,
|
||||
parent = [...mindStack]
|
||||
.reverse()
|
||||
.find((item) => item.indent < indent);
|
||||
ensure(id, unwrapMindLabel(label), parent ? "round" : "circle");
|
||||
if (parent)
|
||||
edges.push({
|
||||
from: parent.id,
|
||||
to: id,
|
||||
label: "",
|
||||
dashed: false,
|
||||
arrow: false,
|
||||
});
|
||||
mindStack = mindStack.filter((item) => item.indent < indent);
|
||||
mindStack.push({ indent, id });
|
||||
} else if (mode === "timeline") {
|
||||
if (/^title\s+/iu.test(line)) continue;
|
||||
const section = /^section\s+(.{1,200})$/iu.exec(line);
|
||||
const id = `time-${++generated}`;
|
||||
if (section) ensure(id, section[1]!.trim(), "round");
|
||||
else {
|
||||
const event = /^(.{1,120}?)\s*:\s*(.{1,300})$/u.exec(line);
|
||||
if (!event) throw new Error("Expected section Name or Time : Event.");
|
||||
ensure(id, `${event[1]!.trim()} — ${event[2]!.trim()}`, "rect");
|
||||
}
|
||||
if (timelinePrevious)
|
||||
edges.push({
|
||||
from: timelinePrevious,
|
||||
to: id,
|
||||
label: "",
|
||||
dashed: false,
|
||||
arrow: true,
|
||||
});
|
||||
timelinePrevious = id;
|
||||
} else if (mode === "sequence") parseSequence(line, ensure, edges);
|
||||
else if (mode === "state") parseState(line, ensure, edges);
|
||||
else parseGraph(line, ensure, edges, mode);
|
||||
} catch (error) {
|
||||
diagnostics.push(
|
||||
@@ -97,6 +191,19 @@ export function parseDiagram(source: string): Diagram {
|
||||
if (nodes.size > MAX_NODES) throw new Error("Diagram exceeds 200 nodes.");
|
||||
if (edges.length > MAX_EDGES) throw new Error("Diagram exceeds 500 edges.");
|
||||
}
|
||||
if (erEntity)
|
||||
diagnostics.push(
|
||||
diag(lines.length, 1, "error", `Entity ${erEntity.id} has no closing }.`),
|
||||
);
|
||||
if (classEntity)
|
||||
diagnostics.push(
|
||||
diag(
|
||||
lines.length,
|
||||
1,
|
||||
"error",
|
||||
`Class ${classEntity.id} has no closing }.`,
|
||||
),
|
||||
);
|
||||
for (const edge of edges) {
|
||||
ensure(
|
||||
edge.from,
|
||||
@@ -113,6 +220,31 @@ export function parseDiagram(source: string): Diagram {
|
||||
diagnostics.push(diag(first + 1, 1, "warning", "Diagram has no nodes."));
|
||||
return { mode, direction, nodes: [...nodes.values()], edges, diagnostics };
|
||||
}
|
||||
function parseEr(
|
||||
line: string,
|
||||
ensure: (id: string, label?: string, shape?: Node["shape"]) => Node,
|
||||
edges: Edge[],
|
||||
) {
|
||||
const relation =
|
||||
/^([A-Za-z_][\w-]*)\s+([|o}{.]+)--([|o}{.]+)\s+([A-Za-z_][\w-]*)(?:\s*:\s*(.{0,200}))?$/u.exec(
|
||||
line,
|
||||
);
|
||||
if (!relation)
|
||||
throw new Error("Expected ENTITY { or ENTITY ||--o{ ENTITY : relation.");
|
||||
ensure(relation[1]!, relation[1], "class");
|
||||
ensure(relation[4]!, relation[4], "class");
|
||||
edges.push({
|
||||
from: relation[1]!,
|
||||
to: relation[4]!,
|
||||
label: `${relation[2]}–${relation[3]}${relation[5] ? ` ${relation[5]!.trim()}` : ""}`,
|
||||
dashed: relation[2]!.includes(".") || relation[3]!.includes("."),
|
||||
arrow: false,
|
||||
});
|
||||
}
|
||||
function unwrapMindLabel(value: string) {
|
||||
const match = /^(?:\(\((.*)\)\)|\((.*)\)|\[(.*)\)|\{(.*)\})$/u.exec(value);
|
||||
return (match?.slice(1).find((item) => item !== undefined) ?? value).trim();
|
||||
}
|
||||
function parseGraph(
|
||||
line: string,
|
||||
ensure: (id: string, label?: string, shape?: Node["shape"]) => Node,
|
||||
@@ -140,20 +272,29 @@ function parseGraph(
|
||||
}
|
||||
function parseNode(source: string, mode: DiagramMode) {
|
||||
const match =
|
||||
/^([A-Za-z_][\w-]*)(?:\[([^\]]{0,500})\]|\(([^)]{0,500})\)|\{([^}]{0,500})\})?$/u.exec(
|
||||
/^(\[\s*\*\s*\]|[A-Za-z_][\w-]*)(?:\[([^\]]{0,500})\]|\(\(([^)]{0,500})\)\)|\(([^)]{0,500})\)|\{([^}]{0,500})\})?$/u.exec(
|
||||
source,
|
||||
);
|
||||
if (!match) throw new Error(`Unsupported node/edge syntax: ${source}`);
|
||||
const rawId = match[1]!;
|
||||
return {
|
||||
id: match[1]!,
|
||||
label: match[2] ?? match[3] ?? match[4] ?? match[1]!,
|
||||
shape: match[4]
|
||||
? ("diamond" as const)
|
||||
: match[3]
|
||||
? ("round" as const)
|
||||
: mode === "state"
|
||||
? ("state" as const)
|
||||
: ("rect" as const),
|
||||
id: rawId === "[*]" ? "__boundary" : rawId,
|
||||
label:
|
||||
rawId === "[*]"
|
||||
? "●"
|
||||
: (match[2] ?? match[3] ?? match[4] ?? match[5] ?? rawId),
|
||||
shape:
|
||||
rawId === "[*]"
|
||||
? ("circle" as const)
|
||||
: match[5]
|
||||
? ("diamond" as const)
|
||||
: match[3]
|
||||
? ("circle" as const)
|
||||
: match[4]
|
||||
? ("round" as const)
|
||||
: mode === "state"
|
||||
? ("state" as const)
|
||||
: ("rect" as const),
|
||||
};
|
||||
}
|
||||
function parseSequence(
|
||||
@@ -161,6 +302,24 @@ function parseSequence(
|
||||
ensure: (id: string, label?: string, shape?: Node["shape"]) => Node,
|
||||
edges: Edge[],
|
||||
) {
|
||||
const note =
|
||||
/^note\s+(?:over|right of|left of)\s+([A-Za-z_][\w-]*)(?:\s*,\s*([A-Za-z_][\w-]*))?\s*:\s*(.{1,500})$/iu.exec(
|
||||
line,
|
||||
);
|
||||
if (note) {
|
||||
const from = note[1]!,
|
||||
to = note[2] ?? from;
|
||||
ensure(from, from, "actor");
|
||||
ensure(to, to, "actor");
|
||||
edges.push({
|
||||
from,
|
||||
to,
|
||||
label: `Note: ${note[3]!.trim()}`,
|
||||
dashed: true,
|
||||
arrow: false,
|
||||
});
|
||||
return;
|
||||
}
|
||||
const participant =
|
||||
/^(?:participant|actor)\s+([A-Za-z_][\w-]*)(?:\s+as\s+(.{1,200}))?$/iu.exec(
|
||||
line,
|
||||
@@ -218,6 +377,47 @@ function parseClass(
|
||||
arrow: relation[2] !== "--",
|
||||
});
|
||||
}
|
||||
|
||||
function parseState(
|
||||
line: string,
|
||||
ensure: (id: string, label?: string, shape?: Node["shape"]) => Node,
|
||||
edges: Edge[],
|
||||
) {
|
||||
const declaration =
|
||||
/^state\s+(?:"([^"]{1,500})"\s+as\s+)?([A-Za-z_][\w-]*)$/iu.exec(line);
|
||||
if (declaration) {
|
||||
ensure(declaration[2]!, declaration[1]?.trim() ?? declaration[2], "state");
|
||||
return;
|
||||
}
|
||||
const labelled = /^([A-Za-z_][\w-]*)\s*:\s*(.{1,500})$/u.exec(line);
|
||||
if (labelled) {
|
||||
ensure(labelled[1]!, labelled[2]!.trim(), "state");
|
||||
return;
|
||||
}
|
||||
if (/^state\s+.+\{$/iu.test(line) || line === "}")
|
||||
throw new Error("Nested composite states are not supported.");
|
||||
const transition =
|
||||
/^(\[\s*\*\s*\]|[A-Za-z_][\w-]*)\s*(-->|-.->)\s*(\[\s*\*\s*\]|[A-Za-z_][\w-]*)(?:\s*:\s*(.{1,500}))?$/u.exec(
|
||||
line,
|
||||
);
|
||||
if (!transition)
|
||||
throw new Error(
|
||||
'Expected state "Label" as id, id : Label, or A --> B : event.',
|
||||
);
|
||||
const fromBoundary = /^\[\s*\*\s*\]$/u.test(transition[1]!),
|
||||
toBoundary = /^\[\s*\*\s*\]$/u.test(transition[3]!),
|
||||
from = fromBoundary ? "__start" : transition[1]!,
|
||||
to = toBoundary ? "__end" : transition[3]!;
|
||||
ensure(from, fromBoundary ? "●" : from, fromBoundary ? "circle" : "state");
|
||||
ensure(to, toBoundary ? "●" : to, toBoundary ? "circle" : "state");
|
||||
edges.push({
|
||||
from,
|
||||
to,
|
||||
label: transition[4]?.trim() ?? "",
|
||||
dashed: transition[2] === "-.->",
|
||||
arrow: true,
|
||||
});
|
||||
}
|
||||
function diag(
|
||||
line: number,
|
||||
column: number,
|
||||
@@ -229,14 +429,21 @@ function diag(
|
||||
|
||||
export function renderSvg(diagram: Diagram): string {
|
||||
if (diagram.mode === "sequence") return renderSequence(diagram);
|
||||
const nodeWidth = 180,
|
||||
nodeHeight = diagram.mode === "class" ? 110 : 72,
|
||||
gapX = 100,
|
||||
gapY = 75,
|
||||
const nodeWidth = 160,
|
||||
memberRows = Math.min(
|
||||
8,
|
||||
Math.max(0, ...diagram.nodes.map((node) => node.members.length)),
|
||||
),
|
||||
nodeHeight =
|
||||
diagram.mode === "class" || diagram.mode === "er"
|
||||
? Math.max(100, 58 + memberRows * 13)
|
||||
: 68,
|
||||
gapX = 50,
|
||||
gapY = 45,
|
||||
columns =
|
||||
diagram.direction === "LR"
|
||||
? Math.min(5, Math.max(1, diagram.nodes.length))
|
||||
: Math.min(4, Math.max(1, Math.ceil(Math.sqrt(diagram.nodes.length)))),
|
||||
diagram.direction === "LR" || diagram.mode === "timeline"
|
||||
? Math.min(14, Math.max(1, diagram.nodes.length))
|
||||
: Math.min(14, Math.max(1, Math.ceil(Math.sqrt(diagram.nodes.length)))),
|
||||
positions = new Map<string, { x: number; y: number }>();
|
||||
diagram.nodes.forEach((node, index) => {
|
||||
const column =
|
||||
@@ -248,8 +455,8 @@ export function renderSvg(diagram: Diagram): string {
|
||||
});
|
||||
});
|
||||
const rows = Math.max(1, Math.ceil(diagram.nodes.length / columns)),
|
||||
width = Math.min(2400, 100 + columns * (nodeWidth + gapX) - gapX),
|
||||
height = Math.min(2400, 110 + rows * (nodeHeight + gapY) - gapY);
|
||||
width = 100 + columns * (nodeWidth + gapX) - gapX,
|
||||
height = 110 + rows * (nodeHeight + gapY) - gapY;
|
||||
const edges = diagram.edges
|
||||
.map((edge) => {
|
||||
const a = positions.get(edge.from)!,
|
||||
@@ -280,7 +487,9 @@ function renderNode(
|
||||
shape =
|
||||
node.shape === "diamond"
|
||||
? `<polygon points="${p.x + w / 2},${p.y} ${p.x + w},${p.y + h / 2} ${p.x + w / 2},${p.y + h} ${p.x},${p.y + h / 2}"/>`
|
||||
: `<rect x="${p.x}" y="${p.y}" width="${w}" height="${h}" rx="${node.shape === "round" || node.shape === "state" ? 18 : 7}"/>`;
|
||||
: node.shape === "circle"
|
||||
? `<ellipse cx="${p.x + w / 2}" cy="${p.y + h / 2}" rx="${Math.min(w, h) / 2}" ry="${Math.min(w, h) / 2}"/>`
|
||||
: `<rect x="${p.x}" y="${p.y}" width="${w}" height="${h}" rx="${node.shape === "round" || node.shape === "state" ? 18 : 7}"/>`;
|
||||
const members = node.members
|
||||
.slice(0, 8)
|
||||
.map(
|
||||
@@ -292,8 +501,8 @@ function renderNode(
|
||||
}
|
||||
function renderSequence(d: Diagram) {
|
||||
const gap = 190,
|
||||
width = Math.min(2400, 100 + Math.max(1, d.nodes.length) * gap),
|
||||
height = Math.min(2400, 140 + Math.max(1, d.edges.length) * 65),
|
||||
width = 100 + Math.max(1, d.nodes.length) * gap,
|
||||
height = 140 + Math.max(1, d.edges.length) * 65,
|
||||
positions = new Map(
|
||||
d.nodes.map((node, index) => [node.id, 70 + index * gap]),
|
||||
);
|
||||
@@ -308,7 +517,7 @@ function renderSequence(d: Diagram) {
|
||||
const x1 = positions.get(edge.from)!,
|
||||
x2 = positions.get(edge.to)!,
|
||||
y = 105 + index * 65;
|
||||
return `<g><path d="M${x1} ${y}H${x2}" stroke="#475569" stroke-width="2" ${edge.dashed ? 'stroke-dasharray="7 5"' : ""} marker-end="url(#arrow)"/><text x="${(x1 + x2) / 2}" y="${y - 8}" text-anchor="middle" font-size="13" fill="#334155">${xml(edge.label)}</text></g>`;
|
||||
return `<g><path d="M${x1} ${y}H${x2}" stroke="#475569" stroke-width="2" ${edge.dashed ? 'stroke-dasharray="7 5"' : ""} ${edge.arrow ? 'marker-end="url(#arrow)"' : ""}/><text x="${(x1 + x2) / 2}" y="${y - 8}" text-anchor="middle" font-size="13" fill="#334155">${xml(edge.label)}</text></g>`;
|
||||
})
|
||||
.join("");
|
||||
return svgRoot(width, height, actors + messages);
|
||||
|
||||
@@ -71,6 +71,23 @@ input {
|
||||
color: CanvasText;
|
||||
padding: 0.55rem 0.7rem;
|
||||
}
|
||||
.file-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
border: 1px solid var(--line);
|
||||
border-radius: 0.6rem;
|
||||
background: Canvas;
|
||||
color: CanvasText;
|
||||
padding: 0.55rem 0.7rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.file-button input {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
}
|
||||
button {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
"schemaVersion": 1,
|
||||
"id": "de.add-ideas.diagram-tools",
|
||||
"name": "Diagram Tools",
|
||||
"version": "0.1.0",
|
||||
"version": "0.2.0",
|
||||
"description": "Create safe diagrams locally.",
|
||||
"entry": "./",
|
||||
"icon": "./favicon.svg",
|
||||
@@ -21,9 +21,43 @@
|
||||
"crossOriginIsolated": false,
|
||||
"topLevelContext": false
|
||||
},
|
||||
"io": {
|
||||
"accepts": [
|
||||
{
|
||||
"mediaType": "text/plain",
|
||||
"extensions": [".txt", ".mmd", ".mermaid"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/json",
|
||||
"extensions": [".json"]
|
||||
}
|
||||
],
|
||||
"produces": [
|
||||
{
|
||||
"mediaType": "image/svg+xml",
|
||||
"extensions": [".svg"]
|
||||
},
|
||||
{
|
||||
"mediaType": "image/png",
|
||||
"extensions": [".png"]
|
||||
},
|
||||
{
|
||||
"mediaType": "application/json",
|
||||
"extensions": [".json"]
|
||||
},
|
||||
{
|
||||
"mediaType": "text/plain",
|
||||
"extensions": [".txt", ".mmd"]
|
||||
}
|
||||
]
|
||||
},
|
||||
"capabilities": {
|
||||
"required": [],
|
||||
"optional": []
|
||||
},
|
||||
"privacy": {
|
||||
"processing": "local",
|
||||
"fileUploads": false,
|
||||
"fileUploads": true,
|
||||
"telemetry": false,
|
||||
"label": "Diagram source and exports remain local."
|
||||
},
|
||||
|
||||
+1
-1
@@ -1 +1 @@
|
||||
export const APP_VERSION = "0.1.0";
|
||||
export const APP_VERSION = "0.2.0";
|
||||
|
||||
@@ -74,6 +74,6 @@ test("PWA theme, offline reload, and headers", async ({
|
||||
const manifest = await request.get("/deep/nested/diagram/toolbox-app.json");
|
||||
await expect(manifest.json()).resolves.toMatchObject({
|
||||
id: "de.add-ideas.diagram-tools",
|
||||
version: "0.1.0",
|
||||
version: "0.2.0",
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,18 @@
|
||||
import { expect, test } from "@playwright/test";
|
||||
|
||||
test("keeps the primary workspace inside a narrow viewport", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.goto("/deep/nested/diagram/");
|
||||
await expect(page.locator("main").first()).toBeVisible();
|
||||
await expect(
|
||||
page.locator("main .loading, main .workbench-loading"),
|
||||
).toHaveCount(0);
|
||||
|
||||
const widths = await page.evaluate(() => ({
|
||||
content: document.documentElement.scrollWidth,
|
||||
viewport: document.documentElement.clientWidth,
|
||||
}));
|
||||
expect(widths.viewport).toBeLessThanOrEqual(430);
|
||||
expect(widths.content).toBeLessThanOrEqual(widths.viewport + 1);
|
||||
});
|
||||
@@ -23,9 +23,29 @@ describe("diagram grammar", () => {
|
||||
parseDiagram("classDiagram\nclass A\nA : +field\nA <|-- B").nodes[0]
|
||||
?.members,
|
||||
).toEqual(["+field"]);
|
||||
expect(parseDiagram("stateDiagram-v2\nIdle --> Ready : go").mode).toBe(
|
||||
"state",
|
||||
const state = parseDiagram(
|
||||
'stateDiagram-v2\n[*] --> Idle\nstate "Working" as Ready\nIdle --> Ready : go\nReady --> [*]',
|
||||
);
|
||||
expect(state.mode).toBe("state");
|
||||
expect(state.diagnostics).toEqual([]);
|
||||
expect(state.edges.map((edge) => edge.label)).toContain("go");
|
||||
expect(state.nodes.map((node) => node.id)).toEqual([
|
||||
"__start",
|
||||
"Idle",
|
||||
"Ready",
|
||||
"__end",
|
||||
]);
|
||||
});
|
||||
it("supports bounded class member blocks and reports unclosed blocks", () => {
|
||||
const diagram = parseDiagram(
|
||||
"classDiagram\nclass Account {\n +String id\n +close()\n}\nAccount --> Ledger",
|
||||
);
|
||||
expect(diagram.diagnostics).toEqual([]);
|
||||
expect(diagram.nodes[0]?.members).toEqual(["+String id", "+close()"]);
|
||||
expect(
|
||||
parseDiagram("classDiagram\nclass Account {\n +String id").diagnostics[0]
|
||||
?.message,
|
||||
).toMatch(/no closing/iu);
|
||||
});
|
||||
it("diagnoses directives without executing", () => {
|
||||
const d = parseDiagram(
|
||||
@@ -34,4 +54,23 @@ describe("diagram grammar", () => {
|
||||
expect(d.diagnostics).toHaveLength(2);
|
||||
expect(renderSvg(d)).not.toContain("bad.test");
|
||||
});
|
||||
it("supports ER attributes, mind maps, timelines and sequence notes", () => {
|
||||
const er = parseDiagram(
|
||||
"erDiagram\nUSER {\n string id PK\n}\nUSER ||--o{ ORDER : places",
|
||||
);
|
||||
expect(er.mode).toBe("er");
|
||||
expect(er.nodes.find((node) => node.id === "USER")?.members).toEqual([
|
||||
"string id PK",
|
||||
]);
|
||||
expect(er.edges[0]?.label).toContain("places");
|
||||
expect(parseDiagram("mindmap\n Root\n Child").edges).toHaveLength(1);
|
||||
expect(
|
||||
parseDiagram("timeline\nsection Build\n2026 : Release").nodes,
|
||||
).toHaveLength(2);
|
||||
expect(
|
||||
parseDiagram(
|
||||
"sequenceDiagram\nparticipant A\nparticipant B\nNote over A,B: inert",
|
||||
).edges[0],
|
||||
).toMatchObject({ arrow: false, dashed: true });
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user