返回 CodeWhale
markdown.ts
根目录 / extensions / vscode / src / markdown.ts
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, "&amp;")
130 .replace(/</g, "&lt;")
131 .replace(/>/g, "&gt;")
132 .replace(/"/g, "&quot;")
133 .replace(/'/g, "&#39;");
134 }
135
135 lines TYPESCRIPT