diff --git a/frontend/src/utils/latex.test.ts b/frontend/src/utils/latex.test.ts
new file mode 100644
index 0000000..008f8ef
--- /dev/null
+++ b/frontend/src/utils/latex.test.ts
@@ -0,0 +1,72 @@
+import { describe, it, expect } from 'vitest'
+import { escapeHtml, renderLatex, highlightCode } from './latex'
+
+describe('escapeHtml', () => {
+ it('escapes all 5 HTML entities', () => {
+ expect(escapeHtml('&')).toBe('&')
+ expect(escapeHtml('<')).toBe('<')
+ expect(escapeHtml('>')).toBe('>')
+ expect(escapeHtml('"')).toBe('"')
+ expect(escapeHtml("'")).toBe(''')
+ })
+
+ it('escapes multiple entities in a single string', () => {
+ expect(escapeHtml('it\'s &')).toBe(
+ '<b class="x">it's &</b>'
+ )
+ })
+})
+
+describe('renderLatex', () => {
+ it('returns empty string for empty input', () => {
+ expect(renderLatex('')).toBe('')
+ })
+
+ it('HTML-escapes plain text with dangerous characters', () => {
+ const result = renderLatex('
')
+ expect(result).not.toContain('
{
+ const result = renderLatex('$x^2$')
+ expect(result.toLowerCase()).toContain('katex')
+ })
+
+ it('renders display math $$x^2$$ and wraps in katex-display-wrap span', () => {
+ const result = renderLatex('$$x^2$$')
+ expect(result).toContain('katex-display-wrap')
+ expect(result.toLowerCase()).toContain('katex')
+ })
+
+ it('HTML-escapes plain text portions alongside math', () => {
+ const result = renderLatex('bold and $x^2$')
+ // Plain text portion must be escaped
+ expect(result).toContain('<b>bold</b>')
+ // Math portion must still be rendered
+ expect(result.toLowerCase()).toContain('katex')
+ })
+})
+
+describe('highlightCode', () => {
+ it('wraps keywords in cm-keyword spans', () => {
+ const result = highlightCode('const x = 1', 'js')
+ expect(result).toContain('const')
+ })
+
+ it('wraps comments in cm-comment spans', () => {
+ const result = highlightCode('// a comment', 'js')
+ expect(result).toContain('cm-comment')
+ })
+
+ it('wraps string literals in cm-string spans', () => {
+ const result = highlightCode('"hello"', 'js')
+ expect(result).toContain('cm-string')
+ })
+
+ it('wraps numbers in cm-number spans', () => {
+ const result = highlightCode('return 42', 'js')
+ expect(result).toContain('42')
+ })
+})
diff --git a/frontend/src/utils/latex.ts b/frontend/src/utils/latex.ts
index a047d86..54d033c 100644
--- a/frontend/src/utils/latex.ts
+++ b/frontend/src/utils/latex.ts
@@ -11,23 +11,33 @@ export const escapeHtml = (s: string): string =>
export const renderLatex = (text: string): string => {
if (!text) return ''
let rest = String(text)
- // display math: $$...$$
+ const mathPlaceholders: string[] = []
+
+ // Replace math regions with null-byte placeholders before HTML-escaping
rest = rest.replace(/\$\$([\s\S]+?)\$\$/g, (_, tex: string) => {
+ const idx = mathPlaceholders.length
try {
- return `${katex.renderToString(tex, { displayMode: true, throwOnError: false })}`
+ mathPlaceholders.push(`${katex.renderToString(tex, { displayMode: true, throwOnError: false })}`)
} catch {
- return `${escapeHtml(tex)}`
+ mathPlaceholders.push(`${escapeHtml(tex)}`)
}
+ return `\x00MATH${idx}\x00`
})
- // inline math: $...$
rest = rest.replace(/\$([^$\n]+?)\$/g, (_, tex: string) => {
+ const idx = mathPlaceholders.length
try {
- return katex.renderToString(tex, { displayMode: false, throwOnError: false })
+ mathPlaceholders.push(katex.renderToString(tex, { displayMode: false, throwOnError: false }))
} catch {
- return `${escapeHtml(tex)}`
+ mathPlaceholders.push(`${escapeHtml(tex)}`)
}
+ return `\x00MATH${idx}\x00`
})
- return rest
+
+ // Escape plain text (null bytes survive escapeHtml since it only targets HTML chars)
+ rest = escapeHtml(rest)
+
+ // Restore rendered math
+ return rest.replace(/\x00MATH(\d+)\x00/g, (_, i) => mathPlaceholders[Number(i)])
}
export const highlightCode = (code: string, _lang: string): string => {