Files
ai-agent/web/components/safe-rich-html.tsx
T

157 lines
4.8 KiB
TypeScript

"use client"
import { useMemo } from "react"
import { cn } from "@/lib/utils"
type SafeRichHTMLProps = {
html?: string | null
fallback?: string
className?: string
}
const allowedTags = new Set([
"a",
"b",
"blockquote",
"br",
"code",
"div",
"em",
"h1",
"h2",
"h3",
"h4",
"h5",
"h6",
"hr",
"i",
"img",
"li",
"ol",
"p",
"pre",
"span",
"strong",
"table",
"tbody",
"td",
"th",
"thead",
"tr",
"u",
"ul",
])
const allowedAttrs = new Set(["alt", "class", "height", "href", "rel", "src", "target", "title", "width"])
function escapeHTML(value: string) {
return value
.replaceAll("&", "&")
.replaceAll("<", "&lt;")
.replaceAll(">", "&gt;")
.replaceAll('"', "&quot;")
.replaceAll("'", "&#39;")
}
function looksLikeHTML(value: string) {
return /<\/?[a-z][\s\S]*>/i.test(value)
}
function plainTextToHTML(value: string) {
return escapeHTML(value)
.split(/\n{2,}/)
.map((part) => `<p>${part.replaceAll("\n", "<br>")}</p>`)
.join("")
}
function isSafeURL(value: string) {
if (!value) {
return false
}
if (value.startsWith("/")) {
return true
}
try {
const url = new URL(value, window.location.origin)
return ["http:", "https:"].includes(url.protocol)
} catch {
return false
}
}
function sanitizeRichHTML(value: string) {
const source = looksLikeHTML(value) ? value : plainTextToHTML(value)
if (typeof window === "undefined") {
return source
}
const doc = new DOMParser().parseFromString(source, "text/html")
const walker = doc.createTreeWalker(doc.body, NodeFilter.SHOW_ELEMENT)
const elements: Element[] = []
while (walker.nextNode()) {
elements.push(walker.currentNode as Element)
}
for (const element of elements) {
const tag = element.tagName.toLowerCase()
if (!allowedTags.has(tag)) {
element.replaceWith(...Array.from(element.childNodes))
continue
}
for (const attr of Array.from(element.attributes)) {
const name = attr.name.toLowerCase()
const attrValue = attr.value.trim()
if (name.startsWith("on") || !allowedAttrs.has(name)) {
element.removeAttribute(attr.name)
continue
}
if ((name === "href" || name === "src") && !isSafeURL(attrValue)) {
element.removeAttribute(attr.name)
}
}
if (tag === "a" && element.getAttribute("href")) {
element.setAttribute("target", "_blank")
element.setAttribute("rel", "noreferrer noopener")
}
}
return doc.body.innerHTML
}
export function isRichTextEmpty(value?: string | null) {
const normalized = String(value ?? "").trim()
if (!normalized) {
return true
}
if (typeof window === "undefined") {
return normalized.replace(/<[^>]*>/g, "").trim() === ""
}
const doc = new DOMParser().parseFromString(normalized, "text/html")
return (doc.body.textContent ?? "").trim() === "" && doc.body.querySelector("img") === null
}
export function SafeRichHTML({ html, fallback = "-", className }: SafeRichHTMLProps) {
const normalized = String(html ?? "").trim()
const safeHTML = useMemo(() => {
if (!normalized) {
return plainTextToHTML(fallback)
}
return sanitizeRichHTML(normalized)
}, [fallback, normalized])
return (
<div
className={cn(
"break-words text-sm leading-6 [&_a]:text-primary [&_a]:underline [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-muted-foreground/40 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_h1]:mb-2 [&_h1]:text-lg [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:text-base [&_h2]:font-semibold [&_h3]:mb-1 [&_h3]:font-semibold [&_li]:my-1 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:m-0 [&_p+*]:mt-2 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_strong]:font-semibold [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5",
"break-words text-sm leading-6 [&_a]:text-primary [&_a]:underline [&_blockquote]:my-2 [&_blockquote]:border-l-2 [&_blockquote]:border-muted-foreground/40 [&_blockquote]:pl-3 [&_blockquote]:text-muted-foreground [&_code]:rounded [&_code]:bg-muted [&_code]:px-1 [&_code]:py-0.5 [&_h1]:mb-2 [&_h1]:text-lg [&_h1]:font-semibold [&_h2]:mb-2 [&_h2]:text-base [&_h2]:font-semibold [&_h3]:mb-1 [&_h3]:font-semibold [&_img]:my-3 [&_img]:max-w-full [&_img]:rounded-md [&_li]:my-1 [&_ol]:my-2 [&_ol]:list-decimal [&_ol]:pl-5 [&_p]:m-0 [&_p+*]:mt-2 [&_pre]:my-2 [&_pre]:overflow-x-auto [&_pre]:rounded-md [&_pre]:bg-muted [&_pre]:p-3 [&_strong]:font-semibold [&_table]:my-3 [&_table]:w-full [&_table]:border-collapse [&_td]:border [&_td]:px-2 [&_td]:py-1 [&_th]:border [&_th]:bg-muted [&_th]:px-2 [&_th]:py-1 [&_ul]:my-2 [&_ul]:list-disc [&_ul]:pl-5",
className,
)}
dangerouslySetInnerHTML={{ __html: safeHTML }}
/>
)
}