Merge pull request #4442 from deepseek-harness/revert-3906-perf/code-block-preview-renders

Revert "feat(client): optimize code block previews and source highlighting"
This commit is contained in:
imccyu authored and GitHub committed 2026-09-17 16:41:25 +08:00
commit a4452b2ed2
94 files changed
+455 -6001

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/feature/2026-09-09-markdown-static-previews.md
2026-09-09-markdown-static-previews.md: cb63b0ccd02abfaf148e61ef452cb714765bfffe
2026-09-09-markdown-static-previews.zh.md: 7dec68564136d2e9d677c1fd03d8a0b6840cf81b
@@ -1,47 +0,0 @@
# Agent Note: Static Markdown fence previews
Status: implemented
English | [中文](2026-09-09-markdown-static-previews.zh.md)
## Problem
Readers need an optional way to inspect Mermaid and DOT diagrams and SVG artwork directly in Assistant replies while retaining code as the primary representation. 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`, and `svg` 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. HTML, consumers without preview labels, and streaming messages retain code.
`CodeBlock.preview` is a standard source-preview descriptor containing the renderer and complete localized output and control labels; callers do not pass React nodes. `MarkdownText` creates one descriptor catalog per preview-label identity, and every settled render reuses its language entries while source text, file mentions, or local-image vocabulary changes. The [CodeBlock interaction decision](2026-09-10-codeblock-preview-interaction.md) supersedes this note's source-first default and release-on-toggle lifetime; it owns the current controls and result retention. The [preview sizing decision](../simplification/2026-09-14-source-sized-code-block-previews.md) owns current geometry. This note retains the independent rendering, security, and distribution decisions.
`SourcePreview` owns pending work, failure, and cancellation of stale result publication. Pending previews show localized status. 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. Parsed flowchart nodes are inspected before layout and image nodes are rejected: Mermaid's strict mode still fetches their resources during layout, before SVG image isolation applies. 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. Intersecting previews and open lightboxes 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 and SVG colors remain intact.
SVG is parsed as XML and displayed with Graphviz output as inert images, so SVG scripts, links, and external resources never become active. A magnifier opens the body-portaled dialog whose viewport fit, pan, and zoom behavior is owned by the [CodeBlock interaction decision](2026-09-10-codeblock-preview-interaction.md).
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 attestation 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 its DOMPurify dependency's selected Apache-2.0 terms. 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.
Source highlighting uses the shared lazy Shiki highlighter for both streamed and settled diagram fences. SVG resolves to XML; Mermaid selects the upstream grammar's `#mermaid` body rules because CodeBlock has already removed the Markdown fence. DOT uses the MIT TextMate grammar pinned in the [third-party notices](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt), with patterns and scopes preserved by mechanical conversion. This keeps source coloring independent of preview rendering and avoids maintaining a separate DOT tokenizer.
## Alternatives considered
**Show visualization by default with overlay icon actions.** The source-first decision favored authored code and avoided diagram work before an explicit request. The [interaction decision](2026-09-10-codeblock-preview-interaction.md) supersedes that default while retaining actions in the banner.
**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).
**Pass rendered JSX through `CodeBlock.preview`.** Caller-created elements split preview ownership between the renderer and the primitive. A standard data descriptor keeps rendering, result ownership, and controls in one component API.
**Preview HTML in a sandboxed frame.** HTML preview adds sanitization, iframe policy, a distinct document layout, and another browser dependency. Source display covers the code-review use case without that separate security and distribution surface.
**Insert rendered markup into Chat.** SVG styles and executable behavior would share the application document. Image mode disables SVG behavior while retaining the rendered result.
**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. `SourcePreview` associates a result with both its source and renderer and publishes nothing until the matching result settles. 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, HTML rendering, or interactive diagram links.
Component tests cover source copying, preview rendering, 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, inert SVG images, real image decoding, source toggling, English/Chinese UI snapshots, and served license text. Notices checks pin the reviewed wrapper version and native source downloads so upgrades require renewed review.
@@ -1,47 +0,0 @@
# Agent Note: 静态 Markdown fence 预览
Status: implemented
[English](2026-09-09-markdown-static-previews.md) | 中文
## Problem
读者需要在保留代码为主要表示的同时,按需直接查看 Assistant 回复中的 Mermaid 与 DOT 图表和 SVG 图像。这些源码不可信,而渲染器的 npm 许可证字段可能遗漏编译组件各自的分发义务。
## Decision
共享 Markdown 渲染器通过本地化的 `MarkdownLabels.preview` 和解析后的 fence 语言启用定稿后的 `mermaid`、`graphviz`/`dot` 和 `svg` fence。[UI primitives 包](../../../../packages/client/ui-primitives/README.zh.md)拥有 `SourcePreview`,它接收渲染器、源码和文案,不依赖 Session、文件或 Cordis。Mermaid 与 Graphviz 各自向同一组件提供 `.ts` renderer。HTML、未提供预览文案的调用方与流式消息保留代码显示。
`CodeBlock.preview` 是标准化的源码预览描述,包含 renderer 以及完整的本地化输出与控件文案;调用方不传入 React 节点。`MarkdownText` 按预览文案对象的 identity 创建一份描述目录,之后正文、文件提及或本地图片词表变化引起的每次定稿渲染都复用其中的语言条目。[CodeBlock 交互决策](2026-09-10-codeblock-preview-interaction.zh.md)取代本文的默认源码和切换时释放结果的选择,负责当前控件与结果保留。[预览尺寸决策](../simplification/2026-09-14-source-sized-code-block-previews.zh.md)负责当前几何行为。本文保留独立的渲染、安全与分发决策。
`SourcePreview` 负责待完成工作、失败和取消过期结果发布。待完成的预览显示本地化状态。替换源码和卸载组件会取消结果发布;在运行时加载完成前取消会跳过布局。失败时显示本地化错误与原始源码,用有效输入替换无效源码后可以恢复预览。
Mermaid 按需加载。共享队列将主题初始化与图表渲染一起串行执行,每次调用都在 `finally` 中删除临时测量 DOM。图表配置无法覆盖严格安全模式、禁用 HTML 标签、应用配色和错误渲染策略。布局前检查解析后的 flowchart 节点并拒绝图片节点;Mermaid 的严格模式仍会在图片节点布局时请求外部资源,最终 SVG 图片隔离无法阻止这一请求。生成的 SVG 作为图片显示,不安装链接或脚本。固有尺寸来自 SVG viewBox;大图缩小以适应宽度,画布使用代码块背景。与视口相交的预览和已打开的 lightbox 观察文档主题属性,仅在解析后的配色变化时重新生成;过期渲染不能发布结果。Graphviz 的默认颜色采用同一配色,DOT 与 SVG 中明确指定的颜色保持原样。
SVG 按 XML 解析后,与 Graphviz 输出一同作为不可执行图片显示,因此 SVG 脚本、链接与外部资源不会激活。放大镜会打开 body portal 对话框;其视口适应、平移和缩放行为由 [CodeBlock 交互决策](2026-09-10-codeblock-preview-interaction.zh.md)负责。
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 元数据策略。
源码高亮在流式与定稿图表 fence 中共用按需加载的 Shiki 高亮器。SVG 解析为 XML;Mermaid 选用上游语法的 `#mermaid` 正文规则,因为 CodeBlock 已去除 Markdown 围栏。DOT 使用[第三方声明](../../../../packages/client/ui-primitives/THIRD_PARTY_PREVIEW_NOTICES.txt)中固定的 MIT TextMate 语法,机械转换保留所有模式与 scope。这让源码着色独立于预览渲染,也避免维护单独的 DOT tokenizer。
## Alternatives considered
**默认显示可视化并使用浮层图标操作。** 默认源码的决策优先展示作者代码,并避免显式请求前执行图表工作。[交互决策](2026-09-10-codeblock-preview-interaction.zh.md)取代这一默认选择,同时继续将操作放在标题栏中。
**渲染每个流式片段。** 未完成的图表通常无效,反复布局会与文本流式输出争用资源。消息定稿可提供完整源码。
**在 Chat 内部渲染,或添加通用预览注册表。** 使用普通属性的共享基础组件即可满足复用,无需另加注册表或功能插件依赖。这遵循[共享控件规则](../architecture/2026-09-05-shared-client-control-primitives.zh.md)。
**通过 `CodeBlock.preview` 传入已渲染 JSX。** 调用方创建的元素会把预览所有权拆分到 renderer 与基础组件。标准数据描述让渲染、结果所有权与控件归入同一个组件 API。
**在 sandbox iframe 中预览 HTML。** HTML 预览需要清理策略、iframe 权限、独立文档布局和额外浏览器依赖。源码展示足以覆盖代码审阅场景,无需引入这组独立的安全与分发范围。
**把渲染标记插入 Chat。** SVG 样式和可执行行为会与应用共享文档。图片模式会禁用 SVG 行为,同时保留渲染结果。
**将 Viz.js 视为仅使用 MIT,或使用远程 Graphviz 服务。** 本地编译的 Graphviz 仍受其许可证约束;远程渲染会把对话内容发送到设备之外。内置渲染器保留源码可获取性与法律声明,无需网络渲染。
## Consequences
该功能改变呈现,不改变持久化消息、provider 请求、工具或 Host API。`SourcePreview` 同时按源码与 renderer 关联结果,在匹配结果完成前不发布预览内容。Mermaid 和 Graphviz 增加按需加载的浏览器资源,并在浏览器线程上执行布局。取消无法抢占进行中的布局;即使结果无法发布,Mermaid 仍会完成并释放测量 DOM。预览不提供编辑、导出、HTML 渲染或交互式图表链接。
组件测试覆盖源码复制、预览渲染、延迟完成、过期成功与失败、卸载、回退、取消、运行时加载失败与恢复。无密钥[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证中文 Mermaid 流程图、时序图、无效源码、配置覆盖、不可执行 SVG 图片、真实图片解码、源码切换、中英文 UI 快照和已提供的许可证文本。声明检查固定已审查的包装层版本与原生源码下载地址,升级时须重新审查。
@@ -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-10-codeblock-preview-interaction.md
2026-09-10-codeblock-preview-interaction.md: 678daa6544f92ab5216251658f9e9471eae4cf10
2026-09-10-codeblock-preview-interaction.zh.md: afd3a3e2416765b3f71832ddd867c83bf90d8e81
@@ -1,57 +0,0 @@
# Agent Note: CodeBlock preview interaction and retained work
Status: implemented
English | [中文](2026-09-10-codeblock-preview-interaction.zh.md)
## Problem
Readers need to inspect diagrams directly and return to source without paying for the same diagram again. Toolbar state and unrelated grammar notifications can repeat source work even when its text is unchanged.
## Decision
Supported settled blocks initially show Preview. Supported streaming blocks show a 180px image placeholder with a localized loading status and a decorative glyph whose opacity pulses over 1.8 seconds; reduced motion disables the animation. Streaming mounts neither source highlighting nor diagram generation, including after a fence freezes in the incremental parser. Controls return when message streaming ends; the placeholder remains until the image loads. Stopping the stream also exits the waiting state; invalid or unloadable images show an error with Source available. This supersedes the source-first and release-on-toggle choices in the [static preview decision](2026-09-09-markdown-static-previews.md), whose renderer isolation, cancellation, security, and distribution rules remain active.
The language stays on the left. The right side contains magnifier, copy, and Source/Preview controls in that order. Icons follow the Sidebar's 28px circular control and 15px glyph treatment. The segmented control moves only its selected background for 160ms; content switches immediately, and reduced-motion settings suppress that transition. Unsupported blocks have a static selected Source label. Copy always reads source, and its fixed-size icon and localized tooltip show success. Preview provides a magnifier that opens `PreviewLightbox`, disabled with an accessible localized explanation when no usable image is available. Source replaces it with a line-number toggle whose state survives view changes. Preview toolbar opacity transitions over 160ms without a timer or layout change; block hover and keyboard focus reveal it, and touch devices keep it visible. Source toolbar opacity stays at one.
The stable `data-code-block-content` wrapper and `contentRef` let sidebar owners retain their scrollport and restore scroll position. The [preview sizing decision](../simplification/2026-09-14-source-sized-code-block-previews.md) supersedes this note's active-body height and inline-resize choices; it owns current Source/Preview geometry and overflow.
Each mounted `CodeBlock` owns its preview result and pending renderer call. A shared IntersectionObserver defers diagram work and runtime loading until intersection, and cancels unfinished work on exit unless the lightbox is open. Browsers without this API render immediately. Switching views retains completed results; source, renderer, or resolved-theme changes invalidate the work, and unmounting cancels publication. Reentry reuses results with matching source, renderer and palette. Theme refreshes retain the loaded image, its geometry and the open lightbox until a replacement loads. A failed refresh preserves the usable image and displays the localized error. Retention is bounded to one displayed image and one replacement per block.
The owner retains failures too, so toggling cannot repeatedly submit invalid source. Previewable blocks mount source on first selection, then retain that DOM with its highlighting state. `CodeBlock`, its source child, and its copy control are independently memoized. Grammar readiness snapshots report only the source language, so loading another grammar does not schedule source work. This is local retention, with no cache shared across blocks or Sessions.
The lightbox fits the image within 88% of viewport width and 84% of viewport height, independently of inline size. Pointer capture owns dragging; wheel zoom preserves the image point beneath the cursor and stays between 0.25 and 8 times the fitted size. Double-click, Home, source-image changes, and viewport resizing restore the fit. Arrow keys pan and +/− zoom. Gestures write only the image transform and retain its URL; they do not schedule React state or diagram generation. Closing releases capture and listeners and restores focus to the opener.
The source renderer consumes each highlight frame once. `StreamingHighlightSession.updateFrame` returns the same delta frame for repeated code and language, while refs retain completed-line groups across React render retries. The line cache therefore retains the frame identity and returns its existing body for the same frame, code, and language; memoization alone cannot prevent duplicate line insertion. StrictMode coverage checks multiple streaming chunks and settlement, and the Markdown DOM fixtures run under StrictMode without changing their expected output.
## Alternatives considered
**Unmount inactive content.** Releasing images on every Source visit repeats diagram generation and removes the source-view magnifier's result. Retaining one result per mounted block gives the result the same lifetime as its controls.
**Share a global preview cache.** Cross-block reuse adds eviction, renderer identity, and theme-key ownership while retaining conversation content beyond individual mounts. The measured repeated work occurs within one block, so its mounted lifetime is sufficient.
**Mount and highlight source before selection.** The retained image determines block dimensions, so hidden source adds work without improving the preview.
## Consequences
Previewable blocks mount and highlight source on first selection; preview-only readers do not mount source. Ordinary source-only blocks still defer highlighting until they intersect; streaming diagram placeholders do not mount source.
Offscreen previews submit no diagram work until intersection; offscreen theme changes wait for reentry. Returning to Source keeps the image and renderer owner alive, and blocks whose Source view has been opened retain source token DOM. Memory therefore follows mounted blocks, with no claim of reduced heap use. Mermaid's queue and synchronous Graphviz layout still run on the browser thread, and cancellation cannot preempt active layout.
The [viewport tests](../../../../packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx) pin deferred rendering, viewport cancellation and reuse; the 20-block regression calls every renderer against the unoptimized implementation. The [component tests](../../../../packages/client/ui-primitives/tests/source-preview.client.spec.tsx) cover retained results, source identity, pending and failed controls, copy, streaming transitions, invalidation, and stale publication. The [browser scenario](../../../../apps/web/tests/markdown-mermaid.e2e.ts) exercises equal Source/Preview geometry, internal overflow, toolbar fading, line-number controls, lightbox interaction, and localized UI snapshots. Highlighting retains the existing streamed/settled parity tests. The streaming browser scenario waits for the turn to finish persisting before closing Session handles, including after failed assertions.
## Performance evidence
The [manual browser diagnostic](../../../../apps/web/tests/diagram-preview.perf.ts), run with `pnpm run test:web:perf:built apps/web/tests/diagram-preview.perf.ts`, uses 20 Mermaid flowcharts with 10 nodes and 12 edges each, followed by 40 paragraphs. Three fresh Chromium instances use a 1680×1000 viewport, the shipped Web composition and built Client on macOS arm64, Apple M5 Pro and Node 24.20.0. Opening starts at a real Session click and ends when the tail is visible plus two animation-frame opportunities; theme timing starts immediately before system-theme emulation and ends after a visible replacement plus two frames. These endpoints do not measure hardware presentation or model latency.
| Metric | Eager previews, samples → median | Viewport previews, samples → median |
| --- | --- | --- |
| Open endpoint (ms) | 129.0, 167.3, 98.5 → 129.0 | 72.1, 106.6, 81.7 → 81.7 |
| Long tasks in first 3 seconds (ms) | 61, 58, 0 → 58 | 0, 0, 0 → 0 |
| Additional initial JS transfer (bytes) | 239449 in every sample | 0 in every sample |
| Initially decoded offscreen diagrams | 20 in every sample | 0 in every sample |
| Visible theme replacement (ms) | 956.0, 617.6, 697.7 → 697.7 | 93.0, 93.1, 82.1 → 93.0 |
| Theme placeholder frames / height range | 2 / 180–632px in every sample | 0 / 632–632px in every sample |
Theme preparation scrolls to the loaded target after cold diagrams settle; otherwise an earlier diagram's growth can move the target outside the viewport. Both compared theme phases contain two intersecting previews. First activation and its geometry changes are excluded from timing. Unrelated host activity and filesystem caches are uncontrolled; timings are descriptive, with no CI timing threshold or memory claim. The owning component regression fails on eager rendering, and the browser case verifies deferred Mermaid and Graphviz requests plus a keyless offscreen-state snapshot.
@@ -1,57 +0,0 @@
# Agent Note: CodeBlock 预览交互与工作保留
Status: implemented
[English](2026-09-10-codeblock-preview-interaction.md) | 中文
## Problem
读者需要直接查看图表,并返回源码而不必再次生成同一张图。即使文本未变,工具栏状态和无关语法通知也可能重复执行源码工作。
## Decision
支持预览的定稿代码块初始显示预览。受支持的流式代码块显示高 180px 的图片占位、本地化加载状态和以 1.8 秒周期改变透明度的装饰图标;减少动态效果设置禁用动画。流式期间既不挂载源码高亮,也不生成图表,包括 fence 被增量解析器冻结之后。消息流式结束后恢复控件;占位保留至图片加载完成。停止输出也会退出等待状态;无效或无法加载的图片显示错误,并保留源码入口。这取代[静态预览决策](2026-09-09-markdown-static-previews.zh.md)中的默认源码和切换时释放结果的选择;其中的渲染器隔离、取消、安全与分发规则继续适用。
语言名位于左侧。右侧依次放置放大镜、复制和源码/预览控件。图标沿用侧边栏的 28px 圆形控件与 15px 图形样式。分段控件只让选中背景移动 160ms;正文立即切换,减少动态效果设置会禁用该过渡。不支持预览的代码块显示静态选中的源码文案。复制始终读取源码,通过固定尺寸图标与本地化 tooltip 表示成功。预览视图提供放大镜,打开 `PreviewLightbox`;没有可用图片时禁用,并提供本地化的可访问说明。源码视图用行号开关替换放大镜,开关状态跨视图切换保留。预览工具栏的透明度在 160ms 内过渡,不使用计时器或改变布局;鼠标移入代码块和键盘聚焦时显示,触摸设备保持可见。源码工具栏透明度始终为一。
稳定的 `data-code-block-content` 容器与 `contentRef` 让侧栏所有者保留滚动容器并恢复滚动位置。[预览尺寸决策](../simplification/2026-09-14-source-sized-code-block-previews.zh.md)取代本文中由当前正文定高与行内调整尺寸的选择;它负责当前的源码/预览几何与溢出行为。
每个已挂载的 `CodeBlock` 拥有预览结果及待完成的 renderer 调用。共享 IntersectionObserver 把图表生成与运行时加载推迟到与视口相交之后;离开视口会取消未完成的工作,已打开 lightbox 时除外。不支持该 API 的浏览器立即渲染。切换视图保留已完成结果;源码、renderer 或解析后的主题变化会使工作失效,卸载则取消发布。重新进入时复用源码、renderer 与配色相同的结果。主题刷新保留已加载图片、其几何尺寸和已打开的 lightbox,直到替换图片加载完成。刷新失败时保留可用图片并显示本地化错误。每个代码块最多保留一张已显示图片和一张替换图片。
所有者也保留失败结果,因此切换不会反复提交无效源码。支持预览的代码块在首次选中源码时挂载,随后连同高亮状态一起保留该 DOM。`CodeBlock`、源码子组件和复制控件分别 memo 化。语法就绪快照只报告源码语言,因此加载其他语法不会调度源码工作。结果仅在本地保留,不在代码块或 Session 间共享缓存。
大图以屏幕宽度的 88% 和高度的 84% 为上限等比适应,不依赖行内尺寸。指针捕获管理拖拽;滚轮缩放保持鼠标下的图片位置不动,倍率限制在适应尺寸的 0.25 至 8 倍。双击、Home、图片源变化和视口尺寸变化会恢复适应尺寸。方向键平移,+/− 缩放。手势只写入图片 transform 并保留 URL,不调度 React state 或重新生成图表。关闭时释放指针捕获和监听器,并将焦点还给打开按钮。
源码渲染器只消费每个高亮帧一次。对于重复的代码与语言,`StreamingHighlightSession.updateFrame` 返回同一个增量帧,而 ref 会跨 React 渲染重试保留已完成行的分组。因此,行缓存保留帧 identity,并在帧、代码与语言相同时返回已有正文;仅靠 memo 化无法防止重复插入行。StrictMode 测试覆盖多个流式分片及定稿,Markdown DOM fixture 也在 StrictMode 下运行,预期输出保持不变。
## Alternatives considered
**卸载未激活内容。** 每次查看源码都释放图片会重复生成图表,也会移除源码视图放大镜所需的结果。按已挂载代码块保留一个结果,让结果与其控件具有相同生命周期。
**共享全局预览缓存。** 跨代码块复用需要处理淘汰、renderer identity 和主题键归属,还会在单个组件卸载后保留对话内容。测得的重复工作发生在同一代码块内部,因此其挂载生命周期足以满足需求。
**在选中前就挂载并高亮源码。** 保留的图片决定代码块尺寸,隐藏源码只会增加工作,无法改善预览。
## Consequences
支持预览的代码块在首次选中源码时挂载并高亮;只阅读预览时不挂载源码。普通纯源码代码块仍推迟到进入视口后高亮;流式图表占位不挂载源码。
视口外的预览直到与视口相交后才提交图表工作;视口外的主题变化等待重新进入视口后处理。返回源码后,图片与 renderer 所有者保持存活,已经打开过源码视图的代码块保留源码 token DOM。因此内存占用随已挂载代码块增长,不声称减少堆内存。Mermaid 队列和同步 Graphviz 布局仍在浏览器线程执行,取消无法抢占已开始的布局。
[视口测试](../../../../packages/client/ui-primitives/tests/highlight-viewport.client.spec.tsx)固定延迟渲染、视口取消和复用行为;20 个代码块的回归用例在未优化实现上会调用全部 renderer。[组件测试](../../../../packages/client/ui-primitives/tests/source-preview.client.spec.tsx)覆盖结果保留、源码 identity、待完成与失败控件、复制、流式转换、失效和过期发布。[浏览器场景](../../../../apps/web/tests/markdown-mermaid.e2e.ts)验证源码/预览几何一致、内部溢出、工具栏淡出、行号控件、lightbox 交互和本地化 UI 快照。高亮保留已有的流式/定稿一致性测试。流式浏览器场景在关闭 Session 句柄前等待回合完成持久化,包括断言失败的情况。
## 性能证据
[手动浏览器诊断](../../../../apps/web/tests/diagram-preview.perf.ts)通过 `pnpm run test:web:perf:built apps/web/tests/diagram-preview.perf.ts` 运行,使用 20 个 Mermaid 流程图,每图 10 个节点和 12 条边,后接 40 段文本。三个全新 Chromium 实例使用 1680×1000 视口、正式 Web 组合和已构建 Client,运行于 macOS arm64、Apple M5 Pro 与 Node 24.20.0。打开阶段从真实 Session 点击开始,到尾部可见再加两次动画帧机会结束;主题阶段从模拟系统主题前开始,到替换图片可见再加两帧结束。这些终点不测量硬件呈现或模型延迟。
| 指标 | 挂载即渲染,样本 → 中位数 | 视口渲染,样本 → 中位数 |
| --- | --- | --- |
| 打开终点(ms) | 129.0, 167.3, 98.5 → 129.0 | 72.1, 106.6, 81.7 → 81.7 |
| 前 3 秒长任务(ms) | 61, 58, 0 → 58 | 0, 0, 0 → 0 |
| 初始新增 JS 传输(bytes) | 每个样本均为 239449 | 每个样本均为 0 |
| 初始已解码的视口外图表 | 每个样本均为 20 | 每个样本均为 0 |
| 可见主题替换(ms) | 956.0, 617.6, 697.7 → 697.7 | 93.0, 93.1, 82.1 → 93.0 |
| 主题占位帧/高度范围 | 每个样本均为 2 / 180–632px | 每个样本均为 0 / 632–632px |
主题阶段的准备会在冷图表布局稳定后滚到已加载的目标;否则前方图表增高可能把目标移出视口。对比的两个主题阶段都包含两个与视口相交的预览。首次激活及其几何变化不计时。其他主机活动与文件系统缓存不受控制;计时仅作描述,不设 CI 时间阈值,也不声明内存收益。对应组件回归在挂载即渲染实现上失败,浏览器用例验证 Mermaid 与 Graphviz 请求的延迟加载及无需 API key 的屏外状态快照。
@@ -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/simplification/2026-09-14-source-sized-code-block-previews.md
2026-09-14-source-sized-code-block-previews.md: 66ba419efcf4113886cfddafc4eb9f3ad2583565
2026-09-14-source-sized-code-block-previews.zh.md: 7e8e96eced72f78f7aff852d993cf32bff22a7e4
@@ -1,29 +0,0 @@
# Agent Note: Diagram-sized code block previews
Status: implemented
English | [中文](2026-09-14-source-sized-code-block-previews.zh.md)
## Problem
Source line count does not predict diagram proportions. Sizing a preview from hidden highlighted source can clip a tall diagram, waste space around a short one, and parse a large source tree before the reader requests it. Independent Source and Preview heights move the surrounding transcript on every toggle.
## Decision
The browser sizes the retained preview image from its intrinsic aspect ratio and available width. CSS limits image height to the smaller of 60vh and 640px; the canvas adds 16px padding and a 120px minimum height. The image remains in document flow while hidden, so both views keep the same geometry through toggles and viewport resizing without JavaScript measurements or resize state.
Source mounts on first selection, overlays the preview area, and scrolls internally. Subsequent toggles retain the source DOM and generated image. Preview remains the default. Supported streaming fences keep the 180px placeholder and mount neither source nor the diagram renderer until settlement.
`CodeBlock` exposes no inline resize controls. Enlarging the image opens the existing lightbox with pan, zoom, and viewport fit.
## Alternatives considered
**Use hidden source as the size owner.** Source length and wrapping do not describe the diagram, and eagerly highlighting it adds work to the default preview path.
**Give each view its own natural height or allow drag resizing.** Independent heights move transcript content; manual resizing adds state and controls where the browser can derive the size from the image.
## Consequences
Preview-only readers do not mount or highlight source. Source-only blocks retain viewport-triggered highlighting. Very tall diagrams scale down in the transcript; the lightbox provides detailed reading. Source DOM remains allocated after the reader first opens it, preserving highlight and scroll state across toggles.
Browser regressions cover wide and narrow viewports, short and tall images, stable toggle geometry, retained DOM, and hidden-source accessibility. Component tests verify that preview settlement performs no source highlighting before Source is selected.
@@ -1,29 +0,0 @@
# Agent Note: 按图表定高的代码块预览
Status: implemented
[English](2026-09-14-source-sized-code-block-previews.md) | 中文
## Problem
源码行数无法预测图表比例。根据隐藏的高亮源码确定预览尺寸,会裁切高图、在短图周围浪费空间,并在读者需要之前解析大量源码节点。源码与预览分别使用独立高度时,每次切换都会移动后续对话内容。
## Decision
浏览器根据保留的预览图片的原始宽高比与可用宽度计算尺寸。CSS 将图片高度限制为 60vh 与 640px 中的较小值;画布增加 16px 内边距与 120px 最小高度。图片隐藏时仍保留在文档流中,因此切换视图与调整视口时,两种视图保持相同几何,无需 JavaScript 测量或尺寸状态。
源码在首次选中时挂载,覆盖预览区域并在内部滚动。后续切换保留源码 DOM 与已生成图片。默认仍为预览。受支持的流式 fence 保留 180px 占位,在定稿前既不挂载源码,也不挂载图表 renderer。
`CodeBlock` 不提供行内尺寸调整控件。放大图片使用现有 lightbox,支持平移、缩放与适应视口。
## Alternatives considered
**用隐藏源码确定尺寸。** 源码长度与换行无法描述图表,提前高亮还会给默认预览路径增加工作。
**让两种视图分别使用自然高度或允许拖拽调整。** 独立高度会移动对话内容;浏览器可以根据图片推导尺寸,手动调整却增加了状态与控件。
## Consequences
只阅读预览时不挂载或高亮源码。仅源码代码块保留视口触发的高亮。特别高的图表在对话中等比缩小,读者可通过 lightbox 查看细节。首次打开后,源码 DOM 持续保留,让高亮与滚动状态跨切换保持不变。
浏览器回归覆盖宽窄视口、长短图片、稳定的切换几何、DOM 保留及隐藏源码的可访问性。组件测试验证预览定稿不会在选中源码前执行源码高亮。
+1 -4
View File
@@ -61,7 +61,6 @@ External packages installed for runtime use or distributed inside the prebuilt b
| [`@standard-schema/spec`](https://github.com/standard-schema/standard-schema) | MIT |
| [`@tanstack/react-virtual`](https://github.com/TanStack/virtual) | MIT |
| [`@trycua/cua-driver`](https://github.com/trycua/cua) | MIT |
| [`@viz-js/viz`](https://github.com/mdaines/viz-js) | MIT |
| [`@vscode/ripgrep`](https://github.com/microsoft/vscode-ripgrep) | MIT |
| [`@xterm/addon-fit`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-fit) | MIT |
| [`@xterm/addon-serialize`](https://github.com/xtermjs/xterm.js/tree/master/addons/addon-serialize) | MIT |
@@ -90,7 +89,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 |
@@ -125,8 +123,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 its DOMPurify dependency'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:
- `@electron/[email protected]` — [`patches/@[email protected]`](patches/@[email protected])
@@ -216,6 +212,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 |
| [`oxlint`](https://github.com/oxc-project/oxc) | MIT |
| [`oxlint-tsgolint`](https://github.com/oxc-project/tsgolint) | MIT |
-322
View File
@@ -1,322 +0,0 @@
/** Manual built-Web diagnostic for offscreen diagrams and visible theme refreshes; no timing budgets. */
import { execFileSync } from 'node:child_process'
import { mkdir, writeFile } from 'node:fs/promises'
import { arch, cpus, platform, release } from 'node:os'
import { join } from 'node:path'
import { chromium } from 'playwright'
import type { Browser, Locator, Page } from 'playwright'
import { describe, expect, it } from 'vitest'
import { createAssistantMessage, 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 { launchWebScaffold, seedSession, watchConsole, webSnapshotMode } from './scaffold.ts'
import type { WebScaffold } from './scaffold.ts'
import { newEnglishPage, REPO_ROOT } from './support.ts'
const SAMPLE_COUNT = 3
const DIAGRAM_COUNT = 20
const TAIL_PARAGRAPHS = 40
const OBSERVATION_MS = 3_000
const SESSION_ID = 'diagram-preview-performance'
const TITLE = 'DIAGRAM_PREVIEW_PERF'
const TAIL = 'DIAGRAM_PREVIEW_TAIL'
const PREVIEW = '[data-code-block-preview]'
const PROBE_KEY = '__dshDiagramPreviewPerf'
interface LongTask {
readonly startTime: number
readonly duration: number
}
interface Frame {
readonly elapsedMs: number
readonly height: number
readonly placeholder: boolean
}
interface Probe {
start?: number
opportunity?: number
oldSrc?: string
trustedClick?: boolean
frames: Frame[]
longTasks: LongTask[]
observer: PerformanceObserver
raf: number
}
function fixture(): string {
const session = Session.create(SessionId(SESSION_ID))
session.append('turn/start', { turn: 1 })
const user = session.append('user/message', createUserMessage({
content: [{ type: 'text', text: 'Inspect this synthetic diagram history.' }],
source: { kind: 'user' },
}), { surfaceOp: 'append' })
session.append('session/title', { title: TITLE, messageSeqs: [user.seq], source: { kind: 'fallback' } })
session.append('step/start', { turn: 1, step: 1 })
const diagrams = Array.from({ length: DIAGRAM_COUNT }, (_, index) => [
`Diagram ${index + 1}`,
'```mermaid',
'flowchart TD',
` A[Request ${index + 1}] --> B[Read history]`,
' B --> C{Cached}',
' C --> D[Load source]',
' C --> E[Reuse source]',
' D --> F[Parse]',
' E --> F',
' F --> G[Layout]',
' G --> H[Draw]',
' H --> I{Loaded}',
' I --> J[Display]',
' I --> D',
' E --> G',
'```',
].join('\n'))
const paragraphs = Array.from({ length: TAIL_PARAGRAPHS }, (_, index) =>
`History note ${index + 1}. This ordinary paragraph keeps the diagrams above the initial reading position. `
+ 'The reader can inspect the completed discussion without opening a chart.')
session.append('assistant/message', {
turn: 1,
step: 1,
stream: [],
message: createAssistantMessage({
content: [{ type: 'text', text: [...diagrams, ...paragraphs, TAIL].join('\n\n') }],
source: { provider: 'deepseek-official', model: 'deepseek-v4-flash' },
}),
usage: { inputTokens: 20, outputTokens: 2_000 },
}, { surfaceOp: 'append' })
session.append('step/end', { turn: 1, step: 1 })
session.append('turn/end', { turn: 1, reason: { kind: 'completed' } })
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)), ''].join('\n')
}
async function installProbe(phase: 'open' | 'theme', target: Locator): Promise<void> {
await target.evaluate((node, options) => {
const longTasks: LongTask[] = []
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) longTasks.push({ startTime: entry.startTime, duration: entry.duration })
})
observer.observe({ type: 'longtask' })
const probe: Probe = { frames: [], longTasks, observer, raf: 0 }
Reflect.set(globalThis, options.key, probe)
const visible = (element: Element): boolean => {
const rect = element.getBoundingClientRect()
const scroll = document.querySelector('[data-conversation-scroll]')?.getBoundingClientRect()
const style = getComputedStyle(element)
return rect.width > 0 && rect.height > 0 && style.visibility !== 'hidden'
&& rect.bottom > (scroll?.top ?? 0) && rect.top < (scroll?.bottom ?? innerHeight)
}
const frame = (): void => {
if (probe.start === undefined) return
let ready = false
if (options.phase === 'open') {
const tail = Array.from(document.querySelectorAll('[data-conversation-scroll] p'))
.find(element => element.textContent === options.tail)
ready = tail !== undefined && visible(tail)
} else {
const preview = document.querySelectorAll(options.preview)[options.diagramCount - 1]
if (preview === undefined) throw new Error('theme probe has no last diagram')
probe.frames.push({
elapsedMs: performance.now() - probe.start,
height: preview.getBoundingClientRect().height,
placeholder: Array.from(preview.querySelectorAll('[data-preview-placeholder]')).some(visible),
})
ready = Array.from(preview.querySelectorAll('img')).some(image =>
image.src !== probe.oldSrc && image.complete && image.naturalWidth > 0 && visible(image))
}
if (ready && probe.opportunity === undefined) {
// The second callback is a rendering opportunity, not hardware presentation.
probe.opportunity = -1
requestAnimationFrame(() => {
requestAnimationFrame(() => { probe.opportunity = performance.now() })
})
}
probe.raf = requestAnimationFrame(frame)
}
if (options.phase === 'open') {
node.addEventListener('click', (event) => {
probe.trustedClick = event.isTrusted
probe.start = performance.now()
probe.raf = requestAnimationFrame(frame)
}, { capture: true, once: true })
} else {
const image = node.querySelector('img')
if (image === null) throw new Error('theme probe has no loaded image')
probe.oldSrc = image.src
probe.start = performance.now()
probe.raf = requestAnimationFrame(frame)
}
}, { phase, key: PROBE_KEY, preview: PREVIEW, diagramCount: DIAGRAM_COUNT, tail: TAIL })
}
async function collectProbe(page: Page) {
try {
await page.waitForFunction(({ key, windowMs }) => {
const probe = Reflect.get(globalThis, key) as Probe
return probe.start !== undefined && (probe.opportunity ?? -1) > 0
&& performance.now() - probe.start >= windowMs
}, { key: PROBE_KEY, windowMs: OBSERVATION_MS }, { timeout: 30_000 })
} catch (error) {
const diagnostic = await page.evaluate(({ key, previewSelector }) => {
const probe = Reflect.get(globalThis, key) as Probe
const preview = Array.from(document.querySelectorAll(previewSelector)).at(-1)
const bounds = (element: Element | null | undefined) => {
const rect = element?.getBoundingClientRect()
return rect === undefined ? null : { top: rect.top, bottom: rect.bottom, height: rect.height }
}
return {
opportunity: probe.opportunity,
frames: probe.frames.slice(-5),
theme: getComputedStyle(document.documentElement).colorScheme,
scroll: bounds(document.querySelector('[data-conversation-scroll]')),
preview: bounds(preview),
images: Array.from(preview?.querySelectorAll('img') ?? []).map(image => ({
sameSource: image.src === probe.oldSrc, complete: image.complete, naturalWidth: image.naturalWidth,
hidden: image.hidden, rect: bounds(image),
})),
}
}, { key: PROBE_KEY, previewSelector: PREVIEW })
throw new Error(`diagram probe endpoint unavailable: ${JSON.stringify(diagnostic)}`, { cause: error })
}
return await page.evaluate(({ key, windowMs, previewSelector }) => {
const probe = Reflect.get(globalThis, key) as Probe
probe.observer.disconnect()
cancelAnimationFrame(probe.raf)
Reflect.deleteProperty(globalThis, key)
if (probe.start === undefined || probe.opportunity === undefined) throw new Error('incomplete diagram probe')
const start = probe.start
const stop = start + windowMs
const tasks = probe.longTasks.filter(task => task.startTime < stop && task.startTime + task.duration > start)
const scripts = performance.getEntriesByType('resource').filter((entry): entry is PerformanceResourceTiming =>
entry instanceof PerformanceResourceTiming && new URL(entry.name).pathname.endsWith('.js')
&& entry.startTime >= start && entry.startTime < stop)
.map(entry => ({
path: new URL(entry.name).pathname,
transferBytes: entry.transferSize,
encodedBytes: entry.encodedBodySize,
decodedBytes: entry.decodedBodySize,
durationMs: entry.duration,
}))
const previews = Array.from(document.querySelectorAll(previewSelector))
const scrollRect = document.querySelector('[data-conversation-scroll]')?.getBoundingClientRect()
const intersectingPreviews = previews.filter((preview) => {
const rect = preview.getBoundingClientRect()
return rect.bottom > (scrollRect?.top ?? 0) && rect.top < (scrollRect?.bottom ?? innerHeight)
}).length
const frames = probe.frames.filter(frame => frame.elapsedMs < windowMs)
return {
opportunityMs: probe.opportunity - start,
observedMs: performance.now() - start,
trustedClick: probe.trustedClick,
longTaskMs: tasks.reduce((sum, task) => sum + Math.max(0,
Math.min(stop, task.startTime + task.duration) - Math.max(start, task.startTime)), 0),
longTasks: tasks.map(task => ({ startMs: task.startTime - start, durationMs: task.duration })),
loadedDiagramImages: previews.flatMap(preview => Array.from(preview.querySelectorAll('img')))
.filter(image => image.complete && image.naturalWidth > 0).length,
previewCount: previews.length,
intersectingPreviews,
scriptTransferBytes: scripts.reduce((sum, script) => sum + script.transferBytes, 0),
scriptEncodedBytes: scripts.reduce((sum, script) => sum + script.encodedBytes, 0),
scripts,
frames,
heightMin: frames.length === 0 ? null : Math.min(...frames.map(frame => frame.height)),
heightMax: frames.length === 0 ? null : Math.max(...frames.map(frame => frame.height)),
placeholderFrames: frames.filter(frame => frame.placeholder).length,
}
}, { key: PROBE_KEY, windowMs: OBSERVATION_MS, previewSelector: PREVIEW })
}
async function sample() {
let scaffold: WebScaffold | undefined
let browser: Browser | undefined
try {
scaffold = await launchWebScaffold()
await seedSession(scaffold, fixture(), SESSION_ID)
browser = await chromium.launch()
const page = await newEnglishPage(browser)
await page.emulateMedia({ colorScheme: 'light' })
const tripwire = watchConsole(page)
await page.goto(scaffold.authenticatedUrl, { waitUntil: 'load' })
const group = page.getByRole('treeitem').first()
await group.waitFor({ timeout: 30_000 })
await group.click()
const session = page.getByRole('treeitem').nth(1)
await session.waitFor({ timeout: 30_000 })
const bootScriptBytes = await page.evaluate(() => performance.getEntriesByType('resource')
.filter((entry): entry is PerformanceResourceTiming => entry instanceof PerformanceResourceTiming
&& new URL(entry.name).pathname.endsWith('.js'))
.reduce((sum, entry) => sum + entry.transferSize, 0))
await installProbe('open', session)
await session.click()
const open = await collectProbe(page)
expect(open.trustedClick).toBe(true)
expect(open.previewCount).toBe(DIAGRAM_COUNT)
expect(open.intersectingPreviews).toBe(0)
const last = page.locator(PREVIEW).last()
await last.scrollIntoViewIfNeeded()
await expect.poll(() => last.locator('img').evaluateAll(images => images.some((node) => {
const image = node as HTMLImageElement
return image.complete && image.naturalWidth > 0 && image.getBoundingClientRect().height > 0
}))).toBe(true)
// Earlier cold previews may grow after the placeholder was scrolled into view.
await last.scrollIntoViewIfNeeded()
await expect.poll(() => last.evaluate((node) => {
const rect = node.getBoundingClientRect()
const scroll = document.querySelector('[data-conversation-scroll]')!.getBoundingClientRect()
return rect.bottom > scroll.top && rect.top < scroll.bottom
}), { timeout: 30_000 }).toBe(true)
await installProbe('theme', last)
await page.emulateMedia({ colorScheme: 'dark' })
const theme = await collectProbe(page)
expect(await page.evaluate(() => getComputedStyle(document.documentElement).colorScheme)).toBe('dark')
expect(tripwire.pageErrors).toEqual([])
return { browser: browser.version(), bootScriptBytes, open, theme, consoleWarnings: tripwire.warnings }
} finally {
try {
await browser?.close()
} finally {
await scaffold?.close()
}
}
}
function median(values: number[]): number {
return [...values].sort((a, b) => a - b)[Math.floor(values.length / 2)]!
}
describe('manual web performance: diagram previews', () => {
it('reports cold offscreen opening and a visible theme refresh', async () => {
if (webSnapshotMode() !== 'replay') throw new Error('diagram performance requires keyless replay mode')
const directory = join(REPO_ROOT, '.playwright-mcp/preview-perf', new Date().toISOString().replaceAll(':', '-'))
await mkdir(directory, { recursive: true })
const samples: Awaited<ReturnType<typeof sample>>[] = []
for (let index = 0; index < SAMPLE_COUNT; index++) {
const result = await sample()
samples.push(result)
await writeFile(join(directory, `sample-${index + 1}.json`), `${JSON.stringify(result, null, 2)}\n`)
console.log(JSON.stringify({ sample: index + 1, openMs: result.open.opportunityMs, themeMs: result.theme.opportunityMs }))
}
const summary = {
revision: execFileSync('git', ['rev-parse', 'HEAD'], { cwd: REPO_ROOT, encoding: 'utf8' }).trim(),
runtime: { node: process.version, platform: platform(), release: release(), arch: arch(), cpu: cpus()[0]?.model },
workload: {
diagrams: DIAGRAM_COUNT, nodesPerDiagram: 10, edgesPerDiagram: 12,
tailParagraphs: TAIL_PARAGRAPHS, viewport: [1680, 1000], observationMs: OBSERVATION_MS,
},
semantics: 'Fresh Chromium and private shipped-composition scaffold per sample; built Client with source-resolved test Host. Opening starts at trusted session click; theme starts immediately before media emulation. Endpoints require visible output and two animation-frame opportunities. Long tasks are clipped to the fixed observation window. Samples include local transport, no model call. No memory or hardware-presentation measurement and no timing verdict.',
medians: Object.fromEntries((['open', 'theme'] as const).map(phase => [phase, {
opportunityMs: median(samples.map(sample => sample[phase].opportunityMs)),
longTaskMs: median(samples.map(sample => sample[phase].longTaskMs)),
scriptTransferBytes: median(samples.map(sample => sample[phase].scriptTransferBytes)),
loadedDiagramImages: median(samples.map(sample => sample[phase].loadedDiagramImages)),
placeholderFrames: median(samples.map(sample => sample[phase].placeholderFrames)),
}])),
samples,
}
await writeFile(join(directory, 'summary.json'), `${JSON.stringify(summary, null, 2)}\n`)
console.log(JSON.stringify({ directory, medians: summary.medians }))
})
})
@@ -172,9 +172,7 @@
- paragraph:
- img "Token preview"
- text: css
- button "Line numbers"
- button "Copy"
- text: Source
- code: "--inline-code: #F5F5F5;"
- paragraph: LINK_GALLERY_DONE
- heading "Footnotes" [level=2]
@@ -46,9 +46,7 @@
- tab "Host" [selected]
- tabpanel "Host":
- text: javascript
- button "Line numbers"
- button "Copy"
- text: Source
- code: "return { name: \"snapshot-noop\", apply(ctx) {} }"
- text: Result Defined snap-1/pkg-1 (snapshot noop); it is not running yet. Use cordis_run to activate this Package. Run controls live in the Cordis panel above Settings
- button "Inspect"
@@ -1,4 +0,0 @@
- dialog "Mermaid diagram":
- img "Mermaid diagram"
- text: Drag or arrow keys to pan · Scroll or +/− to zoom · Double-click or Home to fit
- button "Close preview"
@@ -1,6 +0,0 @@
- text: mermaid
- button "Enlarge preview" [disabled]
- button "Copy"
- button "Source"
- button "Preview" [pressed]
- status: Generating diagram…
@@ -1,427 +0,0 @@
[
{
"language": "mermaid",
"lines": [
[
{
"text": "flowchart",
"style": "color: var(--shiki-token-keyword);"
},
{
"text": " LR",
"style": "color: var(--shiki-token-function);"
}
],
[
{
"text": " A",
"style": "color: var(--shiki-foreground);"
},
{
"text": "[",
"style": "color: var(--shiki-token-keyword);"
},
{
"text": "输入",
"style": "color: var(--shiki-token-string);"
},
{
"text": "]",
"style": "color: var(--shiki-token-keyword);"
},
{
"text": " --",
"style": "color: var(--shiki-token-keyword);"
},
{
"text": "> B[共享渲染器] ",
"style": "color: var(--shiki-token-string);"
},
{
"text": "-->",
"style": "color: var(--shiki-token-keyword);"
},
{
"text": " C",
"style": "color: var(--shiki-foreground);"
},
{
"text": "[",
"style": "color: var(--shiki-token-keyword);"
},
{
"text": "图形预览",
"style": "color: var(--shiki-token-string);"
},
{
"text": "]",
"style": "color: var(--shiki-token-keyword);"
}
]
]
},
{
"language": "dot",
"lines": [
[
{
"text": "digraph",
"style": "color: var(--shiki-token-keyword);"
},
{
"text": " { ",
"style": "color: var(--shiki-foreground);"
},
{
"text": "rankdir",
"style": "color: var(--shiki-token-constant);"
},
{
"text": "=LR; Input ",
"style": "color: var(--shiki-foreground);"
},
{
"text": "->",
"style": "color: var(--shiki-token-keyword);"
},
{
"text": " Preview }",
"style": "color: var(--shiki-foreground);"
}
]
]
},
{
"language": "svg",
"lines": [
[
{
"text": "<",
"style": "color: var(--shiki-foreground);"
},
{
"text": "svg",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " xmlns",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"http://www.w3.org/2000/svg\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " width",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"460\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " height",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"100\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " onload",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"parent.document.body.dataset.previewEscaped='yes'\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": ">",
"style": "color: var(--shiki-foreground);"
}
],
[
{
"text": "<",
"style": "color: var(--shiki-foreground);"
},
{
"text": "rect",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " width",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"460\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " height",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"100\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " fill",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"lightblue\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": "/><",
"style": "color: var(--shiki-foreground);"
},
{
"text": "text",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " x",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"20\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " y",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"55\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": ">SVG preview</",
"style": "color: var(--shiki-foreground);"
},
{
"text": "text",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": ">",
"style": "color: var(--shiki-foreground);"
}
],
[
{
"text": "<",
"style": "color: var(--shiki-foreground);"
},
{
"text": "script",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": ">parent.document.body.dataset.previewEscaped='yes';alert('unsafe SVG')</",
"style": "color: var(--shiki-foreground);"
},
{
"text": "script",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": ">",
"style": "color: var(--shiki-foreground);"
}
],
[
{
"text": "<",
"style": "color: var(--shiki-foreground);"
},
{
"text": "image",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " href",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"https://preview.invalid/svg-image\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " width",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"1\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " height",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"1\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": "/>",
"style": "color: var(--shiki-foreground);"
}
],
[
{
"text": "<",
"style": "color: var(--shiki-foreground);"
},
{
"text": "foreignObject",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " width",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"1\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " height",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"1\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": "><",
"style": "color: var(--shiki-foreground);"
},
{
"text": "div",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": " xmlns",
"style": "color: var(--shiki-token-function);"
},
{
"text": "=",
"style": "color: var(--shiki-foreground);"
},
{
"text": "\"http://www.w3.org/1999/xhtml\"",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": "><",
"style": "color: var(--shiki-foreground);"
},
{
"text": "script",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": ">alert('unsafe HTML')</",
"style": "color: var(--shiki-foreground);"
},
{
"text": "script",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": "></",
"style": "color: var(--shiki-foreground);"
},
{
"text": "div",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": "></",
"style": "color: var(--shiki-foreground);"
},
{
"text": "foreignObject",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": ">",
"style": "color: var(--shiki-foreground);"
}
],
[
{
"text": "</",
"style": "color: var(--shiki-foreground);"
},
{
"text": "svg",
"style": "color: var(--shiki-token-string-expression);"
},
{
"text": ">",
"style": "color: var(--shiki-foreground);"
}
]
]
}
]
@@ -1,42 +0,0 @@
- heading "Mermaid previews" [level=1]
- text: mermaid
- button "Enlarge preview"
- button "Copy"
- button "Source"
- button "Preview" [pressed]
- img "Mermaid diagram"
- text: mermaid
- button "Enlarge preview"
- button "Copy"
- button "Source"
- button "Preview" [pressed]
- img "Mermaid diagram"
- text: mermaid
- button "Enlarge preview" [disabled]
- button "Copy"
- button "Source"
- button "Preview" [pressed]
- status: Unable to render this diagram. Select Source to inspect the code.
- text: mermaid
- button "Enlarge preview"
- button "Copy"
- button "Source"
- button "Preview" [pressed]
- img "Mermaid diagram"
- text: dot
- button "Enlarge preview"
- button "Copy"
- button "Source"
- button "Preview" [pressed]
- img "Graphviz diagram"
- text: svg
- button "Enlarge preview"
- button "Copy"
- button "Source"
- button "Preview" [pressed]
- img "SVG preview"
- text: html
- button "Line numbers"
- button "Copy"
- text: Source
- code: <h2>Static HTML</h2>
@@ -1,4 +0,0 @@
- dialog "Mermaid 图表":
- img "Mermaid 图表"
- text: 拖拽或方向键移动 · 滚轮或 +/− 缩放 · 双击或 Home 适应屏幕
- button "关闭预览"
@@ -1,42 +0,0 @@
- heading "Mermaid previews" [level=1]
- text: mermaid
- button "放大预览"
- button "复制"
- button "源码"
- button "预览" [pressed]
- img "Mermaid 图表"
- text: mermaid
- button "放大预览"
- button "复制"
- button "源码"
- button "预览" [pressed]
- img "Mermaid 图表"
- text: mermaid
- button "放大预览" [disabled]
- button "复制"
- button "源码"
- button "预览" [pressed]
- status: 无法渲染此图表,可切换到源码查看。
- text: mermaid
- button "放大预览"
- button "复制"
- button "源码"
- button "预览" [pressed]
- img "Mermaid 图表"
- text: dot
- button "放大预览"
- button "复制"
- button "源码"
- button "预览" [pressed]
- img "Graphviz 图表"
- text: svg
- button "放大预览"
- button "复制"
- button "源码"
- button "预览" [pressed]
- img "SVG 预览"
- text: html
- button "行号"
- button "复制"
- text: 源码
- code: <h2>Static HTML</h2>
@@ -216,8 +216,6 @@
- text: .
- math: a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + a + 1 1 + 1 x
- text: typescript
- button "Line numbers"
- button "Copy"
- text: Source
- code: const value = "reasoning code"
- paragraph: {{longToken}}
-631
View File
@@ -1,631 +0,0 @@
import { fileURLToPath } from 'node:url'
import { join } from 'node:path'
import type { Browser, Locator, 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 = '<h2>Static HTML</h2>'
const CPU = `flowchart LR
subgraph CPU["CPU 中央处理器"]
direction TB
CU["控制器 Control Unit"]
ALU["运算器 ALU"]
REG["寄存器 Registers"]
end
MEM["存储器 Memory(指令 + 数据)"]
IN["输入设备 Input"]
OUT["输出设备 Output"]
IN -->|"数据 / 指令"| MEM
MEM -->|"指令"| CU
MEM -->|"数据"| REG
REG -->|"数据"| MEM
CU -->|"控制信号"| ALU
CU -->|"控制信号"| MEM
CU -->|"地址总线"| MEM
ALU -->|"结果"| REG
MEM -->|"数据"| OUT
CU -->|"控制信号"| OUT`
function fixture(diagramSources?: string[], trailingText = ''): 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: diagramSources === undefined ? [
'# 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') : ['# Mermaid previews', ...diagramSources.map(code => `\`\`\`mermaid\n${code}\n\`\`\``), trailingText].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()
}
async function settleBox(locator: Locator): Promise<{ width: number; height: number }> {
return await locator.evaluate(async (node) => {
let previous = { width: -1, height: -1 }
let stableFrames = 0
for (let frame = 0; frame < 180; frame += 1) {
await new Promise<void>((resolve) => { requestAnimationFrame(() => { resolve() }) })
const { width, height } = node.getBoundingClientRect()
const current = { width, height }
stableFrames = Math.abs(width - previous.width) < 0.25 && Math.abs(height - previous.height) < 0.25
? stableFrames + 1
: 0
if (stableFrames === 5) return current
previous = current
}
throw new Error('layout did not settle within 180 animation frames')
})
}
async function showAllPreviews(page: Page, label = 'Preview'): Promise<void> {
const blocks = page.locator('.md-code-block')
for (let index = 0; index < await blocks.count(); index += 1) {
const button = blocks.nth(index).getByRole('button', { name: label, exact: true })
if (await button.count() !== 1) continue
await button.click()
await expect.poll(() => blocks.nth(index).locator('[data-preview-placeholder]').count()).toBe(0)
}
}
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')('keeps preview and source at the same height across viewport widths', async () => {
const page = await newEnglishPage(browser)
let cpuScaffold: WebScaffold | undefined
try {
cpuScaffold = await launchWebScaffold({})
await seedSession(cpuScaffold, fixture([CPU]), SEED_ID)
await openConversation(page, cpuScaffold)
const block = page.locator('.md-code-block')
await block.scrollIntoViewIfNeeded()
const image = block.getByRole('img', { name: 'Mermaid diagram', exact: true })
await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
expect(await block.locator('pre').count()).toBe(0)
const heights: number[] = []
for (const width of [1680, 360]) {
await page.setViewportSize({ width, height: 1000 })
await settleBox(page.locator('[data-chat-flow]'))
await image.waitFor({ state: 'visible' })
const previewBlock = await settleBox(block)
const previewGeometry = await block.evaluate((node) => {
const body = node.querySelector<HTMLElement>('[data-code-block-content]')!
const source = node.querySelector<HTMLElement>('[data-code-block-source-view]')!
const preview = node.querySelector<HTMLElement>('[data-code-block-preview]')!
return {
block: node.getBoundingClientRect().height,
body: body.getBoundingClientRect().height,
source: source.closest('.md-code-block')!.querySelector('[data-code-block-content]')!.getBoundingClientRect().height,
preview: preview.getBoundingClientRect().height,
sourceVisibility: getComputedStyle(source).visibility,
sourceAriaHidden: source.getAttribute('aria-hidden'),
}
})
expect(previewGeometry.sourceVisibility).toBe('hidden')
expect(previewGeometry.sourceAriaHidden).toBe('true')
expect(previewGeometry.preview).toBeCloseTo(previewGeometry.source, 0)
expect(previewGeometry.body).toBeCloseTo(previewGeometry.source, 0)
expect(await block.locator('pre:visible').count()).toBe(0)
await block.getByRole('button', { name: 'Source', exact: true }).click()
await block.locator('pre:visible').waitFor()
const sourceBlock = await settleBox(block)
expect(sourceBlock.height).toBeCloseTo(previewBlock.height, 0)
expect(sourceBlock.width).toBeCloseTo(previewBlock.width, 0)
await block.getByRole('button', { name: 'Preview', exact: true }).click()
await image.waitFor({ state: 'visible' })
const restoredBlock = await settleBox(block)
expect(restoredBlock.height).toBeCloseTo(previewBlock.height, 0)
expect(restoredBlock.width).toBeCloseTo(previewBlock.width, 0)
heights.push(previewGeometry.source)
}
expect(heights.every(height => height > 0 && height <= 632)).toBe(true)
expect(await block.locator('[data-code-block-resize]').count()).toBe(0)
expect(await block.getByRole('button', { name: 'Reset size', exact: true }).count()).toBe(0)
await block.getByRole('button', { name: 'Source', exact: true }).click()
expect(await block.locator('pre code').textContent()).toBe(CPU)
} catch (error) {
await saveFailureShot(page, 'web-e2e-cpu-preview')
throw error
} finally {
await page.close()
await cpuScaffold?.close()
}
}, 60_000)
it.skipIf(MODE === 'record')('retains both equal-height views and keeps toolbar geometry stable', async () => {
const page = await newEnglishPage(browser)
try {
await page.context().grantPermissions(['clipboard-read', 'clipboard-write'])
await openConversation(page, scaffold)
const block = page.locator('.md-code-block').nth(5)
await block.scrollIntoViewIfNeeded()
const image = block.getByRole('img', { name: 'SVG preview', exact: true })
await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
const imgNode = await image.elementHandle()
const sourceView = block.locator('[data-code-block-source-view]')
const previewLayer = block.locator('[data-code-block-preview]')
expect(await sourceView.getAttribute('aria-hidden')).toBe('true')
expect(await block.locator('pre').count()).toBe(0)
const previewHeight = (await previewLayer.boundingBox())!.height
expect(await imgNode!.evaluate(node => node.isConnected)).toBe(true)
const sourceButton = block.getByRole('button', { name: 'Source', exact: true })
await sourceButton.click()
await block.locator('pre.shiki:visible').waitFor()
const source = await block.locator('pre.shiki').elementHandle()
expect(await block.locator('pre code').textContent()).toBe(SVG)
expect(await block.getByRole('button', { name: 'Enlarge preview', exact: true }).count()).toBe(0)
expect(await sourceView.getAttribute('aria-hidden')).toBeNull()
const numbers = block.getByRole('button', { name: 'Line numbers', exact: true })
await numbers.click()
expect(await numbers.getAttribute('aria-pressed')).toBe('true')
expect(await block.getAttribute('data-line-numbers')).toBe('true')
expect(await source!.evaluate(node => node.isConnected)).toBe(true)
const copy = block.getByRole('button', { name: 'Copy', exact: true })
const selectionGeometry = () => sourceButton.evaluate((node) => {
const rect = node.getBoundingClientRect()
return { width: rect.width, height: rect.height, right: node.closest('.md-code-block')!.getBoundingClientRect().right - rect.right }
})
const toolbarBefore = await selectionGeometry()
await copy.click()
await block.getByRole('button', { name: 'Copied', exact: true }).waitFor()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(SVG)
expect(await selectionGeometry()).toEqual(toolbarBefore)
const sourceHeight = (await block.locator('[data-code-block-content]').boundingBox())!.height
expect(sourceHeight).toBeCloseTo(previewHeight, 0)
await block.getByRole('button', { name: 'Preview', exact: true }).click()
expect(await source!.evaluate(node => node.isConnected)).toBe(true)
expect(await sourceView.getAttribute('aria-hidden')).toBe('true')
expect((await previewLayer.boundingBox())!.height).toBeCloseTo(sourceHeight, 0)
expect(await imgNode!.evaluate(node => node.isConnected)).toBe(true)
expect(await block.locator('[data-code-block-resize]').count()).toBe(0)
await page.setViewportSize({ width: 360, height: 800 })
await image.waitFor({ state: 'visible' })
expect((await block.boundingBox())!.width).toBeLessThanOrEqual(360)
} finally {
await page.close()
}
})
it.skipIf(MODE === 'record')('fades only preview toolbar opacity and reveals it for hover or keyboard focus', async () => {
const page = await newEnglishPage(browser)
try {
await openConversation(page, scaffold)
const block = page.locator('.md-code-block').first()
await block.scrollIntoViewIfNeeded()
await block.getByRole('img', { name: 'Mermaid diagram', exact: true }).evaluate(async (node: HTMLImageElement) => { await node.decode() })
const toolbar = block.locator('[data-code-block-banner]').locator('..')
await block.hover()
await expect.poll(() => toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
const shown = await block.boundingBox()
const toolbarBox = await toolbar.boundingBox()
await page.mouse.move(0, 0)
await expect.poll(() => toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('0')
expect(await block.boundingBox()).toEqual(shown)
expect(await toolbar.boundingBox()).toEqual(toolbarBox)
expect(await toolbar.evaluate(node => getComputedStyle(node).transitionDuration)).toBe('0.16s')
await block.getByRole('button', { name: 'Source', exact: true }).focus()
await page.keyboard.press('Shift+Tab')
await expect.poll(() => toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
await block.getByRole('button', { name: 'Source', exact: true }).click()
await page.getByRole('heading', { name: 'Mermaid previews' }).click()
await page.mouse.move(0, 0)
expect(await toolbar.evaluate(node => getComputedStyle(node).opacity)).toBe('1')
expect(await block.locator('[data-code-block-resize]').count()).toBe(0)
await page.emulateMedia({ reducedMotion: 'reduce' })
expect(await toolbar.evaluate(node => getComputedStyle(node).transitionDuration)).toBe('0s')
} finally {
await page.close()
}
})
it.skipIf(MODE === 'record')('highlights diagram source with theme tokens', async () => {
const page = await newEnglishPage(browser)
try {
await openConversation(page, scaffold)
const trees = []
for (const [index, language, source] of [[0, 'mermaid', FLOW], [4, 'dot', DOT], [5, 'svg', SVG]] as const) {
const block = page.locator('.md-code-block').nth(index)
await block.scrollIntoViewIfNeeded()
await block.getByRole('button', { name: 'Source', exact: true }).click()
await block.locator('pre.shiki').waitFor()
expect(await block.locator('pre code').textContent()).toBe(source)
const lines = await block.locator('pre.shiki .line').evaluateAll(nodes => nodes.map(line =>
[...line.querySelectorAll('span')].map(span => ({ text: span.textContent, style: span.style.cssText })),
))
expect(new Set(lines.flat().map(span => span.style)).size).toBeGreaterThan(1)
trees.push({ language, lines })
}
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'source-highlight.expected.md'), JSON.stringify(trees, null, 2), MODE)
} finally {
await page.close()
}
})
it.skipIf(MODE === 'record').each(['light', 'dark'] as const)(
'fits diagram previews to image dimensions within viewport limits 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)
await showAllPreviews(page)
for (const title of ['Mermaid diagram', 'Graphviz diagram', 'SVG preview']) {
const image = page.getByRole('img', { name: title, exact: true }).first()
await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
const geometry = await image.evaluate((node) => {
const block = node.closest('.md-code-block')!
const source = block.querySelector<HTMLElement>('[data-code-block-source-view]')!
const preview = block.querySelector<HTMLElement>('[data-code-block-preview]')!
return {
image: node.getBoundingClientRect().height,
source: source.closest('.md-code-block')!.querySelector('[data-code-block-content]')!.getBoundingClientRect().height,
preview: preview.getBoundingClientRect().height,
client: preview.clientHeight,
scroll: preview.scrollHeight,
}
})
expect(geometry.image).toBeGreaterThan(0)
expect(geometry.preview).toBeCloseTo(geometry.source, 0)
expect(geometry.scroll).toBeGreaterThanOrEqual(geometry.client)
expect(geometry.image).toBeLessThanOrEqual(600)
expect(geometry.preview).toBeCloseTo(Math.max(120, geometry.image + 32), 0)
}
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,
sourceHeight: node.closest('.md-code-block')!.querySelector<HTMLElement>('[data-code-block-content]')!.getBoundingClientRect().height,
previewHeight: node.closest('.md-code-block')!.querySelector<HTMLElement>('[data-code-block-preview]')!.getBoundingClientRect().height,
ratio: node.naturalWidth / node.naturalHeight,
}))
expect(compact.width / compact.height).toBeCloseTo(compact.ratio, 1)
expect(compact.previewHeight).toBeCloseTo(compact.sourceHeight, 0)
} 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)
await page.locator('.md-code-block').first().getByRole('button', { name: 'Preview', exact: true }).click()
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)
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 first = page.locator('.md-code-block').first()
const invalid = page.locator('.md-code-block').nth(2)
await invalid.scrollIntoViewIfNeeded()
await invalid.getByRole('status').filter({ hasText: 'Unable to render this diagram' }).waitFor()
await invalid.getByRole('button', { name: 'Source', exact: true }).click()
expect((await invalid.locator('[data-code-block-content]').boundingBox())!.height).toBeCloseTo(120, 0)
await invalid.getByRole('button', { name: 'Preview', exact: true }).click()
expect(await first.locator('pre').count()).toBe(0)
expect(await first.locator('[data-code-block-source-view]').getAttribute('aria-hidden')).toBe('true')
expect(await first.getByText('mermaid', { exact: true }).count()).toBe(1)
expect(await page.getByRole('button', { name: 'Preview', exact: true }).count()).toBe(6)
const html = page.locator('.md-code-block').nth(6)
expect(await html.locator('pre code').textContent()).toBe(HTML)
expect(await html.getByRole('button', { name: 'Preview', exact: true }).count()).toBe(0)
await first.scrollIntoViewIfNeeded()
const image = first.getByRole('img', { name: 'Mermaid diagram', exact: true })
await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
const diagram = await image.elementHandle()
const layout = await first.evaluate((block) => {
const source = block.querySelector<HTMLElement>('[data-code-block-source-view]')!
const preview = block.querySelector<HTMLElement>('[data-code-block-preview]')!
return {
block: block.getBoundingClientRect().height,
source: source.closest('.md-code-block')!.querySelector('[data-code-block-content]')!.getBoundingClientRect().height,
preview: preview.getBoundingClientRect().height,
width: block.getBoundingClientRect().width,
}
})
expect(layout.preview).toBeCloseTo(layout.source, 0)
await first.getByRole('button', { name: 'Enlarge preview', exact: true }).click()
const dialog = page.getByRole('dialog', { name: 'Mermaid diagram', exact: true })
const enlarged = dialog.getByRole('img', { name: 'Mermaid diagram', exact: true })
const enlargedGeometry = await enlarged.evaluate((node: HTMLImageElement) => ({
width: node.getBoundingClientRect().width,
height: node.getBoundingClientRect().height,
ratio: node.naturalWidth / node.naturalHeight,
}))
const viewport = page.viewportSize()!
expect(enlargedGeometry.width).toBeGreaterThan(layout.width)
expect(enlargedGeometry.width / enlargedGeometry.height).toBeCloseTo(enlargedGeometry.ratio, 1)
expect(Math.max(enlargedGeometry.width / (viewport.width * 0.88), enlargedGeometry.height / (viewport.height * 0.84))).toBeCloseTo(1, 2)
const fitted = (await enlarged.boundingBox())!
expect(fitted.x).toBeGreaterThanOrEqual(viewport.width * 0.06 - 1)
expect(fitted.y).toBeGreaterThanOrEqual(viewport.height * 0.08 - 1)
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'lightbox.expected.md'), await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
const anchor = { x: fitted.x + fitted.width * 0.6, y: fitted.y + fitted.height * 0.6 }
await page.mouse.move(anchor.x, anchor.y)
await page.mouse.wheel(0, -360)
await expect.poll(async () => (await enlarged.boundingBox())!.width).toBeGreaterThan(fitted.width * 1.5)
const zoomed = (await enlarged.boundingBox())!
expect((anchor.x - zoomed.x) / zoomed.width).toBeCloseTo(0.6, 2)
expect((anchor.y - zoomed.y) / zoomed.height).toBeCloseTo(0.6, 2)
await page.mouse.down()
await page.mouse.move(anchor.x + 100, anchor.y + 60, { steps: 15 })
await page.mouse.up()
const panned = (await enlarged.boundingBox())!
expect(panned.x - zoomed.x).toBeCloseTo(100, 0)
expect(panned.y - zoomed.y).toBeCloseTo(60, 0)
expect(panned.width).toBeCloseTo(zoomed.width, 2)
await page.mouse.dblclick(viewport.width / 2, viewport.height / 2)
await expect.poll(async () => (await enlarged.boundingBox())!.width).toBe(fitted.width)
expect((await enlarged.boundingBox())!.x).toBe(fitted.x)
expect(await image.getAttribute('src')).toBe(await enlarged.getAttribute('src'))
await dialog.getByRole('button', { name: 'Close preview', exact: true }).click()
await showAllPreviews(page)
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.getByRole('status').filter({ hasText: 'Unable to render this diagram. Select Source to inspect the code.' }).count()).toBe(1)
const failed = page.locator('.md-code-block').filter({ has: page.locator('[role="status"]') })
await failed.getByRole('button', { name: 'Source', exact: true }).click()
expect(await failed.locator('pre code').textContent()).toBe(INVALID)
await failed.getByRole('button', { name: 'Preview', exact: true }).click()
expect(await images.first().evaluate(node => decodeURIComponent((node as HTMLImageElement).src))).toContain('共享渲染器')
await first.getByRole('button', { name: 'Copy', exact: true }).click()
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe(FLOW)
await first.getByRole('button', { name: 'Source', exact: true }).focus()
await page.keyboard.press('Enter')
expect(await first.getByRole('button', { name: 'Source', 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 && getComputedStyle(node).visibility === 'hidden')).toBe(true)
await first.getByRole('button', { name: 'Preview', exact: true }).click()
await first.getByRole('img', { name: 'Mermaid diagram' }).waitFor()
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)
expect(await page.getByRole('button', { name: '预览', exact: true }).count()).toBe(6)
const first = page.locator('.md-code-block').first()
expect(await first.locator('pre').count()).toBe(0)
expect(await first.locator('[data-code-block-source-view]').getAttribute('aria-hidden')).toBe('true')
await first.getByRole('button', { name: '预览', exact: true }).click()
const firstImage = page.getByRole('img', { name: 'Mermaid 图表' }).first()
await expect.poll(() => firstImage.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
await first.getByRole('button', { name: '放大预览', exact: true }).click()
await page.getByRole('dialog', { name: 'Mermaid 图表' }).waitFor()
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'zh-lightbox.expected.md'), await captureStableAria(page, '[role="dialog"]', scaffold.workspaceCwd), MODE)
await page.keyboard.press('Escape')
expect(await first.getByRole('button', { name: '源码', exact: true }).textContent()).toBe('源码')
expect(await first.getByRole('button', { name: '复制', exact: true }).getAttribute('aria-label')).toBe('复制')
await showAllPreviews(page, '预览')
await page.getByRole('status').filter({ hasText: '无法渲染此图表,可切换到源码查看。' }).waitFor()
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', 'source-highlight.expected.md', 'lightbox.expected.md', 'zh-lightbox.expected.md', 'offscreen.expected.md'])
await page.close()
}, 60_000)
it.skipIf(MODE === 'record')('defers offscreen Mermaid and Graphviz runtimes until a preview enters the viewport', async () => {
const page = await newEnglishPage(browser)
let history: WebScaffold | undefined
const runtimes: string[] = []
page.on('request', (request) => {
const path = new URL(request.url()).pathname
if (/\/(mermaid\.core|viz)-[^/]+\.js$/.test(path)) runtimes.push(path)
})
try {
history = await launchWebScaffold({})
const tail = ['```dot', DOT, '```', ...Array.from({ length: 40 }, (_, index) => `Closing note ${index + 1}.`), 'History complete.'].join('\n\n')
await seedSession(history, fixture(Array.from({ length: 20 }, (_, index) => index === 0 ? FLOW : SEQUENCE), tail), SEED_ID)
await openConversation(page, history)
await page.getByText('History complete.', { exact: true }).waitFor()
await page.evaluate(async () => {
await new Promise<void>(resolve => requestAnimationFrame(() => requestAnimationFrame(() => { resolve() })))
})
const blocks = page.locator('.md-code-block')
expect(await blocks.count()).toBe(21)
expect(await blocks.locator('img').count()).toBe(0)
expect(runtimes).toEqual([])
await compareOrRefreshGolden(join(SNAPSHOT_DIR, 'offscreen.expected.md'),
await captureStableAria(page, '.md-code-block', history.workspaceCwd), MODE)
await blocks.first().scrollIntoViewIfNeeded()
await blocks.first().getByRole('img', { name: 'Mermaid diagram', exact: true })
.evaluate(async (node: HTMLImageElement) => { await node.decode() })
expect(runtimes.some(path => path.includes('/mermaid.core-'))).toBe(true)
expect(runtimes.some(path => path.includes('/viz-'))).toBe(false)
await blocks.last().scrollIntoViewIfNeeded()
await blocks.last().getByRole('img', { name: 'Graphviz diagram', exact: true })
.evaluate(async (node: HTMLImageElement) => { await node.decode() })
expect(runtimes.some(path => path.includes('/viz-'))).toBe(true)
} finally {
await page.close()
await history?.close()
}
}, 60_000)
it.skipIf(MODE === 'record')('rejects native Mermaid image nodes before requests and renders the next diagram', async () => {
const page = await newEnglishPage(browser)
let imageScaffold: WebScaffold | undefined
const requests: string[] = []
try {
await page.route('https://preview.invalid/**', async (route) => {
requests.push(route.request().url())
await route.abort()
})
imageScaffold = await launchWebScaffold({})
await seedSession(imageScaffold, fixture([
'flowchart LR\n A@{ img: "https://preview.invalid/mermaid-image", h: 80 }', FLOW,
]), SEED_ID)
await openConversation(page, imageScaffold)
await page.locator('.md-code-block').first().scrollIntoViewIfNeeded()
await page.locator('.md-code-block').first().getByRole('status')
.filter({ hasText: 'Unable to render this diagram' }).waitFor()
await page.locator('.md-code-block').nth(1).scrollIntoViewIfNeeded()
const image = page.getByRole('img', { name: 'Mermaid diagram', exact: true })
await image.evaluate(async (node: HTMLImageElement) => { await node.decode() })
expect(requests).toEqual([])
} finally {
await page.close()
await imageScaffold?.close()
}
}, 60_000)
it.skipIf(MODE === 'record')('previews inert DOT and SVG images', async () => {
const page = await newEnglishPage(browser)
const requests: string[] = []
const dialogs: string[] = []
// A data-URL SVG image must not fetch source-authored external resources.
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 [index, title, code] of [[4, 'Graphviz diagram', DOT], [5, 'SVG preview', SVG]] as const) {
const block = page.locator('.md-code-block').nth(index)
await block.getByRole('button', { name: 'Preview', exact: true }).click()
const preview = block.getByRole('img', { name: title, exact: true })
await preview.waitFor()
await expect.poll(() => preview.evaluate(node => (node as HTMLImageElement).naturalWidth)).toBeGreaterThan(0)
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()
await preview.waitFor()
}
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()
})
})
@@ -22,9 +22,7 @@
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- text: ts
- button "Line numbers"
- button "Copy"
- text: Source
- code: "const first: number = 1 const second = \"two\" let tail"
- status: Deep diving...
- textbox "Message or run a task, / commands, @ files or sessions"
@@ -1,34 +0,0 @@
- banner:
- navigation "Session hierarchy":
- button "Stream an SVG diagram." [disabled]
- img
- text: Standard mode
- button "More actions":
- img
- button "Open right sidebar":
- img
- tablist:
- tab "Chat" [selected]
- tab "Trajectory"
- button "System prompt":
- img
- img
- text: System prompt
- text: Stream an SVG diagram. {{clock}}
- button "Copy":
- img
- button "Context injection @deepseek-ai/dsh-system-prompt":
- img
- img
- text: Context injection @deepseek-ai/dsh-system-prompt
- text: svg
- status: Generating diagram…
- status: Deep diving...
- textbox "Message or run a task, / commands, @ files or sessions"
- button "Add files or run commands":
- img
- 'button "Access mode, current: Workspace Write"': Workspace Write
- button "Select model, current streaming-fence-highlight-test/streaming-fence":
- text: streaming-fence-highlight-test/streaming-fence
- img
- button "Stop generating"
@@ -58,21 +58,17 @@ class StreamingFenceAdapter extends LlmAdapter {
this.finish()
}
constructor(private readonly first = FIRST_REPLY, private readonly open = OPEN_REPLY, private readonly reply = REPLY) {
super()
}
override async *stream(options: GenerateOptions): AsyncIterable<StreamChunk> {
yield { type: 'block-start', index: 0, blockType: 'text' }
yield { type: 'text-delta', index: 0, text: this.first }
yield { type: 'text-delta', index: 0, text: FIRST_REPLY }
this.resolveFirstPaused()
await this.firstContinuation
yield { type: 'text-delta', index: 0, text: this.open.slice(this.first.length) }
yield { type: 'text-delta', index: 0, text: OPEN_REPLY.slice(FIRST_REPLY.length) }
this.resolveSecondPaused()
await this.secondContinuation
if (options.signal?.aborted === true) throw options.signal.reason
yield { type: 'text-delta', index: 0, text: this.reply.slice(this.open.length) }
yield { type: 'block-end', index: 0, block: { type: 'text', text: this.reply } }
yield { type: 'text-delta', index: 0, text: '\n```' }
yield { type: 'block-end', index: 0, block: { type: 'text', text: REPLY } }
yield { type: 'finish', reason: { kind: 'stop' } }
}
}
@@ -133,62 +129,6 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting',
await scaffold?.close()
})
it('holds a stable image placeholder while a diagram streams, then restores preview controls', async () => {
const first = '```svg\n<svg xmlns="http://www.w3.org/2000/svg" width="460" height="100">\n'
const open = `${first}<rect width="460" height="100" fill="lightblue"/>\n`
const response = `${open}</svg>\n\`\`\``
const pending = new StreamingFenceAdapter(first, open, response)
const local = await launchWebScaffold()
let previewPage: Page | undefined
let settled: ReturnType<WebScaffold['whenTurnSettled']> | undefined
try {
local.ctx.effect(() => local.ctx.llm.registerAdapter([PROVIDER], pending), 'streaming preview adapter')
await local.ctx.agentDefaultModel.saveSelection({ provider: PROVIDER, model: MODEL })
previewPage = await newEnglishPage(browser)
await previewPage.goto(local.authenticatedUrl, { waitUntil: 'load' })
await connectFreshWorkspace(previewPage, local.workspaceCwd)
const input = previewPage.locator('[data-composer-input]').first()
await writeComposerDraft(previewPage, input, 'Stream an SVG diagram.')
await input.press('Enter')
await pending.firstPaused
settled = local.whenTurnSettled(30_000)
const block = previewPage.locator('.md-code-block')
const placeholder = block.locator('[data-preview-placeholder]')
await placeholder.waitFor()
const height = (await block.boundingBox())!.height
expect((await placeholder.boundingBox())!.height).toBe(180)
expect(await block.locator('pre, button').count()).toBe(0)
const glyph = placeholder.locator('svg')
expect(await glyph.evaluate(node => getComputedStyle(node).animationDuration)).toBe('1.8s')
pending.grow()
await pending.secondPaused
expect((await block.boundingBox())!.height).toBe(height)
expect(await block.locator('pre, button').count()).toBe(0)
await compareOrRefreshGolden(
fileURLToPath(new URL('./snapshots/streaming-fence-highlight/preview-placeholder.expected.md', import.meta.url)),
await captureStableAria(previewPage, '[class*="centerCol"]', local.workspaceCwd), MODE,
)
await previewPage.emulateMedia({ reducedMotion: 'reduce' })
expect(await glyph.evaluate(node => getComputedStyle(node).animationName)).toBe('none')
pending.finish()
await settled
await block.getByRole('img', { name: 'SVG preview', exact: true }).waitFor()
expect(await placeholder.count()).toBe(0)
expect(await block.locator('pre').count()).toBe(0)
expect(await block.locator('[data-code-block-source-view]').getAttribute('aria-hidden')).toBe('true')
await block.getByRole('button', { name: 'Source', exact: true }).click()
expect(await block.locator('pre code').textContent()).toBe(response.slice(7, -4))
} finally {
pending.continue()
try {
await settled
} finally {
await previewPage?.close()
await local.close()
}
}
}, 60_000)
it('renders the growing fence through shiki and preserves its token tree when the turn settles', async () => {
onTestFailed(() => saveFailureShot(page, 'web-e2e-streaming-fence-highlight'))
const input = page.locator('[data-composer-input]').first()
@@ -240,6 +180,6 @@ describe.skipIf(MODE === 'record')('web e2e: streaming code-fence highlighting',
}))).toEqual({ block: 'true', pre: 'true', line: 'true' })
expect(tripwire.pageErrors).toEqual([])
expect(tripwire.warnings).toEqual([])
await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md', 'preview-placeholder.expected.md'])
await assertFixtureInventory(SNAPSHOT_DIR, ['mid-stream.expected.md'])
}, 60_000)
})
-2
View File
@@ -96,7 +96,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/cold-blank-session.e2e.ts",
@@ -135,7 +134,6 @@
"tests/chat-continuous-conversation.e2e.ts",
"tests/composer-tab-geometry.e2e.ts",
"tests/complex-history.perf.ts",
"tests/diagram-preview.perf.ts",
"tests/pwsh-terminal.e2e.ts",
"tests/workflow-run.e2e.ts"
],
+1 -15
View File
@@ -29,20 +29,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 {
@@ -170,7 +156,7 @@ export default defineConfig({
// directory, and the served index resolves identically from the site root.
base: './',
plugins: [
previewNotices(), rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage(),
rejectStandaloneServe(), clientDocumentTitle(), react(), emitPreviewPage(),
productWebBundleIsolation(src('../..'), src('.')),
],
build: {
-13
View File
@@ -36,19 +36,6 @@ export const en = {
'truncated': 'Truncated',
'json.label': 'JSON',
'markdown.footnotes': 'Footnotes',
'markdown.preview.graphviz': 'Graphviz diagram',
'markdown.preview.svg': 'SVG preview',
'markdown.preview.error': 'Unable to render this preview. Select Source to inspect the code.',
'markdown.mermaid.diagram': 'Mermaid diagram',
'markdown.preview.preview': 'Preview',
'markdown.preview.source': 'Source',
'markdown.code.source': 'Source',
'markdown.code.lineNumbers': 'Line numbers',
'markdown.preview.pending': 'Generating diagram…',
'markdown.preview.zoom': 'Enlarge preview',
'markdown.preview.interaction': 'Drag or arrow keys to pan · Scroll or +/− to zoom · Double-click or Home to fit',
'markdown.preview.close': 'Close preview',
'markdown.mermaid.error': 'Unable to render this diagram. Select Source to inspect the code.',
'markdown.truncatedCharacters': '… truncated at {total} characters',
'number.thousand': '{value}K',
'number.million': '{value}M',
-13
View File
@@ -34,19 +34,6 @@ export const zh = {
'truncated': '已截断',
'json.label': 'JSON',
'markdown.footnotes': '脚注',
'markdown.preview.graphviz': 'Graphviz 图表',
'markdown.preview.svg': 'SVG 预览',
'markdown.preview.error': '无法渲染此预览,可切换到源码查看。',
'markdown.mermaid.diagram': 'Mermaid 图表',
'markdown.preview.preview': '预览',
'markdown.preview.source': '源码',
'markdown.code.source': '源码',
'markdown.code.lineNumbers': '行号',
'markdown.preview.pending': '正在生成图表…',
'markdown.preview.zoom': '放大预览',
'markdown.preview.interaction': '拖拽或方向键移动 · 滚轮或 +/− 缩放 · 双击或 Home 适应屏幕',
'markdown.preview.close': '关闭预览',
'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: 1c8d99b4a37fa82bdde5d6d97b021c3b714a3a2e
README.zh.md: d2dd2462535892a1a0dd0d1bf57255e4177c5b5e
README.md: b5314413df8195834152c354df89c25600afc63b
README.zh.md: 23288907f6f0dd4dec457ac56e403b050b6ef4e5
-10
View File
@@ -16,7 +16,6 @@ File-mention providers receive the viewed Session ID with the closing-turn owner
- [Reference previews](#reference-previews)
- [System prompt row](#system-prompt-row)
- [Mermaid previews](#mermaid-previews)
- [Turn token usage](#turn-token-usage)
- [Completed-turn footer](#completed-turn-footer)
- [Turn Process Folding](#turn-process-folding)
@@ -37,15 +36,6 @@ HTTP(S) links in Assistant Markdown open a new right-Sidebar Browser tab on ordi
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 defaults to diagram previews for `mermaid`, `graphviz`/`dot`, and `svg` fences. Preview height follows the image within viewport limits; Source uses the same area and mounts on first selection. Both views copy the original source. The magnifier opens a viewport-fitted image with pan and zoom. Supported streaming fences show a placeholder; HTML fences remain code. Invalid diagrams show an error and keep Source available. Chat enables the shared [preview primitive](../ui-primitives/README.md) through localized labels. This presentation adds no model prompt, tool, or Session event.
-----
<a id="turn-token-usage"></a>
## Turn token usage
-8
View File
@@ -16,7 +16,6 @@ kind: "package-reference"
- [引用预览](#reference-previews)
- [系统提示词行](#system-prompt-row)
- [Mermaid 预览](#mermaid-previews)
- [轮次 token 用量](#turn-token-usage)
- [已完成轮次的页脚](#completed-turn-footer)
- [轮次过程折叠](#turn-process-folding)
@@ -39,13 +38,6 @@ Assistant Markdown 中的 HTTP(S) 链接在普通点击时会在该类型已注
-----
<a id="mermaid-previews"></a>
## Mermaid 预览
已定稿的 Assistant Markdown 默认把 `mermaid`、`graphviz`/`dot` 和 `svg` fence 显示为图表预览。预览高度按图片计算并受视口限制;源码在首次选中时挂载,使用同一区域。两种视图复制的都是原始源码。放大镜打开适应视口、支持平移与缩放的图片。受支持的流式 fence 显示占位;HTML fence 保持代码显示。非法图表显示错误并保留源码入口。Chat 通过本地化 label 启用共享的[预览原语](../ui-primitives/README.zh.md)。这种展示不增加模型提示词、工具或 Session 事件。
-----
<a id="turn-token-usage"></a>
## 轮次 token 用量
@@ -9,23 +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 = { error: t('markdown.preview.error') }
return {
code: { copyLabel: t('copy'), copiedLabel: t('copied'), sourceLabel: t('markdown.code.source'), lineNumbersLabel: t('markdown.code.lineNumbers') },
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
footnotes: t('markdown.footnotes'),
preview: {
preview: t('markdown.preview.preview'),
source: t('markdown.preview.source'),
zoom: t('markdown.preview.zoom'),
pending: t('markdown.preview.pending'),
close: t('markdown.preview.close'),
interaction: t('markdown.preview.interaction'),
mermaid: {
diagram: t('markdown.mermaid.diagram'),
error: t('markdown.mermaid.error'),
},
graphviz: { ...status, diagram: t('markdown.preview.graphviz') },
svg: { ...status, diagram: t('markdown.preview.svg') },
},
}
}
@@ -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: e9a6b4d1271d3633b5cdb592ee322c3a60d0aa77
README.zh.md: 9468ac4905019948bf0f8df5a08a97c80593d4d0
README.md: d466de8e4a26a6476e373e19c45c72ae22197f1b
README.zh.md: eb6f4ce836eb8a219aad38f0941e666f3391ebc6
+3 -20
View File
@@ -72,17 +72,7 @@ The catalog above lists what each export is for; this section covers the behavio
### Rendering agent output
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. 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, source-only rendering 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. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
`CodeBlock` highlights `dot`/`graphviz`, `svg`, and `mermaid` when displaying source, with case-insensitive language hints. These grammars load on demand; mounted source automatically gains theme-token colors when loading completes. SVG shares the XML grammar. Mermaid uses the diagram-body rules from `@shikijs/langs/mermaid`. The pinned DOT grammar and its mechanical conversion are recorded in the [third-party notices](THIRD_PARTY_PREVIEW_NOTICES.txt).
Supply `MarkdownLabels.preview` to show settled `mermaid`, `graphviz`/`dot`, and `svg` fences as previews by default. Supported streaming fences show a 180px image placeholder with a pulsing glyph and localized status; controls become available after streaming ends. Consumers without these labels show source. Mermaid and Graphviz use the active document palette on the code-block background and refresh intersecting previews or open lightboxes when resolved colors change. Offscreen previews defer rendering and runtime loading until entry. Explicit colors in DOT and SVG 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 or image-load failure shows the supplied error label with Source available in the toolbar; replacing the source discards late results from the previous render.
`CodeBlock.preview` supplies a standard source-preview descriptor with a renderer and complete localized output and control labels. Callers do not pass React nodes. The language stays on the left; magnifier and copy icons precede the Source/Preview segmented control on the right. Only the selected background slides for 160ms; the body switches immediately. Unsupported blocks show a static selected Source label. Copy always reads source and confirms success through its icon and tooltip without changing toolbar width. Preview shows a magnifier that opens a body-portaled lightbox fitted within 88% of viewport width and 84% of its height. Drag to pan, scroll around the cursor to zoom, or double-click to fit again. Arrow keys pan, +/− zoom, and Home resets. The lightbox reuses the generated image; output without a usable image disables it with a localized accessible explanation. Source replaces the magnifier with a line-number toggle that preserves the source text and copy output. In Preview, the toolbar fades out through opacity alone over 160ms and appears on block hover or keyboard focus; touch devices keep it visible. Source keeps its toolbar visible.
Graphviz loads `@viz-js/viz` on demand and renders DOT with the `dot` engine. SVG and Graphviz output are inert images in the same canvas as Mermaid; malformed SVG and DOT show an error and leave Source available. The browser sizes previews from the image aspect ratio and available width, limiting image height to the smaller of 60vh and 640px. The canvas has 16px padding and a 120px minimum height. Source mounts on first selection, scrolls within that same area, and remains mounted with the generated image across view changes. HTML fences use the ordinary highlighted-code presentation.
[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 choices.
`MarkdownText` renders untrusted GFM and TeX math, blocks unsafe links and images, and can turn resolved file mentions into explicit controls. A surrounding `MarkdownDelegateProvider` receives sanitized HTTP(S) URLs from ordinary clicks; modified clicks and links outside a provider retain native external-anchor behavior. 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. When supplied with `UserTextReferences`, file and skill references become keyboard-accessible preview buttons using the same hover and focus styling as prose file links; the first pointer click can open a preview, while subsequent clicks and existing text selections retain native selection handling. Keyboard activation opens previews even when text is selected.
`MarkdownText` defaults to `variant="body"`. Use `variant="compact"` for secondary content: its 13px text and 20px line height follow the content-size setting, all heading levels use the same size with weight 600, and paragraphs and lists use tighter spacing. Text, links, and code keep the tertiary color; dotted underlines distinguish links. Code headers scroll with their blocks. Tables and math stay enabled at the surrounding text size and scroll horizontally within the available width. Both variants share the parser and streaming cache.
@@ -118,11 +108,7 @@ The package enforces one separation: presentational React atoms with zero Cordis
### Streaming markdown
While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply. A final unclosed top-level fence keeps its parsed code node and sends only the last completed line plus the current partial line through the same GFM grammar; a closing fence or ambiguous parse returns to the ordinary tail path. Highlighting likewise resumes from saved Shiki grammar state and publishes only newly completed lines plus the mutable tail. Re-rendering the same highlight frame reuses its rendered body, so React render retries cannot append completed lines twice. `CodeBlock` seals completed lines into fixed-size React groups, reuses earlier groups, and retains the whole highlighted tree across settlement when code and language are unchanged. The settled full parse still resolves references that crossed the freeze boundary.
### Preview ownership
The streaming placeholder mounts neither the source highlighter nor the diagram renderer. Once streaming ends, the waiting canvas remains until the image loads; stopping a stream also exits the streaming wait. Reduced motion disables its 1.8-second opacity animation. A shared viewport observer activates intersecting previews; leaving cancels unfinished rendering unless the lightbox is open. Each mounted block retains its image across view changes and viewport exits. Reentry reuses matching source, renderer and palette results, including failures. Theme refreshes retain the loaded image and lightbox until the replacement loads; refresh failures keep the usable image and announce the error. Previewable blocks mount and highlight source on first selection, then retain its DOM; source-only blocks retain viewport activation. Independent memoized source and copy controls keep toolbar feedback and unchanged parent props from redoing source work, while grammar readiness is specific to the source language. The [interaction decision](../../../.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.md) owns retention, and the [preview sizing decision](../../../.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.md) owns geometry.
While a reply streams, `MarkdownText` parses incrementally: all but the trailing two blocks freeze as cached React elements and only the source tail re-parses per chunk, so per-chunk work tracks the tail instead of the whole reply. A final unclosed top-level fence keeps its parsed code node and sends only the last completed line plus the current partial line through the same GFM grammar; a closing fence or ambiguous parse returns to the ordinary tail path. Highlighting likewise resumes from saved Shiki grammar state and publishes only newly completed lines plus the mutable tail. `CodeBlock` seals completed lines into fixed-size React groups, reuses earlier groups, and retains the whole highlighted tree across settlement when code and language are unchanged. The settled full parse still resolves references that crossed the freeze boundary.
### Geometry and overflow
@@ -158,12 +144,9 @@ None; this package neither assembles nor sends a provider request.
<a id="known-limitations-and-deferred-work"></a>
These limits define how the atoms behave at the edges; they are current package constraints, not a component roadmap.
- **Diagram preview limits** — Mermaid image nodes are rejected before layout to prevent source-authored resource loads. Browsers without IntersectionObserver render immediately.
- **Diagram rendering runs on the browser thread** — Mermaid serializes layouts; Graphviz performs synchronous WebAssembly layout. Cancellation skips queued work but cannot interrupt active layout. Loaded images and visited source DOM remain until unmount; viewport deferral is not virtualization or worker rendering.
- **Diff search is bounded, input processing is linear** — the edit-distance limit trades precise alignment for a coarse replacement on heavily changed fragments. Normalization, fallback rows, and copied output still scale with input size; the height cap limits visible rows, not those allocations.
- **Known-site marks are a fixed list** — only the named hosts resolve to their own mark, and every other external host keeps the globe; recognizing an arbitrary site would require fetching its icon over the network.
- **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.
+3 -20
View File
@@ -72,17 +72,7 @@ kind: "package-library"
### 渲染 agent 输出
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 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,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
`CodeBlock` 显示源码时对 `dot`/`graphviz`、`svg` 与 `mermaid` 高亮,语言提示不区分大小写。这些语法按需加载;加载完成后,已挂载的源码会自动采用主题 token 颜色。SVG 复用 XML 语法。Mermaid 使用 `@shikijs/langs/mermaid` 的图表正文规则。固定的 DOT 语法及其机械转换记录在[第三方声明](THIRD_PARTY_PREVIEW_NOTICES.txt)中。
传入 `MarkdownLabels.preview`,让定稿后的 `mermaid`、`graphviz`/`dot` 和 `svg` fence 默认显示预览。受支持的流式 fence 显示高 180px 的图片占位、呼吸图标与本地化状态;流式结束后提供控件。未提供这些文案的调用方显示源码。Mermaid 与 Graphviz 在代码块背景上使用当前文档配色,并在解析后的颜色变化时更新与视口相交的预览或已打开的大图。视口外的预览推迟到进入视口后才渲染和加载运行时。DOT 与 SVG 中明确指定的颜色保留为作者内容。渲染按需加载 Mermaid,采用严格安全模式,并将生成的 SVG 作为不带图表链接处理器的图片显示。渲染或图片加载失败时显示传入的错误文案,工具栏仍可切换源码;替换源码会丢弃上一次渲染迟到的结果。
`CodeBlock.preview` 提供标准化的源码预览描述,包含 renderer 以及完整的本地化输出与控件文案。调用方不传入 React 节点。语言名位于左侧;右侧依次放置放大镜、复制图标和源码/预览分段控件。只有选中背景滑动 160ms,正文立即切换。不支持预览的代码块显示静态选中的源码文案。复制始终读取源码,并通过图标和 tooltip 确认成功,不改变工具栏宽度。预览中的放大镜打开 body portal lightbox,图片等比适应屏幕宽度的 88% 和高度的 84%。拖拽可平移,滚轮围绕鼠标位置缩放,双击恢复适应屏幕。方向键平移,+/− 缩放,Home 复位。大图复用已生成的图片;没有可用图片时禁用,并提供本地化的可访问说明。源码视图用行号开关替换放大镜,源码文本和复制结果保持不变。预览工具栏仅通过透明度在 160ms 内淡出,鼠标移入代码块或键盘聚焦时显示;触摸设备保持可见。源码工具栏始终可见。
Graphviz 按需加载 `@viz-js/viz`,使用 `dot` 引擎渲染 DOT。SVG 与 Graphviz 输出都作为不可执行图片显示在与 Mermaid 相同的画布中;无效 SVG 与 DOT 显示错误并保留源码入口。浏览器按图片宽高比与可用宽度计算预览高度,图片高度不超过 60vh 与 640px 中的较小值。画布保留 16px 内边距与 120px 最小高度。源码在首次选中时挂载,在同一区域内滚动;后续切换保留源码 DOM 与已生成图片。HTML fence 使用普通高亮代码视图。
[预览许可证声明](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)记录分发选择。
`MarkdownText` 渲染不可信的 GFM 与 TeX 公式、阻止不安全的链接与图片,并可把已解析的文件提及转换为显式控件。外层 `MarkdownDelegateProvider` 会接收普通点击产生的已净化 HTTP(S) URL;带修饰键的点击和 Provider 外的链接保留原生外部 anchor 行为。当 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,供消息气泡和排队行使用。 传入 `UserTextReferences` 时,文件和 skill 引用成为支持键盘操作的预览按钮,复用正文文件链接的悬停和聚焦样式;第一次指针点击可以打开预览,后续点击和已有选区保留原生选择行为。键盘激活在存在选区时仍可打开预览。
`MarkdownText` 默认为 `variant="body"`。次级内容使用 `variant="compact"`:其 13px 字号与 20px 行高跟随内容字号设置,各级标题保持同一字号并使用 600 字重,段落与列表采用更紧凑的间距。正文、链接和代码均保持 tertiary 颜色,以点状下划线区分链接。代码标题栏随代码块滚动。表格和公式仍然启用,使用周围文字的字号,并在可用宽度内横向滚动。两个变体共享解析器与流式缓存。
@@ -118,11 +108,7 @@ Graphviz 按需加载 `@viz-js/viz`,使用 `dot` 引擎渲染 DOT。SVG 与 Gr
### 流式 Markdown
回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复。末尾的顶层未闭合 fence 会保留已解析的 code node,只把最后一个已完成行与当前未完成行交给同一套 GFM grammar;闭合 fence 或有歧义的解析会回到普通尾部路径。高亮同样从保存的 Shiki grammar state 续接,并只发布新完成行与可变尾部。再次渲染同一高亮帧会复用其已渲染正文,因此 React 重试渲染不会重复追加已完成行。`CodeBlock` 把已完成行封入固定大小的 React 分组、复用更早的分组,并在代码与语言未变化时跨定稿保留整棵高亮树。定稿时的全量解析仍会解析跨过冻结边界的引用。
### 预览所有权
流式占位既不挂载源码高亮组件,也不调用图表渲染器。流式结束后,等待画布保留至图片加载完成;停止输出也会退出流式等待。减少动态效果设置禁用其 1.8 秒透明度动画。共享视口观察器激活与视口相交的预览;离开视口会取消未完成的渲染,已打开 lightbox 时除外。每个已挂载的代码块在视图切换和离开视口时保留图片。重新进入时复用源码、renderer 与配色相同的结果,包括失败结果。主题刷新保留已加载图片和 lightbox,直到替换图片加载完成;刷新失败则保留可用图片并报告错误。支持预览的代码块在首次选中源码时挂载并高亮,之后保留其 DOM;纯源码代码块仍在进入视口后激活高亮。独立 memo 化的源码与复制控件让工具栏反馈和属性未变的父组件更新不必重做源码工作,语法就绪状态也只针对该源码语言。[交互决策](../../../.agents/notes/implemented/feature/2026-09-10-codeblock-preview-interaction.zh.md)负责保留机制,[预览尺寸决策](../../../.agents/notes/implemented/simplification/2026-09-14-source-sized-code-block-previews.zh.md)负责几何行为。
回复流式输出期间,`MarkdownText` 增量解析:除末尾两个块外全部冻结为缓存的 React 元素,每个分片只重新解析其后的源文本尾部,因此每分片的工作量跟随尾部而非整个回复。末尾的顶层未闭合 fence 会保留已解析的 code node,只把最后一个已完成行与当前未完成行交给同一套 GFM grammar;闭合 fence 或有歧义的解析会回到普通尾部路径。高亮同样从保存的 Shiki grammar state 续接,并只发布新完成行与可变尾部。`CodeBlock` 把已完成行封入固定大小的 React 分组、复用更早的分组,并在代码与语言未变化时跨定稿保留整棵高亮树。定稿时的全量解析仍会解析跨过冻结边界的引用。
### 几何与溢出
@@ -158,12 +144,9 @@ Graphviz 按需加载 `@viz-js/viz`,使用 `dot` 引擎渲染 DOT。SVG 与 Gr
<a id="known-limitations-and-deferred-work"></a>
这些限制说明原子组件在边缘情况下的行为;它们是当前包约束,不是组件路线图。
- **图表预览限制**:Mermaid 原生图片节点在布局前被拒绝,避免加载源码指定的资源。不支持 IntersectionObserver 的浏览器会立即渲染。
- **图表渲染在浏览器线程上执行**:Mermaid 串行执行布局;Graphviz 同步执行 WebAssembly 布局。取消会跳过排队工作,但无法中断已开始的布局。已加载图片和访问过的源码 DOM 保留至卸载;视口延迟渲染不等同于虚拟化或 worker 渲染。
- **Diff 搜索有上限,输入处理仍为线性**:编辑距离上限使大量改动的片段采用粗粒度替换,不再精确对齐。规范化、回退行及复制内容仍随输入大小增长;高度限制只约束可见行数,不限制这些分配。
- **已知站点标记是固定列表**:只有列名的主机解析为自己的标记,其余外部主机仍使用地球;要识别任意站点需要通过网络抓取它的图标。
- **流式期间跨边界引用解析被推迟**:定义落在增量冻结边界另一侧的引用式链接或脚注,在回复流式输出期间渲染为字面文本;定稿时的全量解析会将其解析。
@@ -1,722 +0,0 @@
Markdown preview and syntax grammar 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.
===== Graphviz (DOT) TextMate grammar =====
Source: joaompinto/vscode-graphviz, commit e001e27f7831fb34cb9a8de9c456485bbc509ec3
https://github.com/joaompinto/vscode-graphviz/blob/e001e27f7831fb34cb9a8de9c456485bbc509ec3/dot/syntaxes/dot.tmLanguage
The plist is mechanically decoded into a Shiki LanguageRegistration array.
The display name is retained as displayName; name is dot and aliases is [graphviz].
An empty repository supplies Shiki's required registration field.
Editor-only fileTypes, firstLineMatch, keyEquivalent, uuid, and rule comments are omitted.
All patterns, captures, and scopes are unchanged.
The MIT License (MIT)
Copyright (c) 2016 João Pinto
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.
+1 -5
View File
@@ -47,15 +47,11 @@
"mdast-util-math": "^3.0.0",
"micromark-extension-gfm": "^3.0.0",
"micromark-util-sanitize-uri": "^2.0.1",
"simple-icons": "16.31.0",
"mermaid": "11.16.0",
"@viz-js/viz": "3.30.0"
"simple-icons": "16.31.0"
},
"files": [
"lib/index.js",
"lib/*-*.js",
"lib/**/*.css",
"THIRD_PARTY_PREVIEW_NOTICES.txt",
"lib/types/**/*.d.ts"
],
"peerDependencies": {
+1 -3
View File
@@ -66,9 +66,7 @@ export type {
WebBlockProps, WebSearchBlockProps, WebFetchBlockProps, WebSourceView, WebBlockLabels,
} from './WebBlock.tsx'
export { CodeBlock } from './markdown/CodeBlock.tsx'
export type {
CodeBlockPreview, CodeBlockPreviewLabels, CodeBlockPreviewRenderer, CodeBlockProps,
} from './markdown/CodeBlock.tsx'
export type { CodeBlockProps } from './markdown/CodeBlock.tsx'
export { JsonBlock } from './markdown/JsonBlock.tsx'
export { MarkdownDelegateProvider } from './markdown/MarkdownDelegate.tsx'
export type { MarkdownDelegateProviderProps, MarkdownExternalLinkHandler } from './markdown/MarkdownDelegate.tsx'
@@ -7,13 +7,10 @@
/* Local 11/18 instead of the shared --dsw-font-xs-13: the banner scales
with the markdown 0.875 ladder without shrinking every other xs-13 use. */
--dsl-code-block-banner-font: 11px/18px var(--dsw-font-family);
--dsl-code-block-banner-height: 36px;
--dsl-code-block-content-font: var(--dsw-font-markdown-code-block);
--dsl-code-block-background: var(--dsw-alias-markdown-code-block);
position: relative;
width: 100%;
max-width: 100%;
margin: 16px 0;
color: var(--dsw-alias-label-primary);
background: var(--dsl-code-block-background);
@@ -31,21 +28,11 @@
background-color: var(--dsw-alias-bg-base);
border-top-left-radius: var(--dsl-code-block-border-radius);
border-top-right-radius: var(--dsl-code-block-border-radius);
transition: opacity 160ms ease;
}
.block[data-preview] .bannerWrap {
opacity: 0;
}
.block[data-preview]:hover .bannerWrap,
.block[data-preview]:has(:focus-visible) .bannerWrap {
opacity: 1;
}
.banner {
background: var(--dsl-code-block-banner-background-color);
padding: 4px 10px 4px 14px;
padding: 9px 14px;
display: flex;
justify-content: space-between;
align-items: center;
@@ -67,124 +54,25 @@
}
.action {
min-height: 28px;
display: flex;
align-items: center;
flex-shrink: 0;
gap: 2px;
}
/* Match the sidebar pane controls: a 28px circle around a 15px glyph. */
.iconButton {
display: inline-flex;
flex: none;
align-items: center;
justify-content: center;
box-sizing: border-box;
width: 28px;
height: 28px;
padding: 6px;
color: var(--dsw-alias-label-secondary);
line-height: 1;
background: transparent;
.copyButton {
background-color: rgb(255 255 255 / 0);
border: none;
border-radius: 28px;
cursor: pointer;
}
.iconButton svg {
width: 15px;
height: 15px;
}
.iconButton:hover:not(:disabled) {
background: var(--dsw-alias-interactive-bg-hover);
}
.iconButton[aria-pressed='true'] {
background: var(--dsw-alias-interactive-bg-hover);
color: var(--dsw-alias-label-primary);
}
.iconButton:disabled {
color: var(--dsw-alias-label-tertiary);
cursor: default;
}
.previewActionSlot,
.iconSlot {
display: inline-flex;
}
.segments {
position: relative;
display: grid;
grid-auto-flow: column;
grid-auto-columns: 1fr;
padding: 2px;
margin-left: 4px;
border-radius: 8px;
background: var(--dsw-alias-interactive-bg-hover);
isolation: isolate;
}
.selection {
position: absolute;
inset: 2px;
width: calc((100% - 4px) / 2);
border-radius: 6px;
background: var(--dsw-alias-bg-base);
transition: transform 160ms ease;
z-index: -1;
}
.segments[data-preview-selected] .selection {
transform: translateX(100%);
}
.segments:has(.sourceOnly) .selection {
width: calc(100% - 4px);
}
.segments button,
.sourceOnly {
border: 0;
background: transparent;
padding: 0;
margin: 0;
color: inherit;
padding: 2px 8px;
font: inherit;
white-space: nowrap;
text-align: center;
}
.segments button {
cursor: pointer;
font: inherit;
}
.segments button[aria-pressed='false'] {
color: var(--dsw-alias-label-secondary);
}
.body {
min-height: 0;
}
.previewActionSlot[hidden] {
display: none;
}
.sourceHidden {
visibility: hidden;
pointer-events: none;
}
@media (prefers-reduced-motion: reduce) {
.selection { transition: none; }
.bannerWrap { transition: none; }
}
@media (hover: none) {
.block[data-preview] .bannerWrap { opacity: 1; }
/* Consumers may turn the stable content node into a viewport without changing
the default CodeBlock layout. */
.content {
display: contents;
}
.block :where(pre) {
@@ -216,32 +104,6 @@
color: var(--dsw-alias-label-primary);
}
.previewLayer {
display: grid;
overflow: auto;
background: var(--dsl-code-block-background);
border-bottom-right-radius: var(--dsl-code-block-border-radius);
border-bottom-left-radius: var(--dsl-code-block-border-radius);
}
/* The retained image sizes both views without laying out hidden source. */
.previewLayer[hidden] {
display: grid;
visibility: hidden;
pointer-events: none;
}
.previewable > .body {
position: absolute;
inset: 0;
overflow: auto;
border-radius: inherit;
}
.previewable[data-code-block-header] > .body {
inset-block-start: var(--dsl-code-block-banner-height);
}
.numbered :where(pre) code {
display: block;
counter-reset: source-line;
@@ -266,19 +128,3 @@
content: counter(source-line);
user-select: none;
}
@media (max-width: 480px) {
.banner {
gap: 4px;
padding-inline: 8px;
}
.segments {
margin-left: 0;
}
.segments button,
.sourceOnly {
padding-inline: 4px;
}
}
@@ -1,42 +1,21 @@
/** Diagram-sized code surface with retained preview rendering and toolbar state. */
import { memo, useRef, useState } from 'react'
import type { Ref } from 'react'
import { Fragment, useCallback, useMemo, useRef, useState, useSyncExternalStore } from 'react'
import type { CSSProperties, ReactNode, Ref } from 'react'
import clsx from 'clsx'
import { Tooltip } from '../Tooltip.tsx'
import { CopyCodeButton } from './CopyCodeButton.tsx'
import { CodeBlockSource } from './CodeBlockSource.tsx'
import { SourcePreview } from './SourcePreview.tsx'
import { PreviewPlaceholder } from './PreviewPlaceholder.tsx'
import type { PreviewRenderer, SourcePreviewLabels } from './SourcePreview.tsx'
import { writeClipboard } from '../clipboard.ts'
import {
StreamingHighlightSession, grammarLoadCount, highlightToHtml, subscribeGrammarLoaded,
} from './highlight.ts'
import type { HighlightSpan, StreamingHighlightFrame } from './highlight.ts'
import { useViewportHighlighting } from './useViewportHighlighting.ts'
import css from './CodeBlock.module.css'
/** Renderer accepted by the public {@link CodeBlockPreview} descriptor. */
export type CodeBlockPreviewRenderer = PreviewRenderer
/** Localized output and controls for a {@link CodeBlock} preview. */
export interface CodeBlockPreviewLabels extends SourcePreviewLabels {
/** Control label when source is visible. */
preview: string
/** Control label when the preview is visible. */
source: string
}
/** Standard source-backed content displayed by a {@link CodeBlock} preview. */
export interface CodeBlockPreview {
/** Convert the source into an isolated image. */
render: CodeBlockPreviewRenderer
/** Localized failure, accessible output, and control labels. */
labels: CodeBlockPreviewLabels
}
export interface CodeBlockProps {
/** The source text, rendered verbatim (trailing newline trimmed for display). */
code: string
/** Grammar hint (markdown fence info string or a fixed caller id); unknown = plain. */
lang?: string | undefined
/**
* The code is still growing. Previewable fences show a placeholder without
* rendering or highlighting; other streaming fences highlight through
* The code is still growing (a streaming markdown fence): highlight through
* a per-instance {@link StreamingHighlightSession}, which re-tokenizes only
* appended text and keeps completed line groups (and DOM) untouched. The
* caller must keep the component instance stable across growth (a
@@ -46,9 +25,9 @@ export interface CodeBlockProps {
streaming?: boolean | undefined
/** Extra class merged onto the wrapper (callers position; this component draws). */
className?: string | undefined
/** Ref for the stable content wrapper, for owners that use it as a scrollport. */
/** Ref for the stable source-content wrapper, for owners that use it as a scrollport. */
contentRef?: Ref<HTMLDivElement> | undefined
/** Initial gutter preference until the reader toggles line numbers. Defaults to false; copied source excludes numbers. */
/** Show a numbered gutter without adding numbers to copied source. Defaults to false. */
lineNumbers?: boolean | undefined
/** Show the language and copy header; false when the caller supplies a toolbar. Defaults to true. */
showHeader?: boolean | undefined
@@ -56,77 +35,166 @@ export interface CodeBlockProps {
copyLabel: string
/** Copy-button label during the post-copy confirmation window. */
copiedLabel: string
/** Selected source-view label when no preview is available. */
sourceLabel: string
/** Localized tooltip and accessible name for the source line-number toggle. */
lineNumbersLabel: string
/** Image preview, selected initially; streaming shows a placeholder. Copy always uses source. */
preview?: CodeBlockPreview | undefined
}
/**
* Display source or a retained diagram with localized controls.
* @param props - Source, presentation preferences, complete labels and optional preview renderer.
* @returns A content-sized preview, a streaming preview placeholder, or full-width source.
* The `pre` attributes shiki's HTML arm emits for the css-variables theme,
* mirrored so the streaming arm's tree is interchangeable with the settled
* swap (`tests/streaming-code-block.client.spec.tsx` pins the two arms'
* parity).
*/
export const CodeBlock = memo(function CodeBlock({
code, lang, streaming, className, lineNumbers = false, showHeader = true, copyLabel, copiedLabel, sourceLabel,
lineNumbersLabel, preview, contentRef,
const SHIKI_PRE_PROPS = {
className: 'shiki css-variables',
style: { backgroundColor: 'var(--shiki-background)', color: 'var(--shiki-foreground)' },
tabIndex: 0,
} as const
/** Completed-line group size; React reconciles groups while the DOM remains line-for-line identical. */
const STREAMING_LINE_GROUP_SIZE = 32
function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
return (
<Fragment key={index}>
{index > 0 && '\n'}
<span className="line">
{line.map((span, spanIndex) => <span key={spanIndex} style={span.style}>{span.text}</span>)}
</span>
</Fragment>
)
}
export function CodeBlock({
code, lang, streaming, className, contentRef, lineNumbers = false, showHeader = true, copyLabel, copiedLabel,
}: CodeBlockProps) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const previewAvailable = preview !== undefined
const pendingStream = previewAvailable && streaming === true
const [view, setView] = useState<'source' | 'preview'>('preview')
const [sourceVisited, setSourceVisited] = useState(false)
const [numberedOverride, setNumberedOverride] = useState<boolean>()
const numbered = numberedOverride ?? lineNumbers
const [actions, setActions] = useState<HTMLSpanElement | null>(null)
const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
const rootRef = useRef<HTMLDivElement>(null)
const showingPreview = previewAvailable && (pendingStream || view === 'preview')
const highlighting = useViewportHighlighting(rootRef, lang)
// Re-render when a lazy grammar finishes loading, so a fence that showed plain
// text while its language's grammar imported picks up highlighting. The
// snapshot value is opaque; only its change across renders drives the memo.
const loaded = useSyncExternalStore(subscribeGrammarLoaded, grammarLoadCount, grammarLoadCount)
// Streaming state lives in refs mutated inside the memo (the MarkdownText
// streaming-cache pattern): the session's caches carry across chunks only
// because the owner keys this instance stably while the fence grows.
const sessionRef = useRef<StreamingHighlightSession | null>(null)
const lineCacheRef = useRef<{
code: string
lang: string | undefined
generation: number
frame: StreamingHighlightFrame
groups: ReactNode[]
pending: ReactNode[]
nextLine: number
body: ReactNode
} | null>(null)
const settledRef = useRef(false)
const streamedBody = useMemo(() => {
if (!highlighting) {
sessionRef.current = null
lineCacheRef.current = null
settledRef.current = false
return undefined
}
if (streaming !== true) {
const previous = lineCacheRef.current
if (previous !== null && previous.code === trimmed && previous.lang === lang) {
settledRef.current = true
return previous.body
}
sessionRef.current = null
lineCacheRef.current = null
settledRef.current = true
return undefined
}
if (settledRef.current) {
sessionRef.current = null
lineCacheRef.current = null
settledRef.current = false
}
sessionRef.current ??= new StreamingHighlightSession()
const frame = sessionRef.current.updateFrame(trimmed, lang)
if (frame === undefined) {
lineCacheRef.current = null
return undefined
}
const previous = lineCacheRef.current
if (previous?.frame === frame && previous.code === trimmed && previous.lang === lang) {
return previous.body
}
const sameGeneration = previous?.generation === frame.generation
const groups = sameGeneration ? [...previous.groups] : []
let pending = sameGeneration ? [...previous.pending] : []
let nextLine = sameGeneration ? previous.nextLine : 0
for (const line of frame.appended) {
pending.push(renderLine(line, nextLine))
nextLine += 1
if (pending.length !== STREAMING_LINE_GROUP_SIZE) continue
const start = nextLine - pending.length
groups.push(<Fragment key={start}>{pending}</Fragment>)
pending = []
}
const tail = frame.tail.map((line, index) => renderLine(line, nextLine + index))
const tailGroup = <Fragment key={nextLine - pending.length}>{[...pending, ...tail]}</Fragment>
const body = <pre {...SHIKI_PRE_PROPS}><code>{groups}{tailGroup}</code></pre>
lineCacheRef.current = {
code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body,
}
return body
}, [streaming, highlighting, trimmed, lang, loaded])
const html = useMemo(
() => (highlighting && streaming !== true && streamedBody === undefined
? highlightToHtml(trimmed, lang)
: undefined),
[streaming, highlighting, streamedBody, trimmed, lang, loaded],
)
const [copied, setCopied] = useState(false)
const onCopy = useCallback(() => {
if (copied) return
/* 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)
})
}, [copied, trimmed])
// shiki's HTML output is a static span tree it generated from `code` (no
// user HTML passes through), the sanctioned innerHTML consumption path per
// shiki's own docs.
const body = streamedBody !== undefined
? streamedBody
: html === undefined
? (
<pre className={css.plain}><code>{sourceLines === undefined ? trimmed : sourceLines.map((line, index) => (
<Fragment key={index}>{index > 0 && '\n'}<span className="line">{line}</span></Fragment>
))}</code></pre>
)
: (
<div dangerouslySetInnerHTML={{ __html: html }} />
)
return (
<div ref={rootRef} className={clsx(css.block, 'md-code-block', previewAvailable && css.previewable, numbered && css.numbered, className)}
data-code-block-header={(showHeader && previewAvailable) || undefined}
data-line-numbers={numbered || undefined} data-preview={showingPreview || undefined}>
<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`,
} as CSSProperties}>
{/* These paired attributes are stable semantic hooks for owner styling and DOM tests. */}
{showHeader && <div className={css.bannerWrap}>
<div className={css.banner} data-code-block-banner>
<div className={css.infostring}>{lang ?? ''}</div>
<div className={css.action}>
{!pendingStream && <>
{previewAvailable && <span ref={setActions} className={css.previewActionSlot} hidden={!showingPreview} />}
{!showingPreview && <Tooltip label={lineNumbersLabel} side="top" delayMs={500}>
<button type="button" className={css.iconButton} aria-label={lineNumbersLabel} aria-pressed={numbered}
onClick={() => { setNumberedOverride(!numbered) }}>
<svg viewBox="0 0 16 16" width="16" height="16" fill="none" aria-hidden="true">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7"
stroke="currentColor" strokeWidth="1.2" strokeLinecap="round" strokeLinejoin="round" />
</svg>
</button>
</Tooltip>}
<CopyCodeButton code={trimmed} copyLabel={copyLabel} copiedLabel={copiedLabel} />
<div className={css.segments} data-preview-selected={showingPreview || undefined}>
<span className={css.selection} aria-hidden="true" />
{previewAvailable ? <>
<button type="button" aria-pressed={!showingPreview} onClick={() => { setSourceVisited(true); setView('source') }}>{preview.labels.source}</button>
<button type="button" aria-pressed={showingPreview} onClick={() => { setView('preview') }}>{preview.labels.preview}</button>
</> : <span className={css.sourceOnly}>{sourceLabel}</span>}
</div>
</>}
<button type="button" className={css.copyButton} onClick={onCopy}>
{copied ? copiedLabel : copyLabel}
</button>
</div>
</div>
</div>}
<div ref={contentRef} className={clsx(css.body, showingPreview && css.sourceHidden)} data-code-block-content>
{!pendingStream && <div className={clsx(css.source, showingPreview && css.sourceHidden)}
data-code-block-source-view={previewAvailable || undefined} aria-hidden={showingPreview || undefined}>
{(!previewAvailable || sourceVisited) && <CodeBlockSource target={rootRef} code={code} lang={lang}
streaming={streaming} lineNumbers={numbered} highlightImmediately={previewAvailable} />}
</div>}
</div>
{previewAvailable && <div className={css.previewLayer}
data-code-block-preview hidden={!showingPreview}>
{pendingStream ? <PreviewPlaceholder label={preview.labels.pending} /> :
<SourcePreview code={trimmed} labels={preview.labels} render={preview.render}
actions={actions ?? undefined} />}
</div>}
<div ref={contentRef} className={css.content} data-code-block-content>{body}</div>
</div>
)
})
}
@@ -1,143 +0,0 @@
/** Memoized source rendering, including retained incremental highlight groups. */
import { Fragment, memo, useCallback, useMemo, useRef, useSyncExternalStore } from 'react'
import type { CSSProperties, ReactNode, RefObject } from 'react'
import { StreamingHighlightSession, isGrammarLoaded, highlightToHtml, subscribeGrammarLoaded } from './highlight.ts'
import type { HighlightSpan, StreamingHighlightFrame } from './highlight.ts'
import type { CodeBlockProps } from './CodeBlock.tsx'
import { useViewportHighlighting } from './useViewportHighlighting.ts'
import css from './CodeBlock.module.css'
/**
* The `pre` attributes shiki's HTML arm emits for the css-variables theme,
* mirrored so the streaming arm's tree is interchangeable with the settled
* swap (`tests/streaming-code-block.client.spec.tsx` pins the two arms'
* parity).
*/
const SHIKI_PRE_PROPS = {
className: 'shiki css-variables',
style: { backgroundColor: 'var(--shiki-background)', color: 'var(--shiki-foreground)' },
tabIndex: 0,
} as const
/** Completed-line group size; React reconciles groups while the DOM remains line-for-line identical. */
const STREAMING_LINE_GROUP_SIZE = 32
function renderLine(line: readonly HighlightSpan[], index: number): ReactNode {
return (
<Fragment key={index}>
{index > 0 && '\n'}
<span className="line">
{line.map((span, spanIndex) => <span key={spanIndex} style={span.style}>{span.text}</span>)}
</span>
</Fragment>
)
}
/**
* Highlight one source surface independently of toolbar and preview state.
* @param props - Authored source, grammar hint, streaming state and gutter choice.
* @returns Source DOM retained across view changes and repeated rendering of the same highlight frame.
*/
export const CodeBlockSource = memo(function CodeBlockSource({ code, lang, streaming, lineNumbers, target, highlightImmediately }:
Pick<CodeBlockProps, 'code' | 'lang' | 'streaming' | 'lineNumbers'> & {
target: RefObject<HTMLDivElement>
highlightImmediately: boolean
}) {
const trimmed = code.endsWith('\n') ? code.slice(0, -1) : code
const sourceLines = lineNumbers ? trimmed.split('\n') : undefined
const highlighting = useViewportHighlighting(target, lang, highlightImmediately)
const snapshot = useCallback(() => isGrammarLoaded(lang), [lang])
const loaded = useSyncExternalStore(subscribeGrammarLoaded, snapshot, snapshot)
// Streaming state lives in refs mutated inside the memo (the MarkdownText
// streaming-cache pattern): the session's caches carry across chunks only
// because the owner keys this instance stably while the fence grows.
const sessionRef = useRef<StreamingHighlightSession | null>(null)
const lineCacheRef = useRef<{
code: string
lang: string | undefined
generation: number
frame: StreamingHighlightFrame
groups: ReactNode[]
pending: ReactNode[]
nextLine: number
body: ReactNode
} | null>(null)
const settledRef = useRef(false)
const streamedBody = useMemo(() => {
if (!highlighting) {
sessionRef.current = null
lineCacheRef.current = null
settledRef.current = false
return undefined
}
if (streaming !== true) {
const previous = lineCacheRef.current
if (previous !== null && previous.code === trimmed && previous.lang === lang) {
settledRef.current = true
return previous.body
}
sessionRef.current = null
lineCacheRef.current = null
settledRef.current = true
return undefined
}
if (settledRef.current) {
sessionRef.current = null
lineCacheRef.current = null
settledRef.current = false
}
sessionRef.current ??= new StreamingHighlightSession()
const frame = sessionRef.current.updateFrame(trimmed, lang)
if (frame === undefined) {
lineCacheRef.current = null
return undefined
}
const previous = lineCacheRef.current
if (previous?.frame === frame && previous.code === trimmed && previous.lang === lang) {
return previous.body
}
const sameGeneration = previous?.generation === frame.generation
const groups = sameGeneration ? [...previous.groups] : []
let pending = sameGeneration ? [...previous.pending] : []
let nextLine = sameGeneration ? previous.nextLine : 0
for (const line of frame.appended) {
pending.push(renderLine(line, nextLine))
nextLine += 1
if (pending.length !== STREAMING_LINE_GROUP_SIZE) continue
const start = nextLine - pending.length
groups.push(<Fragment key={start}>{pending}</Fragment>)
pending = []
}
const tail = frame.tail.map((line, index) => renderLine(line, nextLine + index))
const tailGroup = <Fragment key={nextLine - pending.length}>{[...pending, ...tail]}</Fragment>
const body = <pre {...SHIKI_PRE_PROPS} data-code-block-source><code>{groups}{tailGroup}</code></pre>
lineCacheRef.current = {
code: trimmed, lang, generation: frame.generation, frame, groups, pending, nextLine, body,
}
return body
}, [streaming, highlighting, trimmed, lang, loaded])
const html = useMemo(
() => (highlighting && streaming !== true && streamedBody === undefined
? highlightToHtml(trimmed, lang)
: undefined),
[streaming, highlighting, streamedBody, trimmed, lang, loaded],
)
// shiki's HTML output is a static span tree it generated from `code` (no
// user HTML passes through), the sanctioned innerHTML consumption path per
// shiki's own docs.
const body = streamedBody !== undefined
? streamedBody
: html === undefined
? (
<pre className={css.plain} data-code-block-source><code>{sourceLines === undefined ? trimmed : sourceLines.map((line, index) => (
<Fragment key={index}>{index > 0 && '\n'}<span className="line">{line}</span></Fragment>
))}</code></pre>
)
: (
<div data-code-block-source dangerouslySetInnerHTML={{ __html: html }} />
)
return <div style={sourceLines === undefined ? undefined : {
'--dsl-code-block-line-number-width': `${Math.max(2, String(sourceLines.length).length)}ch`,
} as CSSProperties}>{body}</div>
})
@@ -1,39 +0,0 @@
/** Copy confirmation is local to the icon button, independent of source rendering. */
import { memo, useEffect, useRef, useState } from 'react'
import { writeClipboard } from '../clipboard.ts'
import { Tooltip } from '../Tooltip.tsx'
import { IconCheckOutline16, IconCopyOutline16 } from '../icons/index.tsx'
import css from './CodeBlock.module.css'
/**
* Copy source without changing toolbar geometry.
* @param props - Source and localized idle/success labels.
* @returns A fixed-size icon action with local confirmation state.
*/
export const CopyCodeButton = memo(function CopyCodeButton({ code, copyLabel, copiedLabel }: {
code: string
copyLabel: string
copiedLabel: string
}) {
const [copied, setCopied] = useState(false)
const alive = useRef(false)
const timer = useRef<ReturnType<typeof setTimeout>>()
useEffect(() => {
alive.current = true
return () => { alive.current = false; clearTimeout(timer.current) }
}, [])
const label = copied ? copiedLabel : copyLabel
return <Tooltip label={label} side="top" delayMs={500}>
<button type="button" className={css.iconButton} aria-label={label} onClick={() => {
if (copied) return
void writeClipboard(code).then((ok) => {
if (!ok || !alive.current) return
setCopied(true)
clearTimeout(timer.current)
timer.current = setTimeout(() => { setCopied(false) }, 1000)
})
}}>
<span aria-hidden="true">{copied ? <IconCheckOutline16 /> : <IconCopyOutline16 />}</span>
</button>
</Tooltip>
})
@@ -17,13 +17,10 @@ import clsx from 'clsx'
import { IncrementalMarkdownParser } from './incremental.ts'
import { parseGfm, parseGfmWithMath } from './parse.ts'
import {
collectReferenceTargets, createFencePreviewCatalog, createReferenceTargets, renderBlocks, renderFootnoteSection,
collectReferenceTargets, createReferenceTargets, renderBlocks, renderFootnoteSection,
wrapBlockChildren,
} from './render.tsx'
import type {
FencePreviewCatalog, MarkdownFileMentions, MarkdownLabels, MarkdownPathImages, MarkdownRenderContext,
ReferenceTargets,
} from './render.tsx'
import type { MarkdownFileMentions, MarkdownLabels, MarkdownPathImages, MarkdownRenderContext, ReferenceTargets } from './render.tsx'
import 'katex/dist/katex.min.css'
import css from './MarkdownText.module.css'
@@ -33,7 +30,6 @@ export type { MarkdownCodeLabels, MarkdownFileMentions, MarkdownLabels, Markdown
function renderSettled(
text: string,
labels: MarkdownLabels,
previews: FencePreviewCatalog | undefined,
fileMentions: MarkdownFileMentions | undefined,
pathImages: MarkdownPathImages | undefined,
): ReactNode[] {
@@ -43,7 +39,6 @@ function renderSettled(
const context: MarkdownRenderContext = {
streaming: false,
labels,
previews,
fileMentions,
pathImages,
targets,
@@ -79,11 +74,8 @@ class StreamingRenderer {
private lastText: string | null = null
private lastRendered: ReactNode[] = []
/**
* @param labels - Localized chrome baked into cached elements.
* @param previews - Stable preview capabilities shared with the settled render.
*/
constructor(private readonly labels: MarkdownLabels, private readonly previews: FencePreviewCatalog | undefined) {}
/** @param labels - Localized Markdown chrome baked into cached elements; the owner replaces the renderer when it changes. */
constructor(private readonly labels: MarkdownLabels) {}
/**
* Render the current accumulated text. Idempotent per text value, so React
@@ -116,7 +108,6 @@ class StreamingRenderer {
const frozenContext: MarkdownRenderContext = {
streaming: true,
labels: this.labels,
previews: this.previews,
fileMentions: undefined,
pathImages: undefined,
targets: frameTargets,
@@ -136,7 +127,6 @@ class StreamingRenderer {
const tailContext: MarkdownRenderContext = {
streaming: true,
labels: this.labels,
previews: this.previews,
fileMentions: undefined,
pathImages: undefined,
targets: frameTargets,
@@ -160,8 +150,7 @@ class StreamingRenderer {
* Render untrusted assistant-authored Markdown as semantic React elements.
* @param props - Markdown source text preserved by the session projection;
* `streaming` parses incrementally across chunks and highlights fences as
* they grow unless preview labels select a loading placeholder (source-only
* fences re-tokenize only appended text; TeX stays literal
* they grow (each fence re-tokenizes only appended text; TeX stays literal
* until the finalize swap so incomplete formulae never flash errors);
* `labels` forwards localized fence and footnote chrome — pass a
* reference-stable object (memoized per locale revision), because a new
@@ -192,18 +181,17 @@ export const MarkdownText = memo(function MarkdownText({
}) {
const streamRef = useRef<StreamingRenderer | null>(null)
const streamLabelsRef = useRef<MarkdownLabels>(labels)
const previews = useMemo(() => createFencePreviewCatalog(labels.preview), [labels.preview])
const children = useMemo(() => {
if (!streaming) {
streamRef.current = null
return renderSettled(text, labels, previews, fileMentions, pathImages)
return renderSettled(text, labels, fileMentions, pathImages)
}
if (streamRef.current === null || streamLabelsRef.current !== labels) {
streamRef.current = new StreamingRenderer(labels, previews)
streamRef.current = new StreamingRenderer(labels)
streamLabelsRef.current = labels
}
return streamRef.current.render(text)
}, [text, streaming, labels, previews, fileMentions, pathImages])
}, [text, streaming, labels, fileMentions, pathImages])
return <div className={clsx(css.markdown, variant === 'compact' && css.compact)}
data-markdown-variant={variant === 'compact' ? variant : undefined}>{children}</div>
})
@@ -1,65 +0,0 @@
.backdrop {
position: fixed;
inset: 0;
z-index: 1000;
overflow: hidden;
overscroll-behavior: contain;
}
.mask {
position: fixed;
inset: 0;
background: var(--dsw-alias-bg-mask-1);
backdrop-filter: var(--dsw-mask-blur);
}
.image {
position: absolute;
left: 50%;
top: 50%;
max-width: none;
transform: translate(-50%, -50%);
cursor: grab;
touch-action: none;
user-select: none;
border-radius: 12px;
background: var(--dsw-specific-input-major);
box-shadow: var(--dsw-shadow-lv3);
}
.image[data-dragging] {
cursor: grabbing;
}
.hint {
position: absolute;
bottom: 16px;
left: 50%;
transform: translateX(-50%);
max-width: calc(100% - 32px);
width: max-content;
padding: 6px 12px;
border-radius: 12px;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-secondary);
font: var(--dsw-font-xs-13);
text-align: center;
pointer-events: none;
}
.close {
position: fixed;
top: 20px;
right: 20px;
z-index: 1;
display: grid;
place-items: center;
width: 36px;
height: 36px;
border: 0.5px solid var(--dsw-alias-border-l2-darkmode-thin);
border-radius: 999px;
corner-shape: round;
background: var(--dsw-specific-input-major);
color: var(--dsw-alias-label-primary);
cursor: pointer;
}
@@ -1,149 +0,0 @@
/** Viewport-fitted image dialog with local pan and zoom transforms. */
import { useEffect, useId, useRef } from 'react'
import { createPortal } from 'react-dom'
import { IconCloseOutline16 } from '../icons/index.tsx'
import css from './PreviewLightbox.module.css'
/**
* Fit a rendered diagram inside the viewport, with pointer pan and cursor-anchored zoom.
* @param props - Image source, accessible labels, gesture instructions, and close callback.
* @returns A dialog with drag, wheel and keyboard controls; double-click or Home restores the fitted view.
*/
export function PreviewLightbox({ src, alt, closeLabel, interactionLabel, onClose }: {
src: string
alt: string
closeLabel: string
interactionLabel: string
onClose: () => void
}) {
const closeRef = useRef<HTMLButtonElement | null>(null)
const imageRef = useRef<HTMLImageElement | null>(null)
const surfaceRef = useRef<HTMLDivElement | null>(null)
const hintId = useId()
useEffect(() => {
const restore = document.activeElement instanceof HTMLElement ? document.activeElement : null
closeRef.current?.focus()
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
if (event.key === 'Escape') onClose()
// The close control is the dialog's only focusable element.
if (event.key === 'Tab') event.preventDefault()
}
window.addEventListener('keydown', onKeyDown)
return () => {
window.removeEventListener('keydown', onKeyDown)
restore?.focus()
}
}, [onClose])
useEffect(() => {
// oxlint-disable-next-line typescript/no-non-null-assertion -- refs belong to this mounted dialog.
const image = imageRef.current!
// oxlint-disable-next-line typescript/no-non-null-assertion -- refs belong to this mounted dialog.
const surface = surfaceRef.current!
const view = { x: 0, y: 0, scale: 1 }
let drag: { id: number; x: number; y: number } | undefined
const paint = () => {
image.style.transform = `translate(-50%, -50%) translate(${view.x}px, ${view.y}px) scale(${view.scale})`
}
const fit = () => {
if (image.naturalWidth === 0 || image.naturalHeight === 0) return
const scale = Math.min(window.innerWidth * 0.88 / image.naturalWidth, window.innerHeight * 0.84 / image.naturalHeight)
image.style.width = `${image.naturalWidth * scale}px`
image.style.height = `${image.naturalHeight * scale}px`
view.x = 0
view.y = 0
view.scale = 1
paint()
}
const zoom = (factor: number, x = 0, y = 0) => {
const scale = Math.min(8, Math.max(0.25, view.scale * factor))
const ratio = scale / view.scale
view.x = x - (x - view.x) * ratio
view.y = y - (y - view.y) * ratio
view.scale = scale
paint()
}
const onWheel = (event: WheelEvent) => {
event.preventDefault()
const unit = event.deltaMode === 1 ? 16 : event.deltaMode === 2 ? window.innerHeight : 1
const rect = surface.getBoundingClientRect()
zoom(Math.exp(-event.deltaY * unit * 0.002), event.clientX - rect.left - rect.width / 2, event.clientY - rect.top - rect.height / 2)
}
const onPointerDown = (event: PointerEvent) => {
if (event.button !== 0 || !event.isPrimary) return
drag = { id: event.pointerId, x: event.clientX, y: event.clientY }
image.setPointerCapture(event.pointerId)
image.dataset['dragging'] = ''
event.preventDefault()
}
const onPointerMove = (event: PointerEvent) => {
if (drag === undefined || drag.id !== event.pointerId) return
view.x += event.clientX - drag.x
view.y += event.clientY - drag.y
drag.x = event.clientX
drag.y = event.clientY
paint()
}
const endDrag = () => {
if (drag === undefined) return
const id = drag.id
drag = undefined
delete image.dataset['dragging']
if (image.hasPointerCapture(id)) image.releasePointerCapture(id)
}
const onPointerEnd = (event: PointerEvent) => {
if (drag?.id === event.pointerId) endDrag()
}
const onKeyDown = (event: KeyboardEvent) => {
if (event.key === '+' || event.key === '=') zoom(1.2)
else if (event.key === '-') zoom(1 / 1.2)
else if (event.key === 'Home' || event.key === '0') fit()
else {
const dx = event.key === 'ArrowRight' ? 32 : event.key === 'ArrowLeft' ? -32 : 0
const dy = event.key === 'ArrowDown' ? 32 : event.key === 'ArrowUp' ? -32 : 0
if (dx === 0 && dy === 0) return
view.x += dx
view.y += dy
paint()
}
event.preventDefault()
}
image.addEventListener('load', fit)
image.addEventListener('dblclick', fit)
image.addEventListener('pointerdown', onPointerDown)
image.addEventListener('pointermove', onPointerMove)
image.addEventListener('pointerup', onPointerEnd)
image.addEventListener('pointercancel', onPointerEnd)
image.addEventListener('lostpointercapture', onPointerEnd)
surface.addEventListener('wheel', onWheel, { passive: false })
window.addEventListener('keydown', onKeyDown)
window.addEventListener('resize', fit)
fit()
return () => {
endDrag()
image.removeEventListener('load', fit)
image.removeEventListener('dblclick', fit)
image.removeEventListener('pointerdown', onPointerDown)
image.removeEventListener('pointermove', onPointerMove)
image.removeEventListener('pointerup', onPointerEnd)
image.removeEventListener('pointercancel', onPointerEnd)
image.removeEventListener('lostpointercapture', onPointerEnd)
surface.removeEventListener('wheel', onWheel)
window.removeEventListener('keydown', onKeyDown)
window.removeEventListener('resize', fit)
}
}, [src])
return createPortal(
<div ref={surfaceRef} className={css.backdrop} role="dialog" aria-modal="true" aria-label={alt} aria-describedby={hintId}>
<div className={css.mask} aria-hidden="true" onMouseDown={onClose} />
<img ref={imageRef} className={css.image} src={src} alt={alt} draggable={false} />
<div id={hintId} className={css.hint}>{interactionLabel}</div>
<button ref={closeRef} type="button" className={css.close} aria-label={closeLabel} onClick={onClose}>
<span aria-hidden="true"><IconCloseOutline16 size={16} /></span>
</button>
</div>,
document.body,
)
}
@@ -1,19 +0,0 @@
/** Stable waiting canvas shared by streaming diagrams and image loading. */
import { memo } from 'react'
import css from './SourcePreview.module.css'
/**
* Display a quiet loading status without depending on the growing source.
* @param props - Localized loading announcement.
* @returns A fixed-height canvas with a decorative pulsing image glyph.
*/
export const PreviewPlaceholder = memo(function PreviewPlaceholder({ label }: { label: string }) {
return <div className={css.placeholder} data-preview-placeholder role="status">
<svg className={css.placeholderIcon} viewBox="0 0 32 32" width="32" height="32" fill="none" aria-hidden="true">
<rect x="4" y="5" width="24" height="22" rx="4" stroke="currentColor" strokeWidth="1.3" />
<circle cx="11" cy="12" r="2" stroke="currentColor" strokeWidth="1.3" />
<path d="m5 23 7-7 5 5 4-4 6 6" stroke="currentColor" strokeWidth="1.3" strokeLinecap="round" strokeLinejoin="round" />
</svg>
<span>{label}</span>
</div>
})
@@ -1,66 +0,0 @@
.root {
min-width: 0;
border-radius: inherit;
}
.canvas {
display: grid;
place-items: center;
min-height: 120px;
padding: 16px;
box-sizing: border-box;
background: var(--dsw-alias-markdown-code-block);
border-radius: inherit;
}
.diagram {
display: block;
max-width: 100%;
height: auto;
max-height: min(60vh, 640px);
margin: 0 auto;
}
.status {
padding: 16px;
color: var(--dsw-alias-label-secondary);
}
.placeholder {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 12px;
height: 180px;
color: var(--dsw-alias-label-tertiary);
font-size: 12px;
}
.placeholderIcon {
animation: previewPulse 1.8s ease-in-out infinite;
}
@keyframes previewPulse {
0%, 100% { opacity: 0.35; }
50% { opacity: 0.8; }
}
.canvas[hidden], .diagram[hidden] { display: none; }
@media (prefers-reduced-motion: reduce) {
.placeholderIcon { animation: none; }
}
.failure {
min-height: 120px;
background: var(--dsw-alias-markdown-code-block);
}
.staging {
position: fixed;
top: 0;
left: -100000px;
visibility: hidden;
pointer-events: none;
}
@@ -1,168 +0,0 @@
/** Read-only diagram preview with image loading status and per-source async ownership. */
import { memo, useCallback, useEffect, useId, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { Tooltip } from '../Tooltip.tsx'
import { IconSearchOutline16 } from '../icons/index.tsx'
import { PreviewLightbox } from './PreviewLightbox.tsx'
import { PreviewPlaceholder } from './PreviewPlaceholder.tsx'
import css from './SourcePreview.module.css'
import blockCss from './CodeBlock.module.css'
import { readPreviewTheme } from './preview-theme.ts'
import { observeViewport } from './viewport.ts'
/** Localized preview output; the diagram source remains verbatim. */
export interface PreviewLabels {
diagram: string
error: string
}
/** Localized controls added by the interactive preview surface. */
export interface SourcePreviewLabels extends PreviewLabels {
zoom: string
pending: string
close: string
interaction: string
}
/**
* Convert source into an isolated preview image URL.
* @param code - Complete preview source.
* @param signal - Cancels publication of obsolete work.
* @returns The isolated image URL, synchronously or asynchronously.
*/
export type PreviewRenderer = (code: string, signal: AbortSignal) => string | Promise<string>
/** Inputs whose identity controls preview rendering and localized status copy. */
export interface SourcePreviewProps {
code: string
labels: SourcePreviewLabels
render: PreviewRenderer
/** Existing action container that receives the zoom control. */
actions?: HTMLElement | undefined
}
type Result = {
code: string
render: PreviewRenderer
palette: string
/** Last loaded image of the same source, retained while its replacement loads or fails. */
previous: string | undefined
} & (
| { kind: 'ok'; src: string; loaded: boolean }
| { kind: 'error' }
)
function loadedImage(result: Result | null): string | undefined {
if (result === null) return undefined
switch (result.kind) {
case 'ok': return result.loaded ? result.src : result.previous
case 'error': return result.previous
/* v8 ignore next -- closed-union backstop; every declared result kind is handled above. */
default: return assertNever(result)
}
}
/* 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)}`)
}
/**
* Render visible diagrams and retain their loaded images during theme refreshes.
* @param props - Source and complete localized labels. Source, visibility and document theme changes cancel obsolete renders.
* @returns A pending status, inert image or error, with a persistent zoom action.
*/
export const SourcePreview = memo(function SourcePreview({ code, labels, render, actions }: SourcePreviewProps) {
const pendingId = useId()
const target = useRef<HTMLDivElement>(null)
const [visible, setVisible] = useState(false)
const [result, setResult] = useState<Result | null>(null)
const [expanded, setExpanded] = useState<{ code: string; render: PreviewRenderer } | null>(null)
const close = useCallback(() => { setExpanded(null) }, [])
const current = result?.code === code && result.render === render ? result : null
const renderedPalette = current?.palette
const expandedCurrent = expanded?.code === code && expanded.render === render
const active = visible || expandedCurrent
useEffect(() => {
// oxlint-disable-next-line typescript/no-non-null-assertion -- React attaches this surface before effects run.
return observeViewport(target.current!, setVisible)
}, [])
useEffect(() => {
if (!active) return
let controller: AbortController | undefined
let palette: string | undefined
const update = () => {
const next = JSON.stringify(readPreviewTheme())
if (next === palette) return
palette = next
controller?.abort()
if (next === renderedPalette) return
const current = new AbortController()
controller = current
void (async () => await render(code, current.signal))().then(
(src) => {
if (current.signal.aborted) return
controller = undefined
setResult((prior) => {
const previous = prior?.code === code && prior.render === render ? loadedImage(prior) : undefined
return { kind: 'ok', code, render, palette: next, src, loaded: src === previous, previous: src === previous ? undefined : previous }
})
},
() => {
if (current.signal.aborted) return
controller = undefined
setResult(prior => ({ kind: 'error', code, render, palette: next,
previous: prior?.code === code && prior.render === render ? loadedImage(prior) : undefined }))
},
)
}
// Isolated images 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() }
}, [active, code, render, renderedPalette])
const displayed = loadedImage(current)
const ready = displayed !== undefined
const pending = !ready && current?.kind !== 'error'
const markLoaded = () => {
if (current?.kind !== 'ok' || current.loaded) return
setResult(prior => prior === current ? { ...current, loaded: true, previous: undefined } : prior)
}
return <div ref={target} className={css.root}>
{actions !== undefined && createPortal(
<Tooltip label={ready ? labels.zoom : pending ? labels.pending : labels.error} side="top" delayMs={500}>
<span className={blockCss.iconSlot}>
<button type="button" className={blockCss.iconButton} aria-label={labels.zoom} disabled={!ready}
aria-describedby={ready ? undefined : pendingId} onClick={() => {
setExpanded({ code, render })
}}><span aria-hidden="true"><IconSearchOutline16 /></span></button>
{!ready && <span id={pendingId} hidden>{pending ? labels.pending : labels.error}</span>}
</span>
</Tooltip>, actions,
)}
{pending && <PreviewPlaceholder label={labels.pending} />}
{current?.kind === 'error' && <div className={css.failure}>
<div className={css.status} role="status">{labels.error}</div>
</div>}
<div className={css.canvas} hidden={!ready}>
{current?.previous !== undefined &&
<img key={current.previous} className={css.diagram} src={current.previous} alt={labels.diagram} />}
{current?.kind === 'ok' && <img key={current.src} className={css.diagram} src={current.src} alt={labels.diagram}
hidden={!current.loaded}
ref={(image) => {
if (image !== null && image.complete && image.naturalWidth > 0) markLoaded()
}}
onLoad={markLoaded} onError={() => {
setResult(prior => prior === current ? { kind: 'error', code, render, palette: current.palette,
previous: current.previous } : prior)
}} />}
</div>
{ready && expandedCurrent && <PreviewLightbox
src={displayed} alt={labels.diagram} closeLabel={labels.close} interactionLabel={labels.interaction} onClose={close}
/>}
</div>
})
@@ -1,136 +0,0 @@
/**
* DOT TextMate grammar from joaompinto/vscode-graphviz (MIT), commit
* e001e27f7831fb34cb9a8de9c456485bbc509ec3, dot/syntaxes/dot.tmLanguage.
* Mechanically decoded from plist; patterns and scopes are unchanged.
* Editor metadata and rule comments are omitted; Shiki uses the dot id and graphviz alias.
* Source and license: ../../../THIRD_PARTY_PREVIEW_NOTICES.txt.
*/
import type { LanguageRegistration } from 'shiki/core'
/** Pinned DOT registration for the shared lazy highlighter. */
const grammar: LanguageRegistration[] = [
{
'name': 'dot',
'repository': {},
'patterns': [
{
'captures': {
'1': {
'name': 'storage.type.dot',
},
'2': {
'name': 'variable.other.dot',
},
'4': {
'name': 'punctuation.section.dot',
},
},
'match': ' ?(digraph)[ \\t]+([A-Za-z0-9]+) ?(\\{)',
},
{
'match': '(<|-)(>|-)',
'name': 'keyword.operator.dot',
},
{
'match': '\\b(node|edge|graph|digraph|subgraph|strict)\\b',
'name': 'storage.type.dot',
},
{
'match': '\\b(bottomlabel|color|comment|distortion|fillcolor|fixedsize|fontcolor|fontname|fontsize|group|height|label|layer|orientation|peripheries|regular|shape|shapefile|sides|skew|style|toplabel|URL|width|z)\\b',
'name': 'support.constant.attribute.node.dot',
},
{
'match': '\\b(arrowhead|arrowsize|arrowtail|color|comment|constraint|decorate|dir|fontcolor|fontname|fontsize|headlabel|headport|headURL|label|labelangle|labeldistance|labelfloat|labelcolor|labelfontname|labelfontsize|layer|lhead|ltail|minlen|samehead|sametail|splines|style|taillabel|tailport|tailURL|weight)\\b',
'name': 'support.constant.attribute.edge.dot',
},
{
'match': '\\b(bgcolor|center|clusterrank|color|comment|compound|concentrate|fillcolor|fontname|fontpath|fontsize|label|labeljust|labelloc|layers|margin|mclimit|nodesep|nslimit|nslimit1|ordering|orientation|page|pagedir|quantum|rank|rankdir|ranksep|ratio|remincross|rotate|samplepoints|searchsize|size|style|URL)\\b',
'name': 'support.constant.attribute.graph.dot',
},
{
'match': '\\b(box|polygon|ellipse|oval|circle|point|egg|triangle|plaintext|plain|diamond|trapezium|parallelogram|house|pentagon|hexagon|septagon|octagon|doublecircle|doubleoctagon|tripleoctagon|invtriangle|invtrapezium|invhouse|Mdiamond|Msquare|Mcircle|rect|rectangle|square|star|none|underline|cylinder|note|tab|folder|box3d|component|promoter|cds|terminator|utr|primersite|restrictionsite|fivepoverhang|threepoverhang|noverhang|assembly|signature|insulator|ribosite|rnastab|proteasesite|proteinstab|rpromoter|rarrow|larrow|lpromoter)\\b',
'name': 'variable.other.dot',
},
{
'begin': '"',
'beginCaptures': {
'0': {
'name': 'punctuation.definition.string.begin.dot',
},
},
'end': '"',
'endCaptures': {
'0': {
'name': 'punctuation.definition.string.end.dot',
},
},
'name': 'string.quoted.double.dot',
'patterns': [
{
'match': '\\\\.',
'name': 'constant.character.escape.dot',
},
],
},
{
'begin': '(^[ \\t]+)?(?=//)',
'beginCaptures': {
'1': {
'name': 'punctuation.whitespace.comment.leading.dot',
},
},
'end': '(?!\\G)',
'patterns': [
{
'begin': '//',
'beginCaptures': {
'0': {
'name': 'punctuation.definition.comment.dot',
},
},
'end': '\\n',
'name': 'comment.line.double-slash.dot',
},
],
},
{
'begin': '(^[ \\t]*)(?=#)',
'beginCaptures': {
'1': {
'name': 'punctuation.whitespace.comment.leading.dot',
},
},
'end': '(?!\\G)',
'patterns': [
{
'begin': '#',
'beginCaptures': {
'0': {
'name': 'punctuation.definition.comment.dot',
},
},
'end': '\\n',
'name': 'comment.line.number-sign.dot',
},
],
},
{
'begin': '/\\*',
'captures': {
'0': {
'name': 'punctuation.definition.comment.dot',
},
},
'end': '\\*/',
'name': 'comment.block.dot',
},
],
'scopeName': 'source.dot',
'displayName': 'Graphviz (DOT)',
'aliases': [
'graphviz',
],
},
]
export default grammar
@@ -1,36 +0,0 @@
/** Lazy Graphviz WebAssembly layout; generated SVG is displayed only as an inert image. */
import type { instance } from '@viz-js/viz'
import { renderSvg } from './svg.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.
* @returns An SVG image data URL. Import, layout, invalid source, and cancellation failures reject.
*/
export async function renderGraphviz(code: string, signal: AbortSignal): Promise<string> {
signal.throwIfAborted()
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', format: 'svg',
graphAttributes: { bgcolor: 'transparent', fontcolor: color(palette.text) },
nodeAttributes: {
color: color(palette.border), fontcolor: color(palette.text), fillcolor: color(palette.surface), style: 'filled',
},
edgeAttributes: { color: color(palette.line), fontcolor: color(palette.text) },
}), signal)
}
@@ -9,10 +9,10 @@
* JSON) load into the singleton at boot — the set every session renders. The
* read card's wider extension set (the file-extension language hints the read
* tool's `langFromPath` emits — `packages/fs/tool-fs`: python, rust, yaml,
* markup, …) and diagram source grammars are imported lazily and registered
* on first use, keeping their grammar modules and synchronous initialization
* out of sessions that never render those languages.
* The first render of a lazy language falls back to plain text while its
* markup, …) is imported lazily and registered the first time such a language
* is requested, so a session that never opens a read card in one of those
* languages pays neither the ~1.6 MB of grammar modules nor their synchronous
* init. The first render of a lazy language falls back to plain text while its
* grammar loads, then {@link onGrammarLoaded} notifies subscribers to re-render
* with highlighting. An unknown or absent language falls back to plain text (no
* highlighting, still monospace) — never an error.
@@ -42,8 +42,8 @@ type LangModule = { default: typeof langTs }
const LANGS = [langTs, langBash, langJson]
/**
* Additional source grammars, each behind a dynamic import so its module
* stays out of the boot chunk until that language renders.
* The read card's extension grammars, each behind a dynamic import so its
* module stays out of the boot chunk until a read of that language renders.
* Keyed by the grammar id (`LanguageRegistration.name`) the aliases resolve to.
* `@shikijs/langs`' default export is a `LanguageRegistration[]`; the loader
* hands the whole array to `loadLanguageSync`, which registers each entry
@@ -73,12 +73,6 @@ const LAZY_GRAMMARS = new Map<string, () => Promise<LangModule>>([
['less', () => import('@shikijs/langs/less')],
['sql', () => import('@shikijs/langs/sql')],
['xml', () => import('@shikijs/langs/xml')],
['dot', () => import('./grammars/dot.ts')],
// Upstream's entry matches Markdown fences; CodeBlock supplies only their body.
['mermaid', async () => {
const { default: grammars } = await import('@shikijs/langs/mermaid')
return { default: grammars.map(grammar => ({ ...grammar, patterns: [{ include: '#mermaid' }] })) }
}],
['lua', () => import('@shikijs/langs/lua')],
])
@@ -135,10 +129,6 @@ const LANG_ALIASES = new Map<string, string>([
['less', 'less'],
['sql', 'sql'],
['xml', 'xml'],
['svg', 'xml'],
['dot', 'dot'],
['graphviz', 'dot'],
['mermaid', 'mermaid'],
['lua', 'lua'],
])
@@ -235,16 +225,6 @@ export function grammarLoadCount(): number {
return loadCount
}
/**
* Read readiness without loading a grammar or invalidating unrelated source views.
* @param lang - Case-insensitive language hint.
* @returns Whether its grammar is already registered.
*/
export function isGrammarLoaded(lang: string | undefined): boolean {
const resolved = lang === undefined ? undefined : LANG_ALIASES.get(lang.toLowerCase())
return resolved !== undefined && highlighter().getLoadedLanguages().includes(resolved)
}
/**
* Ensure the grammar `resolved` names is registered. A boot grammar (not in
* {@link LAZY_GRAMMARS}) and an already-loaded lazy grammar report ready
@@ -1,105 +0,0 @@
/** Lazy Mermaid runtime; each render owns and removes its temporary measurement DOM. */
import type { Mermaid } from 'mermaid'
import type { FlowDB } from 'mermaid/dist/diagrams/flowchart/flowDb.js'
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): void {
const palette = readPreviewTheme()
mermaid.initialize({
theme: 'base',
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,
},
htmlLabels: false,
// Mermaid still accepts this legacy override alongside the global HTML-label setting.
// oxlint-disable-next-line typescript/no-deprecated
flowchart: { htmlLabels: false },
secure: [
'secure', 'securityLevel', 'startOnLoad', 'maxTextSize', 'maxEdges',
'suppressErrorRendering', 'theme', 'themeVariables', 'themeCSS', 'htmlLabels', 'flowchart',
],
})
}
/**
* 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.
* @returns An SVG data URL. Image nodes, import, parse, rendering, and cancellation failures reject.
*/
export async function renderMermaid(code: string, signal: AbortSignal): Promise<string> {
const task = pending.then(() => renderOne(code, signal))
pending = task.catch(() => { /* The caller owns rejection; subsequent diagrams still render. */ })
return task
}
async function renderOne(code: string, signal: AbortSignal): Promise<string> {
signal.throwIfAborted()
const mermaid = await loadMermaid()
signal.throwIfAborted()
initialize(mermaid)
// Mermaid image nodes fetch resources during layout, even under strict security.
// The replacement parse API exposes validity, not parsed nodes.
// oxlint-disable-next-line typescript/no-deprecated
const diagram = await mermaid.mermaidAPI.getDiagramFromText(code)
signal.throwIfAborted()
if (diagram.type.startsWith('flowchart')) {
for (const node of (diagram.db as FlowDB).getVertices().values()) {
if (node.img !== undefined) throw new Error('Mermaid image nodes are not supported')
}
}
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,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,81 +23,24 @@ 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 type { CodeBlockPreview } from './CodeBlock.tsx'
import { renderGraphviz } from './graphviz.ts'
import { renderSvg } from './svg.ts'
import { renderMermaid } from './mermaid.ts'
import type { PreviewLabels } from './SourcePreview.tsx'
import { renderTexToReact } from './katex.tsx'
import { LinkIcon, classifyLinkPath } from '../LinkIcon.tsx'
import { useMarkdownExternalLinkDelegate } from './MarkdownDelegate.tsx'
import type { PositionedBlock } from './incremental.ts'
import css from './MarkdownText.module.css'
const FENCE_PREVIEW_RENDERERS = {
mermaid: renderMermaid,
graphviz: renderGraphviz,
svg: renderSvg,
} as const
/** Copy-button labels forwarded to fence CodeBlocks (this package is cordis-free, so copy arrives via props). */
export interface MarkdownCodeLabels {
/** Copy-button idle label. */
copyLabel: string
/** Copy-button label during the post-copy confirmation window. */
copiedLabel: string
/** Selected source-view label. */
sourceLabel: string
/** Localized source line-number toggle label. */
lineNumbersLabel: string
}
/** Localized chrome for a Markdown document. */
export interface MarkdownLabels {
code: MarkdownCodeLabels
footnotes: string
/** Opt into settled Mermaid, Graphviz, and SVG previews with complete localized labels. */
preview?: {
mermaid: PreviewLabels
graphviz: PreviewLabels
svg: PreviewLabels
preview: string
source: string
zoom: string
pending: string
close: string
interaction: string
}
}
type FencePreviewKind = keyof typeof FENCE_PREVIEW_RENDERERS
/** Stable preview descriptors shared by every settled fence in one label revision. */
export type FencePreviewCatalog = Readonly<Record<FencePreviewKind, CodeBlockPreview>>
/**
* Build the preview descriptors owned by one localized label revision.
* @param labels - Localized preview labels, or undefined when previews are disabled.
* @returns Stable language descriptors for the lifetime of `labels`.
*/
export function createFencePreviewCatalog(
labels: MarkdownLabels['preview'],
): FencePreviewCatalog | undefined {
if (labels === undefined) return undefined
const previewLabels = <K extends FencePreviewKind>(kind: K) => ({
...labels[kind],
preview: labels.preview,
source: labels.source,
zoom: labels.zoom,
pending: labels.pending,
close: labels.close,
interaction: labels.interaction,
})
return {
mermaid: { render: FENCE_PREVIEW_RENDERERS.mermaid, labels: previewLabels('mermaid') },
graphviz: { render: FENCE_PREVIEW_RENDERERS.graphviz, labels: previewLabels('graphviz') },
svg: { render: FENCE_PREVIEW_RENDERERS.svg, labels: previewLabels('svg') },
}
}
function sanitizeUrl(url: string): string {
@@ -236,8 +179,6 @@ export interface MarkdownRenderContext {
readonly streaming: boolean
/** Localized fence copy-button labels. */
readonly labels: MarkdownLabels
/** Settled fence previews resolved once for the current localized labels. */
readonly previews: FencePreviewCatalog | undefined
/** Inside a blockquote's children: tables there always fill the quote's width. */
readonly inBlockquote?: boolean
/** Inline-code file mentions; absent wherever no opener vocabulary exists. */
@@ -422,10 +363,8 @@ function renderNode(node: Md.RootContent, key: Key, context: MarkdownRenderConte
function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): ReactNode {
const language = node.lang ?? undefined
const lang = language === undefined ? undefined : /^[\w-]+/.exec(language)?.[0]
const preview = fencePreview(lang, context.previews)
if (node.value === '' && !(context.streaming && preview !== undefined)) {
// Empty source-only fences keep the stock Markdown <pre>.
if (node.value === '') {
// Parity: the replaced pipeline kept the stock <pre> for an empty fence.
return (
<pre key={key}>
<code className={language === undefined ? undefined : `language-${language}`} />
@@ -434,6 +373,7 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
}
// The replaced pipeline recovered the grammar id from the hast class with
// /language-([\w-]+)/, which truncates at the first non-word character.
const lang = language === undefined ? undefined : /^[\w-]+/.exec(language)?.[0]
if (!context.streaming && lang === 'math') {
// ```math fences render as display TeX once settled (rehype-katex parity);
// its text extraction saw the code block's trailing newline.
@@ -449,26 +389,16 @@ function renderCode(node: Md.Code, key: Key, context: MarkdownRenderContext): Re
lang={lang}
// Streaming keys are source offsets, stable while the fence grows, so
// the CodeBlock instance (and its incremental highlight session)
// survives every chunk. The language hint is final once content starts.
// survives every chunk. A fence whose info string is still mid-chunk
// has no content yet and took the empty-fence arm above, so `lang`
// here is final: it can never re-resolve to a different grammar.
streaming={context.streaming}
sourceLabel={context.labels.code.sourceLabel}
lineNumbersLabel={context.labels.code.lineNumbersLabel}
copyLabel={context.labels.code.copyLabel}
copiedLabel={context.labels.code.copiedLabel}
preview={preview}
/>
)
}
/** Resolve the supported fence language without treating arbitrary HTML in Markdown as a preview. */
function fencePreview(lang: string | undefined, previews: FencePreviewCatalog | undefined): CodeBlockPreview | undefined {
if (previews === undefined) return undefined
const normalized = lang?.toLowerCase()
const kind = normalized === 'dot' ? 'graphviz' : normalized
if (kind !== 'mermaid' && kind !== 'graphviz' && kind !== 'svg') return undefined
return previews[kind]
}
/** 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)
@@ -1,17 +0,0 @@
/** SVG image previews without executable source markup. */
/**
* 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,7 +1,48 @@
import { useCallback, useEffect, useState } from 'react'
import type { RefObject } from 'react'
import { supportsHighlighting } from './highlight.ts'
import { observeViewport } from './viewport.ts'
const noop = (): void => {}
/** One document-wide observer; activated elements leave it permanently. */
class HighlightViewport {
private observer: IntersectionObserver | undefined
private readonly activators = new Map<Element, () => void>()
observe(element: Element, activate: () => void): () => void {
if (typeof IntersectionObserver === 'undefined') {
activate()
return noop
}
this.observer ??= new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue
const current = this.activators.get(entry.target)
/* v8 ignore next -- the observer reports only elements still registered with it. */
if (current === undefined) continue
this.activators.delete(entry.target)
this.observer?.unobserve(entry.target)
current()
}
this.releaseEmptyObserver()
})
this.activators.set(element, activate)
this.observer.observe(element)
return () => {
this.activators.delete(element)
this.observer?.unobserve(element)
this.releaseEmptyObserver()
}
}
private releaseEmptyObserver(): void {
if (this.activators.size > 0) return
this.observer?.disconnect()
this.observer = undefined
}
}
const highlightViewport = new HighlightViewport()
/**
* Activate one supported code surface when it first intersects the viewport.
@@ -9,24 +50,22 @@ import { observeViewport } from './viewport.ts'
* IntersectionObserver activate immediately.
* @param target - Code surface whose plain rendering reserves its geometry.
* @param lang - Optional language hint.
* @param initiallyActive - Activate at mount on explicit reader intent without waiting for viewport delivery.
* @returns Whether this component may build highlighted output.
*/
export function useViewportHighlighting(
target: RefObject<Element>,
lang: string | undefined,
initiallyActive = false,
): boolean {
const supported = supportsHighlighting(lang)
const [activated, setActivated] = useState(initiallyActive)
const activate = useCallback((visible: boolean) => { if (visible) setActivated(true) }, [])
const [activated, setActivated] = useState(false)
const activate = useCallback(() => { setActivated(true) }, [])
useEffect(() => {
if (activated || !supported) return
const element = target.current
/* v8 ignore next -- React attaches the host ref before running effects. */
if (element === null) return
return observeViewport(element, activate)
return highlightViewport.observe(element, activate)
}, [activate, activated, supported, target])
return activated && supported
@@ -1,29 +0,0 @@
/** Shared viewport observation for deferred code and diagram work. */
let observer: IntersectionObserver | undefined
const listeners = new Map<Element, (visible: boolean) => void>()
/**
* Observe visibility until disposal; unsupported browsers activate immediately.
* @param element - Stable surface whose geometry remains available while work is deferred.
* @param changed - Receives intersection changes, including exits.
* @returns A disposer that releases the shared observer after the last surface leaves.
*/
export function observeViewport(element: Element, changed: (visible: boolean) => void): () => void {
if (typeof IntersectionObserver === 'undefined') {
changed(true)
return () => {}
}
observer ??= new IntersectionObserver((entries) => {
for (const entry of entries) listeners.get(entry.target)?.(entry.isIntersecting)
})
listeners.set(element, changed)
observer.observe(element)
return () => {
listeners.delete(element)
observer?.unobserve(element)
if (listeners.size > 0) return
observer?.disconnect()
observer = undefined
}
}
@@ -7,22 +7,11 @@ import { CodeBlock as LocalizedCodeBlock } from '../src/markdown/CodeBlock.tsx'
import { highlightToHtml, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
import { markdownLabels } from './labels.client.ts'
function CodeBlock(props: Omit<ComponentProps<typeof LocalizedCodeBlock>, 'copyLabel' | 'copiedLabel' | 'sourceLabel' | 'lineNumbersLabel'>) {
function CodeBlock(props: Omit<ComponentProps<typeof LocalizedCodeBlock>, 'copyLabel' | 'copiedLabel'>) {
return <LocalizedCodeBlock {...props} {...markdownLabels.code} />
}
const clipboardDescriptor = Object.getOwnPropertyDescriptor(navigator, 'clipboard')
const execCommandDescriptor = Object.getOwnPropertyDescriptor(document, 'execCommand')
afterEach(() => {
cleanup()
vi.useRealTimers()
vi.restoreAllMocks()
if (clipboardDescriptor === undefined) Reflect.deleteProperty(navigator, 'clipboard')
else Object.defineProperty(navigator, 'clipboard', clipboardDescriptor)
if (execCommandDescriptor === undefined) Reflect.deleteProperty(document, 'execCommand')
else Object.defineProperty(document, 'execCommand', execCommandDescriptor)
})
afterEach(cleanup)
beforeEach(() => {
vi.useRealTimers()
@@ -70,13 +59,6 @@ describe('highlightToHtml', () => {
})
describe('CodeBlock', () => {
it('omits its header when the owner supplies toolbar controls', () => {
const view = render(<CodeBlock code="plain text" showHeader={false} />)
expect(view.container.querySelector('[data-code-block-header]')).toBeNull()
expect(view.container.querySelector('[data-code-block-banner]')).toBeNull()
expect(view.container.querySelector('pre')?.textContent).toBe('plain text')
})
it('reports the stable source-content wrapper to its owner', () => {
const contentRef = vi.fn<(node: HTMLDivElement | null) => void>()
const view = render(<CodeBlock code="plain text" contentRef={contentRef} />)
@@ -124,7 +106,7 @@ describe('CodeBlock', () => {
const view = render(<CodeBlock code="" lineNumbers />)
expect(view.container.querySelectorAll('code > .line')).toHaveLength(1)
expect(view.container.querySelector('pre')!.textContent).toBe('')
const gutter = () => view.container.querySelector<HTMLElement>('[style*="--dsl-code-block-line-number-width"]')!
const gutter = () => view.container.querySelector<HTMLElement>('[data-line-numbers]')!
.style.getPropertyValue('--dsl-code-block-line-number-width')
expect(gutter()).toBe('2ch')
view.rerender(<CodeBlock code="const first = 1" lang="ts" streaming lineNumbers />)
@@ -151,88 +133,27 @@ describe('CodeBlock', () => {
expect(view.getByText('plain text')).toBeTruthy()
})
it('shows a noninteractive selected source label for a block without preview', () => {
const view = render(<CodeBlock code="const a = 1" lang="ts" />)
const banner = view.container.querySelector('[data-code-block-banner]')!
expect(screen.getByText(markdownLabels.code.sourceLabel).tagName).toBe('SPAN')
expect(screen.queryByRole('button', { name: markdownLabels.code.sourceLabel })).toBeNull()
expect([...banner.querySelectorAll('button')].map(button => button.getAttribute('aria-label')))
.toEqual([markdownLabels.code.lineNumbersLabel, markdownLabels.code.copyLabel])
})
it.each(['ts', undefined])('trims exactly one trailing newline from %s source and its clipboard text', async (lang) => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const view = render(<CodeBlock code={'const a = 1\n\n'} lang={lang} />)
expect(view.container.querySelector('pre')?.textContent).toBe('const a = 1\n')
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
expect(writeText).toHaveBeenCalledWith('const a = 1\n')
})
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', {
configurable: true,
value: { writeText },
})
const view = render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
const sourceElement = view.container.querySelector('pre')
render(<CodeBlock code={'const a = 1\n'} lang="ts" />)
expect(screen.getByText('ts')).toBeTruthy()
const copy = screen.getByRole('button', { name: '复制' })
expect(copy.textContent).toBe('')
const idleIcon = copy.querySelector('svg')?.outerHTML
fireEvent.click(copy)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('const a = 1')
// Flush the clipboard promise under fake timers before asserting the label.
await act(async () => {
await Promise.resolve()
})
expect(screen.getByRole('button', { name: '复制成功' })).toBe(copy)
expect(copy.textContent).toBe('')
expect(copy.querySelector('svg')?.outerHTML).not.toBe(idleIcon)
expect(view.container.querySelector('pre')).toBe(sourceElement)
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
// While the ok label is showing, further clicks are no-ops.
fireEvent.click(screen.getByRole('button', { name: '复制成功' }))
expect(writeText).toHaveBeenCalledTimes(1)
await act(async () => { await vi.advanceTimersByTimeAsync(1000) })
expect(screen.getByRole('button', { name: '复制' })).toBe(copy)
expect(copy.querySelector('svg')?.outerHTML).toBe(idleIcon)
})
it('shows localized copy and confirmation tooltips for keyboard focus', async () => {
const writeText = vi.fn().mockResolvedValue(undefined)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
render(<CodeBlock code="plain body" />)
const copy = screen.getByRole('button', { name: '复制' })
fireEvent.focus(copy)
expect(screen.getByRole('tooltip').textContent).toBe('复制')
await act(async () => { fireEvent.click(copy) })
expect(screen.getByRole('tooltip').textContent).toBe('复制成功')
})
it('does not schedule confirmation after an outstanding clipboard write finishes on an unmounted block', async () => {
vi.useFakeTimers()
const pending = Promise.withResolvers<undefined>()
const writeText = vi.fn(() => pending.promise)
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText } })
const view = render(<CodeBlock code="plain body" />)
fireEvent.click(screen.getByRole('button', { name: '复制' }))
expect(writeText).toHaveBeenCalledWith('plain body')
view.unmount()
await act(async () => { pending.resolve(undefined) })
expect(vi.getTimerCount()).toBe(0)
expect(screen.queryByRole('button', { name: '复制成功' })).toBeNull()
})
it('clears the confirmation timer when the block unmounts', async () => {
vi.useFakeTimers()
Object.defineProperty(navigator, 'clipboard', { configurable: true, value: { writeText: vi.fn().mockResolvedValue(undefined) } })
const view = render(<CodeBlock code="plain body" />)
await act(async () => { fireEvent.click(screen.getByRole('button', { name: '复制' })) })
expect(screen.getByRole('button', { name: '复制成功' })).toBeTruthy()
view.unmount()
expect(vi.getTimerCount()).toBe(0)
await vi.advanceTimersByTimeAsync(1000)
expect(screen.getByRole('button', { name: '复制' })).toBeTruthy()
})
it('does not claim success when clipboard.writeText rejects', async () => {
@@ -1,88 +0,0 @@
// @vitest-environment jsdom
import { act, cleanup, render } from '@testing-library/react'
import { afterEach, describe, expect, it } from 'vitest'
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
import { highlightToHtml, StreamingHighlightSession, subscribeGrammarLoaded } from '../src/markdown/highlight.ts'
import { markdownLabels } from './labels.client.ts'
const samples = [
{
lang: 'dot',
code: 'digraph G {\n // route\n /* multi\n line */\n node [shape=box];\n A -> B [label="hello"];\n}',
tokens: [['digraph', 'keyword'], ['// route', 'comment'], ['line */', 'comment'], ['"hello"', 'string-expression']],
},
{
lang: 'svg',
code: '<svg xmlns="http://www.w3.org/2000/svg">\n <!-- multi\n line -->\n <rect width="20" fill="red"/>\n</svg>',
tokens: [['svg', 'string-expression'], ['line -->', 'comment'], ['width', 'function'], ['"red"', 'string-expression']],
},
{
lang: 'mermaid',
code: 'flowchart LR\n %% route\n A["Input"] --> B["Preview"]',
tokens: [['flowchart', 'keyword'], ['%% route', 'comment'], ['"Input"', 'string']],
},
] as const
afterEach(cleanup)
function tokenTree(root: ParentNode) {
return [...root.querySelectorAll('pre.shiki .line')].map(line =>
[...line.querySelectorAll<HTMLElement>('span[style]')].map(span => [span.textContent, span.style.cssText]),
)
}
describe('diagram source highlighting', () => {
// Each grammar's first use is isolated from the other highlighter spec files.
it.each(samples)('automatically highlights cold $lang source in both rendering modes', async ({ lang, code, tokens }) => {
const ready = Promise.withResolvers<undefined>()
const stop = subscribeGrammarLoaded(() => { ready.resolve(undefined) })
try {
const settled = render(<CodeBlock code={code} lang={lang.toUpperCase()} {...markdownLabels.code} />)
const streamed = render(<CodeBlock code={code} lang={lang} streaming {...markdownLabels.code} />)
expect(settled.container.querySelector('pre.shiki')).toBeNull()
expect(streamed.container.querySelector('pre.shiki')).toBeNull()
await act(async () => { await ready.promise })
expect(settled.container.querySelector('pre.shiki')?.textContent).toBe(code)
expect(streamed.container.querySelector('pre.shiki')?.textContent).toBe(code)
expect(tokenTree(streamed.container)).toEqual(tokenTree(settled.container))
const spans = [...settled.container.querySelectorAll<HTMLElement>('pre.shiki span[style]')]
for (const [text, color] of tokens) {
expect(spans.some(span => span.textContent?.trim() === text
&& span.style.color === `var(--shiki-token-${color})`), text).toBe(true)
}
} finally {
stop()
}
})
it('uses one grammar for DOT aliases and reuses XML for SVG', () => {
const code = samples[0].code
expect(highlightToHtml(code, 'DoT')).toBe(highlightToHtml(code, 'GrApHvIz'))
expect(highlightToHtml(samples[1].code, 'SVG')).toBe(highlightToHtml(samples[1].code, 'xml'))
const session = new StreamingHighlightSession()
const first = session.update(code, 'dot')
expect(session.update(code, 'GRAPHVIZ')?.[0]).toBe(first?.[0])
})
it.each(samples)('matches full $lang highlighting at every appended character and retains settled DOM', ({ lang, code }) => {
const session = new StreamingHighlightSession()
for (let end = 1; end <= code.length; end += 1) {
const prefix = code.slice(0, end)
expect(session.update(prefix, lang)).toEqual(new StreamingHighlightSession().update(prefix, lang))
}
const firstChunk = code.slice(0, code.indexOf('\n') + 1)
const view = render(<CodeBlock code={firstChunk} lang={lang} streaming {...markdownLabels.code} />)
const firstLine = view.container.querySelector('pre.shiki .line')
expect(firstLine).not.toBeNull()
view.rerender(<CodeBlock code={code} lang={lang} streaming {...markdownLabels.code} />)
expect(view.container.querySelector('pre.shiki .line')).toBe(firstLine)
const pre = view.container.querySelector('pre.shiki')
const streamed = tokenTree(view.container)
const settled = render(<CodeBlock code={code} lang={lang} {...markdownLabels.code} />)
expect(streamed).toEqual(tokenTree(settled.container))
view.rerender(<CodeBlock code={code} lang={lang} {...markdownLabels.code} />)
expect(view.container.querySelector('pre.shiki')).toBe(pre)
expect(tokenTree(view.container)).toEqual(streamed)
})
})
@@ -1,111 +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 { renderSvg } from '../src/markdown/svg.ts'
import { markdownLabels } from './labels.client.ts'
const status = { error: 'Cannot preview' }
const preview = {
mermaid: { ...status, diagram: 'Mermaid diagram' },
graphviz: { ...status, diagram: 'Graphviz diagram' },
svg: { ...status, diagram: 'SVG preview' },
preview: 'Preview', source: 'Source', zoom: 'Enlarge preview', pending: 'Preparing preview…', close: 'Close preview', interaction: 'Drag to pan, scroll to zoom',
}
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],
] 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)('selects settled %s preview and retains source and image across view changes', 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')).toBeNull()
expect(screen.getByRole('status').textContent).toBe(preview.pending)
view.rerender(<MarkdownText {...props} />)
expect(view.container.querySelector('pre')).toBeNull()
expect(view.container.querySelector('[data-code-block-source-view]')?.getAttribute('aria-hidden')).toBe('true')
expect(screen.getByRole('button', { name: 'Preview' }).getAttribute('aria-pressed')).toBe('true')
const findPreview = async () => {
const img = await waitFor(() => {
const node = document.querySelector('img')
expect(node).not.toBeNull()
return node!
})
fireEvent.load(img)
return screen.getByRole('img', { name: title })
}
const element = await findPreview()
expect(element.getAttribute('src')).toMatch(/^data:image\/svg\+xml/)
expect(view.container.querySelector('iframe')).toBeNull()
expect(view.container.querySelector('[data-code-block-preview]')).not.toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
expect(view.container.querySelector('pre code')?.textContent).toBe(code)
expect(view.container.querySelector('[data-code-block-preview]')?.hasAttribute('hidden')).toBe(true)
expect(screen.queryByRole('img', { name: title })).toBeNull()
expect(element.isConnected).toBe(true)
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)
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 waitFor(() => { expect(screen.getByRole('status').textContent).toBe(status.error) })
expect(view.container.querySelector('pre')).toBeNull()
expect(view.container.querySelector('[data-code-block-source-view]')?.getAttribute('aria-hidden')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
expect(view.container.querySelector('pre code')?.textContent).toBe('broken')
fireEvent.click(screen.getByRole('button', { name: 'Preview' }))
view.rerender(<MarkdownText text={`\`\`\`svg\n${svg}\n\`\`\``} labels={{ ...markdownLabels, preview }} />)
await waitFor(() => { expect(view.container.querySelector('img')).not.toBeNull() })
fireEvent.load(view.container.querySelector('img')!)
await screen.findByRole('img', { name: preview.svg.diagram })
expect(screen.queryByRole('status')).toBeNull()
})
it('requires an opted-in supported code fence and leaves HTML and other languages unrendered', () => {
const view = render(<MarkdownText text={'```html\n<h1>Example</h1>\n```'} labels={{ ...markdownLabels, preview }} />)
expect(view.container.querySelector('pre code')?.textContent).toBe('<h1>Example</h1>')
expect(screen.queryByRole('button', { name: 'Preview' })).toBeNull()
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 SVG previews', () => {
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('does not prepare a cancelled SVG', () => {
const controller = new AbortController()
controller.abort()
expect(() => renderSvg(svg, controller.signal)).toThrow(controller.signal.reason)
})
})
@@ -5,113 +5,69 @@
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<div data-code-block-source="true">
<pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
<code>
<span class="line">
<span style="color:var(--shiki-token-keyword)">
#text "const"
<span style="color:var(--shiki-token-constant)">
#text " answer"
<span style="color:var(--shiki-token-keyword)">
#text ":"
<span style="color:var(--shiki-token-constant)">
#text " number"
<span style="color:var(--shiki-token-keyword)">
#text " ="
<span style="color:var(--shiki-token-constant)">
#text " 42"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<div>
<pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
<code>
<span class="line">
<span style="color:var(--shiki-token-keyword)">
#text "const"
<span style="color:var(--shiki-token-constant)">
#text " answer"
<span style="color:var(--shiki-token-keyword)">
#text ":"
<span style="color:var(--shiki-token-constant)">
#text " number"
<span style="color:var(--shiki-token-keyword)">
#text " ="
<span style="color:var(--shiki-token-constant)">
#text " 42"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<pre class="_plain_9aea57" data-code-block-source="true">
<code>
#text "no language"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<pre class="_plain_9aea57">
<code>
#text "no language"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "unknown-lang"
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<pre class="_plain_9aea57" data-code-block-source="true">
<code>
#text "plain fallback"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<pre class="_plain_9aea57">
<code>
#text "plain fallback"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<div data-code-block-source="true">
<pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
<code>
<span class="line">
<span style="color:var(--shiki-token-keyword)">
#text "const"
<span style="color:var(--shiki-token-constant)">
#text " withMeta"
<span style="color:var(--shiki-token-keyword)">
#text " ="
<span style="color:var(--shiki-token-constant)">
#text " true"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<div>
<pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
<code>
<span class="line">
<span style="color:var(--shiki-token-keyword)">
#text "const"
<span style="color:var(--shiki-token-constant)">
#text " withMeta"
<span style="color:var(--shiki-token-keyword)">
#text " ="
<span style="color:var(--shiki-token-constant)">
#text " true"
<pre>
<code>
<div class="_block_9aea57 md-code-block">
@@ -119,20 +75,9 @@
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<pre class="_plain_9aea57" data-code-block-source="true">
<code>
#text "indented code block\nsecond line"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<pre class="_plain_9aea57">
<code>
#text "indented code block\nsecond line"
@@ -5,111 +5,67 @@
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<pre class="shiki css-variables" data-code-block-source="true" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
<code>
<span class="line">
<span style="color: var(--shiki-token-keyword);">
#text "const"
<span style="color: var(--shiki-token-constant);">
#text " answer"
<span style="color: var(--shiki-token-keyword);">
#text ":"
<span style="color: var(--shiki-token-constant);">
#text " number"
<span style="color: var(--shiki-token-keyword);">
#text " ="
<span style="color: var(--shiki-token-constant);">
#text " 42"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
<code>
<span class="line">
<span style="color: var(--shiki-token-keyword);">
#text "const"
<span style="color: var(--shiki-token-constant);">
#text " answer"
<span style="color: var(--shiki-token-keyword);">
#text ":"
<span style="color: var(--shiki-token-constant);">
#text " number"
<span style="color: var(--shiki-token-keyword);">
#text " ="
<span style="color: var(--shiki-token-constant);">
#text " 42"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<pre class="_plain_9aea57" data-code-block-source="true">
<code>
#text "no language"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<pre class="_plain_9aea57">
<code>
#text "no language"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "unknown-lang"
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<pre class="_plain_9aea57" data-code-block-source="true">
<code>
#text "plain fallback"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<pre class="_plain_9aea57">
<code>
#text "plain fallback"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<pre class="shiki css-variables" data-code-block-source="true" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
<code>
<span class="line">
<span style="color: var(--shiki-token-keyword);">
#text "const"
<span style="color: var(--shiki-token-constant);">
#text " withMeta"
<span style="color: var(--shiki-token-keyword);">
#text " ="
<span style="color: var(--shiki-token-constant);">
#text " true"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
<code>
<span class="line">
<span style="color: var(--shiki-token-keyword);">
#text "const"
<span style="color: var(--shiki-token-constant);">
#text " withMeta"
<span style="color: var(--shiki-token-keyword);">
#text " ="
<span style="color: var(--shiki-token-constant);">
#text " true"
<pre>
<code>
<div class="_block_9aea57 md-code-block">
@@ -117,20 +73,9 @@
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<pre class="_plain_9aea57" data-code-block-source="true">
<code>
#text "indented code block\nsecond line"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<pre class="_plain_9aea57">
<code>
#text "indented code block\nsecond line"
@@ -4,58 +4,36 @@
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<pre class="_plain_9aea57" data-code-block-source="true">
<code>
#text "kept blank line follows\n"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<pre class="_plain_9aea57">
<code>
#text "kept blank line follows\n"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<div data-code-block-source="true">
<pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
<code>
<span class="line">
<span style="color:var(--shiki-token-keyword)">
#text "const"
<span style="color:var(--shiki-token-constant)">
#text " doubled"
<span style="color:var(--shiki-token-keyword)">
#text " ="
<span style="color:var(--shiki-token-constant)">
#text " true"
#text "\n"
<span class="line">
#text "\n"
<span class="line">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<div>
<pre class="shiki css-variables" style="background-color:var(--shiki-background);color:var(--shiki-foreground)" tabindex="0">
<code>
<span class="line">
<span style="color:var(--shiki-token-keyword)">
#text "const"
<span style="color:var(--shiki-token-constant)">
#text " doubled"
<span style="color:var(--shiki-token-keyword)">
#text " ="
<span style="color:var(--shiki-token-constant)">
#text " true"
#text "\n"
<span class="line">
#text "\n"
<span class="line">
<p>
#text "after"
@@ -4,57 +4,35 @@
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<pre class="_plain_9aea57" data-code-block-source="true">
<code>
#text "kept blank line follows\n"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<pre class="_plain_9aea57">
<code>
#text "kept blank line follows\n"
<div class="_block_9aea57 md-code-block">
<div class="_bannerWrap_9aea57">
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
#text "ts"
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<pre class="shiki css-variables" data-code-block-source="true" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
<code>
<span class="line">
<span style="color: var(--shiki-token-keyword);">
#text "const"
<span style="color: var(--shiki-token-constant);">
#text " doubled"
<span style="color: var(--shiki-token-keyword);">
#text " ="
<span style="color: var(--shiki-token-constant);">
#text " true"
#text "\n"
<span class="line">
#text "\n"
<span class="line">
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<pre class="shiki css-variables" style="background-color: var(--shiki-background); color: var(--shiki-foreground);" tabindex="0">
<code>
<span class="line">
<span style="color: var(--shiki-token-keyword);">
#text "const"
<span style="color: var(--shiki-token-constant);">
#text " doubled"
<span style="color: var(--shiki-token-keyword);">
#text " ="
<span style="color: var(--shiki-token-constant);">
#text " true"
#text "\n"
<span class="line">
#text "\n"
<span class="line">
<p>
#text "after"
@@ -37,20 +37,9 @@
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<pre class="_plain_9aea57" data-code-block-source="true">
<code>
#text "fenced inside list"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<pre class="_plain_9aea57">
<code>
#text "fenced inside list"
@@ -37,20 +37,9 @@
<div class="_banner_9aea57" data-code-block-banner="true">
<div class="_infostring_9aea57">
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<pre class="_plain_9aea57" data-code-block-source="true">
<code>
#text "fenced inside list"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<pre class="_plain_9aea57">
<code>
#text "fenced inside list"
@@ -23,20 +23,9 @@
<div class="_infostring_9aea57">
#text "math"
<div class="_action_9aea57">
<button aria-label="行号" aria-pressed="false" class="_iconButton_9aea57" type="button">
<svg aria-hidden="true" fill="none" height="16" viewBox="0 0 16 16" width="16">
<path d="M2 2.5h1v4m-1 0h2M1.5 10a1.25 1.25 0 0 1 2.5 0c0 1-2.5 1.5-2.5 3H4M7 4.5h7M7 11.5h7" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.2" stroke="currentColor">
<button aria-label="复制" class="_iconButton_9aea57" type="button">
<span aria-hidden="true">
<svg fill="none" height="16" viewBox="0 0 16 16" width="16" xmlns="http://www.w3.org/2000/svg">
<path d="M6.14929 4.02032C7.11197 4.02032 7.87983 4.02016 8.49597 4.07598C9.12128 4.13269 9.65792 4.25188 10.1415 4.53106C10.7202 4.8653 11.2008 5.3459 11.535 5.92462C11.8142 6.40818 11.9334 6.94481 11.9901 7.57012C12.0459 8.18625 12.0458 8.95419 12.0458 9.9168C12.0458 10.8795 12.0459 11.6473 11.9901 12.2635C11.9334 12.8888 11.8142 13.4254 11.535 13.909C11.2008 14.4877 10.7202 14.9683 10.1415 15.3025C9.65792 15.5817 9.12128 15.7009 8.49597 15.7576C7.87984 15.8134 7.11196 15.8133 6.14929 15.8133C5.18667 15.8133 4.41874 15.8134 3.80261 15.7576C3.1773 15.7009 2.64067 15.5817 2.1571 15.3025C1.5784 14.9683 1.09778 14.4877 0.76355 13.909C0.484366 13.4254 0.365184 12.8888 0.308472 12.2635C0.252649 11.6473 0.252808 10.8795 0.252808 9.9168C0.252808 8.95418 0.252664 8.18625 0.308472 7.57012C0.365184 6.94481 0.484366 6.40818 0.76355 5.92462C1.09777 5.34589 1.57839 4.86529 2.1571 4.53106C2.64067 4.25188 3.1773 4.13269 3.80261 4.07598C4.41874 4.02017 5.18666 4.02032 6.14929 4.02032ZM6.14929 5.37774C5.16181 5.37774 4.46634 5.37761 3.92566 5.42657C3.39434 5.47472 3.07859 5.56574 2.83582 5.70587C2.4632 5.92106 2.15354 6.2307 1.93835 6.60333C1.79823 6.8461 1.70721 7.16185 1.65906 7.69317C1.6101 8.23385 1.61023 8.92933 1.61023 9.9168C1.61023 10.9043 1.61009 11.5998 1.65906 12.1404C1.70721 12.6717 1.79823 12.9875 1.93835 13.2303C2.15356 13.6029 2.46321 13.9126 2.83582 14.1277C3.07859 14.2679 3.39434 14.3589 3.92566 14.407C4.46634 14.456 5.16182 14.4559 6.14929 14.4559C7.13682 14.4559 7.83224 14.456 8.37292 14.407C8.90425 14.3589 9.21999 14.2679 9.46277 14.1277C9.83535 13.9126 10.145 13.6029 10.3602 13.2303C10.5004 12.9875 10.5914 12.6717 10.6395 12.1404C10.6885 11.5998 10.6884 10.9043 10.6884 9.9168C10.6884 8.92934 10.6885 8.23384 10.6395 7.69317C10.5914 7.16185 10.5004 6.8461 10.3602 6.60333C10.1451 6.23071 9.83536 5.92107 9.46277 5.70587C9.21999 5.56574 8.90424 5.47472 8.37292 5.42657C7.83224 5.3776 7.13682 5.37774 6.14929 5.37774ZM9.80164 0.367975C10.7638 0.367975 11.5314 0.36788 12.1473 0.423639C12.7726 0.480307 13.3093 0.598759 13.7928 0.877741C14.3717 1.21192 14.8521 1.69355 15.1864 2.27227C15.4655 2.75574 15.5857 3.29164 15.6425 3.9168C15.6983 4.53301 15.6971 5.3016 15.6971 6.26446V7.82989C15.6971 8.29264 15.6989 8.58993 15.6649 8.84844C15.4668 10.3525 14.401 11.5738 12.9833 11.9988V10.5467C13.6973 10.1903 14.2105 9.49662 14.3192 8.67169C14.3387 8.52347 14.3407 8.3358 14.3407 7.82989V6.26446C14.3407 5.27706 14.3398 4.58149 14.2909 4.04083C14.2428 3.50968 14.1526 3.19372 14.0126 2.95098C13.7974 2.57849 13.4876 2.26869 13.1151 2.05352C12.8724 1.91347 12.5564 1.82237 12.0253 1.77423C11.4847 1.72528 10.7888 1.7254 9.80164 1.7254H7.71472C6.7562 1.72558 5.92665 2.27697 5.52332 3.07891H4.07019C4.54221 1.51132 5.9932 0.368186 7.71472 0.367975H9.80164Z" fill="currentColor">
<div class="_segments_9aea57">
<span aria-hidden="true" class="_selection_9aea57">
<span class="_sourceOnly_9aea57">
#text "源码"
<div class="_body_9aea57" data-code-block-content="true">
<div class="_source_9aea57">
<div>
<pre class="_plain_9aea57" data-code-block-source="true">
<code>
#text "\\sqrt{2}"
<button class="_copyButton_9aea57" type="button">
#text "复制"
<div class="_content_9aea57" data-code-block-content="true">
<pre class="_plain_9aea57">
<code>
#text "\\sqrt{2}"
@@ -1,66 +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' },
}))
} finally {
if (previous === null) document.body.removeAttribute('style')
else document.body.setAttribute('style', previous)
}
})
it('does not initialize the runtime for already cancelled work', async () => {
const { renderGraphviz } = await import('../src/markdown/graphviz.ts')
const controller = new AbortController()
controller.abort()
await expect(renderGraphviz('digraph {}', controller.signal)).rejects.toMatchObject({ name: 'AbortError' })
expect(instance).not.toHaveBeenCalled()
})
@@ -1,8 +1,7 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
import { act, cleanup, render, waitFor } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { SourcePreview } from '../src/markdown/SourcePreview.tsx'
import { ReadBlock } from '../src/ReadBlock.tsx'
import { CodeBlock } from '../src/markdown/CodeBlock.tsx'
import { markdownLabels, readBlockLabels } from './labels.client.ts'
@@ -101,24 +100,6 @@ describe('viewport-activated syntax highlighting', () => {
expect(observer.disconnected).toBe(true)
})
it.each([
['mermaid', 'flowchart LR\n A[Input] --> B[Output]'],
['svg', '<svg xmlns="http://www.w3.org/2000/svg"><rect width="10"/></svg>'],
['dot', 'digraph { a [label="Input"]; a -> b }'],
])('defers %s source until selection, then highlights without viewport delivery', async (lang, code) => {
const preview = {
render: async () => 'data:image/svg+xml,%3Csvg%2F%3E',
labels: { source: 'Source', preview: 'Preview', diagram: 'Diagram', error: 'Error', zoom: 'Zoom', pending: 'Pending', close: 'Close', interaction: 'Pan and zoom' },
}
const view = render(<CodeBlock code={code} lang={lang} preview={preview} {...markdownLabels.code} />)
expect(view.container.querySelector('pre')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
await waitFor(() => { expect(view.container.querySelector('pre.shiki')).not.toBeNull() })
expect(view.container.querySelector('[data-code-block-source-view]')?.hasAttribute('aria-hidden')).toBe(false)
expect(view.container.querySelector('pre code')?.textContent).toBe(code)
expect(view.container.querySelectorAll('pre span[style]').length).toBeGreaterThan(1)
})
it('does not observe an unsupported language', () => {
const view = render(
<CodeBlock code="IDENTIFICATION DIVISION." lang="cobol" {...markdownLabels.code} />,
@@ -127,32 +108,6 @@ describe('viewport-activated syntax highlighting', () => {
expect(IntersectionObserverStub.instances).toHaveLength(0)
})
it('releases the offscreen observer when preview replaces source, then highlights on selection', async () => {
const code = '<svg><rect width="10"/></svg>'
const props = { code, lang: 'svg', ...markdownLabels.code }
const view = render(<CodeBlock {...props} />)
const block = view.container.querySelector('.md-code-block')!
const observer = IntersectionObserverStub.instances[0]!
expect(observer.observed.has(block)).toBe(true)
expect(block.querySelector('pre.shiki')).toBeNull()
const preview = {
render: async () => 'data:image/svg+xml,%3Csvg%2F%3E',
labels: { source: 'Source', preview: 'Preview', diagram: 'Diagram', error: 'Error', zoom: 'Zoom', pending: 'Pending', close: 'Close', interaction: 'Pan and zoom' },
}
view.rerender(<CodeBlock {...props} preview={preview} />)
fireEvent.click(screen.getByRole('button', { name: 'Source' }))
await waitFor(() => { expect(block.querySelector('pre.shiki')).not.toBeNull() })
const highlighted = block.querySelector('pre.shiki')!
expect(highlighted.textContent).toBe(code)
expect(observer.unobserved.has(block)).toBe(true)
expect(observer.disconnected).toBe(true)
view.rerender(<CodeBlock {...props} />)
expect(block.querySelector('pre.shiki')).toBe(highlighted)
expect(IntersectionObserverStub.instances.every(instance => instance.disconnected)).toBe(true)
})
it('releases the shared observer when the last pending block unmounts', () => {
const view = render(<CodeBlock code="const pending = true" lang="ts" {...markdownLabels.code} />)
const block = view.container.querySelector('.md-code-block')!
@@ -203,113 +158,3 @@ describe('viewport-activated syntax highlighting', () => {
})
})
})
describe('viewport-activated diagram previews', () => {
const labels = { diagram: 'Diagram', error: 'Error', zoom: 'Zoom', pending: 'Pending', close: 'Close', interaction: 'Pan and zoom' }
const imageUrl = 'data:image/svg+xml,%3Csvg%2F%3E'
it('leaves an offscreen history unrendered and activates only visible previews', async () => {
const renderer = vi.fn(async () => imageUrl)
const view = render(<>{Array.from({ length: 20 }, (_, index) =>
<SourcePreview key={index} code={String(index)} render={renderer} labels={labels} />)}</>)
expect(renderer).not.toHaveBeenCalled()
expect(view.container.querySelectorAll('img')).toHaveLength(0)
expect(IntersectionObserverStub.instances).toHaveLength(1)
const observer = IntersectionObserverStub.instances[0]!
expect(observer.observed.size).toBe(20)
const target = [...observer.observed][19]!
await act(async () => { observer.intersect(target, true) })
expect(renderer).toHaveBeenCalledExactlyOnceWith('19', expect.any(AbortSignal))
fireEvent.load(view.container.querySelector('img')!)
const image = screen.getByRole('img')
await act(async () => { observer.intersect(target, false) })
await act(async () => { observer.intersect(target, true) })
expect(renderer).toHaveBeenCalledOnce()
expect(screen.getByRole('img')).toBe(image)
view.unmount()
expect(observer.observed.size).toBe(0)
expect(observer.disconnected).toBe(true)
})
it('cancels work leaving the viewport and ignores its completion after reentry', async () => {
const first = Promise.withResolvers<string>()
const renderer = vi.fn((_code: string, _signal: AbortSignal) => first.promise).mockResolvedValueOnce(imageUrl)
const view = render(<SourcePreview code="first" render={renderer} labels={labels} />)
const observer = IntersectionObserverStub.instances[0]!
const target = [...observer.observed][0]!
await act(async () => { observer.intersect(target, true) })
fireEvent.load(view.container.querySelector('img')!)
await act(async () => { observer.intersect(target, false) })
view.rerender(<SourcePreview code="second" render={renderer} labels={labels} />)
expect(renderer).toHaveBeenCalledOnce()
await act(async () => { observer.intersect(target, true) })
const signal = renderer.mock.calls[1]![1]
await act(async () => { observer.intersect(target, false) })
expect(signal.aborted).toBe(true)
renderer.mockResolvedValueOnce(imageUrl)
await act(async () => { observer.intersect(target, true) })
fireEvent.load(view.container.querySelector('img')!)
await act(async () => { first.resolve('obsolete') })
expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
expect(renderer).toHaveBeenCalledTimes(3)
})
it('keeps an offscreen lightbox active and cancels its unfinished refresh on close', async () => {
const replacement = Promise.withResolvers<string>()
const renderer = vi.fn((_code: string, _signal: AbortSignal) => replacement.promise).mockResolvedValueOnce(imageUrl)
const actions = document.createElement('div')
document.body.append(actions)
const previous = document.documentElement.style.colorScheme
const view = render(<SourcePreview code="diagram" render={renderer} labels={labels} actions={actions} />)
try {
const observer = IntersectionObserverStub.instances[0]!
const target = [...observer.observed][0]!
await act(async () => { observer.intersect(target, true) })
fireEvent.load(view.container.querySelector('img')!)
fireEvent.click(screen.getByRole('button', { name: labels.zoom }))
await act(async () => { observer.intersect(target, false) })
await act(async () => { document.documentElement.style.colorScheme = 'dark' })
expect(renderer).toHaveBeenCalledTimes(2)
const signal = renderer.mock.calls[1]![1]
expect(signal.aborted).toBe(false)
expect(screen.getByRole('dialog')).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: labels.close }))
expect(signal.aborted).toBe(true)
await act(async () => { replacement.reject(new Error('cancelled')) })
expect(screen.queryByRole('status')).toBeNull()
expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
} finally {
view.unmount()
actions.remove()
document.documentElement.style.colorScheme = previous
}
})
it('defers offscreen theme updates and retains the loaded image until reentry refresh loads', async () => {
const renderer = vi.fn(async () => imageUrl)
const previous = document.documentElement.style.colorScheme
const view = render(<SourcePreview code="diagram" render={renderer} labels={labels} />)
try {
const observer = IntersectionObserverStub.instances[0]!
const target = [...observer.observed][0]!
await act(async () => { observer.intersect(target, true) })
fireEvent.load(view.container.querySelector('img')!)
const image = screen.getByRole('img')
await act(async () => { observer.intersect(target, false) })
await act(async () => { document.documentElement.style.colorScheme = 'dark' })
expect(renderer).toHaveBeenCalledOnce()
expect(screen.getByRole('img')).toBe(image)
renderer.mockResolvedValueOnce('data:image/svg+xml,new')
await act(async () => { observer.intersect(target, true) })
expect(renderer).toHaveBeenCalledTimes(2)
expect(screen.getByRole('img')).toBe(image)
expect(screen.queryByRole('status')).toBeNull()
fireEvent.load(view.container.querySelector('img[src="data:image/svg+xml,new"]')!)
expect(screen.getByRole('img').getAttribute('src')).toBe('data:image/svg+xml,new')
} finally {
view.unmount()
document.documentElement.style.colorScheme = previous
}
})
})
@@ -9,7 +9,7 @@ import type {
} from '../src/index.ts'
export const markdownLabels: MarkdownLabels = {
code: { copyLabel: '复制', copiedLabel: '复制成功', sourceLabel: '源码', lineNumbersLabel: '行号' },
code: { copyLabel: '复制', copiedLabel: '复制成功' },
footnotes: 'Footnotes',
}
@@ -6,7 +6,6 @@
// incremental fence-highlight decision, so `*.streaming.txt` fixtures with
// fenced code pin shiki span trees where react-markdown had the plain arm.
import { cleanup, render } from '@testing-library/react'
import { StrictMode } from 'react'
import { afterEach, describe, expect, it } from 'vitest'
import { MarkdownText } from './markdown-test-components.tsx'
@@ -54,7 +53,7 @@ function serializeChildren(element: Element, indent: string, inPre: boolean): st
}
function renderCase(text: string, streaming: boolean): string {
const { container, unmount } = render(<StrictMode><MarkdownText text={text} streaming={streaming} /></StrictMode>)
const { container, unmount } = render(<MarkdownText text={text} streaming={streaming} />)
const out = [...container.childNodes].map(child => serialize(child, '', false)).join('')
unmount()
return out
@@ -96,13 +96,13 @@ describe('incremental streaming rendering', () => {
it('drops the streaming cache when the copy labels change identity', () => {
const doc = ['```ts', 'const a = 1', '```', '', 'p1', '', 'p2', '', 'p3'].join('\n')
const live = render(
<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Copy', copiedLabel: 'Copied', sourceLabel: 'Source', lineNumbersLabel: 'Line numbers' }} />,
<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Copy', copiedLabel: 'Copied' }} />,
)
expect([...live.container.querySelectorAll('button')].map(b => b.getAttribute('aria-label'))).toEqual(['Line numbers', 'Copy'])
expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Copy'])
live.rerender(
<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Kopieren', copiedLabel: 'Kopiert', sourceLabel: 'Quelle', lineNumbersLabel: 'Zeilennummern' }} />,
<MarkdownText text={doc} streaming codeLabels={{ copyLabel: 'Kopieren', copiedLabel: 'Kopiert' }} />,
)
expect([...live.container.querySelectorAll('button')].map(b => b.getAttribute('aria-label'))).toEqual(['Zeilennummern', 'Kopieren'])
expect([...live.container.querySelectorAll('button')].map(b => b.textContent)).toEqual(['Kopieren'])
live.unmount()
})
@@ -21,7 +21,6 @@ function makeContext(): MarkdownRenderContext {
return {
streaming: false,
labels: markdownLabels,
previews: undefined,
fileMentions: undefined,
pathImages: undefined,
targets: createReferenceTargets(),
@@ -225,25 +224,6 @@ describe('renderFootnoteSection edge shapes', () => {
})
describe('MarkdownText under StrictMode', () => {
it('renders each completed fence line once across stream growth and settlement', () => {
const chunks = [
'const first = 1\nlet',
'const first = 1\nlet second = 2\n// tail',
'const first = 1\nlet second = 2\n// tail complete',
]
const view = render(<StrictMode><MarkdownText text="```ts\n" streaming /></StrictMode>)
for (const code of chunks) {
view.rerender(<StrictMode><MarkdownText text={`\`\`\`ts\n${code}`} streaming /></StrictMode>)
const pre = view.container.querySelector('pre.shiki')
expect(pre?.textContent).toBe(code)
expect(pre?.querySelectorAll('.line')).toHaveLength(code.split('\n').length)
}
const highlighted = view.container.querySelector('pre.shiki')
view.rerender(<StrictMode><MarkdownText text={`\`\`\`ts\n${chunks[2]}\n\`\`\``} /></StrictMode>)
expect(view.container.querySelector('pre.shiki')).toBe(highlighted)
expect(highlighted?.textContent).toBe(chunks[2])
})
it('streams identically when React double-invokes render work', () => {
const doc = 'one\n\ntwo\n\nthree\n\nfour\n\nfive'
const strict = render(<StrictMode><MarkdownText text={doc.slice(0, 8)} streaming /></StrictMode>)
@@ -310,7 +310,7 @@ describe('MarkdownText', () => {
})
it('forwards localized labels to fenced code blocks', () => {
render(<MarkdownText text={'```ts\nconst answer = 42\n```'} codeLabels={{ copyLabel: 'Copy code', copiedLabel: 'Copied', sourceLabel: 'Source', lineNumbersLabel: 'Line numbers' }} />)
render(<MarkdownText text={'```ts\nconst answer = 42\n```'} codeLabels={{ copyLabel: 'Copy code', copiedLabel: 'Copied' }} />)
expect(screen.getByRole('button', { name: 'Copy code' })).toBeTruthy()
})
@@ -1,152 +0,0 @@
// @vitest-environment jsdom
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
const initialize = vi.fn()
const renderDiagram = vi.fn()
const getDiagramFromText = vi.fn()
const importRuntime = vi.fn(() => ({ default: { initialize, render: renderDiagram, mermaidAPI: { getDiagramFromText } } }))
beforeEach(() => {
vi.resetModules()
vi.resetAllMocks()
getDiagramFromText.mockResolvedValue({ type: 'sequence', db: {} })
vi.doMock('mermaid', importRuntime)
})
afterEach(() => { vi.doUnmock('mermaid') })
describe('Mermaid runtime', () => {
it('rejects image nodes before layout and leaves the queue available', async () => {
const { renderMermaid } = await import('../src/markdown/mermaid.ts')
getDiagramFromText.mockResolvedValueOnce({ type: 'flowchart-v2', db: {
getVertices: () => new Map([['A', { img: 'https://preview.invalid/image' }]]),
} })
await expect(renderMermaid('image', new AbortController().signal)).rejects.toThrow('image nodes')
expect(renderDiagram).not.toHaveBeenCalled()
getDiagramFromText.mockResolvedValueOnce({ type: 'flowchart-v2', db: {
getVertices: () => new Map([['A', {}]]),
} })
renderDiagram.mockResolvedValue({ svg: '<svg/>' })
await expect(renderMermaid('plain', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
expect(renderDiagram).toHaveBeenCalledOnce()
})
it('allows another attempt after the runtime import fails', async () => {
const importError = new Error('runtime unavailable')
importRuntime.mockImplementationOnce(() => { throw importError })
const { renderMermaid } = await import('../src/markdown/mermaid.ts')
await expect(renderMermaid('first', new AbortController().signal)).rejects.toMatchObject({ cause: importError })
expect(initialize).not.toHaveBeenCalled()
vi.doMock('mermaid', importRuntime)
renderDiagram.mockResolvedValue({ svg: '<svg/>' })
await expect(renderMermaid('retry', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
expect(renderDiagram).toHaveBeenCalledOnce()
})
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 import the runtime for cancelled queued work', 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()
expect(importRuntime).not.toHaveBeenCalled()
})
it('skips layout after cancellation while loading the diagram parser', async () => {
const parsed = Promise.withResolvers<{ type: string; db: object }>()
getDiagramFromText.mockReturnValueOnce(parsed.promise)
const { renderMermaid } = await import('../src/markdown/mermaid.ts')
const controller = new AbortController()
const result = renderMermaid('sequenceDiagram', controller.signal)
const rejection = expect(result).rejects.toMatchObject({ name: 'AbortError' })
await vi.waitFor(() => { expect(getDiagramFromText).toHaveBeenCalledOnce() })
controller.abort()
parsed.resolve({ type: 'sequence', db: {} })
await rejection
expect(renderDiagram).not.toHaveBeenCalled()
renderDiagram.mockResolvedValue({ svg: '<svg/>' })
await expect(renderMermaid('next', new AbortController().signal)).resolves.toContain('data:image/svg+xml')
})
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,176 +0,0 @@
// @vitest-environment jsdom
import { cleanup, fireEvent, render } from '@testing-library/react'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import { PreviewLightbox } from '../src/markdown/PreviewLightbox.tsx'
beforeEach(() => {
vi.stubGlobal('innerWidth', 1000)
vi.stubGlobal('innerHeight', 800)
})
afterEach(() => { cleanup(); vi.restoreAllMocks(); vi.unstubAllGlobals() })
const props = {
src: 'data:image/svg+xml;charset=utf-8,%3Csvg%2F%3E',
alt: 'Diagram preview',
closeLabel: 'Close preview',
interactionLabel: 'Drag or arrow keys to pan; scroll or +/- to zoom; double-click or Home to fit',
}
function fixture(width = 400, height = 200) {
const onClose = vi.fn()
const view = render(<PreviewLightbox {...props} onClose={onClose} />)
const image = view.getByRole('img') as HTMLImageElement
const surface = view.getByRole('dialog')
// jsdom has neither image decoding nor pointer capture; provide the browser-owned observations.
Object.defineProperty(image, 'naturalWidth', { configurable: true, value: width })
Object.defineProperty(image, 'naturalHeight', { configurable: true, value: height })
vi.spyOn(surface, 'getBoundingClientRect').mockReturnValue(new DOMRect(0, 0, 1000, 800))
const capture = new Set<number>()
const release = vi.fn((id: number) => { capture.delete(id) })
Object.defineProperties(image, {
setPointerCapture: { value: (id: number) => { capture.add(id) } },
hasPointerCapture: { value: (id: number) => capture.has(id) },
releasePointerCapture: { value: release },
})
fireEvent.load(image)
function pointer(type: string, x: number, y: number, id = 1, button = 0, primary = true) {
const event = new MouseEvent(type, { bubbles: true, cancelable: true, clientX: x, clientY: y, button })
Object.defineProperties(event, { pointerId: { value: id }, isPrimary: { value: primary } })
fireEvent(image, event)
}
return { ...view, image, surface, onClose, pointer, release, capture }
}
const fitted = 'translate(-50%, -50%) translate(0px, 0px) scale(1)'
describe('PreviewLightbox', () => {
it('fits the image with viewport margins, closes and restores keyboard focus', () => {
const opener = document.createElement('button')
document.body.appendChild(opener)
try {
opener.focus()
const view = fixture()
expect(Number.parseFloat(view.image.style.width)).toBeCloseTo(880)
expect(Number.parseFloat(view.image.style.height)).toBeCloseTo(440)
expect(view.image.style.transform).toBe(fitted)
expect(view.surface.getAttribute('aria-describedby')).toBe(view.getByText(props.interactionLabel).id)
const close = view.getByRole('button', { name: props.closeLabel })
expect(document.activeElement).toBe(close)
expect(fireEvent.keyDown(window, { key: 'Tab' })).toBe(false)
expect(fireEvent.keyDown(window, { key: 'a' })).toBe(true)
expect(view.onClose).not.toHaveBeenCalled()
fireEvent.keyDown(window, { key: 'Escape' })
fireEvent.click(close)
expect(view.onClose).toHaveBeenCalledTimes(2)
view.unmount()
expect(document.activeElement).toBe(opener)
} finally { opener.remove() }
})
it('fits tall images by height and waits for intrinsic dimensions before fitting', () => {
const tall = fixture(200, 800)
expect(Number.parseFloat(tall.image.style.width)).toBeCloseTo(168)
expect(Number.parseFloat(tall.image.style.height)).toBeCloseTo(672)
tall.unmount()
const pending = fixture(400, 0)
expect(pending.image.style.width).toBe('')
Object.defineProperty(pending.image, 'naturalHeight', { value: 200 })
fireEvent.load(pending.image)
expect(Number.parseFloat(pending.image.style.width)).toBeCloseTo(880)
})
it('anchors wheel zoom at the cursor, normalizes wheel units and bounds magnification', () => {
const view = fixture()
expect(fireEvent.wheel(view.surface, { deltaY: -Math.log(2) / 0.002, clientX: 600, clientY: 450 })).toBe(false)
expect(view.image.style.transform).toBe('translate(-50%, -50%) translate(-100px, -50px) scale(2)')
fireEvent.wheel(view.surface, { deltaY: 1, deltaMode: 1, clientX: 600, clientY: 450 })
expect(view.image.style.transform).toContain(`scale(${2 * Math.exp(-16 * 0.002)})`)
fireEvent.wheel(view.surface, { deltaY: 10, deltaMode: 2 })
expect(view.image.style.transform).toContain('scale(0.25)')
fireEvent.wheel(view.surface, { deltaY: -10000 })
expect(view.image.style.transform).toContain('scale(8)')
expect(view.image.getAttribute('src')).toBe(props.src)
fireEvent.doubleClick(view.image)
expect(view.image.style.transform).toBe(fitted)
})
it('captures a primary drag and ignores other pointers and inactive movement', () => {
const view = fixture()
view.pointer('pointerdown', 500, 400, 1, 2)
view.pointer('pointerdown', 500, 400, 2, 0, false)
view.pointer('pointermove', 540, 420)
expect(view.capture.size).toBe(0)
expect(view.image.style.transform).toBe(fitted)
view.pointer('pointerdown', 500, 400)
expect(view.capture.has(1)).toBe(true)
expect(view.image.hasAttribute('data-dragging')).toBe(true)
view.pointer('pointermove', 540, 420, 2)
view.pointer('pointerup', 540, 420, 2)
expect(view.image.style.transform).toBe(fitted)
view.pointer('pointermove', 580, 440)
expect(view.image.style.transform).toBe('translate(-50%, -50%) translate(80px, 40px) scale(1)')
view.pointer('pointerup', 580, 440)
expect(view.release).toHaveBeenCalledWith(1)
expect(view.image.hasAttribute('data-dragging')).toBe(false)
view.pointer('pointermove', 900, 900)
expect(view.image.style.transform).toContain('translate(80px, 40px)')
})
it.each(['pointercancel', 'lostpointercapture'])('ends a gesture on %s and releases capture on unmount', (type) => {
const view = fixture()
view.pointer('pointerdown', 500, 400)
view.capture.clear()
view.pointer(type, 500, 400)
view.pointer('pointermove', 700, 500)
expect(view.image.style.transform).toBe(fitted)
view.pointer('pointerdown', 500, 400)
view.unmount()
expect(view.release).toHaveBeenCalledWith(1)
view.pointer('pointermove', 700, 500)
expect(view.image.style.transform).toBe(fitted)
})
it('supports keyboard pan, zoom and reset, and refits on viewport or source changes', () => {
const view = fixture()
for (const key of ['ArrowRight', 'ArrowDown']) fireEvent.keyDown(window, { key })
expect(view.image.style.transform).toContain('translate(32px, 32px)')
for (const key of ['ArrowLeft', 'ArrowUp', '+', '-']) fireEvent.keyDown(window, { key })
expect(view.image.style.transform).toBe(fitted)
fireEvent.keyDown(window, { key: '=' })
expect(view.image.style.transform).toContain('scale(1.2)')
fireEvent.keyDown(window, { key: '0' })
expect(view.image.style.transform).toBe(fitted)
fireEvent.keyDown(window, { key: '+' })
fireEvent.keyDown(window, { key: 'Home' })
expect(view.image.style.transform).toBe(fitted)
fireEvent.keyDown(window, { key: '+' })
vi.stubGlobal('innerWidth', 600)
fireEvent.resize(window)
expect(Number.parseFloat(view.image.style.width)).toBeCloseTo(528)
expect(view.image.style.transform).toBe(fitted)
fireEvent.keyDown(window, { key: '+' })
view.rerender(<PreviewLightbox {...props} src={`${props.src}#new`} onClose={view.onClose} />)
expect(view.image.style.transform).toBe(fitted)
view.unmount()
fireEvent.keyDown(window, { key: '+' })
fireEvent.wheel(view.surface, { deltaY: -100 })
expect(view.image.style.transform).toBe(fitted)
})
it('tolerates a focus owner it cannot restore', () => {
Object.defineProperty(document, 'activeElement', { configurable: true, get: () => null })
try {
const view = render(<PreviewLightbox {...props} onClose={vi.fn()} />)
view.unmount()
} finally { delete (document as { activeElement?: unknown }).activeElement }
})
it('closes on a mask press but not on a press over the image', () => {
const view = fixture()
fireEvent.mouseDown(view.image)
expect(view.onClose).not.toHaveBeenCalled()
fireEvent.mouseDown(view.surface.querySelector('[aria-hidden="true"]')!)
expect(view.onClose).toHaveBeenCalledOnce()
})
})
@@ -1,477 +0,0 @@
// @vitest-environment jsdom
import { act, cleanup, fireEvent, render, screen, waitFor, within } 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 * as highlight from '../src/markdown/highlight.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', error: 'Unable to render this diagram.',
preview: 'Preview', source: 'Source', zoom: 'Enlarge preview', pending: 'Preparing preview…', close: 'Close preview', interaction: 'Drag to pan, scroll to zoom',
}
const preview = {
preview: labels.preview, source: labels.source, zoom: labels.zoom, pending: labels.pending,
close: labels.close, interaction: labels.interaction,
mermaid: labels, graphviz: labels, svg: labels,
}
const source = 'flowchart LR\n A[Input] --> B[Preview]'
async function loadPreviewImage() {
const image = await waitFor(() => {
const node = document.querySelector<HTMLImageElement>('[data-code-block-preview] img, img')
expect(node).not.toBeNull()
return node!
})
fireEvent.load(image)
return image
}
const imageUrl = 'data:image/svg+xml;charset=utf-8,%3Csvg%2F%3E'
afterEach(() => {
cleanup()
document.querySelector('[data-test-preview-actions]')?.remove()
vi.restoreAllMocks()
vi.unstubAllGlobals()
})
beforeEach(() => { vi.resetAllMocks() })
describe('SourcePreview', () => {
it('keeps a loaded preview and its lightbox while a theme replacement renders and loads', async () => {
const replacement = Promise.withResolvers<string>()
vi.mocked(renderMermaid).mockResolvedValueOnce(imageUrl).mockReturnValueOnce(replacement.promise)
const actions = document.createElement('div')
actions.dataset['testPreviewActions'] = ''
document.body.append(actions)
const previous = document.documentElement.style.colorScheme
const view = render(<SourcePreview code={source} render={renderMermaid} labels={labels} actions={actions} />)
try {
const image = await loadPreviewImage()
fireEvent.click(screen.getByRole('button', { name: labels.zoom }))
const dialog = screen.getByRole('dialog')
await act(async () => { document.documentElement.style.colorScheme = 'dark' })
expect(view.container.querySelector('img')).toBe(image)
expect(screen.getByRole('dialog')).toBe(dialog)
await act(async () => { replacement.resolve('data:image/svg+xml,new') })
expect(screen.queryByRole('status')).toBeNull()
expect(within(dialog).getByRole('img').getAttribute('src')).toBe(imageUrl)
expect(view.container.querySelector('[data-preview-placeholder]')).toBeNull()
fireEvent.load(view.container.querySelector('img[src="data:image/svg+xml,new"]')!)
expect(screen.getByRole('dialog')).toBe(dialog)
expect(within(dialog).getByRole('img').getAttribute('src')).toBe('data:image/svg+xml,new')
} finally {
view.unmount()
document.documentElement.style.colorScheme = previous
}
})
it.each(['render', 'load'] as const)('retains the usable image and announces a failed theme %s', async (failure) => {
vi.mocked(renderMermaid).mockResolvedValueOnce(imageUrl)
const previous = document.documentElement.style.colorScheme
const view = render(<SourcePreview code={source} render={renderMermaid} labels={labels} />)
try {
const image = await loadPreviewImage()
if (failure === 'render') vi.mocked(renderMermaid).mockRejectedValueOnce(new Error('layout failed'))
else vi.mocked(renderMermaid).mockResolvedValueOnce('data:image/svg+xml,broken')
await act(async () => { document.documentElement.style.colorScheme = 'dark' })
if (failure === 'load') fireEvent.error(view.container.querySelector('img[src="data:image/svg+xml,broken"]')!)
expect(screen.getByRole('status').textContent).toBe(labels.error)
expect(screen.getByRole('img')).toBe(image)
expect(view.container.querySelector('[data-preview-placeholder]')).toBeNull()
} finally {
view.unmount()
document.documentElement.style.colorScheme = previous
}
})
it('reports failure from a replacement renderer without restoring the old renderer image', async () => {
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const view = render(<SourcePreview code={source} render={renderMermaid} labels={labels} />)
await loadPreviewImage()
const replacement = vi.fn(async () => { throw new Error('unavailable') })
view.rerender(<SourcePreview code={source} render={replacement} labels={labels} />)
expect((await screen.findByText(labels.error)).getAttribute('role')).toBe('status')
expect(screen.queryByRole('img')).toBeNull()
})
it.each(['load', 'error'] as const)('ignores a queued image %s event after another event settled the candidate', async (event) => {
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const view = render(<SourcePreview code={source} render={renderMermaid} labels={labels} />)
const image = await waitFor(() => {
expect(view.container.querySelector('img')).not.toBeNull()
return view.container.querySelector('img')!
})
act(() => {
fireEvent.load(image)
fireEvent[event](image)
})
expect(screen.getByRole('img')).toBe(image)
expect(screen.queryByRole('status')).toBeNull()
})
it('retains a loaded image when a theme render returns the same URL', async () => {
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
const previous = document.documentElement.style.colorScheme
try {
const image = await loadPreviewImage()
Object.defineProperty(image, 'complete', { value: true })
Object.defineProperty(image, 'naturalWidth', { value: 120 })
await act(async () => { document.documentElement.style.colorScheme = 'dark' })
expect(renderMermaid).toHaveBeenCalledTimes(2)
expect(screen.queryByRole('status')).toBeNull()
expect(screen.getByRole('img')).toBe(image)
} finally {
view.unmount()
document.documentElement.style.colorScheme = previous
}
})
it('keeps the image placeholder until load and reports an image load failure', async () => {
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
await waitFor(() => { expect(view.container.querySelector('img')).not.toBeNull() })
expect(screen.getByRole('status').textContent).toBe(labels.pending)
expect(screen.queryByRole('img')).toBeNull()
fireEvent.error(view.container.querySelector('img')!)
expect(screen.getByRole('status').textContent).toBe(labels.error)
expect(view.container.querySelector('[data-preview-placeholder]')).toBeNull()
})
it('keeps one placeholder through appended and frozen fences without rendering or highlighting source', async () => {
const html = vi.spyOn(highlight, 'highlightToHtml')
const tokens = vi.spyOn(highlight.StreamingHighlightSession.prototype, 'update')
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const props = { labels: { ...markdownLabels, preview } }
const view = render(<MarkdownText {...props} text="```mermaid\n" streaming />)
const placeholder = view.container.querySelector('[data-preview-placeholder]')
expect(placeholder).not.toBeNull()
for (const text of [
'```mermaid\nflowchart LR\n',
`\`\`\`mermaid\n${source}`,
`\`\`\`mermaid\n${source}\n\`\`\`\n\nOne\n\nTwo\n\nThree`,
]) {
view.rerender(<MarkdownText {...props} text={text} streaming />)
expect(view.container.querySelector('[data-preview-placeholder]')).toBe(placeholder)
expect(view.container.querySelector('pre')).toBeNull()
expect(view.container.querySelector('.md-code-block button')).toBeNull()
}
expect(renderMermaid).not.toHaveBeenCalled()
expect(html).not.toHaveBeenCalled()
expect(tokens).not.toHaveBeenCalled()
view.rerender(<MarkdownText {...props} text={`\`\`\`mermaid\n${source}`} />)
await loadPreviewImage()
expect(renderMermaid).toHaveBeenCalledTimes(1)
expect(html).not.toHaveBeenCalled()
expect(tokens).not.toHaveBeenCalled()
})
it('shows source if the owner removes preview support while preview is selected', async () => {
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const props = { code: source, lang: 'mermaid', ...markdownLabels.code }
const view = render(<CodeBlock {...props} preview={{ render: renderMermaid, labels }} />)
await loadPreviewImage()
view.rerender(<CodeBlock {...props} />)
expect(view.container.querySelector('pre code')?.textContent).toBe(source)
expect(screen.queryByRole('img')).toBeNull()
})
it('mounts source on demand and retains it across view changes', async () => {
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const props = { code: source, lang: 'mermaid', ...markdownLabels.code, preview: { render: renderMermaid, labels } }
const view = render(<CodeBlock {...props} />)
const image = await loadPreviewImage()
const sourceView = view.container.querySelector<HTMLElement>('[data-code-block-source-view]')!
expect(view.container.querySelector('pre')).toBeNull()
expect(sourceView.getAttribute('aria-hidden')).toBe('true')
expect(view.container.querySelector('[data-code-block-preview]')?.hasAttribute('hidden')).toBe(false)
expect(screen.queryByRole('button', { name: 'Reset size' })).toBeNull()
fireEvent.click(screen.getByRole('button', { name: labels.source }))
expect(sourceView.hasAttribute('aria-hidden')).toBe(false)
const sourceElement = view.container.querySelector('pre')
expect(sourceElement).not.toBeNull()
expect(view.container.querySelector('[data-code-block-preview]')?.hasAttribute('hidden')).toBe(true)
expect(screen.queryByRole('img')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: labels.preview }))
expect(sourceView.getAttribute('aria-hidden')).toBe('true')
expect(screen.getByRole('img', { name: labels.diagram })).toBe(image)
expect(view.container.querySelector('pre')).toBe(sourceElement)
expect(renderMermaid).toHaveBeenCalledTimes(1)
view.rerender(<CodeBlock {...props} streaming />)
expect(view.container.querySelector('[data-code-block-source-view]')).toBeNull()
expect(screen.getByRole('status').textContent).toBe(labels.pending)
view.rerender(<CodeBlock {...props} preview={undefined} />)
expect(view.container.querySelector('pre code')?.textContent).toBe(source)
})
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 loadPreviewImage()
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('announces pending rendering and enables the persistent zoom action when the image is ready', async () => {
const pending = Promise.withResolvers<string>()
vi.mocked(renderMermaid).mockReturnValue(pending.promise)
const actions = document.createElement('div')
actions.dataset['testPreviewActions'] = ''
document.body.append(actions)
const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} actions={actions} />)
const zoom = screen.getByRole<HTMLButtonElement>('button', { name: labels.zoom })
expect(screen.getByRole('status').textContent).toBe(labels.pending)
expect(zoom.disabled).toBe(true)
expect(document.getElementById(zoom.getAttribute('aria-describedby')!)?.textContent).toBe(labels.pending)
fireEvent.click(zoom)
expect(screen.queryByRole('dialog')).toBeNull()
expect(screen.queryByRole('img')).toBeNull()
await act(async () => { pending.resolve(imageUrl) })
await loadPreviewImage()
expect(screen.getByRole('img', { name: labels.diagram }).getAttribute('src')).toBe(imageUrl)
expect(view.container.querySelector(`img[alt="${labels.diagram}"]`)).not.toBeNull()
expect(view.container.querySelector('[data-preview-placeholder]')).toBeNull()
expect(screen.queryByRole('status')).toBeNull()
expect(screen.getByRole('button', { name: labels.zoom })).toBe(zoom)
expect(zoom.disabled).toBe(false)
expect(zoom.hasAttribute('aria-describedby')).toBe(false)
})
it('opens the rendered image in the viewport dialog and restores focus on close', async () => {
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const actions = document.createElement('div')
actions.dataset['testPreviewActions'] = ''
document.body.append(actions)
render(<SourcePreview render={renderMermaid} code={source} labels={labels} actions={actions} />)
await loadPreviewImage()
const zoom = screen.getByRole('button', { name: labels.zoom })
zoom.focus()
fireEvent.click(zoom)
const dialog = screen.getByRole('dialog', { name: labels.diagram })
expect(within(dialog).getByRole('img').getAttribute('src')).toBe(imageUrl)
const close = within(dialog).getByRole('button', { name: labels.close })
expect(document.activeElement).toBe(close)
fireEvent.keyDown(window, { key: 'Escape' })
expect(screen.queryByRole('dialog', { name: labels.diagram })).toBeNull()
expect(document.activeElement).toBe(zoom)
})
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')).toBeNull()
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.pending)
expect(view.container.querySelector('pre')).toBeNull()
await act(async () => { next.resolve(imageUrl) })
await loadPreviewImage()
expect(screen.getByRole('img').getAttribute('src')).toBe(imageUrl)
})
it('waits for a replacement renderer when the source is unchanged', async () => {
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const view = render(<SourcePreview render={renderMermaid} code={source} labels={labels} />)
expect((await loadPreviewImage()).getAttribute('src')).toBe(imageUrl)
const pending = Promise.withResolvers<string>()
const replacement = vi.fn(() => pending.promise)
view.rerender(<SourcePreview render={replacement} code={source} labels={labels} />)
expect(screen.queryByRole('img')).toBeNull()
await act(async () => { pending.resolve('data:image/svg+xml;charset=utf-8,replacement') })
await loadPreviewImage()
expect(screen.getByRole('img').getAttribute('src')).toContain('replacement')
})
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 loadPreviewImage()
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 rendering', 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('defaults to preview, then retains numbered source and the same image across view changes', async () => {
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const view = render(<CodeBlock code={source} lang="mermaid" lineNumbers {...markdownLabels.code}
preview={{ render: renderMermaid, labels }} />)
expect(view.container.querySelector('[data-line-numbers]')).not.toBeNull()
const sourceView = view.container.querySelector<HTMLElement>('[data-code-block-source-view]')!
expect(view.container.querySelector('pre')).toBeNull()
expect(sourceView.getAttribute('aria-hidden')).toBe('true')
expect(screen.getByRole('button', { name: labels.preview }).getAttribute('aria-pressed')).toBe('true')
const diagram = await loadPreviewImage()
fireEvent.click(screen.getByRole('button', { name: labels.source }))
await waitFor(() => { expect(view.container.querySelector('pre.shiki')).not.toBeNull() })
const sourceElement = view.container.querySelector('pre')
expect([...view.container.querySelectorAll('code > .line')].map(line => line.textContent)).toEqual(source.split('\n'))
expect(screen.getByRole('button', { name: labels.source }).getAttribute('aria-pressed')).toBe('true')
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(sourceView.getAttribute('aria-hidden')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: labels.source }))
expect(view.container.querySelector('pre')).toBe(sourceElement)
expect(sourceView.hasAttribute('aria-hidden')).toBe(false)
expect(renderMermaid).toHaveBeenCalledTimes(1)
})
it('shows a streaming placeholder, then selects preview and copies source from an icon action', 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')).toBeNull()
expect(screen.getByRole('status').textContent).toBe(labels.pending)
expect(renderMermaid).not.toHaveBeenCalled()
expect(screen.queryByRole('button', { name: labels.source })).toBeNull()
expect(screen.queryByRole('button', { name: labels.zoom })).toBeNull()
view.rerender(<MarkdownText {...props} />)
expect(view.container.querySelector('pre')).toBeNull()
expect(view.container.querySelector('[data-code-block-source-view]')?.getAttribute('aria-hidden')).toBe('true')
const diagram = await loadPreviewImage()
expect(screen.getByText('mermaid', { exact: true })).toBeDefined()
expect(screen.getByRole('button', { name: labels.source }).textContent).toBe(labels.source)
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.getByText('mermaid', { exact: true })).toBeDefined()
fireEvent.click(screen.getByRole('button', { name: labels.preview }))
expect(screen.getByRole('img', { name: labels.diagram })).toBe(diagram)
expect(screen.queryByRole('status')).toBeNull()
expect(renderMermaid).toHaveBeenCalledTimes(1)
})
it('returns to the placeholder when a preview starts streaming again', async () => {
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const props = { code: source, lang: 'mermaid', ...markdownLabels.code, preview: { render: renderMermaid, labels } }
const view = render(<CodeBlock {...props} />)
await loadPreviewImage()
view.rerender(<CodeBlock {...props} code={`${source}\n B --> C`} streaming />)
expect(view.container.querySelector('pre')).toBeNull()
expect(screen.getByRole('status').textContent).toBe(labels.pending)
expect(renderMermaid).toHaveBeenCalledTimes(1)
view.rerender(<CodeBlock {...props} code={`${source}\n B --> C`} />)
await loadPreviewImage()
expect(renderMermaid).toHaveBeenCalledTimes(2)
})
it('replaces source zoom with a persistent line-number choice without regenerating the preview', async () => {
vi.mocked(renderMermaid).mockResolvedValue(imageUrl)
const view = render(<CodeBlock code={source} lang="mermaid" {...markdownLabels.code}
preview={{ render: renderMermaid, labels }} />)
const diagram = await loadPreviewImage()
fireEvent.click(screen.getByRole('button', { name: labels.source }))
const block = view.container.querySelector<HTMLElement>('.md-code-block')!
expect(screen.queryByRole('button', { name: labels.zoom })).toBeNull()
const numbers = screen.getByRole('button', { name: markdownLabels.code.lineNumbersLabel })
const firstLine = view.container.querySelector('pre .line')
expect(numbers.getAttribute('aria-pressed')).toBe('false')
fireEvent.click(numbers)
expect(numbers.getAttribute('aria-pressed')).toBe('true')
expect(block.dataset['lineNumbers']).toBe('true')
expect(view.container.querySelector('pre .line')).toBe(firstLine)
fireEvent.click(screen.getByRole('button', { name: labels.preview }))
expect(screen.queryByRole('button', { name: markdownLabels.code.lineNumbersLabel })).toBeNull()
const zoom = screen.getByRole<HTMLButtonElement>('button', { name: labels.zoom })
expect(zoom.disabled).toBe(false)
zoom.focus()
fireEvent.click(zoom)
const dialog = screen.getByRole('dialog', { name: labels.diagram })
expect(within(dialog).getByRole('img').getAttribute('src')).toBe(diagram.getAttribute('src'))
fireEvent.click(within(dialog).getByRole('button', { name: labels.close }))
expect(screen.queryByRole('dialog')).toBeNull()
expect(document.activeElement).toBe(zoom)
fireEvent.click(screen.getByRole('button', { name: labels.source }))
expect(screen.getByRole('button', { name: markdownLabels.code.lineNumbersLabel }).getAttribute('aria-pressed')).toBe('true')
fireEvent.click(screen.getByRole('button', { name: markdownLabels.code.lineNumbersLabel }))
expect(block.hasAttribute('data-line-numbers')).toBe(false)
expect(renderMermaid).toHaveBeenCalledTimes(1)
})
it('keeps pending work through a source visit and retains a failure without retrying on toggles', async () => {
const pending = Promise.withResolvers<string>()
vi.mocked(renderMermaid).mockReturnValue(pending.promise)
const view = render(<CodeBlock code={source} lang="mermaid" {...markdownLabels.code}
preview={{ render: renderMermaid, labels }} />)
const signal = vi.mocked(renderMermaid).mock.calls[0]![1]
const zoom = screen.getByRole<HTMLButtonElement>('button', { name: labels.zoom })
fireEvent.click(screen.getByRole('button', { name: labels.source }))
expect(view.container.querySelector('pre code')?.textContent).toBe(source)
expect(signal.aborted).toBe(false)
expect(screen.queryByRole('button', { name: labels.zoom })).toBeNull()
expect(zoom.disabled).toBe(true)
expect(document.getElementById(zoom.getAttribute('aria-describedby')!)?.textContent).toBe(labels.pending)
await act(async () => { pending.reject(new Error('Parse error')) })
expect(zoom.disabled).toBe(true)
expect(document.getElementById(zoom.getAttribute('aria-describedby')!)?.textContent).toBe(labels.error)
fireEvent.click(screen.getByRole('button', { name: labels.preview }))
expect(screen.getByRole('status').textContent).toBe(labels.error)
expect(view.container.querySelector('[data-code-block-preview] pre')).toBeNull()
fireEvent.click(screen.getByRole('button', { name: labels.source }))
fireEvent.click(screen.getByRole('button', { name: labels.preview }))
expect(renderMermaid).toHaveBeenCalledTimes(1)
expect(screen.getByRole('status').textContent).toBe(labels.error)
})
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()
})
})
@@ -28,8 +28,6 @@ export function CodeBody({ resourceAddress, content, wrap, scrollportRef, t }: C
lineNumbers
copyLabel={t('copy')}
copiedLabel={t('copied')}
sourceLabel={t('markdown.code.source')}
lineNumbersLabel={t('markdown.code.lineNumbers')}
/>
</div>
)
@@ -18,12 +18,10 @@ export type MarkdownBodyProps = DocumentPreviewProps & PropsLocale<'documentMark
export function MarkdownBody({ content, t }: MarkdownBodyProps): ReactNode {
const copyLabel = t('code.copy')
const copiedLabel = t('code.copied')
const sourceLabel = t('markdown.code.source')
const lineNumbersLabel = t('markdown.code.lineNumbers')
const footnotes = t('footnotes')
const labels = useMemo<MarkdownLabels>(() => ({
code: { copyLabel, copiedLabel, sourceLabel, lineNumbersLabel }, footnotes,
}), [copyLabel, copiedLabel, sourceLabel, lineNumbersLabel, footnotes])
code: { copyLabel, copiedLabel }, footnotes,
}), [copyLabel, copiedLabel, footnotes])
if (content.kind !== 'text') return null
return (
<div className={css.document} data-document-markdown>
@@ -293,7 +293,7 @@ export function ToolRow({
<>
{variant === 'code' && bodyText !== null && (
<div className={css.bodyScroll}>
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} sourceLabel={t('markdown.code.source')} lineNumbersLabel={t('markdown.code.lineNumbers')} className={css.codeBody} />
<CodeBlock code={bodyText} lang="typescript" copyLabel={t('copy')} copiedLabel={t('copied')} className={css.codeBody} />
</div>
)}
{(cardBody !== null || outputText !== null) && (
@@ -18,7 +18,7 @@ type T = TranslateNS<'conversation'>
*/
export function markdownLabels(t: T): MarkdownLabels {
return {
code: { copyLabel: t('copy'), copiedLabel: t('copied'), sourceLabel: t('markdown.code.source'), lineNumbersLabel: t('markdown.code.lineNumbers') },
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
footnotes: t('markdown.footnotes'),
}
}
@@ -248,7 +248,7 @@ function jsonTreeLabels(t: TrajectoryTranslate): JsonTreeLabels {
function markdownLabels(t: TrajectoryTranslate): MarkdownLabels {
return {
code: { copyLabel: t('copy'), copiedLabel: t('copied'), sourceLabel: t('markdown.code.source'), lineNumbersLabel: t('markdown.code.lineNumbers') },
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
footnotes: t('markdown.footnotes'),
}
}
@@ -1908,8 +1908,7 @@ function ProgramInput({ program, initialWrapped, stringWrapping, onOpen, t }: {
? <JsonTree data={program.arguments} label={t('record.parametersJson')}
labels={jsonTreeLabels(t)} stringWrapping={stringWrapping} />
: <CodeBlock code={program.source} lang={program.language} lineNumbers showHeader={false}
className={css.programSource} copyLabel={t('code.copySource')} copiedLabel={t('copied')}
sourceLabel={t('markdown.code.source')} lineNumbersLabel={t('markdown.code.lineNumbers')} />}
className={css.programSource} copyLabel={t('code.copySource')} copiedLabel={t('copied')} />}
</div>
)
return onOpen === undefined
@@ -1,4 +1,3 @@
import { createMarkdownLabels } from './markdown-labels.ts'
import { useMemo, useState } from 'react'
import { Button, IconEditOutline16, MarkdownText } from '@deepseek-ai/dsh-client-ui-primitives'
import type { PendingQuestion, PlanReview, QuestionComposerProps } from './contract/slots.ts'
@@ -26,7 +25,10 @@ function tooltip(description: string | undefined): { title?: string } {
* @returns The plan-review takeover for this request.
*/
export function PlanReviewPanel({ pending, review, t }: PlanReviewPanelProps) {
const markdownLabels = useMemo(() => createMarkdownLabels(t), [t])
const markdownLabels = useMemo(() => ({
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
footnotes: t('markdown.footnotes'),
}), [t])
// The panel waits for the host's resolved frame before leaving, so repeated
// clicks must not resubmit. A failed send re-enables it and shows the error.
const [busy, setBusy] = useState(false)
@@ -1,4 +1,3 @@
import { createMarkdownLabels } from './markdown-labels.ts'
import { useMemo, useRef, useState, type ChangeEvent, type KeyboardEvent } from 'react'
import clsx from 'clsx'
import {
@@ -132,7 +131,10 @@ type QuestionFlowProps =
function QuestionFlow({ pending, t, useStore, actions }: QuestionFlowProps) {
const questions = pending.questions
const markdownLabels = useMemo(() => createMarkdownLabels(t), [t])
const markdownLabels = useMemo(() => ({
code: { copyLabel: t('copy'), copiedLabel: t('copied') },
footnotes: t('markdown.footnotes'),
}), [t])
const initialProgress = useMemo<QuestionDraftProgress>(() => ({
index: 0,
drafts: questions.map(() => ({ selected: [], custom: '', skipped: false })),
@@ -1,18 +0,0 @@
/** Localized Markdown controls shared by question and plan-review bodies. */
import type { MarkdownLabels } from '@deepseek-ai/dsh-client-ui-primitives'
import type { QuestionComposerProps } from './contract/slots.ts'
/**
* Localize source controls and footnotes for the question domain.
* @param t - The question composer's locale translator.
* @returns Labels accepted by the shared Markdown renderer.
*/
export function createMarkdownLabels(t: QuestionComposerProps['t']): MarkdownLabels {
return {
code: {
copyLabel: t('copy'), copiedLabel: t('copied'), sourceLabel: t('markdown.code.source'),
lineNumbersLabel: t('markdown.code.lineNumbers'),
},
footnotes: t('markdown.footnotes'),
}
}
@@ -146,8 +146,6 @@ export function CordisDefineRow({
lang="javascript"
copyLabel={t('body.copy')}
copiedLabel={t('body.copied')}
sourceLabel={t('markdown.code.source')}
lineNumbersLabel={t('markdown.code.lineNumbers')}
className={css.sourceCode}
/>
</div>
-11
View File
@@ -3758,9 +3758,6 @@ 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
@@ -3782,9 +3779,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
@@ -15927,9 +15921,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]
@@ -22779,8 +22770,6 @@ snapshots:
convert-source-map: 2.0.0
tinyrainbow: 3.1.0
'@viz-js/[email protected]': {}
'@vscode/[email protected]':
optional: true
@@ -206,16 +206,3 @@ it('requires Office skill bodies and helpers in the published payload', () => {
...manifest, files: ['lib/index.js', 'lib/types/**/*.d.ts'],
} })).toEqual([expect.stringContaining('package.json files must be')])
})
it.each(['lib/*-*.js', 'THIRD_PARTY_PREVIEW_NOTICES.txt'])('requires %s in the published UI primitives payload', (required) => {
const name = '@deepseek-ai/dsh-client-ui-primitives'
const files = expectedDshPackageFiles({ name })
expect(files).toContain(required)
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 !== required))).toEqual([
expect.stringContaining('package.json files must be'),
])
})
+1 -2
View File
@@ -155,8 +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.
// Lazy grammar and diagram imports resolve hashed runtime chunks beside index.js.
'@deepseek-ai/dsh-client-ui-primitives': ['lib/*-*.js', '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-ui-sidebar-documentpreview': ['lib/client.*.js'],
'@deepseek-ai/dsh-client-ui-sidebar-terminal': ['lib/client.*.js'],
+1 -66
View File
@@ -1,11 +1,10 @@
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, symlinkSync, unlinkSync, writeFileSync } from 'node:fs'
import { mkdirSync, mkdtempSync, readdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
import { join, resolve } from 'node:path'
import { tmpdir } from 'node:os'
import { describe, expect, it } from 'vitest'
import desktopRuntimeLock from '../apps/desktop/scripts/primary-runtime-lock.json' with { type: 'json' }
import {
CLAUDE_AGENT_SDK_PACKAGE,
assertPreviewDistribution,
assertRuntimeLicenses,
claudeDistributionFromManifest,
collectPythonDependencies,
@@ -14,7 +13,6 @@ import {
isPermissive,
type Manifest,
manifestPatterns,
mermaidDomPurifyVersion,
parsePyprojectRequirements,
parseVendoredRows,
render,
@@ -469,66 +467,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('checks Mermaid\'s selected DOMPurify when other versions remain installed', () => {
const fixture = mkdtempSync(join(tmpdir(), 'dsh-notices-mermaid-'))
try {
const modules = join(fixture, 'node_modules')
const store = join(modules, '.pnpm')
const mermaidModules = join(store, '[email protected]', 'node_modules')
const mermaid = join(mermaidModules, 'mermaid')
mkdirSync(mermaid, { recursive: true })
for (const version of ['3.4.11', '3.4.12']) {
const dir = join(store, `dompurify@${version}`, 'node_modules', 'dompurify')
mkdirSync(dir, { recursive: true })
writeFileSync(join(dir, 'package.json'), JSON.stringify({ name: 'dompurify', version }))
}
const reviewed = join(store, '[email protected]', 'node_modules', 'dompurify')
const upgraded = join(store, '[email protected]', 'node_modules', 'dompurify')
const linkedMermaid = join(modules, 'mermaid')
const dependency = join(mermaidModules, 'dompurify')
symlinkSync(mermaid, linkedMermaid, 'junction')
symlinkSync(reviewed, join(modules, 'dompurify'), 'junction')
symlinkSync(upgraded, dependency, 'junction')
const version = mermaidDomPurifyVersion(linkedMermaid)
expect(version).toBe('3.4.12')
expect(() => { assertPreviewDistribution({ ...dependencies, dompurify: version }, sources) })
.toThrow('review dompurify 3.4.12')
unlinkSync(dependency)
symlinkSync(reviewed, dependency, 'junction')
expect(() => {
assertPreviewDistribution({ ...dependencies, dompurify: mermaidDomPurifyVersion(linkedMermaid) }, sources)
}).not.toThrow()
writeFileSync(join(reviewed, 'package.json'), JSON.stringify({ name: 'dompurify' }))
expect(() => mermaidDomPurifyVersion(linkedMermaid)).toThrow('missing Mermaid DOMPurify dependency')
unlinkSync(dependency)
expect(() => mermaidDomPurifyVersion(linkedMermaid)).toThrow('ENOENT')
} finally {
rmSync(fixture, { recursive: true, force: true })
}
})
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('===== DOMPurify =====')
})
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')
})
})
+1 -50
View File
@@ -8,7 +8,7 @@
* `.agents/notes/implemented/process/2026-07-30-generated-third-party-notices.md`.
*/
import { existsSync, globSync, readdirSync, readFileSync, realpathSync, writeFileSync } from 'node:fs'
import { existsSync, globSync, readdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname, resolve } from 'node:path'
import * as yaml from 'js-yaml'
import { parse as parseToml, type TomlTableWithoutBigInt, type TomlValueWithoutBigInt } from 'smol-toml'
@@ -737,40 +737,6 @@ ${rows.join('\n')}
`
}
/**
* Read the DOMPurify version selected by Mermaid's pnpm dependency link.
* @param mermaidDirectory - Installed Mermaid directory, including workspace symlinks.
* @returns The installed version; throws when the dependency or its version is missing.
*/
export function mermaidDomPurifyVersion(mermaidDirectory: string): string {
const path = resolve(realpathSync(mermaidDirectory), '..', 'dompurify/package.json')
const manifest = JSON.parse(readFileSync(path, 'utf8')) as Manifest
if (manifest.version === undefined) throw new Error('preview notices: missing Mermaid DOMPurify dependency')
return manifest.version
}
/**
* 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 attestation.
* @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.
@@ -781,19 +747,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'
// Viz.js fixes the upstream SLSA statement filename; the repository uses concrete local names.
const statementPath = resolve(root, previewPackage, 'node_modules/@viz-js/viz/lib', 'prove' + 'nance.json')
const buildStatement = JSON.parse(readFileSync(statementPath, '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')
const dompurify = mermaidDomPurifyVersion(resolve(root, previewPackage, 'node_modules/mermaid'))
assertPreviewDistribution(
{ ...previewDependencies, dompurify },
buildStatement.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)
@@ -837,8 +790,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 its DOMPurify dependency'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')}
@@ -85,13 +85,9 @@
- text: — 随机选中的包:
- code: packages/context/session-reference
- text: 📦
- button "Line numbers"
- button "Copy"
- text: Source
- code: session-reference ├── README.md ├── package.json ├── src │ ├── index.ts │ └── uri.ts └── tests └── session-reference.spec.ts
- button "Line numbers"
- button "Copy"
- text: Source
- code: "session-reference/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ ├── index.ts # 入口,导出公共 API │ └── uri.ts # 会话引用 URI 相关实现 └── tests/ └── session-reference.spec.ts # 单元测试"
- paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。
- button "Copy":
@@ -146,13 +142,9 @@
- text: — 随机选中的包:
- code: packages/llm/token-meter
- text: 📦
- button "Line numbers"
- button "Copy"
- text: Source
- code: token-meter ├── README.md ├── package.json ├── src │ └── index.ts └── tests └── token-meter.spec.ts
- button "Line numbers"
- button "Copy"
- text: Source
- code: "token-meter/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ └── index.ts # 入口,导出公共 API(token 计量逻辑) └── tests/ └── token-meter.spec.ts # 单元测试"
- paragraph:
- text: 两个 turn 都完成了(turn 1:
@@ -182,18 +174,14 @@
- text: — 随机选中
- code: packages/context/session-reference
- text: ,输出了文件结构:
- button "Line numbers"
- button "Copy"
- text: Source
- code: session-reference/ ├── README.md ├── package.json ├── src/ │ ├── index.ts │ └── uri.ts └── tests/ └── session-reference.spec.ts
- listitem:
- strong: Turn 2
- text: — 随机选中
- code: packages/llm/token-meter
- text: ,输出了文件结构:
- button "Line numbers"
- button "Copy"
- text: Source
- code: token-meter/ ├── README.md ├── package.json ├── src/ │ └── index.ts └── tests/ └── token-meter.spec.ts
- paragraph:
- strong: 验证方式:
@@ -30,13 +30,9 @@
- text: — 随机选中的包:
- code: packages/context/session-reference
- text: 📦
- button "Line numbers"
- button "Copy"
- text: Source
- code: session-reference ├── README.md ├── package.json ├── src │ ├── index.ts │ └── uri.ts └── tests └── session-reference.spec.ts
- button "Line numbers"
- button "Copy"
- text: Source
- code: "session-reference/ ├── README.md # 包说明文档 ├── package.json # 包元数据与依赖声明 ├── src/ │ ├── index.ts # 入口,导出公共 API │ └── uri.ts # 会话引用 URI 相关实现 └── tests/ └── session-reference.spec.ts # 单元测试"
- paragraph: 这是一个很典型的轻量 TypeScript 包结构:入口 + 实现 + 测试。这一轮到此结束,等系统开启下一个 turn。
- button "Copy":
@@ -67,18 +63,14 @@
- text: — 随机选中
- code: packages/context/session-reference
- text: ,输出了文件结构:
- button "Line numbers"
- button "Copy"
- text: Source
- code: session-reference/ ├── README.md ├── package.json ├── src/ │ ├── index.ts │ └── uri.ts └── tests/ └── session-reference.spec.ts
- listitem:
- strong: Turn 2
- text: — 随机选中
- code: packages/llm/token-meter
- text: ,输出了文件结构:
- button "Line numbers"
- button "Copy"
- text: Source
- code: token-meter/ ├── README.md ├── package.json ├── src/ │ └── index.ts └── tests/ └── token-meter.spec.ts
- paragraph:
- strong: 验证方式:
-2
View File
@@ -89,7 +89,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",
@@ -126,7 +125,6 @@
"apps/web/tests/chat-continuous-conversation.e2e.ts",
"apps/web/tests/composer-tab-geometry.e2e.ts",
"apps/web/tests/complex-history.perf.ts",
"apps/web/tests/diagram-preview.perf.ts",
"apps/web/tests/pwsh-terminal.e2e.ts",
"apps/web/tests/workflow-run.e2e.ts",
"apps/web/stress-tests/reasoning-chunks.stress.ts",