@creately/rdm-canvas 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.
@@ -0,0 +1,492 @@
1
+ /**
2
+ * rdm-canvas Server — Bun HTTP + WebSocket with bidirectional sync
3
+ *
4
+ * WebSocket Protocol:
5
+ * Server → Client: { type: "rdm", text: "<full rdm text>" }
6
+ * Server → Client: { type: "error", errors: [{code, message, line?, column?}] }
7
+ * Client → Server: { type: "rdm-update", text: "<full rdm text>" }
8
+ * Client → Server: { type: "rdm-patch", op: PatchOperation }
9
+ */
10
+
11
+ import { FileWatcher } from './fileWatcher';
12
+ import { readFileSync, existsSync } from 'fs';
13
+ import { resolve, dirname, join } from 'path';
14
+ import type { Server, ServerWebSocket } from 'bun';
15
+ import { parse } from '@creately/rdm-core/parser';
16
+ import { patchRdm } from '@creately/rdm-core/patcher';
17
+ import type { PatchOperation } from '@creately/rdm-core/patcher';
18
+ import { scanRdmFiles } from './fileScanner';
19
+ import { renderIndexPage } from './indexPage';
20
+
21
+ interface ServerOptions {
22
+ filePath: string;
23
+ port: number;
24
+ }
25
+
26
+ interface WsMessage {
27
+ type: string;
28
+ text?: string;
29
+ op?: PatchOperation;
30
+ errors?: Array<{ code: string; message: string; line?: number; column?: number }>;
31
+ }
32
+
33
+ export function startServer(options: ServerOptions): Server {
34
+ const { filePath, port } = options;
35
+ const absolutePath = resolve(filePath);
36
+ const clients = new Set<ServerWebSocket<unknown>>();
37
+
38
+ // Read initial content
39
+ let currentContent = readFileSync(absolutePath, 'utf-8');
40
+
41
+ // Set up file watcher
42
+ const watcher = new FileWatcher({
43
+ filePath: absolutePath,
44
+ debounceMs: 100,
45
+ onChange: (content: string) => {
46
+ currentContent = content;
47
+ broadcast({ type: 'rdm', text: content });
48
+ console.log(`[file→browser] Updated (${content.length} bytes)`);
49
+ },
50
+ });
51
+ watcher.start();
52
+
53
+ function broadcast(msg: WsMessage): void {
54
+ const data = JSON.stringify(msg);
55
+ for (const ws of clients) {
56
+ try {
57
+ ws.send(data);
58
+ } catch {
59
+ clients.delete(ws);
60
+ }
61
+ }
62
+ }
63
+
64
+ // Resolve static HTML path
65
+ const staticDir = resolve(dirname(new URL(import.meta.url).pathname), '../static');
66
+
67
+ // Look for the viewer bundle in several locations
68
+ const viewerBundlePaths = [
69
+ resolve(dirname(absolutePath), 'node_modules/rdm-canvas/static/rdm-viewer.iife.js'),
70
+ resolve(process.cwd(), 'dist/rdm-viewer/rdm-viewer.iife.js'),
71
+ resolve(dirname(new URL(import.meta.url).pathname), '../static/rdm-viewer.iife.js'),
72
+ // Walk up from the watched file looking for dist/rdm-viewer/
73
+ ...findAncestorPaths(absolutePath, 'dist/rdm-viewer/rdm-viewer.iife.js'),
74
+ ];
75
+
76
+ const server = Bun.serve({
77
+ port,
78
+ fetch(req, server) {
79
+ const url = new URL(req.url);
80
+
81
+ // WebSocket upgrade
82
+ if (url.pathname === '/ws') {
83
+ const upgraded = server.upgrade(req);
84
+ if (!upgraded) {
85
+ return new Response('WebSocket upgrade failed', { status: 400 });
86
+ }
87
+ return undefined;
88
+ }
89
+
90
+ // Serve the viewer bundle
91
+ if (url.pathname === '/rdm-viewer.iife.js') {
92
+ for (const bundlePath of viewerBundlePaths) {
93
+ if (existsSync(bundlePath)) {
94
+ return new Response(Bun.file(bundlePath), {
95
+ headers: {
96
+ 'Content-Type': 'application/javascript',
97
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
98
+ },
99
+ });
100
+ }
101
+ }
102
+ return new Response('Viewer bundle not found. Run: bun run build:rdm-viewer', {
103
+ status: 404,
104
+ });
105
+ }
106
+
107
+ // Serve static HTML
108
+ if (url.pathname === '/' || url.pathname === '/index.html') {
109
+ const htmlPath = resolve(staticDir, 'index.html');
110
+ if (existsSync(htmlPath)) {
111
+ return new Response(Bun.file(htmlPath), {
112
+ headers: { 'Content-Type': 'text/html' },
113
+ });
114
+ }
115
+ // Inline fallback HTML
116
+ return new Response(getInlineHtml(), {
117
+ headers: { 'Content-Type': 'text/html' },
118
+ });
119
+ }
120
+
121
+ return new Response('Not found', { status: 404 });
122
+ },
123
+ websocket: {
124
+ open(ws) {
125
+ clients.add(ws);
126
+ // Send current content on connect
127
+ ws.send(JSON.stringify({ type: 'rdm', text: currentContent }));
128
+ console.log(`[ws] Client connected (${clients.size} total)`);
129
+ },
130
+ message(ws, message) {
131
+ try {
132
+ const msg: WsMessage = JSON.parse(String(message));
133
+ if (msg.type === 'rdm-update' && msg.text) {
134
+ // Validate before writing — reject if the serialized output doesn't parse
135
+ const parseResult = parse(msg.text);
136
+ if (parseResult.errors && parseResult.errors.length > 0) {
137
+ console.log(`[browser→file] REJECTED: ${parseResult.errors.length} parse error(s) — not writing to disk`);
138
+ return;
139
+ }
140
+ currentContent = msg.text;
141
+ // Write back to file (watcher suppressed during write)
142
+ watcher.writeBack(msg.text).then(() => {
143
+ console.log(`[browser→file] Updated (${msg.text!.length} bytes)`);
144
+ });
145
+ // Broadcast to other clients
146
+ for (const client of clients) {
147
+ if (client !== ws) {
148
+ client.send(JSON.stringify({ type: 'rdm', text: msg.text }));
149
+ }
150
+ }
151
+ } else if (msg.type === 'rdm-patch' && msg.op) {
152
+ const result = patchRdm(currentContent, msg.op);
153
+ if (!result.success) {
154
+ ws.send(JSON.stringify({ type: 'error', errors: (result.errors || []).map(m => ({ code: 'PATCH_FAILED', message: m })) }));
155
+ return;
156
+ }
157
+ currentContent = result.text;
158
+ watcher.writeBack(result.text).then(() => {
159
+ console.log(`[patch→file] Updated (${result.text.length} bytes)`);
160
+ });
161
+ // Broadcast to other clients (not the sender — they already have the position)
162
+ for (const client of clients) {
163
+ if (client !== ws) {
164
+ try { client.send(JSON.stringify({ type: 'rdm', text: result.text })); } catch { clients.delete(client); }
165
+ }
166
+ }
167
+ }
168
+ } catch {
169
+ // Invalid JSON, ignore
170
+ }
171
+ },
172
+ close(ws) {
173
+ clients.delete(ws);
174
+ console.log(`[ws] Client disconnected (${clients.size} total)`);
175
+ },
176
+ },
177
+ });
178
+
179
+ return server;
180
+ }
181
+
182
+ // --- Directory Mode ---
183
+
184
+ interface DirectoryServerOptions {
185
+ rootDir: string;
186
+ port: number;
187
+ title?: string;
188
+ }
189
+
190
+ interface FileSession {
191
+ watcher: FileWatcher;
192
+ clients: Set<ServerWebSocket<{ filePath: string }>>;
193
+ content: string;
194
+ }
195
+
196
+ export function startDirectoryServer(options: DirectoryServerOptions): Server {
197
+ const { rootDir, port, title = 'rdm-canvas' } = options;
198
+ const absoluteRoot = resolve(rootDir);
199
+ const fileSessions = new Map<string, FileSession>();
200
+
201
+ const staticDir = resolve(dirname(new URL(import.meta.url).pathname), '../static');
202
+
203
+ const viewerBundlePaths = [
204
+ resolve(process.cwd(), 'dist/rdm-viewer/rdm-viewer.iife.js'),
205
+ resolve(dirname(new URL(import.meta.url).pathname), '../static/rdm-viewer.iife.js'),
206
+ ...findAncestorPaths(absoluteRoot, 'dist/rdm-viewer/rdm-viewer.iife.js'),
207
+ ];
208
+
209
+ function getOrCreateSession(relPath: string): FileSession | null {
210
+ if (fileSessions.has(relPath)) return fileSessions.get(relPath)!;
211
+
212
+ const absPath = resolve(absoluteRoot, relPath);
213
+ if (!existsSync(absPath)) return null;
214
+
215
+ const content = readFileSync(absPath, 'utf-8');
216
+ const clients = new Set<ServerWebSocket<{ filePath: string }>>();
217
+
218
+ const watcher = new FileWatcher({
219
+ filePath: absPath,
220
+ debounceMs: 100,
221
+ onChange: (newContent: string) => {
222
+ session.content = newContent;
223
+ const data = JSON.stringify({ type: 'rdm', text: newContent });
224
+ for (const ws of clients) {
225
+ try { ws.send(data); } catch { clients.delete(ws); }
226
+ }
227
+ console.log(`[file→browser] ${relPath} (${newContent.length} bytes)`);
228
+ },
229
+ });
230
+ watcher.start();
231
+
232
+ const session: FileSession = { watcher, clients, content };
233
+ fileSessions.set(relPath, session);
234
+ return session;
235
+ }
236
+
237
+ function cleanupSession(relPath: string): void {
238
+ const session = fileSessions.get(relPath);
239
+ if (session && session.clients.size === 0) {
240
+ session.watcher.stop();
241
+ fileSessions.delete(relPath);
242
+ console.log(`[watch] Stopped watching ${relPath}`);
243
+ }
244
+ }
245
+
246
+ function serveViewerPage(filePath: string): Response {
247
+ const htmlPath = resolve(staticDir, 'index.html');
248
+ if (existsSync(htmlPath)) {
249
+ let html = readFileSync(htmlPath, 'utf-8');
250
+ // Inject back-link and file query param
251
+ const backLink = `<a href="/" style="position:fixed;top:12px;left:12px;z-index:9999;font-family:system-ui,sans-serif;font-size:13px;color:#64748b;text-decoration:none;background:rgba(255,255,255,0.9);padding:4px 10px;border-radius:6px;border:1px solid #e2e8f0;">\u2190 Index</a>`;
252
+ html = html.replace('<body>', `<body>${backLink}`);
253
+ // Inject file path as a global so the WS script can use it
254
+ const script = `<script>window.__RDM_FILE__ = ${JSON.stringify(filePath)};</script>`;
255
+ html = html.replace('</head>', `${script}</head>`);
256
+ return new Response(html, { headers: { 'Content-Type': 'text/html' } });
257
+ }
258
+ return new Response('Viewer HTML not found', { status: 404 });
259
+ }
260
+
261
+ const server = Bun.serve<{ filePath: string }>({
262
+ port,
263
+ fetch(req, server) {
264
+ const url = new URL(req.url);
265
+
266
+ // Index page
267
+ if (url.pathname === '/') {
268
+ const files = scanRdmFiles(absoluteRoot);
269
+ return new Response(renderIndexPage(files, title), {
270
+ headers: { 'Content-Type': 'text/html' },
271
+ });
272
+ }
273
+
274
+ // API: file list
275
+ if (url.pathname === '/api/files') {
276
+ const files = scanRdmFiles(absoluteRoot);
277
+ return new Response(JSON.stringify(files), {
278
+ headers: { 'Content-Type': 'application/json' },
279
+ });
280
+ }
281
+
282
+ // Viewer page
283
+ if (url.pathname.startsWith('/view/')) {
284
+ const filePath = decodeURIComponent(url.pathname.slice(6));
285
+ const absPath = resolve(absoluteRoot, filePath);
286
+ if (!absPath.startsWith(absoluteRoot) || !existsSync(absPath)) {
287
+ return new Response('File not found', { status: 404 });
288
+ }
289
+ return serveViewerPage(filePath);
290
+ }
291
+
292
+ // WebSocket upgrade
293
+ if (url.pathname === '/ws') {
294
+ const filePath = url.searchParams.get('file') || '';
295
+ if (!filePath) return new Response('Missing file param', { status: 400 });
296
+ const upgraded = server.upgrade(req, { data: { filePath } });
297
+ if (!upgraded) return new Response('WebSocket upgrade failed', { status: 400 });
298
+ return undefined;
299
+ }
300
+
301
+ // Viewer bundle
302
+ if (url.pathname === '/rdm-viewer.iife.js') {
303
+ for (const bundlePath of viewerBundlePaths) {
304
+ if (existsSync(bundlePath)) {
305
+ return new Response(Bun.file(bundlePath), {
306
+ headers: {
307
+ 'Content-Type': 'application/javascript',
308
+ 'Cache-Control': 'no-cache, no-store, must-revalidate',
309
+ },
310
+ });
311
+ }
312
+ }
313
+ return new Response('Viewer bundle not found. Run: bun run build:rdm-viewer', { status: 404 });
314
+ }
315
+
316
+ return new Response('Not found', { status: 404 });
317
+ },
318
+ websocket: {
319
+ open(ws) {
320
+ const { filePath } = ws.data;
321
+ const session = getOrCreateSession(filePath);
322
+ if (!session) {
323
+ ws.close(1008, 'File not found');
324
+ return;
325
+ }
326
+ session.clients.add(ws);
327
+ ws.send(JSON.stringify({ type: 'rdm', text: session.content }));
328
+ console.log(`[ws] ${filePath}: client connected (${session.clients.size} total)`);
329
+ },
330
+ message(ws, message) {
331
+ const { filePath } = ws.data;
332
+ const session = fileSessions.get(filePath);
333
+ if (!session) return;
334
+ try {
335
+ const msg: WsMessage = JSON.parse(String(message));
336
+ if (msg.type === 'rdm-update' && msg.text) {
337
+ const parseResult = parse(msg.text);
338
+ if (parseResult.errors && parseResult.errors.length > 0) {
339
+ console.log(`[browser→file] ${filePath}: REJECTED (${parseResult.errors.length} errors)`);
340
+ return;
341
+ }
342
+ session.content = msg.text;
343
+ session.watcher.writeBack(msg.text).then(() => {
344
+ console.log(`[browser→file] ${filePath}: Updated (${msg.text!.length} bytes)`);
345
+ });
346
+ for (const client of session.clients) {
347
+ if (client !== ws) {
348
+ client.send(JSON.stringify({ type: 'rdm', text: msg.text }));
349
+ }
350
+ }
351
+ } else if (msg.type === 'rdm-patch' && msg.op) {
352
+ const result = patchRdm(session.content, msg.op);
353
+ if (!result.success) {
354
+ ws.send(JSON.stringify({ type: 'error', errors: (result.errors || []).map(m => ({ code: 'PATCH_FAILED', message: m })) }));
355
+ return;
356
+ }
357
+ session.content = result.text;
358
+ session.watcher.writeBack(result.text).then(() => {
359
+ console.log(`[patch→file] ${filePath}: Updated (${result.text.length} bytes)`);
360
+ });
361
+ // Broadcast to other clients (not the sender)
362
+ const data = JSON.stringify({ type: 'rdm', text: result.text });
363
+ for (const client of session.clients) {
364
+ if (client !== ws) {
365
+ try { client.send(data); } catch { session.clients.delete(client); }
366
+ }
367
+ }
368
+ }
369
+ } catch {
370
+ // Invalid JSON
371
+ }
372
+ },
373
+ close(ws) {
374
+ const { filePath } = ws.data;
375
+ const session = fileSessions.get(filePath);
376
+ if (session) {
377
+ session.clients.delete(ws);
378
+ console.log(`[ws] ${filePath}: client disconnected (${session.clients.size} total)`);
379
+ cleanupSession(filePath);
380
+ }
381
+ },
382
+ },
383
+ });
384
+
385
+ return server;
386
+ }
387
+
388
+ function findAncestorPaths(fromPath: string, target: string): string[] {
389
+ const paths: string[] = [];
390
+ let dir = dirname(fromPath);
391
+ for (let i = 0; i < 10; i++) {
392
+ const candidate = resolve(dir, target);
393
+ paths.push(candidate);
394
+ const parent = dirname(dir);
395
+ if (parent === dir) break;
396
+ dir = parent;
397
+ }
398
+ return paths;
399
+ }
400
+
401
+ function getInlineHtml(): string {
402
+ return `<!DOCTYPE html>
403
+ <html lang="en">
404
+ <head>
405
+ <meta charset="UTF-8" />
406
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
407
+ <title>rdm-canvas</title>
408
+ <style>
409
+ * { margin: 0; padding: 0; box-sizing: border-box; }
410
+ html, body { width: 100%; height: 100%; overflow: hidden; }
411
+ #rdm-root { width: 100%; height: 100%; }
412
+ .ws-status {
413
+ position: fixed; top: 8px; left: 8px; z-index: 1000;
414
+ padding: 4px 10px; border-radius: 4px; font-size: 12px;
415
+ font-family: system-ui, sans-serif;
416
+ background: rgba(0,0,0,0.7); color: #4ade80;
417
+ }
418
+ .ws-status.disconnected { color: #f87171; }
419
+ </style>
420
+ </head>
421
+ <body>
422
+ <div id="ws-status" class="ws-status">Connecting...</div>
423
+ <div id="rdm-root"></div>
424
+ <script src="/rdm-viewer.iife.js"></script>
425
+ <script>
426
+ let viewer = null;
427
+ let ws = null;
428
+ let reconnectTimer = null;
429
+ const statusEl = document.getElementById('ws-status');
430
+
431
+ function connect() {
432
+ const protocol = location.protocol === 'https:' ? 'wss:' : 'ws:';
433
+ ws = new WebSocket(protocol + '//' + location.host + '/ws');
434
+
435
+ ws.onopen = () => {
436
+ statusEl.textContent = 'Connected';
437
+ statusEl.className = 'ws-status';
438
+ if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
439
+ };
440
+
441
+ ws.onmessage = (event) => {
442
+ try {
443
+ const msg = JSON.parse(event.data);
444
+ if (msg.type === 'rdm') {
445
+ if (!viewer) {
446
+ viewer = window._RdmViewer.mount(document.getElementById('rdm-root'), {
447
+ rdmText: msg.text,
448
+ editable: true,
449
+ onPatch: (op) => {
450
+ if (ws && ws.readyState === WebSocket.OPEN) {
451
+ ws.send(JSON.stringify({ type: 'rdm-patch', op: op }));
452
+ }
453
+ },
454
+ onRdmChange: (rdmText) => {
455
+ if (ws && ws.readyState === WebSocket.OPEN) {
456
+ ws.send(JSON.stringify({ type: 'rdm-update', text: rdmText }));
457
+ }
458
+ }
459
+ });
460
+ } else {
461
+ viewer.updateRdm(msg.text);
462
+ }
463
+ }
464
+ } catch (e) {
465
+ console.error('WS message error:', e);
466
+ }
467
+ };
468
+
469
+ ws.onclose = () => {
470
+ statusEl.textContent = 'Disconnected - reconnecting...';
471
+ statusEl.className = 'ws-status disconnected';
472
+ reconnectTimer = setTimeout(connect, 2000);
473
+ };
474
+
475
+ ws.onerror = () => ws.close();
476
+ }
477
+
478
+ // Resolve double-nesting from Vite IIFE: window.RdmViewer.RdmViewer.mount or window.RdmViewer.mount
479
+ var Viewer = window.RdmViewer;
480
+ if (Viewer && Viewer.RdmViewer) Viewer = Viewer.RdmViewer;
481
+ if (Viewer && Viewer.default) Viewer = Viewer.default;
482
+ if (Viewer && Viewer.mount) {
483
+ window._RdmViewer = Viewer;
484
+ connect();
485
+ } else {
486
+ statusEl.textContent = 'Error: Viewer bundle not loaded';
487
+ statusEl.className = 'ws-status disconnected';
488
+ }
489
+ </script>
490
+ </body>
491
+ </html>`;
492
+ }
@@ -0,0 +1,108 @@
1
+ /**
2
+ * Index Page — Server-rendered HTML for directory mode file listing
3
+ */
4
+
5
+ import type { RdmFileInfo } from './fileScanner';
6
+
7
+ export function renderIndexPage(files: RdmFileInfo[], title: string): string {
8
+ const grouped = new Map<string, RdmFileInfo[]>();
9
+ for (const file of files) {
10
+ const dir = file.directory || '(root)';
11
+ if (!grouped.has(dir)) grouped.set(dir, []);
12
+ grouped.get(dir)!.push(file);
13
+ }
14
+
15
+ const sections = Array.from(grouped.entries())
16
+ .map(([dir, dirFiles]) => {
17
+ const cards = dirFiles
18
+ .map((f) => {
19
+ const href = `/view/${encodeURIComponent(f.relativePath)}`;
20
+ const typeLabel = f.type
21
+ ? `<span class="badge badge-${f.type}">${f.type}</span>`
22
+ : '';
23
+ const sizeKb = (f.size / 1024).toFixed(1);
24
+ return `<a class="card" href="${href}">
25
+ <div class="card-title">${escapeHtml(f.title || f.name)}</div>
26
+ <div class="card-meta">
27
+ ${typeLabel}
28
+ <span class="size">${sizeKb} KB</span>
29
+ </div>
30
+ ${f.title ? `<div class="card-filename">${escapeHtml(f.name)}</div>` : ''}
31
+ </a>`;
32
+ })
33
+ .join('\n');
34
+
35
+ const dirLabel = dir === '(root)' ? 'Files' : dir;
36
+ return `<section>
37
+ <h2>${escapeHtml(dirLabel)}<span class="count">${dirFiles.length}</span></h2>
38
+ <div class="grid">${cards}</div>
39
+ </section>`;
40
+ })
41
+ .join('\n');
42
+
43
+ return `<!DOCTYPE html>
44
+ <html lang="en">
45
+ <head>
46
+ <meta charset="UTF-8" />
47
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
48
+ <title>${escapeHtml(title)}</title>
49
+ <style>
50
+ * { margin: 0; padding: 0; box-sizing: border-box; }
51
+ body {
52
+ font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
53
+ background: #f8f9fa; color: #1a1a2e; padding: 32px;
54
+ max-width: 960px; margin: 0 auto;
55
+ }
56
+ h1 { font-size: 24px; font-weight: 600; margin-bottom: 8px; }
57
+ .subtitle { color: #666; font-size: 14px; margin-bottom: 32px; }
58
+ section { margin-bottom: 32px; }
59
+ h2 {
60
+ font-size: 14px; font-weight: 600; text-transform: uppercase;
61
+ letter-spacing: 0.05em; color: #666; margin-bottom: 12px;
62
+ display: flex; align-items: center; gap: 8px;
63
+ }
64
+ .count {
65
+ background: #e2e8f0; color: #475569; border-radius: 10px;
66
+ padding: 1px 8px; font-size: 12px; font-weight: 500;
67
+ }
68
+ .grid {
69
+ display: grid; grid-template-columns: repeat(auto-fill, minmax(260px, 1fr));
70
+ gap: 12px;
71
+ }
72
+ .card {
73
+ display: block; background: #fff; border: 1px solid #e2e8f0;
74
+ border-radius: 8px; padding: 16px; text-decoration: none; color: inherit;
75
+ transition: border-color 0.15s, box-shadow 0.15s;
76
+ }
77
+ .card:hover {
78
+ border-color: #3b82f6; box-shadow: 0 2px 8px rgba(59, 130, 246, 0.1);
79
+ }
80
+ .card-title { font-size: 14px; font-weight: 500; margin-bottom: 8px; }
81
+ .card-meta { display: flex; align-items: center; gap: 8px; font-size: 12px; }
82
+ .card-filename { font-size: 11px; color: #94a3b8; margin-top: 6px; }
83
+ .badge {
84
+ padding: 2px 8px; border-radius: 4px; font-size: 11px;
85
+ font-weight: 500; text-transform: uppercase; letter-spacing: 0.03em;
86
+ }
87
+ .badge-genogram { background: #fef3c7; color: #92400e; }
88
+ .badge-orgchart { background: #dbeafe; color: #1e40af; }
89
+ .badge-process { background: #d1fae5; color: #065f46; }
90
+ .badge-bpmn { background: #ede9fe; color: #5b21b6; }
91
+ .size { color: #94a3b8; }
92
+ </style>
93
+ </head>
94
+ <body>
95
+ <h1>${escapeHtml(title)}</h1>
96
+ <p class="subtitle">${files.length} diagram${files.length !== 1 ? 's' : ''}</p>
97
+ ${sections}
98
+ </body>
99
+ </html>`;
100
+ }
101
+
102
+ function escapeHtml(str: string): string {
103
+ return str
104
+ .replace(/&/g, '&amp;')
105
+ .replace(/</g, '&lt;')
106
+ .replace(/>/g, '&gt;')
107
+ .replace(/"/g, '&quot;');
108
+ }