Skip to content

Commit e31e37c

Browse files
committed
feat: Mermaid diagram rendering + GFM alert support
1 parent 6c14ea1 commit e31e37c

4 files changed

Lines changed: 298 additions & 2 deletions

File tree

‎packages/ui/package.json‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,7 @@
4444
"dependencies": {
4545
"@kobalte/core": "catalog:",
4646
"@opencode-ai/sdk": "workspace:*",
47+
"mermaid": "11.12.3",
4748
"@opencode-ai/util": "workspace:*",
4849
"@pierre/diffs": "catalog:",
4950
"@shikijs/transformers": "3.9.2",

‎packages/ui/src/components/markdown.css‎

Lines changed: 111 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -258,6 +258,117 @@
258258
margin: 1.5rem 0;
259259
display: block;
260260
}
261+
262+
/* Mermaid Diagrams */
263+
[data-component="mermaid-diagram"] {
264+
position: relative;
265+
margin: 1.5rem 0;
266+
border: 1px solid var(--border-weaker-base);
267+
border-radius: 8px;
268+
background: var(--surface-inset-base);
269+
}
270+
271+
[data-slot="mermaid-render"] {
272+
padding: 20px 16px;
273+
display: flex;
274+
justify-content: center;
275+
overflow-x: auto;
276+
scrollbar-width: none;
277+
&::-webkit-scrollbar { display: none; }
278+
}
279+
280+
[data-slot="mermaid-render"] svg {
281+
max-width: 100%;
282+
height: auto;
283+
}
284+
285+
[data-slot="mermaid-loading"] {
286+
color: var(--text-dimmed);
287+
font-size: var(--font-size-small);
288+
padding: 2rem;
289+
text-align: center;
290+
}
291+
292+
[data-slot="mermaid-source"] {
293+
& pre { margin: 0; }
294+
}
295+
296+
[data-slot="mermaid-toggle"] {
297+
position: absolute;
298+
top: 4px;
299+
right: 4px;
300+
opacity: 0;
301+
transition: opacity 0.15s ease;
302+
z-index: 1;
303+
box-shadow: none;
304+
border: 1px solid var(--border-weak-base);
305+
}
306+
307+
[data-component="mermaid-diagram"]:hover [data-slot="mermaid-toggle"] {
308+
opacity: 1;
309+
}
310+
311+
[data-slot="mermaid-toggle"] [data-slot="icon-svg"] {
312+
color: var(--icon-base);
313+
}
314+
315+
[data-slot="mermaid-toggle"][data-view="diagram"] [data-slot="toggle-diagram-icon"] {
316+
display: none;
317+
}
318+
[data-slot="mermaid-toggle"][data-view="source"] [data-slot="toggle-code-icon"] {
319+
display: none;
320+
}
321+
322+
/* GFM Alerts */
323+
[data-component="gfm-alert"] {
324+
margin: 1.5rem 0;
325+
padding: 0.75rem 1rem;
326+
border-radius: var(--radius-md);
327+
border-left: 3px solid;
328+
}
329+
330+
[data-component="gfm-alert"][data-alert-type="note"] {
331+
border-left-color: hsl(var(--color-blue-500));
332+
background: hsl(var(--color-blue-500) / 0.08);
333+
}
334+
[data-component="gfm-alert"][data-alert-type="tip"] {
335+
border-left-color: hsl(var(--color-green-500));
336+
background: hsl(var(--color-green-500) / 0.08);
337+
}
338+
[data-component="gfm-alert"][data-alert-type="important"] {
339+
border-left-color: hsl(var(--color-purple-500));
340+
background: hsl(var(--color-purple-500) / 0.08);
341+
}
342+
[data-component="gfm-alert"][data-alert-type="warning"] {
343+
border-left-color: hsl(var(--color-amber-500));
344+
background: hsl(var(--color-amber-500) / 0.08);
345+
}
346+
[data-component="gfm-alert"][data-alert-type="caution"] {
347+
border-left-color: hsl(var(--color-red-500));
348+
background: hsl(var(--color-red-500) / 0.08);
349+
}
350+
351+
[data-slot="gfm-alert-title"] {
352+
font-weight: 600;
353+
font-size: var(--font-size-small);
354+
text-transform: uppercase;
355+
letter-spacing: 0.04em;
356+
margin-bottom: 0.25rem;
357+
}
358+
359+
[data-alert-type="note"] [data-slot="gfm-alert-title"] { color: hsl(var(--color-blue-500)); }
360+
[data-alert-type="tip"] [data-slot="gfm-alert-title"] { color: hsl(var(--color-green-500)); }
361+
[data-alert-type="important"] [data-slot="gfm-alert-title"] { color: hsl(var(--color-purple-500)); }
362+
[data-alert-type="warning"] [data-slot="gfm-alert-title"] { color: hsl(var(--color-amber-500)); }
363+
[data-alert-type="caution"] [data-slot="gfm-alert-title"] { color: hsl(var(--color-red-500)); }
364+
365+
[data-slot="gfm-alert-body"] {
366+
color: var(--text-base);
367+
font-size: var(--font-size-base);
368+
}
369+
[data-slot="gfm-alert-body"] > p:last-child {
370+
margin-bottom: 0;
371+
}
261372
}
262373

