mirror of
https://github.com/anomalyco/opencode.git
synced 2026-08-21 11:43:25 +00:00
feat(app): render Mermaid code blocks (#43024)
Co-authored-by: Luke Parker <10430890+Hona@users.noreply.github.com>
This commit is contained in:
parent
aca42423d3
commit
8644db2c2a
8 changed files with 105 additions and 11 deletions
9
bun.lock
9
bun.lock
|
|
@ -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=="],
|
||||
|
|
|
|||
|
|
@ -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"],
|
||||
},
|
||||
}
|
||||
},
|
||||
|
|
|
|||
|
|
@ -54,6 +54,7 @@
|
|||
"fuzzysort": "catalog:",
|
||||
"luxon": "catalog:",
|
||||
"marked": "catalog:",
|
||||
"mermaid": "11.16.1",
|
||||
"morphdom": "2.7.8",
|
||||
"motion": "12.34.5",
|
||||
"remeda": "catalog:",
|
||||
|
|
|
|||
19
packages/session-ui/src/components/markdown-mermaid.ts
Normal file
19
packages/session-ui/src/components/markdown-mermaid.ts
Normal 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
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>) => {
|
||||
|
|
|
|||
|
|
@ -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:",
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue