diff --git a/frontend/src/components/Tex.tsx b/frontend/src/components/Tex.tsx
new file mode 100644
index 0000000..736f71d
--- /dev/null
+++ b/frontend/src/components/Tex.tsx
@@ -0,0 +1,14 @@
+import { renderLatex } from '../utils/latex'
+
+interface TexProps {
+ children?: string | null
+ tag?: keyof JSX.IntrinsicElements
+ className?: string
+ style?: React.CSSProperties
+}
+
+// Safe: renderLatex only produces KaTeX-controlled HTML + HTML-escaped plain text.
+export default function Tex({ children, tag: Tag = 'span', className, style }: TexProps) {
+ const html = String(children ?? '').split('\n').map(renderLatex).join('
')
+ return
+}
diff --git a/frontend/src/utils/latex.ts b/frontend/src/utils/latex.ts
new file mode 100644
index 0000000..a047d86
--- /dev/null
+++ b/frontend/src/utils/latex.ts
@@ -0,0 +1,42 @@
+import katex from 'katex'
+
+export const escapeHtml = (s: string): string =>
+ String(s)
+ .replace(/&/g, '&')
+ .replace(//g, '>')
+ .replace(/"/g, '"')
+ .replace(/'/g, ''')
+
+export const renderLatex = (text: string): string => {
+ if (!text) return ''
+ let rest = String(text)
+ // display math: $$...$$
+ rest = rest.replace(/\$\$([\s\S]+?)\$\$/g, (_, tex: string) => {
+ try {
+ return `${katex.renderToString(tex, { displayMode: true, throwOnError: false })}`
+ } catch {
+ return `${escapeHtml(tex)}`
+ }
+ })
+ // inline math: $...$
+ rest = rest.replace(/\$([^$\n]+?)\$/g, (_, tex: string) => {
+ try {
+ return katex.renderToString(tex, { displayMode: false, throwOnError: false })
+ } catch {
+ return `${escapeHtml(tex)}`
+ }
+ })
+ return rest
+}
+
+export const highlightCode = (code: string, _lang: string): string => {
+ // escapeHtml runs first — no raw user content reaches the span injection
+ const escaped = escapeHtml(code)
+ return escaped
+ .replace(/(\/\/[^\n]*|#[^\n]*)/g, '')
+ .replace(/("[^&]*?"|'[^&]*?'|`[^`]*?`)/g, '$1')
+ .replace(/\b(function|return|if|else|const|let|var|for|while|import|export|from|class|new|this|def|print|in|not|and|or|True|False|None|null|undefined|true|false)\b/g, '$1')
+ .replace(/\b(\d+(?:\.\d+)?)\b/g, '$1')
+ .replace(/\b([a-zA-Z_][a-zA-Z0-9_]*)\s*(?=\()/g, '$1')
+}