263374
[data-component="markdown"] a.external-link:hover > code {

‎packages/ui/src/components/markdown.tsx‎

Lines changed: 139 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,8 @@ const config = {
3838
const iconPaths = {
3939
copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
4040
check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
41+
code: '<path d="M7 5L3 10L7 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/><path d="M13 5L17 10L13 15" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round"/>',
42+
diagram: '<path d="M3 3H17V17H3V3Z" stroke="currentColor" stroke-linecap="round"/><path d="M3 10H17" stroke="currentColor"/><path d="M10 3V17" stroke="currentColor"/>',
4143
}
4244

4345
function sanitize(html: string) {
@@ -173,9 +175,128 @@ function markCodeLinks(root: HTMLDivElement) {
173175
}
174176
}
175177

178+
let mermaidPromise: Promise<typeof import("mermaid")> | undefined
179+
180+
function getMermaid() {
181+
if (!mermaidPromise) {
182+
mermaidPromise = import("mermaid").then((m) => {
183+
m.default.initialize({
184+
startOnLoad: false,
185+
theme: "dark",
186+
fontFamily: "var(--font-family-sans)",
187+
darkMode: true,
188+
themeVariables: {
189+
darkMode: true,
190+
background: "transparent",
191+
primaryColor: "hsl(var(--color-blue-600))",
192+
primaryTextColor: "var(--text-strong)",
193+
primaryBorderColor: "var(--border-base)",
194+
lineColor: "var(--text-dimmed)",
195+
secondaryColor: "hsl(var(--color-purple-600))",
196+
tertiaryColor: "hsl(var(--color-green-600))",
197+
},
198+
})
199+
return m
200+
})
201+
}
202+
return mermaidPromise
203+
}
204+
205+
let mermaidCounter = 0
206+
207+
async function renderMermaidDiagrams(root: HTMLDivElement) {
208+
const diagrams = Array.from(root.querySelectorAll<HTMLElement>('[data-component="mermaid-diagram"]'))
209+
if (diagrams.length === 0) return
210+
211+
const pending = diagrams.filter((d) => !d.hasAttribute("data-rendered"))
212+
if (pending.length === 0) return
213+
214+
const mermaid = await getMermaid()
215+
216+
for (const container of pending) {
217+
const encoded = container.getAttribute("data-mermaid")
218+
if (!encoded) continue
219+
220+
const renderSlot = container.querySelector<HTMLElement>('[data-slot="mermaid-render"]')
221+
if (!renderSlot) continue
222+
223+
let source: string
224+
try {
225+
source = decodeURIComponent(escape(atob(encoded)))
226+
} catch {
227+
continue
228+
}
229+
230+
try {
231+
const id = `mermaid-${++mermaidCounter}`
232+
const { svg } = await mermaid.default.render(id, source)
233+
renderSlot.innerHTML = svg
234+
container.setAttribute("data-rendered", "true")
235+
236+
if (!container.querySelector('[data-slot="mermaid-toggle"]')) {
237+
const toggle = document.createElement("button")
238+
toggle.type = "button"
239+
toggle.setAttribute("data-component", "icon-button")
240+
toggle.setAttribute("data-variant", "secondary")
241+
toggle.setAttribute("data-size", "small")
242+
toggle.setAttribute("data-slot", "mermaid-toggle")
243+
toggle.setAttribute("data-view", "diagram")
244+
toggle.setAttribute("aria-label", "View source")
245+
toggle.setAttribute("data-tooltip", "View source")
246+
toggle.appendChild(createIcon(iconPaths.code, "toggle-code-icon"))
247+
toggle.appendChild(createIcon(iconPaths.diagram, "toggle-diagram-icon"))
248+
container.appendChild(toggle)
249+
}
250+
} catch {
251+
const sourceSlot = container.querySelector<HTMLElement>('[data-slot="mermaid-source"]')
252+
if (renderSlot && sourceSlot) {
253+
renderSlot.hidden = true
254+
sourceSlot.hidden = false
255+
}
256+
container.setAttribute("data-rendered", "error")
257+
}
258+
}
259+
}
260+
261+
function setupMermaidToggle(root: HTMLDivElement) {
262+
const handleClick = (event: MouseEvent) => {
263+
const target = event.target
264+
if (!(target instanceof Element)) return
265+
266+
const toggle = target.closest('[data-slot="mermaid-toggle"]')
267+
if (!(toggle instanceof HTMLButtonElement)) return
268+
269+
const container = toggle.closest('[data-component="mermaid-diagram"]')
270+
if (!container) return
271+
272+
const renderSlot = container.querySelector<HTMLElement>('[data-slot="mermaid-render"]')
273+
const sourceSlot = container.querySelector<HTMLElement>('[data-slot="mermaid-source"]')
274+
if (!renderSlot || !sourceSlot) return
275+
276+
const showingDiagram = toggle.getAttribute("data-view") === "diagram"
277+
if (showingDiagram) {
278+
renderSlot.hidden = true
279+
sourceSlot.hidden = false
280+
toggle.setAttribute("data-view", "source")
281+
toggle.setAttribute("aria-label", "View diagram")
282+
toggle.setAttribute("data-tooltip", "View diagram")
283+
} else {
284+
renderSlot.hidden = false
285+
sourceSlot.hidden = true
286+
toggle.setAttribute("data-view", "diagram")
287+
toggle.setAttribute("aria-label", "View source")
288+
toggle.setAttribute("data-tooltip", "View source")
289+
}
290+
}
291+
292+
root.addEventListener("click", handleClick)
293+
return () => root.removeEventListener("click", handleClick)
294+
}
295+
176296
function decorate(root: HTMLDivElement, labels: CopyLabels) {
177297
const blocks = Array.from(root.querySelectorAll("pre"))
178298
for (const block of blocks) {
299+
if (block.closest('[data-slot="mermaid-source"]')) continue
179300
ensureCodeWrapper(block, labels)
180301
}
181302
markCodeLinks(root)
@@ -286,6 +407,7 @@ export function Markdown(
286407
)
287408

288409
let copyCleanup: (() => void) | undefined
410+
let mermaidToggleCleanup: (() => void) | undefined
289411

290412
createEffect(() => {
291413
const container = root()
@@ -309,6 +431,7 @@ export function Markdown(
309431
morphdom(container, temp, {
310432
childrenOnly: true,
311433
onBeforeElUpdated: (fromEl, toEl) => {
434+
// Preserve copy button state during morphdom updates
312435
if (
313436
fromEl instanceof HTMLButtonElement &&
314437
toEl instanceof HTMLButtonElement &&
@@ -318,6 +441,15 @@ export function Markdown(
318441
) {
319442
setCopyState(toEl, labels, true)
320443
}
444+
// Preserve already-rendered mermaid diagrams
445+
if (
446+
fromEl instanceof HTMLElement &&
447+
fromEl.getAttribute("data-rendered") === "true" &&
448+
toEl instanceof HTMLElement &&
449+
fromEl.getAttribute("data-mermaid") === toEl.getAttribute("data-mermaid")
450+
) {
451+
return false
452+
}
321453
if (fromEl.isEqualNode(toEl)) return false
322454
return true
323455
},
@@ -328,10 +460,17 @@ export function Markdown(
328460
copy: i18n.t("ui.message.copy"),
329461
copied: i18n.t("ui.message.copied"),
330462
}))
463+
464+
if (!mermaidToggleCleanup) {
465+
mermaidToggleCleanup = setupMermaidToggle(container)
466+
}
467+
468+
renderMermaidDiagrams(container)
331469
})
332470

333471
onCleanup(() => {
334472
if (copyCleanup) copyCleanup()
473+
if (mermaidToggleCleanup) mermaidToggleCleanup()
335474
})
336475

337476
return (

‎packages/ui/src/context/marked.tsx‎

Lines changed: 47 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -423,6 +423,27 @@ function renderMathExpressions(html: string): string {
423423
.join("")
424424
}
425425

426+
const GFM_ALERT_TYPES: Record<string, { label: string }> = {
427+
NOTE: { label: "Note" },
428+
TIP: { label: "Tip" },
429+
IMPORTANT: { label: "Important" },
430+
WARNING: { label: "Warning" },
431+
CAUTION: { label: "Caution" },
432+
}
433+
434+
function renderGfmAlerts(html: string): string {
435+
return html.replace(
436+
/<blockquote>\s*<p>\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(?:<br\s*\/?>)?\s*([\s\S]*?)<\/blockquote>/gi,
437+
(match, type, body) => {
438+
const key = type.toUpperCase()
439+
const alert = GFM_ALERT_TYPES[key]
440+
if (!alert) return match
441+
const cleanBody = body.replace(/<\/p>\s*$/, "")
442+
return `<div data-component="gfm-alert" data-alert-type="${key.toLowerCase()}"><div data-slot="gfm-alert-title">${alert.label}</div><div data-slot="gfm-alert-body">${cleanBody}</div></div>`
443+
},
444+
)
445+
}
446+
426447
async function highlightCodeBlocks(html: string): Promise<string> {
427448
const codeBlockRegex = /<pre><code(?:\s+class="language-([^"]*)")?>([\s\S]*?)<\/code><\/pre>/g
428449
const matches = [...html.matchAll(codeBlockRegex)]
@@ -444,6 +465,14 @@ async function highlightCodeBlocks(html: string): Promise<string> {
444465
.replace(/&quot;/g, '"')
445466
.replace(/&#39;/g, "'")
446467

468+
// Mermaid blocks: emit render container instead of syntax-highlighted code
469+
if (lang === "mermaid") {
470+
const encoded = btoa(unescape(encodeURIComponent(code)))
471+
const mermaidHtml = `<div data-component="mermaid-diagram" data-mermaid="${encoded}"><div data-slot="mermaid-render"><div data-slot="mermaid-loading">Loading diagram\u2026</div></div><div data-slot="mermaid-source" hidden><pre><code class="language-mermaid">${escapedCode}</code></pre></div></div>`
472+
result = result.replace(fullMatch, () => mermaidHtml)
473+
continue
474+
}
475+
447476
let language = lang || "text"
448477
if (!(language in bundledLanguages)) {
449478
language = "text"
@@ -483,6 +512,16 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
483512
}),
484513
markedShiki({
485514
async highlight(code, lang) {
515+
// Mermaid blocks: emit a container with render target + hidden source
516+
if (lang === "mermaid") {
517+
const encoded = btoa(unescape(encodeURIComponent(code)))
518+
const escaped = code
519+
.replace(/&/g, "&amp;")
520+
.replace(/</g, "&lt;")
521+
.replace(/>/g, "&gt;")
522+
return `<div data-component="mermaid-diagram" data-mermaid="${encoded}"><div data-slot="mermaid-render"><div data-slot="mermaid-loading">Loading diagram\u2026</div></div><div data-slot="mermaid-source" hidden><pre><code class="language-mermaid">${escaped}</code></pre></div></div>`
523+
}
524+
486525
const highlighter = await getSharedHighlighter({
487526
themes: ["OpenCode"],
488527
langs: [],
@@ -509,11 +548,17 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
509548
async parse(markdown: string): Promise<string> {
510549
const html = await nativeParser(markdown)
511550
const withMath = renderMathExpressions(html)
512-
return highlightCodeBlocks(withMath)
551+
const withAlerts = renderGfmAlerts(withMath)
552+
return highlightCodeBlocks(withAlerts)
513553
},
514554
}
515555
}
516556

517-
return jsParser
557+
return {
558+
async parse(markdown: string): Promise<string> {
559+
const html = await jsParser.parse(markdown)
560+
return renderGfmAlerts(html)
561+
},
562+
}
518563
},
519564
})

0 commit comments

Comments
 (0)