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` 策略替代)。 > **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 即可覆盖。 > code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
### 4.5 注册示例 ### 4.5 注册示例
@@ -729,6 +729,7 @@ better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 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/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/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` - **`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`**:设计文档(含实施偏差记录) - **`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 走系统浏览器(设置页可分别调整) - **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具 - **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
- **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `sidebar_open` 工具——模型可主动在侧边栏打开文件 / 文件夹(树以该目录为根)/ HTTP(S) 网页 - **📂 模型侧边栏打开(可选)**:全局设置开启后注入 `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> | | **🖥️ 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>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> | | **🌐 内嵌浏览器**<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", "@codemirror/view": "^6.43.8",
"@lezer/highlight": "^1.2.3", "@lezer/highlight": "^1.2.3",
"clsx": "^2.1.1", "clsx": "^2.1.1",
"dompurify": "^3.4.14",
"mermaid": "^11.16.1", "mermaid": "^11.16.1",
"node-pty": "^1.1.0", "node-pty": "^1.1.0",
"react-icons": "5.7.0", "react-icons": "5.7.0",
+3
View File
@@ -77,6 +77,9 @@ importers:
clsx: clsx:
specifier: ^2.1.1 specifier: ^2.1.1
version: 2.1.1 version: 2.1.1
dompurify:
specifier: ^3.4.14
version: 3.4.14
mermaid: mermaid:
specifier: ^11.16.1 specifier: ^11.16.1
version: 11.17.0 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. * renders the controls instead.
*/ */
import { useEffect, useMemo, useRef, useState } from 'react' import { useEffect, useMemo, useRef, useState } from 'react'
import type { ComponentType } from 'react'
import { createPortal } from 'react-dom' import { createPortal } from 'react-dom'
import clsx from 'clsx' import clsx from 'clsx'
import { EditorState } from '@codemirror/state' import { EditorState } from '@codemirror/state'
@@ -30,7 +29,10 @@ import { SandboxStatusBar } from './SandboxStatusBar.tsx'
import { appendToDraft } from './conversation-draft.ts' import { appendToDraft } from './conversation-draft.ts'
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts' import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
import { lazyChunkComponent } from './lazy-chunk.tsx' 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 { t } from './locales.ts'
import type { EditorToolbarState, FileViewerProps } from './service.ts' import type { EditorToolbarState, FileViewerProps } from './service.ts'
import css from './sidebar.module.css' import css from './sidebar.module.css'
@@ -45,16 +47,6 @@ interface SelectionPopup {
top: number 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 * 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 * 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 === 'preview' ? splitMermaidBlocks(mdText) : []),
[markdown, mode, 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( const hasMermaid = useMemo(
() => mdBlocks.some(block => block.kind === 'mermaid'), () => htmlInfo !== null
[mdBlocks], ? 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') } 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 dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
fall back to hardcoded Chinese otherwise (same pattern as the fall back to hardcoded Chinese otherwise (same pattern as the
chat's AssistantMarkdown). Render-time t() keeps them following chat's AssistantMarkdown). Render-time t() keeps them following
the active locale on live switches. Mermaid fences hand the the active locale on live switches. Plain markdown (no HTML)
whole document to the mermaid lazy chunk (single markdown renders exactly as before — one MarkdownText pass for the whole
parse; cross-fence references/footnotes stay intact); files document, or the mermaid lazy chunk (single markdown parse;
without one render exactly as before. */} cross-fence references/footnotes stay intact) when a mermaid
{hasMermaid fence exists. Documents containing HTML (block runs or inline
? <LazyMermaidMarkdown text={previewText} codeLabels={codeLabels} /> tags) render through the split document renderer: markdown runs
: <MarkdownText text={previewText} codeLabels={codeLabels} />} 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> </div>
)} )}
{html && mode === 'preview' && ( {html && mode === 'preview' && (
+1
View File
@@ -80,6 +80,7 @@ export const ar: Record<string, string> = {
terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):', terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):',
terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})', terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})',
preview: 'معاينة', preview: 'معاينة',
toc: 'الفهرس',
edit: 'تحرير', edit: 'تحرير',
mermaidError: 'فشل رسم Mermaid', mermaidError: 'فشل رسم Mermaid',
mermaidZoomIn: 'تكبير', 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):', 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})', terminalDepsProfile: ' (erkanntes Profil: {profile})',
preview: 'Vorschau', preview: 'Vorschau',
toc: 'Inhaltsverzeichnis',
edit: 'Bearbeiten', edit: 'Bearbeiten',
mermaidError: 'Mermaid-Rendering fehlgeschlagen', mermaidError: 'Mermaid-Rendering fehlgeschlagen',
mermaidZoomIn: 'Vergrößern', 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) :', 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})', terminalDepsProfile: ' (profil détecté : {profile})',
preview: 'Aperçu', preview: 'Aperçu',
toc: 'Sommaire',
edit: 'Modifier', edit: 'Modifier',
mermaidError: 'Échec du rendu Mermaid', mermaidError: 'Échec du rendu Mermaid',
mermaidZoomIn: 'Agrandir', mermaidZoomIn: 'Agrandir',
+1
View File
@@ -79,6 +79,7 @@ export const hi: Record<string, string> = {
terminalDepsHint: 'DSH मशीन पर एक टर्मिनल या cmd में नीचे दिया गया कमांड चलाएँ, फिर पुनः प्रयास करें (node-pty DSH कोर संस्करण के साथ सिंक रहता है):', terminalDepsHint: 'DSH मशीन पर एक टर्मिनल या cmd में नीचे दिया गया कमांड चलाएँ, फिर पुनः प्रयास करें (node-pty DSH कोर संस्करण के साथ सिंक रहता है):',
terminalDepsProfile: ' (पहचाना गया प्रोफ़ाइल: {profile})', terminalDepsProfile: ' (पहचाना गया प्रोफ़ाइल: {profile})',
preview: 'पूर्वावलोकन', preview: 'पूर्वावलोकन',
toc: 'विषय-सूची',
edit: 'संपादित करें', edit: 'संपादित करें',
mermaidError: 'Mermaid रेंडर विफल', mermaidError: 'Mermaid रेंडर विफल',
mermaidZoomIn: 'ज़ूम इन', 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):', 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})', terminalDepsProfile: ' (profil terdeteksi: {profile})',
preview: 'Pratinjau', preview: 'Pratinjau',
toc: 'Daftar Isi',
edit: 'Edit', edit: 'Edit',
mermaidError: 'Render Mermaid gagal', mermaidError: 'Render Mermaid gagal',
mermaidZoomIn: 'Perbesar', 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):', 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})', terminalDepsProfile: ' (rilevato profilo: {profile})',
preview: 'Anteprima', preview: 'Anteprima',
toc: 'Indice',
edit: 'Modifica', edit: 'Modifica',
mermaidError: 'Rendering Mermaid non riuscito', mermaidError: 'Rendering Mermaid non riuscito',
mermaidZoomIn: 'Ingrandisci', mermaidZoomIn: 'Ingrandisci',
+1
View File
@@ -79,6 +79,7 @@ export const ja: Record<string, string> = {
terminalDepsHint: 'DSH 環境のターミナルまたは cmd で以下のコマンドを実行して修復し、再試行してください(node-pty は DSH コアと同じバージョンを維持):', terminalDepsHint: 'DSH 環境のターミナルまたは cmd で以下のコマンドを実行して修復し、再試行してください(node-pty は DSH コアと同じバージョンを維持):',
terminalDepsProfile: '(検出された profile:{profile})', terminalDepsProfile: '(検出された profile:{profile})',
preview: 'プレビュー', preview: 'プレビュー',
toc: '目次',
edit: '編集', edit: '編集',
mermaidError: 'Mermaid レンダリング失敗', mermaidError: 'Mermaid レンダリング失敗',
mermaidZoomIn: '拡大', mermaidZoomIn: '拡大',
+1
View File
@@ -71,6 +71,7 @@ export const ko: Record<string, string> = {
terminalDepsHint: 'DSH가 설치된 환경의 터미널 또는 cmd에서 아래 명령을 실행하여 복구한 후 다시 시도를 클릭하세요(node-pty는 DSH 코어와 동일한 버전을 유지합니다):', terminalDepsHint: 'DSH가 설치된 환경의 터미널 또는 cmd에서 아래 명령을 실행하여 복구한 후 다시 시도를 클릭하세요(node-pty는 DSH 코어와 동일한 버전을 유지합니다):',
terminalDepsProfile: ' (profile 감지됨: {profile})', terminalDepsProfile: ' (profile 감지됨: {profile})',
preview: '미리보기', preview: '미리보기',
toc: '목차',
edit: '편집', edit: '편집',
mermaidError: 'Mermaid 렌더링 실패', mermaidError: 'Mermaid 렌더링 실패',
mermaidZoomIn: '확대', 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):', 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})', terminalDepsProfile: ' (gedetecteerde profile: {profile})',
preview: 'Voorbeeld', preview: 'Voorbeeld',
toc: 'Inhoudsopgave',
edit: 'Bewerken', edit: 'Bewerken',
mermaidError: 'Mermaid-renderen mislukt', mermaidError: 'Mermaid-renderen mislukt',
mermaidZoomIn: 'Inzoomen', 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):', 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})', terminalDepsProfile: ' (wykryty profil: {profile})',
preview: 'Podgląd', preview: 'Podgląd',
toc: 'Spis treści',
edit: 'Edytuj', edit: 'Edytuj',
mermaidError: 'Renderowanie Mermaid nie powiodło się', mermaidError: 'Renderowanie Mermaid nie powiodło się',
mermaidZoomIn: 'Powiększ', 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):', 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})', terminalDepsProfile: ' (perfil detectado: {profile})',
preview: 'Pré-visualizar', preview: 'Pré-visualizar',
toc: 'Sumário',
edit: 'Editar', edit: 'Editar',
mermaidError: 'Falha ao renderizar o Mermaid', mermaidError: 'Falha ao renderizar o Mermaid',
mermaidZoomIn: 'Ampliar', mermaidZoomIn: 'Ampliar',
+1
View File
@@ -75,6 +75,7 @@ export const ru: Record<string, string> = {
terminalDepsHint: 'Выполните приведённую ниже команду в терминале или cmd на машине DSH, затем нажмите «Повторить» (node-pty остаётся синхронизированным с версией ядра DSH):', terminalDepsHint: 'Выполните приведённую ниже команду в терминале или cmd на машине DSH, затем нажмите «Повторить» (node-pty остаётся синхронизированным с версией ядра DSH):',
terminalDepsProfile: ' (обнаружен profile: {profile})', terminalDepsProfile: ' (обнаружен profile: {profile})',
preview: 'Предпросмотр', preview: 'Предпросмотр',
toc: 'Оглавление',
edit: 'Изменить', edit: 'Изменить',
mermaidError: 'Сбой рендеринга Mermaid', mermaidError: 'Сбой рендеринга Mermaid',
mermaidZoomIn: 'Приблизить', 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):', 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})', terminalDepsProfile: ' (upptäckt profil: {profile})',
preview: 'Förhandsgranska', preview: 'Förhandsgranska',
toc: 'Innehållsförteckning',
edit: 'Redigera', edit: 'Redigera',
mermaidError: 'Mermaid-rendering misslyckades', mermaidError: 'Mermaid-rendering misslyckades',
mermaidZoomIn: 'Zooma in', mermaidZoomIn: 'Zooma in',
+1
View File
@@ -79,6 +79,7 @@ export const th: Record<string, string> = {
terminalDepsHint: 'เรียกใช้คำสั่งด้านล่างในเทอร์มินัลหรือ cmd บนเครื่อง DSH เพื่อซ่อมแซม แล้วกดลองอีกครั้ง (node-pty ต้องซิงค์กับเวอร์ชันหลักของ DSH):', terminalDepsHint: 'เรียกใช้คำสั่งด้านล่างในเทอร์มินัลหรือ cmd บนเครื่อง DSH เพื่อซ่อมแซม แล้วกดลองอีกครั้ง (node-pty ต้องซิงค์กับเวอร์ชันหลักของ DSH):',
terminalDepsProfile: ' (ตรวจพบ profile: {profile})', terminalDepsProfile: ' (ตรวจพบ profile: {profile})',
preview: 'พรีวิว', preview: 'พรีวิว',
toc: 'สารบัญ',
edit: 'แก้ไข', edit: 'แก้ไข',
mermaidError: 'เรนเดอร์ Mermaid ล้มเหลว', mermaidError: 'เรนเดอร์ Mermaid ล้มเหลว',
mermaidZoomIn: 'ซูมเข้า', 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):', 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})', terminalDepsProfile: ' (algılanan profil: {profile})',
preview: 'Önizleme', preview: 'Önizleme',
toc: 'İçindekiler',
edit: 'Düzenle', edit: 'Düzenle',
mermaidError: 'Mermaid oluşturma başarısız', mermaidError: 'Mermaid oluşturma başarısız',
mermaidZoomIn: 'Yakınlaştır', 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):', 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})', terminalDepsProfile: ' (phát hiện profile: {profile})',
preview: 'Xem trước', preview: 'Xem trước',
toc: 'Mục lục',
edit: 'Chỉnh sửa', edit: 'Chỉnh sửa',
mermaidError: 'Render Mermaid thất bại', mermaidError: 'Render Mermaid thất bại',
mermaidZoomIn: 'Phóng to', mermaidZoomIn: 'Phóng to',
+1
View File
@@ -94,6 +94,7 @@ export const zhHK: Record<string, string> = {
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):', terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})', terminalDepsProfile: '(偵測到 profile:{profile})',
preview: '預覽', preview: '預覽',
toc: '目錄',
edit: '編輯', edit: '編輯',
mermaidError: 'Mermaid 渲染失敗', mermaidError: 'Mermaid 渲染失敗',
mermaidZoomIn: '放大', mermaidZoomIn: '放大',
+1
View File
@@ -94,6 +94,7 @@ export const zhMO: Record<string, string> = {
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):', terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})', terminalDepsProfile: '(偵測到 profile:{profile})',
preview: '預覽', preview: '預覽',
toc: '目錄',
edit: '編輯', edit: '編輯',
mermaidError: 'Mermaid 渲染失敗', mermaidError: 'Mermaid 渲染失敗',
mermaidZoomIn: '放大', mermaidZoomIn: '放大',
+1
View File
@@ -94,6 +94,7 @@ export const zhTW: Record<string, string> = {
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):', terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
terminalDepsProfile: '(偵測到 profile:{profile})', terminalDepsProfile: '(偵測到 profile:{profile})',
preview: '預覽', preview: '預覽',
toc: '目錄',
edit: '編輯', edit: '編輯',
mermaidError: 'Mermaid 渲染失敗', mermaidError: 'Mermaid 渲染失敗',
mermaidZoomIn: '放大', mermaidZoomIn: '放大',
+2
View File
@@ -86,6 +86,7 @@ export const zh = {
terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):', terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
terminalDepsProfile: '(检测到 profile:{profile})', terminalDepsProfile: '(检测到 profile:{profile})',
preview: '预览', preview: '预览',
toc: '目录',
edit: '编辑', edit: '编辑',
mermaidError: 'Mermaid 渲染失败', mermaidError: 'Mermaid 渲染失败',
mermaidZoomIn: '放大', 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):', 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})', terminalDepsProfile: ' (detected profile: {profile})',
preview: 'Preview', preview: 'Preview',
toc: 'Table of contents',
edit: 'Edit', edit: 'Edit',
mermaidError: 'Mermaid render failed', mermaidError: 'Mermaid render failed',
mermaidZoomIn: 'Zoom in', 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. * so the core rewrite stays pure and unit-testable.
* @returns The markdown with local image destinations rewritten in place. * @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( export function rewriteLocalImageUrls(
text: string, text: string,
scope: SessionScope, scope: SessionScope,
filePath: string, filePath: string,
origin: string, origin: string,
): string { ): string {
const slash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\')) const resolve = (dest: string): string => resolveLocalMediaDest(dest, scope, filePath, origin)
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))
}
// Mask fenced code blocks and inline code spans so image-looking text // Mask fenced code blocks and inline code spans so image-looking text
// inside documentation examples is never rewritten. The sentinel uses a // 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. */ /** 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. */ /** 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. */ /** 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] ?? '' const info = rest.trimStart().split(/\s+/)[0] ?? ''
// CommonMark: a backtick fence's info string may not contain backticks — // CommonMark: a backtick fence's info string may not contain backticks —
// such a line is not an opening fence at all. // such a line is not an opening fence at all.
+132
View File
@@ -2758,3 +2758,135 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
.openWithPinActive { .openWithPinActive {
color: var(--dsw-alias-state-business-primary); 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). */ /** 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 { function mountSidebar(): MountedSidebar {
vi.stubGlobal('WebSocket', FakeWebSocket) vi.stubGlobal('WebSocket', FakeWebSocket)
const container = document.createElement('div') const container = document.createElement('div')
@@ -52,14 +55,18 @@ function mountSidebar(): MountedSidebar {
const service = createBetterSidebarService(store) const service = createBetterSidebarService(store)
// Fresh-session seed: the right panel starts OPEN, the bottom panel closed // Fresh-session seed: the right panel starts OPEN, the bottom panel closed
// (bottomOpen false → the first expansion is a false→true TRANSITION). // (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 // useSyncExternalStore requires STABLE snapshots across calls (the real DSH
// services return stable objects) — a fresh object per call loops forever. // services return stable objects) — a fresh object per call loops forever.
const localeSnapshot = { active: 'en' } const localeSnapshot = { active: 'en' }
const sessionsSnapshot = { const sessionsSnapshot = {
current: 's1', current: sessionId,
// cwd present → api.sessionCwd is never called in these tests. // cwd present → api.sessionCwd is never called in these tests.
byId: { s1: { cwd: '/tmp' } }, byId: { [sessionId]: { cwd: '/tmp' } },
} }
const ctx = { const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot }, locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
@@ -81,6 +88,9 @@ function mountSidebar(): MountedSidebar {
afterEach(() => { afterEach(() => {
document.body.innerHTML = '' 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() vi.unstubAllGlobals()
}) })
+109
View File
@@ -48,6 +48,11 @@ const SEEDED_FILE = 'hello.txt'
* and render a sanitized SVG diagram. */ * and render a sanitized SVG diagram. */
const SEEDED_MD_FILE = 'diagram.md' 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 * 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 * renders a strip whose text starts with these prefixes instead of crashing
@@ -87,6 +92,41 @@ async function seedSession(): Promise<void> {
'tail text', 'tail text',
'', '',
].join('\n')) ].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`, { const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
data: { type: 'client-request', rpcId: 'e2e-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } }, 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() ).toBeHidden()
await assertNoCrash() 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 // The plugin's own console prefix must never appear in errors, and no
// unhandled rejection may escape the sweep. // unhandled rejection may escape the sweep.
const pluginErrors = consoleErrors.filter((text) => /dsh-better-sidebar|Unhandled/.test(text)) const pluginErrors = consoleErrors.filter((text) => /dsh-better-sidebar|Unhandled/.test(text))
+18 -5
View File
@@ -60,7 +60,10 @@ interface Mounted {
unmount: () => void 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) vi.stubGlobal('WebSocket', FakeWebSocket)
if (!('ResizeObserver' in globalThis)) { if (!('ResizeObserver' in globalThis)) {
vi.stubGlobal('ResizeObserver', class { vi.stubGlobal('ResizeObserver', class {
@@ -83,9 +86,15 @@ function mountSidebar(): Mounted {
component: ({ visible }) => createElement('div', { 'data-visible': String(visible) }, 'notes body'), component: ({ visible }) => createElement('div', { 'data-visible': String(visible) }, 'notes body'),
}) })
store.setPrefs({ ...store.getPrefs(), openByDefault: true }) 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 localeSnapshot = { active: 'en' }
const sessionsSnapshot = { current: 's1', byId: { s1: { cwd: '/tmp' } } } const sessionsSnapshot = { current: sessionId, byId: { [sessionId]: { cwd: '/tmp' } } }
const ctx = { const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot }, locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } }, sessions: { list: { subscribe: () => () => {}, getSnapshot: () => sessionsSnapshot } },
@@ -132,6 +141,10 @@ afterEach(() => {
document.body.innerHTML = '' document.body.innerHTML = ''
document.documentElement.style.cssText = '' document.documentElement.style.cssText = ''
vi.unstubAllGlobals() 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', () => { 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 getItem = vi.fn((key: string): string | null => saved.get(key) ?? null)
const setItem = vi.fn((key: string, value: string): void => { saved.set(key, value) }) const setItem = vi.fn((key: string, value: string): void => { saved.set(key, value) })
vi.stubGlobal('localStorage', { getItem, setItem, removeItem: () => {} }) vi.stubGlobal('localStorage', { getItem, setItem, removeItem: () => {} })
const first = mountSidebar() const first = mountSidebar('s1')
try { try {
act(() => { first.service.openTab({ type: 'notes', title: 'Notes' }) }) act(() => { first.service.openTab({ type: 'notes', title: 'Notes' }) })
act(() => { first.store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) }) act(() => { first.store.reduce(s => floatTabReducer(s, 'notes', 512, 384)) })
@@ -390,7 +403,7 @@ describe('free windows: the window', () => {
} finally { } finally {
first.unmount() first.unmount()
} }
const second = mountSidebar() const second = mountSidebar('s1')
try { try {
const state = second.store.getSnapshot().state! const state = second.store.getSnapshot().state!
expect(state.floats).toHaveLength(1) expect(state.floats).toHaveLength(1)
+8 -5
View File
@@ -1,6 +1,6 @@
import { describe, expect, it } from 'vitest' import { describe, expect, it } from 'vitest'
import { spawnSync } from 'node:child_process' 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 { tmpdir } from 'node:os'
import { join, resolve } from 'node:path' import { join, resolve } from 'node:path'
import { parseWorktreeList, resolveWorktree, status, worktrees } from '../src/git.ts' 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, ['commit', '-q', '-m', 'base'])
git(main, ['worktree', 'add', '-q', '-b', 'agent', agent]) git(main, ['worktree', 'add', '-q', '-b', 'agent', agent])
writeFileSync(join(agent, 'tracked.txt'), 'changed by agent\n') 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) const listed = await worktrees(main)
expect(listed).toHaveLength(2) expect(listed).toHaveLength(2)
expect(listed.find(entry => entry.current)).toMatchObject({ branch: 'main', changes: 0 }) expect(listed.find(entry => entry.current)).toMatchObject({ branch: 'main', changes: 0 })
expect(listed.find(entry => !entry.current)).toMatchObject({ branch: 'agent', changes: 1 }) expect(listed.find(entry => !entry.current)).toMatchObject({ branch: 'agent', changes: 1 })
expect(resolve(await resolveWorktree(main, agent))).toBe(resolve(agent)) expect(resolve(await resolveWorktree(main, agentPath))).toBe(resolve(agentPath))
expect((await status(await resolveWorktree(main, agent))).entries).toEqual([ expect((await status(await resolveWorktree(main, agentPath))).entries).toEqual([
{ path: 'tracked.txt', xy: ' M' }, { path: 'tracked.txt', xy: ' M' },
]) ])
await expect(resolveWorktree(main, root)).rejects.toThrow('unknown linked worktree') await expect(resolveWorktree(main, root)).rejects.toThrow('unknown linked worktree')
@@ -84,9 +87,9 @@ describe('linked Git worktrees', () => {
rmSync(agent, { recursive: true, force: true }) rmSync(agent, { recursive: true, force: true })
const remaining = await worktrees(main) const remaining = await worktrees(main)
expect(remaining).toHaveLength(1) 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) 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 { } finally {
try { git(main, ['worktree', 'remove', '--force', agent]) } catch { /* fixture may not be fully initialized */ } try { git(main, ['worktree', 'remove', '--force', agent]) } catch { /* fixture may not be fully initialized */ }
rmSync(root, { recursive: true, force: true }) rmSync(root, { recursive: true, force: true })
+8 -5
View File
@@ -1,5 +1,5 @@
import { execFile, execFileSync } from 'node:child_process' 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 { mkdir, mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os' import { tmpdir } from 'node:os'
import { join } from 'node:path' import { join } from 'node:path'
@@ -10,6 +10,9 @@ import { parseLogLines, parsePorcelainZ, repoRoots, status } from '../src/git.ts
const execFileAsync = promisify(execFile) const execFileAsync = promisify(execFile)
const normalizePath = (path: string): string => path.replaceAll('\\', '/') 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', () => { describe('git parsing', () => {
it('discovers and selects direct child repositories under a workspace directory', async () => { it('discovers and selects direct child repositories under a workspace directory', async () => {
@@ -23,11 +26,11 @@ describe('git parsing', () => {
execFileAsync('git', ['-C', second, 'init']), execFileAsync('git', ['-C', second, 'init']),
]) ])
await expect(repoRoots(workspace)).resolves.toEqual([normalizePath(first), normalizePath(second)]) await expect(repoRoots(workspace)).resolves.toEqual([canonical(first), canonical(second)])
await expect(status(workspace, normalizePath(second))).resolves.toMatchObject({ await expect(status(workspace, canonical(second))).resolves.toMatchObject({
isRepo: true, isRepo: true,
root: normalizePath(second), root: canonical(second),
repositories: [normalizePath(first), normalizePath(second)], repositories: [canonical(first), canonical(second)],
}) })
} finally { } finally {
await rm(workspace, { recursive: true, force: true }) 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). */ /** 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 { function mountSidebar(): MountedSidebar {
vi.stubGlobal('WebSocket', FakeWebSocket) vi.stubGlobal('WebSocket', FakeWebSocket)
const container = document.createElement('div') const container = document.createElement('div')
@@ -55,14 +58,18 @@ function mountSidebar(): MountedSidebar {
const service = createBetterSidebarService(store) const service = createBetterSidebarService(store)
// Fresh-session seed: open the panel explicitly (openByDefault defaults off). // Fresh-session seed: open the panel explicitly (openByDefault defaults off).
store.setPrefs({ ...store.getPrefs(), openByDefault: true }) 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 // useSyncExternalStore requires STABLE snapshots across calls (the real DSH
// services return stable objects) — a fresh object per call loops forever. // services return stable objects) — a fresh object per call loops forever.
const localeSnapshot = { active: 'en' } const localeSnapshot = { active: 'en' }
const sessionsSnapshot = { const sessionsSnapshot = {
current: 's1', current: sessionId,
// cwd present → api.sessionCwd is never called in these tests. // cwd present → api.sessionCwd is never called in these tests.
byId: { s1: { cwd: '/tmp' } }, byId: { [sessionId]: { cwd: '/tmp' } },
} }
const ctx = { const ctx = {
locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot }, locale: { subscribe: () => () => {}, getSnapshot: () => localeSnapshot },
@@ -84,6 +91,9 @@ function mountSidebar(): MountedSidebar {
afterEach(() => { afterEach(() => {
document.body.innerHTML = '' 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() vi.unstubAllGlobals()
}) })