snatching amp's walkthrough for my own purposes mwhahaha traverse.dunkirk.sh/diagram/6121f05c-a5ef-4ecf-8ffc-02534c5e767c
at main 1017 lines 31 kB view raw
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">&rsaquo;</span> 623 <span class="breadcrumb-title" id="breadcrumb-title">${escapeHTML(diagram.summary)}</span> 624 <span class="sep header-sep">&rsaquo;</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 &#x2764;&#xFE0F; 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,"&amp;").replace(/"/g,"&quot;").replace(/'/g,"&#39;").replace(/</g,"&lt;").replace(/>/g,"&gt;"); 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, "&amp;") 1014 .replace(/</g, "&lt;") 1015 .replace(/>/g, "&gt;") 1016 .replace(/"/g, "&quot;"); 1017}