| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391 |
- import { useMarked } from "../context/marked"
- import { useI18n } from "../context/i18n"
- import DOMPurify from "dompurify"
- import morphdom from "morphdom"
- import { marked, type Tokens } from "marked"
- 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)
- }
- function escape(text: string) {
- return text
- .replace(/&/g, "&")
- .replace(/</g, "<")
- .replace(/>/g, ">")
- .replace(/\"/g, """)
- .replace(/'/g, "'")
- }
- function fallback(markdown: string) {
- return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
- }
- type Block = {
- raw: string
- mode: "full" | "live"
- }
- function references(markdown: string) {
- return /^\[[^\]]+\]:\s+\S+/m.test(markdown) || /^\[\^[^\]]+\]:\s+/m.test(markdown)
- }
- function incomplete(raw: string) {
- const open = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
- if (!open) return false
- const mark = open[1]
- if (!mark) return false
- const char = mark[0]
- const size = mark.length
- const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
- return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
- }
- function blocks(markdown: string, streaming: boolean) {
- if (!streaming || references(markdown)) return [{ raw: markdown, mode: "full" }] satisfies Block[]
- const tokens = marked.lexer(markdown)
- const last = tokens.findLast((token) => token.type !== "space")
- if (!last || last.type !== "code") return [{ raw: markdown, mode: "full" }] satisfies Block[]
- const code = last as Tokens.Code
- if (!incomplete(code.raw)) return [{ raw: markdown, mode: "full" }] satisfies Block[]
- const head = tokens
- .slice(
- 0,
- tokens.findLastIndex((token) => token.type !== "space"),
- )
- .map((token) => token.raw)
- .join("")
- if (!head) return [{ raw: code.raw, mode: "live" }] satisfies Block[]
- return [
- { raw: head, mode: "full" },
- { raw: code.raw, mode: "live" },
- ] satisfies Block[]
- }
- 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 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")
- parent.replaceChild(wrapper, block)
- wrapper.appendChild(block)
- wrapper.appendChild(createCopyButton(labels))
- return
- }
- 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 decorate(root: HTMLDivElement, labels: CopyLabels) {
- const blocks = Array.from(root.querySelectorAll("pre"))
- for (const block of blocks) {
- ensureCodeWrapper(block, labels)
- }
- markCodeLinks(root)
- }
- function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
- const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
- 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)
- }
- decorate(root, getLabels())
- 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
- streaming?: boolean
- class?: string
- classList?: Record<string, boolean>
- },
- ) {
- const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
- const marked = useMarked()
- const i18n = useI18n()
- const [root, setRoot] = createSignal<HTMLDivElement>()
- const [html] = createResource(
- () => ({
- text: local.text,
- key: local.cacheKey,
- streaming: local.streaming ?? false,
- }),
- async (src) => {
- if (isServer) return fallback(src.text)
- if (!src.text) return ""
- const base = src.key ?? checksum(src.text)
- return Promise.all(
- blocks(src.text, src.streaming).map(async (block, index) => {
- const hash = checksum(block.raw)
- const key = base ? `${base}:${index}:${block.mode}` : hash
- if (key && hash) {
- const cached = cache.get(key)
- if (cached && cached.hash === hash) {
- touch(key, cached)
- return cached.html
- }
- }
- const next = await Promise.resolve(marked.parse(block.raw))
- const safe = sanitize(next)
- if (key && hash) touch(key, { hash, html: safe })
- return safe
- }),
- )
- .then((list) => list.join(""))
- .catch(() => fallback(src.text))
- },
- { initialValue: fallback(local.text) },
- )
- let copyCleanup: (() => void) | undefined
- createEffect(() => {
- const container = root()
- const content = local.text ? (html.latest ?? html() ?? "") : ""
- if (!container) return
- if (isServer) return
- if (!content) {
- container.innerHTML = ""
- return
- }
- const labels = {
- copy: i18n.t("ui.message.copy"),
- copied: i18n.t("ui.message.copied"),
- }
- const temp = document.createElement("div")
- temp.innerHTML = content
- decorate(temp, labels)
- morphdom(container, temp, {
- childrenOnly: true,
- onBeforeElUpdated: (fromEl, toEl) => {
- if (
- fromEl instanceof HTMLButtonElement &&
- toEl instanceof HTMLButtonElement &&
- fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
- toEl.getAttribute("data-slot") === "markdown-copy-button" &&
- fromEl.getAttribute("data-copied") === "true"
- ) {
- setCopyState(toEl, labels, true)
- }
- if (fromEl.isEqualNode(toEl)) return false
- return true
- },
- })
- if (!copyCleanup)
- copyCleanup = setupCodeCopy(container, () => ({
- copy: i18n.t("ui.message.copy"),
- copied: i18n.t("ui.message.copied"),
- }))
- })
- onCleanup(() => {
- if (copyCleanup) copyCleanup()
- })
- return (
- <div
- data-component="markdown"
- classList={{
- ...(local.classList ?? {}),
- [local.class ?? ""]: !!local.class,
- }}
- ref={setRoot}
- {...others}
- />
- )
- }
|