snatching amp's walkthrough for my own purposes mwhahaha traverse.dunkirk.sh/diagram/6121f05c-a5ef-4ecf-8ffc-02534c5e767c

feat: add git hash and loading state

dunkirk.sh 15d4ca65 40afd145

verified
+92 -23
+48 -15
src/index.ts
··· 5 5 import type { WalkthroughDiagram } from "./types.ts"; 6 6 7 7 const PORT = parseInt(process.env.TRAVERSE_PORT || "4173", 10); 8 + const GIT_HASH = await Bun.$`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => "dev"); 8 9 9 10 // In-memory diagram store 10 11 const diagrams = new Map<string, WalkthroughDiagram>(); ··· 26 27 headers: { "Content-Type": "text/html; charset=utf-8" }, 27 28 }); 28 29 } 29 - return new Response(generateViewerHTML(diagram), { 30 + return new Response(generateViewerHTML(diagram, GIT_HASH, process.cwd()), { 30 31 headers: { "Content-Type": "text/html; charset=utf-8" }, 31 32 }); 32 33 } ··· 39 40 40 41 // List available diagrams 41 42 if (url.pathname === "/") { 42 - return new Response(generateIndexHTML(diagrams), { 43 + return new Response(generateIndexHTML(diagrams, GIT_HASH), { 43 44 headers: { "Content-Type": "text/html; charset=utf-8" }, 44 45 }); 45 46 } ··· 157 158 </html>`; 158 159 } 159 160 160 - function generateIndexHTML(diagrams: Map<string, WalkthroughDiagram>): string { 161 + function generateIndexHTML(diagrams: Map<string, WalkthroughDiagram>, gitHash: string): string { 161 162 const items = [...diagrams.entries()] 162 163 .map( 163 164 ([id, d]) => { 164 - const nodeCount = Object.keys(d.nodes).length; 165 + const nodes = Object.values(d.nodes); 166 + const nodeCount = nodes.length; 167 + const preview = nodes.slice(0, 4).map(n => escapeHTML(n.title)); 168 + const extra = nodeCount > 4 ? ` <span class="more">+${nodeCount - 4}</span>` : ""; 169 + const tags = preview.map(t => `<span class="tag">${t}</span>`).join("") + extra; 165 170 return `<a href="/diagram/${id}" class="diagram-item"> 166 - <span class="diagram-title">${escapeHTML(d.summary)}</span> 167 - <span class="diagram-meta">${nodeCount} node${nodeCount !== 1 ? "s" : ""}</span> 171 + <div class="diagram-header"> 172 + <span class="diagram-title">${escapeHTML(d.summary)}</span> 173 + <span class="diagram-meta">${nodeCount} node${nodeCount !== 1 ? "s" : ""}</span> 174 + </div> 175 + <div class="diagram-tags">${tags}</div> 168 176 </a>`; 169 177 }, 170 178 ) ··· 208 216 body { 209 217 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 210 218 background: var(--bg); color: var(--text); min-height: 100vh; 219 + display: flex; flex-direction: column; 211 220 } 221 + .main-content { flex: 1; } 212 222 .header { 213 223 padding: 48px 20px 32px; 214 224 max-width: 520px; margin: 0 auto; ··· 226 236 .header p { color: var(--text-muted); font-size: 14px; margin-top: 8px; } 227 237 .diagram-list { 228 238 max-width: 520px; margin: 0 auto; padding: 0 20px 48px; 229 - display: flex; flex-direction: column; gap: 8px; 239 + display: flex; flex-direction: column; gap: 12px; 230 240 } 231 241 .diagram-item { 232 - display: flex; align-items: center; justify-content: space-between; 233 - padding: 14px 16px; border: 1px solid var(--border); 242 + display: flex; flex-direction: column; gap: 10px; 243 + padding: 16px; border: 1px solid var(--border); 234 244 border-radius: 8px; text-decoration: none; color: var(--text); 235 245 transition: border-color 0.15s, background 0.15s; 236 246 } 237 247 .diagram-item:hover { 238 248 border-color: var(--text-muted); background: var(--code-bg); 239 249 } 250 + .diagram-header { 251 + display: flex; align-items: center; justify-content: space-between; 252 + } 240 253 .diagram-title { font-size: 14px; font-weight: 500; } 241 254 .diagram-meta { 242 255 font-size: 12px; color: var(--text-muted); 243 256 flex-shrink: 0; margin-left: 12px; 244 257 } 258 + .diagram-tags { 259 + display: flex; flex-wrap: wrap; gap: 6px; align-items: center; 260 + } 261 + .diagram-tags .tag { 262 + font-size: 11px; color: var(--text-muted); 263 + background: var(--code-bg); padding: 2px 8px; 264 + border-radius: 4px; 265 + } 266 + .diagram-tags .more { 267 + font-size: 11px; color: var(--text-muted); opacity: 0.6; 268 + } 245 269 .empty { 246 270 max-width: 520px; margin: 0 auto; padding: 60px 20px; 247 271 text-align: center; color: var(--text-muted); ··· 254 278 border-radius: 3px; font-size: 12px; 255 279 } 256 280 .site-footer { 257 - padding: 32px 20px; text-align: center; 281 + padding: 32px 20px; 258 282 font-size: 13px; color: var(--text-muted); 283 + display: flex; justify-content: space-between; align-items: center; 259 284 } 260 285 .site-footer .heart { color: #e25555; } 261 286 .site-footer a { color: var(--text); text-decoration: none; } 262 287 .site-footer a:hover { text-decoration: underline; } 288 + .site-footer .hash { 289 + font-family: "SF Mono", "Fira Code", monospace; 290 + font-size: 11px; opacity: 0.6; 291 + color: var(--text-muted) !important; 292 + } 263 293 </style> 264 294 </head> 265 295 <body> 266 - <div class="header"> 267 - <h1>Traverse <span>v0.1</span></h1> 268 - <p>Interactive code walkthrough diagrams</p> 296 + <div class="main-content"> 297 + <div class="header"> 298 + <h1>Traverse <span>v0.1</span></h1> 299 + <p>Interactive code walkthrough diagrams</p> 300 + </div> 301 + ${content} 269 302 </div> 270 - ${content} 271 303 <footer class="site-footer"> 272 - Made with <span class="heart">&hearts;</span> by <a href="https://dunkirk.sh">Kieran Klukas</a> &middot; <a href="https://github.com/taciturnaxolotl/traverse">GitHub</a> 304 + <span>Made with &#x2764;&#xFE0F; by <a href="https://dunkirk.sh">Kieran Klukas</a></span> 305 + <a class="hash" href="https://github.com/taciturnaxolotl/traverse/commit/${escapeHTML(gitHash)}">${escapeHTML(gitHash)}</a> 273 306 </footer> 274 307 </body> 275 308 </html>`;
+44 -8
src/template.ts
··· 1 1 import type { WalkthroughDiagram } from "./types.ts"; 2 2 3 - export function generateViewerHTML(diagram: WalkthroughDiagram): string { 3 + export function generateViewerHTML(diagram: WalkthroughDiagram, gitHash: string = "dev", projectRoot: string = ""): string { 4 4 const diagramJSON = JSON.stringify(diagram).replace(/<\//g, "<\\/"); 5 5 6 6 return `<!DOCTYPE html> ··· 56 56 background: var(--bg); 57 57 color: var(--text); 58 58 min-height: 100vh; 59 + display: flex; 60 + flex-direction: column; 59 61 } 60 62 61 63 /* ── Summary bar with breadcrumb ── */ ··· 77 79 78 80 .summary-bar .label { 79 81 font-weight: 600; 80 - color: var(--accent); 82 + color: var(--text-muted); 81 83 text-transform: uppercase; 82 84 font-size: 11px; 83 85 letter-spacing: 0.05em; 84 86 flex-shrink: 0; 87 + text-decoration: none; 85 88 transition: color 0.15s; 86 89 } 87 90 ··· 139 142 padding: 24px; 140 143 border: 1px solid var(--border); 141 144 border-radius: 8px; 145 + opacity: 0; 146 + transition: opacity 0.3s ease; 147 + } 148 + 149 + .diagram-section.ready { 150 + opacity: 1; 142 151 } 143 152 144 153 .diagram-section pre.mermaid { ··· 364 373 max-width: 720px; 365 374 margin: 0 auto; 366 375 padding: 32px 20px; 376 + flex: 1; 367 377 } 368 378 369 379 /* ── Detail section ── */ ··· 459 469 } 460 470 461 471 .links-list a { 462 - color: var(--accent); 472 + color: #5a7bc4; 463 473 text-decoration: none; 464 474 font-size: 13px; 465 475 font-family: "SF Mono", "Fira Code", monospace; ··· 467 477 transition: color 0.15s; 468 478 } 469 479 470 - .links-list a:hover { color: var(--accent-hover); text-decoration: underline; } 480 + .links-list a:hover { color: #7b9ad8; text-decoration: underline; } 471 481 472 482 .code-snippet { 473 483 margin-top: 12px; ··· 517 527 518 528 .site-footer { 519 529 padding: 32px 20px; 520 - text-align: center; 521 530 font-size: 13px; 522 531 color: var(--text-muted); 532 + display: flex; 533 + justify-content: space-between; 534 + align-items: center; 523 535 } 524 536 525 537 .site-footer .heart { color: #e25555; } ··· 530 542 } 531 543 532 544 .site-footer a:hover { text-decoration: underline; } 545 + 546 + .site-footer .hash { 547 + font-family: "SF Mono", "Fira Code", monospace; 548 + font-size: 11px; 549 + color: var(--text-muted) !important; 550 + opacity: 0.6; 551 + } 533 552 </style> 534 553 </head> 535 554 <body> 536 555 <div class="summary-bar"> 537 - <a class="label" href="/" style="text-decoration:none;color:inherit">Traverse</a> 556 + <a class="label" href="/">Traverse</a> 538 557 <span class="sep">&rsaquo;</span> 539 558 <span class="breadcrumb-title" id="breadcrumb-title">${escapeHTML(diagram.summary)}</span> 540 559 <span class="sep header-sep">&rsaquo;</span> ··· 552 571 </div> 553 572 554 573 <footer class="site-footer"> 555 - Made with <span class="heart">&hearts;</span> by <a href="https://dunkirk.sh">Kieran Klukas</a> &middot; <a href="https://github.com/taciturnaxolotl/traverse">GitHub</a> 574 + <span>Made with &#x2764;&#xFE0F; by <a href="https://dunkirk.sh">Kieran Klukas</a></span> 575 + <a class="hash" href="https://github.com/taciturnaxolotl/traverse/commit/${escapeHTML(gitHash)}">${escapeHTML(gitHash)}</a> 556 576 </footer> 557 577 558 578 <script type="module"> ··· 562 582 mermaid.registerLayoutLoaders(elkLayouts); 563 583 564 584 const DIAGRAM_DATA = ${diagramJSON}; 585 + const PROJECT_ROOT = ${JSON.stringify(projectRoot)}; 565 586 566 587 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>'; 567 588 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>'; ··· 589 610 // Set viewBox so the SVG scales to fit the container 590 611 requestAnimationFrame(() => { 591 612 fitDiagram(); 613 + document.querySelector(".diagram-section").classList.add("ready"); 592 614 attachClickHandlers(); 593 615 594 616 // Check URL hash for deep link ··· 616 638 } 617 639 }); 618 640 641 + // Escape key to deselect 642 + document.addEventListener("keydown", (e) => { 643 + if (e.key === "Escape" && selectedNodeId) deselectAll(); 644 + }); 645 + 619 646 // Handle browser back/forward 620 647 window.addEventListener("hashchange", () => { 621 648 const hash = window.location.hash.slice(1); ··· 711 738 html += '<div class="section-label">Related Files</div>'; 712 739 html += '<ul class="links-list">'; 713 740 meta.links.forEach(link => { 714 - html += '<li><a href="' + escapeAttr(link.url) + '">' + escapeText(link.label) + "</a></li>"; 741 + const href = buildFileUrl(link.label, link.url); 742 + html += '<li><a href="' + escapeAttr(href) + '">' + escapeText(link.label) + "</a></li>"; 715 743 }); 716 744 html += "</ul>"; 717 745 } ··· 814 842 transitionContent(() => { 815 843 renderAllNodes(); 816 844 }); 845 + } 846 + 847 + function buildFileUrl(label, url) { 848 + // Parse line number from label like "src/index.ts:56-59" or "src/index.ts:56" 849 + const lineMatch = label.match(/:(\d+)/); 850 + const line = lineMatch ? lineMatch[1] : "1"; 851 + const filePath = PROJECT_ROOT + "/" + url; 852 + return "vscode://file/" + filePath + ":" + line; 817 853 } 818 854 819 855 function escapeText(s) {