Files
DadaoTask/web/src/lib/markdown.ts
T
dadao 8d61289f90
CI / backend (push) Canceled after 0s
docs(rich): 表格可读性、安卓编辑器、签名直链都验证过了
- README:富文本行改成「Web Vditor 编辑 / 安卓 Markwon 预览编辑」,e2e 15 项
- ROADMAP 第 5 节:勾掉表格与超过一两行的旧描述(表格是只读渲染,
  HTML 片段仍按文本显示);附件改为短期签名 raw_url;安卓从"未做"改成已完成
- 记下仍欠的三件事:保存后回列表而不是回详情、图片压缩/缩略图、离线缓存

验证:pytest 74 passed;richtext 15 / smoke 7 / tickets 8 / knowledge 7 全绿;
tsc -b 0 错;vite build 通过;安卓 assembleDebug 与模拟器逐屏走通。
2026-10-29 03:19:33 +08:00

78 lines
3.0 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* 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;
}