88 lines
3.1 KiB
JavaScript
88 lines
3.1 KiB
JavaScript
import { marked } from 'marked'
|
|
import { markedHighlight } from 'marked-highlight'
|
|
import hljs from 'highlight.js'
|
|
import 'highlight.js/styles/github-dark.css'
|
|
import katex from 'katex'
|
|
import 'katex/dist/katex.min.css'
|
|
import DOMPurify from 'dompurify'
|
|
|
|
marked.use(markedHighlight({
|
|
langPrefix: 'hljs language-',
|
|
highlight(code, lang) {
|
|
if (lang && hljs.getLanguage(lang)) {
|
|
return hljs.highlight(code, { language: lang }).value
|
|
}
|
|
return hljs.highlightAuto(code).value
|
|
},
|
|
}))
|
|
marked.setOptions({ breaks: true, gfm: true })
|
|
|
|
// LaTeX math via KaTeX. Own marked extensions (instead of marked-katex-extension,
|
|
// which lags behind marked v18). marked tokenizes code first → $…$ inside code
|
|
// blocks is NOT treated as math. throwOnError:false renders broken TeX in red.
|
|
function renderTex(tex, displayMode) {
|
|
return katex.renderToString(tex, { displayMode, throwOnError: false, output: 'html' })
|
|
}
|
|
|
|
const blockMath = {
|
|
name: 'blockMath',
|
|
level: 'block',
|
|
start(src) { const i = src.indexOf('$$'); return i < 0 ? undefined : i },
|
|
tokenizer(src) {
|
|
const m = /^\$\$([\s\S]+?)\$\$/.exec(src)
|
|
if (m) return { type: 'blockMath', raw: m[0], text: m[1].trim() }
|
|
},
|
|
renderer(token) { return renderTex(token.text, true) },
|
|
}
|
|
|
|
const inlineMath = {
|
|
name: 'inlineMath',
|
|
level: 'inline',
|
|
start(src) { const i = src.indexOf('$'); return i < 0 ? undefined : i },
|
|
tokenizer(src) {
|
|
// $…$: no $$, no space right after the opening $ or before the closing $
|
|
// (pandoc style) → reduces collisions with dollar signs in prose.
|
|
const m = /^\$(?![\s$])((?:\\\$|[^$])+?)\$/.exec(src)
|
|
if (!m || /\s$/.test(m[1])) return
|
|
return { type: 'inlineMath', raw: m[0], text: m[1].trim() }
|
|
},
|
|
renderer(token) { return renderTex(token.text, false) },
|
|
}
|
|
|
|
marked.use({ extensions: [blockMath, inlineMath] })
|
|
|
|
// Raw HTML in markdown (e.g. <p>, <img> without backticks from agent output)
|
|
// shown as text instead of rendered — otherwise the browser swallows the content.
|
|
marked.use({
|
|
renderer: {
|
|
html(token) {
|
|
const text = typeof token === 'string' ? token : token.text
|
|
return text.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>')
|
|
},
|
|
},
|
|
})
|
|
|
|
export function renderMarkdown(text) {
|
|
return DOMPurify.sanitize(marked.parse(text || ''))
|
|
}
|
|
|
|
// Inline variant (no <p> wrapping) for short texts like quiz options or
|
|
// gap-text sentence fragments — renders $…$ math and markdown without a block break.
|
|
export function renderMarkdownInline(text) {
|
|
return DOMPurify.sanitize(marked.parseInline(text || ''))
|
|
}
|
|
|
|
// Strip markdown to plain text (code fences + inline marks) — for previews/search.
|
|
export function plainText(text) {
|
|
return (text || '').replace(/```[a-z]*\n?/g, '').replace(/[`*_#]/g, '')
|
|
}
|
|
|
|
// Split markdown into top-level blocks: each block { raw (exact source), html }.
|
|
// raw is lossless (tokens.map(raw).join('') === original) → block-precise replacement.
|
|
export function renderBlocks(text) {
|
|
const tokens = marked.lexer(text || '')
|
|
return tokens
|
|
.filter((t) => t.type !== 'space' && (t.raw || '').trim())
|
|
.map((t) => ({ raw: t.raw, html: DOMPurify.sanitize(marked.parser([t])) }))
|
|
}
|