@synergenius/flow-weaver 0.10.2 → 0.10.4
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/dist/ast/types.d.ts +2 -0
- package/dist/chevrotain-parser/connect-parser.d.ts +5 -0
- package/dist/chevrotain-parser/connect-parser.js +24 -4
- package/dist/cli/flow-weaver.mjs +401 -35
- package/dist/diagram/html-viewer.d.ts +8 -0
- package/dist/diagram/html-viewer.js +170 -7
- package/dist/diagram/index.js +48 -12
- package/dist/friendly-errors.js +45 -10
- package/dist/generator/code-utils.d.ts +12 -1
- package/dist/generator/code-utils.js +88 -3
- package/dist/jsdoc-parser.js +2 -1
- package/dist/validator.js +56 -0
- package/package.json +1 -1
|
@@ -8,6 +8,14 @@
|
|
|
8
8
|
export interface HtmlViewerOptions {
|
|
9
9
|
title?: string;
|
|
10
10
|
theme?: 'dark' | 'light';
|
|
11
|
+
nodeSources?: Record<string, {
|
|
12
|
+
description?: string;
|
|
13
|
+
source?: string;
|
|
14
|
+
ports?: Record<string, {
|
|
15
|
+
type: string;
|
|
16
|
+
tsType?: string;
|
|
17
|
+
}>;
|
|
18
|
+
}>;
|
|
11
19
|
}
|
|
12
20
|
export declare function wrapSVGInHTML(svgContent: string, options?: HtmlViewerOptions): string;
|
|
13
21
|
//# sourceMappingURL=html-viewer.d.ts.map
|
|
@@ -122,24 +122,46 @@ path[data-source].port-hover { opacity: 1; }
|
|
|
122
122
|
/* Info panel */
|
|
123
123
|
#info-panel {
|
|
124
124
|
position: fixed; bottom: 52px; left: 16px;
|
|
125
|
-
max-width:
|
|
125
|
+
max-width: 480px; min-width: 260px;
|
|
126
126
|
background: ${surfaceMain}; border: 1px solid ${borderSubtle};
|
|
127
127
|
border-radius: 8px; padding: 12px 16px;
|
|
128
128
|
font-size: 13px; line-height: 1.5;
|
|
129
129
|
box-shadow: 0 2px 8px rgba(0,0,0,0.2);
|
|
130
130
|
z-index: 10; display: none;
|
|
131
|
+
max-height: calc(100vh - 120px); overflow-y: auto;
|
|
131
132
|
}
|
|
132
133
|
#info-panel.visible { display: block; }
|
|
133
134
|
#info-panel h3 {
|
|
134
135
|
font-size: 14px; font-weight: 700; margin-bottom: 6px;
|
|
135
136
|
white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
|
|
136
137
|
}
|
|
138
|
+
#info-panel .node-desc { color: ${textMed}; font-size: 12px; margin-bottom: 8px; font-style: italic; }
|
|
137
139
|
#info-panel .info-section { margin-bottom: 6px; }
|
|
138
140
|
#info-panel .info-label { font-size: 11px; font-weight: 600; color: ${textLow}; text-transform: uppercase; letter-spacing: 0.5px; }
|
|
139
141
|
#info-panel .info-value { color: ${textMed}; }
|
|
140
142
|
#info-panel .port-list { list-style: none; padding: 0; }
|
|
141
143
|
#info-panel .port-list li { padding: 1px 0; }
|
|
142
144
|
#info-panel .port-list li::before { content: '\\2022'; margin-right: 6px; color: ${textLow}; }
|
|
145
|
+
#info-panel .port-type { color: ${textLow}; font-size: 11px; margin-left: 4px; font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace; }
|
|
146
|
+
#info-panel pre {
|
|
147
|
+
background: ${isDark ? '#161625' : '#f0f1fa'}; border: 1px solid ${borderSubtle};
|
|
148
|
+
border-radius: 6px; padding: 10px; overflow-x: auto;
|
|
149
|
+
font-family: 'SF Mono', 'Fira Code', 'Consolas', monospace;
|
|
150
|
+
font-size: 12px; line-height: 1.6; white-space: pre;
|
|
151
|
+
max-height: 300px; overflow-y: auto; margin: 6px 0 0;
|
|
152
|
+
color: ${isDark ? '#e6edf3' : '#1a2340'};
|
|
153
|
+
}
|
|
154
|
+
.hl-kw { color: ${isDark ? '#8e9eff' : '#4040bf'}; }
|
|
155
|
+
.hl-str { color: ${isDark ? '#ff7b72' : '#c4432b'}; }
|
|
156
|
+
.hl-num { color: ${isDark ? '#f0a050' : '#b35e14'}; }
|
|
157
|
+
.hl-cm { color: #6a737d; font-style: italic; }
|
|
158
|
+
.hl-fn { color: ${isDark ? '#d2a8ff' : '#7c3aed'}; }
|
|
159
|
+
.hl-ty { color: ${isDark ? '#d2a8ff' : '#7c3aed'}; }
|
|
160
|
+
.hl-pn { color: ${isDark ? '#b8bdd0' : '#4a5578'}; }
|
|
161
|
+
.hl-ann { color: ${isDark ? '#8e9eff' : '#4040bf'}; font-weight: 600; font-style: normal; }
|
|
162
|
+
.hl-arr { color: ${isDark ? '#79c0ff' : '#0969da'}; font-weight: 600; font-style: normal; }
|
|
163
|
+
.hl-id { color: ${isDark ? '#e6edf3' : '#1a2340'}; font-style: normal; }
|
|
164
|
+
.hl-sc { color: ${isDark ? '#d2a8ff' : '#7c3aed'}; font-style: italic; }
|
|
143
165
|
|
|
144
166
|
/* Branding badge */
|
|
145
167
|
#branding {
|
|
@@ -194,8 +216,8 @@ path[data-source].port-hover { opacity: 1; }
|
|
|
194
216
|
<circle cx="10" cy="10" r="1.5" fill="${dotColor}" opacity="0.6"/>
|
|
195
217
|
</pattern>
|
|
196
218
|
</defs>
|
|
197
|
-
<rect x="-100000" y="-100000" width="200000" height="200000" fill="${bg}"/>
|
|
198
|
-
<rect x="-100000" y="-100000" width="200000" height="200000" fill="url(#viewer-dots)"/>
|
|
219
|
+
<rect x="-100000" y="-100000" width="200000" height="200000" fill="${bg}" pointer-events="none"/>
|
|
220
|
+
<rect x="-100000" y="-100000" width="200000" height="200000" fill="url(#viewer-dots)" pointer-events="none"/>
|
|
199
221
|
<g id="diagram">${inner}</g>
|
|
200
222
|
</svg>
|
|
201
223
|
<div id="controls">
|
|
@@ -225,6 +247,7 @@ path[data-source].port-hover { opacity: 1; }
|
|
|
225
247
|
</a>
|
|
226
248
|
<div id="scroll-hint">Use <kbd id="mod-key">Ctrl</kbd> + scroll to zoom</div>
|
|
227
249
|
<div id="studio-hint">Like rearranging? <a href="https://flowweaver.ai" target="_blank" rel="noopener">Flow Weaver Studio</a> saves your layouts.</div>
|
|
250
|
+
<script>var nodeSources = ${JSON.stringify(options.nodeSources ?? {})};</script>
|
|
228
251
|
<script>
|
|
229
252
|
(function() {
|
|
230
253
|
'use strict';
|
|
@@ -309,6 +332,7 @@ path[data-source].port-hover { opacity: 1; }
|
|
|
309
332
|
|
|
310
333
|
// ---- Pan (drag) + Node drag ----
|
|
311
334
|
var draggedNodeId = null, dragNodeStart = null, didDragNode = false;
|
|
335
|
+
var clickTarget = null; // stash the real target before setPointerCapture steals it
|
|
312
336
|
var dragCount = 0, nudgeIndex = 0, nudgeTimer = null;
|
|
313
337
|
var nudgeMessages = [
|
|
314
338
|
'Like rearranging? <a href="https://flowweaver.ai" target="_blank" rel="noopener">Flow Weaver Studio</a> saves your layouts.',
|
|
@@ -323,6 +347,8 @@ path[data-source].port-hover { opacity: 1; }
|
|
|
323
347
|
|
|
324
348
|
canvas.addEventListener('pointerdown', function(e) {
|
|
325
349
|
if (e.button !== 0) return;
|
|
350
|
+
clickTarget = e.target; // stash before setPointerCapture redirects events
|
|
351
|
+
didDrag = false;
|
|
326
352
|
// Check if clicking on a node body (walk up to detect data-node-id)
|
|
327
353
|
var t = e.target;
|
|
328
354
|
while (t && t !== canvas) {
|
|
@@ -339,7 +365,6 @@ path[data-source].port-hover { opacity: 1; }
|
|
|
339
365
|
}
|
|
340
366
|
// Canvas pan
|
|
341
367
|
pointerDown = true;
|
|
342
|
-
didDrag = false;
|
|
343
368
|
dragLast = { x: e.clientX, y: e.clientY };
|
|
344
369
|
canvas.setPointerCapture(e.pointerId);
|
|
345
370
|
});
|
|
@@ -792,14 +817,28 @@ path[data-source].port-hover { opacity: 1; }
|
|
|
792
817
|
// Build info panel
|
|
793
818
|
infoTitle.textContent = labelText;
|
|
794
819
|
var html = '';
|
|
820
|
+
var src = nodeSources[nodeId];
|
|
821
|
+
if (src && src.description) {
|
|
822
|
+
html += '<div class="node-desc">' + escapeH(src.description) + '</div>';
|
|
823
|
+
}
|
|
824
|
+
var portInfo = (src && src.ports) ? src.ports : {};
|
|
825
|
+
function portLabel(name) {
|
|
826
|
+
var p = portInfo[name];
|
|
827
|
+
var label = escapeH(name);
|
|
828
|
+
if (p) {
|
|
829
|
+
var typeStr = p.tsType || p.type;
|
|
830
|
+
if (typeStr) label += ' <span class="port-type">' + escapeH(typeStr) + '</span>';
|
|
831
|
+
}
|
|
832
|
+
return label;
|
|
833
|
+
}
|
|
795
834
|
if (inputs.length) {
|
|
796
835
|
html += '<div class="info-section"><div class="info-label">Inputs</div><ul class="port-list">';
|
|
797
|
-
inputs.forEach(function(n) { html += '<li>' +
|
|
836
|
+
inputs.forEach(function(n) { html += '<li>' + portLabel(n) + '</li>'; });
|
|
798
837
|
html += '</ul></div>';
|
|
799
838
|
}
|
|
800
839
|
if (outputs.length) {
|
|
801
840
|
html += '<div class="info-section"><div class="info-label">Outputs</div><ul class="port-list">';
|
|
802
|
-
outputs.forEach(function(n) { html += '<li>' +
|
|
841
|
+
outputs.forEach(function(n) { html += '<li>' + portLabel(n) + '</li>'; });
|
|
803
842
|
html += '</ul></div>';
|
|
804
843
|
}
|
|
805
844
|
if (connectedNodes.size) {
|
|
@@ -807,6 +846,10 @@ path[data-source].port-hover { opacity: 1; }
|
|
|
807
846
|
html += Array.from(connectedNodes).map(escapeH).join(', ');
|
|
808
847
|
html += '</div></div>';
|
|
809
848
|
}
|
|
849
|
+
if (src && src.source) {
|
|
850
|
+
html += '<div class="info-section"><div class="info-label">Source</div>';
|
|
851
|
+
html += '<pre><code>' + highlightTS(src.source) + '</code></pre></div>';
|
|
852
|
+
}
|
|
810
853
|
infoBody.innerHTML = html;
|
|
811
854
|
infoPanel.classList.add('visible');
|
|
812
855
|
}
|
|
@@ -815,10 +858,130 @@ path[data-source].port-hover { opacity: 1; }
|
|
|
815
858
|
return s.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>');
|
|
816
859
|
}
|
|
817
860
|
|
|
861
|
+
var fwAnnotations = 'flowWeaver,input,output,step,node,connect,param,returns,fwImport,label,scope,position,color,icon,tag,map,path,name,description,expression,executeWhen,pullExecution,strictTypes,autoConnect,port,trigger,cancelOn,retries,timeout,throttle';
|
|
862
|
+
|
|
863
|
+
function highlightJSDoc(block) {
|
|
864
|
+
var annSet = fwAnnotations.split(',');
|
|
865
|
+
var out = '';
|
|
866
|
+
var re = /(@[a-zA-Z]+)|(->)|(\\.[a-zA-Z_][a-zA-Z0-9_]*)|("[^"]*")|('\\''[^'\\'']*'\\'')|(-?[0-9]+(?:\\.[0-9]+)?)|([a-zA-Z_][a-zA-Z0-9_]*)|([^@a-zA-Z0-9"'\\-.]+)/g;
|
|
867
|
+
var m;
|
|
868
|
+
while ((m = re.exec(block)) !== null) {
|
|
869
|
+
if (m[1]) {
|
|
870
|
+
var tag = m[1].slice(1);
|
|
871
|
+
if (annSet.indexOf(tag) >= 0) {
|
|
872
|
+
out += '<span class="hl-ann">' + m[0] + '</span>';
|
|
873
|
+
} else {
|
|
874
|
+
out += '<span class="hl-ann">' + m[0] + '</span>';
|
|
875
|
+
}
|
|
876
|
+
} else if (m[2]) {
|
|
877
|
+
out += '<span class="hl-arr">' + m[2] + '</span>';
|
|
878
|
+
} else if (m[3]) {
|
|
879
|
+
// .portName scope reference
|
|
880
|
+
out += '<span class="hl-sc">' + m[3] + '</span>';
|
|
881
|
+
} else if (m[4] || m[5]) {
|
|
882
|
+
out += '<span class="hl-str">' + (m[4] || m[5]) + '</span>';
|
|
883
|
+
} else if (m[6]) {
|
|
884
|
+
out += '<span class="hl-num">' + m[6] + '</span>';
|
|
885
|
+
} else if (m[7]) {
|
|
886
|
+
var tys = 'string,number,boolean,any,void,never,unknown,STEP,STRING,NUMBER,BOOLEAN,ARRAY,OBJECT,FUNCTION,ANY';
|
|
887
|
+
if (tys.split(',').indexOf(m[7]) >= 0) {
|
|
888
|
+
out += '<span class="hl-ty">' + m[7] + '</span>';
|
|
889
|
+
} else {
|
|
890
|
+
out += '<span class="hl-id">' + m[7] + '</span>';
|
|
891
|
+
}
|
|
892
|
+
} else {
|
|
893
|
+
out += m[0];
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
return out;
|
|
897
|
+
}
|
|
898
|
+
|
|
899
|
+
function highlightTS(code) {
|
|
900
|
+
var tokens = [];
|
|
901
|
+
var i = 0;
|
|
902
|
+
while (i < code.length) {
|
|
903
|
+
// Line comments
|
|
904
|
+
if (code[i] === '/' && code[i+1] === '/') {
|
|
905
|
+
var end = code.indexOf('\\n', i);
|
|
906
|
+
if (end === -1) end = code.length;
|
|
907
|
+
tokens.push({ t: 'cm', v: code.slice(i, end) });
|
|
908
|
+
i = end;
|
|
909
|
+
continue;
|
|
910
|
+
}
|
|
911
|
+
// Block comments (detect JSDoc for annotation highlighting)
|
|
912
|
+
if (code[i] === '/' && code[i+1] === '*') {
|
|
913
|
+
var end = code.indexOf('*/', i + 2);
|
|
914
|
+
if (end === -1) end = code.length; else end += 2;
|
|
915
|
+
var block = code.slice(i, end);
|
|
916
|
+
var hasFW = /@(flowWeaver|input|output|step|node|connect|param|returns)/.test(block);
|
|
917
|
+
if (hasFW) {
|
|
918
|
+
tokens.push({ t: 'jsdoc', v: block });
|
|
919
|
+
} else {
|
|
920
|
+
tokens.push({ t: 'cm', v: block });
|
|
921
|
+
}
|
|
922
|
+
i = end;
|
|
923
|
+
continue;
|
|
924
|
+
}
|
|
925
|
+
// Strings
|
|
926
|
+
if (code[i] === "'" || code[i] === '"' || code[i] === '\`') {
|
|
927
|
+
var q = code[i], j = i + 1;
|
|
928
|
+
while (j < code.length && code[j] !== q) { if (code[j] === '\\\\') j++; j++; }
|
|
929
|
+
tokens.push({ t: 'str', v: code.slice(i, j + 1) });
|
|
930
|
+
i = j + 1;
|
|
931
|
+
continue;
|
|
932
|
+
}
|
|
933
|
+
// Numbers
|
|
934
|
+
if (/[0-9]/.test(code[i]) && (i === 0 || /[^a-zA-Z_$]/.test(code[i-1]))) {
|
|
935
|
+
var j = i;
|
|
936
|
+
while (j < code.length && /[0-9a-fA-FxX._]/.test(code[j])) j++;
|
|
937
|
+
tokens.push({ t: 'num', v: code.slice(i, j) });
|
|
938
|
+
i = j;
|
|
939
|
+
continue;
|
|
940
|
+
}
|
|
941
|
+
// Words
|
|
942
|
+
if (/[a-zA-Z_$]/.test(code[i])) {
|
|
943
|
+
var j = i;
|
|
944
|
+
while (j < code.length && /[a-zA-Z0-9_$]/.test(code[j])) j++;
|
|
945
|
+
var w = code.slice(i, j);
|
|
946
|
+
var kws = 'async,await,break,case,catch,class,const,continue,default,delete,do,else,export,extends,finally,for,from,function,if,import,in,instanceof,let,new,of,return,switch,throw,try,typeof,var,void,while,yield';
|
|
947
|
+
var tys = 'string,number,boolean,any,void,never,unknown,null,undefined,true,false,Promise,Record,Map,Set,Array,Partial,Required,Omit,Pick';
|
|
948
|
+
if (kws.split(',').indexOf(w) >= 0) {
|
|
949
|
+
tokens.push({ t: 'kw', v: w });
|
|
950
|
+
} else if (tys.split(',').indexOf(w) >= 0) {
|
|
951
|
+
tokens.push({ t: 'ty', v: w });
|
|
952
|
+
} else if (j < code.length && code[j] === '(') {
|
|
953
|
+
tokens.push({ t: 'fn', v: w });
|
|
954
|
+
} else {
|
|
955
|
+
tokens.push({ t: '', v: w });
|
|
956
|
+
}
|
|
957
|
+
i = j;
|
|
958
|
+
continue;
|
|
959
|
+
}
|
|
960
|
+
// Punctuation
|
|
961
|
+
if (/[{}()\\[\\];:.,<>=!&|?+\\-*/%^~@]/.test(code[i])) {
|
|
962
|
+
tokens.push({ t: 'pn', v: code[i] });
|
|
963
|
+
i++;
|
|
964
|
+
continue;
|
|
965
|
+
}
|
|
966
|
+
// Whitespace and other
|
|
967
|
+
tokens.push({ t: '', v: code[i] });
|
|
968
|
+
i++;
|
|
969
|
+
}
|
|
970
|
+
return tokens.map(function(tk) {
|
|
971
|
+
if (tk.t === 'jsdoc') {
|
|
972
|
+
return '<span class="hl-cm">' + highlightJSDoc(escapeH(tk.v)) + '</span>';
|
|
973
|
+
}
|
|
974
|
+
var v = escapeH(tk.v);
|
|
975
|
+
return tk.t ? '<span class="hl-' + tk.t + '">' + v + '</span>' : v;
|
|
976
|
+
}).join('');
|
|
977
|
+
}
|
|
978
|
+
|
|
818
979
|
// Delegate click: port click > node click > background
|
|
980
|
+
// Use clickTarget (stashed from pointerdown) because setPointerCapture redirects click to canvas
|
|
819
981
|
canvas.addEventListener('click', function(e) {
|
|
820
982
|
if (didDrag || didDragNode) { didDragNode = false; return; }
|
|
821
|
-
var target = e.target;
|
|
983
|
+
var target = clickTarget || e.target;
|
|
984
|
+
clickTarget = null;
|
|
822
985
|
while (target && target !== canvas) {
|
|
823
986
|
if (target.hasAttribute && target.hasAttribute('data-port-id')) {
|
|
824
987
|
e.stopPropagation();
|
package/dist/diagram/index.js
CHANGED
|
@@ -28,37 +28,73 @@ export function fileToSVG(filePath, options = {}) {
|
|
|
28
28
|
*/
|
|
29
29
|
export function workflowToHTML(ast, options = {}) {
|
|
30
30
|
const svg = workflowToSVG(ast, options);
|
|
31
|
-
return wrapSVGInHTML(svg, { title: options.workflowName ?? ast.name, theme: options.theme });
|
|
31
|
+
return wrapSVGInHTML(svg, { title: options.workflowName ?? ast.name, theme: options.theme, nodeSources: buildNodeSourceMap(ast) });
|
|
32
32
|
}
|
|
33
33
|
/**
|
|
34
34
|
* Parse TypeScript source code and render the first (or named) workflow to interactive HTML.
|
|
35
35
|
*/
|
|
36
36
|
export function sourceToHTML(code, options = {}) {
|
|
37
|
-
const
|
|
38
|
-
|
|
37
|
+
const result = parser.parseFromString(code);
|
|
38
|
+
const ast = pickWorkflow(result.workflows, options);
|
|
39
|
+
const svg = workflowToSVG(ast, options);
|
|
40
|
+
return wrapSVGInHTML(svg, { title: options.workflowName ?? ast.name, theme: options.theme, nodeSources: buildNodeSourceMap(ast) });
|
|
39
41
|
}
|
|
40
42
|
/**
|
|
41
43
|
* Parse a workflow file and render the first (or named) workflow to interactive HTML.
|
|
42
44
|
*/
|
|
43
45
|
export function fileToHTML(filePath, options = {}) {
|
|
44
|
-
const
|
|
45
|
-
|
|
46
|
+
const result = parser.parse(filePath);
|
|
47
|
+
const ast = pickWorkflow(result.workflows, options);
|
|
48
|
+
const svg = workflowToSVG(ast, options);
|
|
49
|
+
return wrapSVGInHTML(svg, { title: options.workflowName ?? ast.name, theme: options.theme, nodeSources: buildNodeSourceMap(ast) });
|
|
46
50
|
}
|
|
47
|
-
function
|
|
51
|
+
function buildNodeSourceMap(ast) {
|
|
52
|
+
const typeMap = new Map(ast.nodeTypes.map(nt => [nt.functionName, nt]));
|
|
53
|
+
const map = {};
|
|
54
|
+
for (const inst of ast.instances) {
|
|
55
|
+
const nt = typeMap.get(inst.nodeType);
|
|
56
|
+
if (!nt)
|
|
57
|
+
continue;
|
|
58
|
+
const ports = {};
|
|
59
|
+
for (const [name, def] of Object.entries(nt.inputs ?? {})) {
|
|
60
|
+
ports[name] = { type: def.dataType, tsType: def.tsType };
|
|
61
|
+
}
|
|
62
|
+
for (const [name, def] of Object.entries(nt.outputs ?? {})) {
|
|
63
|
+
ports[name] = { type: def.dataType, tsType: def.tsType };
|
|
64
|
+
}
|
|
65
|
+
map[inst.id] = { description: nt.description, source: nt.functionText, ports };
|
|
66
|
+
}
|
|
67
|
+
// Virtual Start/Exit nodes get their port types from the workflow definition
|
|
68
|
+
const startPorts = {};
|
|
69
|
+
for (const [name, def] of Object.entries(ast.startPorts ?? {})) {
|
|
70
|
+
startPorts[name] = { type: def.dataType, tsType: def.tsType };
|
|
71
|
+
}
|
|
72
|
+
if (Object.keys(startPorts).length) {
|
|
73
|
+
map['Start'] = { description: ast.description, ports: startPorts };
|
|
74
|
+
}
|
|
75
|
+
const exitPorts = {};
|
|
76
|
+
for (const [name, def] of Object.entries(ast.exitPorts ?? {})) {
|
|
77
|
+
exitPorts[name] = { type: def.dataType, tsType: def.tsType };
|
|
78
|
+
}
|
|
79
|
+
if (Object.keys(exitPorts).length) {
|
|
80
|
+
map['Exit'] = { ports: exitPorts };
|
|
81
|
+
}
|
|
82
|
+
return map;
|
|
83
|
+
}
|
|
84
|
+
function pickWorkflow(workflows, options) {
|
|
48
85
|
if (workflows.length === 0) {
|
|
49
86
|
throw new Error('No workflows found in source code');
|
|
50
87
|
}
|
|
51
|
-
let workflow;
|
|
52
88
|
if (options.workflowName) {
|
|
53
89
|
const found = workflows.find(w => w.name === options.workflowName);
|
|
54
90
|
if (!found) {
|
|
55
91
|
throw new Error(`Workflow "${options.workflowName}" not found. Available: ${workflows.map(w => w.name).join(', ')}`);
|
|
56
92
|
}
|
|
57
|
-
|
|
58
|
-
}
|
|
59
|
-
else {
|
|
60
|
-
workflow = workflows[0];
|
|
93
|
+
return found;
|
|
61
94
|
}
|
|
62
|
-
return
|
|
95
|
+
return workflows[0];
|
|
96
|
+
}
|
|
97
|
+
function pickAndRender(workflows, options) {
|
|
98
|
+
return workflowToSVG(pickWorkflow(workflows, options), options);
|
|
63
99
|
}
|
|
64
100
|
//# sourceMappingURL=index.js.map
|
package/dist/friendly-errors.js
CHANGED
|
@@ -35,7 +35,7 @@ const COERCE_TARGET_TYPES = {
|
|
|
35
35
|
OBJECT: 'object',
|
|
36
36
|
};
|
|
37
37
|
/**
|
|
38
|
-
* Build a concrete
|
|
38
|
+
* Build a concrete `@connect ... as <type>` coercion suggestion from error context.
|
|
39
39
|
* Returns null if not enough info is available.
|
|
40
40
|
*/
|
|
41
41
|
function buildCoerceSuggestion(quoted, targetType) {
|
|
@@ -51,7 +51,7 @@ function buildCoerceSuggestion(quoted, targetType) {
|
|
|
51
51
|
const coerceType = COERCE_TARGET_TYPES[targetType.toUpperCase()] || targetType.toLowerCase();
|
|
52
52
|
if (!['string', 'number', 'boolean', 'json', 'object'].includes(coerceType))
|
|
53
53
|
return null;
|
|
54
|
-
return `@
|
|
54
|
+
return `@connect ${portRefs[0]} -> ${portRefs[1]} as ${coerceType}`;
|
|
55
55
|
}
|
|
56
56
|
function extractCyclePath(message) {
|
|
57
57
|
const match = message.match(/:\s*(.+ -> .+)/);
|
|
@@ -163,8 +163,8 @@ const errorMappers = {
|
|
|
163
163
|
title: 'Type Mismatch',
|
|
164
164
|
explanation: `Type mismatch: you're connecting a ${source} to a ${target}. The value will be automatically converted, but this might cause unexpected behavior.`,
|
|
165
165
|
fix: coerceSuggestion
|
|
166
|
-
? `
|
|
167
|
-
: `Add
|
|
166
|
+
? `Use \`${coerceSuggestion}\` for explicit coercion, change one of the port types, or use @strictTypes to turn this into an error.`
|
|
167
|
+
: `Add \`as <type>\` to the @connect annotation (e.g. \`as string\`), change one of the port types, or use @strictTypes to turn this into an error.`,
|
|
168
168
|
code: error.code,
|
|
169
169
|
};
|
|
170
170
|
},
|
|
@@ -264,8 +264,8 @@ const errorMappers = {
|
|
|
264
264
|
title: 'Type Incompatible',
|
|
265
265
|
explanation: `Type mismatch: ${source} to ${target}. With @strictTypes enabled, this is an error instead of a warning.`,
|
|
266
266
|
fix: coerceSuggestion
|
|
267
|
-
? `
|
|
268
|
-
: `Add
|
|
267
|
+
? `Use \`${coerceSuggestion}\` for explicit coercion, change one of the port types, or remove @strictTypes to allow implicit coercions.`
|
|
268
|
+
: `Add \`as <type>\` to the @connect annotation (e.g. \`as number\`), change one of the port types, or remove @strictTypes to allow implicit coercions.`,
|
|
269
269
|
code: error.code,
|
|
270
270
|
};
|
|
271
271
|
},
|
|
@@ -279,8 +279,8 @@ const errorMappers = {
|
|
|
279
279
|
title: 'Unusual Type Coercion',
|
|
280
280
|
explanation: `Converting ${source} to ${target} is technically valid but semantically unusual and may produce unexpected behavior.`,
|
|
281
281
|
fix: coerceSuggestion
|
|
282
|
-
? `If intentional,
|
|
283
|
-
: `If intentional, add
|
|
282
|
+
? `If intentional, use \`${coerceSuggestion}\` for explicit coercion, or use @strictTypes to enforce type safety.`
|
|
283
|
+
: `If intentional, add \`as <type>\` to the @connect annotation, or use @strictTypes to enforce type safety.`,
|
|
284
284
|
code: error.code,
|
|
285
285
|
};
|
|
286
286
|
},
|
|
@@ -505,6 +505,41 @@ const errorMappers = {
|
|
|
505
505
|
code: error.code,
|
|
506
506
|
};
|
|
507
507
|
},
|
|
508
|
+
COERCE_TYPE_MISMATCH(error) {
|
|
509
|
+
const coerceMatch = error.message.match(/`as (\w+)`/);
|
|
510
|
+
const coerceType = coerceMatch?.[1] || 'unknown';
|
|
511
|
+
const expectsMatch = error.message.match(/expects (\w+)/);
|
|
512
|
+
const expectedType = expectsMatch?.[1] || 'unknown';
|
|
513
|
+
const suggestedType = COERCE_TARGET_TYPES[expectedType.toUpperCase()] || expectedType.toLowerCase();
|
|
514
|
+
return {
|
|
515
|
+
title: 'Wrong Coercion Type',
|
|
516
|
+
explanation: `The \`as ${coerceType}\` coercion produces the wrong type for the target port. The target expects ${expectedType}.`,
|
|
517
|
+
fix: `Change \`as ${coerceType}\` to \`as ${suggestedType}\` in the @connect annotation.`,
|
|
518
|
+
code: error.code,
|
|
519
|
+
};
|
|
520
|
+
},
|
|
521
|
+
REDUNDANT_COERCE(error) {
|
|
522
|
+
const coerceMatch = error.message.match(/`as (\w+)`/);
|
|
523
|
+
const coerceType = coerceMatch?.[1] || 'unknown';
|
|
524
|
+
const bothMatch = error.message.match(/both (\w+)/);
|
|
525
|
+
const dataType = bothMatch?.[1] || 'the same type';
|
|
526
|
+
return {
|
|
527
|
+
title: 'Redundant Coercion',
|
|
528
|
+
explanation: `The \`as ${coerceType}\` coercion is unnecessary because both the source and target ports are already ${dataType}.`,
|
|
529
|
+
fix: `Remove \`as ${coerceType}\` from the @connect annotation — no coercion is needed.`,
|
|
530
|
+
code: error.code,
|
|
531
|
+
};
|
|
532
|
+
},
|
|
533
|
+
COERCE_ON_FUNCTION_PORT(error) {
|
|
534
|
+
const coerceMatch = error.message.match(/`as (\w+)`/);
|
|
535
|
+
const coerceType = coerceMatch?.[1] || 'unknown';
|
|
536
|
+
return {
|
|
537
|
+
title: 'Coercion on Function Port',
|
|
538
|
+
explanation: `The \`as ${coerceType}\` coercion cannot be applied to FUNCTION ports. Function values are callable references and cannot be meaningfully converted to other types.`,
|
|
539
|
+
fix: `Remove \`as ${coerceType}\` from the @connect annotation. If you need to convert the function's return value, add a transformation node.`,
|
|
540
|
+
code: error.code,
|
|
541
|
+
};
|
|
542
|
+
},
|
|
508
543
|
LOSSY_TYPE_COERCION(error) {
|
|
509
544
|
const types = extractTypes(error.message);
|
|
510
545
|
const source = types?.source || 'unknown';
|
|
@@ -515,8 +550,8 @@ const errorMappers = {
|
|
|
515
550
|
title: 'Lossy Type Conversion',
|
|
516
551
|
explanation: `Converting ${source} to ${target} may lose data or produce unexpected results (e.g., NaN, truncation).`,
|
|
517
552
|
fix: coerceSuggestion
|
|
518
|
-
? `
|
|
519
|
-
: `Add
|
|
553
|
+
? `Use \`${coerceSuggestion}\` for explicit coercion, or use @strictTypes to enforce type safety.`
|
|
554
|
+
: `Add \`as <type>\` to the @connect annotation for explicit conversion, or use @strictTypes to enforce type safety.`,
|
|
520
555
|
code: error.code,
|
|
521
556
|
};
|
|
522
557
|
},
|
|
@@ -1,4 +1,15 @@
|
|
|
1
|
-
import type { TNodeTypeAST, TWorkflowAST, TMergeStrategy } from '../ast/index.js';
|
|
1
|
+
import type { TNodeTypeAST, TWorkflowAST, TMergeStrategy, TConnectionAST, TDataType } from '../ast/index.js';
|
|
2
|
+
/**
|
|
3
|
+
* Get the coercion expression to wrap a value, if coercion is needed.
|
|
4
|
+
* Returns null if no coercion needed.
|
|
5
|
+
*
|
|
6
|
+
* Priority:
|
|
7
|
+
* 1. Explicit coerce on the connection (from `as <type>` annotation)
|
|
8
|
+
* 2. Auto-coercion for safe pairs:
|
|
9
|
+
* - anything -> STRING (String() never fails)
|
|
10
|
+
* - BOOLEAN -> NUMBER (well-defined: false->0, true->1)
|
|
11
|
+
*/
|
|
12
|
+
export declare function getCoercionWrapper(connection: TConnectionAST, sourceDataType: TDataType | undefined, targetDataType: TDataType | undefined): string | null;
|
|
2
13
|
/**
|
|
3
14
|
* Sanitize a node ID to be a valid JavaScript identifier.
|
|
4
15
|
* Replaces non-alphanumeric characters (except _ and $) with underscores.
|
|
@@ -1,6 +1,72 @@
|
|
|
1
1
|
import { RESERVED_PORT_NAMES, isStartNode, isExitNode, isExecutePort, isSuccessPort, isFailurePort, } from '../constants.js';
|
|
2
2
|
import { generateScopeFunctionClosure } from './scope-function-generator.js';
|
|
3
3
|
import { mapToTypeScript } from '../type-mappings.js';
|
|
4
|
+
/** Map coercion target type to inline JS expression */
|
|
5
|
+
const COERCION_EXPRESSIONS = {
|
|
6
|
+
string: 'String',
|
|
7
|
+
number: 'Number',
|
|
8
|
+
boolean: 'Boolean',
|
|
9
|
+
json: 'JSON.stringify',
|
|
10
|
+
object: 'JSON.parse',
|
|
11
|
+
};
|
|
12
|
+
/** Map TDataType to TCoerceTargetType for auto-coercion */
|
|
13
|
+
const DATATYPE_TO_COERCE = {
|
|
14
|
+
STRING: 'string',
|
|
15
|
+
NUMBER: 'number',
|
|
16
|
+
BOOLEAN: 'boolean',
|
|
17
|
+
OBJECT: 'object',
|
|
18
|
+
};
|
|
19
|
+
/**
|
|
20
|
+
* Resolve the dataType of a source port by looking up the node instance -> node type -> outputs.
|
|
21
|
+
*/
|
|
22
|
+
function resolveSourcePortDataType(workflow, sourceNodeId, sourcePort) {
|
|
23
|
+
if (isStartNode(sourceNodeId)) {
|
|
24
|
+
return workflow.startPorts?.[sourcePort]?.dataType;
|
|
25
|
+
}
|
|
26
|
+
if (isExitNode(sourceNodeId)) {
|
|
27
|
+
return workflow.exitPorts?.[sourcePort]?.dataType;
|
|
28
|
+
}
|
|
29
|
+
const instance = workflow.instances.find((i) => i.id === sourceNodeId);
|
|
30
|
+
if (!instance)
|
|
31
|
+
return undefined;
|
|
32
|
+
const nodeType = workflow.nodeTypes.find((nt) => nt.name === instance.nodeType || nt.functionName === instance.nodeType);
|
|
33
|
+
if (!nodeType)
|
|
34
|
+
return undefined;
|
|
35
|
+
return nodeType.outputs?.[sourcePort]?.dataType;
|
|
36
|
+
}
|
|
37
|
+
/**
|
|
38
|
+
* Get the coercion expression to wrap a value, if coercion is needed.
|
|
39
|
+
* Returns null if no coercion needed.
|
|
40
|
+
*
|
|
41
|
+
* Priority:
|
|
42
|
+
* 1. Explicit coerce on the connection (from `as <type>` annotation)
|
|
43
|
+
* 2. Auto-coercion for safe pairs:
|
|
44
|
+
* - anything -> STRING (String() never fails)
|
|
45
|
+
* - BOOLEAN -> NUMBER (well-defined: false->0, true->1)
|
|
46
|
+
*/
|
|
47
|
+
export function getCoercionWrapper(connection, sourceDataType, targetDataType) {
|
|
48
|
+
// Explicit coerce on connection
|
|
49
|
+
if (connection.coerce) {
|
|
50
|
+
return COERCION_EXPRESSIONS[connection.coerce];
|
|
51
|
+
}
|
|
52
|
+
// No auto-coercion if types are unknown or same
|
|
53
|
+
if (!sourceDataType || !targetDataType || sourceDataType === targetDataType)
|
|
54
|
+
return null;
|
|
55
|
+
// Skip STEP and ANY ports — no coercion needed
|
|
56
|
+
if (sourceDataType === 'STEP' || targetDataType === 'STEP')
|
|
57
|
+
return null;
|
|
58
|
+
if (sourceDataType === 'ANY' || targetDataType === 'ANY')
|
|
59
|
+
return null;
|
|
60
|
+
// Auto-coerce: anything -> STRING
|
|
61
|
+
if (targetDataType === 'STRING' && sourceDataType !== 'STRING') {
|
|
62
|
+
return 'String';
|
|
63
|
+
}
|
|
64
|
+
// Auto-coerce: BOOLEAN -> NUMBER
|
|
65
|
+
if (sourceDataType === 'BOOLEAN' && targetDataType === 'NUMBER') {
|
|
66
|
+
return 'Number';
|
|
67
|
+
}
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
4
70
|
/**
|
|
5
71
|
* Sanitize a node ID to be a valid JavaScript identifier.
|
|
6
72
|
* Replaces non-alphanumeric characters (except _ and $) with underscores.
|
|
@@ -197,7 +263,16 @@ export function buildNodeArgumentsWithContext(opts) {
|
|
|
197
263
|
lines.push(`${indent}const ${varName} = ${varName}_resolved.fn as ${portType};`);
|
|
198
264
|
}
|
|
199
265
|
else {
|
|
200
|
-
|
|
266
|
+
// Check for coercion (explicit or auto)
|
|
267
|
+
const sourceDataType = resolveSourcePortDataType(workflow, sourceNode, sourcePort);
|
|
268
|
+
const coerceExpr = getCoercionWrapper(connection, sourceDataType, portConfig.dataType);
|
|
269
|
+
const getExpr = `${getCall}({ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${sourceIdx}${nonNullAssert} })`;
|
|
270
|
+
if (coerceExpr) {
|
|
271
|
+
lines.push(`${indent}const ${varName} = ${coerceExpr}(${getExpr}) as ${portType};`);
|
|
272
|
+
}
|
|
273
|
+
else {
|
|
274
|
+
lines.push(`${indent}const ${varName} = ${getExpr} as ${portType};`);
|
|
275
|
+
}
|
|
201
276
|
}
|
|
202
277
|
}
|
|
203
278
|
else {
|
|
@@ -214,11 +289,21 @@ export function buildNodeArgumentsWithContext(opts) {
|
|
|
214
289
|
return;
|
|
215
290
|
}
|
|
216
291
|
const attempts = [];
|
|
217
|
-
validConnections.forEach((conn
|
|
292
|
+
validConnections.forEach((conn) => {
|
|
218
293
|
const sourceNode = conn.from.node;
|
|
219
294
|
const sourcePort = conn.from.port;
|
|
220
295
|
const sourceIdx = isStartNode(sourceNode) ? 'startIdx' : `${toValidIdentifier(sourceNode)}Idx`;
|
|
221
|
-
|
|
296
|
+
const getExpr = `${getCall}({ id: '${sourceNode}', portName: '${sourcePort}', executionIndex: ${sourceIdx} })`;
|
|
297
|
+
// Per-connection coercion: each source gets its own coercion wrapper
|
|
298
|
+
if (portConfig.dataType !== 'FUNCTION') {
|
|
299
|
+
const sourceDataType = resolveSourcePortDataType(workflow, sourceNode, sourcePort);
|
|
300
|
+
const coerceExpr = getCoercionWrapper(conn, sourceDataType, portConfig.dataType);
|
|
301
|
+
const wrapped = coerceExpr ? `${coerceExpr}(${getExpr})` : getExpr;
|
|
302
|
+
attempts.push(`(${sourceIdx} !== undefined ? ${wrapped} : undefined)`);
|
|
303
|
+
}
|
|
304
|
+
else {
|
|
305
|
+
attempts.push(`(${sourceIdx} !== undefined ? ${getExpr} : undefined)`);
|
|
306
|
+
}
|
|
222
307
|
});
|
|
223
308
|
const ternary = attempts.join(' ?? ');
|
|
224
309
|
const portType = mapToTypeScript(portConfig.dataType, portConfig.tsType);
|
package/dist/jsdoc-parser.js
CHANGED
|
@@ -889,7 +889,7 @@ export class JSDocParser {
|
|
|
889
889
|
warnings.push(`Invalid @connect tag format: @connect ${comment}`);
|
|
890
890
|
return;
|
|
891
891
|
}
|
|
892
|
-
const { source, target } = result;
|
|
892
|
+
const { source, target, coerce } = result;
|
|
893
893
|
// Capture source location from tag
|
|
894
894
|
const line = tag.getStartLineNumber();
|
|
895
895
|
config.connections.push({
|
|
@@ -904,6 +904,7 @@ export class JSDocParser {
|
|
|
904
904
|
...(target.scope && { scope: target.scope }),
|
|
905
905
|
},
|
|
906
906
|
sourceLocation: { line, column: 0 },
|
|
907
|
+
...(coerce && { coerce }),
|
|
907
908
|
});
|
|
908
909
|
}
|
|
909
910
|
/**
|