2e14873f2d
Track application source and tests; exclude local env, user workspaces, and runtime data via .gitignore. Co-authored-by: Cursor <cursoragent@cursor.com>
121 lines
3.3 KiB
TypeScript
121 lines
3.3 KiB
TypeScript
/**
|
|
* 轻量级 Markdown 渲染,用于聊天气泡中的文本。
|
|
* 支持:链接、粗体、斜体、行内代码、代码块、换行。
|
|
*/
|
|
|
|
function escapeHtml(text: string): string {
|
|
return text
|
|
.replace(/&/g, '&')
|
|
.replace(/</g, '<')
|
|
.replace(/>/g, '>')
|
|
.replace(/"/g, '"');
|
|
}
|
|
|
|
function renderInline(text: string): string {
|
|
// 行内代码 `code` — 优先处理,避免干扰其他标记
|
|
let html = text.replace(/`([^`]+)`/g, '<code>$1</code>');
|
|
|
|
// 图片 
|
|
html = html.replace(
|
|
/!\[([^\]]*)]\(([^)]+)\)/g,
|
|
'<img src="$2" alt="$1" loading="lazy" class="md-img" />',
|
|
);
|
|
|
|
// 链接 [text](url)
|
|
html = html.replace(
|
|
/\[([^\]]+)]\(([^)]+)\)/g,
|
|
(_, label, url) => {
|
|
const safeUrl = url.startsWith('http://') || url.startsWith('https://') || url.startsWith('mailto:')
|
|
? url
|
|
: `http://${url}`;
|
|
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
|
},
|
|
);
|
|
|
|
// 粗体 **text** 或 __text__
|
|
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
|
|
html = html.replace(/__(.+?)__/g, '<strong>$1</strong>');
|
|
|
|
// 斜体 *text* 或 _text_
|
|
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
|
|
html = html.replace(/_(.+?)_/g, '<em>$1</em>');
|
|
|
|
// 删除线 ~~text~~
|
|
html = html.replace(/~~(.+?)~~/g, '<s>$1</s>');
|
|
|
|
return html;
|
|
}
|
|
|
|
export function renderMarkdown(text: string): string {
|
|
const lines = text.split('\n');
|
|
const result: string[] = [];
|
|
let inCodeBlock = false;
|
|
let codeBuf: string[] = [];
|
|
let codeLang = '';
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
|
|
if (line.startsWith('```')) {
|
|
if (inCodeBlock) {
|
|
// 关闭代码块
|
|
const langClass = codeLang ? ` class="lang-${escapeHtml(codeLang)}"` : '';
|
|
result.push(`<pre${langClass}><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`);
|
|
codeBuf = [];
|
|
codeLang = '';
|
|
inCodeBlock = false;
|
|
} else {
|
|
inCodeBlock = true;
|
|
codeLang = line.slice(3).trim();
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if (inCodeBlock) {
|
|
codeBuf.push(line);
|
|
continue;
|
|
}
|
|
|
|
// 空行
|
|
if (line.trim() === '') {
|
|
result.push('<br>');
|
|
continue;
|
|
}
|
|
|
|
// 标题 # ~ ######
|
|
const headingMatch = line.match(/^(#{1,6})\s+(.+)$/);
|
|
if (headingMatch) {
|
|
const level = headingMatch[1].length;
|
|
const content = renderInline(escapeHtml(headingMatch[2]));
|
|
result.push(`<h${level} class="md-h${level}">${content}</h${level}>`);
|
|
continue;
|
|
}
|
|
|
|
// 无序列表 - 或 *
|
|
if (/^[\s]*[-*+]\s+(.+)$/.test(line)) {
|
|
const content = renderInline(escapeHtml(line.replace(/^[\s]*[-*+]\s+/, '')));
|
|
result.push(`<li class="md-li">${content}</li>`);
|
|
continue;
|
|
}
|
|
|
|
// 有序列表 1. 2.
|
|
const orderedMatch = line.match(/^(\s*)\d+\.\s+(.+)$/);
|
|
if (orderedMatch) {
|
|
const content = renderInline(escapeHtml(orderedMatch[2]));
|
|
result.push(`<li class="md-li">${content}</li>`);
|
|
continue;
|
|
}
|
|
|
|
// 普通段落
|
|
const content = renderInline(escapeHtml(line));
|
|
result.push(`<p class="md-p">${content}</p>`);
|
|
}
|
|
|
|
// 未闭合的代码块
|
|
if (inCodeBlock) {
|
|
result.push(`<pre><code>${escapeHtml(codeBuf.join('\n'))}</code></pre>`);
|
|
}
|
|
|
|
return result.join('\n');
|
|
}
|