markdown.tsx 9.0 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308
  1. import { useMarked } from "../context/marked"
  2. import { useI18n } from "../context/i18n"
  3. import DOMPurify from "dompurify"
  4. import morphdom from "morphdom"
  5. import { checksum } from "@opencode-ai/util/encode"
  6. import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
  7. import { isServer } from "solid-js/web"
  8. type Entry = {
  9. hash: string
  10. html: string
  11. }
  12. const max = 200
  13. const cache = new Map<string, Entry>()
  14. if (typeof window !== "undefined" && DOMPurify.isSupported) {
  15. DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
  16. if (!(node instanceof HTMLAnchorElement)) return
  17. if (node.target !== "_blank") return
  18. const rel = node.getAttribute("rel") ?? ""
  19. const set = new Set(rel.split(/\s+/).filter(Boolean))
  20. set.add("noopener")
  21. set.add("noreferrer")
  22. node.setAttribute("rel", Array.from(set).join(" "))
  23. })
  24. }
  25. const config = {
  26. USE_PROFILES: { html: true, mathMl: true },
  27. SANITIZE_NAMED_PROPS: true,
  28. FORBID_TAGS: ["style"],
  29. FORBID_CONTENTS: ["style", "script"],
  30. }
  31. const iconPaths = {
  32. copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
  33. check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
  34. }
  35. function sanitize(html: string) {
  36. if (!DOMPurify.isSupported) return ""
  37. return DOMPurify.sanitize(html, config)
  38. }
  39. type CopyLabels = {
  40. copy: string
  41. copied: string
  42. }
  43. const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
  44. function codeUrl(text: string) {
  45. const href = text.trim().replace(/[),.;!?]+$/, "")
  46. if (!urlPattern.test(href)) return
  47. try {
  48. const url = new URL(href)
  49. return url.toString()
  50. } catch {
  51. return
  52. }
  53. }
  54. function createIcon(path: string, slot: string) {
  55. const icon = document.createElement("div")
  56. icon.setAttribute("data-component", "icon")
  57. icon.setAttribute("data-size", "small")
  58. icon.setAttribute("data-slot", slot)
  59. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
  60. svg.setAttribute("data-slot", "icon-svg")
  61. svg.setAttribute("fill", "none")
  62. svg.setAttribute("viewBox", "0 0 20 20")
  63. svg.setAttribute("aria-hidden", "true")
  64. svg.innerHTML = path
  65. icon.appendChild(svg)
  66. return icon
  67. }
  68. function createCopyButton(labels: CopyLabels) {
  69. const button = document.createElement("button")
  70. button.type = "button"
  71. button.setAttribute("data-component", "icon-button")
  72. button.setAttribute("data-variant", "secondary")
  73. button.setAttribute("data-size", "small")
  74. button.setAttribute("data-slot", "markdown-copy-button")
  75. button.setAttribute("aria-label", labels.copy)
  76. button.setAttribute("data-tooltip", labels.copy)
  77. button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
  78. button.appendChild(createIcon(iconPaths.check, "check-icon"))
  79. return button
  80. }
  81. function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) {
  82. if (copied) {
  83. button.setAttribute("data-copied", "true")
  84. button.setAttribute("aria-label", labels.copied)
  85. button.setAttribute("data-tooltip", labels.copied)
  86. return
  87. }
  88. button.removeAttribute("data-copied")
  89. button.setAttribute("aria-label", labels.copy)
  90. button.setAttribute("data-tooltip", labels.copy)
  91. }
  92. function setupCodeCopy(root: HTMLDivElement, labels: CopyLabels) {
  93. const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
  94. const updateLabel = (button: HTMLButtonElement) => {
  95. const copied = button.getAttribute("data-copied") === "true"
  96. setCopyState(button, labels, copied)
  97. }
  98. const ensureWrapper = (block: HTMLPreElement) => {
  99. const parent = block.parentElement
  100. if (!parent) return
  101. const wrapped = parent.getAttribute("data-component") === "markdown-code"
  102. if (wrapped) return
  103. const wrapper = document.createElement("div")
  104. wrapper.setAttribute("data-component", "markdown-code")
  105. parent.replaceChild(wrapper, block)
  106. wrapper.appendChild(block)
  107. wrapper.appendChild(createCopyButton(labels))
  108. }
  109. const markCodeLinks = () => {
  110. const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
  111. for (const code of codeNodes) {
  112. const href = codeUrl(code.textContent ?? "")
  113. const parentLink =
  114. code.parentElement instanceof HTMLAnchorElement && code.parentElement.classList.contains("external-link")
  115. ? code.parentElement
  116. : null
  117. if (!href) {
  118. if (parentLink) parentLink.replaceWith(code)
  119. continue
  120. }
  121. if (parentLink) {
  122. parentLink.href = href
  123. continue
  124. }
  125. const link = document.createElement("a")
  126. link.href = href
  127. link.className = "external-link"
  128. link.target = "_blank"
  129. link.rel = "noopener noreferrer"
  130. code.parentNode?.replaceChild(link, code)
  131. link.appendChild(code)
  132. }
  133. }
  134. const handleClick = async (event: MouseEvent) => {
  135. const target = event.target
  136. if (!(target instanceof Element)) return
  137. const button = target.closest('[data-slot="markdown-copy-button"]')
  138. if (!(button instanceof HTMLButtonElement)) return
  139. const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
  140. const content = code?.textContent ?? ""
  141. if (!content) return
  142. const clipboard = navigator?.clipboard
  143. if (!clipboard) return
  144. await clipboard.writeText(content)
  145. setCopyState(button, labels, true)
  146. const existing = timeouts.get(button)
  147. if (existing) clearTimeout(existing)
  148. const timeout = setTimeout(() => setCopyState(button, labels, false), 2000)
  149. timeouts.set(button, timeout)
  150. }
  151. const blocks = Array.from(root.querySelectorAll("pre"))
  152. for (const block of blocks) {
  153. ensureWrapper(block)
  154. }
  155. markCodeLinks()
  156. const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
  157. for (const button of buttons) {
  158. if (button instanceof HTMLButtonElement) updateLabel(button)
  159. }
  160. root.addEventListener("click", handleClick)
  161. return () => {
  162. root.removeEventListener("click", handleClick)
  163. for (const timeout of timeouts.values()) {
  164. clearTimeout(timeout)
  165. }
  166. }
  167. }
  168. function touch(key: string, value: Entry) {
  169. cache.delete(key)
  170. cache.set(key, value)
  171. if (cache.size <= max) return
  172. const first = cache.keys().next().value
  173. if (!first) return
  174. cache.delete(first)
  175. }
  176. export function Markdown(
  177. props: ComponentProps<"div"> & {
  178. text: string
  179. cacheKey?: string
  180. class?: string
  181. classList?: Record<string, boolean>
  182. },
  183. ) {
  184. const [local, others] = splitProps(props, ["text", "cacheKey", "class", "classList"])
  185. const marked = useMarked()
  186. const i18n = useI18n()
  187. const [root, setRoot] = createSignal<HTMLDivElement>()
  188. const [html] = createResource(
  189. () => local.text,
  190. async (markdown) => {
  191. if (isServer) return ""
  192. const hash = checksum(markdown)
  193. const key = local.cacheKey ?? hash
  194. if (key && hash) {
  195. const cached = cache.get(key)
  196. if (cached && cached.hash === hash) {
  197. touch(key, cached)
  198. return cached.html
  199. }
  200. }
  201. const next = await marked.parse(markdown)
  202. const safe = sanitize(next)
  203. if (key && hash) touch(key, { hash, html: safe })
  204. return safe
  205. },
  206. { initialValue: "" },
  207. )
  208. let copySetupTimer: ReturnType<typeof setTimeout> | undefined
  209. let copyCleanup: (() => void) | undefined
  210. createEffect(() => {
  211. const container = root()
  212. const content = html()
  213. if (!container) return
  214. if (isServer) return
  215. if (!content) {
  216. container.innerHTML = ""
  217. return
  218. }
  219. const temp = document.createElement("div")
  220. temp.innerHTML = content
  221. morphdom(container, temp, {
  222. childrenOnly: true,
  223. onBeforeElUpdated: (fromEl, toEl) => {
  224. if (fromEl.isEqualNode(toEl)) return false
  225. if (fromEl.getAttribute("data-component") === "markdown-code") {
  226. const fromPre = fromEl.querySelector("pre")
  227. const toPre = toEl.querySelector("pre")
  228. if (fromPre && toPre && !fromPre.isEqualNode(toPre)) {
  229. morphdom(fromPre, toPre)
  230. }
  231. return false
  232. }
  233. return true
  234. },
  235. onBeforeNodeDiscarded: (node) => {
  236. if (node instanceof Element) {
  237. if (node.getAttribute("data-slot") === "markdown-copy-button") return false
  238. if (node.getAttribute("data-component") === "markdown-code") return false
  239. }
  240. return true
  241. },
  242. })
  243. if (copySetupTimer) clearTimeout(copySetupTimer)
  244. copySetupTimer = setTimeout(() => {
  245. if (copyCleanup) copyCleanup()
  246. copyCleanup = setupCodeCopy(container, {
  247. copy: i18n.t("ui.message.copy"),
  248. copied: i18n.t("ui.message.copied"),
  249. })
  250. }, 150)
  251. })
  252. onCleanup(() => {
  253. if (copySetupTimer) clearTimeout(copySetupTimer)
  254. if (copyCleanup) copyCleanup()
  255. })
  256. return (
  257. <div
  258. data-component="markdown"
  259. classList={{
  260. ...(local.classList ?? {}),
  261. [local.class ?? ""]: !!local.class,
  262. }}
  263. ref={setRoot}
  264. {...others}
  265. />
  266. )
  267. }