snatching amp's walkthrough for my own purposes mwhahaha traverse.dunkirk.sh/diagram/6121f05c-a5ef-4ecf-8ffc-02534c5e767c
at main 655 lines 25 kB view raw
1import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js"; 2import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js"; 3import { z } from "zod/v4"; 4import { generateViewerHTML } from "./template.ts"; 5import type { WalkthroughDiagram } from "./types.ts"; 6import { initDb, loadAllDiagrams, saveDiagram, deleteDiagramFromDb, generateId, getSharedUrl, saveSharedUrl } from "./storage.ts"; 7import { generateOgImage, generateIndexOgImage } from "./og.ts"; 8import { loadConfig } from "./config.ts"; 9 10const config = loadConfig(); 11const PORT = config.port; 12const MODE = config.mode; 13const VERSION = await Bun.$`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(async () => { 14 const pkg = await Bun.file(import.meta.dir + "/../package.json").json(); 15 return `v${pkg.version}`; 16}); 17const GIT_HASH = await Bun.$`git rev-parse HEAD`.text().then(s => s.trim()).catch(() => ""); 18const GITHUB_REPO = await Bun.$`git remote get-url origin`.text().then(url => { 19 url = url.trim(); 20 // Convert SSH URLs: git@github.com:user/repo.git -> https://github.com/user/repo 21 const sshMatch = url.match(/^git@github\.com:(.+?)(?:\.git)?$/); 22 if (sshMatch) return `https://github.com/${sshMatch[1]}`; 23 // Convert HTTPS URLs: https://github.com/user/repo.git -> https://github.com/user/repo 24 const httpsMatch = url.match(/^https:\/\/github\.com\/(.+?)(?:\.git)?$/); 25 if (httpsMatch) return `https://github.com/${httpsMatch[1]}`; 26 return ""; 27}).catch(() => ""); 28initDb(); 29 30// Load persisted diagrams 31const diagrams = loadAllDiagrams(); 32 33// --- Web server for serving interactive diagrams --- 34let isClient = false; 35 36try { 37 Bun.serve({ 38 port: PORT, 39 async fetch(req) { 40 const url = new URL(req.url); 41 42 // OG image route — must be matched before /diagram/:id 43 const ogMatch = url.pathname.match(/^\/diagram\/([\w-]+)\/og\.png$/); 44 if (ogMatch) { 45 const id = ogMatch[1]!; 46 const diagram = diagrams.get(id); 47 if (!diagram) { 48 return new Response("Not found", { status: 404 }); 49 } 50 const nodeNames = Object.values(diagram.nodes).map(n => n.title); 51 const png = await generateOgImage(id, diagram.summary, nodeNames); 52 return new Response(png, { 53 headers: { 54 "Content-Type": "image/png", 55 "Cache-Control": "public, max-age=86400", 56 }, 57 }); 58 } 59 60 const diagramMatch = url.pathname.match(/^\/diagram\/([\w-]+)$/); 61 62 if (diagramMatch) { 63 const id = diagramMatch[1]!; 64 const diagram = diagrams.get(id); 65 if (!diagram) { 66 return new Response(generate404HTML("Diagram not found", "This diagram doesn't exist or may have expired."), { 67 status: 404, 68 headers: { "Content-Type": "text/html; charset=utf-8" }, 69 }); 70 } 71 const existingShareUrl = getSharedUrl(id); 72 return new Response(generateViewerHTML(diagram, VERSION, process.cwd(), { 73 mode: MODE, 74 shareServerUrl: config.shareServerUrl, 75 diagramId: id, 76 existingShareUrl: existingShareUrl ?? undefined, 77 baseUrl: url.origin, 78 }), { 79 headers: { "Content-Type": "text/html; charset=utf-8" }, 80 }); 81 } 82 83 // DELETE /api/diagrams/:id 84 const apiMatch = url.pathname.match(/^\/api\/diagrams\/([\w-]+)$/); 85 if (apiMatch && req.method === "DELETE") { 86 const id = apiMatch[1]!; 87 if (!diagrams.has(id)) { 88 return Response.json({ error: "not found" }, { status: 404 }); 89 } 90 diagrams.delete(id); 91 deleteDiagramFromDb(id); 92 return Response.json({ ok: true, id }); 93 } 94 95 // POST /api/diagrams/:id/shared-url — save a shared URL for a local diagram 96 const sharedUrlMatch = url.pathname.match(/^\/api\/diagrams\/([\w-]+)\/shared-url$/); 97 if (sharedUrlMatch && req.method === "POST") { 98 const id = sharedUrlMatch[1]!; 99 try { 100 const body = await req.json() as { url: string }; 101 if (!body.url) { 102 return Response.json({ error: "missing required field: url" }, { status: 400 }); 103 } 104 saveSharedUrl(id, body.url); 105 return Response.json({ ok: true, id, url: body.url }); 106 } catch { 107 return Response.json({ error: "invalid JSON body" }, { status: 400 }); 108 } 109 } 110 111 // GET /api/diagrams/:id/shared-url — retrieve a stored shared URL 112 if (sharedUrlMatch && req.method === "GET") { 113 const id = sharedUrlMatch[1]!; 114 const sharedUrl = getSharedUrl(id); 115 if (!sharedUrl) { 116 return Response.json({ url: null }); 117 } 118 return Response.json({ url: sharedUrl }); 119 } 120 121 // POST /api/diagrams — accept diagrams from remote or sibling instances 122 if (url.pathname === "/api/diagrams" && req.method === "POST") { 123 try { 124 const body = await req.json() as WalkthroughDiagram; 125 if (!body.code || !body.summary || !body.nodes) { 126 return Response.json({ error: "missing required fields: code, summary, nodes" }, { status: 400 }); 127 } 128 const id = generateId(); 129 const diagram: WalkthroughDiagram = { 130 code: body.code, 131 summary: body.summary, 132 nodes: body.nodes, 133 githubRepo: body.githubRepo || undefined, 134 githubRef: body.githubRef || undefined, 135 createdAt: new Date().toISOString(), 136 }; 137 diagrams.set(id, diagram); 138 saveDiagram(id, diagram); 139 const diagramUrl = `${url.origin}/diagram/${id}`; 140 return Response.json({ id, url: diagramUrl }, { 141 status: 201, 142 headers: { 143 "Access-Control-Allow-Origin": "*", 144 }, 145 }); 146 } catch { 147 return Response.json({ error: "invalid JSON body" }, { status: 400 }); 148 } 149 } 150 151 // OPTIONS /api/diagrams — CORS preflight 152 if (url.pathname === "/api/diagrams" && req.method === "OPTIONS") { 153 return new Response(null, { 154 status: 204, 155 headers: { 156 "Access-Control-Allow-Origin": "*", 157 "Access-Control-Allow-Methods": "POST, OPTIONS", 158 "Access-Control-Allow-Headers": "Content-Type", 159 }, 160 }); 161 } 162 163 if (url.pathname === "/icon.svg") { 164 return new Response(Bun.file(import.meta.dir + "/../icon.svg"), { 165 headers: { "Content-Type": "image/svg+xml" }, 166 }); 167 } 168 169 // Index OG image 170 if (url.pathname === "/og.png") { 171 const png = await generateIndexOgImage(MODE, diagrams.size); 172 return new Response(png, { 173 headers: { 174 "Content-Type": "image/png", 175 "Cache-Control": "public, max-age=3600", 176 }, 177 }); 178 } 179 180 // List available diagrams 181 if (url.pathname === "/") { 182 const html = MODE === "server" 183 ? generateServerIndexHTML(diagrams.size, VERSION, url.origin) 184 : generateLocalIndexHTML(diagrams, VERSION, url.origin); 185 return new Response(html, { 186 headers: { "Content-Type": "text/html; charset=utf-8" }, 187 }); 188 } 189 190 return new Response(generate404HTML("Page not found", "There's nothing at this URL."), { 191 status: 404, 192 headers: { "Content-Type": "text/html; charset=utf-8" }, 193 }); 194 }, 195 }); 196} catch { 197 isClient = true; 198 console.error(`Web server already running on port ${PORT}, running in client mode`); 199} 200 201// --- MCP Server (local mode only) --- 202if (MODE === "local") { 203 const server = new McpServer({ 204 name: "traverse", 205 version: "0.1.0", 206 }); 207 208 const nodeMetadataSchema = z.object({ 209 title: z.string(), 210 description: z.string(), 211 links: z 212 .array(z.object({ label: z.string(), url: z.string() })) 213 .optional(), 214 codeSnippet: z.string().optional(), 215 }); 216 217 server.registerTool("walkthrough_diagram", { 218 title: "Walkthrough Diagram", 219 description: `Render an interactive Mermaid diagram where users can click nodes to see details. 220 221BEFORE calling this tool, deeply explore the codebase: 2221. Use search/read tools to find key files, entry points, and architecture patterns 2232. Trace execution paths and data flow between components 2243. Read source files — don't guess from filenames 225 226Then build the diagram: 227- Use \`flowchart TB\` with plain text labels, no HTML or custom styling 228- 5-12 nodes at the right abstraction level (not too granular, not too high-level) 229- Node keys must match Mermaid node IDs exactly 230- Descriptions: 2-3 paragraphs of markdown per node. Write for someone who has never seen this codebase — explain what the component does, how it works, and why it matters. Use \`code spans\` for identifiers and markdown headers to organize longer explanations 231- Links: include file:line references from your exploration 232- Code snippets: key excerpts (under 15 lines) showing the most important or representative code`, 233 inputSchema: z.object({ 234 code: z.string(), 235 summary: z.string(), 236 nodes: z.record(z.string(), nodeMetadataSchema), 237 }), 238 }, async ({ code, summary, nodes }) => { 239 let diagramUrl: string; 240 241 if (isClient) { 242 // POST diagram to the existing web server instance 243 const res = await fetch(`http://localhost:${PORT}/api/diagrams`, { 244 method: "POST", 245 headers: { "Content-Type": "application/json" }, 246 body: JSON.stringify({ code, summary, nodes, githubRepo: GITHUB_REPO || undefined, githubRef: GIT_HASH || undefined }), 247 }); 248 if (!res.ok) { 249 return { 250 content: [{ type: "text", text: `Failed to send diagram to server: ${res.statusText}` }], 251 }; 252 } 253 const data = await res.json() as { id: string; url: string }; 254 diagramUrl = data.url; 255 } else { 256 const id = generateId(); 257 const diagram: WalkthroughDiagram = { 258 code, 259 summary, 260 nodes, 261 githubRepo: GITHUB_REPO || undefined, githubRef: GIT_HASH || undefined, 262 createdAt: new Date().toISOString(), 263 }; 264 diagrams.set(id, diagram); 265 saveDiagram(id, diagram); 266 diagramUrl = `http://localhost:${PORT}/diagram/${id}`; 267 } 268 269 return { 270 content: [ 271 { 272 type: "text", 273 text: `Interactive diagram ready.\n\nOpen in browser: ${diagramUrl}\n\nClick nodes in the diagram to explore details about each component.`, 274 }, 275 ], 276 }; 277 }); 278 279 // Connect MCP server to stdio transport 280 const transport = new StdioServerTransport(); 281 await server.connect(transport); 282} 283 284function generate404HTML(title: string, message: string): string { 285 return `<!DOCTYPE html> 286<html lang="en"> 287<head> 288 <meta charset="UTF-8" /> 289 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 290 <title>Traverse — ${escapeHTML(title)}</title> 291 <link rel="icon" href="/icon.svg" type="image/svg+xml" /> 292 <style> 293 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 294 :root { 295 --bg: #fafafa; --text: #1a1a1a; --text-muted: #666; 296 --border: #e2e2e2; --code-bg: #f4f4f5; 297 } 298 @media (prefers-color-scheme: dark) { 299 :root { 300 --bg: #0a0a0a; --text: #e5e5e5; --text-muted: #a3a3a3; 301 --border: #262626; --code-bg: #1c1c1e; 302 } 303 } 304 body { 305 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 306 background: var(--bg); color: var(--text); min-height: 100vh; 307 display: flex; align-items: center; justify-content: center; 308 } 309 .container { text-align: center; padding: 20px; } 310 .code { font-size: 64px; font-weight: 700; color: var(--text-muted); opacity: 0.3; } 311 h1 { font-size: 20px; font-weight: 600; margin-top: 8px; } 312 p { color: var(--text-muted); font-size: 14px; margin-top: 8px; } 313 a { 314 display: inline-block; margin-top: 24px; font-size: 13px; 315 color: var(--text); text-decoration: none; 316 border: 1px solid var(--border); border-radius: 6px; 317 padding: 8px 16px; transition: all 0.15s; 318 } 319 a:hover { border-color: var(--text-muted); background: var(--code-bg); } 320 </style> 321</head> 322<body> 323 <div class="container"> 324 <div class="code">404</div> 325 <h1>${escapeHTML(title)}</h1> 326 <p>${escapeHTML(message)}</p> 327 <a href="/">Back to diagrams</a> 328 </div> 329</body> 330</html>`; 331} 332 333function generateLocalIndexHTML(diagrams: Map<string, WalkthroughDiagram>, gitHash: string, baseUrl: string): string { 334 const items = [...diagrams.entries()] 335 .map( 336 ([id, d]) => { 337 const nodes = Object.values(d.nodes); 338 const nodeCount = nodes.length; 339 const preview = nodes.slice(0, 4).map(n => escapeHTML(n.title)); 340 const extra = nodeCount > 4 ? ` <span class="more">+${nodeCount - 4}</span>` : ""; 341 const tags = preview.map(t => `<span class="tag">${t}</span>`).join("") + extra; 342 return `<div class="diagram-item-wrap"> 343 <a href="/diagram/${id}" class="diagram-item"> 344 <div class="diagram-header"> 345 <span class="diagram-title">${escapeHTML(d.summary)}</span> 346 <span class="diagram-meta">${nodeCount} node${nodeCount !== 1 ? "s" : ""}</span> 347 </div> 348 <div class="diagram-tags">${tags}</div> 349 </a> 350 <button class="delete-btn" onclick="deleteDiagram('${escapeHTML(id)}', this)" title="Delete diagram"> 351 <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 352 <path d="M2 4h12M5.333 4V2.667a1.333 1.333 0 011.334-1.334h2.666a1.333 1.333 0 011.334 1.334V4m2 0v9.333a1.333 1.333 0 01-1.334 1.334H4.667a1.333 1.333 0 01-1.334-1.334V4h9.334z"/> 353 </svg> 354 </button> 355 </div>`; 356 }, 357 ) 358 .join("\n"); 359 360 const content = diagrams.size === 0 361 ? `<div class="empty"> 362 <div class="empty-icon"> 363 <svg width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round"> 364 <rect x="8" y="8" width="32" height="32" rx="4"/> 365 <circle cx="20" cy="20" r="3"/><circle cx="28" cy="28" r="3"/> 366 <path d="M22 21l4 5"/> 367 </svg> 368 </div> 369 <p>No diagrams yet.</p> 370 <p class="hint">Use the <code>walkthrough_diagram</code> MCP tool to create one.</p> 371 </div>` 372 : `<div class="diagram-list">${items}</div>`; 373 374 const diagramCount = diagrams.size; 375 return `<!DOCTYPE html> 376<html lang="en"> 377<head> 378 <meta charset="UTF-8" /> 379 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 380 <title>Traverse</title> 381 <link rel="icon" href="/icon.svg" type="image/svg+xml" /> 382 <meta property="og:type" content="website" /> 383 <meta property="og:title" content="Traverse" /> 384 <meta property="og:description" content="Interactive code walkthrough diagrams. ${diagramCount} diagram${diagramCount !== 1 ? "s" : ""}." /> 385 <meta property="og:image" content="${escapeHTML(baseUrl)}/og.png" /> 386 <meta name="twitter:card" content="summary_large_image" /> 387 <meta name="twitter:title" content="Traverse" /> 388 <meta name="twitter:description" content="Interactive code walkthrough diagrams." /> 389 <meta name="twitter:image" content="${escapeHTML(baseUrl)}/og.png" /> 390 <style> 391 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 392 :root { 393 --bg: #fafafa; --bg-panel: #ffffff; --border: #e2e2e2; 394 --text: #1a1a1a; --text-muted: #666; --accent: #2563eb; 395 --code-bg: #f4f4f5; 396 } 397 @media (prefers-color-scheme: dark) { 398 :root { 399 --bg: #0a0a0a; --bg-panel: #141414; --border: #262626; 400 --text: #e5e5e5; --text-muted: #a3a3a3; --accent: #3b82f6; 401 --code-bg: #1c1c1e; 402 } 403 } 404 body { 405 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 406 background: var(--bg); color: var(--text); min-height: 100vh; 407 display: flex; flex-direction: column; 408 } 409 .main-content { flex: 1; } 410 .header { 411 padding: 48px 20px 32px; 412 max-width: 520px; margin: 0 auto; 413 } 414 .header h1 { 415 font-size: 24px; font-weight: 700; 416 display: flex; align-items: center; gap: 10px; 417 } 418 .header h1 span { 419 font-size: 11px; font-weight: 600; text-transform: uppercase; 420 letter-spacing: 0.05em; color: var(--text-muted); 421 background: var(--code-bg); padding: 3px 8px; 422 border-radius: 4px; 423 } 424 .header p { color: var(--text-muted); font-size: 14px; margin-top: 8px; } 425 .diagram-list { 426 max-width: 520px; margin: 0 auto; padding: 0 20px 48px; 427 display: flex; flex-direction: column; gap: 12px; 428 } 429 .diagram-item-wrap { 430 position: relative; 431 display: flex; 432 align-items: stretch; 433 gap: 0; 434 } 435 .diagram-item { 436 display: flex; flex-direction: column; gap: 10px; 437 padding: 16px 32px 16px 16px; border: 1px solid var(--border); 438 border-radius: 8px; text-decoration: none; color: var(--text); 439 transition: border-color 0.15s, background 0.15s; 440 flex: 1; 441 min-width: 0; 442 } 443 .diagram-item:hover { 444 border-color: var(--text-muted); background: var(--code-bg); 445 } 446 .delete-btn { 447 position: absolute; 448 top: 8px; 449 right: 8px; 450 background: none; 451 border: none; 452 color: var(--text-muted); 453 cursor: pointer; 454 padding: 4px; 455 border-radius: 4px; 456 opacity: 0; 457 transition: opacity 0.15s, color 0.15s, background 0.15s; 458 display: flex; 459 align-items: center; 460 justify-content: center; 461 } 462 .diagram-item-wrap:hover .delete-btn { 463 opacity: 1; 464 } 465 .delete-btn:hover { 466 color: #ef4444; 467 background: rgba(239, 68, 68, 0.1); 468 } 469 .diagram-header { 470 display: flex; align-items: center; justify-content: space-between; 471 } 472 .diagram-title { font-size: 14px; font-weight: 500; } 473 .diagram-meta { 474 font-size: 12px; color: var(--text-muted); 475 flex-shrink: 0; margin-left: 12px; 476 } 477 .diagram-tags { 478 display: flex; flex-wrap: wrap; gap: 6px; align-items: center; 479 } 480 .diagram-tags .tag { 481 font-size: 11px; color: var(--text-muted); 482 background: var(--code-bg); padding: 2px 8px; 483 border-radius: 4px; 484 } 485 .diagram-tags .more { 486 font-size: 11px; color: var(--text-muted); opacity: 0.6; 487 } 488 .empty { 489 max-width: 520px; margin: 0 auto; padding: 60px 20px; 490 text-align: center; color: var(--text-muted); 491 } 492 .empty-icon { margin-bottom: 16px; opacity: 0.4; } 493 .empty p { font-size: 15px; } 494 .empty .hint { font-size: 13px; margin-top: 8px; } 495 .empty code { 496 background: var(--code-bg); padding: 2px 6px; 497 border-radius: 3px; font-size: 12px; 498 } 499 .site-footer { 500 padding: 32px 20px; 501 font-size: 13px; color: var(--text-muted); 502 display: flex; justify-content: space-between; align-items: center; 503 } 504 .site-footer .heart { color: #e25555; } 505 .site-footer a { color: var(--text); text-decoration: none; } 506 .site-footer a:hover { text-decoration: underline; } 507 .site-footer .hash { 508 font-family: "SF Mono", "Fira Code", monospace; 509 font-size: 11px; opacity: 0.6; 510 color: var(--text-muted) !important; 511 } 512 </style> 513</head> 514<body> 515 <div class="main-content"> 516 <div class="header"> 517 <h1>Traverse <span>v0.1</span></h1> 518 <p>Interactive code walkthrough diagrams</p> 519 </div> 520 ${content} 521 </div> 522 <footer class="site-footer"> 523 <span>Made with &#x2764;&#xFE0F; by <a href="https://dunkirk.sh">Kieran Klukas</a></span> 524 <a class="hash" href="https://github.com/taciturnaxolotl/traverse/${/^v\d+\./.test(gitHash) ? "releases/tag" : "commit"}/${escapeHTML(gitHash)}">${escapeHTML(gitHash)}</a> 525 </footer> 526 <script> 527 async function deleteDiagram(id, btn) { 528 if (!confirm('Delete this diagram?')) return; 529 try { 530 const res = await fetch('/api/diagrams/' + id, { method: 'DELETE' }); 531 if (res.ok) { 532 const wrap = btn.closest('.diagram-item-wrap'); 533 wrap.remove(); 534 // If no diagrams left, reload to show empty state 535 if (!document.querySelector('.diagram-item-wrap')) { 536 location.reload(); 537 } 538 } 539 } catch (e) { 540 console.error('Failed to delete diagram:', e); 541 } 542 } 543 </script> 544</body> 545</html>`; 546} 547 548function generateServerIndexHTML(diagramCount: number, gitHash: string, baseUrl: string): string { 549 return `<!DOCTYPE html> 550<html lang="en"> 551<head> 552 <meta charset="UTF-8" /> 553 <meta name="viewport" content="width=device-width, initial-scale=1.0" /> 554 <title>Traverse</title> 555 <link rel="icon" href="/icon.svg" type="image/svg+xml" /> 556 <meta property="og:type" content="website" /> 557 <meta property="og:title" content="Traverse" /> 558 <meta property="og:description" content="Interactive code walkthrough diagrams, shareable with anyone. ${diagramCount} diagram${diagramCount !== 1 ? "s" : ""} shared." /> 559 <meta property="og:image" content="${escapeHTML(baseUrl)}/og.png" /> 560 <meta name="twitter:card" content="summary_large_image" /> 561 <meta name="twitter:title" content="Traverse" /> 562 <meta name="twitter:description" content="Interactive code walkthrough diagrams, shareable with anyone." /> 563 <meta name="twitter:image" content="${escapeHTML(baseUrl)}/og.png" /> 564 <style> 565 *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; } 566 :root { 567 --bg: #fafafa; --bg-panel: #ffffff; --border: #e2e2e2; 568 --text: #1a1a1a; --text-muted: #666; --accent: #2563eb; 569 --code-bg: #f4f4f5; 570 } 571 @media (prefers-color-scheme: dark) { 572 :root { 573 --bg: #0a0a0a; --bg-panel: #141414; --border: #262626; 574 --text: #e5e5e5; --text-muted: #a3a3a3; --accent: #3b82f6; 575 --code-bg: #1c1c1e; 576 } 577 } 578 body { 579 font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif; 580 background: var(--bg); color: var(--text); min-height: 100vh; 581 display: flex; flex-direction: column; align-items: center; justify-content: center; 582 } 583 .landing { 584 max-width: 480px; text-align: center; padding: 40px 20px; 585 } 586 .landing h1 { 587 font-size: 32px; font-weight: 700; margin-bottom: 8px; 588 } 589 .landing .tagline { 590 color: var(--text-muted); font-size: 16px; line-height: 1.5; 591 margin-bottom: 32px; 592 } 593 .stat { 594 display: inline-flex; align-items: center; gap: 8px; 595 background: var(--code-bg); border: 1px solid var(--border); 596 border-radius: 8px; padding: 10px 20px; 597 font-size: 14px; color: var(--text-muted); 598 margin-bottom: 32px; 599 } 600 .stat strong { 601 font-size: 20px; font-weight: 700; color: var(--text); 602 font-variant-numeric: tabular-nums; 603 } 604 .github-btn { 605 display: inline-flex; align-items: center; gap: 8px; 606 background: var(--text); color: var(--bg); 607 border: none; border-radius: 8px; 608 padding: 12px 24px; font-size: 15px; font-weight: 500; 609 text-decoration: none; transition: opacity 0.15s; 610 font-family: inherit; 611 } 612 .github-btn:hover { opacity: 0.85; } 613 .github-btn svg { flex-shrink: 0; } 614 .site-footer { 615 position: fixed; bottom: 0; left: 0; right: 0; 616 padding: 20px; 617 font-size: 13px; color: var(--text-muted); 618 display: flex; justify-content: space-between; align-items: center; 619 } 620 .site-footer a { color: var(--text); text-decoration: none; } 621 .site-footer a:hover { text-decoration: underline; } 622 .site-footer .hash { 623 font-family: "SF Mono", "Fira Code", monospace; 624 font-size: 11px; opacity: 0.6; 625 color: var(--text-muted) !important; 626 } 627 </style> 628</head> 629<body> 630 <div class="landing"> 631 <h1>Traverse</h1> 632 <p class="tagline">Interactive code walkthrough diagrams, shareable with anyone. Powered by an MCP server you run locally.</p> 633 <div class="stat"> 634 <strong>${diagramCount}</strong> diagram${diagramCount !== 1 ? "s" : ""} shared 635 </div> 636 <br /><br /> 637 <a class="github-btn" href="https://github.com/taciturnaxolotl/traverse"> 638 <svg width="20" height="20" viewBox="0 0 16 16" fill="currentColor"><path d="M8 0C3.58 0 0 3.58 0 8c0 3.54 2.29 6.53 5.47 7.59.4.07.55-.17.55-.38 0-.19-.01-.82-.01-1.49-2.01.37-2.53-.49-2.69-.94-.09-.23-.48-.94-.82-1.13-.28-.15-.68-.52-.01-.53.63-.01 1.08.58 1.23.82.72 1.21 1.87.87 2.33.66.07-.52.28-.87.51-1.07-1.78-.2-3.64-.89-3.64-3.95 0-.87.31-1.59.82-2.15-.08-.2-.36-1.02.08-2.12 0 0 .67-.21 2.2.82.64-.18 1.32-.27 2-.27.68 0 1.36.09 2 .27 1.53-1.04 2.2-.82 2.2-.82.44 1.1.16 1.92.08 2.12.51.56.82 1.27.82 2.15 0 3.07-1.87 3.75-3.65 3.95.29.25.54.73.54 1.48 0 1.07-.01 1.93-.01 2.2 0 .21.15.46.55.38A8.013 8.013 0 0016 8c0-4.42-3.58-8-8-8z"/></svg> 639 View on GitHub 640 </a> 641 </div> 642 <footer class="site-footer"> 643 <span>Made with &#x2764;&#xFE0F; by <a href="https://dunkirk.sh">Kieran Klukas</a></span> 644 <a class="hash" href="https://github.com/taciturnaxolotl/traverse/${/^v\d+\./.test(gitHash) ? "releases/tag" : "commit"}/${escapeHTML(gitHash)}">${escapeHTML(gitHash)}</a> 645 </footer> 646</body> 647</html>`; 648} 649 650function escapeHTML(str: string): string { 651 return str 652 .replace(/&/g, "&amp;") 653 .replace(/</g, "&lt;") 654 .replace(/>/g, "&gt;"); 655}