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:
@@ -21,7 +21,7 @@
|
||||
|
||||
## ✨ 功能一览
|
||||
|
||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown / HTML / PDF / Office 内联预览
|
||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF / Office 内联预览
|
||||
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
|
||||
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
|
||||
@@ -29,7 +29,7 @@
|
||||
- **🪟 双工作台**:右侧栏 + 底部面板;拖 Tab 拆分 / 合并分栏(可跨面板),移动端自动合并全宽抽屉
|
||||
- **🔁 会话隔离**:布局 / Tab / 面板按会话持久化,陈旧状态自动净化
|
||||
- **⚙️ 声明式设置**:设置页「侧边卡片」逐项独立开关,二级设置经齿轮弹窗
|
||||
- **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md))
|
||||
- **⚡ 按需加载**:启动只拉 ~325KB 核心,终端 / 编辑器 / Mermaid 图表等重依赖用到才按需拉取([设计文档](docs/plans/2026-08-12-lazy-chunks-design.md))
|
||||
- **🌏 多语言**:界面文案跟随 DSH 语言(zh / en)实时切换
|
||||
|
||||
> 🔌 **核心理念**:服务优先——内置的 7 tab + 6 viewer 与第三方插件通过同一套 `ctx.betterSidebar` API 注册,能力完全对等;官方不再内置、可由生态提供的功能,交由生态插件实现。接入文档见下方「🔌 服务化」与 [外部插件接入指南](./docs/external-plugin-guide.md)。
|
||||
|
||||
+2
-2
@@ -21,7 +21,7 @@
|
||||
|
||||
## ✨ Features
|
||||
|
||||
- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown / HTML / PDF / Office
|
||||
- **🗂️ File Workbench**: file explorer (lazy-loading tree; symlinks show their target kind — directory links expand, dangling links flagged) + CodeMirror editor; inline preview for images / Markdown (incl. Mermaid diagrams, strict-mode safe rendering + click-to-zoom) / HTML / PDF / Office
|
||||
- **🌐 Embedded Browser**: multiple web tabs with back / forward / refresh; content runs in a sandboxed iframe; external links are routed by protocol by default — HTTP opens in the sidebar, HTTPS goes to the system browser (both adjustable in settings)
|
||||
- **💻 Real Terminal**: xterm.js + node-pty real shell, reconnect with transcript replay; optionally injects `terminal_*` tools for the model
|
||||
- **🌿 Git Panel**: real diff + VSCode-style diff tabs, history, right-click to stage / commit / revert
|
||||
@@ -29,7 +29,7 @@
|
||||
- **🪟 Dual Workbench**: right sidebar + bottom panel; drag tabs to split / merge panes (cross-panel), mobile auto-merges into a full-width drawer
|
||||
- **🔁 Session Isolation**: layout / tabs / panels persisted per session, stale state auto-purged
|
||||
- **⚙️ Declarative Settings**: per-item toggles in the "Side Cards" settings section, secondary settings via the gear dialog
|
||||
- **⚡ On-demand Loading**: only ~325KB core at startup; heavy deps (terminal / editor) load on demand ([design](docs/plans/2026-08-12-lazy-chunks-design.md))
|
||||
- **⚡ On-demand Loading**: only ~325KB core at startup; heavy deps (terminal / editor / mermaid diagrams) load on demand ([design](docs/plans/2026-08-12-lazy-chunks-design.md))
|
||||
- **🌏 i18n**: UI text follows DSH's language (zh / en) with live switching
|
||||
|
||||
> 🔌 **Core principle**: service-first — the 7 built-in tabs + 6 viewers register through the same `ctx.betterSidebar` API as third-party plugins, with fully equal capabilities; anything the ecosystem can provide better is delegated to ecosystem plugins. See the "🔌 Service" section below and the [external plugin guide](./docs/external-plugin-guide.md).
|
||||
|
||||
@@ -46,6 +46,7 @@ chunk 脚本**不经过** `window.__ModuleLoader__` / `ClientModuleSystem.import
|
||||
| `pptx` | `src/client/chunks/pptx.tsx` | `PptxView` + pptx-renderer | lib/client-pptx.js(~2.4MB) | 打开 .pptx |
|
||||
| `terminal` | `src/client/chunks/terminal.tsx` | `TerminalView` + xterm + addon-fit + xterm.css | lib/client-terminal.js(~450KB) | 打开终端 tab |
|
||||
| `editor` | `src/client/chunks/editor.tsx` | `TextEditor` + @codemirror/* + lang.ts + cm-themes | lib/client-editor.js(~1.6MB) | 打开 code/md/html 文件 |
|
||||
| `mermaid` | `src/client/chunks/mermaid.tsx` | `MermaidBlocks` + mermaid + d3/dagre/cytoscape | lib/client-mermaid.js(~7MB) | 预览含 mermaid fence 的 md |
|
||||
| 核心 | `src/client/index.tsx` | 其余全部 | lib/client.js(~325KB) | 启动 |
|
||||
|
||||
关键拆分:**`office-view.tsx` 拆为 `docx-view.tsx` + `xlsx-view.tsx` + `office-shared.tsx`**(共享 `LoadState`/`BinaryFallback`)。若不拆,docx chunk 会经共享文件静态拖入 `xlsx-to-univer` → Univer 14.5MB。原路径 `office-view.tsx` 保留为 re-export 垫片,兼容 `./src/*` 深导入。
|
||||
|
||||
@@ -0,0 +1,82 @@
|
||||
# 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,CSS `display: 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>`(默认的 `foreignObject` HTML 标签通道会被清洗器整体删除,导致文字丢失),同时把 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)`,与 DSH `Modal` 一致;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,`addEventListener` stub)/ `manifest-consistency.spec.ts` / `bundle-route.spec.ts` 的 chunk 清单同步加入 `mermaid`;
|
||||
- 挂载冒烟:`tests/e2e/mount.e2e.ts` 新增 seed `diagram.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` 分支即可回到纯代码块展示。
|
||||
@@ -60,6 +60,7 @@
|
||||
"lib/client-registry.js",
|
||||
"lib/client-terminal.js",
|
||||
"lib/client-editor.js",
|
||||
"lib/client-mermaid.js",
|
||||
"lib/types/**/*.d.ts",
|
||||
"src",
|
||||
"scripts/install.sh",
|
||||
@@ -130,6 +131,7 @@
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"clsx": "^2.1.1",
|
||||
"mermaid": "^11.16.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"rxjs": "^7.8.2",
|
||||
"schemastery": "^3.18.0",
|
||||
|
||||
Generated
+803
File diff suppressed because it is too large
Load Diff
+1
-1
@@ -19,7 +19,7 @@ import { fileURLToPath } from 'node:url'
|
||||
import type { Context, SidebarHttpRequest, SidebarHttpResponse } from './context-types.ts'
|
||||
|
||||
/** The chunk names the client may request (mirror of src/client/chunk-loader.ts). */
|
||||
export const CHUNK_NAMES = ['terminal', 'editor'] as const
|
||||
export const CHUNK_NAMES = ['terminal', 'editor', 'mermaid'] as const
|
||||
export type ChunkName = (typeof CHUNK_NAMES)[number]
|
||||
|
||||
/** Directory of this host-half module (lib/ — the chunk scripts live next to it). */
|
||||
|
||||
@@ -13,7 +13,8 @@
|
||||
* the FileViewerProps toolbar callbacks so the host's path-input header
|
||||
* renders the controls instead.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react'
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ComponentType } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
@@ -27,6 +28,8 @@ import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
||||
import { appendToDraft } from './conversation-draft.ts'
|
||||
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
|
||||
import { lazyChunkComponent } from './lazy-chunk.tsx'
|
||||
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { EditorToolbarState, FileViewerProps } from './service.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -41,6 +44,16 @@ interface SelectionPopup {
|
||||
top: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The chunk-resident markdown preview renderer (mermaid lazy chunk): one
|
||||
* MarkdownText pass over the whole source, with rendered mermaid fences
|
||||
* swapped for diagrams. Module-level `pick` keeps the load effect stable.
|
||||
*/
|
||||
const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
|
||||
'mermaid',
|
||||
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
|
||||
)
|
||||
|
||||
/**
|
||||
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
|
||||
* preview must stay in an opaque origin — with the route's own origin it
|
||||
@@ -237,6 +250,19 @@ export function TextEditor(props: FileViewerProps) {
|
||||
|
||||
const markdown = viewerId === 'markdown'
|
||||
const html = viewerId === 'html'
|
||||
/** The markdown source the preview renders (draft wins over saved content). */
|
||||
const mdText = draft ?? content ?? ''
|
||||
/** md/mermaid block split for the preview (mermaid fences lift out). Split
|
||||
* only in preview mode: edit-mode keystrokes must not re-scan the source. */
|
||||
const mdBlocks = useMemo(
|
||||
() => (markdown && mode === 'preview' ? splitMermaidBlocks(mdText) : []),
|
||||
[markdown, mode, mdText],
|
||||
)
|
||||
const hasMermaid = useMemo(
|
||||
() => mdBlocks.some(block => block.kind === 'mermaid'),
|
||||
[mdBlocks],
|
||||
)
|
||||
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
|
||||
|
||||
/**
|
||||
* Selection popup for the markdown preview: a mouse-up inside the preview
|
||||
@@ -263,7 +289,7 @@ export function TextEditor(props: FileViewerProps) {
|
||||
return
|
||||
}
|
||||
const rect = sel.getRangeAt(0).getBoundingClientRect()
|
||||
const lines = linesOfSelection(draft ?? content ?? '', text)
|
||||
const lines = linesOfSelection(mdText, text)
|
||||
showPopup(
|
||||
buildSelectionInsert(path, scope.cwd, lines ?? undefined, text),
|
||||
rect.left + rect.width / 2,
|
||||
@@ -361,11 +387,13 @@ export function TextEditor(props: FileViewerProps) {
|
||||
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
|
||||
fall back to hardcoded Chinese otherwise (same pattern as the
|
||||
chat's AssistantMarkdown). Render-time t() keeps them following
|
||||
the active locale on live switches. */}
|
||||
<MarkdownText
|
||||
text={draft ?? content ?? ''}
|
||||
codeLabels={{ copyLabel: t('copy'), copiedLabel: t('copied') }}
|
||||
/>
|
||||
the active locale on live switches. Mermaid fences hand the
|
||||
whole document to the mermaid lazy chunk (single markdown
|
||||
parse; cross-fence references/footnotes stay intact); files
|
||||
without one render exactly as before. */}
|
||||
{hasMermaid
|
||||
? <LazyMermaidMarkdown text={mdText} codeLabels={codeLabels} />
|
||||
: <MarkdownText text={mdText} codeLabels={codeLabels} />}
|
||||
</div>
|
||||
)}
|
||||
{html && mode === 'preview' && (
|
||||
|
||||
@@ -44,7 +44,7 @@
|
||||
* open. Chunk-only source edits still need a manual page refresh (the HMR
|
||||
* poll watches only client.js).
|
||||
*/
|
||||
export type ChunkName = 'terminal' | 'editor'
|
||||
export type ChunkName = 'terminal' | 'editor' | 'mermaid'
|
||||
|
||||
/** The module exports a chunk factory provides (namespace-ish record). */
|
||||
export type ChunkExports = Record<string, unknown>
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
/**
|
||||
* Lazy chunk entry: the mermaid diagram renderer for the markdown preview
|
||||
* (mermaid + its d3/dagre/cytoscape graph deps). Built as
|
||||
* lib/client-mermaid.js and registered under the `mermaid` global chunk
|
||||
* slot — fetched only when a previewed markdown file contains a mermaid
|
||||
* fence (see chunk-loader.ts and the mermaid markdown preview design doc).
|
||||
* Never import this module from the core bundle: it pulls mermaid into the
|
||||
* startup path.
|
||||
*/
|
||||
export { MermaidMarkdown } from '../mermaid.tsx'
|
||||
@@ -49,6 +49,11 @@ export const zh = {
|
||||
terminalDepsProfile: '(检测到 profile:{profile})',
|
||||
preview: '预览',
|
||||
edit: '编辑',
|
||||
mermaidError: 'Mermaid 渲染失败',
|
||||
mermaidZoomIn: '放大',
|
||||
mermaidZoomOut: '缩小',
|
||||
mermaidZoomReset: '重置',
|
||||
mermaidZoomHint: '滚轮缩放 · 拖拽平移 · Esc 关闭',
|
||||
refresh: '刷新',
|
||||
save: '保存',
|
||||
saved: '已保存',
|
||||
@@ -291,6 +296,11 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
terminalDepsProfile: ' (detected profile: {profile})',
|
||||
preview: 'Preview',
|
||||
edit: 'Edit',
|
||||
mermaidError: 'Mermaid render failed',
|
||||
mermaidZoomIn: 'Zoom in',
|
||||
mermaidZoomOut: 'Zoom out',
|
||||
mermaidZoomReset: 'Reset',
|
||||
mermaidZoomHint: 'Scroll to zoom · drag to pan · Esc to close',
|
||||
refresh: 'Refresh',
|
||||
save: 'Save',
|
||||
saved: 'Saved',
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
/**
|
||||
* Markdown/mermaid fence detection for the markdown preview. The preview
|
||||
* renders the WHOLE document through one DSH `MarkdownText` pass (so
|
||||
* cross-fence semantics — reference-style links, footnotes, list
|
||||
* continuity — stay intact) and the mermaid lazy chunk then swaps the
|
||||
* rendered `language-mermaid` code blocks for diagrams. This module's pure
|
||||
* splitter exists to detect whether the source contains a mermaid fence at
|
||||
* all, so the mermaid chunk is only fetched when needed (unit-tested in
|
||||
* tests/mermaid-blocks.spec.ts).
|
||||
*/
|
||||
|
||||
/** One fenced mermaid diagram lifted out of the markdown source. */
|
||||
export interface MermaidBlock {
|
||||
kind: 'mermaid'
|
||||
/** The raw diagram source between the fences (info string stripped). */
|
||||
code: string
|
||||
}
|
||||
|
||||
/** A span of plain markdown source (may itself contain non-mermaid fences). */
|
||||
export interface MarkdownBlock {
|
||||
kind: 'markdown'
|
||||
text: string
|
||||
}
|
||||
|
||||
export type MdBlock = MarkdownBlock | MermaidBlock
|
||||
|
||||
/** Props of the chunk-resident `MermaidMarkdown` component (shared contract). */
|
||||
export interface MermaidMarkdownProps {
|
||||
/** The full markdown source (rendered in a single MarkdownText pass). */
|
||||
text: string
|
||||
codeLabels: { copyLabel: string; copiedLabel: string }
|
||||
}
|
||||
|
||||
/** CommonMark opening fence: 0-3 spaces indent + a run of 3+ backticks or tildes. */
|
||||
const OPEN_FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
|
||||
|
||||
/** A closing-fence line: 0-3 spaces indent + 3+ backticks/tildes + trailing spaces only. */
|
||||
const CLOSE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
|
||||
|
||||
/** Parse the info string from the line tail after the fence run; null when invalid. */
|
||||
function fenceInfo(rest: string, fence: string): string | null {
|
||||
const info = rest.trimStart().split(/\s+/)[0] ?? ''
|
||||
// CommonMark: a backtick fence's info string may not contain backticks —
|
||||
// such a line is not an opening fence at all.
|
||||
if (fence.charAt(0) === '`' && info.includes('`')) return null
|
||||
return info
|
||||
}
|
||||
|
||||
/** True when the fence info string names mermaid (bare or `mermaid{...}`). */
|
||||
function isMermaidInfo(info: string): boolean {
|
||||
const word = info.toLowerCase()
|
||||
return word === 'mermaid' || word.startsWith('mermaid{')
|
||||
}
|
||||
|
||||
/**
|
||||
* Split markdown source into md/mermaid blocks for detection: only fences
|
||||
* whose info string names mermaid are lifted; every other line stays in the
|
||||
* markdown stream untouched. CommonMark fence rules are honored — opening
|
||||
* fences of 3+ backticks OR tildes, and a closing fence must use the same
|
||||
* character with at least as many characters as the opening fence. An
|
||||
* unterminated mermaid fence swallows the rest of the file (the same
|
||||
* recovery CommonMark applies to open fences).
|
||||
*/
|
||||
export function splitMermaidBlocks(text: string): MdBlock[] {
|
||||
if (text === '') return []
|
||||
const lines = text.split('\n')
|
||||
const blocks: MdBlock[] = []
|
||||
let markdown: string[] = []
|
||||
let index = 0
|
||||
const flushMarkdown = (): void => {
|
||||
if (markdown.length === 0) return
|
||||
blocks.push({ kind: 'markdown', text: markdown.join('\n') })
|
||||
markdown = []
|
||||
}
|
||||
while (index < lines.length) {
|
||||
const line = lines[index] ?? ''
|
||||
const fenceMatch = OPEN_FENCE_RE.exec(line)
|
||||
if (fenceMatch === null) {
|
||||
markdown.push(line)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
const fence = fenceMatch[1]!
|
||||
// The line tail after the whole matched prefix (indent + fence run).
|
||||
const rest = line.slice(fenceMatch.index + fenceMatch[0].length)
|
||||
const info = fenceInfo(rest, fence)
|
||||
if (info === null || !isMermaidInfo(info)) {
|
||||
markdown.push(line)
|
||||
index += 1
|
||||
continue
|
||||
}
|
||||
flushMarkdown()
|
||||
const char = fence.charAt(0)
|
||||
const length = fence.length
|
||||
const code: string[] = []
|
||||
index += 1
|
||||
while (index < lines.length) {
|
||||
const candidate = lines[index] ?? ''
|
||||
const close = CLOSE_FENCE_RE.exec(candidate)
|
||||
if (close !== null && close[1]!.charAt(0) === char && close[1]!.length >= length) break
|
||||
code.push(candidate)
|
||||
index += 1
|
||||
}
|
||||
// Consume the closing fence (or run off the end on an open fence).
|
||||
index += 1
|
||||
blocks.push({ kind: 'mermaid', code: code.join('\n') })
|
||||
}
|
||||
flushMarkdown()
|
||||
return blocks
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* SVG sanitization for mermaid-rendered diagrams (markdown preview). The
|
||||
* diagrams come from untrusted markdown sources, so the emitted SVG is
|
||||
* re-sanitized before it reaches dangerouslySetInnerHTML — defense in depth
|
||||
* on top of mermaid's own `securityLevel: 'strict'` (labels escaped, click
|
||||
* directives inert) and `htmlLabels: false` (labels as real SVG <text>).
|
||||
*
|
||||
* What is stripped, and why:
|
||||
* - `foreignObject`: the only channel that can carry raw HTML inside an
|
||||
* SVG document — with it gone, a hostile label cannot smuggle an
|
||||
* <img onerror> / <iframe> through the XML parse (any non-SVG element
|
||||
* outside foreignObject makes the XML parse fail and the whole diagram
|
||||
* is rejected).
|
||||
* - `script` and foreign HTML elements (`img`/`iframe`/`object`/`embed`/
|
||||
* `video`/`audio`/`input`/`button`/`form`/`link`/`meta`/`base`): belt and
|
||||
* braces — real browsers reject these outside foreignObject at XML parse
|
||||
* time (→ '') while lenient parsers keep them, so they are stripped
|
||||
* explicitly instead of relying on the parser alone. Element names are
|
||||
* matched case-insensitively (XML preserves their case; the output is
|
||||
* re-parsed as HTML whose parser normalizes tag casing, so `<sCrIpT>` /
|
||||
* `<foreignobject>` would otherwise come back as script/foreignObject).
|
||||
* - `@*` / `on*` attributes: event-handler channels (Vue-style directives
|
||||
* and native listeners), matched case-insensitively — the output is
|
||||
* re-parsed as HTML whose parser normalizes attribute casing, so
|
||||
* `oNload`/`OnClick` must not survive either.
|
||||
* - ALL `href` / `xlink:href` attributes: the diagrams are static (no
|
||||
* bindFunctions, no click navigation), so links carry no value — and an
|
||||
* attacker-controlled URL (even an http(s) one) could otherwise navigate
|
||||
* the whole GUI away from the sidebar. Removing every href keeps the
|
||||
* surface deterministic.
|
||||
*
|
||||
* Parsing happens with `image/svg+xml`, so a malformed SVG fails the XML
|
||||
* parse and the function returns '' (the caller surfaces the error
|
||||
* fallback) instead of ever passing the raw string through; only a document
|
||||
* whose root is an `<svg>` element is accepted.
|
||||
*/
|
||||
/** Element local names stripped case-insensitively (all lowercase). */
|
||||
const STRIP_ELEMENTS = new Set([
|
||||
'foreignobject',
|
||||
'script',
|
||||
'img',
|
||||
'iframe',
|
||||
'object',
|
||||
'embed',
|
||||
'video',
|
||||
'audio',
|
||||
'input',
|
||||
'button',
|
||||
'form',
|
||||
'link',
|
||||
'meta',
|
||||
'base',
|
||||
])
|
||||
|
||||
/** A parse failure keeps nothing of the input: the caller shows the error. */
|
||||
export function sanitizeSvg(svg: string): string {
|
||||
if (typeof DOMParser === 'undefined' || typeof XMLSerializer === 'undefined') return ''
|
||||
let doc: Document
|
||||
try {
|
||||
doc = new DOMParser().parseFromString(svg, 'image/svg+xml')
|
||||
} catch {
|
||||
return ''
|
||||
}
|
||||
if (doc.querySelector('parsererror') !== null) return ''
|
||||
if (doc.documentElement === null || doc.documentElement.localName !== 'svg') return ''
|
||||
doc.querySelectorAll('*').forEach((node) => {
|
||||
// XML element names preserve case; the string is later re-parsed as HTML
|
||||
// whose parser normalizes tag casing — so mixed-case spellings of the
|
||||
// strip list (e.g. `<sCrIpT>`, `<foreignobject>`) must be caught the
|
||||
// same way as their canonical forms.
|
||||
if (STRIP_ELEMENTS.has(node.localName.toLowerCase())) {
|
||||
node.remove()
|
||||
return
|
||||
}
|
||||
for (const attribute of [...node.attributes]) {
|
||||
const name = attribute.name
|
||||
// XML attribute names preserve case; the string is later re-parsed as
|
||||
// HTML (dangerouslySetInnerHTML), whose parser normalizes attribute
|
||||
// casing — so a mixed-case `oNload`/`HREF` must be caught the same way
|
||||
// as its lowercase form. Compare on the lowercased name, remove with
|
||||
// the original.
|
||||
const normalized = name.toLowerCase()
|
||||
if (normalized.startsWith('@') || normalized.startsWith('on')) {
|
||||
node.removeAttribute(name)
|
||||
continue
|
||||
}
|
||||
if (normalized === 'href' || normalized === 'xlink:href') {
|
||||
node.removeAttribute(name)
|
||||
}
|
||||
}
|
||||
})
|
||||
return new XMLSerializer().serializeToString(doc.documentElement)
|
||||
}
|
||||
@@ -0,0 +1,401 @@
|
||||
/**
|
||||
* Mermaid diagram renderer for the markdown preview, resident in the
|
||||
* `mermaid` lazy chunk (src/client/chunks/mermaid.tsx): the mermaid library
|
||||
* and its transitive graph deps (d3/dagre/cytoscape) are inlined into
|
||||
* lib/client-mermaid.js and fetched only when a previewed markdown file
|
||||
* actually contains a mermaid fence (see mermaid-blocks.ts).
|
||||
*
|
||||
* Rendering architecture: the whole document is rendered ONCE through the
|
||||
* DSH `MarkdownText` (so cross-fence semantics — reference-style links,
|
||||
* footnotes, ordered-list continuity — stay intact), then a layout effect
|
||||
* swaps every rendered `language-mermaid` CodeBlock for a diagram. The
|
||||
* swap keeps the React-managed `.md-code-block` host node in the tree and
|
||||
* only replaces its children (display:contents suppresses the code-block
|
||||
* chrome), so React reconciliation never loses the host; when a swapped
|
||||
* block stops being a mermaid fence, the original children are restored.
|
||||
*
|
||||
* Security: mermaid.render → sanitized SVG injected into the block.
|
||||
* `bindFunctions` is intentionally NOT applied (static diagrams; no
|
||||
* flowchart click handlers), `securityLevel` stays 'strict' (labels are
|
||||
* escaped, no raw-HTML foreignObject), `htmlLabels: false` keeps node text
|
||||
* as real SVG <text>, and the emitted SVG is re-sanitized (see
|
||||
* mermaid-sanitize.ts) before it reaches dangerouslySetInnerHTML. Clicking
|
||||
* a rendered diagram opens a zoom/pan modal (borrowed interaction design
|
||||
* from the mermaid PR #75, reimplemented on top of the sanitized SVG —
|
||||
* the clone carries no event surface, so the modal adds no attack surface).
|
||||
*/
|
||||
import { useCallback, useEffect, useLayoutEffect, useRef, useState, type MouseEvent as ReactMouseEvent } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import mermaid from 'mermaid'
|
||||
import { IconCopyOutline16, MarkdownText, writeClipboard } from '@deepseek-ai/dsh-client-ui-primitives'
|
||||
import { isDarkScheme, subscribeColorScheme } from './theme.ts'
|
||||
import { t } from './locales.ts'
|
||||
import { sanitizeSvg } from './mermaid-sanitize.ts'
|
||||
import type { MermaidMarkdownProps } from './mermaid-blocks.ts'
|
||||
import css from './sidebar.module.css'
|
||||
|
||||
/** Monotonic id seed: every render call gets a fresh, document-unique id. */
|
||||
let mermaidSeq = 0
|
||||
|
||||
/** Configure mermaid for the current color scheme (idempotent). */
|
||||
function configureMermaid(): void {
|
||||
mermaid.initialize({
|
||||
startOnLoad: false,
|
||||
securityLevel: 'strict',
|
||||
// Labels as real SVG <text>: mermaid's default htmlLabels renders node
|
||||
// text inside <foreignObject>, which the sanitizer strips wholesale —
|
||||
// forcing pure SVG text keeps labels visible and the HTML label channel
|
||||
// closed (strict already escapes label content).
|
||||
htmlLabels: false,
|
||||
theme: isDarkScheme() ? 'dark' : 'default',
|
||||
})
|
||||
}
|
||||
|
||||
/** First lines of a mermaid error (its dumps are huge; the head explains). */
|
||||
function summarizeError(error: unknown): string {
|
||||
const message = error instanceof Error ? error.message : String(error)
|
||||
return message.split('\n').slice(0, 6).join('\n')
|
||||
}
|
||||
|
||||
/** The zoom/pan modal for one rendered diagram (click-to-enlarge). */
|
||||
function MermaidZoomModal({ svg, onClose }: { svg: SVGSVGElement; onClose: () => void }): React.ReactNode {
|
||||
const overlayRef = useRef<HTMLDivElement>(null)
|
||||
const stageRef = useRef<HTMLDivElement>(null)
|
||||
const svgRef = useRef<SVGSVGElement | null>(null)
|
||||
const dragRef = useRef({ active: false, startX: 0, startY: 0 })
|
||||
const zoomRef = useRef({ scale: 1, tx: 0, ty: 0 })
|
||||
|
||||
const applyTransform = (): void => {
|
||||
const node = svgRef.current
|
||||
if (node === null) return
|
||||
const { scale, tx, ty } = zoomRef.current
|
||||
node.style.transform = `translate(${tx}px, ${ty}px) scale(${scale})`
|
||||
}
|
||||
|
||||
/** Zoom by `delta` keeping the stage point (centerX/centerY) fixed. */
|
||||
const zoom = useCallback((delta: number, centerX?: number, centerY?: number): void => {
|
||||
const stage = stageRef.current
|
||||
if (stage === null) return
|
||||
const rect = stage.getBoundingClientRect()
|
||||
const cx = centerX ?? rect.width / 2
|
||||
const cy = centerY ?? rect.height / 2
|
||||
const current = zoomRef.current
|
||||
const newScale = Math.min(8, Math.max(0.2, current.scale * delta))
|
||||
// The svg is flex-centered in the stage, so its center sits at
|
||||
// (rect.width/2, rect.height/2). Solve for the translate that keeps the
|
||||
// mouse point stationary in stage coordinates across the scale change.
|
||||
const sx = rect.width / 2
|
||||
const sy = rect.height / 2
|
||||
const ratio = newScale / current.scale
|
||||
current.tx = cx - sx - (cx - sx - current.tx) * ratio
|
||||
current.ty = cy - sy - (cy - sy - current.ty) * ratio
|
||||
current.scale = newScale
|
||||
applyTransform()
|
||||
}, [])
|
||||
|
||||
const reset = useCallback((): void => {
|
||||
zoomRef.current = { scale: 1, tx: 0, ty: 0 }
|
||||
applyTransform()
|
||||
}, [])
|
||||
|
||||
const close = useCallback((): void => { onClose() }, [onClose])
|
||||
|
||||
// Mount the caller-provided (sanitized) svg clone imperatively: React
|
||||
// types don't accept a raw DOM node as a child, and the modal owns the
|
||||
// node's lifetime (removed on unmount; the preview copy is untouched).
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current
|
||||
if (stage === null) return
|
||||
svgRef.current = svg
|
||||
stage.appendChild(svg)
|
||||
return () => {
|
||||
svg.remove()
|
||||
svgRef.current = null
|
||||
}
|
||||
}, [svg])
|
||||
|
||||
useEffect(() => {
|
||||
const stage = stageRef.current
|
||||
const node = svgRef.current
|
||||
const overlay = overlayRef.current
|
||||
if (stage === null || node === null || overlay === null) return
|
||||
|
||||
const onWheel = (event: WheelEvent): void => {
|
||||
event.preventDefault()
|
||||
const rect = stage.getBoundingClientRect()
|
||||
zoom(event.deltaY < 0 ? 1.1 : 1 / 1.1, event.clientX - rect.left, event.clientY - rect.top)
|
||||
}
|
||||
const onKey = (event: KeyboardEvent): void => {
|
||||
if (event.key === 'Escape') { close(); return }
|
||||
if (event.key === '+' || event.key === '=') zoom(1.2)
|
||||
else if (event.key === '-') zoom(1 / 1.2)
|
||||
else if (event.key === '0') reset()
|
||||
}
|
||||
const onMouseDown = (event: MouseEvent): void => {
|
||||
event.preventDefault()
|
||||
dragRef.current = {
|
||||
active: true,
|
||||
startX: event.clientX - zoomRef.current.tx,
|
||||
startY: event.clientY - zoomRef.current.ty,
|
||||
}
|
||||
}
|
||||
const onMouseMove = (event: MouseEvent): void => {
|
||||
if (!dragRef.current.active) return
|
||||
zoomRef.current.tx = event.clientX - dragRef.current.startX
|
||||
zoomRef.current.ty = event.clientY - dragRef.current.startY
|
||||
applyTransform()
|
||||
}
|
||||
const onMouseUp = (): void => { dragRef.current.active = false }
|
||||
const onOverlayClick = (event: MouseEvent): void => {
|
||||
if (event.target === overlay) close()
|
||||
}
|
||||
|
||||
// React's synthetic wheel is passive; a native listener is required to
|
||||
// preventDefault (the page must not scroll while zooming the modal).
|
||||
stage.addEventListener('wheel', onWheel, { passive: false })
|
||||
node.addEventListener('mousedown', onMouseDown)
|
||||
window.addEventListener('mousemove', onMouseMove)
|
||||
window.addEventListener('mouseup', onMouseUp)
|
||||
window.addEventListener('keydown', onKey)
|
||||
overlay.addEventListener('click', onOverlayClick)
|
||||
return () => {
|
||||
stage.removeEventListener('wheel', onWheel)
|
||||
node.removeEventListener('mousedown', onMouseDown)
|
||||
window.removeEventListener('mousemove', onMouseMove)
|
||||
window.removeEventListener('mouseup', onMouseUp)
|
||||
window.removeEventListener('keydown', onKey)
|
||||
overlay.removeEventListener('click', onOverlayClick)
|
||||
}
|
||||
}, [zoom, reset, close])
|
||||
|
||||
return createPortal(
|
||||
<div className={css.mermaidModal} data-mermaid-modal ref={overlayRef}>
|
||||
<div className={css.mermaidModalToolbar}>
|
||||
<button
|
||||
type="button"
|
||||
className={css.mermaidModalButton}
|
||||
title={t('mermaidZoomOut')}
|
||||
onClick={() => zoom(1 / 1.2)}
|
||||
>
|
||||
−
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.mermaidModalButton}
|
||||
title={t('mermaidZoomIn')}
|
||||
onClick={() => zoom(1.2)}
|
||||
>
|
||||
+
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.mermaidModalButton}
|
||||
title={t('mermaidZoomReset')}
|
||||
onClick={reset}
|
||||
>
|
||||
⟳
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={css.mermaidModalButton}
|
||||
title={t('close')}
|
||||
onClick={close}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
<div className={css.mermaidModalStage} ref={stageRef} />
|
||||
<div className={css.mermaidModalHint}>{t('mermaidZoomHint')}</div>
|
||||
</div>,
|
||||
document.body,
|
||||
)
|
||||
}
|
||||
|
||||
/** One rendered mermaid fence: header chrome + diagram (or error + source). */
|
||||
function MermaidDiagram({ code }: { code: string }): React.ReactNode {
|
||||
const [svg, setSvg] = useState<string | null>(null)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [copied, setCopied] = useState(false)
|
||||
/** Scheme state: a flip re-renders the diagram with the matching theme. */
|
||||
const [dark, setDark] = useState(() => isDarkScheme())
|
||||
/** The cloned svg shown in the zoom modal (null = closed). */
|
||||
const [zoomSvg, setZoomSvg] = useState<SVGSVGElement | null>(null)
|
||||
const copyTimer = useRef<number | undefined>(undefined)
|
||||
|
||||
useEffect(() => subscribeColorScheme(() => { setDark(isDarkScheme()) }), [])
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
setSvg(null)
|
||||
setError(null)
|
||||
if (code.trim() === '') return () => { cancelled = true }
|
||||
configureMermaid()
|
||||
const id = `dsh-md-mermaid-${mermaidSeq += 1}`
|
||||
mermaid.render(id, code)
|
||||
.then(({ svg: rendered }) => {
|
||||
if (cancelled) return
|
||||
const clean = sanitizeSvg(rendered)
|
||||
if (clean === '') {
|
||||
// Parse/sanitize rejection: never pass the raw string through.
|
||||
setError(t('mermaidError'))
|
||||
return
|
||||
}
|
||||
setSvg(clean)
|
||||
})
|
||||
.catch((reason: unknown) => {
|
||||
if (cancelled) return
|
||||
setError(summarizeError(reason))
|
||||
})
|
||||
return () => { cancelled = true }
|
||||
}, [code, dark])
|
||||
|
||||
const onCopy = useCallback(() => {
|
||||
if (copied) return
|
||||
writeClipboard(code).then((ok) => {
|
||||
if (!ok) return
|
||||
setCopied(true)
|
||||
window.clearTimeout(copyTimer.current)
|
||||
copyTimer.current = window.setTimeout(() => { setCopied(false) }, 1000)
|
||||
})
|
||||
}, [code, copied])
|
||||
|
||||
/** Clicking the diagram opens the zoom modal with a sanitized clone. */
|
||||
const onBodyClick = (event: ReactMouseEvent<HTMLDivElement>): void => {
|
||||
const svgEl = (event.target as Element).closest('svg')
|
||||
if (svgEl === null) return
|
||||
const clone = svgEl.cloneNode(true) as SVGSVGElement
|
||||
// The modal owns sizing/transform; drop the preview's inline geometry.
|
||||
clone.removeAttribute('style')
|
||||
clone.removeAttribute('width')
|
||||
clone.removeAttribute('height')
|
||||
setZoomSvg(clone)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={css.mermaidWrap}>
|
||||
<div className={css.mermaidHeader}>
|
||||
<span className={css.mermaidInfo}>mermaid</span>
|
||||
<button
|
||||
type="button"
|
||||
className={css.mermaidCopy}
|
||||
onClick={onCopy}
|
||||
aria-label={t('copy')}
|
||||
title={t('copy')}
|
||||
>
|
||||
<IconCopyOutline16 />
|
||||
<span>{copied ? t('copied') : t('copy')}</span>
|
||||
</button>
|
||||
</div>
|
||||
{error !== null && <div className={css.mermaidError} title={error}>{t('mermaidError')}</div>}
|
||||
{svg !== null && (
|
||||
<div
|
||||
className={css.mermaidBody}
|
||||
data-mermaid-diagram
|
||||
onClick={onBodyClick}
|
||||
dangerouslySetInnerHTML={{ __html: svg }}
|
||||
/>
|
||||
)}
|
||||
{error !== null && <pre className={css.mermaidCode}><code>{code}</code></pre>}
|
||||
{zoomSvg !== null && <MermaidZoomModal svg={zoomSvg} onClose={() => { setZoomSvg(null) }} />}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/** One swapped mount: the diagram root + the CodeBlock children it displaced. */
|
||||
interface MermaidMount {
|
||||
root: Root
|
||||
source: string
|
||||
removed: ChildNode[]
|
||||
}
|
||||
|
||||
/**
|
||||
* True when a rendered CodeBlock is a mermaid fence. The DSH CodeBlock has
|
||||
* two bodies: the shiki path carries the `language-*` class on generated
|
||||
* <code> elements, while the plain path (which is always the one mermaid
|
||||
* takes — no shiki grammar) only shows the language in the banner
|
||||
* infostring (first element of the banner row; CSS-module classes are
|
||||
* hashed, so that match is structural).
|
||||
*/
|
||||
function isMermaidBlock(block: HTMLElement): boolean {
|
||||
const code = block.querySelector('code')
|
||||
if (code !== null && [...code.classList].some(c => c.startsWith('language-mermaid'))) return true
|
||||
const infostring = block.firstElementChild?.firstElementChild?.firstElementChild
|
||||
return infostring !== null
|
||||
&& infostring !== undefined
|
||||
&& (infostring.textContent ?? '').trim() === 'mermaid'
|
||||
}
|
||||
|
||||
/**
|
||||
* The chunk-resident markdown preview renderer: ONE MarkdownText pass over
|
||||
* the full source (cross-fence reference/footnote/list semantics intact),
|
||||
* then every rendered `language-mermaid` code block is swapped for a
|
||||
* `MermaidDiagram`. The `.md-code-block` host stays in the React tree —
|
||||
* only its children are replaced — so reconciliation never loses the host;
|
||||
* a block that stops being a mermaid fence gets its original children back.
|
||||
* Only mounted when the source contains at least one mermaid fence (see
|
||||
* TextEditor.tsx).
|
||||
*/
|
||||
export function MermaidMarkdown({ text, codeLabels }: MermaidMarkdownProps): React.ReactNode {
|
||||
const containerRef = useRef<HTMLDivElement>(null)
|
||||
const mountsRef = useRef(new Map<HTMLElement, MermaidMount>())
|
||||
|
||||
useLayoutEffect(() => {
|
||||
const container = containerRef.current
|
||||
if (container === null) return
|
||||
const mounts = mountsRef.current
|
||||
const seen = new Set<HTMLElement>()
|
||||
|
||||
for (const block of container.querySelectorAll<HTMLElement>('.md-code-block')) {
|
||||
const mount = mounts.get(block)
|
||||
const isMermaid = isMermaidBlock(block)
|
||||
if (!isMermaid) {
|
||||
// A previously swapped block that is no longer a mermaid fence:
|
||||
// restore the CodeBlock children React still manages, so the plain
|
||||
// fence renders normally again.
|
||||
if (mount !== undefined) {
|
||||
mount.root.unmount()
|
||||
block.replaceChildren(...mount.removed)
|
||||
block.removeAttribute('data-mermaid-processed')
|
||||
mounts.delete(block)
|
||||
}
|
||||
continue
|
||||
}
|
||||
seen.add(block)
|
||||
// The plain body always carries the fence source in <code>.
|
||||
const source = block.querySelector('code')?.textContent ?? ''
|
||||
if (mount !== undefined && mount.source === source) continue
|
||||
if (mount === undefined) {
|
||||
const host = document.createElement('div')
|
||||
const removed = [...block.childNodes]
|
||||
block.replaceChildren(host)
|
||||
block.setAttribute('data-mermaid-processed', 'true')
|
||||
const root = createRoot(host)
|
||||
mounts.set(block, { root, source, removed })
|
||||
root.render(<MermaidDiagram code={source} />)
|
||||
} else {
|
||||
mount.source = source
|
||||
mount.root.render(<MermaidDiagram code={source} />)
|
||||
}
|
||||
}
|
||||
|
||||
// Drop mounts whose code block left the tree (fence removed, document
|
||||
// restructured): the host node is gone with it.
|
||||
for (const [block, mount] of mounts) {
|
||||
if (seen.has(block)) continue
|
||||
mount.root.unmount()
|
||||
mounts.delete(block)
|
||||
}
|
||||
}, [text])
|
||||
|
||||
useEffect(() => () => {
|
||||
for (const { root } of mountsRef.current.values()) root.unmount()
|
||||
mountsRef.current.clear()
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div className={css.mermaidMarkdown} ref={containerRef}>
|
||||
<MarkdownText text={text} codeLabels={codeLabels} />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -145,6 +145,6 @@ describe('/sidebar/bundle route', () => {
|
||||
})
|
||||
|
||||
it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => {
|
||||
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor'])
|
||||
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid'])
|
||||
})
|
||||
})
|
||||
|
||||
@@ -15,11 +15,16 @@ import { CHUNK_EXTERNALS } from '../src/client/chunk-loader.ts'
|
||||
|
||||
const g = globalThis as Record<string, unknown>
|
||||
|
||||
const CHUNKS = ['terminal', 'editor']
|
||||
const CHUNKS = ['terminal', 'editor', 'mermaid']
|
||||
|
||||
describe('built chunk artifacts', () => {
|
||||
it('each chunk assigns its global registry slot when executed as a script', () => {
|
||||
g.window = g // classic-script globals
|
||||
// mermaid's core hooks window.addEventListener('load') at module scope
|
||||
// (its startOnLoad wiring); the Node global lacks the API, so stub it
|
||||
// exactly like browser-globals.ts does for its window stub.
|
||||
if (typeof g.addEventListener !== 'function') g.addEventListener = () => {}
|
||||
if (typeof g.removeEventListener !== 'function') g.removeEventListener = () => {}
|
||||
for (const name of CHUNKS) {
|
||||
const code = readFileSync(`lib/client-${name}.js`, 'utf8')
|
||||
// eslint-disable-next-line no-new-func
|
||||
|
||||
+81
-7
@@ -15,11 +15,12 @@
|
||||
* fail() strips, no `pageerror`, no plugin-prefixed console errors);
|
||||
* 4. expands the collapsed panel (openByDefault defaults off), sweeps every
|
||||
* built-in tab (Files / Source Control / Tasks / Terminal / Browser) —
|
||||
* including the lazily-fetched terminal chunk — and then opens a seeded
|
||||
* file through the Files window's tree (in-place mode: the seeded home
|
||||
* tab itself switches to the file, no new tab), while a response wait
|
||||
* armed before goto proves the lazily-fetched editor chunk
|
||||
* (client-editor.js) loaded.
|
||||
* including the lazily-fetched terminal chunk — and then opens seeded
|
||||
* files through the Files window's tree (in-place mode: the seeded home
|
||||
* tab itself switches to the file, no new tab), while response waits
|
||||
* armed before goto prove the lazily-fetched editor chunk
|
||||
* (client-editor.js) and the mermaid chunk (client-mermaid.js, rendered
|
||||
* SVG diagram + zoom modal) loaded.
|
||||
*
|
||||
* Deterministic by construction: every wait is on a DOM/network marker, the
|
||||
* suite is serial (one server instance), and any crash trips the very next
|
||||
@@ -42,6 +43,11 @@ const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-
|
||||
* exercise the file-open path (editor chunk = client-editor.js). */
|
||||
const SEEDED_FILE = 'hello.txt'
|
||||
|
||||
/** A markdown file with a mermaid fence, opened through the Files window's
|
||||
* tree to force the lazily-packed mermaid chunk (client-mermaid.js) to load
|
||||
* and render a sanitized SVG diagram. */
|
||||
const SEEDED_MD_FILE = 'diagram.md'
|
||||
|
||||
/**
|
||||
* The plugin's crash markers. The client mounts inside an error boundary that
|
||||
* renders a strip whose text starts with these prefixes instead of crashing
|
||||
@@ -54,11 +60,31 @@ const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser']
|
||||
|
||||
let api: APIRequestContext
|
||||
|
||||
/** Seed one workspace + one session (plus a file for the editor-chunk probe)
|
||||
* through the host's unary RPC surface. */
|
||||
/** Seed one workspace + one session (plus files for the editor/mermaid-chunk
|
||||
* probes) through the host's unary RPC surface. */
|
||||
async function seedSession(): Promise<void> {
|
||||
mkdirSync(WORKSPACE_PATH, { recursive: true })
|
||||
writeFileSync(join(WORKSPACE_PATH, SEEDED_FILE), 'hello from the mount lane\n')
|
||||
// The mermaid-chunk probe file: a markdown doc whose preview must fetch
|
||||
// client-mermaid.js and render the fence into an SVG diagram. The
|
||||
// reference-style link's definition sits AFTER the fence: it only
|
||||
// resolves when the preview is one single markdown parse (the mermaid
|
||||
// path must not split the document into independent MarkdownText blocks).
|
||||
writeFileSync(join(WORKSPACE_PATH, SEEDED_MD_FILE), [
|
||||
'# Diagram',
|
||||
'',
|
||||
'[before][shared]',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph TD',
|
||||
' A[Hello] --> B[World]',
|
||||
'```',
|
||||
'',
|
||||
'[shared]: https://example.com',
|
||||
'',
|
||||
'tail text',
|
||||
'',
|
||||
].join('\n'))
|
||||
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
|
||||
})
|
||||
@@ -230,6 +256,54 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
await page.waitForTimeout(1_500)
|
||||
await assertNoCrash()
|
||||
|
||||
// The mermaid chunk (client-mermaid.js) only loads when a previewed
|
||||
// markdown file contains a mermaid fence. Open the seeded diagram file
|
||||
// from the files window's tree (the embedded tree stays pinned while
|
||||
// hello.txt is open) and require the full round-trip: chunk fetch +
|
||||
// sanitized SVG diagram in the preview, so a missing/corrupt mermaid
|
||||
// chunk or a broken render fails the lane.
|
||||
const mermaidChunk = page.waitForResponse(
|
||||
(response) => response.url().includes('/sidebar/bundle/mermaid.js'),
|
||||
{ timeout: 30_000 },
|
||||
)
|
||||
const mdRow = sidebar.locator(`[role="button"][title$="${SEEDED_MD_FILE}"]:visible`)
|
||||
await expect(mdRow, `the seeded "${SEEDED_MD_FILE}" file must appear in the files window's tree`).toHaveCount(1, { timeout: 30_000 })
|
||||
await mdRow.click({ position: { x: 8, y: 8 } })
|
||||
await mermaidChunk
|
||||
await expect(
|
||||
sidebar.locator('[data-mermaid-diagram] svg'),
|
||||
'the mermaid fence must render into an SVG diagram in the markdown preview',
|
||||
).toHaveCount(1, { timeout: 30_000 })
|
||||
// Labels must survive as real SVG <text> (htmlLabels stays off so the
|
||||
// sanitizer's foreignObject strip cannot eat the node text).
|
||||
await expect(
|
||||
sidebar.locator('[data-mermaid-diagram]').first(),
|
||||
'the diagram node labels must render inside the SVG',
|
||||
).toContainText('Hello', { timeout: 30_000 })
|
||||
// Cross-fence semantics: the reference-style link [before][shared] must
|
||||
// resolve to the definition that sits AFTER the fence — proof that the
|
||||
// preview is a single markdown parse and not per-fence fragments.
|
||||
await expect(
|
||||
sidebar.locator('a[href="https://example.com"]').first(),
|
||||
'reference-style links with definitions across a mermaid fence must resolve',
|
||||
).toContainText('before', { timeout: 30_000 })
|
||||
// Click-to-enlarge: clicking the diagram opens the zoom modal (portalled
|
||||
// to document.body), Esc closes it again.
|
||||
const modal = page.locator('[data-mermaid-modal]')
|
||||
await sidebar.locator('[data-mermaid-diagram] svg').first().click()
|
||||
await expect(modal, 'clicking the diagram must open the zoom modal').toHaveCount(1, { timeout: 10_000 })
|
||||
await page.keyboard.press('Escape')
|
||||
await expect(modal, 'Esc must close the zoom modal').toHaveCount(0, { timeout: 10_000 })
|
||||
// The preview/edit toggle is mutually exclusive: in preview mode the
|
||||
// CodeMirror surface must be hidden (regression guard — a stale css copy
|
||||
// in the page made the editor stay visible under the preview, breaking
|
||||
// the toggle semantics).
|
||||
await expect(
|
||||
sidebar.locator('.cm-editor').first(),
|
||||
'preview mode must hide the CodeMirror editor (mutually exclusive toggle)',
|
||||
).toBeHidden()
|
||||
await assertNoCrash()
|
||||
|
||||
// The plugin's own console prefix must never appear in errors, and no
|
||||
// unhandled rejection may escape the sweep.
|
||||
const pluginErrors = consoleErrors.filter((text) => /dsh-better-sidebar|Unhandled/.test(text))
|
||||
|
||||
@@ -65,7 +65,7 @@ function bundleId(file: string): string {
|
||||
}
|
||||
|
||||
/** The lazy chunk bundle names (mirror of src/bundle-route.ts CHUNK_NAMES). */
|
||||
const CHUNK_FILES = ['terminal', 'editor'].map(name => `lib/client-${name}.js`)
|
||||
const CHUNK_FILES = ['terminal', 'editor', 'mermaid'].map(name => `lib/client-${name}.js`)
|
||||
|
||||
/** The global registry slot a built chunk script assigns (its factory key). */
|
||||
function chunkSlot(file: string): string {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Markdown/mermaid block splitting: the pure splitter the markdown preview
|
||||
* uses to lift mermaid fences out of the MarkdownText stream. Covers fence
|
||||
* recognition (case, attributes, indentation), non-mermaid fences staying
|
||||
* untouched, interleaved ordering, and open-fence recovery.
|
||||
*/
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { splitMermaidBlocks } from '../src/client/mermaid-blocks.ts'
|
||||
|
||||
describe('splitMermaidBlocks', () => {
|
||||
it('leaves mermaid-free markdown as one markdown block', () => {
|
||||
expect(splitMermaidBlocks('# hi\n\ntext')).toEqual([
|
||||
{ kind: 'markdown', text: '# hi\n\ntext' },
|
||||
])
|
||||
})
|
||||
|
||||
it('lifts a mermaid fence into a diagram block with the raw source', () => {
|
||||
const blocks = splitMermaidBlocks('before\n```mermaid\ngraph TD\n A-->B\n```\nafter')
|
||||
expect(blocks).toEqual([
|
||||
{ kind: 'markdown', text: 'before' },
|
||||
{ kind: 'mermaid', code: 'graph TD\n A-->B' },
|
||||
{ kind: 'markdown', text: 'after' },
|
||||
])
|
||||
})
|
||||
|
||||
it('is case-insensitive and accepts mermaid attribute info strings', () => {
|
||||
const lower = splitMermaidBlocks('```Mermaid\na-->b\n```')
|
||||
expect(lower[0]).toEqual({ kind: 'mermaid', code: 'a-->b' })
|
||||
const attributed = splitMermaidBlocks("```mermaid{theme:'dark'}\na-->b\n```")
|
||||
expect(attributed[0]).toEqual({ kind: 'mermaid', code: 'a-->b' })
|
||||
})
|
||||
|
||||
it('allows up to 3 spaces of fence indent (CommonMark)', () => {
|
||||
const blocks = splitMermaidBlocks(' ```mermaid\na-->b\n ```')
|
||||
expect(blocks).toEqual([{ kind: 'mermaid', code: 'a-->b' }])
|
||||
})
|
||||
|
||||
it('does not lift fences inside a 4-space indented code block', () => {
|
||||
const source = ' ```mermaid\n a-->b\n ```'
|
||||
expect(splitMermaidBlocks(source)).toEqual([{ kind: 'markdown', text: source }])
|
||||
})
|
||||
|
||||
it('keeps non-mermaid fences in the markdown stream untouched', () => {
|
||||
const source = '```ts\nconst a = 1\n```'
|
||||
expect(splitMermaidBlocks(source)).toEqual([{ kind: 'markdown', text: source }])
|
||||
})
|
||||
|
||||
it('interleaves multiple diagrams preserving source order', () => {
|
||||
const blocks = splitMermaidBlocks(
|
||||
'# t\n```mermaid\ngraph TD\n A\n```\nmid\n```mermaid\npie\n "a": 1\n```\ntail',
|
||||
)
|
||||
expect(blocks.map(block => block.kind)).toEqual(['markdown', 'mermaid', 'markdown', 'mermaid', 'markdown'])
|
||||
expect(blocks[1]).toEqual({ kind: 'mermaid', code: 'graph TD\n A' })
|
||||
expect(blocks[3]).toEqual({ kind: 'mermaid', code: 'pie\n "a": 1' })
|
||||
})
|
||||
|
||||
it('handles an empty file and mermaid-free text with no markdown blocks', () => {
|
||||
expect(splitMermaidBlocks('')).toEqual([])
|
||||
expect(splitMermaidBlocks('a\n\nb')).toEqual([{ kind: 'markdown', text: 'a\n\nb' }])
|
||||
})
|
||||
|
||||
it('swallows the rest of the file on an unterminated mermaid fence', () => {
|
||||
const blocks = splitMermaidBlocks('```mermaid\ngraph TD\n A-->B')
|
||||
expect(blocks).toEqual([{ kind: 'mermaid', code: 'graph TD\n A-->B' }])
|
||||
})
|
||||
|
||||
it('treats an empty mermaid fence as an empty diagram block', () => {
|
||||
const blocks = splitMermaidBlocks('```mermaid\n```')
|
||||
expect(blocks).toEqual([{ kind: 'mermaid', code: '' }])
|
||||
})
|
||||
|
||||
it('lifts four-backtick and tilde fences (CommonMark fence runs)', () => {
|
||||
const backticks = splitMermaidBlocks('````mermaid\ngraph TD\n A\n````')
|
||||
expect(backticks).toEqual([{ kind: 'mermaid', code: 'graph TD\n A' }])
|
||||
const tildes = splitMermaidBlocks('~~~mermaid\ngraph TD\n A\n~~~')
|
||||
expect(tildes).toEqual([{ kind: 'mermaid', code: 'graph TD\n A' }])
|
||||
})
|
||||
|
||||
it('only a same-char, at-least-as-long fence closes the opening fence', () => {
|
||||
// A shorter backtick run must not close a 4-backtick opening fence.
|
||||
const blocks = splitMermaidBlocks('````mermaid\na\n```\nb\n````')
|
||||
expect(blocks).toEqual([{ kind: 'mermaid', code: 'a\n```\nb' }])
|
||||
// A different fence character must not close the fence either.
|
||||
const mixed = splitMermaidBlocks('~~~mermaid\na\n```\n~~~')
|
||||
expect(mixed).toEqual([{ kind: 'mermaid', code: 'a\n```' }])
|
||||
})
|
||||
|
||||
it('ignores backticks inside a backtick fence info string (CommonMark)', () => {
|
||||
const source = '```mermaid`graph\na-->b\n```'
|
||||
expect(splitMermaidBlocks(source)).toEqual([{ kind: 'markdown', text: source }])
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,84 @@
|
||||
/**
|
||||
* MermaidMarkdown architecture spec: the preview renders the WHOLE document
|
||||
* through one MarkdownText pass (cross-fence reference-style links must
|
||||
* resolve — the P1 regression from the CR), then swaps every rendered
|
||||
* mermaid CodeBlock for a diagram. mermaid is mocked so the swap + semantics
|
||||
* are asserted without pulling the real layout engine into jsdom.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it, vi, beforeEach } from 'vitest'
|
||||
import { createElement } from 'react'
|
||||
import { createRoot, type Root } from 'react-dom/client'
|
||||
import { act } from 'react-dom/test-utils'
|
||||
|
||||
// The act() environment flag (React 18.2 reads it before flushing effects).
|
||||
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
|
||||
|
||||
vi.mock('mermaid', () => ({
|
||||
default: {
|
||||
initialize: () => {},
|
||||
render: async () => ({
|
||||
svg: '<svg xmlns="http://www.w3.org/2000/svg"><text>MOCK-DIAGRAM</text></svg>',
|
||||
}),
|
||||
},
|
||||
}))
|
||||
|
||||
import { MermaidMarkdown } from '../src/client/mermaid.tsx'
|
||||
|
||||
const codeLabels = { copyLabel: 'Copy', copiedLabel: 'Copied' }
|
||||
|
||||
async function renderMarkdown(text: string): Promise<{ container: HTMLElement; root: Root }> {
|
||||
const container = document.createElement('div')
|
||||
document.body.appendChild(container)
|
||||
const root = createRoot(container)
|
||||
await act(async () => {
|
||||
root.render(createElement(MermaidMarkdown, { text, codeLabels }))
|
||||
// Let the mocked mermaid.render promise settle inside the act scope.
|
||||
await new Promise(resolve => { setTimeout(resolve, 0) })
|
||||
})
|
||||
return { container, root }
|
||||
}
|
||||
|
||||
async function unmount(root: Root): Promise<void> {
|
||||
await act(async () => { root.unmount() })
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
document.body.innerHTML = ''
|
||||
})
|
||||
|
||||
describe('MermaidMarkdown', () => {
|
||||
it('swaps the mermaid code block for a rendered diagram', async () => {
|
||||
const { container, root } = await renderMarkdown('```mermaid\ngraph TD\n A-->B\n```')
|
||||
const diagram = container.querySelector('[data-mermaid-diagram] svg')
|
||||
expect(diagram, 'the mermaid fence must be swapped for a diagram').not.toBeNull()
|
||||
expect(diagram?.textContent).toContain('MOCK-DIAGRAM')
|
||||
await unmount(root)
|
||||
})
|
||||
|
||||
it('resolves cross-fence reference-style links (single markdown parse)', async () => {
|
||||
const text = [
|
||||
'[before][shared]',
|
||||
'',
|
||||
'```mermaid',
|
||||
'graph TD',
|
||||
' A --> B',
|
||||
'```',
|
||||
'',
|
||||
'[shared]: https://example.com',
|
||||
].join('\n')
|
||||
const { container, root } = await renderMarkdown(text)
|
||||
const link = container.querySelector('a[href="https://example.com"]')
|
||||
expect(link, 'the definition after the fence must resolve the link before it').not.toBeNull()
|
||||
expect(link?.textContent).toContain('before')
|
||||
expect(container.querySelector('[data-mermaid-diagram] svg'), 'the diagram must still swap in').not.toBeNull()
|
||||
await unmount(root)
|
||||
})
|
||||
|
||||
it('leaves non-mermaid fences untouched', async () => {
|
||||
const { container, root } = await renderMarkdown('```ts\nconst a = 1\n```')
|
||||
expect(container.querySelector('[data-mermaid-diagram]'), 'no mermaid fence → no swap').toBeNull()
|
||||
expect(container.querySelectorAll('.md-code-block').length, 'the ts fence stays a code block').toBe(1)
|
||||
await unmount(root)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,116 @@
|
||||
/**
|
||||
* Mermaid SVG sanitizer security spec: the rendered diagram must never
|
||||
* carry an executable surface into the markdown preview — foreignObject
|
||||
* (the raw-HTML channel) is stripped wholesale, event/`@*` attributes are
|
||||
* removed, href/xlink:href are removed, script elements are removed, and a
|
||||
* malformed/foreign document is rejected entirely (empty string) rather
|
||||
* than passed through.
|
||||
*/
|
||||
// @vitest-environment jsdom
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { sanitizeSvg } from '../src/client/mermaid-sanitize.ts'
|
||||
|
||||
describe('sanitizeSvg', () => {
|
||||
it('keeps a benign diagram (svg + text nodes)', () => {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>Hello</text></svg>'
|
||||
const out = sanitizeSvg(svg)
|
||||
expect(out).toContain('<text>Hello</text>')
|
||||
expect(out).not.toContain('foreignObject')
|
||||
})
|
||||
|
||||
it('strips foreignObject wholesale (the raw-HTML channel)', () => {
|
||||
const svg = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">',
|
||||
'<foreignObject><div><img src="x" onerror="alert(1)"/></div></foreignObject>',
|
||||
'<text>keep</text>',
|
||||
'</svg>',
|
||||
].join('')
|
||||
const out = sanitizeSvg(svg)
|
||||
expect(out).not.toContain('foreignObject')
|
||||
expect(out).not.toContain('<img')
|
||||
expect(out).not.toContain('onerror')
|
||||
expect(out).toContain('<text>keep</text>')
|
||||
})
|
||||
|
||||
it('strips on* and @* attributes from every element', () => {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><text onclick="x()" @click="y()">t</text></svg>'
|
||||
const out = sanitizeSvg(svg)
|
||||
expect(out).not.toContain('onload')
|
||||
expect(out).not.toContain('onclick')
|
||||
expect(out).not.toContain('@click')
|
||||
})
|
||||
|
||||
it('strips mixed-case event/link attributes (HTML parsing normalizes case)', () => {
|
||||
const svg = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">',
|
||||
'<text oNload="alert(1)" OnClick="x()">t</text>',
|
||||
'<a HREF="javascript:alert(1)" xlink:HREF="data:text/html,<script>1</script>">x</a>',
|
||||
'<text @Click="y()">u</text>',
|
||||
'</svg>',
|
||||
].join('')
|
||||
const out = sanitizeSvg(svg)
|
||||
expect(out).not.toContain('oNload')
|
||||
expect(out).not.toContain('OnClick')
|
||||
expect(out).not.toContain('@Click')
|
||||
expect(out).not.toContain('HREF')
|
||||
expect(out).not.toContain('xlink:HREF')
|
||||
expect(out).not.toContain('javascript:')
|
||||
})
|
||||
|
||||
it('removes href and xlink:href regardless of scheme', () => {
|
||||
const svg = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">',
|
||||
'<a href="javascript:alert(1)" xlink:href="data:text/html,<script>1</script>">x</a>',
|
||||
'<a href="https://example.com">y</a>',
|
||||
'</svg>',
|
||||
].join('')
|
||||
const out = sanitizeSvg(svg)
|
||||
expect(out).not.toContain('href')
|
||||
expect(out).not.toContain('javascript:')
|
||||
expect(out).not.toContain('data:text/html')
|
||||
})
|
||||
|
||||
it('removes script elements', () => {
|
||||
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><text>t</text></svg>'
|
||||
const out = sanitizeSvg(svg)
|
||||
expect(out).not.toContain('script')
|
||||
expect(out).toContain('<text>t</text>')
|
||||
})
|
||||
|
||||
it('strips mixed-case element names (HTML parsing normalizes tag casing)', () => {
|
||||
const svg = [
|
||||
'<svg xmlns="http://www.w3.org/2000/svg">',
|
||||
'<sCrIpT>alert(1)</sCrIpT>',
|
||||
'<foreignobject><div><img src="x" onerror="alert(1)"/></div></foreignobject>',
|
||||
'<ImG src="y" onerror="alert(2)"/>',
|
||||
'<iFrAmE src="https://evil.test"/>',
|
||||
'<text>keep</text>',
|
||||
'</svg>',
|
||||
].join('')
|
||||
const out = sanitizeSvg(svg)
|
||||
expect(out).not.toContain('sCrIpT')
|
||||
expect(out).not.toContain('alert')
|
||||
expect(out).not.toContain('foreignobject')
|
||||
expect(out).not.toContain('<img')
|
||||
expect(out).not.toContain('<ImG')
|
||||
expect(out).not.toContain('iFrAmE')
|
||||
expect(out).not.toContain('onerror')
|
||||
expect(out).toContain('<text>keep</text>')
|
||||
})
|
||||
|
||||
it('rejects malformed XML instead of passing it through', () => {
|
||||
const out = sanitizeSvg('<svg xmlns="http://www.w3.org/2000/svg"><text>oops</svg>')
|
||||
expect(out).toBe('')
|
||||
})
|
||||
|
||||
it('strips foreign HTML elements smuggled past lenient parsers', () => {
|
||||
const out = sanitizeSvg('<svg xmlns="http://www.w3.org/2000/svg"><img src="x" onerror="alert(1)"/></svg>')
|
||||
expect(out).not.toContain('<img')
|
||||
expect(out).not.toContain('onerror')
|
||||
})
|
||||
|
||||
it('rejects a non-svg document', () => {
|
||||
const out = sanitizeSvg('<html><body onload="alert(1)">hi</body></html>')
|
||||
expect(out).toBe('')
|
||||
})
|
||||
})
|
||||
+29
-2
@@ -204,7 +204,11 @@ function chunkBundle(name: string): UserConfig {
|
||||
},
|
||||
},
|
||||
noExternal: (id: string) => (CLIENT_EXTERNALS.includes(id) ? undefined : true),
|
||||
plugins: [purityGatePlugin(), makeCssPlugin('dsh-better-sidebar')],
|
||||
plugins: [
|
||||
purityGatePlugin(),
|
||||
makeCssPlugin('dsh-better-sidebar'),
|
||||
...(name === 'mermaid' ? [mermaidChunkAliases()] : []),
|
||||
],
|
||||
outputOptions: {
|
||||
entryFileNames: `client-${name}.js`,
|
||||
sourcemapPathTransform: browserSourcePath,
|
||||
@@ -219,6 +223,29 @@ function chunkBundle(name: string): UserConfig {
|
||||
/** A rolldown plugin as tsdown's config accepts it (contextual `this` for load/resolveId). */
|
||||
type BuildPlugin = NonNullable<UserConfig['plugins']>
|
||||
|
||||
/**
|
||||
* Mermaid-chunk-only alias: pin uuid's BROWSER entry. The mermaid core
|
||||
* (mindmap definition) imports the bare `uuid` specifier, which rolldown
|
||||
* resolves to uuid's node entry — its dist-node modules import
|
||||
* `node:crypto` and trip the client purity gate. The browser entry
|
||||
* (uuid/dist/index.js, Web Crypto based) carries no Node builtins, so alias
|
||||
* the specifier there instead of special-casing the gate. Resolved relative
|
||||
* to mermaid's own dependency tree (pnpm/npm layout agnostic).
|
||||
*/
|
||||
function mermaidChunkAliases(): BuildPlugin {
|
||||
const uuidBrowserEntry = resolvePath(
|
||||
dirname(require.resolve('uuid/package.json', { paths: [dirname(require.resolve('mermaid/package.json'))] })),
|
||||
'dist/index.js',
|
||||
)
|
||||
return {
|
||||
name: 'dsh-mermaid-uuid-browser-alias',
|
||||
resolveId(source: string) {
|
||||
if (source === 'uuid') return uuidBrowserEntry
|
||||
return null
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
/** The shared client-bundle purity gate (see the clientBundle doc). */
|
||||
function purityGatePlugin(): BuildPlugin {
|
||||
return {
|
||||
@@ -287,7 +314,7 @@ function makeCssPlugin(pluginId: string): BuildPlugin {
|
||||
}
|
||||
|
||||
/** The lazy chunk names (keep in sync with src/bundle-route.ts CHUNK_NAMES). */
|
||||
const CHUNKS = ['terminal', 'editor']
|
||||
const CHUNKS = ['terminal', 'editor', 'mermaid']
|
||||
|
||||
export default [
|
||||
{
|
||||
|
||||
Reference in New Issue
Block a user