snatching amp's walkthrough for my own purposes mwhahaha
traverse.dunkirk.sh/diagram/6121f05c-a5ef-4ecf-8ffc-02534c5e767c
1import type { WalkthroughDiagram } from "./types.ts";
2
3interface ViewerOptions {
4 mode?: "local" | "server";
5 shareServerUrl?: string;
6 diagramId?: string;
7 existingShareUrl?: string;
8 baseUrl?: string;
9}
10
11export function generateViewerHTML(diagram: WalkthroughDiagram, gitHash: string = "dev", projectRoot: string = "", options: ViewerOptions = {}): string {
12 const diagramJSON = JSON.stringify(diagram).replace(/<\//g, "<\\/");
13 const { mode = "local", shareServerUrl = "", diagramId = "", existingShareUrl = "", baseUrl = "" } = options;
14
15 return `<!DOCTYPE html>
16<html lang="en">
17<head>
18 <meta charset="UTF-8" />
19 <meta name="viewport" content="width=device-width, initial-scale=1.0" />
20 <title>Traverse — ${escapeHTML(diagram.summary)}</title>
21 <link rel="icon" href="/icon.svg" type="image/svg+xml" />${baseUrl && diagramId ? `
22 <meta property="og:type" content="website" />
23 <meta property="og:title" content="${escapeHTML(diagram.summary)}" />
24 <meta property="og:description" content="Interactive code walkthrough with ${Object.keys(diagram.nodes).length} nodes" />
25 <meta property="og:image" content="${escapeHTML(baseUrl)}/diagram/${escapeHTML(diagramId)}/og.png" />
26 <meta name="twitter:card" content="summary_large_image" />
27 <meta name="twitter:title" content="${escapeHTML(diagram.summary)}" />
28 <meta name="twitter:image" content="${escapeHTML(baseUrl)}/diagram/${escapeHTML(diagramId)}/og.png" />` : ""}
29 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11/styles/github-dark.min.css" id="hljs-dark" disabled />
30 <link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/highlight.js@11/styles/github.min.css" id="hljs-light" />
31 <script src="https://cdn.jsdelivr.net/gh/highlightjs/cdn-release@11/build/highlight.min.js"></script>
32 <script src="https://cdn.jsdelivr.net/npm/marked@15/marked.min.js"></script>
33 <style>
34 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
35
36 :root {
37 --bg: #fafafa;
38 --bg-panel: #ffffff;
39 --border: #e2e2e2;
40 --text: #1a1a1a;
41 --text-muted: #666;
42 --accent: #2563eb;
43 --accent-hover: #1d4ed8;
44 --accent-subtle: rgba(37, 99, 235, 0.08);
45 --node-hover: rgba(37, 99, 235, 0.08);
46 --code-bg: #f4f4f5;
47 --summary-bg: #f0f4ff;
48 --shadow: 0 1px 3px rgba(0,0,0,0.06), 0 1px 2px rgba(0,0,0,0.04);
49 --shadow-lg: 0 4px 12px rgba(0,0,0,0.08), 0 2px 4px rgba(0,0,0,0.04);
50 }
51
52 @media (prefers-color-scheme: dark) {
53 :root {
54 --bg: #0a0a0a;
55 --bg-panel: #141414;
56 --border: #262626;
57 --text: #e5e5e5;
58 --text-muted: #a3a3a3;
59 --accent: #3b82f6;
60 --accent-hover: #60a5fa;
61 --accent-subtle: rgba(59, 130, 246, 0.1);
62 --node-hover: rgba(59, 130, 246, 0.12);
63 --code-bg: #1c1c1e;
64 --summary-bg: #111827;
65 --shadow: 0 1px 3px rgba(0,0,0,0.3);
66 --shadow-lg: 0 4px 12px rgba(0,0,0,0.4);
67 }
68 }
69
70 body {
71 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
72 background: var(--bg);
73 color: var(--text);
74 min-height: 100vh;
75 display: flex;
76 flex-direction: column;
77 }
78
79 /* ── Summary bar with breadcrumb ── */
80 .summary-bar {
81 position: sticky;
82 top: 0;
83 z-index: 100;
84 padding: 12px 20px;
85 background: var(--summary-bg);
86 border-bottom: 1px solid var(--border);
87 font-size: 14px;
88 color: var(--text-muted);
89 display: flex;
90 align-items: center;
91 gap: 8px;
92 backdrop-filter: blur(12px);
93 -webkit-backdrop-filter: blur(12px);
94 }
95
96 .summary-bar .label {
97 font-weight: 600;
98 color: var(--text-muted);
99 text-transform: uppercase;
100 font-size: 11px;
101 letter-spacing: 0.05em;
102 flex-shrink: 0;
103 text-decoration: none;
104 transition: color 0.15s;
105 }
106
107 .summary-bar .label:hover {
108 color: var(--text);
109 }
110
111 .summary-bar .sep {
112 color: var(--text-muted);
113 flex-shrink: 0;
114 font-size: 11px;
115 }
116
117 .summary-bar .breadcrumb-title {
118 color: var(--text-muted);
119 overflow: hidden;
120 text-overflow: ellipsis;
121 white-space: nowrap;
122 }
123
124 body.has-selection .summary-bar .breadcrumb-title {
125 cursor: pointer;
126 }
127
128 body.has-selection .summary-bar .breadcrumb-title:hover {
129 color: var(--text);
130 }
131
132 .summary-bar .header-sep,
133 .summary-bar .header-node {
134 display: none;
135 }
136
137 body.has-selection .summary-bar .header-sep,
138 body.has-selection .summary-bar .header-node {
139 display: inline;
140 }
141
142 .summary-bar .header-node {
143 color: var(--text);
144 font-weight: 500;
145 overflow: hidden;
146 text-overflow: ellipsis;
147 white-space: nowrap;
148 }
149
150 .share-btn {
151 margin-left: auto;
152 flex-shrink: 0;
153 display: flex;
154 align-items: center;
155 gap: 5px;
156 background: none;
157 border: 1px solid var(--border);
158 border-radius: 6px;
159 padding: 4px 10px;
160 font-size: 12px;
161 color: var(--text-muted);
162 cursor: pointer;
163 transition: color 0.15s, border-color 0.15s, background 0.15s;
164 font-family: inherit;
165 }
166 .share-btn:hover {
167 color: var(--text);
168 border-color: var(--text-muted);
169 background: var(--code-bg);
170 }
171 .share-btn.shared {
172 color: #16a34a;
173 border-color: #16a34a;
174 }
175
176 .diagram-wrap {
177 padding: 32px 32px 0;
178 }
179
180 .diagram-section {
181 display: flex;
182 align-items: center;
183 justify-content: center;
184 padding: 24px;
185 border: 1px solid var(--border);
186 border-radius: 8px;
187 opacity: 0;
188 transition: opacity 0.3s ease;
189 }
190
191 .diagram-section.ready {
192 opacity: 1;
193 }
194
195 .diagram-section pre.mermaid {
196 width: 100%;
197 }
198
199 .diagram-section svg {
200 max-height: calc(100vh - 100px);
201 width: 100%;
202 }
203
204 /* ── Force theme colors on all Mermaid elements ── */
205
206 /* Global mermaid label overrides */
207 .diagram-section .label {
208 font-family: inherit;
209 color: var(--text) !important;
210 }
211 .diagram-section .label text,
212 .diagram-section .label span {
213 fill: var(--text) !important;
214 color: var(--text) !important;
215 }
216 .diagram-section .cluster-label text,
217 .diagram-section .cluster-label span,
218 .diagram-section .cluster-label span p {
219 fill: var(--text) !important;
220 color: var(--text) !important;
221 background-color: transparent !important;
222 }
223
224 /* Flowchart nodes */
225 .diagram-section .node rect,
226 .diagram-section .node circle,
227 .diagram-section .node ellipse,
228 .diagram-section .node polygon,
229 .diagram-section .node path,
230 .diagram-section .node .label-container,
231 .diagram-section .node .label-container path,
232 .diagram-section .node g path {
233 fill: var(--bg) !important;
234 stroke: var(--text) !important;
235 transition: fill 0.15s, stroke 0.15s, stroke-width 0.15s;
236 }
237 .diagram-section .node .label,
238 .diagram-section .node .nodeLabel,
239 .diagram-section .node text,
240 .diagram-section .node foreignObject {
241 color: var(--text) !important;
242 fill: var(--text) !important;
243 }
244 .diagram-section .node foreignObject div,
245 .diagram-section .node foreignObject span,
246 .diagram-section .node foreignObject p {
247 color: var(--text) !important;
248 }
249
250 /* Edge labels */
251 .diagram-section .edgeLabel,
252 .diagram-section .edgeLabel span,
253 .diagram-section .edgeLabel div,
254 .diagram-section .edgeLabel p,
255 .diagram-section .edgeLabel foreignObject,
256 .diagram-section .edgeLabel foreignObject *,
257 .diagram-section .edgeLabel text,
258 .diagram-section .edgeLabel tspan {
259 color: var(--text) !important;
260 fill: var(--text) !important;
261 }
262 .diagram-section .edgeLabel,
263 .diagram-section .edgeLabel p,
264 .diagram-section .edgeLabel span {
265 background-color: var(--bg) !important;
266 }
267 .diagram-section .edgeLabel rect,
268 .diagram-section .edgeLabel .labelBkg {
269 fill: var(--bg) !important;
270 opacity: 1 !important;
271 }
272
273 /* Edge paths and arrows */
274 .diagram-section .edgePath path,
275 .diagram-section .flowchart-link,
276 .diagram-section path.path,
277 .diagram-section .edge-pattern-solid,
278 .diagram-section .edge-pattern-dotted,
279 .diagram-section .edge-pattern-dashed {
280 stroke: var(--text) !important;
281 }
282 .diagram-section marker path,
283 .diagram-section .arrowheadPath,
284 .diagram-section .arrowMarkerAbs path {
285 fill: var(--text) !important;
286 stroke: var(--text) !important;
287 }
288
289 /* Subgraph/cluster styling */
290 .diagram-section .cluster rect,
291 .diagram-section .cluster-label,
292 .diagram-section g.cluster > rect {
293 fill: var(--bg-panel) !important;
294 stroke: var(--text) !important;
295 }
296 .diagram-section .cluster text,
297 .diagram-section .cluster-label text,
298 .diagram-section .cluster .nodeLabel {
299 fill: var(--text) !important;
300 color: var(--text) !important;
301 }
302
303 /* ── Sequence diagram overrides ── */
304 .diagram-section rect.actor {
305 fill: var(--bg) !important;
306 stroke: var(--text) !important;
307 }
308 .diagram-section text.actor,
309 .diagram-section text.actor tspan,
310 .diagram-section .actor > tspan {
311 fill: var(--text) !important;
312 stroke: none !important;
313 }
314 .diagram-section .actor-man circle,
315 .diagram-section .actor-man line {
316 fill: var(--bg) !important;
317 stroke: var(--text) !important;
318 }
319 .diagram-section line.actor-line,
320 .diagram-section .actor-line {
321 stroke: var(--text) !important;
322 }
323 .diagram-section .sequenceNumber {
324 fill: var(--bg) !important;
325 }
326 .diagram-section .messageLine0,
327 .diagram-section .messageLine1 {
328 stroke: var(--text) !important;
329 }
330 .diagram-section .messageText {
331 fill: var(--text) !important;
332 }
333 .diagram-section .activation0,
334 .diagram-section .activation1,
335 .diagram-section .activation2 {
336 fill: var(--code-bg) !important;
337 stroke: var(--text) !important;
338 }
339 .diagram-section .labelBox {
340 fill: var(--bg-panel) !important;
341 stroke: var(--text) !important;
342 }
343 .diagram-section .labelText,
344 .diagram-section .loopText {
345 fill: var(--text) !important;
346 }
347
348 /* ── ERD diagram overrides ── */
349 .diagram-section .entityBox {
350 fill: var(--bg-panel) !important;
351 }
352 .diagram-section .row-rect-odd path,
353 .diagram-section .row-rect-even path {
354 fill: var(--bg) !important;
355 }
356 .diagram-section .row-rect-even path {
357 fill: var(--code-bg) !important;
358 }
359 .diagram-section .relationshipLine path {
360 stroke: var(--text) !important;
361 }
362 .diagram-section .relationshipLabel {
363 fill: var(--text) !important;
364 }
365
366 /* ── Node interaction ── */
367 .diagram-section .node { cursor: pointer; }
368
369 .diagram-section .node:hover :is(rect, circle, ellipse, polygon, path) {
370 fill: var(--code-bg) !important;
371 stroke-width: 2px !important;
372 }
373
374 .diagram-section .node.selected :is(rect, circle, ellipse, polygon, path) {
375 fill: var(--text) !important;
376 stroke: var(--text) !important;
377 stroke-width: 2px !important;
378 }
379 .diagram-section .node.selected .label,
380 .diagram-section .node.selected .nodeLabel,
381 .diagram-section .node.selected text,
382 .diagram-section .node.selected foreignObject,
383 .diagram-section .node.selected foreignObject div,
384 .diagram-section .node.selected foreignObject span,
385 .diagram-section .node.selected foreignObject p {
386 color: var(--bg) !important;
387 fill: var(--bg) !important;
388 }
389
390 /* Edge hover */
391 .diagram-section .node:hover ~ .edgePath path,
392 .diagram-section .edgePath:hover path {
393 stroke-width: 2.5px;
394 }
395
396 /* Highlight pulse animation */
397 .diagram-section .node.highlighted :is(rect, circle, ellipse, polygon, path) {
398 animation: node-highlight-pulse 0.5s ease-in-out 3;
399 }
400 @keyframes node-highlight-pulse {
401 0%, 100% { stroke-width: 1px; }
402 50% { stroke-width: 3px; }
403 }
404
405 /* ERD: don't apply hover/selected effects to individual row cells */
406 .diagram-section .erDiagram .node:hover :is(.row-rect-odd, .row-rect-even) :is(path, rect, polygon),
407 .diagram-section .erDiagram .node.selected :is(.row-rect-odd, .row-rect-even) :is(path, rect, polygon) {
408 filter: none !important;
409 stroke: none !important;
410 stroke-width: 0 !important;
411 }
412
413 /* ── Content wrap ── */
414 .content-wrap {
415 max-width: 720px;
416 margin: 0 auto;
417 padding: 32px 20px;
418 flex: 1;
419 }
420
421 /* ── Detail section ── */
422 #detail-section {
423 transition: opacity 0.15s ease;
424 }
425
426 #detail-section.fading { opacity: 0; }
427
428 .content-summary {
429 font-size: 20px;
430 font-weight: 600;
431 padding: 24px 0 0;
432 }
433
434 .node-card {
435 padding: 24px 0;
436 border-top: 1px solid var(--border);
437 }
438
439 .node-card:first-child {
440 border-top: none;
441 }
442
443 .node-card h3 {
444 font-size: 16px;
445 font-weight: 600;
446 margin-bottom: 12px;
447 }
448
449 .node-card .description {
450 font-size: 14px;
451 line-height: 1.65;
452 }
453
454 .node-card .description h1,
455 .node-card .description h2,
456 .node-card .description h3 {
457 margin-top: 16px;
458 margin-bottom: 8px;
459 }
460
461 .node-card .description h1 { font-size: 18px; }
462 .node-card .description h2 { font-size: 16px; }
463 .node-card .description h3 { font-size: 14px; }
464
465 .node-card .description p { margin-bottom: 12px; }
466
467 .node-card .description code {
468 background: var(--code-bg);
469 padding: 2px 5px;
470 border-radius: 3px;
471 font-size: 13px;
472 }
473
474 .node-card .description pre {
475 background: var(--code-bg);
476 padding: 12px;
477 border-radius: 6px;
478 overflow-x: auto;
479 margin-bottom: 12px;
480 }
481
482 .node-card .description pre code {
483 background: none;
484 padding: 0;
485 line-height: 1.625 !important;
486 }
487
488 .node-card .description ul,
489 .node-card .description ol {
490 margin-bottom: 12px;
491 padding-left: 20px;
492 }
493
494 .node-card .description li { margin-bottom: 4px; }
495
496 .section-label {
497 font-size: 11px;
498 font-weight: 600;
499 text-transform: uppercase;
500 letter-spacing: 0.05em;
501 color: var(--text-muted);
502 margin-top: 20px;
503 margin-bottom: 8px;
504 }
505
506 .links-list {
507 list-style: none;
508 display: flex;
509 flex-direction: column;
510 gap: 4px;
511 }
512
513 .links-list a {
514 color: #5a7bc4;
515 text-decoration: none;
516 font-size: 13px;
517 font-family: "SF Mono", "Fira Code", monospace;
518 padding: 4px 0;
519 transition: color 0.15s;
520 }
521
522 .links-list a:hover { color: #7b9ad8; text-decoration: underline; }
523
524 .code-snippet {
525 margin-top: 12px;
526 position: relative;
527 }
528
529 .code-snippet pre {
530 background: var(--code-bg);
531 border-radius: 6px;
532 padding: 12px;
533 overflow-x: auto;
534 font-size: 13px;
535 line-height: 1.5;
536 }
537
538 .copy-btn {
539 position: absolute;
540 top: 8px;
541 right: 8px;
542 background: var(--bg-panel);
543 border: 1px solid var(--border);
544 border-radius: 5px;
545 padding: 4px 6px;
546 cursor: pointer;
547 color: var(--text-muted);
548 opacity: 0;
549 transition: opacity 0.15s, color 0.15s, border-color 0.15s;
550 display: flex;
551 align-items: center;
552 justify-content: center;
553 }
554
555 .copy-btn svg { width: 14px; height: 14px; }
556
557 .code-snippet:hover .copy-btn { opacity: 1; }
558
559 .copy-btn:hover {
560 color: var(--accent);
561 border-color: var(--accent);
562 }
563
564 .copy-btn.copied {
565 color: #16a34a;
566 border-color: #16a34a;
567 opacity: 1;
568 }
569
570 .site-footer {
571 padding: 32px 20px;
572 font-size: 13px;
573 color: var(--text-muted);
574 display: flex;
575 justify-content: space-between;
576 align-items: center;
577 }
578
579 .site-footer .heart { color: #e25555; }
580
581 .site-footer a {
582 color: var(--text);
583 text-decoration: none;
584 }
585
586 .site-footer a:hover { text-decoration: underline; }
587
588 .site-footer .hash {
589 font-family: "SF Mono", "Fira Code", monospace;
590 font-size: 11px;
591 color: var(--text-muted) !important;
592 opacity: 0.6;
593 }
594
595 @media (max-width: 640px) {
596 .diagram-wrap {
597 padding: 12px 8px 0;
598 }
599 .diagram-section {
600 padding: 12px;
601 overflow-x: auto;
602 }
603 .diagram-section svg {
604 min-width: 500px;
605 max-height: none;
606 }
607 .content-wrap {
608 padding: 20px 16px;
609 }
610 .summary-bar {
611 padding: 10px 12px;
612 }
613 .site-footer {
614 padding: 20px 12px;
615 }
616 }
617 </style>
618</head>
619<body>
620 <div class="summary-bar">
621 <a class="label" href="/">Traverse</a>
622 <span class="sep">›</span>
623 <span class="breadcrumb-title" id="breadcrumb-title">${escapeHTML(diagram.summary)}</span>
624 <span class="sep header-sep">›</span>
625 <span class="header-node" id="header-node"></span>
626 ${mode === "local" && shareServerUrl ? `<button class="share-btn" id="share-btn" title="Share diagram">
627 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
628 <path d="M4 12V14H12V12M8 2V10M5 5L8 2L11 5"/>
629 </svg>
630 <span>Share</span>
631 </button>` : ""}
632 </div>
633
634 <div class="diagram-wrap">
635 <div class="diagram-section">
636 <pre class="mermaid">${escapeHTML(diagram.code)}</pre>
637 </div>
638 </div>
639
640 <div class="content-wrap">
641 <div id="detail-section"></div>
642 </div>
643
644 <footer class="site-footer">
645 <span>Made with ❤️ by <a href="https://dunkirk.sh">Kieran Klukas</a></span>
646 <a class="hash" href="https://github.com/taciturnaxolotl/traverse/${/^v\d+\./.test(gitHash) ? "releases/tag" : "commit"}/${escapeHTML(gitHash)}">${escapeHTML(gitHash)}</a>
647 </footer>
648
649 <script type="module">
650 import mermaid from "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs";
651 import elkLayouts from "https://cdn.jsdelivr.net/npm/@mermaid-js/layout-elk@0/dist/mermaid-layout-elk.esm.min.mjs";
652
653 mermaid.registerLayoutLoaders(elkLayouts);
654
655 const DIAGRAM_DATA = ${diagramJSON};
656 const PROJECT_ROOT = ${JSON.stringify(projectRoot)};
657 const GITHUB_REPO = ${JSON.stringify(diagram.githubRepo || "")};
658 const GITHUB_REF = ${JSON.stringify(diagram.githubRef || "main")};
659 const SHARE_SERVER_URL = ${JSON.stringify(shareServerUrl)};
660 const DIAGRAM_ID = ${JSON.stringify(diagramId)};
661 const VIEWER_MODE = ${JSON.stringify(mode)};
662 let EXISTING_SHARE_URL = ${JSON.stringify(existingShareUrl)};
663
664 const COPY_ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"><rect x="5" y="5" width="8" height="8" rx="1.5"/><path d="M3 11V3a1.5 1.5 0 011.5-1.5H11"/></svg>';
665 const CHECK_ICON = '<svg viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M3 8.5l3.5 3.5 6.5-7"/></svg>';
666
667 function initTheme() {
668 const dark = window.matchMedia("(prefers-color-scheme: dark)").matches;
669 document.getElementById("hljs-dark").disabled = !dark;
670 document.getElementById("hljs-light").disabled = dark;
671 }
672 initTheme();
673 window.matchMedia("(prefers-color-scheme: dark)").addEventListener("change", initTheme);
674
675 async function init() {
676 await mermaid.initialize({
677 startOnLoad: true,
678 theme: "base",
679 layout: "elk",
680 flowchart: { useMaxWidth: false, htmlLabels: true, nodeSpacing: 24, rankSpacing: 40 },
681 securityLevel: "loose",
682 });
683
684 // Wait for mermaid to finish rendering
685 await mermaid.run();
686
687 // Set viewBox so the SVG scales to fit the container
688 requestAnimationFrame(() => {
689 fitDiagram();
690 document.querySelector(".diagram-section").classList.add("ready");
691 attachClickHandlers();
692
693 // Check URL hash for deep link
694 const hash = window.location.hash.slice(1);
695 if (hash && DIAGRAM_DATA.nodes[hash]) {
696 const svg = document.querySelector(".diagram-section svg");
697 const nodeEl = svg && findNodeEl(svg, hash);
698 if (nodeEl) {
699 selectNode(hash, nodeEl, false);
700 } else {
701 renderAllNodes();
702 }
703 } else {
704 renderAllNodes();
705 }
706 });
707
708 window.addEventListener("resize", fitDiagram);
709
710 // Header breadcrumb title click to deselect
711 document.getElementById("breadcrumb-title").addEventListener("click", (e) => {
712 if (selectedNodeId) {
713 e.stopPropagation();
714 deselectAll();
715 }
716 });
717
718 // Escape key to deselect
719 document.addEventListener("keydown", (e) => {
720 if (e.key === "Escape" && selectedNodeId) deselectAll();
721 });
722
723 // Handle browser back/forward
724 window.addEventListener("hashchange", () => {
725 const hash = window.location.hash.slice(1);
726 if (!hash) {
727 deselectAll(true);
728 } else if (DIAGRAM_DATA.nodes[hash]) {
729 const svg = document.querySelector(".diagram-section svg");
730 const nodeEl = svg && findNodeEl(svg, hash);
731 if (nodeEl) selectNode(hash, nodeEl, false);
732 }
733 });
734 }
735
736 function fitDiagram() {
737 const svg = document.querySelector(".diagram-section svg");
738 if (!svg) return;
739
740 // Read the intrinsic size mermaid rendered
741 const bbox = svg.getBBox();
742 const pad = 20;
743 const vb = \`\${bbox.x - pad} \${bbox.y - pad} \${bbox.width + pad * 2} \${bbox.height + pad * 2}\`;
744 svg.setAttribute("viewBox", vb);
745 svg.removeAttribute("width");
746 svg.removeAttribute("height");
747 }
748
749 function findNodeEl(svg, nodeId) {
750 const nodeIds = Object.keys(DIAGRAM_DATA.nodes);
751 const allNodes = svg.querySelectorAll(".node");
752 for (const nodeEl of allNodes) {
753 const id = nodeEl.id;
754 if (!id) continue;
755 const matchedId = nodeIds.find(nid =>
756 id === nid ||
757 id.endsWith("-" + nid) ||
758 id.startsWith("flowchart-" + nid + "-") ||
759 id.includes("-" + nid + "-")
760 );
761 if (matchedId === nodeId) return nodeEl;
762 }
763 return null;
764 }
765
766 function attachClickHandlers() {
767 const svg = document.querySelector(".diagram-section svg");
768 if (!svg) return;
769
770 const nodeIds = Object.keys(DIAGRAM_DATA.nodes);
771
772 // Find all node groups in the SVG
773 const allNodes = svg.querySelectorAll(".node");
774
775 allNodes.forEach(nodeEl => {
776 // Extract node ID from the element
777 const id = nodeEl.id;
778 if (!id) return;
779
780 // Match against our known node IDs
781 const matchedId = nodeIds.find(nid =>
782 id === nid ||
783 id.endsWith("-" + nid) ||
784 id.startsWith("flowchart-" + nid + "-") ||
785 id.includes("-" + nid + "-")
786 );
787
788 if (matchedId) {
789 nodeEl.style.cursor = "pointer";
790 nodeEl.dataset.nodeId = matchedId;
791 nodeEl.addEventListener("click", (e) => {
792 e.stopPropagation();
793 selectNode(matchedId, nodeEl);
794 });
795 }
796 });
797
798 // Click outside to deselect
799 document.addEventListener("click", (e) => {
800 if (!e.target.closest("#detail-section") && !e.target.closest(".node") && !e.target.closest(".summary-bar")) {
801 deselectAll();
802 }
803 });
804 }
805
806 let selectedEl = null;
807 let selectedNodeId = null;
808
809 function renderNodeCard(nodeId, meta) {
810 let html = '<div class="node-card" data-card-id="' + escapeAttr(nodeId) + '">';
811 html += '<h3>' + escapeText(meta.title) + '</h3>';
812 html += '<div class="description">' + marked.parse(meta.description) + "</div>";
813
814 if (meta.links && meta.links.length > 0) {
815 html += '<div class="section-label">Related Files</div>';
816 html += '<ul class="links-list">';
817 meta.links.forEach(link => {
818 const href = buildFileUrl(link.label, link.url);
819 const target = GITHUB_REPO ? ' target="_blank" rel="noopener"' : '';
820 html += '<li><a href="' + escapeAttr(href) + '"' + target + '>' + escapeText(link.label) + "</a></li>";
821 });
822 html += "</ul>";
823 }
824
825 if (meta.codeSnippet) {
826 html += '<div class="section-label">Code</div>';
827 html += '<div class="code-snippet"><button class="copy-btn" title="Copy code">' + COPY_ICON + '</button><pre><code>' + escapeText(meta.codeSnippet) + "</code></pre></div>";
828 }
829
830 html += '</div>';
831 return html;
832 }
833
834 function renderAllNodes() {
835 const section = document.getElementById("detail-section");
836 let html = '<h2 class="content-summary">' + escapeText(DIAGRAM_DATA.summary) + '</h2>';
837 for (const [nodeId, meta] of Object.entries(DIAGRAM_DATA.nodes)) {
838 html += renderNodeCard(nodeId, meta);
839 }
840 section.innerHTML = html;
841 highlightAll(section);
842 attachCopyButtons(section);
843 }
844
845 function highlightAll(container) {
846 container.querySelectorAll("pre code").forEach(block => {
847 hljs.highlightElement(block);
848 });
849 }
850
851 function attachCopyButtons(container) {
852 container.querySelectorAll(".copy-btn").forEach(btn => {
853 btn.addEventListener("click", (e) => {
854 e.stopPropagation();
855 const code = btn.closest(".code-snippet").querySelector("code").textContent;
856 navigator.clipboard.writeText(code).then(() => {
857 btn.innerHTML = CHECK_ICON;
858 btn.classList.add("copied");
859 setTimeout(() => {
860 btn.innerHTML = COPY_ICON;
861 btn.classList.remove("copied");
862 }, 1500);
863 });
864 });
865 });
866 }
867
868 function transitionContent(callback) {
869 const section = document.getElementById("detail-section");
870 section.classList.add("fading");
871 setTimeout(() => {
872 callback();
873 section.classList.remove("fading");
874 }, 150);
875 }
876
877 function selectNode(nodeId, el, pushState = true) {
878 const meta = DIAGRAM_DATA.nodes[nodeId];
879 if (!meta) return;
880
881 if (selectedEl) selectedEl.classList.remove("selected");
882 el.classList.add("selected");
883 selectedEl = el;
884 selectedNodeId = nodeId;
885
886 // Update breadcrumbs
887 document.body.classList.add("has-selection");
888 document.getElementById("header-node").textContent = meta.title;
889
890 // Update URL hash
891 if (pushState) {
892 history.pushState(null, "", "#" + nodeId);
893 }
894
895 transitionContent(() => {
896 const section = document.getElementById("detail-section");
897 section.innerHTML = renderNodeCard(nodeId, meta);
898 highlightAll(section);
899 attachCopyButtons(section);
900 });
901
902 }
903
904 function deselectAll(skipHistory) {
905 if (selectedEl) {
906 selectedEl.classList.remove("selected");
907 selectedEl = null;
908 }
909 selectedNodeId = null;
910
911 // Update breadcrumbs
912 document.body.classList.remove("has-selection");
913 document.getElementById("header-node").textContent = "";
914
915 // Clear hash
916 if (!skipHistory) {
917 history.pushState(null, "", window.location.pathname);
918 }
919
920 transitionContent(() => {
921 renderAllNodes();
922 });
923 }
924
925 function buildFileUrl(label, url) {
926 // Parse line number from label or url like "src/index.ts:56-59" or "src/index.ts:56"
927 const lineMatch = label.match(/:(\\d+)(?:-(\\d+))?/) || url.match(/:(\\d+)(?:-(\\d+))?/);
928 const line = lineMatch ? lineMatch[1] : "1";
929 // Strip any :line suffix from url path
930 const cleanUrl = url.replace(/:[\\d-]+$/, "");
931 if (GITHUB_REPO) {
932 const lineAnchor = lineMatch && lineMatch[2]
933 ? "?plain=1#L" + lineMatch[1] + "-L" + lineMatch[2]
934 : "?plain=1#L" + line;
935 return GITHUB_REPO + "/blob/" + GITHUB_REF + "/" + cleanUrl + lineAnchor;
936 }
937 const filePath = PROJECT_ROOT + "/" + cleanUrl;
938 return "vscode://file/" + filePath + ":" + line;
939 }
940
941 function escapeText(s) {
942 const d = document.createElement("div");
943 d.textContent = s;
944 return d.innerHTML;
945 }
946
947 function escapeAttr(s) {
948 return s.replace(/&/g,"&").replace(/"/g,""").replace(/'/g,"'").replace(/</g,"<").replace(/>/g,">");
949 }
950
951 init();
952
953 // Share button handler
954 const shareBtn = document.getElementById("share-btn");
955 if (shareBtn && SHARE_SERVER_URL) {
956 shareBtn.addEventListener("click", async () => {
957 shareBtn.disabled = true;
958 try {
959 // If we already have a shared URL, just copy it
960 if (EXISTING_SHARE_URL) {
961 await navigator.clipboard.writeText(EXISTING_SHARE_URL);
962 shareBtn.querySelector("span").textContent = "Copied link!";
963 shareBtn.classList.add("shared");
964 setTimeout(() => {
965 shareBtn.querySelector("span").textContent = "Share";
966 shareBtn.classList.remove("shared");
967 }, 2000);
968 return;
969 }
970
971 const res = await fetch(SHARE_SERVER_URL + "/api/diagrams", {
972 method: "POST",
973 headers: { "Content-Type": "application/json" },
974 body: JSON.stringify(DIAGRAM_DATA),
975 });
976 if (!res.ok) throw new Error("Share failed");
977 const data = await res.json();
978 await navigator.clipboard.writeText(data.url);
979
980 // Save the shared URL locally so we don't re-upload next time
981 if (DIAGRAM_ID) {
982 fetch("/api/diagrams/" + DIAGRAM_ID + "/shared-url", {
983 method: "POST",
984 headers: { "Content-Type": "application/json" },
985 body: JSON.stringify({ url: data.url }),
986 }).catch(() => {}); // best-effort
987 EXISTING_SHARE_URL = data.url;
988 }
989
990 shareBtn.querySelector("span").textContent = "Copied link!";
991 shareBtn.classList.add("shared");
992 setTimeout(() => {
993 shareBtn.querySelector("span").textContent = "Share";
994 shareBtn.classList.remove("shared");
995 }, 2000);
996 } catch (e) {
997 shareBtn.querySelector("span").textContent = "Failed";
998 setTimeout(() => {
999 shareBtn.querySelector("span").textContent = "Share";
1000 }, 2000);
1001 } finally {
1002 shareBtn.disabled = false;
1003 }
1004 });
1005 }
1006 </script>
1007</body>
1008</html>`;
1009}
1010
1011function escapeHTML(str: string): string {
1012 return str
1013 .replace(/&/g, "&")
1014 .replace(/</g, "<")
1015 .replace(/>/g, ">")
1016 .replace(/"/g, """);
1017}