diff --git a/web/components/safe-rich-html.tsx b/web/components/safe-rich-html.tsx
new file mode 100644
index 0000000..569f10e
--- /dev/null
+++ b/web/components/safe-rich-html.tsx
@@ -0,0 +1,148 @@
+"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",
+ "li",
+ "ol",
+ "p",
+ "pre",
+ "span",
+ "strong",
+ "u",
+ "ul",
+])
+
+const allowedAttrs = new Set(["class", "href", "rel", "target", "title"])
+
+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" && !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 (
+
+ )
+}