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": "^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",
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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} />
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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",
|
"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",
|
||||||
|
|||||||
@@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user