@synergenius/flow-weaver 0.3.0 → 0.4.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.
Files changed (73) hide show
  1. package/README.md +1 -0
  2. package/dist/annotation-generator.js +36 -0
  3. package/dist/api/generate-in-place.js +39 -0
  4. package/dist/api/generate.js +11 -1
  5. package/dist/api/manipulation/nodes.js +22 -0
  6. package/dist/ast/types.d.ts +27 -1
  7. package/dist/built-in-nodes/index.d.ts +1 -0
  8. package/dist/built-in-nodes/index.js +1 -0
  9. package/dist/built-in-nodes/invoke-workflow.js +12 -1
  10. package/dist/built-in-nodes/mock-types.d.ts +2 -0
  11. package/dist/built-in-nodes/wait-for-agent.d.ts +13 -0
  12. package/dist/built-in-nodes/wait-for-agent.js +26 -0
  13. package/dist/chevrotain-parser/fan-parser.d.ts +38 -0
  14. package/dist/chevrotain-parser/fan-parser.js +149 -0
  15. package/dist/chevrotain-parser/grammar-diagrams.d.ts +1 -0
  16. package/dist/chevrotain-parser/grammar-diagrams.js +3 -0
  17. package/dist/chevrotain-parser/index.d.ts +3 -1
  18. package/dist/chevrotain-parser/index.js +3 -1
  19. package/dist/chevrotain-parser/tokens.d.ts +2 -0
  20. package/dist/chevrotain-parser/tokens.js +10 -0
  21. package/dist/cli/commands/diagram.d.ts +2 -1
  22. package/dist/cli/commands/diagram.js +9 -6
  23. package/dist/cli/commands/run.js +59 -1
  24. package/dist/cli/flow-weaver.mjs +1396 -77
  25. package/dist/cli/index.js +23 -36
  26. package/dist/diagram/geometry.js +47 -5
  27. package/dist/diagram/html-viewer.d.ts +12 -0
  28. package/dist/diagram/html-viewer.js +399 -0
  29. package/dist/diagram/index.d.ts +12 -0
  30. package/dist/diagram/index.js +22 -0
  31. package/dist/diagram/types.d.ts +1 -0
  32. package/dist/doc-metadata/extractors/annotations.js +282 -1
  33. package/dist/doc-metadata/types.d.ts +6 -0
  34. package/dist/generator/control-flow.d.ts +13 -0
  35. package/dist/generator/control-flow.js +74 -0
  36. package/dist/generator/inngest.js +23 -0
  37. package/dist/generator/unified.js +122 -2
  38. package/dist/jsdoc-parser.d.ts +24 -0
  39. package/dist/jsdoc-parser.js +41 -1
  40. package/dist/mcp/agent-channel.d.ts +35 -0
  41. package/dist/mcp/agent-channel.js +61 -0
  42. package/dist/mcp/run-registry.d.ts +29 -0
  43. package/dist/mcp/run-registry.js +24 -0
  44. package/dist/mcp/tools-diagram.d.ts +1 -1
  45. package/dist/mcp/tools-diagram.js +15 -7
  46. package/dist/mcp/tools-editor.js +75 -3
  47. package/dist/mcp/workflow-executor.d.ts +28 -0
  48. package/dist/mcp/workflow-executor.js +62 -1
  49. package/dist/parser.d.ts +8 -0
  50. package/dist/parser.js +100 -0
  51. package/dist/runtime/ExecutionContext.d.ts +2 -0
  52. package/dist/runtime/ExecutionContext.js +2 -0
  53. package/dist/runtime/events.d.ts +1 -1
  54. package/dist/sugar-optimizer.js +28 -3
  55. package/dist/validator.d.ts +8 -0
  56. package/dist/validator.js +92 -0
  57. package/docs/reference/advanced-annotations.md +431 -0
  58. package/docs/reference/built-in-nodes.md +225 -0
  59. package/docs/reference/cli-reference.md +882 -0
  60. package/docs/reference/compilation.md +351 -0
  61. package/docs/reference/concepts.md +400 -0
  62. package/docs/reference/debugging.md +255 -0
  63. package/docs/reference/deployment.md +207 -0
  64. package/docs/reference/error-codes.md +686 -0
  65. package/docs/reference/export-interface.md +229 -0
  66. package/docs/reference/iterative-development.md +186 -0
  67. package/docs/reference/jsdoc-grammar.md +471 -0
  68. package/docs/reference/marketplace.md +205 -0
  69. package/docs/reference/node-conversion.md +308 -0
  70. package/docs/reference/patterns.md +161 -0
  71. package/docs/reference/scaffold.md +160 -0
  72. package/docs/reference/tutorial.md +519 -0
  73. package/package.json +37 -1
package/dist/cli/index.js CHANGED
@@ -99,13 +99,14 @@ program
99
99
  // Diagram command
100
100
  program
101
101
  .command('diagram <input>')
