diff --git a/README.md b/README.md
index e3ed150..06814f2 100644
--- a/README.md
+++ b/README.md
@@ -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)。
diff --git a/README_EN.md b/README_EN.md
index 829a872..5f36d68 100644
--- a/README_EN.md
+++ b/README_EN.md
@@ -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).
diff --git a/docs/plans/2026-08-12-lazy-chunks-design.md b/docs/plans/2026-08-12-lazy-chunks-design.md
index 0a81d9d..2281767 100644
--- a/docs/plans/2026-08-12-lazy-chunks-design.md
+++ b/docs/plans/2026-08-12-lazy-chunks-design.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/*` 深导入。
diff --git a/docs/plans/2026-08-17-mermaid-safe-render-design.md b/docs/plans/2026-08-17-mermaid-safe-render-design.md
new file mode 100644
index 0000000..0429572
--- /dev/null
+++ b/docs/plans/2026-08-17-mermaid-safe-render-design.md
@@ -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 ``(默认的 `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 导航走);仅接受根元素为 `',
+ ].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 = [
+ '',
+ ].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 = ''
+ const out = sanitizeSvg(svg)
+ expect(out).not.toContain('script')
+ expect(out).toContain('t')
+ })
+
+ it('strips mixed-case element names (HTML parsing normalizes tag casing)', () => {
+ const 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('
keep')
+ })
+
+ it('rejects malformed XML instead of passing it through', () => {
+ const out = sanitizeSvg('')
+ expect(out).toBe('')
+ })
+
+ it('strips foreign HTML elements smuggled past lenient parsers', () => {
+ const out = sanitizeSvg('')
+ expect(out).not.toContain('
{
+ const out = sanitizeSvg('
hi')
+ expect(out).toBe('')
+ })
+})
diff --git a/tsdown.config.ts b/tsdown.config.ts
index 3163253..e098429 100644
--- a/tsdown.config.ts
+++ b/tsdown.config.ts
@@ -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
+/**
+ * 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 [
{