pi-tau-web-server 1.0.8

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.
Files changed (98) hide show
  1. package/README.md +303 -0
  2. package/bin/auth.js +96 -0
  3. package/bin/config.js +99 -0
  4. package/bin/model-utils.js +134 -0
  5. package/bin/server-main.js +1189 -0
  6. package/bin/sessions.js +492 -0
  7. package/bin/tau.js +8 -0
  8. package/bin/tree.js +248 -0
  9. package/bin/types.js +2 -0
  10. package/package.json +74 -0
  11. package/public/app-main.js +2025 -0
  12. package/public/app-types.js +1 -0
  13. package/public/app.js +1 -0
  14. package/public/command-palette.js +42 -0
  15. package/public/dialogs.js +199 -0
  16. package/public/file-browser.js +196 -0
  17. package/public/icons/apple-touch-icon.png +0 -0
  18. package/public/icons/favicon-16.png +0 -0
  19. package/public/icons/favicon-32.png +0 -0
  20. package/public/icons/tau-192.png +0 -0
  21. package/public/icons/tau-512.png +0 -0
  22. package/public/icons/tau-logo.png +0 -0
  23. package/public/icons/tau-logo.svg +13 -0
  24. package/public/icons/tau-maskable-512.png +0 -0
  25. package/public/icons/tau-new.png +0 -0
  26. package/public/index.html +264 -0
  27. package/public/launcher-panel.js +54 -0
  28. package/public/launcher.js +84 -0
  29. package/public/manifest.json +28 -0
  30. package/public/markdown.js +336 -0
  31. package/public/message-renderer.js +268 -0
  32. package/public/model-picker.js +478 -0
  33. package/public/session-sidebar.js +460 -0
  34. package/public/session-stats-card.js +123 -0
  35. package/public/state.js +81 -0
  36. package/public/style.css +3864 -0
  37. package/public/sw.js +66 -0
  38. package/public/themes.js +72 -0
  39. package/public/tool-card.js +317 -0
  40. package/public/tree-view.js +474 -0
  41. package/public/vendor/katex/fonts/KaTeX_AMS-Regular.woff2 +0 -0
  42. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Bold.woff2 +0 -0
  43. package/public/vendor/katex/fonts/KaTeX_Caligraphic-Regular.woff2 +0 -0
  44. package/public/vendor/katex/fonts/KaTeX_Fraktur-Bold.woff2 +0 -0
  45. package/public/vendor/katex/fonts/KaTeX_Fraktur-Regular.woff2 +0 -0
  46. package/public/vendor/katex/fonts/KaTeX_Main-Bold.woff2 +0 -0
  47. package/public/vendor/katex/fonts/KaTeX_Main-BoldItalic.woff2 +0 -0
  48. package/public/vendor/katex/fonts/KaTeX_Main-Italic.woff2 +0 -0
  49. package/public/vendor/katex/fonts/KaTeX_Main-Regular.woff2 +0 -0
  50. package/public/vendor/katex/fonts/KaTeX_Math-BoldItalic.woff2 +0 -0
  51. package/public/vendor/katex/fonts/KaTeX_Math-Italic.woff2 +0 -0
  52. package/public/vendor/katex/fonts/KaTeX_SansSerif-Bold.woff2 +0 -0
  53. package/public/vendor/katex/fonts/KaTeX_SansSerif-Italic.woff2 +0 -0
  54. package/public/vendor/katex/fonts/KaTeX_SansSerif-Regular.woff2 +0 -0
  55. package/public/vendor/katex/fonts/KaTeX_Script-Regular.woff2 +0 -0
  56. package/public/vendor/katex/fonts/KaTeX_Size1-Regular.woff2 +0 -0
  57. package/public/vendor/katex/fonts/KaTeX_Size2-Regular.woff2 +0 -0
  58. package/public/vendor/katex/fonts/KaTeX_Size3-Regular.woff2 +0 -0
  59. package/public/vendor/katex/fonts/KaTeX_Size4-Regular.woff2 +0 -0
  60. package/public/vendor/katex/fonts/KaTeX_Typewriter-Regular.woff2 +0 -0
  61. package/public/vendor/katex/katex.min.css +1 -0
  62. package/public/vendor/katex/katex.min.js +1 -0
  63. package/public/voice-input.js +74 -0
  64. package/public/websocket-client.js +156 -0
  65. package/scripts/copy-katex.mjs +32 -0
  66. package/src/pi-extension/tau-tree.ts +71 -0
  67. package/src/public/app-main.ts +2190 -0
  68. package/src/public/app-types.ts +96 -0
  69. package/src/public/app.ts +1 -0
  70. package/src/public/command-palette.ts +53 -0
  71. package/src/public/dialogs.ts +251 -0
  72. package/src/public/file-browser.ts +224 -0
  73. package/src/public/launcher-panel.ts +68 -0
  74. package/src/public/launcher.ts +101 -0
  75. package/src/public/legacy-dom.d.ts +29 -0
  76. package/src/public/markdown.ts +372 -0
  77. package/src/public/message-renderer.ts +311 -0
  78. package/src/public/model-picker.ts +500 -0
  79. package/src/public/session-sidebar.ts +522 -0
  80. package/src/public/session-stats-card.ts +176 -0
  81. package/src/public/state.ts +96 -0
  82. package/src/public/sw.ts +79 -0
  83. package/src/public/themes.ts +73 -0
  84. package/src/public/tool-card.ts +375 -0
  85. package/src/public/tree-view.ts +527 -0
  86. package/src/public/voice-input.ts +98 -0
  87. package/src/public/websocket-client.ts +165 -0
  88. package/src/server/auth.ts +88 -0
  89. package/src/server/config.ts +88 -0
  90. package/src/server/model-utils.ts +122 -0
  91. package/src/server/server-main.ts +1004 -0
  92. package/src/server/sessions.ts +481 -0
  93. package/src/server/tau.ts +9 -0
  94. package/src/server/tree.ts +288 -0
  95. package/src/server/types.ts +68 -0
  96. package/tsconfig.json +3 -0
  97. package/tsconfig.public.json +15 -0
  98. package/tsconfig.server.json +16 -0
