118 lines
4.0 KiB
TypeScript
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, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"')
|
|
.replace(/'/g, ''');
|
|
}
|