archgraph 0.1.0
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 +21 -0
- package/README.md +123 -0
- package/bin/bgx.js +2 -0
- package/docs/examples/executors.example.json +15 -0
- package/docs/examples/view-spec.yaml +6 -0
- package/docs/release.md +15 -0
- package/docs/view-spec.md +28 -0
- package/integrations/README.md +20 -0
- package/integrations/claude/.claude/skills/backend-graphing/SKILL.md +56 -0
- package/integrations/claude/.claude/skills/backend-graphing-describe/SKILL.md +50 -0
- package/integrations/claude/.claude-plugin/marketplace.json +18 -0
- package/integrations/claude/.claude-plugin/plugin.json +9 -0
- package/integrations/claude/skills/backend-graphing/SKILL.md +56 -0
- package/integrations/claude/skills/backend-graphing-describe/SKILL.md +50 -0
- package/integrations/codex/skills/backend-graphing/SKILL.md +56 -0
- package/integrations/codex/skills/backend-graphing-describe/SKILL.md +50 -0
- package/package.json +49 -0
- package/packages/cli/src/index.js +415 -0
- package/packages/core/src/analyze-project.js +1238 -0
- package/packages/core/src/export.js +77 -0
- package/packages/core/src/index.js +4 -0
- package/packages/core/src/types.js +37 -0
- package/packages/core/src/view.js +86 -0
- package/packages/viewer/public/app.js +226 -0
- package/packages/viewer/public/canvas.js +181 -0
- package/packages/viewer/public/comments.js +193 -0
- package/packages/viewer/public/index.html +95 -0
- package/packages/viewer/public/layout.js +72 -0
- package/packages/viewer/public/minimap.js +92 -0
- package/packages/viewer/public/render.js +366 -0
- package/packages/viewer/public/sidebar.js +107 -0
- package/packages/viewer/public/styles.css +728 -0
- package/packages/viewer/public/theme.js +19 -0
- package/packages/viewer/public/tooltip.js +44 -0
- package/packages/viewer/src/index.js +590 -0
|
@@ -0,0 +1,366 @@
|
|
|
1
|
+
// render.js — Graph rendering, comment pins, node repositioning
|
|
2
|
+
|
|
3
|
+
import { layoutDag } from './layout.js';
|
|
4
|
+
import { nodePositions, canvasTransform } from './canvas.js';
|
|
5
|
+
import { showTooltip, moveTooltip, hideTooltip } from './tooltip.js';
|
|
6
|
+
import { openCommentOverlay, getMostUrgentStatus } from './comments.js';
|
|
7
|
+
|
|
8
|
+
export const collapsedGroups = new Set(); // group IDs
|
|
9
|
+
export const groupRoots = new Map(); // groupId → rootNodeId
|
|
10
|
+
|
|
11
|
+
let canvasGroup = null;
|
|
12
|
+
let graphSvg = null;
|
|
13
|
+
let viewRef = null;
|
|
14
|
+
let currentTasks = [];
|
|
15
|
+
let onSelectNode = null; // callback(nodeId)
|
|
16
|
+
let selectedNodeId = null;
|
|
17
|
+
|
|
18
|
+
export function initRender(svgEl, groupEl, { onSelect }) {
|
|
19
|
+
graphSvg = svgEl;
|
|
20
|
+
canvasGroup = groupEl;
|
|
21
|
+
onSelectNode = onSelect;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function setRenderView(view) {
|
|
25
|
+
viewRef = view;
|
|
26
|
+
computeGroupRoots(view);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function setSelectedNode(nodeId) {
|
|
30
|
+
selectedNodeId = nodeId;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function setCurrentTasks(tasks) {
|
|
34
|
+
currentTasks = tasks;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
function computeGroupRoots(view) {
|
|
38
|
+
groupRoots.clear();
|
|
39
|
+
for (const group of view.groups ?? []) {
|
|
40
|
+
// Find the node within the group that has the most incoming edges from outside the group
|
|
41
|
+
// Simplest heuristic: the endpoint node (kind === 'endpoint' or 'ui_route')
|
|
42
|
+
const memberSet = new Set(group.memberNodeIds);
|
|
43
|
+
let root = null;
|
|
44
|
+
for (const nodeId of group.memberNodeIds) {
|
|
45
|
+
const node = view.nodes.find((n) => n.id === nodeId);
|
|
46
|
+
if (node && (node.kind === 'endpoint' || node.kind === 'ui_route')) {
|
|
47
|
+
root = nodeId;
|
|
48
|
+
break;
|
|
49
|
+
}
|
|
50
|
+
}
|
|
51
|
+
// Fallback: node with no incoming edges from within the group
|
|
52
|
+
if (!root) {
|
|
53
|
+
const internalEdges = new Set(
|
|
54
|
+
view.edges.filter((e) => memberSet.has(e.from) && memberSet.has(e.to)).map((e) => e.to),
|
|
55
|
+
);
|
|
56
|
+
for (const nodeId of group.memberNodeIds) {
|
|
57
|
+
if (!internalEdges.has(nodeId)) {
|
|
58
|
+
root = nodeId;
|
|
59
|
+
break;
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
if (!root && group.memberNodeIds.length > 0) {
|
|
64
|
+
root = group.memberNodeIds[0];
|
|
65
|
+
}
|
|
66
|
+
if (root) groupRoots.set(group.id, root);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// Returns { nodes, edges } with collapse logic applied
|
|
71
|
+
export function applyCollapse(nodes, edges) {
|
|
72
|
+
if (collapsedGroups.size === 0) return { nodes, edges };
|
|
73
|
+
|
|
74
|
+
const hiddenNodes = new Set();
|
|
75
|
+
for (const groupId of collapsedGroups) {
|
|
76
|
+
const group = viewRef?.groups?.find((g) => g.id === groupId);
|
|
77
|
+
if (!group) continue;
|
|
78
|
+
const root = groupRoots.get(groupId);
|
|
79
|
+
for (const memberId of group.memberNodeIds) {
|
|
80
|
+
if (memberId !== root) hiddenNodes.add(memberId);
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
const visibleNodes = nodes.filter((n) => !hiddenNodes.has(n.id));
|
|
85
|
+
const visibleIds = new Set(visibleNodes.map((n) => n.id));
|
|
86
|
+
|
|
87
|
+
// Remap edges: if an edge goes to a hidden node, redirect to the group root
|
|
88
|
+
const remappedEdges = [];
|
|
89
|
+
for (const edge of edges) {
|
|
90
|
+
const fromVisible = visibleIds.has(edge.from);
|
|
91
|
+
const toVisible = visibleIds.has(edge.to);
|
|
92
|
+
if (fromVisible && toVisible) {
|
|
93
|
+
remappedEdges.push(edge);
|
|
94
|
+
} else if (fromVisible && !toVisible) {
|
|
95
|
+
// Find which collapsed group contains edge.to
|
|
96
|
+
for (const groupId of collapsedGroups) {
|
|
97
|
+
const group = viewRef?.groups?.find((g) => g.id === groupId);
|
|
98
|
+
if (group?.memberNodeIds.includes(edge.to)) {
|
|
99
|
+
const root = groupRoots.get(groupId);
|
|
100
|
+
if (root && visibleIds.has(root) && edge.from !== root) {
|
|
101
|
+
remappedEdges.push({ ...edge, to: root });
|
|
102
|
+
}
|
|
103
|
+
break;
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
return { nodes: visibleNodes, edges: remappedEdges };
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
export function renderGraph(nodes, edges) {
|
|
113
|
+
// Clear canvas group content (but keep defs)
|
|
114
|
+
const existingDefs = canvasGroup.querySelector('defs');
|
|
115
|
+
canvasGroup.innerHTML = '';
|
|
116
|
+
|
|
117
|
+
// Add defs with arrowhead marker
|
|
118
|
+
const defs = document.createElementNS('http://www.w3.org/2000/svg', 'defs');
|
|
119
|
+
defs.innerHTML = `
|
|
120
|
+
<marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
121
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#51627f" />
|
|
122
|
+
</marker>
|
|
123
|
+
<marker id="arrow-cond" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">
|
|
124
|
+
<polygon points="0 0, 10 3.5, 0 7" fill="#b45309" />
|
|
125
|
+
</marker>
|
|
126
|
+
`;
|
|
127
|
+
canvasGroup.appendChild(defs);
|
|
128
|
+
|
|
129
|
+
if (!nodes.length) return;
|
|
130
|
+
|
|
131
|
+
// Layout: only compute positions for nodes not already in nodePositions
|
|
132
|
+
const newNodes = nodes.filter((n) => !nodePositions.has(n.id));
|
|
133
|
+
if (newNodes.length > 0) {
|
|
134
|
+
const layoutPoints = layoutDag(nodes, edges);
|
|
135
|
+
for (const [id, pos] of layoutPoints) {
|
|
136
|
+
if (!nodePositions.has(id)) {
|
|
137
|
+
nodePositions.set(id, pos);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
// Render edges
|
|
143
|
+
for (const edge of edges) {
|
|
144
|
+
const from = nodePositions.get(edge.from);
|
|
145
|
+
const to = nodePositions.get(edge.to);
|
|
146
|
+
if (!from || !to) continue;
|
|
147
|
+
|
|
148
|
+
const cls = edgeClass(edge.kind);
|
|
149
|
+
const isCondition = cls === 'edge-condition';
|
|
150
|
+
|
|
151
|
+
// Draw curved path instead of straight line for nicer look
|
|
152
|
+
const x1 = from.x + 90;
|
|
153
|
+
const y1 = from.y;
|
|
154
|
+
const x2 = to.x - 90;
|
|
155
|
+
const y2 = to.y;
|
|
156
|
+
const mx = (x1 + x2) / 2;
|
|
157
|
+
|
|
158
|
+
const path = document.createElementNS('http://www.w3.org/2000/svg', 'path');
|
|
159
|
+
path.setAttribute('d', `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`);
|
|
160
|
+
path.setAttribute('class', `edge ${cls}`);
|
|
161
|
+
path.setAttribute('fill', 'none');
|
|
162
|
+
path.setAttribute('marker-end', isCondition ? 'url(#arrow-cond)' : 'url(#arrow)');
|
|
163
|
+
path.setAttribute('data-from', edge.from);
|
|
164
|
+
path.setAttribute('data-to', edge.to);
|
|
165
|
+
canvasGroup.appendChild(path);
|
|
166
|
+
|
|
167
|
+
if (edge.label) {
|
|
168
|
+
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
169
|
+
label.setAttribute('x', String(mx));
|
|
170
|
+
label.setAttribute('y', String((y1 + y2) / 2 - 6));
|
|
171
|
+
label.setAttribute('class', 'edge-label');
|
|
172
|
+
label.setAttribute('text-anchor', 'middle');
|
|
173
|
+
label.textContent = edge.label;
|
|
174
|
+
canvasGroup.appendChild(label);
|
|
175
|
+
}
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
// Render nodes
|
|
179
|
+
for (const node of nodes) {
|
|
180
|
+
const pos = nodePositions.get(node.id);
|
|
181
|
+
if (!pos) continue;
|
|
182
|
+
|
|
183
|
+
const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
184
|
+
group.setAttribute('class', `node ${nodeClass(node.kind)} ${selectedNodeId === node.id ? 'selected' : ''}`);
|
|
185
|
+
group.setAttribute('transform', `translate(${pos.x},${pos.y})`);
|
|
186
|
+
group.dataset.nodeId = node.id;
|
|
187
|
+
|
|
188
|
+
const rect = document.createElementNS('http://www.w3.org/2000/svg', 'rect');
|
|
189
|
+
rect.setAttribute('x', '-90');
|
|
190
|
+
rect.setAttribute('y', '-28');
|
|
191
|
+
rect.setAttribute('width', '180');
|
|
192
|
+
rect.setAttribute('height', '56');
|
|
193
|
+
rect.setAttribute('rx', '10');
|
|
194
|
+
group.appendChild(rect);
|
|
195
|
+
|
|
196
|
+
const kindLabel = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
197
|
+
kindLabel.setAttribute('x', '-78');
|
|
198
|
+
kindLabel.setAttribute('y', '-8');
|
|
199
|
+
kindLabel.setAttribute('class', 'node-kind');
|
|
200
|
+
kindLabel.textContent = node.kind;
|
|
201
|
+
group.appendChild(kindLabel);
|
|
202
|
+
|
|
203
|
+
const label = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
204
|
+
label.setAttribute('x', '-78');
|
|
205
|
+
label.setAttribute('y', '14');
|
|
206
|
+
label.setAttribute('class', 'node-label');
|
|
207
|
+
label.textContent = node.label.length > 30 ? `${node.label.slice(0, 27)}…` : node.label;
|
|
208
|
+
group.appendChild(label);
|
|
209
|
+
|
|
210
|
+
// Collapse toggle for endpoint/agent groups
|
|
211
|
+
const ownsGroup = findNodeGroup(node.id);
|
|
212
|
+
if (ownsGroup && ownsGroup.memberNodeIds.length > 1) {
|
|
213
|
+
const toggle = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
214
|
+
toggle.setAttribute('class', 'collapse-toggle');
|
|
215
|
+
toggle.dataset.groupId = ownsGroup.id;
|
|
216
|
+
|
|
217
|
+
const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
218
|
+
circle.setAttribute('cx', '74');
|
|
219
|
+
circle.setAttribute('cy', '-20');
|
|
220
|
+
circle.setAttribute('r', '9');
|
|
221
|
+
toggle.appendChild(circle);
|
|
222
|
+
|
|
223
|
+
const isCollapsed = collapsedGroups.has(ownsGroup.id);
|
|
224
|
+
const sign = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
225
|
+
sign.setAttribute('x', '74');
|
|
226
|
+
sign.setAttribute('y', '-16');
|
|
227
|
+
sign.setAttribute('text-anchor', 'middle');
|
|
228
|
+
sign.setAttribute('class', 'collapse-sign');
|
|
229
|
+
sign.textContent = isCollapsed ? '+' : '−';
|
|
230
|
+
toggle.appendChild(sign);
|
|
231
|
+
|
|
232
|
+
toggle.addEventListener('click', (e) => {
|
|
233
|
+
e.stopPropagation();
|
|
234
|
+
const groupId = e.currentTarget.dataset.groupId;
|
|
235
|
+
if (collapsedGroups.has(groupId)) {
|
|
236
|
+
collapsedGroups.delete(groupId);
|
|
237
|
+
} else {
|
|
238
|
+
collapsedGroups.add(groupId);
|
|
239
|
+
}
|
|
240
|
+
if (window.__bgxRender) window.__bgxRender();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
group.appendChild(toggle);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// Tooltip
|
|
247
|
+
group.addEventListener('pointerenter', (e) => {
|
|
248
|
+
showTooltip(e.clientX, e.clientY, node);
|
|
249
|
+
});
|
|
250
|
+
group.addEventListener('pointermove', (e) => {
|
|
251
|
+
moveTooltip(e.clientX, e.clientY);
|
|
252
|
+
});
|
|
253
|
+
group.addEventListener('pointerleave', () => {
|
|
254
|
+
hideTooltip();
|
|
255
|
+
});
|
|
256
|
+
|
|
257
|
+
canvasGroup.appendChild(group);
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
// Register repositionNodeAndEdges globally for canvas.js to call
|
|
261
|
+
window.__bgxRepositionNode = (nodeId) => repositionNodeAndEdges(nodeId);
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
export function renderCommentPins(tasks) {
|
|
265
|
+
// Remove old pins
|
|
266
|
+
for (const pin of canvasGroup.querySelectorAll('.comment-pin')) pin.remove();
|
|
267
|
+
|
|
268
|
+
// Group tasks by nodeId
|
|
269
|
+
const pinsByNode = new Map();
|
|
270
|
+
for (const task of tasks) {
|
|
271
|
+
const nid = task.target?.nodeId;
|
|
272
|
+
if (!nid) continue;
|
|
273
|
+
if (!pinsByNode.has(nid)) pinsByNode.set(nid, []);
|
|
274
|
+
pinsByNode.get(nid).push(task);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
for (const [nodeId, nodeTasks] of pinsByNode) {
|
|
278
|
+
const pos = nodePositions.get(nodeId);
|
|
279
|
+
if (!pos) continue;
|
|
280
|
+
|
|
281
|
+
const pin = document.createElementNS('http://www.w3.org/2000/svg', 'g');
|
|
282
|
+
pin.setAttribute('class', 'comment-pin');
|
|
283
|
+
pin.dataset.nodeId = nodeId;
|
|
284
|
+
pin.setAttribute('transform', `translate(${pos.x + 90},${pos.y - 38})`);
|
|
285
|
+
|
|
286
|
+
const status = getMostUrgentStatus(nodeTasks);
|
|
287
|
+
|
|
288
|
+
const bubble = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
|
|
289
|
+
bubble.setAttribute('r', '12');
|
|
290
|
+
bubble.setAttribute('class', `comment-bubble status-${status}`);
|
|
291
|
+
pin.appendChild(bubble);
|
|
292
|
+
|
|
293
|
+
const countText = document.createElementNS('http://www.w3.org/2000/svg', 'text');
|
|
294
|
+
countText.setAttribute('text-anchor', 'middle');
|
|
295
|
+
countText.setAttribute('dominant-baseline', 'central');
|
|
296
|
+
countText.setAttribute('class', 'comment-count');
|
|
297
|
+
countText.textContent = nodeTasks.length > 9 ? '9+' : String(nodeTasks.length);
|
|
298
|
+
pin.appendChild(countText);
|
|
299
|
+
|
|
300
|
+
pin.addEventListener('click', (e) => {
|
|
301
|
+
e.stopPropagation();
|
|
302
|
+
openCommentOverlay(nodeId, nodeTasks, pos, canvasTransform, graphSvg);
|
|
303
|
+
});
|
|
304
|
+
|
|
305
|
+
canvasGroup.appendChild(pin);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
export function repositionNodeAndEdges(nodeId) {
|
|
310
|
+
const pos = nodePositions.get(nodeId);
|
|
311
|
+
if (!pos) return;
|
|
312
|
+
|
|
313
|
+
// Move node group
|
|
314
|
+
const nodeGroup = canvasGroup.querySelector(`g[data-node-id="${nodeId}"]`);
|
|
315
|
+
if (nodeGroup) {
|
|
316
|
+
nodeGroup.setAttribute('transform', `translate(${pos.x},${pos.y})`);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
// Update connected edges (paths with data-from or data-to)
|
|
320
|
+
for (const pathEl of canvasGroup.querySelectorAll(`path[data-from="${nodeId}"], path[data-to="${nodeId}"]`)) {
|
|
321
|
+
const fromId = pathEl.getAttribute('data-from');
|
|
322
|
+
const toId = pathEl.getAttribute('data-to');
|
|
323
|
+
const from = nodePositions.get(fromId);
|
|
324
|
+
const to = nodePositions.get(toId);
|
|
325
|
+
if (!from || !to) continue;
|
|
326
|
+
|
|
327
|
+
const x1 = from.x + 90;
|
|
328
|
+
const y1 = from.y;
|
|
329
|
+
const x2 = to.x - 90;
|
|
330
|
+
const y2 = to.y;
|
|
331
|
+
const mx = (x1 + x2) / 2;
|
|
332
|
+
pathEl.setAttribute('d', `M ${x1} ${y1} C ${mx} ${y1}, ${mx} ${y2}, ${x2} ${y2}`);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
// Update comment pin position
|
|
336
|
+
const pin = canvasGroup.querySelector(`.comment-pin[data-node-id="${nodeId}"]`);
|
|
337
|
+
if (pin) {
|
|
338
|
+
pin.setAttribute('transform', `translate(${pos.x + 90},${pos.y - 38})`);
|
|
339
|
+
}
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
function findNodeGroup(nodeId) {
|
|
343
|
+
if (!viewRef) return null;
|
|
344
|
+
for (const group of viewRef.groups ?? []) {
|
|
345
|
+
const root = groupRoots.get(group.id);
|
|
346
|
+
if (root === nodeId) return group;
|
|
347
|
+
}
|
|
348
|
+
return null;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
export function edgeClass(kind) {
|
|
352
|
+
if (kind.startsWith('condition_') || kind === 'evaluates_condition') return 'edge-condition';
|
|
353
|
+
if (kind === 'handles') return 'edge-handles';
|
|
354
|
+
if (kind === 'calls') return 'edge-calls';
|
|
355
|
+
if (kind === 'langgraph_edge') return 'edge-langgraph';
|
|
356
|
+
return 'edge-default';
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
export function nodeClass(kind) {
|
|
360
|
+
if (kind === 'endpoint' || kind === 'ui_route') return 'node-endpoint';
|
|
361
|
+
if (kind === 'condition') return 'node-condition';
|
|
362
|
+
if (kind === 'route_handler') return 'node-handler';
|
|
363
|
+
if (kind === 'agent' || kind === 'langgraph_node') return 'node-agent';
|
|
364
|
+
if (kind === 'mcp_tool') return 'node-mcp';
|
|
365
|
+
return 'node-default';
|
|
366
|
+
}
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
// sidebar.js — Code sidebar (slides in from right)
|
|
2
|
+
|
|
3
|
+
let sidebarEl = null;
|
|
4
|
+
let sidebarContent = null;
|
|
5
|
+
let sidebarNodeTitle = null;
|
|
6
|
+
let closeSidebarBtn = null;
|
|
7
|
+
|
|
8
|
+
export let codeSidebarOpen = false;
|
|
9
|
+
|
|
10
|
+
export function initSidebar() {
|
|
11
|
+
sidebarEl = document.getElementById('codeSidebar');
|
|
12
|
+
sidebarContent = document.getElementById('sidebarContent');
|
|
13
|
+
sidebarNodeTitle = document.getElementById('sidebarNodeTitle');
|
|
14
|
+
closeSidebarBtn = document.getElementById('closeSidebarBtn');
|
|
15
|
+
|
|
16
|
+
closeSidebarBtn.addEventListener('click', closeCodeSidebar);
|
|
17
|
+
|
|
18
|
+
document.addEventListener('keydown', (e) => {
|
|
19
|
+
if (['INPUT', 'TEXTAREA', 'SELECT'].includes(e.target.tagName)) return;
|
|
20
|
+
if (e.key === 'Escape' && codeSidebarOpen) {
|
|
21
|
+
closeCodeSidebar();
|
|
22
|
+
}
|
|
23
|
+
});
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function openCodeSidebar(payload, descriptions) {
|
|
27
|
+
renderCodePayload(payload, descriptions);
|
|
28
|
+
sidebarEl.classList.add('sidebar--open');
|
|
29
|
+
sidebarEl.setAttribute('aria-hidden', 'false');
|
|
30
|
+
codeSidebarOpen = true;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function closeCodeSidebar() {
|
|
34
|
+
sidebarEl.classList.remove('sidebar--open');
|
|
35
|
+
sidebarEl.setAttribute('aria-hidden', 'true');
|
|
36
|
+
codeSidebarOpen = false;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function renderCodePayload(payload, descriptions) {
|
|
40
|
+
sidebarNodeTitle.textContent = payload.node.label;
|
|
41
|
+
|
|
42
|
+
const description = lookupDescription(payload.node.id, descriptions);
|
|
43
|
+
const relatedHtml = (payload.related ?? [])
|
|
44
|
+
.map(
|
|
45
|
+
(entry) => `
|
|
46
|
+
<details>
|
|
47
|
+
<summary>${escHtml(entry.edge.kind)} → ${escHtml(entry.node.label)}</summary>
|
|
48
|
+
<pre>${highlightCode(escHtml(entry.snippet || '(no local source)'))}</pre>
|
|
49
|
+
</details>
|
|
50
|
+
`,
|
|
51
|
+
)
|
|
52
|
+
.join('');
|
|
53
|
+
|
|
54
|
+
sidebarContent.innerHTML = `
|
|
55
|
+
<div class="sidebar-file">${escHtml(payload.node.file || '(external)')}</div>
|
|
56
|
+
${
|
|
57
|
+
description
|
|
58
|
+
? `<div class="desc-card">
|
|
59
|
+
<h4>Description</h4>
|
|
60
|
+
<p><strong>KO:</strong> ${escHtml(description.sections?.ko?.purpose || '-')}</p>
|
|
61
|
+
<p><strong>EN:</strong> ${escHtml(description.sections?.en?.purpose || '-')}</p>
|
|
62
|
+
</div>`
|
|
63
|
+
: ''
|
|
64
|
+
}
|
|
65
|
+
<pre class="code-block">${highlightCode(escHtml(payload.snippet || '(no local source)'))}</pre>
|
|
66
|
+
<h4 class="related-title">Related (1-hop)</h4>
|
|
67
|
+
<div class="related-list">${relatedHtml || '<small>No related nodes.</small>'}</div>
|
|
68
|
+
`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function lookupDescription(nodeId, descriptions) {
|
|
72
|
+
const entries = descriptions?.descriptions?.entries;
|
|
73
|
+
if (!Array.isArray(entries)) return null;
|
|
74
|
+
return entries.find((entry) => entry.id === nodeId || entry.id === `group:${nodeId}`) ?? null;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Minimal regex-based syntax highlighter
|
|
78
|
+
function highlightCode(escapedCode) {
|
|
79
|
+
// Input is already HTML-escaped. We apply span wrapping in order.
|
|
80
|
+
let code = escapedCode;
|
|
81
|
+
|
|
82
|
+
// Comments (must come first to prevent keyword matching inside them)
|
|
83
|
+
code = code.replace(/(\/\/[^\n]*)/g, '<span class="tok-comment">$1</span>');
|
|
84
|
+
code = code.replace(/(\/\*[\s\S]*?\*\/)/g, '<span class="tok-comment">$1</span>');
|
|
85
|
+
|
|
86
|
+
// Strings (simple heuristic — works for most cases)
|
|
87
|
+
code = code.replace(/('[^&#]*'|"[^&]*")/g, '<span class="tok-string">$1</span>');
|
|
88
|
+
|
|
89
|
+
// Keywords
|
|
90
|
+
const keywords =
|
|
91
|
+
/\b(const|let|var|function|return|if|else|for|while|async|await|class|export|import|from|of|in|new|this|null|undefined|true|false|try|catch|finally|throw|switch|case|break|default|typeof|instanceof|void|delete|static|extends|super)\b/g;
|
|
92
|
+
code = code.replace(keywords, '<span class="tok-keyword">$1</span>');
|
|
93
|
+
|
|
94
|
+
// Numbers
|
|
95
|
+
code = code.replace(/\b(\d+\.?\d*)\b/g, '<span class="tok-number">$1</span>');
|
|
96
|
+
|
|
97
|
+
return code;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function escHtml(value) {
|
|
101
|
+
return String(value)
|
|
102
|
+
.replaceAll('&', '&')
|
|
103
|
+
.replaceAll('<', '<')
|
|
104
|
+
.replaceAll('>', '>')
|
|
105
|
+
.replaceAll('"', '"')
|
|
106
|
+
.replaceAll("'", ''');
|
|
107
|
+
}
|