Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/ui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
111 changes: 111 additions & 0 deletions packages/ui/src/components/markdown.css
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand Down
139 changes: 139 additions & 0 deletions packages/ui/src/components/markdown.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ const config = {
const iconPaths = {
copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
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"/>',
diagram: '<path d="M3 3H17V17H3V3Z" stroke="currentColor" stroke-linecap="round"/><path d="M3 10H17" stroke="currentColor"/><path d="M10 3V17" stroke="currentColor"/>',
}

function sanitize(html: string) {
Expand Down Expand Up @@ -173,9 +175,128 @@ function markCodeLinks(root: HTMLDivElement) {
}
}

let mermaidPromise: Promise<typeof import("mermaid")> | 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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[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<HTMLElement>('[data-slot="mermaid-render"]')
const sourceSlot = container.querySelector<HTMLElement>('[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)
Expand Down Expand Up @@ -286,6 +407,7 @@ export function Markdown(
)

let copyCleanup: (() => void) | undefined
let mermaidToggleCleanup: (() => void) | undefined

createEffect(() => {
const container = root()
Expand All @@ -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 &&
Expand All @@ -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
},
Expand All @@ -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 (
Expand Down
49 changes: 47 additions & 2 deletions packages/ui/src/context/marked.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -423,6 +423,27 @@ function renderMathExpressions(html: string): string {
.join("")
}

const GFM_ALERT_TYPES: Record<string, { label: string }> = {
NOTE: { label: "Note" },
TIP: { label: "Tip" },
IMPORTANT: { label: "Important" },
WARNING: { label: "Warning" },
CAUTION: { label: "Caution" },
}

function renderGfmAlerts(html: string): string {
return html.replace(
/<blockquote>\s*<p>\[!(NOTE|TIP|IMPORTANT|WARNING|CAUTION)\]\s*(?:<br\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 `<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>`
},
)
}

async function highlightCodeBlocks(html: string): Promise<string> {
const codeBlockRegex = /<pre><code(?:\s+class="language-([^"]*)")?>([\s\S]*?)<\/code><\/pre>/g
const matches = [...html.matchAll(codeBlockRegex)]
Expand All @@ -444,6 +465,14 @@ async function highlightCodeBlocks(html: string): Promise<string> {
.replace(/&quot;/g, '"')
.replace(/&#39;/g, "'")

// Mermaid blocks: emit render container instead of syntax-highlighted code
if (lang === "mermaid") {
const encoded = btoa(unescape(encodeURIComponent(code)))
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>`
result = result.replace(fullMatch, () => mermaidHtml)
continue
}

let language = lang || "text"
if (!(language in bundledLanguages)) {
language = "text"
Expand Down Expand Up @@ -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, "&amp;")
.replace(/</g, "&lt;")
.replace(/>/g, "&gt;")
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>`
}

const highlighter = await getSharedHighlighter({
themes: ["OpenCode"],
langs: [],
Expand All @@ -509,11 +548,17 @@ export const { use: useMarked, provider: MarkedProvider } = createSimpleContext(
async parse(markdown: string): Promise<string> {
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<string> {
const html = await jsParser.parse(markdown)
return renderGfmAlerts(html)
},
}
},
})
Loading