Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f128568e4e |
@@ -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
|
# 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.
|
||||||
|
|||||||
@@ -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, 25–300% 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, 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`.
|
Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`.
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Generated
+20
-20
@@ -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
@@ -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
@@ -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"] },
|
||||||
|
},
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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, 25–300% 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, 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`.
|
Run `npm ci`, `npm run check`, `npm run test:browser`, then `npm run package:release -- --force`.
|
||||||
|
|||||||
+1
-1
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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");
|
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",
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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]
|
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 });
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user