The login entry no longer ships KaTeX. Chat bubbles and document text render formulas after a dynamic import, and rich-text copy does the same. Co-authored-by: Cursor <cursoragent@cursor.com>
267 lines
10 KiB
TypeScript
267 lines
10 KiB
TypeScript
export interface RenderMarkdownOptions {
|
|
preserveNewlines?: boolean;
|
|
}
|
|
|
|
type MathRenderer = (source: string, displayMode: boolean) => string;
|
|
|
|
let mathRenderer: MathRenderer | null = null;
|
|
let mathRendererLoading: Promise<void> | null = null;
|
|
|
|
export function contentHasMath(content: string): boolean {
|
|
return /\$\$[\s\S]*?\$\$|\$[^$\n]+?\$/.test(content);
|
|
}
|
|
|
|
/** 首次遇到公式才加载 KaTeX 及其样式。没有公式的会话不请求这个包。 */
|
|
export function ensureMathRenderer(): Promise<void> {
|
|
if (mathRenderer) return Promise.resolve();
|
|
if (!mathRendererLoading) {
|
|
mathRendererLoading = (async () => {
|
|
if (typeof document !== 'undefined') {
|
|
await import('katex/dist/katex.min.css');
|
|
}
|
|
const mod = await import('./katexRender');
|
|
mathRenderer = mod.renderKatexHtml;
|
|
})();
|
|
}
|
|
return mathRendererLoading;
|
|
}
|
|
|
|
function renderMathSource(math: string, displayMode: boolean): string {
|
|
const trimmed = math.trim();
|
|
if (mathRenderer) {
|
|
try {
|
|
return mathRenderer(trimmed, displayMode);
|
|
} catch {
|
|
// 落到转义文本,避免把未渲染的源码再送进 Markdown 规则。
|
|
}
|
|
}
|
|
const escaped = escapeHtml(trimmed);
|
|
if (displayMode) {
|
|
return `<span class="math-pending">$$${escaped}$$</span>`;
|
|
}
|
|
return `<span class="math-pending">$${escaped}$</span>`;
|
|
}
|
|
|
|
/**
|
|
* 健壮的行内 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[] = [];
|
|
// 每次渲染换盐,避免用户文本伪造 XMATHTOKENX0XENDX 在 escapeHtml 之后被还原成 HTML
|
|
const tokenSalt = Math.random().toString(36).slice(2, 10).toUpperCase();
|
|
const placeholderPrefix = `XMATHTOKENX${tokenSalt}X`;
|
|
|
|
// 1.1 块级独立公式 $$ ... $$
|
|
text = text.replace(/\$\$([\s\S]*?)\$\$/g, (_, math) => {
|
|
const idx = mathPlaceholders.length;
|
|
mathPlaceholders.push(renderMathSource(math, true));
|
|
return `${placeholderPrefix}${idx}XENDX`;
|
|
});
|
|
|
|
// 1.2 行内公式 $ ... $
|
|
text = text.replace(/\$([^\$\n]+?)\$/g, (_, math) => {
|
|
const idx = mathPlaceholders.length;
|
|
mathPlaceholders.push(renderMathSource(math, false));
|
|
return `${placeholderPrefix}${idx}XENDX`;
|
|
});
|
|
|
|
// 2. 行内代码保护 `code`
|
|
const codePlaceholders: string[] = [];
|
|
const codePlaceholderPrefix = `XCODETOKENX${tokenSalt}X`;
|
|
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`;
|
|
});
|
|
|
|
// 2.5 转义剩余文本中的 HTML,防止用户/LLM 输入经 dangerouslySetInnerHTML 执行脚本
|
|
text = escapeHtml(text);
|
|
|
|
// 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,
|
|
(_, label, url) =>
|
|
`<a href="${sanitizeHref(url)}" target="_blank" rel="noopener noreferrer" style="color:var(--brand-500);text-decoration:underline">${label}</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;
|
|
|
|
const isBlockHtml = (s: string) =>
|
|
/<(ol|ul|h[1-6]|table|hr|blockquote)\b/i.test(s) || s.startsWith('<ol') || s.startsWith('<ul');
|
|
|
|
while (i < rawLines.length) {
|
|
const line = rawLines[i];
|
|
const headingMatch = line.match(/^(#{1,6})\s+(.*)$/);
|
|
const hrMatch = line.match(/^\s*(?:-{3,}|\*{3,}|_{3,})\s*$/);
|
|
const quoteMatch = line.match(/^>\s?(.*)$/);
|
|
const tableMatch = line.match(/^\s*\|.+\|\s*$/);
|
|
const orderedMatch = line.match(/^(\s*)(\d+)\.\s+(.*)$/);
|
|
const unorderedMatch = line.match(/^(\s*)[-*+]\s+(.*)$/);
|
|
|
|
if (headingMatch) {
|
|
const level = Math.min(headingMatch[1].length, 6);
|
|
const sizes = ['1.35em', '1.2em', '1.1em', '1.05em', '1em', '0.95em'];
|
|
resultParts.push(
|
|
`<h${level} class="chat-md-h" style="margin:10px 0 6px;font-size:${sizes[level - 1]};font-weight:700;line-height:1.35;color:inherit;">${headingMatch[2]}</h${level}>`
|
|
);
|
|
i++;
|
|
} else if (hrMatch) {
|
|
resultParts.push(
|
|
`<hr class="chat-md-hr" style="border:none;border-top:1px solid var(--border);margin:10px 0;opacity:0.85;" />`
|
|
);
|
|
i++;
|
|
} else if (tableMatch) {
|
|
const tableLines: string[] = [];
|
|
while (i < rawLines.length && /^\s*\|.+\|\s*$/.test(rawLines[i])) {
|
|
tableLines.push(rawLines[i].trim());
|
|
i++;
|
|
}
|
|
const parsedRows: string[][] = [];
|
|
for (const tl of tableLines) {
|
|
if (/^\|?\s*[:\-| ]+\s*\|?$/.test(tl)) continue;
|
|
const cells = tl
|
|
.replace(/^\|/, '')
|
|
.replace(/\|$/, '')
|
|
.split('|')
|
|
.map((c) => c.trim());
|
|
if (cells.length > 0) parsedRows.push(cells);
|
|
}
|
|
if (parsedRows.length > 0) {
|
|
const head = parsedRows[0];
|
|
const body = parsedRows.slice(1);
|
|
const thead = `<thead><tr>${head.map((c) => `<th style="border:1px solid var(--border);padding:6px 8px;background:var(--bg-subtle);text-align:left;font-weight:600;">${c}</th>`).join('')}</tr></thead>`;
|
|
const tbody =
|
|
body.length > 0
|
|
? `<tbody>${body.map((r) => `<tr>${r.map((c) => `<td style="border:1px solid var(--border);padding:6px 8px;vertical-align:top;">${c}</td>`).join('')}</tr>`).join('')}</tbody>`
|
|
: '';
|
|
resultParts.push(
|
|
`<div style="overflow-x:auto;margin:8px 0;"><table class="chat-md-table" style="border-collapse:collapse;width:100%;font-size:12.5px;line-height:1.45;">${thead}${tbody}</table></div>`
|
|
);
|
|
}
|
|
} else if (quoteMatch) {
|
|
const quotes: string[] = [];
|
|
while (i < rawLines.length) {
|
|
const qm = rawLines[i].match(/^>\s?(.*)$/);
|
|
if (!qm) break;
|
|
quotes.push(qm[1] || '');
|
|
i++;
|
|
}
|
|
resultParts.push(
|
|
`<blockquote class="chat-md-quote" style="margin:8px 0;padding:6px 10px;border-left:3px solid var(--brand-400);background:var(--bg-subtle);border-radius:0 6px 6px 0;">${quotes.join('<br/>')}</blockquote>`
|
|
);
|
|
} else 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];
|
|
if (!isBlockHtml(prev) && !isBlockHtml(cur)) {
|
|
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, ''');
|
|
}
|
|
|
|
/** Block executable URL schemes in markdown link hrefs. */
|
|
function sanitizeHref(url: string): string {
|
|
const trimmed = url.trim();
|
|
if (/^(javascript|data|vbscript):/i.test(trimmed)) {
|
|
return '#';
|
|
}
|
|
return escapeHtml(trimmed);
|
|
}
|