Public Access
feat: add markdown rendering support with marked and integrate into Tex component
This commit is contained in:
1 parent
e906951b06
commit
b58500901a
10 files changed
+112
-6
No files matched your search
Generated
+13
@@ -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",
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -242,7 +242,7 @@ export default function ExamRunner({
|
||||
</span>
|
||||
</header>
|
||||
|
||||
<Tex tag="div" className="q-prompt">
|
||||
<Tex tag="div" className="q-prompt" markdown>
|
||||
{q.prompt}
|
||||
</Tex>
|
||||
<QuestionMedia q={q} />
|
||||
|
||||
@@ -200,7 +200,7 @@ export default function Results({ exam, state, onRestart, onExit, onRedoFailed,
|
||||
{openIdx === i && (
|
||||
<div className="review-body">
|
||||
<div className="q-prompt" style={{ fontSize: 20, marginBottom: 20 }}>
|
||||
<Tex>{q.prompt}</Tex>
|
||||
<Tex markdown>{q.prompt}</Tex>
|
||||
</div>
|
||||
<QuestionMedia q={q} />
|
||||
<QuestionRenderer key={q.id} q={q} value={answers[q.id]} onChange={() => {}} locked graded />
|
||||
|
||||
@@ -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<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 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 }} />
|
||||
}
|
||||
@@ -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 (
|
||||
<div className="flashcard-wrap">
|
||||
<div className="flashcard-prompt">
|
||||
{q.prompt}
|
||||
<Tex markdown>{q.prompt}</Tex>
|
||||
</div>
|
||||
|
||||
{!revealed ? (
|
||||
|
||||
@@ -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 (
|
||||
<div className="answer-input-wrap">
|
||||
<div className="grammar-text-wrap">
|
||||
@@ -16,7 +18,7 @@ export default function GrammarCorrection({ q, value = '', onChange, locked, gra
|
||||
Sentence to correct:
|
||||
</div>
|
||||
<div className="grammar-sentence">
|
||||
{q.textToCorrect || q.prompt}
|
||||
<Tex markdown>{displayText}</Tex>
|
||||
</div>
|
||||
</div>
|
||||
<input
|
||||
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
Generated
+15
@@ -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",
|
||||
|
||||
@@ -4,5 +4,8 @@
|
||||
"version": "0.0.1",
|
||||
"devDependencies": {
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"marked": "^18.0.3"
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user