1 Commits
Author SHA1 Message Date
zemion f128568e4e Release Diagram Tools 0.2.0
Verify / verify (push) Canceled after 0s
2026-09-02 12:11:46 +02:00
24 changed files with 599 additions and 77 deletions
+39
View File
@@ -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
+5
View File
@@ -1,5 +1,10 @@
# Changelog # 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 ## 0.1.0 - 2026-09-01
- Add bounded flowchart, sequence, class and state parser/layout. - Add bounded flowchart, sequence, class and state parser/layout.
+13 -1
View File
@@ -1,7 +1,19 @@
# Diagram Tools # 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, 25300% zoom and an accessible textual outline. 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, 25300% 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`. Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`.
+1 -1
View File
@@ -1,3 +1,3 @@
# Source identity # 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
View File
@@ -1,3 +1,14 @@
# Architecture # 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.
+20 -20
View File
@@ -1,22 +1,22 @@
{ {
"name": "diagram-tools", "name": "diagram-tools",
"version": "0.1.0", "version": "0.2.0",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "diagram-tools", "name": "diagram-tools",
"version": "0.1.0", "version": "0.2.0",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
@@ -42,24 +42,24 @@
} }
}, },
"node_modules/@add-ideas/toolbox-contract": { "node_modules/@add-ideas/toolbox-contract": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-contract/-/0.2.3/toolbox-contract-0.2.3.tgz", "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-T0PVSuMT40GjTDfQJhEEY3ZawQq8zz1/ry95JdKI6W39CdLacaRXdGnEpDCMHt+jUbf1Jz7Nat/M5dFCgKVM9A==", "integrity": "sha512-dKrK7BjOFwqJaBfJuhKxZKIld4sH0AKjEn6a0yLnbdMUFY+fFv4VSLGV2tNSBD016gumc2iNqOjUj/ld7x4rtA==",
"license": "Apache-2.0" "license": "Apache-2.0"
}, },
"node_modules/@add-ideas/toolbox-helpers": { "node_modules/@add-ideas/toolbox-helpers": {
"version": "0.1.0", "version": "0.2.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-helpers/-/0.1.0/toolbox-helpers-0.1.0.tgz", "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-UKl1Oxekedf8D2df86VrnVA53AcMhrnh6iUPXY+k8frirBXotb0yd8SGT+IF/3hcqYwcYe/v9WVFuSgKtIYVnw==", "integrity": "sha512-SdOqkw+P+3J3fa5iVkzb5P15rVepB001GNV21Oh8w0CZcVL+YRltgD/s+MVcTyrNijWQf3E5vtQON/3N2LLyKg==",
"license": "GPL-3.0-or-later" "license": "GPL-3.0-or-later"
}, },
"node_modules/@add-ideas/toolbox-shell-react": { "node_modules/@add-ideas/toolbox-shell-react": {
"version": "0.2.3", "version": "0.3.0",
"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", "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-DT5lQDH48BFkFcmFLZnQh7+Cm73JzBPcmp5WzUXypfkUXpEyDYHzaXgmW4kZ0edSwh4RK4sPmx+JPtK0X4aKCQ==", "integrity": "sha512-74p6JzAOG0YCAKdlc1hLofV4ZIko7vb448S75cIiM88PKm93EHl5VD7g8YVyfM56Ui97UY9dmy+Whiq4sGzpsg==",
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"peerDependencies": { "peerDependencies": {
"react": ">=18 <20", "react": ">=18 <20",
@@ -67,13 +67,13 @@
} }
}, },
"node_modules/@add-ideas/toolbox-testkit": { "node_modules/@add-ideas/toolbox-testkit": {
"version": "0.2.3", "version": "0.3.0",
"resolved": "https://git.add-ideas.de/api/packages/lotobo/npm/%40add-ideas%2Ftoolbox-testkit/-/0.2.3/toolbox-testkit-0.2.3.tgz", "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-sq1MwhKWfFKen+N+124hl74qQimRSvmQ9sOU7jdcI+2qCKZ67+2B8rWyezeV80uTFu4Jv6deHksfYQ/tKNV6XQ==", "integrity": "sha512-4Fk+oSvZFspOMIXr8Xy040nhAaBsIQAzsGyXWSpjn3+k3yBKq7nB1r5zCHhsXzfdLzvPDAx2KcmSNOhM330D9w==",
"dev": true, "dev": true,
"license": "Apache-2.0", "license": "Apache-2.0",
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3" "@add-ideas/toolbox-contract": "0.3.0"
}, },
"bin": { "bin": {
"toolbox-check": "dist/cli.js" "toolbox-check": "dist/cli.js"
+8 -5
View File
@@ -1,6 +1,6 @@
{ {
"name": "diagram-tools", "name": "diagram-tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Create safe deterministic diagrams locally.", "description": "Create safe deterministic diagrams locally.",
"license": "GPL-3.0-or-later", "license": "GPL-3.0-or-later",
"author": "Albrecht Degering", "author": "Albrecht Degering",
@@ -9,6 +9,9 @@
"url": "git+https://git.add-ideas.de/lotobo/diagram-tools.git" "url": "git+https://git.add-ideas.de/lotobo/diagram-tools.git"
}, },
"homepage": "https://git.add-ideas.de/lotobo/diagram-tools", "homepage": "https://git.add-ideas.de/lotobo/diagram-tools",
"bugs": {
"url": "https://git.add-ideas.de/lotobo/diagram-tools/issues"
},
"private": true, "private": true,
"type": "module", "type": "module",
"engines": { "engines": {
@@ -34,14 +37,14 @@
"release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force" "release:artifact": "npm run check && npm run test:browser && npm run package:release -- --force"
}, },
"dependencies": { "dependencies": {
"@add-ideas/toolbox-contract": "0.2.3", "@add-ideas/toolbox-contract": "0.3.0",
"@add-ideas/toolbox-helpers": "0.1.0", "@add-ideas/toolbox-helpers": "0.2.0",
"@add-ideas/toolbox-shell-react": "0.2.3", "@add-ideas/toolbox-shell-react": "0.3.0",
"react": "19.2.8", "react": "19.2.8",
"react-dom": "19.2.8" "react-dom": "19.2.8"
}, },
"devDependencies": { "devDependencies": {
"@add-ideas/toolbox-testkit": "0.2.3", "@add-ideas/toolbox-testkit": "0.3.0",
"@eslint/js": "10.0.1", "@eslint/js": "10.0.1",
"@playwright/test": "1.62.1", "@playwright/test": "1.62.1",
"@testing-library/jest-dom": "6.9.1", "@testing-library/jest-dom": "6.9.1",
+20 -2
View File
@@ -12,7 +12,25 @@ export default defineConfig({
timeout: 180000, timeout: 180000,
}, },
projects: [ 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"] },
},
], ],
}); });
+5
View File
@@ -1,5 +1,10 @@
# Changelog # 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 ## 0.1.0 - 2026-09-01
- Add bounded flowchart, sequence, class and state parser/layout. - Add bounded flowchart, sequence, class and state parser/layout.
+3 -3
View File
@@ -1,5 +1,5 @@
============================================================================== ==============================================================================
@add-ideas/toolbox-contract@0.2.3 @add-ideas/toolbox-contract@0.3.0
Declared licence: Apache-2.0 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: GPL-3.0-or-later
============================================================================== ==============================================================================
--- LICENSE --- --- 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 Declared licence: Apache-2.0
============================================================================== ==============================================================================
--- LICENSE --- --- LICENSE ---
+13 -1
View File
@@ -1,7 +1,19 @@
# Diagram Tools # 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, 25300% zoom and an accessible textual outline. 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, 25300% 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`. Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`.
+1 -1
View File
@@ -1,3 +1,3 @@
# Source identity # 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
View File
@@ -1,3 +1,14 @@
# Architecture # 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
View File
@@ -1,4 +1,4 @@
const CACHE = "diagram-tools-v0.1.0", const CACHE = "diagram-tools-v0.2.0",
APP = [ APP = [
"./", "./",
"./index.html", "./index.html",
+15 -2
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.diagram-tools", "id": "de.add-ideas.diagram-tools",
"name": "Diagram Tools", "name": "Diagram Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Create safe diagrams locally.", "description": "Create safe diagrams locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -21,9 +21,22 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": false, "fileUploads": true,
"telemetry": false, "telemetry": false,
"label": "Diagram source and exports remain local." "label": "Diagram source and exports remain local."
}, },
+3 -1
View File
@@ -18,7 +18,9 @@ export function HelpDialog({
onCancel={onClose} onCancel={onClose}
aria-label="About Diagram Tools" aria-label="About Diagram Tools"
> >
<button onClick={onClose}>×</button> <button type="button" aria-label="Close help" onClick={onClose}>
×
</button>
<h2>Diagram Tools</h2> <h2>Diagram Tools</h2>
<p> <p>
The syntax resembles a small familiar Mermaid subset but uses an The syntax resembles a small familiar Mermaid subset but uses an
+77 -3
View File
@@ -26,6 +26,31 @@ Idle --> Reading : open
Reading --> Ready : parsed Reading --> Ready : parsed
Reading -.-> Error : invalid Reading -.-> Error : invalid
Error --> Idle : reset`, 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; } as const;
export function Workbench() { export function Workbench() {
const [source, setSource] = useState<string>(EXAMPLES.flowchart), const [source, setSource] = useState<string>(EXAMPLES.flowchart),
@@ -60,6 +85,23 @@ export function Workbench() {
setStatus(e instanceof Error ? e.message : String(e)); 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 ( return (
<main className="workbench"> <main className="workbench">
<section className="hero panel"> <section className="hero panel">
@@ -111,6 +153,36 @@ export function Workbench() {
SVG SVG
</button> </button>
<button onClick={() => void png()}>PNG 2×</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> </div>
<h2>Syntax diagnostics</h2> <h2>Syntax diagnostics</h2>
{diagram.diagnostics.length ? ( {diagram.diagnostics.length ? (
@@ -126,9 +198,11 @@ export function Workbench() {
<p className="success">No syntax diagnostics.</p> <p className="success">No syntax diagnostics.</p>
)} )}
<p className="boundary"> <p className="boundary">
Caps: 50,000 characters, 2,000 lines, 200 nodes, 500 edges and a Caps: 50,000 characters, 2,000 lines, 200 nodes, 500 edges and a 20
2,400 px layout edge. Unsupported lines produce diagnostics; megapixel PNG export. Large vector canvases remain scrollable.
directives never execute. Flowchart, sequence, class, state, ER, mind-map and timeline
families share the inert model. Unsupported lines produce
diagnostics; directives never execute.
</p> </p>
</section> </section>
<section className="panel canvas-panel"> <section className="panel canvas-panel">
+237 -28
View File
@@ -1,8 +1,17 @@
export type DiagramMode = "flowchart" | "sequence" | "class" | "state"; export type DiagramMode =
"flowchart" | "sequence" | "class" | "state" | "er" | "mindmap" | "timeline";
export interface Node { export interface Node {
id: string; id: string;
label: string; label: string;
shape: "rect" | "round" | "diamond" | "actor" | "class" | "state"; shape:
| "rect"
| "round"
| "diamond"
| "actor"
| "class"
| "state"
| "circle"
| "note";
members: string[]; members: string[];
} }
export interface Edge { export interface Edge {
@@ -47,9 +56,12 @@ export function parseDiagram(source: string): Diagram {
} else if (/^sequenceDiagram$/iu.test(header)) mode = "sequence"; } else if (/^sequenceDiagram$/iu.test(header)) mode = "sequence";
else if (/^classDiagram$/iu.test(header)) mode = "class"; else if (/^classDiagram$/iu.test(header)) mode = "class";
else if (/^stateDiagram(?:-v2)?$/iu.test(header)) mode = "state"; 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 else
throw new Error( 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>(), const nodes = new Map<string, Node>(),
edges: Edge[] = [], edges: Edge[] = [],
@@ -65,6 +77,11 @@ export function parseDiagram(source: string): Diagram {
} }
return node; 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++) { for (let index = first + 1; index < lines.length; index++) {
const raw = lines[index]!, const raw = lines[index]!,
line = raw.trim(); line = raw.trim();
@@ -81,8 +98,85 @@ export function parseDiagram(source: string): Diagram {
continue; continue;
} }
try { try {
if (mode === "sequence") parseSequence(line, ensure, edges); if (mode === "er") {
else if (mode === "class") parseClass(line, ensure, edges); 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); else parseGraph(line, ensure, edges, mode);
} catch (error) { } catch (error) {
diagnostics.push( 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 (nodes.size > MAX_NODES) throw new Error("Diagram exceeds 200 nodes.");
if (edges.length > MAX_EDGES) throw new Error("Diagram exceeds 500 edges."); 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) { for (const edge of edges) {
ensure( ensure(
edge.from, edge.from,
@@ -113,6 +220,31 @@ export function parseDiagram(source: string): Diagram {
diagnostics.push(diag(first + 1, 1, "warning", "Diagram has no nodes.")); diagnostics.push(diag(first + 1, 1, "warning", "Diagram has no nodes."));
return { mode, direction, nodes: [...nodes.values()], edges, diagnostics }; 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( function parseGraph(
line: string, line: string,
ensure: (id: string, label?: string, shape?: Node["shape"]) => Node, ensure: (id: string, label?: string, shape?: Node["shape"]) => Node,
@@ -140,20 +272,29 @@ function parseGraph(
} }
function parseNode(source: string, mode: DiagramMode) { function parseNode(source: string, mode: DiagramMode) {
const match = 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, source,
); );
if (!match) throw new Error(`Unsupported node/edge syntax: ${source}`); if (!match) throw new Error(`Unsupported node/edge syntax: ${source}`);
const rawId = match[1]!;
return { return {
id: match[1]!, id: rawId === "[*]" ? "__boundary" : rawId,
label: match[2] ?? match[3] ?? match[4] ?? match[1]!, label:
shape: match[4] rawId === "[*]"
? ("diamond" as const) ? "●"
: match[3] : (match[2] ?? match[3] ?? match[4] ?? match[5] ?? rawId),
? ("round" as const) shape:
: mode === "state" rawId === "[*]"
? ("state" as const) ? ("circle" as const)
: ("rect" 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( function parseSequence(
@@ -161,6 +302,24 @@ function parseSequence(
ensure: (id: string, label?: string, shape?: Node["shape"]) => Node, ensure: (id: string, label?: string, shape?: Node["shape"]) => Node,
edges: Edge[], 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 = const participant =
/^(?:participant|actor)\s+([A-Za-z_][\w-]*)(?:\s+as\s+(.{1,200}))?$/iu.exec( /^(?:participant|actor)\s+([A-Za-z_][\w-]*)(?:\s+as\s+(.{1,200}))?$/iu.exec(
line, line,
@@ -218,6 +377,47 @@ function parseClass(
arrow: relation[2] !== "--", 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( function diag(
line: number, line: number,
column: number, column: number,
@@ -229,14 +429,21 @@ function diag(
export function renderSvg(diagram: Diagram): string { export function renderSvg(diagram: Diagram): string {
if (diagram.mode === "sequence") return renderSequence(diagram); if (diagram.mode === "sequence") return renderSequence(diagram);
const nodeWidth = 180, const nodeWidth = 160,
nodeHeight = diagram.mode === "class" ? 110 : 72, memberRows = Math.min(
gapX = 100, 8,
gapY = 75, 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 = columns =
diagram.direction === "LR" diagram.direction === "LR" || diagram.mode === "timeline"
? Math.min(5, Math.max(1, diagram.nodes.length)) ? Math.min(14, Math.max(1, diagram.nodes.length))
: Math.min(4, Math.max(1, Math.ceil(Math.sqrt(diagram.nodes.length)))), : Math.min(14, Math.max(1, Math.ceil(Math.sqrt(diagram.nodes.length)))),
positions = new Map<string, { x: number; y: number }>(); positions = new Map<string, { x: number; y: number }>();
diagram.nodes.forEach((node, index) => { diagram.nodes.forEach((node, index) => {
const column = const column =
@@ -248,8 +455,8 @@ export function renderSvg(diagram: Diagram): string {
}); });
}); });
const rows = Math.max(1, Math.ceil(diagram.nodes.length / columns)), const rows = Math.max(1, Math.ceil(diagram.nodes.length / columns)),
width = Math.min(2400, 100 + columns * (nodeWidth + gapX) - gapX), width = 100 + columns * (nodeWidth + gapX) - gapX,
height = Math.min(2400, 110 + rows * (nodeHeight + gapY) - gapY); height = 110 + rows * (nodeHeight + gapY) - gapY;
const edges = diagram.edges const edges = diagram.edges
.map((edge) => { .map((edge) => {
const a = positions.get(edge.from)!, const a = positions.get(edge.from)!,
@@ -280,7 +487,9 @@ function renderNode(
shape = shape =
node.shape === "diamond" 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}"/>` ? `<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 const members = node.members
.slice(0, 8) .slice(0, 8)
.map( .map(
@@ -292,8 +501,8 @@ function renderNode(
} }
function renderSequence(d: Diagram) { function renderSequence(d: Diagram) {
const gap = 190, const gap = 190,
width = Math.min(2400, 100 + Math.max(1, d.nodes.length) * gap), width = 100 + Math.max(1, d.nodes.length) * gap,
height = Math.min(2400, 140 + Math.max(1, d.edges.length) * 65), height = 140 + Math.max(1, d.edges.length) * 65,
positions = new Map( positions = new Map(
d.nodes.map((node, index) => [node.id, 70 + index * gap]), d.nodes.map((node, index) => [node.id, 70 + index * gap]),
); );
@@ -308,7 +517,7 @@ function renderSequence(d: Diagram) {
const x1 = positions.get(edge.from)!, const x1 = positions.get(edge.from)!,
x2 = positions.get(edge.to)!, x2 = positions.get(edge.to)!,
y = 105 + index * 65; 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(""); .join("");
return svgRoot(width, height, actors + messages); return svgRoot(width, height, actors + messages);
+17
View File
@@ -71,6 +71,23 @@ input {
color: CanvasText; color: CanvasText;
padding: 0.55rem 0.7rem; 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 { button {
cursor: pointer; cursor: pointer;
} }
+36 -2
View File
@@ -3,7 +3,7 @@
"schemaVersion": 1, "schemaVersion": 1,
"id": "de.add-ideas.diagram-tools", "id": "de.add-ideas.diagram-tools",
"name": "Diagram Tools", "name": "Diagram Tools",
"version": "0.1.0", "version": "0.2.0",
"description": "Create safe diagrams locally.", "description": "Create safe diagrams locally.",
"entry": "./", "entry": "./",
"icon": "./favicon.svg", "icon": "./favicon.svg",
@@ -21,9 +21,43 @@
"crossOriginIsolated": false, "crossOriginIsolated": false,
"topLevelContext": 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": { "privacy": {
"processing": "local", "processing": "local",
"fileUploads": false, "fileUploads": true,
"telemetry": false, "telemetry": false,
"label": "Diagram source and exports remain local." "label": "Diagram source and exports remain local."
}, },
+1 -1
View File
@@ -1 +1 @@
export const APP_VERSION = "0.1.0"; export const APP_VERSION = "0.2.0";
+1 -1
View File
@@ -74,6 +74,6 @@ test("PWA theme, offline reload, and headers", async ({
const manifest = await request.get("/deep/nested/diagram/toolbox-app.json"); const manifest = await request.get("/deep/nested/diagram/toolbox-app.json");
await expect(manifest.json()).resolves.toMatchObject({ await expect(manifest.json()).resolves.toMatchObject({
id: "de.add-ideas.diagram-tools", id: "de.add-ideas.diagram-tools",
version: "0.1.0", version: "0.2.0",
}); });
}); });
+18
View File
@@ -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);
});
+41 -2
View File
@@ -23,9 +23,29 @@ describe("diagram grammar", () => {
parseDiagram("classDiagram\nclass A\nA : +field\nA <|-- B").nodes[0] parseDiagram("classDiagram\nclass A\nA : +field\nA <|-- B").nodes[0]
?.members, ?.members,
).toEqual(["+field"]); ).toEqual(["+field"]);
expect(parseDiagram("stateDiagram-v2\nIdle --> Ready : go").mode).toBe( const state = parseDiagram(
"state", '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", () => { it("diagnoses directives without executing", () => {
const d = parseDiagram( const d = parseDiagram(
@@ -34,4 +54,23 @@ describe("diagram grammar", () => {
expect(d.diagnostics).toHaveLength(2); expect(d.diagnostics).toHaveLength(2);
expect(renderSvg(d)).not.toContain("bad.test"); 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 });
});
}); });