sfn-diagram 0.2.0
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/LICENSE +21 -0
- package/README.md +461 -0
- package/dist/index.cjs +1951 -0
- package/dist/index.d.cts +1076 -0
- package/dist/index.d.ts +1076 -0
- package/dist/index.js +1911 -0
- package/package.json +81 -0
package/dist/index.cjs
ADDED
|
@@ -0,0 +1,1951 @@
|
|
|
1
|
+
//#region rolldown:runtime
|
|
2
|
+
var __create = Object.create;
|
|
3
|
+
var __defProp = Object.defineProperty;
|
|
4
|
+
var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
|
|
5
|
+
var __getOwnPropNames = Object.getOwnPropertyNames;
|
|
6
|
+
var __getProtoOf = Object.getPrototypeOf;
|
|
7
|
+
var __hasOwnProp = Object.prototype.hasOwnProperty;
|
|
8
|
+
var __copyProps = (to, from, except, desc) => {
|
|
9
|
+
if (from && typeof from === "object" || typeof from === "function") {
|
|
10
|
+
for (var keys = __getOwnPropNames(from), i = 0, n = keys.length, key; i < n; i++) {
|
|
11
|
+
key = keys[i];
|
|
12
|
+
if (!__hasOwnProp.call(to, key) && key !== except) {
|
|
13
|
+
__defProp(to, key, {
|
|
14
|
+
get: ((k) => from[k]).bind(null, key),
|
|
15
|
+
enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable
|
|
16
|
+
});
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
}
|
|
20
|
+
return to;
|
|
21
|
+
};
|
|
22
|
+
var __toESM = (mod, isNodeMode, target) => (target = mod != null ? __create(__getProtoOf(mod)) : {}, __copyProps(isNodeMode || !mod || !mod.__esModule ? __defProp(target, "default", {
|
|
23
|
+
value: mod,
|
|
24
|
+
enumerable: true
|
|
25
|
+
}) : target, mod));
|
|
26
|
+
|
|
27
|
+
//#endregion
|
|
28
|
+
let _dagrejs_dagre = require("@dagrejs/dagre");
|
|
29
|
+
_dagrejs_dagre = __toESM(_dagrejs_dagre);
|
|
30
|
+
let d3 = require("d3");
|
|
31
|
+
let jsdom = require("jsdom");
|
|
32
|
+
let node_html_to_image = require("node-html-to-image");
|
|
33
|
+
node_html_to_image = __toESM(node_html_to_image);
|
|
34
|
+
|
|
35
|
+
//#region src/config/themes.ts
|
|
36
|
+
/**
|
|
37
|
+
* AWS Light Theme - matches the AWS Step Functions console light mode
|
|
38
|
+
*/
|
|
39
|
+
const AWS_LIGHT_THEME = {
|
|
40
|
+
background: "#ffffff",
|
|
41
|
+
nodeColors: {
|
|
42
|
+
Pass: {
|
|
43
|
+
fill: "#e1f5fe",
|
|
44
|
+
stroke: "#0277bd"
|
|
45
|
+
},
|
|
46
|
+
Task: {
|
|
47
|
+
fill: "#fff3e0",
|
|
48
|
+
stroke: "#ef6c00"
|
|
49
|
+
},
|
|
50
|
+
Choice: {
|
|
51
|
+
fill: "#f3e5f5",
|
|
52
|
+
stroke: "#7b1fa2"
|
|
53
|
+
},
|
|
54
|
+
Wait: {
|
|
55
|
+
fill: "#e8f5e8",
|
|
56
|
+
stroke: "#388e3c"
|
|
57
|
+
},
|
|
58
|
+
Succeed: {
|
|
59
|
+
fill: "#e8f5e8",
|
|
60
|
+
stroke: "#4caf50"
|
|
61
|
+
},
|
|
62
|
+
Fail: {
|
|
63
|
+
fill: "#ffebee",
|
|
64
|
+
stroke: "#f44336"
|
|
65
|
+
},
|
|
66
|
+
Parallel: {
|
|
67
|
+
fill: "#fce4ec",
|
|
68
|
+
stroke: "#c2185b"
|
|
69
|
+
},
|
|
70
|
+
Map: {
|
|
71
|
+
fill: "#f1f8e9",
|
|
72
|
+
stroke: "#689f38"
|
|
73
|
+
}
|
|
74
|
+
},
|
|
75
|
+
edgeColors: {
|
|
76
|
+
choice: "#7b1fa2",
|
|
77
|
+
default: "#9c27b0",
|
|
78
|
+
error: "#f44336",
|
|
79
|
+
normal: "#546e7a"
|
|
80
|
+
},
|
|
81
|
+
textColor: "#212121",
|
|
82
|
+
fontSize: 14,
|
|
83
|
+
fontFamily: "Arial, sans-serif"
|
|
84
|
+
};
|
|
85
|
+
/**
|
|
86
|
+
* AWS Dark Theme - matches the AWS Step Functions console dark mode
|
|
87
|
+
*/
|
|
88
|
+
const AWS_DARK_THEME = {
|
|
89
|
+
background: "#1e1e1e",
|
|
90
|
+
nodeColors: {
|
|
91
|
+
Pass: {
|
|
92
|
+
fill: "#01579b",
|
|
93
|
+
stroke: "#4fc3f7"
|
|
94
|
+
},
|
|
95
|
+
Task: {
|
|
96
|
+
fill: "#e65100",
|
|
97
|
+
stroke: "#ffb74d"
|
|
98
|
+
},
|
|
99
|
+
Choice: {
|
|
100
|
+
fill: "#4a148c",
|
|
101
|
+
stroke: "#ce93d8"
|
|
102
|
+
},
|
|
103
|
+
Wait: {
|
|
104
|
+
fill: "#1b5e20",
|
|
105
|
+
stroke: "#81c784"
|
|
106
|
+
},
|
|
107
|
+
Succeed: {
|
|
108
|
+
fill: "#2e7d32",
|
|
109
|
+
stroke: "#a5d6a7"
|
|
110
|
+
},
|
|
111
|
+
Fail: {
|
|
112
|
+
fill: "#b71c1c",
|
|
113
|
+
stroke: "#ef5350"
|
|
114
|
+
},
|
|
115
|
+
Parallel: {
|
|
116
|
+
fill: "#880e4f",
|
|
117
|
+
stroke: "#f48fb1"
|
|
118
|
+
},
|
|
119
|
+
Map: {
|
|
120
|
+
fill: "#33691e",
|
|
121
|
+
stroke: "#aed581"
|
|
122
|
+
}
|
|
123
|
+
},
|
|
124
|
+
edgeColors: {
|
|
125
|
+
choice: "#ce93d8",
|
|
126
|
+
default: "#ba68c8",
|
|
127
|
+
error: "#ef5350",
|
|
128
|
+
normal: "#90a4ae"
|
|
129
|
+
},
|
|
130
|
+
textColor: "#e0e0e0",
|
|
131
|
+
fontSize: 14,
|
|
132
|
+
fontFamily: "Arial, sans-serif"
|
|
133
|
+
};
|
|
134
|
+
/**
|
|
135
|
+
* Get theme object from theme name or custom theme
|
|
136
|
+
*/
|
|
137
|
+
function getTheme(theme, customColors) {
|
|
138
|
+
let baseTheme;
|
|
139
|
+
if (!theme || theme === "light") baseTheme = AWS_LIGHT_THEME;
|
|
140
|
+
else if (theme === "dark") baseTheme = AWS_DARK_THEME;
|
|
141
|
+
else baseTheme = theme;
|
|
142
|
+
if (customColors) return {
|
|
143
|
+
...baseTheme,
|
|
144
|
+
nodeColors: {
|
|
145
|
+
...baseTheme.nodeColors,
|
|
146
|
+
...customColors
|
|
147
|
+
}
|
|
148
|
+
};
|
|
149
|
+
return baseTheme;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/styles/NodeStyles.ts
|
|
154
|
+
/**
|
|
155
|
+
* Get node style for a given state type
|
|
156
|
+
*/
|
|
157
|
+
function getNodeStyle(params) {
|
|
158
|
+
const { stateType, theme = AWS_LIGHT_THEME, customColors, stylePreset = "aws-standard" } = params;
|
|
159
|
+
if (customColors?.[stateType]) return customColors[stateType];
|
|
160
|
+
const themeColor = theme.nodeColors[stateType];
|
|
161
|
+
if (themeColor) return {
|
|
162
|
+
fill: themeColor.fill,
|
|
163
|
+
shape: getShapeForStateType({
|
|
164
|
+
stateType,
|
|
165
|
+
stylePreset
|
|
166
|
+
}),
|
|
167
|
+
stroke: themeColor.stroke,
|
|
168
|
+
strokeWidth: getStrokeWidthForType(stateType)
|
|
169
|
+
};
|
|
170
|
+
return {
|
|
171
|
+
fill: theme.nodeColors.Task.fill,
|
|
172
|
+
shape: "rect",
|
|
173
|
+
stroke: theme.nodeColors.Task.stroke,
|
|
174
|
+
strokeWidth: 2
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
/**
|
|
178
|
+
* Get the shape for a state type based on the style preset
|
|
179
|
+
*/
|
|
180
|
+
function getShapeForStateType(params) {
|
|
181
|
+
const { stateType, stylePreset } = params;
|
|
182
|
+
if (stylePreset === "aws-standard") return "rect";
|
|
183
|
+
switch (stateType) {
|
|
184
|
+
case "Choice": return "diamond";
|
|
185
|
+
case "Succeed":
|
|
186
|
+
case "Fail": return "circle";
|
|
187
|
+
default: return "rect";
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
/**
|
|
191
|
+
* Get stroke width for a state type
|
|
192
|
+
*/
|
|
193
|
+
function getStrokeWidthForType(stateType) {
|
|
194
|
+
switch (stateType) {
|
|
195
|
+
case "Succeed":
|
|
196
|
+
case "Fail": return 3;
|
|
197
|
+
default: return 2;
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
//#endregion
|
|
202
|
+
//#region src/constants/labels.ts
|
|
203
|
+
/**
|
|
204
|
+
* Label constants used in diagram generation
|
|
205
|
+
*/
|
|
206
|
+
const EDGE_LABELS = {
|
|
207
|
+
BRANCH_PREFIX: "Branch",
|
|
208
|
+
CATCH_PREFIX: "Catch",
|
|
209
|
+
CHOICE_PREFIX: "Choice",
|
|
210
|
+
CONDITION_FALLBACK: "Condition",
|
|
211
|
+
DEFAULT: "Default",
|
|
212
|
+
ERROR_PREFIX: "Error:",
|
|
213
|
+
ITERATOR: "Iterator"
|
|
214
|
+
};
|
|
215
|
+
/**
|
|
216
|
+
* Generates an error label with error types
|
|
217
|
+
*/
|
|
218
|
+
function getErrorLabel(errorTypes) {
|
|
219
|
+
const errors = errorTypes?.join(", ") || "Any";
|
|
220
|
+
return `${EDGE_LABELS.ERROR_PREFIX} ${errors}`;
|
|
221
|
+
}
|
|
222
|
+
/**
|
|
223
|
+
* Generates a catch block label based on style preference
|
|
224
|
+
*/
|
|
225
|
+
function getCatchLabel(params) {
|
|
226
|
+
const { catchLabelStyle = "error-type", errorTypes, index } = params;
|
|
227
|
+
if (catchLabelStyle === "catch-number") return `${EDGE_LABELS.CATCH_PREFIX} #${index + 1}`;
|
|
228
|
+
return getErrorLabel(errorTypes);
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
//#endregion
|
|
232
|
+
//#region src/services/ServiceDetector.ts
|
|
233
|
+
/**
|
|
234
|
+
* Mapping of AWS service names to their icon filenames in the aws-icons package
|
|
235
|
+
* Icons sourced from: https://github.com/MKAbuMattar/aws-icons
|
|
236
|
+
* URL pattern: https://cdn.jsdelivr.net/npm/aws-icons@latest/icons/architecture-service/{ICON_NAME}.svg
|
|
237
|
+
*/
|
|
238
|
+
const SERVICE_ICON_MAP = {
|
|
239
|
+
"apigateway": "AmazonAPIGateway",
|
|
240
|
+
"appflow": "AmazonAppFlow",
|
|
241
|
+
"appsync": "AWSAppSync",
|
|
242
|
+
"eventbridge": "AmazonEventBridge",
|
|
243
|
+
"events": "AmazonEventBridge",
|
|
244
|
+
"mq": "AmazonMQ",
|
|
245
|
+
"sns": "AmazonSimpleNotificationService",
|
|
246
|
+
"sqs": "AmazonSimpleQueueService",
|
|
247
|
+
"stepfunctions": "AWSStepFunctions",
|
|
248
|
+
"sfn": "AWSStepFunctions",
|
|
249
|
+
"states": "AWSStepFunctions",
|
|
250
|
+
"athena": "AmazonAthena",
|
|
251
|
+
"emr": "AmazonEMR",
|
|
252
|
+
"glue": "AWSGlue",
|
|
253
|
+
"kinesis": "AmazonKinesis",
|
|
254
|
+
"kinesisanalytics": "AmazonKinesisDataAnalytics",
|
|
255
|
+
"kinesisfirehose": "AmazonKinesisDataFirehose",
|
|
256
|
+
"redshift": "AmazonRedshift",
|
|
257
|
+
"batch": "AWSBatch",
|
|
258
|
+
"ec2": "AmazonEC2",
|
|
259
|
+
"ecs": "AmazonElasticContainerService",
|
|
260
|
+
"eks": "AmazonElasticKubernetesService",
|
|
261
|
+
"fargate": "AWSFargate",
|
|
262
|
+
"lambda": "AWSLambda",
|
|
263
|
+
"ecr": "AmazonElasticContainerRegistry",
|
|
264
|
+
"aurora": "AmazonAurora",
|
|
265
|
+
"documentdb": "AmazonDocumentDB",
|
|
266
|
+
"dynamodb": "AmazonDynamoDB",
|
|
267
|
+
"elasticache": "AmazonElastiCache",
|
|
268
|
+
"neptune": "AmazonNeptune",
|
|
269
|
+
"rds": "AmazonRDS",
|
|
270
|
+
"timestream": "AmazonTimestream",
|
|
271
|
+
"codebuild": "AWSCodeBuild",
|
|
272
|
+
"codecommit": "AWSCodeCommit",
|
|
273
|
+
"codedeploy": "AWSCodeDeploy",
|
|
274
|
+
"codepipeline": "AWSCodePipeline",
|
|
275
|
+
"bedrock": "AmazonBedrock",
|
|
276
|
+
"comprehend": "AmazonComprehend",
|
|
277
|
+
"forecast": "AmazonForecast",
|
|
278
|
+
"personalize": "AmazonPersonalize",
|
|
279
|
+
"polly": "AmazonPolly",
|
|
280
|
+
"rekognition": "AmazonRekognition",
|
|
281
|
+
"sagemaker": "AmazonSageMaker",
|
|
282
|
+
"textract": "AmazonTextract",
|
|
283
|
+
"transcribe": "AmazonTranscribe",
|
|
284
|
+
"translate": "AmazonTranslate",
|
|
285
|
+
"cloudformation": "AWSCloudFormation",
|
|
286
|
+
"cloudwatch": "AmazonCloudWatch",
|
|
287
|
+
"config": "AWSConfig",
|
|
288
|
+
"systemsmanager": "AWSSystemsManager",
|
|
289
|
+
"ssm": "AWSSystemsManager",
|
|
290
|
+
"kms": "AWSKeyManagementService",
|
|
291
|
+
"secretsmanager": "AWSSecretsManager",
|
|
292
|
+
"waf": "AWSWAF",
|
|
293
|
+
"efs": "AmazonElasticFileSystem",
|
|
294
|
+
"fsx": "AmazonFSx",
|
|
295
|
+
"s3": "AmazonSimpleStorageService",
|
|
296
|
+
"s3glacier": "AmazonS3Glacier"
|
|
297
|
+
};
|
|
298
|
+
/**
|
|
299
|
+
* Extract AWS service name from ARN (Amazon Resource Name)
|
|
300
|
+
*
|
|
301
|
+
* Supports three ARN patterns:
|
|
302
|
+
* 1. Direct service ARNs: arn:aws:SERVICE:region:account:resource
|
|
303
|
+
* 2. Service integrations: arn:aws:states:::SERVICE:action
|
|
304
|
+
* 3. SDK integrations: arn:aws:states:::aws-sdk:SERVICE:action
|
|
305
|
+
*
|
|
306
|
+
* @param params - Parameters containing the ARN to parse
|
|
307
|
+
* @returns Normalized service name or null if parsing fails
|
|
308
|
+
*
|
|
309
|
+
* @example
|
|
310
|
+
* extractServiceFromArn({ arn: 'arn:aws:lambda:us-east-1:123:function:MyFunc' })
|
|
311
|
+
* // Returns: 'lambda'
|
|
312
|
+
*
|
|
313
|
+
* @example
|
|
314
|
+
* extractServiceFromArn({ arn: 'arn:aws:states:::dynamodb:getItem' })
|
|
315
|
+
* // Returns: 'dynamodb'
|
|
316
|
+
*/
|
|
317
|
+
function extractServiceFromArn(params) {
|
|
318
|
+
const { arn } = params;
|
|
319
|
+
const directMatch = arn.match(/^arn:aws:([^:]+):/);
|
|
320
|
+
if (directMatch && directMatch[1] !== "states") return normalizeServiceName({ serviceName: directMatch[1] });
|
|
321
|
+
const integrationMatch = arn.match(/^arn:aws:states:::([^:]+):/);
|
|
322
|
+
if (integrationMatch) {
|
|
323
|
+
const service = integrationMatch[1];
|
|
324
|
+
if (service === "aws-sdk") {
|
|
325
|
+
const sdkMatch = arn.match(/^arn:aws:states:::aws-sdk:([^:]+):/);
|
|
326
|
+
if (sdkMatch) return normalizeServiceName({ serviceName: sdkMatch[1] });
|
|
327
|
+
}
|
|
328
|
+
return normalizeServiceName({ serviceName: service });
|
|
329
|
+
}
|
|
330
|
+
return null;
|
|
331
|
+
}
|
|
332
|
+
/**
|
|
333
|
+
* Normalize AWS service name to canonical form
|
|
334
|
+
*
|
|
335
|
+
* Converts service names to lowercase and removes hyphens for consistent mapping
|
|
336
|
+
*
|
|
337
|
+
* @param params - Parameters containing the service name to normalize
|
|
338
|
+
* @returns Normalized service name
|
|
339
|
+
*
|
|
340
|
+
* @example
|
|
341
|
+
* normalizeServiceName({ serviceName: 'Amazon-S3' })
|
|
342
|
+
* // Returns: 'amazons3'
|
|
343
|
+
*
|
|
344
|
+
* @example
|
|
345
|
+
* normalizeServiceName({ serviceName: 'DynamoDB' })
|
|
346
|
+
* // Returns: 'dynamodb'
|
|
347
|
+
*/
|
|
348
|
+
function normalizeServiceName(params) {
|
|
349
|
+
const { serviceName } = params;
|
|
350
|
+
return serviceName.toLowerCase().replace(/-/g, "");
|
|
351
|
+
}
|
|
352
|
+
/**
|
|
353
|
+
* Build CDN URL for AWS service icon
|
|
354
|
+
*
|
|
355
|
+
* Constructs jsDelivr CDN URL for icons from the aws-icons npm package
|
|
356
|
+
*
|
|
357
|
+
* @param params - Parameters containing the icon filename
|
|
358
|
+
* @returns Full CDN URL to the icon SVG file
|
|
359
|
+
*
|
|
360
|
+
* @example
|
|
361
|
+
* buildIconUrl({ iconName: 'AWSLambda' })
|
|
362
|
+
* // Returns: 'https://cdn.jsdelivr.net/npm/aws-icons@latest/icons/architecture-service/AWSLambda.svg'
|
|
363
|
+
*/
|
|
364
|
+
function buildIconUrl(params) {
|
|
365
|
+
const { iconName } = params;
|
|
366
|
+
return `https://cdn.jsdelivr.net/npm/aws-icons@latest/icons/architecture-service/${iconName}.svg`;
|
|
367
|
+
}
|
|
368
|
+
/**
|
|
369
|
+
* Detect AWS service from ASL Task state and resolve icon URL
|
|
370
|
+
*
|
|
371
|
+
* Analyzes the Resource field of Task states to identify the AWS service,
|
|
372
|
+
* then maps to the corresponding icon URL from the aws-icons CDN
|
|
373
|
+
*
|
|
374
|
+
* @param params - Parameters for service detection
|
|
375
|
+
* @param params.state - ASL state definition to analyze
|
|
376
|
+
* @param params.iconResolver - Optional custom function to resolve icon URLs
|
|
377
|
+
* @returns Service information with name and icon URL, or null for non-Task states
|
|
378
|
+
*
|
|
379
|
+
* @example
|
|
380
|
+
* const taskState = {
|
|
381
|
+
* Type: 'Task',
|
|
382
|
+
* Resource: 'arn:aws:lambda:us-east-1:123456789012:function:MyFunction'
|
|
383
|
+
* };
|
|
384
|
+
*
|
|
385
|
+
* detectService({ state: taskState })
|
|
386
|
+
* // Returns: {
|
|
387
|
+
* // serviceName: 'lambda',
|
|
388
|
+
* // iconUrl: 'https://cdn.jsdelivr.net/npm/aws-icons@latest/icons/arch/Arch_AWS-Lambda_48.svg'
|
|
389
|
+
* // }
|
|
390
|
+
*
|
|
391
|
+
* @example
|
|
392
|
+
* // With custom icon resolver
|
|
393
|
+
* detectService({
|
|
394
|
+
* state: taskState,
|
|
395
|
+
* iconResolver: (service) => {
|
|
396
|
+
* if (service === 'lambda') {
|
|
397
|
+
* return 'https://my-cdn.com/lambda.svg';
|
|
398
|
+
* }
|
|
399
|
+
* return null; // Use default
|
|
400
|
+
* }
|
|
401
|
+
* })
|
|
402
|
+
*/
|
|
403
|
+
function detectService(params) {
|
|
404
|
+
const { iconResolver, state } = params;
|
|
405
|
+
if (state.Type !== "Task" || !state.Resource) return null;
|
|
406
|
+
const serviceName = extractServiceFromArn({ arn: state.Resource });
|
|
407
|
+
if (!serviceName) return null;
|
|
408
|
+
if (iconResolver) return {
|
|
409
|
+
iconUrl: iconResolver(serviceName),
|
|
410
|
+
serviceName
|
|
411
|
+
};
|
|
412
|
+
const iconName = SERVICE_ICON_MAP[serviceName];
|
|
413
|
+
if (!iconName) return {
|
|
414
|
+
iconUrl: null,
|
|
415
|
+
serviceName
|
|
416
|
+
};
|
|
417
|
+
return {
|
|
418
|
+
iconUrl: buildIconUrl({ iconName }),
|
|
419
|
+
serviceName
|
|
420
|
+
};
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
//#endregion
|
|
424
|
+
//#region src/AslParser.ts
|
|
425
|
+
/**
|
|
426
|
+
* Error thrown when ASL validation fails
|
|
427
|
+
*/
|
|
428
|
+
var AslValidationError = class extends Error {
|
|
429
|
+
constructor(message) {
|
|
430
|
+
super(message);
|
|
431
|
+
this.name = "AslValidationError";
|
|
432
|
+
}
|
|
433
|
+
};
|
|
434
|
+
/** Valid ASL state types */
|
|
435
|
+
const VALID_STATE_TYPES = [
|
|
436
|
+
"Pass",
|
|
437
|
+
"Task",
|
|
438
|
+
"Choice",
|
|
439
|
+
"Wait",
|
|
440
|
+
"Succeed",
|
|
441
|
+
"Fail",
|
|
442
|
+
"Parallel",
|
|
443
|
+
"Map"
|
|
444
|
+
];
|
|
445
|
+
/**
|
|
446
|
+
* Validates an ASL definition and returns helpful error messages
|
|
447
|
+
*
|
|
448
|
+
* @param params - Validation parameters
|
|
449
|
+
* @throws {AslValidationError} When the ASL definition is invalid
|
|
450
|
+
*/
|
|
451
|
+
function validateAsl(params) {
|
|
452
|
+
const { definition } = params;
|
|
453
|
+
if (!definition || typeof definition !== "object") throw new AslValidationError("ASL definition must be a non-null object");
|
|
454
|
+
const asl = definition;
|
|
455
|
+
if (!("StartAt" in asl)) throw new AslValidationError("ASL definition missing required field: StartAt");
|
|
456
|
+
if (typeof asl.StartAt !== "string" || asl.StartAt.trim() === "") throw new AslValidationError("StartAt must be a non-empty string");
|
|
457
|
+
if (!("States" in asl)) throw new AslValidationError("ASL definition missing required field: States");
|
|
458
|
+
if (!asl.States || typeof asl.States !== "object") throw new AslValidationError("States must be a non-null object");
|
|
459
|
+
const states = asl.States;
|
|
460
|
+
const stateNames = Object.keys(states);
|
|
461
|
+
if (stateNames.length === 0) throw new AslValidationError("States object cannot be empty");
|
|
462
|
+
if (!stateNames.includes(asl.StartAt)) throw new AslValidationError(`StartAt references non-existent state: "${asl.StartAt}". Available states: ${stateNames.join(", ")}`);
|
|
463
|
+
for (const [stateName, stateValue] of Object.entries(states)) validateState({
|
|
464
|
+
stateName,
|
|
465
|
+
stateNames,
|
|
466
|
+
stateValue
|
|
467
|
+
});
|
|
468
|
+
}
|
|
469
|
+
/**
|
|
470
|
+
* Validates an individual state within an ASL definition
|
|
471
|
+
*/
|
|
472
|
+
function validateState(params) {
|
|
473
|
+
const { stateName, stateNames, stateValue } = params;
|
|
474
|
+
if (!stateValue || typeof stateValue !== "object") throw new AslValidationError(`State "${stateName}" must be a non-null object`);
|
|
475
|
+
const state = stateValue;
|
|
476
|
+
if (!("Type" in state)) throw new AslValidationError(`State "${stateName}" missing required field: Type`);
|
|
477
|
+
const stateType = state.Type;
|
|
478
|
+
if (typeof stateType !== "string" || !VALID_STATE_TYPES.includes(stateType)) throw new AslValidationError(`State "${stateName}" has invalid Type: "${stateType}". Valid types: ${VALID_STATE_TYPES.join(", ")}`);
|
|
479
|
+
if ("Next" in state && state.Next !== void 0) {
|
|
480
|
+
if (typeof state.Next !== "string") throw new AslValidationError(`State "${stateName}": Next must be a string`);
|
|
481
|
+
if (!stateNames.includes(state.Next)) throw new AslValidationError(`State "${stateName}": Next references non-existent state "${state.Next}"`);
|
|
482
|
+
}
|
|
483
|
+
if ("Default" in state && state.Default !== void 0) {
|
|
484
|
+
if (typeof state.Default !== "string") throw new AslValidationError(`State "${stateName}": Default must be a string`);
|
|
485
|
+
if (!stateNames.includes(state.Default)) throw new AslValidationError(`State "${stateName}": Default references non-existent state "${state.Default}"`);
|
|
486
|
+
}
|
|
487
|
+
if ("Choices" in state && Array.isArray(state.Choices)) {
|
|
488
|
+
for (const [index, choice] of state.Choices.entries()) if (choice && typeof choice === "object" && "Next" in choice) {
|
|
489
|
+
const choiceNext = choice.Next;
|
|
490
|
+
if (typeof choiceNext === "string" && !stateNames.includes(choiceNext)) throw new AslValidationError(`State "${stateName}": Choices[${index}].Next references non-existent state "${choiceNext}"`);
|
|
491
|
+
}
|
|
492
|
+
}
|
|
493
|
+
if ("Catch" in state && Array.isArray(state.Catch)) {
|
|
494
|
+
for (const [index, catchBlock] of state.Catch.entries()) if (catchBlock && typeof catchBlock === "object" && "Next" in catchBlock) {
|
|
495
|
+
const catchNext = catchBlock.Next;
|
|
496
|
+
if (typeof catchNext === "string" && !stateNames.includes(catchNext)) throw new AslValidationError(`State "${stateName}": Catch[${index}].Next references non-existent state "${catchNext}"`);
|
|
497
|
+
}
|
|
498
|
+
}
|
|
499
|
+
if (!["Succeed", "Fail"].includes(stateType) && stateType !== "Choice") {
|
|
500
|
+
const hasNext = "Next" in state;
|
|
501
|
+
const hasEnd = "End" in state && state.End === true;
|
|
502
|
+
if (!hasNext && !hasEnd) throw new AslValidationError(`State "${stateName}" (Type: ${stateType}) must have either "Next" or "End: true"`);
|
|
503
|
+
}
|
|
504
|
+
}
|
|
505
|
+
function parseAsl(params) {
|
|
506
|
+
const { definition, options } = params;
|
|
507
|
+
const nodes = [];
|
|
508
|
+
const edges = [];
|
|
509
|
+
validateAsl({ definition });
|
|
510
|
+
extractStatesRecursively({
|
|
511
|
+
definition,
|
|
512
|
+
nodes,
|
|
513
|
+
options
|
|
514
|
+
});
|
|
515
|
+
for (const [stateName, state] of Object.entries(definition.States)) {
|
|
516
|
+
const stateEdges = extractEdgesFromState({
|
|
517
|
+
catchLabelStyle: options?.catchLabelStyle,
|
|
518
|
+
state,
|
|
519
|
+
stateName
|
|
520
|
+
});
|
|
521
|
+
edges.push(...stateEdges);
|
|
522
|
+
}
|
|
523
|
+
extractNestedEdges({
|
|
524
|
+
definition,
|
|
525
|
+
edges,
|
|
526
|
+
options
|
|
527
|
+
});
|
|
528
|
+
return {
|
|
529
|
+
edges,
|
|
530
|
+
nodes
|
|
531
|
+
};
|
|
532
|
+
}
|
|
533
|
+
function createStateNode(params) {
|
|
534
|
+
const { name, options, state, stylePreset } = params;
|
|
535
|
+
const isContainer = state.Type === "Parallel" || state.Type === "Map";
|
|
536
|
+
const baseNode = {
|
|
537
|
+
id: name,
|
|
538
|
+
isContainer,
|
|
539
|
+
label: state.Comment || name,
|
|
540
|
+
style: getNodeStyle({
|
|
541
|
+
stateType: state.Type,
|
|
542
|
+
stylePreset
|
|
543
|
+
}),
|
|
544
|
+
type: state.Type
|
|
545
|
+
};
|
|
546
|
+
if (isContainer) baseNode.children = [];
|
|
547
|
+
if (options?.showIcons && state.Type === "Task") {
|
|
548
|
+
const serviceInfo = detectService({
|
|
549
|
+
iconResolver: options.iconResolver,
|
|
550
|
+
state
|
|
551
|
+
});
|
|
552
|
+
if (serviceInfo) {
|
|
553
|
+
baseNode.serviceType = serviceInfo.serviceName;
|
|
554
|
+
baseNode.iconUrl = serviceInfo.iconUrl || void 0;
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
return baseNode;
|
|
558
|
+
}
|
|
559
|
+
function extractEdgesFromState(params) {
|
|
560
|
+
const { catchLabelStyle, state, stateName } = params;
|
|
561
|
+
const edges = [];
|
|
562
|
+
switch (state.Type) {
|
|
563
|
+
case "Choice":
|
|
564
|
+
if (state.Choices) state.Choices.forEach((choice) => {
|
|
565
|
+
const condition = extractConditionLabel(choice);
|
|
566
|
+
edges.push({
|
|
567
|
+
condition,
|
|
568
|
+
from: stateName,
|
|
569
|
+
label: condition,
|
|
570
|
+
to: choice.Next,
|
|
571
|
+
type: "choice"
|
|
572
|
+
});
|
|
573
|
+
});
|
|
574
|
+
if (state.Default) {
|
|
575
|
+
if (!(state.Choices?.map((choice) => choice.Next) || []).includes(state.Default)) edges.push({
|
|
576
|
+
from: stateName,
|
|
577
|
+
label: EDGE_LABELS.DEFAULT,
|
|
578
|
+
to: state.Default,
|
|
579
|
+
type: "default"
|
|
580
|
+
});
|
|
581
|
+
}
|
|
582
|
+
break;
|
|
583
|
+
case "Parallel": break;
|
|
584
|
+
case "Map": break;
|
|
585
|
+
default:
|
|
586
|
+
if (state.Next) edges.push({
|
|
587
|
+
from: stateName,
|
|
588
|
+
to: state.Next,
|
|
589
|
+
type: "normal"
|
|
590
|
+
});
|
|
591
|
+
break;
|
|
592
|
+
}
|
|
593
|
+
if (state.Catch) state.Catch.forEach((catchBlock, index) => {
|
|
594
|
+
if (catchBlock.Next) edges.push({
|
|
595
|
+
from: stateName,
|
|
596
|
+
label: getCatchLabel({
|
|
597
|
+
catchLabelStyle,
|
|
598
|
+
errorTypes: catchBlock.ErrorEquals,
|
|
599
|
+
index
|
|
600
|
+
}),
|
|
601
|
+
to: catchBlock.Next,
|
|
602
|
+
type: "error"
|
|
603
|
+
});
|
|
604
|
+
});
|
|
605
|
+
return edges;
|
|
606
|
+
}
|
|
607
|
+
function extractConditionLabel(choice) {
|
|
608
|
+
const conditions = [];
|
|
609
|
+
const variable = choice.Variable || "";
|
|
610
|
+
if (choice.StringEquals !== void 0) conditions.push(`${variable} == "${choice.StringEquals}"`);
|
|
611
|
+
if (choice.NumericEquals !== void 0) conditions.push(`${variable} == ${choice.NumericEquals}`);
|
|
612
|
+
if (choice.BooleanEquals !== void 0) conditions.push(`${variable} == ${choice.BooleanEquals}`);
|
|
613
|
+
return conditions.join(" AND ") || EDGE_LABELS.CONDITION_FALLBACK;
|
|
614
|
+
}
|
|
615
|
+
/**
|
|
616
|
+
* Recursively extract all states including those nested in Parallel branches and Map iterators
|
|
617
|
+
*/
|
|
618
|
+
function extractStatesRecursively(params) {
|
|
619
|
+
const { definition, nodes, options } = params;
|
|
620
|
+
for (const [stateName, state] of Object.entries(definition.States)) {
|
|
621
|
+
const stateNode = createStateNode({
|
|
622
|
+
name: stateName,
|
|
623
|
+
options,
|
|
624
|
+
state,
|
|
625
|
+
stylePreset: options?.stylePreset
|
|
626
|
+
});
|
|
627
|
+
nodes.push(stateNode);
|
|
628
|
+
if (state.Type === "Parallel" && state.Branches) state.Branches.forEach((branch, index) => {
|
|
629
|
+
extractStatesRecursively({
|
|
630
|
+
definition: branch,
|
|
631
|
+
nodes,
|
|
632
|
+
options
|
|
633
|
+
});
|
|
634
|
+
if (nodes.find((node) => node.id === branch.StartAt)) stateNode.children?.push(branch.StartAt);
|
|
635
|
+
const endNodeId = `${stateName}__branch${index}__end`;
|
|
636
|
+
const endNode = {
|
|
637
|
+
id: endNodeId,
|
|
638
|
+
isContainer: false,
|
|
639
|
+
label: "",
|
|
640
|
+
style: {
|
|
641
|
+
fill: "#fff9cc",
|
|
642
|
+
shape: "circle",
|
|
643
|
+
stroke: "#687078",
|
|
644
|
+
strokeWidth: .6
|
|
645
|
+
},
|
|
646
|
+
type: "BranchEnd"
|
|
647
|
+
};
|
|
648
|
+
nodes.push(endNode);
|
|
649
|
+
stateNode.children?.push(endNodeId);
|
|
650
|
+
markBranchStatesAsChildren({
|
|
651
|
+
branch,
|
|
652
|
+
containerNode: stateNode,
|
|
653
|
+
nodes
|
|
654
|
+
});
|
|
655
|
+
});
|
|
656
|
+
if (state.Type === "Map" && state.Iterator) {
|
|
657
|
+
const iterator = state.Iterator;
|
|
658
|
+
extractStatesRecursively({
|
|
659
|
+
definition: iterator,
|
|
660
|
+
nodes,
|
|
661
|
+
options
|
|
662
|
+
});
|
|
663
|
+
if (nodes.find((node) => node.id === iterator.StartAt)) stateNode.children?.push(iterator.StartAt);
|
|
664
|
+
const endNodeId = `${stateName}__iterator__end`;
|
|
665
|
+
const endNode = {
|
|
666
|
+
id: endNodeId,
|
|
667
|
+
isContainer: false,
|
|
668
|
+
label: "",
|
|
669
|
+
style: {
|
|
670
|
+
fill: "#fff9cc",
|
|
671
|
+
shape: "circle",
|
|
672
|
+
stroke: "#687078",
|
|
673
|
+
strokeWidth: .6
|
|
674
|
+
},
|
|
675
|
+
type: "IteratorEnd"
|
|
676
|
+
};
|
|
677
|
+
nodes.push(endNode);
|
|
678
|
+
stateNode.children?.push(endNodeId);
|
|
679
|
+
markBranchStatesAsChildren({
|
|
680
|
+
branch: iterator,
|
|
681
|
+
containerNode: stateNode,
|
|
682
|
+
nodes
|
|
683
|
+
});
|
|
684
|
+
}
|
|
685
|
+
}
|
|
686
|
+
}
|
|
687
|
+
/**
|
|
688
|
+
* Mark all states in a branch as children of the container for bounding box calculation
|
|
689
|
+
*/
|
|
690
|
+
function markBranchStatesAsChildren(params) {
|
|
691
|
+
const { branch, containerNode, nodes } = params;
|
|
692
|
+
for (const stateName of Object.keys(branch.States)) if (nodes.find((n) => n.id === stateName) && !containerNode.children?.includes(stateName)) containerNode.children?.push(stateName);
|
|
693
|
+
}
|
|
694
|
+
/**
|
|
695
|
+
* Extract edges from nested state machines (Parallel branches and Map iterators)
|
|
696
|
+
*/
|
|
697
|
+
function extractNestedEdges(params) {
|
|
698
|
+
const { definition, edges, options } = params;
|
|
699
|
+
for (const [stateName, state] of Object.entries(definition.States)) {
|
|
700
|
+
if (state.Type === "Parallel" && state.Branches) state.Branches.forEach((branch, index) => {
|
|
701
|
+
const endNodeId = `${stateName}__branch${index}__end`;
|
|
702
|
+
edges.push({
|
|
703
|
+
from: stateName,
|
|
704
|
+
to: branch.StartAt,
|
|
705
|
+
type: "normal",
|
|
706
|
+
visualOnly: true
|
|
707
|
+
});
|
|
708
|
+
for (const [branchStateName, branchState] of Object.entries(branch.States)) {
|
|
709
|
+
const branchEdges = extractEdgesFromState({
|
|
710
|
+
catchLabelStyle: options?.catchLabelStyle,
|
|
711
|
+
state: branchState,
|
|
712
|
+
stateName: branchStateName
|
|
713
|
+
});
|
|
714
|
+
edges.push(...branchEdges);
|
|
715
|
+
if (branchState.End || !branchState.Next && branchState.Type !== "Choice") edges.push({
|
|
716
|
+
from: branchStateName,
|
|
717
|
+
to: endNodeId,
|
|
718
|
+
type: "normal"
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
if (state.Next) edges.push({
|
|
722
|
+
from: endNodeId,
|
|
723
|
+
to: state.Next,
|
|
724
|
+
type: "normal"
|
|
725
|
+
});
|
|
726
|
+
if (state.Branches && index === state.Branches.length - 1 && state.Next) edges.push({
|
|
727
|
+
from: stateName,
|
|
728
|
+
to: state.Next,
|
|
729
|
+
type: "normal",
|
|
730
|
+
visualOnly: true
|
|
731
|
+
});
|
|
732
|
+
extractNestedEdges({
|
|
733
|
+
definition: branch,
|
|
734
|
+
edges,
|
|
735
|
+
options
|
|
736
|
+
});
|
|
737
|
+
});
|
|
738
|
+
if (state.Type === "Map" && state.Iterator) {
|
|
739
|
+
const endNodeId = `${stateName}__iterator__end`;
|
|
740
|
+
edges.push({
|
|
741
|
+
from: stateName,
|
|
742
|
+
to: state.Iterator.StartAt,
|
|
743
|
+
type: "normal",
|
|
744
|
+
visualOnly: true
|
|
745
|
+
});
|
|
746
|
+
for (const [iteratorStateName, iteratorState] of Object.entries(state.Iterator.States)) {
|
|
747
|
+
const iteratorEdges = extractEdgesFromState({
|
|
748
|
+
catchLabelStyle: options?.catchLabelStyle,
|
|
749
|
+
state: iteratorState,
|
|
750
|
+
stateName: iteratorStateName
|
|
751
|
+
});
|
|
752
|
+
edges.push(...iteratorEdges);
|
|
753
|
+
if (iteratorState.End || !iteratorState.Next && iteratorState.Type !== "Choice") edges.push({
|
|
754
|
+
from: iteratorStateName,
|
|
755
|
+
to: endNodeId,
|
|
756
|
+
type: "normal"
|
|
757
|
+
});
|
|
758
|
+
}
|
|
759
|
+
if (state.Next) {
|
|
760
|
+
edges.push({
|
|
761
|
+
from: endNodeId,
|
|
762
|
+
to: state.Next,
|
|
763
|
+
type: "normal"
|
|
764
|
+
});
|
|
765
|
+
edges.push({
|
|
766
|
+
from: stateName,
|
|
767
|
+
to: state.Next,
|
|
768
|
+
type: "normal",
|
|
769
|
+
visualOnly: true
|
|
770
|
+
});
|
|
771
|
+
}
|
|
772
|
+
extractNestedEdges({
|
|
773
|
+
definition: state.Iterator,
|
|
774
|
+
edges,
|
|
775
|
+
options
|
|
776
|
+
});
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
//#endregion
|
|
782
|
+
//#region src/layout/DagreLayout.ts
|
|
783
|
+
/**
|
|
784
|
+
* DagreLayout - Calculates node positions and edge routing using Dagre algorithm
|
|
785
|
+
*/
|
|
786
|
+
var DagreLayout = class {
|
|
787
|
+
options;
|
|
788
|
+
constructor(options) {
|
|
789
|
+
this.options = options;
|
|
790
|
+
}
|
|
791
|
+
/**
|
|
792
|
+
* Calculate layout positions for nodes and edges
|
|
793
|
+
*/
|
|
794
|
+
calculate(nodes, edges) {
|
|
795
|
+
const graph = new _dagrejs_dagre.default.graphlib.Graph();
|
|
796
|
+
graph.setGraph({
|
|
797
|
+
marginx: this.options.padding || 20,
|
|
798
|
+
marginy: this.options.padding || 20,
|
|
799
|
+
nodesep: this.options.nodeSeparation || 50,
|
|
800
|
+
rankdir: this.options.layout || "TB",
|
|
801
|
+
ranksep: this.options.rankSeparation || 50
|
|
802
|
+
});
|
|
803
|
+
graph.setDefaultEdgeLabel(() => ({}));
|
|
804
|
+
const layoutNodes = nodes.filter((node) => !node.isContainer);
|
|
805
|
+
layoutNodes.forEach((node) => {
|
|
806
|
+
const dimensions = this.getNodeDimensions(node);
|
|
807
|
+
graph.setNode(node.id, {
|
|
808
|
+
height: dimensions.height,
|
|
809
|
+
label: node.label,
|
|
810
|
+
shape: node.style?.shape || "rect",
|
|
811
|
+
width: dimensions.width
|
|
812
|
+
});
|
|
813
|
+
});
|
|
814
|
+
edges.filter((edge) => !edge.visualOnly).forEach((edge) => {
|
|
815
|
+
graph.setEdge(edge.from, edge.to, {
|
|
816
|
+
label: edge.label,
|
|
817
|
+
type: edge.type
|
|
818
|
+
});
|
|
819
|
+
});
|
|
820
|
+
_dagrejs_dagre.default.layout(graph);
|
|
821
|
+
const positionedNodes = layoutNodes.map((node) => {
|
|
822
|
+
const dagNode = graph.node(node.id);
|
|
823
|
+
return {
|
|
824
|
+
...node,
|
|
825
|
+
height: dagNode.height,
|
|
826
|
+
width: dagNode.width,
|
|
827
|
+
x: dagNode.x,
|
|
828
|
+
y: dagNode.y
|
|
829
|
+
};
|
|
830
|
+
});
|
|
831
|
+
const containerNodes = this.calculateContainerBounds({
|
|
832
|
+
containers: nodes.filter((node) => node.isContainer),
|
|
833
|
+
positionedNodes
|
|
834
|
+
});
|
|
835
|
+
const allPositionedNodes = [...positionedNodes, ...containerNodes];
|
|
836
|
+
const routedEdges = edges.map((edge) => {
|
|
837
|
+
if (edge.visualOnly) return {
|
|
838
|
+
...edge,
|
|
839
|
+
points: this.calculateVisualEdgePoints({
|
|
840
|
+
edge,
|
|
841
|
+
positionedNodes: allPositionedNodes
|
|
842
|
+
})
|
|
843
|
+
};
|
|
844
|
+
const dagEdge = graph.edge(edge.from, edge.to);
|
|
845
|
+
return {
|
|
846
|
+
...edge,
|
|
847
|
+
points: dagEdge.points
|
|
848
|
+
};
|
|
849
|
+
});
|
|
850
|
+
const graphDims = graph.graph();
|
|
851
|
+
return {
|
|
852
|
+
edges: routedEdges,
|
|
853
|
+
graph: {
|
|
854
|
+
height: this.options.height || (graphDims.height ?? 600),
|
|
855
|
+
width: this.options.width || (graphDims.width ?? 800)
|
|
856
|
+
},
|
|
857
|
+
nodes: allPositionedNodes
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
/**
|
|
861
|
+
* Calculate bounding boxes for container nodes based on their children positions
|
|
862
|
+
*/
|
|
863
|
+
calculateContainerBounds(params) {
|
|
864
|
+
const { containers, positionedNodes } = params;
|
|
865
|
+
return containers.map((container) => {
|
|
866
|
+
const children = positionedNodes.filter((node) => container.children?.includes(node.id) && node.type !== "BranchEnd" && node.type !== "IteratorEnd");
|
|
867
|
+
if (children.length === 0) return {
|
|
868
|
+
...container,
|
|
869
|
+
height: 200,
|
|
870
|
+
width: 400,
|
|
871
|
+
x: 0,
|
|
872
|
+
y: 0
|
|
873
|
+
};
|
|
874
|
+
const padding = 40;
|
|
875
|
+
const headerHeight = 50;
|
|
876
|
+
const minX = Math.min(...children.map((child) => (child.x || 0) - (child.width || 0) / 2));
|
|
877
|
+
const maxX = Math.max(...children.map((child) => (child.x || 0) + (child.width || 0) / 2));
|
|
878
|
+
const minY = Math.min(...children.map((child) => (child.y || 0) - (child.height || 0) / 2));
|
|
879
|
+
const maxY = Math.max(...children.map((child) => (child.y || 0) + (child.height || 0) / 2));
|
|
880
|
+
const width = maxX - minX + padding * 2;
|
|
881
|
+
const height = maxY - minY + padding * 2 + headerHeight;
|
|
882
|
+
const x = (minX + maxX) / 2;
|
|
883
|
+
const y = (minY + maxY) / 2 + headerHeight / 2;
|
|
884
|
+
return {
|
|
885
|
+
...container,
|
|
886
|
+
height,
|
|
887
|
+
width,
|
|
888
|
+
x,
|
|
889
|
+
y
|
|
890
|
+
};
|
|
891
|
+
});
|
|
892
|
+
}
|
|
893
|
+
/**
|
|
894
|
+
* Calculate routing points for visual-only edges
|
|
895
|
+
*/
|
|
896
|
+
calculateVisualEdgePoints(params) {
|
|
897
|
+
const { edge, positionedNodes } = params;
|
|
898
|
+
const fromNode = positionedNodes.find((node) => node.id === edge.from);
|
|
899
|
+
const toNode = positionedNodes.find((node) => node.id === edge.to);
|
|
900
|
+
if (!fromNode || !toNode) return [];
|
|
901
|
+
if (fromNode.isContainer && fromNode.children?.includes(edge.to)) {
|
|
902
|
+
const headerHeight = 50;
|
|
903
|
+
const fromX$1 = fromNode.x || 0;
|
|
904
|
+
const toX$1 = toNode.x || 0;
|
|
905
|
+
const fromY$1 = (fromNode.y || 0) - (fromNode.height || 0) / 2 + headerHeight;
|
|
906
|
+
const toY$1 = (toNode.y || 0) - (toNode.height || 0) / 2;
|
|
907
|
+
return [{
|
|
908
|
+
x: fromX$1,
|
|
909
|
+
y: fromY$1
|
|
910
|
+
}, {
|
|
911
|
+
x: toX$1,
|
|
912
|
+
y: toY$1
|
|
913
|
+
}];
|
|
914
|
+
}
|
|
915
|
+
const fromX = fromNode.x || 0;
|
|
916
|
+
const fromY = (fromNode.y || 0) + (fromNode.height || 0) / 2;
|
|
917
|
+
const toX = toNode.x || 0;
|
|
918
|
+
const toY = (toNode.y || 0) - (toNode.height || 0) / 2;
|
|
919
|
+
return [{
|
|
920
|
+
x: fromX,
|
|
921
|
+
y: fromY
|
|
922
|
+
}, {
|
|
923
|
+
x: toX,
|
|
924
|
+
y: toY
|
|
925
|
+
}];
|
|
926
|
+
}
|
|
927
|
+
/**
|
|
928
|
+
* Get node dimensions based on shape and options
|
|
929
|
+
*/
|
|
930
|
+
getNodeDimensions(node) {
|
|
931
|
+
const baseWidth = this.options.nodeWidth || 120;
|
|
932
|
+
const baseHeight = this.options.nodeHeight || 60;
|
|
933
|
+
switch (node.style?.shape) {
|
|
934
|
+
case "circle": {
|
|
935
|
+
if (node.type === "BranchEnd" || node.type === "IteratorEnd") return {
|
|
936
|
+
height: 16,
|
|
937
|
+
width: 16
|
|
938
|
+
};
|
|
939
|
+
const terminalDiameter = baseHeight * 1.4;
|
|
940
|
+
return {
|
|
941
|
+
height: terminalDiameter,
|
|
942
|
+
width: terminalDiameter
|
|
943
|
+
};
|
|
944
|
+
}
|
|
945
|
+
case "diamond": return {
|
|
946
|
+
height: baseHeight * 1.2,
|
|
947
|
+
width: baseWidth * 1.2
|
|
948
|
+
};
|
|
949
|
+
default: return {
|
|
950
|
+
height: baseHeight,
|
|
951
|
+
width: baseWidth
|
|
952
|
+
};
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
};
|
|
956
|
+
|
|
957
|
+
//#endregion
|
|
958
|
+
//#region src/renderers/SvgRenderer.ts
|
|
959
|
+
/**
|
|
960
|
+
* SvgRenderer - Generates SVG diagrams from positioned nodes and edges
|
|
961
|
+
*/
|
|
962
|
+
var SvgRenderer = class {
|
|
963
|
+
options;
|
|
964
|
+
theme;
|
|
965
|
+
constructor(options) {
|
|
966
|
+
this.options = options;
|
|
967
|
+
this.theme = getTheme(options.theme, options.customColors);
|
|
968
|
+
}
|
|
969
|
+
/**
|
|
970
|
+
* Render the diagram to SVG string
|
|
971
|
+
*/
|
|
972
|
+
render(layout) {
|
|
973
|
+
const document = new jsdom.JSDOM("<!DOCTYPE html><html><body></body></html>").window.document;
|
|
974
|
+
const bounds = this.calculateBounds(layout);
|
|
975
|
+
const svgNode = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
|
976
|
+
const svg = (0, d3.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}`);
|
|
977
|
+
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);
|
|
978
|
+
const defs = svg.append("defs");
|
|
979
|
+
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);
|
|
980
|
+
defs.append("marker").attr("id", "arrowhead-error").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.error);
|
|
981
|
+
defs.append("marker").attr("id", "arrowhead-choice").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.choice);
|
|
982
|
+
defs.append("marker").attr("id", "arrowhead-default").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.default);
|
|
983
|
+
const edgesGroup = svg.append("g").attr("class", "edges");
|
|
984
|
+
const containersGroup = svg.append("g").attr("class", "containers");
|
|
985
|
+
const nodesGroup = svg.append("g").attr("class", "nodes");
|
|
986
|
+
const containerNodes = layout.nodes.filter((node) => node.isContainer);
|
|
987
|
+
const regularNodes = layout.nodes.filter((node) => !node.isContainer);
|
|
988
|
+
layout.edges.forEach((edge) => {
|
|
989
|
+
const fromNode = layout.nodes.find((node) => node.id === edge.from);
|
|
990
|
+
if (fromNode && (fromNode.type === "BranchEnd" || fromNode.type === "IteratorEnd")) return;
|
|
991
|
+
this.renderEdge({
|
|
992
|
+
edge,
|
|
993
|
+
group: edgesGroup
|
|
994
|
+
});
|
|
995
|
+
});
|
|
996
|
+
containerNodes.forEach((node) => {
|
|
997
|
+
this.renderContainer({
|
|
998
|
+
group: containersGroup,
|
|
999
|
+
node
|
|
1000
|
+
});
|
|
1001
|
+
});
|
|
1002
|
+
regularNodes.forEach((node) => {
|
|
1003
|
+
this.renderNode({
|
|
1004
|
+
group: nodesGroup,
|
|
1005
|
+
node
|
|
1006
|
+
});
|
|
1007
|
+
});
|
|
1008
|
+
return {
|
|
1009
|
+
height: bounds.height,
|
|
1010
|
+
metadata: {
|
|
1011
|
+
edgeCount: layout.edges.length,
|
|
1012
|
+
nodeCount: layout.nodes.length
|
|
1013
|
+
},
|
|
1014
|
+
svg: svgNode.outerHTML,
|
|
1015
|
+
width: bounds.width
|
|
1016
|
+
};
|
|
1017
|
+
}
|
|
1018
|
+
/**
|
|
1019
|
+
* Calculate bounding box including all nodes and edge points
|
|
1020
|
+
*/
|
|
1021
|
+
calculateBounds(layout) {
|
|
1022
|
+
let minX = Infinity;
|
|
1023
|
+
let minY = Infinity;
|
|
1024
|
+
let maxX = -Infinity;
|
|
1025
|
+
let maxY = -Infinity;
|
|
1026
|
+
layout.nodes.forEach((node) => {
|
|
1027
|
+
const halfWidth = (node.width || 0) / 2;
|
|
1028
|
+
const halfHeight = (node.height || 0) / 2;
|
|
1029
|
+
minX = Math.min(minX, (node.x || 0) - halfWidth);
|
|
1030
|
+
minY = Math.min(minY, (node.y || 0) - halfHeight);
|
|
1031
|
+
maxX = Math.max(maxX, (node.x || 0) + halfWidth);
|
|
1032
|
+
maxY = Math.max(maxY, (node.y || 0) + halfHeight);
|
|
1033
|
+
});
|
|
1034
|
+
layout.edges.forEach((edge) => {
|
|
1035
|
+
if (edge.points) edge.points.forEach((point) => {
|
|
1036
|
+
minX = Math.min(minX, point.x);
|
|
1037
|
+
minY = Math.min(minY, point.y);
|
|
1038
|
+
maxX = Math.max(maxX, point.x);
|
|
1039
|
+
maxY = Math.max(maxY, point.y);
|
|
1040
|
+
});
|
|
1041
|
+
if (edge.label && edge.points && edge.points.length > 0) {
|
|
1042
|
+
const midpoint = this.getPathMidpoint(edge.points);
|
|
1043
|
+
const labelDimensions = this.calculateLabelDimensions(edge.label);
|
|
1044
|
+
const labelMinX = midpoint.x - labelDimensions.width / 2;
|
|
1045
|
+
const labelMaxX = midpoint.x + labelDimensions.width / 2;
|
|
1046
|
+
const labelMinY = midpoint.y - labelDimensions.height / 2;
|
|
1047
|
+
const labelMaxY = midpoint.y + labelDimensions.height / 2;
|
|
1048
|
+
minX = Math.min(minX, labelMinX);
|
|
1049
|
+
minY = Math.min(minY, labelMinY);
|
|
1050
|
+
maxX = Math.max(maxX, labelMaxX);
|
|
1051
|
+
maxY = Math.max(maxY, labelMaxY);
|
|
1052
|
+
}
|
|
1053
|
+
});
|
|
1054
|
+
const padding = this.options.padding || 20;
|
|
1055
|
+
minX -= padding;
|
|
1056
|
+
minY -= padding;
|
|
1057
|
+
maxX += padding;
|
|
1058
|
+
maxY += padding;
|
|
1059
|
+
return {
|
|
1060
|
+
height: maxY - minY,
|
|
1061
|
+
minX,
|
|
1062
|
+
minY,
|
|
1063
|
+
width: maxX - minX
|
|
1064
|
+
};
|
|
1065
|
+
}
|
|
1066
|
+
/**
|
|
1067
|
+
* Render a container node (Parallel/Map) with bounding box
|
|
1068
|
+
*/
|
|
1069
|
+
renderContainer(params) {
|
|
1070
|
+
const { group, node } = params;
|
|
1071
|
+
const containerGroup = group.append("g").attr("class", `container container-${node.type}`).attr("transform", `translate(${node.x}, ${node.y})`);
|
|
1072
|
+
const width = node.width || 480;
|
|
1073
|
+
const height = node.height || 180;
|
|
1074
|
+
const headerHeight = 50;
|
|
1075
|
+
containerGroup.append("rect").attr("x", -width / 2).attr("y", -height / 2).attr("width", width).attr("height", height).attr("rx", 7).attr("fill", node.style?.fill || "#fce4ec").attr("stroke", node.style?.stroke || "#c2185b").attr("stroke-width", 2).attr("opacity", .5);
|
|
1076
|
+
containerGroup.append("rect").attr("x", -width / 2).attr("y", -height / 2).attr("width", width).attr("height", headerHeight).attr("rx", 7).attr("fill", node.style?.fill || "#fce4ec").attr("stroke", node.style?.stroke || "#c2185b").attr("stroke-width", 2);
|
|
1077
|
+
containerGroup.append("text").attr("x", 0).attr("y", -height / 2 + headerHeight / 2).attr("text-anchor", "middle").attr("dominant-baseline", "middle").attr("fill", this.theme.textColor).attr("font-size", this.theme.fontSize).attr("font-family", this.theme.fontFamily).text(node.label);
|
|
1078
|
+
if (this.options.showStateTypes) containerGroup.append("text").attr("x", 0).attr("y", -height / 2 + headerHeight / 2 + 18).attr("text-anchor", "middle").attr("dominant-baseline", "middle").attr("fill", this.theme.textColor).attr("font-size", this.theme.fontSize - 2).attr("opacity", .7).text(`${node.type} state`);
|
|
1079
|
+
}
|
|
1080
|
+
/**
|
|
1081
|
+
* Render a single node
|
|
1082
|
+
*/
|
|
1083
|
+
renderNode(params) {
|
|
1084
|
+
const { group, node } = params;
|
|
1085
|
+
const nodeGroup = group.append("g").attr("class", `node node-${node.type}`).attr("transform", `translate(${node.x}, ${node.y})`);
|
|
1086
|
+
const style = node.style;
|
|
1087
|
+
if (!style) return;
|
|
1088
|
+
switch (style.shape) {
|
|
1089
|
+
case "circle":
|
|
1090
|
+
this.renderCircle({
|
|
1091
|
+
group: nodeGroup,
|
|
1092
|
+
node,
|
|
1093
|
+
style
|
|
1094
|
+
});
|
|
1095
|
+
break;
|
|
1096
|
+
case "diamond":
|
|
1097
|
+
this.renderDiamond({
|
|
1098
|
+
group: nodeGroup,
|
|
1099
|
+
node,
|
|
1100
|
+
style
|
|
1101
|
+
});
|
|
1102
|
+
break;
|
|
1103
|
+
default: this.renderRect({
|
|
1104
|
+
group: nodeGroup,
|
|
1105
|
+
node,
|
|
1106
|
+
style
|
|
1107
|
+
});
|
|
1108
|
+
}
|
|
1109
|
+
if (node.iconUrl && this.options.showIcons) this.renderIcon({
|
|
1110
|
+
group: nodeGroup,
|
|
1111
|
+
iconPosition: this.options.iconPosition || "left",
|
|
1112
|
+
iconSize: this.options.iconSize || 24,
|
|
1113
|
+
iconUrl: node.iconUrl,
|
|
1114
|
+
node
|
|
1115
|
+
});
|
|
1116
|
+
const labelX = this.calculateLabelX({
|
|
1117
|
+
hasIcon: !!node.iconUrl && !!this.options.showIcons,
|
|
1118
|
+
iconPosition: this.options.iconPosition || "left",
|
|
1119
|
+
iconSize: this.options.iconSize || 24,
|
|
1120
|
+
node
|
|
1121
|
+
});
|
|
1122
|
+
const labelY = this.calculateLabelY({
|
|
1123
|
+
hasIcon: !!node.iconUrl && !!this.options.showIcons,
|
|
1124
|
+
iconPosition: this.options.iconPosition || "left",
|
|
1125
|
+
iconSize: this.options.iconSize || 24,
|
|
1126
|
+
node
|
|
1127
|
+
});
|
|
1128
|
+
nodeGroup.append("text").attr("x", labelX).attr("y", labelY).attr("text-anchor", "middle").attr("dominant-baseline", "middle").attr("fill", this.theme.textColor).attr("font-size", this.theme.fontSize).attr("font-family", this.theme.fontFamily).text(node.label);
|
|
1129
|
+
if (this.options.showStateTypes) nodeGroup.append("text").attr("x", labelX).attr("y", labelY + 20).attr("text-anchor", "middle").attr("dominant-baseline", "middle").attr("fill", this.theme.textColor).attr("font-size", this.theme.fontSize - 2).attr("opacity", .7).text(node.type);
|
|
1130
|
+
}
|
|
1131
|
+
/**
|
|
1132
|
+
* Render rectangle node
|
|
1133
|
+
*/
|
|
1134
|
+
renderRect(params) {
|
|
1135
|
+
const { group, node, style } = params;
|
|
1136
|
+
const width = node.width || 120;
|
|
1137
|
+
const height = node.height || 60;
|
|
1138
|
+
group.append("rect").attr("x", -width / 2).attr("y", -height / 2).attr("width", width).attr("height", height).attr("rx", 5).attr("fill", style.fill).attr("stroke", style.stroke).attr("stroke-width", style.strokeWidth);
|
|
1139
|
+
}
|
|
1140
|
+
/**
|
|
1141
|
+
* Render circle node
|
|
1142
|
+
*/
|
|
1143
|
+
renderCircle(params) {
|
|
1144
|
+
const { group, node, style } = params;
|
|
1145
|
+
const radius = (node.width || 60) / 2;
|
|
1146
|
+
group.append("circle").attr("r", radius).attr("fill", style.fill).attr("stroke", style.stroke).attr("stroke-width", style.strokeWidth);
|
|
1147
|
+
}
|
|
1148
|
+
/**
|
|
1149
|
+
* Render diamond node
|
|
1150
|
+
*/
|
|
1151
|
+
renderDiamond(params) {
|
|
1152
|
+
const { group, node, style } = params;
|
|
1153
|
+
const halfWidth = (node.width || 120) / 2;
|
|
1154
|
+
const halfHeight = (node.height || 60) / 2;
|
|
1155
|
+
const path = `M 0,-${halfHeight} L ${halfWidth},0 L 0,${halfHeight} L -${halfWidth},0 Z`;
|
|
1156
|
+
group.append("path").attr("d", path).attr("fill", style.fill).attr("stroke", style.stroke).attr("stroke-width", style.strokeWidth);
|
|
1157
|
+
}
|
|
1158
|
+
/**
|
|
1159
|
+
* Render AWS service icon within node
|
|
1160
|
+
*/
|
|
1161
|
+
renderIcon(params) {
|
|
1162
|
+
const { group, iconPosition, iconSize, iconUrl, node } = params;
|
|
1163
|
+
const width = node.width || 120;
|
|
1164
|
+
const height = node.height || 60;
|
|
1165
|
+
const padding = 8;
|
|
1166
|
+
let iconX = 0;
|
|
1167
|
+
let iconY = 0;
|
|
1168
|
+
switch (iconPosition) {
|
|
1169
|
+
case "left":
|
|
1170
|
+
iconX = -width / 2 + padding;
|
|
1171
|
+
iconY = -iconSize / 2;
|
|
1172
|
+
break;
|
|
1173
|
+
case "top":
|
|
1174
|
+
iconX = -iconSize / 2;
|
|
1175
|
+
iconY = -height / 2 + padding;
|
|
1176
|
+
break;
|
|
1177
|
+
case "right":
|
|
1178
|
+
iconX = width / 2 - iconSize - padding;
|
|
1179
|
+
iconY = -iconSize / 2;
|
|
1180
|
+
break;
|
|
1181
|
+
}
|
|
1182
|
+
group.append("image").attr("x", iconX).attr("y", iconY).attr("width", iconSize).attr("height", iconSize).attr("href", iconUrl).attr("preserveAspectRatio", "xMidYMid meet");
|
|
1183
|
+
}
|
|
1184
|
+
/**
|
|
1185
|
+
* Calculate label X position based on icon presence and position
|
|
1186
|
+
*/
|
|
1187
|
+
calculateLabelX(params) {
|
|
1188
|
+
const { hasIcon, iconPosition, iconSize } = params;
|
|
1189
|
+
if (!hasIcon) return 0;
|
|
1190
|
+
const padding = 8;
|
|
1191
|
+
const gap = 4;
|
|
1192
|
+
switch (iconPosition) {
|
|
1193
|
+
case "left": return (padding + iconSize + gap) / 2;
|
|
1194
|
+
case "right": return -(padding + iconSize + gap) / 2;
|
|
1195
|
+
default: return 0;
|
|
1196
|
+
}
|
|
1197
|
+
}
|
|
1198
|
+
/**
|
|
1199
|
+
* Calculate label Y position based on icon presence and position
|
|
1200
|
+
*/
|
|
1201
|
+
calculateLabelY(params) {
|
|
1202
|
+
const { hasIcon, iconPosition, iconSize } = params;
|
|
1203
|
+
if (!hasIcon || iconPosition !== "top") return 0;
|
|
1204
|
+
return (8 + iconSize + 4) / 2;
|
|
1205
|
+
}
|
|
1206
|
+
/**
|
|
1207
|
+
* Render an edge
|
|
1208
|
+
*/
|
|
1209
|
+
renderEdge(params) {
|
|
1210
|
+
const { edge, group } = params;
|
|
1211
|
+
if (!edge.points || edge.points.length < 2) return;
|
|
1212
|
+
const edgeColor = this.theme.edgeColors[edge.type || "normal"];
|
|
1213
|
+
const markerType = edge.type || "normal";
|
|
1214
|
+
const pathGenerator = (0, d3.line)().x((d) => d.x).y((d) => d.y);
|
|
1215
|
+
if (this.options.edgeStyle === "curved") pathGenerator.curve(d3.curveBasis);
|
|
1216
|
+
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})`);
|
|
1217
|
+
if (edge.type === "error") pathElement.attr("stroke-dasharray", "5,5");
|
|
1218
|
+
else if (edge.type === "default") pathElement.attr("stroke-dasharray", "8,4");
|
|
1219
|
+
if (edge.label) {
|
|
1220
|
+
const midpoint = this.getPathMidpoint(edge.points);
|
|
1221
|
+
const labelDimensions = this.calculateLabelDimensions(edge.label);
|
|
1222
|
+
group.append("rect").attr("x", midpoint.x - labelDimensions.width / 2).attr("y", midpoint.y - labelDimensions.height / 2).attr("width", labelDimensions.width).attr("height", labelDimensions.height).attr("fill", this.theme.background || "#ffffff").attr("stroke", edgeColor).attr("stroke-width", .5).attr("rx", 3);
|
|
1223
|
+
group.append("text").attr("x", midpoint.x).attr("y", midpoint.y).attr("text-anchor", "middle").attr("dominant-baseline", "middle").attr("fill", edgeColor).attr("font-size", this.theme.fontSize - 2).text(edge.label);
|
|
1224
|
+
}
|
|
1225
|
+
}
|
|
1226
|
+
/**
|
|
1227
|
+
* Get the midpoint of a path for label placement
|
|
1228
|
+
*/
|
|
1229
|
+
getPathMidpoint(points) {
|
|
1230
|
+
return points[Math.floor(points.length / 2)];
|
|
1231
|
+
}
|
|
1232
|
+
/**
|
|
1233
|
+
* Calculate dimensions for edge label
|
|
1234
|
+
*/
|
|
1235
|
+
calculateLabelDimensions(label) {
|
|
1236
|
+
const fontSize = this.theme.fontSize - 2;
|
|
1237
|
+
const avgCharWidth = fontSize * .6;
|
|
1238
|
+
const padding = 8;
|
|
1239
|
+
const verticalPadding = 4;
|
|
1240
|
+
const width = label.length * avgCharWidth + padding * 2;
|
|
1241
|
+
return {
|
|
1242
|
+
height: fontSize + verticalPadding * 2,
|
|
1243
|
+
width
|
|
1244
|
+
};
|
|
1245
|
+
}
|
|
1246
|
+
};
|
|
1247
|
+
|
|
1248
|
+
//#endregion
|
|
1249
|
+
//#region src/renderers/MermaidRenderer.ts
|
|
1250
|
+
/**
|
|
1251
|
+
* MermaidRenderer - Generates Mermaid state diagram syntax from ASL
|
|
1252
|
+
*/
|
|
1253
|
+
var MermaidRenderer = class {
|
|
1254
|
+
/**
|
|
1255
|
+
* Render nodes and edges to Mermaid syntax
|
|
1256
|
+
*/
|
|
1257
|
+
render(params) {
|
|
1258
|
+
const { asl, edges, nodes } = params;
|
|
1259
|
+
const lines = [];
|
|
1260
|
+
lines.push("stateDiagram-v2");
|
|
1261
|
+
lines.push("");
|
|
1262
|
+
const startState = this.findStartState({
|
|
1263
|
+
asl,
|
|
1264
|
+
edges,
|
|
1265
|
+
nodes
|
|
1266
|
+
});
|
|
1267
|
+
if (startState) lines.push(` [*] --> ${this.sanitizeId(startState)}`);
|
|
1268
|
+
const stateDefinitions = /* @__PURE__ */ new Set();
|
|
1269
|
+
nodes.forEach((node) => {
|
|
1270
|
+
const id = this.sanitizeId(node.id);
|
|
1271
|
+
if (node.label !== node.id && !stateDefinitions.has(id)) {
|
|
1272
|
+
lines.push(` ${id}: ${this.escapeLabel(node.label)}`);
|
|
1273
|
+
stateDefinitions.add(id);
|
|
1274
|
+
}
|
|
1275
|
+
});
|
|
1276
|
+
if (stateDefinitions.size > 0) lines.push("");
|
|
1277
|
+
edges.forEach((edge) => {
|
|
1278
|
+
const from = this.sanitizeId(edge.from);
|
|
1279
|
+
const to = this.sanitizeId(edge.to);
|
|
1280
|
+
if (edge.label || edge.condition) {
|
|
1281
|
+
const label = this.escapeLabel(edge.condition || edge.label || "");
|
|
1282
|
+
lines.push(` ${from} --> ${to}: ${label}`);
|
|
1283
|
+
} else lines.push(` ${from} --> ${to}`);
|
|
1284
|
+
});
|
|
1285
|
+
const endStates = nodes.filter((node) => node.type === "Succeed" || node.type === "Fail");
|
|
1286
|
+
if (endStates.length > 0) {
|
|
1287
|
+
lines.push("");
|
|
1288
|
+
endStates.forEach((node) => {
|
|
1289
|
+
const id = this.sanitizeId(node.id);
|
|
1290
|
+
lines.push(` ${id} --> [*]`);
|
|
1291
|
+
});
|
|
1292
|
+
}
|
|
1293
|
+
lines.push("");
|
|
1294
|
+
lines.push(" classDef successState fill:#e8f5e8,stroke:#4caf50,stroke-width:3px");
|
|
1295
|
+
lines.push(" classDef failState fill:#ffebee,stroke:#f44336,stroke-width:3px");
|
|
1296
|
+
lines.push(" classDef choiceState fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px");
|
|
1297
|
+
lines.push(" classDef taskState fill:#fff3e0,stroke:#ef6c00,stroke-width:2px");
|
|
1298
|
+
lines.push("");
|
|
1299
|
+
nodes.forEach((node) => {
|
|
1300
|
+
const id = this.sanitizeId(node.id);
|
|
1301
|
+
switch (node.type) {
|
|
1302
|
+
case "Succeed":
|
|
1303
|
+
lines.push(` class ${id} successState`);
|
|
1304
|
+
break;
|
|
1305
|
+
case "Fail":
|
|
1306
|
+
lines.push(` class ${id} failState`);
|
|
1307
|
+
break;
|
|
1308
|
+
case "Choice":
|
|
1309
|
+
lines.push(` class ${id} choiceState`);
|
|
1310
|
+
break;
|
|
1311
|
+
case "Task":
|
|
1312
|
+
lines.push(` class ${id} taskState`);
|
|
1313
|
+
break;
|
|
1314
|
+
}
|
|
1315
|
+
});
|
|
1316
|
+
return {
|
|
1317
|
+
code: lines.join("\n"),
|
|
1318
|
+
metadata: {
|
|
1319
|
+
stateCount: nodes.length,
|
|
1320
|
+
edgeCount: edges.length
|
|
1321
|
+
}
|
|
1322
|
+
};
|
|
1323
|
+
}
|
|
1324
|
+
/**
|
|
1325
|
+
* Sanitize state ID for Mermaid (no spaces, special chars)
|
|
1326
|
+
*/
|
|
1327
|
+
sanitizeId(id) {
|
|
1328
|
+
return id.replace(/[^a-zA-Z0-9_]/g, "_");
|
|
1329
|
+
}
|
|
1330
|
+
/**
|
|
1331
|
+
* Escape label text for Mermaid
|
|
1332
|
+
*/
|
|
1333
|
+
escapeLabel(label) {
|
|
1334
|
+
return label.replace(/"/g, "'").replace(/\n/g, " ");
|
|
1335
|
+
}
|
|
1336
|
+
/**
|
|
1337
|
+
* Find the start state from ASL definition or by analyzing edges
|
|
1338
|
+
*/
|
|
1339
|
+
findStartState(params) {
|
|
1340
|
+
const { asl, edges, nodes } = params;
|
|
1341
|
+
if (asl?.StartAt) return asl.StartAt;
|
|
1342
|
+
const targetNodes = new Set(edges.map((edge) => edge.to));
|
|
1343
|
+
return nodes.find((node) => !targetNodes.has(node.id))?.id || nodes[0]?.id || null;
|
|
1344
|
+
}
|
|
1345
|
+
};
|
|
1346
|
+
|
|
1347
|
+
//#endregion
|
|
1348
|
+
//#region src/exporters/PngExporter.ts
|
|
1349
|
+
/**
|
|
1350
|
+
* PngExporter - Converts SVG to PNG using headless rendering
|
|
1351
|
+
*/
|
|
1352
|
+
var PngExporter = class {
|
|
1353
|
+
options;
|
|
1354
|
+
constructor(options) {
|
|
1355
|
+
this.options = options;
|
|
1356
|
+
}
|
|
1357
|
+
/**
|
|
1358
|
+
* Convert SVG string to PNG buffer
|
|
1359
|
+
*
|
|
1360
|
+
* Note: External images (like AWS service icons from CDN) may not render in PNG export
|
|
1361
|
+
* due to limitations with headless browser rendering. Use SVG output for best results
|
|
1362
|
+
* when showIcons is enabled.
|
|
1363
|
+
*/
|
|
1364
|
+
async convert(params) {
|
|
1365
|
+
const { svg, width, height } = params;
|
|
1366
|
+
return {
|
|
1367
|
+
buffer: await (0, node_html_to_image.default)({
|
|
1368
|
+
html: this.wrapSvgInHtml({
|
|
1369
|
+
svg,
|
|
1370
|
+
width,
|
|
1371
|
+
height
|
|
1372
|
+
}),
|
|
1373
|
+
puppeteerArgs: { args: ["--no-sandbox", "--disable-setuid-sandbox"] },
|
|
1374
|
+
quality: this.options.pngQuality || 90,
|
|
1375
|
+
transparent: this.options.backgroundColor === "transparent",
|
|
1376
|
+
type: "png"
|
|
1377
|
+
}),
|
|
1378
|
+
height,
|
|
1379
|
+
metadata: { format: "png" },
|
|
1380
|
+
width
|
|
1381
|
+
};
|
|
1382
|
+
}
|
|
1383
|
+
/**
|
|
1384
|
+
* Wrap SVG in HTML for rendering
|
|
1385
|
+
*/
|
|
1386
|
+
wrapSvgInHtml(params) {
|
|
1387
|
+
const { svg, width, height } = params;
|
|
1388
|
+
return `
|
|
1389
|
+
<!DOCTYPE html>
|
|
1390
|
+
<html>
|
|
1391
|
+
<head>
|
|
1392
|
+
<meta charset="UTF-8">
|
|
1393
|
+
<style>
|
|
1394
|
+
* {
|
|
1395
|
+
margin: 0;
|
|
1396
|
+
padding: 0;
|
|
1397
|
+
box-sizing: border-box;
|
|
1398
|
+
}
|
|
1399
|
+
body {
|
|
1400
|
+
width: ${width}px;
|
|
1401
|
+
height: ${height}px;
|
|
1402
|
+
background: ${this.options.backgroundColor || "transparent"};
|
|
1403
|
+
display: flex;
|
|
1404
|
+
align-items: center;
|
|
1405
|
+
justify-content: center;
|
|
1406
|
+
}
|
|
1407
|
+
svg {
|
|
1408
|
+
max-width: 100%;
|
|
1409
|
+
max-height: 100%;
|
|
1410
|
+
}
|
|
1411
|
+
</style>
|
|
1412
|
+
</head>
|
|
1413
|
+
<body>
|
|
1414
|
+
${svg}
|
|
1415
|
+
</body>
|
|
1416
|
+
</html>
|
|
1417
|
+
`.trim();
|
|
1418
|
+
}
|
|
1419
|
+
};
|
|
1420
|
+
|
|
1421
|
+
//#endregion
|
|
1422
|
+
//#region src/config/defaults.ts
|
|
1423
|
+
/**
|
|
1424
|
+
* Default diagram options - used when options not provided
|
|
1425
|
+
*/
|
|
1426
|
+
const DEFAULT_DIAGRAM_OPTIONS = {
|
|
1427
|
+
format: "svg",
|
|
1428
|
+
theme: "light",
|
|
1429
|
+
customColors: void 0,
|
|
1430
|
+
layout: "TB",
|
|
1431
|
+
rankSeparation: 50,
|
|
1432
|
+
nodeSeparation: 50,
|
|
1433
|
+
width: void 0,
|
|
1434
|
+
height: void 0,
|
|
1435
|
+
nodeWidth: 120,
|
|
1436
|
+
nodeHeight: 60,
|
|
1437
|
+
padding: 20,
|
|
1438
|
+
includeComments: true,
|
|
1439
|
+
showStateTypes: false,
|
|
1440
|
+
edgeStyle: "curved",
|
|
1441
|
+
catchLabelStyle: "error-type",
|
|
1442
|
+
stylePreset: "aws-standard",
|
|
1443
|
+
iconPosition: "left",
|
|
1444
|
+
iconResolver: void 0,
|
|
1445
|
+
iconSize: 24,
|
|
1446
|
+
showIcons: false,
|
|
1447
|
+
pngQuality: 90,
|
|
1448
|
+
backgroundColor: "transparent"
|
|
1449
|
+
};
|
|
1450
|
+
/**
|
|
1451
|
+
* Merge user-provided options with defaults
|
|
1452
|
+
*/
|
|
1453
|
+
function mergeOptions(options = {}) {
|
|
1454
|
+
return {
|
|
1455
|
+
...DEFAULT_DIAGRAM_OPTIONS,
|
|
1456
|
+
...options
|
|
1457
|
+
};
|
|
1458
|
+
}
|
|
1459
|
+
|
|
1460
|
+
//#endregion
|
|
1461
|
+
//#region src/utils/iconEmbedder.ts
|
|
1462
|
+
/** Default timeout for icon fetch operations in milliseconds */
|
|
1463
|
+
const DEFAULT_FETCH_TIMEOUT_MS = 5e3;
|
|
1464
|
+
/**
|
|
1465
|
+
* Fetch a remote resource and convert it to a base64 data URI
|
|
1466
|
+
*
|
|
1467
|
+
* @param params - Parameters containing the URL and optional timeout
|
|
1468
|
+
* @returns Base64 data URI string, or original URL on failure
|
|
1469
|
+
*/
|
|
1470
|
+
async function fetchAsDataUri(params) {
|
|
1471
|
+
const { timeoutMs = DEFAULT_FETCH_TIMEOUT_MS, url } = params;
|
|
1472
|
+
const controller = new AbortController();
|
|
1473
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
1474
|
+
try {
|
|
1475
|
+
const response = await fetch(url, { signal: controller.signal });
|
|
1476
|
+
clearTimeout(timeoutId);
|
|
1477
|
+
if (!response.ok) throw new Error(`Failed to fetch ${url}: ${response.statusText}`);
|
|
1478
|
+
const buffer = await response.arrayBuffer();
|
|
1479
|
+
const base64 = Buffer.from(buffer).toString("base64");
|
|
1480
|
+
return `data:${url.endsWith(".svg") ? "image/svg+xml" : "image/png"};base64,${base64}`;
|
|
1481
|
+
} catch (error) {
|
|
1482
|
+
clearTimeout(timeoutId);
|
|
1483
|
+
const errorMessage = error instanceof Error && error.name === "AbortError" ? `Timeout after ${timeoutMs}ms` : error;
|
|
1484
|
+
console.warn(`Failed to embed icon from ${url}:`, errorMessage);
|
|
1485
|
+
return url;
|
|
1486
|
+
}
|
|
1487
|
+
}
|
|
1488
|
+
/**
|
|
1489
|
+
* Embed external icon URLs in SVG as base64 data URIs
|
|
1490
|
+
*
|
|
1491
|
+
* This function scans the SVG for external image references (href attributes)
|
|
1492
|
+
* and replaces them with inline base64-encoded data URIs. This ensures icons
|
|
1493
|
+
* display correctly in standalone SVG files, PNG exports, and contexts where
|
|
1494
|
+
* external resources are blocked by security policies.
|
|
1495
|
+
*
|
|
1496
|
+
* @param params - Parameters for icon embedding
|
|
1497
|
+
* @param params.svg - SVG string containing external icon URLs
|
|
1498
|
+
* @returns Promise resolving to SVG string with embedded icons
|
|
1499
|
+
*
|
|
1500
|
+
* @example
|
|
1501
|
+
* const svgWithExternalIcons = generateSvg({ aslDefinition, showIcons: true });
|
|
1502
|
+
* const svgWithEmbeddedIcons = await embedIcons({ svg: svgWithExternalIcons.svg });
|
|
1503
|
+
* writeFileSync('diagram.svg', svgWithEmbeddedIcons);
|
|
1504
|
+
*
|
|
1505
|
+
* @example
|
|
1506
|
+
* // Chain with PNG export
|
|
1507
|
+
* const { svg } = generateSvg({ aslDefinition, showIcons: true });
|
|
1508
|
+
* const embeddedSvg = await embedIcons({ svg });
|
|
1509
|
+
* const png = await exportPng({ svg: embeddedSvg });
|
|
1510
|
+
*/
|
|
1511
|
+
async function embedIcons(params) {
|
|
1512
|
+
const { svg, timeoutMs } = params;
|
|
1513
|
+
const matches = Array.from(svg.matchAll(/href="(https?:\/\/[^"]+)"/g));
|
|
1514
|
+
if (matches.length === 0) return svg;
|
|
1515
|
+
const uniqueUrls = [...new Set(matches.map((match) => match[1]))];
|
|
1516
|
+
const urlToDataUri = /* @__PURE__ */ new Map();
|
|
1517
|
+
await Promise.all(uniqueUrls.map(async (url) => {
|
|
1518
|
+
const dataUri = await fetchAsDataUri({
|
|
1519
|
+
timeoutMs,
|
|
1520
|
+
url
|
|
1521
|
+
});
|
|
1522
|
+
urlToDataUri.set(url, dataUri);
|
|
1523
|
+
}));
|
|
1524
|
+
let embeddedSvg = svg;
|
|
1525
|
+
urlToDataUri.forEach((dataUri, url) => {
|
|
1526
|
+
embeddedSvg = embeddedSvg.replaceAll(`href="${url}"`, `href="${dataUri}"`);
|
|
1527
|
+
});
|
|
1528
|
+
return embeddedSvg;
|
|
1529
|
+
}
|
|
1530
|
+
|
|
1531
|
+
//#endregion
|
|
1532
|
+
//#region src/index.ts
|
|
1533
|
+
/**
|
|
1534
|
+
* Generate an SVG diagram from an AWS Step Functions ASL definition
|
|
1535
|
+
*
|
|
1536
|
+
* This function parses an ASL definition and renders it as an SVG diagram using D3.js
|
|
1537
|
+
* with automatic graph layout via Dagre. The output is a complete SVG string that can
|
|
1538
|
+
* be saved to a file or embedded in HTML.
|
|
1539
|
+
*
|
|
1540
|
+
* @param params - Configuration object
|
|
1541
|
+
* @param params.aslDefinition - ASL definition as an object or JSON string
|
|
1542
|
+
* @param params.theme - Color theme: 'light' (default), 'dark', or a CustomTheme object
|
|
1543
|
+
* @param params.layout - Layout direction: 'TB' (top-bottom, default), 'LR' (left-right), 'RL', or 'BT'
|
|
1544
|
+
* @param params.nodeWidth - Width of state nodes in pixels (default: 120)
|
|
1545
|
+
* @param params.nodeHeight - Height of state nodes in pixels (default: 60)
|
|
1546
|
+
* @param params.rankSeparation - Vertical separation between ranks in pixels (default: 50)
|
|
1547
|
+
* @param params.nodeSeparation - Horizontal separation between nodes in pixels (default: 50)
|
|
1548
|
+
* @param params.padding - Padding around the diagram in pixels (default: 20)
|
|
1549
|
+
* @param params.edgeStyle - Edge path style: 'curved' (default), 'straight', or 'orthogonal'
|
|
1550
|
+
* @param params.showStateTypes - Whether to display state types on nodes (default: false)
|
|
1551
|
+
* @param params.includeComments - Whether to use state comments as labels (default: true)
|
|
1552
|
+
* @param params.customColors - Override colors for specific state types
|
|
1553
|
+
*
|
|
1554
|
+
* @returns SVG output containing the diagram string, dimensions, and metadata
|
|
1555
|
+
*
|
|
1556
|
+
* @throws {SyntaxError} If params.asl is a string with invalid JSON
|
|
1557
|
+
* @throws {Error} If the ASL definition structure is invalid
|
|
1558
|
+
*
|
|
1559
|
+
* @example
|
|
1560
|
+
* ```typescript
|
|
1561
|
+
* import { generateSvg } from 'sfn-diagram';
|
|
1562
|
+
* import { writeFileSync } from 'fs';
|
|
1563
|
+
*
|
|
1564
|
+
* const asl = {
|
|
1565
|
+
* StartAt: 'HelloWorld',
|
|
1566
|
+
* States: {
|
|
1567
|
+
* HelloWorld: { Type: 'Pass', Result: 'Hello!', End: true }
|
|
1568
|
+
* }
|
|
1569
|
+
* };
|
|
1570
|
+
*
|
|
1571
|
+
* const { svg, width, height } = generateSvg({ aslDefinition: asl, theme: 'dark', layout: 'LR' });
|
|
1572
|
+
* writeFileSync('diagram.svg', svg);
|
|
1573
|
+
* console.log(`Generated ${width}x${height} diagram`);
|
|
1574
|
+
* ```
|
|
1575
|
+
*
|
|
1576
|
+
* @example
|
|
1577
|
+
* ```typescript
|
|
1578
|
+
* // From JSON string
|
|
1579
|
+
* const aslJson = fs.readFileSync('state-machine.json', 'utf-8');
|
|
1580
|
+
* const result = generateSvg({ asl: aslJson, theme: 'light' });
|
|
1581
|
+
* ```
|
|
1582
|
+
*
|
|
1583
|
+
* @example
|
|
1584
|
+
* ```typescript
|
|
1585
|
+
* // With AWS service icons
|
|
1586
|
+
* const { svg } = generateSvg({
|
|
1587
|
+
* aslDefinition: asl,
|
|
1588
|
+
* showIcons: true,
|
|
1589
|
+
* iconPosition: 'left',
|
|
1590
|
+
* nodeWidth: 150
|
|
1591
|
+
* });
|
|
1592
|
+
* ```
|
|
1593
|
+
*/
|
|
1594
|
+
function generateSvg(params) {
|
|
1595
|
+
const { aslDefinition, ...options } = params;
|
|
1596
|
+
const aslObj = typeof aslDefinition === "string" ? JSON.parse(aslDefinition) : aslDefinition;
|
|
1597
|
+
const mergedOptions = mergeOptions(options);
|
|
1598
|
+
const { nodes, edges } = parseAsl({
|
|
1599
|
+
definition: aslObj,
|
|
1600
|
+
options: mergedOptions
|
|
1601
|
+
});
|
|
1602
|
+
const positioned = new DagreLayout(mergedOptions).calculate(nodes, edges);
|
|
1603
|
+
return new SvgRenderer(mergedOptions).render(positioned);
|
|
1604
|
+
}
|
|
1605
|
+
/**
|
|
1606
|
+
* Generate Mermaid diagram syntax from an AWS Step Functions ASL definition
|
|
1607
|
+
*
|
|
1608
|
+
* This function converts an ASL definition into Mermaid state diagram syntax,
|
|
1609
|
+
* which can be rendered using the Mermaid library or included in Markdown documentation.
|
|
1610
|
+
* The output includes CSS classes for styling different state types.
|
|
1611
|
+
*
|
|
1612
|
+
* @param params - Configuration object
|
|
1613
|
+
* @param params.aslDefinition - ASL definition as an object or JSON string
|
|
1614
|
+
*
|
|
1615
|
+
* @returns Mermaid output containing the diagram code and metadata
|
|
1616
|
+
*
|
|
1617
|
+
* @throws {SyntaxError} If params.asl is a string with invalid JSON
|
|
1618
|
+
* @throws {Error} If the ASL definition structure is invalid
|
|
1619
|
+
*
|
|
1620
|
+
* @example
|
|
1621
|
+
* ```typescript
|
|
1622
|
+
* import { generateMermaid } from 'sfn-diagram';
|
|
1623
|
+
*
|
|
1624
|
+
* const asl = {
|
|
1625
|
+
* StartAt: 'Process',
|
|
1626
|
+
* States: {
|
|
1627
|
+
* Process: { Type: 'Task', Resource: 'arn:aws:...', Next: 'Done' },
|
|
1628
|
+
* Done: { Type: 'Succeed' }
|
|
1629
|
+
* }
|
|
1630
|
+
* };
|
|
1631
|
+
*
|
|
1632
|
+
* const { code, metadata } = generateMermaid({ asl });
|
|
1633
|
+
* console.log(code);
|
|
1634
|
+
* // Output:
|
|
1635
|
+
* // stateDiagram-v2
|
|
1636
|
+
* // [*] --> Process
|
|
1637
|
+
* // Process --> Done
|
|
1638
|
+
* // Done --> [*]
|
|
1639
|
+
* // ...
|
|
1640
|
+
*
|
|
1641
|
+
* console.log(`Generated diagram with ${metadata.stateCount} states`);
|
|
1642
|
+
* ```
|
|
1643
|
+
*
|
|
1644
|
+
* @example
|
|
1645
|
+
* ```typescript
|
|
1646
|
+
* // Use in Markdown
|
|
1647
|
+
* const { code } = generateMermaid({ asl: myStateMachine });
|
|
1648
|
+
* const markdown = `\`\`\`mermaid\n${code}\n\`\`\``;
|
|
1649
|
+
* fs.writeFileSync('diagram.md', markdown);
|
|
1650
|
+
* ```
|
|
1651
|
+
*/
|
|
1652
|
+
function generateMermaid(params) {
|
|
1653
|
+
const { aslDefinition, ...options } = params;
|
|
1654
|
+
const aslObj = typeof aslDefinition === "string" ? JSON.parse(aslDefinition) : aslDefinition;
|
|
1655
|
+
const { nodes, edges } = parseAsl({
|
|
1656
|
+
definition: aslObj,
|
|
1657
|
+
options: mergeOptions(options)
|
|
1658
|
+
});
|
|
1659
|
+
return new MermaidRenderer().render({
|
|
1660
|
+
nodes,
|
|
1661
|
+
edges,
|
|
1662
|
+
asl: aslObj
|
|
1663
|
+
});
|
|
1664
|
+
}
|
|
1665
|
+
/**
|
|
1666
|
+
* Generate a diagram from an ASL definition (auto-detects format from options)
|
|
1667
|
+
*
|
|
1668
|
+
* This is a convenience function that generates either an SVG or Mermaid diagram
|
|
1669
|
+
* based on the `format` option. If no format is specified, defaults to SVG.
|
|
1670
|
+
*
|
|
1671
|
+
* @param params - Configuration object
|
|
1672
|
+
* @param params.aslDefinition - ASL definition as an object or JSON string
|
|
1673
|
+
* @param params.format - Output format: 'svg' (default) or 'mermaid'
|
|
1674
|
+
* @param params.theme - Color theme (SVG only)
|
|
1675
|
+
* @param params.layout - Layout direction (SVG only)
|
|
1676
|
+
* @param ...params - Other options (see generateSvg or generateMermaid)
|
|
1677
|
+
*
|
|
1678
|
+
* @returns SVG output if format is 'svg', Mermaid output if format is 'mermaid'
|
|
1679
|
+
*
|
|
1680
|
+
* @throws {SyntaxError} If params.asl is a string with invalid JSON
|
|
1681
|
+
* @throws {Error} If the ASL definition structure is invalid
|
|
1682
|
+
*
|
|
1683
|
+
* @example
|
|
1684
|
+
* ```typescript
|
|
1685
|
+
* import { generateDiagram } from 'sfn-diagram';
|
|
1686
|
+
*
|
|
1687
|
+
* // Generate SVG (default)
|
|
1688
|
+
* const svgResult = generateDiagram({ asl: myAsl });
|
|
1689
|
+
*
|
|
1690
|
+
* // Generate Mermaid
|
|
1691
|
+
* const mermaidResult = generateDiagram({ asl: myAsl, format: 'mermaid' });
|
|
1692
|
+
* ```
|
|
1693
|
+
*/
|
|
1694
|
+
function generateDiagram(params) {
|
|
1695
|
+
const { aslDefinition, format, ...options } = params;
|
|
1696
|
+
if (mergeOptions({
|
|
1697
|
+
format,
|
|
1698
|
+
...options
|
|
1699
|
+
}).format === "mermaid") return generateMermaid({
|
|
1700
|
+
aslDefinition,
|
|
1701
|
+
...options
|
|
1702
|
+
});
|
|
1703
|
+
return generateSvg({
|
|
1704
|
+
aslDefinition,
|
|
1705
|
+
...options
|
|
1706
|
+
});
|
|
1707
|
+
}
|
|
1708
|
+
/**
|
|
1709
|
+
* Export a diagram as PNG (asynchronous)
|
|
1710
|
+
*
|
|
1711
|
+
* This function generates an SVG diagram and converts it to PNG format using
|
|
1712
|
+
* headless browser rendering. The output is a Buffer that can be written to a file.
|
|
1713
|
+
*
|
|
1714
|
+
* **Note:** This function is asynchronous and uses Puppeteer for rendering,
|
|
1715
|
+
* which may take a moment on first run to download the browser binary.
|
|
1716
|
+
*
|
|
1717
|
+
* @param params - Configuration object
|
|
1718
|
+
* @param params.aslDefinition - ASL definition as an object or JSON string
|
|
1719
|
+
* @param params.pngQuality - PNG quality from 1-100 (default: 90)
|
|
1720
|
+
* @param params.backgroundColor - Background color as CSS color or 'transparent' (default: 'transparent')
|
|
1721
|
+
* @param ...params - Other SVG options (see generateSvg)
|
|
1722
|
+
*
|
|
1723
|
+
* @returns Promise resolving to PNG output with buffer and dimensions
|
|
1724
|
+
*
|
|
1725
|
+
* @throws {SyntaxError} If params.asl is a string with invalid JSON
|
|
1726
|
+
* @throws {Error} If the ASL definition structure is invalid or PNG conversion fails
|
|
1727
|
+
*
|
|
1728
|
+
* @example
|
|
1729
|
+
* ```typescript
|
|
1730
|
+
* import { exportPng } from 'sfn-diagram';
|
|
1731
|
+
* import { writeFileSync } from 'fs';
|
|
1732
|
+
*
|
|
1733
|
+
* const asl = { StartAt: 'Hello', States: { Hello: { Type: 'Pass', End: true } } };
|
|
1734
|
+
*
|
|
1735
|
+
* const { buffer, width, height } = await exportPng({
|
|
1736
|
+
* asl,
|
|
1737
|
+
* theme: 'dark',
|
|
1738
|
+
* pngQuality: 95,
|
|
1739
|
+
* backgroundColor: 'white'
|
|
1740
|
+
* });
|
|
1741
|
+
*
|
|
1742
|
+
* writeFileSync('diagram.png', buffer);
|
|
1743
|
+
* console.log(`Saved ${width}x${height} PNG`);
|
|
1744
|
+
* ```
|
|
1745
|
+
*/
|
|
1746
|
+
async function exportPng(params) {
|
|
1747
|
+
const { aslDefinition, ...options } = params;
|
|
1748
|
+
const svgOutput = generateSvg({
|
|
1749
|
+
aslDefinition,
|
|
1750
|
+
...options
|
|
1751
|
+
});
|
|
1752
|
+
return new PngExporter(options).convert({
|
|
1753
|
+
svg: svgOutput.svg,
|
|
1754
|
+
width: svgOutput.width,
|
|
1755
|
+
height: svgOutput.height
|
|
1756
|
+
});
|
|
1757
|
+
}
|
|
1758
|
+
/**
|
|
1759
|
+
* Generate a diagram from an AWS SDK DescribeStateMachine response
|
|
1760
|
+
*
|
|
1761
|
+
* This is a convenience function for integrating with the AWS SDK. It extracts
|
|
1762
|
+
* the ASL definition from the response and generates a diagram.
|
|
1763
|
+
*
|
|
1764
|
+
* @param params - Configuration object
|
|
1765
|
+
* @param params.response - AWS SDK DescribeStateMachine response object
|
|
1766
|
+
* @param params.format - Output format: 'svg' (default) or 'mermaid'
|
|
1767
|
+
* @param ...params - Other options (see generateSvg or generateMermaid)
|
|
1768
|
+
*
|
|
1769
|
+
* @returns SVG output if format is 'svg', Mermaid output if format is 'mermaid'
|
|
1770
|
+
*
|
|
1771
|
+
* @throws {Error} If response.definition is missing
|
|
1772
|
+
* @throws {SyntaxError} If the definition contains invalid JSON
|
|
1773
|
+
* @throws {Error} If the ASL definition structure is invalid
|
|
1774
|
+
*
|
|
1775
|
+
* @example
|
|
1776
|
+
* ```typescript
|
|
1777
|
+
* import { SFNClient, DescribeStateMachineCommand } from '@aws-sdk/client-sfn';
|
|
1778
|
+
* import { generateFromAwsResponse } from 'sfn-diagram';
|
|
1779
|
+
*
|
|
1780
|
+
* const client = new SFNClient({ region: 'us-east-1' });
|
|
1781
|
+
* const response = await client.send(
|
|
1782
|
+
* new DescribeStateMachineCommand({
|
|
1783
|
+
* stateMachineArn: 'arn:aws:states:...'
|
|
1784
|
+
* })
|
|
1785
|
+
* );
|
|
1786
|
+
*
|
|
1787
|
+
* const { svg } = generateFromAwsResponse({
|
|
1788
|
+
* response,
|
|
1789
|
+
* theme: 'light',
|
|
1790
|
+
* layout: 'TB'
|
|
1791
|
+
* });
|
|
1792
|
+
* ```
|
|
1793
|
+
*/
|
|
1794
|
+
function generateFromAwsResponse(params) {
|
|
1795
|
+
const { response, ...options } = params;
|
|
1796
|
+
if (!response.definition) throw new Error("No definition found in AWS response");
|
|
1797
|
+
return generateDiagram({
|
|
1798
|
+
aslDefinition: response.definition,
|
|
1799
|
+
...options
|
|
1800
|
+
});
|
|
1801
|
+
}
|
|
1802
|
+
/**
|
|
1803
|
+
* Class-based API for advanced usage with fluent interface
|
|
1804
|
+
*
|
|
1805
|
+
* This class allows you to configure diagram options once and generate multiple
|
|
1806
|
+
* diagrams with the same settings. Options can be updated using the fluent
|
|
1807
|
+
* `setOptions()` method.
|
|
1808
|
+
*
|
|
1809
|
+
* @example
|
|
1810
|
+
* ```typescript
|
|
1811
|
+
* import { SfnDiagramGenerator } from 'sfn-diagram';
|
|
1812
|
+
*
|
|
1813
|
+
* const generator = new SfnDiagramGenerator({
|
|
1814
|
+
* theme: 'dark',
|
|
1815
|
+
* layout: 'LR',
|
|
1816
|
+
* nodeWidth: 150
|
|
1817
|
+
* });
|
|
1818
|
+
*
|
|
1819
|
+
* // Generate multiple diagrams with the same options
|
|
1820
|
+
* const diagram1 = generator.generateSvg({ asl: stateMachine1 });
|
|
1821
|
+
* const diagram2 = generator.generateSvg({ asl: stateMachine2 });
|
|
1822
|
+
*
|
|
1823
|
+
* // Update options and generate more
|
|
1824
|
+
* generator.setOptions({ theme: 'light' });
|
|
1825
|
+
* const diagram3 = generator.generateSvg({ asl: stateMachine3 });
|
|
1826
|
+
* ```
|
|
1827
|
+
*/
|
|
1828
|
+
var SfnDiagramGenerator = class {
|
|
1829
|
+
options;
|
|
1830
|
+
/**
|
|
1831
|
+
* Create a new diagram generator with default options
|
|
1832
|
+
*
|
|
1833
|
+
* @param options - Default diagram options for all subsequent generations
|
|
1834
|
+
*
|
|
1835
|
+
* @example
|
|
1836
|
+
* ```typescript
|
|
1837
|
+
* const generator = new SfnDiagramGenerator({ theme: 'dark', layout: 'LR' });
|
|
1838
|
+
* ```
|
|
1839
|
+
*/
|
|
1840
|
+
constructor(options = {}) {
|
|
1841
|
+
this.options = mergeOptions(options);
|
|
1842
|
+
}
|
|
1843
|
+
/**
|
|
1844
|
+
* Generate a diagram (auto-detects format from options)
|
|
1845
|
+
*
|
|
1846
|
+
* @param params - Generation parameters
|
|
1847
|
+
* @param params.asl - ASL definition as an object or JSON string
|
|
1848
|
+
* @returns SVG or Mermaid output based on format option
|
|
1849
|
+
*
|
|
1850
|
+
* @example
|
|
1851
|
+
* ```typescript
|
|
1852
|
+
* const result = generator.generate({ asl: myStateMachine });
|
|
1853
|
+
* ```
|
|
1854
|
+
*/
|
|
1855
|
+
generate(params) {
|
|
1856
|
+
return generateDiagram({
|
|
1857
|
+
...params,
|
|
1858
|
+
...this.options
|
|
1859
|
+
});
|
|
1860
|
+
}
|
|
1861
|
+
/**
|
|
1862
|
+
* Generate an SVG diagram
|
|
1863
|
+
*
|
|
1864
|
+
* @param params - Generation parameters
|
|
1865
|
+
* @param params.asl - ASL definition as an object or JSON string
|
|
1866
|
+
* @returns SVG output with diagram and metadata
|
|
1867
|
+
*
|
|
1868
|
+
* @example
|
|
1869
|
+
* ```typescript
|
|
1870
|
+
* const { svg } = generator.generateSvg({ asl: myStateMachine });
|
|
1871
|
+
* ```
|
|
1872
|
+
*/
|
|
1873
|
+
generateSvg(params) {
|
|
1874
|
+
return generateSvg({
|
|
1875
|
+
...params,
|
|
1876
|
+
...this.options
|
|
1877
|
+
});
|
|
1878
|
+
}
|
|
1879
|
+
/**
|
|
1880
|
+
* Generate Mermaid diagram syntax
|
|
1881
|
+
*
|
|
1882
|
+
* @param params - Generation parameters
|
|
1883
|
+
* @param params.asl - ASL definition as an object or JSON string
|
|
1884
|
+
* @returns Mermaid output with code and metadata
|
|
1885
|
+
*
|
|
1886
|
+
* @example
|
|
1887
|
+
* ```typescript
|
|
1888
|
+
* const { code } = generator.generateMermaid({ asl: myStateMachine });
|
|
1889
|
+
* ```
|
|
1890
|
+
*/
|
|
1891
|
+
generateMermaid(params) {
|
|
1892
|
+
return generateMermaid({
|
|
1893
|
+
...params,
|
|
1894
|
+
...this.options
|
|
1895
|
+
});
|
|
1896
|
+
}
|
|
1897
|
+
/**
|
|
1898
|
+
* Export diagram as PNG
|
|
1899
|
+
*
|
|
1900
|
+
* @param params - Generation parameters
|
|
1901
|
+
* @param params.asl - ASL definition as an object or JSON string
|
|
1902
|
+
* @returns Promise resolving to PNG output
|
|
1903
|
+
*
|
|
1904
|
+
* @example
|
|
1905
|
+
* ```typescript
|
|
1906
|
+
* const { buffer } = await generator.exportPng({ asl: myStateMachine });
|
|
1907
|
+
* ```
|
|
1908
|
+
*/
|
|
1909
|
+
async exportPng(params) {
|
|
1910
|
+
return exportPng({
|
|
1911
|
+
...params,
|
|
1912
|
+
...this.options
|
|
1913
|
+
});
|
|
1914
|
+
}
|
|
1915
|
+
/**
|
|
1916
|
+
* Update the generator's default options (fluent interface)
|
|
1917
|
+
*
|
|
1918
|
+
* @param options - Partial options to merge with existing options
|
|
1919
|
+
* @returns This generator instance for method chaining
|
|
1920
|
+
*
|
|
1921
|
+
* @example
|
|
1922
|
+
* ```typescript
|
|
1923
|
+
* generator
|
|
1924
|
+
* .setOptions({ theme: 'dark' })
|
|
1925
|
+
* .setOptions({ layout: 'LR' });
|
|
1926
|
+
*
|
|
1927
|
+
* const result = generator.generateSvg({ asl: myAsl });
|
|
1928
|
+
* ```
|
|
1929
|
+
*/
|
|
1930
|
+
setOptions(options) {
|
|
1931
|
+
this.options = mergeOptions({
|
|
1932
|
+
...this.options,
|
|
1933
|
+
...options
|
|
1934
|
+
});
|
|
1935
|
+
return this;
|
|
1936
|
+
}
|
|
1937
|
+
};
|
|
1938
|
+
|
|
1939
|
+
//#endregion
|
|
1940
|
+
exports.AWS_DARK_THEME = AWS_DARK_THEME;
|
|
1941
|
+
exports.AWS_LIGHT_THEME = AWS_LIGHT_THEME;
|
|
1942
|
+
exports.AslValidationError = AslValidationError;
|
|
1943
|
+
exports.PngExporter = PngExporter;
|
|
1944
|
+
exports.SfnDiagramGenerator = SfnDiagramGenerator;
|
|
1945
|
+
exports.embedIcons = embedIcons;
|
|
1946
|
+
exports.exportPng = exportPng;
|
|
1947
|
+
exports.generateDiagram = generateDiagram;
|
|
1948
|
+
exports.generateFromAwsResponse = generateFromAwsResponse;
|
|
1949
|
+
exports.generateMermaid = generateMermaid;
|
|
1950
|
+
exports.generateSvg = generateSvg;
|
|
1951
|
+
exports.validateAsl = validateAsl;
|