@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.
- package/README.md +1 -0
- package/dist/annotation-generator.js +36 -0
- package/dist/api/generate-in-place.js +39 -0
- package/dist/api/generate.js +11 -1
- package/dist/api/manipulation/nodes.js +22 -0
- package/dist/ast/types.d.ts +27 -1
- package/dist/built-in-nodes/index.d.ts +1 -0
- package/dist/built-in-nodes/index.js +1 -0
- package/dist/built-in-nodes/invoke-workflow.js +12 -1
- package/dist/built-in-nodes/mock-types.d.ts +2 -0
- package/dist/built-in-nodes/wait-for-agent.d.ts +13 -0
- package/dist/built-in-nodes/wait-for-agent.js +26 -0
- package/dist/chevrotain-parser/fan-parser.d.ts +38 -0
- package/dist/chevrotain-parser/fan-parser.js +149 -0
- package/dist/chevrotain-parser/grammar-diagrams.d.ts +1 -0
- package/dist/chevrotain-parser/grammar-diagrams.js +3 -0
- package/dist/chevrotain-parser/index.d.ts +3 -1
- package/dist/chevrotain-parser/index.js +3 -1
- package/dist/chevrotain-parser/tokens.d.ts +2 -0
- package/dist/chevrotain-parser/tokens.js +10 -0
- package/dist/cli/commands/diagram.d.ts +2 -1
- package/dist/cli/commands/diagram.js +9 -6
- package/dist/cli/commands/run.js +59 -1
- package/dist/cli/flow-weaver.mjs +1396 -77
- package/dist/cli/index.js +23 -36
- package/dist/diagram/geometry.js +47 -5
- package/dist/diagram/html-viewer.d.ts +12 -0
- package/dist/diagram/html-viewer.js +399 -0
- package/dist/diagram/index.d.ts +12 -0
- package/dist/diagram/index.js +22 -0
- package/dist/diagram/types.d.ts +1 -0
- package/dist/doc-metadata/extractors/annotations.js +282 -1
- package/dist/doc-metadata/types.d.ts +6 -0
- package/dist/generator/control-flow.d.ts +13 -0
- package/dist/generator/control-flow.js +74 -0
- package/dist/generator/inngest.js +23 -0
- package/dist/generator/unified.js +122 -2
- package/dist/jsdoc-parser.d.ts +24 -0
- package/dist/jsdoc-parser.js +41 -1
- package/dist/mcp/agent-channel.d.ts +35 -0
- package/dist/mcp/agent-channel.js +61 -0
- package/dist/mcp/run-registry.d.ts +29 -0
- package/dist/mcp/run-registry.js +24 -0
- package/dist/mcp/tools-diagram.d.ts +1 -1
- package/dist/mcp/tools-diagram.js +15 -7
- package/dist/mcp/tools-editor.js +75 -3
- package/dist/mcp/workflow-executor.d.ts +28 -0
- package/dist/mcp/workflow-executor.js +62 -1
- package/dist/parser.d.ts +8 -0
- package/dist/parser.js +100 -0
- package/dist/runtime/ExecutionContext.d.ts +2 -0
- package/dist/runtime/ExecutionContext.js +2 -0
- package/dist/runtime/events.d.ts +1 -1
- package/dist/sugar-optimizer.js +28 -3
- package/dist/validator.d.ts +8 -0
- package/dist/validator.js +92 -0
- package/docs/reference/advanced-annotations.md +431 -0
- package/docs/reference/built-in-nodes.md +225 -0
- package/docs/reference/cli-reference.md +882 -0
- package/docs/reference/compilation.md +351 -0
- package/docs/reference/concepts.md +400 -0
- package/docs/reference/debugging.md +255 -0
- package/docs/reference/deployment.md +207 -0
- package/docs/reference/error-codes.md +686 -0
- package/docs/reference/export-interface.md +229 -0
- package/docs/reference/iterative-development.md +186 -0
- package/docs/reference/jsdoc-grammar.md +471 -0
- package/docs/reference/marketplace.md +205 -0
- package/docs/reference/node-conversion.md +308 -0
- package/docs/reference/patterns.md +161 -0
- package/docs/reference/scaffold.md +160 -0
- package/docs/reference/tutorial.md +519 -0
- 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('-
|
|
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
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
.
|
|
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 (
|
|
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
|
-
|
|
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'");
|
package/dist/diagram/geometry.js
CHANGED
|
@@ -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) *
|
|
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 +
|
|
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) *
|
|
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 +
|
|
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">−</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,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
|
398
|
+
}
|
|
399
|
+
//# sourceMappingURL=html-viewer.js.map
|
package/dist/diagram/index.d.ts
CHANGED
|
@@ -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
|
package/dist/diagram/index.js
CHANGED
|
@@ -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');
|