snatching amp's walkthrough for my own purposes mwhahaha
traverse.dunkirk.sh/diagram/6121f05c-a5ef-4ecf-8ffc-02534c5e767c
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 ❤️ 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 ❤️ 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, "&")
653 .replace(/</g, "<")
654 .replace(/>/g, ">");
655}