From b0efb524b1841ae511fd1e47b04aacaa5f75853f Mon Sep 17 00:00:00 2001 From: LaurensBartelds Date: Tue, 21 Apr 2026 21:35:43 +0200 Subject: [PATCH] fix: escape plain text in renderLatex; add latex utility tests Co-Authored-By: Claude Sonnet 4.6 --- frontend/src/utils/latex.test.ts | 72 ++++++++++++++++++++++++++++++++ frontend/src/utils/latex.ts | 24 +++++++---- 2 files changed, 89 insertions(+), 7 deletions(-) create mode 100644 frontend/src/utils/latex.test.ts 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 &amp;</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 => {