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

175 lines
6.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))
* - 有序列表 (1. item, 2. item ...)
* - 无序列表 (- item, * item ...)
* - 荧光高亮 (<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) {
const rawLines = text.split('\n');
const resultParts: string[] = [];
let i = 0;
while (i < rawLines.length) {
const line = rawLines[i];
const orderedMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
const unorderedMatch = line.match(/^(\s*)[-*+]\s+(.*)$/);
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(
`<ol class="doc-ol" start="${startNum}" style="margin: 6px 0; padding-left: 24px; line-height: 1.65; list-style-type: decimal;">` +
listItems.map((li) => `<li style="margin-bottom: 4px;">${li}</li>`).join('') +
`</ol>`
);
} 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(
`<ul class="doc-ul" style="margin: 6px 0; padding-left: 20px; line-height: 1.65; list-style-type: disc;">` +
listItems.map((li) => `<li style="margin-bottom: 4px;">${li}</li>`).join('') +
`</ul>`
);
} 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];
const isPrevBlock = prev.startsWith('<ol') || prev.startsWith('<ul');
const isCurBlock = cur.startsWith('<ol') || cur.startsWith('<ul');
if (!isPrevBlock && !isCurBlock) {
outHtml += '<br/>';
}
}
outHtml += cur;
}
text = outHtml;
}
return text;
}
function escapeHtml(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}