Files
Agent-System/web/src/engine/renderMarkdown.ts
T

118 lines
4.0 KiB
TypeScript

import katex from 'katex';
export interface RenderMarkdownOptions {
preserveNewlines?: boolean;
}
/**
* 健壮的行内 Markdown 与 LaTeX 公式渲染引擎
* 支持:
* - 块级数学公式 ($$...$$) 与行内数学公式 ($...$)
* - 粗体 (**text** / __text__)
* - 斜体 (*text* / _text_)
* - 粗斜体 (***text***)
* - 删除线 (~~text~~)
* - 下划线 (<u>text</u>)
* - 行内代码 (`code`)
* - 超链接 ([text](url))
* - 荧光高亮 (<mark> / 预置语法)
* - 安全内联样式标签 (<span style="...">)
*/
export function renderInlineMarkdown(content: string, options: RenderMarkdownOptions = {}): string {
if (!content) return '';
let text = content;
// 1. 占位保护预处理:优先解析并提取数学公式,避免公式内部的 * 或 _ 被 Markdown 解析器误伤
// 注意:占位符严禁使用包含 _ 或 * 等 Markdown 特殊字符,采用纯大写字母标识符
const mathPlaceholders: string[] = [];
const placeholderPrefix = 'XMATHTOKENX';
// 1.1 块级独立公式 $$ ... $$
text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => {
try {
const rendered = katex.renderToString(math.trim(), {
displayMode: true,
throwOnError: false,
});
const idx = mathPlaceholders.length;
mathPlaceholders.push(rendered);
return `${placeholderPrefix}${idx}XENDX`;
} catch {
const idx = mathPlaceholders.length;
mathPlaceholders.push(`<span style="color:var(--danger)">$$${math}$$</span>`);
return `${placeholderPrefix}${idx}XENDX`;
}
});
// 1.2 行内公式 $ ... $
text = text.replace(/\$([^\$\n]+?)\$/g, (_, math) => {
try {
const rendered = katex.renderToString(math.trim(), {
displayMode: false,
throwOnError: false,
});
const idx = mathPlaceholders.length;
mathPlaceholders.push(rendered);
return `${placeholderPrefix}${idx}XENDX`;
} catch {
const idx = mathPlaceholders.length;
mathPlaceholders.push(`$${math}$`);
return `${placeholderPrefix}${idx}XENDX`;
}
});
// 2. 行内代码保护 `code`
const codePlaceholders: string[] = [];
const codePlaceholderPrefix = 'XCODETOKENX';
text = text.replace(/`([^`\n]+)`/g, (_, code) => {
const rendered = `<code style="font-family:var(--font-mono);font-size:12px;background:var(--bg-subtle);color:var(--brand-500);padding:2px 5px;border-radius:4px;border:1px solid var(--border)">${escapeHtml(code)}</code>`;
const idx = codePlaceholders.length;
codePlaceholders.push(rendered);
return `${codePlaceholderPrefix}${idx}XENDX`;
});
// 3. 粗斜体 ***text***
text = text.replace(/\*\*\*([^\*\n]+?)\*\*\*/g, '<strong><em>$1</em></strong>');
// 4. 粗体 **text** 或 __text__
text = text.replace(/\*\*([^\*\n]+?)\*\*/g, '<strong>$1</strong>');
text = text.replace(/__([^_\n]+?)__/g, '<strong>$1</strong>');
// 5. 斜体 *text* 或 _text_
text = text.replace(/\*([^\*\n]+?)\*/g, '<em>$1</em>');
text = text.replace(/_([^_\n]+?)_/g, '<em>$1</em>');
// 6. 删除线 ~~text~~
text = text.replace(/~~([^~\n]+?)~~/g, '<del style="color:var(--text-muted)">$1</del>');
// 7. 链接 [text](url)
text = text.replace(/\[([^\]\n]+)\]\(([^)\s]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer" style="color:var(--brand-500);text-decoration:underline">$1</a>');
// 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) {
text = text.replace(/\n/g, '<br/>');
}
return text;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}