diff --git a/docs/webui.md b/docs/webui.md index ef2109586..d63e1671b 100644 --- a/docs/webui.md +++ b/docs/webui.md @@ -264,6 +264,28 @@ plain `
` view that shipped in slice 02:
   for `cp file.js file.js.bak; copy in panel; paste back`) and never
   leaks the gutter line numbers into the copied text.
 
+## Markdown rendering and Mermaid diagrams (slice 23)
+
+Assistant messages and Markdown file previews render through
+`webapp/lib/markdown.ts` (`marked`, already a workspace dependency — no
+CDN). A fenced code block whose language token is `mermaid` renders as a
+diagram instead of a code block. The behaviour is a contract, not an
+implementation accident:
+
+| Aspect | Contract | Backed by |
+| --- | --- | --- |
+| Fence language | The bare token after the fence opener must be `mermaid`, case-insensitive; trailing metadata (```` ```mermaid {theme: dark} ````) still matches | `webapp/lib/markdown.ts:133-136` |
+| Renderer seam | Fence languages dispatch through a language→renderer registry; the markdown main flow never branches on a language name. Any other fence language can be taken over the same way — one `registerLanguageRenderer(...)` call. That is the seam a future `minimax-code-plugin` renderer will install through | `webapp/lib/markdown.ts:54-113`, `webapp/lib/mermaid-renderer.ts:64-71` |
+| Theme | Diagrams re-render when the app switches light/dark: the host watches ``'s class and mermaid is re-initialised per theme | `components/markdown-html.tsx:52-66`, `components/mermaid-block.tsx:156-166` + `223-229` |
+| Failure state | A syntax error does not blank the page. The failing diagram shows its error text plus the **original source in a copyable `
`** — the copy round-trips byte-exact, including `-->|label|` edge syntax — and the rest of the document renders normally. (If the chart library itself fails to load — offline, say — the same failure card appears with the source still copyable. If a language renderer throws, the fence falls back to the plain code block — same "never blank the document" rule at the parser level.) | `components/mermaid-block.tsx:285-307`, `components/markdown-html.tsx:183-230`, `webapp/lib/markdown.ts:149-165` |
+| Sizing | Diagrams scale to the column width; a diagram wider than its card scrolls inside the card | `webapp/styles/mermaid.css:62-80` |
+| CJK labels | Node and edge labels render through a font stack with PingFang SC / Microsoft YaHei / Noto Sans CJK SC fallbacks, so Chinese text does not come out as tofu | `components/mermaid-block.tsx:109` |
+| Loading | The chart library is several megabytes and is `import()`-ed when the **first** diagram of a page mounts; the chunk ships with a one-year immutable cache, so later page loads fetch it from the browser cache. A page with no mermaid fence never requests the chunk | `components/mermaid-block.tsx:54-66`, `server/lib/static.js:64-66` |
+| Outline | A diagram is never a heading: the fence emits a `
`/`
` placeholder pair, not `h1`–`h6`, so diagrams appear in no heading-derived outline (the webui itself renders no Markdown outline today) | `webapp/lib/mermaid-renderer.ts:44-57` | + +The `mermaid` dependency (11.12.1, MIT) is recorded in +`release/dependency-licenses.json`. + ## Persistence keys (client-side `localStorage` / `sessionStorage`) | Key | Channel | Owner | Introduced by | Shape | diff --git a/docs/webui.zh-CN.md b/docs/webui.zh-CN.md index 85ef62cc4..e43d65d55 100644 --- a/docs/webui.zh-CN.md +++ b/docs/webui.zh-CN.md @@ -252,6 +252,57 @@ slice 22 增强: 文件字节互为往返 ——`cp file.js file.js.bak; 在面板里复制; 粘回去`), 且绝不让行号槽混入复制文本。 +## Markdown 里的 Mermaid 图(slice 23) + +助手回复和 Markdown 文件预览共用一套渲染管线 +(`webapp/lib/markdown.ts`,`marked` 是工作区既有依赖,不走 CDN)。在 +代码围栏的语言位置写 `mermaid`,围栏内容就会被画成图,而不是显示为 +代码块: + +````markdown +```mermaid +flowchart LR + 需求 --> 开发 --> 验收 +``` +```` + +**你会得到什么** + +- 只要围栏语言是 `mermaid` 就出图,大小写不敏感;围栏后跟的 + `{...}` 参数不影响识别(`webapp/lib/markdown.ts:133-136`)。 +- 中文标签正常显示:节点和边上的文字走 PingFang SC / Microsoft + YaHei / Noto Sans CJK SC 字体栈,不会画成方块 + (`components/mermaid-block.tsx:109`)。 +- 图跟随界面浅色/深色主题,切换主题时已渲染的图会重新画 + (`components/markdown-html.tsx:52-66`)。 +- 图按列宽缩放;特别宽的图在卡片内横向滚动,不撑破版面 + (`webapp/styles/mermaid.css:62-80`)。 + +**图坏了会怎样** + +- **语法写错**:出错的那张图显示"Mermaid 渲染失败"卡片——错误原因 + 加原始源码。源码可以原样选中复制,复制回来的内容和当初写的逐字节 + 一致,包括 `-->|标签|` 这类箭头语法 + (`components/mermaid-block.tsx:285-307`、 + `components/markdown-html.tsx:183-230`)。文档其余部分照常渲染, + 一张图坏了不会让整篇白屏。 +- **图表库加载失败**(如断网):同样落入失败卡片,源码仍可复制, + 其余内容不受影响。 + +**限制** + +- **首次遇到图需要加载**:图表库有几 MB,页面里第一张图出现时才从 + 服务端加载(没有任何 mermaid 图的页面完全不请求它, + `components/mermaid-block.tsx:54-66`);**图表库文件**带一年期 immutable + 强缓存(`server/lib/static.js:64-66`),之后的页面加载直接用浏览 + 器缓存,不重复下载。 +- **不进目录大纲**:图不产生标题。围栏渲染为 `
`/`
` 占位 + 元素而不是 `h1`-`h6`(`webapp/lib/mermaid-renderer.ts:44-57`), + 因此图永远不会出现在按标题组织的大纲或导航里(webui 目前的 + Markdown 渲染本身也不生成大纲)。 + +依赖:`mermaid` 11.12.1(MIT),已登记于 `release/dependency-licenses.json`。 + ## 持久化键(客户端 `localStorage` / `sessionStorage`) | 键 | 通道 | 归属 | 引入 ticket | 数据形态 | diff --git a/packages/webui/docs/CAPABILITIES.md b/packages/webui/docs/CAPABILITIES.md index 01bde314a..f12815ee0 100644 --- a/packages/webui/docs/CAPABILITIES.md +++ b/packages/webui/docs/CAPABILITIES.md @@ -60,6 +60,7 @@ single index that satisfies the check. | Long chat list virtualization (≥ 200 messages) | ✅ | `webapp/lib/transcript.ts` virtual-window branch (N ≥ 200) with scroll/resize rAF handler; covered by `webapp/test/transcript.test.ts`. | | Markdown rendering (headings, lists, code) | ✅ | `webapp/lib/markdown.ts` wrapping the workspace `marked` package (`packages/tui` already depends on it; no CDN) | | Syntax highlighting in code blocks | ✅ | `marked` code renderer (`webapp/lib/markdown.ts`) + CSS classes from `webapp/styles/official-utilities.css` | +| Mermaid diagrams in markdown (slice 23) | ✅ | a fenced code block whose language token is `mermaid` renders as a diagram instead of a code block. Fence languages dispatch through the language→renderer registry (`webapp/lib/markdown.ts#registerLanguageRenderer`, lines 107-113); mermaid self-registers on import (`webapp/lib/mermaid-renderer.ts:64-71`), and a renderer for any other fence language plugs into the same seam — the markdown main flow never branches on a language name. The chart library is `import()`-ed on the first diagram of a page (`webapp/components/mermaid-block.tsx:62-66`) and its chunk is served with a one-year immutable cache (`server/lib/static.js:66`); a page without a mermaid fence never requests it. Diagrams follow the light/dark theme (`components/markdown-html.tsx:52-66` re-renders on the `` class flip; `components/mermaid-block.tsx:156-166,223-229` re-initialises mermaid per theme). A syntax error renders a legible failure card — error text plus the original source in a copyable `
` (`components/mermaid-block.tsx:285-307`) — while the rest of the document renders normally. Limits: diagrams scale to the column width and scroll when wider (`webapp/styles/mermaid.css:62-80`); CJK labels render via the font stack at `components/mermaid-block.tsx:109`; a diagram is never a heading, so it appears in no heading-derived outline (the fence emits a `
`/`
` pair, not `h1`-`h6` — `lib/mermaid-renderer.ts:44-57`). Dependency `mermaid` 11.12.1 (MIT) is recorded in `release/dependency-licenses.json`. | | Cancel mid-run | ✅ | acp `session/cancel` is sent as a notification, pinned on the cid's active child (`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`). The hard-kill fallback (`/api/stop` → SIGTERM/SIGKILL) is only used when the notification cannot be delivered. The acp session may emit a few extra events before draining. | | Rewind / fork a message | ⚠ | The engine implements `session/fork` and `session/resume` (`MCODE_ACP_CAPABILITIES.fork / .resume = true`), but no webui route exposes them yet — see [§13](CAPABILITIES.md#13-what-mcode-would-need-to-add-to-enable-the--rows). | | Edit a sent message and resend | ❌ | Not exposed by the acp protocol | @@ -181,7 +182,7 @@ single index that satisfies the check. | Custom CSS themes | ❌ | no theme loader; would need a CSS-vars system | | User-defined hotkeys | ❌ | shortcuts are hard-coded | | Four-column shell (sidebar · conversation · preview · tree) | ✅ | slice 17 + slice 21 (`webapp/components/workspace-columns.tsx`). Conversation column is fluid in `[280, 768]` px while at least one fixed column is visible; with **both** on-demand columns folded the conversation column lifts past 768 and takes the full remainder — measured at 1280 / 240-px chrome = 1040 px, at 1920 = 1680 px (`webapp/test/workspace-tabs-state.test.ts#computeColumnLayout — slice 21 idle state`). The preview and tree columns are **on demand** (slice 21): each appears when at least one matching-role tab is open and auto-closes when the last tab in that role closes. `syncColumnVisibility(tabStrip, layout)` re-derives the visibility flags on every change; the deserializer normalises a stale "column open but empty" payload to closed so a stale disk write cannot conjure an empty column. Surface kinds are split by `columnRoleForKind` — `file: \| browser` lives on the preview column, `files \| git \| tasks \| search \| plugins` on the tree column. The previously-shipped `search`, `alerts`, and `progress` `PanelKind` values were removed from the union (`webapp/lib/persist.ts#PanelKind`). The sidebar tree column's "搜索" surface is **real as of slice 19b** (`webapp/components/workspace-tree-column.tsx#SearchSurface`); the engine contract for plugins is not yet shipped, so the Plugins surface still renders an i18n "this is coming" card rather than a silent no-op. | -| IDE-grade code preview (slice 22 — line gutter, per-language lazy syntax highlighting, byte-faithful copy) | ✅ | `webapp/components/code-view.tsx` is mounted inside `file-preview`. (1) **Line-number gutter** via `splitHighlightedLines` (`webapp/lib/code-highlight.ts`) — line numbers are aligned to code lines and independent of horizontal scroll; cross-line `` from highlight.js is balanced per-line so each row is hover-stable and copy-faithful. (2) **Per-language lazy highlighting** via `loadHljsLanguage` — only the open file's grammar is imported. The switch / if-ladder of literal `import("highlight.js/lib/languages/.js")` branches is what lets webpack code-split each grammar into its own chunk (a Record-driven dynamic import would have bundled all 191 grammars). Bounded work: 32 KiB / 1500 lines; larger files are truncated before highlight and the UI renders an honest `truncated` notice. Unknown languages fall through to a plain monospace view — the contract is total. (3) **Byte-faithful copy** — `endsWithNewline` is tracked across the highlight → split → copy chain so the clipboard text round-trips to the file bytes (`cp file.js file.js.bak; copy in panel; paste back`); line numbers never leak into the copied text. The server labels the file via `EXT_LANGUAGE` (`server/lib/fs-util.js#languageForExtension`); the view does not re-guess. Mermaid rendering in markdown (a candidate slice 23) is **not** implemented in `webapp/lib/markdown.ts` and is not documented as a capability — it is being built and has not landed. | +| IDE-grade code preview (slice 22 — line gutter, per-language lazy syntax highlighting, byte-faithful copy) | ✅ | `webapp/components/code-view.tsx` is mounted inside `file-preview`. (1) **Line-number gutter** via `splitHighlightedLines` (`webapp/lib/code-highlight.ts`) — line numbers are aligned to code lines and independent of horizontal scroll; cross-line `` from highlight.js is balanced per-line so each row is hover-stable and copy-faithful. (2) **Per-language lazy highlighting** via `loadHljsLanguage` — only the open file's grammar is imported. The switch / if-ladder of literal `import("highlight.js/lib/languages/.js")` branches is what lets webpack code-split each grammar into its own chunk (a Record-driven dynamic import would have bundled all 191 grammars). Bounded work: 32 KiB / 1500 lines; larger files are truncated before highlight and the UI renders an honest `truncated` notice. Unknown languages fall through to a plain monospace view — the contract is total. (3) **Byte-faithful copy** — `endsWithNewline` is tracked across the highlight → split → copy chain so the clipboard text round-trips to the file bytes (`cp file.js file.js.bak; copy in panel; paste back`); line numbers never leak into the copied text. The server labels the file via `EXT_LANGUAGE` (`server/lib/fs-util.js#languageForExtension`); the view does not re-guess. | ## 11. Network & access control diff --git a/packages/webui/docs/CAPABILITIES.zh-CN.md b/packages/webui/docs/CAPABILITIES.zh-CN.md index 7dbfe5c00..fadb99c4a 100644 --- a/packages/webui/docs/CAPABILITIES.zh-CN.md +++ b/packages/webui/docs/CAPABILITIES.zh-CN.md @@ -52,6 +52,7 @@ CI 会对上述每一个名称是否出现在本文档中进行断言 | 长聊天列表虚拟化(≥ 200 条消息) | ✅ | `webapp/lib/transcript.ts` 虚拟窗口分支(N ≥ 200),带滚动/缩放 rAF 处理器;由 `webapp/test/transcript.test.ts` 覆盖。 | | Markdown 渲染(标题、列表、代码) | ✅ | `lib/marked.min.js` 本地内置(不走 CDN) | | 代码块语法高亮 | ✅ | highlight.js(本地副本) | +| Markdown 内 Mermaid 图渲染(slice 23) | ✅ | 代码围栏语言写 `mermaid` 即渲染为图,而非代码块。围栏语言经"语言→渲染器"注册表分发(`webapp/lib/markdown.ts` 的 `registerLanguageRenderer`,107-113 行);mermaid 在模块导入时自注册(`webapp/lib/mermaid-renderer.ts:64-71`),其他语言的渲染器可经同一接缝接入——markdown 主流程不针对语言名写分支。图表库在页面首张图出现时才动态加载(`components/mermaid-block.tsx:62-66`),该文件带一年 immutable 强缓存(`server/lib/static.js:66`);没有 mermaid 围栏的页面完全不加载。图跟随浅色/深色主题(`components/markdown-html.tsx:52-66` 监听 `` class;`components/mermaid-block.tsx:156-166、223-229` 按主题重新初始化)。语法错误时渲染可读的失败卡片——错误信息 + 可复制的原始源码(`components/mermaid-block.tsx:285-307`)——文档其余部分照常渲染。限制:图按列宽缩放、超宽时在卡片内横向滚动(`webapp/styles/mermaid.css:62-80`);中文标签经字体栈正常显示(`components/mermaid-block.tsx:109`);图不产生标题,因此不会进入任何按标题组织的大纲(围栏只产出 `
`/`
` 占位对,不产出 `h1`-`h6`,见 `lib/mermaid-renderer.ts:44-57`)。依赖 `mermaid` 11.12.1(MIT)已登记于 `release/dependency-licenses.json`。 | | 运行中取消 | ✅ | acp `session/cancel` 以 notification 形式发送,并钉在该 cid 的活动子进程上(`/api/protocol/cancel` → `server/lib/mcode-rpc.js#cancelSession`)。只有当 notification 无法投递时,才会走硬杀兜底(`/api/stop` → SIGTERM/SIGKILL)。acp 会话在排空前可能还会再发出几个事件。 | | 回退 / 分叉某条消息 | ⚠ | 引擎已实现 `session/fork` 和 `session/resume`(`MCODE_ACP_CAPABILITIES.fork / .resume = true`),但目前 webui 还没有路由暴露它们——参见 [§13](CAPABILITIES.zh-CN.md#13-要启用--行-mcode-需要增加什么)。 | | 编辑已发送的消息并重新发送 | ❌ | acp 协议未暴露 | diff --git a/packages/webui/package.json b/packages/webui/package.json index 0b8ade8d0..44fbbdf91 100644 --- a/packages/webui/package.json +++ b/packages/webui/package.json @@ -35,6 +35,7 @@ "autoprefixer": "10.6.1", "highlight.js": "10.7.3", "marked": "18.0.12", + "mermaid": "11.12.1", "next": "14.2.35", "postcss": "8.5.28", "react": "18.3.1", diff --git a/packages/webui/webapp/app/layout.tsx b/packages/webui/webapp/app/layout.tsx index 460a1982d..dfeab1b2a 100644 --- a/packages/webui/webapp/app/layout.tsx +++ b/packages/webui/webapp/app/layout.tsx @@ -10,6 +10,7 @@ import "../styles/official-utilities.css"; import "../styles/mavis-dropdown.css"; import "../styles/desktop-typography.css"; import "../styles/code-preview.css"; +import "../styles/mermaid.css"; export const metadata: Metadata = { title: "MiniMax Code", diff --git a/packages/webui/webapp/components/chat.tsx b/packages/webui/webapp/components/chat.tsx index d79ad03bb..27863a037 100644 --- a/packages/webui/webapp/components/chat.tsx +++ b/packages/webui/webapp/components/chat.tsx @@ -4,6 +4,8 @@ import { useCallback, useEffect, useMemo, useRef, useState } from "react"; import * as api from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; +import { MarkdownHtml } from "./markdown-html"; +import "../lib/mermaid-renderer"; // registers the mermaid language renderer import { reportActionError } from "@/lib/action-errors"; import { findSubagentForBlock } from "@/lib/agent-team-lookup"; import { badgeLabelAndGlyph, agentLabel } from "@/lib/i18n-agent-team"; @@ -531,12 +533,14 @@ function MarkdownBody({ text, streaming }: { text: string; streaming?: boolean } const html = useMemo(() => renderMarkdown(text), [text]); return (
-
+
+ {/* Slice 23 — MarkdownHtml walks the rendered DOM, finds the + mermaid-block placeholders, and mounts the lazy mermaid + component into each one. The parser seam + (`registerLanguageRenderer` in lib/markdown.ts) means + third-party diagram renderers can attach here too. */} + +
{streaming ? ( ▍ ) : null} diff --git a/packages/webui/webapp/components/file-preview.tsx b/packages/webui/webapp/components/file-preview.tsx index c966934bb..98c4405e3 100644 --- a/packages/webui/webapp/components/file-preview.tsx +++ b/packages/webui/webapp/components/file-preview.tsx @@ -12,6 +12,8 @@ import { type FsFilePayload, } from "@/lib/api"; import { renderMarkdown } from "@/lib/markdown"; +import "@/lib/mermaid-renderer"; // registers the mermaid language renderer +import { MarkdownHtml } from "@/components/markdown-html"; import { CodeView as IdeCodeView } from "@/components/code-view"; import { basenameOf, @@ -295,14 +297,19 @@ function MarkdownView({ content }: { content: string }) { // lib/markdown.ts#sanitize) — the same policy used by chat.tsx for // assistant output. Reusing it keeps the threat model and allow-list // identical across surfaces. + // + // Slice 23 — the markdown is no longer injected directly. The + // MarkdownHtml component walks the rendered DOM, finds the + // mermaid-block placeholders, and mounts the lazy mermaid + // component into each one. The seam (the language → renderer + // registry in lib/markdown.ts) means the renderer used here is + // plugin-extensible without an `if (lang === "mermaid")` inside + // this component. const html = useMemo(() => renderMarkdown(content), [content]); return ( -
+
+ +
); } diff --git a/packages/webui/webapp/components/markdown-html.tsx b/packages/webui/webapp/components/markdown-html.tsx new file mode 100644 index 000000000..cbc41f5c8 --- /dev/null +++ b/packages/webui/webapp/components/markdown-html.tsx @@ -0,0 +1,252 @@ +"use client"; + +import { + Children, + createElement, + useEffect, + useState, + type ReactNode, +} from "react"; + +import { MermaidBlock } from "./mermaid-block"; + +/** + * Render pre-sanitised HTML and mount mermaid blocks as real React + * components. + * + * Why this is a real React tree and not a `dangerouslySetInnerHTML` + * injection followed by a one-shot DOM walk + portal: + * + * - The previous design injected the sanitised HTML once after + * mount, then walked the DOM to find mermaid placeholders and + * replaced each placeholder with a `createPortal(, + * placeholder)`. That works on first commit but a later React + * re-commit of the SAME html re-applies `dangerouslySetInnerHTML`, + * which **replaces the inner DOM** (the placeholders are now + * fresh nodes). The previously-portalled `MermaidBlock` instances + * are attached to the OLD detached placeholders and become + * orphans — the diagram never mounts, and the placeholder stays + * at "渲染中…" forever. No console error fires; the failure is + * silent. (Acceptance run.) + * + * - The fix in this version parses the sanitised HTML on every + * render and converts it to a React element tree. Each mermaid + * placeholder pair is recognised by the walker and replaced with + * a `` element — a real React + * node, in the real React tree, that React re-reconciles on every + * commit. Re-commits are idempotent: the walker runs again, finds + * the placeholders in the FRESH input, and emits the same + * `` elements. No portals, no DOM walks, no + * orphaned subtrees. + * + * SSR fallback: when `DOMParser` is unavailable (the static-export + * prerender), the walker falls back to a single `dangerouslySetInnerHTML` + * element. The mermaid path is a no-op in SSR — there is nothing to + * render — and the live mermaid mount happens entirely client-side. + */ +export function MarkdownHtml({ html }: { html: string }) { + const [theme, setTheme] = useState<"light" | "dark">("light"); + + // Watch the element for the light/dark class flip so diagrams + // re-render when the user changes the theme. + useEffect(() => { + if (typeof document === "undefined") return; + const compute = (): "light" | "dark" => { + return document.documentElement.classList.contains("dark") + ? "dark" + : "light"; + }; + setTheme(compute()); + const obs = new MutationObserver(() => setTheme(compute())); + obs.observe(document.documentElement, { + attributes: true, + attributeFilter: ["class"], + }); + return () => obs.disconnect(); + }, []); + + // Convert the sanitised HTML to a React tree. The walker is pure — + // same input always yields the same tree — and mermaid blocks are + // emitted as `` directly. No + // portal, no second DOM pass. + const tree = htmlToReact(html, theme); + + return ( +
+ {tree} +
+ ); +} + +/** + * Walk a sanitised HTML string and convert it to a React tree. + * + * The walker: + * + * - emits one `ReactNode` per child node of the parsed body, in + * order (text nodes, element nodes); + * - recognises the mermaid placeholder pair `
…
` and + * replaces the `
` with a `` — + * the `
` is consumed (skipped) since its text content has
+ *     been folded into the MermaidBlock prop;
+ *   - passes through every other tag with the sanitiser's allowed
+ *     attributes (`class`, `href`, `title`, `align`) so a markdown
+ *     document looks the same as before — only the mermaid fences
+ *     are upgraded from inert HTML to a live component.
+ *
+ * Returns a single `dangerouslySetInnerHTML` element from inside the
+ * tree on SSR (when `DOMParser` is undefined); the prerender still
+ * produces a non-empty HTML response.
+ */
+function htmlToReact(html: string, theme: "light" | "dark"): ReactNode {
+  if (typeof DOMParser === "undefined") {
+    return (
+      
+ ); + } + + const doc = new DOMParser().parseFromString( + `${html}`, + "text/html", + ); + + // Keep a counter for stable React keys across the walk. + let key = 0; + + const walkChildren = (parent: Element | Document): ReactNode[] => { + const out: ReactNode[] = []; + for (const child of [...parent.childNodes]) { + if (child.nodeType === 3 /* text */) { + const text = child.textContent ?? ""; + if (text.length === 0) continue; + out.push(text); + continue; + } + if (child.nodeType !== 1 /* element */) continue; + const el = child as Element; + const tag = el.tagName.toLowerCase(); + + // mermaid placeholder pair: consume the preceding source
+      // (skipped) and replace the following 
+ // with a real . + if (tag === "pre" && el.classList.contains("mermaid-source")) { + continue; + } + if (tag === "div" && el.classList.contains("mermaid-block")) { + const source = findMermaidSourceBefore(el); + out.push( + createElement(MermaidBlock, { + key: `mermaid-${key++}`, + source, + theme, + }), + ); + continue; + } + + const props: Record = { key: `n${key++}` }; + for (const attr of el.attributes) { + const name = attr.name.toLowerCase(); + if (name === "class") { + props.className = attr.value; + } else { + props[attr.name] = attr.value; + } + } + const children = walkChildren(el); + out.push( + createElement(tag, props, children.length > 0 ? children : undefined), + ); + } + return out; + }; + + return walkChildren(doc.body); +} + +/** + * Find the source text for a mermaid placeholder. + * + * The renderer (lib/mermaid-renderer.ts) emits a + * `` immediately + * before each `
`. We walk the previous + * siblings of `placeholder` to find the source. + * + * IMPORTANT: read `pre.textContent`, not `pre.innerHTML`. + * + * The renderer escapes the source via `escapeHtml` (`&`→`&`, + * `<`→`<`, `>`→`>`, `"`→`"`, `'`→`'`) so a hostile + * fence cannot smuggle markup through the placeholder. By the time + * DOMParser has parsed the sanitised HTML, the entity references + * have already been decoded back into literal characters — that is + * what `textContent` returns. + * + * `pre.innerHTML`, by contrast, **re-serialises** the text content + * and re-emits entities (`<` → `<`). A naive `.replace(/|label|` edge syntax and + * corrupts the failure-state "copy source" button (the user copies + * `A -->|是| B`, not `A -->|是| B`). + * + * Exported under a test-only name so the markdown-html-render test + * suite can assert this contract without booting a full DOM (the + * suite has no jsdom / happy-dom and the walker lives in client + * code). The element contract is the small DOM Level 1 surface the + * function actually touches: `nodeType`, `tagName`, `classList`, + * `textContent`, `previousSibling`. + */ +export function findMermaidSourceBefore(placeholder: { + previousSibling: unknown; +}): string { + let cur: unknown = placeholder.previousSibling; + while (cur) { + const node = cur as { + nodeType?: number; + tagName?: string; + classList?: { contains(c: string): boolean }; + textContent?: string | null; + }; + if ( + node.nodeType === 1 && + typeof node.tagName === "string" && + node.tagName.toLowerCase() === "pre" && + node.classList?.contains("mermaid-source") + ) { + return node.textContent ?? ""; + } + cur = (cur as { previousSibling?: unknown }).previousSibling; + } + return ""; +} + +/** + * Count the number of `` instances in a rendered tree. + * + * Used for the `data-mermaid-count` attribute on the host div so a + * regression test can assert "the markdown produced N mermaid + * diagrams" without depending on internal walker details. + */ +function countMermaid(node: ReactNode): number { + let count = 0; + Children.forEach(node, (child) => { + if (!child || typeof child !== "object") return; + if (Array.isArray(child)) { + for (const c of child) count += countMermaid(c); + return; + } + const el = child as { type?: unknown; props?: { children?: ReactNode } }; + if (el.type === MermaidBlock) count++; + if (el.props?.children) count += countMermaid(el.props.children); + }); + return count; +} \ No newline at end of file diff --git a/packages/webui/webapp/components/mermaid-block.tsx b/packages/webui/webapp/components/mermaid-block.tsx new file mode 100644 index 000000000..375eb61fd --- /dev/null +++ b/packages/webui/webapp/components/mermaid-block.tsx @@ -0,0 +1,545 @@ +"use client"; + +import { useEffect, useRef, useState } from "react"; + +/** + * Lazy mermaid block — slice 23 of webui-parity. + * + * The markdown pipeline emits a `