export interface RenderMarkdownOptions { preserveNewlines?: boolean; } type MathRenderer = (source: string, displayMode: boolean) => string; let mathRenderer: MathRenderer | null = null; let mathRendererLoading: Promise | null = null; export function contentHasMath(content: string): boolean { return /\$\$[\s\S]*?\$\$|\$[^$\n]+?\$/.test(content); } /** 首次遇到公式才加载 KaTeX 及其样式。没有公式的会话不请求这个包。 */ export function ensureMathRenderer(): Promise { if (mathRenderer) return Promise.resolve(); if (!mathRendererLoading) { mathRendererLoading = (async () => { if (typeof document !== 'undefined') { await import('katex/dist/katex.min.css'); } const mod = await import('./katexRender'); mathRenderer = mod.renderKatexHtml; })(); } return mathRendererLoading; } function renderMathSource(math: string, displayMode: boolean): string { const trimmed = math.trim(); if (mathRenderer) { try { return mathRenderer(trimmed, displayMode); } catch { // 落到转义文本,避免把未渲染的源码再送进 Markdown 规则。 } } const escaped = escapeHtml(trimmed); if (displayMode) { return `$$${escaped}$$`; } return `$${escaped}$`; } /** * 健壮的行内 Markdown、LaTeX 公式与有序/无序列表渲染引擎 * 支持: * - 块级数学公式 ($$...$$) 与行内数学公式 ($...$) * - 粗体 (**text** / __text__) * - 斜体 (*text* / _text_) * - 粗斜体 (***text***) * - 删除线 (~~text~~) * - 下划线 (text) * - 行内代码 (`code`) * - 超链接 ([text](url)) * - 有序列表 (1. item, 2. item ...) * - 无序列表 (- item, * item ...) * - 荧光高亮 ( / 预置语法) * - 安全内联样式标签 () */ export function renderInlineMarkdown(content: string, options: RenderMarkdownOptions = {}): string { if (!content) return ''; let text = content; // 1. 占位保护预处理:优先解析并提取数学公式,避免公式内部的 * 或 _ 被 Markdown 解析器误伤 // 注意:占位符严禁使用包含 _ 或 * 等 Markdown 特殊字符,采用纯大写字母标识符 const mathPlaceholders: string[] = []; // 每次渲染换盐,避免用户文本伪造 XMATHTOKENX0XENDX 在 escapeHtml 之后被还原成 HTML const tokenSalt = Math.random().toString(36).slice(2, 10).toUpperCase(); const placeholderPrefix = `XMATHTOKENX${tokenSalt}X`; // 1.1 块级独立公式 $$ ... $$ text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => { const idx = mathPlaceholders.length; mathPlaceholders.push(renderMathSource(math, true)); return `${placeholderPrefix}${idx}XENDX`; }); // 1.2 行内公式 $ ... $ text = text.replace(/\$([^\$\n]+?)\$/g, (_, math) => { const idx = mathPlaceholders.length; mathPlaceholders.push(renderMathSource(math, false)); return `${placeholderPrefix}${idx}XENDX`; }); // 2. 行内代码保护 `code` const codePlaceholders: string[] = []; const codePlaceholderPrefix = `XCODETOKENX${tokenSalt}X`; text = text.replace(/`([^`\n]+)`/g, (_, code) => { const rendered = `${escapeHtml(code)}`; const idx = codePlaceholders.length; codePlaceholders.push(rendered); return `${codePlaceholderPrefix}${idx}XENDX`; }); // 2.5 转义剩余文本中的 HTML,防止用户/LLM 输入经 dangerouslySetInnerHTML 执行脚本 text = escapeHtml(text); // 3. 粗斜体 ***text*** text = text.replace(/\*\*\*([^\*\n]+?)\*\*\*/g, '$1'); // 4. 粗体 **text** 或 __text__ text = text.replace(/\*\*([^\*\n]+?)\*\*/g, '$1'); text = text.replace(/__([^_\n]+?)__/g, '$1'); // 5. 斜体 *text* 或 _text_ text = text.replace(/\*([^\*\n]+?)\*/g, '$1'); text = text.replace(/_([^_\n]+?)_/g, '$1'); // 6. 删除线 ~~text~~ text = text.replace(/~~([^~\n]+?)~~/g, '$1'); // 7. 链接 [text](url) text = text.replace( /\[([^\]\n]+)\]\(([^)\s]+)\)/g, (_, label, url) => `${label}` ); // 8. 还原行内代码占位符 codePlaceholders.forEach((codeHtml, idx) => { text = text.replace(`${codePlaceholderPrefix}${idx}XENDX`, () => codeHtml); }); // 9. 还原数学公式占位符 mathPlaceholders.forEach((mathHtml, idx) => { text = text.replace(`${placeholderPrefix}${idx}XENDX`, () => mathHtml); }); // 10. 处理标题 / 分隔线 / 表格 / 引用 / 有序无序列表与换行 if (options.preserveNewlines !== false) { const rawLines = text.split('\n'); const resultParts: string[] = []; let i = 0; const isBlockHtml = (s: string) => /<(ol|ul|h[1-6]|table|hr|blockquote)\b/i.test(s) || s.startsWith('\s?(.*)$/); const tableMatch = line.match(/^\s*\|.+\|\s*$/); const orderedMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/); const unorderedMatch = line.match(/^(\s*)[-*+]\s+(.*)$/); if (headingMatch) { const level = Math.min(headingMatch[1].length, 6); const sizes = ['1.35em', '1.2em', '1.1em', '1.05em', '1em', '0.95em']; resultParts.push( `${headingMatch[2]}` ); i++; } else if (hrMatch) { resultParts.push( `
` ); i++; } else if (tableMatch) { const tableLines: string[] = []; while (i < rawLines.length && /^\s*\|.+\|\s*$/.test(rawLines[i])) { tableLines.push(rawLines[i].trim()); i++; } const parsedRows: string[][] = []; for (const tl of tableLines) { if (/^\|?\s*[:\-| ]+\s*\|?$/.test(tl)) continue; const cells = tl .replace(/^\|/, '') .replace(/\|$/, '') .split('|') .map((c) => c.trim()); if (cells.length > 0) parsedRows.push(cells); } if (parsedRows.length > 0) { const head = parsedRows[0]; const body = parsedRows.slice(1); const thead = `${head.map((c) => `${c}`).join('')}`; const tbody = body.length > 0 ? `${body.map((r) => `${r.map((c) => `${c}`).join('')}`).join('')}` : ''; resultParts.push( `
${thead}${tbody}
` ); } } else if (quoteMatch) { const quotes: string[] = []; while (i < rawLines.length) { const qm = rawLines[i].match(/^>\s?(.*)$/); if (!qm) break; quotes.push(qm[1] || ''); i++; } resultParts.push( `
${quotes.join('
')}
` ); } else if (orderedMatch) { const listItems: string[] = []; const startNum = parseInt(orderedMatch[2], 10) || 1; while (i < rawLines.length) { const curMatch = rawLines[i].match(/^\s*\d+\.\s+(.*)$/); if (!curMatch) break; listItems.push(curMatch[1]); i++; } resultParts.push( `
    ` + listItems.map((li) => `
  1. ${li}
  2. `).join('') + `
` ); } else if (unorderedMatch) { const listItems: string[] = []; while (i < rawLines.length) { const curMatch = rawLines[i].match(/^\s*[-*+]\s+(.*)$/); if (!curMatch) break; listItems.push(curMatch[1]); i++; } resultParts.push( `
    ` + listItems.map((li) => `
  • ${li}
  • `).join('') + `
` ); } else { resultParts.push(line); i++; } } let outHtml = ''; for (let p = 0; p < resultParts.length; p++) { const cur = resultParts[p]; if (p > 0) { const prev = resultParts[p - 1]; if (!isBlockHtml(prev) && !isBlockHtml(cur)) { outHtml += '
'; } } outHtml += cur; } text = outHtml; } return text; } function escapeHtml(str: string): string { return str .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); } /** Block executable URL schemes in markdown link hrefs. */ function sanitizeHref(url: string): string { const trimmed = url.trim(); if (/^(javascript|data|vbscript):/i.test(trimmed)) { return '#'; } return escapeHtml(trimmed); }