@taciturnaxolotl/traverse 0.1.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/src/index.ts ADDED
@@ -0,0 +1,542 @@
1
+ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
+ import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
3
+ import { z } from "zod/v4";
4
+ import { generateViewerHTML } from "./template.ts";
5
+ import type { WalkthroughDiagram } from "./types.ts";
6
+ import { initDb, loadAllDiagrams, saveDiagram, deleteDiagramFromDb, generateId } from "./storage.ts";
7
+ import { loadConfig } from "./config.ts";
8
+
9
+ const PORT = parseInt(process.env.TRAVERSE_PORT || "4173", 10);
10
+ const MODE = (process.env.TRAVERSE_MODE || "local") as "local" | "server";
11
+ const GIT_HASH = await Bun.$`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => "dev");
12
+
13
+ // Load config and init persistence
14
+ const config = loadConfig();
15
+ initDb();
16
+
17
+ // Load persisted diagrams
18
+ const diagrams = loadAllDiagrams();
19
+
20
+ // --- Web server for serving interactive diagrams ---
21
+ Bun.serve({
22
+ port: PORT,
23
+ async fetch(req) {
24
+ const url = new URL(req.url);
25
+ const diagramMatch = url.pathname.match(/^\/diagram\/([\w-]+)$/);
26
+
27
+ if (diagramMatch) {
28
+ const id = diagramMatch[1]!;
29
+ const diagram = diagrams.get(id);
30
+ if (!diagram) {
31
+ return new Response(generate404HTML("Diagram not found", "This diagram doesn't exist or may have expired."), {
32
+ status: 404,
33
+ headers: { "Content-Type": "text/html; charset=utf-8" },
34
+ });
35
+ }
36
+ return new Response(generateViewerHTML(diagram, GIT_HASH, process.cwd(), {
37
+ mode: MODE,
38
+ shareServerUrl: config.shareServerUrl,
39
+ diagramId: id,
40
+ }), {
41
+ headers: { "Content-Type": "text/html; charset=utf-8" },
42
+ });
43
+ }
44
+
45
+ // DELETE /api/diagrams/:id
46
+ const apiMatch = url.pathname.match(/^\/api\/diagrams\/([\w-]+)$/);
47
+ if (apiMatch && req.method === "DELETE") {
48
+ const id = apiMatch[1]!;
49
+ if (!diagrams.has(id)) {
50
+ return Response.json({ error: "not found" }, { status: 404 });
51
+ }
52
+ diagrams.delete(id);
53
+ deleteDiagramFromDb(id);
54
+ return Response.json({ ok: true, id });
55
+ }
56
+
57
+ // POST /api/diagrams (server mode: accept diagrams from remote)
58
+ if (url.pathname === "/api/diagrams" && req.method === "POST") {
59
+ if (MODE !== "server") {
60
+ return Response.json({ error: "POST only available in server mode" }, { status: 403 });
61
+ }
62
+ try {
63
+ const body = await req.json() as WalkthroughDiagram;
64
+ if (!body.code || !body.summary || !body.nodes) {
65
+ return Response.json({ error: "missing required fields: code, summary, nodes" }, { status: 400 });
66
+ }
67
+ const id = generateId();
68
+ const diagram: WalkthroughDiagram = {
69
+ code: body.code,
70
+ summary: body.summary,
71
+ nodes: body.nodes,
72
+ createdAt: new Date().toISOString(),
73
+ };
74
+ diagrams.set(id, diagram);
75
+ saveDiagram(id, diagram);
76
+ const diagramUrl = `${url.origin}/diagram/${id}`;
77
+ return Response.json({ id, url: diagramUrl }, {
78
+ status: 201,
79
+ headers: {
80
+ "Access-Control-Allow-Origin": "*",
81
+ },
82
+ });
83
+ } catch {
84
+ return Response.json({ error: "invalid JSON body" }, { status: 400 });
85
+ }
86
+ }
87
+
88
+ // OPTIONS /api/diagrams — CORS preflight
89
+ if (url.pathname === "/api/diagrams" && req.method === "OPTIONS") {
90
+ return new Response(null, {
91
+ status: 204,
92
+ headers: {
93
+ "Access-Control-Allow-Origin": "*",
94
+ "Access-Control-Allow-Methods": "POST, OPTIONS",
95
+ "Access-Control-Allow-Headers": "Content-Type",
96
+ },
97
+ });
98
+ }
99
+
100
+ if (url.pathname === "/icon.svg") {
101
+ return new Response(Bun.file(import.meta.dir + "/../icon.svg"), {
102
+ headers: { "Content-Type": "image/svg+xml" },
103
+ });
104
+ }
105
+
106
+ // List available diagrams
107
+ if (url.pathname === "/") {
108
+ const html = MODE === "server"
109
+ ? generateServerIndexHTML(diagrams.size, GIT_HASH)
110
+ : generateLocalIndexHTML(diagrams, GIT_HASH);
111
+ return new Response(html, {
112
+ headers: { "Content-Type": "text/html; charset=utf-8" },
113
+ });
114
+ }
115
+
116
+ return new Response(generate404HTML("Page not found", "There's nothing at this URL."), {
117
+ status: 404,
118
+ headers: { "Content-Type": "text/html; charset=utf-8" },
119
+ });
120
+ },
121
+ });
122
+
123
+ // --- MCP Server (local mode only) ---
124
+ if (MODE === "local") {
125
+ const server = new McpServer({
126
+ name: "traverse",
127
+ version: "0.1.0",
128
+ });
129
+
130
+ const nodeMetadataSchema = z.object({
131
+ title: z.string(),
132
+ description: z.string(),
133
+ links: z
134
+ .array(z.object({ label: z.string(), url: z.string() }))
135
+ .optional(),
136
+ codeSnippet: z.string().optional(),
137
+ });
138
+
139
+ server.registerTool("walkthrough_diagram", {
140
+ title: "Walkthrough Diagram",
141
+ description: `Render an interactive Mermaid diagram where users can click nodes to see details.
142
+
143
+ BEFORE calling this tool, deeply explore the codebase:
144
+ 1. Use search/read tools to find key files, entry points, and architecture patterns
145
+ 2. Trace execution paths and data flow between components
146
+ 3. Read source files — don't guess from filenames
147
+
148
+ Then build the diagram:
149
+ - Use \`flowchart TB\` with plain text labels, no HTML or custom styling
150
+ - 5-12 nodes at the right abstraction level (not too granular, not too high-level)
151
+ - Node keys must match Mermaid node IDs exactly
152
+ - 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
153
+ - Links: include file:line references from your exploration
154
+ - Code snippets: key excerpts (under 15 lines) showing the most important or representative code`,
155
+ inputSchema: z.object({
156
+ code: z.string(),
157
+ summary: z.string(),
158
+ nodes: z.record(z.string(), nodeMetadataSchema),
159
+ }),
160
+ }, async ({ code, summary, nodes }) => {
161
+ const id = generateId();
162
+ const diagram: WalkthroughDiagram = {
163
+ code,
164
+ summary,
165
+ nodes,
166
+ createdAt: new Date().toISOString(),
167
+ };
168
+ diagrams.set(id, diagram);
169
+ saveDiagram(id, diagram);
170
+
171
+ const diagramUrl = `http://localhost:${PORT}/diagram/${id}`;
172
+
173
+ return {
174
+ content: [
175
+ {
176
+ type: "text",
177
+ text: `Interactive diagram ready.\n\nOpen in browser: ${diagramUrl}\n\nClick nodes in the diagram to explore details about each component.`,
178
+ },
179
+ ],
180
+ };
181
+ });
182
+
183
+ // Connect MCP server to stdio transport
184
+ const transport = new StdioServerTransport();
185
+ await server.connect(transport);
186
+ }
187
+
188
+ function generate404HTML(title: string, message: string): string {
189
+ return `<!DOCTYPE html>
190
+ <html lang="en">
191
+ <head>
192
+ <meta charset="UTF-8" />
193
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
194
+ <title>Traverse — ${escapeHTML(title)}</title>
195
+ <link rel="icon" href="/icon.svg" type="image/svg+xml" />
196
+ <style>
197
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
198
+ :root {
199
+ --bg: #fafafa; --text: #1a1a1a; --text-muted: #666;
200
+ --border: #e2e2e2; --code-bg: #f4f4f5;
201
+ }
202
+ @media (prefers-color-scheme: dark) {
203
+ :root {
204
+ --bg: #0a0a0a; --text: #e5e5e5; --text-muted: #a3a3a3;
205
+ --border: #262626; --code-bg: #1c1c1e;
206
+ }
207
+ }
208
+ body {
209
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
210
+ background: var(--bg); color: var(--text); min-height: 100vh;
211
+ display: flex; align-items: center; justify-content: center;
212
+ }
213
+ .container { text-align: center; padding: 20px; }
214
+ .code { font-size: 64px; font-weight: 700; color: var(--text-muted); opacity: 0.3; }
215
+ h1 { font-size: 20px; font-weight: 600; margin-top: 8px; }
216
+ p { color: var(--text-muted); font-size: 14px; margin-top: 8px; }
217
+ a {
218
+ display: inline-block; margin-top: 24px; font-size: 13px;
219
+ color: var(--text); text-decoration: none;
220
+ border: 1px solid var(--border); border-radius: 6px;
221
+ padding: 8px 16px; transition: all 0.15s;
222
+ }
223
+ a:hover { border-color: var(--text-muted); background: var(--code-bg); }
224
+ </style>
225
+ </head>
226
+ <body>
227
+ <div class="container">
228
+ <div class="code">404</div>
229
+ <h1>${escapeHTML(title)}</h1>
230
+ <p>${escapeHTML(message)}</p>
231
+ <a href="/">Back to diagrams</a>
232
+ </div>
233
+ </body>
234
+ </html>`;
235
+ }
236
+
237
+ function generateLocalIndexHTML(diagrams: Map<string, WalkthroughDiagram>, gitHash: string): string {
238
+ const items = [...diagrams.entries()]
239
+ .map(
240
+ ([id, d]) => {
241
+ const nodes = Object.values(d.nodes);
242
+ const nodeCount = nodes.length;
243
+ const preview = nodes.slice(0, 4).map(n => escapeHTML(n.title));
244
+ const extra = nodeCount > 4 ? ` <span class="more">+${nodeCount - 4}</span>` : "";
245
+ const tags = preview.map(t => `<span class="tag">${t}</span>`).join("") + extra;
246
+ return `<div class="diagram-item-wrap">
247
+ <a href="/diagram/${id}" class="diagram-item">
248
+ <div class="diagram-header">
249
+ <span class="diagram-title">${escapeHTML(d.summary)}</span>
250
+ <span class="diagram-meta">${nodeCount} node${nodeCount !== 1 ? "s" : ""}</span>
251
+ </div>
252
+ <div class="diagram-tags">${tags}</div>
253
+ </a>
254
+ <button class="delete-btn" onclick="deleteDiagram('${escapeHTML(id)}', this)" title="Delete diagram">
255
+ <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
256
+ <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"/>
257
+ </svg>
258
+ </button>
259
+ </div>`;
260
+ },
261
+ )
262
+ .join("\n");
263
+
264
+ const content = diagrams.size === 0
265
+ ? `<div class="empty">
266
+ <div class="empty-icon">
267
+ <svg width="48" height="48" viewBox="0 0 48 48" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
268
+ <rect x="8" y="8" width="32" height="32" rx="4"/>
269
+ <circle cx="20" cy="20" r="3"/><circle cx="28" cy="28" r="3"/>
270
+ <path d="M22 21l4 5"/>
271
+ </svg>
272
+ </div>
273
+ <p>No diagrams yet.</p>
274
+ <p class="hint">Use the <code>walkthrough_diagram</code> MCP tool to create one.</p>
275
+ </div>`
276
+ : `<div class="diagram-list">${items}</div>`;
277
+
278
+ return `<!DOCTYPE html>
279
+ <html lang="en">
280
+ <head>
281
+ <meta charset="UTF-8" />
282
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
283
+ <title>Traverse</title>
284
+ <link rel="icon" href="/icon.svg" type="image/svg+xml" />
285
+ <style>
286
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
287
+ :root {
288
+ --bg: #fafafa; --bg-panel: #ffffff; --border: #e2e2e2;
289
+ --text: #1a1a1a; --text-muted: #666; --accent: #2563eb;
290
+ --code-bg: #f4f4f5;
291
+ }
292
+ @media (prefers-color-scheme: dark) {
293
+ :root {
294
+ --bg: #0a0a0a; --bg-panel: #141414; --border: #262626;
295
+ --text: #e5e5e5; --text-muted: #a3a3a3; --accent: #3b82f6;
296
+ --code-bg: #1c1c1e;
297
+ }
298
+ }
299
+ body {
300
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
301
+ background: var(--bg); color: var(--text); min-height: 100vh;
302
+ display: flex; flex-direction: column;
303
+ }
304
+ .main-content { flex: 1; }
305
+ .header {
306
+ padding: 48px 20px 32px;
307
+ max-width: 520px; margin: 0 auto;
308
+ }
309
+ .header h1 {
310
+ font-size: 24px; font-weight: 700;
311
+ display: flex; align-items: center; gap: 10px;
312
+ }
313
+ .header h1 span {
314
+ font-size: 11px; font-weight: 600; text-transform: uppercase;
315
+ letter-spacing: 0.05em; color: var(--text-muted);
316
+ background: var(--code-bg); padding: 3px 8px;
317
+ border-radius: 4px;
318
+ }
319
+ .header p { color: var(--text-muted); font-size: 14px; margin-top: 8px; }
320
+ .diagram-list {
321
+ max-width: 520px; margin: 0 auto; padding: 0 20px 48px;
322
+ display: flex; flex-direction: column; gap: 12px;
323
+ }
324
+ .diagram-item-wrap {
325
+ position: relative;
326
+ display: flex;
327
+ align-items: stretch;
328
+ gap: 0;
329
+ }
330
+ .diagram-item {
331
+ display: flex; flex-direction: column; gap: 10px;
332
+ padding: 16px; border: 1px solid var(--border);
333
+ border-radius: 8px; text-decoration: none; color: var(--text);
334
+ transition: border-color 0.15s, background 0.15s;
335
+ flex: 1;
336
+ min-width: 0;
337
+ }
338
+ .diagram-item:hover {
339
+ border-color: var(--text-muted); background: var(--code-bg);
340
+ }
341
+ .delete-btn {
342
+ position: absolute;
343
+ top: 8px;
344
+ right: 8px;
345
+ background: none;
346
+ border: none;
347
+ color: var(--text-muted);
348
+ cursor: pointer;
349
+ padding: 4px;
350
+ border-radius: 4px;
351
+ opacity: 0;
352
+ transition: opacity 0.15s, color 0.15s, background 0.15s;
353
+ display: flex;
354
+ align-items: center;
355
+ justify-content: center;
356
+ }
357
+ .diagram-item-wrap:hover .delete-btn {
358
+ opacity: 1;
359
+ }
360
+ .delete-btn:hover {
361
+ color: #ef4444;
362
+ background: rgba(239, 68, 68, 0.1);
363
+ }
364
+ .diagram-header {
365
+ display: flex; align-items: center; justify-content: space-between;
366
+ }
367
+ .diagram-title { font-size: 14px; font-weight: 500; }
368
+ .diagram-meta {
369
+ font-size: 12px; color: var(--text-muted);
370
+ flex-shrink: 0; margin-left: 12px;
371
+ }
372
+ .diagram-tags {
373
+ display: flex; flex-wrap: wrap; gap: 6px; align-items: center;
374
+ }
375
+ .diagram-tags .tag {
376
+ font-size: 11px; color: var(--text-muted);
377
+ background: var(--code-bg); padding: 2px 8px;
378
+ border-radius: 4px;
379
+ }
380
+ .diagram-tags .more {
381
+ font-size: 11px; color: var(--text-muted); opacity: 0.6;
382
+ }
383
+ .empty {
384
+ max-width: 520px; margin: 0 auto; padding: 60px 20px;
385
+ text-align: center; color: var(--text-muted);
386
+ }
387
+ .empty-icon { margin-bottom: 16px; opacity: 0.4; }
388
+ .empty p { font-size: 15px; }
389
+ .empty .hint { font-size: 13px; margin-top: 8px; }
390
+ .empty code {
391
+ background: var(--code-bg); padding: 2px 6px;
392
+ border-radius: 3px; font-size: 12px;
393
+ }
394
+ .site-footer {
395
+ padding: 32px 20px;
396
+ font-size: 13px; color: var(--text-muted);
397
+ display: flex; justify-content: space-between; align-items: center;
398
+ }
399
+ .site-footer .heart { color: #e25555; }
400
+ .site-footer a { color: var(--text); text-decoration: none; }
401
+ .site-footer a:hover { text-decoration: underline; }
402
+ .site-footer .hash {
403
+ font-family: "SF Mono", "Fira Code", monospace;
404
+ font-size: 11px; opacity: 0.6;
405
+ color: var(--text-muted) !important;
406
+ }
407
+ </style>
408
+ </head>
409
+ <body>
410
+ <div class="main-content">
411
+ <div class="header">
412
+ <h1>Traverse <span>v0.1</span></h1>
413
+ <p>Interactive code walkthrough diagrams</p>
414
+ </div>
415
+ ${content}
416
+ </div>
417
+ <footer class="site-footer">
418
+ <span>Made with &#x2764;&#xFE0F; by <a href="https://dunkirk.sh">Kieran Klukas</a></span>
419
+ <a class="hash" href="https://github.com/taciturnaxolotl/traverse/commit/${escapeHTML(gitHash)}">${escapeHTML(gitHash)}</a>
420
+ </footer>
421
+ <script>
422
+ async function deleteDiagram(id, btn) {
423
+ if (!confirm('Delete this diagram?')) return;
424
+ try {
425
+ const res = await fetch('/api/diagrams/' + id, { method: 'DELETE' });
426
+ if (res.ok) {
427
+ const wrap = btn.closest('.diagram-item-wrap');
428
+ wrap.remove();
429
+ // If no diagrams left, reload to show empty state
430
+ if (!document.querySelector('.diagram-item-wrap')) {
431
+ location.reload();
432
+ }
433
+ }
434
+ } catch (e) {
435
+ console.error('Failed to delete diagram:', e);
436
+ }
437
+ }
438
+ </script>
439
+ </body>
440
+ </html>`;
441
+ }
442
+
443
+ function generateServerIndexHTML(diagramCount: number, gitHash: string): string {
444
+ return `<!DOCTYPE html>
445
+ <html lang="en">
446
+ <head>
447
+ <meta charset="UTF-8" />
448
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
449
+ <title>Traverse</title>
450
+ <link rel="icon" href="/icon.svg" type="image/svg+xml" />
451
+ <style>
452
+ *, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
453
+ :root {
454
+ --bg: #fafafa; --bg-panel: #ffffff; --border: #e2e2e2;
455
+ --text: #1a1a1a; --text-muted: #666; --accent: #2563eb;
456
+ --code-bg: #f4f4f5;
457
+ }
458
+ @media (prefers-color-scheme: dark) {
459
+ :root {
460
+ --bg: #0a0a0a; --bg-panel: #141414; --border: #262626;
461
+ --text: #e5e5e5; --text-muted: #a3a3a3; --accent: #3b82f6;
462
+ --code-bg: #1c1c1e;
463
+ }
464
+ }
465
+ body {
466
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
467
+ background: var(--bg); color: var(--text); min-height: 100vh;
468
+ display: flex; flex-direction: column; align-items: center; justify-content: center;
469
+ }
470
+ .landing {
471
+ max-width: 480px; text-align: center; padding: 40px 20px;
472
+ }
473
+ .landing h1 {
474
+ font-size: 32px; font-weight: 700; margin-bottom: 8px;
475
+ }
476
+ .landing .tagline {
477
+ color: var(--text-muted); font-size: 16px; line-height: 1.5;
478
+ margin-bottom: 32px;
479
+ }
480
+ .stat {
481
+ display: inline-flex; align-items: center; gap: 8px;
482
+ background: var(--code-bg); border: 1px solid var(--border);
483
+ border-radius: 8px; padding: 10px 20px;
484
+ font-size: 14px; color: var(--text-muted);
485
+ margin-bottom: 32px;
486
+ }
487
+ .stat strong {
488
+ font-size: 20px; font-weight: 700; color: var(--text);
489
+ font-variant-numeric: tabular-nums;
490
+ }
491
+ .github-btn {
492
+ display: inline-flex; align-items: center; gap: 8px;
493
+ background: var(--text); color: var(--bg);
494
+ border: none; border-radius: 8px;
495
+ padding: 12px 24px; font-size: 15px; font-weight: 500;
496
+ text-decoration: none; transition: opacity 0.15s;
497
+ font-family: inherit;
498
+ }
499
+ .github-btn:hover { opacity: 0.85; }
500
+ .github-btn svg { flex-shrink: 0; }
501
+ .site-footer {
502
+ position: fixed; bottom: 0; left: 0; right: 0;
503
+ padding: 20px;
504
+ font-size: 13px; color: var(--text-muted);
505
+ display: flex; justify-content: space-between; align-items: center;
506
+ }
507
+ .site-footer a { color: var(--text); text-decoration: none; }
508
+ .site-footer a:hover { text-decoration: underline; }
509
+ .site-footer .hash {
510
+ font-family: "SF Mono", "Fira Code", monospace;
511
+ font-size: 11px; opacity: 0.6;
512
+ color: var(--text-muted) !important;
513
+ }
514
+ </style>
515
+ </head>
516
+ <body>
517
+ <div class="landing">
518
+ <h1>Traverse</h1>
519
+ <p class="tagline">Interactive code walkthrough diagrams, shareable with anyone. Powered by an MCP server you run locally.</p>
520
+ <div class="stat">
521
+ <strong>${diagramCount}</strong> diagram${diagramCount !== 1 ? "s" : ""} shared
522
+ </div>
523
+ <br /><br />
524
+ <a class="github-btn" href="https://github.com/taciturnaxolotl/traverse">
525
+ <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>
526
+ View on GitHub
527
+ </a>
528
+ </div>
529
+ <footer class="site-footer">
530
+ <span>Made with &#x2764;&#xFE0F; by <a href="https://dunkirk.sh">Kieran Klukas</a></span>
531
+ <a class="hash" href="https://github.com/taciturnaxolotl/traverse/commit/${escapeHTML(gitHash)}">${escapeHTML(gitHash)}</a>
532
+ </footer>
533
+ </body>
534
+ </html>`;
535
+ }
536
+
537
+ function escapeHTML(str: string): string {
538
+ return str
539
+ .replace(/&/g, "&amp;")
540
+ .replace(/</g, "&lt;")
541
+ .replace(/>/g, "&gt;");
542
+ }
package/src/storage.ts ADDED
@@ -0,0 +1,62 @@
1
+ import { Database } from "bun:sqlite";
2
+ import { mkdirSync, existsSync } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { homedir } from "node:os";
5
+ import type { WalkthroughDiagram } from "./types.ts";
6
+
7
+ export function getDataDir(): string {
8
+ if (process.env.TRAVERSE_DATA_DIR) return process.env.TRAVERSE_DATA_DIR;
9
+
10
+ const platform = process.platform;
11
+ if (platform === "darwin") {
12
+ return join(homedir(), "Library", "Application Support", "traverse");
13
+ }
14
+ // Linux / other: XDG_DATA_HOME or fallback
15
+ const xdg = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
16
+ return join(xdg, "traverse");
17
+ }
18
+
19
+ let db: Database;
20
+
21
+ export function initDb(): Database {
22
+ const dataDir = getDataDir();
23
+ if (!existsSync(dataDir)) {
24
+ mkdirSync(dataDir, { recursive: true });
25
+ }
26
+
27
+ const dbPath = join(dataDir, "traverse.db");
28
+ db = new Database(dbPath);
29
+ db.run(`
30
+ CREATE TABLE IF NOT EXISTS diagrams (
31
+ id TEXT PRIMARY KEY,
32
+ summary TEXT,
33
+ data TEXT,
34
+ created_at TEXT
35
+ )
36
+ `);
37
+ return db;
38
+ }
39
+
40
+ export function loadAllDiagrams(): Map<string, WalkthroughDiagram> {
41
+ const rows = db.query("SELECT id, data FROM diagrams").all() as { id: string; data: string }[];
42
+ const map = new Map<string, WalkthroughDiagram>();
43
+ for (const row of rows) {
44
+ map.set(row.id, JSON.parse(row.data));
45
+ }
46
+ return map;
47
+ }
48
+
49
+ export function saveDiagram(id: string, diagram: WalkthroughDiagram): void {
50
+ db.run(
51
+ "INSERT OR REPLACE INTO diagrams (id, summary, data, created_at) VALUES (?, ?, ?, ?)",
52
+ [id, diagram.summary, JSON.stringify(diagram), new Date().toISOString()]
53
+ );
54
+ }
55
+
56
+ export function deleteDiagramFromDb(id: string): void {
57
+ db.run("DELETE FROM diagrams WHERE id = ?", [id]);
58
+ }
59
+
60
+ export function generateId(): string {
61
+ return crypto.randomUUID();
62
+ }