diff --git a/.gitea/workflows/verify.yml b/.gitea/workflows/verify.yml
new file mode 100644
index 0000000..b84a7cb
--- /dev/null
+++ b/.gitea/workflows/verify.yml
@@ -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
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 30e8c37..9e46d45 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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.
diff --git a/README.md b/README.md
index b51c7f9..68025c9 100644
--- a/README.md
+++ b/README.md
@@ -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`.
diff --git a/SOURCE.md b/SOURCE.md
index bbfcd24..02ec320 100644
--- a/SOURCE.md
+++ b/SOURCE.md
@@ -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.
diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md
index 2ec1b96..114ac82 100644
--- a/docs/ARCHITECTURE.md
+++ b/docs/ARCHITECTURE.md
@@ -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.
diff --git a/package-lock.json b/package-lock.json
index 004f702..14b45b9 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -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"
diff --git a/package.json b/package.json
index e77a02a..d643ec9 100644
--- a/package.json
+++ b/package.json
@@ -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",
diff --git a/playwright.config.ts b/playwright.config.ts
index 05fc1e4..a1e1af4 100644
--- a/playwright.config.ts
+++ b/playwright.config.ts
@@ -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"] },
+ },
],
});
diff --git a/public/CHANGELOG.md b/public/CHANGELOG.md
index 30e8c37..9e46d45 100644
--- a/public/CHANGELOG.md
+++ b/public/CHANGELOG.md
@@ -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.
diff --git a/public/LICENSES/npm-runtime-licenses.txt b/public/LICENSES/npm-runtime-licenses.txt
index 45a7c95..4f412f1 100644
--- a/public/LICENSES/npm-runtime-licenses.txt
+++ b/public/LICENSES/npm-runtime-licenses.txt
@@ -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 ---
diff --git a/public/README.md b/public/README.md
index b51c7f9..68025c9 100644
--- a/public/README.md
+++ b/public/README.md
@@ -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`.
diff --git a/public/SOURCE.md b/public/SOURCE.md
index bbfcd24..02ec320 100644
--- a/public/SOURCE.md
+++ b/public/SOURCE.md
@@ -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.
diff --git a/public/docs/ARCHITECTURE.md b/public/docs/ARCHITECTURE.md
index 2ec1b96..114ac82 100644
--- a/public/docs/ARCHITECTURE.md
+++ b/public/docs/ARCHITECTURE.md
@@ -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.
diff --git a/public/sw.js b/public/sw.js
index 1c25ead..f5d3216 100644
--- a/public/sw.js
+++ b/public/sw.js
@@ -1,4 +1,4 @@
-const CACHE = "diagram-tools-v0.1.0",
+const CACHE = "diagram-tools-v0.2.0",
APP = [
"./",
"./index.html",
diff --git a/public/toolbox-app.json b/public/toolbox-app.json
index b60d590..9265620 100644
--- a/public/toolbox-app.json
+++ b/public/toolbox-app.json
@@ -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."
},
diff --git a/src/components/HelpDialog.tsx b/src/components/HelpDialog.tsx
index 0a2ee0c..b496f05 100644
--- a/src/components/HelpDialog.tsx
+++ b/src/components/HelpDialog.tsx
@@ -18,7 +18,9 @@ export function HelpDialog({
onCancel={onClose}
aria-label="About Diagram Tools"
>
-
+
Diagram Tools
The syntax resembles a small familiar Mermaid subset but uses an
diff --git a/src/components/Workbench.tsx b/src/components/Workbench.tsx
index 84c17dd..7ba2e6c 100644
--- a/src/components/Workbench.tsx
+++ b/src/components/Workbench.tsx
@@ -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(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 (
@@ -111,6 +153,36 @@ export function Workbench() {
SVG
+
+
+
Syntax diagnostics
{diagram.diagnostics.length ? (
@@ -126,9 +198,11 @@ export function Workbench() {
No syntax diagnostics.
)}
- 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.
diff --git a/src/core/diagram.ts b/src/core/diagram.ts
index 2c1f7fc..3d7aa51 100644
--- a/src/core/diagram.ts
+++ b/src/core/diagram.ts
@@ -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(),
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();
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"
? ``
- : ``;
+ : node.shape === "circle"
+ ? ``
+ : ``;
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 `${xml(edge.label)}`;
+ return `${xml(edge.label)}`;
})
.join("");
return svgRoot(width, height, actors + messages);
diff --git a/src/styles.css b/src/styles.css
index f915475..c6c7b96 100644
--- a/src/styles.css
+++ b/src/styles.css
@@ -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;
}
diff --git a/src/toolbox/manifest.source.json b/src/toolbox/manifest.source.json
index b60d590..6c456f8 100644
--- a/src/toolbox/manifest.source.json
+++ b/src/toolbox/manifest.source.json
@@ -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."
},
diff --git a/src/version.ts b/src/version.ts
index 76162f8..0ba6e37 100644
--- a/src/version.ts
+++ b/src/version.ts
@@ -1 +1 @@
-export const APP_VERSION = "0.1.0";
+export const APP_VERSION = "0.2.0";
diff --git a/tests/browser/app.spec.ts b/tests/browser/app.spec.ts
index 0cb54f8..bae62e6 100644
--- a/tests/browser/app.spec.ts
+++ b/tests/browser/app.spec.ts
@@ -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",
});
});
diff --git a/tests/browser/responsive.spec.ts b/tests/browser/responsive.spec.ts
new file mode 100644
index 0000000..1ef2c7e
--- /dev/null
+++ b/tests/browser/responsive.spec.ts
@@ -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);
+});
diff --git a/tests/core/diagram.test.ts b/tests/core/diagram.test.ts
index ab7c5b3..5f0b57c 100644
--- a/tests/core/diagram.test.ts
+++ b/tests/core/diagram.test.ts
@@ -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 });
+ });
});