feat(app): render Mermaid code blocks (#43024)

Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
This commit is contained in:
opencode-agent[bot] 2026-08-18 08:02:06 +10:00 committed by GitHub
parent aca42423d3
commit 8644db2c2a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
8 changed files with 105 additions and 11 deletions

View file

@ -700,6 +700,7 @@
"fuzzysort": "catalog:",
"luxon": "catalog:",
"marked": "catalog:",
"mermaid": "11.16.1",
"morphdom": "2.7.8",
"motion": "12.34.5",
"remeda": "catalog:",
@ -916,7 +917,7 @@
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"katex": "0.16.47",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",
@ -4523,7 +4524,7 @@
"jwt-decode": ["jwt-decode@3.1.2", "", {}, "sha512-UfpWE/VZn0iP50d8cz9NrZLM9lSWhcJ+0Gt/nm4by88UL+J1SiKN8/5dkjMmbEzwL2CAe+67GsegCbIKtbp75A=="],
"katex": ["katex@0.16.27", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-aeQoDkuRWSqQN6nSvVCEFvfXdqo1OQiCmmW1kc9xSdjutPv7BGO7pqY9sQRJpMOGrEdfDgF2TfRXe5eUAD2Waw=="],
"katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"keyv": ["keyv@4.5.4", "", { "dependencies": { "json-buffer": "3.0.1" } }, "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw=="],
@ -6787,8 +6788,6 @@
"mermaid/dompurify": ["dompurify@3.4.13", "", { "optionalDependencies": { "@types/trusted-types": "^2.0.7" } }, "sha512-2vmYIoqjze2d+kakP8S/nS5shfsl587kzwEjcGlTdiksUVgFHnFCsLYDVj/JNqJVOQZGSYBTmuycv0PodwmnMQ=="],
"mermaid/katex": ["katex@0.16.47", "", { "dependencies": { "commander": "^8.3.0" }, "bin": { "katex": "cli.js" } }, "sha512-Eeo8Ys1doU1z+x8AZsPpQu+p/QcZBI5PeOo7QGQdy2x2m0MU/hYagBbGOmXwr5KVbEfVuWv9LpnQWeehogurjg=="],
"mermaid/marked": ["marked@16.4.2", "", { "bin": { "marked": "bin/marked.js" } }, "sha512-TI3V8YYWvkVf3KJe1dRkpnjs68JUPyEa5vjKrp1XEEJUAOaQc+Qj+L1qWbPd0SJuAdQkFU0h73sXXqwDYxsiDA=="],
"micromark-extension-mdxjs/acorn": ["acorn@8.18.0", "", { "bin": { "acorn": "bin/acorn" } }, "sha512-lGq+9yr1/GuAWaVYIHRjvvySG5/4VfKIvC8EWxStPdcDh/Ka7FG3twP6v4d5BkravUilhIAsG4Qj83t02LWUPQ=="],
@ -7811,8 +7810,6 @@
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],
"mermaid/katex/commander": ["commander@8.3.0", "", {}, "sha512-OkTL9umf+He2DZkUq8f8J9of7yL6RJKI24dVITBmNfZBmri9zYZQrKkuXiKhyfPSu8tUhnVBB1iKXevvnlR4Ww=="],
"p-locate/p-limit/yocto-queue": ["yocto-queue@0.1.0", "", {}, "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q=="],
"pkg-dir/find-up/locate-path": ["locate-path@5.0.0", "", { "dependencies": { "p-locate": "^4.1.0" } }, "sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g=="],

View file

@ -32,7 +32,8 @@ export default [
format: "es",
},
optimizeDeps: {
exclude: ["@shikijs/stream", "katex", "marked", "marked-shiki", "remend"],
exclude: ["@shikijs/stream", "marked", "marked-shiki", "remend"],
include: ["@opencode-ai/session-ui > mermaid", "@opencode-ai/session-ui > mermaid > katex"],
},
}
},

View file

@ -54,6 +54,7 @@
"fuzzysort": "catalog:",
"luxon": "catalog:",
"marked": "catalog:",
"mermaid": "11.16.1",
"morphdom": "2.7.8",
"motion": "12.34.5",
"remeda": "catalog:",

View file

@ -0,0 +1,19 @@
let loaded: ReturnType<typeof loadMermaid> | undefined
let sequence = 0
export async function renderMermaidSvg(source: string) {
const mermaid = await (loaded ??= loadMermaid())
if (!(await mermaid.parse(source, { suppressErrors: true }))) return
return (await mermaid.render(`markdown-mermaid-${sequence++}`, source)).svg
}
async function loadMermaid() {
const { default: mermaid } = await import("mermaid")
mermaid.initialize({
startOnLoad: false,
securityLevel: "strict",
theme: document.documentElement.dataset.colorScheme === "light" ? "default" : "dark",
flowchart: { htmlLabels: false },
})
return mermaid
}

View file

