sfn-diagram 0.4.1 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +130 -45
- package/dist/cli.js +126 -32
- package/dist/index.cjs +228 -33
- package/dist/index.d.cts +45 -1
- package/dist/index.d.ts +45 -1
- package/dist/index.js +228 -34
- package/dist/png.cjs +123 -31
- package/dist/png.d.cts +5 -0
- package/dist/png.d.ts +5 -0
- package/dist/png.js +123 -31
- package/package.json +16 -8
package/README.md
CHANGED
|
@@ -2,9 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
[](https://www.npmjs.com/package/sfn-diagram)
|
|
4
4
|
[](https://opensource.org/licenses/MIT)
|
|
5
|
+
[](https://yusufaf.github.io/sfn-diagram/)
|
|
5
6
|
|
|
6
7
|
Generate beautiful, interactive diagrams from AWS Step Functions ASL (Amazon States Language) definitions. Supports dual output formats: D3.js-based SVG and Mermaid.js diagram code, plus PNG export.
|
|
7
8
|
|
|
9
|
+
**▶ [Try it in the live playground](https://yusufaf.github.io/sfn-diagram/)** — paste any ASL definition and preview the diagram instantly, no install required.
|
|
10
|
+
|
|
8
11
|
## Features
|
|
9
12
|
|
|
10
13
|
- **Multiple Output Formats**: SVG (D3.js), Mermaid syntax, and PNG
|
|
@@ -15,6 +18,7 @@ Generate beautiful, interactive diagrams from AWS Step Functions ASL (Amazon Sta
|
|
|
15
18
|
- **Type-Safe**: Full TypeScript support with comprehensive type definitions
|
|
16
19
|
- **AWS SDK Integration**: Direct integration with AWS Step Functions API
|
|
17
20
|
- **Dual APIs**: Function-based and class-based interfaces
|
|
21
|
+
- **Runs Anywhere**: SVG and Mermaid generation has zero platform dependencies — works in Node, the browser, and edge runtimes
|
|
18
22
|
|
|
19
23
|
## Installation
|
|
20
24
|
|
|
@@ -22,6 +26,24 @@ Generate beautiful, interactive diagrams from AWS Step Functions ASL (Amazon Sta
|
|
|
22
26
|
npm install sfn-diagram
|
|
23
27
|
```
|
|
24
28
|
|
|
29
|
+
## Runtime Support
|
|
30
|
+
|
|
31
|
+
The core entry (`sfn-diagram`) builds SVG with a DOM-free string renderer, so
|
|
32
|
+
`generateSvg`, `generateMermaid`, `generateDiagram`, and `generateFromAwsResponse`
|
|
33
|
+
run in **Node, browsers, and edge runtimes** (Cloudflare Workers, Vercel Edge, Deno, Bun)
|
|
34
|
+
with no DOM polyfill.
|
|
35
|
+
|
|
36
|
+
PNG export (`sfn-diagram/png`) and the CLI are **Node-only** — they rely on a headless
|
|
37
|
+
browser (`node-html-to-image`) and Node's filesystem respectively.
|
|
38
|
+
|
|
39
|
+
```ts
|
|
40
|
+
// Works in Node, browser, and edge:
|
|
41
|
+
import { generateSvg } from 'sfn-diagram';
|
|
42
|
+
|
|
43
|
+
// Node-only:
|
|
44
|
+
import { exportPng } from 'sfn-diagram/png';
|
|
45
|
+
```
|
|
46
|
+
|
|
25
47
|
## Command-line Usage
|
|
26
48
|
|
|
27
49
|
The package ships a CLI for use without writing any JavaScript:
|
|
@@ -82,15 +104,15 @@ console.log(`Generated ${width}x${height} diagram`);
|
|
|
82
104
|
```typescript
|
|
83
105
|
import { SfnDiagramGenerator } from 'sfn-diagram';
|
|
84
106
|
|
|
85
|
-
const generator = new SfnDiagramGenerator(
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
107
|
+
const generator = new SfnDiagramGenerator({
|
|
108
|
+
theme: 'dark',
|
|
109
|
+
layout: 'TB',
|
|
110
|
+
nodeWidth: 150,
|
|
111
|
+
nodeHeight: 80,
|
|
112
|
+
});
|
|
90
113
|
|
|
91
|
-
const { svg } = generator.generateSvg();
|
|
92
|
-
const {
|
|
93
|
-
const { png } = await generator.exportPng();
|
|
114
|
+
const { svg } = generator.generateSvg({ aslDefinition: asl });
|
|
115
|
+
const { code } = generator.generateMermaid({ aslDefinition: asl });
|
|
94
116
|
```
|
|
95
117
|
|
|
96
118
|
## API Reference
|
|
@@ -117,7 +139,7 @@ const result = generateSvg({
|
|
|
117
139
|
customColors: {} // Override colors for specific states
|
|
118
140
|
});
|
|
119
141
|
|
|
120
|
-
// Returns: { svg: string, width: number, height: number, nodeCount: number }
|
|
142
|
+
// Returns SvgOutput: { svg: string, width: number, height: number, metadata: { edgeCount: number, nodeCount: number } }
|
|
121
143
|
```
|
|
122
144
|
|
|
123
145
|
### generateMermaid(params)
|
|
@@ -132,40 +154,43 @@ const result = generateMermaid({
|
|
|
132
154
|
includeComments: true
|
|
133
155
|
});
|
|
134
156
|
|
|
135
|
-
// Returns: {
|
|
157
|
+
// Returns MermaidOutput: { code: string, metadata: { edgeCount: number, stateCount: number } }
|
|
136
158
|
```
|
|
137
159
|
|
|
138
160
|
### generateDiagram(params)
|
|
139
161
|
|
|
140
|
-
Generate
|
|
162
|
+
Generate a diagram, choosing the output format via the `format` option (defaults to `'svg'`).
|
|
141
163
|
|
|
142
164
|
```typescript
|
|
143
165
|
import { generateDiagram } from 'sfn-diagram';
|
|
144
166
|
|
|
145
|
-
const
|
|
167
|
+
const svgResult = generateDiagram({
|
|
146
168
|
aslDefinition: asl,
|
|
147
169
|
theme: 'dark'
|
|
148
|
-
});
|
|
170
|
+
}); // Returns SvgOutput
|
|
149
171
|
|
|
150
|
-
|
|
172
|
+
const mermaidResult = generateDiagram({
|
|
173
|
+
aslDefinition: asl,
|
|
174
|
+
format: 'mermaid'
|
|
175
|
+
}); // Returns MermaidOutput
|
|
151
176
|
```
|
|
152
177
|
|
|
153
178
|
### exportPng(params)
|
|
154
179
|
|
|
155
|
-
Export diagram as PNG image.
|
|
180
|
+
Export diagram as PNG image. Node-only — imported from the `sfn-diagram/png` subpath.
|
|
156
181
|
|
|
157
182
|
```typescript
|
|
158
|
-
import { exportPng } from 'sfn-diagram';
|
|
183
|
+
import { exportPng } from 'sfn-diagram/png';
|
|
159
184
|
|
|
160
185
|
const result = await exportPng({
|
|
161
186
|
aslDefinition: asl,
|
|
162
187
|
theme: 'light',
|
|
163
|
-
|
|
164
|
-
|
|
188
|
+
pngQuality: 90, // 1–100 (default 90)
|
|
189
|
+
backgroundColor: 'transparent'
|
|
165
190
|
});
|
|
166
191
|
|
|
167
|
-
// Returns: {
|
|
168
|
-
writeFileSync('diagram.png', result.
|
|
192
|
+
// Returns PngOutput: { buffer: Buffer, width: number, height: number, metadata: { format: 'png' } }
|
|
193
|
+
writeFileSync('diagram.png', result.buffer);
|
|
169
194
|
```
|
|
170
195
|
|
|
171
196
|
### generateFromAwsResponse(params)
|
|
@@ -184,33 +209,39 @@ const response = await client.send(
|
|
|
184
209
|
);
|
|
185
210
|
|
|
186
211
|
const { svg } = generateFromAwsResponse({
|
|
187
|
-
|
|
212
|
+
response,
|
|
188
213
|
theme: 'dark'
|
|
189
214
|
});
|
|
190
215
|
```
|
|
191
216
|
|
|
192
217
|
### SfnDiagramGenerator Class
|
|
193
218
|
|
|
194
|
-
|
|
219
|
+
Reusable generator that holds diagram options once and applies them to every call. Configure options via the constructor or the fluent `setOptions()` method; pass the `aslDefinition` per generation.
|
|
195
220
|
|
|
196
221
|
```typescript
|
|
197
222
|
import { SfnDiagramGenerator } from 'sfn-diagram';
|
|
198
223
|
|
|
199
|
-
const generator = new SfnDiagramGenerator(
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
|
|
224
|
+
const generator = new SfnDiagramGenerator({
|
|
225
|
+
theme: 'dark',
|
|
226
|
+
layout: 'LR',
|
|
227
|
+
nodeWidth: 150,
|
|
228
|
+
nodeHeight: 80,
|
|
229
|
+
rankSeparation: 60,
|
|
230
|
+
nodeSeparation: 60,
|
|
231
|
+
edgeStyle: 'curved',
|
|
232
|
+
padding: 30,
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
// Update options later (chainable)
|
|
236
|
+
generator.setOptions({ theme: 'light' });
|
|
237
|
+
|
|
238
|
+
// Generate outputs (aslDefinition passed per call)
|
|
239
|
+
const svgResult = generator.generateSvg({ aslDefinition: asl });
|
|
240
|
+
const mermaidResult = generator.generateMermaid({ aslDefinition: asl });
|
|
212
241
|
```
|
|
213
242
|
|
|
243
|
+
> PNG export is a standalone function from `sfn-diagram/png` (`exportPng`), not a method on this class.
|
|
244
|
+
|
|
214
245
|
## Configuration Options
|
|
215
246
|
|
|
216
247
|
### Themes
|
|
@@ -434,21 +465,23 @@ const { svg } = generateSvg({ aslDefinition: parallelAsl });
|
|
|
434
465
|
### Export Multiple Formats
|
|
435
466
|
|
|
436
467
|
```typescript
|
|
437
|
-
import {
|
|
468
|
+
import { generateSvg, generateMermaid } from 'sfn-diagram';
|
|
469
|
+
import { exportPng } from 'sfn-diagram/png';
|
|
438
470
|
import { writeFileSync } from 'fs';
|
|
439
471
|
|
|
440
472
|
// Generate SVG and Mermaid
|
|
441
|
-
const { svg
|
|
442
|
-
|
|
443
|
-
writeFileSync('diagram.
|
|
473
|
+
const { svg } = generateSvg({ aslDefinition: asl });
|
|
474
|
+
const { code } = generateMermaid({ aslDefinition: asl });
|
|
475
|
+
writeFileSync('diagram.svg', svg);
|
|
476
|
+
writeFileSync('diagram.mmd', code);
|
|
444
477
|
|
|
445
|
-
// Generate PNG
|
|
446
|
-
const {
|
|
478
|
+
// Generate PNG (Node-only)
|
|
479
|
+
const { buffer } = await exportPng({
|
|
447
480
|
aslDefinition: asl,
|
|
448
|
-
|
|
449
|
-
|
|
481
|
+
pngQuality: 90,
|
|
482
|
+
backgroundColor: 'transparent'
|
|
450
483
|
});
|
|
451
|
-
writeFileSync('diagram.png',
|
|
484
|
+
writeFileSync('diagram.png', buffer);
|
|
452
485
|
```
|
|
453
486
|
|
|
454
487
|
## TypeScript Support
|
|
@@ -461,10 +494,62 @@ import type {
|
|
|
461
494
|
DiagramOptions,
|
|
462
495
|
SvgOutput,
|
|
463
496
|
MermaidOutput,
|
|
464
|
-
PngOutput,
|
|
465
497
|
CustomTheme,
|
|
466
498
|
StateType
|
|
467
499
|
} from 'sfn-diagram';
|
|
500
|
+
|
|
501
|
+
// PNG types live on the Node-only subpath
|
|
502
|
+
import type { PngOutput, ExportPngParams } from 'sfn-diagram/png';
|
|
503
|
+
```
|
|
504
|
+
|
|
505
|
+
## Ecosystem
|
|
506
|
+
|
|
507
|
+
### Playground
|
|
508
|
+
|
|
509
|
+
An interactive browser-based editor for exploring ASL definitions and previewing diagrams in real time. **[Open the live playground →](https://yusufaf.github.io/sfn-diagram/)** or run it locally from the [`playground/`](playground/) directory.
|
|
510
|
+
|
|
511
|
+
```bash
|
|
512
|
+
cd playground
|
|
513
|
+
pnpm install
|
|
514
|
+
pnpm dev
|
|
515
|
+
```
|
|
516
|
+
|
|
517
|
+
Paste any ASL JSON, switch themes, and see the SVG diagram update instantly — no install required beyond the dev server.
|
|
518
|
+
|
|
519
|
+
### VS Code Extension
|
|
520
|
+
|
|
521
|
+
Preview Step Functions diagrams directly inside VS Code. Located in [`packages/vscode-sfn-diagram/`](packages/vscode-sfn-diagram/).
|
|
522
|
+
|
|
523
|
+
**Install from source:**
|
|
524
|
+
```bash
|
|
525
|
+
cd packages/vscode-sfn-diagram
|
|
526
|
+
pnpm install
|
|
527
|
+
pnpm package # produces vscode-sfn-diagram-*.vsix
|
|
528
|
+
code --install-extension vscode-sfn-diagram-*.vsix
|
|
529
|
+
```
|
|
530
|
+
|
|
531
|
+
**Usage:** Open any `.json` or `.asl` file and run **Step Functions: Preview Step Functions Diagram** from the command palette, or click the diagram icon in the editor title bar.
|
|
532
|
+
|
|
533
|
+
### GitHub Action
|
|
534
|
+
|
|
535
|
+
Post SVG diff previews and Mermaid diagrams as PR comments whenever ASL files change. Located in [`packages/github-action-sfn-diagram/`](packages/github-action-sfn-diagram/).
|
|
536
|
+
|
|
537
|
+
```yaml
|
|
538
|
+
# .github/workflows/sfn-preview.yml
|
|
539
|
+
name: Step Functions Preview
|
|
540
|
+
on:
|
|
541
|
+
pull_request:
|
|
542
|
+
paths: ['**/*.asl.json']
|
|
543
|
+
|
|
544
|
+
jobs:
|
|
545
|
+
preview:
|
|
546
|
+
runs-on: ubuntu-latest
|
|
547
|
+
steps:
|
|
548
|
+
- uses: actions/checkout@v4
|
|
549
|
+
with: { fetch-depth: 0 }
|
|
550
|
+
- uses: ./packages/github-action-sfn-diagram
|
|
551
|
+
with:
|
|
552
|
+
github-token: ${{ secrets.GITHUB_TOKEN }}
|
|
468
553
|
```
|
|
469
554
|
|
|
470
555
|
## Contributing
|
package/dist/cli.js
CHANGED
|
@@ -1,10 +1,9 @@
|
|
|
1
1
|
#!/usr/bin/env node
|
|
2
2
|
import { readFileSync, writeFileSync } from "node:fs";
|
|
3
3
|
import { resolve } from "node:path";
|
|
4
|
+
import { pathToFileURL } from "node:url";
|
|
4
5
|
import dagre from "@dagrejs/dagre";
|
|
5
|
-
import { select } from "d3-selection";
|
|
6
6
|
import { curveBasis, line } from "d3-shape";
|
|
7
|
-
import { JSDOM } from "jsdom";
|
|
8
7
|
import nodeHtmlToImage from "node-html-to-image";
|
|
9
8
|
//#region src/config/themes.ts
|
|
10
9
|
/**
|
|
@@ -479,6 +478,7 @@ function parseAsl(params) {
|
|
|
479
478
|
validateAsl({ definition });
|
|
480
479
|
extractStatesRecursively({
|
|
481
480
|
definition,
|
|
481
|
+
nodeIndex: /* @__PURE__ */ new Map(),
|
|
482
482
|
nodes,
|
|
483
483
|
options
|
|
484
484
|
});
|
|
@@ -586,7 +586,7 @@ function extractConditionLabel(choice) {
|
|
|
586
586
|
* Recursively extract all states including those nested in Parallel branches and Map iterators
|
|
587
587
|
*/
|
|
588
588
|
function extractStatesRecursively(params) {
|
|
589
|
-
const { definition, nodes, options } = params;
|
|
589
|
+
const { definition, nodeIndex, nodes, options } = params;
|
|
590
590
|
for (const [stateName, state] of Object.entries(definition.States)) {
|
|
591
591
|
const stateNode = createStateNode({
|
|
592
592
|
name: stateName,
|
|
@@ -595,13 +595,15 @@ function extractStatesRecursively(params) {
|
|
|
595
595
|
stylePreset: options?.stylePreset
|
|
596
596
|
});
|
|
597
597
|
nodes.push(stateNode);
|
|
598
|
+
nodeIndex.set(stateNode.id, stateNode);
|
|
598
599
|
if (state.Type === "Parallel" && state.Branches) state.Branches.forEach((branch, index) => {
|
|
599
600
|
extractStatesRecursively({
|
|
600
601
|
definition: branch,
|
|
602
|
+
nodeIndex,
|
|
601
603
|
nodes,
|
|
602
604
|
options
|
|
603
605
|
});
|
|
604
|
-
if (
|
|
606
|
+
if (nodeIndex.get(branch.StartAt)) stateNode.children?.push(branch.StartAt);
|
|
605
607
|
const endNodeId = `${stateName}__branch${index}__end`;
|
|
606
608
|
const endNode = {
|
|
607
609
|
id: endNodeId,
|
|
@@ -616,21 +618,23 @@ function extractStatesRecursively(params) {
|
|
|
616
618
|
type: "BranchEnd"
|
|
617
619
|
};
|
|
618
620
|
nodes.push(endNode);
|
|
621
|
+
nodeIndex.set(endNodeId, endNode);
|
|
619
622
|
stateNode.children?.push(endNodeId);
|
|
620
623
|
markBranchStatesAsChildren({
|
|
621
624
|
branch,
|
|
622
625
|
containerNode: stateNode,
|
|
623
|
-
|
|
626
|
+
nodeIndex
|
|
624
627
|
});
|
|
625
628
|
});
|
|
626
629
|
if (state.Type === "Map" && state.Iterator) {
|
|
627
630
|
const iterator = state.Iterator;
|
|
628
631
|
extractStatesRecursively({
|
|
629
632
|
definition: iterator,
|
|
633
|
+
nodeIndex,
|
|
630
634
|
nodes,
|
|
631
635
|
options
|
|
632
636
|
});
|
|
633
|
-
if (
|
|
637
|
+
if (nodeIndex.get(iterator.StartAt)) stateNode.children?.push(iterator.StartAt);
|
|
634
638
|
const endNodeId = `${stateName}__iterator__end`;
|
|
635
639
|
const endNode = {
|
|
636
640
|
id: endNodeId,
|
|
@@ -645,11 +649,12 @@ function extractStatesRecursively(params) {
|
|
|
645
649
|
type: "IteratorEnd"
|
|
646
650
|
};
|
|
647
651
|
nodes.push(endNode);
|
|
652
|
+
nodeIndex.set(endNodeId, endNode);
|
|
648
653
|
stateNode.children?.push(endNodeId);
|
|
649
654
|
markBranchStatesAsChildren({
|
|
650
655
|
branch: iterator,
|
|
651
656
|
containerNode: stateNode,
|
|
652
|
-
|
|
657
|
+
nodeIndex
|
|
653
658
|
});
|
|
654
659
|
}
|
|
655
660
|
}
|
|
@@ -658,8 +663,14 @@ function extractStatesRecursively(params) {
|
|
|
658
663
|
* Mark all states in a branch as children of the container for bounding box calculation
|
|
659
664
|
*/
|
|
660
665
|
function markBranchStatesAsChildren(params) {
|
|
661
|
-
const { branch, containerNode,
|
|
662
|
-
|
|
666
|
+
const { branch, containerNode, nodeIndex } = params;
|
|
667
|
+
const children = containerNode.children;
|
|
668
|
+
if (!children) return;
|
|
669
|
+
const existingChildren = new Set(children);
|
|
670
|
+
for (const stateName of Object.keys(branch.States)) if (nodeIndex.has(stateName) && !existingChildren.has(stateName)) {
|
|
671
|
+
children.push(stateName);
|
|
672
|
+
existingChildren.add(stateName);
|
|
673
|
+
}
|
|
663
674
|
}
|
|
664
675
|
/**
|
|
665
676
|
* Extract edges from nested state machines (Parallel branches and Map iterators)
|
|
@@ -797,17 +808,19 @@ var DagreLayout = class {
|
|
|
797
808
|
y: dagNode.y
|
|
798
809
|
};
|
|
799
810
|
});
|
|
811
|
+
const positionedNodeIndex = new Map(positionedNodes.map((node) => [node.id, node]));
|
|
800
812
|
const containerNodes = this.calculateContainerBounds({
|
|
801
813
|
containers: nodes.filter((node) => node.isContainer),
|
|
802
|
-
|
|
814
|
+
positionedNodeIndex
|
|
803
815
|
});
|
|
804
816
|
const allPositionedNodes = [...positionedNodes, ...containerNodes];
|
|
817
|
+
const positionedNodesById = new Map(allPositionedNodes.map((node) => [node.id, node]));
|
|
805
818
|
const routedEdges = edges.map((edge) => {
|
|
806
819
|
if (edge.visualOnly) return {
|
|
807
820
|
...edge,
|
|
808
821
|
points: this.calculateVisualEdgePoints({
|
|
809
822
|
edge,
|
|
810
|
-
|
|
823
|
+
positionedNodesById
|
|
811
824
|
})
|
|
812
825
|
};
|
|
813
826
|
const dagEdge = graph.edge(edge.from, edge.to);
|
|
@@ -830,9 +843,13 @@ var DagreLayout = class {
|
|
|
830
843
|
* Calculate bounding boxes for container nodes based on their children positions
|
|
831
844
|
*/
|
|
832
845
|
calculateContainerBounds(params) {
|
|
833
|
-
const { containers,
|
|
846
|
+
const { containers, positionedNodeIndex } = params;
|
|
834
847
|
return containers.map((container) => {
|
|
835
|
-
const children =
|
|
848
|
+
const children = [];
|
|
849
|
+
for (const childId of container.children || []) {
|
|
850
|
+
const child = positionedNodeIndex.get(childId);
|
|
851
|
+
if (child && child.type !== "BranchEnd" && child.type !== "IteratorEnd") children.push(child);
|
|
852
|
+
}
|
|
836
853
|
if (children.length === 0) return {
|
|
837
854
|
...container,
|
|
838
855
|
height: 200,
|
|
@@ -842,10 +859,20 @@ var DagreLayout = class {
|
|
|
842
859
|
};
|
|
843
860
|
const padding = 40;
|
|
844
861
|
const headerHeight = 50;
|
|
845
|
-
|
|
846
|
-
|
|
847
|
-
|
|
848
|
-
|
|
862
|
+
let minX = Infinity;
|
|
863
|
+
let maxX = -Infinity;
|
|
864
|
+
let minY = Infinity;
|
|
865
|
+
let maxY = -Infinity;
|
|
866
|
+
for (const child of children) {
|
|
867
|
+
const halfWidth = (child.width || 0) / 2;
|
|
868
|
+
const halfHeight = (child.height || 0) / 2;
|
|
869
|
+
const childX = child.x || 0;
|
|
870
|
+
const childY = child.y || 0;
|
|
871
|
+
minX = Math.min(minX, childX - halfWidth);
|
|
872
|
+
maxX = Math.max(maxX, childX + halfWidth);
|
|
873
|
+
minY = Math.min(minY, childY - halfHeight);
|
|
874
|
+
maxY = Math.max(maxY, childY + halfHeight);
|
|
875
|
+
}
|
|
849
876
|
const width = maxX - minX + padding * 2;
|
|
850
877
|
const height = maxY - minY + padding * 2 + headerHeight;
|
|
851
878
|
const x = (minX + maxX) / 2;
|
|
@@ -863,9 +890,9 @@ var DagreLayout = class {
|
|
|
863
890
|
* Calculate routing points for visual-only edges
|
|
864
891
|
*/
|
|
865
892
|
calculateVisualEdgePoints(params) {
|
|
866
|
-
const { edge,
|
|
867
|
-
const fromNode =
|
|
868
|
-
const toNode =
|
|
893
|
+
const { edge, positionedNodesById } = params;
|
|
894
|
+
const fromNode = positionedNodesById.get(edge.from);
|
|
895
|
+
const toNode = positionedNodesById.get(edge.to);
|
|
869
896
|
if (!fromNode || !toNode) return [];
|
|
870
897
|
if (fromNode.isContainer && fromNode.children?.includes(edge.to)) {
|
|
871
898
|
const headerHeight = 50;
|
|
@@ -923,6 +950,65 @@ var DagreLayout = class {
|
|
|
923
950
|
}
|
|
924
951
|
};
|
|
925
952
|
//#endregion
|
|
953
|
+
//#region src/renderers/svgBuilder.ts
|
|
954
|
+
/**
|
|
955
|
+
* Escape a string for use as a double-quoted HTML attribute value.
|
|
956
|
+
*
|
|
957
|
+
* Matches the HTML serialization algorithm: only `&`, U+00A0, and `"` are escaped
|
|
958
|
+
* (`<` and `>` are intentionally left untouched inside attribute values).
|
|
959
|
+
*/
|
|
960
|
+
function escapeAttribute(value) {
|
|
961
|
+
return value.replace(/&/g, "&").replace(/\u00A0/g, " ").replace(/"/g, """);
|
|
962
|
+
}
|
|
963
|
+
/**
|
|
964
|
+
* Escape a string for use as element text content.
|
|
965
|
+
*
|
|
966
|
+
* Matches the HTML serialization algorithm: `&`, U+00A0, `<`, and `>` are escaped.
|
|
967
|
+
*/
|
|
968
|
+
function escapeText(value) {
|
|
969
|
+
return value.replace(/&/g, "&").replace(/\u00A0/g, " ").replace(/</g, "<").replace(/>/g, ">");
|
|
970
|
+
}
|
|
971
|
+
/**
|
|
972
|
+
* A single SVG element node with chainable construction.
|
|
973
|
+
*
|
|
974
|
+
* Create a root with `new SvgElement('svg')`, build the tree with `append`/`attr`/`text`,
|
|
975
|
+
* then call {@link serialize} to produce the SVG string.
|
|
976
|
+
*/
|
|
977
|
+
var SvgElement = class SvgElement {
|
|
978
|
+
tag;
|
|
979
|
+
attributes = [];
|
|
980
|
+
children = [];
|
|
981
|
+
textContent = null;
|
|
982
|
+
constructor(tag) {
|
|
983
|
+
this.tag = tag;
|
|
984
|
+
}
|
|
985
|
+
/**
|
|
986
|
+
* Set an attribute. Repeated calls preserve insertion order, matching the DOM,
|
|
987
|
+
* so a later call appends the attribute rather than reordering an existing one.
|
|
988
|
+
*/
|
|
989
|
+
attr(name, value) {
|
|
990
|
+
this.attributes.push([name, String(value)]);
|
|
991
|
+
return this;
|
|
992
|
+
}
|
|
993
|
+
/** Append a child element of the given tag and return it for chaining. */
|
|
994
|
+
append(tag) {
|
|
995
|
+
const child = new SvgElement(tag);
|
|
996
|
+
this.children.push(child);
|
|
997
|
+
return child;
|
|
998
|
+
}
|
|
999
|
+
/** Set the element's text content. */
|
|
1000
|
+
text(value) {
|
|
1001
|
+
this.textContent = String(value);
|
|
1002
|
+
return this;
|
|
1003
|
+
}
|
|
1004
|
+
/** Serialize this element and its subtree to an SVG string. */
|
|
1005
|
+
serialize() {
|
|
1006
|
+
const attrs = this.attributes.map(([name, value]) => ` ${name}="${escapeAttribute(value)}"`).join("");
|
|
1007
|
+
const inner = (this.textContent !== null ? escapeText(this.textContent) : "") + this.children.map((child) => child.serialize()).join("");
|
|
1008
|
+
return `<${this.tag}${attrs}>${inner}</${this.tag}>`;
|
|
1009
|
+
}
|
|
1010
|
+
};
|
|
1011
|
+
//#endregion
|
|
926
1012
|
//#region src/renderers/SvgRenderer.ts
|
|
927
1013
|
/**
|
|
928
1014
|
* SvgRenderer - Generates SVG diagrams from positioned nodes and edges
|
|
@@ -930,18 +1016,20 @@ var DagreLayout = class {
|
|
|
930
1016
|
var SvgRenderer = class {
|
|
931
1017
|
options;
|
|
932
1018
|
theme;
|
|
1019
|
+
pathGenerator;
|
|
933
1020
|
constructor(options) {
|
|
934
1021
|
this.options = options;
|
|
935
1022
|
this.theme = getTheme(options.theme, options.customColors);
|
|
1023
|
+
const generator = line().x((point) => point.x).y((point) => point.y);
|
|
1024
|
+
if (options.edgeStyle === "curved") generator.curve(curveBasis);
|
|
1025
|
+
this.pathGenerator = generator;
|
|
936
1026
|
}
|
|
937
1027
|
/**
|
|
938
1028
|
* Render the diagram to SVG string
|
|
939
1029
|
*/
|
|
940
1030
|
render(layout) {
|
|
941
|
-
const document = new JSDOM("<!DOCTYPE html><html><body></body></html>").window.document;
|
|
942
1031
|
const bounds = this.calculateBounds(layout);
|
|
943
|
-
const
|
|
944
|
-
const svg = select(svgNode).attr("width", bounds.width).attr("height", bounds.height).attr("xmlns", "http://www.w3.org/2000/svg").attr("viewBox", `${bounds.minX} ${bounds.minY} ${bounds.width} ${bounds.height}`);
|
|
1032
|
+
const svg = new SvgElement("svg").attr("width", bounds.width).attr("height", bounds.height).attr("xmlns", "http://www.w3.org/2000/svg").attr("viewBox", `${bounds.minX} ${bounds.minY} ${bounds.width} ${bounds.height}`);
|
|
945
1033
|
if (this.theme.background && this.theme.background !== "transparent") svg.append("rect").attr("x", bounds.minX).attr("y", bounds.minY).attr("width", bounds.width).attr("height", bounds.height).attr("fill", this.theme.background);
|
|
946
1034
|
const defs = svg.append("defs");
|
|
947
1035
|
defs.append("marker").attr("id", "arrowhead-normal").attr("markerWidth", 10).attr("markerHeight", 10).attr("refX", 9).attr("refY", 3).attr("orient", "auto").append("polygon").attr("points", "0 0, 10 3, 0 6").attr("fill", this.theme.edgeColors.normal);
|
|
@@ -953,8 +1041,9 @@ var SvgRenderer = class {
|
|
|
953
1041
|
const nodesGroup = svg.append("g").attr("class", "nodes");
|
|
954
1042
|
const containerNodes = layout.nodes.filter((node) => node.isContainer);
|
|
955
1043
|
const regularNodes = layout.nodes.filter((node) => !node.isContainer);
|
|
1044
|
+
const nodesById = new Map(layout.nodes.map((node) => [node.id, node]));
|
|
956
1045
|
layout.edges.forEach((edge) => {
|
|
957
|
-
const fromNode =
|
|
1046
|
+
const fromNode = nodesById.get(edge.from);
|
|
958
1047
|
if (fromNode && (fromNode.type === "BranchEnd" || fromNode.type === "IteratorEnd")) return;
|
|
959
1048
|
this.renderEdge({
|
|
960
1049
|
edge,
|
|
@@ -979,7 +1068,7 @@ var SvgRenderer = class {
|
|
|
979
1068
|
edgeCount: layout.edges.length,
|
|
980
1069
|
nodeCount: layout.nodes.length
|
|
981
1070
|
},
|
|
982
|
-
svg:
|
|
1071
|
+
svg: svg.serialize(),
|
|
983
1072
|
width: bounds.width
|
|
984
1073
|
};
|
|
985
1074
|
}
|
|
@@ -1051,8 +1140,13 @@ var SvgRenderer = class {
|
|
|
1051
1140
|
renderNode(params) {
|
|
1052
1141
|
const { group, node } = params;
|
|
1053
1142
|
const nodeGroup = group.append("g").attr("class", `node node-${node.type}`).attr("transform", `translate(${node.x}, ${node.y})`);
|
|
1054
|
-
const
|
|
1055
|
-
if (!
|
|
1143
|
+
const baseStyle = node.style;
|
|
1144
|
+
if (!baseStyle) return;
|
|
1145
|
+
const override = this.options.nodeOverrides?.[node.id];
|
|
1146
|
+
const style = override ? {
|
|
1147
|
+
...baseStyle,
|
|
1148
|
+
...override
|
|
1149
|
+
} : baseStyle;
|
|
1056
1150
|
switch (style.shape) {
|
|
1057
1151
|
case "circle":
|
|
1058
1152
|
this.renderCircle({
|
|
@@ -1179,9 +1273,7 @@ var SvgRenderer = class {
|
|
|
1179
1273
|
if (!edge.points || edge.points.length < 2) return;
|
|
1180
1274
|
const edgeColor = this.theme.edgeColors[edge.type || "normal"];
|
|
1181
1275
|
const markerType = edge.type || "normal";
|
|
1182
|
-
const
|
|
1183
|
-
if (this.options.edgeStyle === "curved") pathGenerator.curve(curveBasis);
|
|
1184
|
-
const pathElement = group.append("path").attr("d", pathGenerator(edge.points)).attr("fill", "none").attr("stroke", edgeColor).attr("stroke-width", edge.type === "error" ? 2 : 1.5).attr("marker-end", `url(#arrowhead-${markerType})`);
|
|
1276
|
+
const pathElement = group.append("path").attr("d", this.pathGenerator(edge.points) ?? "").attr("fill", "none").attr("stroke", edgeColor).attr("stroke-width", edge.type === "error" ? 2 : 1.5).attr("marker-end", `url(#arrowhead-${markerType})`);
|
|
1185
1277
|
if (edge.type === "error") pathElement.attr("stroke-dasharray", "5,5");
|
|
1186
1278
|
else if (edge.type === "default") pathElement.attr("stroke-dasharray", "8,4");
|
|
1187
1279
|
if (edge.label) {
|
|
@@ -1337,7 +1429,8 @@ const DEFAULT_DIAGRAM_OPTIONS = {
|
|
|
1337
1429
|
iconSize: 24,
|
|
1338
1430
|
showIcons: false,
|
|
1339
1431
|
pngQuality: 90,
|
|
1340
|
-
backgroundColor: "transparent"
|
|
1432
|
+
backgroundColor: "transparent",
|
|
1433
|
+
nodeOverrides: void 0
|
|
1341
1434
|
};
|
|
1342
1435
|
/**
|
|
1343
1436
|
* Merge user-provided options with defaults
|
|
@@ -1744,7 +1837,8 @@ function writeOutput(content, outputPath) {
|
|
|
1744
1837
|
if (!content.endsWith("\n")) process.stdout.write("\n");
|
|
1745
1838
|
}
|
|
1746
1839
|
}
|
|
1747
|
-
run(process.argv.slice(2)).then((code) => {
|
|
1840
|
+
if (process.argv[1] !== void 0 && import.meta.url === pathToFileURL(process.argv[1]).href) run(process.argv.slice(2)).then((code) => {
|
|
1748
1841
|
process.exit(code);
|
|
1749
1842
|
});
|
|
1750
1843
|
//#endregion
|
|
1844
|
+
export { CliError, parseArgs, run };
|