docit 0.4.0 → 0.5.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.
@@ -0,0 +1,1495 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Docit
4
+ module UI
5
+ module SystemScript
6
+ def self.javascript(graph_url:, insights_url:)
7
+ <<~JS
8
+ (function() {
9
+ "use strict";
10
+
11
+ /* ───────────────────────── Configuration ───────────────────────── */
12
+
13
+ const graphUrl = #{json_escape(JSON.generate(graph_url))};
14
+ const insightsUrl = #{json_escape(JSON.generate(insights_url))};
15
+
16
+ const TYPE_COLORS = {
17
+ route: "#4da6ff",
18
+ controller: "#8b5cf6",
19
+ action: "#f5793a",
20
+ doc: "#34c759",
21
+ schema: "#2dd4bf",
22
+ model: "#ff4f4f",
23
+ service: "#ffb340",
24
+ job: "#f59e0b",
25
+ mailer: "#ec4899"
26
+ };
27
+
28
+ /* SVG path icons — 16×16 box, rendered with <g transform="translate(…)"> */
29
+ const TYPE_ICON_SVG = {
30
+ route: '<path d="M2,8 H13 M9,4.5 L13,8 L9,11.5" stroke="currentColor" stroke-width="1.6" fill="none" stroke-linecap="round" stroke-linejoin="round"/>',
31
+ controller: '<rect x="2" y="3" width="12" height="10" rx="2" stroke="currentColor" stroke-width="1.5" fill="none"/><line x1="2" y1="7" x2="14" y2="7" stroke="currentColor" stroke-width="1.2" stroke-opacity="0.6"/>',
32
+ action: '<polygon points="4,3 4,13 13,8" stroke="currentColor" stroke-width="1.2" fill="currentColor" fill-opacity="0.25"/>',
33
+ doc: '<path d="M3,1 H10 L13,4 V15 H3 Z M10,1 V4 H13" stroke="currentColor" stroke-width="1.3" fill="none" stroke-linecap="round" stroke-linejoin="round"/><line x1="5" y1="8" x2="11" y2="8" stroke="currentColor" stroke-width="1.2" stroke-opacity="0.7"/><line x1="5" y1="11" x2="9" y2="11" stroke="currentColor" stroke-width="1.2" stroke-opacity="0.7"/>',
34
+ schema: '<rect x="1" y="1" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.3" fill="none"/><rect x="9" y="1" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.3" fill="none"/><rect x="1" y="9" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.3" fill="none"/><rect x="9" y="9" width="6" height="6" rx="1.5" stroke="currentColor" stroke-width="1.3" fill="none"/>',
35
+ model: '<ellipse cx="8" cy="4.5" rx="5" ry="2.5" stroke="currentColor" stroke-width="1.3" fill="none"/><path d="M3,4.5 v7 M13,4.5 v7" stroke="currentColor" stroke-width="1.3"/><ellipse cx="8" cy="11.5" rx="5" ry="2.5" stroke="currentColor" stroke-width="1.3" fill="none"/><ellipse cx="8" cy="8" rx="5" ry="2.5" stroke="currentColor" stroke-width="1.2" fill="none" stroke-opacity="0.4"/>',
36
+ service: '<circle cx="8" cy="8" r="5.5" stroke="currentColor" stroke-width="1.3" fill="none"/><circle cx="8" cy="8" r="2" stroke="currentColor" stroke-width="1.3" fill="none"/>',
37
+ job: '<circle cx="8" cy="8" r="5.5" stroke="currentColor" stroke-width="1.3" fill="none"/><line x1="8" y1="3.5" x2="8" y2="8" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/><line x1="8" y1="8" x2="11" y2="10.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>',
38
+ mailer: '<rect x="1.5" y="4" width="13" height="9" rx="1.5" stroke="currentColor" stroke-width="1.3" fill="none"/><path d="M1.5,5 L8,9.5 L14.5,5" stroke="currentColor" stroke-width="1.2" fill="none"/>'
39
+ };
40
+
41
+ const TYPE_DESCRIPTIONS = {
42
+ route: "HTTP entrypoint — the URL pattern that browsers and clients hit.",
43
+ controller: "Rails controller — groups related actions that handle requests.",
44
+ action: "A single controller method — processes one specific request.",
45
+ doc: "Docit documentation block — describes what an endpoint expects and returns.",
46
+ schema: "Reusable data shape — defines the structure of request/response objects.",
47
+ model: "Database-backed model — represents a table and its relationships.",
48
+ service: "Service object — encapsulates business logic outside controllers.",
49
+ job: "Background job — runs asynchronous work (emails, processing, etc.).",
50
+ mailer: "Mailer — sends emails from the application."
51
+ };
52
+
53
+ const METHOD_COLORS = {
54
+ GET: "#34c759", POST: "#4da6ff", PUT: "#ffb340",
55
+ PATCH: "#f59e0b", DELETE: "#ff4f4f"
56
+ };
57
+
58
+ const EDGE_COLORS = {
59
+ routes_to: "#4da6ff", contains: "#6b7a94", documents: "#34c759",
60
+ association: "#ff4f4f", uses_model: "#ffb340", calls: "#f59e0b", manual: "#f5793a"
61
+ };
62
+
63
+ const NODE_W = 280;
64
+ const NODE_H = 100;
65
+ const HEADER_H = 38;
66
+ const COL_GAP = 340;
67
+ const ROW_GAP = 134;
68
+ const COL_ORDER = ["doc","route","action","controller","schema","model","service","job","mailer"];
69
+
70
+ /* ───────────────────────── State ───────────────────────── */
71
+
72
+ let graph = { nodes: [], edges: [], stats: {} };
73
+ let positions = {};
74
+ let selectedId = null;
75
+ let dragState = null;
76
+ let panState = null;
77
+ let zoom = { scale: 1, tx: 0, ty: 0 };
78
+ /* Set of node ids directly adjacent to the selected node, for focus
79
+ mode. Rebuilt only when the selection changes — not per render. */
80
+ let focusNeighbors = null;
81
+
82
+ /* ───────────────────────── DOM refs ───────────────────────── */
83
+
84
+ const $ = (id) => document.getElementById(id);
85
+ const canvas = $("canvas");
86
+ const canvasWrap = $("canvas-wrap");
87
+ const panel = $("panel");
88
+ const searchEl = $("search");
89
+ const sectionFilterDiagram = $("diagram-section-filter");
90
+ const statsEl = $("stats");
91
+ const exportBtn = $('export-png');
92
+ const toastEl = $("toast");
93
+ const zoomInBtn = $("zoom-in");
94
+ const zoomOutBtn = $("zoom-out");
95
+ const zoomFitBtn = $("zoom-fit");
96
+ const zoomLabel = $("zoom-level");
97
+ const legendEl = $("legend");
98
+ const legendToggle = $("legend-toggle");
99
+ const legendContent = $("legend-content");
100
+ const themeToggle = $("theme-toggle");
101
+
102
+ /* ───────────────────────── Theme ───────────────────────── */
103
+
104
+ const SUN_ICON = '<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><circle cx="8" cy="8" r="3"/><path d="M8 1v1.6M8 13.4V15M2.4 2.4l1.1 1.1M12.5 12.5l1.1 1.1M1 8h1.6M13.4 8H15M2.4 13.6l1.1-1.1M12.5 3.5l1.1-1.1"/></svg>';
105
+ const MOON_ICON = '<svg width="15" height="15" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.4" stroke-linecap="round" stroke-linejoin="round"><path d="M13.5 9.3A5.5 5.5 0 0 1 6.7 2.5a5.5 5.5 0 1 0 6.8 6.8z"/></svg>';
106
+
107
+ /* Shared line-icon glyphs used across panels (replaces emoji). */
108
+ function lineIcon(paths, size) {
109
+ var s = size || 14;
110
+ return '<svg width="' + s + '" height="' + s + '" viewBox="0 0 16 16" fill="none" stroke="currentColor" ' +
111
+ 'stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round">' + paths + '</svg>';
112
+ }
113
+ const ICON_SPARKLE = '<path d="M8 1.5l1.6 4.3 4.4 1.6-4.4 1.6L8 13.4 6.4 9 2 7.4l4.4-1.6L8 1.5z"/>';
114
+ const ICON_CLICK = '<path d="M6 7V3.5a1.2 1.2 0 0 1 2.4 0V7m0-1.2a1.2 1.2 0 0 1 2.4 0V7m0-.6a1.2 1.2 0 0 1 2.4 0v3.2a4 4 0 0 1-4 4H9a4 4 0 0 1-3.3-1.7L3.5 8.8a1.2 1.2 0 0 1 2-1.3L6 8"/>';
115
+ const ICON_DRAG = '<path d="M3 8h10M8 3l5 5-5 5M3 8l5-5M3 8l5 5"/>';
116
+ const ICON_SEARCH = '<circle cx="7" cy="7" r="4.5"/><path d="M10.5 10.5l3 3"/>';
117
+
118
+ function currentTheme() {
119
+ return document.documentElement.getAttribute("data-theme") === "dark" ? "dark" : "light";
120
+ }
121
+
122
+ /* The button shows the theme you'll switch TO, the common affordance. */
123
+ function syncThemeButton() {
124
+ if (!themeToggle) return;
125
+ themeToggle.innerHTML = currentTheme() === "dark" ? SUN_ICON : MOON_ICON;
126
+ }
127
+
128
+ function toggleTheme() {
129
+ var next = currentTheme() === "dark" ? "light" : "dark";
130
+ if (next === "dark") document.documentElement.setAttribute("data-theme", "dark");
131
+ else document.documentElement.removeAttribute("data-theme");
132
+ try { localStorage.setItem("docit-theme", next); } catch (e) {}
133
+ syncThemeButton();
134
+ /* Re-render so SVG diagram picks up theme-driven fills. */
135
+ if (currentView === "diagram") render();
136
+ else renderStripeDocs();
137
+ }
138
+
139
+ syncThemeButton();
140
+ if (themeToggle) themeToggle.addEventListener("click", toggleTheme);
141
+
142
+ /* "/" focuses node search (diagram view); Esc clears focus/selection. */
143
+ document.addEventListener("keydown", function(e) {
144
+ var typing = /^(INPUT|SELECT|TEXTAREA)$/.test(document.activeElement && document.activeElement.tagName);
145
+ if (e.key === "/" && !typing && currentView === "diagram") {
146
+ e.preventDefault();
147
+ searchEl.focus();
148
+ searchEl.select();
149
+ } else if (e.key === "Escape" && selectedId !== null) {
150
+ selectedId = null;
151
+ focusNeighbors = null;
152
+ resetPanel();
153
+ render();
154
+ }
155
+ });
156
+
157
+ /* ───────────────────────── Init ───────────────────────── */
158
+
159
+ fetch(graphUrl)
160
+ .then(function(r) { return r.json(); })
161
+ .then(function(data) {
162
+ graph = data;
163
+ buildFilters();
164
+ buildLegend();
165
+ positions = layoutNodes(graph.nodes);
166
+ render();
167
+ resetPanel();
168
+ setTimeout(zoomToFit, 60);
169
+ })
170
+ .catch(function(err) {
171
+ canvas.innerHTML = '<div class="panel-empty">Unable to load system graph: ' + esc(err.message) + '</div>';
172
+ });
173
+
174
+ /* ───────────────────────── Events ───────────────────────── */
175
+
176
+ const sectionFilter = $("section-filter");
177
+ const diagramFilters = $("diagram-filters");
178
+ const docsFilters = $("docs-filters");
179
+
180
+ searchEl.addEventListener("input", function() {
181
+ if (currentView === "diagram") render();
182
+ else renderStripeDocs();
183
+ });
184
+ if (sectionFilterDiagram) {
185
+ sectionFilterDiagram.addEventListener("change", function() {
186
+ zoomToFit(); /* reframe to the chosen section */
187
+ });
188
+ }
189
+ if (sectionFilter) sectionFilter.addEventListener("change", renderStripeDocs);
190
+ exportBtn.addEventListener("click", exportPng);
191
+ zoomInBtn.addEventListener("click", function() { applyZoom(1.3); });
192
+ zoomOutBtn.addEventListener("click", function() { applyZoom(0.77); });
193
+ zoomFitBtn.addEventListener("click", zoomToFit);
194
+ legendToggle.addEventListener("click", toggleLegendPanel);
195
+
196
+ const viewDiagramBtn = $("view-diagram");
197
+ const viewListBtn = $("view-list");
198
+ const canvasWrapEl = $("canvas-wrap");
199
+ const stripeDocsWrapEl = $("stripe-docs-wrap");
200
+ let currentView = "diagram";
201
+
202
+ viewDiagramBtn.addEventListener("click", function() { switchViewMode("diagram"); });
203
+ viewListBtn.addEventListener("click", function() { switchViewMode("list"); });
204
+
205
+ function switchViewMode(mode) {
206
+ currentView = mode;
207
+ var isDiagram = mode === "diagram";
208
+
209
+ /* Each view shows only the filters that make sense for it:
210
+ free-text search + type on the diagram, resource section on docs. */
211
+ if (diagramFilters) diagramFilters.style.display = isDiagram ? "flex" : "none";
212
+ if (docsFilters) docsFilters.style.display = isDiagram ? "none" : "flex";
213
+
214
+ if (isDiagram) {
215
+ viewListBtn.classList.remove("active");
216
+ viewDiagramBtn.classList.add("active");
217
+ stripeDocsWrapEl.style.display = "none";
218
+ canvasWrapEl.style.display = "block";
219
+ render();
220
+ } else {
221
+ viewDiagramBtn.classList.remove("active");
222
+ viewListBtn.classList.add("active");
223
+ canvasWrapEl.style.display = "none";
224
+ stripeDocsWrapEl.style.display = "grid";
225
+ buildSectionFilter();
226
+ renderStripeDocs();
227
+ }
228
+ }
229
+
230
+ /* Populate the resource-section dropdown from controllers, once.
231
+ Each option is a resource (e.g. "Users"), value is the controller id. */
232
+ function buildSectionFilter() {
233
+ if (!sectionFilter || sectionFilter.dataset.built === "1") return;
234
+ var controllers = graph.nodes
235
+ .filter(function(n) { return n.type === "controller"; })
236
+ .map(function(n) { return { id: n.id, name: resourceName(n.label).plural }; })
237
+ .sort(function(a, b) { return a.name.localeCompare(b.name); });
238
+ controllers.forEach(function(c) {
239
+ var opt = document.createElement("option");
240
+ opt.value = c.id;
241
+ opt.textContent = c.name;
242
+ sectionFilter.appendChild(opt);
243
+ });
244
+ sectionFilter.dataset.built = "1";
245
+ }
246
+
247
+ /* Scroll-wheel zoom */
248
+ canvasWrap.addEventListener("wheel", function(e) {
249
+ e.preventDefault();
250
+ var factor = e.deltaY < 0 ? 1.1 : 0.91;
251
+ var rect = canvasWrap.getBoundingClientRect();
252
+ var mx = e.clientX - rect.left;
253
+ var my = e.clientY - rect.top;
254
+ zoomAtPoint(mx, my, factor);
255
+ }, { passive: false });
256
+
257
+ /* Canvas panning — drag empty space to pan, or shift/middle-click anywhere */
258
+ canvasWrap.addEventListener("pointerdown", function(e) {
259
+ /* If clicking on a node element, let the node's drag handler take over */
260
+ if (!e.shiftKey && e.button !== 1 && e.target.closest(".node")) return;
261
+ if (e.button !== 0 && e.button !== 1) return;
262
+
263
+ e.preventDefault();
264
+ panState = { sx: e.clientX, sy: e.clientY, stx: zoom.tx, sty: zoom.ty, moved: false };
265
+ canvasWrap.setPointerCapture(e.pointerId);
266
+ canvasWrap.classList.add("panning");
267
+ });
268
+ canvasWrap.addEventListener("pointermove", function(e) {
269
+ if (!panState) return;
270
+ var dx = e.clientX - panState.sx;
271
+ var dy = e.clientY - panState.sy;
272
+ if (Math.abs(dx) + Math.abs(dy) > 3) panState.moved = true;
273
+ zoom.tx = panState.stx + dx;
274
+ zoom.ty = panState.sty + dy;
275
+ render();
276
+ });
277
+ canvasWrap.addEventListener("pointerup", function() {
278
+ /* A click on empty canvas (no drag) clears focus/selection. */
279
+ if (panState && !panState.moved && selectedId !== null) {
280
+ selectedId = null;
281
+ focusNeighbors = null;
282
+ resetPanel();
283
+ render();
284
+ }
285
+ panState = null;
286
+ canvasWrap.classList.remove("panning");
287
+ });
288
+ canvasWrap.addEventListener("pointercancel", function() {
289
+ panState = null;
290
+ canvasWrap.classList.remove("panning");
291
+ });
292
+
293
+ /* Panel delegation — edge removal + AI checkboxes */
294
+ panel.addEventListener("click", function(e) {
295
+ var edgeBtn = e.target.closest("button[data-edge]");
296
+ if (edgeBtn) {
297
+ graph.edges = graph.edges.filter(function(edge) { return edge.id !== edgeBtn.dataset.edge; });
298
+ if (selectedId) showNodeDetail(selectedId);
299
+ render();
300
+ }
301
+ });
302
+
303
+ /* ───────────────────────── Filters & Layout ───────────────────────── */
304
+
305
+ /* Populate the diagram's section filter from controllers — one option
306
+ per resource (Users, Orders…), value is the controller id. Same
307
+ source as the docs section filter, so the two views agree. */
308
+ function buildFilters() {
309
+ if (!sectionFilterDiagram) return;
310
+ graph.nodes
311
+ .filter(function(n) { return n.type === "controller"; })
312
+ .map(function(n) { return { id: n.id, name: resourceName(n.label).plural }; })
313
+ .sort(function(a, b) { return a.name.localeCompare(b.name); })
314
+ .forEach(function(c) {
315
+ var opt = document.createElement("option");
316
+ opt.value = c.id;
317
+ opt.textContent = c.name;
318
+ sectionFilterDiagram.appendChild(opt);
319
+ });
320
+ }
321
+
322
+ /* All node ids that make up a section's end-to-end story:
323
+ the controller, its actions, each action's routes & docs, and the
324
+ models/services/jobs/mailers those actions use. */
325
+ function sectionNodeIds(controllerId) {
326
+ var ids = new Set([controllerId]);
327
+ var actionIds = sectionActionIds(controllerId);
328
+ actionIds.forEach(function(actionId) {
329
+ ids.add(actionId);
330
+ graph.edges.forEach(function(e) {
331
+ /* incoming: route -> action, doc -> action */
332
+ if (e.target === actionId && (e.type === "routes_to" || e.type === "documents")) {
333
+ ids.add(e.source);
334
+ }
335
+ /* outgoing: action -> model/service/job/mailer */
336
+ if (e.source === actionId) ids.add(e.target);
337
+ });
338
+ });
339
+ return ids;
340
+ }
341
+
342
+ function visibleGraph() {
343
+ var q = searchEl.value.toLowerCase();
344
+ var section = sectionFilterDiagram ? sectionFilterDiagram.value : "";
345
+ var sectionIds = section ? sectionNodeIds(section) : null;
346
+
347
+ var nodes = graph.nodes.filter(function(n) {
348
+ return (!sectionIds || sectionIds.has(n.id)) &&
349
+ (!q || n.label.toLowerCase().indexOf(q) !== -1);
350
+ });
351
+ var ids = new Set(nodes.map(function(n) { return n.id; }));
352
+ var edges = graph.edges.filter(function(e) { return ids.has(e.source) && ids.has(e.target); });
353
+ return { nodes: nodes, edges: edges };
354
+ }
355
+
356
+ function layoutNodes(nodes) {
357
+ var groups = {};
358
+ nodes.forEach(function(n) { (groups[n.type] = groups[n.type] || []).push(n); });
359
+ var pos = {};
360
+ var order = COL_ORDER.concat(Object.keys(groups).filter(function(t) { return COL_ORDER.indexOf(t) === -1; }));
361
+ order.forEach(function(type, col) {
362
+ (groups[type] || []).forEach(function(node, row) {
363
+ pos[node.id] = { x: 100 + col * COL_GAP, y: 120 + row * ROW_GAP };
364
+ });
365
+ });
366
+ return pos;
367
+ }
368
+
369
+ /* ───────────────────────── Zoom & Pan ───────────────────────── */
370
+
371
+ function applyZoom(factor) {
372
+ var rect = canvasWrap.getBoundingClientRect();
373
+ zoomAtPoint(rect.width / 2, rect.height / 2, factor);
374
+ }
375
+
376
+ function zoomAtPoint(cx, cy, factor) {
377
+ var prev = zoom.scale;
378
+ zoom.scale = Math.min(4, Math.max(0.15, zoom.scale * factor));
379
+ var ratio = zoom.scale / prev;
380
+ zoom.tx = cx - ratio * (cx - zoom.tx);
381
+ zoom.ty = cy - ratio * (cy - zoom.ty);
382
+ render();
383
+ }
384
+
385
+ function zoomToFit() {
386
+ var current = visibleGraph();
387
+ if (current.nodes.length === 0) return;
388
+ var b = graphBounds(current.nodes);
389
+ var rect = canvasWrap.getBoundingClientRect();
390
+ var pad = 80;
391
+ var sx = (rect.width - pad * 2) / b.w;
392
+ var sy = (rect.height - pad * 2) / b.h;
393
+ zoom.scale = Math.min(1.2, Math.max(0.15, Math.min(sx, sy)));
394
+ zoom.tx = pad - b.x * zoom.scale + (rect.width - pad * 2 - b.w * zoom.scale) / 2;
395
+ zoom.ty = pad - b.y * zoom.scale + (rect.height - pad * 2 - b.h * zoom.scale) / 2;
396
+ render();
397
+ }
398
+
399
+ function graphBounds(nodes) {
400
+ var xs = [], ys = [];
401
+ nodes.forEach(function(n) {
402
+ var p = positions[n.id];
403
+ if (p) { xs.push(p.x); ys.push(p.y); }
404
+ });
405
+ if (xs.length === 0) return { x: 0, y: 0, w: 800, h: 600 };
406
+ var minX = Math.min.apply(null, xs) - 40;
407
+ var minY = Math.min.apply(null, ys) - 60;
408
+ var maxX = Math.max.apply(null, xs) + NODE_W + 40;
409
+ var maxY = Math.max.apply(null, ys) + NODE_H + 40;
410
+ return { x: minX, y: minY, w: Math.max(400, maxX - minX), h: Math.max(300, maxY - minY) };
411
+ }
412
+
413
+ /* ───────────────────────── Rendering ───────────────────────── */
414
+
415
+ function render() {
416
+ var current = visibleGraph();
417
+ statsEl.textContent = current.nodes.length + " nodes \\u00b7 " + current.edges.length + " edges";
418
+ zoomLabel.textContent = Math.round(zoom.scale * 100) + "%";
419
+
420
+ if (current.nodes.length === 0) {
421
+ canvas.innerHTML = '<div class="panel-empty">No nodes match current filters.</div>';
422
+ return;
423
+ }
424
+ canvas.innerHTML = buildSVG(current);
425
+ bindNodes();
426
+ }
427
+
428
+ function buildSVG(data) {
429
+ var nodes = data.nodes;
430
+ var edges = data.edges;
431
+ return '<svg id="system-svg" role="img" aria-label="System architecture diagram" ' +
432
+ 'width="100%" height="100%" xmlns="http://www.w3.org/2000/svg">' +
433
+ '<defs>' +
434
+ '<marker id="arrow" markerWidth="10" markerHeight="7" refX="9" refY="3.5" orient="auto">' +
435
+ '<polygon points="0 0, 10 3.5, 0 7" fill="#7a8fb5"/>' +
436
+ '</marker>' +
437
+ '<filter id="glow"><feGaussianBlur stdDeviation="3" result="b"/>' +
438
+ '<feMerge><feMergeNode in="b"/><feMergeNode in="SourceGraphic"/></feMerge></filter>' +
439
+ '</defs>' +
440
+ '<g id="zoom-group" transform="translate(' + zoom.tx + ',' + zoom.ty + ') scale(' + zoom.scale + ')">' +
441
+ renderSwimlanes(nodes) + renderEdges(edges) + renderNodes(nodes) +
442
+ '</g></svg>';
443
+ }
444
+
445
+ function renderSwimlanes(nodes) {
446
+ var types = uniqueSorted(nodes.map(function(n) { return n.type; }));
447
+ return types.map(function(type) {
448
+ var typeNodes = nodes.filter(function(n) { return n.type === type; });
449
+ var pxs = [], pys = [];
450
+ typeNodes.forEach(function(n) {
451
+ var p = positions[n.id];
452
+ if (p) { pxs.push(p.x); pys.push(p.y); }
453
+ });
454
+ if (pxs.length === 0) return "";
455
+ var minX = Math.min.apply(null, pxs);
456
+ var minY = Math.min.apply(null, pys);
457
+ var maxY = Math.max.apply(null, pys);
458
+ var c = TYPE_COLORS[type] || "#7a8fb5";
459
+ var count = typeNodes.length;
460
+ return '<g class="swimlane">' +
461
+ /* Lane background */
462
+ '<rect x="' + (minX - 24) + '" y="' + (minY - 50) + '" width="' + (NODE_W + 48) + '" height="' + (maxY - minY + NODE_H + 70) + '" ' +
463
+ 'rx="20" fill="' + c + '" fill-opacity="0.025" stroke="' + c + '" stroke-opacity="0.06" stroke-width="1"/>' +
464
+ /* Lane label pill */
465
+ '<rect x="' + (minX - 4) + '" y="' + (minY - 32) + '" width="' + Math.max(80, type.length * 9 + 30) + '" height="22" rx="11" ' +
466
+ 'fill="' + c + '" fill-opacity="0.1" stroke="' + c + '" stroke-opacity="0.18"/>' +
467
+ '<text x="' + (minX + 8) + '" y="' + (minY - 17) + '" fill="' + c + '" font-size="10" font-weight="700" ' +
468
+ 'font-family="monospace" letter-spacing="0.1em">' + esc(type.toUpperCase()) + '</text>' +
469
+ '<text x="' + (minX + Math.max(80, type.length * 9 + 30) - 18) + '" y="' + (minY - 17) + '" fill="' + c + '" font-size="10" font-weight="600" ' +
470
+ 'font-family="monospace" opacity="0.6">' + count + '</text>' +
471
+ '</g>';
472
+ }).join("");
473
+ }
474
+
475
+ function renderEdges(edges) {
476
+ return edges.map(function(edge) {
477
+ var s = positions[edge.source];
478
+ var t = positions[edge.target];
479
+ if (!s || !t) return "";
480
+
481
+ var x1 = s.x + NODE_W;
482
+ var y1 = s.y + NODE_H / 2;
483
+ var x2 = t.x;
484
+ var y2 = t.y + NODE_H / 2;
485
+ var sourceIsRight = false;
486
+
487
+ /* If target is to the left, connect from left side of source to right side of target */
488
+ if (s.x > t.x + NODE_W) {
489
+ x1 = s.x;
490
+ x2 = t.x + NODE_W;
491
+ sourceIsRight = true;
492
+ } else if (Math.abs(s.x - t.x) < NODE_W && s.y !== t.y) {
493
+ /* Same column — curve via side */
494
+ x1 = s.x + NODE_W;
495
+ x2 = t.x + NODE_W;
496
+ }
497
+
498
+ var dist = Math.abs(x2 - x1);
499
+ var mid = Math.max(60, Math.min(dist / 2, 200));
500
+ var cx1 = sourceIsRight ? x1 - mid : x1 + mid;
501
+ var cx2 = sourceIsRight ? x2 + mid : x2 - mid;
502
+ var d = "M" + x1 + "," + y1 + " C" + cx1 + "," + y1 + " " + cx2 + "," + y2 + " " + x2 + "," + y2;
503
+
504
+ var conf = edge.confidence || "medium";
505
+ var color = EDGE_COLORS[edge.type] || "#7a8fb5";
506
+ var dash = conf === "manual" ? "6,4" : (edge.type === "documents" ? "4,3" : "none");
507
+ var opacity = focusDimEdge(edge) ? 0.07 : 0.45;
508
+ var width = conf === "high" ? 1.6 : 1.2;
509
+
510
+ return '<path d="' + d + '" stroke="' + color + '" stroke-width="' + width + '" fill="none" ' +
511
+ 'stroke-dasharray="' + dash + '" opacity="' + opacity + '" marker-end="url(#arrow)">' +
512
+ '<title>' + esc(edge.type) + ': ' + esc(edge.evidence || "") + '</title></path>';
513
+ }).join("");
514
+ }
515
+
516
+ function renderNodes(nodes) {
517
+ return nodes.map(function(node) {
518
+ var pos = positions[node.id];
519
+ if (!pos) return "";
520
+ var status = node.status || "unknown";
521
+ var isSel = node.id === selectedId;
522
+ var dimmed = focusDimNode(node.id);
523
+ var tc = TYPE_COLORS[node.type] || "#7a8fb5";
524
+ var sc = status === "documented" ? "#34c759" : status === "undocumented" ? "#ffb340" : "#7a8fb5";
525
+
526
+ var cls = "node " + esc(node.type);
527
+ if (isSel) cls += " selected";
528
+
529
+ var opacity = dimmed ? 0.14 : 1;
530
+ var strokeW = isSel ? 2 : 1.2;
531
+ var strokeOp = isSel ? 1 : 0.32;
532
+ var dashArr = "";
533
+ var filter = isSel ? ' filter="url(#glow)"' : "";
534
+
535
+ /* Header band path — rounded top corners, flat bottom */
536
+ var headerPath = "M14,0 L" + (NODE_W - 14) + ",0 A14,14 0 0 1 " + NODE_W + ",14 L" + NODE_W + "," + HEADER_H + " L0," + HEADER_H + " L0,14 A14,14 0 0 1 14,0 Z";
537
+
538
+ /* ---- Per-type layout decisions ---- */
539
+ var badge = "";
540
+ var titleX = 38; /* default: icon (14×16) at x=8 → title at x=38 */
541
+ var label;
542
+ var line1 = ""; /* body first line */
543
+ var line2 = ""; /* body second line */
544
+ var showStatusDot = false;
545
+
546
+ if (node.type === "route") {
547
+ /* Route: just the path in header + method badge on right, no body lines */
548
+ titleX = 14;
549
+ var routeMethod = node.metadata ? (node.metadata.method || "").toUpperCase() : "";
550
+ var routePath = node.metadata && node.metadata.path ? node.metadata.path : node.label;
551
+ label = truncate(routePath, routeMethod ? 18 : 26);
552
+ if (routeMethod) {
553
+ var mc = METHOD_COLORS[routeMethod] || "#7a8fb5";
554
+ badge = '<rect x="' + (NODE_W - 64) + '" y="9" width="48" height="20" rx="5" fill="' + mc + '" fill-opacity="0.18"/>' +
555
+ '<text x="' + (NODE_W - 40) + '" y="23" text-anchor="middle" fill="' + mc + '" font-size="9" font-weight="700" font-family="monospace" letter-spacing="0.04em">' + routeMethod + '</text>';
556
+ }
557
+ /* no body lines for routes */
558
+
559
+ } else if (node.type === "action") {
560
+ /* Action: just the function name in header, HTTP method+path in body */
561
+ label = node.metadata && node.metadata.action ? node.metadata.action : truncate(node.label, 20);
562
+ if (node.metadata && node.metadata.http_method && node.metadata.path) {
563
+ line1 = esc(node.metadata.http_method.toUpperCase()) + " " + esc(node.metadata.path);
564
+ }
565
+ /* no status dot */
566
+
567
+ } else if (node.type === "doc") {
568
+ /* Doc: title/summary in header, just "documented" status in body */
569
+ var docSummary = node.metadata && node.metadata.summary ? node.metadata.summary : node.label;
570
+ label = truncate(docSummary, 22);
571
+ line1 = "documented";
572
+ /* no status dot */
573
+
574
+ } else if (node.type === "controller") {
575
+ /* Controller: short name in header, file path in body */
576
+ label = truncate(node.label, 22);
577
+ if (node.file) line2 = node.file.split("/").slice(-1)[0];
578
+ /* no type/status display */
579
+
580
+ } else if (node.type === "model") {
581
+ label = truncate(node.label, 22);
582
+ if (node.metadata && node.metadata.table_name) line1 = node.metadata.table_name;
583
+ else if (node.file) line2 = node.file.split("/").slice(-1)[0];
584
+
585
+ } else {
586
+ /* service, job, mailer, schema — default */
587
+ label = truncate(node.label, 22);
588
+ if (node.file) line2 = node.file.split("/").slice(-1)[0];
589
+ }
590
+
591
+ /* SVG icon group (16×16 box) translated to sit in header */
592
+ var iconG = node.type === "route" ? "" :
593
+ '<g transform="translate(8,' + Math.round((HEADER_H - 16) / 2) + ')" stroke="' + tc + '" color="' + tc + '">' +
594
+ (TYPE_ICON_SVG[node.type] || '') + '</g>';
595
+
596
+ return '<g class="' + cls + '" data-id="' + esc(node.id) + '" transform="translate(' + pos.x + ',' + pos.y + ')" ' +
597
+ 'style="opacity:' + opacity + '"' + filter + '>' +
598
+ /* Outer card */
599
+ '<rect width="' + NODE_W + '" height="' + NODE_H + '" rx="14" fill="var(--bg-solid)" ' +
600
+ 'stroke="' + tc + '" stroke-width="' + strokeW + '" stroke-opacity="' + strokeOp + '"' +
601
+ (dashArr ? ' stroke-dasharray="' + dashArr + '"' : '') + '/>' +
602
+ /* Header band */
603
+ '<path d="' + headerPath + '" fill="' + tc + '" fill-opacity="0.09"/>' +
604
+ '<line x1="0" y1="' + HEADER_H + '" x2="' + NODE_W + '" y2="' + HEADER_H + '" stroke="' + tc + '" stroke-width="1" stroke-opacity="0.18"/>' +
605
+ /* Icon + title in header */
606
+ iconG +
607
+ '<text x="' + titleX + '" y="24" fill="var(--text)" font-size="12" font-weight="700" font-family="-apple-system,BlinkMacSystemFont,sans-serif" letter-spacing="-0.005em">' + esc(label) + '</text>' +
608
+ badge +
609
+ /* Body lines */
610
+ (line1 ? '<text x="16" y="60" fill="var(--smoke)" font-size="11" font-family="monospace" letter-spacing="0.02em">' + line1 + '</text>' : '') +
611
+ (line2 ? '<text x="16" y="' + (line1 ? 82 : 64) + '" fill="var(--haze)" font-size="10.5" font-family="monospace">' + esc(truncate(line2, 38)) + '</text>' : '') +
612
+ '</g>';
613
+ }).join("");
614
+ }
615
+
616
+ /* ───────────────────────── Focus mode ─────────────────────────
617
+ When a node is selected, spotlight it and its direct neighbors;
618
+ everything else fades back. Helps trace one component's
619
+ connections in a busy graph. */
620
+
621
+ function focusActive() { return selectedId !== null && focusNeighbors !== null; }
622
+
623
+ function rebuildFocusNeighbors() {
624
+ if (selectedId === null) { focusNeighbors = null; return; }
625
+ var set = new Set([selectedId]);
626
+ graph.edges.forEach(function(e) {
627
+ if (e.source === selectedId) set.add(e.target);
628
+ if (e.target === selectedId) set.add(e.source);
629
+ });
630
+ focusNeighbors = set;
631
+ }
632
+
633
+ function focusDimNode(nodeId) {
634
+ return focusActive() && !focusNeighbors.has(nodeId);
635
+ }
636
+
637
+ function focusDimEdge(edge) {
638
+ return focusActive() && edge.source !== selectedId && edge.target !== selectedId;
639
+ }
640
+
641
+ /* ───────────────────────── Node interactions ───────────────────────── */
642
+
643
+ function bindNodes() {
644
+ canvas.querySelectorAll(".node").forEach(function(el) {
645
+ el.addEventListener("pointerdown", startDrag);
646
+ el.addEventListener("click", handleNodeClick);
647
+ });
648
+ }
649
+
650
+ function startDrag(e) {
651
+ if (e.shiftKey || e.button !== 0) return;
652
+ var id = e.currentTarget.dataset.id;
653
+ /* Collect doc nodes linked to this node (one-directional: action/controller dragging pulls its doc) */
654
+ var draggedNode = graph.nodes.find(function(n) { return n.id === id; });
655
+ var linkedDocs = [];
656
+ if (draggedNode && draggedNode.type !== "doc") {
657
+ graph.edges.forEach(function(edge) {
658
+ if (edge.type === "documents" && edge.target === id) {
659
+ var p = positions[edge.source];
660
+ if (p) linkedDocs.push({ id: edge.source, ox: p.x, oy: p.y });
661
+ }
662
+ });
663
+ }
664
+ dragState = { id: id, cx: e.clientX, cy: e.clientY, ox: positions[id].x, oy: positions[id].y, moved: false, linkedDocs: linkedDocs };
665
+ document.addEventListener("pointermove", onDrag);
666
+ document.addEventListener("pointerup", endDrag, { once: true });
667
+ }
668
+
669
+ function onDrag(e) {
670
+ if (!dragState) return;
671
+ var dx = (e.clientX - dragState.cx) / zoom.scale;
672
+ var dy = (e.clientY - dragState.cy) / zoom.scale;
673
+ if (Math.abs(dx) + Math.abs(dy) > 3) dragState.moved = true;
674
+ positions[dragState.id] = { x: dragState.ox + dx, y: dragState.oy + dy };
675
+ /* Move linked doc nodes with the parent */
676
+ dragState.linkedDocs.forEach(function(ld) {
677
+ positions[ld.id] = { x: ld.ox + dx, y: ld.oy + dy };
678
+ });
679
+ render();
680
+ }
681
+
682
+ function endDrag() {
683
+ document.removeEventListener("pointermove", onDrag);
684
+ setTimeout(function() { dragState = null; }, 0);
685
+ }
686
+
687
+ function handleNodeClick(e) {
688
+ if (dragState && dragState.moved) return;
689
+ var id = e.currentTarget.dataset.id;
690
+
691
+ selectedId = id;
692
+ rebuildFocusNeighbors();
693
+ showNodeDetail(id);
694
+ render();
695
+ }
696
+
697
+ function formatMarkdown(text) {
698
+ return text
699
+ /* Mermaid blocks — label them clearly and show as readable code */
700
+ .replace(/```mermaid[\\s\\S]*?```/g, function(block) {
701
+ var code = block.replace(/```mermaid\\n?/, "").replace(/```$/, "").trim();
702
+ return '<div style="margin:10px 0">' +
703
+ '<div style="font-size:10px;color:var(--haze);font-family:monospace;text-transform:uppercase;letter-spacing:.06em;margin-bottom:6px">Flow diagram</div>' +
704
+ '<pre style="background:var(--bg-code);border:1px solid var(--border);border-radius:8px;padding:12px;font-size:11px;color:var(--smoke);overflow-x:auto;line-height:1.6">' + esc(code) + '</pre>' +
705
+ '</div>';
706
+ })
707
+ /* Generic code blocks */
708
+ .replace(/```[\\s\\S]*?```/g, function(block) {
709
+ var code = block.replace(/```\\w*\\n?/, "").replace(/```$/, "").trim();
710
+ return '<pre style="background:var(--bg-code);border:1px solid var(--border);border-radius:8px;padding:10px;font-size:11px;color:var(--smoke);overflow-x:auto">' + esc(code) + '</pre>';
711
+ })
712
+ .replace(/\\*\\*(.+?)\\*\\*/g, '<strong style="color:var(--text)">$1</strong>')
713
+ .replace(/`([^`]+)`/g, '<code style="background:var(--bg-code);border:1px solid var(--border);padding:1px 5px;border-radius:4px;font-size:11px;color:var(--smoke)">$1</code>')
714
+ /* Bullet lists — ember accent bar instead of purple */
715
+ .replace(/^- (.+)$/gm, '<div style="padding:3px 0 3px 12px;border-left:2px solid rgba(245,121,58,.35);margin:4px 0;color:var(--smoke)">$1</div>')
716
+ /* Numbered lists — ember number instead of purple */
717
+ .replace(/^(\\d+)\\. (.+)$/gm, '<div style="display:flex;gap:8px;padding:3px 0;margin:4px 0"><span style="color:var(--ember);font-weight:600;font-size:11px;min-width:18px">$1.</span><span style="color:var(--smoke)">$2</span></div>')
718
+ /* Inline arrows — keep readable */
719
+ .replace(/\\u2192|\\u2194|\\->/g, '<span style="color:var(--ember)">&rarr;</span>')
720
+ .replace(/\\n\\n/g, '<br>')
721
+ .replace(/\\n/g, ' ');
722
+ }
723
+
724
+ /* ───────────────────────── Node Detail Panel ───────────────────────── */
725
+
726
+ function showNodeDetail(id) {
727
+ var node = graph.nodes.find(function(n) { return n.id === id; });
728
+ if (!node) return;
729
+ var incoming = graph.edges.filter(function(e) { return e.target === id; });
730
+ var outgoing = graph.edges.filter(function(e) { return e.source === id; });
731
+ var tc = TYPE_COLORS[node.type] || "#7a8fb5";
732
+ var iconSvg = TYPE_ICON_SVG[node.type] || '<path d="M8,3 L8,13 M3,8 L13,8" stroke="currentColor" stroke-width="1.3" stroke-linecap="round"/>';
733
+ var icon = '<svg width="20" height="20" viewBox="0 0 16 16" style="stroke:' + tc + ';color:' + tc + '">' + iconSvg + '</svg>';
734
+ var desc = TYPE_DESCRIPTIONS[node.type] || "Application component.";
735
+
736
+ /* Build the human-readable display label */
737
+ var displayLabel = node.label;
738
+ if (node.type === "action" && node.metadata && node.metadata.action) {
739
+ displayLabel = node.metadata.action;
740
+ }
741
+
742
+ /* Build connection items */
743
+ var allConnections = [];
744
+ incoming.forEach(function(e) {
745
+ var other = graph.nodes.find(function(n) { return n.id === e.source; });
746
+ allConnections.push(buildConnectionItem(e, other, "incoming"));
747
+ });
748
+ outgoing.forEach(function(e) {
749
+ var other = graph.nodes.find(function(n) { return n.id === e.target; });
750
+ allConnections.push(buildConnectionItem(e, other, "outgoing"));
751
+ });
752
+
753
+ /* Build a quick-facts row for the header */
754
+ var facts = [];
755
+ if (node.type === "route" && node.metadata) {
756
+ if (node.metadata.method) facts.push(node.metadata.method.toUpperCase());
757
+ if (node.metadata.path) facts.push(node.metadata.path);
758
+ } else if (node.type === "action" && node.metadata) {
759
+ if (node.metadata.http_method) facts.push(node.metadata.http_method.toUpperCase());
760
+ if (node.metadata.path) facts.push(node.metadata.path);
761
+ } else if (node.status) {
762
+ facts.push(node.status);
763
+ }
764
+
765
+ var factsHtml = facts.length > 0 ?
766
+ facts.map(function(f) { return '<span class="node-fact">' + esc(f) + '</span>'; }).join(" ") : "";
767
+
768
+ panel.innerHTML =
769
+ '<div class="panel-section">' +
770
+ '<div class="node-detail-header">' +
771
+ '<div class="node-detail-badge" style="background:' + tc + '18;color:' + tc + ';border:1px solid ' + tc + '35">' + icon + '</div>' +
772
+ '<div style="min-width:0">' +
773
+ '<div class="node-detail-title">' + esc(displayLabel) + '</div>' +
774
+ '<div class="node-detail-type">' + esc(node.type) + (factsHtml ? '&ensp;' + factsHtml : '') + '</div>' +
775
+ '</div>' +
776
+ '</div>' +
777
+ '<dl>' +
778
+ detail("What it does", '<span style="color:var(--smoke);font-size:12px;line-height:1.5">' + desc + '</span>') +
779
+ (node.file ? detail("File", '<code style="font-size:11px;color:var(--smoke);word-break:break-all">' + esc(node.file) + '</code>') : '') +
780
+ (node.type === "controller" ? (function() {
781
+ var actions = graph.edges.filter(function(e) { return e.type === "contains" && e.source === node.id; })
782
+ .map(function(e) { return graph.nodes.find(function(n) { return n.id === e.target; }); })
783
+ .filter(function(n) { return n && n.type === "action"; });
784
+ if (actions.length === 0) return "";
785
+ var items = actions.map(function(a) {
786
+ var lbl = a.metadata && a.metadata.action ? a.metadata.action : a.label;
787
+ var st = a.status || "undocumented";
788
+ var sc = st === "documented" ? "#34c759" : "#ffb340";
789
+ var routes = graph.edges.filter(function(e) { return e.type === "routes_to" && e.target === a.id; })
790
+ .map(function(e) { return graph.nodes.find(function(n) { return n.id === e.source; }); })
791
+ .filter(function(n) { return n && n.type === "route"; });
792
+ var routeStr = routes.length > 0 ? routes.map(function(r) {
793
+ return (r.metadata && r.metadata.method ? r.metadata.method.toUpperCase() : "?") + " " +
794
+ (r.metadata && r.metadata.path ? r.metadata.path : r.label);
795
+ }).join(", ") : "(no route)";
796
+ return '<div style="margin:6px 0"><span style="color:var(--text);font-weight:600">' + esc(lbl) + '</span> ' +
797
+ '<span style="background:' + sc + '35;color:' + sc + ';font-size:9px;padding:2px 6px;border-radius:3px;display:inline-block">' + st + '</span>' +
798
+ '<div style="color:var(--smoke);font-size:11px;margin-top:2px">' + esc(routeStr) + '</div></div>';
799
+ }).join("");
800
+ return detail("Actions (" + actions.length + ")", items);
801
+ })() : "") +
802
+ detail("Connections",
803
+ allConnections.length > 0
804
+ ? allConnections.join("")
805
+ : '<span style="color:var(--haze);font-size:12px">No connections found</span>') +
806
+ '</dl>' +
807
+ '</div>';
808
+ }
809
+
810
+ function buildConnectionItem(edge, otherNode, direction) {
811
+ var verb = friendlyEdgeVerb(edge.type, direction);
812
+ var otherLabel = "Unknown";
813
+ if (otherNode) {
814
+ otherLabel = otherNode.type === "action" && otherNode.metadata && otherNode.metadata.action
815
+ ? otherNode.metadata.action
816
+ : otherNode.label;
817
+ }
818
+ var otherType = otherNode ? otherNode.type : "";
819
+ var otherColor = TYPE_COLORS[otherType] || "#7a8fb5";
820
+ var arrow = direction === "incoming" ? "←" : "→";
821
+
822
+ return '<div class="connection-item">' +
823
+ '<span class="connection-arrow" style="color:' + otherColor + '">' + arrow + '</span>' +
824
+ '<div class="connection-info">' +
825
+ '<span class="connection-verb">' + verb + '</span>' +
826
+ '<span class="connection-label">' + esc(truncate(otherLabel, 32)) + '</span>' +
827
+ (otherType ? '<span class="connection-type">(' + esc(otherType) + ')</span>' : '') +
828
+ '</div>' +
829
+ '<button type="button" data-edge="' + esc(edge.id) + '" class="connection-remove" title="Remove">\u00d7</button>' +
830
+ '</div>';
831
+ }
832
+
833
+ function friendlyEdgeVerb(edgeType, direction) {
834
+ var verbs = {
835
+ routes_to: { incoming: "Receives request from", outgoing: "Routes to" },
836
+ contains: { incoming: "Belongs to", outgoing: "Contains" },
837
+ documents: { incoming: "Documented by", outgoing: "Documents" },
838
+ association: { incoming: "Associated with", outgoing: "Associated with" },
839
+ uses_model: { incoming: "Used by", outgoing: "Uses model" },
840
+ calls: { incoming: "Called by", outgoing: "Calls" },
841
+ manual: { incoming: "Linked from", outgoing: "Linked to" }
842
+ };
843
+ var entry = verbs[edgeType];
844
+ if (entry) return entry[direction] || edgeType;
845
+ return edgeType;
846
+ }
847
+
848
+ /* ───────────────────────── Endpoint titles ─────────────────────────
849
+ A "proper definition" for an endpoint heading. Order of preference:
850
+ 1. the documented summary (human-written, always wins)
851
+ 2. a REST-conventional title derived from the action + resource
852
+ (index -> "List Posts", show -> "Get a Post", ...)
853
+ 3. a humanized version of the raw action name as a last resort. */
854
+
855
+ function humanize(str) {
856
+ return String(str)
857
+ .replace(/[_-]+/g, " ")
858
+ .replace(/([a-z])([A-Z])/g, "$1 $2")
859
+ .replace(/\\b\\w/g, function(c) { return c.toUpperCase(); })
860
+ .trim();
861
+ }
862
+
863
+ /* "UsersController" / "Admin::OrdersController" -> { plural:"Orders", singular:"Order" } */
864
+ function resourceName(controllerLabel) {
865
+ var base = String(controllerLabel || "")
866
+ .split(/::|\\//).pop()
867
+ .replace(/Controller$/, "");
868
+ var plural = humanize(base);
869
+ var singular = plural
870
+ .replace(/ies$/, "y")
871
+ .replace(/ses$/, "s")
872
+ .replace(/s$/, "");
873
+ return { plural: plural || "Resource", singular: singular || "Resource" };
874
+ }
875
+
876
+ function endpointTitle(action, controllerLabel, docSummary) {
877
+ if (docSummary && docSummary.trim()) return docSummary.trim();
878
+
879
+ var name = (action.metadata && action.metadata.action ? action.metadata.action : action.label) || "";
880
+ var res = resourceName(controllerLabel);
881
+ var templates = {
882
+ index: "List " + res.plural,
883
+ show: "Get a " + res.singular,
884
+ "new": "New " + res.singular + " form",
885
+ create: "Create a " + res.singular,
886
+ edit: "Edit " + res.singular + " form",
887
+ update: "Update a " + res.singular,
888
+ destroy: "Delete a " + res.singular
889
+ };
890
+ return templates[name] || humanize(name) + " " + res.singular;
891
+ }
892
+
893
+ function resetPanel() {
894
+ panel.innerHTML =
895
+ '<div class="panel-welcome">' +
896
+ '<div class="welcome-icon">' +
897
+ '<svg width="22" height="22" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.3" stroke-linecap="round" stroke-linejoin="round"><rect x="2" y="2" width="5" height="5" rx="1.2"/><rect x="9" y="2" width="5" height="5" rx="1.2"/><rect x="2" y="9" width="5" height="5" rx="1.2"/><rect x="9" y="9" width="5" height="5" rx="1.2"/><path d="M7 4.5h2M4.5 7v2M11.5 7v2"/></svg>' +
898
+ '</div>' +
899
+ '<h2>System Architecture</h2>' +
900
+ '<p>Click a node to inspect how it connects to the rest of the system.</p>' +
901
+ '<div class="welcome-tips">' +
902
+ '<div class="tip"><span class="tip-icon">' + lineIcon(ICON_CLICK) + '</span><span>Click a node to inspect it</span></div>' +
903
+ '<div class="tip"><span class="tip-icon">' + lineIcon(ICON_DRAG) + '</span><span>Drag nodes to rearrange the layout</span></div>' +
904
+ '<div class="tip"><span class="tip-icon">' + lineIcon(ICON_SEARCH) + '</span><span>Scroll to zoom · Shift-drag to pan</span></div>' +
905
+ '</div>' +
906
+ '</div>';
907
+ }
908
+
909
+ /* ───────────────────────── PNG Export ───────────────────────── */
910
+
911
+ function exportPng() {
912
+ var svg = document.getElementById("system-svg");
913
+ if (!svg) { toast("No diagram to export"); return; }
914
+
915
+ var current = visibleGraph();
916
+ var bounds = graphBounds(current.nodes);
917
+ var pad = 60;
918
+
919
+ /* Export background must match the active theme, not a fixed color. */
920
+ var exportBg = currentTheme() === "dark" ? "#0f1117" : "#ffffff";
921
+
922
+ /* Clone and adjust the SVG for standalone rendering */
923
+ var clone = svg.cloneNode(true);
924
+ var w = bounds.w + pad * 2;
925
+ var h = bounds.h + pad * 2;
926
+ clone.setAttribute("width", w);
927
+ clone.setAttribute("height", h);
928
+ clone.setAttribute("viewBox", (bounds.x - pad) + " " + (bounds.y - pad) + " " + w + " " + h);
929
+ clone.setAttribute("xmlns", "http://www.w3.org/2000/svg");
930
+
931
+ /* Remove the zoom transform so the viewBox framing is used instead */
932
+ var zoomGroup = clone.querySelector("#zoom-group");
933
+ if (zoomGroup) zoomGroup.setAttribute("transform", "");
934
+
935
+ /* Add a dark background rect as the first child */
936
+ var bg = document.createElementNS("http://www.w3.org/2000/svg", "rect");
937
+ bg.setAttribute("x", bounds.x - pad);
938
+ bg.setAttribute("y", bounds.y - pad);
939
+ bg.setAttribute("width", w);
940
+ bg.setAttribute("height", h);
941
+ bg.setAttribute("fill", exportBg);
942
+ clone.insertBefore(bg, clone.firstChild);
943
+
944
+ /* Serialize to XML string */
945
+ var xml = new XMLSerializer().serializeToString(clone);
946
+
947
+ /* Ensure proper XML namespace */
948
+ if (xml.indexOf("xmlns") === -1) {
949
+ xml = xml.replace("<svg", '<svg xmlns="http://www.w3.org/2000/svg"');
950
+ }
951
+
952
+ var blob = new Blob([xml], { type: "image/svg+xml;charset=utf-8" });
953
+ var url = URL.createObjectURL(blob);
954
+ var img = new Image();
955
+ var scale = 2;
956
+
957
+ img.onload = function() {
958
+ var canvasEl = document.createElement("canvas");
959
+ canvasEl.width = w * scale;
960
+ canvasEl.height = h * scale;
961
+ var ctx = canvasEl.getContext("2d");
962
+ ctx.scale(scale, scale);
963
+ ctx.fillStyle = exportBg;
964
+ ctx.fillRect(0, 0, w, h);
965
+ ctx.drawImage(img, 0, 0, w, h);
966
+ URL.revokeObjectURL(url);
967
+
968
+ canvasEl.toBlob(function(pngBlob) {
969
+ var link = document.createElement("a");
970
+ link.download = "docit-system-architecture.png";
971
+ link.href = URL.createObjectURL(pngBlob);
972
+ link.click();
973
+ setTimeout(function() { URL.revokeObjectURL(link.href); }, 100);
974
+ toast("PNG exported!");
975
+ }, "image/png");
976
+ };
977
+
978
+ img.onerror = function() {
979
+ URL.revokeObjectURL(url);
980
+ /* Fallback: download SVG instead */
981
+ var svgLink = document.createElement("a");
982
+ svgLink.download = "docit-system-architecture.svg";
983
+ svgLink.href = URL.createObjectURL(blob);
984
+ svgLink.click();
985
+ toast("Exported as SVG (PNG rendering failed)");
986
+ };
987
+
988
+ img.src = url;
989
+ }
990
+
991
+ /* ───────────────────────── Legend ───────────────────────── */
992
+
993
+ function buildLegend() {
994
+ var types = uniqueSorted(graph.nodes.map(function(n) { return n.type; }));
995
+ var edgeTypes = uniqueSorted(graph.edges.map(function(e) { return e.type; }));
996
+
997
+ legendContent.innerHTML =
998
+ '<div class="legend-section">' +
999
+ '<div class="legend-heading">Node Types</div>' +
1000
+ types.map(function(t) {
1001
+ return '<div class="legend-item"><span class="legend-dot" style="background:' + (TYPE_COLORS[t] || "#7a8fb5") + '"></span><span>' + esc(t) + '</span></div>';
1002
+ }).join("") +
1003
+ '</div>' +
1004
+ '<div class="legend-section">' +
1005
+ '<div class="legend-heading">Edge Types</div>' +
1006
+ edgeTypes.map(function(t) {
1007
+ return '<div class="legend-item"><span class="legend-line" style="background:' + (EDGE_COLORS[t] || "#7a8fb5") + '"></span><span>' + esc(t) + '</span></div>';
1008
+ }).join("") +
1009
+ '</div>' +
1010
+ '<div class="legend-section">' +
1011
+ '<div class="legend-heading">Status</div>' +
1012
+ '<div class="legend-item"><span class="legend-dot" style="background:#34c759"></span><span>Documented</span></div>' +
1013
+ '<div class="legend-item"><span class="legend-dot" style="background:#ffb340"></span><span>Undocumented</span></div>' +
1014
+ '<div class="legend-item"><span class="legend-dot" style="background:#7a8fb5"></span><span>Unknown</span></div>' +
1015
+ '</div>';
1016
+ }
1017
+
1018
+ function toggleLegendPanel() {
1019
+ legendEl.classList.toggle("collapsed");
1020
+ legendToggle.textContent = legendEl.classList.contains("collapsed") ? "Legend ▸" : "Legend ▾";
1021
+ }
1022
+
1023
+ /* ───────────────────────── Utilities ───────────────────────── */
1024
+
1025
+ function detail(label, value) {
1026
+ return '<dt>' + esc(label) + '</dt><dd>' + value + '</dd>';
1027
+ }
1028
+
1029
+ function toast(msg) {
1030
+ toastEl.textContent = msg;
1031
+ toastEl.classList.add("visible");
1032
+ setTimeout(function() { toastEl.classList.remove("visible"); }, 2200);
1033
+ }
1034
+
1035
+ function truncate(s, n) {
1036
+ return s.length > n ? s.slice(0, n - 1) + "…" : s;
1037
+ }
1038
+
1039
+ function esc(s) {
1040
+ var map = { "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" };
1041
+ return String(s).replace(/[&<>"']/g, function(c) { return map[c]; });
1042
+ }
1043
+
1044
+ function uniqueSorted(arr) {
1045
+ return Array.from(new Set(arr)).sort();
1046
+ }
1047
+
1048
+ /* ───────────────────────── Stripe Docs logical view ───────────────────────── */
1049
+
1050
+ window.inspectNode = function(nodeId) {
1051
+ selectedId = nodeId;
1052
+ showNodeDetail(nodeId);
1053
+ render();
1054
+ if (window.innerWidth <= 980) {
1055
+ panel.scrollIntoView({ behavior: "smooth" });
1056
+ }
1057
+ };
1058
+
1059
+ /* ───────────────────────── Docs detail panel ───────────────────────── */
1060
+
1061
+ const detailPanel = $("stripe-detail");
1062
+ const detailBody = $("stripe-detail-body");
1063
+ const detailClose = $("stripe-detail-close");
1064
+
1065
+ /* Action node-ids belonging to a controller (the section's endpoints). */
1066
+ function sectionActionIds(controllerId) {
1067
+ return graph.edges
1068
+ .filter(function(e) { return e.type === "contains" && e.source === controllerId; })
1069
+ .map(function(e) { return e.target; })
1070
+ .filter(function(id) {
1071
+ var n = graph.nodes.find(function(x) { return x.id === id; });
1072
+ return n && n.type === "action";
1073
+ });
1074
+ }
1075
+
1076
+ /* The doc node documenting an action, if any. */
1077
+ function docForAction(actionId) {
1078
+ var docEdge = graph.edges.find(function(e) {
1079
+ return e.type === "documents" && e.target === actionId;
1080
+ });
1081
+ if (!docEdge) return null;
1082
+ return graph.nodes.find(function(n) { return n.id === docEdge.source; });
1083
+ }
1084
+
1085
+ /* The route (method + path) for an action, if any. */
1086
+ function routeForAction(actionId) {
1087
+ var routeEdge = graph.edges.find(function(e) {
1088
+ return e.type === "routes_to" && e.target === actionId;
1089
+ });
1090
+ if (!routeEdge) return null;
1091
+ return graph.nodes.find(function(n) { return n.id === routeEdge.source; });
1092
+ }
1093
+
1094
+ function openDetailPanel(html) {
1095
+ if (!detailPanel || !detailBody) return;
1096
+ detailBody.innerHTML = html;
1097
+ detailPanel.scrollTop = 0;
1098
+ detailPanel.classList.add("open"); /* only matters in drawer mode */
1099
+ }
1100
+
1101
+ function closeDetailPanel() {
1102
+ if (detailPanel) detailPanel.classList.remove("open");
1103
+ }
1104
+
1105
+ if (detailClose) detailClose.addEventListener("click", closeDetailPanel);
1106
+
1107
+ /* Render an endpoint's request/response reference into the panel.
1108
+ All fields come from the doc node — nothing is invented. */
1109
+ function showEndpointDetail(actionId) {
1110
+ var action = graph.nodes.find(function(n) { return n.id === actionId; });
1111
+ if (!action) return;
1112
+
1113
+ /* Highlight the matching card. */
1114
+ var contentEl = $("stripe-content");
1115
+ if (contentEl) {
1116
+ contentEl.querySelectorAll(".stripe-endpoint-card.detail-active").forEach(function(c) {
1117
+ c.classList.remove("detail-active");
1118
+ });
1119
+ var card = document.getElementById("action-" + actionId.replace(/:/g, "-"));
1120
+ if (card) card.classList.add("detail-active");
1121
+ }
1122
+
1123
+ var controllerEdge = graph.edges.find(function(e) { return e.type === "contains" && e.target === actionId; });
1124
+ var controllerLabel = controllerEdge
1125
+ ? (graph.nodes.find(function(n) { return n.id === controllerEdge.source; }) || {}).label
1126
+ : "";
1127
+
1128
+ var route = routeForAction(actionId);
1129
+ var method = route && route.metadata ? (route.metadata.method || "GET").toUpperCase() : "";
1130
+ var path = route && route.metadata ? (route.metadata.path || "") : "";
1131
+ var doc = docForAction(actionId);
1132
+ var meta = (doc && doc.metadata) || {};
1133
+ var actionName = action.metadata && action.metadata.action ? action.metadata.action : action.label;
1134
+ var definition = endpointTitle(action, controllerLabel, doc ? doc.label : "");
1135
+ var methodColor = METHOD_COLORS[method] || "var(--haze)";
1136
+
1137
+ var html = '<div class="detail-kicker">Endpoint</div>';
1138
+ html += '<h2 class="detail-title">' + esc(definition) + '</h2>';
1139
+ if (method && path) {
1140
+ html += '<div class="detail-endpoint-line">' +
1141
+ '<span class="verb" style="color:' + methodColor + '">' + method + '</span>' +
1142
+ '<span class="path">' + esc(path) + '</span></div>';
1143
+ }
1144
+ if (meta.description) {
1145
+ html += '<p class="detail-desc">' + esc(meta.description) + '</p>';
1146
+ }
1147
+
1148
+ html += renderParams(meta.parameters);
1149
+ html += renderRequestBody(meta.request_body);
1150
+ html += renderResponses(meta.responses);
1151
+
1152
+ if (!doc) {
1153
+ html += '<div class="detail-section"><div class="detail-error">' +
1154
+ 'This endpoint has no documentation yet. Add a Docit doc-block to ' +
1155
+ '<span class="mono">' + esc(actionName) + '</span> to see its parameters, request body, and responses here.' +
1156
+ '</div></div>';
1157
+ }
1158
+
1159
+ openDetailPanel(html);
1160
+ }
1161
+
1162
+ function renderParams(params) {
1163
+ if (!params || params.length === 0) return "";
1164
+ var rows = params.map(function(p) {
1165
+ return '<div class="detail-param">' +
1166
+ '<div class="detail-param-head">' +
1167
+ '<span class="detail-param-name">' + esc(p.name) + '</span>' +
1168
+ (p.type ? '<span class="detail-param-type">' + esc(p.type) + '</span>' : '') +
1169
+ (p.location ? '<span class="detail-chip loc">' + esc(p.location) + '</span>' : '') +
1170
+ (p.required ? '<span class="detail-chip req">required</span>' : '') +
1171
+ '</div>' +
1172
+ (p.description ? '<div class="detail-param-desc">' + esc(p.description) + '</div>' : '') +
1173
+ '</div>';
1174
+ }).join("");
1175
+ return '<div class="detail-section"><div class="detail-section-title">Parameters</div>' + rows + '</div>';
1176
+ }
1177
+
1178
+ function renderRequestBody(body) {
1179
+ if (!body) return "";
1180
+ var props = body.properties;
1181
+ var shape;
1182
+ if (props && typeof props === "object") {
1183
+ shape = JSON.stringify(props, null, 2);
1184
+ } else {
1185
+ shape = "(no schema described)";
1186
+ }
1187
+ var ct = body.content_type ? esc(body.content_type) : "application/json";
1188
+ return '<div class="detail-section">' +
1189
+ '<div class="detail-section-title">Request body' + (body.required ? ' · required' : '') + '</div>' +
1190
+ '<div class="detail-param-desc" style="margin-bottom:8px">Content-Type: <span class="mono">' + ct + '</span></div>' +
1191
+ '<pre class="detail-code">' + esc(shape) + '</pre></div>';
1192
+ }
1193
+
1194
+ function renderResponses(responses) {
1195
+ if (!responses || responses.length === 0) return "";
1196
+ var rows = responses.map(function(r) {
1197
+ var status = String(r.status || "");
1198
+ var sc = status.charAt(0) === "2" ? "var(--success)"
1199
+ : status.charAt(0) === "4" ? "var(--danger)"
1200
+ : status.charAt(0) === "5" ? "var(--warning)" : "var(--haze)";
1201
+ var body = "";
1202
+ if (r.examples && Object.keys(r.examples).length > 0) {
1203
+ body = '<pre class="detail-code">' + esc(JSON.stringify(r.examples, null, 2)) + '</pre>';
1204
+ } else if (r.properties && Object.keys(r.properties).length > 0) {
1205
+ body = '<pre class="detail-code">' + esc(JSON.stringify(r.properties, null, 2)) + '</pre>';
1206
+ }
1207
+ return '<div class="detail-response">' +
1208
+ '<div class="detail-response-head">' +
1209
+ '<span class="detail-chip" style="background:' + sc + '1a;color:' + sc + '">' + esc(status) + '</span>' +
1210
+ (r.description ? '<span class="detail-response-desc">' + esc(r.description) + '</span>' : '') +
1211
+ '</div>' + body +
1212
+ '</div>';
1213
+ }).join("");
1214
+ return '<div class="detail-section"><div class="detail-section-title">Responses</div>' + rows + '</div>';
1215
+ }
1216
+
1217
+ /* Render the AI section explanation into the panel. The
1218
+ undocumented-endpoint warning is handled by the caller. */
1219
+ function showSectionExplain(controllerId) {
1220
+ var ids = sectionActionIds(controllerId);
1221
+ var controller = graph.nodes.find(function(n) { return n.id === controllerId; });
1222
+ var sectionName = controller ? resourceName(controller.label).plural : "Section";
1223
+
1224
+ if (ids.length === 0) {
1225
+ openDetailPanel('<div class="detail-kicker">Section</div><h2 class="detail-title">' + esc(sectionName) +
1226
+ '</h2><div class="detail-error">No endpoints to explain in this section.</div>');
1227
+ return;
1228
+ }
1229
+
1230
+ openDetailPanel('<div class="detail-kicker">Section</div><h2 class="detail-title">' + esc(sectionName) + '</h2>' +
1231
+ '<div class="detail-ai-head">' + lineIcon(ICON_SPARKLE) + ' How this section works</div>' +
1232
+ '<div class="detail-loading">Generating explanation…</div>');
1233
+
1234
+ var url = insightsUrl + "?mode=section&node_ids=" + encodeURIComponent(ids.join(","));
1235
+ fetch(url)
1236
+ .then(function(r) { return r.json(); })
1237
+ .then(function(data) {
1238
+ if (data.error) throw new Error(data.error);
1239
+ openDetailPanel('<div class="detail-kicker">Section</div><h2 class="detail-title">' + esc(sectionName) + '</h2>' +
1240
+ '<div class="detail-ai-head">' + lineIcon(ICON_SPARKLE) + ' How this section works</div>' +
1241
+ '<div class="detail-ai-body">' + formatMarkdown(data.insight) + '</div>');
1242
+ })
1243
+ .catch(function(err) {
1244
+ openDetailPanel('<div class="detail-kicker">Section</div><h2 class="detail-title">' + esc(sectionName) + '</h2>' +
1245
+ '<div class="detail-error">Explanation unavailable: ' + esc(err.message) +
1246
+ '<br>Run <span class="mono">rails generate docit:ai_setup</span> to configure an AI provider.</div>');
1247
+ });
1248
+ }
1249
+
1250
+ /* One-time delegation on the docs content: section Explain + endpoint clicks. */
1251
+ (function bindDocsInteractions() {
1252
+ var content = $("stripe-content");
1253
+ if (!content) return;
1254
+ content.addEventListener("click", function(e) {
1255
+ var sectionBtn = e.target.closest(".stripe-explain-btn");
1256
+ if (sectionBtn) {
1257
+ /* Gate: warn before spending tokens on a thinly-documented section. */
1258
+ if (sectionBtn.dataset.fulldoc !== "1") {
1259
+ var ok = window.confirm(
1260
+ sectionBtn.dataset.undoc + " endpoint(s) in this section are undocumented.\\n\\n" +
1261
+ "The explanation may be weak and will use more tokens. For the best result, " +
1262
+ "document these endpoints first.\\n\\nGenerate anyway?");
1263
+ if (!ok) return;
1264
+ }
1265
+ showSectionExplain(sectionBtn.dataset.section);
1266
+ return;
1267
+ }
1268
+ /* Relation cards have their own behavior (jump to the diagram). */
1269
+ if (e.target.closest(".stripe-relation-card")) return;
1270
+ /* A click on an endpoint card (or its View details button) opens
1271
+ that endpoint's request/response detail in the panel. */
1272
+ var endpointBtn = e.target.closest(".stripe-endpoint-explain");
1273
+ if (endpointBtn) { showEndpointDetail(endpointBtn.dataset.action); return; }
1274
+ var card = e.target.closest(".stripe-endpoint-card");
1275
+ if (card && card.dataset.action) { showEndpointDetail(card.dataset.action); return; }
1276
+ });
1277
+ })();
1278
+
1279
+ function renderStripeDocs() {
1280
+ const sidebar = $("stripe-sidebar");
1281
+ const content = $("stripe-content");
1282
+ if (!sidebar || !content) return;
1283
+
1284
+ /* Docs view filters by resource section, not free-text. An empty
1285
+ value means "All sections"; otherwise it's a controller id. */
1286
+ const selectedSection = sectionFilter ? sectionFilter.value : "";
1287
+ let controllers = graph.nodes.filter(function(n) {
1288
+ return n.type === "controller" && (!selectedSection || n.id === selectedSection);
1289
+ });
1290
+
1291
+ controllers = controllers.sort(function(a, b) { return a.label.localeCompare(b.label); });
1292
+
1293
+ let sidebarHtml = "";
1294
+ let contentHtml = "";
1295
+
1296
+ if (controllers.length === 0) {
1297
+ content.innerHTML = '<div class="panel-empty">No endpoints found for this section.</div>';
1298
+ sidebar.innerHTML = "";
1299
+ return;
1300
+ }
1301
+
1302
+ controllers.forEach(function(controller) {
1303
+ let actions = graph.edges
1304
+ .filter(function(e) { return e.type === "contains" && e.source === controller.id; })
1305
+ .map(function(e) { return graph.nodes.find(function(n) { return n.id === e.target; }); })
1306
+ .filter(function(n) { return n && n.type === "action"; });
1307
+
1308
+ actions = actions.sort(function(a, b) { return a.label.localeCompare(b.label); });
1309
+ if (actions.length === 0) return;
1310
+
1311
+ const controllerAnchor = "controller-" + controller.id.replace(/:/g, "-");
1312
+
1313
+ sidebarHtml += '<div class="stripe-sidebar-group">';
1314
+ sidebarHtml += '<div class="stripe-sidebar-heading">' + esc(controller.label.replace("Controller", "")) + '</div>';
1315
+
1316
+ var res = resourceName(controller.label);
1317
+
1318
+ /* Documentation coverage drives both the badge and the AI gate:
1319
+ a fully-documented section gives the model real input; a thin
1320
+ one yields a weak explanation and still costs tokens. */
1321
+ var documentedCount = actions.filter(function(a) { return a.status === "documented"; }).length;
1322
+ var totalCount = actions.length;
1323
+ var fullyDocumented = documentedCount === totalCount;
1324
+ var coverageColor = fullyDocumented ? "var(--success)" : "var(--warning)";
1325
+
1326
+ contentHtml += '<section class="stripe-controller-block" id="' + controllerAnchor + '">';
1327
+ contentHtml += ' <div class="stripe-controller-header">';
1328
+ contentHtml += ' <div>';
1329
+ contentHtml += ' <div class="stripe-controller-kicker">Resource</div>';
1330
+ contentHtml += ' <h2 class="stripe-controller-title">' + esc(res.plural) + '</h2>';
1331
+ contentHtml += ' </div>';
1332
+ contentHtml += ' <div class="stripe-controller-aside">';
1333
+ contentHtml += ' <span class="stripe-coverage" style="color:' + coverageColor + ';border-color:' + coverageColor + '40;background:' + coverageColor + '12">' +
1334
+ documentedCount + '/' + totalCount + ' documented</span>';
1335
+ contentHtml += ' <button class="stripe-explain-btn" type="button" ' +
1336
+ 'data-section="' + esc(controller.id) + '" data-fulldoc="' + (fullyDocumented ? "1" : "0") +
1337
+ '" data-undoc="' + (totalCount - documentedCount) + '">Explain section</button>';
1338
+ contentHtml += ' </div>';
1339
+ contentHtml += ' </div>';
1340
+ contentHtml += ' <p class="stripe-controller-sub">' + actions.length + ' endpoint' + (actions.length === 1 ? '' : 's') +
1341
+ (controller.file ? ' &middot; <span class="mono">' + esc(controller.file) + '</span>' : '') + '</p>';
1342
+
1343
+ actions.forEach(function(action) {
1344
+ const actionAnchor = "action-" + action.id.replace(/:/g, "-");
1345
+ const actionLabel = action.metadata && action.metadata.action ? action.metadata.action : action.label;
1346
+
1347
+ const routes = graph.edges
1348
+ .filter(function(e) { return e.type === "routes_to" && e.target === action.id; })
1349
+ .map(function(e) { return graph.nodes.find(function(n) { return n.id === e.source; }); })
1350
+ .filter(function(n) { return n && n.type === "route"; });
1351
+
1352
+ const route = routes[0];
1353
+ const method = route && route.metadata ? (route.metadata.method || "GET").toUpperCase() : "";
1354
+ const path = route && route.metadata ? (route.metadata.path || "") : "";
1355
+
1356
+ const docs = graph.edges
1357
+ .filter(function(e) { return e.type === "documents" && e.target === action.id; })
1358
+ .map(function(e) { return graph.nodes.find(function(n) { return n.id === e.source; }); })
1359
+ .filter(function(n) { return n && n.type === "doc"; });
1360
+
1361
+ const doc = docs[0];
1362
+ const summary = doc && doc.label ? doc.label : (action.metadata && action.metadata.summary ? action.metadata.summary : "");
1363
+ const status = action.status || "undocumented";
1364
+ const isDocumented = status === "documented";
1365
+
1366
+ /* The heading is a proper definition; the raw action name becomes a kicker. */
1367
+ const definition = endpointTitle(action, controller.label, summary);
1368
+ const methodColor = METHOD_COLORS[method] || "var(--haze)";
1369
+ const statusColor = isDocumented ? "var(--success)" : "var(--warning)";
1370
+
1371
+ /* Sidebar: definition as the link text, method as a trailing chip.
1372
+ Carries data-action so a click also opens the detail panel. */
1373
+ sidebarHtml += '<a class="stripe-sidebar-item" href="#' + actionAnchor + '" data-action="' + esc(action.id) + '">';
1374
+ sidebarHtml += ' <span class="stripe-sidebar-label">' + esc(definition) + '</span>';
1375
+ if (method) {
1376
+ sidebarHtml += ' <span class="stripe-sidebar-badge" style="background:' + methodColor + '20;color:' + methodColor + '">' + method + '</span>';
1377
+ }
1378
+ sidebarHtml += '</a>';
1379
+
1380
+ contentHtml += '<article class="stripe-endpoint-card" id="' + actionAnchor + '" data-action="' + esc(action.id) + '">';
1381
+ contentHtml += ' <div class="stripe-endpoint-header">';
1382
+ contentHtml += ' <div class="stripe-endpoint-title-wrap">';
1383
+ contentHtml += ' <div class="stripe-endpoint-kicker"><span class="mono">' + esc(actionLabel) + '</span>';
1384
+ contentHtml += ' <span class="action-doc-badge" style="background:' + statusColor + '15;color:' + statusColor + ';border-color:' + statusColor + '30">' + status + '</span>';
1385
+ contentHtml += ' </div>';
1386
+ contentHtml += ' <h3 class="stripe-endpoint-title">' + esc(definition) + '</h3>';
1387
+ contentHtml += ' </div>';
1388
+ contentHtml += ' <button class="stripe-endpoint-explain system-btn" type="button" data-action="' + esc(action.id) + '">View details</button>';
1389
+ contentHtml += ' </div>';
1390
+
1391
+ if (method && path) {
1392
+ contentHtml += ' <div class="stripe-endpoint-meta">';
1393
+ contentHtml += ' <span class="stripe-endpoint-verb" style="color:' + methodColor + '">' + method + '</span>';
1394
+ contentHtml += ' <span class="stripe-endpoint-path">' + esc(path) + '</span>';
1395
+ contentHtml += ' </div>';
1396
+ }
1397
+
1398
+ if (summary && summary !== definition) {
1399
+ contentHtml += ' <div class="stripe-endpoint-desc">' + esc(summary) + '</div>';
1400
+ } else if (!summary) {
1401
+ contentHtml += ' <div class="stripe-endpoint-desc stripe-endpoint-desc--empty">No description provided yet. Add a Docit doc-block to this action to document it.</div>';
1402
+ }
1403
+
1404
+ const relations = graph.edges
1405
+ .filter(function(e) { return e.source === action.id || e.source === controller.id; })
1406
+ .map(function(e) { return { edge: e, node: graph.nodes.find(function(n) { return n.id === e.target; }) }; })
1407
+ .filter(function(r) { return r.node && ["model", "service", "job", "mailer"].indexOf(r.node.type) !== -1; });
1408
+
1409
+ if (relations.length > 0) {
1410
+ contentHtml += ' <div class="stripe-relations-title">Interacts with</div>';
1411
+ contentHtml += ' <div class="stripe-relations-grid">';
1412
+ relations.forEach(function(rel) {
1413
+ const typeColor = TYPE_COLORS[rel.node.type] || "var(--haze)";
1414
+ const typeIcon = TYPE_ICON_SVG[rel.node.type] || "";
1415
+ contentHtml += ' <div class="stripe-relation-card" onclick="window.inspectNode(\\\'' + rel.node.id + '\\\')">';
1416
+ contentHtml += ' <div class="stripe-relation-icon" style="background:' + typeColor + '15;color:' + typeColor + ';border:1px solid ' + typeColor + '30">';
1417
+ contentHtml += ' <svg width="13" height="13" viewBox="0 0 16 16" style="color:' + typeColor + '">' + typeIcon + '</svg>';
1418
+ contentHtml += ' </div>';
1419
+ contentHtml += ' <div class="stripe-relation-text">';
1420
+ contentHtml += ' <span class="stripe-relation-name">' + esc(rel.node.label) + '</span>';
1421
+ contentHtml += ' <span class="stripe-relation-type">' + esc(rel.node.type) + '</span>';
1422
+ contentHtml += ' </div>';
1423
+ contentHtml += ' </div>';
1424
+ });
1425
+ contentHtml += ' </div>';
1426
+ }
1427
+
1428
+ contentHtml += '</article>';
1429
+ });
1430
+
1431
+ contentHtml += '</section>';
1432
+ sidebarHtml += '</div>';
1433
+ });
1434
+
1435
+ sidebar.innerHTML = sidebarHtml;
1436
+ content.innerHTML = contentHtml;
1437
+
1438
+ const sidebarLinks = sidebar.querySelectorAll(".stripe-sidebar-item");
1439
+ sidebarLinks.forEach(function(link) {
1440
+ link.addEventListener("click", function(e) {
1441
+ e.preventDefault();
1442
+ const targetId = this.getAttribute("href").substring(1);
1443
+ const targetEl = document.getElementById(targetId);
1444
+ if (targetEl) {
1445
+ targetEl.scrollIntoView({ behavior: "smooth", block: "start" });
1446
+ sidebarLinks.forEach(function(l) { l.classList.remove("active"); });
1447
+ this.classList.add("active");
1448
+ }
1449
+ /* Also open the endpoint's detail in the right panel. */
1450
+ if (this.dataset.action) showEndpointDetail(this.dataset.action);
1451
+ });
1452
+ });
1453
+
1454
+ content.onscroll = function() {
1455
+ const scrollPos = content.scrollTop + 60;
1456
+ controllers.forEach(function(controller) {
1457
+ const actions = graph.edges
1458
+ .filter(function(e) { return e.type === "contains" && e.source === controller.id; })
1459
+ .map(function(e) { return graph.nodes.find(function(n) { return n.id === e.target; }); })
1460
+ .filter(function(n) { return n && n.type === "action"; });
1461
+
1462
+ actions.forEach(function(action) {
1463
+ const actionAnchor = "action-" + action.id.replace(/:/g, "-");
1464
+ const el = document.getElementById(actionAnchor);
1465
+ if (el && el.offsetTop <= scrollPos && (el.offsetTop + el.offsetHeight) > scrollPos) {
1466
+ sidebarLinks.forEach(function(l) {
1467
+ if (l.getAttribute("href") === "#" + actionAnchor) {
1468
+ l.classList.add("active");
1469
+ } else {
1470
+ l.classList.remove("active");
1471
+ }
1472
+ });
1473
+ }
1474
+ });
1475
+ });
1476
+ };
1477
+ }
1478
+
1479
+ })();
1480
+ JS
1481
+ end
1482
+
1483
+ def self.json_escape(json_string)
1484
+ json_string.to_s.gsub(/[&<>'\u2028\u2029]/, {
1485
+ '&' => '\u0026',
1486
+ '<' => '\u003c',
1487
+ '>' => '\u003e',
1488
+ "'" => '\u0027',
1489
+ "\u2028" => '\u2028',
1490
+ "\u2029" => '\u2029'
1491
+ })
1492
+ end
1493
+ end
1494
+ end
1495
+ end