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