feat(mermaid): Markdown 预览安全渲染 Mermaid 图表(strict 清洗 + 懒加载 chunk + 点击放大) (#164)
* 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 例。
This commit is contained in:
@@ -1290,6 +1290,174 @@
|
||||
font: var(--dsw-font-xs-13);
|
||||
}
|
||||
|
||||
/* ── Mermaid diagram block (markdown preview, mermaid lazy chunk) ────────
|
||||
Mirrors the md-code-block chrome the DSH CodeBlock renders, so a diagram
|
||||
reads as a peer of the surrounding fences: hairline border, layer-2
|
||||
header with the info string + copy action, layer-1 body. The SVG body is
|
||||
static (no bindFunctions) and sanitized (no foreignObject/script/@attrs/
|
||||
hrefs), so it carries no event surface beyond the copy button; the body
|
||||
click opens the zoom modal on a sanitized clone. */
|
||||
|
||||
.mermaidWrap {
|
||||
margin: 6px 0;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.mermaidHeader {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 6px;
|
||||
padding: 4px 8px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
}
|
||||
|
||||
.mermaidInfo {
|
||||
font: var(--dsw-font-xxxs-strong-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.mermaidCopy {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 4px;
|
||||
padding: 0 6px;
|
||||
height: 20px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.mermaidCopy:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
.mermaidBody {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
padding: 10px;
|
||||
overflow: auto;
|
||||
cursor: zoom-in;
|
||||
}
|
||||
|
||||
.mermaidBody :global(svg) {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
|
||||
.mermaidError {
|
||||
padding: 6px 10px;
|
||||
border-bottom: 1px solid var(--dsw-alias-border-l1);
|
||||
color: var(--dsw-alias-state-error-primary);
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
}
|
||||
|
||||
/* The diagram source shown under a render error (helps fixing the fence). */
|
||||
.mermaidCode {
|
||||
margin: 0;
|
||||
padding: 8px 10px;
|
||||
overflow: auto;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
|
||||
}
|
||||
|
||||
/* The mermaid markdown container (single MarkdownText pass): a swapped
|
||||
code block keeps its React-managed .md-code-block wrapper in the tree
|
||||
(reconciliation safety), but the wrapper's own box is suppressed so only
|
||||
the diagram chrome renders; the swapped-in host div flows as a block. */
|
||||
.mermaidMarkdown :global(.md-code-block[data-mermaid-processed]) {
|
||||
display: contents;
|
||||
}
|
||||
|
||||
/* ── Mermaid zoom modal (click-to-enlarge; portalled to document.body) ──
|
||||
Fixed overlay above the DSH overlay stack (1000+), token-driven surface.
|
||||
The stage centers the (sanitized, cloned) svg and the modal owns the
|
||||
transform; the drag/zoom listeners live in the component, nothing here
|
||||
targets global class names. */
|
||||
|
||||
.mermaidModal {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 1000;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
background: var(--dsw-alias-bg-mask-1);
|
||||
backdrop-filter: blur(2px);
|
||||
}
|
||||
|
||||
.mermaidModalToolbar {
|
||||
position: absolute;
|
||||
top: 16px;
|
||||
right: 16px;
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
z-index: 10;
|
||||
}
|
||||
|
||||
.mermaidModalButton {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
font: var(--dsw-font-xs-strong-13);
|
||||
cursor: pointer;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mermaidModalButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
}
|
||||
|
||||
.mermaidModalStage {
|
||||
width: 90vw;
|
||||
height: 80vh;
|
||||
overflow: hidden;
|
||||
position: relative;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.mermaidModalStage :global(svg) {
|
||||
max-width: none;
|
||||
max-height: none;
|
||||
cursor: grab;
|
||||
transform-origin: center center;
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
padding: 16px;
|
||||
border-radius: 12px;
|
||||
}
|
||||
|
||||
.mermaidModalStage :global(svg):active {
|
||||
cursor: grabbing;
|
||||
}
|
||||
|
||||
.mermaidModalHint {
|
||||
position: absolute;
|
||||
bottom: 16px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* The floating "add to conversation" button anchored above a text
|
||||
selection in the preview/code viewers. Portalled to document.body and
|
||||
position:fixed, so the editor's overflow clips cannot crop it; flat
|
||||
|
||||
Reference in New Issue
Block a user