@gmag11/nodered-mcp-server 1.0.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/LICENSE +201 -0
- package/README.md +162 -0
- package/index.js +133 -0
- package/package.json +58 -0
- package/resources/skills/nodered-flow-builder/SKILL.md +659 -0
- package/resources/skills/nodered-flow-layout/SKILL.md +395 -0
- package/resources/skills/nodered-flowfuse-dashboard/SKILL.md +941 -0
- package/resources/skills/nodered-fundamentals/SKILL.md +323 -0
- package/resources/skills/nodered-jsonata/SKILL.md +1039 -0
- package/resources/skills/nodered-mustache/SKILL.md +588 -0
- package/resources/skills/nodered-node-reference/SKILL.md +1020 -0
- package/resources/skills/nodered-node-reference/examples/common.json +113 -0
- package/resources/skills/nodered-node-reference/examples/network.json +107 -0
- package/resources/skills/nodered-node-reference/examples/parser.json +147 -0
- package/resources/skills/nodered-node-reference/examples/sequence.json +141 -0
- package/resources/skills/nodered-node-reference/examples/storage.json +104 -0
- package/resources/skills/nodered-patterns/SKILL.md +414 -0
- package/resources/skills/nodered-patterns/examples/error-handler.json +72 -0
- package/resources/skills/nodered-patterns/examples/http-endpoint.json +42 -0
- package/resources/skills/nodered-patterns/examples/mqtt-subscriber.json +47 -0
- package/resources/skills/nodered-patterns/examples/timer-flow.json +50 -0
- package/resources/skills/nodered-subflows/SKILL.md +261 -0
- package/resources/skills/nodered-uibuilder/SKILL.md +500 -0
- package/src/auth/api-key-verifier.js +36 -0
- package/src/auth/composite-verifier.js +59 -0
- package/src/auth/config.js +106 -0
- package/src/auth/oauth-clients-store.js +107 -0
- package/src/auth/oauth-provider.js +149 -0
- package/src/auth/oauth-token-store.js +312 -0
- package/src/nodered/auth.js +158 -0
- package/src/nodered/client.js +199 -0
- package/src/nodered/comms-client.js +500 -0
- package/src/renderer/colors.js +161 -0
- package/src/renderer/geometry.js +115 -0
- package/src/renderer/html-builder.js +571 -0
- package/src/renderer/index.js +51 -0
- package/src/renderer/ir-builder.js +161 -0
- package/src/renderer/layout.js +126 -0
- package/src/renderer/mermaid-builder.js +109 -0
- package/src/renderer/svg-builder.js +228 -0
- package/src/schemas/responses.js +283 -0
- package/src/server.js +844 -0
- package/src/skills/loader.js +84 -0
- package/src/staging-store.js +258 -0
- package/src/tools/add-nodes-to-group.js +216 -0
- package/src/tools/connect-nodes.js +115 -0
- package/src/tools/constants.js +45 -0
- package/src/tools/create-flow.js +87 -0
- package/src/tools/create-node.js +126 -0
- package/src/tools/create-subflow-instance.js +123 -0
- package/src/tools/create-subflow.js +101 -0
- package/src/tools/delete-context.js +60 -0
- package/src/tools/delete-flow.js +81 -0
- package/src/tools/delete-group.js +116 -0
- package/src/tools/delete-node.js +73 -0
- package/src/tools/delete-subflow.js +103 -0
- package/src/tools/deploy.js +94 -0
- package/src/tools/disconnect-nodes.js +158 -0
- package/src/tools/export-flow.js +161 -0
- package/src/tools/export-subflow.js +78 -0
- package/src/tools/flow-utils.js +376 -0
- package/src/tools/get-config-nodes.js +86 -0
- package/src/tools/get-context.js +76 -0
- package/src/tools/get-flow-diagram.js +99 -0
- package/src/tools/get-flow-nodes.js +116 -0
- package/src/tools/get-flows.js +74 -0
- package/src/tools/get-node-detail.js +77 -0
- package/src/tools/get-node-type-detail.js +92 -0
- package/src/tools/get-palette-nodes.js +63 -0
- package/src/tools/get-staging-status.js +34 -0
- package/src/tools/get-subflow-detail.js +110 -0
- package/src/tools/get-subflows.js +105 -0
- package/src/tools/import-flow.js +310 -0
- package/src/tools/inject-message.js +117 -0
- package/src/tools/install-node.js +31 -0
- package/src/tools/read-debug-messages.js +155 -0
- package/src/tools/refresh-staging.js +62 -0
- package/src/tools/remove-nodes-from-group.js +162 -0
- package/src/tools/render-staging.js +69 -0
- package/src/tools/response-utils.js +42 -0
- package/src/tools/search-nodes.js +134 -0
- package/src/tools/uninstall-node.js +31 -0
- package/src/tools/update-flow.js +95 -0
- package/src/tools/update-group.js +77 -0
- package/src/tools/update-node.js +132 -0
- package/src/tools/update-subflow.js +84 -0
- package/src/transport/http.js +252 -0
- package/src/transport/stdio.js +16 -0
- package/src/transport/ws-server.js +223 -0
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Intermediate Representation (IR) Builder
|
|
3
|
+
*
|
|
4
|
+
* Transforms raw Node-RED flows into a normalized intermediate representation
|
|
5
|
+
* that all format builders (SVG, HTML, Mermaid) consume.
|
|
6
|
+
*
|
|
7
|
+
* @module renderer/ir-builder
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* @typedef {object} IRNode
|
|
12
|
+
* @property {string} id - Node ID
|
|
13
|
+
* @property {string} type - Node type (e.g., 'inject', 'function')
|
|
14
|
+
* @property {string} [name] - Node display name
|
|
15
|
+
* @property {number} x - X position
|
|
16
|
+
* @property {number} y - Y position
|
|
17
|
+
* @property {number} w - Node width
|
|
18
|
+
* @property {number} h - Node height
|
|
19
|
+
* @property {number} [inputs] - Number of input ports
|
|
20
|
+
* @property {number} [outputs] - Number of output ports
|
|
21
|
+
* @property {boolean} [d] - Whether node is disabled
|
|
22
|
+
* @property {boolean} [dirty] - Whether node has un-deployed changes
|
|
23
|
+
* @property {string} [z] - Parent flow ID
|
|
24
|
+
* @property {string} [g] - Parent group ID
|
|
25
|
+
* @property {object[]} [wires] - Outgoing wire connections
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* @typedef {object} IRLink
|
|
30
|
+
* @property {IRNode} source - Source node
|
|
31
|
+
* @property {number} sourcePort - Source output port index
|
|
32
|
+
* @property {IRNode} target - Target node
|
|
33
|
+
*/
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* @typedef {object} IRGroup
|
|
37
|
+
* @property {string} id - Group ID
|
|
38
|
+
* @property {string} [name] - Group label
|
|
39
|
+
* @property {number} x - X position
|
|
40
|
+
* @property {number} y - Y position
|
|
41
|
+
* @property {number} w - Width
|
|
42
|
+
* @property {number} h - Height
|
|
43
|
+
* @property {object} [style] - Group style (fill, stroke, color, etc.)
|
|
44
|
+
* @property {string[]} nodes - Member node IDs
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* @typedef {object} IR
|
|
49
|
+
* @property {IRNode[]} nodes - All flow nodes
|
|
50
|
+
* @property {IRGroup[]} groups - All group nodes
|
|
51
|
+
* @property {IRLink[]} links - All wire connections
|
|
52
|
+
* @property {Set<string>} dirtyNodeIds - IDs of dirty (un-deployed) nodes
|
|
53
|
+
*/
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Default node dimensions if not specified.
|
|
57
|
+
*/
|
|
58
|
+
const DEFAULT_W = 100;
|
|
59
|
+
const DEFAULT_H = 30;
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Build the intermediate representation from raw flows data.
|
|
63
|
+
*
|
|
64
|
+
* @param {object[]} flows - Raw Node-RED flows array
|
|
65
|
+
* @param {object} options
|
|
66
|
+
* @param {string} [options.flowId] - Filter to a single flow
|
|
67
|
+
* @param {boolean} [options.highlightDirty=true] - Mark dirty nodes
|
|
68
|
+
* @param {Set<string>} [options.dirtyNodeIds] - Dirty node IDs
|
|
69
|
+
* @param {Set<string>} [options.dirtyFlowIds] - Dirty flow IDs
|
|
70
|
+
* @returns {IR}
|
|
71
|
+
* @throws {Error} If flowId is provided but not found
|
|
72
|
+
*/
|
|
73
|
+
export function buildIR(flows, options = {}) {
|
|
74
|
+
const {
|
|
75
|
+
flowId,
|
|
76
|
+
highlightDirty = true,
|
|
77
|
+
dirtyNodeIds = new Set(),
|
|
78
|
+
} = options;
|
|
79
|
+
|
|
80
|
+
let filteredFlows = flows;
|
|
81
|
+
|
|
82
|
+
// Filter by flow if requested
|
|
83
|
+
if (flowId) {
|
|
84
|
+
filteredFlows = flows.filter(
|
|
85
|
+
(n) => n.z === flowId || n.id === flowId
|
|
86
|
+
);
|
|
87
|
+
if (filteredFlows.length === 0 && !flows.some((n) => n.id === flowId)) {
|
|
88
|
+
throw new Error(`Flow not found: ${flowId}`);
|
|
89
|
+
}
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
// Separate nodes, groups, and junctions
|
|
93
|
+
const allFlowNodes = filteredFlows.filter(
|
|
94
|
+
(n) => n.type !== 'group' && n.type !== 'tab' && n.type !== 'junction'
|
|
95
|
+
);
|
|
96
|
+
const groups = filteredFlows.filter((n) => n.type === 'group');
|
|
97
|
+
const junctions = filteredFlows.filter((n) => n.type === 'junction');
|
|
98
|
+
|
|
99
|
+
// Build node ID set for this flow
|
|
100
|
+
const nodeIdSet = new Set(allFlowNodes.map((n) => n.id));
|
|
101
|
+
|
|
102
|
+
// Convert to IR nodes
|
|
103
|
+
const nodes = allFlowNodes.map((n) => {
|
|
104
|
+
const numWires = Array.isArray(n.wires) ? n.wires.length : 0;
|
|
105
|
+
return {
|
|
106
|
+
id: n.id,
|
|
107
|
+
type: n.type,
|
|
108
|
+
name: n.name || n.type,
|
|
109
|
+
x: n.x || 0,
|
|
110
|
+
y: n.y || 0,
|
|
111
|
+
w: n.w || DEFAULT_W,
|
|
112
|
+
h: n.h || DEFAULT_H,
|
|
113
|
+
inputs: n.inputs ?? 0,
|
|
114
|
+
outputs: n.outputs ?? numWires,
|
|
115
|
+
d: n.d === true,
|
|
116
|
+
dirty: highlightDirty ? dirtyNodeIds.has(n.id) : false,
|
|
117
|
+
z: n.z,
|
|
118
|
+
g: n.g,
|
|
119
|
+
wires: n.wires || [],
|
|
120
|
+
};
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
// Convert groups
|
|
124
|
+
const irGroups = groups.map((g) => ({
|
|
125
|
+
id: g.id,
|
|
126
|
+
name: g.name || 'Group',
|
|
127
|
+
x: g.x || 0,
|
|
128
|
+
y: g.y || 0,
|
|
129
|
+
w: g.w || 40,
|
|
130
|
+
h: g.h || 40,
|
|
131
|
+
style: g.style || {},
|
|
132
|
+
nodes: (g.nodes || []).filter((mid) => nodeIdSet.has(mid)),
|
|
133
|
+
}));
|
|
134
|
+
|
|
135
|
+
// Build links
|
|
136
|
+
const links = [];
|
|
137
|
+
for (const node of nodes) {
|
|
138
|
+
if (!node.wires || node.wires.length === 0) continue;
|
|
139
|
+
node.wires.forEach((targets, portIndex) => {
|
|
140
|
+
if (!Array.isArray(targets)) return;
|
|
141
|
+
for (const targetId of targets) {
|
|
142
|
+
const targetNode = nodes.find((n) => n.id === targetId);
|
|
143
|
+
if (targetNode) {
|
|
144
|
+
links.push({
|
|
145
|
+
source: node,
|
|
146
|
+
sourcePort: portIndex,
|
|
147
|
+
target: targetNode,
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
}
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
return {
|
|
155
|
+
nodes,
|
|
156
|
+
groups: irGroups,
|
|
157
|
+
links,
|
|
158
|
+
junctions,
|
|
159
|
+
dirtyNodeIds,
|
|
160
|
+
};
|
|
161
|
+
}
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Layout Utilities
|
|
3
|
+
*
|
|
4
|
+
* Computes bounding boxes, auto-fit viewport dimensions, and scale factors
|
|
5
|
+
* for rendering the staging workspace.
|
|
6
|
+
*
|
|
7
|
+
* @module renderer/layout
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Default canvas dimensions (matching Node-RED's virtual canvas).
|
|
12
|
+
*/
|
|
13
|
+
const CANVAS_WIDTH = 8000;
|
|
14
|
+
const CANVAS_HEIGHT = 8000;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Default viewport padding around nodes (in workspace units).
|
|
18
|
+
*/
|
|
19
|
+
const VIEWPORT_PADDING = 60;
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Compute the bounding box of a set of nodes and groups.
|
|
23
|
+
*
|
|
24
|
+
* @param {import('./ir-builder.js').IRNode[]} nodes - Flow nodes
|
|
25
|
+
* @param {import('./ir-builder.js').IRGroup[]} groups - Group nodes
|
|
26
|
+
* @returns {{ minX: number, minY: number, maxX: number, maxY: number, width: number, height: number }}
|
|
27
|
+
*/
|
|
28
|
+
export function computeBoundingBox(nodes, groups = []) {
|
|
29
|
+
if (nodes.length === 0 && groups.length === 0) {
|
|
30
|
+
return { minX: 0, minY: 0, maxX: 400, maxY: 200, width: 400, height: 200 };
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
let minX = Infinity;
|
|
34
|
+
let minY = Infinity;
|
|
35
|
+
let maxX = -Infinity;
|
|
36
|
+
let maxY = -Infinity;
|
|
37
|
+
|
|
38
|
+
for (const node of nodes) {
|
|
39
|
+
const left = node.x - node.w / 2;
|
|
40
|
+
const right = node.x + node.w / 2;
|
|
41
|
+
const top = node.y - node.h / 2;
|
|
42
|
+
const bottom = node.y + node.h / 2;
|
|
43
|
+
|
|
44
|
+
if (left < minX) minX = left;
|
|
45
|
+
if (right > maxX) maxX = right;
|
|
46
|
+
if (top < minY) minY = top;
|
|
47
|
+
if (bottom > maxY) maxY = bottom;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
// Include group boundaries
|
|
51
|
+
for (const group of groups) {
|
|
52
|
+
if (group.x < minX) minX = group.x;
|
|
53
|
+
if (group.y < minY) minY = group.y;
|
|
54
|
+
if (group.x + group.w > maxX) maxX = group.x + group.w;
|
|
55
|
+
if (group.y + group.h > maxY) maxY = group.y + group.h;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Add padding
|
|
59
|
+
minX -= VIEWPORT_PADDING;
|
|
60
|
+
minY -= VIEWPORT_PADDING;
|
|
61
|
+
maxX += VIEWPORT_PADDING;
|
|
62
|
+
maxY += VIEWPORT_PADDING;
|
|
63
|
+
|
|
64
|
+
return {
|
|
65
|
+
minX,
|
|
66
|
+
minY,
|
|
67
|
+
maxX,
|
|
68
|
+
maxY,
|
|
69
|
+
width: maxX - minX,
|
|
70
|
+
height: maxY - minY,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/**
|
|
75
|
+
* Calculate the scale factor to fit content within a viewport.
|
|
76
|
+
*
|
|
77
|
+
* @param {number} contentWidth - Width of content in workspace units
|
|
78
|
+
* @param {number} contentHeight - Height of content in workspace units
|
|
79
|
+
* @param {number} viewportWidth - Available viewport width in pixels
|
|
80
|
+
* @param {number} viewportHeight - Available viewport height in pixels
|
|
81
|
+
* @param {number} [maxScale=1.5] - Maximum allowed scale factor
|
|
82
|
+
* @param {number} [minScale=0.1] - Minimum allowed scale factor
|
|
83
|
+
* @returns {number} Scale factor (1.0 = 100%)
|
|
84
|
+
*/
|
|
85
|
+
export function calculateScaleFactor(
|
|
86
|
+
contentWidth,
|
|
87
|
+
contentHeight,
|
|
88
|
+
viewportWidth,
|
|
89
|
+
viewportHeight,
|
|
90
|
+
maxScale = 1.5,
|
|
91
|
+
minScale = 0.1
|
|
92
|
+
) {
|
|
93
|
+
if (contentWidth <= 0 || contentHeight <= 0) return 1;
|
|
94
|
+
|
|
95
|
+
const scaleX = viewportWidth / contentWidth;
|
|
96
|
+
const scaleY = viewportHeight / contentHeight;
|
|
97
|
+
const fitScale = Math.min(scaleX, scaleY);
|
|
98
|
+
|
|
99
|
+
return Math.max(minScale, Math.min(maxScale, fitScale));
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Calculate the SVG viewBox string to fit the content within the given dimensions.
|
|
104
|
+
*
|
|
105
|
+
* @param {import('./ir-builder.js').IRNode[]} nodes - Flow nodes
|
|
106
|
+
* @param {import('./ir-builder.js').IRGroup[]} groups - Group nodes
|
|
107
|
+
* @param {number} [targetWidth=800] - Target SVG width
|
|
108
|
+
* @param {number} [targetHeight=600] - Target SVG height
|
|
109
|
+
* @returns {{ viewBox: string, width: number, height: number }}
|
|
110
|
+
*/
|
|
111
|
+
export function calculateViewBox(nodes, groups = [], targetWidth = 800, targetHeight = 600) {
|
|
112
|
+
const bb = computeBoundingBox(nodes, groups);
|
|
113
|
+
const scale = calculateScaleFactor(bb.width, bb.height, targetWidth, targetHeight);
|
|
114
|
+
|
|
115
|
+
// Apply scale to get actual SVG dimensions
|
|
116
|
+
const svgWidth = Math.max(targetWidth, Math.ceil(bb.width * scale));
|
|
117
|
+
const svgHeight = Math.max(targetHeight, Math.ceil(bb.height * scale));
|
|
118
|
+
|
|
119
|
+
return {
|
|
120
|
+
viewBox: `${bb.minX} ${bb.minY} ${bb.width} ${bb.height}`,
|
|
121
|
+
width: svgWidth,
|
|
122
|
+
height: svgHeight,
|
|
123
|
+
};
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
export { CANVAS_WIDTH, CANVAS_HEIGHT, VIEWPORT_PADDING };
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Mermaid Builder
|
|
3
|
+
*
|
|
4
|
+
* Generates Mermaid flowchart TD diagrams from the intermediate representation.
|
|
5
|
+
* Consolidates and enhances the existing `generateMermaidDiagram()` logic
|
|
6
|
+
* from src/tools/get-flow-diagram.js with dirty highlighting support.
|
|
7
|
+
*
|
|
8
|
+
* @module renderer/mermaid-builder
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { getMermaidClass } from './colors.js';
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* Escape a string for use as a Mermaid node label.
|
|
15
|
+
*
|
|
16
|
+
* @param {string} label
|
|
17
|
+
* @returns {string}
|
|
18
|
+
*/
|
|
19
|
+
function escapeMermaidLabel(label) {
|
|
20
|
+
return `"${label.replace(/"/g, '#quot;')}"`;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/**
|
|
24
|
+
* Build a Mermaid flowchart TD diagram string from the IR.
|
|
25
|
+
*
|
|
26
|
+
* @param {import('./ir-builder.js').IR} ir - Intermediate representation
|
|
27
|
+
* @returns {string} Mermaid diagram string
|
|
28
|
+
*/
|
|
29
|
+
export function buildMermaid(ir) {
|
|
30
|
+
const { nodes, groups, links } = ir;
|
|
31
|
+
|
|
32
|
+
if (nodes.length === 0 && groups.length === 0) {
|
|
33
|
+
return 'flowchart TD\n %% Empty flow — no nodes to display';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const lines = ['flowchart TD'];
|
|
37
|
+
const hasDirty = nodes.some((n) => n.dirty);
|
|
38
|
+
const hasDisabled = nodes.some((n) => n.d);
|
|
39
|
+
|
|
40
|
+
// Build member-to-group lookup
|
|
41
|
+
const memberGroupMap = new Map();
|
|
42
|
+
for (const g of groups) {
|
|
43
|
+
for (const mid of g.nodes) {
|
|
44
|
+
memberGroupMap.set(mid, g);
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Identify grouped vs ungrouped nodes
|
|
49
|
+
const groupedIds = new Set();
|
|
50
|
+
for (const n of nodes) {
|
|
51
|
+
if (memberGroupMap.has(n.id)) {
|
|
52
|
+
groupedIds.add(n.id);
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
const ungroupedIds = new Set(nodes.map((n) => n.id).filter((id) => !groupedIds.has(id)));
|
|
56
|
+
|
|
57
|
+
const nodeIds = new Set(nodes.map((n) => n.id));
|
|
58
|
+
|
|
59
|
+
// Render ungrouped nodes
|
|
60
|
+
for (const node of nodes) {
|
|
61
|
+
if (!ungroupedIds.has(node.id)) continue;
|
|
62
|
+
const label = escapeMermaidLabel(node.name);
|
|
63
|
+
const classTag = getMermaidClass(node);
|
|
64
|
+
lines.push(` ${node.id}[${label}]${classTag}`);
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
// Render groups as subgraphs
|
|
68
|
+
for (const g of groups) {
|
|
69
|
+
const gMembers = (g.nodes || []).filter((mid) => nodeIds.has(mid));
|
|
70
|
+
const gLabel = escapeMermaidLabel(g.name || 'Group');
|
|
71
|
+
lines.push(` subgraph ${g.id}[${gLabel}]`);
|
|
72
|
+
for (const mid of gMembers) {
|
|
73
|
+
const member = nodes.find((n) => n.id === mid);
|
|
74
|
+
if (!member) continue;
|
|
75
|
+
const mLabel = escapeMermaidLabel(member.name);
|
|
76
|
+
const mClass = getMermaidClass(member);
|
|
77
|
+
lines.push(` ${mid}[${mLabel}]${mClass}`);
|
|
78
|
+
}
|
|
79
|
+
lines.push(' end');
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
// Edge definitions
|
|
83
|
+
for (const link of links) {
|
|
84
|
+
const { source, sourcePort, target } = link;
|
|
85
|
+
if (!nodeIds.has(target.id)) continue;
|
|
86
|
+
const edgeLabel = source.outputs > 1 ? `|out${sourcePort + 1}|` : '';
|
|
87
|
+
lines.push(` ${source.id} -->${edgeLabel} ${target.id}`);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
// Group style definitions
|
|
91
|
+
for (const g of groups) {
|
|
92
|
+
if (g.style && Object.keys(g.style).length > 0) {
|
|
93
|
+
const styleProps = Object.entries(g.style)
|
|
94
|
+
.map(([k, v]) => `${k}:${v}`)
|
|
95
|
+
.join(',');
|
|
96
|
+
lines.push(` style ${g.id} ${styleProps}`);
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
// Class definitions
|
|
101
|
+
if (hasDirty) {
|
|
102
|
+
lines.push(' classDef dirty stroke:#ff8c00,stroke-width:3px');
|
|
103
|
+
}
|
|
104
|
+
if (hasDisabled) {
|
|
105
|
+
lines.push(' classDef disabled stroke-dasharray:5 5,stroke:#999,color:#999');
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
return lines.join('\n');
|
|
109
|
+
}
|
|
@@ -0,0 +1,228 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* SVG Builder
|
|
3
|
+
*
|
|
4
|
+
* Generates server-side SVG strings from the intermediate representation.
|
|
5
|
+
* Produces a static SVG suitable for embedding in chat responses.
|
|
6
|
+
*
|
|
7
|
+
* @module renderer/svg-builder
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { generateLinkPath } from './geometry.js';
|
|
11
|
+
import { computeBoundingBox, calculateViewBox } from './layout.js';
|
|
12
|
+
import {
|
|
13
|
+
getNodeColor,
|
|
14
|
+
getNodeStyle,
|
|
15
|
+
DEFAULT_COLOR,
|
|
16
|
+
DISABLED_STYLE,
|
|
17
|
+
} from './colors.js';
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Build an SVG string from the intermediate representation.
|
|
21
|
+
*
|
|
22
|
+
* @param {import('./ir-builder.js').IR} ir - Intermediate representation
|
|
23
|
+
* @returns {string} Complete SVG document as a string
|
|
24
|
+
*/
|
|
25
|
+
export function buildSVG(ir) {
|
|
26
|
+
const { nodes, groups, links } = ir;
|
|
27
|
+
const { viewBox, width, height } = calculateViewBox(nodes, groups);
|
|
28
|
+
const hasDirtyNodes = nodes.some((n) => n.dirty);
|
|
29
|
+
|
|
30
|
+
const parts = [];
|
|
31
|
+
|
|
32
|
+
// SVG header
|
|
33
|
+
parts.push(
|
|
34
|
+
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="${viewBox}" width="${width}" height="${height}">`
|
|
35
|
+
);
|
|
36
|
+
|
|
37
|
+
// Defs
|
|
38
|
+
parts.push(' <defs>');
|
|
39
|
+
parts.push(
|
|
40
|
+
` <pattern id="grid" width="20" height="20" patternUnits="userSpaceOnUse">`
|
|
41
|
+
);
|
|
42
|
+
parts.push(
|
|
43
|
+
` <path d="M 20 0 L 0 0 0 20" fill="none" stroke="#e0e0e0" stroke-width="0.5"/>`
|
|
44
|
+
);
|
|
45
|
+
parts.push(` </pattern>`);
|
|
46
|
+
parts.push(' </defs>');
|
|
47
|
+
|
|
48
|
+
// Grid background
|
|
49
|
+
parts.push(
|
|
50
|
+
` <rect width="100%" height="100%" fill="url(#grid)" class="nr-grid"/>`
|
|
51
|
+
);
|
|
52
|
+
|
|
53
|
+
// Groups layer
|
|
54
|
+
for (const g of groups) {
|
|
55
|
+
parts.push(...buildGroupSVG(g));
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
// Links layer
|
|
59
|
+
for (const link of links) {
|
|
60
|
+
parts.push(...buildLinkSVG(link));
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Nodes layer
|
|
64
|
+
for (const node of nodes) {
|
|
65
|
+
parts.push(...buildNodeSVG(node));
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// Legend for dirty highlighting
|
|
69
|
+
if (hasDirtyNodes) {
|
|
70
|
+
parts.push(...buildLegend(nodes));
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
// SVG footer
|
|
74
|
+
parts.push('</svg>');
|
|
75
|
+
|
|
76
|
+
return parts.join('\n');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Build SVG elements for a group.
|
|
81
|
+
*
|
|
82
|
+
* @param {import('./ir-builder.js').IRGroup} g - Group
|
|
83
|
+
* @returns {string[]} SVG lines
|
|
84
|
+
*/
|
|
85
|
+
function buildGroupSVG(g) {
|
|
86
|
+
const lines = [];
|
|
87
|
+
const label = escapeXml(g.name);
|
|
88
|
+
const fillColor = g.style?.fill || 'none';
|
|
89
|
+
const strokeColor = g.style?.stroke || '#999';
|
|
90
|
+
const strokeOpacity = g.style?.['stroke-opacity'] ?? 0.5;
|
|
91
|
+
|
|
92
|
+
lines.push(
|
|
93
|
+
` <g class="nr-group" id="${g.id}">`
|
|
94
|
+
);
|
|
95
|
+
lines.push(
|
|
96
|
+
` <rect x="${g.x}" y="${g.y}" width="${g.w}" height="${g.h}" rx="4" ry="4" fill="${fillColor}" fill-opacity="${g.style?.['fill-opacity'] ?? 0.1}" stroke="${strokeColor}" stroke-opacity="${strokeOpacity}" stroke-dasharray="5,3"/>`
|
|
97
|
+
);
|
|
98
|
+
lines.push(
|
|
99
|
+
` <text x="${g.x + 5}" y="${g.y + 15}" font-size="10" fill="${g.style?.color || '#999'}" font-family="sans-serif">${label}</text>`
|
|
100
|
+
);
|
|
101
|
+
lines.push(` </g>`);
|
|
102
|
+
return lines;
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/**
|
|
106
|
+
* Build SVG elements for a wire/link.
|
|
107
|
+
*
|
|
108
|
+
* @param {import('./ir-builder.js').IRLink} link - Link
|
|
109
|
+
* @returns {string[]} SVG lines
|
|
110
|
+
*/
|
|
111
|
+
function buildLinkSVG(link) {
|
|
112
|
+
const { source, sourcePort, target } = link;
|
|
113
|
+
|
|
114
|
+
// Calculate port Y positions
|
|
115
|
+
const numOutputs = source.outputs || 1;
|
|
116
|
+
const portY = -((numOutputs - 1) / 2) * 13 + 13 * sourcePort;
|
|
117
|
+
|
|
118
|
+
const x1 = source.x + source.w / 2;
|
|
119
|
+
const y1 = source.y + portY;
|
|
120
|
+
const x2 = target.x - target.w / 2;
|
|
121
|
+
const y2 = target.y;
|
|
122
|
+
|
|
123
|
+
const pathD = generateLinkPath(x1, y1, x2, y2, 1);
|
|
124
|
+
|
|
125
|
+
return [
|
|
126
|
+
` <path class="nr-link" d="${pathD}" fill="none" stroke="#999" stroke-width="1.5"/>`,
|
|
127
|
+
];
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
/**
|
|
131
|
+
* Build SVG elements for a node.
|
|
132
|
+
*
|
|
133
|
+
* @param {import('./ir-builder.js').IRNode} node - Node
|
|
134
|
+
* @returns {string[]} SVG lines
|
|
135
|
+
*/
|
|
136
|
+
function buildNodeSVG(node) {
|
|
137
|
+
const lines = [];
|
|
138
|
+
const color = getNodeColor(node.type);
|
|
139
|
+
const style = getNodeStyle(node, color);
|
|
140
|
+
const label = escapeXml(truncateLabel(node.name, 15));
|
|
141
|
+
const x = node.x - node.w / 2;
|
|
142
|
+
const y = node.y - node.h / 2;
|
|
143
|
+
|
|
144
|
+
lines.push(` <g class="nr-node-group" id="${node.id}">`);
|
|
145
|
+
|
|
146
|
+
// Main node rectangle
|
|
147
|
+
lines.push(
|
|
148
|
+
` <rect class="nr-node" x="${x}" y="${y}" width="${node.w}" height="${node.h}" rx="5" ry="5" style="${style}"/>`
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
// Dirty highlight overlay
|
|
152
|
+
if (node.dirty) {
|
|
153
|
+
lines.push(
|
|
154
|
+
` <rect class="nr-node-dirty-glow" x="${x - 2}" y="${y - 2}" width="${node.w + 4}" height="${node.h + 4}" rx="6" ry="6" fill="none" stroke="#ff8c00" stroke-width="2.5"/>`
|
|
155
|
+
);
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
// Node label
|
|
159
|
+
lines.push(
|
|
160
|
+
` <text x="${node.x}" y="${node.y + 5}" text-anchor="middle" font-size="10" fill="#333" font-family="sans-serif">${label}</text>`
|
|
161
|
+
);
|
|
162
|
+
|
|
163
|
+
// Input port indicator
|
|
164
|
+
if (node.inputs > 0) {
|
|
165
|
+
lines.push(
|
|
166
|
+
` <rect class="nr-port" x="${x - 4}" y="${y + node.h / 2 - 4}" width="8" height="8" rx="2" fill="#999"/>`
|
|
167
|
+
);
|
|
168
|
+
}
|
|
169
|
+
|
|
170
|
+
// Output port indicators
|
|
171
|
+
const numOutputs = node.outputs || 0;
|
|
172
|
+
for (let i = 0; i < numOutputs; i++) {
|
|
173
|
+
const portY = y + node.h / 2 - ((numOutputs - 1) / 2) * 13 + 13 * i - 4;
|
|
174
|
+
lines.push(
|
|
175
|
+
` <rect class="nr-port" x="${x + node.w - 4}" y="${portY}" width="8" height="8" rx="2" fill="#999"/>`
|
|
176
|
+
);
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
lines.push(` </g>`);
|
|
180
|
+
return lines;
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/**
|
|
184
|
+
* Build a legend explaining the dirty highlight.
|
|
185
|
+
*
|
|
186
|
+
* @param {import('./ir-builder.js').IRNode[]} nodes - All nodes (to find bounding box top-right)
|
|
187
|
+
* @returns {string[]} SVG lines
|
|
188
|
+
*/
|
|
189
|
+
function buildLegend(nodes) {
|
|
190
|
+
const bb = computeBoundingBox(nodes);
|
|
191
|
+
const legendX = bb.maxX - 180;
|
|
192
|
+
const legendY = bb.minY + 10;
|
|
193
|
+
|
|
194
|
+
return [
|
|
195
|
+
` <g class="nr-legend" transform="translate(${legendX}, ${legendY})">`,
|
|
196
|
+
` <rect x="0" y="0" width="170" height="28" rx="4" fill="white" fill-opacity="0.9" stroke="#ccc"/>`,
|
|
197
|
+
` <rect x="8" y="8" width="16" height="12" rx="2" fill="${DEFAULT_COLOR}" stroke="#ff8c00" stroke-width="2"/>`,
|
|
198
|
+
` <text x="30" y="18" font-size="10" fill="#666" font-family="sans-serif">= Un-deployed changes</text>`,
|
|
199
|
+
` </g>`,
|
|
200
|
+
];
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
/**
|
|
204
|
+
* Escape a string for safe inclusion in XML/SVG.
|
|
205
|
+
*
|
|
206
|
+
* @param {string} str
|
|
207
|
+
* @returns {string}
|
|
208
|
+
*/
|
|
209
|
+
function escapeXml(str) {
|
|
210
|
+
return str
|
|
211
|
+
.replace(/&/g, '&')
|
|
212
|
+
.replace(/</g, '<')
|
|
213
|
+
.replace(/>/g, '>')
|
|
214
|
+
.replace(/"/g, '"')
|
|
215
|
+
.replace(/'/g, ''');
|
|
216
|
+
}
|
|
217
|
+
|
|
218
|
+
/**
|
|
219
|
+
* Truncate a label to a maximum length, adding ellipsis if needed.
|
|
220
|
+
*
|
|
221
|
+
* @param {string} label
|
|
222
|
+
* @param {number} maxLen
|
|
223
|
+
* @returns {string}
|
|
224
|
+
*/
|
|
225
|
+
function truncateLabel(label, maxLen) {
|
|
226
|
+
if (label.length <= maxLen) return label;
|
|
227
|
+
return label.substring(0, maxLen - 1) + '…';
|
|
228
|
+
}
|