1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
|
/**
* Pure Markdown → sanitized-HTML renderer for assistant messages.
*
* Mirrors old Dispatch's stack (marked + marked-highlight + highlight.js +
* DOMPurify; GFM + line breaks; streaming delimiter-closing), but kept fully
* SYNCHRONOUS and pure: `input → output`, no effects, no `$effect`. Languages
* are a fixed "hot set" registered at module load (no lazy dynamic import), so a
* single `renderMarkdown(text)` call is deterministic and unit-testable.
*
* The only ambient dependency is DOMPurify, which sanitizes against the DOM —
* present in the browser and in the jsdom test env.
*/
import DOMPurify from "dompurify";
import type { LanguageFn } from "highlight.js";
import hljs from "highlight.js/lib/core";
import bash from "highlight.js/lib/languages/bash";
import c from "highlight.js/lib/languages/c";
import cpp from "highlight.js/lib/languages/cpp";
import csharp from "highlight.js/lib/languages/csharp";
import css from "highlight.js/lib/languages/css";
import go from "highlight.js/lib/languages/go";
import java from "highlight.js/lib/languages/java";
import javascript from "highlight.js/lib/languages/javascript";
import json from "highlight.js/lib/languages/json";
import markdownLang from "highlight.js/lib/languages/markdown";
import php from "highlight.js/lib/languages/php";
import plaintext from "highlight.js/lib/languages/plaintext";
import python from "highlight.js/lib/languages/python";
import ruby from "highlight.js/lib/languages/ruby";
import rust from "highlight.js/lib/languages/rust";
import shell from "highlight.js/lib/languages/shell";
import sql from "highlight.js/lib/languages/sql";
import typescript from "highlight.js/lib/languages/typescript";
import xml from "highlight.js/lib/languages/xml";
import yaml from "highlight.js/lib/languages/yaml";
import { Marked } from "marked";
import { markedHighlight } from "marked-highlight";
// Hot set: registered eagerly so common code blocks highlight on first paint.
const HOT_LANGUAGES: Record<string, LanguageFn> = {
bash,
c,
cpp,
csharp,
css,
go,
java,
javascript,
json,
markdown: markdownLang,
php,
plaintext,
python,
ruby,
rust,
shell,
sql,
typescript,
xml,
yaml,
};
for (const [name, lang] of Object.entries(HOT_LANGUAGES)) {
hljs.registerLanguage(name, lang);
}
// Normalize common fence aliases to canonical highlight.js names.
const ALIASES: Record<string, string> = {
js: "javascript",
jsx: "javascript",
mjs: "javascript",
cjs: "javascript",
ts: "typescript",
tsx: "typescript",
py: "python",
py3: "python",
rb: "ruby",
sh: "bash",
zsh: "bash",
yml: "yaml",
"c++": "cpp",
cxx: "cpp",
"c#": "csharp",
cs: "csharp",
htm: "xml",
html: "xml",
svg: "xml",
md: "markdown",
mdx: "markdown",
golang: "go",
rs: "rust",
};
function normalizeLang(lang: string): string {
const lower = lang.toLowerCase().trim();
return ALIASES[lower] ?? lower;
}
function escapeHtml(s: string): string {
return s
.replace(/&/g, "&")
.replace(/</g, "<")
.replace(/>/g, ">")
.replace(/"/g, """)
.replace(/'/g, "'");
}
const md = new Marked(
markedHighlight({
emptyLangClass: "hljs",
langPrefix: "hljs language-",
highlight(code: string, lang: string): string {
if (!lang) return escapeHtml(code);
const name = normalizeLang(lang);
if (!hljs.getLanguage(name)) return escapeHtml(code);
try {
return hljs.highlight(code, { language: name, ignoreIllegals: true }).value;
} catch {
return escapeHtml(code);
}
},
}),
{ gfm: true, breaks: true },
);
/**
* While a message is still streaming, balance dangling fences / emphasis so the
* partial text renders cleanly instead of flashing raw markers.
*/
function closeOpenDelimiters(src: string): string {
let out = src;
const fenceCount = (out.match(/^```/gm) ?? []).length;
if (fenceCount % 2 !== 0) out += "\n```";
const boldCount = (out.match(/\*\*/g) ?? []).length;
if (boldCount % 2 !== 0) out += "**";
const inlineCode = (out.match(/(?<!`)`(?!`)/g) ?? []).length;
if (inlineCode % 2 !== 0) out += "`";
return out;
}
// Wrap each fenced code block (`<pre>…</pre>`) in a positioned container with a
// copy button. marked emits exactly one `<pre>`/`</pre>` pair per block and
// escapes `<`/`>` inside code, so these literal tags only ever delimit blocks.
// `data-copy` is the delegation hook the component listens for; DOMPurify keeps
// `<button>` + `data-*` by default. Inline `<code>` has no `<pre>`, so it's untouched.
const COPY_BUTTON =
'<button type="button" data-copy aria-label="Copy code"' +
' class="copy-btn btn btn-xs absolute right-2 top-2 opacity-0 transition-opacity group-hover:opacity-100">Copy</button>';
function addCopyButtons(html: string): string {
return html
.replace(/<pre>/g, `<div class="code-block group relative">${COPY_BUTTON}<pre>`)
.replace(/<\/pre>/g, "</pre></div>");
}
/** Render Markdown to sanitized HTML. Returns `""` if parsing ever throws. */
export function renderMarkdown(text: string, opts?: { streaming?: boolean }): string {
const src = opts?.streaming === true ? closeOpenDelimiters(text) : text;
try {
const raw = md.parse(src) as string;
return DOMPurify.sanitize(addCopyButtons(raw));
} catch {
return "";
}
}
|