102
- .description('Generate SVG diagram of a workflow')
102
+ .description('Generate SVG or interactive HTML diagram of a workflow')
103
103
  .option('-t, --theme <theme>', 'Color theme: dark (default), light', 'dark')
104
104
  .option('-w, --width <pixels>', 'SVG width in pixels')
105
105
  .option('-p, --padding <pixels>', 'Canvas padding in pixels')
106
106
  .option('--no-port-labels', 'Hide data type labels on ports')
107
107
  .option('--workflow-name <name>', 'Specific workflow to render')
108
- .option('-o, --output <file>', 'Write SVG to file instead of stdout')
108
+ .option('-f, --format <format>', 'Output format: svg (default), html (interactive viewer)', 'svg')
109
+ .option('-o, --output <file>', 'Write output to file instead of stdout')
109
110
  .action(async (input, options) => {
110
111
  try {
111
112
  if (options.width)
@@ -586,43 +587,28 @@ program
586
587
  process.exit(1);
587
588
  }
588
589
  });
589
- // Docs command group
590
- const docsCmd = program.command('docs').description('Browse reference documentation');
591
- docsCmd
592
- .command('list', { isDefault: true })
593
- .description('List available documentation topics')
594
- .option('--json', 'Output as JSON', false)
595
- .option('--compact', 'Compact output', false)
596
- .action(async (options) => {
597
- try {
598
- await docsListCommand(options);
599
- }
600
- catch (error) {
601
- logger.error(`Command failed: ${getErrorMessage(error)}`);
602
- process.exit(1);
603
- }
604
- });
605
- docsCmd
606
- .command('read <topic>')
607
- .description('Read a documentation topic')
590
+ // Docs command: flow-weaver docs [topic] | flow-weaver docs search <query>
591
+ program
592
+ .command('docs [args...]')
593
+ .description('Browse reference documentation')
608
594
  .option('--json', 'Output as JSON', false)
609
595
  .option('--compact', 'Return compact LLM-friendly version', false)
