From b58500901a015e890e55faffd5e81f11273589a8 Mon Sep 17 00:00:00 2001 From: LaurensBartelds <48316768+LaurensBartelds@users.noreply.github.com> Date: Sat, 2 May 2026 19:57:42 +0200 Subject: [PATCH] feat: add markdown rendering support with marked and integrate into Tex component --- frontend/package-lock.json | 13 ++++ frontend/package.json | 1 + frontend/src/components/ExamRunner.tsx | 2 +- frontend/src/components/Results.tsx | 2 +- frontend/src/components/Tex.tsx | 10 ++- .../src/components/questions/Flashcard.tsx | 3 +- .../questions/GrammarCorrection.tsx | 4 +- frontend/src/utils/markdown.ts | 65 +++++++++++++++++++ package-lock.json | 15 +++++ package.json | 3 + 10 files changed, 112 insertions(+), 6 deletions(-) create mode 100644 frontend/src/utils/markdown.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index da77718..8277f06 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -12,6 +12,7 @@ "i18next": "^23.16.8", "i18next-browser-languagedetector": "^8.0.4", "katex": "^0.16.45", + "marked": "^18.0.3", "react": "^19.2.5", "react-dom": "^19.2.5", "react-i18next": "^15.4.1", @@ -2898,6 +2899,18 @@ "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": { "version": "3.1.5", "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-3.1.5.tgz", diff --git a/frontend/package.json b/frontend/package.json index 42bc900..9f05089 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -16,6 +16,7 @@ "i18next": "^23.16.8", "i18next-browser-languagedetector": "^8.0.4", "katex": "^0.16.45", + "marked": "^18.0.3", "react": "^19.2.5", "react-dom": "^19.2.5", "react-i18next": "^15.4.1", diff --git a/frontend/src/components/ExamRunner.tsx b/frontend/src/components/ExamRunner.tsx index db3964f..3f8b0dd 100644 --- a/frontend/src/components/ExamRunner.tsx +++ b/frontend/src/components/ExamRunner.tsx @@ -242,7 +242,7 @@ export default function ExamRunner({ - + {q.prompt} diff --git a/frontend/src/components/Results.tsx b/frontend/src/components/Results.tsx index c8e54c0..d0a445f 100644 --- a/frontend/src/components/Results.tsx +++ b/frontend/src/components/Results.tsx @@ -200,7 +200,7 @@ export default function Results({ exam, state, onRestart, onExit, onRedoFailed, {openIdx === i && (
- {q.prompt} + {q.prompt}
{}} locked graded /> diff --git a/frontend/src/components/Tex.tsx b/frontend/src/components/Tex.tsx index cb37ebc..5f3cc7d 100644 --- a/frontend/src/components/Tex.tsx +++ b/frontend/src/components/Tex.tsx @@ -1,16 +1,22 @@ import type React from 'react' import { renderLatex } from '../utils/latex' +import { renderMarkdown } from '../utils/markdown' interface TexProps { children?: string | null tag?: keyof React.JSX.IntrinsicElements className?: string style?: React.CSSProperties + markdown?: boolean } // Safe: renderLatex only produces KaTeX-controlled HTML + HTML-escaped plain text. -export default function Tex({ children, tag = 'span', className, style }: Readonly) { +// When markdown is true, uses renderMarkdown which applies marked + KaTeX + DOMPurify. +export default function Tex({ children, tag = 'span', className, style, markdown }: Readonly) { const Tag = tag as React.ElementType - const html = String(children ?? '').split('\n').map(renderLatex).join('
') + const text = String(children ?? '') + const html = markdown + ? renderMarkdown(text) + : text.split('\n').map(renderLatex).join('
') return } diff --git a/frontend/src/components/questions/Flashcard.tsx b/frontend/src/components/questions/Flashcard.tsx index 3a77374..dc22fc3 100644 --- a/frontend/src/components/questions/Flashcard.tsx +++ b/frontend/src/components/questions/Flashcard.tsx @@ -1,3 +1,4 @@ +import Tex from '../Tex' import type { Question } from '../../types' interface Props { @@ -23,7 +24,7 @@ export default function Flashcard({ q, value, onChange, locked }: Props) { return (
- {q.prompt} + {q.prompt}
{!revealed ? ( diff --git a/frontend/src/components/questions/GrammarCorrection.tsx b/frontend/src/components/questions/GrammarCorrection.tsx index 4c1c7dd..8c97893 100644 --- a/frontend/src/components/questions/GrammarCorrection.tsx +++ b/frontend/src/components/questions/GrammarCorrection.tsx @@ -1,3 +1,4 @@ +import Tex from '../Tex' import type { Question } from '../../types' interface Props { @@ -9,6 +10,7 @@ interface Props { } export default function GrammarCorrection({ q, value = '', onChange, locked, graded }: Props) { + const displayText = q.textToCorrect || q.prompt return (
@@ -16,7 +18,7 @@ export default function GrammarCorrection({ q, value = '', onChange, locked, gra Sentence to correct:
- {q.textToCorrect || q.prompt} + {displayText}
{ + 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 `${escapeHtml(tex)}` + } + }) + + // Sanitize the final HTML + return DOMPurify.sanitize(result, { + ALLOWED_TAGS, + ALLOWED_ATTR, + }) +} diff --git a/package-lock.json b/package-lock.json index 300ee2b..b6fc872 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,10 +7,25 @@ "": { "name": "brainwave", "version": "0.0.1", + "dependencies": { + "marked": "^18.0.3" + }, "devDependencies": { "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": { "version": "6.0.3", "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", diff --git a/package.json b/package.json index 0925460..b165ae1 100644 --- a/package.json +++ b/package.json @@ -4,5 +4,8 @@ "version": "0.0.1", "devDependencies": { "typescript": "^6.0.3" + }, + "dependencies": { + "marked": "^18.0.3" } }