@ -187,6 +187,33 @@
unicode-bidi: isolate;
}
[data-component="markdown-mermaid"] {
min-height: 96px;
margin: 12px 0 24px;
padding: 16px;
overflow: auto;
border: 0.5px solid var(--v2-border-border-base);
border-radius: 6px;
background: var(--v2-background-bg-layer-02);
direction: ltr;
unicode-bidi: isolate;
}
[data-component="markdown-mermaid"] > svg {
display: block;
width: 100%;
min-width: 480px;
height: auto;
margin: 0 auto;
}
[data-component="markdown-code"][data-mermaid-pending="true"] > pre,
[data-component="markdown-code"][data-mermaid-pending="true"] > [data-slot="markdown-copy-button"],
[data-component="markdown-code"][data-mermaid-ready="true"] > pre,
[data-component="markdown-code"][data-mermaid-ready="true"] > [data-slot="markdown-copy-button"] {
display: none;
}
[data-component="markdown-code"][data-code-kind="shell"] .shiki {
background: var(--markdown-shell-code-background);
border-color: var(--markdown-shell-code-border);

View file

@ -31,6 +31,7 @@ import { markdownBlockKey, type MarkdownToken } from "./markdown-worker-protocol
import { shouldResetCodeTokens, type RenderedCodeState } from "./markdown-code-state"
import { getCachedMarkdown, sanitizeMarkdown, touchCachedMarkdown, type MarkdownCacheEntry } from "./markdown-cache"
import { inlineCodeKind } from "./markdown-inline-code-kind"
import { renderMermaidSvg } from "./markdown-mermaid"
type RenderedBlock =
| (MarkdownCacheEntry & { key: string; mode: Exclude<Block["mode"], "code"> })
@ -229,6 +230,47 @@ function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
}
}
function decorateMermaid(wrapper: HTMLElement, code: HTMLElement, complete: boolean) {
if (!code.classList.contains("language-mermaid")) {
clearMermaid(wrapper)
return
}
const source = code.textContent ?? ""
if (!source) return
const diagram = wrapper.querySelector('[data-component="markdown-mermaid"]') ?? document.createElement("div")
diagram.setAttribute("data-component", "markdown-mermaid")
if (!diagram.parentElement) wrapper.appendChild(diagram)
wrapper.dataset.mermaidPending = "true"
const input = complete ? source : source.slice(0, source.lastIndexOf("\n") + 1)
if (!input) return
const attempt = `${complete ? "complete" : "streaming"}:${input.length}`
if (wrapper.dataset.mermaidAttempt === attempt) return
wrapper.dataset.mermaidAttempt = attempt
void renderMermaidSvg(input)
.then((svg) => {
if (!svg) {
if (complete && code.textContent === source) clearMermaid(wrapper)
return
}
if (!(code.textContent ?? "").startsWith(input)) return
diagram.innerHTML = svg
delete wrapper.dataset.mermaidPending
wrapper.dataset.mermaidReady = "true"
})
.catch(() => {
if (!complete || code.textContent !== source) return
clearMermaid(wrapper)
})
}
function clearMermaid(wrapper: HTMLElement) {
delete wrapper.dataset.mermaidAttempt
delete wrapper.dataset.mermaidPending
delete wrapper.dataset.mermaidReady
wrapper.querySelector('[data-component="markdown-mermaid"]')?.remove()
}
function markCodeLinks(root: HTMLDivElement) {
const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
for (const code of codeNodes) {
@ -268,10 +310,13 @@ function markInlineCode(root: HTMLDivElement) {
}
}
function decorate(root: HTMLDivElement, labels: CopyLabels) {
function decorate(root: HTMLDivElement, labels: CopyLabels, complete: boolean) {
const blocks = Array.from(root.querySelectorAll("pre"))
for (const block of blocks) {
ensureCodeWrapper(block, labels)
const wrapper = block.parentElement
const code = block.querySelector("code")
if (wrapper instanceof HTMLElement && code instanceof HTMLElement) decorateMermaid(wrapper, code, complete)
}
markInlineCode(root)
markCodeLinks(root)
@ -598,7 +643,7 @@ function updateBlock(container: HTMLDivElement, index: number, block: RenderedBl
next.dataset.markdownHash = block.hash
next.style.display = "contents"
next.innerHTML = block.html
decorate(next, labels)
decorate(next, labels, block.mode === "full")
if (!(current instanceof HTMLDivElement)) {
container.appendChild(next)
@ -668,6 +713,7 @@ function updateCodeBlock(
unstable: block.unstable,
raw: block.raw,
})
if (wrapper instanceof HTMLElement) decorateMermaid(wrapper, code, block.complete)
return
}
@ -682,6 +728,7 @@ function updateCodeBlock(
pre.appendChild(codeElement)
wrapper.appendChild(pre)
wrapper.appendChild(createCopyButton(labels))
decorateMermaid(wrapper, codeElement, block.complete)
next.appendChild(wrapper)
renderedCodeTokens.set(next, {
language: block.language,

View file

@ -39,7 +39,9 @@ const parser = createMarkdownParser(async (code, language) => {
const name = language in bundledLanguages ? language : "text"
if (!instance.getLoadedLanguages().includes(name))
await instance.loadLanguage(bundledLanguages[name as BundledLanguage])
return instance.codeToHtml(code, { lang: name as BundledLanguage, theme: "OpenCode", tabindex: false })
return instance
.codeToHtml(code, { lang: name as BundledLanguage, theme: "OpenCode", tabindex: false })
.replace("<code>", `<code class="language-${name}">`)
})
self.onmessage = (event: MessageEvent<MarkdownWorkerRequest>) => {

View file

@ -89,7 +89,7 @@
"diff": "catalog:",
"dompurify": "3.3.1",
"fuzzysort": "catalog:",
"katex": "0.16.27",
"katex": "0.16.47",
"luxon": "catalog:",
"marked": "catalog:",
"marked-shiki": "catalog:",