feat(mermaid): Markdown 预览安全渲染 Mermaid 图表(strict 清洗 + 懒加载 chunk + 点击放大) (#164)

* feat(mermaid): 新增 mermaid 懒加载 chunk(构建链四处同步 + mermaid 依赖)

tsdown CHUNKS / bundle-route CHUNK_NAMES / chunk-loader ChunkName /
package.json files 同步注册 mermaid chunk;mermaid ^11.16.1 进依赖与
lockfile;chunk 构建注入 uuid 浏览器入口别名,绕开 node:crypto 纯度门。

* feat(mermaid): Markdown 预览安全渲染 mermaid 图表 + 点击放大弹窗

- splitMermaidBlocks 纯函数按 fence 切块,无 mermaid 文件零行为变化;
- mermaid.initialize 走 securityLevel strict + htmlLabels:false,不应用
  bindFunctions(静态图);渲染 SVG 经 mermaid-sanitize 二次清洗:
  删 foreignObject/script/外来 HTML 元素、剥 @*/on* 属性、移除全部
  href/xlink:href,仅接受 SVG 根,解析失败返回空串;
- 点击图表弹出放大弹窗(滚轮缩放/拖拽平移/快捷键,借鉴 PR #75 交互,
  基于已清洗 SVG 克隆重写,不新增攻击面);
- 深浅色跟随重渲、失败回退错误条 + 原码、CodeBlock 同款 chrome。

* test(mermaid): splitter/sanitize 单测 + chunk 清单同步 + 挂载冒烟 diagram.md

- mermaid-blocks.spec 10 例 fence 切块语义;
- mermaid-sanitize.spec 8 例 XSS(foreignObject/on*/@*/href/xlink:href/
  script/外来元素/畸形 XML/非 SVG 根);
- bundle-route / chunk-artifact / manifest-consistency 清单加入 mermaid
  (chunk-artifact 补 addEventListener stub);
- 挂载冒烟新增 diagram.md:client-mermaid.js 往返、SVG 节点文字、
  点击弹窗 Esc 关闭、preview 模式编辑器隐藏。

* docs(mermaid): 安全渲染设计文档 + lazy-chunks 表 + README 双语更新

设计文档记录基线决策(PR #99 架构 + PR #75 点击放大)、安全模型
(strict/htmlLabels/清洗/href 剥离/无 bindFunctions)、验证与取舍。

* fix(mermaid): sanitizer 属性名大小写归一,堵住混合大小写属性绕过(CR P1)

XML 属性名保留大小写,而输出会经 HTML 解析归一化(dangerouslySetInnerHTML),
oNload/HREF/xlink:HREF 这类混合大小写属性此前会漏过清洗。比较前先
toLowerCase(),按原名删除;新增混合大小写事件/链接属性回归用例。

* fix(mermaid): sanitizer 元素黑名单大小写归一,堵住混合大小写元素绕过(CR P1)

XML 元素名保留大小写,querySelectorAll('script') 等选择器匹配不到
<sCrIpT>/<foreignobject>,而输出经 HTML 解析会被归一化为 SVG
script/foreignObject(可执行/可注入 HTML)。改为遍历全部元素按
localName.toLowerCase() 命中小写黑名单后整节点删除;新增混合大小写
script/foreignObject/外来 HTML 元素回归用例(jsdom 实证:旧选择器
命中 0,新逻辑命中 2)。

* fix(mermaid): 单次 Markdown 解析 + 占位交换(CR P1)+ CommonMark fence 规则(CR P2)

P1(跨 fence 语义):弃用逐块 MarkdownText 拆分,改为整篇文档单次
MarkdownText 渲染,布局后把 language-mermaid 代码块交换为 MermaidDiagram
(React 管理的 .md-code-block 宿主保留在树中、display:contents 抑制盒子,
协调安全;块不再 mermaid 时还原原 children)。新增
tests/mermaid-markdown.spec.tsx 与 e2e 跨 fence 引用链接断言。

P2(fence 匹配):支持 3+ 反引号与波浪号开 fence;闭 fence 须同字符且
长度不小于开 fence;反引号 info string 含反引号不视为开 fence;修复
带缩进开 fence 的 info 提取。blocks 单测 +3 例。
This commit is contained in:
Atlas Zeng
2026-08-18 01:22:28 +08:00
committed by GitHub
parent 9bea6c5539
commit 5e416eccef
23 changed files with 2131 additions and 25 deletions
+1 -1
View File
@@ -145,6 +145,6 @@ describe('/sidebar/bundle route', () => {
})
it('exports the chunk allowlist (mirror of src/client/chunk-loader.ts)', () => {
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor'])
expect([...CHUNK_NAMES]).toEqual(['terminal', 'editor', 'mermaid'])
})
})
+6 -1
View File
@@ -15,11 +15,16 @@ import { CHUNK_EXTERNALS } from '../src/client/chunk-loader.ts'
const g = globalThis as Record<string, unknown>
const CHUNKS = ['terminal', 'editor']
const CHUNKS = ['terminal', 'editor', 'mermaid']
describe('built chunk artifacts', () => {
it('each chunk assigns its global registry slot when executed as a script', () => {
g.window = g // classic-script globals
// mermaid's core hooks window.addEventListener('load') at module scope
// (its startOnLoad wiring); the Node global lacks the API, so stub it
// exactly like browser-globals.ts does for its window stub.
if (typeof g.addEventListener !== 'function') g.addEventListener = () => {}
if (typeof g.removeEventListener !== 'function') g.removeEventListener = () => {}
for (const name of CHUNKS) {
const code = readFileSync(`lib/client-${name}.js`, 'utf8')
// eslint-disable-next-line no-new-func
+81 -7
View File
@@ -15,11 +15,12 @@
* fail() strips, no `pageerror`, no plugin-prefixed console errors);
* 4. expands the collapsed panel (openByDefault defaults off), sweeps every
* built-in tab (Files / Source Control / Tasks / Terminal / Browser) —
* including the lazily-fetched terminal chunk — and then opens a seeded
* file through the Files window's tree (in-place mode: the seeded home
* tab itself switches to the file, no new tab), while a response wait
* armed before goto proves the lazily-fetched editor chunk
* (client-editor.js) loaded.
* including the lazily-fetched terminal chunk — and then opens seeded
* files through the Files window's tree (in-place mode: the seeded home
* tab itself switches to the file, no new tab), while response waits
* armed before goto prove the lazily-fetched editor chunk
* (client-editor.js) and the mermaid chunk (client-mermaid.js, rendered
* SVG diagram + zoom modal) loaded.
*
* Deterministic by construction: every wait is on a DOM/network marker, the
* suite is serial (one server instance), and any crash trips the very next
@@ -42,6 +43,11 @@ const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-
* exercise the file-open path (editor chunk = client-editor.js). */
const SEEDED_FILE = 'hello.txt'
/** A markdown file with a mermaid fence, opened through the Files window's
* tree to force the lazily-packed mermaid chunk (client-mermaid.js) to load
* and render a sanitized SVG diagram. */
const SEEDED_MD_FILE = 'diagram.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
@@ -54,11 +60,31 @@ const BUILTIN_TABS = ['Files', 'Source Control', 'Tasks', 'Terminal', 'Browser']
let api: APIRequestContext
/** Seed one workspace + one session (plus a file for the editor-chunk probe)
* through the host's unary RPC surface. */
/** Seed one workspace + one session (plus files for the editor/mermaid-chunk
* probes) through the host's unary RPC surface. */
async function seedSession(): Promise<void> {
mkdirSync(WORKSPACE_PATH, { recursive: true })
writeFileSync(join(WORKSPACE_PATH, SEEDED_FILE), 'hello from the mount lane\n')
// The mermaid-chunk probe file: a markdown doc whose preview must fetch
// client-mermaid.js and render the fence into an SVG diagram. The
// reference-style link's definition sits AFTER the fence: it only
// resolves when the preview is one single markdown parse (the mermaid
// path must not split the document into independent MarkdownText blocks).
writeFileSync(join(WORKSPACE_PATH, SEEDED_MD_FILE), [
'# Diagram',
'',
'[before][shared]',
'',
'```mermaid',
'graph TD',
' A[Hello] --> B[World]',
'```',
'',
'[shared]: https://example.com',
'',
'tail text',
'',
].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 } },
})
@@ -230,6 +256,54 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
await page.waitForTimeout(1_500)
await assertNoCrash()
// The mermaid chunk (client-mermaid.js) only loads when a previewed
// markdown file contains a mermaid fence. Open the seeded diagram file
// from the files window's tree (the embedded tree stays pinned while
// hello.txt is open) and require the full round-trip: chunk fetch +
// sanitized SVG diagram in the preview, so a missing/corrupt mermaid
// chunk or a broken render fails the lane.
const mermaidChunk = page.waitForResponse(
(response) => response.url().includes('/sidebar/bundle/mermaid.js'),
{ timeout: 30_000 },
)
const mdRow = sidebar.locator(`[role="button"][title$="${SEEDED_MD_FILE}"]:visible`)
await expect(mdRow, `the seeded "${SEEDED_MD_FILE}" file must appear in the files window's tree`).toHaveCount(1, { timeout: 30_000 })
await mdRow.click({ position: { x: 8, y: 8 } })
await mermaidChunk
await expect(
sidebar.locator('[data-mermaid-diagram] svg'),
'the mermaid fence must render into an SVG diagram in the markdown preview',
).toHaveCount(1, { timeout: 30_000 })
// Labels must survive as real SVG <text> (htmlLabels stays off so the
// sanitizer's foreignObject strip cannot eat the node text).
await expect(
sidebar.locator('[data-mermaid-diagram]').first(),
'the diagram node labels must render inside the SVG',
).toContainText('Hello', { timeout: 30_000 })
// Cross-fence semantics: the reference-style link [before][shared] must
// resolve to the definition that sits AFTER the fence — proof that the
// preview is a single markdown parse and not per-fence fragments.
await expect(
sidebar.locator('a[href="https://example.com"]').first(),
'reference-style links with definitions across a mermaid fence must resolve',
).toContainText('before', { timeout: 30_000 })
// Click-to-enlarge: clicking the diagram opens the zoom modal (portalled
// to document.body), Esc closes it again.
const modal = page.locator('[data-mermaid-modal]')
await sidebar.locator('[data-mermaid-diagram] svg').first().click()
await expect(modal, 'clicking the diagram must open the zoom modal').toHaveCount(1, { timeout: 10_000 })
await page.keyboard.press('Escape')
await expect(modal, 'Esc must close the zoom modal').toHaveCount(0, { timeout: 10_000 })
// The preview/edit toggle is mutually exclusive: in preview mode the
// CodeMirror surface must be hidden (regression guard — a stale css copy
// in the page made the editor stay visible under the preview, breaking
// the toggle semantics).
await expect(
sidebar.locator('.cm-editor').first(),
'preview mode must hide the CodeMirror editor (mutually exclusive toggle)',
).toBeHidden()
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))
+1 -1
View File
@@ -65,7 +65,7 @@ function bundleId(file: string): string {
}
/** The lazy chunk bundle names (mirror of src/bundle-route.ts CHUNK_NAMES). */
const CHUNK_FILES = ['terminal', 'editor'].map(name => `lib/client-${name}.js`)
const CHUNK_FILES = ['terminal', 'editor', 'mermaid'].map(name => `lib/client-${name}.js`)
/** The global registry slot a built chunk script assigns (its factory key). */
function chunkSlot(file: string): string {
+92
View File
@@ -0,0 +1,92 @@
/**
* Markdown/mermaid block splitting: the pure splitter the markdown preview
* uses to lift mermaid fences out of the MarkdownText stream. Covers fence
* recognition (case, attributes, indentation), non-mermaid fences staying
* untouched, interleaved ordering, and open-fence recovery.
*/
import { describe, expect, it } from 'vitest'
import { splitMermaidBlocks } from '../src/client/mermaid-blocks.ts'
describe('splitMermaidBlocks', () => {
it('leaves mermaid-free markdown as one markdown block', () => {
expect(splitMermaidBlocks('# hi\n\ntext')).toEqual([
{ kind: 'markdown', text: '# hi\n\ntext' },
])
})
it('lifts a mermaid fence into a diagram block with the raw source', () => {
const blocks = splitMermaidBlocks('before\n```mermaid\ngraph TD\n A-->B\n```\nafter')
expect(blocks).toEqual([
{ kind: 'markdown', text: 'before' },
{ kind: 'mermaid', code: 'graph TD\n A-->B' },
{ kind: 'markdown', text: 'after' },
])
})
it('is case-insensitive and accepts mermaid attribute info strings', () => {
const lower = splitMermaidBlocks('```Mermaid\na-->b\n```')
expect(lower[0]).toEqual({ kind: 'mermaid', code: 'a-->b' })
const attributed = splitMermaidBlocks("```mermaid{theme:'dark'}\na-->b\n```")
expect(attributed[0]).toEqual({ kind: 'mermaid', code: 'a-->b' })
})
it('allows up to 3 spaces of fence indent (CommonMark)', () => {
const blocks = splitMermaidBlocks(' ```mermaid\na-->b\n ```')
expect(blocks).toEqual([{ kind: 'mermaid', code: 'a-->b' }])
})
it('does not lift fences inside a 4-space indented code block', () => {
const source = ' ```mermaid\n a-->b\n ```'
expect(splitMermaidBlocks(source)).toEqual([{ kind: 'markdown', text: source }])
})
it('keeps non-mermaid fences in the markdown stream untouched', () => {
const source = '```ts\nconst a = 1\n```'
expect(splitMermaidBlocks(source)).toEqual([{ kind: 'markdown', text: source }])
})
it('interleaves multiple diagrams preserving source order', () => {
const blocks = splitMermaidBlocks(
'# t\n```mermaid\ngraph TD\n A\n```\nmid\n```mermaid\npie\n "a": 1\n```\ntail',
)
expect(blocks.map(block => block.kind)).toEqual(['markdown', 'mermaid', 'markdown', 'mermaid', 'markdown'])
expect(blocks[1]).toEqual({ kind: 'mermaid', code: 'graph TD\n A' })
expect(blocks[3]).toEqual({ kind: 'mermaid', code: 'pie\n "a": 1' })
})
it('handles an empty file and mermaid-free text with no markdown blocks', () => {
expect(splitMermaidBlocks('')).toEqual([])
expect(splitMermaidBlocks('a\n\nb')).toEqual([{ kind: 'markdown', text: 'a\n\nb' }])
})
it('swallows the rest of the file on an unterminated mermaid fence', () => {
const blocks = splitMermaidBlocks('```mermaid\ngraph TD\n A-->B')
expect(blocks).toEqual([{ kind: 'mermaid', code: 'graph TD\n A-->B' }])
})
it('treats an empty mermaid fence as an empty diagram block', () => {
const blocks = splitMermaidBlocks('```mermaid\n```')
expect(blocks).toEqual([{ kind: 'mermaid', code: '' }])
})
it('lifts four-backtick and tilde fences (CommonMark fence runs)', () => {
const backticks = splitMermaidBlocks('````mermaid\ngraph TD\n A\n````')
expect(backticks).toEqual([{ kind: 'mermaid', code: 'graph TD\n A' }])
const tildes = splitMermaidBlocks('~~~mermaid\ngraph TD\n A\n~~~')
expect(tildes).toEqual([{ kind: 'mermaid', code: 'graph TD\n A' }])
})
it('only a same-char, at-least-as-long fence closes the opening fence', () => {
// A shorter backtick run must not close a 4-backtick opening fence.
const blocks = splitMermaidBlocks('````mermaid\na\n```\nb\n````')
expect(blocks).toEqual([{ kind: 'mermaid', code: 'a\n```\nb' }])
// A different fence character must not close the fence either.
const mixed = splitMermaidBlocks('~~~mermaid\na\n```\n~~~')
expect(mixed).toEqual([{ kind: 'mermaid', code: 'a\n```' }])
})
it('ignores backticks inside a backtick fence info string (CommonMark)', () => {
const source = '```mermaid`graph\na-->b\n```'
expect(splitMermaidBlocks(source)).toEqual([{ kind: 'markdown', text: source }])
})
})
+84
View File
@@ -0,0 +1,84 @@
/**
* MermaidMarkdown architecture spec: the preview renders the WHOLE document
* through one MarkdownText pass (cross-fence reference-style links must
* resolve — the P1 regression from the CR), then swaps every rendered
* mermaid CodeBlock for a diagram. mermaid is mocked so the swap + semantics
* are asserted without pulling the real layout engine into jsdom.
*/
// @vitest-environment jsdom
import { describe, expect, it, vi, beforeEach } from 'vitest'
import { createElement } from 'react'
import { createRoot, type Root } from 'react-dom/client'
import { act } from 'react-dom/test-utils'
// The act() environment flag (React 18.2 reads it before flushing effects).
;(globalThis as Record<string, unknown>).IS_REACT_ACT_ENVIRONMENT = true
vi.mock('mermaid', () => ({
default: {
initialize: () => {},
render: async () => ({
svg: '<svg xmlns="http://www.w3.org/2000/svg"><text>MOCK-DIAGRAM</text></svg>',
}),
},
}))
import { MermaidMarkdown } from '../src/client/mermaid.tsx'
const codeLabels = { copyLabel: 'Copy', copiedLabel: 'Copied' }
async function renderMarkdown(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(MermaidMarkdown, { text, codeLabels }))
// Let the mocked mermaid.render promise 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('MermaidMarkdown', () => {
it('swaps the mermaid code block for a rendered diagram', async () => {
const { container, root } = await renderMarkdown('```mermaid\ngraph TD\n A-->B\n```')
const diagram = container.querySelector('[data-mermaid-diagram] svg')
expect(diagram, 'the mermaid fence must be swapped for a diagram').not.toBeNull()
expect(diagram?.textContent).toContain('MOCK-DIAGRAM')
await unmount(root)
})
it('resolves cross-fence reference-style links (single markdown parse)', async () => {
const text = [
'[before][shared]',
'',
'```mermaid',
'graph TD',
' A --> B',
'```',
'',
'[shared]: https://example.com',
].join('\n')
const { container, root } = await renderMarkdown(text)
const link = container.querySelector('a[href="https://example.com"]')
expect(link, 'the definition after the fence must resolve the link before it').not.toBeNull()
expect(link?.textContent).toContain('before')
expect(container.querySelector('[data-mermaid-diagram] svg'), 'the diagram must still swap in').not.toBeNull()
await unmount(root)
})
it('leaves non-mermaid fences untouched', async () => {
const { container, root } = await renderMarkdown('```ts\nconst a = 1\n```')
expect(container.querySelector('[data-mermaid-diagram]'), 'no mermaid fence → no swap').toBeNull()
expect(container.querySelectorAll('.md-code-block').length, 'the ts fence stays a code block').toBe(1)
await unmount(root)
})
})
+116
View File
@@ -0,0 +1,116 @@
/**
* Mermaid SVG sanitizer security spec: the rendered diagram must never
* carry an executable surface into the markdown preview — foreignObject
* (the raw-HTML channel) is stripped wholesale, event/`@*` attributes are
* removed, href/xlink:href are removed, script elements are removed, and a
* malformed/foreign document is rejected entirely (empty string) rather
* than passed through.
*/
// @vitest-environment jsdom
import { describe, expect, it } from 'vitest'
import { sanitizeSvg } from '../src/client/mermaid-sanitize.ts'
describe('sanitizeSvg', () => {
it('keeps a benign diagram (svg + text nodes)', () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><text>Hello</text></svg>'
const out = sanitizeSvg(svg)
expect(out).toContain('<text>Hello</text>')
expect(out).not.toContain('foreignObject')
})
it('strips foreignObject wholesale (the raw-HTML channel)', () => {
const svg = [
'<svg xmlns="http://www.w3.org/2000/svg">',
'<foreignObject><div><img src="x" onerror="alert(1)"/></div></foreignObject>',
'<text>keep</text>',
'</svg>',
].join('')
const out = sanitizeSvg(svg)
expect(out).not.toContain('foreignObject')
expect(out).not.toContain('<img')
expect(out).not.toContain('onerror')
expect(out).toContain('<text>keep</text>')
})
it('strips on* and @* attributes from every element', () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg" onload="alert(1)"><text onclick="x()" @click="y()">t</text></svg>'
const out = sanitizeSvg(svg)
expect(out).not.toContain('onload')
expect(out).not.toContain('onclick')
expect(out).not.toContain('@click')
})
it('strips mixed-case event/link attributes (HTML parsing normalizes case)', () => {
const svg = [
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">',
'<text oNload="alert(1)" OnClick="x()">t</text>',
'<a HREF="javascript:alert(1)" xlink:HREF="data:text/html,<script>1</script>">x</a>',
'<text @Click="y()">u</text>',
'</svg>',
].join('')
const out = sanitizeSvg(svg)
expect(out).not.toContain('oNload')
expect(out).not.toContain('OnClick')
expect(out).not.toContain('@Click')
expect(out).not.toContain('HREF')
expect(out).not.toContain('xlink:HREF')
expect(out).not.toContain('javascript:')
})
it('removes href and xlink:href regardless of scheme', () => {
const svg = [
'<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">',
'<a href="javascript:alert(1)" xlink:href="data:text/html,<script>1</script>">x</a>',
'<a href="https://example.com">y</a>',
'</svg>',
].join('')
const out = sanitizeSvg(svg)
expect(out).not.toContain('href')
expect(out).not.toContain('javascript:')
expect(out).not.toContain('data:text/html')
})
it('removes script elements', () => {
const svg = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script><text>t</text></svg>'
const out = sanitizeSvg(svg)
expect(out).not.toContain('script')
expect(out).toContain('<text>t</text>')
})
it('strips mixed-case element names (HTML parsing normalizes tag casing)', () => {
const svg = [
'<svg xmlns="http://www.w3.org/2000/svg">',
'<sCrIpT>alert(1)</sCrIpT>',
'<foreignobject><div><img src="x" onerror="alert(1)"/></div></foreignobject>',
'<ImG src="y" onerror="alert(2)"/>',
'<iFrAmE src="https://evil.test"/>',
'<text>keep</text>',
'</svg>',
].join('')
const out = sanitizeSvg(svg)
expect(out).not.toContain('sCrIpT')
expect(out).not.toContain('alert')
expect(out).not.toContain('foreignobject')
expect(out).not.toContain('<img')
expect(out).not.toContain('<ImG')
expect(out).not.toContain('iFrAmE')
expect(out).not.toContain('onerror')
expect(out).toContain('<text>keep</text>')
})
it('rejects malformed XML instead of passing it through', () => {
const out = sanitizeSvg('<svg xmlns="http://www.w3.org/2000/svg"><text>oops</svg>')
expect(out).toBe('')
})
it('strips foreign HTML elements smuggled past lenient parsers', () => {
const out = sanitizeSvg('<svg xmlns="http://www.w3.org/2000/svg"><img src="x" onerror="alert(1)"/></svg>')
expect(out).not.toContain('<img')
expect(out).not.toContain('onerror')
})
it('rejects a non-svg document', () => {
const out = sanitizeSvg('<html><body onload="alert(1)">hi</body></html>')
expect(out).toBe('')
})
})