refactor: 第三方库替换 & 代码精简 — v5.1.5

- Markdown 解析器:内联正则 → marked 库(完整 GFM 支持)
- HTML 净化器:内联 DOM 遍历 → DOMPurify(业界标准)
- crypto.ts:删除 sha256js/xorBytes 死代码回退路径
- 消除 5 个文件中 escapeHtml/formatSize 重复定义
- 版本号升级至 5.1.5
This commit is contained in:
thzxx
2026-04-19 20:48:56 +08:00
parent 586cd33563
commit 8805858b79
15 changed files with 183 additions and 310 deletions
+39 -77
View File
@@ -1,84 +1,46 @@
/**
* Markdown 渲染器配置
* Markdown 渲染器配置 — 基于 marked 库
*/
import { SAFE_URI_SCHEMES } from './sanitizer.js';
import { marked as markedLib, Renderer } from 'marked';
import { sanitize } from './sanitizer.js';
// 简化的 Markdown 解析器(内联,不依赖外部 marked 库)
// 支持基本 GFM 语法:标题、粗体、斜体、代码块、链接、列表、表格
// 自定义渲染器:链接安全检查
const renderer = new Renderer();
const SAFE_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:', 'ftp:', 'ftps:', 'xmpp:']);
function parseMarkdown(md: string): string {
renderer.link = function ({ href, text, tokens }) {
try {
const protocol = new URL(href, 'https://placeholder').protocol;
if (!SAFE_SCHEMES.has(protocol)) {
return `<span class="blocked-link">${text}</span>`;
}
} catch { /* 允许相对路径 */ }
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`;
};
renderer.image = function ({ href, text }) {
try {
const protocol = new URL(href, 'https://placeholder').protocol;
if (!SAFE_SCHEMES.has(protocol)) {
return `<span class="blocked-link">${text || '图片'}</span>`;
}
} catch { /* 允许相对路径 */ }
return `<img src="${href}" alt="${text || ''}" loading="lazy">`;
};
// 配置 marked
markedLib.use({
renderer,
gfm: true,
breaks: true,
});
/**
* 解析 Markdown 并净化 HTML 输出
*/
export function marked(md: string): string {
if (!md) return '';
let html = escapeForMd(md);
// 代码块 ```...```
const codeBlockRe = new RegExp('```(\\w*)\\n([\\s\\S]*?)```', 'g');
html = html.replace(codeBlockRe, (_m, lang, code) => {
return `<pre><code class="language-${lang}">${code.trim()}</code></pre>`;
});
// 行内代码
html = html.replace(/`([^`]+)`/g, '<code>$1</code>');
// 标题
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>');
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>');
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>');
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>');
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>');
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>');
// 粗体+斜体
html = html.replace(/\*\*\*(.+?)\*\*\*/g, '<strong><em>$1</em></strong>');
// 粗体
html = html.replace(/\*\*(.+?)\*\*/g, '<strong>$1</strong>');
// 斜体
html = html.replace(/\*(.+?)\*/g, '<em>$1</em>');
// 删除线
html = html.replace(/~~(.+?)~~/g, '<del>$1</del>');
// 链接 [text](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (_m, text, href) => {
try {
const safe = SAFE_URI_SCHEMES.has(new URL(href, 'https://placeholder').protocol);
if (!safe) return `<span class="blocked-link">${text}</span>`;
} catch { /* allow relative */ }
return `<a href="${href}" target="_blank" rel="noopener noreferrer">${text}</a>`;
});
// 图片 ![alt](url)
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" loading="lazy">');
// 引用块
html = html.replace(/^>\s+(.+)$/gm, '<blockquote><p>$1</p></blockquote>');
// 水平线
html = html.replace(/^---+$/gm, '<hr>');
// 无序列表
html = html.replace(/^[\-\*]\s+(.+)$/gm, '<li>$1</li>');
html = html.replace(/((?:<li>.*<\/li>\n?)+)/g, '<ul>$1</ul>');
// 有序列表
html = html.replace(/^\d+\.\s+(.+)$/gm, '<li>$1</li>');
// 换行
html = html.replace(/\n\n/g, '</p><p>');
html = html.replace(/\n/g, '<br>');
// 包裹在 p 标签中
if (!html.startsWith('<')) {
html = '<p>' + html + '</p>';
}
return html;
const raw = markedLib.parse(md) as string;
return sanitize(raw);
}
function escapeForMd(str: string): string {
return str
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;');
}
export { parseMarkdown as marked };
+24 -66
View File
@@ -1,73 +1,31 @@
/**
* Sanitizer - HTML 净化器
* Sanitizer — 基于 DOMPurify 的 HTML 净化器
*/
export const SAFE_URI_SCHEMES = new Set(['http:', 'https:', 'mailto:', 'tel:', 'ftp:', 'ftps:', 'xmpp:']);
import DOMPurify from 'dompurify';
// 允许的 data URI 类型
const SAFE_DATA_TYPES = new Set(['image/png', 'image/jpeg', 'image/gif', 'image/webp', 'image/svg+xml']);
const ALLOWED_TAGS = new Set([
'a', 'abbr', 'b', 'blockquote', 'br', 'code', 'del', 'details', 'div',
'dl', 'dt', 'dd', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr',
'i', 'img', 'ins', 'kbd', 'li', 'ol', 'p', 'pre', 'q', 'rp', 'rt',
'ruby', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub',
'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead',
'tr', 'u', 'ul', 'var', 'input',
]);
const ALLOWED_ATTRS = new Set([
'href', 'src', 'alt', 'title', 'width', 'height', 'align',
'target', 'rel', 'class', 'id', 'type', 'checked', 'disabled',
'start', 'value', 'scope', 'rowspan', 'colspan', 'lang',
]);
const DANGEROUS_PREFIXES = ['on', 'xlink:href', 'xmlns:'];
/**
* 净化 HTML,移除危险标签和属性
*/
export function sanitize(html: string): string {
try {
const template = document.createElement('template');
template.innerHTML = html;
const root = template.content;
const walker = document.createTreeWalker(root, NodeFilter.SHOW_COMMENT);
const comments: Comment[] = [];
while (walker.nextNode()) comments.push(walker.currentNode as Comment);
comments.forEach(c => c.remove());
const elements = [...root.querySelectorAll('*')];
for (const el of elements) {
if (!el.parentNode) continue;
const tag = el.tagName.toLowerCase();
if (!ALLOWED_TAGS.has(tag)) {
while (el.firstChild) el.parentNode.insertBefore(el.firstChild, el);
el.remove();
continue;
}
for (const attr of [...el.attributes]) {
const name = attr.name.toLowerCase();
if (DANGEROUS_PREFIXES.some(p => name.startsWith(p))) {
el.removeAttribute(attr.name);
continue;
}
if (!ALLOWED_ATTRS.has(name)) {
el.removeAttribute(attr.name);
continue;
}
if ((name === 'href' || name === 'src' || name === 'action') && attr.value) {
const val = attr.value.trim().toLowerCase();
if (/^(javascript|vbscript|data|file):/i.test(val)) {
const mimeMatch = val.match(/^data:([^;,]+)/i);
if (!mimeMatch || !SAFE_DATA_TYPES.has(mimeMatch[1].toLowerCase())) {
el.removeAttribute(attr.name);
}
}
}
}
}
return root.innerHTML;
} catch (e) {
return html;
}
return DOMPurify.sanitize(html, {
ALLOWED_TAGS: [
'a', 'abbr', 'b', 'blockquote', 'br', 'code', 'del', 'details', 'div',
'dl', 'dt', 'dd', 'em', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'hr',
'i', 'img', 'ins', 'kbd', 'li', 'ol', 'p', 'pre', 'q', 'rp', 'rt',
'ruby', 's', 'samp', 'small', 'span', 'strike', 'strong', 'sub',
'summary', 'sup', 'table', 'tbody', 'td', 'tfoot', 'th', 'thead',
'tr', 'u', 'ul', 'var', 'input',
],
ALLOWED_ATTR: [
'href', 'src', 'alt', 'title', 'width', 'height', 'align',
'target', 'rel', 'class', 'id', 'type', 'checked', 'disabled',
'start', 'value', 'scope', 'rowspan', 'colspan', 'lang',
],
ALLOWED_URI_REGEXP: /^(?:(?:https?|ftp|ftps|mailto|tel|xmpp):|data:image\/(?:png|jpeg|gif|webp|svg\+xml);)/i,
ADD_ATTR: ['target'],
});
}
+2 -2
View File
@@ -44,9 +44,9 @@ export function formatSize(bytes: number): string {
/** HTML 转义 */
export function escapeHtml(str: string): string {
const map: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
return String(str).replace(/[&<>"']/g, (c) => map[c]);
return str.replace(/[&<>"']/g, (c) => ESCAPE_MAP[c]);
}
const ESCAPE_MAP: Record<string, string> = { '&': '&amp;', '<': '&lt;', '>': '&gt;', '"': '&quot;', "'": '&#39;' };
/** 根据文件名检测语言标识(用于 Markdown 代码块) */
export function detectLanguage(filename: string): string {