/** * Markdown 渲染:markdown-it + DOMPurify。 * * 这两块不自己写。上一次我用正则拼标签,能显示粗体和列表,但表格、嵌套列表、 * 代码块语言标注、链接里的括号都会出错——那是通用解析器的活,不是产品特有的逻辑。 * * 安全策略:markdown-it 关掉原始 HTML(`html: false`),DOMPurify 再过一遍。 * 两道防线看着重复,但它们的失败模式不一样:前者防"作者写进 HTML", * 后者防"链接/图片协议"这类由解析器生成的属性被滥用。 */ import DOMPurify from "dompurify"; import MarkdownIt from "markdown-it"; import { applyAttachmentUrls, type UrlResources } from "./attachmentUrls"; const md = new MarkdownIt({ html: false, // 不解析原始 HTML linkify: true, // 裸 URL 自动成链接 breaks: true, // 换行即换行(中文写作里这是预期行为) typographer: false, }); /** 站内相对地址、http(s)、mailto 之外的协议一律拦掉。 */ const SAFE_PROTOCOL = /^(?:https?:|mailto:|\/|#)/i; const defaultLinkOpen = md.renderer.rules.link_open ?? ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options)); md.renderer.rules.link_open = (tokens, idx, options, env, self) => { const href = String(tokens[idx].attrGet("href") ?? ""); if (!SAFE_PROTOCOL.test(href)) { tokens[idx].attrSet("href", "#"); } // 外链统一新窗口打开;站内链接保持原行为 if (/^https?:/i.test(href)) { tokens[idx].attrSet("target", "_blank"); tokens[idx].attrSet("rel", "noreferrer noopener"); } return defaultLinkOpen(tokens, idx, options, env, self); }; const defaultImage = md.renderer.rules.image ?? ((tokens, idx, options, _env, self) => self.renderToken(tokens, idx, options)); md.renderer.rules.image = (tokens, idx, options, env, self) => { const src = String(tokens[idx].attrGet("src") ?? ""); if (!SAFE_PROTOCOL.test(src)) { tokens[idx].attrSet("src", ""); } tokens[idx].attrSet("loading", "lazy"); return defaultImage(tokens, idx, options, env, self); }; /** 渲染成可直接塞进 innerHTML 的安全 HTML。 */ export function renderMarkdown(source: string, resources?: UrlResources): string { const safe = DOMPurify.sanitize(md.render(source ?? ""), { // 图片走我们自己的 /raw 直链;data: 只放行图片,别的都去掉 ALLOWED_URI_REGEXP: /^(?:(?:https?|mailto):|\/|#|data:image\/)/i, }); // 库里存的是干净的直链,渲染这一层按附件列表换成带签名的地址(见 lib/attachmentUrls.ts) return applyAttachmentUrls(safe, resources); } /** 纯文本摘要,用于列表里的一行预览(去掉标记符号,压缩空白)。 */ export function plainSummary(source: string, limit = 90): string { const text = (source ?? "") .replace(/```[\s\S]*?```/g, " ") .replace(/!\[[^\]]*\]\([^)]*\)/g, " ") .replace(/\[([^\]]*)\]\([^)]*\)/g, "$1") .replace(/[#>*_~`|-]+/g, " ") .replace(/\s+/g, " ") .trim(); return text.length > limit ? `${text.slice(0, limit)}…` : text; }