Merge pull request #360 from omdsh-dev/feat/markdown-html-toc

feat(editor): Markdown 预览内嵌 HTML 安全渲染 + 目录大纲(TOC)
This commit is contained in:
Atlas Zeng
2026-08-24 14:31:58 +08:00
committed by GitHub
41 changed files with 1733 additions and 64 deletions
+2 -1
View File
@@ -437,7 +437,7 @@ interface FileViewerProps {
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件),该插件以相同 id 经 `ctx.betterSidebar.registerFileViewer` 注册。
> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead,**README 级内嵌 HTML 支持**——块级 HTML 段按文档顺序 DOMPurify 白名单消毒内联渲染、`<details>` 跨段嵌套内部 markdown、markdown 表格单元格等处的内联标签消毒还原为元素、本地媒体 src 重写走 `/sidebar/file`;预览顶部浮动目录大纲按钮(≥3 标题出现,点击条目平滑滚动并自动展开折叠 `<details>`;纯 markdown 文档走原单次解析路径零变化。实现 `src/client/markdown-html.ts` / `MarkdownHtml.tsx` / `md-toc.tsx`,设计文档 `docs/plans/2026-08-24-markdown-html-toc-design.md`) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件),该插件以相同 id 经 `ctx.betterSidebar.registerFileViewer` 注册。
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
### 4.5 注册示例
@@ -729,6 +729,7 @@ better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据)
- **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护
- **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git` / `node_modules` / 构建缓存 / symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`)
- **`src/client/markdown-html.ts`** / **`src/client/MarkdownHtml.tsx`** / **`src/client/md-toc.tsx`**:markdown 预览的内嵌 HTML 渲染(fence 感知切分器 + DOMPurify 消毒管线 + 跨段 `<details>` 嵌套状态机 + 内联标签 commit 后还原 + MutationObserver 迟到内容)与目录大纲(DOM 收集 + sticky 浮动按钮 + 跳转展开折叠块;注意 `md-toc.tsx` 头注释记录的「子组件读父 ref 为 null」React 时序陷阱)。测试 `tests/markdown-html.spec.ts` / `tests/markdown-html-render.spec.tsx` / `tests/md-toc.spec.tsx`,设计文档 `docs/plans/2026-08-24-markdown-html-toc-design.md`
- **`src/agent-opens.ts`** / **`/sidebar/ws/agent-opens`**:模型主动打开(`sidebar_open` 工具 + `agentOpenTools` 设置,默认关闭)——会话级队列推送注册表 + 工具注册;客户端在 Sidebar.tsx 订阅推送并路由到 editor(文件/文件夹窗口)/browser(HTTP(S) 网页)tab,文件夹窗口是 `meta.dir: true` 的 editor tab(全窗树以目录为根);设计文档 `docs/plans/2026-08-23-agent-open-tools-design.md`
- **`docs/plans/2026-08-11-service-registry-design.md`** / **`docs/plans/2026-08-11-declarative-sidebar-settings-design.md`** / **`docs/plans/2026-08-14-add-plugins-modal-design.md`**:设计文档(含实施偏差记录)
+2 -2
View File
@@ -40,7 +40,7 @@
## ✨ 功能一览
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `<details>` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页
@@ -156,7 +156,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
| | |
|---|---|
| **🗂️ 文件工作台:资源管理器**<br/><sub>支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。</sub><br/><div align="center"><img width="420" alt="文件资源管理器" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Markdown · 图片 · PDF 内联预览**<br/><sub>Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid 预览" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
| **🗂️ 文件工作台:资源管理器**<br/><sub>支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。</sub><br/><div align="center"><img width="420" alt="文件资源管理器" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Markdown · 图片 · PDF 内联预览**<br/><sub>Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移)、**README 级内嵌 HTML**(徽章墙 `<div align=center>`、`<details>` 折叠块内嵌 markdown、表格单元格内联标签——DOMPurify 白名单消毒真实渲染,`<script>` 等活性内容剥除,本地图片经会话媒体路由重写)与**浮动目录大纲**(≥3 标题出现,点击平滑跳转、自动展开折叠块);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid 预览" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
| **🖥️ CodeMirror 代码编辑器**<br/><div align="center"><img width="420" alt="CodeMirror 代码编辑器" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div> | **🖼️ 图片内联预览**<br/><div align="center"><img width="420" alt="图片内联预览" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div> |
| **💻 真实终端**<br/><sub>xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。</sub><br/><div align="center"><img width="420" alt="真实终端" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 Git 面板**<br/><sub>暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原,历史列表;点击改动文件打开 **VSCode 式 diff tab**(红绿行级对比)。</sub><br/><div align="center"><img width="420" alt="Git 面板" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
| **🌐 内嵌浏览器**<br/><sub>多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。</sub><br/><div align="center"><img width="420" alt="内嵌浏览器" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div> | **🧩 任务页:子代理拓扑 + 后台任务**<br/><sub>子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动展开侧边栏(可关)。</sub><br/><div align="center"><img width="420" alt="任务页:子代理拓扑" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div> |
@@ -0,0 +1,52 @@
# Markdown 预览内嵌 HTML 渲染 + 目录大纲(TOC)设计
> 日期:2026-08-24
> 状态:已实现
> 范围:`dsh-better-sidebar` 插件,Markdown 文件预览(`TextEditor.tsx` preview 模式);Sidechat 与其他渲染面**零改动**
> 基线:复用 mermaid 预览(PR #164)确立的「检测切分 + 单次 MarkdownText 解析 + commit 后 DOM 手术」架构与安全姿势
## 1. 背景与问题
DSH 宿主的 `MarkdownText` 出于聊天安全把 raw HTML 按字面文本渲染(协议白名单、相对链接禁用、图片仅绝对 http(s))。这对聊天是对的,但对**文件预览**是灾难:GitHub 风格的 README(本仓库自己的 README 即典型)大量使用内嵌 HTML——`<div align="center">` 徽章墙、`<details>` 折叠块内嵌 markdown、表格单元格里的 `<br/>`/`<sub>`/`<img>`、`<video>`/`<picture>`——预览时全部显示为源码文本。
用户需求(确认记录):覆盖 Markdown 文件预览;块级 + 内联 HTML 都做;安全模型选 DOMPurify 白名单内联渲染(非沙箱 iframe);TOC 选浮动按钮 + 弹出面板;Sidechat 暂不动;**不更换渲染器**(保住 MarkdownText 的 shiki 高亮 + KaTeX 数学 + GFM 表格)。
## 2. 方案
### 2.1 检测与切分:`src/client/markdown-html.ts`(纯函数)
- `splitHtmlBlocks(text)`:行扫描器把文档切成 `markdown | html` 段。HTML 段 = 围栏外的**块级标签行**(CommonMark type-6 名单 + `<summary>`,开或闭标签)开头、延伸到空行的连续行;`<!-- -->` 注释段在含 `-->` 的行结束。**围栏识别复用 `mermaid-blocks.ts` 导出的 fence 助手**——```html 围栏内的 `<div>` 示例不会被误切。段边界的空行是分隔符,被丢弃。
- `analyzeHtmlSegment(source)`:段内标签 tokenizer(跳过注释,void/自闭合标签不进栈,错配闭标签按 HTML 解析器的隐式闭合弹出)归约为有序 parts:`html`(平衡的原始片段,渲染为消毒叶子)/ `open`(未闭合开标签 + 原始 attrs,成为包裹后续段的容器)/ `close`(多余闭标签,弹出一层)。
- `collectReferenceDefinitions(segments)`:从 markdown 段收集 `[label]: dest` 行。**每个 markdown 段渲染时追加密全文档定义**——跨 HTML 段的 `[text][id]` 语义保真(追加副本在已含定义的段内因 first-match-wins 而惰性)。这是 mermaid 预览「单次解析」约束(引用定义跨 fence 解析,e2e 钉死)在切分渲染下的等价物。
- `analyzeMarkdownHtml(text)`:整体入口,含 `hasInlineHtml` 廉价正则门(fence 内误报只多走增强路径,内联 pass 本身跳过代码块,无行为差异)。
### 2.2 渲染:`src/client/MarkdownHtml.tsx`
- **安全管线**(三个入口共用):块叶子 / 内联文本 / 包裹开标签 attrs 全部过 DOMPurify(显式 `FORBID_TAGS: script/style/iframe/object/embed/form/input/button/select/textarea/meta/link/base/frame/frameset/applet` + `FORBID_ATTR: srcdoc/formaction`,叠加在默认白名单之上);消毒后 DOM 遍历强制 `<a>` → `target=_blank rel=noopener noreferrer`;本地 `img/video/audio/source` 的 `src` 经 `resolveLocalMediaDest`(从 `markdown-images.ts` 抽出的共享助手)重写为 `/sidebar/file` 会话媒体 URL——与 markdown 图片重写同一信任围栏。包裹标签的 attrs 走「迷你文档消毒再读回」;`class/for` 映射 React 名,`style` 丢弃(React 需对象,不值得引入 CSS 解析器);整标签被禁时容器透明(子内容照常渲染)。
- **`MarkdownDocument` 段渲染状态机**:markdown 段 → `MarkdownSegment`;平衡 html 段 → `dangerouslySetInnerHTML` 叶子(`[data-dsh-html-segment]`);`open`/`close` 部件驱动一个**跨段持久的帧栈**——`<details>` 开段把后续段降为自己的子元素,`</details>` 段弹栈(GitHub 线性 HTML 输出的同款嵌套)。顶层多余闭标签渲染为无(消毒器/解析器本就会丢弃);文档结束仍开着的帧按浏览器解析器语义收口。消毒在 `useMemo` 内按段缓存。
- **`MarkdownSegment` 内联替换**:段内含 mermaid fence 走现有 `LazyMermaidMarkdown`(chunk 逻辑不变),否则 `MarkdownText`;`useLayoutEffect` + **MutationObserver**(微任务去抖、幂等、跳过 `code/pre/.md-code-block/[data-mermaid-processed]/[data-html-inline]`)把含标签样式文本的文本节点整体消毒后包进 `<span data-html-inline>` 替换——纯散文(`a < b`)消毒后无元素子节点则原样保留;observer 覆盖懒 chunk 迟挂载、shiki 异步高亮等迟到内容。与 mermaid 换装同款「React 持有宿主树、只动叶子」姿势。
- **接线(`TextEditor.tsx`)**:`analyzeMarkdownHtml` 发现块级或内联 HTML 才走 `MarkdownDocument`;**纯 markdown 文档走原路径原样**(无 HTML → 单次 `MarkdownText`/mermaid chunk,现有 e2e「单次解析」守卫继续成立——种子 md 文件不含 HTML,断言一字未改)。
### 2.3 TOC:`src/client/md-toc.tsx`
- 零高度 sticky 条(`position: sticky; top: 0; height: 0; pointer-events: none`,不占布局、滚动常驻)+ 右上角按钮 `[data-dsh-md-toc]`(≥3 个标题才出现)+ 弹出面板 `[data-dsh-md-toc-panel]`(按层级缩进、独立滚动、Esc 关闭)。
- 标题从**渲染后的 DOM** 收集(h1–h6,含 HTML 段内标题),MutationObserver 重扫(签名比对防重渲染循环)。
- 跳转:展开 `closest('details:not([open])')` → `scrollIntoView({behavior:'smooth'})` → CSS 动画高亮 1.2s。
- **⚠️ 关键实现陷阱(本次踩坑)**:MdToc **不能**通过传入父容器 ref 定位——React 的 layout 阶段子先于父,子组件的 `useLayoutEffect` 执行时父 div 的 ref **尚未挂上**(实测 `containerRef.current === null`,且依赖数组恒定导致 effect 永不重跑,按钮永不出现;jsdom 单测传预填 ref 对象会掩盖此 bug,只有真实挂载 e2e 能抓到)。修复:MdToc 用**自身 bar div 的 ref**(自身 effect 前必然已挂)取 `parentElement` 作为容器——挂载契约「渲染为目标滚动容器的直接子元素」写入组件文档;bar 常驻渲染(零高度无副作用)以保证 ref 恒在。
### 2.4 依赖与产物
- `dompurify ^3.4`(已是 mermaid 的传递依赖,提升为直接依赖后 pnpm 去重单版本,零额外体积)随 editor chunk 内联,未新增 chunk。
- 市场清单守卫合规(dependencies 无 `cordis`、无 install 脚本)。
## 3. 测试
- `tests/markdown-html.spec.ts`:切分器(21 例:块级标签成段/空行终止/注释段/闭标签段/围栏免疫/CommonMark 恢复/README details 三段形)+ tokenizer 平衡分析(9 例)+ 引用定义收集 + 内联门。
- `tests/markdown-html-render.spec.tsx`:真实 `MarkdownText` 渲染——消毒叶子(script 剥除、a 加固、本地 src 重写)、details 跨段嵌套(fence 在 details 元素内、后续段是兄弟)、跨段引用定义解析、禁用标签容器透明、内联替换(表格单元格 `<br/>` 生效、代码块与 `a < b` 不动、内联本地 img 重写)。
- `tests/md-toc.spec.tsx`:阈值门、标题收集、跳转(mock `scrollIntoView`、折叠祖先展开、高亮清除)、observer 迟到标题、Esc 关闭。
- `tests/e2e/mount.e2e.ts`:新增 `readme-style.md` 种子(徽章 div 含 script、details 包 fence+标题、表格内联 `<br/>`、跨段引用定义)+ 探针(徽章 img 真实元素、无 script、details 嵌套、内联 br、引用解析、TOC 按钮→面板→跳转展开)。**现有 mermaid 探针与断言一字不动**(纯 markdown 文档零回归证明)。
## 4. 已知取舍(明确不做)
- GFM 脚注跨段不保(宿主本就把脚注渲染为纯文本);`srcset` 不重写(实际只有远程 URL);HTML 实体不解码;标题锚点 permalink、TOC 滚动联动高亮不做;Sidechat 不动;不换渲染器。
- CommonMark HTML 块类型 1(script/pre/style/textarea 按闭标签终止)与类型 7(单行完整标签)不做专门处理——块级名单 + 空行终止已覆盖 README 级真实用法。
+1
View File
@@ -131,6 +131,7 @@
"@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3",
"clsx": "^2.1.1",
"dompurify": "^3.4.14",
"mermaid": "^11.16.1",
"node-pty": "^1.1.0",
"react-icons": "5.7.0",
+3
View File
@@ -77,6 +77,9 @@ importers:
clsx:
specifier: ^2.1.1
version: 2.1.1
dompurify:
specifier: ^3.4.14
version: 3.4.14
mermaid:
specifier: ^11.16.1
version: 11.17.0
+295
View File
@@ -0,0 +1,295 @@
/**
* The markdown preview's raw-HTML renderer. `markdown-html.ts` lifts HTML
* runs out of the markdown stream; this module renders them as sanitized DOM
* alongside the markdown runs (which keep flowing through the shared
* `MarkdownText`), nests markdown into unclosed block elements the way
* GitHub's linear HTML output does (`<details>` … fence … `</details>`), and
* runs an inline pass that turns literal tag text inside rendered markdown
* (table cells with `<br/>`, `<sub>`, `<img>`) back into elements.
*
* Security posture: every HTML string (block leaves, inline text, wrapper
* open-tag attributes) goes through DOMPurify with an explicit denylist on
* top of its defaults (no script/style/iframe/forms), anchors are forced to
* open in a new tab with noopener, and local media `src` attributes are
* rewritten through the session-scoped `/sidebar/file` media route — the same
* trust fence the markdown image rewriter (`markdown-images.ts`) uses.
*/
import { useLayoutEffect, useMemo, useRef } from 'react'
import { createElement, type ReactNode } from 'react'
import DOMPurify from 'dompurify'
import { MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { ComponentType } from 'react'
import { lazyChunkComponent } from './lazy-chunk.tsx'
import { resolveLocalMediaDest } from './markdown-images.ts'
import {
analyzeHtmlSegment,
type AnalyzedMarkdownHtml,
} from './markdown-html.ts'
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
import type { SessionScope } from './api.ts'
import css from './sidebar.module.css'
/** The chunk-resident markdown renderer (mermaid lazy chunk), shared with the
* legacy no-HTML preview path in TextEditor. */
export const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
'mermaid',
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
)
/** Everything the sanitizers need to resolve local media + scope the route. */
export interface MarkdownHtmlMedia {
scope: SessionScope
path: string
origin: string
}
/** Tag-like text in a rendered text node — the inline pass gate. */
const TAGLIKE_TEXT_RE = /<\/?[a-zA-Z][a-zA-Z0-9-]*[\s/>]/
/** Explicit denylist on top of DOMPurify's defaults: no active content, no
* form chrome, no document-level elements inside a preview. */
const PURIFY_FORBID_TAGS = [
'script', 'style', 'iframe', 'object', 'embed', 'form', 'input', 'button',
'select', 'textarea', 'meta', 'link', 'base', 'frame', 'frameset', 'applet',
]
const PURIFY_FORBID_ATTR = ['srcdoc', 'formaction']
/**
* Post-sanitize hardening on a detached element tree: anchors open in a new
* tab (never navigate the GUI), and local media sources go through the media
* route so they render instead of being dropped by protocol allowlists.
*/
function postProcessSanitized(root: Element, media: MarkdownHtmlMedia): void {
for (const anchor of root.querySelectorAll('a[href]')) {
anchor.setAttribute('target', '_blank')
anchor.setAttribute('rel', 'noopener noreferrer')
}
for (const element of root.querySelectorAll('img, video, audio, source')) {
const src = element.getAttribute('src')
if (src === null) continue
element.setAttribute('src', resolveLocalMediaDest(src, media.scope, media.path, media.origin))
}
}
/** Sanitize one balanced HTML span into markup for dangerouslySetInnerHTML. */
function sanitizeHtmlBlock(source: string, media: MarkdownHtmlMedia): string {
const holder = document.createElement('div')
holder.innerHTML = DOMPurify.sanitize(source, {
FORBID_TAGS: PURIFY_FORBID_TAGS,
FORBID_ATTR: PURIFY_FORBID_ATTR,
})
postProcessSanitized(holder, media)
return holder.innerHTML
}
/**
* Sanitize literal tag text from a rendered markdown text node. Returns null
* when nothing real survived (pure prose like `a < b` — the DOMPurify output
* has no element children), so the caller leaves the text node untouched.
*/
function sanitizeInlineHtml(text: string, media: MarkdownHtmlMedia): string | null {
const holder = document.createElement('span')
holder.innerHTML = DOMPurify.sanitize(text, {
FORBID_TAGS: PURIFY_FORBID_TAGS,
FORBID_ATTR: PURIFY_FORBID_ATTR,
})
if (holder.firstElementChild === null) return null
postProcessSanitized(holder, media)
return holder.innerHTML
}
/**
* Sanitize a wrapper open tag (`<details open>`) into React props. Returns
* null when DOMPurify dropped the whole tag (denied element) — the renderer
* then treats the wrapper as transparent. `class`/`for` map to their React
* names; `style` is dropped (React needs an object; wrappers with inline
* styles are vanishingly rare and not worth a CSS parser).
*/
function sanitizeTagProps(tag: string, attrs: string): Record<string, string> | null {
const probe = DOMPurify.sanitize(`<${tag}${attrs}></${tag}>`, {
FORBID_TAGS: PURIFY_FORBID_TAGS,
FORBID_ATTR: PURIFY_FORBID_ATTR,
})
const holder = document.createElement('div')
holder.innerHTML = probe
const element = holder.firstElementChild
if (element === null || element.tagName.toLowerCase() !== tag) return null
const props: Record<string, string> = {}
for (const attr of element.attributes) {
if (/^on/i.test(attr.name) || !/^[a-zA-Z][a-zA-Z0-9:._-]*$/.test(attr.name)) continue
if (attr.name === 'style') continue
props[attr.name === 'class' ? 'className' : attr.name === 'for' ? 'htmlFor' : attr.name] = attr.value
}
return props
}
/**
* The inline pass: walk the rendered markdown's text nodes and swap any that
* contain tag-like text for a sanitized `<span data-html-inline>`. Rendered
* code (inline `code`, `pre`, the host `.md-code-block`, mermaid mounts, and
* spans this pass already produced) is skipped. The same commit-then-operate
* pattern the mermaid swap uses: React keeps owning the host tree, only leaf
* text nodes are replaced, and a text change re-renders the subtree fresh.
*/
function runInlineHtmlPass(container: HTMLElement, media: MarkdownHtmlMedia): void {
const walker = document.createTreeWalker(container, NodeFilter.SHOW_TEXT, {
acceptNode: (node) => {
const parent = (node as Text).parentElement
if (parent === null) return NodeFilter.FILTER_REJECT
if (parent.closest('code, pre, .md-code-block, [data-mermaid-processed], [data-html-inline]')) {
return NodeFilter.FILTER_REJECT
}
return TAGLIKE_TEXT_RE.test((node as Text).data)
? NodeFilter.FILTER_ACCEPT
: NodeFilter.FILTER_REJECT
},
})
const targets: Text[] = []
for (let node = walker.nextNode(); node !== null; node = walker.nextNode()) targets.push(node as Text)
for (const node of targets) {
const html = sanitizeInlineHtml(node.data, media)
if (html === null) continue
const span = document.createElement('span')
span.setAttribute('data-html-inline', '')
span.innerHTML = html
node.replaceWith(span)
}
}
interface MarkdownSegmentProps {
text: string
hasMermaid: boolean
media: MarkdownHtmlMedia
codeLabels: { copyLabel: string; copiedLabel: string }
}
/**
* One markdown run of a split document: the shared MarkdownText pass (or the
* mermaid chunk renderer when the run contains a mermaid fence), plus the
* inline HTML pass. The pass runs after every text change and is re-armed by
* a MutationObserver so it also catches content that appears late (the lazy
* mermaid chunk mounting, shiki highlighting settling) — it is idempotent and
* skips its own output, so mutation feedback settles after one extra pass.
*/
function MarkdownSegment({ text, hasMermaid, media, codeLabels }: MarkdownSegmentProps): ReactNode {
const containerRef = useRef<HTMLDivElement>(null)
useLayoutEffect(() => {
const container = containerRef.current
if (container === null) return
runInlineHtmlPass(container, media)
let scheduled = false
const observer = new MutationObserver(() => {
if (scheduled) return
scheduled = true
queueMicrotask(() => {
scheduled = false
runInlineHtmlPass(container, media)
})
})
observer.observe(container, { childList: true, subtree: true })
return () => { observer.disconnect() }
}, [text, media])
return (
<div ref={containerRef}>
{hasMermaid
? <LazyMermaidMarkdown text={text} codeLabels={codeLabels} />
: <MarkdownText text={text} codeLabels={codeLabels} />}
</div>
)
}
/** A sanitized, balanced HTML span rendered as its own block. */
function HtmlLeaf({ html }: { html: string }): ReactNode {
return (
<div className={css.editorHtmlBlock} data-dsh-html-segment dangerouslySetInnerHTML={{ __html: html }} />
)
}
/** One fully-prepared HTML run: sanitized leaves + wrapper opens/closes. */
type PreparedHtmlPart =
| { kind: 'html'; html: string }
| { kind: 'open'; tag: string; props: Record<string, string> | null }
| { kind: 'close' }
type PreparedSegment =
| { kind: 'markdown'; text: string; hasMermaid: boolean }
| { kind: 'html'; parts: PreparedHtmlPart[] }
interface MarkdownDocumentProps {
info: AnalyzedMarkdownHtml
media: MarkdownHtmlMedia
codeLabels: { copyLabel: string; copiedLabel: string }
}
/**
* The split-document renderer: markdown runs render through MarkdownSegment,
* HTML runs render as sanitized leaves, and unclosed block elements lower the
* following runs into themselves until their close part pops the frame (the
* renderer's frame stack persists across segments). Stray closes at the top
* level render nothing (the sanitizer/parser would drop them anyway), and
* frames still open at the end of the document are closed like a browser
* parser would. Sanitization runs once per prepared change, in a memo.
*/
export function MarkdownDocument({ info, media, codeLabels }: MarkdownDocumentProps): ReactNode {
const prepared = useMemo<PreparedSegment[]>(() => info.segments.map((segment): PreparedSegment => {
if (segment.kind === 'markdown') {
const defs = info.referenceDefinitions
const text = defs === '' ? segment.text : `${segment.text}\n\n${defs}`
return {
kind: 'markdown',
text,
hasMermaid: splitMermaidBlocks(text).some((block) => block.kind === 'mermaid'),
}
}
return {
kind: 'html',
parts: analyzeHtmlSegment(segment.text).parts.map((part): PreparedHtmlPart => {
if (part.kind === 'html') return { kind: 'html', html: sanitizeHtmlBlock(part.html, media) }
if (part.kind === 'open') return { kind: 'open', tag: part.tag, props: sanitizeTagProps(part.tag, part.attrs) }
return { kind: 'close' }
}),
}
// `media` is a memoized object in the host (TextEditor); identity tracks
// scope/path/origin changes so sanitization re-runs exactly when needed.
}), [info, media])
const nodes: ReactNode[] = []
const frames: { tag: string; props: Record<string, string> | null; children: ReactNode[] }[] = []
const emit = (node: ReactNode): void => {
const frame = frames[frames.length - 1]
if (frame !== undefined) frame.children.push(node)
else nodes.push(node)
}
let key = 0
for (const segment of prepared) {
if (segment.kind === 'markdown') {
emit(<MarkdownSegment key={`md-${key += 1}`} text={segment.text} hasMermaid={segment.hasMermaid} media={media} codeLabels={codeLabels} />)
continue
}
for (const part of segment.parts) {
if (part.kind === 'html') {
if (part.html.trim() === '') continue
emit(<HtmlLeaf key={`html-${key += 1}`} html={part.html} />)
} else if (part.kind === 'open') {
frames.push({ tag: part.tag, props: part.props, children: [] })
} else {
const frame = frames.pop()
if (frame === undefined) continue
if (frame.props === null) {
for (const child of frame.children) emit(child)
} else {
emit(createElement(frame.tag, { ...frame.props, key: `wrap-${key += 1}` }, ...frame.children))
}
}
}
}
while (frames.length > 0) {
const frame = frames.pop()!
if (frame.props === null) {
for (const child of frame.children) emit(child)
} else {
emit(createElement(frame.tag, { ...frame.props, key: `wrap-${key += 1}` }, ...frame.children))
}
}
return <>{nodes}</>
}
+42 -21
View File
@@ -14,7 +14,6 @@
* renders the controls instead.
*/
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'
@@ -30,7 +29,10 @@ 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 { analyzeMarkdownHtml } from './markdown-html.ts'
import { LazyMermaidMarkdown, MarkdownDocument, type MarkdownHtmlMedia } from './MarkdownHtml.tsx'
import { MdToc } from './md-toc.tsx'
import { splitMermaidBlocks } from './mermaid-blocks.ts'
import { t } from './locales.ts'
import type { EditorToolbarState, FileViewerProps } from './service.ts'
import css from './sidebar.module.css'
@@ -45,16 +47,6 @@ 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
@@ -266,9 +258,28 @@ export function TextEditor(props: FileViewerProps) {
() => (markdown && mode === 'preview' ? splitMermaidBlocks(mdText) : []),
[markdown, mode, mdText],
)
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null only
* for documents that actually contain HTML — plain markdown keeps the
* legacy single-pass render path below, byte-for-byte. */
const htmlInfo = useMemo(
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(mdText) : null),
[markdown, mode, mdText],
)
const hasMermaid = useMemo(
() => mdBlocks.some(block => block.kind === 'mermaid'),
[mdBlocks],
() => htmlInfo !== null
? htmlInfo.segments.some((segment) => segment.kind === 'markdown'
&& splitMermaidBlocks(segment.text).some((block) => block.kind === 'mermaid'))
: mdBlocks.some((block) => block.kind === 'mermaid'),
[htmlInfo, mdBlocks],
)
/** The media context for the split renderer (local-src rewriting inside
* sanitized HTML). Memoized on primitives: MarkdownDocument sanitizes per
* `media` identity, so a fresh object per render would re-sanitize every
* keystroke. */
const htmlMedia = useMemo<MarkdownHtmlMedia>(
() => ({ scope, path, origin: window.location.origin }),
// eslint-disable-next-line react-hooks/exhaustive-deps
[scope.sessionId, scope.cwd, path],
)
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
@@ -395,13 +406,23 @@ 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. 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={previewText} codeLabels={codeLabels} />
: <MarkdownText text={previewText} codeLabels={codeLabels} />}
the active locale on live switches. Plain markdown (no HTML)
renders exactly as before — one MarkdownText pass for the whole
document, or the mermaid lazy chunk (single markdown parse;
cross-fence references/footnotes stay intact) when a mermaid
fence exists. Documents containing HTML (block runs or inline
tags) render through the split document renderer: markdown runs
keep the MarkdownText/mermaid path while raw-HTML runs render
as sanitized DOM (see markdown-html.tsx). */}
{/* The outline button rides on top of the preview scroll container
(sticky, zero-height — first child so it pins from the very
top) once the document has enough headings. */}
<MdToc />
{htmlInfo !== null
? <MarkdownDocument info={htmlInfo} media={htmlMedia} codeLabels={codeLabels} />
: hasMermaid
? <LazyMermaidMarkdown text={previewText} codeLabels={codeLabels} />
: <MarkdownText text={previewText} codeLabels={codeLabels} />}
</div>
)}
{html && mode === 'preview' && (
+1
View File
@@ -80,6 +80,7 @@ export const ar: Record<string, string> = {
terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):',
terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})',
preview: 'معاينة',
toc: 'الفهرس',
edit: 'تحرير',
mermaidError: 'فشل رسم Mermaid',
mermaidZoomIn: 'تكبير',
+1
View File
@@ -65,6 +65,7 @@ export const de: Record<string, string> = {
terminalDepsHint: 'Führen Sie den folgenden Befehl in einem Terminal oder in cmd auf dem DSH-System aus, um dies zu beheben, und klicken Sie dann auf „Erneut versuchen“ (node-pty bleibt mit der DSH-Core-Version synchron):',
terminalDepsProfile: ' (erkanntes Profil: {profile})',
preview: 'Vorschau',
toc: 'Inhaltsverzeichnis',
edit: 'Bearbeiten',
mermaidError: 'Mermaid-Rendering fehlgeschlagen',
mermaidZoomIn: 'Vergrößern',
+1
View File
@@ -72,6 +72,7 @@ export const fr: Record<string, string> = {
terminalDepsHint: 'Exécutez la commande suivante dans un terminal ou cmd de l’environnement DSH pour réparer, puis cliquez sur Réessayer (node-pty reste à la même version que le cœur DSH) :',
terminalDepsProfile: ' (profil détecté : {profile})',
preview: 'Aperçu',
toc: 'Sommaire',
edit: 'Modifier',
mermaidError: 'Échec du rendu Mermaid',
mermaidZoomIn: 'Agrandir',
+1
View File
@@ -79,6 +79,7 @@ export const hi: Record<string, string> = {
terminalDepsHint: 'DSH मशीन पर एक टर्मिनल या cmd में नीचे दिया गया कमांड चलाएँ, फिर पुनः प्रयास करें (node-pty DSH कोर संस्करण के साथ सिंक रहता है):',
terminalDepsProfile: ' (पहचाना गया प्रोफ़ाइल: {profile})',
preview: 'पूर्वावलोकन',
toc: 'विषय-सूची',
edit: 'संपादित करें',
mermaidError: 'Mermaid रेंडर विफल',
mermaidZoomIn: 'ज़ूम इन',
+1
View File
@@ -77,6 +77,7 @@ export const id: Record<string, string> = {
terminalDepsHint: 'Jalankan perintah di bawah ini di terminal atau cmd pada mesin DSH untuk memperbaikinya, lalu coba lagi (node-pty tetap sinkron dengan versi inti DSH):',
terminalDepsProfile: ' (profil terdeteksi: {profile})',
preview: 'Pratinjau',
toc: 'Daftar Isi',
edit: 'Edit',
mermaidError: 'Render Mermaid gagal',
mermaidZoomIn: 'Perbesar',
+1
View File
@@ -70,6 +70,7 @@ export const it: Record<string, string> = {
terminalDepsHint: 'Esegua il comando seguente in un terminale o cmd sul computer DSH per ripristinarlo, poi riprovi (node-pty resta sincronizzato con la versione del core DSH):',
terminalDepsProfile: ' (rilevato profilo: {profile})',
preview: 'Anteprima',
toc: 'Indice',
edit: 'Modifica',
mermaidError: 'Rendering Mermaid non riuscito',
mermaidZoomIn: 'Ingrandisci',
+1
View File
@@ -79,6 +79,7 @@ export const ja: Record<string, string> = {
terminalDepsHint: 'DSH 環境のターミナルまたは cmd で以下のコマンドを実行して修復し、再試行してください(node-pty は DSH コアと同じバージョンを維持):',
terminalDepsProfile: '(検出された profile:{profile})',
preview: 'プレビュー',
toc: '目次',
edit: '編集',
mermaidError: 'Mermaid レンダリング失敗',
mermaidZoomIn: '拡大',
+1
View File
@@ -71,6 +71,7 @@ export const ko: Record<string, string> = {
terminalDepsHint: 'DSH가 설치된 환경의 터미널 또는 cmd에서 아래 명령을 실행하여 복구한 후 다시 시도를 클릭하세요(node-pty는 DSH 코어와 동일한 버전을 유지합니다):',
terminalDepsProfile: ' (profile 감지됨: {profile})',
preview: '미리보기',
toc: '목차',
edit: '편집',
mermaidError: 'Mermaid 렌더링 실패',
mermaidZoomIn: '확대',
+1
View File
@@ -77,6 +77,7 @@ export const nl: Record<string, string> = {
terminalDepsHint: 'Voer het onderstaande commando uit in een terminal of cmd op de DSH-machine om het te herstellen en probeer het opnieuw (node-pty blijft in sync met de DSH-coreversie):',
terminalDepsProfile: ' (gedetecteerde profile: {profile})',
preview: 'Voorbeeld',
toc: 'Inhoudsopgave',
edit: 'Bewerken',
mermaidError: 'Mermaid-renderen mislukt',
mermaidZoomIn: 'Inzoomen',
+1
View File
@@ -81,6 +81,7 @@ export const pl: Record<string, string> = {
terminalDepsHint: 'Uruchom poniższe polecenie w terminalu lub cmd na maszynie DSH, aby to naprawić, a następnie ponów (node-pty pozostaje w synchronizacji z wersją rdzenia DSH):',
terminalDepsProfile: ' (wykryty profil: {profile})',
preview: 'Podgląd',
toc: 'Spis treści',
edit: 'Edytuj',
mermaidError: 'Renderowanie Mermaid nie powiodło się',
mermaidZoomIn: 'Powiększ',
+1
View File
@@ -62,6 +62,7 @@ export const pt: Record<string, string> = {
terminalDepsHint: 'Execute o comando abaixo em um terminal ou cmd na máquina do DSH para reparar e tente novamente (node-pty permanece em sincronia com a versão do núcleo do DSH):',
terminalDepsProfile: ' (perfil detectado: {profile})',
preview: 'Pré-visualizar',
toc: 'Sumário',
edit: 'Editar',
mermaidError: 'Falha ao renderizar o Mermaid',
mermaidZoomIn: 'Ampliar',
+1
View File
@@ -75,6 +75,7 @@ export const ru: Record<string, string> = {
terminalDepsHint: 'Выполните приведённую ниже команду в терминале или cmd на машине DSH, затем нажмите «Повторить» (node-pty остаётся синхронизированным с версией ядра DSH):',
terminalDepsProfile: ' (обнаружен profile: {profile})',
preview: 'Предпросмотр',
toc: 'Оглавление',
edit: 'Изменить',
mermaidError: 'Сбой рендеринга Mermaid',
mermaidZoomIn: 'Приблизить',
+1
View File
@@ -62,6 +62,7 @@ export const sv: Record<string, string> = {
terminalDepsHint: 'Kör kommandot nedan i en terminal eller cmd på DSH-maskinen för att reparera det, försök sedan igen (node-pty hålls synkad med DSH-kärnversionen):',
terminalDepsProfile: ' (upptäckt profil: {profile})',
preview: 'Förhandsgranska',
toc: 'Innehållsförteckning',
edit: 'Redigera',
mermaidError: 'Mermaid-rendering misslyckades',
mermaidZoomIn: 'Zooma in',
+1
View File
@@ -79,6 +79,7 @@ export const th: Record<string, string> = {
terminalDepsHint: 'เรียกใช้คำสั่งด้านล่างในเทอร์มินัลหรือ cmd บนเครื่อง DSH เพื่อซ่อมแซม แล้วกดลองอีกครั้ง (node-pty ต้องซิงค์กับเวอร์ชันหลักของ DSH):',
terminalDepsProfile: ' (ตรวจพบ profile: {profile})',
preview: 'พรีวิว',
toc: 'สารบัญ',
edit: 'แก้ไข',
mermaidError: 'เรนเดอร์ Mermaid ล้มเหลว',
mermaidZoomIn: 'ซูมเข้า',
+1
View File
@@ -79,6 +79,7 @@ export const tr: Record<string, string> = {
terminalDepsHint: 'Onarmak için DSH makinesindeki bir terminalde veya cmd’de aşağıdaki komutu çalıştırın, sonra yeniden deneyin (node-pty, DSH çekirdek sürümüyle senkron kalır):',
terminalDepsProfile: ' (algılanan profil: {profile})',
preview: 'Önizleme',
toc: 'İçindekiler',
edit: 'Düzenle',
mermaidError: 'Mermaid oluşturma başarısız',
mermaidZoomIn: 'Yakınlaştır',
+1
View File
@@ -79,6 +79,7 @@ export const vi: Record<string, string> = {
terminalDepsHint: 'Chạy lệnh sau trong terminal hoặc cmd trên máy DSH để sửa, rồi nhấn thử lại (node-pty đồng bộ phiên bản với lõi DSH):',
terminalDepsProfile: ' (phát hiện profile: {profile})',
preview: 'Xem trước',
toc: 'Mục lục',
edit: 'Chỉnh sửa',
mermaidError: 'Render Mermaid thất bại',
mermaidZoomIn: 'Phóng to',
+1
View File
@@ -94,6 +94,7 @@ export const zhHK: Record<string, string> = {
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
preview: '預覽',
toc: '目錄',
edit: '編輯',
mermaidError: 'Mermaid 渲染失敗',
mermaidZoomIn: '放大',
+1
View File
@@ -94,6 +94,7 @@ export const zhMO: Record<string, string> = {
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
preview: '預覽',
toc: '目錄',
edit: '編輯',
mermaidError: 'Mermaid 渲染失敗',
mermaidZoomIn: '放大',
+1
View File
@@ -94,6 +94,7 @@ export const zhTW: Record<string, string> = {
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})',
preview: '預覽',
toc: '目錄',
edit: '編輯',
mermaidError: 'Mermaid 渲染失敗',
mermaidZoomIn: '放大',
+2
View File
@@ -86,6 +86,7 @@ export const zh = {
terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
terminalDepsProfile: '(检测到 profile:{profile})',
preview: '预览',
toc: '目录',
edit: '编辑',
mermaidError: 'Mermaid 渲染失败',
mermaidZoomIn: '放大',
@@ -422,6 +423,7 @@ export const en: Record<keyof typeof zh, string> = {
terminalDepsHint: 'Run the command below in a terminal or cmd on the DSH machine to repair it, then retry (node-pty stays in sync with the DSH core version):',
terminalDepsProfile: ' (detected profile: {profile})',
preview: 'Preview',
toc: 'Table of contents',
edit: 'Edit',
mermaidError: 'Mermaid render failed',
mermaidZoomIn: 'Zoom in',
+331
View File
@@ -0,0 +1,331 @@
/**
* Raw-HTML block detection for the markdown preview. The shared `MarkdownText`
* renders raw HTML as literal text (a chat-security stance), so a GitHub-style
* README (`<div align="center">` badge walls, `<details>` collapsibles with
* markdown inside, table cells full of inline tags) previews as source soup.
* This module's pure splitter lifts those HTML runs OUT of the markdown stream
* before rendering: markdown runs keep flowing through `MarkdownText` (shiki /
* KaTeX / GFM intact, mermaid chunk path unchanged) while HTML runs render as
* sanitized DOM (see markdown-html.tsx).
*
* Splitting follows CommonMark's shape closely enough for real-world READMEs:
* a line outside code fences that starts with a block-level tag (type-6 list
* below) or `<!--` opens an HTML run that extends to the next blank line
* (comments end at the line containing `-->`). Inline-only tags (`<b>`, `<br>`,
* `<a>`…) never open a run — they stay in the markdown stream and are handled
* by the inline pass instead. Unclosed block tags (`<details>` … markdown …
* `</details>`) are surfaced by {@link analyzeHtmlSegment} as ordered parts so
* the renderer can nest the in-between markdown inside the open element, the
* way GitHub's linear HTML output nests.
*/
import { CLOSE_FENCE_RE, OPEN_FENCE_RE, fenceInfo } from './mermaid-blocks.ts'
/** A run of raw HTML lines lifted out of the markdown stream. */
export interface HtmlSegment {
kind: 'html'
text: string
}
/** A run of markdown source (may contain non-mermaid fences, inline HTML…). */
export interface MarkdownHtmlSegment {
kind: 'markdown'
text: string
}
export type MdHtmlSegment = MarkdownHtmlSegment | HtmlSegment
/** The doc-wide analysis the preview consumes (all pure, unit-tested here). */
export interface AnalyzedMarkdownHtml {
/** The document split into markdown / html runs (empty input → []). */
segments: MdHtmlSegment[]
/** True when at least one html run was lifted out. */
hasBlockHtml: boolean
/** True when the source contains any tag-like text (block OR inline). */
hasInlineHtml: boolean
/** Every reference definition found in the markdown runs (`[label]: dest`
* lines), joined — appended to each markdown run so `[text][id]` keeps
* resolving across the lifted HTML runs (first-match wins makes the
* appended copy inert inside runs that already define it). */
referenceDefinitions: string
}
/**
* CommonMark HTML-block type-6 tag names (block-level elements), lowercased.
* A line starting with one of these (open or close) outside a fence begins an
* HTML run. `<summary>` is CommonMark-inline but intentionally included: it is
* the idiomatic first child of a `<details>` run in GitHub-flavored READMEs.
*/
export const HTML_BLOCK_TAGS: ReadonlySet<string> = new Set([
'address', 'article', 'aside', 'base', 'basefont', 'blockquote', 'body',
'caption', 'center', 'col', 'colgroup', 'dd', 'details', 'dialog', 'dir',
'div', 'dl', 'dt', 'fieldset', 'figcaption', 'figure', 'footer', 'form',
'frame', 'frameset', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'head', 'header',
'hr', 'html', 'legend', 'li', 'link', 'main', 'menu', 'menuitem', 'nav',
'noframes', 'ol', 'optgroup', 'option', 'p', 'param', 'picture', 'pre',
'section', 'source', 'summary', 'table', 'tbody', 'td', 'tfoot', 'th',
'thead', 'title', 'tr', 'track', 'ul', 'video',
])
/** HTML void elements — never pushed on the balance stack. */
const VOID_TAGS: ReadonlySet<string> = new Set([
'area', 'base', 'br', 'col', 'embed', 'hr', 'img', 'input', 'link', 'meta',
'param', 'source', 'track', 'wbr',
])
/** A block-tag line start: indent + `<` or `</` + tag name + space/`/`/`>`. */
const HTML_BLOCK_START_RE = /^ {0,3}<\/?([a-zA-Z][a-zA-Z0-9-]*)[\s/>]/
/** A comment-open line start (CommonMark type 2). */
const COMMENT_START_RE = /^ {0,3}<!--/
/** Tag-like text anywhere — the cheap gate for the inline pass. */
const TAGLIKE_RE = /<\/?[a-zA-Z][a-zA-Z0-9-]*[\s/>]/
/** One HTML tag token (open/close/void) in a lifted HTML run. */
interface HtmlToken {
kind: 'open' | 'close' | 'void'
tag: string
/** Raw attribute source of an open tag (between the name and `>`/`/>`). */
attrs: string
start: number
end: number
}
/** A comment range within a run — tags inside comments are not tokens. */
interface CommentRange {
start: number
end: number
}
/** A full tag match including its `>` (token regex; quotes guard `>` inside attrs). */
const TAG_TOKEN_RE = /<(\/?)([a-zA-Z][a-zA-Z0-9-]*)((?:"[^"]*"|'[^']*'|[^>"'])*)>/g
const COMMENT_RE = /<!--[\s\S]*?-->/g
/**
* Tokenize a lifted HTML run into tags (comments are located first so tags
* inside them are skipped). Text between tokens is not tokenized — it stays
* part of the raw `html` spans the part analysis slices out.
*/
function tokenizeHtml(source: string): HtmlToken[] {
const comments: CommentRange[] = []
for (const match of source.matchAll(COMMENT_RE)) {
comments.push({ start: match.index, end: match.index + match[0].length })
}
const inComment = (index: number): boolean =>
comments.some((range) => index >= range.start && index < range.end)
const tokens: HtmlToken[] = []
for (const match of source.matchAll(TAG_TOKEN_RE)) {
if (inComment(match.index)) continue
const closing = match[1] === '/'
const tag = match[2]!.toLowerCase()
let attrs = match[3] ?? ''
// Self-closing syntax (`<div/>`) behaves as void regardless of the name.
const selfClosing = /\/\s*$/.test(attrs)
if (selfClosing) attrs = attrs.replace(/\/\s*$/, '')
tokens.push({
kind: closing ? 'close' : selfClosing || VOID_TAGS.has(tag) ? 'void' : 'open',
tag,
attrs,
start: match.index,
end: match.index + match[0].length,
})
}
return tokens
}
/** One structural piece of a lifted HTML run, in document order. */
export type HtmlPart =
| { kind: 'html'; html: string }
| { kind: 'open'; tag: string; attrs: string }
| { kind: 'close'; tag: string }
/** The run's structure relative to the surrounding document nesting. */
export interface HtmlSegmentShape {
parts: HtmlPart[]
}
/**
* Reduce a lifted HTML run to ordered parts: balanced spans become `html`
* leaves, unclosed open tags become `open` (a wrapper the renderer lowers
* following markdown runs into), unmatched closes become `close` (pops one
* wrapper level). A mismatched close pops through the matching open — the
* HTML parser's implicit-close behavior. Runs with no structural tags reduce
* to a single `html` part.
*/
export function analyzeHtmlSegment(source: string): HtmlSegmentShape {
const tokens = tokenizeHtml(source)
const stack: { token: HtmlToken }[] = []
const unmatchedCloses: HtmlToken[] = []
for (let index = 0; index < tokens.length; index += 1) {
const token = tokens[index]!
if (token.kind === 'void') continue
if (token.kind === 'open') {
stack.push({ token })
continue
}
// Close: pop through the nearest matching open (implicit closes inside).
let matchAt = -1
for (let depth = stack.length - 1; depth >= 0; depth -= 1) {
if (stack[depth]!.token.tag === token.tag) { matchAt = depth; break }
}
if (matchAt === -1) {
unmatchedCloses.push(token)
continue
}
stack.length = matchAt
}
// Structural tokens in source order: unmatched closes + surviving opens.
const structural: Array<{ token: HtmlToken; kind: 'open' | 'close' }> = []
for (const token of unmatchedCloses) structural.push({ token, kind: 'close' })
for (const entry of stack) structural.push({ token: entry.token, kind: 'open' })
structural.sort((a, b) => a.token.start - b.token.start)
if (structural.length === 0) return { parts: [{ kind: 'html', html: source }] }
const parts: HtmlPart[] = []
let cursor = 0
for (const { token, kind } of structural) {
if (token.start > cursor) parts.push({ kind: 'html', html: source.slice(cursor, token.start) })
parts.push(kind === 'open'
? { kind: 'open', tag: token.tag, attrs: token.attrs }
: { kind: 'close', tag: token.tag })
cursor = token.end
}
if (cursor < source.length) parts.push({ kind: 'html', html: source.slice(cursor) })
return { parts }
}
/**
* Split markdown source into markdown / html runs (fence-aware: an HTML-looking
* line inside any fenced code block is content, not a run start). Blank lines
* terminate HTML runs and are dropped between segments (they carry no markdown
* semantics the preview needs); everything else stays byte-identical.
*/
export function splitHtmlBlocks(text: string): MdHtmlSegment[] {
if (text === '') return []
const lines = text.split('\n')
const segments: MdHtmlSegment[] = []
let markdown: string[] = []
let html: string[] = []
/** Fence state: the char + length of the currently open fence, if any. */
let openFence: { char: string; length: number } | null = null
/** Comment state: an HTML comment run ends at the line containing `-->`. */
let inComment = false
/** True while accumulating a blank-line-terminated HTML run. */
let inHtmlRun = false
const flushMarkdown = (): void => {
// Separator blank lines around HTML runs carry no markdown semantics.
while (markdown.length > 0 && isBlank(markdown[markdown.length - 1] ?? '')) markdown.pop()
if (markdown.length === 0) return
segments.push({ kind: 'markdown', text: markdown.join('\n') })
markdown = []
}
const flushHtml = (): void => {
if (html.length === 0) return
segments.push({ kind: 'html', text: html.join('\n') })
html = []
inHtmlRun = false
inComment = false
}
const isBlank = (line: string): boolean => /^[ \t]*$/.test(line)
for (const line of lines) {
if (openFence !== null) {
const close = CLOSE_FENCE_RE.exec(line)
if (close !== null && close[1]!.charAt(0) === openFence.char && close[1]!.length >= openFence.length) {
openFence = null
}
markdown.push(line)
continue
}
const fenceMatch = OPEN_FENCE_RE.exec(line)
if (fenceMatch !== null) {
const fence = fenceMatch[1]!
const rest = line.slice(fenceMatch.index + fenceMatch[0].length)
const info = fenceInfo(rest, fence)
if (info !== null) {
openFence = { char: fence.charAt(0), length: fence.length }
markdown.push(line)
continue
}
// Not a valid fence (backtick info containing backticks): plain text.
markdown.push(line)
continue
}
if (inComment) {
html.push(line)
if (line.includes('-->')) flushHtml()
continue
}
if (inHtmlRun) {
if (isBlank(line)) {
flushHtml()
continue
}
html.push(line)
continue
}
if (COMMENT_START_RE.test(line)) {
flushMarkdown()
html.push(line)
inComment = true
if (line.includes('-->')) flushHtml()
continue
}
const htmlMatch = HTML_BLOCK_START_RE.exec(line)
const tag = htmlMatch?.[1]?.toLowerCase()
if (htmlMatch !== null && tag !== undefined && HTML_BLOCK_TAGS.has(tag)) {
flushMarkdown()
html.push(line)
inHtmlRun = true
continue
}
// Blank lines only survive inside a markdown run (paragraph separators);
// at a run boundary they are separators and are dropped.
if (isBlank(line) && markdown.length === 0) continue
markdown.push(line)
}
flushMarkdown()
flushHtml()
return segments
}
/** A reference definition line: `[label]: destination` (up to 3 spaces indent). */
const REFERENCE_DEF_RE = /^ {0,3}\[((?:[^\][]|\[[^\]]*\])*)\]:\s*(?:<([^<>]*)>|(\S+))/
/**
* Collect the reference definitions of every markdown run (HTML runs cannot
* define them), in document order, newline-joined for appending.
*/
export function collectReferenceDefinitions(segments: readonly MdHtmlSegment[]): string {
const defs: string[] = []
for (const segment of segments) {
if (segment.kind !== 'markdown') continue
for (const line of segment.text.split('\n')) {
if (REFERENCE_DEF_RE.test(line)) defs.push(line)
}
}
return defs.join('\n')
}
/**
* The whole-document gate + split the preview consumes. `hasInlineHtml` is a
* cheap source-level regex (code-fence content may false-positive; the inline
* pass skips rendered code blocks anyway, so a false positive only costs the
* enhanced render path, never a behavior change).
*/
export function analyzeMarkdownHtml(text: string): AnalyzedMarkdownHtml {
const segments = splitHtmlBlocks(text)
return {
segments,
hasBlockHtml: segments.some((segment) => segment.kind === 'html'),
hasInlineHtml: TAGLIKE_RE.test(text),
referenceDefinitions: collectReferenceDefinitions(segments),
}
}
+29 -16
View File
@@ -65,28 +65,41 @@ function normalizeLocalPath(path: string): string {
* so the core rewrite stays pure and unit-testable.
* @returns The markdown with local image destinations rewritten in place.
*/
/**
* Resolve one media destination against the session's media route: local
* (relative or absolute) paths become absolute `/sidebar/file` URLs (prefixed
* with the GUI's own origin so the shared MarkdownText http(s) allowlist
* accepts them), while remote URLs, `#`-anchors and empty destinations are
* returned untouched. Shared by the markdown image rewriter below and by the
* preview's raw-HTML sanitizer (`markdown-html.tsx`, which meets the same
* allowlist when rendering `<img src="./x.png">` inside HTML blocks).
*/
export function resolveLocalMediaDest(
dest: string,
scope: SessionScope,
filePath: string,
origin: string,
): string {
const trimmed = dest.trim()
if (trimmed === '' || trimmed.startsWith('#')) return dest
if (isRemoteUrl(trimmed)) return dest
const slash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
const directory = slash === -1 ? '/' : filePath.slice(0, slash + 1)
const candidate = isAbsolutePath(trimmed) ? trimmed : directory + trimmed
// Mirrors api.ts fileUrl/mediaUrl for the /sidebar/file media route, made
// absolute so the shared MarkdownText http(s) allowlist accepts it.
const params = new URLSearchParams({ sessionId: scope.sessionId, path: normalizeLocalPath(candidate) })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
return `${origin}/sidebar/file?${params.toString()}`
}
export function rewriteLocalImageUrls(
text: string,
scope: SessionScope,
filePath: string,
origin: string,
): string {
const slash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
const directory = slash === -1 ? '/' : filePath.slice(0, slash + 1)
const mediaUrl = (candidate: string): string => {
// Mirrors api.ts fileUrl/mediaUrl for the /sidebar/file media route, made
// absolute so the shared MarkdownText http(s) allowlist accepts it.
const params = new URLSearchParams({ sessionId: scope.sessionId, path: candidate })
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
return `${origin}/sidebar/file?${params.toString()}`
}
const resolve = (dest: string): string => {
const trimmed = dest.trim()
if (trimmed === '' || trimmed.startsWith('#')) return dest
if (isRemoteUrl(trimmed)) return dest
const candidate = isAbsolutePath(trimmed) ? trimmed : directory + trimmed
return mediaUrl(normalizeLocalPath(candidate))
}
const resolve = (dest: string): string => resolveLocalMediaDest(dest, scope, filePath, origin)
// Mask fenced code blocks and inline code spans so image-looking text
// inside documentation examples is never rewritten. The sentinel uses a
+127
View File
@@ -0,0 +1,127 @@
/**
* The markdown preview's table of contents: a sticky zero-height bar keeping
* a small "outline" button pinned at the top-right of the preview's scroll
* container, visible once the document has at least {@link TOC_MIN_HEADINGS}
* headings. The button opens a popover outline (h1–h6 collected from the
* rendered DOM — so headings inside HTML runs count too); clicking an entry
* expands any collapsed `<details>` ancestor, smooth-scrolls the heading into
* view and flashes it. The scan re-runs through a MutationObserver so late
* content (lazy mermaid chunk, shiki highlighting) is picked up.
*
* Mount contract: render as a DIRECT child of the scroll container it
* outlines. The container is discovered through the bar's own DOM position
* (`parentElement`), not a passed ref — React attaches a parent host
* element's ref only after its children's layout effects have run, so a
* ref-based container read here would see null at mount and never re-run.
* The bar itself renders unconditionally (zero height, pointer-events
* none), so it never affects the preview layout.
*/
import { useLayoutEffect, useRef, useState } from 'react'
import type { ReactNode } from 'react'
import { IconListPenOutline16 } from '@deepseek-ai/dsh-client-ui-primitives'
import { t } from './locales.ts'
import css from './sidebar.module.css'
/** Fewer headings than this and the button stays hidden (no outline value). */
export const TOC_MIN_HEADINGS = 3
/** One collected outline entry; `el` is the live rendered heading element. */
interface TocEntry {
level: number
text: string
el: HTMLElement
}
/** The signature used to skip no-op rescans (identity-safe setState guard). */
function signatureOf(entries: readonly TocEntry[]): string {
return entries.map((entry) => `${entry.level}:${entry.text}`).join('\n')
}
export function MdToc(): ReactNode {
const barRef = useRef<HTMLDivElement>(null)
const [entries, setEntries] = useState<TocEntry[]>([])
const [open, setOpen] = useState(false)
const signatureRef = useRef('')
useLayoutEffect(() => {
// The bar's own host ref is attached before this effect runs; the scroll
// container is its parent by the mount contract above.
const container = barRef.current?.parentElement ?? null
if (container === null) return
const scan = (): void => {
const found: TocEntry[] = []
for (const el of container.querySelectorAll<HTMLElement>('h1, h2, h3, h4, h5, h6')) {
const text = (el.textContent ?? '').trim()
if (text === '') continue
found.push({ level: Number(el.tagName.charAt(1)), text, el })
}
const signature = signatureOf(found)
if (signature === signatureRef.current) return
signatureRef.current = signature
setEntries(found)
}
scan()
const observer = new MutationObserver(() => { scan() })
observer.observe(container, { childList: true, subtree: true })
return () => { observer.disconnect() }
}, [])
// Esc closes the popover (the panel is a sibling of the button, so focus
// stays wherever the user came from — a document listener is the simplest).
useLayoutEffect(() => {
if (!open) return
const onKey = (event: KeyboardEvent): void => {
if (event.key === 'Escape') setOpen(false)
}
document.addEventListener('keydown', onKey)
return () => { document.removeEventListener('keydown', onKey) }
}, [open])
const jump = (entry: TocEntry): void => {
entry.el.closest('details:not([open])')?.setAttribute('open', '')
entry.el.scrollIntoView({ behavior: 'smooth', block: 'start' })
const el = entry.el
const flash = css.tocFlash
if (flash !== undefined) {
el.classList.add(flash)
window.setTimeout(() => { el.classList.remove(flash) }, 1200)
}
setOpen(false)
}
const showOutline = entries.length >= TOC_MIN_HEADINGS
return (
<div className={css.tocBar} ref={barRef}>
{open && showOutline && (
<div className={css.tocPanel} data-dsh-md-toc-panel>
{entries.map((entry, index) => (
<button
key={index}
type="button"
className={css.tocItem}
data-level={entry.level}
title={entry.text}
onClick={() => { jump(entry) }}
>
<span className={css.tocItemLevel}>{entry.level}</span>
<span className={css.tocItemText}>{entry.text}</span>
</button>
))}
</div>
)}
{showOutline && (
<button
type="button"
className={css.tocButton}
data-dsh-md-toc=""
aria-label={t('toc')}
title={t('toc')}
aria-expanded={open}
onClick={() => { setOpen(!open) }}
>
<IconListPenOutline16 />
</button>
)}
</div>
)
}
+3 -3
View File
@@ -32,13 +32,13 @@ export interface MermaidMarkdownProps {
}
/** CommonMark opening fence: 0-3 spaces indent + a run of 3+ backticks or tildes. */
const OPEN_FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
export 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]*$/
export 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 {
export 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.
+132
View File
@@ -2758,3 +2758,135 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.openWithPinActive {
color: var(--dsw-alias-state-business-primary);
}
/* ── Markdown preview: raw-HTML runs + table of contents ─────────────────
The HTML runs lifted out of the markdown source (MarkdownHtml.tsx) render
as sanitized block leaves; the TOC is a zero-height sticky bar keeping the
outline button pinned at the preview's top-right without consuming layout.
All visuals ride the alias tokens (skin system follows automatically). */
/* A sanitized HTML run leaf (block-level box; content is DOMPurify output). */
.editorHtmlBlock {
margin: 8px 0;
}
.editorHtmlBlock img,
.editorHtmlBlock video {
max-width: 100%;
}
.editorHtmlBlock details {
margin: 4px 0;
padding: 4px 0;
}
.editorHtmlBlock summary {
cursor: pointer;
}
/* The zero-height sticky bar: occupies no layout, keeps the button visible
while the preview scrolls, and lets pointer events pass through the bar
itself (only the interactive children re-enable them). */
.tocBar {
position: sticky;
top: 0;
z-index: 3;
display: flex;
justify-content: flex-end;
height: 0;
pointer-events: none;
}
.tocButton {
pointer-events: auto;
display: inline-flex;
align-items: center;
justify-content: center;
width: 26px;
height: 26px;
margin: 4px 2px 0 0;
padding: 0;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 6px;
background: var(--dsw-alias-bg-layer-1);
color: var(--dsw-alias-label-secondary);
cursor: pointer;
}
.tocButton:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* The outline popover, anchored under the button (the bar is the containing
block; its height is 0, so the panel drops below the pinned button row). */
.tocPanel {
position: absolute;
top: 32px;
right: 2px;
display: flex;
flex-direction: column;
width: min(300px, 82%);
max-height: 60vh;
overflow-y: auto;
padding: 4px;
border: 1px solid var(--dsw-alias-border-l1);
border-radius: 8px;
background: var(--dsw-alias-bg-layer-2);
box-shadow: var(--dsw-shadow-lv2);
pointer-events: auto;
}
.tocItem {
display: flex;
align-items: baseline;
gap: 8px;
min-width: 0;
padding: 4px 8px;
border: none;
border-radius: 6px;
background: transparent;
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xxs-12);
text-align: left;
cursor: pointer;
}
.tocItem:hover {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
/* Indent by heading level (h2 = one step, h3 = two steps…). */
.tocItem[data-level='2'] { padding-left: 18px; }
.tocItem[data-level='3'] { padding-left: 28px; }
.tocItem[data-level='4'] { padding-left: 38px; }
.tocItem[data-level='5'] { padding-left: 48px; }
.tocItem[data-level='6'] { padding-left: 58px; }
/* The tiny level number keeps deep outlines scannable. */
.tocItemLevel {
flex: none;
font: var(--dsw-font-xxxs-11);
color: var(--dsw-alias-label-tertiary);
}
.tocItemText {
flex: 1 1 auto;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
/* The jumped-to heading flashes once (the class is added to the host-rendered
heading element by md-toc.tsx; @keyframes rides the CSS module). */
@keyframes dsh-toc-flash {
0%, 60% { background: var(--dsw-alias-interactive-bg-hover); }
100% { background: transparent; }
}
.tocFlash {
animation: dsh-toc-flash 1.2s ease-out;
border-radius: 4px;
}
+13 -3
View File
@@ -44,6 +44,9 @@ interface MountedSidebar {
}
/** Mount the real Sidebar shell against a minimal context (real store + service). */
/** Unique per-test session ids (see the comment inside). */
let sessionSeq = 0
function mountSidebar(): MountedSidebar {
vi.stubGlobal('WebSocket', FakeWebSocket)
const container = document.createElement('div')
@@ -52,14 +55,18 @@ function mountSidebar(): MountedSidebar {
const service = createBetterSidebarService(store)
// Fresh-session seed: the right panel starts OPEN, the bottom panel closed
// (bottomOpen false → the first expansion is a false→true TRANSITION).
store.setSession('s1')
// Unique session per test — the store persists per-session state to
// localStorage (200ms debounce); a shared id lets a previous test's late
// write leak into this store's setSession restore.
const sessionId = `s1-${++sessionSeq}`
store.setSession(sessionId)
// useSyncExternalStore requires STABLE snapshots across calls (the real DSH
// services return stable objects) — a fresh object per call loops forever.
const localeSnapshot = { active: 'en' }
const sessionsSnapshot = {
current: 's1',
current: sessionId,
// cwd present → api.sessionCwd is never called in these tests.
byId: { s1: { cwd: '/tmp' } },
byId: { [sessionId]: { cwd: '/tmp' } },
}
const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
@@ -81,6 +88,9 @@ function mountSidebar(): MountedSidebar {
afterEach(() => {
document.body.innerHTML = ''
// Belt and braces: drop any persisted layout a pending 200ms debounce
// write left behind between tests (unique session ids already isolate).
localStorage.clear()
vi.unstubAllGlobals()
})
+109
View File
@@ -48,6 +48,11 @@ const SEEDED_FILE = 'hello.txt'
* and render a sanitized SVG diagram. */
const SEEDED_MD_FILE = 'diagram.md'
/** A GitHub-style README markdown (badge div, <details> nesting markdown,
* inline tags in table cells), opened through the Files window's tree to
* prove raw-HTML runs render as sanitized DOM and the TOC outline works. */
const SEEDED_README_FILE = 'readme-style.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
@@ -87,6 +92,41 @@ async function seedSession(): Promise<void> {
'tail text',
'',
].join('\n'))
// The README-style probe file: raw-HTML runs (badge wall div with an
// embedded script, a <details> nesting a fence + heading) plus inline tags
// inside a table cell. The script must be sanitized away in the preview.
writeFileSync(join(WORKSPACE_PATH, SEEDED_README_FILE), [
'# Readme Style',
'',
'<div align="center">',
' <img alt="badge" src="https://img.shields.io/badge/x-y-blue" />',
' <script>alert(1)</script>',
'</div>',
'',
'## Setup',
'',
'[docs link][def]',
'',
'<details>',
'<summary><b>Steps</b></summary>',
'',
'### Inside',
'',
'```sh',
'dsh plugin --profile web add x',
'```',
'',
'</details>',
'',
'## Table',
'',
'| col | note |',
'| --- | --- |',
'| alpha | line one<br/>line two |',
'',
'[def]: https://example.com/def',
'',
].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 } },
})
@@ -433,6 +473,75 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
).toBeHidden()
await assertNoCrash()
// README-style markdown (raw-HTML runs + TOC): open the seeded file and
// require the full round-trip — sanitized HTML leaves (badge image as a
// real element, active content stripped), markdown nested inside the
// unclosed <details> run, the inline pass turning the table cell's <br/>
// into an element, the reference link resolving across the HTML run, and
// the TOC outline jumping into the collapsed details (auto-expanding it).
await sidebar.locator('[title="Files"][draggable="true"]').first().click()
const readmeRow = sidebar.locator(`[role="button"][title$="${SEEDED_README_FILE}"]:visible`)
await expect(
readmeRow,
`the seeded "${SEEDED_README_FILE}" file must appear in the files window's tree`,
).toHaveCount(1, { timeout: 30_000 })
await readmeRow.click({ position: { x: 8, y: 8 } })
await expect(
sidebar.locator(`[title="${SEEDED_README_FILE}"][draggable="true"]`),
'separate mode opens a new tab for the README-style markdown file',
).toHaveCount(1, { timeout: 30_000 })
// The hero div renders as a sanitized leaf with the badge image as a real
// element (the remote src may not load in the sandboxed lane; the ELEMENT
// is the proof of rendering, not the bytes).
await expect(
sidebar.locator('[data-dsh-html-segment] img[src*="img.shields.io"]'),
'the badge-wall div must render its image as a real element',
).toHaveCount(1, { timeout: 30_000 })
// Active content never survives sanitization.
await expect(
sidebar.locator('script'),
'the embedded <script> must be sanitized away',
).toHaveCount(0)
// The unclosed <details> run lowers the following markdown inside itself.
const details = sidebar.locator('details')
await expect(
details,
'the details run must render as a real element',
).toHaveCount(1, { timeout: 30_000 })
await expect(details.locator('summary'), 'the details summary must render').toHaveCount(1)
await expect(
details.locator('h3', { hasText: 'Inside' }),
'the heading between the details tags must nest inside the element',
).toHaveCount(1)
// The inline pass: the table cell's literal <br/> became a real element.
await expect(
sidebar.locator('[data-html-inline] br'),
'the table cell <br/> must render as a real element',
).toHaveCount(1, { timeout: 30_000 })
// The reference link resolves although its definition sits after an HTML run.
await expect(
sidebar.locator('a[href="https://example.com/def"]'),
'reference links must resolve across lifted HTML runs',
).toHaveCount(1, { timeout: 30_000 })
// TOC: the outline button appears (4 headings), opens the panel, and
// jumping into the collapsed details expands it.
const tocButton = sidebar.locator('[data-dsh-md-toc]')
await expect(
tocButton,
'the TOC button must appear once the document has enough headings',
).toHaveCount(1, { timeout: 30_000 })
await tocButton.click()
const tocPanel = sidebar.locator('[data-dsh-md-toc-panel]')
await expect(tocPanel, 'the TOC panel must open').toHaveCount(1)
const insideItem = tocPanel.locator('button', { hasText: 'Inside' })
await expect(insideItem, 'the nested heading must appear in the outline').toHaveCount(1)
await insideItem.click()
await expect(
details,
'jumping into a collapsed details must expand it',
).toHaveAttribute('open', '')
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))
+18 -5
View File
@@ -60,7 +60,10 @@ interface Mounted {
unmount: () => void
}
function mountSidebar(): Mounted {
/** Unique per-test session ids (see the comment inside). */
let sessionSeq = 0
function mountSidebar(sessionId: string = `s1-${++sessionSeq}`): Mounted {
vi.stubGlobal('WebSocket', FakeWebSocket)
if (!('ResizeObserver' in globalThis)) {
vi.stubGlobal('ResizeObserver', class {
@@ -83,9 +86,15 @@ function mountSidebar(): Mounted {
component: ({ visible }) => createElement('div', { 'data-visible': String(visible) }, 'notes body'),
})
store.setPrefs({ ...store.getPrefs(), openByDefault: true })
store.setSession('s1')
// The default session id is unique per test: the store persists
// per-session state to localStorage on a 200ms debounce, and a shared id
// let a PREVIOUS test's late timer write leak into this store's
// setSession restore (the portaled-menu/resize flakes — a stale float at
// the old drop point won the querySelector race on slow runners). The
// persistence round-trip test passes its own fixed id instead.
store.setSession(sessionId)
const localeSnapshot = { active: 'en' }
const sessionsSnapshot = { current: 's1', byId: { s1: { cwd: '/tmp' } } }
const sessionsSnapshot = { current: sessionId, byId: { [sessionId]: { cwd: '/tmp' } } }
const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
@@ -132,6 +141,10 @@ afterEach(() => {
document.body.innerHTML = ''
document.documentElement.style.cssText = ''
vi.unstubAllGlobals()
// Belt and braces (after unstub, so a test's storage stub never sees it):
// drop any persisted layout a pending 200ms debounce write left behind
// between tests — unique session ids already isolate the stores.
localStorage.clear()
})
describe('free windows: drag-out detection', () => {
@@ -380,7 +393,7 @@ describe('free windows: the window', () => {
const getItem = vi.fn((key: string): string | null => saved.get(key) ?? null)
const setItem = vi.fn((key: string, value: string): void => { saved.set(key, value) })
vi.stubGlobal('localStorage', { getItem, setItem, removeItem: () => {} })
const first = mountSidebar()
const first = mountSidebar('s1')
try {
act(() => { first.service.openTab({ type: 'notes', title: 'Notes' }) })
act(() => { first.store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
@@ -390,7 +403,7 @@ describe('free windows: the window', () => {
} finally {
first.unmount()
}
const second = mountSidebar()
const second = mountSidebar('s1')
try {
const state = second.store.getSnapshot().state!
expect(state.floats).toHaveLength(1)
+8 -5
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process'
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { tmpdir } from 'node:os'
import { join, resolve } from 'node:path'
import { parseWorktreeList, resolveWorktree, status, worktrees } from '../src/git.ts'
@@ -67,13 +67,16 @@ describe('linked Git worktrees', () => {
git(main, ['commit', '-q', '-m', 'base'])
git(main, ['worktree', 'add', '-q', '-b', 'agent', agent])
writeFileSync(join(agent, 'tracked.txt'), 'changed by agent\n')
// macOS tmpdir() keeps the /var symlink while git reports the resolved
// /private/var prefix — canonicalize every path handed to git APIs.
const agentPath = realpathSync(agent)
const listed = await worktrees(main)
expect(listed).toHaveLength(2)
expect(listed.find(entry => entry.current)).toMatchObject({ branch: 'main', changes: 0 })
expect(listed.find(entry => !entry.current)).toMatchObject({ branch: 'agent', changes: 1 })
expect(resolve(await resolveWorktree(main, agent))).toBe(resolve(agent))
expect((await status(await resolveWorktree(main, agent))).entries).toEqual([
expect(resolve(await resolveWorktree(main, agentPath))).toBe(resolve(agentPath))
expect((await status(await resolveWorktree(main, agentPath))).entries).toEqual([
{ path: 'tracked.txt', xy: ' M' },
])
await expect(resolveWorktree(main, root)).rejects.toThrow('unknown linked worktree')
@@ -84,9 +87,9 @@ describe('linked Git worktrees', () => {
rmSync(agent, { recursive: true, force: true })
const remaining = await worktrees(main)
expect(remaining).toHaveLength(1)
expect(resolve(remaining[0]!.path)).toBe(resolve(main))
expect(resolve(remaining[0]!.path)).toBe(resolve(realpathSync(main)))
expect(remaining[0]!.current).toBe(true)
await expect(resolveWorktree(main, agent)).rejects.toThrow('unknown linked worktree')
await expect(resolveWorktree(main, agentPath)).rejects.toThrow('unknown linked worktree')
} finally {
try { git(main, ['worktree', 'remove', '--force', agent]) } catch { /* fixture may not be fully initialized */ }
rmSync(root, { recursive: true, force: true })
+8 -5
View File
@@ -1,5 +1,5 @@
import { execFile, execFileSync } from 'node:child_process'
import { mkdirSync, mkdtempSync, rmSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, realpathSync, rmSync, writeFileSync } from 'node:fs'
import { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
@@ -10,6 +10,9 @@ import { parseLogLines, parsePorcelainZ, repoRoots, status } from '../src/git.ts
const execFileAsync = promisify(execFile)
const normalizePath = (path: string): string => path.replaceAll('\\', '/')
// macOS tmpdir() is the /var symlink while git reports the resolved
// /private/var prefix — canonicalize both sides before comparing.
const canonical = (path: string): string => normalizePath(realpathSync(path))
describe('git parsing', () => {
it('discovers and selects direct child repositories under a workspace directory', async () => {
@@ -23,11 +26,11 @@ describe('git parsing', () => {
execFileAsync('git', ['-C', second, 'init']),
])
await expect(repoRoots(workspace)).resolves.toEqual([normalizePath(first), normalizePath(second)])
await expect(status(workspace, normalizePath(second))).resolves.toMatchObject({
await expect(repoRoots(workspace)).resolves.toEqual([canonical(first), canonical(second)])
await expect(status(workspace, canonical(second))).resolves.toMatchObject({
isRepo: true,
root: normalizePath(second),
repositories: [normalizePath(first), normalizePath(second)],
root: canonical(second),
repositories: [canonical(first), canonical(second)],
})
} finally {
await rm(workspace, { recursive: true, force: true })
+176
View File
@@ -0,0 +1,176 @@
/**
* MarkdownDocument spec: the split-document renderer — sanitized HTML leaves
* (denylist + anchor hardening + local media rewrite), `<details>` nesting of
* in-between markdown, cross-segment reference definitions, and the inline
* pass that turns tag text inside rendered markdown back into elements.
* Renders with the real shared MarkdownText in jsdom (same pattern as
* tests/mermaid-markdown.spec.tsx).
*/
// @vitest-environment jsdom
import { describe, expect, it, beforeEach } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { MarkdownDocument, type MarkdownHtmlMedia } from '../src/client/MarkdownHtml.tsx'
import { analyzeMarkdownHtml } from '../src/client/markdown-html.ts'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
const media: MarkdownHtmlMedia = {
scope: { sessionId: 's1', cwd: '/ws' },
path: '/ws/docs/README.md',
origin: 'http://gui.origin',
}
const codeLabels = { copyLabel: 'Copy', copiedLabel: 'Copied' }
async function renderDocument(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(MarkdownDocument, {
info: analyzeMarkdownHtml(text),
media,
codeLabels,
}))
// Let the MutationObserver's queued inline pass 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('MarkdownDocument (HTML leaves)', () => {
it('renders a balanced HTML block as sanitized DOM, not literal text', async () => {
const { container, root } = await renderDocument([
'# Title',
'',
'<div align="center">',
' <img alt="badge" src="https://img.shields.io/badge/x-y-blue" />',
'</div>',
].join('\n'))
const leaf = container.querySelector('[data-dsh-html-segment]')
expect(leaf, 'the html run must render as a sanitized leaf').not.toBeNull()
expect(leaf?.querySelector('img[src*="img.shields.io"]')).not.toBeNull()
expect(leaf?.firstElementChild?.getAttribute('align')).toBe('center')
expect(container.textContent).not.toContain('<div')
await unmount(root)
})
it('strips denied tags and hardens anchors', async () => {
const { container, root } = await renderDocument([
'<div>',
' <script>alert(1)</script>',
' <a href="https://example.com">link</a>',
'</div>',
].join('\n'))
expect(container.querySelector('script')).toBeNull()
const anchor = container.querySelector('a[href="https://example.com"]')
expect(anchor).not.toBeNull()
expect(anchor?.getAttribute('target')).toBe('_blank')
expect(anchor?.getAttribute('rel')).toBe('noopener noreferrer')
await unmount(root)
})
it('rewrites local media sources through the /sidebar/file route', async () => {
const { container, root } = await renderDocument('<picture><img src="./shot.png" alt="shot"/></picture>')
const img = container.querySelector('img')
expect(img?.getAttribute('src')).toBe('http://gui.origin/sidebar/file?sessionId=s1&path=%2Fws%2Fdocs%2Fshot.png&cwd=%2Fws')
await unmount(root)
})
})
describe('MarkdownDocument (nesting + markdown runs)', () => {
it('nests markdown runs inside an unclosed <details> element', async () => {
const { container, root } = await renderDocument([
'<details>',
'<summary><b>更新</b></summary>',
'',
'```sh',
'dsh plugin --profile web add x',
'```',
'',
'</details>',
'',
'after the fold',
].join('\n'))
const details = container.querySelector('details')
expect(details, 'the open part must lower following runs into a details element').not.toBeNull()
expect(details?.querySelector('summary')).not.toBeNull()
expect(details?.textContent).toContain('dsh plugin --profile web add x')
// The close part pops the frame: the trailing markdown is a sibling.
expect(details?.textContent).not.toContain('after the fold')
expect(container.querySelector('details')?.nextElementSibling?.textContent).toContain('after the fold')
await unmount(root)
})
it('keeps reference definitions resolving across lifted HTML runs', async () => {
const { container, root } = await renderDocument([
'see [the docs][shared]',
'',
'<div align="center">badges</div>',
'',
'[shared]: https://example.com/docs',
].join('\n'))
const anchor = container.querySelector('a[href="https://example.com/docs"]')
expect(anchor, 'a definition after an HTML run must still resolve').not.toBeNull()
expect(anchor?.textContent).toContain('the docs')
await unmount(root)
})
it('renders a forbidden wrapper as transparent (children survive)', async () => {
const { container, root } = await renderDocument([
'<form action="/x">',
'',
'inner text',
'',
'</form>',
].join('\n'))
expect(container.querySelector('form')).toBeNull()
expect(container.textContent).toContain('inner text')
await unmount(root)
})
})
describe('MarkdownDocument (inline pass)', () => {
it('turns tag text in table cells back into elements', async () => {
const { container, root } = await renderDocument([
'| feature | notes |',
'|---|---|',
'| alpha | line one<br/>line two |',
].join('\n'))
const cell = [...container.querySelectorAll('td')].find((td) => td.textContent?.includes('line one'))
expect(cell?.querySelector('[data-html-inline] br'), 'the <br/> must render as a real element').not.toBeNull()
await unmount(root)
})
it('leaves rendered code and comparison prose untouched', async () => {
const { container, root } = await renderDocument([
'a < b comparison stays text',
'',
'```html',
'<div>demo</div>',
'```',
].join('\n'))
expect(container.querySelectorAll('[data-html-inline]').length).toBe(0)
expect(container.textContent).toContain('a < b comparison stays text')
const code = container.querySelector('code')
expect(code?.textContent).toContain('<div>demo</div>')
await unmount(root)
})
it('rewrites local image src inside inline-swapped tags', async () => {
const { container, root } = await renderDocument('text <img src="./icon.png" alt="i"/> tail')
const img = container.querySelector('[data-html-inline] img')
expect(img?.getAttribute('src')).toContain('/sidebar/file?')
await unmount(root)
})
})
+196
View File
@@ -0,0 +1,196 @@
/**
* markdown-html splitter spec: the pure lifting of raw-HTML runs out of the
* markdown stream (fence-aware), the tag-balance part analysis (unclosed
* `<details>` → wrapper opens, stray closes → pops), the doc-wide reference
* definition collection, and the inline-html gate.
*/
import { describe, expect, it } from 'vitest'
import {
analyzeHtmlSegment,
analyzeMarkdownHtml,
collectReferenceDefinitions,
splitHtmlBlocks,
type HtmlPart,
} from '../src/client/markdown-html.ts'
/** Structural view of parts: html parts as [html <text>], opens/closes by kind. */
function shape(parts: readonly HtmlPart[]): string[] {
return parts.map((part) => part.kind === 'html'
? `html(${part.html})`
: part.kind === 'open'
? `open(${part.tag}${part.attrs.trim() !== '' ? ` ${part.attrs.trim()}` : ''})`
: `close(${part.tag})`)
}
describe('splitHtmlBlocks', () => {
it('returns one markdown segment for plain markdown (no HTML)', () => {
const text = '# Title\n\nsome **markdown**\n\n- a\n- b\n'
expect(splitHtmlBlocks(text)).toEqual([{ kind: 'markdown', text: text.replace(/\n$/, '') }])
})
it('lifts a balanced block-tag run out of the markdown stream', () => {
const segments = splitHtmlBlocks([
'# Title',
'',
'<div align="center">',
' <img alt="badge" src="https://img.shields.io/badge/x-y-blue" />',
'</div>',
'',
'tail text',
].join('\n'))
expect(segments).toEqual([
{ kind: 'markdown', text: '# Title' },
{ kind: 'html', text: '<div align="center">\n <img alt="badge" src="https://img.shields.io/badge/x-y-blue" />\n</div>' },
{ kind: 'markdown', text: 'tail text' },
])
})
it('ends an HTML run at a blank line', () => {
const segments = splitHtmlBlocks('<p>a</p>\n\nafter')
expect(segments).toEqual([
{ kind: 'html', text: '<p>a</p>' },
{ kind: 'markdown', text: 'after' },
])
})
it('does not lift tag-like lines inside fenced code blocks', () => {
const segments = splitHtmlBlocks([
'```html',
'<div class="demo">',
' <p>example</p>',
'</div>',
'```',
'',
'<div align="center">real</div>',
].join('\n'))
expect(segments).toEqual([
{ kind: 'markdown', text: '```html\n<div class="demo">\n <p>example</p>\n</div>\n```' },
{ kind: 'html', text: '<div align="center">real</div>' },
])
})
it('does not lift inline-only tags or prose starting with a comparison', () => {
const segments = splitHtmlBlocks([
'a < b and <code>x</code> inline',
'<br/> alone on its line',
].join('\n'))
expect(segments).toEqual([
{ kind: 'markdown', text: 'a < b and <code>x</code> inline\n<br/> alone on its line' },
])
})
it('treats a close-tag-only line as an HTML run', () => {
const segments = splitHtmlBlocks('text\n\n</details>\n\nmore')
expect(segments).toEqual([
{ kind: 'markdown', text: 'text' },
{ kind: 'html', text: '</details>' },
{ kind: 'markdown', text: 'more' },
])
})
it('accumulates a comment run until the closing marker line', () => {
const segments = splitHtmlBlocks('<!-- section\n banner\n-->\n\nbody')
expect(segments).toEqual([
{ kind: 'html', text: '<!-- section\n banner\n-->' },
{ kind: 'markdown', text: 'body' },
])
})
it('keeps an unterminated fence as markdown (CommonMark recovery)', () => {
const segments = splitHtmlBlocks('```\n<div>x</div>')
expect(segments).toEqual([{ kind: 'markdown', text: '```\n<div>x</div>' }])
})
it('keeps the README details shape as three segments', () => {
const segments = splitHtmlBlocks([
'<details>',
'<summary><b>更新</b></summary>',
'',
'```sh',
'dsh plugin --profile web add x',
'```',
'',
'</details>',
].join('\n'))
expect(segments.map((segment) => segment.kind)).toEqual(['html', 'markdown', 'html'])
expect(segments[0]).toEqual({ kind: 'html', text: '<details>\n<summary><b>更新</b></summary>' })
expect(segments[2]).toEqual({ kind: 'html', text: '</details>' })
})
})
describe('analyzeHtmlSegment', () => {
it('reduces a balanced run to a single html part', () => {
expect(shape(analyzeHtmlSegment('<div align="center"><img src="https://x/y.png"/></div>').parts))
.toEqual(['html(<div align="center"><img src="https://x/y.png"/></div>)'])
})
it('surfaces an unclosed open tag as a wrapper with its inner html', () => {
expect(shape(analyzeHtmlSegment('<details>\n<summary><b>更新</b></summary>').parts))
.toEqual(['open(details)', 'html(\n<summary><b>更新</b></summary>)'])
})
it('surfaces an unmatched close as a pop', () => {
expect(shape(analyzeHtmlSegment('</details>').parts)).toEqual(['close(details)'])
})
it('orders mixed leading closes before following html and closes', () => {
expect(shape(analyzeHtmlSegment('</div> mid </details>').parts))
.toEqual(['close(div)', 'html( mid )', 'close(details)'])
})
it('nests multiple unclosed opens outermost-first', () => {
expect(shape(analyzeHtmlSegment('<div align="center"><details>').parts))
.toEqual(['open(div align="center")', 'open(details)'])
})
it('keeps attrs of the open token for the wrapper', () => {
const parts = analyzeHtmlSegment('<details open class="x">body').parts
expect(parts[0]).toEqual({ kind: 'open', tag: 'details', attrs: ' open class="x"' })
})
it('ignores void and self-closing tags for balance', () => {
expect(shape(analyzeHtmlSegment('<img src="x"><br/><span/>after').parts))
.toEqual(['html(<img src="x"><br/><span/>after)'])
})
it('ignores tags inside comments', () => {
expect(shape(analyzeHtmlSegment('<!-- <div> commented out -->').parts))
.toEqual(['html(<!-- <div> commented out -->)'])
})
it('pops implicitly through a mismatched close', () => {
// <p> is implicitly closed by </div>: nothing structural remains.
expect(shape(analyzeHtmlSegment('<div><p>text</div>').parts))
.toEqual(['html(<div><p>text</div>)'])
})
})
describe('collectReferenceDefinitions + analyzeMarkdownHtml', () => {
it('collects definitions from markdown runs only, in document order', () => {
const info = analyzeMarkdownHtml([
'intro [link][shared]',
'',
'<div align="center">badges</div>',
'',
'[shared]: https://example.com',
'[img]: ./local.png',
].join('\n'))
expect(info.referenceDefinitions).toBe('[shared]: https://example.com\n[img]: ./local.png')
expect(collectReferenceDefinitions(info.segments)).toBe(info.referenceDefinitions)
})
it('reports hasBlockHtml only when a run was lifted', () => {
expect(analyzeMarkdownHtml('# plain\ntext').hasBlockHtml).toBe(false)
expect(analyzeMarkdownHtml('<div>x</div>').hasBlockHtml).toBe(true)
// Inline-only tags: no block html, but the inline gate is on.
const inline = analyzeMarkdownHtml('| a | b |\n|---|---|\n| x<br/>y | z |')
expect(inline.hasBlockHtml).toBe(false)
expect(inline.hasInlineHtml).toBe(true)
})
it('gates inline html on tag-like source text', () => {
expect(analyzeMarkdownHtml('a < b').hasInlineHtml).toBe(false)
expect(analyzeMarkdownHtml('a <b>bold</b>').hasInlineHtml).toBe(true)
expect(analyzeMarkdownHtml('plain markdown').hasInlineHtml).toBe(false)
})
})
+152
View File
@@ -0,0 +1,152 @@
/**
* MdToc spec: the markdown preview's outline button + popover. Covers the
* minimum-headings gate, collection from the rendered DOM (including headings
* inside HTML `<details>` runs, which appear late through the observer),
* jump behavior (smooth scroll + flash + auto-expanding collapsed ancestors)
* and Esc-to-close.
*/
// @vitest-environment jsdom
import { describe, expect, it, beforeEach, vi } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
import { MdToc, TOC_MIN_HEADINGS } from '../src/client/md-toc.tsx'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
const scrollIntoView = vi.fn()
Element.prototype.scrollIntoView = scrollIntoView
/** The preview-harness shape MdToc mounts in: the bar is a direct child of
* the scroll container, headings are siblings (inside or outside details). */
interface HeadingSpec {
tag: string
text: string
insideClosedDetails?: boolean
}
function Harness({ specs }: { specs: readonly HeadingSpec[] }): React.ReactElement {
return createElement(
'div',
null,
createElement(MdToc),
...specs.map((spec) => spec.insideClosedDetails === true
? createElement('details', null, createElement(spec.tag, null, spec.text))
: createElement(spec.tag, null, spec.text)),
)
}
/** Render the harness; returns the scroll container (the bar's parent). */
async function mountToc(specs: readonly HeadingSpec[]): Promise<{ container: HTMLDivElement; root: Root }> {
const host = document.createElement('div')
document.body.appendChild(host)
const root = createRoot(host)
await act(async () => {
root.render(createElement(Harness, { specs }))
await new Promise((resolve) => { setTimeout(resolve, 0) })
})
const container = host.firstElementChild as HTMLDivElement
return { container, root }
}
async function unmount(root: Root): Promise<void> {
await act(async () => { root.unmount() })
}
beforeEach(() => {
document.body.innerHTML = ''
scrollIntoView.mockClear()
})
describe('MdToc', () => {
it('exposes the minimum-headings threshold', () => {
expect(TOC_MIN_HEADINGS).toBe(3)
})
it('stays hidden below the heading threshold', async () => {
const { container, root } = await mountToc([
{ tag: 'h1', text: 'One' },
{ tag: 'h2', text: 'Two' },
])
expect(container.querySelector('[data-dsh-md-toc]')).toBeNull()
await unmount(root)
})
it('shows the button and lists collected headings', async () => {
const { container, root } = await mountToc([
{ tag: 'h1', text: 'Title' },
{ tag: 'h2', text: 'Install' },
{ tag: 'h2', text: 'Usage' },
{ tag: 'h3', text: 'Deep' },
])
const button = container.querySelector<HTMLButtonElement>('[data-dsh-md-toc]')
expect(button).not.toBeNull()
await act(async () => { button!.click() })
const panel = container.querySelector('[data-dsh-md-toc-panel]')
expect(panel).not.toBeNull()
const items = [...panel!.querySelectorAll('button')]
expect(items.map((item) => item.textContent)).toEqual(['1Title', '2Install', '2Usage', '3Deep'])
await unmount(root)
})
it('jumps: expands collapsed details ancestors, smooth-scrolls, flashes, closes', async () => {
// Mount with real timers (the settle await needs them), then fake only the
// flash-removal timeout window.
const { container, root } = await mountToc([
{ tag: 'h1', text: 'Title' },
{ tag: 'h2', text: 'Outside' },
{ tag: 'h3', text: 'Hidden', insideClosedDetails: true },
])
vi.useFakeTimers()
try {
const button = container.querySelector<HTMLButtonElement>('[data-dsh-md-toc]')!
await act(async () => { button.click() })
const target = [...container.querySelectorAll('h3')].find((h) => h.textContent === 'Hidden')!
const item = [...container.querySelectorAll<HTMLButtonElement>('[data-dsh-md-toc-panel] button')]
.find((candidate) => candidate.textContent?.includes('Hidden'))!
await act(async () => { item.click() })
expect(scrollIntoView).toHaveBeenCalledWith({ behavior: 'smooth', block: 'start' })
expect(target.closest('details')?.hasAttribute('open'), 'the collapsed ancestor must open').toBe(true)
expect(target.className, 'the jumped-to heading must flash').not.toBe('')
expect(container.querySelector('[data-dsh-md-toc-panel]'), 'the panel must close on jump').toBeNull()
vi.advanceTimersByTime(1300)
expect(target.className, 'the flash class must clear after the timeout').toBe('')
await unmount(root)
} finally {
vi.useRealTimers()
}
})
it('collects headings that appear after mount (observer path)', async () => {
const { container, root } = await mountToc([
{ tag: 'h1', text: 'Title' },
{ tag: 'h2', text: 'Only two so far' },
])
expect(container.querySelector('[data-dsh-md-toc]')).toBeNull()
const late = document.createElement('h2')
late.textContent = 'Late'
await act(async () => {
container.appendChild(late)
await new Promise((resolve) => { setTimeout(resolve, 0) })
})
expect(container.querySelector('[data-dsh-md-toc]'), 'late headings must surface the button').not.toBeNull()
await unmount(root)
})
it('closes the popover on Escape', async () => {
const { container, root } = await mountToc([
{ tag: 'h1', text: 'Title' },
{ tag: 'h2', text: 'A' },
{ tag: 'h2', text: 'B' },
])
const button = container.querySelector<HTMLButtonElement>('[data-dsh-md-toc]')!
await act(async () => { button.click() })
expect(container.querySelector('[data-dsh-md-toc-panel]')).not.toBeNull()
await act(async () => {
document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' }))
})
expect(container.querySelector('[data-dsh-md-toc-panel]')).toBeNull()
await unmount(root)
})
})
+13 -3
View File
@@ -47,6 +47,9 @@ interface MountedSidebar {
}
/** Mount the real Sidebar shell against a minimal context (real store + service). */
/** Unique per-test session ids (see the comment inside). */
let sessionSeq = 0
function mountSidebar(): MountedSidebar {
vi.stubGlobal('WebSocket', FakeWebSocket)
const container = document.createElement('div')
@@ -55,14 +58,18 @@ function mountSidebar(): MountedSidebar {
const service = createBetterSidebarService(store)
// Fresh-session seed: open the panel explicitly (openByDefault defaults off).
store.setPrefs({ ...store.getPrefs(), openByDefault: true })
store.setSession('s1')
// Unique session per test — the store persists per-session state to
// localStorage (200ms debounce); a shared id lets a previous test's late
// write leak into this store's setSession restore.
const sessionId = `s1-${++sessionSeq}`
store.setSession(sessionId)
// useSyncExternalStore requires STABLE snapshots across calls (the real DSH
// services return stable objects) — a fresh object per call loops forever.
const localeSnapshot = { active: 'en' }
const sessionsSnapshot = {
current: 's1',
current: sessionId,
// cwd present → api.sessionCwd is never called in these tests.
byId: { s1: { cwd: '/tmp' } },
byId: { [sessionId]: { cwd: '/tmp' } },
}
const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
@@ -84,6 +91,9 @@ function mountSidebar(): MountedSidebar {
afterEach(() => {
document.body.innerHTML = ''
// Belt and braces: drop any persisted layout a pending 200ms debounce
// write left behind between tests (unique session ids already isolate).
localStorage.clear()
vi.unstubAllGlobals()
})