@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.
Files changed (89) hide show
  1. package/LICENSE +201 -0
  2. package/README.md +162 -0
  3. package/index.js +133 -0
  4. package/package.json +58 -0
  5. package/resources/skills/nodered-flow-builder/SKILL.md +659 -0
  6. package/resources/skills/nodered-flow-layout/SKILL.md +395 -0
  7. package/resources/skills/nodered-flowfuse-dashboard/SKILL.md +941 -0
  8. package/resources/skills/nodered-fundamentals/SKILL.md +323 -0
  9. package/resources/skills/nodered-jsonata/SKILL.md +1039 -0
  10. package/resources/skills/nodered-mustache/SKILL.md +588 -0
  11. package/resources/skills/nodered-node-reference/SKILL.md +1020 -0
  12. package/resources/skills/nodered-node-reference/examples/common.json +113 -0
  13. package/resources/skills/nodered-node-reference/examples/network.json +107 -0
  14. package/resources/skills/nodered-node-reference/examples/parser.json +147 -0
  15. package/resources/skills/nodered-node-reference/examples/sequence.json +141 -0
  16. package/resources/skills/nodered-node-reference/examples/storage.json +104 -0
  17. package/resources/skills/nodered-patterns/SKILL.md +414 -0
  18. package/resources/skills/nodered-patterns/examples/error-handler.json +72 -0
  19. package/resources/skills/nodered-patterns/examples/http-endpoint.json +42 -0
  20. package/resources/skills/nodered-patterns/examples/mqtt-subscriber.json +47 -0
  21. package/resources/skills/nodered-patterns/examples/timer-flow.json +50 -0
  22. package/resources/skills/nodered-subflows/SKILL.md +261 -0
  23. package/resources/skills/nodered-uibuilder/SKILL.md +500 -0
  24. package/src/auth/api-key-verifier.js +36 -0
  25. package/src/auth/composite-verifier.js +59 -0
  26. package/src/auth/config.js +106 -0
  27. package/src/auth/oauth-clients-store.js +107 -0
  28. package/src/auth/oauth-provider.js +149 -0
  29. package/src/auth/oauth-token-store.js +312 -0
  30. package/src/nodered/auth.js +158 -0
  31. package/src/nodered/client.js +199 -0
  32. package/src/nodered/comms-client.js +500 -0
  33. package/src/renderer/colors.js +161 -0
  34. package/src/renderer/geometry.js +115 -0
  35. package/src/renderer/html-builder.js +571 -0
  36. package/src/renderer/index.js +51 -0
  37. package/src/renderer/ir-builder.js +161 -0
  38. package/src/renderer/layout.js +126 -0
  39. package/src/renderer/mermaid-builder.js +109 -0
  40. package/src/renderer/svg-builder.js +228 -0
  41. package/src/schemas/responses.js +283 -0
  42. package/src/server.js +844 -0
  43. package/src/skills/loader.js +84 -0
  44. package/src/staging-store.js +258 -0
  45. package/src/tools/add-nodes-to-group.js +216 -0
  46. package/src/tools/connect-nodes.js +115 -0
  47. package/src/tools/constants.js +45 -0
  48. package/src/tools/create-flow.js +87 -0
  49. package/src/tools/create-node.js +126 -0
  50. package/src/tools/create-subflow-instance.js +123 -0
  51. package/src/tools/create-subflow.js +101 -0
  52. package/src/tools/delete-context.js +60 -0
  53. package/src/tools/delete-flow.js +81 -0
  54. package/src/tools/delete-group.js +116 -0
  55. package/src/tools/delete-node.js +73 -0
  56. package/src/tools/delete-subflow.js +103 -0
  57. package/src/tools/deploy.js +94 -0
  58. package/src/tools/disconnect-nodes.js +158 -0
  59. package/src/tools/export-flow.js +161 -0
  60. package/src/tools/export-subflow.js +78 -0
  61. package/src/tools/flow-utils.js +376 -0
  62. package/src/tools/get-config-nodes.js +86 -0
  63. package/src/tools/get-context.js +76 -0
  64. package/src/tools/get-flow-diagram.js +99 -0
  65. package/src/tools/get-flow-nodes.js +116 -0
  66. package/src/tools/get-flows.js +74 -0
  67. package/src/tools/get-node-detail.js +77 -0
  68. package/src/tools/get-node-type-detail.js +92 -0
  69. package/src/tools/get-palette-nodes.js +63 -0
  70. package/src/tools/get-staging-status.js +34 -0
  71. package/src/tools/get-subflow-detail.js +110 -0
  72. package/src/tools/get-subflows.js +105 -0
  73. package/src/tools/import-flow.js +310 -0
  74. package/src/tools/inject-message.js +117 -0
  75. package/src/tools/install-node.js +31 -0
  76. package/src/tools/read-debug-messages.js +155 -0
  77. package/src/tools/refresh-staging.js +62 -0
  78. package/src/tools/remove-nodes-from-group.js +162 -0
  79. package/src/tools/render-staging.js +69 -0
  80. package/src/tools/response-utils.js +42 -0
  81. package/src/tools/search-nodes.js +134 -0
  82. package/src/tools/uninstall-node.js +31 -0
  83. package/src/tools/update-flow.js +95 -0
  84. package/src/tools/update-group.js +77 -0
  85. package/src/tools/update-node.js +132 -0
  86. package/src/tools/update-subflow.js +84 -0
  87. package/src/transport/http.js +252 -0
  88. package/src/transport/stdio.js +16 -0
  89. 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, '&amp;')
212
+ .replace(/</g, '&lt;')
213
+ .replace(/>/g, '&gt;')
214
+ .replace(/"/g, '&quot;')
215
+ .replace(/'/g, '&apos;');
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
+ }