import { highlightTextSegments, type TextHighlight } from "@/lib/guides/highlights"; const ALLOWED_TAGS = new Set([ "a", "blockquote", "br", "code", "em", "li", "ol", "p", "pre", "s", "span", "strong", "u", "ul", ]); const BLOCKED_CONTENT = /<(script|style|iframe|object|embed|svg|math|template)\b[^>]*>[\s\S]*?<\/\1\s*>/gi; const HTML_COMMENT = //g; const TAG = /<\/?([a-zA-Z][a-zA-Z0-9-]*)([^>]*)>/g; const ATTRIBUTE = /([a-zA-Z_:][a-zA-Z0-9_:.-]*)\s*=\s*("[^"]*"|'[^']*'|[^\s"'=<>`]+)/g; const HEX_COLOR = /^#[0-9a-fA-F]{6}$/; const RGB_COLOR = /^rgb\(\s*(\d{1,3})\s*,\s*(\d{1,3})\s*,\s*(\d{1,3})\s*\)$/i; function normalizeRichTextColor(value: string): string | null { const color = value.trim(); if (HEX_COLOR.test(color)) return color.toUpperCase(); const rgb = color.match(RGB_COLOR); if (!rgb) return null; const channels = rgb.slice(1).map(Number); if (channels.some((channel) => channel > 255)) return null; return `#${channels.map((channel) => channel.toString(16).padStart(2, "0")).join("")}`.toUpperCase(); } export function sanitizeRichTextUrl(value: string): string { const url = value.trim(); if (!url) return ""; try { const parsed = new URL(url, "https://example.invalid"); return ["http:", "https:", "mailto:"].includes(parsed.protocol) ? url : ""; } catch { return ""; } } function escapeAttribute(value: string): string { return value .replaceAll("&", "&") .replaceAll('"', """) .replaceAll("<", "<") .replaceAll(">", ">"); } function escapeHtml(value: string): string { return escapeAttribute(value).replaceAll("'", "'"); } function attributeValue(rawAttributes: string, name: string): string { for (const match of rawAttributes.matchAll(ATTRIBUTE)) { if (match[1].toLowerCase() === name) { return match[2].replace(/^['"]|['"]$/g, ""); } } return ""; } export function sanitizeRichTextHtml(value: string): string { return value .replace(BLOCKED_CONTENT, "") .replace(HTML_COMMENT, "") .replace(TAG, (tag, rawName: string, rawAttributes: string) => { const name = rawName.toLowerCase(); if (!ALLOWED_TAGS.has(name)) return ""; if (tag.startsWith("`; if (name === "br") return "
"; if (name === "a") { const href = sanitizeRichTextUrl(attributeValue(rawAttributes, "href")); return href ? `` : ""; } if (name === "span") { const style = attributeValue(rawAttributes, "style"); const color = normalizeRichTextColor( style.match(/(?:^|;)\s*color\s*:\s*([^;]+?)\s*(?:;|$)/i)?.[1] ?? "", ); return color ? `` : ""; } return `<${name}>`; }); } export function isRichTextHtml(value: string): boolean { return /<(?:p|br|strong|em|u|s|a|span|ul|ol|li|blockquote|pre|code)\b/i.test(value); } export function normalizeExtraRichText( body: string, highlights: readonly TextHighlight[] = [], ): string { if (isRichTextHtml(body)) return sanitizeRichTextHtml(body); const content = highlightTextSegments(body, highlights) .map((segment) => { const text = escapeHtml(segment.text).replaceAll("\n", "
"); return segment.color && HEX_COLOR.test(segment.color) ? `${text}` : text; }) .join(""); return content ? `

${content}

` : ""; }