import { useMarked } from "../context/marked" import { useI18n } from "../context/i18n" import DOMPurify from "dompurify" import { checksum } from "@opencode-ai/util/encode" import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js" import { isServer } from "solid-js/web" type Entry = { hash: string html: string } const max = 200 const cache = new Map() if (typeof window !== "undefined" && DOMPurify.isSupported) { DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => { if (!(node instanceof HTMLAnchorElement)) return if (node.target !== "_blank") return const rel = node.getAttribute("rel") ?? "" const set = new Set(rel.split(/\s+/).filter(Boolean)) set.add("noopener") set.add("noreferrer") node.setAttribute("rel", Array.from(set).join(" ")) }) } const config = { USE_PROFILES: { html: true, mathMl: true }, SANITIZE_NAMED_PROPS: true, FORBID_TAGS: ["style"], FORBID_CONTENTS: ["style", "script"], } const iconPaths = { copy: '', check: '', } function sanitize(html: string) { if (!DOMPurify.isSupported) return "" return DOMPurify.sanitize(html, config) } type CopyLabels = { copy: string copied: string } 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", "normal") button.setAttribute("data-slot", "markdown-copy-button") button.setAttribute("aria-label", labels.copy) button.setAttribute("title", 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("title", labels.copied) return } button.removeAttribute("data-copied") button.setAttribute("aria-label", labels.copy) button.setAttribute("title", labels.copy) } function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) { const timeouts = new Map>() const updateLabel = (button: HTMLButtonElement) => { const copied = button.getAttribute("data-copied") === "true" setCopyState(button, labels, copied) } const ensureWrapper = (block: HTMLPreElement) => { const parent = block.parentElement if (!parent) return const wrapped = parent.getAttribute("data-component") === "markdown-code" if (wrapped) return const wrapper = document.createElement("div") wrapper.setAttribute("data-component", "markdown-code") parent.replaceChild(wrapper, block) wrapper.appendChild(block) wrapper.appendChild(createCopyButton(labels)) } 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) 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 blocks = Array.from(root.querySelectorAll("pre")) for (const block of blocks) { ensureWrapper(block) } 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 touch(key: string, value: Entry) { cache.delete(key) cache.set(key, value) if (cache.size <= max) return const first = cache.keys().next().value if (!first) return cache.delete(first) } export function Markdown( props: ComponentProps<"div"> & { text: string cacheKey?: string class?: string classList?: Record }, ) { const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"]) const marked = useMarked() const i18n = useI18n() const [root, setRoot] = createSignal() const [html] = createResource( () => local.text, async (markdown) => { if (isServer) return "" const hash = checksum(markdown) const key = local.cacheKey ?? hash if (key && hash) { const cached = cache.get(key) if (cached && cached.hash === hash) { touch(key, cached) return cached.html } } const next = await marked.parse(markdown) const safe = sanitize(next) if (key && hash) touch(key, { hash, html: safe }) return safe }, { initialValue: "" }, ) createEffect(() => { const container = root() const content = html() if (!container) return if (!content) return if (isServer) return const cleanup = setupCodeCopy(container, { copy: i18n.t("ui.message.copy"), copied: i18n.t("ui.message.copied"), }) onCleanup(cleanup) }) return (
) }