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 CHANGED
@@ -2,9 +2,12 @@
2
2
 
3
3
  [![npm version](https://img.shields.io/npm/v/sfn-diagram.svg)](https://www.npmjs.com/package/sfn-diagram)
4
4
  [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](https://opensource.org/licenses/MIT)
5
+ [![Live Playground](https://img.shields.io/badge/playground-live-brightgreen)](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
- .setAsl(asl)
87
- .setTheme('dark')
88
- .setLayout('TB')
89
- .setNodeDimensions({ width: 150, height: 80 });
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 { mermaid } = generator.generateMermaid();
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: { mermaid: string, nodeCount: number, edgeCount: number }
157
+ // Returns MermaidOutput: { code: string, metadata: { edgeCount: number, stateCount: number } }
136
158
  ```
137
159
 
138
160
  ### generateDiagram(params)
139
161
 
140
- Generate both SVG and Mermaid output simultaneously.
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 result = generateDiagram({
167
+ const svgResult = generateDiagram({
146
168
  aslDefinition: asl,
147
169
  theme: 'dark'
148
- });
170
+ }); // Returns SvgOutput
149
171
 
150
- // Returns: { svg: SvgOutput, mermaid: MermaidOutput }
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
- quality: 1.0,
164
- transparent: false
188
+ pngQuality: 90, // 1–100 (default 90)
189
+ backgroundColor: 'transparent'
165
190
  });
166
191
 
167
- // Returns: { png: Buffer, width: number, height: number }
168
- writeFileSync('diagram.png', result.png);
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
- awsResponse: response,
212
+ response,
188
213
  theme: 'dark'
189
214
  });
190
215
  ```
191
216
 
192
217
  ### SfnDiagramGenerator Class
193
218
 
194
- Fluent interface for generating diagrams.
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
- .setAsl(asl)
201
- .setTheme('dark')
202
- .setLayout('LR')
203
- .setNodeDimensions({ width: 150, height: 80 })
204
- .setSpacing({ rankSeparation: 60, nodeSeparation: 60 })
205
- .setEdgeStyle('curved')
206
- .setPadding(30);
207
-
208
- // Generate outputs
209
- const svgResult = generator.generateSvg();
210
- const mermaidResult = generator.generateMermaid();
211
- const pngResult = await generator.exportPng({ quality: 1.0 });
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 { generateDiagram, exportPng } from 'sfn-diagram';
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, mermaid } = generateDiagram({ aslDefinition: asl });
442
- writeFileSync('diagram.svg', svg.svg);
443
- writeFileSync('diagram.mmd', mermaid.mermaid);
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 { png } = await exportPng({
478
+ // Generate PNG (Node-only)
479
+ const { buffer } = await exportPng({
447
480
  aslDefinition: asl,
448
- quality: 1.0,
449
- transparent: false
481
+ pngQuality: 90,
482
+ backgroundColor: 'transparent'
450
483
  });
451
- writeFileSync('diagram.png', 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 (nodes.find((node) => node.id === branch.StartAt)) stateNode.children?.push(branch.StartAt);
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
- nodes
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 (nodes.find((node) => node.id === iterator.StartAt)) stateNode.children?.push(iterator.StartAt);
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
- nodes
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, nodes } = params;
662
- for (const stateName of Object.keys(branch.States)) if (nodes.find((n) => n.id === stateName) && !containerNode.children?.includes(stateName)) containerNode.children?.push(stateName);
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
- positionedNodes
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
- positionedNodes: allPositionedNodes
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, positionedNodes } = params;
846
+ const { containers, positionedNodeIndex } = params;
834
847
  return containers.map((container) => {
835
- const children = positionedNodes.filter((node) => container.children?.includes(node.id) && node.type !== "BranchEnd" && node.type !== "IteratorEnd");
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
- const minX = Math.min(...children.map((child) => (child.x || 0) - (child.width || 0) / 2));
846
- const maxX = Math.max(...children.map((child) => (child.x || 0) + (child.width || 0) / 2));
847
- const minY = Math.min(...children.map((child) => (child.y || 0) - (child.height || 0) / 2));
848
- const maxY = Math.max(...children.map((child) => (child.y || 0) + (child.height || 0) / 2));
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, positionedNodes } = params;
867
- const fromNode = positionedNodes.find((node) => node.id === edge.from);
868
- const toNode = positionedNodes.find((node) => node.id === edge.to);
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, "&amp;").replace(/\u00A0/g, "&nbsp;").replace(/"/g, "&quot;");
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, "&amp;").replace(/\u00A0/g, "&nbsp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
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 svgNode = document.createElementNS("http://www.w3.org/2000/svg", "svg");
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 = layout.nodes.find((node) => node.id === edge.from);
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: svgNode.outerHTML,
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 style = node.style;
1055
- if (!style) return;
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 pathGenerator = line().x((d) => d.x).y((d) => d.y);
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 };