610
- .action(async (topic, options) => {
611
- try {
612
- await docsReadCommand(topic, options);
613
- }
614
- catch (error) {
615
- logger.error(`Command failed: ${getErrorMessage(error)}`);
616
- process.exit(1);
617
- }
618
- });
619
- docsCmd
620
- .command('search <query>')
621
- .description('Search across all documentation')
622
- .option('--json', 'Output as JSON', false)
623
- .action(async (query, options) => {
596
+ .action(async (args, options) => {
624
597
  try {
625
- await docsSearchCommand(query, options);
598
+ if (args.length === 0) {
599
+ await docsListCommand(options);
600
+ }
601
+ else if (args[0] === 'search') {
602
+ const query = args.slice(1).join(' ');
603
+ if (!query) {
604
+ logger.error('Usage: flow-weaver docs search <query>');
605
+ process.exit(1);
606
+ }
607
+ await docsSearchCommand(query, options);
608
+ }
609
+ else {
610
+ await docsReadCommand(args[0], options);
611
+ }
626
612
  }
627
613
  catch (error) {
628
614
  logger.error(`Command failed: ${getErrorMessage(error)}`);
@@ -727,6 +713,7 @@ program.on('--help', () => {
727
713
  logger.log(' $ flow-weaver describe workflow.ts --node validator');
728
714
  logger.log(' $ flow-weaver diagram workflow.ts');
729
715
  logger.log(' $ flow-weaver diagram workflow.ts --theme light -o diagram.svg');
716
+ logger.log(' $ flow-weaver diagram workflow.ts --format html -o diagram.html');
730
717
  logger.log(' $ flow-weaver diff workflow-v1.ts workflow-v2.ts');
731
718
  logger.log(' $ flow-weaver diff workflow-v1.ts workflow-v2.ts --format json');
732
719
  logger.log(" $ flow-weaver validate '**/*.ts'");
@@ -508,6 +508,11 @@ function layerInputExtent(layerNodes, diagramNodes) {
508
508
  * Gap between layers adapts to port label extents so labels never overlap,
509
509
  * while using LAYER_GAP_X as the target center-to-center distance.
510
510
  */
511
+ function adaptiveGapY(layerSize) {
512
+ if (layerSize <= 2)
513
+ return NODE_GAP_Y;
514
+ return Math.max(24, Math.round(NODE_GAP_Y * 2 / layerSize));
515
+ }
511
516
  function assignLayerCoordinates(layers, diagramNodes) {
512
517
  // Pre-compute filtered layers
513
518
  const filtered = layers.map(l => l.filter(id => diagramNodes.has(id)));
@@ -519,17 +524,18 @@ function assignLayerCoordinates(layers, diagramNodes) {
519
524
  continue;
520
525
  }
521
526
  const maxWidth = Math.max(...layerNodes.map(id => diagramNodes.get(id).width));
527
+ const gapY = adaptiveGapY(layerNodes.length);
522
528
  const totalHeight = layerNodes.reduce((sum, id) => {
523
529
  const n = diagramNodes.get(id);
524
530
  return sum + n.height + LABEL_HEIGHT + LABEL_GAP;
525
- }, 0) + (layerNodes.length - 1) * NODE_GAP_Y;
531
+ }, 0) + (layerNodes.length - 1) * gapY;
526
532
  let currentY = -totalHeight / 2;
527
533
  for (const id of layerNodes) {
528
534
  const node = diagramNodes.get(id);
529
535
  currentY += LABEL_HEIGHT + LABEL_GAP;
530
536
  node.x = currentX + (maxWidth - node.width) / 2;
531
537
  node.y = currentY;
532
- currentY += node.height + NODE_GAP_Y;
538
+ currentY += node.height + gapY;
533
539
  }
534
540
  // Compute label-aware edge gap to the next layer
535
541
  const nextLayerNodes = filtered[i + 1];
@@ -570,17 +576,18 @@ function assignUnpositionedNodes(layers, diagramNodes, explicitPositions) {
570
576
  const unpositioned = layerNodes.filter(id => !explicitPositions.has(id));
571
577
  const maxWidth = Math.max(...layerNodes.map(id => diagramNodes.get(id).width));
572
578
  if (unpositioned.length > 0) {
579
+ const gapY = adaptiveGapY(unpositioned.length);
573
580
  const totalHeight = unpositioned.reduce((sum, id) => {
574
581
  const n = diagramNodes.get(id);
575
582
  return sum + n.height + LABEL_HEIGHT + LABEL_GAP;
576
- }, 0) + (unpositioned.length - 1) * NODE_GAP_Y;
583
+ }, 0) + (unpositioned.length - 1) * gapY;
577
584
  let currentY = -totalHeight / 2;
578
585
  for (const id of unpositioned) {
579
586
  const node = diagramNodes.get(id);
580
587
  currentY += LABEL_HEIGHT + LABEL_GAP;
581
588
  node.x = currentX + (maxWidth - node.width) / 2;
582
589
  node.y = currentY;
583
- currentY += node.height + NODE_GAP_Y;
590
+ currentY += node.height + gapY;
584
591
  }
585
592
  }
586
593
  // Label-aware edge gap
@@ -896,6 +903,40 @@ export function buildDiagramGraph(ast, options = {}) {
896
903
  });
897
904
  // Create a single TrackAllocator for deterministic batch routing
898
905
  const allocator = new TrackAllocator();
906
+ // Pre-compute routing mode consistency for fan-out and fan-in:
907
+ // When multiple connections share the same source port (fan-out), target
908
+ // port (fan-in), or target node (multi-port fan-in), use the same routing
909
+ // mode for visual consistency. If ANY connection in the group would use
910
+ // curves, force ALL connections in the group to curves.
911
+ const srcPortKey = (pc) => `srcPort:${pc.fromNodeId}.${pc.fromPortName}`;
912
+ const tgtPortKey = (pc) => `tgtPort:${pc.toNodeId}.${pc.toPortName}`;
913
+ const tgtNodeKey = (pc) => `tgtNode:${pc.toNodeId}`;
914
+ const routingGroups = new Map();
915
+ for (const pc of pendingConnections) {
916
+ for (const key of [srcPortKey(pc), tgtPortKey(pc), tgtNodeKey(pc)]) {
917
+ if (!routingGroups.has(key))
918
+ routingGroups.set(key, []);
919
+ routingGroups.get(key).push(pc);
920
+ }
921
+ }
922
+ const forceCurveKeys = new Set();
923
+ for (const [key, group] of routingGroups) {
924
+ if (group.length < 2)
925
+ continue;
926
+ const anyShort = group.some(pc => {
927
+ const dx = pc.targetPort.cx - pc.sourcePort.cx;
928
+ const dy = pc.targetPort.cy - pc.sourcePort.cy;
929
+ return Math.sqrt(dx * dx + dy * dy) <= ORTHOGONAL_DISTANCE_THRESHOLD;
930
+ });
931
+ if (anyShort)
932
+ forceCurveKeys.add(key);
933
+ }
934
+ const forceCurveSet = new Set();
935
+ for (const pc of pendingConnections) {
936
+ if (forceCurveKeys.has(srcPortKey(pc)) || forceCurveKeys.has(tgtPortKey(pc)) || forceCurveKeys.has(tgtNodeKey(pc))) {
937
+ forceCurveSet.add(pc);
938
+ }
939
+ }
899
940
  // Compute all connection paths with routing mode selection
900
941
  const connections = [];
901
942
  for (const pc of pendingConnections) {
@@ -906,8 +947,9 @@ export function buildDiagramGraph(ast, options = {}) {
906
947
  const dx = tx - sx;
907
948
  const dy = ty - sy;
908
949
  const distance = Math.sqrt(dx * dx + dy * dy);
950
+ const useCurve = forceCurveSet.has(pc);
909
951
  let path;
910
- if (distance > ORTHOGONAL_DISTANCE_THRESHOLD) {
952
+ if (!useCurve && distance > ORTHOGONAL_DISTANCE_THRESHOLD) {
911
953
  // Try orthogonal routing for long-distance connections
912
954
  const orthoPath = calculateOrthogonalPathSafe([sx, sy], [tx, ty], nodeBoxes, pc.fromNodeId, pc.toNodeId, { fromPortIndex: pc.fromPortIndex, toPortIndex: pc.toPortIndex }, allocator);
913
955
  path = orthoPath ?? computeConnectionPath(sx, sy, tx, ty);
@@ -0,0 +1,12 @@
1
+ /**
2
+ * HTML Viewer — wraps an SVG diagram in a self-contained interactive HTML page.
3
+ *
4
+ * Provides zoom/pan, fit-to-view, hover effects, click-to-inspect, and connection tracing.
5
+ * No external dependencies — works standalone or inside an iframe.
6
+ */
7
+ export interface HtmlViewerOptions {
8
+ title?: string;
9
+ theme?: 'dark' | 'light';
10
+ }
11
+ export declare function wrapSVGInHTML(svgContent: string, options?: HtmlViewerOptions): string;
12
+ //# sourceMappingURL=html-viewer.d.ts.map
@@ -0,0 +1,399 @@
1
+ /**
2
+ * HTML Viewer — wraps an SVG diagram in a self-contained interactive HTML page.
3
+ *
4
+ * Provides zoom/pan, fit-to-view, hover effects, click-to-inspect, and connection tracing.
5
+ * No external dependencies — works standalone or inside an iframe.
6
+ */
7
+ /** Strip the SVG background rects so the HTML page controls the background. */
8
+ function stripSvgBackground(svg) {
9
+ let result = svg.replace(/<pattern\s+id="dot-grid"[^>]*>[\s\S]*?<\/pattern>/g, '');
10
+ result = result.replace(/<rect[^>]*fill="url\(#dot-grid\)"[^>]*\/>/g, '');
11
+ // Remove the solid background rect (first rect after </defs>)
12
+ result = result.replace(/(<\/defs>\n)<rect[^>]*\/>\n/, '$1');
13
+ return result;
14
+ }
15
+ export function wrapSVGInHTML(svgContent, options = {}) {
16
+ const title = options.title ?? 'Workflow Diagram';
17
+ const theme = options.theme ?? 'dark';
18
+ const svg = stripSvgBackground(svgContent);
19
+ const isDark = theme === 'dark';
20
+ const bg = isDark ? '#202139' : '#f6f7ff';
21
+ const dotColor = isDark ? 'rgba(142, 158, 255, 0.6)' : 'rgba(84, 104, 255, 0.6)';
22
+ const surfaceMain = isDark ? '#1a1a2e' : '#ffffff';
23
+ const borderSubtle = isDark ? '#313143' : '#e6e6e6';
24
+ const textHigh = isDark ? '#e8e8ee' : '#1a1a2e';
25
+ const textMed = isDark ? '#babac0' : '#606060';
26
+ const textLow = isDark ? '#767682' : '#999999';
27
+ const surfaceHigh = isDark ? '#313143' : '#f0f0f5';
28
+ return `<!DOCTYPE html>
29
+ <html lang="en">
30
+ <head>
31
+ <meta charset="utf-8">
32
+ <meta name="viewport" content="width=device-width, initial-scale=1">
33
+ <title>${escapeHtml(title)} — Flow Weaver</title>
34
+ <style>
35
+ *, *::before, *::after { margin: 0; padding: 0; box-sizing: border-box; }
36
+
37
+ body {
38
+ width: 100vw; height: 100vh; overflow: hidden;
39
+ background: ${bg};
40
+ font-family: Montserrat, 'Segoe UI', Roboto, sans-serif;
41
+ color: ${textHigh};
42
+ }
43
+
44
+ #viewport {
45
+ width: 100%; height: 100%;
46
+ overflow: hidden; cursor: grab;
47
+ touch-action: none; user-select: none;
48
+ background-image: radial-gradient(circle, ${dotColor} 7.5%, transparent 7.5%);
49
+ background-size: 20px 20px;
50
+ }
51
+ #viewport.dragging { cursor: grabbing; }
52
+
53
+ #content {
54
+ transform-origin: 0 0;
55
+ will-change: transform;
56
+ }
57
+ #content svg { display: block; width: auto; height: auto; }
58
+
59
+ /* Port labels: hidden by default, shown on node hover */
60
+ .nodes > g .port-label,
61
+ .nodes > g .port-type-label,
62
+ .labels g[data-port-label] {
63
+ opacity: 0; pointer-events: none;
64
+ transition: opacity 0.15s ease-in-out;
65
+ }
66
+ /* Show port labels for hovered node */
67
+ .nodes > g:hover ~ .show-port-labels .port-label,
68
+ .nodes > g:hover ~ .show-port-labels .port-type-label { opacity: 1; }
69
+
70
+ /* Connection hover & dimming */
71
+ .connections path { transition: opacity 0.2s ease, stroke-width 0.15s ease; }
72
+ .connections path:hover { stroke-width: 4; cursor: pointer; }
73
+ body.node-active .connections path.dimmed { opacity: 0.15; }
74
+
75
+ /* Node hover glow */
76
+ .nodes > g:hover rect:first-of-type { filter: brightness(1.08); }
77
+
78
+ /* Zoom controls */
79
+ #controls {
80
+ position: fixed; bottom: 16px; right: 16px;
81
+ display: flex; align-items: center; gap: 2px;
82
+ background: ${surfaceMain}; border: 1px solid ${borderSubtle};
83
+ border-radius: 8px; padding: 4px; z-index: 10;
84
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
85
+ }
86
+ .ctrl-btn {
87
+ display: flex; align-items: center; justify-content: center;
88
+ width: 28px; height: 28px; border: none; border-radius: 6px;
89
+ background: transparent; color: ${textMed};
90
+ font-size: 16px; font-weight: 600; cursor: pointer;
91
+ transition: background 0.15s, color 0.15s;
92
+ }
93
+ .ctrl-btn:hover { background: ${surfaceHigh}; color: ${textHigh}; }
94
+ #zoom-label {
95
+ font-size: 11px; font-family: 'SF Mono', 'Fira Code', monospace;
96
+ color: ${textLow}; min-width: 36px; text-align: center;
97
+ }
98
+
99
+ /* Info panel */
100
+ #info-panel {
101
+ position: fixed; bottom: 16px; left: 16px;
102
+ max-width: 320px; min-width: 200px;
103
+ background: ${surfaceMain}; border: 1px solid ${borderSubtle};
104
+ border-radius: 8px; padding: 12px 16px;
105
+ font-size: 13px; line-height: 1.5;
106
+ box-shadow: 0 2px 8px rgba(0,0,0,0.2);
107
+ z-index: 10; display: none;
108
+ }
109
+ #info-panel.visible { display: block; }
110
+ #info-panel h3 {
111
+ font-size: 14px; font-weight: 700; margin-bottom: 6px;
112
+ white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
113
+ }
114
+ #info-panel .info-section { margin-bottom: 6px; }
115
+ #info-panel .info-label { font-size: 11px; font-weight: 600; color: ${textLow}; text-transform: uppercase; letter-spacing: 0.5px; }
116
+ #info-panel .info-value { color: ${textMed}; }
117
+ #info-panel .port-list { list-style: none; padding: 0; }
118
+ #info-panel .port-list li { padding: 1px 0; }
119
+ #info-panel .port-list li::before { content: '\\2022'; margin-right: 6px; color: ${textLow}; }
120
+
121
+ /* Scroll hint */
122
+ #scroll-hint {
123
+ position: fixed; top: 50%; left: 50%;
124
+ transform: translate(-50%, -50%);
125
+ background: rgba(0,0,0,0.75); color: #fff;
126
+ padding: 6px 14px; border-radius: 8px;
127
+ font-size: 13px; pointer-events: none;
128
+ z-index: 20; opacity: 0; transition: opacity 0.3s;
129
+ }
130
+ #scroll-hint.visible { opacity: 1; }
131
+ #scroll-hint kbd {
132
+ display: inline-block; padding: 1px 5px;
133
+ border: 1px solid rgba(255,255,255,0.3);
134
+ border-radius: 3px; font-family: 'SF Mono', 'Fira Code', monospace;
135
+ font-size: 12px; background: rgba(255,255,255,0.1);
136
+ }
137
+ </style>
138
+ </head>
139
+ <body>
140
+ <div id="viewport">
141
+ <div id="content">${svg}</div>
142
+ </div>
143
+ <div id="controls">
144
+ <button class="ctrl-btn" id="btn-in" title="Zoom in" aria-label="Zoom in">+</button>
145
+ <span id="zoom-label">100%</span>
146
+ <button class="ctrl-btn" id="btn-out" title="Zoom out" aria-label="Zoom out">&minus;</button>
147
+ <button class="ctrl-btn" id="btn-fit" title="Fit to view" aria-label="Fit to view">
148
+ <svg width="14" height="14" viewBox="0 0 14 14" fill="none" stroke="currentColor" stroke-width="1.5">
149
+ <rect x="1" y="1" width="12" height="12" rx="2"/>
150
+ <path d="M1 5h12M1 9h12M5 1v12M9 1v12" opacity="0.4"/>
151
+ </svg>
152
+ </button>
153
+ </div>
154
+ <div id="info-panel">
155
+ <h3 id="info-title"></h3>
156
+ <div id="info-body"></div>
157
+ </div>
158
+ <div id="scroll-hint">Use <kbd id="mod-key">Ctrl</kbd> + scroll to zoom</div>
159
+ <script>
160
+ (function() {
161
+ 'use strict';
162
+
163
+ var MIN_ZOOM = 0.25, MAX_ZOOM = 3, GRID_SIZE = 20;
164
+ var viewport = document.getElementById('viewport');
165
+ var content = document.getElementById('content');
166
+ var zoomLabel = document.getElementById('zoom-label');
167
+ var infoPanel = document.getElementById('info-panel');
168
+ var infoTitle = document.getElementById('info-title');
169
+ var infoBody = document.getElementById('info-body');
170
+ var scrollHint = document.getElementById('scroll-hint');
171
+
172
+ var scale = 1, tx = 0, ty = 0;
173
+ var dragging = false, dragLast = { x: 0, y: 0 };
174
+ var selectedNodeId = null;
175
+ var hintTimer = null;
176
+
177
+ // Detect Mac for modifier key
178
+ var isMac = /Mac/.test(navigator.userAgent);
179
+ document.getElementById('mod-key').textContent = isMac ? '\\u2318' : 'Ctrl';
180
+
181
+ function clamp(v, min, max) { return Math.min(max, Math.max(min, v)); }
182
+
183
+ function applyTransform() {
184
+ content.style.transform = 'translate(' + tx + 'px,' + ty + 'px) scale(' + scale + ')';
185
+ viewport.style.backgroundSize = (GRID_SIZE * scale) + 'px ' + (GRID_SIZE * scale) + 'px';
186
+ viewport.style.backgroundPosition = tx + 'px ' + ty + 'px';
187
+ zoomLabel.textContent = Math.round(scale * 100) + '%';
188
+ }
189
+
190
+ function fitToView() {
191
+ var svgEl = content.querySelector('svg');
192
+ if (!svgEl) { scale = 1; tx = 0; ty = 0; applyTransform(); return; }
193
+ var ww = viewport.clientWidth, wh = viewport.clientHeight;
194
+ var sw = svgEl.width.baseVal.value || svgEl.getBoundingClientRect().width;
195
+ var sh = svgEl.height.baseVal.value || svgEl.getBoundingClientRect().height;
196
+ var padding = 60;
197
+ var fitScale = Math.min((ww - padding) / sw, (wh - padding) / sh, 1);
198
+ scale = fitScale;
199
+ tx = (ww - sw * fitScale) / 2;
200
+ ty = (wh - sh * fitScale) / 2;
201
+ applyTransform();
202
+ }
203
+
204
+ // ---- Zoom (Ctrl/Cmd + scroll) ----
205
+ viewport.addEventListener('wheel', function(e) {
206
+ if (!e.ctrlKey && !e.metaKey) {
207
+ scrollHint.classList.add('visible');
208
+ clearTimeout(hintTimer);
209
+ hintTimer = setTimeout(function() { scrollHint.classList.remove('visible'); }, 1500);
210
+ return;
211
+ }
212
+ e.preventDefault();
213
+ var rect = viewport.getBoundingClientRect();
214
+ var cx = e.clientX - rect.left, cy = e.clientY - rect.top;
215
+ var oldScale = scale;
216
+ var delta = clamp(e.deltaY, -10, 10);
217
+ var newScale = clamp(oldScale - delta * 0.005, MIN_ZOOM, MAX_ZOOM);
218
+ var contentX = (cx - tx) / oldScale, contentY = (cy - ty) / oldScale;
219
+ tx = cx - contentX * newScale;
220
+ ty = cy - contentY * newScale;
221
+ scale = newScale;
222
+ applyTransform();
223
+ }, { passive: false });
224
+
225
+ // ---- Pan (drag) ----
226
+ viewport.addEventListener('pointerdown', function(e) {
227
+ if (e.button !== 0) return;
228
+ dragging = true;
229
+ dragLast = { x: e.clientX, y: e.clientY };
230
+ viewport.setPointerCapture(e.pointerId);
231
+ viewport.classList.add('dragging');
232
+ });
233
+ viewport.addEventListener('pointermove', function(e) {
234
+ if (!dragging) return;
235
+ tx += e.clientX - dragLast.x;
236
+ ty += e.clientY - dragLast.y;
237
+ dragLast = { x: e.clientX, y: e.clientY };
238
+ applyTransform();
239
+ });
240
+ function endDrag() { dragging = false; viewport.classList.remove('dragging'); }
241
+ viewport.addEventListener('pointerup', endDrag);
242
+ viewport.addEventListener('pointercancel', endDrag);
243
+
244
+ // ---- Zoom buttons ----
245
+ function zoomBy(dir) {
246
+ var rect = viewport.getBoundingClientRect();
247
+ var cx = rect.width / 2, cy = rect.height / 2;
248
+ var oldScale = scale;
249
+ var newScale = clamp(oldScale + dir * 0.15 * oldScale, MIN_ZOOM, MAX_ZOOM);
250
+ var contentX = (cx - tx) / oldScale, contentY = (cy - ty) / oldScale;
251
+ tx = cx - contentX * newScale;
252
+ ty = cy - contentY * newScale;
253
+ scale = newScale;
254
+ applyTransform();
255
+ }
256
+ document.getElementById('btn-in').addEventListener('click', function() { zoomBy(1); });
257
+ document.getElementById('btn-out').addEventListener('click', function() { zoomBy(-1); });
258
+ document.getElementById('btn-fit').addEventListener('click', fitToView);
259
+
260
+ // ---- Keyboard shortcuts ----
261
+ document.addEventListener('keydown', function(e) {
262
+ if (e.target.tagName === 'INPUT' || e.target.tagName === 'TEXTAREA') return;
263
+ if (e.key === '+' || e.key === '=') zoomBy(1);
264
+ else if (e.key === '-') zoomBy(-1);
265
+ else if (e.key === '0') fitToView();
266
+ else if (e.key === 'Escape') deselectNode();
267
+ });
268
+
269
+ // ---- Port label visibility via JS (since CSS sibling selectors can't reach .labels group) ----
270
+ var labelEls = content.querySelectorAll('.labels g[data-port-label]');
271
+ var nodeEls = content.querySelectorAll('.nodes > g[data-node-id]');
272
+
273
+ nodeEls.forEach(function(nodeG) {
274
+ var nodeId = nodeG.getAttribute('data-node-id');
275
+ nodeG.addEventListener('mouseenter', function() {
276
+ labelEls.forEach(function(lbl) {
277
+ var portId = lbl.getAttribute('data-port-label') || '';
278
+ if (portId.indexOf(nodeId + '.') === 0) {
279
+ lbl.style.opacity = '1';
280
+ lbl.style.pointerEvents = 'auto';
281
+ }
282
+ });
283
+ });
284
+ nodeG.addEventListener('mouseleave', function() {
285
+ labelEls.forEach(function(lbl) {
286
+ var portId = lbl.getAttribute('data-port-label') || '';
287
+ if (portId.indexOf(nodeId + '.') === 0) {
288
+ lbl.style.opacity = '0';
289
+ lbl.style.pointerEvents = 'none';
290
+ }
291
+ });
292
+ });
293
+ });
294
+
295
+ // ---- Click to inspect node ----
296
+ function deselectNode() {
297
+ selectedNodeId = null;
298
+ document.body.classList.remove('node-active');
299
+ infoPanel.classList.remove('visible');
300
+ content.querySelectorAll('.connections path.dimmed').forEach(function(p) {
301
+ p.classList.remove('dimmed');
302
+ });
303
+ }
304
+
305
+ function selectNode(nodeId) {
306
+ if (selectedNodeId === nodeId) { deselectNode(); return; }
307
+ selectedNodeId = nodeId;
308
+ document.body.classList.add('node-active');
309
+
310
+ // Gather info
311
+ var nodeG = content.querySelector('[data-node-id="' + CSS.escape(nodeId) + '"]');
312
+ var labelG = content.querySelector('[data-label-for="' + CSS.escape(nodeId) + '"]');
313
+ var labelText = labelG ? (labelG.querySelector('.node-label') || {}).textContent || nodeId : nodeId;
314
+
315
+ // Ports
316
+ var ports = content.querySelectorAll('[data-port-id^="' + CSS.escape(nodeId) + '."]');
317
+ var inputs = [], outputs = [];
318
+ ports.forEach(function(p) {
319
+ var id = p.getAttribute('data-port-id');
320
+ var dir = p.getAttribute('data-direction');
321
+ var name = id.split('.').slice(1).join('.');
322
+ if (dir === 'input') inputs.push(name);
323
+ else outputs.push(name);
324
+ });
325
+
326
+ // Connected paths
327
+ var allPaths = content.querySelectorAll('.connections path');
328
+ var connectedPaths = [];
329
+ var connectedNodes = new Set();
330
+ allPaths.forEach(function(p) {
331
+ var src = p.getAttribute('data-source') || '';
332
+ var tgt = p.getAttribute('data-target') || '';
333
+ var srcNode = src.split('.')[0];
334
+ var tgtNode = tgt.split('.')[0];
335
+ if (srcNode === nodeId || tgtNode === nodeId) {
336
+ connectedPaths.push(p);
337
+ if (srcNode !== nodeId) connectedNodes.add(srcNode);
338
+ if (tgtNode !== nodeId) connectedNodes.add(tgtNode);
339
+ p.classList.remove('dimmed');
340
+ } else {
341
+ p.classList.add('dimmed');
342
+ }
343
+ });
344
+
345
+ // Build info panel
346
+ infoTitle.textContent = labelText;
347
+ var html = '';
348
+ if (inputs.length) {
349
+ html += '<div class="info-section"><div class="info-label">Inputs</div><ul class="port-list">';
350
+ inputs.forEach(function(n) { html += '<li>' + escapeH(n) + '</li>'; });
351
+ html += '</ul></div>';
352
+ }
353
+ if (outputs.length) {
354
+ html += '<div class="info-section"><div class="info-label">Outputs</div><ul class="port-list">';
355
+ outputs.forEach(function(n) { html += '<li>' + escapeH(n) + '</li>'; });
356
+ html += '</ul></div>';
357
+ }
358
+ if (connectedNodes.size) {
359
+ html += '<div class="info-section"><div class="info-label">Connected to</div><div class="info-value">';
360
+ html += Array.from(connectedNodes).map(escapeH).join(', ');
361
+ html += '</div></div>';
362
+ }
363
+ infoBody.innerHTML = html;
364
+ infoPanel.classList.add('visible');
365
+ }
366
+
367
+ function escapeH(s) {
368
+ return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
369
+ }
370
+
371
+ // Delegate click on node groups
372
+ viewport.addEventListener('click', function(e) {
373
+ if (dragging) return;
374
+ var target = e.target;
375
+ // Walk up to find a [data-node-id] ancestor within #content
376
+ while (target && target !== viewport) {
377
+ if (target.hasAttribute && target.hasAttribute('data-node-id')) {
378
+ e.stopPropagation();
379
+ selectNode(target.getAttribute('data-node-id'));
380
+ return;
381
+ }
382
+ target = target.parentElement;
383
+ }
384
+ // Clicked on background
385
+ deselectNode();
386
+ });
387
+
388
+ // ---- Init ----
389
+ requestAnimationFrame(fitToView);
390
+ window.addEventListener('resize', fitToView);
391
+ })();
392
+ </script>
393
+ </body>
394
+ </html>`;
395
+ }
396
+ function escapeHtml(s) {
397
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
398
+ }
399
+ //# sourceMappingURL=html-viewer.js.map
@@ -13,4 +13,16 @@ export declare function sourceToSVG(code: string, options?: DiagramOptions): str
13
13
  * Parse a workflow file (resolves imports) and render the first (or named) workflow to SVG.
14
14
  */
15
15
  export declare function fileToSVG(filePath: string, options?: DiagramOptions): string;
16
+ /**
17
+ * Render a workflow AST to a self-contained interactive HTML page.
18
+ */
19
+ export declare function workflowToHTML(ast: TWorkflowAST, options?: DiagramOptions): string;
20
+ /**
21
+ * Parse TypeScript source code and render the first (or named) workflow to interactive HTML.
22
+ */
23
+ export declare function sourceToHTML(code: string, options?: DiagramOptions): string;
24
+ /**
25
+ * Parse a workflow file and render the first (or named) workflow to interactive HTML.
26
+ */
27
+ export declare function fileToHTML(filePath: string, options?: DiagramOptions): string;
16
28
  //# sourceMappingURL=index.d.ts.map
@@ -1,6 +1,7 @@
1
1
  import { parser } from '../parser.js';
2
2
  import { buildDiagramGraph } from './geometry.js';
3
3
  import { renderSVG } from './renderer.js';
4
+ import { wrapSVGInHTML } from './html-viewer.js';
4
5
  /**
5
6
  * Render a workflow AST to an SVG string.
6
7
  */
@@ -22,6 +23,27 @@ export function fileToSVG(filePath, options = {}) {
22
23
  const result = parser.parse(filePath);
23
24
  return pickAndRender(result.workflows, options);
24
25
  }
26
+ /**
27
+ * Render a workflow AST to a self-contained interactive HTML page.
28
+ */
29
+ export function workflowToHTML(ast, options = {}) {
30
+ const svg = workflowToSVG(ast, options);
31
+ return wrapSVGInHTML(svg, { title: options.workflowName ?? ast.name, theme: options.theme });
32
+ }
33
+ /**
34
+ * Parse TypeScript source code and render the first (or named) workflow to interactive HTML.
35
+ */
36
+ export function sourceToHTML(code, options = {}) {
37
+ const svg = sourceToSVG(code, options);
38
+ return wrapSVGInHTML(svg, { title: options.workflowName, theme: options.theme });
39
+ }
40
+ /**
41
+ * Parse a workflow file and render the first (or named) workflow to interactive HTML.
42
+ */
43
+ export function fileToHTML(filePath, options = {}) {
44
+ const svg = fileToSVG(filePath, options);
45
+ return wrapSVGInHTML(svg, { title: options.workflowName, theme: options.theme });
46
+ }
25
47
  function pickAndRender(workflows, options) {
26
48
  if (workflows.length === 0) {
27
49
  throw new Error('No workflows found in source code');
@@ -5,6 +5,7 @@ export interface DiagramOptions {
5
5
  padding?: number;
6
6
  showPortLabels?: boolean;
7
7
  workflowName?: string;
8
+ format?: 'svg' | 'html';
8
9
  }
9
10
  export interface DiagramPort {
10
11
  name: string;