96 lines
2.3 KiB
TypeScript
96 lines
2.3 KiB
TypeScript
import { useMarked } from "../context/marked"
|
|
import DOMPurify from "dompurify"
|
|
import { checksum } from "@opencode-ai/util/encode"
|
|
import { ComponentProps, createResource, 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"],
|
|
}
|
|
|
|
function sanitize(html: string) {
|
|
if (!DOMPurify.isSupported) return ""
|
|
return DOMPurify.sanitize(html, config)
|
|
}
|
|
|
|
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 [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: "" },
|
|
)
|
|
return (
|
|
<div
|
|
data-component="markdown"
|
|
classList={{
|
|
...(local.classList ?? {}),
|
|
[local.class ?? ""]: !!local.class,
|
|
}}
|
|
innerHTML={html.latest}
|
|
{...others}
|
|
/>
|
|
)
|
|
}
|