// lib/markdown.ts:由 lib/utils.ts 按职责拆分(实现未改)
import { marked, Renderer, walkTokens } from 'marked';
import type { Tokens } from 'marked';
import katex from 'katex';
import { sanitizeHtml } from '/@/utils/markdownSafe';
const mdRenderer = new Renderer();
const defaultLinkRenderer = mdRenderer.link.bind(mdRenderer);
mdRenderer.link = (token: Tokens.Link): string => {
const html = defaultLinkRenderer(token);
return html.replace(/^ {
// 匹配块级公式($$...$$)
html = html.replace(/\$\$(.*?)\$\$/gs, (_match, formula) => {
return katex.renderToString(formula.trim(), {
displayMode: true,
throwOnError: false,
strict: false,
trust: true,
});
});
// 匹配行内公式($...$)
html = html.replace(/\$(.*?)\$/g, (_match, formula) => {
return katex.renderToString(formula.trim(), {
displayMode: false,
throwOnError: false,
strict: false,
});
});
return html;
};
const processFootnotes = (html: string, maxId?: number): string => {
if (maxId === undefined) {
// 无 citations 上下文,剥离所有 [^n] 标记
return html.replace(/\[\^\d+\]/g, '');
}
// 有效编号范围 [1, maxId],范围外的剥离
return html.replace(/\[\^(\d+)\]/g, (_match, n) => {
const id = parseInt(n, 10);
if (id >= 1 && id <= maxId) {
return `[${n}]`;
}
return '';
});
};
export const renderMarkdown = (
text: string,
icons?: { copy: string; download: string; preview: string; copySuccess: string },
maxCitationId?: number
): string => {
if (!text) return '';
const processed = text.replace(/
/gi, ' \n');
let html = marked.parse(processed, { renderer: mdRenderer }) as string;
html = processFootnotes(html, maxCitationId);
html = renderLatexInHtml(html);
// Sanitize HTML to prevent XSS attacks
// [perf-base-v1] 卡6: 转调公共 sanitizeHtml(DOMPurify,ADD_TAGS:['img']),保留本模块额外交互属性白名单
html = sanitizeHtml(html, ['target', 'data-action', 'data-table', 'data-icon', 'data-icon-success', 'data-n']);
// 原始 HTML 锚点不经过 marked link renderer,统一补 target/rel
html = html.replace(/]*>/gi, (tag) => {
let attrs = '';
if (!/\btarget\s*=/i.test(tag)) attrs += ' target="_blank"';
if (!/\brel\s*=/i.test(tag)) attrs += ' rel="noopener"';
return attrs ? tag.replace(/^();
export const renderMarkdownCached = (
text: string,
icons?: { copy: string; download: string; preview: string; copySuccess: string },
maxCitationId?: number,
cacheable = true
): string => {
if (!cacheable || !text) return renderMarkdown(text, icons, maxCitationId);
// \u0001 作分隔符:不会出现在正文与图标地址里,避免不同入参拼出同一个 key
const iconKey = icons ? `${icons.copy}\u0001${icons.download}\u0001${icons.preview}\u0001${icons.copySuccess}` : '';
const key = `${maxCitationId ?? -1}\u0001${iconKey}\u0001${text}`;
const cached = mdCache.get(key);
if (cached !== undefined) return cached;
const html = renderMarkdown(text, icons, maxCitationId);
// 简单 FIFO 淘汰,长会话下缓存条目数有上限
if (mdCache.size >= MD_CACHE_MAX) {
const oldestKey = mdCache.keys().next().value as string | undefined;
if (oldestKey !== undefined) mdCache.delete(oldestKey);
}
mdCache.set(key, html);
return html;
};
// 从 markdown 文本中提取链接(与 renderMarkdown 共用同一套 marked 解析,
// 仅收集 link token,代码块/图片等不会被误收)
export const extractMarkdownLinks = (text: string): Array<{ title: string; href: string }> => {
if (!text) return [];
const links: Array<{ title: string; href: string }> = [];
try {
const tokens = marked.lexer(text);
walkTokens(tokens, (token) => {
if (token.type === 'link') {
const linkToken = token as Tokens.Link;
const href = linkToken.href || '';
if (href) {
links.push({ title: linkToken.text || href, href });
}
}
});
} catch {
// 文本解析异常时返回已收集到的部分链接
}
return links;
};
export const wrapTables = (html: string, icons: { copy: string; download: string; preview: string; copySuccess: string }): string => {
return html.replace(/