@@ -0,0 +1,101 @@
1
+ /**
2
+ * Launcher — project directory picker with visual bubbles
3
+ */
4
+
5
+ type LauncherProject = {
6
+ name: string;
7
+ path: string;
8
+ sessionCount?: number;
9
+ lastActive?: number | null;
10
+ active?: boolean;
11
+ };
12
+
13
+ export class Launcher {
14
+ container: HTMLElement;
15
+ onLaunch: (projectPath: string) => void | Promise<void>;
16
+ projects: LauncherProject[];
17
+
18
+ constructor(container: HTMLElement, onLaunch: (projectPath: string) => void | Promise<void>) {
19
+ this.container = container;
20
+ this.onLaunch = onLaunch;
21
+ this.projects = [];
22
+ }
23
+
24
+ async load() {
25
+ this.container.innerHTML = '<div class="launcher-loading">Loading projects…</div>';
26
+ try {
27
+ const res = await fetch('/api/projects');
28
+ const data = await res.json();
29
+ this.projects = data.projects || [];
30
+ this.render();
31
+ } catch (e) {
32
+ this.container.innerHTML = '<div class="launcher-loading">Failed to load projects</div>';
33
+ }
34
+ }
35
+
36
+ render() {
37
+ if (!this.projects.length) {
38
+ this.container.innerHTML = `
39
+ <div class="launcher-empty">
40
+ <p>No projects directory configured.</p>
41
+ <p class="hint">Add <code>"tau": { "projectsDir": "~/Projects" }</code> to <code>~/.pi/agent/settings.json</code></p>
42
+ </div>`;
43
+ return;
44
+ }
45
+
46
+ // Find max session count for relative sizing
47
+ const maxSessions = Math.max(1, ...this.projects.map(p => p.sessionCount || 0));
48
+ const now = Date.now();
49
+
50
+ // Sort: active first, then by recency
51
+ const sorted = [...this.projects].sort((a, b) => {
52
+ if (a.active && !b.active) return -1;
53
+ if (!a.active && b.active) return 1;
54
+ return (b.lastActive || 0) - (a.lastActive || 0);
55
+ });
56
+
57
+ const bubbles = sorted.map(p => {
58
+ // Size: scale between 0.7 and 1.3 based on session count
59
+ const sizeRatio = 0.7 + ((p.sessionCount || 0) / maxSessions) * 0.6;
60
+
61
+ // Recency: how fresh is this project (0 = ancient, 1 = today)
62
+ let freshness = 0;
63
+ if (p.lastActive) {
64
+ const ageMs = now - p.lastActive;
65
+ const ageDays = ageMs / (1000 * 60 * 60 * 24);
66
+ freshness = Math.max(0, 1 - (ageDays / 30)); // fades over 30 days
67
+ }
68
+
69
+ return `
70
+ <button class="launcher-bubble${p.active ? ' active' : ''}"
71
+ data-path="${this.escAttr(p.path)}"
72
+ style="--size: ${sizeRatio}; --freshness: ${freshness.toFixed(2)}"
73
+ title="${this.escAttr(p.path)}${p.active ? ' (running)' : ''}${p.sessionCount ? ` • ${p.sessionCount} session${p.sessionCount !== 1 ? 's' : ''}` : ''}">
74
+ <span class="launcher-bubble-name">${this.escHtml(p.name)}</span>
75
+ ${p.active ? '<span class="launcher-bubble-dot"></span>' : ''}
76
+ </button>`;
77
+ }).join('');
78
+
79
+ this.container.innerHTML = `
80
+ <div class="launcher-content">
81
+ <div class="launcher-title">Projects</div>
82
+ <div class="launcher-grid">${bubbles}</div>
83
+ </div>`;
84
+
85
+ // Bind click handlers
86
+ this.container.querySelectorAll('.launcher-bubble').forEach(btn => {
87
+ btn.addEventListener('click', () => {
88
+ const projectPath = btn.dataset.path;
89
+ if (projectPath) this.onLaunch(projectPath);
90
+ });
91
+ });
92
+ }
93
+
94
+ escHtml(s: string) {
95
+ return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
96
+ }
97
+
98
+ escAttr(s: string) {
99
+ return s.replace(/&/g, '&amp;').replace(/"/g, '&quot;');
100
+ }
101
+ }
@@ -0,0 +1,29 @@
1
+ type SpeechRecognitionConstructor = new () => EventTarget & {
2
+ continuous: boolean;
3
+ interimResults: boolean;
4
+ lang: string;
5
+ start(): void;
6
+ stop(): void;
7
+ };
8
+
9
+ interface Window {
10
+ copyCode?: (button: HTMLElement) => void;
11
+ SpeechRecognition?: SpeechRecognitionConstructor;
12
+ webkitSpeechRecognition?: SpeechRecognitionConstructor;
13
+ }
14
+
15
+ interface Element {
16
+ dataset: DOMStringMap;
17
+ disabled: boolean;
18
+ files: FileList | null;
19
+ focus(options?: FocusOptions): void;
20
+ isContentEditable: boolean;
21
+ onclick: ((this: GlobalEventHandlers, ev: MouseEvent) => unknown) | null;
22
+ placeholder: string;
23
+ select(): void;
24
+ selectionEnd: number | null;
25
+ selectionStart: number | null;
26
+ setSelectionRange(start: number, end: number, direction?: 'forward' | 'backward' | 'none'): void;
27
+ style: CSSStyleDeclaration;
28
+ value: string;
29
+ }
@@ -0,0 +1,372 @@
1
+ /**
2
+ * Lightweight Markdown renderer — no dependencies.
3
+ * Handles: headings, bold, italic, inline code, code blocks with language,
4
+ * links, unordered/ordered lists, blockquotes, horizontal rules, tables,
5
+ * task lists, images, paragraphs.
6
+ */
7
+
8
+ export function renderMarkdown(text: string) {
9
+ if (!text) return '';
10
+
11
+ // Normalize line endings
12
+ text = text.replace(/\r\n/g, '\n');
13
+
14
+ // Extract code blocks first to protect them
15
+ const codeBlocks: { lang: string; code: string }[] = [];
16
+ text = text.replace(/```(\w*)\n([\s\S]*?)```/g, (_, lang: string, code: string) => {
17
+ const idx = codeBlocks.length;
18
+ codeBlocks.push({ lang, code: code.replace(/\n$/, '') });
19
+ return `%%CODEBLOCK_${idx}%%`;
20
+ });
21
+
22
+ // Extract display math after code fences (so $$ inside fences stays literal)
23
+ // but before the line split, since $$...$$ may span multiple lines.
24
+ const mathBlocks: string[] = [];
25
+ text = extractDisplayMath(text, mathBlocks);
26
+
27
+ // Split into lines and process block-level elements
28
+ const lines = text.split('\n');
29
+ let html = '';
30
+ let inList = false;
31
+ let listType = '';
32
+ let inBlockquote = false;
33
+ let blockquoteLines: string[] = [];
34
+
35
+ function flushBlockquote() {
36
+ if (inBlockquote) {
37
+ html += '<blockquote>' + blockquoteLines.map(l => renderInline(l)).join('<br>') + '</blockquote>';
38
+ inBlockquote = false;
39
+ blockquoteLines = [];
40
+ }
41
+ }
42
+
43
+ function flushList() {
44
+ if (inList) { html += `</${listType}>`; inList = false; }
45
+ }
46
+
47
+ // Check if a line is a table separator (e.g. |---|---|)
48
+ function isTableSeparator(line: string) {
49
+ return /^\|?(\s*:?-{3,}:?\s*\|)+\s*:?-{3,}:?\s*\|?\s*$/.test(line);
50
+ }
51
+
52
+ // Check if a line looks like a table row
53
+ function isTableRow(line: string) {
54
+ return line.trim().startsWith('|') && line.trim().endsWith('|');
55
+ }
56
+
57
+ // Parse alignment from separator row
58
+ function parseAlignments(line: string) {
59
+ return line.split('|').filter((c: string) => c.trim()).map((cell: string) => {
60
+ const trimmed = cell.trim();
61
+ if (trimmed.startsWith(':') && trimmed.endsWith(':')) return 'center';
62
+ if (trimmed.endsWith(':')) return 'right';
63
+ return 'left';
64
+ });
65
+ }
66
+
67
+ for (let i = 0; i < lines.length; i++) {
68
+ let line = lines[i];
69
+
70
+ // Code block placeholder
71
+ const codeMatch = line.match(/^%%CODEBLOCK_(\d+)%%$/);
72
+ if (codeMatch) {
73
+ flushList();
74
+ flushBlockquote();
75
+ const block = codeBlocks[parseInt(codeMatch[1])];
76
+ const langLabel = block.lang || 'code';
77
+ html += `<div class="code-block-wrapper">`;
78
+ html += `<div class="code-block-header"><span>${escapeHtml(langLabel)}</span><button class="copy-btn" onclick="copyCode(this)">Copy</button></div>`;
79
+ html += `<pre><code>${escapeHtml(block.code)}</code></pre></div>`;
80
+ continue;
81
+ }
82
+
83
+ // Display math placeholder
84
+ const mathMatch = line.match(/^%%MATHBLOCK(\d+)%%$/);
85
+ if (mathMatch) {
86
+ flushList();
87
+ flushBlockquote();
88
+ html += `<div class="math-block">${mathBlocks[parseInt(mathMatch[1])]}</div>`;
89
+ continue;
90
+ }
91
+
92
+ // Table detection: look ahead for header + separator pattern
93
+ if (isTableRow(line) && i + 1 < lines.length && isTableSeparator(lines[i + 1])) {
94
+ flushList();
95
+ flushBlockquote();
96
+
97
+ const alignments = parseAlignments(lines[i + 1]);
98
+
99
+ // Parse header
100
+ const headerCells = line.split('|').filter((c: string) => c.trim() !== '' || line.trim() === '|');
101
+ // More robust: split between first and last pipe
102
+ const headerRow = line.trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
103
+
104
+ html += '<div class="table-wrapper"><table><thead><tr>';
105
+ headerRow.forEach((cell: string, idx: number) => {
106
+ const align = alignments[idx] || 'left';
107
+ html += `<th style="text-align:${align}">${renderInline(cell.trim())}</th>`;
108
+ });
109
+ html += '</tr></thead><tbody>';
110
+
111
+ // Skip separator
112
+ i += 2;
113
+
114
+ // Parse body rows
115
+ while (i < lines.length && isTableRow(lines[i])) {
116
+ const rowCells = lines[i].trim().replace(/^\|/, '').replace(/\|$/, '').split('|');
117
+ html += '<tr>';
118
+ rowCells.forEach((cell: string, idx: number) => {
119
+ const align = alignments[idx] || 'left';
120
+ html += `<td style="text-align:${align}">${renderInline(cell.trim())}</td>`;
121
+ });
122
+ html += '</tr>';
123
+ i++;
124
+ }
125
+
126
+ html += '</tbody></table></div>';
127
+ i--; // back up since the for loop will increment
128
+ continue;
129
+ }
130
+
131
+ // Horizontal rule
132
+ if (/^(-{3,}|\*{3,}|_{3,})\s*$/.test(line)) {
133
+ flushList();
134
+ flushBlockquote();
135
+ html += '<hr>';
136
+ continue;
137
+ }
138
+
139
+ // Headings
140
+ const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
141
+ if (headingMatch) {
142
+ flushList();
143
+ flushBlockquote();
144
+ const level = headingMatch[1].length;
145
+ html += `<h${level}>${renderInline(headingMatch[2])}</h${level}>`;
146
+ continue;
147
+ }
148
+
149
+ // Blockquote — handle `>` with or without trailing space, and empty `>` lines
150
+ if (/^>\s?/.test(line)) {
151
+ flushList();
152
+ if (!inBlockquote) { inBlockquote = true; blockquoteLines = []; }
153
+ const content = line.replace(/^>\s?/, '');
154
+ if (content === '') {
155
+ // Empty blockquote line acts as paragraph break within quote
156
+ blockquoteLines.push('');
157
+ } else {
158
+ blockquoteLines.push(content);
159
+ }
160
+ continue;
161
+ } else if (inBlockquote) {
162
+ flushBlockquote();
163
+ }
164
+
165
+ // Task list (must check before regular list)
166
+ const taskMatch = line.match(/^(\s*)[*\-+]\s+\[([ xX])\]\s+(.+)$/);
167
+ if (taskMatch) {
168
+ if (!inList || listType !== 'ul') {
169
+ flushList();
170
+ html += '<ul class="task-list">';
171
+ inList = true;
172
+ listType = 'ul';
173
+ }
174
+ const checked = taskMatch[2] !== ' ';
175
+ html += `<li class="task-list-item"><input type="checkbox" disabled ${checked ? 'checked' : ''}> ${renderInline(taskMatch[3])}</li>`;
176
+ continue;
177
+ }
178
+
179
+ // Unordered list
180
+ const ulMatch = line.match(/^(\s*)[*\-+]\s+(.+)$/);
181
+ if (ulMatch) {
182
+ flushBlockquote();
183
+ if (!inList || listType !== 'ul') {
184
+ if (inList) html += `</${listType}>`;
185
+ html += '<ul>';
186
+ inList = true;
187
+ listType = 'ul';
188
+ }
189
+ html += `<li>${renderInline(ulMatch[2])}</li>`;
190
+ continue;
191
+ }
192
+
193
+ // Ordered list
194
+ const olMatch = line.match(/^(\s*)\d+\.\s+(.+)$/);
195
+ if (olMatch) {
196
+ flushBlockquote();
197
+ if (!inList || listType !== 'ol') {
198
+ if (inList) html += `</${listType}>`;
199
+ html += '<ol>';
200
+ inList = true;
201
+ listType = 'ol';
202
+ }
203
+ html += `<li>${renderInline(olMatch[2])}</li>`;
204
+ continue;
205
+ }
206
+
207
+ // Close list if we're out of list items
208
+ flushList();
209
+
210
+ // Empty line
211
+ if (line.trim() === '') {
212
+ continue;
213
+ }
214
+
215
+ // Regular paragraph
216
+ html += `<p>${renderInline(line)}</p>`;
217
+ }
218
+
219
+ // Close any open blocks
220
+ flushList();
221
+ flushBlockquote();
222
+
223
+ return html;
224
+ }
225
+
226
+ /**
227
+ * Lightweight user-message renderer — inline formatting + blockquotes only.
228
+ * Preserves whitespace/newlines for everything else.
229
+ */
230
+ export function renderUserMarkdown(text: string) {
231
+ if (!text) return '';
232
+ text = text.replace(/\r\n/g, '\n');
233
+
234
+ const mathBlocks: string[] = [];
235
+ text = extractDisplayMath(text, mathBlocks);
236
+
237
+ const lines = text.split('\n');
238
+ let html = '';
239
+ let inBlockquote = false;
240
+ let bqLines: string[] = [];
241
+
242
+ function flushBq() {
243
+ if (inBlockquote) {
244
+ html += '<blockquote>' + bqLines.map(l => renderInline(l)).join('<br>') + '</blockquote>';
245
+ inBlockquote = false;
246
+ bqLines = [];
247
+ }
248
+ }
249
+
250
+ for (const line of lines) {
251
+ const mathMatch = line.match(/^%%MATHBLOCK(\d+)%%$/);
252
+ if (mathMatch) {
253
+ flushBq();
254
+ html += `<div class="math-block">${mathBlocks[parseInt(mathMatch[1])]}</div>`;
255
+ continue;
256
+ }
257
+ if (/^>\s?/.test(line)) {
258
+ if (!inBlockquote) { inBlockquote = true; bqLines = []; }
259
+ bqLines.push(line.replace(/^>\s?/, ''));
260
+ continue;
261
+ }
262
+ flushBq();
263
+ html += renderInline(line) + '\n';
264
+ }
265
+ flushBq();
266
+
267
+ return html.replace(/\n$/, '');
268
+ }
269
+
270
+ function renderInline(text: string) {
271
+ // Inline code (must come first to protect content)
272
+ const codeSpans: string[] = [];
273
+ text = text.replace(/`([^`]+)`/g, (_, code: string) => {
274
+ const idx = codeSpans.length;
275
+ codeSpans.push(`<code>${escapeHtml(code)}</code>`);
276
+ return `%%ICODE${idx}%%`;
277
+ });
278
+
279
+ // Inline math — after inline code (so `$x$` stays code) but before the
280
+ // emphasis regexes, which would otherwise mangle math like $a_i * b_i$.
281
+ const mathSpans: string[] = [];
282
+ const putMath = (src: string) => `%%IMATH${mathSpans.push(renderMath(src.trim(), false)) - 1}%%`;
283
+ text = text.replace(/\\\((.+?)\\\)/g, (_, src: string) => putMath(src));
284
+ // Single-$ form with currency heuristics: opening $ not escaped/doubled and
285
+ // not followed by whitespace; closing $ not escaped, not preceded by
286
+ // whitespace, not followed by a digit ("$5 and $10" stays text).
287
+ text = text.replace(/(?<![\\$])\$(?!\s)((?:\\.|[^$])+?)(?<![\s\\])\$(?!\d)/g, (_, src: string) => putMath(src));
288
+
289
+ // Images (before links so ![...](...) isn't caught by link regex)
290
+ text = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" class="inline-image">');
291
+
292
+ // Bold + italic
293
+ text = text.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
294
+
295
+ // Bold
296
+ text = text.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
297
+ text = text.replace(/__(.+?)__/g, '<strong>$1</strong>');
298
+
299
+ // Italic
300
+ text = text.replace(/\*(.+?)\*/g, '<em>$1</em>');
301
+ text = text.replace(/_(.+?)_/g, '<em>$1</em>');
302
+
303
+ // Strikethrough
304
+ text = text.replace(/~~(.+?)~~/g, '<del>$1</del>');
305
+
306
+ // Links
307
+ text = text.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener">$1</a>');
308
+
309
+ // Auto-link bare URLs
310
+ text = text.replace(/(^|[^"'])(https?:\/\/[^\s<]+)/g, '$1<a href="$2" target="_blank" rel="noopener">$2</a>');
311
+
312
+ // Restore inline math and inline code last so the regexes above never see
313
+ // the generated HTML.
314
+ text = text.replace(/%%IMATH(\d+)%%/g, (_, idx: string) => mathSpans[parseInt(idx)]);
315
+ text = text.replace(/%%ICODE(\d+)%%/g, (_, idx: string) => codeSpans[parseInt(idx)]);
316
+
317
+ return text;
318
+ }
319
+
320
+ type Katex = {
321
+ renderToString(tex: string, opts: { displayMode: boolean; throwOnError: boolean }): string;
322
+ };
323
+
324
+ function renderMath(src: string, displayMode: boolean) {
325
+ const katex = (globalThis as { katex?: Katex }).katex;
326
+ if (!katex) return `<code class="math-fallback">${escapeHtml(src)}</code>`;
327
+ try {
328
+ return katex.renderToString(src, { displayMode, throwOnError: false });
329
+ } catch {
330
+ return `<code class="math-fallback">${escapeHtml(src)}</code>`;
331
+ }
332
+ }
333
+
334
+ // Replaces $$...$$ and \[...\] segments with %%MATHBLOCK<N>%% placeholders on
335
+ // their own lines, pushing the KaTeX-rendered HTML into `blocks`. The
336
+ // placeholder deliberately has no underscore (matching %%ICODE0%%) so the
337
+ // italic regex can never split it.
338
+ function extractDisplayMath(text: string, blocks: string[]) {
339
+ const put = (_: string, src: string) =>
340
+ `\n%%MATHBLOCK${blocks.push(renderMath(src.trim(), true)) - 1}%%\n`;
341
+ text = text.replace(/\$\$([\s\S]+?)\$\$/g, put);
342
+ text = text.replace(/\\\[([\s\S]+?)\\\]/g, put);
343
+ return text;
344
+ }
345
+
346
+ function escapeHtml(text: string) {
347
+ return text
348
+ .replace(/&/g, '&amp;')
349
+ .replace(/</g, '&lt;')
350
+ .replace(/>/g, '&gt;')
351
+ .replace(/"/g, '&quot;');
352
+ }
353
+
354
+ // Global copy function for code blocks. Guarded so the module can be imported
355
+ // under node --test, where there is no window.
356
+ if (typeof window !== 'undefined') {
357
+ window.copyCode = function(btn) {
358
+ const wrapper = btn.closest('.code-block-wrapper');
359
+ if (!wrapper) return;
360
+ const codeBlock = wrapper.querySelector('code');
361
+ if (!codeBlock) return;
362
+ const text = codeBlock.textContent || '';
363
+ navigator.clipboard.writeText(text).then(() => {
364
+ btn.textContent = 'Copied!';
365
+ btn.classList.add('copied');
366
+ setTimeout(() => {
367
+ btn.textContent = 'Copy';
368
+ btn.classList.remove('copied');
369
+ }, 2000);
370
+ });
371
+ };
372
+ }