tangled
alpha
login
or
join now
dunkirk.sh
/
traverse
1
fork
atom
snatching amp's walkthrough for my own purposes mwhahaha
traverse.dunkirk.sh/diagram/6121f05c-a5ef-4ecf-8ffc-02534c5e767c
1
fork
atom
overview
issues
pulls
pipelines
feat: add server mode
dunkirk.sh
1 month ago
51a50f1e
db1df6f8
verified
This commit was signed with the committer's
known signature
.
dunkirk.sh
SSH Key Fingerprint:
SHA256:DqcG0RXYExE26KiWo3VxJnsxswN1QNfTBvB+bdSpk80=
+454
-56
5 changed files
expand all
collapse all
unified
split
src
config.ts
index.ts
storage.ts
template.ts
types.ts
+32
src/config.ts
···
1
1
+
import { join } from "node:path";
2
2
+
import { homedir } from "node:os";
3
3
+
import type { TraverseConfig } from "./types.ts";
4
4
+
5
5
+
const DEFAULT_SHARE_URL = "https://traverse.dunkirk.sh";
6
6
+
7
7
+
function getConfigDir(): string {
8
8
+
const platform = process.platform;
9
9
+
if (platform === "darwin") {
10
10
+
return join(homedir(), "Library", "Application Support", "traverse");
11
11
+
}
12
12
+
const xdg = process.env.XDG_CONFIG_HOME || join(homedir(), ".config");
13
13
+
return join(xdg, "traverse");
14
14
+
}
15
15
+
16
16
+
export function loadConfig(): TraverseConfig {
17
17
+
if (process.env.TRAVERSE_SHARE_URL) {
18
18
+
return { shareServerUrl: process.env.TRAVERSE_SHARE_URL };
19
19
+
}
20
20
+
21
21
+
const configPath = join(getConfigDir(), "config.json");
22
22
+
23
23
+
try {
24
24
+
const text = require("node:fs").readFileSync(configPath, "utf-8");
25
25
+
const parsed = JSON.parse(text);
26
26
+
return {
27
27
+
shareServerUrl: parsed.shareServerUrl || DEFAULT_SHARE_URL,
28
28
+
};
29
29
+
} catch {
30
30
+
return { shareServerUrl: DEFAULT_SHARE_URL };
31
31
+
}
32
32
+
}
+281
-55
src/index.ts
···
3
3
import { z } from "zod/v4";
4
4
import { generateViewerHTML } from "./template.ts";
5
5
import type { WalkthroughDiagram } from "./types.ts";
6
6
+
import { initDb, loadAllDiagrams, saveDiagram, deleteDiagramFromDb, generateId } from "./storage.ts";
7
7
+
import { loadConfig } from "./config.ts";
6
8
7
9
const PORT = parseInt(process.env.TRAVERSE_PORT || "4173", 10);
10
10
+
const MODE = (process.env.TRAVERSE_MODE || "local") as "local" | "server";
8
11
const GIT_HASH = await Bun.$`git rev-parse --short HEAD`.text().then(s => s.trim()).catch(() => "dev");
9
12
10
10
-
// In-memory diagram store
11
11
-
const diagrams = new Map<string, WalkthroughDiagram>();
12
12
-
let diagramCounter = 0;
13
13
+
// Load config and init persistence
14
14
+
const config = loadConfig();
15
15
+
initDb();
16
16
+
17
17
+
// Load persisted diagrams
18
18
+
const diagrams = loadAllDiagrams();
13
19
14
20
// --- Web server for serving interactive diagrams ---
15
21
Bun.serve({
16
22
port: PORT,
17
17
-
fetch(req) {
23
23
+
async fetch(req) {
18
24
const url = new URL(req.url);
19
19
-
const match = url.pathname.match(/^\/diagram\/(\w+)$/);
25
25
+
const diagramMatch = url.pathname.match(/^\/diagram\/([\w-]+)$/);
20
26
21
21
-
if (match) {
22
22
-
const id = match[1]!;
27
27
+
if (diagramMatch) {
28
28
+
const id = diagramMatch[1]!;
23
29
const diagram = diagrams.get(id);
24
30
if (!diagram) {
25
31
return new Response(generate404HTML("Diagram not found", "This diagram doesn't exist or may have expired."), {
···
27
33
headers: { "Content-Type": "text/html; charset=utf-8" },
28
34
});
29
35
}
30
30
-
return new Response(generateViewerHTML(diagram, GIT_HASH, process.cwd()), {
36
36
+
return new Response(generateViewerHTML(diagram, GIT_HASH, process.cwd(), {
37
37
+
mode: MODE,
38
38
+
shareServerUrl: config.shareServerUrl,
39
39
+
diagramId: id,
40
40
+
}), {
31
41
headers: { "Content-Type": "text/html; charset=utf-8" },
32
42
});
33
43
}
34
44
45
45
+
// DELETE /api/diagrams/:id
46
46
+
const apiMatch = url.pathname.match(/^\/api\/diagrams\/([\w-]+)$/);
47
47
+
if (apiMatch && req.method === "DELETE") {
48
48
+
const id = apiMatch[1]!;
49
49
+
if (!diagrams.has(id)) {
50
50
+
return Response.json({ error: "not found" }, { status: 404 });
51
51
+
}
52
52
+
diagrams.delete(id);
53
53
+
deleteDiagramFromDb(id);
54
54
+
return Response.json({ ok: true, id });
55
55
+
}
56
56
+
57
57
+
// POST /api/diagrams (server mode: accept diagrams from remote)
58
58
+
if (url.pathname === "/api/diagrams" && req.method === "POST") {
59
59
+
if (MODE !== "server") {
60
60
+
return Response.json({ error: "POST only available in server mode" }, { status: 403 });
61
61
+
}
62
62
+
try {
63
63
+
const body = await req.json() as WalkthroughDiagram;
64
64
+
if (!body.code || !body.summary || !body.nodes) {
65
65
+
return Response.json({ error: "missing required fields: code, summary, nodes" }, { status: 400 });
66
66
+
}
67
67
+
const id = generateId();
68
68
+
const diagram: WalkthroughDiagram = {
69
69
+
code: body.code,
70
70
+
summary: body.summary,
71
71
+
nodes: body.nodes,
72
72
+
createdAt: new Date().toISOString(),
73
73
+
};
74
74
+
diagrams.set(id, diagram);
75
75
+
saveDiagram(id, diagram);
76
76
+
const diagramUrl = `${url.origin}/diagram/${id}`;
77
77
+
return Response.json({ id, url: diagramUrl }, {
78
78
+
status: 201,
79
79
+
headers: {
80
80
+
"Access-Control-Allow-Origin": "*",
81
81
+
},
82
82
+
});
83
83
+
} catch {
84
84
+
return Response.json({ error: "invalid JSON body" }, { status: 400 });
85
85
+
}
86
86
+
}
87
87
+
88
88
+
// OPTIONS /api/diagrams — CORS preflight
89
89
+
if (url.pathname === "/api/diagrams" && req.method === "OPTIONS") {
90
90
+
return new Response(null, {
91
91
+
status: 204,
92
92
+
headers: {
93
93
+
"Access-Control-Allow-Origin": "*",
94
94
+
"Access-Control-Allow-Methods": "POST, OPTIONS",
95
95
+
"Access-Control-Allow-Headers": "Content-Type",
96
96
+
},
97
97
+
});
98
98
+
}
99
99
+
35
100
if (url.pathname === "/icon.svg") {
36
101
return new Response(Bun.file(import.meta.dir + "/../icon.svg"), {
37
102
headers: { "Content-Type": "image/svg+xml" },
···
40
105
41
106
// List available diagrams
42
107
if (url.pathname === "/") {
43
43
-
return new Response(generateIndexHTML(diagrams, GIT_HASH), {
108
108
+
const html = MODE === "server"
109
109
+
? generateServerIndexHTML(diagrams.size, GIT_HASH)
110
110
+
: generateLocalIndexHTML(diagrams, GIT_HASH);
111
111
+
return new Response(html, {
44
112
headers: { "Content-Type": "text/html; charset=utf-8" },
45
113
});
46
114
}
···
52
120
},
53
121
});
54
122
55
55
-
// --- MCP Server ---
56
56
-
const server = new McpServer({
57
57
-
name: "traverse",
58
58
-
version: "0.1.0",
59
59
-
});
123
123
+
// --- MCP Server (local mode only) ---
124
124
+
if (MODE === "local") {
125
125
+
const server = new McpServer({
126
126
+
name: "traverse",
127
127
+
version: "0.1.0",
128
128
+
});
60
129
61
61
-
const nodeMetadataSchema = z.object({
62
62
-
title: z.string(),
63
63
-
description: z.string(),
64
64
-
links: z
65
65
-
.array(z.object({ label: z.string(), url: z.string() }))
66
66
-
.optional(),
67
67
-
codeSnippet: z.string().optional(),
68
68
-
});
130
130
+
const nodeMetadataSchema = z.object({
131
131
+
title: z.string(),
132
132
+
description: z.string(),
133
133
+
links: z
134
134
+
.array(z.object({ label: z.string(), url: z.string() }))
135
135
+
.optional(),
136
136
+
codeSnippet: z.string().optional(),
137
137
+
});
69
138
70
70
-
server.registerTool("walkthrough_diagram", {
71
71
-
title: "Walkthrough Diagram",
72
72
-
description: `Render an interactive Mermaid diagram where users can click nodes to see details.
139
139
+
server.registerTool("walkthrough_diagram", {
140
140
+
title: "Walkthrough Diagram",
141
141
+
description: `Render an interactive Mermaid diagram where users can click nodes to see details.
73
142
74
143
BEFORE calling this tool, deeply explore the codebase:
75
144
1. Use search/read tools to find key files, entry points, and architecture patterns
···
83
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
84
153
- Links: include file:line references from your exploration
85
154
- Code snippets: key excerpts (under 15 lines) showing the most important or representative code`,
86
86
-
inputSchema: z.object({
87
87
-
code: z.string(),
88
88
-
summary: z.string(),
89
89
-
nodes: z.record(z.string(), nodeMetadataSchema),
90
90
-
}),
91
91
-
}, async ({ code, summary, nodes }) => {
92
92
-
const id = String(++diagramCounter);
93
93
-
const diagram: WalkthroughDiagram = { code, summary, nodes };
94
94
-
diagrams.set(id, diagram);
155
155
+
inputSchema: z.object({
156
156
+
code: z.string(),
157
157
+
summary: z.string(),
158
158
+
nodes: z.record(z.string(), nodeMetadataSchema),
159
159
+
}),
160
160
+
}, async ({ code, summary, nodes }) => {
161
161
+
const id = generateId();
162
162
+
const diagram: WalkthroughDiagram = {
163
163
+
code,
164
164
+
summary,
165
165
+
nodes,
166
166
+
createdAt: new Date().toISOString(),
167
167
+
};
168
168
+
diagrams.set(id, diagram);
169
169
+
saveDiagram(id, diagram);
95
170
96
96
-
const url = `http://localhost:${PORT}/diagram/${id}`;
171
171
+
const diagramUrl = `http://localhost:${PORT}/diagram/${id}`;
97
172
98
98
-
return {
99
99
-
content: [
100
100
-
{
101
101
-
type: "text",
102
102
-
text: `Interactive diagram ready.\n\nOpen in browser: ${url}\n\nClick nodes in the diagram to explore details about each component.`,
103
103
-
},
104
104
-
],
105
105
-
};
106
106
-
});
173
173
+
return {
174
174
+
content: [
175
175
+
{
176
176
+
type: "text",
177
177
+
text: `Interactive diagram ready.\n\nOpen in browser: ${diagramUrl}\n\nClick nodes in the diagram to explore details about each component.`,
178
178
+
},
179
179
+
],
180
180
+
};
181
181
+
});
107
182
108
108
-
// Connect MCP server to stdio transport
109
109
-
const transport = new StdioServerTransport();
110
110
-
await server.connect(transport);
183
183
+
// Connect MCP server to stdio transport
184
184
+
const transport = new StdioServerTransport();
185
185
+
await server.connect(transport);
186
186
+
}
111
187
112
188
function generate404HTML(title: string, message: string): string {
113
189
return `<!DOCTYPE html>
···
158
234
</html>`;
159
235
}
160
236
161
161
-
function generateIndexHTML(diagrams: Map<string, WalkthroughDiagram>, gitHash: string): string {
237
237
+
function generateLocalIndexHTML(diagrams: Map<string, WalkthroughDiagram>, gitHash: string): string {
162
238
const items = [...diagrams.entries()]
163
239
.map(
164
240
([id, d]) => {
···
167
243
const preview = nodes.slice(0, 4).map(n => escapeHTML(n.title));
168
244
const extra = nodeCount > 4 ? ` <span class="more">+${nodeCount - 4}</span>` : "";
169
245
const tags = preview.map(t => `<span class="tag">${t}</span>`).join("") + extra;
170
170
-
return `<a href="/diagram/${id}" class="diagram-item">
171
171
-
<div class="diagram-header">
172
172
-
<span class="diagram-title">${escapeHTML(d.summary)}</span>
173
173
-
<span class="diagram-meta">${nodeCount} node${nodeCount !== 1 ? "s" : ""}</span>
174
174
-
</div>
175
175
-
<div class="diagram-tags">${tags}</div>
176
176
-
</a>`;
246
246
+
return `<div class="diagram-item-wrap">
247
247
+
<a href="/diagram/${id}" class="diagram-item">
248
248
+
<div class="diagram-header">
249
249
+
<span class="diagram-title">${escapeHTML(d.summary)}</span>
250
250
+
<span class="diagram-meta">${nodeCount} node${nodeCount !== 1 ? "s" : ""}</span>
251
251
+
</div>
252
252
+
<div class="diagram-tags">${tags}</div>
253
253
+
</a>
254
254
+
<button class="delete-btn" onclick="deleteDiagram('${escapeHTML(id)}', this)" title="Delete diagram">
255
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
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
257
+
</svg>
258
258
+
</button>
259
259
+
</div>`;
177
260
},
178
261
)
179
262
.join("\n");
···
238
321
max-width: 520px; margin: 0 auto; padding: 0 20px 48px;
239
322
display: flex; flex-direction: column; gap: 12px;
240
323
}
324
324
+
.diagram-item-wrap {
325
325
+
position: relative;
326
326
+
display: flex;
327
327
+
align-items: stretch;
328
328
+
gap: 0;
329
329
+
}
241
330
.diagram-item {
242
331
display: flex; flex-direction: column; gap: 10px;
243
332
padding: 16px; border: 1px solid var(--border);
244
333
border-radius: 8px; text-decoration: none; color: var(--text);
245
334
transition: border-color 0.15s, background 0.15s;
335
335
+
flex: 1;
336
336
+
min-width: 0;
246
337
}
247
338
.diagram-item:hover {
248
339
border-color: var(--text-muted); background: var(--code-bg);
249
340
}
341
341
+
.delete-btn {
342
342
+
position: absolute;
343
343
+
top: 8px;
344
344
+
right: 8px;
345
345
+
background: none;
346
346
+
border: none;
347
347
+
color: var(--text-muted);
348
348
+
cursor: pointer;
349
349
+
padding: 4px;
350
350
+
border-radius: 4px;
351
351
+
opacity: 0;
352
352
+
transition: opacity 0.15s, color 0.15s, background 0.15s;
353
353
+
display: flex;
354
354
+
align-items: center;
355
355
+
justify-content: center;
356
356
+
}
357
357
+
.diagram-item-wrap:hover .delete-btn {
358
358
+
opacity: 1;
359
359
+
}
360
360
+
.delete-btn:hover {
361
361
+
color: #ef4444;
362
362
+
background: rgba(239, 68, 68, 0.1);
363
363
+
}
250
364
.diagram-header {
251
365
display: flex; align-items: center; justify-content: space-between;
252
366
}
···
299
413
<p>Interactive code walkthrough diagrams</p>
300
414
</div>
301
415
${content}
416
416
+
</div>
417
417
+
<footer class="site-footer">
418
418
+
<span>Made with ❤️ by <a href="https://dunkirk.sh">Kieran Klukas</a></span>
419
419
+
<a class="hash" href="https://github.com/taciturnaxolotl/traverse/commit/${escapeHTML(gitHash)}">${escapeHTML(gitHash)}</a>
420
420
+
</footer>
421
421
+
<script>
422
422
+
async function deleteDiagram(id, btn) {
423
423
+
if (!confirm('Delete this diagram?')) return;
424
424
+
try {
425
425
+
const res = await fetch('/api/diagrams/' + id, { method: 'DELETE' });
426
426
+
if (res.ok) {
427
427
+
const wrap = btn.closest('.diagram-item-wrap');
428
428
+
wrap.remove();
429
429
+
// If no diagrams left, reload to show empty state
430
430
+
if (!document.querySelector('.diagram-item-wrap')) {
431
431
+
location.reload();
432
432
+
}
433
433
+
}
434
434
+
} catch (e) {
435
435
+
console.error('Failed to delete diagram:', e);
436
436
+
}
437
437
+
}
438
438
+
</script>
439
439
+
</body>
440
440
+
</html>`;
441
441
+
}
442
442
+
443
443
+
function generateServerIndexHTML(diagramCount: number, gitHash: string): string {
444
444
+
return `<!DOCTYPE html>
445
445
+
<html lang="en">
446
446
+
<head>
447
447
+
<meta charset="UTF-8" />
448
448
+
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
449
449
+
<title>Traverse</title>
450
450
+
<link rel="icon" href="/icon.svg" type="image/svg+xml" />
451
451
+
<style>
452
452
+
*, *::before, *::after { box-sizing: border-box; margin: 0; padding: 0; }
453
453
+
:root {
454
454
+
--bg: #fafafa; --bg-panel: #ffffff; --border: #e2e2e2;
455
455
+
--text: #1a1a1a; --text-muted: #666; --accent: #2563eb;
456
456
+
--code-bg: #f4f4f5;
457
457
+
}
458
458
+
@media (prefers-color-scheme: dark) {
459
459
+
:root {
460
460
+
--bg: #0a0a0a; --bg-panel: #141414; --border: #262626;
461
461
+
--text: #e5e5e5; --text-muted: #a3a3a3; --accent: #3b82f6;
462
462
+
--code-bg: #1c1c1e;
463
463
+
}
464
464
+
}
465
465
+
body {
466
466
+
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif;
467
467
+
background: var(--bg); color: var(--text); min-height: 100vh;
468
468
+
display: flex; flex-direction: column; align-items: center; justify-content: center;
469
469
+
}
470
470
+
.landing {
471
471
+
max-width: 480px; text-align: center; padding: 40px 20px;
472
472
+
}
473
473
+
.landing h1 {
474
474
+
font-size: 32px; font-weight: 700; margin-bottom: 8px;
475
475
+
}
476
476
+
.landing .tagline {
477
477
+
color: var(--text-muted); font-size: 16px; line-height: 1.5;
478
478
+
margin-bottom: 32px;
479
479
+
}
480
480
+
.stat {
481
481
+
display: inline-flex; align-items: center; gap: 8px;
482
482
+
background: var(--code-bg); border: 1px solid var(--border);
483
483
+
border-radius: 8px; padding: 10px 20px;
484
484
+
font-size: 14px; color: var(--text-muted);
485
485
+
margin-bottom: 32px;
486
486
+
}
487
487
+
.stat strong {
488
488
+
font-size: 20px; font-weight: 700; color: var(--text);
489
489
+
font-variant-numeric: tabular-nums;
490
490
+
}
491
491
+
.github-btn {
492
492
+
display: inline-flex; align-items: center; gap: 8px;
493
493
+
background: var(--text); color: var(--bg);
494
494
+
border: none; border-radius: 8px;
495
495
+
padding: 12px 24px; font-size: 15px; font-weight: 500;
496
496
+
text-decoration: none; transition: opacity 0.15s;
497
497
+
font-family: inherit;
498
498
+
}
499
499
+
.github-btn:hover { opacity: 0.85; }
500
500
+
.github-btn svg { flex-shrink: 0; }
501
501
+
.site-footer {
502
502
+
position: fixed; bottom: 0; left: 0; right: 0;
503
503
+
padding: 20px;
504
504
+
font-size: 13px; color: var(--text-muted);
505
505
+
display: flex; justify-content: space-between; align-items: center;
506
506
+
}
507
507
+
.site-footer a { color: var(--text); text-decoration: none; }
508
508
+
.site-footer a:hover { text-decoration: underline; }
509
509
+
.site-footer .hash {
510
510
+
font-family: "SF Mono", "Fira Code", monospace;
511
511
+
font-size: 11px; opacity: 0.6;
512
512
+
color: var(--text-muted) !important;
513
513
+
}
514
514
+
</style>
515
515
+
</head>
516
516
+
<body>
517
517
+
<div class="landing">
518
518
+
<h1>Traverse</h1>
519
519
+
<p class="tagline">Interactive code walkthrough diagrams, shareable with anyone. Powered by an MCP server you run locally.</p>
520
520
+
<div class="stat">
521
521
+
<strong>${diagramCount}</strong> diagram${diagramCount !== 1 ? "s" : ""} shared
522
522
+
</div>
523
523
+
<br /><br />
524
524
+
<a class="github-btn" href="https://github.com/taciturnaxolotl/traverse">
525
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
526
+
View on GitHub
527
527
+
</a>
302
528
</div>
303
529
<footer class="site-footer">
304
530
<span>Made with ❤️ by <a href="https://dunkirk.sh">Kieran Klukas</a></span>
+62
src/storage.ts
···
1
1
+
import { Database } from "bun:sqlite";
2
2
+
import { mkdirSync, existsSync } from "node:fs";
3
3
+
import { join } from "node:path";
4
4
+
import { homedir } from "node:os";
5
5
+
import type { WalkthroughDiagram } from "./types.ts";
6
6
+
7
7
+
export function getDataDir(): string {
8
8
+
if (process.env.TRAVERSE_DATA_DIR) return process.env.TRAVERSE_DATA_DIR;
9
9
+
10
10
+
const platform = process.platform;
11
11
+
if (platform === "darwin") {
12
12
+
return join(homedir(), "Library", "Application Support", "traverse");
13
13
+
}
14
14
+
// Linux / other: XDG_DATA_HOME or fallback
15
15
+
const xdg = process.env.XDG_DATA_HOME || join(homedir(), ".local", "share");
16
16
+
return join(xdg, "traverse");
17
17
+
}
18
18
+
19
19
+
let db: Database;
20
20
+
21
21
+
export function initDb(): Database {
22
22
+
const dataDir = getDataDir();
23
23
+
if (!existsSync(dataDir)) {
24
24
+
mkdirSync(dataDir, { recursive: true });
25
25
+
}
26
26
+
27
27
+
const dbPath = join(dataDir, "traverse.db");
28
28
+
db = new Database(dbPath);
29
29
+
db.run(`
30
30
+
CREATE TABLE IF NOT EXISTS diagrams (
31
31
+
id TEXT PRIMARY KEY,
32
32
+
summary TEXT,
33
33
+
data TEXT,
34
34
+
created_at TEXT
35
35
+
)
36
36
+
`);
37
37
+
return db;
38
38
+
}
39
39
+
40
40
+
export function loadAllDiagrams(): Map<string, WalkthroughDiagram> {
41
41
+
const rows = db.query("SELECT id, data FROM diagrams").all() as { id: string; data: string }[];
42
42
+
const map = new Map<string, WalkthroughDiagram>();
43
43
+
for (const row of rows) {
44
44
+
map.set(row.id, JSON.parse(row.data));
45
45
+
}
46
46
+
return map;
47
47
+
}
48
48
+
49
49
+
export function saveDiagram(id: string, diagram: WalkthroughDiagram): void {
50
50
+
db.run(
51
51
+
"INSERT OR REPLACE INTO diagrams (id, summary, data, created_at) VALUES (?, ?, ?, ?)",
52
52
+
[id, diagram.summary, JSON.stringify(diagram), new Date().toISOString()]
53
53
+
);
54
54
+
}
55
55
+
56
56
+
export function deleteDiagramFromDb(id: string): void {
57
57
+
db.run("DELETE FROM diagrams WHERE id = ?", [id]);
58
58
+
}
59
59
+
60
60
+
export function generateId(): string {
61
61
+
return crypto.randomUUID();
62
62
+
}
+74
-1
src/template.ts
···
1
1
import type { WalkthroughDiagram } from "./types.ts";
2
2
3
3
-
export function generateViewerHTML(diagram: WalkthroughDiagram, gitHash: string = "dev", projectRoot: string = ""): string {
3
3
+
interface ViewerOptions {
4
4
+
mode?: "local" | "server";
5
5
+
shareServerUrl?: string;
6
6
+
diagramId?: string;
7
7
+
}
8
8
+
9
9
+
export function generateViewerHTML(diagram: WalkthroughDiagram, gitHash: string = "dev", projectRoot: string = "", options: ViewerOptions = {}): string {
4
10
const diagramJSON = JSON.stringify(diagram).replace(/<\//g, "<\\/");
11
11
+
const { mode = "local", shareServerUrl = "", diagramId = "" } = options;
5
12
6
13
return `<!DOCTYPE html>
7
14
<html lang="en">
···
129
136
overflow: hidden;
130
137
text-overflow: ellipsis;
131
138
white-space: nowrap;
139
139
+
}
140
140
+
141
141
+
.share-btn {
142
142
+
margin-left: auto;
143
143
+
flex-shrink: 0;
144
144
+
display: flex;
145
145
+
align-items: center;
146
146
+
gap: 5px;
147
147
+
background: none;
148
148
+
border: 1px solid var(--border);
149
149
+
border-radius: 6px;
150
150
+
padding: 4px 10px;
151
151
+
font-size: 12px;
152
152
+
color: var(--text-muted);
153
153
+
cursor: pointer;
154
154
+
transition: color 0.15s, border-color 0.15s, background 0.15s;
155
155
+
font-family: inherit;
156
156
+
}
157
157
+
.share-btn:hover {
158
158
+
color: var(--text);
159
159
+
border-color: var(--text-muted);
160
160
+
background: var(--code-bg);
161
161
+
}
162
162
+
.share-btn.shared {
163
163
+
color: #16a34a;
164
164
+
border-color: #16a34a;
132
165
}
133
166
134
167
.diagram-wrap {
···
581
614
<span class="breadcrumb-title" id="breadcrumb-title">${escapeHTML(diagram.summary)}</span>
582
615
<span class="sep header-sep">›</span>
583
616
<span class="header-node" id="header-node"></span>
617
617
+
${mode === "local" && shareServerUrl ? `<button class="share-btn" id="share-btn" title="Share diagram">
618
618
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.5" stroke-linecap="round" stroke-linejoin="round">
619
619
+
<path d="M4 12V14H12V12M8 2V10M5 5L8 2L11 5"/>
620
620
+
</svg>
621
621
+
<span>Share</span>
622
622
+
</button>` : ""}
584
623
</div>
585
624
586
625
<div class="diagram-wrap">
···
606
645
607
646
const DIAGRAM_DATA = ${diagramJSON};
608
647
const PROJECT_ROOT = ${JSON.stringify(projectRoot)};
648
648
+
const SHARE_SERVER_URL = ${JSON.stringify(shareServerUrl)};
649
649
+
const DIAGRAM_ID = ${JSON.stringify(diagramId)};
650
650
+
const VIEWER_MODE = ${JSON.stringify(mode)};
609
651
610
652
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>';
611
653
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>';
···
886
928
}
887
929
888
930
init();
931
931
+
932
932
+
// Share button handler
933
933
+
const shareBtn = document.getElementById("share-btn");
934
934
+
if (shareBtn && SHARE_SERVER_URL) {
935
935
+
shareBtn.addEventListener("click", async () => {
936
936
+
shareBtn.disabled = true;
937
937
+
try {
938
938
+
const res = await fetch(SHARE_SERVER_URL + "/api/diagrams", {
939
939
+
method: "POST",
940
940
+
headers: { "Content-Type": "application/json" },
941
941
+
body: JSON.stringify(DIAGRAM_DATA),
942
942
+
});
943
943
+
if (!res.ok) throw new Error("Share failed");
944
944
+
const data = await res.json();
945
945
+
await navigator.clipboard.writeText(data.url);
946
946
+
shareBtn.querySelector("span").textContent = "Copied link!";
947
947
+
shareBtn.classList.add("shared");
948
948
+
setTimeout(() => {
949
949
+
shareBtn.querySelector("span").textContent = "Share";
950
950
+
shareBtn.classList.remove("shared");
951
951
+
}, 2000);
952
952
+
} catch (e) {
953
953
+
shareBtn.querySelector("span").textContent = "Failed";
954
954
+
setTimeout(() => {
955
955
+
shareBtn.querySelector("span").textContent = "Share";
956
956
+
}, 2000);
957
957
+
} finally {
958
958
+
shareBtn.disabled = false;
959
959
+
}
960
960
+
});
961
961
+
}
889
962
</script>
890
963
</body>
891
964
</html>`;
+5
src/types.ts
···
14
14
code: string;
15
15
summary: string;
16
16
nodes: Record<string, NodeMetadata>;
17
17
+
createdAt?: string;
18
18
+
}
19
19
+
20
20
+
export interface TraverseConfig {
21
21
+
shareServerUrl: string;
17
22
}