"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("<", "<")
.replaceAll(">", ">")
.replaceAll('"', """)
.replaceAll("'", "'")
}
function looksLikeHTML(value: string) {
return /<\/?[a-z][\s\S]*>/i.test(value)
}
function plainTextToHTML(value: string) {
return escapeHTML(value)
.split(/\n{2,}/)
.map((part) => `
${part.replaceAll("\n", "
")}
`)
.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 (
)
}