"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 (
) }