* feat(mermaid): 新增 mermaid 懒加载 chunk(构建链四处同步 + mermaid 依赖) tsdown CHUNKS / bundle-route CHUNK_NAMES / chunk-loader ChunkName / package.json files 同步注册 mermaid chunk;mermaid ^11.16.1 进依赖与 lockfile;chunk 构建注入 uuid 浏览器入口别名,绕开 node:crypto 纯度门。 * feat(mermaid): Markdown 预览安全渲染 mermaid 图表 + 点击放大弹窗 - splitMermaidBlocks 纯函数按 fence 切块,无 mermaid 文件零行为变化; - mermaid.initialize 走 securityLevel strict + htmlLabels:false,不应用 bindFunctions(静态图);渲染 SVG 经 mermaid-sanitize 二次清洗: 删 foreignObject/script/外来 HTML 元素、剥 @*/on* 属性、移除全部 href/xlink:href,仅接受 SVG 根,解析失败返回空串; - 点击图表弹出放大弹窗(滚轮缩放/拖拽平移/快捷键,借鉴 PR #75 交互, 基于已清洗 SVG 克隆重写,不新增攻击面); - 深浅色跟随重渲、失败回退错误条 + 原码、CodeBlock 同款 chrome。 * test(mermaid): splitter/sanitize 单测 + chunk 清单同步 + 挂载冒烟 diagram.md - mermaid-blocks.spec 10 例 fence 切块语义; - mermaid-sanitize.spec 8 例 XSS(foreignObject/on*/@*/href/xlink:href/ script/外来元素/畸形 XML/非 SVG 根); - bundle-route / chunk-artifact / manifest-consistency 清单加入 mermaid (chunk-artifact 补 addEventListener stub); - 挂载冒烟新增 diagram.md:client-mermaid.js 往返、SVG 节点文字、 点击弹窗 Esc 关闭、preview 模式编辑器隐藏。 * docs(mermaid): 安全渲染设计文档 + lazy-chunks 表 + README 双语更新 设计文档记录基线决策(PR #99 架构 + PR #75 点击放大)、安全模型 (strict/htmlLabels/清洗/href 剥离/无 bindFunctions)、验证与取舍。 * fix(mermaid): sanitizer 属性名大小写归一,堵住混合大小写属性绕过(CR P1) XML 属性名保留大小写,而输出会经 HTML 解析归一化(dangerouslySetInnerHTML), oNload/HREF/xlink:HREF 这类混合大小写属性此前会漏过清洗。比较前先 toLowerCase(),按原名删除;新增混合大小写事件/链接属性回归用例。 * fix(mermaid): sanitizer 元素黑名单大小写归一,堵住混合大小写元素绕过(CR P1) XML 元素名保留大小写,querySelectorAll('script') 等选择器匹配不到 <sCrIpT>/<foreignobject>,而输出经 HTML 解析会被归一化为 SVG script/foreignObject(可执行/可注入 HTML)。改为遍历全部元素按 localName.toLowerCase() 命中小写黑名单后整节点删除;新增混合大小写 script/foreignObject/外来 HTML 元素回归用例(jsdom 实证:旧选择器 命中 0,新逻辑命中 2)。 * fix(mermaid): 单次 Markdown 解析 + 占位交换(CR P1)+ CommonMark fence 规则(CR P2) P1(跨 fence 语义):弃用逐块 MarkdownText 拆分,改为整篇文档单次 MarkdownText 渲染,布局后把 language-mermaid 代码块交换为 MermaidDiagram (React 管理的 .md-code-block 宿主保留在树中、display:contents 抑制盒子, 协调安全;块不再 mermaid 时还原原 children)。新增 tests/mermaid-markdown.spec.tsx 与 e2e 跨 fence 引用链接断言。 P2(fence 匹配):支持 3+ 反引号与波浪号开 fence;闭 fence 须同字符且 长度不小于开 fence;反引号 info string 含反引号不视为开 fence;修复 带缩进开 fence 的 info 提取。blocks 单测 +3 例。
9.6 KiB
Markdown 预览 Mermaid 安全渲染设计
日期:2026-08-17 状态:已实现 范围:
dsh-better-sidebar插件,Markdown 预览 + 新mermaid懒加载 chunk 基线:社区 PR #99(feat/mermaid-md-preview)的架构与安全姿势;借鉴 PR #75(feat/mermaid-rendering)的点击放大交互
1. 背景与问题
Markdown 预览(TextEditor.tsx 的 preview 模式)直接把全文交给 DSH 的 MarkdownText(@deepseek-ai/dsh-client-ui-primitives)渲染。该渲染器的 fenced code 一律走 CodeBlock(Shiki 高亮 + 复制按钮),且不支持自定义 fence 渲染器——```mermaid 块只会显示成一段无高亮的代码,不会渲染成图(DSH 宿主内 mermaid 字样出现 0 次,Shiki 别名表也无此语言)。
社区出现过两个 mermaid PR,安全姿势差异极大:
| PR #75 | PR #99 | |
|---|---|---|
| 加载 | 运行时 jsdelivr CDN(第三方供应链、离线不可用、无版本锁) | 自有懒加载 chunk(mermaid 11.16.1 本地打包,/sidebar/bundle 同源下发) |
| 安全 | securityLevel: 'loose'(标签可注入原始 HTML)+ 无清洗 + innerHTML 直插 |
securityLevel: 'strict' + htmlLabels: false + SVG 二次清洗 + 不应用 bindFunctions |
| 测试/文档 | 无 | 单测 + e2e + 设计文档 |
| 亮点 | 点击放大弹窗(滚轮缩放/拖拽/快捷键) | 主题跟随、错误回退、CodeBlock 同款 chrome |
结论:以 #99 为基线。md 文件来自工作区(可能来自克隆仓库/下载),不可信;loose + 无清洗在 DSH GUI origin 下就是 XSS(可读会话数据)。仅借鉴 #75 的点击放大交互,在 #99 的已清洗 SVG 之上用 React 重写(克隆节点无事件面,弹窗不新增攻击面)。本地图片引用重写(#99 顺带功能)不在本 PR 范围,另行独立 PR。
2. 方案
2.1 检测:纯函数按 fence 切块
新增 src/client/mermaid-blocks.ts(splitMermaidBlocks,纯函数、无依赖),职责是检测预览是否需要 mermaid chunk:
- 逐行扫描,命中 info string 为
mermaid的围栏(大小写不敏感,支持mermaid{...}属性后缀,缩进 ≤3 空格,CommonMark 语义)即"提出"为{ kind: 'mermaid', code }块; - 其余行原样保留在
{ kind: 'markdown', text }块中(含非 mermaid 围栏); - 未闭合的 mermaid fence 吞掉文件剩余部分(与 CommonMark 对开 fence 的恢复一致);
- CommonMark fence 规则完整:开 fence 为 3+ 反引号或波浪号;闭 fence 必须同字符且长度 ≥ 开 fence;反引号 fence 的 info string 含反引号时该行不是开 fence。
TextEditor 预览时先 splitMermaidBlocks,无 mermaid 块则走原 MarkdownText 直渲路径(零行为变化);有则渲染 MermaidMarkdown(mermaid chunk 内)。
2.2 渲染架构:单次解析 + 占位替换(CR 第 3 轮 P1 修正)
早期方案把文档切成多个独立 MarkdownText 片段,会破坏跨 fence 的 Markdown 语义(引用式链接定义在 fence 之后、脚注、有序列表连续性)。当前实现:
- 整篇文档只走一次
MarkdownText(MermaidMarkdown),跨 fence 语义完整; - 布局阶段(
useLayoutEffect)扫描容器内.md-code-block,识别 mermaid 块(双通道:shiki 路径的code.language-mermaid类,或 plain 路径的 banner infostring 文本 ===mermaid——mermaid 无 shiki 语法必走 plain,语言只显示在 infostring 里),用createRoot把MermaidDiagram挂进替换后的子节点;React 管理的.md-code-block宿主节点保留在树中(仅替换其 children,CSSdisplay: contents抑制代码块自身盒子),React 协调不会丢失宿主; - 块内容变化(编辑后重新预览)按源码 diff 重渲;块不再是 mermaid fence 时还原原 children(普通代码块恢复显示);fence 消失时卸载对应 root;
- DSH
MarkdownText无代码块渲染扩展入口(且仓库硬约束禁止改 DSH),这是约束内的最小侵入方案。
2.2 懒加载 chunk:mermaid
mermaid 及其图依赖(d3 / dagre-d3-es / cytoscape 等)打包进新的 lib/client-mermaid.js(~7MB),复用既有 chunk 机制(tsdown CHUNKS + /sidebar/bundle 路由 + globalThis.__dshChunks__ 注册表 + lazyChunkComponent 懒包装),仅当预览的 md 文件实际含 mermaid fence 时才下载/执行。chunk 名在 4 处同步:tsdown.config.ts CHUNKS、src/bundle-route.ts CHUNK_NAMES、src/client/chunk-loader.ts ChunkName、package.json files(及各 spec 的硬编码清单)。构建侧对 mermaid chunk 注入 mermaidChunkAliases:把裸 uuid 说明符解析到浏览器入口(uuid/dist/index.js,Web Crypto 实现),绕过其 node 入口的 node:crypto 触发客户端纯度门。
2.3 渲染与安全(纵深防御)
mermaid.render(id, code)客户端渲染 → SVG 字符串注入;每个渲染调用用单调递增 id(文档内唯一);mermaid.initialize({ startOnLoad: false, securityLevel: 'strict', htmlLabels: false, theme })——strict 下标签被转义、click指令失效;htmlLabels: false强制节点文字走真实 SVG<text>(默认的foreignObjectHTML 标签通道会被清洗器整体删除,导致文字丢失),同时把 HTML 标签注入面关死;bindFunctions不应用(静态图,无点击交互);订阅subscribeColorScheme,深浅色切换自动按新主题重渲;- SVG 注入前二次清洗(
src/client/mermaid-sanitize.ts,独立纯函数、可单测):DOMParser(image/svg+xml)解析,删除foreignObject与script/外来 HTML 元素(img/iframe/object/embed/video/audio/input/button/form/link/meta/base),剥离@*/on*属性,移除全部href/xlink:href(静态图不需要链接,杜绝 javascript:/data: 乃至普通外链把整个 GUI 导航走);仅接受根元素为<svg>的文档;解析失败(parsererror/非 SVG 根)返回空串,调用方显示错误回退,原始字符串永不过清洗器; - 渲染失败(语法错误/清洗拒绝)展示错误条 + 原始代码块(便于对照修改),错误全文挂 tooltip;空 fence 不渲染;
- 块头保留与
CodeBlock一致的 chrome:info string「mermaid」+ 复制按钮(writeClipboard,沿用copy/copied字典)。块体带稳定锚点data-mermaid-diagram(e2e 断言用)。
2.4 点击放大弹窗(借鉴 PR #75,React 重写)
点击 [data-mermaid-diagram] 内 SVG → 克隆节点(已清洗,无事件属性)→ portalled fixed overlay(data-mermaid-modal):
- 滚轮缩放(以鼠标位置为不动点,被动标志关闭防止页面滚动);
- 拖拽平移(mousedown/mousemove/mouseup on window);
- 工具栏按钮:− / + / ⟳ / ✕(i18n title);
- 键盘:
+/=放大、-缩小、0重置、Esc关闭; - 背景点击关闭;卸载时移除全部监听;
- 样式全部走 CSS Module + DSH 令牌(遮罩
--dsw-alias-bg-mask-1+blur(2px),与 DSHModal一致;z-index 1000 落在 DSH 浮层栈)。
3. 验证
- 单测:
tests/mermaid-blocks.spec.ts(14 例:fence 识别大小写/属性/缩进、4 反引号/波浪号、同字符且不短于开 fence 的闭 fence、非 mermaid 围栏不动、交错顺序、开 fence 恢复、空文件);tests/mermaid-sanitize.spec.ts(10 例 XSS:foreignObject/script/外来元素整节点剥离(含混合大小写元素名与属性名)、on*/@*剥离、href/xlink:href 全删、畸形 XML → 空串、非 SVG 根 → 空串、良性图保真);tests/mermaid-markdown.spec.tsx(3 例架构回归:mermaid 块被交换为图、跨 fence 引用式链接在单次解析下解析、非 mermaid 围栏不动); - 产物契约:
chunk-artifact.spec.ts(mermaid chunk 执行 + registry 槽位 + 平台 externals require,addEventListenerstub)/manifest-consistency.spec.ts/bundle-route.spec.ts的 chunk 清单同步加入mermaid; - 挂载冒烟:
tests/e2e/mount.e2e.ts新增 seeddiagram.md,打开后强制client-mermaid.js往返、断言[data-mermaid-diagram] svg含节点文字(真实<text>)、跨 fence 引用式链接解析为真实锚点(单次解析证明)、点击弹窗出现且 Esc 关闭、预览模式编辑器隐藏;缺 chunk、文字丢失、语义破坏、弹窗失效任一即红; - 现有
markdown-copy-labels.spec.tsx不回归(无 mermaid 文件走原路径)。
4. 风险与取舍
- 编辑器 chunk 之外新增 ~7MB 懒 chunk(mermaid + d3/dagre/cytoscape/katex 图依赖);仅预览含 mermaid fence 的 md 时下载,启动与普通文本打开路径零成本;
splitMermaidBlocks对"mermaid 围栏嵌在其他代码块内"的极边缘情况会误提(只影响是否走 mermaid 路径,不影响渲染正确性)——预览级工具接受该取舍(与常见 md 渲染器行为一致);- 占位替换依赖 DSH
MarkdownText的 DOM 输出结构(.md-code-block+language-mermaid):宿主节点保留在 React 树中、仅换 children,协调安全;若未来 DSH 渲染器结构变化,data-mermaid-processed扫描点需要同步(有 e2e 兜底); - 清洗器删除全部
href/xlink:href:<use href>类图标引用可能不显示(纯装饰性损失),换取确定性的无导航/无脚本面; - mermaid 官方核心包持续演化,锁
^11.16.1,升级靠 Renovate 常规流程; - 回滚:删除 chunk 三处声明 +
files条目 +TextEditor分支即可回到纯代码块展示。