markdown.tsx 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391
  1. import { useMarked } from "../context/marked"
  2. import { useI18n } from "../context/i18n"
  3. import DOMPurify from "dompurify"
  4. import morphdom from "morphdom"
  5. import { marked, type Tokens } from "marked"
  6. import { checksum } from "@opencode-ai/util/encode"
  7. import { ComponentProps, createEffect, createResource, createSignal, onCleanup, splitProps } from "solid-js"
  8. import { isServer } from "solid-js/web"
  9. type Entry = {
  10. hash: string
  11. html: string
  12. }
  13. const max = 200
  14. const cache = new Map<string, Entry>()
  15. if (typeof window !== "undefined" && DOMPurify.isSupported) {
  16. DOMPurify.addHook("afterSanitizeAttributes", (node: Element) => {
  17. if (!(node instanceof HTMLAnchorElement)) return
  18. if (node.target !== "_blank") return
  19. const rel = node.getAttribute("rel") ?? ""
  20. const set = new Set(rel.split(/\s+/).filter(Boolean))
  21. set.add("noopener")
  22. set.add("noreferrer")
  23. node.setAttribute("rel", Array.from(set).join(" "))
  24. })
  25. }
  26. const config = {
  27. USE_PROFILES: { html: true, mathMl: true },
  28. SANITIZE_NAMED_PROPS: true,
  29. FORBID_TAGS: ["style"],
  30. FORBID_CONTENTS: ["style", "script"],
  31. }
  32. const iconPaths = {
  33. copy: '<path d="M6.2513 6.24935V2.91602H17.0846V13.7493H13.7513M13.7513 6.24935V17.0827H2.91797V6.24935H13.7513Z" stroke="currentColor" stroke-linecap="round"/>',
  34. check: '<path d="M5 11.9657L8.37838 14.7529L15 5.83398" stroke="currentColor" stroke-linecap="square"/>',
  35. }
  36. function sanitize(html: string) {
  37. if (!DOMPurify.isSupported) return ""
  38. return DOMPurify.sanitize(html, config)
  39. }
  40. function escape(text: string) {
  41. return text
  42. .replace(/&/g, "&amp;")
  43. .replace(/</g, "&lt;")
  44. .replace(/>/g, "&gt;")
  45. .replace(/\"/g, "&quot;")
  46. .replace(/'/g, "&#39;")
  47. }
  48. function fallback(markdown: string) {
  49. return escape(markdown).replace(/\r\n?/g, "\n").replace(/\n/g, "<br>")
  50. }
  51. type Block = {
  52. raw: string
  53. mode: "full" | "live"
  54. }
  55. function references(markdown: string) {
  56. return /^\[[^\]]+\]:\s+\S+/m.test(markdown) || /^\[\^[^\]]+\]:\s+/m.test(markdown)
  57. }
  58. function incomplete(raw: string) {
  59. const open = raw.match(/^[ \t]{0,3}(`{3,}|~{3,})/)
  60. if (!open) return false
  61. const mark = open[1]
  62. if (!mark) return false
  63. const char = mark[0]
  64. const size = mark.length
  65. const last = raw.trimEnd().split("\n").at(-1)?.trim() ?? ""
  66. return !new RegExp(`^[\\t ]{0,3}${char}{${size},}[\\t ]*$`).test(last)
  67. }
  68. function blocks(markdown: string, streaming: boolean) {
  69. if (!streaming || references(markdown)) return [{ raw: markdown, mode: "full" }] satisfies Block[]
  70. const tokens = marked.lexer(markdown)
  71. const last = tokens.findLast((token) => token.type !== "space")
  72. if (!last || last.type !== "code") return [{ raw: markdown, mode: "full" }] satisfies Block[]
  73. const code = last as Tokens.Code
  74. if (!incomplete(code.raw)) return [{ raw: markdown, mode: "full" }] satisfies Block[]
  75. const head = tokens
  76. .slice(
  77. 0,
  78. tokens.findLastIndex((token) => token.type !== "space"),
  79. )
  80. .map((token) => token.raw)
  81. .join("")
  82. if (!head) return [{ raw: code.raw, mode: "live" }] satisfies Block[]
  83. return [
  84. { raw: head, mode: "full" },
  85. { raw: code.raw, mode: "live" },
  86. ] satisfies Block[]
  87. }
  88. type CopyLabels = {
  89. copy: string
  90. copied: string
  91. }
  92. const urlPattern = /^https?:\/\/[^\s<>()`"']+$/
  93. function codeUrl(text: string) {
  94. const href = text.trim().replace(/[),.;!?]+$/, "")
  95. if (!urlPattern.test(href)) return
  96. try {
  97. const url = new URL(href)
  98. return url.toString()
  99. } catch {
  100. return
  101. }
  102. }
  103. function createIcon(path: string, slot: string) {
  104. const icon = document.createElement("div")
  105. icon.setAttribute("data-component", "icon")
  106. icon.setAttribute("data-size", "small")
  107. icon.setAttribute("data-slot", slot)
  108. const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg")
  109. svg.setAttribute("data-slot", "icon-svg")
  110. svg.setAttribute("fill", "none")
  111. svg.setAttribute("viewBox", "0 0 20 20")
  112. svg.setAttribute("aria-hidden", "true")
  113. svg.innerHTML = path
  114. icon.appendChild(svg)
  115. return icon
  116. }
  117. function createCopyButton(labels: CopyLabels) {
  118. const button = document.createElement("button")
  119. button.type = "button"
  120. button.setAttribute("data-component", "icon-button")
  121. button.setAttribute("data-variant", "secondary")
  122. button.setAttribute("data-size", "small")
  123. button.setAttribute("data-slot", "markdown-copy-button")
  124. button.setAttribute("aria-label", labels.copy)
  125. button.setAttribute("data-tooltip", labels.copy)
  126. button.appendChild(createIcon(iconPaths.copy, "copy-icon"))
  127. button.appendChild(createIcon(iconPaths.check, "check-icon"))
  128. return button
  129. }
  130. function setCopyState(button: HTMLButtonElement, labels: CopyLabels, copied: boolean) {
  131. if (copied) {
  132. button.setAttribute("data-copied", "true")
  133. button.setAttribute("aria-label", labels.copied)
  134. button.setAttribute("data-tooltip", labels.copied)
  135. return
  136. }
  137. button.removeAttribute("data-copied")
  138. button.setAttribute("aria-label", labels.copy)
  139. button.setAttribute("data-tooltip", labels.copy)
  140. }
  141. function ensureCodeWrapper(block: HTMLPreElement, labels: CopyLabels) {
  142. const parent = block.parentElement
  143. if (!parent) return
  144. const wrapped = parent.getAttribute("data-component") === "markdown-code"
  145. if (!wrapped) {
  146. const wrapper = document.createElement("div")
  147. wrapper.setAttribute("data-component", "markdown-code")
  148. parent.replaceChild(wrapper, block)
  149. wrapper.appendChild(block)
  150. wrapper.appendChild(createCopyButton(labels))
  151. return
  152. }
  153. const buttons = Array.from(parent.querySelectorAll('[data-slot="markdown-copy-button"]')).filter(
  154. (el): el is HTMLButtonElement => el instanceof HTMLButtonElement,
  155. )
  156. if (buttons.length === 0) {
  157. parent.appendChild(createCopyButton(labels))
  158. return
  159. }
  160. for (const button of buttons.slice(1)) {
  161. button.remove()
  162. }
  163. }
  164. function markCodeLinks(root: HTMLDivElement) {
  165. const codeNodes = Array.from(root.querySelectorAll(":not(pre) > code"))
  166. for (const code of codeNodes) {
  167. const href = codeUrl(code.textContent ?? "")
  168. const parentLink =
  169. code.parentElement instanceof HTMLAnchorElement && code.parentElement.classList.contains("external-link")
  170. ? code.parentElement
  171. : null
  172. if (!href) {
  173. if (parentLink) parentLink.replaceWith(code)
  174. continue
  175. }
  176. if (parentLink) {
  177. parentLink.href = href
  178. continue
  179. }
  180. const link = document.createElement("a")
  181. link.href = href
  182. link.className = "external-link"
  183. link.target = "_blank"
  184. link.rel = "noopener noreferrer"
  185. code.parentNode?.replaceChild(link, code)
  186. link.appendChild(code)
  187. }
  188. }
  189. function decorate(root: HTMLDivElement, labels: CopyLabels) {
  190. const blocks = Array.from(root.querySelectorAll("pre"))
  191. for (const block of blocks) {
  192. ensureCodeWrapper(block, labels)
  193. }
  194. markCodeLinks(root)
  195. }
  196. function setupCodeCopy(root: HTMLDivElement, getLabels: () => CopyLabels) {
  197. const timeouts = new Map<HTMLButtonElement, ReturnType<typeof setTimeout>>()
  198. const updateLabel = (button: HTMLButtonElement) => {
  199. const labels = getLabels()
  200. const copied = button.getAttribute("data-copied") === "true"
  201. setCopyState(button, labels, copied)
  202. }
  203. const handleClick = async (event: MouseEvent) => {
  204. const target = event.target
  205. if (!(target instanceof Element)) return
  206. const button = target.closest('[data-slot="markdown-copy-button"]')
  207. if (!(button instanceof HTMLButtonElement)) return
  208. const code = button.closest('[data-component="markdown-code"]')?.querySelector("code")
  209. const content = code?.textContent ?? ""
  210. if (!content) return
  211. const clipboard = navigator?.clipboard
  212. if (!clipboard) return
  213. await clipboard.writeText(content)
  214. const labels = getLabels()
  215. setCopyState(button, labels, true)
  216. const existing = timeouts.get(button)
  217. if (existing) clearTimeout(existing)
  218. const timeout = setTimeout(() => setCopyState(button, labels, false), 2000)
  219. timeouts.set(button, timeout)
  220. }
  221. decorate(root, getLabels())
  222. const buttons = Array.from(root.querySelectorAll('[data-slot="markdown-copy-button"]'))
  223. for (const button of buttons) {
  224. if (button instanceof HTMLButtonElement) updateLabel(button)
  225. }
  226. root.addEventListener("click", handleClick)
  227. return () => {
  228. root.removeEventListener("click", handleClick)
  229. for (const timeout of timeouts.values()) {
  230. clearTimeout(timeout)
  231. }
  232. }
  233. }
  234. function touch(key: string, value: Entry) {
  235. cache.delete(key)
  236. cache.set(key, value)
  237. if (cache.size <= max) return
  238. const first = cache.keys().next().value
  239. if (!first) return
  240. cache.delete(first)
  241. }
  242. export function Markdown(
  243. props: ComponentProps<"div"> & {
  244. text: string
  245. cacheKey?: string
  246. streaming?: boolean
  247. class?: string
  248. classList?: Record<string, boolean>
  249. },
  250. ) {
  251. const [local, others] = splitProps(props, ["text", "cacheKey", "streaming", "class", "classList"])
  252. const marked = useMarked()
  253. const i18n = useI18n()
  254. const [root, setRoot] = createSignal<HTMLDivElement>()
  255. const [html] = createResource(
  256. () => ({
  257. text: local.text,
  258. key: local.cacheKey,
  259. streaming: local.streaming ?? false,
  260. }),
  261. async (src) => {
  262. if (isServer) return fallback(src.text)
  263. if (!src.text) return ""
  264. const base = src.key ?? checksum(src.text)
  265. return Promise.all(
  266. blocks(src.text, src.streaming).map(async (block, index) => {
  267. const hash = checksum(block.raw)
  268. const key = base ? `${base}:${index}:${block.mode}` : hash
  269. if (key && hash) {
  270. const cached = cache.get(key)
  271. if (cached && cached.hash === hash) {
  272. touch(key, cached)
  273. return cached.html
  274. }
  275. }
  276. const next = await Promise.resolve(marked.parse(block.raw))
  277. const safe = sanitize(next)
  278. if (key && hash) touch(key, { hash, html: safe })
  279. return safe
  280. }),
  281. )
  282. .then((list) => list.join(""))
  283. .catch(() => fallback(src.text))
  284. },
  285. { initialValue: fallback(local.text) },
  286. )
  287. let copyCleanup: (() => void) | undefined
  288. createEffect(() => {
  289. const container = root()
  290. const content = local.text ? (html.latest ?? html() ?? "") : ""
  291. if (!container) return
  292. if (isServer) return
  293. if (!content) {
  294. container.innerHTML = ""
  295. return
  296. }
  297. const labels = {
  298. copy: i18n.t("ui.message.copy"),
  299. copied: i18n.t("ui.message.copied"),
  300. }
  301. const temp = document.createElement("div")
  302. temp.innerHTML = content
  303. decorate(temp, labels)
  304. morphdom(container, temp, {
  305. childrenOnly: true,
  306. onBeforeElUpdated: (fromEl, toEl) => {
  307. if (
  308. fromEl instanceof HTMLButtonElement &&
  309. toEl instanceof HTMLButtonElement &&
  310. fromEl.getAttribute("data-slot") === "markdown-copy-button" &&
  311. toEl.getAttribute("data-slot") === "markdown-copy-button" &&
  312. fromEl.getAttribute("data-copied") === "true"
  313. ) {
  314. setCopyState(toEl, labels, true)
  315. }
  316. if (fromEl.isEqualNode(toEl)) return false
  317. return true
  318. },
  319. })
  320. if (!copyCleanup)
  321. copyCleanup = setupCodeCopy(container, () => ({
  322. copy: i18n.t("ui.message.copy"),
  323. copied: i18n.t("ui.message.copied"),
  324. }))
  325. })
  326. onCleanup(() => {
  327. if (copyCleanup) copyCleanup()
  328. })
  329. return (
  330. <div
  331. data-component="markdown"
  332. classList={{
  333. ...(local.classList ?? {}),
  334. [local.class ?? ""]: !!local.class,
  335. }}
  336. ref={setRoot}
  337. {...others}
  338. />
  339. )
  340. }