Revert "feat(web): preview Mermaid, Graphviz, SVG, and HTML code fences"

This commit is contained in:
Tianyi Cui authored and GitHub committed 2026-09-09 22:01:10 +08:00
1 parent 83253e0d16
commit 58956c1a8a
43 files changed
+20 -2346

No files matched your search

@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/bug-fix/2026-09-09-content-sized-diagram-previews.md
2026-09-09-content-sized-diagram-previews.md: ceb29238eec09f2dde312835dbf376855998a507
2026-09-09-content-sized-diagram-previews.zh.md: 2689602bfe3469f53e532367877f7e49d52d3d5d
@@ -1,25 +0,0 @@
# Agent Note: Content-sized diagram previews
Status: implemented
English | [中文](2026-09-09-content-sized-diagram-previews.zh.md)
## Problem
Short Graphviz and SVG images leave large empty regions when displayed inside 400px frames. A 59px Graphviz diagram occupies a 400px preview even though its rendered dimensions are already available to the browser.
## Decision
Mermaid, Graphviz, and SVG share the inert image canvas in `SourcePreview`. The browser derives its height from the image, preserves aspect ratio when shrinking to the available width, and adds 16px padding. Source switching retains the image and copying reads the original code. Theme changes regenerate Graphviz colors without introducing a separate sizing observer.
This replaces the diagram-frame choice in [Static Markdown fence previews](../feature/2026-09-09-markdown-static-previews.md); that note continues to own HTML isolation, rendering lifecycle, and license obligations. HTML retains its opaque, script-free, 400px frame. SVG image mode disables scripts, link interaction, and external resource loading without inserting source-controlled markup into the application document.
## Alternatives considered
**Set iframe height to auto.** An iframe does not derive its outer height from its inner document. This still reserves an unrelated viewport.
**Add a frame measurement script or same-origin access.** A diagram already has image dimensions. Extra execution or origin permissions and resize messaging are unnecessary for that content.
## Consequences
Short diagrams occupy only their rendered height and canvas padding. The browser geometry regression compares image and container height in light and dark modes, then verifies proportional scaling in a narrow viewport. Browser security checks include SVG scripts, event handlers, nested HTML, and external images; copy and source toggling remain covered. HTML automatic height remains outside this change because arbitrary document layout has different measurement and isolation requirements.
@@ -1,25 +0,0 @@
# Agent Note: 按内容定高的图表预览
Status: implemented
[English](2026-09-09-content-sized-diagram-previews.md) | 中文
## Problem
较矮的 Graphviz 与 SVG 图片显示在 400px iframe 中时,会留下大块空白。浏览器已经知道图片的渲染尺寸,但一张 59px 高的 Graphviz 图表仍占据 400px 的预览区域。
## Decision
Mermaid、Graphviz 与 SVG 共享 `SourcePreview` 的不可执行图片画布。浏览器根据图片决定高度,在可用宽度不足时按比例缩小,并添加 16px 内边距。源码切换保留图片,复制读取原始代码。主题变化重新生成 Graphviz 配色,无需单独的尺寸观察器。
这替代了[静态 Markdown fence 预览](../feature/2026-09-09-markdown-static-previews.zh.md)中的图表 iframe 选择;该记录继续维护 HTML 隔离、渲染生命周期与许可证义务。HTML 保留不透明来源、禁用脚本的 400px iframe。SVG 图片模式禁用脚本、链接交互与外部资源加载,无需把源码控制的标记插入应用文档。
## Alternatives considered
**把 iframe 高度设为 auto。** iframe 不会根据内部文档决定外部高度,仍然会保留与内容无关的视口。
**添加 iframe 测量脚本或同源访问。** 图表已经具备图片尺寸,不需要额外执行权限、来源权限或尺寸消息协议。
## Consequences
较矮图表只占据渲染高度与画布内边距。浏览器几何回归用例在浅色和深色下比较图片与容器高度,再验证窄视口中的等比例缩放。浏览器安全检查包含 SVG 脚本、事件处理器、嵌套 HTML 与外部图片,并保留复制和源码切换覆盖。HTML 自动高度不属于本次改动,因为任意文档布局具有不同的测量与隔离要求。
@@ -1,6 +0,0 @@
# Bilingual-pair consistency record (docs/i18n/README.md): the git blob hash of each
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write .agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md
2026-09-09-markdown-static-previews.md: f3a0363e3d7c4cb19ca67dfc5011e936f7c024cf
2026-09-09-markdown-static-previews.zh.md: 24f87cd73504c1fbd274e1420a3692b3ca55ed5e
@@ -1,43 +0,0 @@
# Agent Note: Static Markdown fence previews
Status: implemented
English | [中文](2026-09-09-markdown-static-previews.zh.md)
## Problem
Readers need to see Mermaid and DOT diagrams, SVG artwork, and HTML examples directly in Assistant replies without interpreting source or copying it to another renderer. These sources are untrusted, and a renderer's npm license field may omit compiled components with separate distribution obligations.
## Decision
The shared Markdown renderer enables settled `mermaid`, `graphviz`/`dot`, `svg`, and `html` fences through localized `MarkdownLabels.preview` and the parsed fence language. The [UI primitives package](../../../../packages/client/ui-primitives/README.md) owns `SourcePreview`, which accepts a renderer, source, and labels without Session, file, or Cordis dependencies. Mermaid and Graphviz each supply a `.ts` renderer to that same component. Consumers without preview labels and streaming messages retain code.
`CodeBlock.preview` owns default visualization, source switching, and verbatim copying. Previews omit the language banner and expose compact icon actions on hover or keyboard focus; devices with any touch input keep actions visible below the diagram. Switching to source hides the mounted preview, preserving completed rendering and pending work. Both views use the same focused toggle button, and copying always reads the source prop.
`SourcePreview` owns loading, failure, and cancellation of stale result publication. Source replacement and unmounting cancel publication; cancellation before runtime loading completes skips layout. Failures show a localized error and the original source, and replacing invalid source with valid input recovers the preview.
Mermaid loads on demand. A shared queue serializes theme initialization with diagram work, and each call removes its temporary measurement DOM in `finally`. Strict security, disabled HTML labels, the application palette, and error-rendering policy cannot be overridden by diagram configuration. Generated SVG is displayed as an image without installing links or scripts. Intrinsic dimensions come from the SVG viewBox; large diagrams shrink to fit, and the canvas follows the code-block background. Mounted previews observe document theme attributes and regenerate only when resolved colors change; obsolete renders cannot publish. Graphviz default colors follow the same palette, while authored DOT, SVG, and HTML colors remain intact.
HTML uses DOMPurify with navigation attributes and document-loading elements forbidden, followed by an opaque `sandbox=""` iframe. A trusted CSP precedes source markup and permits only inline CSS, data images, and data fonts. Scripts, external resources, child frames, form submission, and same-origin access are unavailable. SVG is parsed as XML and displayed with Graphviz output as inert images in a [content-sized canvas](../bug-fix/2026-09-09-content-sized-diagram-previews.md), so SVG scripts and links never become active. HTML frames have a fixed scrollable viewport: measuring their content would require additional origin access or trusted frame scripts.
Graphviz uses the pinned, unmodified `@viz-js/viz` 3.30.0 WebAssembly distribution with the `dot` layout engine. Its npm MIT declaration covers the wrapper; its build provenance identifies Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 (MIT/NCSA). Distribution retains these component terms and the exact Graphviz source download under EPL-2.0 section 3.1. [Full preview notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) also retain Mermaid's MIT text and select Apache-2.0 for DOMPurify. The UI primitives package ships the notices and the Web build emits the same bytes alongside its assets. License review covers embedded payloads separately from the general permissive npm-metadata policy.
## Alternatives considered
**Keep the code banner above diagrams.** A language label and permanent text controls distract from the diagram. Overlay actions retain source access without reserving a title row.
**Render each streamed chunk.** Incomplete diagrams are frequently invalid, and repeated layout work competes with text streaming. Message settlement provides complete source.
**Put rendering inside Chat or add a general preview registry.** A shared primitive with plain props satisfies reuse without another registry or feature-plugin dependency. This follows the [shared-control rule](../architecture/2026-09-05-shared-client-control-primitives.md).
**Execute HTML scripts in a frame.** Static chat examples do not need script execution. An empty sandbox plus sanitization and CSP gives the preview fewer capabilities and avoids introducing a frame messaging protocol.
**Insert rendered markup into Chat.** The requested preview needs diagram display and source access; HTML styles and executable SVG would share the application document. Iframes isolate layout and origin; SVG image mode additionally disables SVG behavior.
**Treat Viz.js as MIT-only or use a remote Graphviz service.** The compiled Graphviz license still applies locally; a remote renderer would send conversation content off-device. The bundled renderer retains source availability and legal notices without network rendering.
## Consequences
The feature changes presentation without changing persisted messages, provider requests, tools, or Host APIs. Inline HTML styles work, while interactive scripts, external assets, and links do not. Mermaid and Graphviz add lazy browser assets and run layout on the browser thread. Cancellation cannot preempt active layout; Mermaid finishes and releases its measurement DOM even when its result cannot be published. The previews have no editing, export, zoom controls, or interactive diagram links.
Component tests cover source copying, default preview, delayed completion, stale success and failure, unmounting, fallback, cancellation, runtime loading failure, and recovery. The keyless [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) verifies Chinese Mermaid flowcharts, sequence diagrams, malformed source, configuration overrides, opaque frames, real image decoding, source toggling, blocked script/navigation/resource attempts, English/Chinese UI snapshots, and served license text. Notices checks pin the reviewed wrapper and native provenance so upgrades require renewed review.
@@ -1,43 +0,0 @@
# Agent Note: 静态 Markdown fence 预览
Status: implemented
[English](2026-09-09-markdown-static-previews.md) | 中文
## Problem
读者需要直接在 Assistant 回复中查看 Mermaid 和 DOT 图表、SVG 图像和 HTML 示例,而不必解读源码或复制到其他渲染器。这些源码不可信,而渲染器的 npm 许可证字段可能遗漏编译组件各自的分发义务。
## Decision
共享 Markdown 渲染器通过本地化的 `MarkdownLabels.preview` 和解析后的 fence 语言启用定稿后的 `mermaid`、`graphviz`/`dot`、`svg` 和 `html` fence。[UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `SourcePreview`,它接收渲染器、源码和文案,不依赖 Session、文件或 Cordis。Mermaid 与 Graphviz 各自向同一组件提供 `.ts` 渲染器。未提供预览文案的调用方与流式消息保留代码显示。
`CodeBlock.preview` 负责默认可视化、源码切换与原样复制。预览省略语言横幅,在悬停或键盘聚焦时显示紧凑的图标操作;具有任何触控输入的设备始终在图表下方显示操作。切换到源码会隐藏已挂载的预览,保留完成的渲染和待完成的工作。两种视图使用同一个保持焦点的切换按钮,复制始终读取源码属性。
`SourcePreview` 负责加载、失败和取消过期结果发布。替换源码和卸载组件会取消结果发布;在运行时加载完成前取消会跳过布局。失败时显示本地化错误与原始源码,用有效输入替换无效源码后可以恢复预览。
Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。已挂载的预览观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT、SVG 和 HTML 中明确指定的颜色保持原样。
HTML 先由 DOMPurify 清理,禁止导航属性与文档加载元素,再进入不透明来源的 `sandbox=""` iframe。可信 CSP 位于源码标记之前,仅允许内联 CSS、data 图片与 data 字体。脚本、外部资源、子 iframe、表单提交与同源访问均不可用。SVG 按 XML 解析后,与 Graphviz 输出一同作为不可执行图片显示在[按内容定高的画布](../bug-fix/2026-09-09-content-sized-diagram-previews.zh.md)中,因此 SVG 脚本与链接不会激活。HTML iframe 使用固定的可滚动视口:测量内容需要额外的来源访问权限或可信 iframe 脚本。
Graphviz 使用固定且未修改的 `@viz-js/viz` 3.30.0 WebAssembly 发布包,布局引擎为 `dot`。其 npm MIT 声明覆盖包装层;构建来源记录标明 Graphviz 16.0.0(EPL-2.0)、Expat 2.8.4(MIT)与 Emscripten 5.0.7(MIT/NCSA)。分发保留各组件条款,并按 EPL-2.0 第 3.1 节提供准确的 Graphviz 源码下载地址。[完整预览声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)还保留 Mermaid 的 MIT 文本,并为 DOMPurify 选择 Apache-2.0。UI primitives 包携带该声明,Web 构建在资源旁输出相同字节。内嵌产物的许可证检查独立于通用的宽松 npm 元数据策略。
## Alternatives considered
**在图表上方保留代码横幅。** 语言标签和常驻文字控件会分散对图表的注意力。浮层操作保留源码访问,不占用标题行。
**渲染每个流式片段。** 未完成的图表通常无效,反复布局会与文本流式输出争用资源。消息定稿可提供完整源码。
**在 Chat 内部渲染,或添加通用预览注册表。** 使用普通属性的共享基础组件即可满足复用,无需另加注册表或功能插件依赖。这遵循[共享控件规则](../architecture/2026-09-05-shared-client-control-primitives.zh.md)。
**在 iframe 内执行 HTML 脚本。** 静态聊天示例不需要脚本执行。空 sandbox、清理与 CSP 降低预览权限,也避免引入 iframe 消息协议。
**把渲染标记插入 Chat。** 所需预览只需显示图表和访问源码;HTML 样式和可执行 SVG 会与应用共享文档。iframe 隔离布局与来源,SVG 图片模式额外禁用 SVG 行为。
**将 Viz.js 视为仅使用 MIT,或使用远程 Graphviz 服务。** 本地编译的 Graphviz 仍受其许可证约束;远程渲染会把对话内容发送到设备之外。内置渲染器保留源码可获取性与法律声明,无需网络渲染。
## Consequences
该功能改变呈现,不改变持久化消息、provider 请求、工具或 Host API。HTML 内联样式可用,交互脚本、外部资源和链接不可用。Mermaid 和 Graphviz 增加按需加载的浏览器资源,并在浏览器线程上执行布局。取消无法抢占进行中的布局;即使结果无法发布,Mermaid 仍会完成并释放测量 DOM。预览不提供编辑、导出、缩放控件或交互式图表链接。
组件测试覆盖源码复制、默认预览、延迟完成、过期成功与失败、卸载、回退、取消、运行时加载失败与恢复。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证中文 Mermaid 流程图、时序图、无效源码、配置覆盖、不透明来源 iframe、真实图片解码、源码切换、脚本/导航/资源请求被阻止、中英文 UI 快照和已提供的许可证文本。声明检查固定已审查的包装层与原生构建来源,升级时须重新审查。
+1 -5
View File
@@ -55,7 +55,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`@shikijs/langs`](https://github.com/shikijs/shiki) | MIT |
| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
| [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
| [`@viz-js/viz`](https://github.com/mdaines/viz-js) | MIT |
| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
| [`@xterm/headless`](https://github.com/xtermjs/xterm.js) | MIT |
| [`@yarnpkg/parsers`](https://github.com/yarnpkg/berry) | BSD-2-Clause |
@@ -67,7 +66,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`commander`](https://github.com/tj/commander.js) | MIT |
| [`compression`](https://github.com/expressjs/compression) | MIT |
| [`diff`](https://github.com/kpdecker/jsdiff) | BSD-3-Clause |
| [`dompurify`](https://github.com/cure53/DOMPurify) | (MPL-2.0 OR Apache-2.0) |
| [`e2b`](https://github.com/e2b-dev/e2b) | MIT |
| [`electron-updater`](https://github.com/electron-userland/electron-builder) | MIT |
| [`eventsource-parser`](https://github.com/rexxars/eventsource-parser) | MIT |
@@ -81,7 +79,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`mdast-util-from-markdown`](https://github.com/syntax-tree/mdast-util-from-markdown) | MIT |
| [`mdast-util-gfm`](https://github.com/syntax-tree/mdast-util-gfm) | MIT |
| [`mdast-util-math`](https://github.com/syntax-tree/mdast-util-math) | MIT |
| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
| [`micromark-core-commonmark`](https://github.com/micromark/micromark/tree/main/packages/micromark-core-commonmark) | MIT |
| [`micromark-extension-gfm`](https://github.com/micromark/micromark-extension-gfm) | MIT |
| [`micromark-extension-math`](https://github.com/micromark/micromark-extension-math) | MIT |
@@ -115,8 +112,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`zod`](https://github.com/colinhacks/zod) | MIT |
| [`zustand`](https://github.com/pmndrs/zustand) | MIT |
The Markdown preview distribution also contains Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 runtime code (MIT/NCSA) inside `@viz-js/viz` 3.30.0. The wrapper's MIT metadata does not relicense these components. [Preview notices](packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) preserve their full license texts and Graphviz source availability, together with Mermaid's MIT and DOMPurify's selected Apache-2.0 terms. The UI primitives npm package includes this file; the Web build emits it as `preview-third-party-notices.txt`.
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
- `@yao-pkg/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
@@ -194,6 +189,7 @@ External packages **directly declared** for development, tests, types, or toolin
| [`jsdom`](https://github.com/jsdom/jsdom) | MIT |
| [`lefthook`](https://github.com/evilmartians/lefthook) | MIT |
| [`lightningcss`](https://github.com/parcel-bundler/lightningcss) | MPL-2.0 |
| [`mermaid`](https://github.com/mermaid-js/mermaid) | MIT |
| [`micromark-util-types`](https://github.com/micromark/micromark/tree/main/packages/micromark-util-types) | MIT |
| [`msgpackr`](http://github.com/kriszyp/msgpackr) | MIT |
| [`oxlint`](https://github.com/oxc-project/oxc) | MIT |
@@ -1,24 +0,0 @@
- heading "Mermaid previews" [level=1]
- button "Source"
- button "Copy"
- tooltip "Copy"
- img "Mermaid diagram"
- button "Source"
- button "Copy"
- img "Mermaid diagram"
- button "Source"
- button "Copy"
- status: Unable to render this diagram. The source is shown below.
- code: flowchart LR A[unfinished
- button "Source"
- button "Copy"
- img "Mermaid diagram"
- button "Source"
- button "Copy"
- img "Graphviz diagram"
- button "Source"
- button "Copy"
- img "SVG preview"
- button "Source"
- button "Copy"
- iframe
@@ -1,23 +0,0 @@
- heading "Mermaid previews" [level=1]
- button "源码"
- button "复制"
- img "Mermaid 图表"
- button "源码"
- button "复制"
- img "Mermaid 图表"
- button "源码"
- button "复制"
- status: 无法渲染此图表,源码如下。
- code: flowchart LR A[unfinished
- button "源码"
- button "复制"
- img "Mermaid 图表"
- button "源码"
- button "复制"
- img "Graphviz 图表"
- button "源码"
- button "复制"
- img "SVG 预览"
- button "源码"
- button "复制"
- iframe
-320
View File
@@ -1,320 +0,0 @@
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Page } from 'playwright'
import { chromium } from 'playwright'
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
import { createMessage, createUserMessage } from '@deepseek-ai/dsh-llm'
import { SESSION_FORMAT_VERSION, Session, SessionId } from '@deepseek-ai/dsh-session'
import type {} from '@deepseek-ai/dsh-session-title'
import {
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, launchWebScaffold,
seedSession, watchConsole, webSnapshotMode, type WebScaffold,
} from './scaffold.ts'
import { newEnglishPage, saveFailureShot } from './support.ts'
const SNAPSHOT_DIR = fileURLToPath(new URL('./expected/markdown-mermaid', import.meta.url))
const MODE = webSnapshotMode()
const SEED_ID = 'markdown-mermaid-web-e2e'
const DIAGRAM_MESSAGE = '[class*="markdown"]:has(.md-code-block)'
const FLOW = 'flowchart LR\n A[输入] --> B[共享渲染器] --> C[图形预览]'
const SEQUENCE = 'sequenceDiagram\n participant U as User\n participant R as Renderer\n U->>R: Mermaid source\n R-->>U: Diagram'
const INVALID = 'flowchart LR\n A[unfinished'
const UNTRUSTED = [
'%%{init: {"securityLevel":"loose","htmlLabels":true,"themeCSS":"body {display:none!important}"}}%%',
'flowchart LR',
' A["<img src=x onerror=alert(1)>"] --> B[Safe]',
' click B "javascript:alert(1)"',
].join('\n')
const DOT = 'digraph { rankdir=LR; Input -> Preview }'
const SVG = `<svg xmlns="http://www.w3.org/2000/svg" width="460" height="100" onload="parent.document.body.dataset.previewEscaped='yes'">
<rect width="460" height="100" fill="lightblue"/><text x="20" y="55">SVG preview</text>
<script>parent.document.body.dataset.previewEscaped='yes';alert('unsafe SVG')</script>
<image href="https://preview.invalid/svg-image" width="1" height="1"/>
<foreignObject width="1" height="1"><div xmlns="http://www.w3.org/1999/xhtml"><script>alert('unsafe HTML')</script></div></foreignObject>
</svg>`
const HTML = `<style>h2 { color: green }</style><h2>Static HTML</h2>
<script>parent.document.body.dataset.previewEscaped = 'yes'; alert('unsafe')</script>
<meta http-equiv="refresh" content="0;url=https://preview.invalid/refresh">
<a href="https://preview.invalid/navigate">Disabled navigation</a>
<img src="https://preview.invalid/image" onerror="alert('unsafe')">
<iframe src="https://preview.invalid/frame"></iframe>`
function fixture(): string {
const session = Session.create(SessionId('markdown-mermaid-source'))
session.append('turn/start', { turn: 1 })
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Preview these Mermaid diagrams.' }], source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', { title: 'Mermaid previews', messageSeqs: [user.seq], source: { kind: 'fallback' } })
session.append('step/start', { turn: 1, step: 1 })
session.append('assistant/message', {
stream: [], turn: 1, step: 1,
message: createMessage({
role: 'assistant',
content: [{ type: 'text', text: [
'# Mermaid previews',
...[FLOW, SEQUENCE, INVALID, UNTRUSTED].map(code => `\`\`\`mermaid\n${code}\n\`\`\``),
...[['dot', DOT], ['svg', SVG], ['html', HTML]].map(([lang, code]) => `\`\`\`${lang}\n${code}\n\`\`\``),
].join('\n\n') }],
source: { kind: 'model', provider: 'fixture', model: 'fixture' },
}),
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
const noon = new Date().setHours(12, 0, 0, 0)
return [
JSON.stringify({
type: 'session', version: SESSION_FORMAT_VERSION, id: '{{sessionId}}', createdAt: 0,
cwd: '{{cwd}}', isSeeded: false, delegationDepth: 0,
}),
...session.snapshotEvents().map(event => JSON.stringify({ ...event, time: noon + event.seq * 1000 })),
'',
].join('\n')
}
async function openConversation(page: Page, scaffold: WebScaffold): Promise<void> {
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
await page.getByRole('treeitem').first().click({ timeout: 30_000 })
await page.getByRole('treeitem').nth(1).click()
await page.getByRole('heading', { name: 'Mermaid previews' }).waitFor()
}
describe('web e2e: Mermaid chat previews', () => {
let scaffold: WebScaffold
let browser: Browser
beforeAll(async () => {
scaffold = await launchWebScaffold({})
await seedSession(scaffold, fixture(), SEED_ID)
browser = await chromium.launch()
}, 120_000)
afterAll(async () => {
await browser?.close()
await scaffold?.close()
})
it.skipIf(MODE === 'record').each(['light', 'dark'] as const)(
'tightly wraps short diagram previews in %s mode', async (scheme) => {
const page = await newEnglishPage(browser)
try {
await page.emulateMedia({ colorScheme: scheme })
await openConversation(page, scaffold)
await expect.poll(() => page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe(scheme)
const mermaid = page.getByRole('img', { name: 'Mermaid diagram' }).first()
await mermaid.evaluate(async (node: HTMLImageElement) => { await node.decode() })
const mermaidHeight = await mermaid.evaluate(node => node.getBoundingClientRect().height)
const canvasHeight = await mermaid.evaluate(node => node.parentElement!.getBoundingClientRect().height)
// Compact diagrams need at most the Mermaid canvas's 16px padding on each side.
expect(canvasHeight - mermaidHeight).toBeLessThanOrEqual(32)
for (const title of ['Graphviz diagram', 'SVG preview']) {
const image = page.getByRole('img', { name: title, exact: true })
await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
const imageHeight = await image.evaluate(node => node.getBoundingClientRect().height)
const containerHeight = await image.evaluate(node => node.parentElement!.getBoundingClientRect().height)
expect(imageHeight).toBeGreaterThan(0)
expect(containerHeight).toBeGreaterThanOrEqual(imageHeight)
expect.soft(containerHeight - imageHeight,
`${scheme} ${title}: ${containerHeight}px container around ${imageHeight}px content`,
).toBeLessThanOrEqual(32)
}
await page.setViewportSize({ width: 360, height: 800 })
const svg = page.getByRole('img', { name: 'SVG preview', exact: true })
await expect.poll(() => svg.evaluate((node) => {
const { width, height } = node.getBoundingClientRect()
return width > 0 && height > 0 && width < 460
})).toBe(true)
const compact = await svg.evaluate((node: HTMLImageElement) => ({
width: node.getBoundingClientRect().width,
height: node.getBoundingClientRect().height,
canvasHeight: node.parentElement!.getBoundingClientRect().height,
ratio: node.naturalWidth / node.naturalHeight,
}))
expect(compact.width / compact.height).toBeCloseTo(compact.ratio, 1)
expect(compact.canvasHeight - compact.height).toBeLessThanOrEqual(32)
} finally {
await page.close()
}
},
)
it.skipIf(MODE === 'record')('updates existing diagrams when the document switches between light and dark', async () => {
const page = await newEnglishPage(browser)
try {
await openConversation(page, scaffold)
const image = page.getByRole('img', { name: 'Mermaid diagram' }).first()
await image.waitFor()
let previous = await image.getAttribute('src')
for (const scheme of ['dark', 'light'] as const) {
await page.evaluate((value) => {
document.documentElement.style.colorScheme = value
document.body.toggleAttribute('data-ds-dark-theme', value === 'dark')
}, scheme)
await expect.poll(() => image.getAttribute('src')).not.toBe(previous)
await expect.poll(() => image.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
const colors = await image.evaluate((node) => {
const reference = document.createElement('span')
reference.style.backgroundColor = 'var(--dsw-alias-markdown-code-block)'
document.body.append(reference)
try {
return {
canvas: getComputedStyle(node.parentElement!).backgroundColor,
expected: getComputedStyle(reference).backgroundColor,
}
} finally {
reference.remove()
}
})
expect(colors.canvas).toBe(colors.expected)
await expect.poll(() => page.getByTitle('HTML preview', { exact: true }).getAttribute('srcdoc'))
.toContain(`color-scheme:${scheme}`)
previous = await image.getAttribute('src')
}
} finally {
await page.close()
}
})
it.skipIf(MODE === 'record')('renders diagrams, switches to source, copies source, and contains malformed content', async () => {
const page = await newEnglishPage(browser)
onTestFailed(() => saveFailureShot(page, 'web-e2e-markdown-mermaid'))
const tripwire = watchConsole(page)
const dialogs: string[] = []
page.on('dialog', (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss() })
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await openConversation(page, scaffold)
const images = page.getByRole('img', { name: 'Mermaid diagram' })
await expect.poll(() => images.count(), { timeout: 20_000 }).toBe(3)
await expect.poll(() => images.evaluateAll(nodes => nodes.every(node => (node as HTMLImageElement).naturalWidth > 0))).toBe(true)
expect(await images.evaluateAll(nodes => nodes.every(node =>
node.getBoundingClientRect().width <= (node as HTMLImageElement).naturalWidth))).toBe(true)
expect(await page.getByText('Unable to render this diagram. The source is shown below.', { exact: true }).count()).toBe(1)
expect(await page.locator('pre code').allTextContents()).toContain(INVALID)
expect(await images.first().evaluate(node => decodeURIComponent((node as HTMLImageElement).src))).toContain('共享渲染器')
const first = page.locator('.md-code-block').first()
const diagram = await first.getByRole('img').elementHandle()
const controls = first.locator('[class*="bannerWrap"]')
expect(await first.getByText('mermaid', { exact: true }).count()).toBe(0)
await page.mouse.move(0, 0)
expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('0')
await first.hover()
expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
await first.getByRole('button', { name: 'Copy', exact: true }).click()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(FLOW)
await page.mouse.move(0, 0)
await first.getByRole('button', { name: 'Source', exact: true }).focus()
expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
await page.keyboard.press('Enter')
expect(await first.getByRole('button', { name: 'Preview', exact: true })
.evaluate(node => node === document.activeElement)).toBe(true)
expect(await first.locator('pre code').textContent()).toBe(FLOW)
expect(await diagram!.evaluate(node => node.isConnected)).toBe(true)
expect(await diagram!.isVisible()).toBe(false)
await first.getByRole('button', { name: 'Preview', exact: true }).click()
expect(await diagram!.isVisible()).toBe(true)
await first.getByRole('button', { name: 'Copy', exact: true }).waitFor()
expect(await page.locator('body').evaluate(node => getComputedStyle(node).display)).not.toBe('none')
expect(await page.locator('[id^="dsh-mermaid-"]').count()).toBe(0)
expect(dialogs).toEqual([])
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await page.getByRole('heading', { name: 'Mermaid previews' }).click()
await page.mouse.move(0, 0)
const snapshot = await captureStableAria(page, DIAGRAM_MESSAGE, scaffold.workspaceCwd)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'ui.expected.md'), snapshot, MODE)
await page.close()
}, 60_000)
it.skipIf(MODE === 'record')('localizes the preview and failure states in Chinese', async () => {
const page = await browser.newPage({ viewport: { width: 1680, height: 1000 }, locale: 'zh-CN', timezoneId: 'Asia/Shanghai', hasTouch: true })
await openConversation(page, scaffold)
await expect.poll(() => page.getByRole('img', { name: 'Mermaid 图表' }).count(), { timeout: 20_000 }).toBe(3)
expect(await page.getByRole('button', { name: '源码', exact: true }).count()).toBe(7)
const controls = page.locator('.md-code-block').first().locator('[class*="bannerWrap"]')
expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
expect(await page.getByRole('button', { name: '源码', exact: true }).first()
.evaluate(node => node.getBoundingClientRect().width)).toBe(44)
expect(await page.getByText('无法渲染此图表,源码如下。', { exact: true }).count()).toBe(1)
const firstImage = page.getByRole('img', { name: 'Mermaid 图表' }).first()
await expect.poll(() => firstImage.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
expect(await controls.evaluate(node => node.getBoundingClientRect().top))
.toBeGreaterThanOrEqual(await firstImage.evaluate(node => node.getBoundingClientRect().bottom))
const snapshot = await captureStableAria(page, DIAGRAM_MESSAGE, scaffold.workspaceCwd)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'zh.expected.md'), snapshot, MODE)
await assertFixtureInventory(SNAPSHOT_DIR, ['ui.expected.md', 'zh.expected.md'])
await page.close()
}, 60_000)
it.skipIf(MODE === 'record')('previews inert DOT and SVG images and isolates static HTML', async () => {
const page = await newEnglishPage(browser)
const requests: string[] = []
const dialogs: string[] = []
// Routing observes requests eligible for transport; Chromium also emits 'request' for CSP-blocked URLs.
await page.route('https://preview.invalid/**', async (route) => {
requests.push(route.request().url())
await route.abort()
})
page.on('dialog', (dialog) => { dialogs.push(dialog.message()); void dialog.dismiss() })
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await openConversation(page, scaffold)
for (const [title, code] of [['Graphviz diagram', DOT], ['SVG preview', SVG], ['HTML preview', HTML]] as const) {
const preview = title === 'HTML preview'
? page.getByTitle(title, { exact: true })
: page.getByRole('img', { name: title, exact: true, includeHidden: true })
await preview.waitFor()
if (title === 'HTML preview') {
expect(await preview.getAttribute('sandbox')).toBe('')
const body = preview.contentFrame()
await body.getByRole('heading', { name: 'Static HTML' }).waitFor()
expect(await body.getByRole('heading').evaluate(node => getComputedStyle(node).color)).toBe('rgb(0, 128, 0)')
await body.getByText('Disabled navigation').click()
expect(await preview.evaluate(node => (node as HTMLIFrameElement).contentDocument)).toBeNull()
} else {
await expect.poll(() => preview.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
}
const block = page.locator('.md-code-block').filter({ has: preview })
await block.hover()
await block.getByRole('button', { name: 'Copy', exact: true }).click()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(code)
await block.getByRole('button', { name: 'Source', exact: true }).click()
expect(await block.locator('pre code').textContent()).toBe(code)
await block.getByRole('button', { name: 'Preview', exact: true }).click()
expect(await preview.isVisible()).toBe(true)
}
expect(await page.locator('body').getAttribute('data-preview-escaped')).toBeNull()
expect(dialogs).toEqual([])
expect(requests).toEqual([])
const notices = await page.request.get(new URL('/preview-third-party-notices.txt', scaffold.authenticatedUrl).href)
expect(notices.ok()).toBe(true)
expect(await notices.text()).toContain('Eclipse Public License - v 2.0')
await page.close()
})
it.skipIf(MODE === 'record')('keeps actions visible when a mouse and touchscreen are both available', async () => {
const hybridBrowser = await chromium.launch({
args: ['--blink-settings=availablePointerTypes=6,primaryPointerType=4,availableHoverTypes=2,primaryHoverType=2'],
})
try {
const page = await newEnglishPage(hybridBrowser)
expect(await page.evaluate(() =>
matchMedia('(hover: hover) and (pointer: fine) and (any-pointer: coarse)').matches)).toBe(true)
await openConversation(page, scaffold)
const first = page.locator('.md-code-block').first()
const diagram = first.getByRole('img', { name: 'Mermaid diagram' })
await expect.poll(() => diagram.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
await page.mouse.move(0, 0)
const controls = first.locator('[class*="bannerWrap"]')
expect(await controls.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
expect(await controls.evaluate(node => getComputedStyle(node).pointerEvents)).toBe('auto')
expect(await first.getByRole('button', { name: 'Source', exact: true })
.evaluate(node => node.getBoundingClientRect().width)).toBe(44)
expect(await controls.evaluate(node => node.getBoundingClientRect().top))
.toBeGreaterThanOrEqual(await diagram.evaluate(node => node.getBoundingClientRect().bottom))
await first.getByRole('button', { name: 'Source', exact: true }).click()
expect(await first.locator('pre code').textContent()).toBe(FLOW)
} finally {
await hybridBrowser.close()
}
}, 60_000)
})
-1
View File
@@ -79,7 +79,6 @@
"tests/math-rendering.e2e.ts",
"tests/markdown-cjk-strong.e2e.ts",
"tests/markdown-inline-code-links.e2e.ts",
"tests/markdown-mermaid.e2e.ts",
"tests/clickable-links-gallery.e2e.ts",
"tests/queue-actions.e2e.ts",
"tests/queue-image.e2e.ts",
+1 -15
View File
@@ -27,20 +27,6 @@ function clientDocumentTitle(): Plugin {
}
}
/** Preserve preview licenses and Graphviz source availability in every browser distribution. */
function previewNotices(): Plugin {
return {
name: 'dsh-preview-notices',
async generateBundle() {
this.emitFile({
type: 'asset',
fileName: 'preview-third-party-notices.txt',
source: await readFile(src('../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt'), 'utf8'),
})
},
}
}
/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
function rejectStandaloneServe(): Plugin {
return {
@@ -161,7 +147,7 @@ export default defineConfig({
// Relative asset URLs: preview.html mounts the same output under any base
// directory, and the served index resolves identically from the site root.
base: './',
plugins: [previewNotices(), rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage()],
plugins: [rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage()],
build: {
// The worker bootstrap holds its page at top-level await; Vite's default
// `modules` target (es2020-era) rejects that syntax.
-10
View File
@@ -38,16 +38,6 @@ export const en = {
'json.expandNode': 'Expand JSON node',
'json.label': 'JSON',
'markdown.footnotes': 'Footnotes',
'markdown.preview.graphviz': 'Graphviz diagram',
'markdown.preview.svg': 'SVG preview',
'markdown.preview.html': 'HTML preview',
'markdown.preview.loading': 'Rendering preview…',
'markdown.preview.error': 'Unable to render this preview. The source is shown below.',
'markdown.mermaid.diagram': 'Mermaid diagram',
'markdown.preview.preview': 'Preview',
'markdown.preview.source': 'Source',
'markdown.mermaid.loading': 'Rendering diagram…',
'markdown.mermaid.error': 'Unable to render this diagram. The source is shown below.',
'markdown.truncatedCharacters': '… truncated at {total} characters',
'number.thousand': '{value}K',
'number.million': '{value}M',
-10
View File
@@ -36,16 +36,6 @@ export const zh = {
'json.expandNode': '展开 JSON 节点',
'json.label': 'JSON',
'markdown.footnotes': '脚注',
'markdown.preview.graphviz': 'Graphviz 图表',
'markdown.preview.svg': 'SVG 预览',
'markdown.preview.html': 'HTML 预览',
'markdown.preview.loading': '正在渲染预览…',
'markdown.preview.error': '无法渲染此预览,源码如下。',
'markdown.mermaid.diagram': 'Mermaid 图表',
'markdown.preview.preview': '预览',
'markdown.preview.source': '源码',
'markdown.mermaid.loading': '正在渲染图表…',
'markdown.mermaid.error': '无法渲染此图表,源码如下。',
'markdown.truncatedCharacters': '… 已截断,共 {total} 字符',
'number.thousand': '{value}K',
'number.million': '{value}M',
+2 -2
View File
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-chat/README.md
README.md: 1bd7f008e46b695899c703c928ebe8bb76fbe4c8
README.zh.md: 5919622396dde5b0c8cbf51f71999672a11f13ec
README.md: 860dcb9eeb9c92a14d9128d9d8c95c29796726f8
README.zh.md: 4dd06a16b19b14cacc0ded603cc1cf8869dd1a43
-8
View File
@@ -15,7 +15,6 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
## Table of Contents
- [System prompt row](#system-prompt-row)
- [Mermaid previews](#mermaid-previews)
- [Turn token usage](#turn-token-usage)
- [Turn Process Folding](#turn-process-folding)
- [Scroll ownership](#scroll-ownership)
@@ -30,13 +29,6 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
Each nonempty appended `system/message` owns a collapsed prompt row, including a complete prompt at the start of a headerless window; the same-step header does not duplicate it. Chat also shows a collapsed `System prompt` row for a non-empty initial request, explicit message-series start, or `system/message` surface node replacement whose text differs, reading the last nonempty surviving system node in surface order at the `request/header`; a non-initial request whose preceding header is outside the loaded history window also shows one. A resume repeats the row even when its system text is unchanged, including after pagination supplies the preceding header and system node; same-series config-only or tool-only changes, tool steps, and retries create no repetition, and a `system/message` event is never rendered as a transcript message. The row appears before that request's user messages, matching the provider envelope, and expands to the exact model-visible text with its original line breaks. A request whose system node is empty or outside the loaded window creates no row until the page holding the node arrives.
<a id="mermaid-previews"></a>
## Mermaid previews
Settled Assistant Markdown shows `mermaid` code fences as diagrams. Diagrams have no language banner. Source/Preview and Copy icon actions appear on hover or keyboard focus and remain visible on touch devices; both views copy the original Mermaid source. Streaming fences remain code until the message settles; invalid diagrams show an error and their source without disrupting other blocks. Chat enables the shared [Mermaid preview primitive](../ui-primitives/README.md) through localized labels. Chat also previews `graphviz`/`dot`, `svg`, and static `html` fences in isolated iframes, with the same default visualization, source switching, and copying. HTML scripts, navigation, and remote resources are disabled. This is a presentation change and adds no model prompt, tool, or Session event.
-----
<a id="turn-token-usage"></a>
## Turn token usage
-8
View File
@@ -15,7 +15,6 @@ kind: "package-reference"
## 目录
- [系统提示词行](#system-prompt-row)
- [Mermaid 预览](#mermaid-previews)
- [轮次 token 用量](#turn-token-usage)
- [轮次过程折叠](#turn-process-folding)
- [滚动归属](#scroll-ownership)
@@ -32,13 +31,6 @@ kind: "package-reference"
-----
<a id="mermaid-previews"></a>
## Mermaid 预览
已定稿的 Assistant Markdown 把 `mermaid` 代码 fence 显示为图表。图表不显示语言标题栏。源码/预览切换与复制图标在悬停或键盘聚焦时出现,触屏上保持可见;两种视图复制的都是原始 Mermaid 源码。流式 fence 在消息定稿前保持代码显示;非法图表显示错误与源码,不影响其他块。Chat 通过本地化 label 启用共享的 [Mermaid 预览原语](../ui-primitives/README.zh.md)。Chat 还在隔离 iframe 中预览 `graphviz`/`dot`、`svg` 和静态 `html` fence,共用默认可视化、源码切换与复制操作。HTML 脚本、导航与远程资源均被禁用。这项展示变更不增加模型提示词、工具或 Session 事件。
-----
<a id="turn-token-usage"></a>
## 轮次 token 用量
@@ -9,21 +9,8 @@ import type { ChatViewSlotProps } from './contract/slots.ts'
* @returns Labels for code fences and footnotes.
*/
export function markdownLabels(t: ChatViewSlotProps['t']): MarkdownLabels {
const status = { loading: t('markdown.preview.loading'), error: t('markdown.preview.error') }
return {
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
footnotes: t('markdown.footnotes'),
preview: {
preview: t('markdown.preview.preview'),
source: t('markdown.preview.source'),
mermaid: {
diagram: t('markdown.mermaid.diagram'),
loading: t('markdown.mermaid.loading'),
error: t('markdown.mermaid.error'),
},
graphviz: { ...status, diagram: t('markdown.preview.graphviz') },
svg: { ...status, diagram: t('markdown.preview.svg') },
html: { ...status, diagram: t('markdown.preview.html') },
},
}
}
@@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write packages/client/ui-primitives/README.md
README.md: 22f450914078799f2f9da31389670f83cefe66cd
README.zh.md: c9fd355d4b16ae98bd0f194d13d4331a52513044
README.md: aebb0be860ebaba4543a8fd262599507ead64556
README.zh.md: a545aff9837036890b6250ab1aa32fb651af0a18
-10
View File
@@ -73,15 +73,6 @@ The catalog above lists what each export is for; this section covers the behavio
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. When the owner passes a `pathImages` vocabulary, image destinations that are local media paths rewrite to displayable URLs on settled renders only (the same streaming gate as file mentions); without a vocabulary, local destinations remain inert alt text. A load or decode failure replaces the image with its authored alt text, or the original destination when alt is empty. Changing the image source permits a fresh load. While a reply streams, it freezes completed blocks, advances a top-level open fence by completed lines, and highlights that fence from saved Shiki grammar state. Completed token lines enter fixed-size React groups, so later chunks reconcile only the growing group; an unchanged fence retains that DOM when the final full parse resolves cross-document syntax. `TerminalBlock`, `ReadBlock`, `DiffBlock`, `SearchBlock`, and `WebBlock` render the matching tool-result intent with copy controls, overflow handling, and ANSI processing where applicable. `JsonTree` and `JsonBlock` inspect JSON values read-only, while `projectUserText` projects sent user text into inline plain runs and reference chips for the message bubble and queue rows.
Supply `MarkdownLabels.preview` to enable settled `mermaid`, `graphviz`/`dot`, `svg`, and `html` fences. Each language supplies localized preview states, and consumers without these labels retain code. Mermaid and Graphviz use the active document palette on the code-block background and update mounted previews when the theme changes. Explicit colors in DOT, SVG, and HTML remain authored content. Rendering loads Mermaid on demand, uses strict security, and exposes the generated SVG as an image with no diagram link handlers. A render failure shows the original source with the supplied error label; replacing the source discards late results from the previous render. The diagram keeps its intrinsic size and shrinks to fit the available width.
`CodeBlock.preview` supplies an optional headerless body with icon actions for source switching and copying. Actions appear on hover or keyboard focus, and stay visible below the diagram whenever a touchscreen is available, including with a mouse attached. Switching to source keeps the preview mounted, so returning reuses its result; copying always retains the source.
Each settled fence defaults to visualization and shares the source/copy controls. Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images in the same content-sized canvas as Mermaid; malformed SVG and DOT retain their source with an error. HTML accepts complete documents or fragments and preserves inline styles, but strips scripts and navigation. HTML frames have no sandbox permissions, and their Content Security Policy blocks external resources, embedded frames, forms, and scripts. Diagram canvases follow the rendered image height with 16px padding and shrink images proportionally to fit the available width. HTML frames use a 400px scrollable viewport. Raw HTML outside a code fence remains ordinary Markdown input.
Internal render functions accept optional native `MermaidConfig` and Viz.js `RenderOptions` for developer customization. Attribute and theme-variable overrides merge with document defaults; Mermaid preview restrictions and Graphviz SVG output remain fixed. These parameters have no settings UI.
[Preview license notices](THIRD_PARTY_PREVIEW_NOTICES.txt) ship with this package and the Web frontend. Mermaid is MIT; Viz.js is MIT but embeds Graphviz under EPL-2.0. The notices retain license texts and the exact Graphviz source download. Dependency upgrades require reviewing the embedded payload as well as npm metadata; the [preview decision](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.md) records the distribution and sandbox choices.
### Localizing copy
@@ -150,7 +141,6 @@ None; this package neither assembles nor sends a provider request.
These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap.
- **Diagram rendering runs on the browser thread** — every mounted settled preview starts rendering, including off-screen diagrams. Mermaid serializes layouts; Graphviz performs synchronous WebAssembly layout. Submitted layout work cannot be interrupted. Preview virtualization and worker rendering are not provided.
- **Streaming defers cross-boundary reference resolution** — a reference-style link or footnote whose definition sits on the other side of the incremental freeze boundary renders as literal text while the reply streams; the settled full parse at finalize resolves it.
- **A long highlighted fence retains its complete token DOM** — streaming avoids re-parsing, re-tokenizing, and reconciling the completed prefix, but it does not discard old colors or virtualize token spans. Final DOM cardinality therefore still follows the fence's token count; nested/container fences and a pathological single long line remain on the general tail path.
- **Glyph-level icons are redrawn approximations** — the fish logo and the sparkle mark come from font glyphs whose vector geometry is not exportable from the local design data; hand-authored recreations stand in until an exact export path exists.
@@ -73,15 +73,6 @@ kind: "package-library"
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。当 owner 传入 `pathImages` 词表时,本地媒体路径的图片目标只在落定渲染阶段重写为可展示 URL(与 file mentions 相同的流式门);不传词表时本地目标保持惰性 alt 文本。加载或解码失败后,图片替换为作者的 alt 文本;alt 为空时显示原始目标路径。图片源变化后可重新加载。回复流式输出时,它冻结已完成的块、按已完成行推进顶层未闭合 fence,并从保存的 Shiki grammar state 为该 fence 增量高亮。已完成的 token 行进入固定大小的 React 分组,后续分片只 reconcile 正在增长的分组;最终全量解析解决跨文档语法时,未变化的 fence 会保留该 DOM。`TerminalBlock`、`ReadBlock`、`DiffBlock`、`SearchBlock` 与 `WebBlock` 把对应的工具结果意图渲染为带复制控件、溢出处理及适用时 ANSI 处理的卡片。`JsonTree` 与 `JsonBlock` 以只读方式检查 JSON 值;`projectUserText` 把已发送的用户文本投影为行内普通文本段与引用 chip,供消息气泡和排队行使用。
提供 `MarkdownLabels.preview` 可启用定稿后的 `mermaid`、`graphviz`/`dot`、`svg` 和 `html` fence。每种语言提供本地化预览状态,未提供这些文案的调用方保留代码显示。Mermaid 与 Graphviz 在代码块背景上使用当前文档配色,主题切换时同步更新已挂载的预览。DOT、SVG 与 HTML 中明确指定的颜色仍保留原样。渲染按需加载 Mermaid、使用严格安全模式,并把生成的 SVG 显示为图片,不绑定图内链接处理器。渲染失败时显示原始源码与传入的错误文案;替换源码后会丢弃前一次渲染的延迟结果。图表保留固有尺寸,并在可用宽度不足时缩小。
`CodeBlock.preview` 提供无标题栏的替代正文,以及切换源码和复制的图标操作。操作在悬停或键盘聚焦时出现;只要设备具备触屏,就在图表下方保持可见,包括同时连接鼠标的情况。切换到源码时保留已挂载的预览,返回时复用其结果;复制始终保留源码。
每个定稿 fence 默认显示可视化,并共享源码切换与复制操作。Graphviz 按需加载 `@viz-js/viz`,用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出作为不可执行的图片,使用与 Mermaid 相同的按内容定高画布;非法 SVG 与 DOT 显示错误并保留源码。HTML 接受完整文档或片段、保留内联样式,同时移除脚本与导航。HTML iframe 不授予任何 sandbox 权限,Content Security Policy 阻止外部资源、嵌套 iframe、表单与脚本。图表画布随渲染后的图片高度收缩,保留 16px 内边距,并在可用宽度不足时等比例缩小图片。HTML iframe 使用可滚动的 400px 视口。代码 fence 外的原始 HTML 仍按普通 Markdown 输入处理。
内部渲染函数接受可选的原生 `MermaidConfig` 与 Viz.js `RenderOptions`,供开发者定制。属性与主题变量覆盖项和文档默认值合并;Mermaid 预览限制与 Graphviz SVG 输出保持固定。这些参数没有设置界面。
[预览许可证声明](THIRD_PARTY_PREVIEW_NOTICES.txt)随本包与 Web 前端分发。Mermaid 使用 MIT;Viz.js 使用 MIT,但内嵌 Graphviz 使用 EPL-2.0。声明保留完整许可证文本及准确的 Graphviz 源码下载地址。升级依赖时必须同时检查内嵌产物和 npm 元数据;[预览决策](../../../.agents/notes/implemented/feature/2026-09-09-markdown-static-previews.zh.md)记录分发与 sandbox 选择。
### 本地化文案
@@ -150,7 +141,6 @@ kind: "package-library"
这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
- **图表渲染在浏览器线程上执行**:每个已挂载的定稿预览都会开始渲染,包括视口外的图表。Mermaid 串行执行布局;Graphviz 同步执行 WebAssembly 布局。已提交的布局工作无法中断。当前不提供预览虚拟化或 worker 渲染。
- **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。
- **长高亮 fence 会保留完整 token DOM**:流式路径避免重新解析、重新 tokenize 和 reconcile 已完成前缀,但不会丢弃旧颜色或虚拟化 token span。因此最终 DOM 数量仍随 fence 的 token 数增长;嵌套/容器内 fence 与病态的单个超长行仍走通用尾部路径。
- **字形级图标是重新绘制的近似版本**:鱼形标志与闪光标记来自字体字形,而本地设计数据无法导出其矢量几何;在获得精确导出路径前,使用手工重建版本代替。
@@ -1,688 +0,0 @@
Markdown preview third-party notices
Mermaid 11.16.0: MIT; source https://github.com/mermaid-js/mermaid/tree/mermaid%4011.16.0
@viz-js/viz 3.30.0: MIT JavaScript wrapper; source and build instructions:
https://github.com/mdaines/viz-js/tree/99da545270e6e7b127a5c7ba65974b8a604a6358
The Viz.js WebAssembly payload contains Graphviz 16.0.0 under EPL-2.0,
Expat 2.8.4 under MIT, and Emscripten 5.0.7 runtime code under MIT/NCSA.
Graphviz remains under EPL-2.0; it is not relicensed as MIT by DeepSeek Harness.
Graphviz source code is available under EPL-2.0 at:
https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz
SHA-256: 36a1de1aaf5a2023b14f95170a5f8f0b12522d1c305b517fc261966597050749
Viz.js's backend build instructions and additions are available at the commit above.
DeepSeek Harness uses the unmodified upstream Viz.js package.
Expat source: https://github.com/libexpat/libexpat/tree/R_2_8_4
Emscripten source: https://github.com/emscripten-core/emscripten/tree/5.0.7
DOMPurify 3.4.11: Apache-2.0 OR MPL-2.0; this distribution selects Apache-2.0.
Source: https://github.com/cure53/DOMPurify/tree/3.4.11
The following license texts and copyright notices apply to their respective components.
===== Mermaid =====
The MIT License (MIT)
Copyright (c) 2014 - 2022 Knut Sveidqvist
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
===== Viz.js =====
MIT License
Copyright (c) Michael Daines
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.
===== Graphviz =====
Eclipse Public License - v 2.0
THE ACCOMPANYING PROGRAM IS PROVIDED UNDER THE TERMS OF THIS ECLIPSE
PUBLIC LICENSE ("AGREEMENT"). ANY USE, REPRODUCTION OR DISTRIBUTION
OF THE PROGRAM CONSTITUTES RECIPIENT'S ACCEPTANCE OF THIS AGREEMENT.
1. DEFINITIONS
"Contribution" means:
a) in the case of the initial Contributor, the initial content
Distributed under this Agreement, and
b) in the case of each subsequent Contributor:
i) changes to the Program, and
ii) additions to the Program;
where such changes and/or additions to the Program originate from
and are Distributed by that particular Contributor. A Contribution
"originates" from a Contributor if it was added to the Program by
such Contributor itself or anyone acting on such Contributor's behalf.
Contributions do not include changes or additions to the Program that
are not Modified Works.
"Contributor" means any person or entity that Distributes the Program.
"Licensed Patents" mean patent claims licensable by a Contributor which
are necessarily infringed by the use or sale of its Contribution alone
or when combined with the Program.
"Program" means the Contributions Distributed in accordance with this
Agreement.
"Recipient" means anyone who receives the Program under this Agreement
or any Secondary License (as applicable), including Contributors.
"Derivative Works" shall mean any work, whether in Source Code or other
form, that is based on (or derived from) the Program and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship.
"Modified Works" shall mean any work in Source Code or other form that
results from an addition to, deletion from, or modification of the
contents of the Program, including, for purposes of clarity any new file
in Source Code form that contains any contents of the Program. Modified
Works shall not include works that contain only declarations,
interfaces, types, classes, structures, or files of the Program solely
in each case in order to link to, bind by name, or subclass the Program
or Modified Works thereof.
"Distribute" means the acts of a) distributing or b) making available
in any manner that enables the transfer of a copy.
"Source Code" means the form of a Program preferred for making
modifications, including but not limited to software source code,
documentation source, and configuration files.
"Secondary License" means either the GNU General Public License,
Version 2.0, or any later versions of that license, including any
exceptions or additional permissions as identified by the initial
Contributor.
2. GRANT OF RIGHTS
a) Subject to the terms of this Agreement, each Contributor hereby
grants Recipient a non-exclusive, worldwide, royalty-free copyright
license to reproduce, prepare Derivative Works of, publicly display,
publicly perform, Distribute and sublicense the Contribution of such
Contributor, if any, and such Derivative Works.
b) Subject to the terms of this Agreement, each Contributor hereby
grants Recipient a non-exclusive, worldwide, royalty-free patent
license under Licensed Patents to make, use, sell, offer to sell,
import and otherwise transfer the Contribution of such Contributor,
if any, in Source Code or other form. This patent license shall
apply to the combination of the Contribution and the Program if, at
the time the Contribution is added by the Contributor, such addition
of the Contribution causes such combination to be covered by the
Licensed Patents. The patent license shall not apply to any other
combinations which include the Contribution. No hardware per se is
licensed hereunder.
c) Recipient understands that although each Contributor grants the
licenses to its Contributions set forth herein, no assurances are
provided by any Contributor that the Program does not infringe the
patent or other intellectual property rights of any other entity.
Each Contributor disclaims any liability to Recipient for claims
brought by any other entity based on infringement of intellectual
property rights or otherwise. As a condition to exercising the
rights and licenses granted hereunder, each Recipient hereby
assumes sole responsibility to secure any other intellectual
property rights needed, if any. For example, if a third party
patent license is required to allow Recipient to Distribute the
Program, it is Recipient's responsibility to acquire that license
before distributing the Program.
d) Each Contributor represents that to its knowledge it has
sufficient copyright rights in its Contribution, if any, to grant
the copyright license set forth in this Agreement.
e) Notwithstanding the terms of any Secondary License, no
Contributor makes additional grants to any Recipient (other than
those set forth in this Agreement) as a result of such Recipient's
receipt of the Program under the terms of a Secondary License
(if permitted under the terms of Section 3).
3. REQUIREMENTS
3.1 If a Contributor Distributes the Program in any form, then:
a) the Program must also be made available as Source Code, in
accordance with section 3.2, and the Contributor must accompany
the Program with a statement that the Source Code for the Program
is available under this Agreement, and informs Recipients how to
obtain it in a reasonable manner on or through a medium customarily
used for software exchange; and
b) the Contributor may Distribute the Program under a license
different than this Agreement, provided that such license:
i) effectively disclaims on behalf of all other Contributors all
warranties and conditions, express and implied, including
warranties or conditions of title and non-infringement, and
implied warranties or conditions of merchantability and fitness
for a particular purpose;
ii) effectively excludes on behalf of all other Contributors all
liability for damages, including direct, indirect, special,
incidental and consequential damages, such as lost profits;
iii) does not attempt to limit or alter the recipients' rights
in the Source Code under section 3.2; and
iv) requires any subsequent distribution of the Program by any
party to be under a license that satisfies the requirements
of this section 3.
3.2 When the Program is Distributed as Source Code:
a) it must be made available under this Agreement, or if the
Program (i) is combined with other material in a separate file or
files made available under a Secondary License, and (ii) the initial
Contributor attached to the Source Code the notice described in
Exhibit A of this Agreement, then the Program may be made available
under the terms of such Secondary Licenses, and
b) a copy of this Agreement must be included with each copy of
the Program.
3.3 Contributors may not remove or alter any copyright, patent,
trademark, attribution notices, disclaimers of warranty, or limitations
of liability ("notices") contained within the Program from any copy of
the Program which they Distribute, provided that Contributors may add
their own appropriate notices.
4. COMMERCIAL DISTRIBUTION
Commercial distributors of software may accept certain responsibilities
with respect to end users, business partners and the like. While this
license is intended to facilitate the commercial use of the Program,
the Contributor who includes the Program in a commercial product
offering should do so in a manner which does not create potential
liability for other Contributors. Therefore, if a Contributor includes
the Program in a commercial product offering, such Contributor
("Commercial Contributor") hereby agrees to defend and indemnify every
other Contributor ("Indemnified Contributor") against any losses,
damages and costs (collectively "Losses") arising from claims, lawsuits
and other legal actions brought by a third party against the Indemnified
Contributor to the extent caused by the acts or omissions of such
Commercial Contributor in connection with its distribution of the Program
in a commercial product offering. The obligations in this section do not
apply to any claims or Losses relating to any actual or alleged
intellectual property infringement. In order to qualify, an Indemnified
Contributor must: a) promptly notify the Commercial Contributor in
writing of such claim, and b) allow the Commercial Contributor to control,
and cooperate with the Commercial Contributor in, the defense and any
related settlement negotiations. The Indemnified Contributor may
participate in any such claim at its own expense.
For example, a Contributor might include the Program in a commercial
product offering, Product X. That Contributor is then a Commercial
Contributor. If that Commercial Contributor then makes performance
claims, or offers warranties related to Product X, those performance
claims and warranties are such Commercial Contributor's responsibility
alone. Under this section, the Commercial Contributor would have to
defend claims against the other Contributors related to those performance
claims and warranties, and if a court requires any other Contributor to
pay any damages as a result, the Commercial Contributor must pay
those damages.
5. NO WARRANTY
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
PERMITTED BY APPLICABLE LAW, THE PROGRAM IS PROVIDED ON AN "AS IS"
BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, EITHER EXPRESS OR
IMPLIED INCLUDING, WITHOUT LIMITATION, ANY WARRANTIES OR CONDITIONS OF
TITLE, NON-INFRINGEMENT, MERCHANTABILITY OR FITNESS FOR A PARTICULAR
PURPOSE. Each Recipient is solely responsible for determining the
appropriateness of using and distributing the Program and assumes all
risks associated with its exercise of rights under this Agreement,
including but not limited to the risks and costs of program errors,
compliance with applicable laws, damage to or loss of data, programs
or equipment, and unavailability or interruption of operations.
6. DISCLAIMER OF LIABILITY
EXCEPT AS EXPRESSLY SET FORTH IN THIS AGREEMENT, AND TO THE EXTENT
PERMITTED BY APPLICABLE LAW, NEITHER RECIPIENT NOR ANY CONTRIBUTORS
SHALL HAVE ANY LIABILITY FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL,
EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING WITHOUT LIMITATION LOST
PROFITS), HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN
CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE)
ARISING IN ANY WAY OUT OF THE USE OR DISTRIBUTION OF THE PROGRAM OR THE
EXERCISE OF ANY RIGHTS GRANTED HEREUNDER, EVEN IF ADVISED OF THE
POSSIBILITY OF SUCH DAMAGES.
7. GENERAL
If any provision of this Agreement is invalid or unenforceable under
applicable law, it shall not affect the validity or enforceability of
the remainder of the terms of this Agreement, and without further
action by the parties hereto, such provision shall be reformed to the
minimum extent necessary to make such provision valid and enforceable.
If Recipient institutes patent litigation against any entity
(including a cross-claim or counterclaim in a lawsuit) alleging that the
Program itself (excluding combinations of the Program with other software
or hardware) infringes such Recipient's patent(s), then such Recipient's
rights granted under Section 2(b) shall terminate as of the date such
litigation is filed.
All Recipient's rights under this Agreement shall terminate if it
fails to comply with any of the material terms or conditions of this
Agreement and does not cure such failure in a reasonable period of
time after becoming aware of such noncompliance. If all Recipient's
rights under this Agreement terminate, Recipient agrees to cease use
and distribution of the Program as soon as reasonably practicable.
However, Recipient's obligations under this Agreement and any licenses
granted by Recipient relating to the Program shall continue and survive.
Everyone is permitted to copy and distribute copies of this Agreement,
but in order to avoid inconsistency the Agreement is copyrighted and
may only be modified in the following manner. The Agreement Steward
reserves the right to publish new versions (including revisions) of
this Agreement from time to time. No one other than the Agreement
Steward has the right to modify this Agreement. The Eclipse Foundation
is the initial Agreement Steward. The Eclipse Foundation may assign the
responsibility to serve as the Agreement Steward to a suitable separate
entity. Each new version of the Agreement will be given a distinguishing
version number. The Program (including Contributions) may always be
Distributed subject to the version of the Agreement under which it was
received. In addition, after a new version of the Agreement is published,
Contributor may elect to Distribute the Program (including its
Contributions) under the new version.
Except as expressly stated in Sections 2(a) and 2(b) above, Recipient
receives no rights or licenses to the intellectual property of any
Contributor under this Agreement, whether expressly, by implication,
estoppel or otherwise. All rights in the Program not expressly granted
under this Agreement are reserved. Nothing in this Agreement is intended
to be enforceable by any entity that is not a Contributor or Recipient.
No third-party beneficiary rights are created under this Agreement.
Exhibit A - Form of Secondary Licenses Notice
"This Source Code may also be made available under the following
Secondary Licenses when the conditions for such availability set forth
in the Eclipse Public License, v. 2.0 are satisfied: {name license(s),
version(s), and exceptions or additional permissions here}."
Simply including a copy of this Agreement, including this Exhibit A
is not sufficient to license the Source Code under Secondary Licenses.
If it is not possible or desirable to put the notice in a particular
file, then You may include the notice in a location (such as a LICENSE
file in a relevant directory) where a recipient would be likely to
look for such a notice.
You may add additional accurate notices of copyright ownership.
===== Expat =====
Copyright (c) 1998-2000 Thai Open Source Software Center Ltd and Clark Cooper
Copyright (c) 2001-2025 Expat maintainers
Permission is hereby granted, free of charge, to any person obtaining
a copy of this software and associated documentation files (the
"Software"), to deal in the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
The above copyright notice and this permission notice shall be included
in all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE.
===== Emscripten =====
Emscripten is available under 2 licenses, the MIT license and the
University of Illinois/NCSA Open Source License.
Both are permissive open source licenses, with little if any
practical difference between them.
The reason for offering both is that (1) the MIT license is
well-known, while (2) the University of Illinois/NCSA Open Source
License allows Emscripten's code to be integrated upstream into
LLVM, which uses that license, should the opportunity arise.
The full text of both licenses follows.
==============================================================================
Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
==============================================================================
Copyright (c) 2010-2014 Emscripten authors, see AUTHORS file.
All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a
copy of this software and associated documentation files (the
"Software"), to deal with the Software without restriction, including
without limitation the rights to use, copy, modify, merge, publish,
distribute, sublicense, and/or sell copies of the Software, and to
permit persons to whom the Software is furnished to do so, subject to
the following conditions:
Redistributions of source code must retain the above copyright
notice, this list of conditions and the following disclaimers.
Redistributions in binary form must reproduce the above
copyright notice, this list of conditions and the following disclaimers
in the documentation and/or other materials provided with the
distribution.
Neither the names of Mozilla,
nor the names of its contributors may be used to endorse
or promote products derived from this Software without specific prior
written permission.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS
OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF
MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
IN NO EVENT SHALL THE CONTRIBUTORS OR COPYRIGHT HOLDERS BE LIABLE FOR
ANY CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT,
TORT OR OTHERWISE, ARISING FROM, OUT OF OR IN CONNECTION WITH THE
SOFTWARE OR THE USE OR OTHER DEALINGS WITH THE SOFTWARE.
==============================================================================
This program uses portions of Node.js source code located in src/library_path.js,
in accordance with the terms of the MIT license. Node's license follows:
"""
Copyright Joyent, Inc. and other Node contributors. All rights reserved.
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to
deal in the Software without restriction, including without limitation the
rights to use, copy, modify, merge, publish, distribute, sublicense, and/or
sell copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS
IN THE SOFTWARE.
"""
The musl libc project is bundled in this repo, and it has the MIT license, see
system/lib/libc/musl/COPYRIGHT
The third_party/ subdirectory contains code with other licenses. None of it is
used by default, but certain options use it (e.g., the optional closure compiler
flag will run closure compiler from third_party/).
===== DOMPurify =====
Apache License
Version 2.0, January 2004
http://www.apache.org/licenses/
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
1. Definitions.
"License" shall mean the terms and conditions for use, reproduction,
and distribution as defined by Sections 1 through 9 of this document.
"Licensor" shall mean the copyright owner or entity authorized by
the copyright owner that is granting the License.
"Legal Entity" shall mean the union of the acting entity and all
other entities that control, are controlled by, or are under common
control with that entity. For the purposes of this definition,
"control" means (i) the power, direct or indirect, to cause the
direction or management of such entity, whether by contract or
otherwise, or (ii) ownership of fifty percent (50%) or more of the
outstanding shares, or (iii) beneficial ownership of such entity.
"You" (or "Your") shall mean an individual or Legal Entity
exercising permissions granted by this License.
"Source" form shall mean the preferred form for making modifications,
including but not limited to software source code, documentation
source, and configuration files.
"Object" form shall mean any form resulting from mechanical
transformation or translation of a Source form, including but
not limited to compiled object code, generated documentation,
and conversions to other media types.
"Work" shall mean the work of authorship, whether in Source or
Object form, made available under the License, as indicated by a
copyright notice that is included in or attached to the work
(an example is provided in the Appendix below).
"Derivative Works" shall mean any work, whether in Source or Object
form, that is based on (or derived from) the Work and for which the
editorial revisions, annotations, elaborations, or other modifications
represent, as a whole, an original work of authorship. For the purposes
of this License, Derivative Works shall not include works that remain
separable from, or merely link (or bind by name) to the interfaces of,
the Work and Derivative Works thereof.
"Contribution" shall mean any work of authorship, including
the original version of the Work and any modifications or additions
to that Work or Derivative Works thereof, that is intentionally
submitted to Licensor for inclusion in the Work by the copyright owner
or by an individual or Legal Entity authorized to submit on behalf of
the copyright owner. For the purposes of this definition, "submitted"
means any form of electronic, verbal, or written communication sent
to the Licensor or its representatives, including but not limited to
communication on electronic mailing lists, source code control systems,
and issue tracking systems that are managed by, or on behalf of, the
Licensor for the purpose of discussing and improving the Work, but
excluding communication that is conspicuously marked or otherwise
designated in writing by the copyright owner as "Not a Contribution."
"Contributor" shall mean Licensor and any individual or Legal Entity
on behalf of whom a Contribution has been received by Licensor and
subsequently incorporated within the Work.
2. Grant of Copyright License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
copyright license to reproduce, prepare Derivative Works of,
publicly display, publicly perform, sublicense, and distribute the
Work and such Derivative Works in Source or Object form.
3. Grant of Patent License. Subject to the terms and conditions of
this License, each Contributor hereby grants to You a perpetual,
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
(except as stated in this section) patent license to make, have made,
use, offer to sell, sell, import, and otherwise transfer the Work,
where such license applies only to those patent claims licensable
by such Contributor that are necessarily infringed by their
Contribution(s) alone or by combination of their Contribution(s)
with the Work to which such Contribution(s) was submitted. If You
institute patent litigation against any entity (including a
cross-claim or counterclaim in a lawsuit) alleging that the Work
or a Contribution incorporated within the Work constitutes direct
or contributory patent infringement, then any patent licenses
granted to You under this License for that Work shall terminate
as of the date such litigation is filed.
4. Redistribution. You may reproduce and distribute copies of the
Work or Derivative Works thereof in any medium, with or without
modifications, and in Source or Object form, provided that You
meet the following conditions:
(a) You must give any other recipients of the Work or
Derivative Works a copy of this License; and
(b) You must cause any modified files to carry prominent notices
stating that You changed the files; and
(c) You must retain, in the Source form of any Derivative Works
that You distribute, all copyright, patent, trademark, and
attribution notices from the Source form of the Work,
excluding those notices that do not pertain to any part of
the Derivative Works; and
(d) If the Work includes a "NOTICE" text file as part of its
distribution, then any Derivative Works that You distribute must
include a readable copy of the attribution notices contained
within such NOTICE file, excluding those notices that do not
pertain to any part of the Derivative Works, in at least one
of the following places: within a NOTICE text file distributed
as part of the Derivative Works; within the Source form or
documentation, if provided along with the Derivative Works; or,
within a display generated by the Derivative Works, if and
wherever such third-party notices normally appear. The contents
of the NOTICE file are for informational purposes only and
do not modify the License. You may add Your own attribution
notices within Derivative Works that You distribute, alongside
or as an addendum to the NOTICE text from the Work, provided
that such additional attribution notices cannot be construed
as modifying the License.
You may add Your own copyright statement to Your modifications and
may provide additional or different license terms and conditions
for use, reproduction, or distribution of Your modifications, or
for any such Derivative Works as a whole, provided Your use,
reproduction, and distribution of the Work otherwise complies with
the conditions stated in this License.
5. Submission of Contributions. Unless You explicitly state otherwise,
any Contribution intentionally submitted for inclusion in the Work
by You to the Licensor shall be under the terms and conditions of
this License, without any additional terms or conditions.
Notwithstanding the above, nothing herein shall supersede or modify
the terms of any separate license agreement you may have executed
with Licensor regarding such Contributions.
6. Trademarks. This License does not grant permission to use the trade
names, trademarks, service marks, or product names of the Licensor,
except as required for reasonable and customary use in describing the
origin of the Work and reproducing the content of the NOTICE file.
7. Disclaimer of Warranty. Unless required by applicable law or
agreed to in writing, Licensor provides the Work (and each
Contributor provides its Contributions) on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
implied, including, without limitation, any warranties or conditions
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
PARTICULAR PURPOSE. You are solely responsible for determining the
appropriateness of using or redistributing the Work and assume any
risks associated with Your exercise of permissions under this License.
8. Limitation of Liability. In no event and under no legal theory,
whether in tort (including negligence), contract, or otherwise,
unless required by applicable law (such as deliberate and grossly
negligent acts) or agreed to in writing, shall any Contributor be
liable to You for damages, including any direct, indirect, special,
incidental, or consequential damages of any character arising as a
result of this License or out of the use or inability to use the
Work (including but not limited to damages for loss of goodwill,
work stoppage, computer failure or malfunction, or any and all
other commercial damages or losses), even if such Contributor
has been advised of the possibility of such damages.
9. Accepting Warranty or Additional Liability. While redistributing
the Work or Derivative Works thereof, You may choose to offer,
and charge a fee for, acceptance of support, warranty, indemnity,
or other liability obligations and/or rights consistent with this
License. However, in accepting such obligations, You may act only
on Your own behalf and on Your sole responsibility, not on behalf
of any other Contributor, and only if You agree to indemnify,
defend, and hold each Contributor harmless for any liability
incurred by, or claims asserted against, such Contributor by reason
of your accepting any such warranty or additional liability.
END OF TERMS AND CONDITIONS
APPENDIX: How to apply the Apache License to your work.
To apply the Apache License to your work, attach the following
boilerplate notice, with the fields enclosed by brackets "[]"
replaced with your own identifying information. (Don't include
the brackets!) The text should be enclosed in the appropriate
comment syntax for the file format. We also recommend that a
file or class name and description of purpose be included on the
same "printed page" as the copyright notice for easier
identification within third-party archives.
Copyright [yyyy] [name of copyright owner]
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
+1 -5
View File
@@ -45,15 +45,11 @@
"mdast-util-gfm": "^3.1.0",
"mdast-util-math": "^3.0.0",
"micromark-extension-gfm": "^3.0.0",
"micromark-util-sanitize-uri": "^2.0.1",
"mermaid": "11.16.0",
"@viz-js/viz": "3.30.0",
"dompurify": "3.4.11"
"micromark-util-sanitize-uri": "^2.0.1"
},
"files": [
"lib/index.js",
"lib/**/*.css",
"THIRD_PARTY_PREVIEW_NOTICES.txt",
"lib/types/**/*.d.ts"
],
"peerDependencies": {
@@ -57,7 +57,6 @@
display: flex;
align-items: center;
flex-shrink: 0;
gap: 12px;
}
.copyButton {
@@ -99,89 +98,6 @@
color: var(--dsw-alias-label-primary);
}
/* Alternate content is a figure; its actions overlay instead of taking a title row. */
.preview.showingPreview {
background: transparent;
}
.preview .bannerWrap {
position: absolute;
top: 8px;
right: 8px;
z-index: 1;
background: transparent;
}
.preview .banner {
padding: 2px;
background: var(--dsw-alias-bg-base);
border: 0;
border-radius: 8px;
box-shadow: var(--dsw-elevation-panel);
}
.preview .action {
gap: 2px;
}
.iconButton {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border-radius: 6px;
color: var(--dsw-alias-label-secondary);
}
.iconButton span {
display: flex;
}
.iconButton:hover {
color: var(--dsw-alias-label-primary);
background: var(--dsw-alias-interactive-bg-hover);
}
.preview:not(.showingPreview) :where(pre) {
padding-top: 48px;
}
@media (hover: hover) and (pointer: fine) {
.preview .bannerWrap {
opacity: 0;
pointer-events: none;
}
.preview:hover .bannerWrap,
.preview:focus-within .bannerWrap {
opacity: 1;
pointer-events: auto;
}
}
@media (any-pointer: coarse) {
.preview {
padding-bottom: 56px;
}
.preview .bannerWrap {
top: auto;
bottom: 8px;
opacity: 1;
pointer-events: auto;
}
.preview .iconButton {
width: 44px;
height: 44px;
}
.preview:not(.showingPreview) :where(pre) {
padding-top: 16px;
}
}
.numbered :where(pre) code {
display: block;
counter-reset: source-line;
@@ -2,8 +2,6 @@ import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore
import type { CSSProperties, ReactNode } from 'react'
import clsx from 'clsx'
import { writeClipboard } from '../clipboard.ts'
import { Tooltip } from '../Tooltip.tsx'
import { IconCheckOutline16, IconCodeOutline16, IconCopyOutline16 } from '../icons/index.tsx'
import {
StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
} from './highlight.ts'
@@ -33,8 +31,6 @@ export interface CodeBlockProps {
copyLabel: string
/** Copy-button label during the post-copy confirmation window. */
copiedLabel: string
/** Headerless settled preview with hover/focus actions; copying always retains the source text. */
preview?: { content: ReactNode; previewLabel: string; sourceLabel: string } | undefined
}
/**
@@ -63,7 +59,7 @@ function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
)
}
export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel, preview }: CodeBlockProps) {
export function CodeBlock({ code, lang, streaming, className, lineNumbers = false, copyLabel, copiedLabel }: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
const rootRef = useRef<HTMLDivElement>(null)
@@ -147,13 +143,13 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
[streaming, highlighting, streamedBody, trimmed, lang, loaded],
)
const [copied, setCopied] = useState(false)
const [showSource, setShowSource] = useState(false)
const previewAvailable = preview !== undefined && streaming !== true
const showingPreview = previewAvailable && !showSource
const onCopy = useCallback(() => {
if (copied) return
void writeClipboard(trimmed).then((ok) => {
/* v8 ignore next -- both arms always mount a <pre>; trimmed is the
typed fallback if the DOM shape ever diverges. */
const text = rootRef.current?.querySelector('pre')?.textContent ?? trimmed
void writeClipboard(text).then((ok) => {
if (!ok) return
setCopied(true)
window.setTimeout(() => { setCopied(false) }, 1000)
@@ -175,22 +171,8 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
<div dangerouslySetInnerHTML={{ __html: html }} />
)
const copyActionLabel = copied ? copiedLabel : copyLabel
const copyAction = (
<button
type="button"
className={clsx(css.copyButton, previewAvailable && css.iconButton)}
aria-label={previewAvailable ? copyActionLabel : undefined}
onClick={onCopy}
>
{previewAvailable
? <span aria-hidden="true">{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}</span>
: copyActionLabel}
</button>
)
return (
<div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, previewAvailable && css.preview, showingPreview && css.showingPreview, className)}
<div ref={rootRef} className={clsx(css.block, 'md-code-block', lineNumbers && css.numbered, className)}
data-line-numbers={lineNumbers || undefined}
style={sourceLines === undefined ? undefined : {
'--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
@@ -198,28 +180,15 @@ export function CodeBlock({ code, lang, streaming, className, lineNumbers = fals
<div className={css.bannerWrap}>
{/* The attribute carries no style here; owner stylesheets use it to pin the banner (sidebar code preview, horizontal scroll). */}
<div className={css.banner} data-code-block-banner>
{!previewAvailable && <div className={css.infostring}>{lang ?? ''}</div>}
<div className={css.infostring}>{lang ?? ''}</div>
<div className={css.action}>
{previewAvailable && (
<Tooltip label={showingPreview ? preview.sourceLabel : preview.previewLabel} side="top">
<button
type="button"
className={clsx(css.copyButton, css.iconButton)}
aria-label={showingPreview ? preview.sourceLabel : preview.previewLabel}
onClick={() => { setShowSource(!showSource) }}
>
<span aria-hidden="true"><IconCodeOutline16 /></span>
</button>
</Tooltip>
)}
{previewAvailable
? <Tooltip label={copyActionLabel} side="top">{copyAction}</Tooltip>
: copyAction}
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? copiedLabel : copyLabel}
</button>
</div>
</div>
</div>
{previewAvailable && <div hidden={!showingPreview}>{preview.content}</div>}
{!showingPreview && body}
{body}
</div>
)
}
@@ -1,35 +0,0 @@
.canvas {
overflow: auto;
padding: 16px;
background: var(--dsw-alias-markdown-code-block);
border-radius: 12px;
}
.diagram {
display: block;
max-width: 100%;
height: auto;
margin: 0 auto;
}
.status {
padding: 16px;
color: var(--dsw-alias-label-secondary);
}
.staging {
position: fixed;
top: 0;
left: -100000px;
visibility: hidden;
pointer-events: none;
}
.frame {
display: block;
width: 100%;
height: 400px;
border: 0;
border-radius: 12px;
background: var(--dsw-alias-markdown-code-block);
}
@@ -1,71 +0,0 @@
/** Read-only diagram preview with source fallback and per-source async ownership. */
import { useEffect, useState } from 'react'
import css from './SourcePreview.module.css'
import { readPreviewTheme } from './preview-theme.ts'
/** Localized preview states; the diagram source remains verbatim. */
export interface PreviewLabels {
diagram: string
loading: string
error: string
}
type Result = { kind: 'ok'; code: string; src: string } | { kind: 'error'; code: string }
/* v8 ignore next 3 -- closed-union backstop; only reached if a result is forged */
function assertNever(value: never): never {
throw new Error(`unreachable preview result: ${String(value)}`)
}
/**
* Display a complete document or diagram, retaining the source when rendering fails.
* @param props - Source and complete localized labels. Source and document theme changes cancel obsolete renders.
* @returns A loading status, an inert image or sandboxed document, or an error with the original source.
*/
export function SourcePreview({ code, labels, render, document: isDocument = false }: {
code: string
labels: PreviewLabels
render: (code: string, signal: AbortSignal) => string | Promise<string>
document?: boolean
}) {
const [result, setResult] = useState<Result | null>(null)
useEffect(() => {
let controller: AbortController | undefined
let palette: string | undefined
const update = () => {
const next = JSON.stringify(readPreviewTheme())
if (next === palette) return
palette = next
controller?.abort()
const current = new AbortController()
controller = current
void (async () => await render(code, current.signal))().then(
(src) => { if (!current.signal.aborted) setResult({ kind: 'ok', code, src }) },
() => { if (!current.signal.aborted) setResult({ kind: 'error', code }) },
)
}
// Isolated image/frame documents cannot inherit the host's CSS variables.
const observer = new MutationObserver(update)
observer.observe(document.documentElement, { attributes: true, attributeFilter: ['style'] })
observer.observe(document.body, { attributes: true, attributeFilter: ['style', 'data-ds-dark-theme'] })
update()
return () => { observer.disconnect(); controller?.abort() }
}, [code, render])
if (result?.code !== code) return <div className={css.status} role="status">{labels.loading}</div>
switch (result.kind) {
case 'ok':
if (isDocument) return <iframe className={css.frame} title={labels.diagram} sandbox="" referrerPolicy="no-referrer" srcDoc={result.src} />
return <div className={css.canvas}><img className={css.diagram} src={result.src} alt={labels.diagram} /></div>
case 'error':
return (
<div>
<div className={css.status} role="status">{labels.error}</div>
<pre><code>{code}</code></pre>
</div>
)
/* v8 ignore next -- closed-union backstop; only reached if a result is forged */
default: return assertNever(result)
}
}
@@ -1,37 +0,0 @@
/** Lazy Graphviz WebAssembly layout; generated SVG is displayed only as an inert image. */
import type { instance, RenderOptions } from '@viz-js/viz'
import { renderSvg } from './preview-document.ts'
import { readPreviewTheme } from './preview-theme.ts'
let runtime: ReturnType<typeof instance> | undefined
/**
* Render Graphviz DOT with document-theme defaults; authored attributes override those defaults.
* @param code - Complete Graphviz DOT source.
* @param signal - Cancels work waiting for the runtime; synchronous layout cannot be interrupted.
* @param options - Native overrides; attribute maps merge over theme defaults and output remains SVG regardless of format.
* @returns An SVG image data URL. Import, layout, invalid source, and cancellation failures reject.
*/
export async function renderGraphviz(code: string, signal: AbortSignal, options?: RenderOptions): Promise<string> {
runtime ??= import('@viz-js/viz').then(module => module.instance()).catch((error: unknown) => {
runtime = undefined
throw error
})
const viz = await runtime
signal.throwIfAborted()
const palette = readPreviewTheme()
const color = (value: string): string => value.replace(
/^rgb\(\s*(\d+)[, ]+\s*(\d+)[, ]+\s*(\d+)\s*\)$/,
(_match, r: string, g: string, b: string) => `#${[r, g, b].map(channel => Number(channel).toString(16).padStart(2, '0')).join('')}`,
)
return renderSvg(viz.renderString(code, {
engine: 'dot', ...options, format: 'svg',
graphAttributes: { bgcolor: 'transparent', fontcolor: color(palette.text), ...options?.graphAttributes },
nodeAttributes: {
color: color(palette.border), fontcolor: color(palette.text), fillcolor: color(palette.surface), style: 'filled',
...options?.nodeAttributes,
},
edgeAttributes: { color: color(palette.line), fontcolor: color(palette.text), ...options?.edgeAttributes },
}), signal)
}
@@ -1,97 +0,0 @@
/** Lazy Mermaid runtime; each render owns and removes its temporary measurement DOM. */
import type { Mermaid, MermaidConfig } from 'mermaid'
import clsx from 'clsx'
import css from './SourcePreview.module.css'
import { readPreviewTheme } from './preview-theme.ts'
let runtime: Promise<Mermaid> | undefined
let nextDiagramId = 0
let pending: Promise<unknown> = Promise.resolve()
function loadMermaid(): Promise<Mermaid> {
runtime ??= import('mermaid').then(({ default: mermaid }) => mermaid).catch((error: unknown) => {
runtime = undefined
throw error
})
return runtime
}
function initialize(mermaid: Mermaid, config: MermaidConfig | undefined): void {
const palette = readPreviewTheme()
mermaid.initialize({
theme: 'base',
...config,
startOnLoad: false,
securityLevel: 'strict',
suppressErrorRendering: true,
themeVariables: {
darkMode: palette.dark,
background: palette.background,
primaryColor: palette.surface,
primaryTextColor: palette.text,
primaryBorderColor: palette.border,
secondaryColor: palette.surface,
tertiaryColor: palette.surface,
lineColor: palette.line,
textColor: palette.text,
actorBkg: palette.surface,
actorBorder: palette.border,
actorTextColor: palette.text,
actorLineColor: palette.border,
signalColor: palette.line,
signalTextColor: palette.text,
labelBoxBkgColor: palette.surface,
labelTextColor: palette.text,
edgeLabelBackground: palette.background,
...config?.themeVariables as Record<string, unknown> | undefined,
},
htmlLabels: false,
// Mermaid still accepts this legacy override alongside the global HTML-label setting.
// oxlint-disable-next-line typescript/no-deprecated
flowchart: { ...config?.flowchart, htmlLabels: false },
secure: [
'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges',
'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart',
...(config?.secure ?? []),
],
})
}
/**
* Render untrusted diagram source using the document palette, without installing SVG or link handlers in the UI.
* @param code - Complete Mermaid source.
* @param signal - Cancels work waiting for the runtime; an active Mermaid render finishes before cleanup.
* @param config - Native overrides of theme defaults; strict security, manual rendering, and disabled HTML labels remain fixed.
* @returns An SVG data URL. Import, parse, rendering, and cancellation failures reject.
*/
export async function renderMermaid(code: string, signal: AbortSignal, config?: MermaidConfig): Promise<string> {
const task = pending.then(() => renderOne(code, signal, config))
pending = task.catch(() => { /* The caller owns rejection; subsequent diagrams still render. */ })
return task
}
async function renderOne(code: string, signal: AbortSignal, config: MermaidConfig | undefined): Promise<string> {
const mermaid = await loadMermaid()
signal.throwIfAborted()
initialize(mermaid, config)
const stage = document.createElement('div')
stage.className = clsx(css.staging)
stage.setAttribute('aria-hidden', 'true')
document.body.append(stage)
try {
// Initialization and rendering share the queue because Mermaid owns global configuration.
const { svg } = await mermaid.render(`dsh-mermaid-${nextDiagramId++}`, code, stage)
const root = new DOMParser().parseFromString(svg, 'image/svg+xml').documentElement
const viewBox = root.getAttribute('viewBox')
if (viewBox !== null) {
// An SVG image needs intrinsic dimensions; Mermaid's percentage width is for inline SVG.
const [, , width, height] = viewBox.split(/\s+/) as [string, string, string, string]
root.setAttribute('width', width)
root.setAttribute('height', height)
}
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(new XMLSerializer().serializeToString(root))}`
} finally {
stage.remove()
}
}
@@ -1,44 +0,0 @@
/** Static HTML documents and SVG image URLs without executable source markup. */
import DOMPurify from 'dompurify'
import { readPreviewTheme } from './preview-theme.ts'
// This policy belongs to the preview document, before any source-controlled markup.
const CSP = "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data:; font-src data:; base-uri 'none'; form-action 'none'"
function documentOf(body: string): string {
const scheme = readPreviewTheme().dark ? 'dark' : 'light'
return `<!doctype html><html style="color-scheme:${scheme}"><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${CSP}"></head><body>${body}</body></html>`
}
/**
* Prepare static HTML for an iframe without scripts, same-origin access, or navigation.
* @param code - Untrusted HTML document or fragment; inline styles and embedded images are retained.
* @param signal - Prevents preparation after the preview owner is cancelled.
* @returns A sanitized srcdoc document. Copying uses the original source, not this document.
*/
export function renderHtml(code: string, signal: AbortSignal): string {
signal.throwIfAborted()
const body = DOMPurify.sanitize(code, {
WHOLE_DOCUMENT: true,
FORBID_TAGS: ['script', 'iframe', 'object', 'embed', 'base', 'meta', 'link'],
FORBID_ATTR: ['href', 'xlink:href', 'action', 'formaction', 'target'],
})
return documentOf(body)
}
/**
* Prepare an SVG image URL without activating SVG scripts, links, or external resources.
* @param code - Complete SVG XML; malformed XML or a non-SVG root throws.
* @param signal - Prevents preparation after the preview owner is cancelled.
* @returns A data URL for an image whose intrinsic dimensions determine the preview height.
*/
export function renderSvg(code: string, signal: AbortSignal): string {
signal.throwIfAborted()
const parsed = new DOMParser().parseFromString(code, 'image/svg+xml')
if (parsed.querySelector('parsererror') !== null || parsed.documentElement.localName !== 'svg'
|| parsed.documentElement.namespaceURI !== 'http://www.w3.org/2000/svg') {
throw new Error('Invalid SVG document')
}
return `data:image/svg+xml;charset=utf-8,${encodeURIComponent(code)}`
}
@@ -1,27 +0,0 @@
/** Resolved document colors used by isolated diagram renderers. */
interface PreviewTheme {
dark: boolean
background: string
surface: string
text: string
line: string
border: string
}
/**
* Read the palette after the application's theme presenter updates the DOM.
* @returns Semantic preview colors and the resolved browser color scheme.
*/
export function readPreviewTheme(): PreviewTheme {
const style = getComputedStyle(document.body)
const color = (name: string): string => style.getPropertyValue(`--dsw-alias-${name}`).trim()
return {
dark: getComputedStyle(document.documentElement).colorScheme === 'dark',
background: color('markdown-code-block'),
surface: color('bg-layer-2'),
text: color('label-primary'),
line: color('label-secondary'),
border: color('label-tertiary'),
}
}
@@ -23,11 +23,6 @@ import type * as Md from 'mdast'
import type {} from 'mdast-util-math'
import { normalizeUri } from 'micromark-util-sanitize-uri'
import { CodeBlock } from './CodeBlock.tsx'
import { SourcePreview } from './SourcePreview.tsx'
import { renderGraphviz } from './graphviz.ts'
import { renderHtml, renderSvg } from './preview-document.ts'
import { renderMermaid } from './mermaid.ts'
import type { PreviewLabels } from './SourcePreview.tsx'
import { renderTexToReact } from './katex.tsx'
import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx'
import type { PositionedBlock } from './incremental.ts'
@@ -45,15 +40,6 @@ export interface MarkdownCodeLabels {
export interface MarkdownLabels {
code: MarkdownCodeLabels
footnotes: string
/** Opt into settled Mermaid, Graphviz, SVG, and static HTML previews with complete localized labels. */
preview?: {
mermaid: PreviewLabels
graphviz: PreviewLabels
svg: PreviewLabels
html: PreviewLabels
preview: string
source: string
}
}
function sanitizeUrl(url: string): string {
@@ -408,26 +394,10 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
streaming={context.streaming}
copyLabel={context.labels.code.copyLabel}
copiedLabel={context.labels.code.copiedLabel}
preview={context.streaming ? undefined : fencePreview(lang, node.value, context.labels)}
/>
)
}
/** Resolve the supported fence language without treating arbitrary HTML in Markdown as a preview. */
function fencePreview(lang: string | undefined, code: string, labels: MarkdownLabels) {
if (labels.preview === undefined) return undefined
const kind = lang === 'dot' ? 'graphviz' : lang
if (kind !== 'mermaid' && kind !== 'graphviz' && kind !== 'svg' && kind !== 'html') return undefined
const render = { mermaid: renderMermaid, graphviz: renderGraphviz, svg: renderSvg, html: renderHtml }[kind]
return {
content: (
<SourcePreview key={kind} code={code} labels={labels.preview[kind]} render={render} document={kind === 'html'} />
),
previewLabel: labels.preview.preview,
sourceLabel: labels.preview.source,
}
}
/** A list is loose when it or any of its items is spread; every item then keeps its paragraphs. */
function listLoose(list: Md.List): boolean {
return (list.spread ?? false) || list.children.some(listItemLoose)
@@ -122,7 +122,7 @@ describe('CodeBlock', () => {
expect(view.getByText('plain text')).toBeTruthy()
})
it('shows the language banner and copies the displayed source text', async () => {
it('shows the language banner and copies the pre textContent', async () => {
vi.useFakeTimers()
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', {
@@ -1,126 +0,0 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { afterEach, describe, expect, it, vi } from 'vitest'
import { MarkdownText } from '../src/markdown/MarkdownText.tsx'
import { renderGraphviz } from '../src/markdown/graphviz.ts'
import { renderHtml, renderSvg } from '../src/markdown/preview-document.ts'
import { markdownLabels } from './labels.client.ts'
const status = { loading: 'Loading', error: 'Cannot preview' }
const preview = {
mermaid: { ...status, diagram: 'Mermaid diagram' },
graphviz: { ...status, diagram: 'Graphviz diagram' },
svg: { ...status, diagram: 'SVG preview' },
html: { ...status, diagram: 'HTML preview' },
preview: 'Preview', source: 'Source',
}
const svg = '<svg xmlns="http://www.w3.org/2000/svg" width="120" height="80"><text y="30">示例</text></svg>'
const cases = [
['graphviz', 'digraph { Input -> Preview }', preview.graphviz.diagram],
['dot', 'digraph { Input -> Preview }', preview.graphviz.diagram],
['svg', svg, preview.svg.diagram],
['html', '<style>h1 { color: green }</style><h1>Example</h1>', preview.html.diagram],
] as const
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
describe('Markdown fence previews', () => {
it('renders real DOT', async () => { await renderGraphviz('digraph { Input -> Preview }', new AbortController().signal) })
it.each(cases)('settles %s into an isolated default preview and copies original source in both views', async (lang, code, title) => {
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', Object.create(navigator, { clipboard: { value: { writeText } } }))
const props = { text: `\`\`\`${lang}\n${code}\n\`\`\``, labels: { ...markdownLabels, preview } }
const view = render(<MarkdownText {...props} streaming />)
expect(view.container.querySelector('iframe')).toBeNull()
expect(view.container.querySelector('pre code')?.textContent).toBe(code)
view.rerender(<MarkdownText {...props} />)
const findPreview = async () => lang === 'html' ? await screen.findByTitle(title) : await screen.findByRole('img', { name: title })
const element = await findPreview()
if (lang === 'html') {
expect(element.getAttribute('sandbox')).toBe('')
expect(element.getAttribute('referrerpolicy')).toBe('no-referrer')
expect(element.getAttribute('srcdoc')).toContain('Content-Security-Policy')
} else {
expect(element.getAttribute('src')).toMatch(/^data:image\/svg\+xml/)
expect(view.container.querySelector('iframe')).toBeNull()
expect(element.parentElement!.querySelector('svg')).toBeNull()
}
expect(view.container.querySelector('pre code')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
expect(view.container.querySelector('pre code')?.textContent).toBe(code)
expect(element.closest('[hidden]')).not.toBeNull()
fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel }))
await waitFor(() => { expect(writeText).toHaveBeenCalledWith(code) })
fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
expect(await findPreview()).toBe(element)
expect(element.closest('[hidden]')).toBeNull()
view.unmount()
render(<MarkdownText {...props} />)
await findPreview()
fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel }))
await waitFor(() => { expect(writeText).toHaveBeenCalledTimes(2) })
})
it.each(['svg', 'graphviz'])('retains invalid %s source and recovers after replacement', async (lang) => {
const view = render(<MarkdownText text={`\`\`\`${lang}\nbroken\n\`\`\``} labels={{ ...markdownLabels, preview }} />)
await screen.findByText(status.error)
expect(view.container.querySelector('pre code')?.textContent).toBe('broken')
view.rerender(<MarkdownText text={`\`\`\`svg\n${svg}\n\`\`\``} labels={{ ...markdownLabels, preview }} />)
await screen.findByRole('img', { name: preview.svg.diagram })
expect(screen.queryByText(status.error)).toBeNull()
})
it('requires an opted-in code fence and leaves raw HTML and other languages unrendered', () => {
const view = render(<MarkdownText text={'```html\n<h1>Example</h1>\n```'} labels={markdownLabels} />)
expect(view.container.querySelector('pre code')?.textContent).toBe('<h1>Example</h1>')
view.rerender(<MarkdownText text={'<h1>Example</h1>\n\n```xml\n<node />\n```'} labels={{ ...markdownLabels, preview }} />)
expect(view.container.querySelector('iframe')).toBeNull()
expect(view.container.querySelector('h1')).toBeNull()
})
})
describe('static preview documents', () => {
it.each(['light', 'dark'])('uses the document %s color scheme for HTML previews', (scheme) => {
const previous = document.documentElement.style.colorScheme
try {
document.documentElement.style.colorScheme = scheme
const doc = renderHtml('<h1>Example</h1>', new AbortController().signal)
const parsed = new DOMParser().parseFromString(doc, 'text/html')
expect(parsed.documentElement.style.colorScheme).toBe(scheme)
} finally {
document.documentElement.style.colorScheme = previous
}
})
it('preserves HTML styling while removing scripts, navigation, and nested documents', () => {
const doc = renderHtml(`<!doctype html><html><head><style>h1 { color: green }</style>
<meta http-equiv="refresh" content="0;url=https://example.com"><base href="https://example.com"></head>
<body onload="alert(1)"><h1>Example</h1><script>alert(1)</script>
<a href="https://example.com">Link</a><iframe srcdoc="unsafe"></iframe>
<form action="https://example.com"><button formaction="https://example.com">Submit</button></form></body></html>`, new AbortController().signal)
const parsed = new DOMParser().parseFromString(doc, 'text/html')
expect(parsed.querySelector('h1')?.textContent).toBe('Example')
expect(parsed.querySelector('style')?.textContent).toBe('h1 { color: green }')
expect(parsed.querySelector('script, iframe, base, [href], [action], [formaction], [onload]')).toBeNull()
expect(parsed.querySelectorAll('meta[http-equiv]').length).toBe(1)
expect(parsed.querySelector('meta[http-equiv]')?.getAttribute('content')).toContain("default-src 'none'")
})
it('encodes SVG as an image rather than executable document markup', () => {
const code = '<svg xmlns="http://www.w3.org/2000/svg"><script>alert("test")</script></svg>'
const url = renderSvg(code, new AbortController().signal)
expect(url).toMatch(/^data:image\/svg\+xml;charset=utf-8,/)
expect(decodeURIComponent(url.slice(url.indexOf(',') + 1))).toBe(code)
})
it.each(['<html/>', '<svg/>', '<svg xmlns="http://www.w3.org/2000/svg"><g></svg>'])('rejects non-SVG or malformed XML: %s', (code) => {
expect(() => renderSvg(code, new AbortController().signal)).toThrow('Invalid SVG document')
})
it.each([renderHtml, renderSvg])('does not prepare a cancelled document', (renderDocument) => {
const controller = new AbortController()
controller.abort()
expect(() => renderDocument(svg, controller.signal)).toThrow(controller.signal.reason)
})
})
@@ -1,67 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, expect, it, vi } from 'vitest'
const instance = vi.fn()
beforeEach(() => {
vi.resetModules()
instance.mockReset()
vi.doMock('@viz-js/viz', () => ({ instance }))
})
afterEach(() => { vi.doUnmock('@viz-js/viz') })
it('retries failed initialization and reuses the loaded runtime', async () => {
const renderString = vi.fn().mockReturnValue('<svg xmlns="http://www.w3.org/2000/svg"/>')
instance.mockRejectedValueOnce(new Error('WASM unavailable')).mockResolvedValue({ renderString })
const { renderGraphviz } = await import('../src/markdown/graphviz.ts')
await expect(renderGraphviz('digraph {}', new AbortController().signal)).rejects.toThrow('WASM unavailable')
await expect(renderGraphviz('digraph {}', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
await renderGraphviz('digraph {}', new AbortController().signal)
expect(instance).toHaveBeenCalledTimes(2)
})
it('skips layout after cancellation during runtime loading', async () => {
const pending = Promise.withResolvers<{ renderString: ReturnType<typeof vi.fn> }>()
instance.mockReturnValue(pending.promise)
const { renderGraphviz } = await import('../src/markdown/graphviz.ts')
const controller = new AbortController()
const result = renderGraphviz('digraph {}', controller.signal)
const rejection = expect(result).rejects.toMatchObject({ name: 'AbortError' })
controller.abort()
const renderString = vi.fn()
pending.resolve({ renderString })
await rejection
expect(renderString).not.toHaveBeenCalled()
})
it('uses theme defaults without rewriting authored DOT attributes', async () => {
const previous = document.body.getAttribute('style')
try {
document.body.style.setProperty('--dsw-alias-label-primary', 'rgb(230, 231, 232)')
document.body.style.setProperty('--dsw-alias-label-secondary', '#aabbcc')
document.body.style.setProperty('--dsw-alias-label-tertiary', '#8899aa')
document.body.style.setProperty('--dsw-alias-bg-layer-2', '#222222')
const renderString = vi.fn().mockReturnValue('<svg xmlns="http://www.w3.org/2000/svg"/>')
instance.mockResolvedValue({ renderString })
const { renderGraphviz } = await import('../src/markdown/graphviz.ts')
const code = 'digraph { a [color="red"]; a -> b }'
await renderGraphviz(code, new AbortController().signal)
expect(renderString).toHaveBeenCalledWith(code, expect.objectContaining({
nodeAttributes: { color: '#8899aa', fontcolor: '#e6e7e8', fillcolor: '#222222', style: 'filled' },
edgeAttributes: { color: '#aabbcc', fontcolor: '#e6e7e8' },
}))
await renderGraphviz(code, new AbortController().signal, {
engine: 'neato', format: 'plain', yInvert: true,
graphAttributes: { ranksep: 1 }, nodeAttributes: { shape: 'box' }, edgeAttributes: { color: 'red' },
})
expect(renderString).toHaveBeenLastCalledWith(code, {
engine: 'neato', format: 'svg', yInvert: true,
graphAttributes: { bgcolor: 'transparent', fontcolor: '#e6e7e8', ranksep: 1 },
nodeAttributes: { color: '#8899aa', fontcolor: '#e6e7e8', fillcolor: '#222222', style: 'filled', shape: 'box' },
edgeAttributes: { color: 'red', fontcolor: '#e6e7e8' },
})
} finally {
if (previous === null) document.body.removeAttribute('style')
else document.body.setAttribute('style', previous)
}
})
@@ -1,133 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const initialize = vi.fn()
const renderDiagram = vi.fn()
beforeEach(() => {
vi.resetModules()
vi.resetAllMocks()
vi.doMock('mermaid', () => ({ default: { initialize, render: renderDiagram } }))
})
afterEach(() => { vi.doUnmock('mermaid') })
describe('Mermaid runtime', () => {
it('allows another attempt after the runtime import fails', async () => {
vi.doMock('mermaid', () => { throw new Error('runtime unavailable') })
const { renderMermaid } = await import('../src/markdown/mermaid.ts')
await expect(renderMermaid('first', new AbortController().signal)).rejects.toThrow()
expect(initialize).not.toHaveBeenCalled()
vi.doMock('mermaid', () => ({ default: { initialize, render: renderDiagram } }))
renderDiagram.mockResolvedValue({ svg: '<svg/>' })
await expect(renderMermaid('retry', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
expect(renderDiagram).toHaveBeenCalledOnce()
})
it('accepts native configuration without weakening preview restrictions', async () => {
const { renderMermaid } = await import('../src/markdown/mermaid.ts')
renderDiagram.mockResolvedValue({ svg: '<svg/>' })
await renderMermaid('flowchart LR', new AbortController().signal, {
// oxlint-disable-next-line typescript/no-deprecated
layout: 'dagre', flowchart: { nodeSpacing: 40, htmlLabels: true },
themeVariables: { primaryColor: '#123456' },
securityLevel: 'loose', startOnLoad: true, suppressErrorRendering: false, htmlLabels: true, secure: [],
})
expect(initialize.mock.calls[0]![0]).toMatchObject({
layout: 'dagre', flowchart: { nodeSpacing: 40, htmlLabels: false },
themeVariables: { primaryColor: '#123456', darkMode: false },
securityLevel: 'strict', startOnLoad: false, suppressErrorRendering: true, htmlLabels: false,
})
expect(initialize.mock.calls[0]![0]).toHaveProperty('secure', expect.arrayContaining(['securityLevel']))
})
it('reuses the runtime and removes each measurement container after rendering', async () => {
const { renderMermaid } = await import('../src/markdown/mermaid.ts')
expect(initialize).not.toHaveBeenCalled()
const stages: HTMLElement[] = []
const ids: string[] = []
renderDiagram.mockImplementation(async (id: string, code: string, stage: HTMLElement) => {
expect(stage.isConnected).toBe(true)
expect(stage.getAttribute('aria-hidden')).toBe('true')
stages.push(stage)
ids.push(id)
return { svg: `<svg>${code}</svg>` }
})
const results = await Promise.all([
renderMermaid('中文', new AbortController().signal),
renderMermaid('second', new AbortController().signal),
])
expect(initialize).toHaveBeenCalledTimes(2)
expect(initialize).toHaveBeenCalledWith(expect.objectContaining({
startOnLoad: false, securityLevel: 'strict', suppressErrorRendering: true, htmlLabels: false,
secure: [
'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges',
'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart',
],
}))
expect(results.map(url => decodeURIComponent(url.split(',')[1]!))).toEqual(['<svg>中文</svg>', '<svg>second</svg>'])
expect(new Set(ids).size).toBe(2)
expect(stages.every(stage => !stage.isConnected)).toBe(true)
})
it('keeps theme initialization with its render and recovers the queue after failure', async () => {
const { renderMermaid } = await import('../src/markdown/mermaid.ts')
const first = Promise.withResolvers<{ svg: string }>()
renderDiagram.mockReturnValueOnce(first.promise).mockResolvedValue({ svg: '<svg/>' })
const light = renderMermaid('first', new AbortController().signal)
const failure = expect(light).rejects.toThrow('invalid')
await vi.waitFor(() => { expect(renderDiagram).toHaveBeenCalledOnce() })
const previous = document.documentElement.style.colorScheme
try {
document.documentElement.style.colorScheme = 'dark'
const dark = renderMermaid('second', new AbortController().signal)
await Promise.resolve()
expect(initialize).toHaveBeenCalledOnce()
first.reject(new Error('invalid'))
await failure
await dark
expect(initialize.mock.calls[1]![0]).toMatchObject({ themeVariables: { darkMode: true } })
} finally {
document.documentElement.style.colorScheme = previous
}
})
it('removes measurement DOM even when Mermaid rejects', async () => {
const { renderMermaid } = await import('../src/markdown/mermaid.ts')
let stage: HTMLElement | undefined
const error = new Error('bad diagram')
renderDiagram.mockImplementation(async (_id: string, _code: string, target: HTMLElement) => {
stage = target
target.innerHTML = '<svg>partial render</svg>'
throw error
})
await expect(renderMermaid('bad', new AbortController().signal)).rejects.toBe(error)
expect(stage?.isConnected).toBe(false)
})
it('preserves intrinsic diagram size instead of stretching percentage-width SVG images', async () => {
const { renderMermaid } = await import('../src/markdown/mermaid.ts')
renderDiagram.mockResolvedValue({ svg: '<svg xmlns="http://www.w3.org/2000/svg" width="100%" viewBox="0 0 420 180"/>' })
const url = await renderMermaid('flowchart LR', new AbortController().signal)
const svg = new DOMParser().parseFromString(decodeURIComponent(url.split(',')[1]!), 'image/svg+xml').documentElement
expect(svg.getAttribute('width')).toBe('420')
expect(svg.getAttribute('height')).toBe('180')
})
it('does not start cancelled work after loading the runtime', async () => {
const { renderMermaid } = await import('../src/markdown/mermaid.ts')
const controller = new AbortController()
controller.abort()
await expect(renderMermaid('unused', controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
expect(renderDiagram).not.toHaveBeenCalled()
})
it('allows another attempt after runtime initialization fails', async () => {
const { renderMermaid } = await import('../src/markdown/mermaid.ts')
initialize.mockImplementationOnce(() => { throw new Error('initialization failed') })
await expect(renderMermaid('first', new AbortController().signal)).rejects.toThrow('initialization failed')
renderDiagram.mockResolvedValue({ svg: '<svg/>' })
await expect(renderMermaid('retry', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
expect(initialize).toHaveBeenCalledTimes(2)
})
})
@@ -1,162 +0,0 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SourcePreview } from '../src/markdown/SourcePreview.tsx'
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
import { renderMermaid } from '../src/markdown/mermaid.ts'
import { MarkdownText } from '../src/markdown/MarkdownText.tsx'
import { markdownLabels } from './labels.client.ts'
vi.mock('../src/markdown/mermaid.ts', () => ({ renderMermaid: vi.fn() }))
const labels = {
diagram: 'Mermaid diagram', loading: 'Rendering diagram…', error: 'Unable to render this diagram.',
preview: 'Preview', source: 'Source',
}
const preview = {
preview: labels.preview, source: labels.source,
mermaid: labels, graphviz: labels, svg: labels, html: labels,
}
const source = 'flowchart LR\n A[Input] --> B[Preview]'
const imageUrl = 'data:image/svg+xml;charset=utf-8,%3Csvg%2F%3E'
afterEach(() => { cleanup(); vi.unstubAllGlobals() })
beforeEach(() => { vi.resetAllMocks() })
describe('SourcePreview', () => {
it('updates mounted previews on theme changes and ignores obsolete completions', async () => {
const previous = document.documentElement.style.colorScheme
const old = Promise.withResolvers<string>()
vi.mocked(renderMermaid).mockReturnValueOnce(old.promise).mockResolvedValue(imageUrl)
const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
try {
const oldSignal = vi.mocked(renderMermaid).mock.calls[0]![1]
await act(async () => { document.documentElement.style.colorScheme = 'dark' })
expect(oldSignal.aborted).toBe(true)
const image = await screen.findByRole('img')
await act(async () => { old.resolve('obsolete') })
expect(image.getAttribute('src')).toBe(imageUrl)
await act(async () => { document.body.style.setProperty('--unrelated', '1') })
expect(renderMermaid).toHaveBeenCalledTimes(2)
await act(async () => { document.documentElement.style.colorScheme = 'light' })
expect(renderMermaid).toHaveBeenCalledTimes(3)
view.unmount()
await act(async () => { document.documentElement.style.colorScheme = 'dark' })
expect(renderMermaid).toHaveBeenCalledTimes(3)
} finally {
view.unmount()
document.documentElement.style.colorScheme = previous
document.body.style.removeProperty('--unrelated')
}
})
it('shows loading until rendering completes, then displays an image without inserting SVG', async () => {
const pending = Promise.withResolvers<string>()
vi.mocked(renderMermaid).mockReturnValue(pending.promise)
const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
expect(screen.getByRole('status').textContent).toBe(labels.loading)
expect(screen.queryByRole('img')).toBeNull()
await act(async () => { pending.resolve(imageUrl) })
expect(screen.getByRole('img', { name: labels.diagram }).getAttribute('src')).toBe(imageUrl)
expect(view.container.querySelector('svg')).toBeNull()
expect(screen.queryByRole('status')).toBeNull()
})
it('retains invalid source and recovers when the source changes', async () => {
vi.mocked(renderMermaid).mockRejectedValueOnce(new Error('Parse error'))
const view = render(<SourcePreview render={renderMermaid} code="invalid" labels={labels} />)
expect((await screen.findByText(labels.error)).getAttribute('role')).toBe('status')
expect(view.container.querySelector('pre code')?.textContent).toBe('invalid')
const next = Promise.withResolvers<string>()
vi.mocked(renderMermaid).mockReturnValue(next.promise)
view.rerender(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
expect(screen.getByRole('status').textContent).toBe(labels.loading)
expect(view.container.querySelector('pre')).toBeNull()
await act(async () => { next.resolve(imageUrl) })
expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
})
it.each(['resolve', 'reject'] as const)('ignores a stale %s after a newer source finishes', async (outcome) => {
const old = Promise.withResolvers<string>()
const next = Promise.withResolvers<string>()
vi.mocked(renderMermaid).mockReturnValueOnce(old.promise).mockReturnValueOnce(next.promise)
const view = render(<SourcePreview render={renderMermaid} code="old" labels={labels} />)
const oldSignal = vi.mocked(renderMermaid).mock.calls[0]![1]
view.rerender(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
expect(oldSignal.aborted).toBe(true)
await act(async () => { next.resolve(imageUrl) })
await act(async () => {
if (outcome === 'resolve') old.resolve('obsolete-image')
else old.reject(new Error('obsolete-error'))
})
expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
expect(screen.queryByRole('status')).toBeNull()
})
it('cancels the owner when unmounted while loading', async () => {
const pending = Promise.withResolvers<string>()
vi.mocked(renderMermaid).mockReturnValue(pending.promise)
const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
const signal = vi.mocked(renderMermaid).mock.calls[0]![1]
view.unmount()
expect(signal.aborted).toBe(true)
await act(async () => { pending.reject(new Error('cancelled')) })
expect(screen.queryByRole('img')).toBeNull()
})
})
describe('Markdown Mermaid fences', () => {
it('retains a preview when switching to numbered source', async () => {
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const view = render(<CodeBlock code={source} lang="mermaid" lineNumbers {...markdownLabels.code}
preview={{
content: <SourcePreview render={renderMermaid} code={source} labels={labels} />,
previewLabel: labels.preview, sourceLabel: labels.source,
}} />)
const diagram = await screen.findByRole('img', { name: labels.diagram })
expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
fireEvent.click(screen.getByRole('button', { name: labels.source }))
expect([...view.container.querySelectorAll('code > .line')].map(line => line.textContent)).toEqual(source.split('\n'))
expect(diagram.isConnected).toBe(true)
expect(screen.queryByRole('img')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: labels.preview }))
expect(screen.getByRole('img', { name: labels.diagram })).toBe(diagram)
expect(renderMermaid).toHaveBeenCalledOnce()
})
it('keeps streaming source literal, then offers preview, source and source copying', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
vi.stubGlobal('navigator', Object.create(navigator, { clipboard: { value: { writeText } } }))
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const props = { text: `\`\`\`mermaid\n${source}\n\`\`\``, labels: { ...markdownLabels, preview } }
const view = render(<MarkdownText {...props} streaming />)
expect(view.container.querySelector('pre code')?.textContent).toBe(source)
expect(renderMermaid).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: labels.source })).toBeNull()
view.rerender(<MarkdownText {...props} />)
const diagram = await screen.findByRole('img', { name: labels.diagram })
expect(screen.queryByText('mermaid')).toBeNull()
expect(screen.getByRole('button', { name: labels.source }).textContent).toBe('')
expect(screen.getByRole('button', { name: markdownLabels.code.copyLabel }).textContent).toBe('')
fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.copyLabel }))
await screen.findByRole('button', { name: markdownLabels.code.copiedLabel })
expect(writeText).toHaveBeenCalledWith(source)
fireEvent.click(screen.getByRole('button', { name: labels.source }))
expect(view.container.querySelector('pre code')?.textContent).toBe(source)
expect(screen.queryByRole('img')).toBeNull()
expect(diagram.isConnected).toBe(true)
expect(screen.queryByText('mermaid')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: labels.preview }))
expect(screen.getByRole('img', { name: labels.diagram })).toBe(diagram)
expect(screen.queryByRole('status')).toBeNull()
expect(renderMermaid).toHaveBeenCalledOnce()
})
it('leaves other languages and consumers without preview labels as code', () => {
const view = render(<MarkdownText text={`\`\`\`mermaid\n${source}\n\`\`\``} labels={markdownLabels} />)
expect(view.container.querySelector('pre code')?.textContent).toBe(source)
view.rerender(<MarkdownText text={'```text\nflowchart LR\n```'} labels={{ ...markdownLabels, preview }} />)
expect(view.container.querySelector('pre code')?.textContent).toBe('flowchart LR')
expect(renderMermaid).not.toHaveBeenCalled()
})
})
-14
View File
@@ -3327,18 +3327,12 @@ importers:
'@types/react-dom':
specifier: ~18.3.0
version: 18.3.7(@types/[email protected])
'@viz-js/viz':
specifier: 3.30.0
version: 3.30.0
anser:
specifier: ^2.3.5
version: 2.3.5
clsx:
specifier: ^2.0.0
version: 2.1.1
dompurify:
specifier: 3.4.11
version: 3.4.11
katex:
specifier: ^0.16.47
version: 0.16.47
@@ -3351,9 +3345,6 @@ importers:
mdast-util-math:
specifier: ^3.0.0
version: 3.0.0
mermaid:
specifier: 11.16.0
version: 11.16.0
micromark-core-commonmark:
specifier: ^2.0.3
version: 2.0.3
@@ -14295,9 +14286,6 @@ packages:
'@vitest/[email protected]':
resolution: {integrity: sha512-uOJamYALNhfJ6iolExyQM40yIQwDqYnkKtQ5VCiSe17E33H0aQ/u+1GlRuz4LZBk6Mm3sg90G9hEbmEt37C1Zg==}
'@viz-js/[email protected]':
resolution: {integrity: sha512-2zmcP55QY44uQfZ+GvgNHmGd8S6KUD+2eMur9AbYybSzrOd/m8nMUg0ZircJNiJRKGgVBKAE8kh0hAwpUFUFQQ==}
'@vscode/[email protected]':
resolution: {integrity: sha512-r3ktHSvbFycQNF6sl7sNDPocpsI7J+mEzh1IaZFkY0spm3k2Z9t8hPAeOK7+p0l6p6/swkQC14XWX01low+94Q==}
cpu: [arm64]
@@ -20907,8 +20895,6 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@viz-js/[email protected]': {}
'@vscode/[email protected]':
optional: true
@@ -3,7 +3,6 @@
import { describe, expect, it } from 'vitest'
import {
checkDshFamilyVersion,
checkWorkspaceManifest,
checkExperimentalDependencyIsolation,
checkExperimentalManifest,
expectedDshPackageFiles,
@@ -144,16 +143,3 @@ describe('package payload constraints', () => {
])
})
})
it('requires preview license notices in the published UI primitives payload', () => {
const name = '@deepseek-ai/dsh-client-ui-primitives'
const files = expectedDshPackageFiles({ name })
expect(files).toContain('THIRD_PARTY_PREVIEW_NOTICES.txt')
const check = (entries: readonly string[]) => checkWorkspaceManifest({
dir: 'packages/client/ui-primitives', manifest: { name, files: [...entries] },
}).filter(message => message.includes('package.json files'))
expect(check(files)).toEqual([])
expect(check(files.filter(file => file !== 'THIRD_PARTY_PREVIEW_NOTICES.txt'))).toEqual([
expect.stringContaining('package.json files must be'),
])
})
+1 -1
View File
@@ -155,7 +155,7 @@ const packageFileExtras: Readonly<Record<string, readonly string[]>> = {
// them through its own CSS pipeline, so the sheets are published artifacts.
// The glob covers whichever sheets a package emits; sourcemaps stay
// unpublished, as everywhere else in the repository.
'@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css', 'THIRD_PARTY_PREVIEW_NOTICES.txt'],
'@deepseek-ai/dsh-client-ui-primitives': ['lib/**/*.css'],
'@deepseek-ai/dsh-client-ui-dockkit': ['lib/**/*.css'],
'@deepseek-ai/dsh-client-web': ['lib/**/*.css'],
'@deepseek-ai/dsh-client-ui-theme': ['lib/styles'],
-24
View File
@@ -4,7 +4,6 @@ import { tmpdir } from 'node:os'
import { describe, expect, it } from 'vitest'
import {
CLAUDE_AGENT_SDK_PACKAGE,
assertPreviewDistribution,
assertRuntimeLicenses,
claudeDistributionFromManifest,
collectPythonDependencies,
@@ -396,26 +395,3 @@ describe('manifestPatterns', () => {
])
})
})
describe('preview distribution licenses', () => {
const dependencies = { mermaid: '11.16.0', '@viz-js/viz': '3.30.0', dompurify: '3.4.11' }
const sources = [
'pkg:docker/emscripten/[email protected]?platform=linux%2Famd64',
'https://github.com/libexpat/libexpat/releases/download/R_2_8_4/expat-2.8.4.tar.gz',
'https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz',
]
it('accepts the reviewed wrapper and native sources', () => {
expect(() => { assertPreviewDistribution(dependencies, sources) }).not.toThrow()
const notices = readFileSync(resolve(root, 'packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt'), 'utf8')
expect(notices).toContain('Eclipse Public License - v 2.0')
expect(notices).toContain(sources[2])
expect(notices).toContain(readFileSync(resolve(root, 'packages/client/ui-primitives/node_modules/mermaid/LICENSE'), 'utf8').trim())
expect(notices).toContain(readFileSync(resolve(root, 'packages/client/ui-primitives/node_modules/dompurify/LICENSE'), 'utf8').trim())
})
it.each(Object.keys(dependencies))('rejects an unreviewed %s upgrade', (name) => {
expect(() => { assertPreviewDistribution({ ...dependencies, [name]: '99.0.0' }, sources) }).toThrow('preview notices: review')
})
it.each([sources.slice(1), [...sources, 'https://example.com/new-library'], [...sources.slice(0, 2), 'graphviz-next']])('rejects changed embedded sources', (...uris) => {
expect(() => { assertPreviewDistribution(dependencies, uris) }).toThrow('native sources')
})
})
-34
View File
@@ -685,28 +685,6 @@ ${rows.join('\n')}
`
}
/**
* Require renewed license review when a preview dependency or embedded source changes.
* @param dependencies - UI primitives' browser build dependencies.
* @param sources - Native source URIs from the installed Viz.js build provenance.
* @returns Nothing; throws when a version or native source differs from the reviewed distribution.
*/
export function assertPreviewDistribution(dependencies: Record<string, string>, sources: readonly string[]): void {
for (const [name, version] of Object.entries({ mermaid: '11.16.0', '@viz-js/viz': '3.30.0', dompurify: '3.4.11' })) {
if (dependencies[name] !== version) {
throw new Error(`preview notices: review ${name} ${String(dependencies[name])} licenses and update THIRD_PARTY_PREVIEW_NOTICES.txt`)
}
}
const expected = [
'pkg:docker/emscripten/[email protected]?platform=linux%2Famd64',
'https://github.com/libexpat/libexpat/releases/download/R_2_8_4/expat-2.8.4.tar.gz',
'https://gitlab.com/api/v4/projects/4207231/packages/generic/graphviz-releases/16.0.0/graphviz-16.0.0.tar.gz',
]
if (sources.length !== expected.length || expected.some(uri => !sources.includes(uri))) {
throw new Error('preview notices: review the changed Viz.js native sources and their distribution licenses')
}
}
/**
* Render the complete notices document.
* @returns The exact bytes THIRD_PARTY_NOTICES.md must hold after resolving browser inputs.
@@ -717,16 +695,6 @@ export async function render(): Promise<string> {
// the manifests map it was resolved from; render() owns that single load.
workspaceLinkedManifestCache.clear()
const { manifests, names } = loadWorkspaceManifests()
const previewPackage = 'packages/client/ui-primitives'
const provenance = JSON.parse(readFileSync(resolve(root, previewPackage, 'node_modules/@viz-js/viz/lib/provenance.json'), 'utf8')) as {
predicate: { buildDefinition: { resolvedDependencies: { uri: string }[] } }
}
const previewDependencies = manifests.get(`${previewPackage}/package.json`)?.devDependencies
if (previewDependencies === undefined) throw new Error('preview notices: missing UI primitives browser dependencies')
assertPreviewDistribution(
previewDependencies,
provenance.predicate.buildDefinition.resolvedDependencies.map(source => source.uri),
)
const npm = collectNpmDeps(manifests, names, browser)
const runtimeDeps = npm.filter(dep => dep.runtime)
const devDeps = npm.filter(dep => !dep.runtime)
@@ -767,8 +735,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
${renderNpmTable(runtimeDeps)}
The Markdown preview distribution also contains Graphviz 16.0.0 (EPL-2.0), Expat 2.8.4 (MIT), and Emscripten 5.0.7 runtime code (MIT/NCSA) inside \`@viz-js/viz\` 3.30.0. The wrapper's MIT metadata does not relicense these components. [Preview notices](packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt) preserve their full license texts and Graphviz source availability, together with Mermaid's MIT and DOMPurify's selected Apache-2.0 terms. The UI primitives npm package includes this file; the Web build emits it as \`preview-third-party-notices.txt\`.
pnpm applies local patches to the following packages at install time, so shipped artifacts carry modified copies; each patch file is the complete record of the modification:
${patchedLines.join('\n')}
-1
View File
@@ -67,7 +67,6 @@
"apps/web/tests/math-rendering.e2e.ts",
"apps/web/tests/markdown-cjk-strong.e2e.ts",
"apps/web/tests/markdown-inline-code-links.e2e.ts",
"apps/web/tests/markdown-mermaid.e2e.ts",
"apps/web/tests/clickable-links-gallery.e2e.ts",
"apps/web/tests/queue-actions.e2e.ts",
"apps/web/tests/queue-image.e2e.ts",