feat: add markdown rendering support with marked and integrate into Tex component
Test / backend (push) Successful in 13s
Test / frontend (push) Failing after 21s

This commit is contained in:
LaurensBartelds committed 2026-05-02 19:57:42 +02:00
1 parent e906951b06
commit b58500901a
10 files changed
+112 -6

No files matched your search

+13
View File
@@ -12,6 +12,7 @@
"i18next": "^23.16.8", "i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",
"katex": "^0.16.45", "katex": "^0.16.45",
"marked": "^18.0.3",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
@@ -2898,6 +2899,18 @@
"node": ">=10" "node": ">=10"
} }
}, },
"node_modules/marked": {
"version": "18.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
"integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/minimatch": { "node_modules/minimatch": {
"version": "3.1.5", "version": "3.1.5",
"resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz",
+1
View File
@@ -16,6 +16,7 @@
"i18next": "^23.16.8", "i18next": "^23.16.8",
"i18next-browser-languagedetector": "^8.0.4", "i18next-browser-languagedetector": "^8.0.4",
"katex": "^0.16.45", "katex": "^0.16.45",
"marked": "^18.0.3",
"react": "^19.2.5", "react": "^19.2.5",
"react-dom": "^19.2.5", "react-dom": "^19.2.5",
"react-i18next": "^15.4.1", "react-i18next": "^15.4.1",
+1 -1
View File
@@ -242,7 +242,7 @@ export default function ExamRunner({
</span> </span>
</header> </header>
<Tex tag="div" className="q-prompt"> <Tex tag="div" className="q-prompt" markdown>
{q.prompt} {q.prompt}
</Tex> </Tex>
<QuestionMedia q={q} /> <QuestionMedia q={q} />
+1 -1
View File
@@ -200,7 +200,7 @@ export default function Results({ exam, state, onRestart, onExit, onRedoFailed,
{openIdx === i && ( {openIdx === i && (
<div className="review-body"> <div className="review-body">
<div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}> <div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}>
<Tex>{q.prompt}</Tex> <Tex markdown>{q.prompt}</Tex>
</div> </div>
<QuestionMedia q={q} /> <QuestionMedia q={q} />
<QuestionRenderer key={q.id} q={q} value={answers[q.id]} onChange={() => {}} locked graded /> <QuestionRenderer key={q.id} q={q} value={answers[q.id]} onChange={() => {}} locked graded />
+8 -2
View File
@@ -1,16 +1,22 @@
import type React from 'react' import type React from 'react'
import { renderLatex } from '../utils/latex' import { renderLatex } from '../utils/latex'
import { renderMarkdown } from '../utils/markdown'
interface TexProps { interface TexProps {
children?: string | null children?: string | null
tag?: keyof React.JSX.IntrinsicElements tag?: keyof React.JSX.IntrinsicElements
className?: string className?: string
style?: React.CSSProperties style?: React.CSSProperties
markdown?: boolean
} }
// Safe: renderLatex only produces KaTeX-controlled HTML + HTML-escaped plain text. // Safe: renderLatex only produces KaTeX-controlled HTML + HTML-escaped plain text.
export default function Tex({ children, tag = 'span', className, style }: Readonly<TexProps>) { // When markdown is true, uses renderMarkdown which applies marked + KaTeX + DOMPurify.
export default function Tex({ children, tag = 'span', className, style, markdown }: Readonly<TexProps>) {
const Tag = tag as React.ElementType const Tag = tag as React.ElementType
const html = String(children ?? '').split('\n').map(renderLatex).join('<br/>') const text = String(children ?? '')
const html = markdown
? renderMarkdown(text)
: text.split('\n').map(renderLatex).join('<br/>')
return <Tag className={className} style={style} dangerouslySetInnerHTML={{ __html: html }} /> return <Tag className={className} style={style} dangerouslySetInnerHTML={{ __html: html }} />
} }
@@ -1,3 +1,4 @@
import Tex from '../Tex'
import type { Question } from '../../types' import type { Question } from '../../types'
interface Props { interface Props {
@@ -23,7 +24,7 @@ export default function Flashcard({ q, value, onChange, locked }: Props) {
return ( return (
<div className="flashcard-wrap"> <div className="flashcard-wrap">
<div className="flashcard-prompt"> <div className="flashcard-prompt">
{q.prompt} <Tex markdown>{q.prompt}</Tex>
</div> </div>
{!revealed ? ( {!revealed ? (
@@ -1,3 +1,4 @@
import Tex from '../Tex'
import type { Question } from '../../types' import type { Question } from '../../types'
interface Props { interface Props {
@@ -9,6 +10,7 @@ interface Props {
} }
export default function GrammarCorrection({ q, value = '', onChange, locked, graded }: Props) { export default function GrammarCorrection({ q, value = '', onChange, locked, graded }: Props) {
const displayText = q.textToCorrect || q.prompt
return ( return (
<div className="answer-input-wrap"> <div className="answer-input-wrap">
<div className="grammar-text-wrap"> <div className="grammar-text-wrap">
@@ -16,7 +18,7 @@ export default function GrammarCorrection({ q, value = '', onChange, locked, gra
Sentence to correct: Sentence to correct:
</div> </div>
<div className="grammar-sentence"> <div className="grammar-sentence">
{q.textToCorrect || q.prompt} <Tex markdown>{displayText}</Tex>
</div> </div>
</div> </div>
<input <input
+65
View File
@@ -0,0 +1,65 @@
import { marked } from 'marked'
import DOMPurify from 'dompurify'
import katex from 'katex'
import { escapeHtml } from './latex'
marked.use({
breaks: true,
gfm: true,
})
const ALLOWED_TAGS = [
'p', 'br', 'strong', 'em', 'code', 'pre', 'ul', 'ol', 'li',
'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'blockquote', 'a',
'span', 'div', 'img', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr',
'sub', 'sup', 'del', 's', 'ins',
]
const ALLOWED_ATTR = ['href', 'src', 'alt', 'title', 'class', 'style']
/**
* Render markdown text to HTML, preserving LaTeX math expressions.
* Math ($inline$ and $$block$$) is extracted before markdown parsing
* and rendered with KaTeX afterwards. The final HTML is sanitized
* with DOMPurify.
*/
export function renderMarkdown(text: string): string {
if (!text) return ''
let rest = String(text)
const mathPlaceholders: { type: 'block' | 'inline'; tex: string }[] = []
// Extract block math $$...$$
rest = rest.replace(/\$\$([\s\S]+?)\$\$/g, (_, tex: string) => {
const idx = mathPlaceholders.length
mathPlaceholders.push({ type: 'block', tex })
return `\x00MATH${idx}\x00`
})
// Extract inline math $...$
rest = rest.replace(/\$([^$\n]+?)\$/g, (_, tex: string) => {
const idx = mathPlaceholders.length
mathPlaceholders.push({ type: 'inline', tex })
return `\x00MATH${idx}\x00`
})
// Render markdown (synchronous)
const mdHtml = marked.parse(rest, { async: false }) as string
// Replace placeholders with rendered math
let result = mdHtml.replace(/\x00MATH(\d+)\x00/g, (_, i) => {
const { type, tex } = mathPlaceholders[Number(i)]
try {
const opts = { displayMode: type === 'block', throwOnError: false }
return katex.renderToString(tex, opts)
} catch {
return `<code>${escapeHtml(tex)}</code>`
}
})
// Sanitize the final HTML
return DOMPurify.sanitize(result, {
ALLOWED_TAGS,
ALLOWED_ATTR,
})
}
+15
View File
@@ -7,10 +7,25 @@
"": { "": {
"name": "brainwave", "name": "brainwave",
"version": "0.0.1", "version": "0.0.1",
"dependencies": {
"marked": "^18.0.3"
},
"devDependencies": { "devDependencies": {
"typescript": "^6.0.3" "typescript": "^6.0.3"
} }
}, },
"node_modules/marked": {
"version": "18.0.3",
"resolved": "https://registry.npmjs.org/marked/-/marked-18.0.3.tgz",
"integrity": "sha512-7VT90JOkDeaRWpfjOReRGPEKn0ecdARBkDGL+tT1wZY0efPPqkUxLUSmzy/C7TIylQYJC9STISEsCHrqb/7VIA==",
"license": "MIT",
"bin": {
"marked": "bin/marked.js"
},
"engines": {
"node": ">= 20"
}
},
"node_modules/typescript": { "node_modules/typescript": {
"version": "6.0.3", "version": "6.0.3",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz",
+3
View File
@@ -4,5 +4,8 @@
"version": "0.0.1", "version": "0.0.1",
"devDependencies": { "devDependencies": {
"typescript": "^6.0.3" "typescript": "^6.0.3"
},
"dependencies": {
"marked": "^18.0.3"
} }
} }