Files
apes-sidebar/tests/md-toc.spec.tsx
T
Menghuan1918 7f634866bf 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
2026-08-24 12:05:14 +08:00

153 lines
5.9 KiB
TypeScript

/**
* 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)
})
})