feat(editor): Markdown 预览内嵌 HTML 安全渲染 + 目录大纲(TOC)
宿主 MarkdownText 出于聊天安全把 raw HTML 按字面文本渲染,预览 GitHub 风格 README(徽章墙 div、details 折叠块内嵌 markdown、表格单元格内联 标签、video/picture)时全是源码文本。本次在不动渲染器的前提下: - markdown-html.ts(纯函数):fence 感知切分器把文档切成 markdown/html 段(CommonMark type-6 块级标签名单 + 注释段 + 空行终止);段内标签 tokenizer 归约 open/close/html parts;全文档引用定义收集(每段追加 保持 [text][id] 跨段语义) - MarkdownHtml.tsx:DOMPurify 白名单消毒(显式禁 script/iframe/form 等;a 强制 _blank+noopener;本地媒体 src 经 /sidebar/file 重写); MarkdownDocument 帧栈状态机让 <details> 跨段嵌套内部 markdown; MarkdownSegment 内联替换(MutationObserver 幂等 pass,把表格单元格 等处标签文本消毒还原为元素,纯散文不误伤) - md-toc.tsx:零高度 sticky 浮动目录按钮(≥3 标题)+ 弹出大纲,跳转 平滑滚动 + 自动展开折叠 details + 高亮闪现。关键修复:子组件读父 ref 在 React layout 阶段恒为 null(子先于父),改为经自身 bar ref 的 parentElement 定位容器(jsdom 单测传预填 ref 掩盖了该 bug,仅 真实挂载 e2e 抓到) - 纯 markdown 文档走原单次解析路径零变化(现有 mermaid e2e 守卫一字 不动);新增 readme-style.md 种子 + 探针(消毒渲染/嵌套/内联/TOC 全链路);19 语言词典新增 toc 键;dompurify 提升为直接依赖(与 mermaid 传递依赖去重单版本) 设计文档 docs/plans/2026-08-24-markdown-html-toc-design.md
This commit is contained in:
@@ -437,7 +437,7 @@ interface FileViewerProps {
|
||||
|
||||
> **head 字节从哪来**:第一次匹配(纯扩展名)没有 head。`fsRead` 策略读取后若文件为二进制,host 的 `fs.read` 响应会带 `head` 字段(base64,前 4KB),编辑器会用它对 `detect` viewer **重匹配一次**——所以 detect 型 viewer 的实际触发场景是"扩展名匹配落空/二进制文件"。文本文件的 detect 嗅探不在内置流程内(用 `exts` 或 `custom` 策略替代)。
|
||||
|
||||
> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件),该插件以相同 id 经 `ctx.betterSidebar.registerFileViewer` 注册。
|
||||
> **内置 viewer**(不可重复注册,全部 6 个):image(0) / pdf(0) / markdown(0, fsRead,**README 级内嵌 HTML 支持**——块级 HTML 段按文档顺序 DOMPurify 白名单消毒内联渲染、`<details>` 跨段嵌套内部 markdown、markdown 表格单元格等处的内联标签消毒还原为元素、本地媒体 src 重写走 `/sidebar/file`;预览顶部浮动目录大纲按钮(≥3 标题出现,点击条目平滑滚动并自动展开折叠 `<details>`;纯 markdown 文档走原单次解析路径零变化。实现 `src/client/markdown-html.ts` / `MarkdownHtml.tsx` / `md-toc.tsx`,设计文档 `docs/plans/2026-08-24-markdown-html-toc-design.md`) / html(0, fsRead, 沙箱 iframe 预览) / code(-100, catch-all, fsRead) / binary-download(-50, exts doc/xls/ppt + NUL detect)。Office 三件套预览(.docx/.xlsx/.pptx)**不再内置**——已迁至推荐插件(设置页「添加插件」→ 文件预览弹窗里的 Office 预览插件),该插件以相同 id 经 `ctx.betterSidebar.registerFileViewer` 注册。
|
||||
> code 是兜底 viewer:任何其他 viewer 未认领的文件都会落到 code(CodeMirror 文本编辑);二进制文件经 head 重匹配被 binary-download 的 NUL detect 认领(下载按钮)。外部 viewer 注册同扩展名 + 更高 priority 即可覆盖。
|
||||
|
||||
### 4.5 注册示例
|
||||
@@ -729,6 +729,7 @@ better-sidebar 自己的内置 tab 和 viewer 就是参考实现("吃狗粮"
|
||||
- **`tests/builtins.spec.ts`**:内置注册清单断言(7 tab + 6 viewer + 声明式元数据)
|
||||
- **`src/client/plugins-tabs.ts`** / **`src/client/plugins-viewers.ts`**:推荐插件目录(名字/url/简介/安装脚本,分别对应 Tab 注册与文件预览注册),在设置页两个「添加插件」弹窗展示(共享类型在 `plugins-shared.ts`);插件作者可按扩展点加一条数据(弹窗内「跳转」直达仓库、「复制」把安装命令写入剪贴板,粘贴到 DSH 所在环境的终端执行)——数据完整性由 `tests/plugin-list.spec.ts` 守护
|
||||
- **`src/client/FileTree.tsx`** / **`src/client/TreePanel.tsx`** / **`src/fs-search.ts`**:受控文件树组件(纯树体,文件行右键菜单含「在新 Tab 中打开」「在侧边打开」,仅宿主编排提供回调时渲染)/ 树面板(搜索框 + 刷新 + FileTree,文件窗口的内嵌 dock 使用)与 host 侧递归文件名搜索(`fs.search` 路由,预算兜底 + 跳过 `.git` / `node_modules` / 构建缓存 / symlink 目录;测试 `tests/fs-search.spec.ts`、组件测试 `tests/editor-host.spec.tsx`)
|
||||
- **`src/client/markdown-html.ts`** / **`src/client/MarkdownHtml.tsx`** / **`src/client/md-toc.tsx`**:markdown 预览的内嵌 HTML 渲染(fence 感知切分器 + DOMPurify 消毒管线 + 跨段 `<details>` 嵌套状态机 + 内联标签 commit 后还原 + MutationObserver 迟到内容)与目录大纲(DOM 收集 + sticky 浮动按钮 + 跳转展开折叠块;注意 `md-toc.tsx` 头注释记录的「子组件读父 ref 为 null」React 时序陷阱)。测试 `tests/markdown-html.spec.ts` / `tests/markdown-html-render.spec.tsx` / `tests/md-toc.spec.tsx`,设计文档 `docs/plans/2026-08-24-markdown-html-toc-design.md`
|
||||
- **`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`**:设计文档(含实施偏差记录)
|
||||
|
||||
调试时直接读这些文件即可看到所有 API 的真实用法。
|
||||
|
||||
@@ -40,7 +40,7 @@
|
||||
|
||||
## ✨ 功能一览
|
||||
|
||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大)/ HTML / PDF
|
||||
- **🗂️ 文件工作台**:资源管理器(懒加载目录树;软链接按目标类型展示——目录软链接可展开、失效链接标红)+ CodeMirror 编辑器;图片 / Markdown(含 Mermaid 图表,strict 安全渲染 + 点击放大;README 级内嵌 HTML——徽章墙 / `<details>` 折叠 / 表格内联标签经 DOMPurify 消毒真实渲染;浮动目录大纲一键跳转)/ HTML / PDF
|
||||
- **🌐 内嵌浏览器**:多开网页 tab,后退 / 前进 / 刷新;内容运行在沙箱 iframe;外链默认按协议分流——HTTP 在侧边栏打开、HTTPS 走系统浏览器(设置页可分别调整)
|
||||
- **💻 真实终端**:xterm.js + node-pty 真实 shell,断线重连回放;可选为模型注入 `terminal_*` 工具
|
||||
- **🌿 Git 面板**:真 diff + VSCode 式 diff tab、历史、右键暂存 / 提交 / 还原
|
||||
@@ -155,7 +155,7 @@ dsh registry enable dsh-external/dsh-better-sidebar
|
||||
|
||||
| | |
|
||||
|---|---|
|
||||
| **🗂️ 文件工作台:资源管理器**<br/><sub>支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。</sub><br/><div align="center"><img width="420" alt="文件资源管理器" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Markdown · 图片 · PDF 内联预览**<br/><sub>Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid 预览" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
|
||||
| **🗂️ 文件工作台:资源管理器**<br/><sub>支持两种格式的资源管理器:内嵌在文件预览中 / 独立显示文件树。懒加载目录树、软链接按目标类型展示(目录软链接可展开、失效链接标红)、全局文件名搜索、上传文件/文件夹与拖放上传、右键菜单(在新 Tab 打开 / 在侧边打开 / 复制路径)、悬浮 `@文件` 一键引用进输入框。</sub><br/><div align="center"><img width="420" alt="文件资源管理器" src="https://github.com/user-attachments/assets/a410bfd2-a8ba-43e6-873e-22417756e94d" /></div> | **📝 Markdown · 图片 · PDF 内联预览**<br/><sub>Markdown 预览支持 **Mermaid 图表**(`securityLevel: 'strict'` 安全渲染 + 二次清洗;点击图表弹窗放大、滚轮缩放、拖拽平移)、**README 级内嵌 HTML**(徽章墙 `<div align=center>`、`<details>` 折叠块内嵌 markdown、表格单元格内联标签——DOMPurify 白名单消毒真实渲染,`<script>` 等活性内容剥除,本地图片经会话媒体路由重写)与**浮动目录大纲**(≥3 标题出现,点击平滑跳转、自动展开折叠块);图片 / PDF 走媒体路由内联展示;Office 三件套由生态插件补齐。</sub><br/><div align="center"><img width="420" alt="Markdown + Mermaid 预览" src="https://github.com/user-attachments/assets/fe0e5182-55bb-45cc-b98b-a2877c2bdd38" /></div> |
|
||||
| **🖥️ CodeMirror 代码编辑器**<br/><div align="center"><img width="420" alt="CodeMirror 代码编辑器" src="https://github.com/user-attachments/assets/b44b488e-568c-4ee0-b96c-e9c906598a77" /></div> | **🖼️ 图片内联预览**<br/><div align="center"><img width="420" alt="图片内联预览" src="https://github.com/user-attachments/assets/f9a58c30-5b7a-48b5-9e22-37d7e071f593" /></div> |
|
||||
| **💻 真实终端**<br/><sub>xterm.js + node-pty 真实 shell(不是模拟器):断线重连 transcript 回放、shell / shellArgs 可配置(设置页或 `cordis.patch.yml`)、可选为模型注入 `terminal_*` 工具(agent 可直接开终端跑命令)。</sub><br/><div align="center"><img width="420" alt="真实终端" src="https://github.com/user-attachments/assets/0dad6ad3-ff3f-4b5a-86d2-f832ce65323e" /></div> | **🌿 Git 面板**<br/><sub>暂存 / 取消暂存 / 提交(`Ctrl+Enter`)/ 还原,历史列表;点击改动文件打开 **VSCode 式 diff tab**(红绿行级对比)。</sub><br/><div align="center"><img width="420" alt="Git 面板" src="https://github.com/user-attachments/assets/e7fc1220-305f-4bca-8583-e77ab4f4fa78" /></div> |
|
||||
| **🌐 内嵌浏览器**<br/><sub>多开网页 tab:后退 / 前进 / 刷新 / 地址栏;内容运行在**不透明源沙箱 iframe**(界面实时显示沙箱状态,可按页面临时解锁);聊天里的外链点击可被接管到侧边栏打开(按协议分流,可配)。</sub><br/><div align="center"><img width="420" alt="内嵌浏览器" src="https://github.com/user-attachments/assets/9bc6b65a-64fc-4942-a685-76e391e55606" /></div> | **🧩 任务页:子代理拓扑 + 后台任务**<br/><sub>子代理树实时拓扑(运行状态、批量实时预览)+ 后台任务清单(退出码 / 实时输出 / 强制终止);新子代理 / 新任务可自动展开侧边栏(可关)。</sub><br/><div align="center"><img width="420" alt="任务页:子代理拓扑" src="https://github.com/user-attachments/assets/dcd8ed2f-59fa-405b-937b-2d250f5034dd" /></div> |
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
# Markdown 预览内嵌 HTML 渲染 + 目录大纲(TOC)设计
|
||||
|
||||
> 日期:2026-08-24
|
||||
> 状态:已实现
|
||||
> 范围:`dsh-better-sidebar` 插件,Markdown 文件预览(`TextEditor.tsx` preview 模式);Sidechat 与其他渲染面**零改动**
|
||||
> 基线:复用 mermaid 预览(PR #164)确立的「检测切分 + 单次 MarkdownText 解析 + commit 后 DOM 手术」架构与安全姿势
|
||||
|
||||
## 1. 背景与问题
|
||||
|
||||
DSH 宿主的 `MarkdownText` 出于聊天安全把 raw HTML 按字面文本渲染(协议白名单、相对链接禁用、图片仅绝对 http(s))。这对聊天是对的,但对**文件预览**是灾难:GitHub 风格的 README(本仓库自己的 README 即典型)大量使用内嵌 HTML——`<div align="center">` 徽章墙、`<details>` 折叠块内嵌 markdown、表格单元格里的 `<br/>`/`<sub>`/`<img>`、`<video>`/`<picture>`——预览时全部显示为源码文本。
|
||||
|
||||
用户需求(确认记录):覆盖 Markdown 文件预览;块级 + 内联 HTML 都做;安全模型选 DOMPurify 白名单内联渲染(非沙箱 iframe);TOC 选浮动按钮 + 弹出面板;Sidechat 暂不动;**不更换渲染器**(保住 MarkdownText 的 shiki 高亮 + KaTeX 数学 + GFM 表格)。
|
||||
|
||||
## 2. 方案
|
||||
|
||||
### 2.1 检测与切分:`src/client/markdown-html.ts`(纯函数)
|
||||
|
||||
- `splitHtmlBlocks(text)`:行扫描器把文档切成 `markdown | html` 段。HTML 段 = 围栏外的**块级标签行**(CommonMark type-6 名单 + `<summary>`,开或闭标签)开头、延伸到空行的连续行;`<!-- -->` 注释段在含 `-->` 的行结束。**围栏识别复用 `mermaid-blocks.ts` 导出的 fence 助手**——```html 围栏内的 `<div>` 示例不会被误切。段边界的空行是分隔符,被丢弃。
|
||||
- `analyzeHtmlSegment(source)`:段内标签 tokenizer(跳过注释,void/自闭合标签不进栈,错配闭标签按 HTML 解析器的隐式闭合弹出)归约为有序 parts:`html`(平衡的原始片段,渲染为消毒叶子)/ `open`(未闭合开标签 + 原始 attrs,成为包裹后续段的容器)/ `close`(多余闭标签,弹出一层)。
|
||||
- `collectReferenceDefinitions(segments)`:从 markdown 段收集 `[label]: dest` 行。**每个 markdown 段渲染时追加密全文档定义**——跨 HTML 段的 `[text][id]` 语义保真(追加副本在已含定义的段内因 first-match-wins 而惰性)。这是 mermaid 预览「单次解析」约束(引用定义跨 fence 解析,e2e 钉死)在切分渲染下的等价物。
|
||||
- `analyzeMarkdownHtml(text)`:整体入口,含 `hasInlineHtml` 廉价正则门(fence 内误报只多走增强路径,内联 pass 本身跳过代码块,无行为差异)。
|
||||
|
||||
### 2.2 渲染:`src/client/MarkdownHtml.tsx`
|
||||
|
||||
- **安全管线**(三个入口共用):块叶子 / 内联文本 / 包裹开标签 attrs 全部过 DOMPurify(显式 `FORBID_TAGS: script/style/iframe/object/embed/form/input/button/select/textarea/meta/link/base/frame/frameset/applet` + `FORBID_ATTR: srcdoc/formaction`,叠加在默认白名单之上);消毒后 DOM 遍历强制 `<a>` → `target=_blank rel=noopener noreferrer`;本地 `img/video/audio/source` 的 `src` 经 `resolveLocalMediaDest`(从 `markdown-images.ts` 抽出的共享助手)重写为 `/sidebar/file` 会话媒体 URL——与 markdown 图片重写同一信任围栏。包裹标签的 attrs 走「迷你文档消毒再读回」;`class/for` 映射 React 名,`style` 丢弃(React 需对象,不值得引入 CSS 解析器);整标签被禁时容器透明(子内容照常渲染)。
|
||||
- **`MarkdownDocument` 段渲染状态机**:markdown 段 → `MarkdownSegment`;平衡 html 段 → `dangerouslySetInnerHTML` 叶子(`[data-dsh-html-segment]`);`open`/`close` 部件驱动一个**跨段持久的帧栈**——`<details>` 开段把后续段降为自己的子元素,`</details>` 段弹栈(GitHub 线性 HTML 输出的同款嵌套)。顶层多余闭标签渲染为无(消毒器/解析器本就会丢弃);文档结束仍开着的帧按浏览器解析器语义收口。消毒在 `useMemo` 内按段缓存。
|
||||
- **`MarkdownSegment` 内联替换**:段内含 mermaid fence 走现有 `LazyMermaidMarkdown`(chunk 逻辑不变),否则 `MarkdownText`;`useLayoutEffect` + **MutationObserver**(微任务去抖、幂等、跳过 `code/pre/.md-code-block/[data-mermaid-processed]/[data-html-inline]`)把含标签样式文本的文本节点整体消毒后包进 `<span data-html-inline>` 替换——纯散文(`a < b`)消毒后无元素子节点则原样保留;observer 覆盖懒 chunk 迟挂载、shiki 异步高亮等迟到内容。与 mermaid 换装同款「React 持有宿主树、只动叶子」姿势。
|
||||
- **接线(`TextEditor.tsx`)**:`analyzeMarkdownHtml` 发现块级或内联 HTML 才走 `MarkdownDocument`;**纯 markdown 文档走原路径原样**(无 HTML → 单次 `MarkdownText`/mermaid chunk,现有 e2e「单次解析」守卫继续成立——种子 md 文件不含 HTML,断言一字未改)。
|
||||
|
||||
### 2.3 TOC:`src/client/md-toc.tsx`
|
||||
|
||||
- 零高度 sticky 条(`position: sticky; top: 0; height: 0; pointer-events: none`,不占布局、滚动常驻)+ 右上角按钮 `[data-dsh-md-toc]`(≥3 个标题才出现)+ 弹出面板 `[data-dsh-md-toc-panel]`(按层级缩进、独立滚动、Esc 关闭)。
|
||||
- 标题从**渲染后的 DOM** 收集(h1–h6,含 HTML 段内标题),MutationObserver 重扫(签名比对防重渲染循环)。
|
||||
- 跳转:展开 `closest('details:not([open])')` → `scrollIntoView({behavior:'smooth'})` → CSS 动画高亮 1.2s。
|
||||
- **⚠️ 关键实现陷阱(本次踩坑)**:MdToc **不能**通过传入父容器 ref 定位——React 的 layout 阶段子先于父,子组件的 `useLayoutEffect` 执行时父 div 的 ref **尚未挂上**(实测 `containerRef.current === null`,且依赖数组恒定导致 effect 永不重跑,按钮永不出现;jsdom 单测传预填 ref 对象会掩盖此 bug,只有真实挂载 e2e 能抓到)。修复:MdToc 用**自身 bar div 的 ref**(自身 effect 前必然已挂)取 `parentElement` 作为容器——挂载契约「渲染为目标滚动容器的直接子元素」写入组件文档;bar 常驻渲染(零高度无副作用)以保证 ref 恒在。
|
||||
|
||||
### 2.4 依赖与产物
|
||||
|
||||
- `dompurify ^3.4`(已是 mermaid 的传递依赖,提升为直接依赖后 pnpm 去重单版本,零额外体积)随 editor chunk 内联,未新增 chunk。
|
||||
- 市场清单守卫合规(dependencies 无 `cordis`、无 install 脚本)。
|
||||
|
||||
## 3. 测试
|
||||
|
||||
- `tests/markdown-html.spec.ts`:切分器(21 例:块级标签成段/空行终止/注释段/闭标签段/围栏免疫/CommonMark 恢复/README details 三段形)+ tokenizer 平衡分析(9 例)+ 引用定义收集 + 内联门。
|
||||
- `tests/markdown-html-render.spec.tsx`:真实 `MarkdownText` 渲染——消毒叶子(script 剥除、a 加固、本地 src 重写)、details 跨段嵌套(fence 在 details 元素内、后续段是兄弟)、跨段引用定义解析、禁用标签容器透明、内联替换(表格单元格 `<br/>` 生效、代码块与 `a < b` 不动、内联本地 img 重写)。
|
||||
- `tests/md-toc.spec.tsx`:阈值门、标题收集、跳转(mock `scrollIntoView`、折叠祖先展开、高亮清除)、observer 迟到标题、Esc 关闭。
|
||||
- `tests/e2e/mount.e2e.ts`:新增 `readme-style.md` 种子(徽章 div 含 script、details 包 fence+标题、表格内联 `<br/>`、跨段引用定义)+ 探针(徽章 img 真实元素、无 script、details 嵌套、内联 br、引用解析、TOC 按钮→面板→跳转展开)。**现有 mermaid 探针与断言一字不动**(纯 markdown 文档零回归证明)。
|
||||
|
||||
## 4. 已知取舍(明确不做)
|
||||
|
||||
- GFM 脚注跨段不保(宿主本就把脚注渲染为纯文本);`srcset` 不重写(实际只有远程 URL);HTML 实体不解码;标题锚点 permalink、TOC 滚动联动高亮不做;Sidechat 不动;不换渲染器。
|
||||
- CommonMark HTML 块类型 1(script/pre/style/textarea 按闭标签终止)与类型 7(单行完整标签)不做专门处理——块级名单 + 空行终止已覆盖 README 级真实用法。
|
||||
@@ -131,6 +131,7 @@
|
||||
"@codemirror/view": "^6.43.8",
|
||||
"@lezer/highlight": "^1.2.3",
|
||||
"clsx": "^2.1.1",
|
||||
"dompurify": "^3.4.14",
|
||||
"mermaid": "^11.16.1",
|
||||
"node-pty": "^1.1.0",
|
||||
"react-icons": "5.7.0",
|
||||
|
||||
Generated
+3
@@ -77,6 +77,9 @@ importers:
|
||||
clsx:
|
||||
specifier: ^2.1.1
|
||||
version: 2.1.1
|
||||
dompurify:
|
||||
specifier: ^3.4.14
|
||||
version: 3.4.14
|
||||
mermaid:
|
||||
specifier: ^11.16.1
|
||||
version: 11.17.0
|
||||
|
||||
@@ -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
@@ -14,7 +14,6 @@
|
||||
* renders the controls instead.
|
||||
*/
|
||||
import { useEffect, useMemo, useRef, useState } from 'react'
|
||||
import type { ComponentType } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import clsx from 'clsx'
|
||||
import { EditorState } from '@codemirror/state'
|
||||
@@ -30,7 +29,10 @@ import { SandboxStatusBar } from './SandboxStatusBar.tsx'
|
||||
import { appendToDraft } from './conversation-draft.ts'
|
||||
import { buildSelectionInsert, linesOfSelection } from './selection-payload.ts'
|
||||
import { lazyChunkComponent } from './lazy-chunk.tsx'
|
||||
import { splitMermaidBlocks, type MermaidMarkdownProps } from './mermaid-blocks.ts'
|
||||
import { analyzeMarkdownHtml } from './markdown-html.ts'
|
||||
import { LazyMermaidMarkdown, MarkdownDocument, type MarkdownHtmlMedia } from './MarkdownHtml.tsx'
|
||||
import { MdToc } from './md-toc.tsx'
|
||||
import { splitMermaidBlocks } from './mermaid-blocks.ts'
|
||||
import { t } from './locales.ts'
|
||||
import type { EditorToolbarState, FileViewerProps } from './service.ts'
|
||||
import css from './sidebar.module.css'
|
||||
@@ -45,16 +47,6 @@ interface SelectionPopup {
|
||||
top: number
|
||||
}
|
||||
|
||||
/**
|
||||
* The chunk-resident markdown preview renderer (mermaid lazy chunk): one
|
||||
* MarkdownText pass over the whole source, with rendered mermaid fences
|
||||
* swapped for diagrams. Module-level `pick` keeps the load effect stable.
|
||||
*/
|
||||
const LazyMermaidMarkdown = lazyChunkComponent<MermaidMarkdownProps>(
|
||||
'mermaid',
|
||||
(mod) => mod.MermaidMarkdown as ComponentType<MermaidMarkdownProps> | undefined,
|
||||
)
|
||||
|
||||
/**
|
||||
* The sandbox tokens of the HTML preview iframe. NO allow-same-origin (the
|
||||
* preview must stay in an opaque origin — with the route's own origin it
|
||||
@@ -266,9 +258,28 @@ export function TextEditor(props: FileViewerProps) {
|
||||
() => (markdown && mode === 'preview' ? splitMermaidBlocks(mdText) : []),
|
||||
[markdown, mode, mdText],
|
||||
)
|
||||
/** Raw-HTML analysis (block runs lifted out + inline gate). Non-null only
|
||||
* for documents that actually contain HTML — plain markdown keeps the
|
||||
* legacy single-pass render path below, byte-for-byte. */
|
||||
const htmlInfo = useMemo(
|
||||
() => (markdown && mode === 'preview' ? analyzeMarkdownHtml(mdText) : null),
|
||||
[markdown, mode, mdText],
|
||||
)
|
||||
const hasMermaid = useMemo(
|
||||
() => mdBlocks.some(block => block.kind === 'mermaid'),
|
||||
[mdBlocks],
|
||||
() => htmlInfo !== null
|
||||
? htmlInfo.segments.some((segment) => segment.kind === 'markdown'
|
||||
&& splitMermaidBlocks(segment.text).some((block) => block.kind === 'mermaid'))
|
||||
: mdBlocks.some((block) => block.kind === 'mermaid'),
|
||||
[htmlInfo, mdBlocks],
|
||||
)
|
||||
/** The media context for the split renderer (local-src rewriting inside
|
||||
* sanitized HTML). Memoized on primitives: MarkdownDocument sanitizes per
|
||||
* `media` identity, so a fresh object per render would re-sanitize every
|
||||
* keystroke. */
|
||||
const htmlMedia = useMemo<MarkdownHtmlMedia>(
|
||||
() => ({ scope, path, origin: window.location.origin }),
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[scope.sessionId, scope.cwd, path],
|
||||
)
|
||||
const codeLabels = { copyLabel: t('copy'), copiedLabel: t('copied') }
|
||||
|
||||
@@ -395,13 +406,23 @@ export function TextEditor(props: FileViewerProps) {
|
||||
dictionary: the DSH MarkdownText/CodeBlock are cordis-free and
|
||||
fall back to hardcoded Chinese otherwise (same pattern as the
|
||||
chat's AssistantMarkdown). Render-time t() keeps them following
|
||||
the active locale on live switches. Mermaid fences hand the
|
||||
whole document to the mermaid lazy chunk (single markdown
|
||||
parse; cross-fence references/footnotes stay intact); files
|
||||
without one render exactly as before. */}
|
||||
{hasMermaid
|
||||
? <LazyMermaidMarkdown text={previewText} codeLabels={codeLabels} />
|
||||
: <MarkdownText text={previewText} codeLabels={codeLabels} />}
|
||||
the active locale on live switches. Plain markdown (no HTML)
|
||||
renders exactly as before — one MarkdownText pass for the whole
|
||||
document, or the mermaid lazy chunk (single markdown parse;
|
||||
cross-fence references/footnotes stay intact) when a mermaid
|
||||
fence exists. Documents containing HTML (block runs or inline
|
||||
tags) render through the split document renderer: markdown runs
|
||||
keep the MarkdownText/mermaid path while raw-HTML runs render
|
||||
as sanitized DOM (see markdown-html.tsx). */}
|
||||
{/* The outline button rides on top of the preview scroll container
|
||||
(sticky, zero-height — first child so it pins from the very
|
||||
top) once the document has enough headings. */}
|
||||
<MdToc />
|
||||
{htmlInfo !== null
|
||||
? <MarkdownDocument info={htmlInfo} media={htmlMedia} codeLabels={codeLabels} />
|
||||
: hasMermaid
|
||||
? <LazyMermaidMarkdown text={previewText} codeLabels={codeLabels} />
|
||||
: <MarkdownText text={previewText} codeLabels={codeLabels} />}
|
||||
</div>
|
||||
)}
|
||||
{html && mode === 'preview' && (
|
||||
|
||||
@@ -80,6 +80,7 @@ export const ar: Record<string, string> = {
|
||||
terminalDepsHint: 'شغّل الأمر التالي في طرفية أو cmd على جهاز DSH للإصلاح، ثم أعد المحاولة (يبقى node-pty متزامناً مع إصدار نواة DSH):',
|
||||
terminalDepsProfile: ' (الملف الشخصي المكتشف: {profile})',
|
||||
preview: 'معاينة',
|
||||
toc: 'الفهرس',
|
||||
edit: 'تحرير',
|
||||
mermaidError: 'فشل رسم Mermaid',
|
||||
mermaidZoomIn: 'تكبير',
|
||||
|
||||
@@ -65,6 +65,7 @@ export const de: Record<string, string> = {
|
||||
terminalDepsHint: 'Führen Sie den folgenden Befehl in einem Terminal oder in cmd auf dem DSH-System aus, um dies zu beheben, und klicken Sie dann auf „Erneut versuchen“ (node-pty bleibt mit der DSH-Core-Version synchron):',
|
||||
terminalDepsProfile: ' (erkanntes Profil: {profile})',
|
||||
preview: 'Vorschau',
|
||||
toc: 'Inhaltsverzeichnis',
|
||||
edit: 'Bearbeiten',
|
||||
mermaidError: 'Mermaid-Rendering fehlgeschlagen',
|
||||
mermaidZoomIn: 'Vergrößern',
|
||||
|
||||
@@ -72,6 +72,7 @@ export const fr: Record<string, string> = {
|
||||
terminalDepsHint: 'Exécutez la commande suivante dans un terminal ou cmd de l’environnement DSH pour réparer, puis cliquez sur Réessayer (node-pty reste à la même version que le cœur DSH) :',
|
||||
terminalDepsProfile: ' (profil détecté : {profile})',
|
||||
preview: 'Aperçu',
|
||||
toc: 'Sommaire',
|
||||
edit: 'Modifier',
|
||||
mermaidError: 'Échec du rendu Mermaid',
|
||||
mermaidZoomIn: 'Agrandir',
|
||||
|
||||
@@ -79,6 +79,7 @@ export const hi: Record<string, string> = {
|
||||
terminalDepsHint: 'DSH मशीन पर एक टर्मिनल या cmd में नीचे दिया गया कमांड चलाएँ, फिर पुनः प्रयास करें (node-pty DSH कोर संस्करण के साथ सिंक रहता है):',
|
||||
terminalDepsProfile: ' (पहचाना गया प्रोफ़ाइल: {profile})',
|
||||
preview: 'पूर्वावलोकन',
|
||||
toc: 'विषय-सूची',
|
||||
edit: 'संपादित करें',
|
||||
mermaidError: 'Mermaid रेंडर विफल',
|
||||
mermaidZoomIn: 'ज़ूम इन',
|
||||
|
||||
@@ -77,6 +77,7 @@ export const id: Record<string, string> = {
|
||||
terminalDepsHint: 'Jalankan perintah di bawah ini di terminal atau cmd pada mesin DSH untuk memperbaikinya, lalu coba lagi (node-pty tetap sinkron dengan versi inti DSH):',
|
||||
terminalDepsProfile: ' (profil terdeteksi: {profile})',
|
||||
preview: 'Pratinjau',
|
||||
toc: 'Daftar Isi',
|
||||
edit: 'Edit',
|
||||
mermaidError: 'Render Mermaid gagal',
|
||||
mermaidZoomIn: 'Perbesar',
|
||||
|
||||
@@ -70,6 +70,7 @@ export const it: Record<string, string> = {
|
||||
terminalDepsHint: 'Esegua il comando seguente in un terminale o cmd sul computer DSH per ripristinarlo, poi riprovi (node-pty resta sincronizzato con la versione del core DSH):',
|
||||
terminalDepsProfile: ' (rilevato profilo: {profile})',
|
||||
preview: 'Anteprima',
|
||||
toc: 'Indice',
|
||||
edit: 'Modifica',
|
||||
mermaidError: 'Rendering Mermaid non riuscito',
|
||||
mermaidZoomIn: 'Ingrandisci',
|
||||
|
||||
@@ -79,6 +79,7 @@ export const ja: Record<string, string> = {
|
||||
terminalDepsHint: 'DSH 環境のターミナルまたは cmd で以下のコマンドを実行して修復し、再試行してください(node-pty は DSH コアと同じバージョンを維持):',
|
||||
terminalDepsProfile: '(検出された profile:{profile})',
|
||||
preview: 'プレビュー',
|
||||
toc: '目次',
|
||||
edit: '編集',
|
||||
mermaidError: 'Mermaid レンダリング失敗',
|
||||
mermaidZoomIn: '拡大',
|
||||
|
||||
@@ -71,6 +71,7 @@ export const ko: Record<string, string> = {
|
||||
terminalDepsHint: 'DSH가 설치된 환경의 터미널 또는 cmd에서 아래 명령을 실행하여 복구한 후 다시 시도를 클릭하세요(node-pty는 DSH 코어와 동일한 버전을 유지합니다):',
|
||||
terminalDepsProfile: ' (profile 감지됨: {profile})',
|
||||
preview: '미리보기',
|
||||
toc: '목차',
|
||||
edit: '편집',
|
||||
mermaidError: 'Mermaid 렌더링 실패',
|
||||
mermaidZoomIn: '확대',
|
||||
|
||||
@@ -77,6 +77,7 @@ export const nl: Record<string, string> = {
|
||||
terminalDepsHint: 'Voer het onderstaande commando uit in een terminal of cmd op de DSH-machine om het te herstellen en probeer het opnieuw (node-pty blijft in sync met de DSH-coreversie):',
|
||||
terminalDepsProfile: ' (gedetecteerde profile: {profile})',
|
||||
preview: 'Voorbeeld',
|
||||
toc: 'Inhoudsopgave',
|
||||
edit: 'Bewerken',
|
||||
mermaidError: 'Mermaid-renderen mislukt',
|
||||
mermaidZoomIn: 'Inzoomen',
|
||||
|
||||
@@ -81,6 +81,7 @@ export const pl: Record<string, string> = {
|
||||
terminalDepsHint: 'Uruchom poniższe polecenie w terminalu lub cmd na maszynie DSH, aby to naprawić, a następnie ponów (node-pty pozostaje w synchronizacji z wersją rdzenia DSH):',
|
||||
terminalDepsProfile: ' (wykryty profil: {profile})',
|
||||
preview: 'Podgląd',
|
||||
toc: 'Spis treści',
|
||||
edit: 'Edytuj',
|
||||
mermaidError: 'Renderowanie Mermaid nie powiodło się',
|
||||
mermaidZoomIn: 'Powiększ',
|
||||
|
||||
@@ -62,6 +62,7 @@ export const pt: Record<string, string> = {
|
||||
terminalDepsHint: 'Execute o comando abaixo em um terminal ou cmd na máquina do DSH para reparar e tente novamente (node-pty permanece em sincronia com a versão do núcleo do DSH):',
|
||||
terminalDepsProfile: ' (perfil detectado: {profile})',
|
||||
preview: 'Pré-visualizar',
|
||||
toc: 'Sumário',
|
||||
edit: 'Editar',
|
||||
mermaidError: 'Falha ao renderizar o Mermaid',
|
||||
mermaidZoomIn: 'Ampliar',
|
||||
|
||||
@@ -75,6 +75,7 @@ export const ru: Record<string, string> = {
|
||||
terminalDepsHint: 'Выполните приведённую ниже команду в терминале или cmd на машине DSH, затем нажмите «Повторить» (node-pty остаётся синхронизированным с версией ядра DSH):',
|
||||
terminalDepsProfile: ' (обнаружен profile: {profile})',
|
||||
preview: 'Предпросмотр',
|
||||
toc: 'Оглавление',
|
||||
edit: 'Изменить',
|
||||
mermaidError: 'Сбой рендеринга Mermaid',
|
||||
mermaidZoomIn: 'Приблизить',
|
||||
|
||||
@@ -62,6 +62,7 @@ export const sv: Record<string, string> = {
|
||||
terminalDepsHint: 'Kör kommandot nedan i en terminal eller cmd på DSH-maskinen för att reparera det, försök sedan igen (node-pty hålls synkad med DSH-kärnversionen):',
|
||||
terminalDepsProfile: ' (upptäckt profil: {profile})',
|
||||
preview: 'Förhandsgranska',
|
||||
toc: 'Innehållsförteckning',
|
||||
edit: 'Redigera',
|
||||
mermaidError: 'Mermaid-rendering misslyckades',
|
||||
mermaidZoomIn: 'Zooma in',
|
||||
|
||||
@@ -79,6 +79,7 @@ export const th: Record<string, string> = {
|
||||
terminalDepsHint: 'เรียกใช้คำสั่งด้านล่างในเทอร์มินัลหรือ cmd บนเครื่อง DSH เพื่อซ่อมแซม แล้วกดลองอีกครั้ง (node-pty ต้องซิงค์กับเวอร์ชันหลักของ DSH):',
|
||||
terminalDepsProfile: ' (ตรวจพบ profile: {profile})',
|
||||
preview: 'พรีวิว',
|
||||
toc: 'สารบัญ',
|
||||
edit: 'แก้ไข',
|
||||
mermaidError: 'เรนเดอร์ Mermaid ล้มเหลว',
|
||||
mermaidZoomIn: 'ซูมเข้า',
|
||||
|
||||
@@ -79,6 +79,7 @@ export const tr: Record<string, string> = {
|
||||
terminalDepsHint: 'Onarmak için DSH makinesindeki bir terminalde veya cmd’de aşağıdaki komutu çalıştırın, sonra yeniden deneyin (node-pty, DSH çekirdek sürümüyle senkron kalır):',
|
||||
terminalDepsProfile: ' (algılanan profil: {profile})',
|
||||
preview: 'Önizleme',
|
||||
toc: 'İçindekiler',
|
||||
edit: 'Düzenle',
|
||||
mermaidError: 'Mermaid oluşturma başarısız',
|
||||
mermaidZoomIn: 'Yakınlaştır',
|
||||
|
||||
@@ -79,6 +79,7 @@ export const vi: Record<string, string> = {
|
||||
terminalDepsHint: 'Chạy lệnh sau trong terminal hoặc cmd trên máy DSH để sửa, rồi nhấn thử lại (node-pty đồng bộ phiên bản với lõi DSH):',
|
||||
terminalDepsProfile: ' (phát hiện profile: {profile})',
|
||||
preview: 'Xem trước',
|
||||
toc: 'Mục lục',
|
||||
edit: 'Chỉnh sửa',
|
||||
mermaidError: 'Render Mermaid thất bại',
|
||||
mermaidZoomIn: 'Phóng to',
|
||||
|
||||
@@ -94,6 +94,7 @@ export const zhHK: Record<string, string> = {
|
||||
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
|
||||
terminalDepsProfile: '(偵測到 profile:{profile})',
|
||||
preview: '預覽',
|
||||
toc: '目錄',
|
||||
edit: '編輯',
|
||||
mermaidError: 'Mermaid 渲染失敗',
|
||||
mermaidZoomIn: '放大',
|
||||
|
||||
@@ -94,6 +94,7 @@ export const zhMO: Record<string, string> = {
|
||||
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
|
||||
terminalDepsProfile: '(偵測到 profile:{profile})',
|
||||
preview: '預覽',
|
||||
toc: '目錄',
|
||||
edit: '編輯',
|
||||
mermaidError: 'Mermaid 渲染失敗',
|
||||
mermaidZoomIn: '放大',
|
||||
|
||||
@@ -94,6 +94,7 @@ export const zhTW: Record<string, string> = {
|
||||
terminalDepsHint: '在 DSH 所在環境的終端或 cmd 中執行以下命令修復,然後點重試(node-pty 與 DSH 核心保持同一版本):',
|
||||
terminalDepsProfile: '(偵測到 profile:{profile})',
|
||||
preview: '預覽',
|
||||
toc: '目錄',
|
||||
edit: '編輯',
|
||||
mermaidError: 'Mermaid 渲染失敗',
|
||||
mermaidZoomIn: '放大',
|
||||
|
||||
@@ -86,6 +86,7 @@ export const zh = {
|
||||
terminalDepsHint: '在 DSH 所在环境的终端或 cmd 中执行以下命令修复,然后点重试(node-pty 与 DSH 核心保持同一版本):',
|
||||
terminalDepsProfile: '(检测到 profile:{profile})',
|
||||
preview: '预览',
|
||||
toc: '目录',
|
||||
edit: '编辑',
|
||||
mermaidError: 'Mermaid 渲染失败',
|
||||
mermaidZoomIn: '放大',
|
||||
@@ -420,6 +421,7 @@ export const en: Record<keyof typeof zh, string> = {
|
||||
terminalDepsHint: 'Run the command below in a terminal or cmd on the DSH machine to repair it, then retry (node-pty stays in sync with the DSH core version):',
|
||||
terminalDepsProfile: ' (detected profile: {profile})',
|
||||
preview: 'Preview',
|
||||
toc: 'Table of contents',
|
||||
edit: 'Edit',
|
||||
mermaidError: 'Mermaid render failed',
|
||||
mermaidZoomIn: 'Zoom in',
|
||||
|
||||
@@ -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),
|
||||
}
|
||||
}
|
||||
@@ -65,28 +65,41 @@ function normalizeLocalPath(path: string): string {
|
||||
* so the core rewrite stays pure and unit-testable.
|
||||
* @returns The markdown with local image destinations rewritten in place.
|
||||
*/
|
||||
/**
|
||||
* Resolve one media destination against the session's media route: local
|
||||
* (relative or absolute) paths become absolute `/sidebar/file` URLs (prefixed
|
||||
* with the GUI's own origin so the shared MarkdownText http(s) allowlist
|
||||
* accepts them), while remote URLs, `#`-anchors and empty destinations are
|
||||
* returned untouched. Shared by the markdown image rewriter below and by the
|
||||
* preview's raw-HTML sanitizer (`markdown-html.tsx`, which meets the same
|
||||
* allowlist when rendering `<img src="./x.png">` inside HTML blocks).
|
||||
*/
|
||||
export function resolveLocalMediaDest(
|
||||
dest: string,
|
||||
scope: SessionScope,
|
||||
filePath: string,
|
||||
origin: string,
|
||||
): string {
|
||||
const trimmed = dest.trim()
|
||||
if (trimmed === '' || trimmed.startsWith('#')) return dest
|
||||
if (isRemoteUrl(trimmed)) return dest
|
||||
const slash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
|
||||
const directory = slash === -1 ? '/' : filePath.slice(0, slash + 1)
|
||||
const candidate = isAbsolutePath(trimmed) ? trimmed : directory + trimmed
|
||||
// Mirrors api.ts fileUrl/mediaUrl for the /sidebar/file media route, made
|
||||
// absolute so the shared MarkdownText http(s) allowlist accepts it.
|
||||
const params = new URLSearchParams({ sessionId: scope.sessionId, path: normalizeLocalPath(candidate) })
|
||||
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
||||
return `${origin}/sidebar/file?${params.toString()}`
|
||||
}
|
||||
|
||||
export function rewriteLocalImageUrls(
|
||||
text: string,
|
||||
scope: SessionScope,
|
||||
filePath: string,
|
||||
origin: string,
|
||||
): string {
|
||||
const slash = Math.max(filePath.lastIndexOf('/'), filePath.lastIndexOf('\\'))
|
||||
const directory = slash === -1 ? '/' : filePath.slice(0, slash + 1)
|
||||
const mediaUrl = (candidate: string): string => {
|
||||
// Mirrors api.ts fileUrl/mediaUrl for the /sidebar/file media route, made
|
||||
// absolute so the shared MarkdownText http(s) allowlist accepts it.
|
||||
const params = new URLSearchParams({ sessionId: scope.sessionId, path: candidate })
|
||||
if (scope.cwd !== undefined && scope.cwd !== '') params.set('cwd', scope.cwd)
|
||||
return `${origin}/sidebar/file?${params.toString()}`
|
||||
}
|
||||
const resolve = (dest: string): string => {
|
||||
const trimmed = dest.trim()
|
||||
if (trimmed === '' || trimmed.startsWith('#')) return dest
|
||||
if (isRemoteUrl(trimmed)) return dest
|
||||
const candidate = isAbsolutePath(trimmed) ? trimmed : directory + trimmed
|
||||
return mediaUrl(normalizeLocalPath(candidate))
|
||||
}
|
||||
const resolve = (dest: string): string => resolveLocalMediaDest(dest, scope, filePath, origin)
|
||||
|
||||
// Mask fenced code blocks and inline code spans so image-looking text
|
||||
// inside documentation examples is never rewritten. The sentinel uses a
|
||||
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -32,13 +32,13 @@ export interface MermaidMarkdownProps {
|
||||
}
|
||||
|
||||
/** CommonMark opening fence: 0-3 spaces indent + a run of 3+ backticks or tildes. */
|
||||
const OPEN_FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
|
||||
export const OPEN_FENCE_RE = /^ {0,3}(`{3,}|~{3,})/
|
||||
|
||||
/** A closing-fence line: 0-3 spaces indent + 3+ backticks/tildes + trailing spaces only. */
|
||||
const CLOSE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
|
||||
export const CLOSE_FENCE_RE = /^ {0,3}(`{3,}|~{3,})[ \t]*$/
|
||||
|
||||
/** Parse the info string from the line tail after the fence run; null when invalid. */
|
||||
function fenceInfo(rest: string, fence: string): string | null {
|
||||
export function fenceInfo(rest: string, fence: string): string | null {
|
||||
const info = rest.trimStart().split(/\s+/)[0] ?? ''
|
||||
// CommonMark: a backtick fence's info string may not contain backticks —
|
||||
// such a line is not an opening fence at all.
|
||||
|
||||
@@ -2758,3 +2758,135 @@ body[data-dsh-sidebar-dragging] .bottomPanel {
|
||||
.openWithPinActive {
|
||||
color: var(--dsw-alias-state-business-primary);
|
||||
}
|
||||
|
||||
/* ── Markdown preview: raw-HTML runs + table of contents ─────────────────
|
||||
The HTML runs lifted out of the markdown source (MarkdownHtml.tsx) render
|
||||
as sanitized block leaves; the TOC is a zero-height sticky bar keeping the
|
||||
outline button pinned at the preview's top-right without consuming layout.
|
||||
All visuals ride the alias tokens (skin system follows automatically). */
|
||||
|
||||
/* A sanitized HTML run leaf (block-level box; content is DOMPurify output). */
|
||||
.editorHtmlBlock {
|
||||
margin: 8px 0;
|
||||
}
|
||||
|
||||
.editorHtmlBlock img,
|
||||
.editorHtmlBlock video {
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.editorHtmlBlock details {
|
||||
margin: 4px 0;
|
||||
padding: 4px 0;
|
||||
}
|
||||
|
||||
.editorHtmlBlock summary {
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* The zero-height sticky bar: occupies no layout, keeps the button visible
|
||||
while the preview scrolls, and lets pointer events pass through the bar
|
||||
itself (only the interactive children re-enable them). */
|
||||
.tocBar {
|
||||
position: sticky;
|
||||
top: 0;
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
height: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.tocButton {
|
||||
pointer-events: auto;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 26px;
|
||||
height: 26px;
|
||||
margin: 4px 2px 0 0;
|
||||
padding: 0;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 6px;
|
||||
background: var(--dsw-alias-bg-layer-1);
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tocButton:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* The outline popover, anchored under the button (the bar is the containing
|
||||
block; its height is 0, so the panel drops below the pinned button row). */
|
||||
.tocPanel {
|
||||
position: absolute;
|
||||
top: 32px;
|
||||
right: 2px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
width: min(300px, 82%);
|
||||
max-height: 60vh;
|
||||
overflow-y: auto;
|
||||
padding: 4px;
|
||||
border: 1px solid var(--dsw-alias-border-l1);
|
||||
border-radius: 8px;
|
||||
background: var(--dsw-alias-bg-layer-2);
|
||||
box-shadow: var(--dsw-shadow-lv2);
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.tocItem {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 8px;
|
||||
min-width: 0;
|
||||
padding: 4px 8px;
|
||||
border: none;
|
||||
border-radius: 6px;
|
||||
background: transparent;
|
||||
color: var(--dsw-alias-label-secondary);
|
||||
font: var(--dsw-font-xxs-12);
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.tocItem:hover {
|
||||
background: var(--dsw-alias-interactive-bg-hover);
|
||||
color: var(--dsw-alias-label-primary);
|
||||
}
|
||||
|
||||
/* Indent by heading level (h2 = one step, h3 = two steps…). */
|
||||
.tocItem[data-level='2'] { padding-left: 18px; }
|
||||
.tocItem[data-level='3'] { padding-left: 28px; }
|
||||
.tocItem[data-level='4'] { padding-left: 38px; }
|
||||
.tocItem[data-level='5'] { padding-left: 48px; }
|
||||
.tocItem[data-level='6'] { padding-left: 58px; }
|
||||
|
||||
/* The tiny level number keeps deep outlines scannable. */
|
||||
.tocItemLevel {
|
||||
flex: none;
|
||||
font: var(--dsw-font-xxxs-11);
|
||||
color: var(--dsw-alias-label-tertiary);
|
||||
}
|
||||
|
||||
.tocItemText {
|
||||
flex: 1 1 auto;
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
/* The jumped-to heading flashes once (the class is added to the host-rendered
|
||||
heading element by md-toc.tsx; @keyframes rides the CSS module). */
|
||||
@keyframes dsh-toc-flash {
|
||||
0%, 60% { background: var(--dsw-alias-interactive-bg-hover); }
|
||||
100% { background: transparent; }
|
||||
}
|
||||
|
||||
.tocFlash {
|
||||
animation: dsh-toc-flash 1.2s ease-out;
|
||||
border-radius: 4px;
|
||||
}
|
||||
|
||||
@@ -48,6 +48,11 @@ const SEEDED_FILE = 'hello.txt'
|
||||
* and render a sanitized SVG diagram. */
|
||||
const SEEDED_MD_FILE = 'diagram.md'
|
||||
|
||||
/** A GitHub-style README markdown (badge div, <details> nesting markdown,
|
||||
* inline tags in table cells), opened through the Files window's tree to
|
||||
* prove raw-HTML runs render as sanitized DOM and the TOC outline works. */
|
||||
const SEEDED_README_FILE = 'readme-style.md'
|
||||
|
||||
/**
|
||||
* The plugin's crash markers. The client mounts inside an error boundary that
|
||||
* renders a strip whose text starts with these prefixes instead of crashing
|
||||
@@ -87,6 +92,41 @@ async function seedSession(): Promise<void> {
|
||||
'tail text',
|
||||
'',
|
||||
].join('\n'))
|
||||
// The README-style probe file: raw-HTML runs (badge wall div with an
|
||||
// embedded script, a <details> nesting a fence + heading) plus inline tags
|
||||
// inside a table cell. The script must be sanitized away in the preview.
|
||||
writeFileSync(join(WORKSPACE_PATH, SEEDED_README_FILE), [
|
||||
'# Readme Style',
|
||||
'',
|
||||
'<div align="center">',
|
||||
' <img alt="badge" src="https://img.shields.io/badge/x-y-blue" />',
|
||||
' <script>alert(1)</script>',
|
||||
'</div>',
|
||||
'',
|
||||
'## Setup',
|
||||
'',
|
||||
'[docs link][def]',
|
||||
'',
|
||||
'<details>',
|
||||
'<summary><b>Steps</b></summary>',
|
||||
'',
|
||||
'### Inside',
|
||||
'',
|
||||
'```sh',
|
||||
'dsh plugin --profile web add x',
|
||||
'```',
|
||||
'',
|
||||
'</details>',
|
||||
'',
|
||||
'## Table',
|
||||
'',
|
||||
'| col | note |',
|
||||
'| --- | --- |',
|
||||
'| alpha | line one<br/>line two |',
|
||||
'',
|
||||
'[def]: https://example.com/def',
|
||||
'',
|
||||
].join('\n'))
|
||||
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
|
||||
})
|
||||
@@ -433,6 +473,75 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
).toBeHidden()
|
||||
await assertNoCrash()
|
||||
|
||||
// README-style markdown (raw-HTML runs + TOC): open the seeded file and
|
||||
// require the full round-trip — sanitized HTML leaves (badge image as a
|
||||
// real element, active content stripped), markdown nested inside the
|
||||
// unclosed <details> run, the inline pass turning the table cell's <br/>
|
||||
// into an element, the reference link resolving across the HTML run, and
|
||||
// the TOC outline jumping into the collapsed details (auto-expanding it).
|
||||
await sidebar.locator('[title="Files"][draggable="true"]').first().click()
|
||||
const readmeRow = sidebar.locator(`[role="button"][title$="${SEEDED_README_FILE}"]:visible`)
|
||||
await expect(
|
||||
readmeRow,
|
||||
`the seeded "${SEEDED_README_FILE}" file must appear in the files window's tree`,
|
||||
).toHaveCount(1, { timeout: 30_000 })
|
||||
await readmeRow.click({ position: { x: 8, y: 8 } })
|
||||
await expect(
|
||||
sidebar.locator(`[title="${SEEDED_README_FILE}"][draggable="true"]`),
|
||||
'separate mode opens a new tab for the README-style markdown file',
|
||||
).toHaveCount(1, { timeout: 30_000 })
|
||||
// The hero div renders as a sanitized leaf with the badge image as a real
|
||||
// element (the remote src may not load in the sandboxed lane; the ELEMENT
|
||||
// is the proof of rendering, not the bytes).
|
||||
await expect(
|
||||
sidebar.locator('[data-dsh-html-segment] img[src*="img.shields.io"]'),
|
||||
'the badge-wall div must render its image as a real element',
|
||||
).toHaveCount(1, { timeout: 30_000 })
|
||||
// Active content never survives sanitization.
|
||||
await expect(
|
||||
sidebar.locator('script'),
|
||||
'the embedded <script> must be sanitized away',
|
||||
).toHaveCount(0)
|
||||
// The unclosed <details> run lowers the following markdown inside itself.
|
||||
const details = sidebar.locator('details')
|
||||
await expect(
|
||||
details,
|
||||
'the details run must render as a real element',
|
||||
).toHaveCount(1, { timeout: 30_000 })
|
||||
await expect(details.locator('summary'), 'the details summary must render').toHaveCount(1)
|
||||
await expect(
|
||||
details.locator('h3', { hasText: 'Inside' }),
|
||||
'the heading between the details tags must nest inside the element',
|
||||
).toHaveCount(1)
|
||||
// The inline pass: the table cell's literal <br/> became a real element.
|
||||
await expect(
|
||||
sidebar.locator('[data-html-inline] br'),
|
||||
'the table cell <br/> must render as a real element',
|
||||
).toHaveCount(1, { timeout: 30_000 })
|
||||
// The reference link resolves although its definition sits after an HTML run.
|
||||
await expect(
|
||||
sidebar.locator('a[href="https://example.com/def"]'),
|
||||
'reference links must resolve across lifted HTML runs',
|
||||
).toHaveCount(1, { timeout: 30_000 })
|
||||
// TOC: the outline button appears (4 headings), opens the panel, and
|
||||
// jumping into the collapsed details expands it.
|
||||
const tocButton = sidebar.locator('[data-dsh-md-toc]')
|
||||
await expect(
|
||||
tocButton,
|
||||
'the TOC button must appear once the document has enough headings',
|
||||
).toHaveCount(1, { timeout: 30_000 })
|
||||
await tocButton.click()
|
||||
const tocPanel = sidebar.locator('[data-dsh-md-toc-panel]')
|
||||
await expect(tocPanel, 'the TOC panel must open').toHaveCount(1)
|
||||
const insideItem = tocPanel.locator('button', { hasText: 'Inside' })
|
||||
await expect(insideItem, 'the nested heading must appear in the outline').toHaveCount(1)
|
||||
await insideItem.click()
|
||||
await expect(
|
||||
details,
|
||||
'jumping into a collapsed details must expand it',
|
||||
).toHaveAttribute('open', '')
|
||||
await assertNoCrash()
|
||||
|
||||
// The plugin's own console prefix must never appear in errors, and no
|
||||
// unhandled rejection may escape the sweep.
|
||||
const pluginErrors = consoleErrors.filter((text) => /dsh-better-sidebar|Unhandled/.test(text))
|
||||
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
@@ -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)
|
||||
})
|
||||
})
|
||||
Reference in New Issue
Block a user