| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308 |
- import { useMarked } from "../context/marked"
- import { useI18n } from "../context/i18n"
- import DOMPurify from "dompurify"
- import morphdom from "morphdom"
- 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<string, Entry>()
- 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: '<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"/>',
- }
- function sanitize(html: string) {
- if (!DOMPurify.isSupported) return ""
- return DOMPurify.sanitize(html, config)
- }
- 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)
- }
- function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
- const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
- 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 markCodeLinks = () => {
- 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)
- }
- }
- 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)
- }
- markCodeLinks()
- 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<string, boolean>
- },
- ) {
- const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
- const marked = useMarked()
- const i18n = useI18n()
- const [root, setRoot] = createSignal<HTMLDivElement>()
- 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: "" },
- )
- let copySetupTimer: ReturnType<typeof setTimeout> | undefined
- let copyCleanup: (() => void) | undefined
- createEffect(() => {
- const container = root()
- const content = html()
- if (!container) return
- if (isServer) return
- if (!content) {
- container.innerHTML = ""
- return
- }
- const temp = document.createElement("div")
- temp.innerHTML = content
- morphdom(container, temp, {
- childrenOnly: true,
- onBeforeElUpdated: (fromEl, toEl) => {
- if (fromEl.isEqualNode(toEl)) return false
- if (fromEl.getAttribute("data-component") === "markdown-code") {
- const fromPre = fromEl.querySelector("pre")
- const toPre = toEl.querySelector("pre")
- if (fromPre && toPre && !fromPre.isEqualNode(toPre)) {
- morphdom(fromPre, toPre)
- }
- return false
- }
- return true
- },
- onBeforeNodeDiscarded: (node) => {
- if (node instanceof Element) {
- if (node.getAttribute("data-slot") === "markdown-copy-button") return false
- if (node.getAttribute("data-component") === "markdown-code") return false
- }
- return true
- },
- })
- if (copySetupTimer) clearTimeout(copySetupTimer)
- copySetupTimer = setTimeout(() => {
- if (copyCleanup) copyCleanup()
- copyCleanup = setupCodeCopy(container, {
- copy: i18n.t("ui.message.copy"),
- copied: i18n.t("ui.message.copied"),
- })
- }, 150)
- })
- onCleanup(() => {
- if (copySetupTimer) clearTimeout(copySetupTimer)
- if (copyCleanup) copyCleanup()
- })
- return (
- <div
- data-component="markdown"
- classList={{
- ...(local.classList ?? {}),
- [local.class ?? ""]: !!local.class,
- }}
- ref={setRoot}
- {...others}
- />
- )
- }
|