| 1 | /** |
| 2 | * Small, dependency-free Markdown renderer for chat transcripts. |
| 3 | * |
| 4 | * Scope is deliberately a safe subset — fenced code blocks, headings, inline |
| 5 | * code, bold, links (http/https only), lists, and horizontal rules. All text |
| 6 | * is HTML-escaped before any transform runs, so model output can never inject |
| 7 | * markup. Fenced blocks are also returned raw so the webview can offer |
| 8 | * Copy/Insert actions without round-tripping through the DOM. |
| 9 | */ |
| 10 | |
| 11 | export interface RenderedMarkdown { |
| 12 | html: string; |
| 13 | codeBlocks: string[]; |
| 14 | } |
| 15 | |
| 16 | export function renderMarkdown(source: string): RenderedMarkdown { |
| 17 | const codeBlocks: string[] = []; |
| 18 | const lines = source.replace(/\r\n/g, "\n").split("\n"); |
| 19 | const blocks: string[] = []; |
| 20 | |
| 21 | let index = 0; |
| 22 | while (index < lines.length) { |
| 23 | const line = lines[index]; |
| 24 | const fence = matchFence(line); |
| 25 | if (fence !== undefined) { |
| 26 | const code: string[] = []; |
| 27 | index += 1; |
| 28 | while (index < lines.length && matchFence(lines[index]) === undefined) { |
| 29 | code.push(lines[index]); |
| 30 | index += 1; |
| 31 | } |
| 32 | index += 1; // consume the closing fence (or run off the end) |
| 33 | const slot = codeBlocks.length; |
| 34 | codeBlocks.push(code.join("\n")); |
| 35 | blocks.push(renderCodeBlock(slot, fence, code.join("\n"))); |
| 36 | continue; |
| 37 | } |
| 38 | |
| 39 | if (line.trim() === "") { |
| 40 | index += 1; |
| 41 | continue; |
| 42 | } |
| 43 | |
| 44 | if (/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(line)) { |
| 45 | blocks.push(`<hr>`); |
| 46 | index += 1; |
| 47 | continue; |
| 48 | } |
| 49 | |
| 50 | const heading = line.match(/^(#{1,4})\s+(.*)$/); |
| 51 | if (heading) { |
| 52 | const level = String(heading[1].length + 2); // demote: # -> h3 … keeps sidebar scale sane |
| 53 | blocks.push(`<h${level}>${inline(heading[2])}</h${level}>`); |
| 54 | index += 1; |
| 55 | continue; |
| 56 | } |
| 57 | |
| 58 | const bullet = line.match(/^\s*[-*+]\s+(.*)$/); |
| 59 | const numbered = line.match(/^\s*\d+[.)]\s+(.*)$/); |
| 60 | if (bullet || numbered) { |
| 61 | const ordered = Boolean(numbered); |
| 62 | const items: string[] = []; |
| 63 | while (index < lines.length) { |
| 64 | const itemLine = lines[index].match(ordered ? /^\s*\d+[.)]\s+(.*)$/ : /^\s*[-*+]\s+(.*)$/); |
| 65 | if (!itemLine) { |
| 66 | break; |
| 67 | } |
| 68 | items.push(`<li>${inline(itemLine[1])}</li>`); |
| 69 | index += 1; |
| 70 | } |
| 71 | blocks.push(ordered ? `<ol>${items.join("")}</ol>` : `<ul>${items.join("")}</ul>`); |
| 72 | continue; |
| 73 | } |
| 74 | |
| 75 | const paragraph: string[] = []; |
| 76 | while ( |
| 77 | index < lines.length && |
| 78 | lines[index].trim() !== "" && |
| 79 | matchFence(lines[index]) === undefined && |
| 80 | !/^#{1,4}\s/.test(lines[index]) && |
| 81 | !/^\s*[-*+]\s+/.test(lines[index]) && |
| 82 | !/^\s*\d+[.)]\s+/.test(lines[index]) && |
| 83 | !/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/.test(lines[index]) |
| 84 | ) { |
| 85 | paragraph.push(inline(lines[index])); |
| 86 | index += 1; |
| 87 | } |
| 88 | blocks.push(`<p>${paragraph.join("<br>")}</p>`); |
| 89 | } |
| 90 | |
| 91 | return { html: blocks.join("\n"), codeBlocks }; |
| 92 | } |
| 93 | |
| 94 | function matchFence(line: string): string | undefined { |
| 95 | const match = line.match(/^\s*(```|~~~)\s*([\w+#.-]*)\s*$/); |
| 96 | return match ? match[2] : undefined; |
| 97 | } |
| 98 | |
| 99 | function renderCodeBlock(slot: number, language: string, code: string): string { |
| 100 | const label = language || "code"; |
| 101 | const firstLine = code.split("\n", 1)[0] ?? ""; |
| 102 | return ( |
| 103 | `<div class="codeblock" data-cb="${slot}">` + |
| 104 | `<div class="codeblock-bar"><span class="codeblock-lang">${escapeHtml(label)}</span>` + |
| 105 | `<span class="codeblock-actions">` + |
| 106 | `<button type="button" class="cb-copy" data-cb="${slot}" title="Copy code">Copy</button>` + |
| 107 | `<button type="button" class="cb-insert" data-cb="${slot}" title="Insert at cursor">Insert</button>` + |
| 108 | `</span></div>` + |
| 109 | `<pre><code title="${escapeHtml(firstLine.slice(0, 80))}">${escapeHtml(code)}</code></pre>` + |
| 110 | `</div>` |
| 111 | ); |
| 112 | } |
| 113 | |
| 114 | function inline(text: string): string { |
| 115 | let result = escapeHtml(text); |
| 116 | const spans: string[] = []; |
| 117 | result = result.replace(/`([^`]+)`/g, (_match, code: string) => { |
| 118 | spans.push(`<code>${code}</code>`); |
| 119 | return `\u0000S${spans.length - 1}\u0000`; |
| 120 | }); |
| 121 | result = result.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>"); |
| 122 | result = result.replace(/\[([^\]]+)\]\((https?:\/\/[^)\s]+)\)/g, '<a href="$2">$1</a>'); |
| 123 | result = result.replace(/\u0000S(\d+)\u0000/g, (_match, slot: string) => spans[Number(slot)] ?? ""); |
| 124 | return result; |
| 125 | } |
| 126 | |
| 127 | export function escapeHtml(value: string): string { |
| 128 | return value |
| 129 | .replace(/&/g, "&") |
| 130 | .replace(/</g, "<") |
| 131 | .replace(/>/g, ">") |
| 132 | .replace(/"/g, """) |
| 133 | .replace(/'/g, "'"); |
| 134 | } |
| 135 |