|
|
@@ -1,7 +1,15 @@
|
|
|
-import { marked } from 'marked';
|
|
|
+import { marked, Renderer } from 'marked';
|
|
|
+import type { Tokens } from 'marked';
|
|
|
import katex from 'katex';
|
|
|
import DOMPurify from 'dompurify';
|
|
|
|
|
|
+const mdRenderer = new Renderer();
|
|
|
+const defaultLinkRenderer = mdRenderer.link.bind(mdRenderer);
|
|
|
+mdRenderer.link = (token: Tokens.Link): string => {
|
|
|
+ const html = defaultLinkRenderer(token);
|
|
|
+ return html.replace(/^<a\s/, '<a target="_blank" rel="noopener" ');
|
|
|
+};
|
|
|
+
|
|
|
const renderLatexInHtml = (html: string): string => {
|
|
|
// 匹配块级公式($$...$$)
|
|
|
html = html.replace(/\$\$(.*?)\$\$/gs, (_match, formula) => {
|
|
|
@@ -45,13 +53,20 @@ export const renderMarkdown = (
|
|
|
): string => {
|
|
|
if (!text) return '';
|
|
|
const processed = text.replace(/<br\s*\/?>/gi, ' \n');
|
|
|
- let html = marked.parse(processed) as string;
|
|
|
+ let html = marked.parse(processed, { renderer: mdRenderer }) as string;
|
|
|
html = processFootnotes(html, maxCitationId);
|
|
|
html = renderLatexInHtml(html);
|
|
|
// Sanitize HTML to prevent XSS attacks
|
|
|
html = DOMPurify.sanitize(html, {
|
|
|
ADD_TAGS: ['img'],
|
|
|
- ADD_ATTR: ['data-action', 'data-table', 'data-icon', 'data-icon-success', 'data-n'],
|
|
|
+ ADD_ATTR: ['target', 'data-action', 'data-table', 'data-icon', 'data-icon-success', 'data-n'],
|
|
|
+ });
|
|
|
+ // 原始 HTML 锚点不经过 marked link renderer,统一补 target/rel
|
|
|
+ html = html.replace(/<a\b[^>]*>/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(/^<a\b/i, '<a' + attrs) : tag;
|
|
|
});
|
|
|
return icons ? wrapTables(html, icons) : html;
|
|
|
};
|