diff --git a/packages/ui/package.json b/packages/ui/package.json index 8214a7a1d30b..853dca392fd6 100644 --- a/packages/ui/package.json +++ b/packages/ui/package.json @@ -44,6 +44,7 @@ "dependencies": { "@kobalte/core": "catalog:", "@opencode-ai/sdk": "workspace:*", + "mermaid": "11.12.3", "@opencode-ai/util": "workspace:*", "@pierre/diffs": "catalog:", "@shikijs/transformers": "3.9.2", diff --git a/packages/ui/src/components/markdown.css b/packages/ui/src/components/markdown.css index f82723807d6c..f3079d25f428 100644 --- a/packages/ui/src/components/markdown.css +++ b/packages/ui/src/components/markdown.css @@ -258,6 +258,117 @@ margin: 1.5rem 0; display: block; } + + /* Mermaid Diagrams */ + [data-component="mermaid-diagram"] { + position: relative; + margin: 1.5rem 0; + border: 1px solid var(--border-weaker-base); + border-radius: 8px; + background: var(--surface-inset-base); + } + + [data-slot="mermaid-render"] { + padding: 20px 16px; + display: flex; + justify-content: center; + overflow-x: auto; + scrollbar-width: none; + &::-webkit-scrollbar { display: none; } + } + + [data-slot="mermaid-render"] svg { + max-width: 100%; + height: auto; + } + + [data-slot="mermaid-loading"] { + color: var(--text-dimmed); + font-size: var(--font-size-small); + padding: 2rem; + text-align: center; + } + + [data-slot="mermaid-source"] { + & pre { margin: 0; } + } + + [data-slot="mermaid-toggle"] { + position: absolute; + top: 4px; + right: 4px; + opacity: 0; + transition: opacity 0.15s ease; + z-index: 1; + box-shadow: none; + border: 1px solid var(--border-weak-base); + } + + [data-component="mermaid-diagram"]:hover [data-slot="mermaid-toggle"] { + opacity: 1; + } + + [data-slot="mermaid-toggle"] [data-slot="icon-svg"] { + color: var(--icon-base); + } + + [data-slot="mermaid-toggle"][data-view="diagram"] [data-slot="toggle-diagram-icon"] { + display: none; + } + [data-slot="mermaid-toggle"][data-view="source"] [data-slot="toggle-code-icon"] { + display: none; + } + + /* GFM Alerts */ + [data-component="gfm-alert"] { + margin: 1.5rem 0; + padding: 0.75rem 1rem; + border-radius: var(--radius-md); + border-left: 3px solid; + } + + [data-component="gfm-alert"][data-alert-type="note"] { + border-left-color: hsl(var(--color-blue-500)); + background: hsl(var(--color-blue-500) / 0.08); + } + [data-component="gfm-alert"][data-alert-type="tip"] { + border-left-color: hsl(var(--color-green-500)); + background: hsl(var(--color-green-500) / 0.08); + } + [data-component="gfm-alert"][data-alert-type="important"] { + border-left-color: hsl(var(--color-purple-500)); + background: hsl(var(--color-purple-500) / 0.08); + } + [data-component="gfm-alert"][data-alert-type="warning"] { + border-left-color: hsl(var(--color-amber-500)); + background: hsl(var(--color-amber-500) / 0.08); + } + [data-component="gfm-alert"][data-alert-type="caution"] { + border-left-color: hsl(var(--color-red-500)); + background: hsl(var(--color-red-500) / 0.08); + } + + [data-slot="gfm-alert-title"] { + font-weight: 600; + font-size: var(--font-size-small); + text-transform: uppercase; + letter-spacing: 0.04em; + margin-bottom: 0.25rem; + } + + [data-alert-type="note"] [data-slot="gfm-alert-title"] { color: hsl(var(--color-blue-500)); } + [data-alert-type="tip"] [data-slot="gfm-alert-title"] { color: hsl(var(--color-green-500)); } + [data-alert-type="important"] [data-slot="gfm-alert-title"] { color: hsl(var(--color-purple-500)); } + [data-alert-type="warning"] [data-slot="gfm-alert-title"] { color: hsl(var(--color-amber-500)); } + [data-alert-type="caution"] [data-slot="gfm-alert-title"] { color: hsl(var(--color-red-500)); } + + [data-slot="gfm-alert-body"] { + color: var(--text-base); + font-size: var(--font-size-base); + } + [data-slot="gfm-alert-body"] > p:last-child { + margin-bottom: 0; + } } [data-component="markdown"] a.external-link:hover > code { diff --git a/packages/ui/src/components/markdown.tsx b/packages/ui/src/components/markdown.tsx index ceab10df98ac..7f8795d47c12 100644 --- a/packages/ui/src/components/markdown.tsx +++ b/packages/ui/src/components/markdown.tsx @@ -38,6 +38,8 @@ const config = { const iconPaths = { copy: '', check: '', + code: '', + diagram: '', } function sanitize(html: string) { @@ -173,9 +175,128 @@ function markCodeLinks(root: HTMLDivElement) { } } +let mermaidPromise: Promise | undefined + +function getMermaid() { + if (!mermaidPromise) { + mermaidPromise = import("mermaid").then((m) => { + m.default.initialize({ + startOnLoad: false, + theme: "dark", + fontFamily: "var(--font-family-sans)", + darkMode: true, + themeVariables: { + darkMode: true, + background: "transparent", + primaryColor: "hsl(var(--color-blue-600))", + primaryTextColor: "var(--text-strong)", + primaryBorderColor: "var(--border-base)", + lineColor: "var(--text-dimmed)", + secondaryColor: "hsl(var(--color-purple-600))", + tertiaryColor: "hsl(var(--color-green-600))", + }, + }) + return m + }) + } + return mermaidPromise +} + +let mermaidCounter = 0 + +async function renderMermaidDiagrams(root: HTMLDivElement) { + const diagrams = Array.from(root.querySelectorAll('[data-component="mermaid-diagram"]')) + if (diagrams.length === 0) return + + const pending = diagrams.filter((d) => !d.hasAttribute("data-rendered")) + if (pending.length === 0) return + + const mermaid = await getMermaid() + + for (const container of pending) { + const encoded = container.getAttribute("data-mermaid") + if (!encoded) continue + + const renderSlot = container.querySelector('[data-slot="mermaid-render"]') + if (!renderSlot) continue + + let source: string + try { + source = decodeURIComponent(escape(atob(encoded))) + } catch { + continue + } + + try { + const id = `mermaid-${++mermaidCounter}` + const { svg } = await mermaid.default.render(id, source) + renderSlot.innerHTML = svg + container.setAttribute("data-rendered", "true") + + if (!container.querySelector('[data-slot="mermaid-toggle"]')) { + const toggle = document.createElement("button") + toggle.type = "button" + toggle.setAttribute("data-component", "icon-button") + toggle.setAttribute("data-variant", "secondary") + toggle.setAttribute("data-size", "small") + toggle.setAttribute("data-slot", "mermaid-toggle") + toggle.setAttribute("data-view", "diagram") + toggle.setAttribute("aria-label", "View source") + toggle.setAttribute("data-tooltip", "View source") + toggle.appendChild(createIcon(iconPaths.code, "toggle-code-icon")) + toggle.appendChild(createIcon(iconPaths.diagram, "toggle-diagram-icon")) + container.appendChild(toggle) + } + } catch { + const sourceSlot = container.querySelector('[data-slot="mermaid-source"]') + if (renderSlot && sourceSlot) { + renderSlot.hidden = true + sourceSlot.hidden = false + } + container.setAttribute("data-rendered", "error") + } + } +} + +function setupMermaidToggle(root: HTMLDivElement) { + const handleClick = (event: MouseEvent) => { + const target = event.target + if (!(target instanceof Element)) return + + const toggle = target.closest('[data-slot="mermaid-toggle"]') + if (!(toggle instanceof HTMLButtonElement)) return + + const container = toggle.closest('[data-component="mermaid-diagram"]') + if (!container) return + + const renderSlot = container.querySelector('[data-slot="mermaid-render"]') + const sourceSlot = container.querySelector('[data-slot="mermaid-source"]') + if (!renderSlot || !sourceSlot) return + + const showingDiagram = toggle.getAttribute("data-view") === "diagram" + if (showingDiagram) { + renderSlot.hidden = true + sourceSlot.hidden = false + toggle.setAttribute("data-view", "source") + toggle.setAttribute("aria-label", "View diagram") + toggle.setAttribute("data-tooltip", "View diagram") + } else { + renderSlot.hidden = false + sourceSlot.hidden = true + toggle.setAttribute("data-view", "diagram") + toggle.setAttribute("aria-label", "View source") + toggle.setAttribute("data-tooltip", "View source") + } + } + + root.addEventListener("click", handleClick) + return () => root.removeEventListener("click", handleClick) +} + function decorate(root: HTMLDivElement, labels: CopyLabels) { const blocks = Array.from(root.querySelectorAll("pre")) for (const block of blocks) { + if (block.closest('[data-slot="mermaid-source"]')) continue ensureCodeWrapper(block, labels) } markCodeLinks(root) @@ -286,6 +407,7 @@ export function Markdown( ) let copyCleanup: (() => void) | undefined + let mermaidToggleCleanup: (() => void) | undefined createEffect(() => { const container = root() @@ -309,6 +431,7 @@ export function Markdown( morphdom(container, temp, { childrenOnly: true, onBeforeElUpdated: (fromEl, toEl) => { + // Preserve copy button state during morphdom updates if ( fromEl instanceof HTMLButtonElement && toEl instanceof HTMLButtonElement && @@ -318,6 +441,15 @@ export function Markdown( ) { setCopyState(toEl, labels, true) } + // Preserve already-rendered mermaid diagrams + if ( + fromEl instanceof HTMLElement && + fromEl.getAttribute("data-rendered") === "true" && + toEl instanceof HTMLElement && + fromEl.getAttribute("data-mermaid") === toEl.getAttribute("data-mermaid") + ) { + return false + } if (fromEl.isEqualNode(toEl)) return false return true }, @@ -328,10 +460,17 @@ export function Markdown( copy: i18n.t("ui.message.copy"), copied: i18n.t("ui.message.copied"), })) + + if (!mermaidToggleCleanup) { + mermaidToggleCleanup = setupMermaidToggle(container) + } + + renderMermaidDiagrams(container) }) onCleanup(() => { if (copyCleanup) copyCleanup() + if (mermaidToggleCleanup) mermaidToggleCleanup() }) return ( diff --git a/packages/ui/src/context/marked.tsx b/packages/ui/src/context/marked.tsx index 46f4993babde..d204a9b6afff 100644 --- a/packages/ui/src/context/marked.tsx +++ b/packages/ui/src/context/marked.tsx @@ -423,6 +423,27 @@ function renderMathExpressions(html: string): string { .join("") } +const GFM_ALERT_TYPES: Record = { + NOTE: { label: "Note" }, + TIP: { label: "Tip" }, + IMPORTANT: { label: "Important" }, + WARNING: { label: "Warning" }, + CAUTION: { label: "Caution" }, +} + +function renderGfmAlerts(html: string): string { + return html.replace( + /
\s*

\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(?:)?\s*([\s\S]*?)<\/blockquote>/gi, + (match, type, body) => { + const key = type.toUpperCase() + const alert = GFM_ALERT_TYPES[key] + if (!alert) return match + const cleanBody = body.replace(/<\/p>\s*$/, "") + return `

${alert.label}
${cleanBody}
` + }, + ) +} + async function highlightCodeBlocks(html: string): Promise { const codeBlockRegex = /
([\s\S]*?)<\/code><\/pre>/g
   const matches = [...html.matchAll(codeBlockRegex)]
@@ -444,6 +465,14 @@ async function highlightCodeBlocks(html: string): Promise {
       .replace(/"/g, '"')
       .replace(/'/g, "'")
 
+    // Mermaid blocks: emit render container instead of syntax-highlighted code
+    if (lang === "mermaid") {
+      const encoded = btoa(unescape(encodeURIComponent(code)))
+      const mermaidHtml = `
Loading diagram\u2026
` + result = result.replace(fullMatch, () => mermaidHtml) + continue + } + let language = lang || "text" if (!(language in bundledLanguages)) { language = "text" @@ -483,6 +512,16 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( }), markedShiki({ async highlight(code, lang) { + // Mermaid blocks: emit a container with render target + hidden source + if (lang === "mermaid") { + const encoded = btoa(unescape(encodeURIComponent(code))) + const escaped = code + .replace(/&/g, "&") + .replace(//g, ">") + return `
Loading diagram\u2026
` + } + const highlighter = await getSharedHighlighter({ themes: ["OpenCode"], langs: [], @@ -509,11 +548,17 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext( async parse(markdown: string): Promise { const html = await nativeParser(markdown) const withMath = renderMathExpressions(html) - return highlightCodeBlocks(withMath) + const withAlerts = renderGfmAlerts(withMath) + return highlightCodeBlocks(withAlerts) }, } } - return jsParser + return { + async parse(markdown: string): Promise { + const html = await jsParser.parse(markdown) + return renderGfmAlerts(html) + }, + } }, })