import { Marked } from 'marked' import type { Tokens } from 'marked' import hljs from 'highlight.js' import DOMPurify from 'dompurify' // --------------------------------------------------------------------------- // Language metadata // --------------------------------------------------------------------------- const LANG_DISPLAY: Record = { js: 'JavaScript', javascript: 'JavaScript', ts: 'TypeScript', typescript: 'TypeScript', py: 'Python', python: 'Python', java: 'Java', kt: 'Kotlin', kotlin: 'Kotlin', go: 'Go', rust: 'Rust', rs: 'Rust', rb: 'Ruby', ruby: 'Ruby', cpp: 'C++', c: 'C', cs: 'C#', csharp: 'C#', swift: 'Swift', sh: 'Shell', bash: 'Bash', zsh: 'Zsh', shell: 'Shell', sql: 'SQL', html: 'HTML', css: 'CSS', scss: 'SCSS', less: 'LESS', json: 'JSON', xml: 'XML', yaml: 'YAML', yml: 'YAML', toml: 'TOML', md: 'Markdown', markdown: 'Markdown', dockerfile: 'Dockerfile', vue: 'Vue', jsx: 'JSX', tsx: 'TSX', php: 'PHP', lua: 'Lua', } const KNOWN_LANGS = [ 'typescript', 'javascript', 'python', 'kotlin', 'csharp', 'dockerfile', 'markdown', 'shell', 'swift', 'rust', 'ruby', 'bash', 'scss', 'less', 'yaml', 'toml', 'html', 'java', 'json', 'css', 'cpp', 'xml', 'vue', 'jsx', 'tsx', 'php', 'lua', 'sql', 'zsh', 'yml', 'go', 'kt', 'rs', 'rb', 'cs', 'ts', 'js', 'py', 'sh', 'md', 'c', ] function extractLang(raw: string): string { if (!raw) return '' const lower = raw.toLowerCase() if (hljs.getLanguage(lower)) return lower for (const lang of KNOWN_LANGS) { if (lower.startsWith(lang) && lower.length > lang.length) return lang } return lower } function escapeHtml(str: string): string { return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"') } // --------------------------------------------------------------------------- // Code block thresholds (must match `useMarkdownRenderer` doc comments) // --------------------------------------------------------------------------- /** Lines >= this trigger collapsible
wrap. */ const COLLAPSE_LINE_THRESHOLD = 20 /** JSON blob char count >= this triggers collapse even when line count is low. */ const COLLAPSE_JSON_CHAR_THRESHOLD = 800 // --------------------------------------------------------------------------- // Link safety // --------------------------------------------------------------------------- /** * Scheme whitelist. Only http(s), mailto, fragment, and same-origin paths * (absolute `/...`, relative `./...` / `../...`) are permitted. Everything * else (javascript:, data:, vbscript:, file:, …) is degraded to plain text. */ const SAFE_LINK_RE = /^(https?:|mailto:|#|\/|\.\/|\.\.\/)/i // --------------------------------------------------------------------------- // LaTeX pre-processor // --------------------------------------------------------------------------- // `$$ ... $$` (block) and `$ ... $` (inline) are extracted from raw markdown // and replaced with HTML placeholders that survive marked + DOMPurify. The // post-render KaTeX composable (useKatexRenderer) finds them by class + // data-tex attribute and mounts the typeset output. // // We deliberately walk the source character-by-character rather than running // a global regex, so that fenced/inline code blocks are skipped — otherwise // dollar signs inside Bash snippets or JSON blobs would be misinterpreted. function preprocessLatex(text: string): string { let out = '' let i = 0 let inFence = false let fenceMarker = '' while (i < text.length) { // Detect fence open/close at line start. if (i === 0 || text[i - 1] === '\n') { const fenceMatch = /^(```+|~~~+)([^\n]*)/.exec(text.slice(i)) if (fenceMatch) { const marker = fenceMatch[1] if (!inFence) { inFence = true fenceMarker = marker } else if (marker.length >= fenceMarker.length && marker[0] === fenceMarker[0]) { inFence = false fenceMarker = '' } out += fenceMatch[0] i += fenceMatch[0].length continue } } if (inFence) { out += text[i++] continue } // Inline code: copy verbatim until the matching backtick run. if (text[i] === '`') { let n = 0 while (text[i + n] === '`') n++ const tickRun = '`'.repeat(n) const close = text.indexOf(tickRun, i + n) if (close < 0) { // Unmatched — treat the rest as text but still advance past the ticks. out += text[i++] continue } out += text.slice(i, close + n) i = close + n continue } // LaTeX-style block math: \[...\] — must be checked BEFORE marked sees // the source, because CommonMark eats the backslash escape (`\[ → [`) // and the marker would be lost. LLMs (DeepSeek, Qwen, Claude) emit this // form heavily for display equations. if (text[i] === '\\' && text[i + 1] === '[') { const close = text.indexOf('\\]', i + 2) // Bound length so a stray `\[` doesn't swallow the rest of the doc. if (close > 0 && close - i < 800) { const tex = text.slice(i + 2, close) out += `\n\n
\n\n` i = close + 2 continue } } // LaTeX-style inline math: \(...\) if (text[i] === '\\' && text[i + 1] === '(') { const close = text.indexOf('\\)', i + 2) if (close > 0 && close - i < 400) { const tex = text.slice(i + 2, close) out += `` i = close + 2 continue } } // Block math: $$...$$ if (text[i] === '$' && text[i + 1] === '$') { const close = text.indexOf('$$', i + 2) if (close > 0) { const tex = text.slice(i + 2, close) // Wrap in newlines so marked treats the placeholder as its own block, // not glued onto a surrounding paragraph (which would make
a // direct child of

— invalid HTML the browser silently splits). out += `\n\n

\n\n` i = close + 2 continue } } // Inline math: $...$ — require non-whitespace adjacent to the dollars // so that "$5.99" or "saved $10" are NOT treated as math. if (text[i] === '$') { const m = /^\$([^$\n]+?)\$(?!\d)/.exec(text.slice(i)) if (m && !/^\s/.test(m[1]) && !/\s$/.test(m[1])) { const tex = m[1] out += `` i += m[0].length continue } } out += text[i++] } return out } // --------------------------------------------------------------------------- // Custom renderer (marked v15 requires a plain object — class instances are // NOT dispatched). // --------------------------------------------------------------------------- const customRenderer = { code({ text, lang }: { type: string; raw: string; text: string; lang?: string }): string { const rawCode = text || '' const infoStr = (lang || '').split(/\s/)[0] // Mermaid: ship raw source through a placeholder div for the // useMermaidRenderer post-mount step. Skip syntax highlighting entirely. if (infoStr === 'mermaid') { return `
` } // ECharts: same pattern, mounted by useEChartsRenderer. if (infoStr === 'echarts') { return `
` } const detectedLang = extractLang(infoStr) const hasLanguage = !!detectedLang && !!hljs.getLanguage(detectedLang) let highlighted: string try { highlighted = hasLanguage ? hljs.highlight(rawCode, { language: detectedLang }).value : hljs.highlightAuto(rawCode).value } catch { highlighted = escapeHtml(rawCode) } const langLabel = LANG_DISPLAY[detectedLang] || detectedLang || 'Code' const encodedCode = encodeURIComponent(rawCode) const langClass = hasLanguage ? ` language-${detectedLang}` : '' // Split into one
  • per source line so CSS counter renders the gutter. // We trim a trailing empty line if highlight.js produced one (common when // the user's fenced block ends with a newline), to avoid a blank tail row. const rawLines = highlighted.split('\n') if (rawLines.length && rawLines[rawLines.length - 1] === '') rawLines.pop() const lineCount = rawLines.length || 1 const linesHtml = `
      ${rawLines.map(l => `
    1. ${l || ' '}
    2. `).join('')}
    ` const isJson = detectedLang === 'json' const isLongJson = isJson && rawCode.length >= COLLAPSE_JSON_CHAR_THRESHOLD const shouldCollapse = lineCount >= COLLAPSE_LINE_THRESHOLD || isLongJson // Default-open for normal long code (the user wants to see it; the // collapsible header is just an opt-in fold). Default-closed only for // giant JSON blobs, which are typically noisy tool-call output. const openByDefault = !isLongJson // Header content: lang badge (left) — line-count badge (only shown when // collapsed) — copy button (right). We render the SAME inner content into // either a
    (non-collapsible) or directly // into (collapsible). Nesting a div // inside caused weird browser-native height behavior and made // the header visibly inflate; flattening fixes it. const headerInner = `${escapeHtml(langLabel)}` + `${lineCount} lines` + `` const codeBody = `
    ${linesHtml}
    ` if (shouldCollapse) { const openAttr = openByDefault ? ' open' : '' return `
    ` + `${headerInner}` + codeBody + `
    ` } return `
    ` + `
    ${headerInner}
    ` + codeBody + `
    ` }, link({ href, title, tokens }: Tokens.Link): string { // marked v15 passes already-parsed inline tokens; render them ourselves so // that the inner content keeps any bold/italic formatting from `[**x**](u)`. const innerHtml = (this as unknown as { parser: { parseInline: (t: unknown[]) => string } }) .parser.parseInline(tokens) if (!href || !SAFE_LINK_RE.test(href)) { // Dangerous scheme — render the inner content as plain content (no anchor). return innerHtml } let extra = '' try { const url = new URL(href, typeof window !== 'undefined' ? window.location.href : 'http://localhost/') if (typeof window !== 'undefined' && url.origin !== window.location.origin) { extra = ' target="_blank" rel="noopener noreferrer"' } } catch { // Malformed URL — treat as same-origin (relative link path). } const titleAttr = title ? ` title="${escapeHtml(title)}"` : '' return `${innerHtml}` }, } // --------------------------------------------------------------------------- // marked instance // --------------------------------------------------------------------------- const markedInstance = new Marked({ gfm: true, breaks: true, renderer: customRenderer, }) // --------------------------------------------------------------------------- // DOMPurify config — allow Markdown + custom blocks (code-block, KaTeX/Mermaid // placeholders) and the inline copy SVG button. // --------------------------------------------------------------------------- const purifyConfig = { ADD_ATTR: [ 'target', 'rel', 'class', 'data-code', 'data-echarts-option', 'data-wiki-title', 'data-slug', 'data-tex', 'data-mermaid', 'aria-label', 'open', 'type', 'viewBox', 'fill', 'stroke', 'stroke-width', 'd', 'x', 'y', 'width', 'height', 'rx', 'ry', 'points', ], ADD_TAGS: [ 'input', 'button', 'svg', 'path', 'rect', 'polyline', 'circle', 'line', 'span', 'details', 'summary', ], // Defence in depth: even if a malicious href slips past our link() // override, DOMPurify drops anything outside this whitelist. ALLOWED_URI_REGEXP: /^(?:https?:|mailto:|#|\/|\.\/|\.\.\/)/i, } // --------------------------------------------------------------------------- // LRU render cache // --------------------------------------------------------------------------- // Streaming token-by-token defeats this (each delta produces a new key) but // scrolling history and re-renders of completed messages are common, and the // cost of marked + highlight.js + DOMPurify is non-trivial for long messages. const RENDER_CACHE = new Map() const RENDER_CACHE_CAP = 200 function cacheKey(text: string): string { // Compact key — collisions on the order of 10^-6 in single-conversation // scope, and a false hit only causes a "stale" render of unchanged content // (no security implication since cached values are sanitized HTML). return `${text.length}:${text.slice(0, 40)}:${text.slice(-40)}` } // --------------------------------------------------------------------------- // Public API // --------------------------------------------------------------------------- export function useMarkdownRenderer() { function renderMarkdown(content: string): string { if (!content) return '' const k = cacheKey(content) const cached = RENDER_CACHE.get(k) if (cached !== undefined) { // Refresh LRU position — re-insert at the tail. RENDER_CACHE.delete(k) RENDER_CACHE.set(k, cached) return cached } // 1. LaTeX placeholders (skips fenced/inline code). const withLatex = preprocessLatex(content) // 2. Wiki link substitution: [[Title]] → . const withWikiLinks = withLatex.replace( /\[\[([^\]]+)\]\]/g, '$1' ) // 3. Marked → 4. DOMPurify. const rawHtml = markedInstance.parse(withWikiLinks) as string const result = DOMPurify.sanitize(rawHtml, purifyConfig) // Evict oldest entry when at capacity (Map preserves insertion order). if (RENDER_CACHE.size >= RENDER_CACHE_CAP) { const oldestKey = RENDER_CACHE.keys().next().value if (oldestKey !== undefined) RENDER_CACHE.delete(oldestKey) } RENDER_CACHE.set(k, result) return result } function escapeText(text: string): string { return escapeHtml(text) } return { renderMarkdown, escapeText, markedInstance, } } // Direct singletons for tests / advanced callers. export { markedInstance, purifyConfig } export default markedInstance