import { useMarked } from "@opencode-ai/ui/context/marked" import { useI18n } from "@opencode-ai/ui/context/i18n" import morphdom from "morphdom" import { checksum } from "@opencode-ai/core/util/encode" import { ComponentProps, createEffect, createMemo, createResource, createSignal, createUniqueId, onCleanup, splitProps, } from "solid-js" import { isServer } from "solid-js/web" import { bundledLanguages } from "shiki" import { canReusePendingBlock, project, type Block, type Projection } from "./markdown-stream" import { disposeStreamingCode, highlightStreamingCode, MarkdownWorkerDisposedError, MarkdownWorkerSupersededError, MarkdownWorkerUnavailableError, } from "./markdown-worker" 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" type RenderedBlock = | (MarkdownCacheEntry & { key: string; mode: Exclude }) | { key: string mode: "code" raw: string hash: string language: string complete: boolean generation: number stable: MarkdownToken[] unstable: MarkdownToken[] } type RenderResult = { text: string blocks: RenderedBlock[] } const renderedCodeTokens = new WeakMap() const iconPaths = { copy: '', check: '', } function escape(text: string) { return text .replace(/&/g, "&") .replace(//g, ">") .replace(/"/g, """) .replace(/'/g, "'") } function fallback(markdown: string) { return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "
") } async function code(text: string, language: string | undefined, key: string, complete = false) { const name = language && language in bundledLanguages ? language : "text" try { const result = await highlightStreamingCode(key, text, name, complete) return { language: name, generation: result.generation, stable: result.stable, unstable: result.unstable } } catch (error) { if ( !(error instanceof MarkdownWorkerDisposedError) && !(error instanceof MarkdownWorkerSupersededError) && !(error instanceof MarkdownWorkerUnavailableError) ) console.error("Markdown highlighting worker failed", error) return { language: name, generation: 0, stable: [], unstable: [[text, ""] as MarkdownToken] } } } type CopyLabels = { copy: string copied: string } const urlPattern = /^https?:\/\/[^\s<>()`"']+$/ function codeUrl(text: string) { const href = text.trim().replace(/[),.;!?]+$/, "") if (!urlPattern.test(href)) return try { const url = new URL(href) return url.toString() } catch { return } } function createIcon(path: string, slot: string) { const icon = document.createElement("div") icon.setAttribute("data-component", "icon") icon.setAttribute("data-size", "small") icon.setAttribute("data-slot", slot) const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg") svg.setAttribute("data-slot", "icon-svg") svg.setAttribute("fill", "none") svg.setAttribute("viewBox", "0 0 20 20") svg.setAttribute("aria-hidden", "true") svg.innerHTML = path icon.appendChild(svg) return icon } function createCopyButton(labels: CopyLabels) { const button = document.createElement("button") button.type = "button" button.setAttribute("data-component", "icon-button") button.setAttribute("data-variant", "secondary") button.setAttribute("data-size", "small") button.setAttribute("data-slot", "markdown-copy-button") button.setAttribute("aria-label", labels.copy) button.setAttribute("data-tooltip", labels.copy) button.appendChild(createIcon(iconPaths.copy, "copy-icon")) button.appendChild(createIcon(iconPaths.check, "check-icon")) return button } function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) { if (copied) { button.setAttribute("data-copied", "true") button.setAttribute("aria-label", labels.copied) button.setAttribute("data-tooltip", labels.copied) return } button.removeAttribute("data-copied") button.setAttribute("aria-label", labels.copy) button.setAttribute("data-tooltip", labels.copy) } const shellLanguages = new Set(["bash", "sh", "shell", "zsh", "fish", "console", "terminal"]) function codeKind(language: string | undefined) { const value = language?.toLowerCase() if (!value) return if (shellLanguages.has(value)) return "shell" } function codeLanguage(block: HTMLPreElement) { const code = block.querySelector("code") if (!(code instanceof HTMLElement)) return return code.className.match(/(?:^|\s)language-([^\s]+)/)?.[1] } function applyCodeMetadata(wrapper: HTMLElement, language: string | undefined) { if (!document.body.hasAttribute("data-new-layout")) { delete wrapper.dataset.language delete wrapper.dataset.codeKind return } if (language) wrapper.dataset.language = language else delete wrapper.dataset.language const kind = codeKind(language) if (kind) wrapper.dataset.codeKind = kind else delete wrapper.dataset.codeKind } function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) { const parent = block.parentElement if (!parent) return const wrapped = parent.getAttribute("data-component") === "markdown-code" if (!wrapped) { const wrapper = document.createElement("div") wrapper.setAttribute("data-component", "markdown-code") applyCodeMetadata(wrapper, codeLanguage(block)) parent.replaceChild(wrapper, block) wrapper.appendChild(block) wrapper.appendChild(createCopyButton(labels)) return } applyCodeMetadata(parent, codeLanguage(block)) const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter( (el): el is HTMLButtonElement => el instanceof HTMLButtonElement, ) if (buttons.length === 0) { parent.appendChild(createCopyButton(labels)) return } for (const button of buttons.slice(1)) { button.remove() } } function markCodeLinks(root: HTMLDivElement) { const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code")) for (const code of codeNodes) { const href = codeUrl(code.textContent ?? "") const parentLink = code.parentElement instanceof HTMLAnchorElement && code.parentElement.classList.contains("external-link") ? code.parentElement : null if (!href) { if (parentLink) parentLink.replaceWith(code) continue } if (parentLink) { parentLink.href = href continue } const link = document.createElement("a") link.href = href link.className = "external-link" link.target = "_blank" link.rel = "noopener noreferrer" code.parentNode?.replaceChild(link, code) link.appendChild(code) } } function markInlineCode(root: HTMLDivElement) { const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code")) for (const code of codeNodes) { if (!(code instanceof HTMLElement)) continue delete code.dataset.inlineCodeKind const kind = inlineCodeKind(code.textContent ?? "") if (kind) code.dataset.inlineCodeKind = kind } } function decorate(root: HTMLDivElement, labels: CopyLabels) { const blocks = Array.from(root.querySelectorAll("pre")) for (const block of blocks) { ensureCodeWrapper(block, labels) } if (!document.body.hasAttribute("data-new-layout")) return markInlineCode(root) markCodeLinks(root) } function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) { const timeouts = new Map>() const updateLabel = (button: HTMLButtonElement) => { const labels = getLabels() const copied = button.getAttribute("data-copied") === "true" setCopyState(button, labels, copied) } const handleClick = async (event: MouseEvent) => { const target = event.target if (!(target instanceof Element)) return const button = target.closest('[data-slot="markdown-copy-button"]') if (!(button instanceof HTMLButtonElement)) return const code = button.closest('[data-component="markdown-code"]')?.querySelector("code") const content = code?.textContent ?? "" if (!content) return const clipboard = navigator?.clipboard if (!clipboard) return await clipboard.writeText(content) const labels = getLabels() setCopyState(button, labels, true) const existing = timeouts.get(button) if (existing) clearTimeout(existing) const timeout = setTimeout(() => setCopyState(button, labels, false), 2000) timeouts.set(button, timeout) } const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]')) for (const button of buttons) { if (button instanceof HTMLButtonElement) updateLabel(button) } root.addEventListener("click", handleClick) return () => { root.removeEventListener("click", handleClick) for (const timeout of timeouts.values()) { clearTimeout(timeout) } } } function initialResult(text: string, key: string | undefined, projection: Projection, owner: string): RenderResult { if (!text) return { text, blocks: [] } const base = key ?? checksum(text) if (base) { const blocks = projection.blocks.flatMap((block, index) => { if (block.mode === "code") return [] const cacheKey = `${base}:${index}:${block.mode}` const cached = getCachedMarkdown(cacheKey) if (cached?.raw !== block.raw) return [] return [{ key: `${owner}:${cacheKey}`, mode: block.mode, ...cached }] }) if (blocks.length === projection.blocks.length) return { text, blocks } } return { text, blocks: [ { key: "initial", mode: "full", raw: text, hash: checksum(text) ?? "", html: fallback(text), }, ], } } export function Markdown( props: ComponentProps<"div"> & { text: string cacheKey?: string streaming?: boolean class?: string classList?: Record }, ) { const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"]) const marked = useMarked() const i18n = useI18n() const [root, setRoot] = createSignal() const owner = createUniqueId() const activeCodeKeys = new Set() const completedCode = new Map>() const projection = createMemo((previous: Projection | undefined) => project(previous, local.text, local.streaming ?? false), ) const [html] = createResource( () => { return { text: local.text, key: local.cacheKey, projection: projection(), } }, async (src) => { if (isServer) return { text: src.text, blocks: [ { key: "server", mode: "full" as const, raw: src.text, hash: checksum(src.text) ?? "", html: fallback(src.text), }, ], } satisfies RenderResult if (!src.text) return { text: src.text, blocks: [] } satisfies RenderResult const base = src.key ?? checksum(src.text) return Promise.all( src.projection.blocks.map(async (block, index) => { const key = base ? `${base}:${index}:${block.mode}` : undefined const blockKey = markdownBlockKey(owner, src.key, index, block.mode) if (block.mode === "code") { const cached = completedCode.get(blockKey) if (block.complete && cached?.raw === block.raw) return cached const result = await code(block.src, block.language, blockKey, block.complete) const rendered = { key: blockKey, mode: block.mode, raw: block.raw, hash: String(block.raw.length), complete: !!block.complete, ...result, } if (block.complete) completedCode.set(blockKey, rendered) return rendered } if (key) { const cached = getCachedMarkdown(key) if (cached?.raw === block.raw) { touchCachedMarkdown(key, cached) return { key: blockKey, mode: block.mode, ...cached } } } const hash = checksum(block.raw) const safe = sanitizeMarkdown(await Promise.resolve(marked.parse(block.src))) if (key && hash) touchCachedMarkdown(key, { raw: block.raw, hash, html: safe }) return { key: blockKey, mode: block.mode, raw: block.raw, hash: hash ?? "", html: safe } }), ) .then((blocks) => ({ text: src.text, blocks }) satisfies RenderResult) .catch( () => ({ text: src.text, blocks: [ { key: base ?? "fallback", mode: "full" as const, raw: src.text, hash: checksum(src.text) ?? "", html: fallback(src.text), }, ], }) satisfies RenderResult, ) }, { initialValue: initialResult(local.text, local.cacheKey, projection(), owner), }, ) let copyCleanup: (() => void) | undefined createEffect(() => { const container = root() const result = html.latest ?? html() const projected = projection() const content = local.text ? pendingBlocks(result, projected, local.cacheKey, owner) : [] if (!container) return if (isServer) return if (content.length === 0) { container.innerHTML = "" return } const labels = { copy: i18n.t("ui.message.copy"), copied: i18n.t("ui.message.copied"), } const nextCodeKeys = new Set(content.filter((block) => block.mode === "code").map((block) => block.key)) activeCodeKeys.forEach((key) => { if (!nextCodeKeys.has(key)) disposeCode(key) }) activeCodeKeys.clear() nextCodeKeys.forEach((key) => activeCodeKeys.add(key)) content.forEach((block, index) => updateBlock(container, index, block, labels)) while (container.children.length > content.length) container.lastElementChild?.remove() container .querySelectorAll('[data-slot="markdown-copy-button"]') .forEach((button) => setCopyState(button, labels, button.dataset.copied === "true")) if (!copyCleanup) copyCleanup = setupCodeCopy(container, () => ({ copy: i18n.t("ui.message.copy"), copied: i18n.t("ui.message.copied"), })) }) onCleanup(() => { if (copyCleanup) copyCleanup() activeCodeKeys.forEach(disposeCode) completedCode.clear() }) return (
) } function pendingBlocks( result: RenderResult | undefined, projection: Projection | undefined, cacheKey: string | undefined, owner: string, ) { if (!result) return [] if (!projection || result.text === projection.text) return result.blocks const initial = result.blocks.length === 1 && result.blocks[0]?.key === "initial" return projection.blocks.map((block, index) => { const current = initial ? undefined : result.blocks[index] if (current && canReusePendingBlock(current, block)) return current const key = markdownBlockKey(owner, cacheKey, index, block.mode) if (block.mode !== "code") return { key, mode: block.mode, raw: block.raw, hash: String(block.raw.length), html: fallback(block.src) } return { key, mode: block.mode, raw: block.raw, hash: String(block.raw.length), language: block.language ?? "text", complete: !!block.complete, stable: [], generation: 0, unstable: [[block.src, ""] as MarkdownToken], } }) } function disposeCode(key: string) { disposeStreamingCode(key) } function updateBlock(container: HTMLDivElement, index: number, block: RenderedBlock, labels: CopyLabels) { const current = container.children[index] if (block.mode === "code") { updateCodeBlock(container, current, block, labels) return } if ( current instanceof HTMLDivElement && current.dataset.markdownKey === block.key && current.dataset.markdownHash === block.hash ) return const next = document.createElement("div") next.dataset.markdownBlock = "" next.dataset.markdownKey = block.key next.dataset.markdownHash = block.hash next.style.display = "contents" next.innerHTML = block.html decorate(next, labels) if (!(current instanceof HTMLDivElement)) { container.appendChild(next) return } morphdom(current, next, { onBeforeElUpdated: (fromEl, toEl) => { if ( fromEl instanceof HTMLButtonElement && toEl instanceof HTMLButtonElement && fromEl.getAttribute("data-slot") === "markdown-copy-button" && toEl.getAttribute("data-slot") === "markdown-copy-button" ) { return false } if (fromEl.isEqualNode(toEl)) return false return true }, }) } function updateCodeBlock( container: HTMLDivElement, current: Element | undefined, block: Extract, labels: CopyLabels, ) { const existing = current instanceof HTMLDivElement && current.dataset.markdownKey === block.key ? current : undefined const next = existing ?? document.createElement("div") next.dataset.markdownBlock = "" next.dataset.markdownKey = block.key next.dataset.markdownHash = block.hash next.dataset.markdownComplete = block.complete ? "true" : "false" next.style.display = "contents" const code = existing?.querySelector("code") if (code instanceof HTMLElement) { const wrapper = code.closest('[data-component="markdown-code"]') if (wrapper instanceof HTMLElement) applyCodeMetadata(wrapper, block.language) code.className = `language-${block.language}` const previous = renderedCodeTokens.get(next) const reset = shouldResetCodeTokens(previous, { language: block.language, generation: block.generation, stableCount: block.stable.length, raw: block.raw, }) const stableCount = reset ? 0 : previous!.stableCount const tail = [...block.stable.slice(stableCount), ...block.unstable] const prior = reset ? [] : previous!.unstable const prefix = prior.findIndex((token, index) => !sameToken(token, tail[index])) const keep = stableCount + (prefix < 0 ? Math.min(prior.length, tail.length) : prefix) while (code.children.length > keep) code.lastElementChild?.remove() tail .slice(keep - stableCount) .map(createTokenSpan) .forEach((span) => code.appendChild(span)) renderedCodeTokens.set(next, { language: block.language, generation: block.generation, stableCount: block.stable.length, unstable: block.unstable, raw: block.raw, }) return } const wrapper = document.createElement("div") wrapper.setAttribute("data-component", "markdown-code") applyCodeMetadata(wrapper, block.language) const pre = document.createElement("pre") pre.className = "shiki OpenCode" const codeElement = document.createElement("code") codeElement.className = `language-${block.language}` ;[...block.stable, ...block.unstable].map(createTokenSpan).forEach((span) => codeElement.appendChild(span)) pre.appendChild(codeElement) wrapper.appendChild(pre) wrapper.appendChild(createCopyButton(labels)) next.appendChild(wrapper) renderedCodeTokens.set(next, { language: block.language, generation: block.generation, stableCount: block.stable.length, unstable: block.unstable, raw: block.raw, }) if (current) current.replaceWith(next) else container.appendChild(next) } function sameToken(left: MarkdownToken, right: MarkdownToken | undefined) { return !!right && left[0] === right[0] && left[1] === right[1] } function createTokenSpan(token: MarkdownToken) { const span = document.createElement("span") span.setAttribute("style", token[1]) span.textContent = token[0] return span }