const ALLOWED_TAGS = new Set([ 'p', 'br', 'hr', 'div', 'span', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'strong', 'em', 'b', 'i', 'u', 's', 'del', 'ins', 'blockquote', 'pre', 'code', 'a', 'img', 'dl', 'dt', 'dd', 'sup', 'sub', ]); const ALLOWED_ATTRS: Record> = { '*': new Set(['class']), a: new Set(['href', 'title']), img: new Set(['src', 'alt', 'width', 'height']), td: new Set(['colspan', 'rowspan']), th: new Set(['colspan', 'rowspan']), }; const URL_ATTRS = new Set(['href', 'src']); const SAFE_URL_RE = /^(?:https?|mailto|tel):|^$/i; const TAG_RE = /<\/?([a-zA-Z][a-zA-Z0-9]*)\b[^>]*\/?>/g; const ATTR_RE = /([a-zA-Z][a-zA-Z0-9-]*)\s*=\s*(?:"([^"]*)"|'([^']*)')/g; export function sanitizeHtml(html: string): string { if (!html) return ''; return html.replace(TAG_RE, (fullMatch, tagName) => { const tag = tagName.toLowerCase(); if (!ALLOWED_TAGS.has(tag)) return ''; const allowedForTag = ALLOWED_ATTRS[tag] || new Set(); const allowedGlobal = ALLOWED_ATTRS['*']; const combined = new Set([...allowedForTag, ...allowedGlobal]); const cleaned = fullMatch.replace(ATTR_RE, (_, attrName, dqVal, sqVal) => { const attr = attrName.toLowerCase(); const val = dqVal ?? sqVal ?? ''; if (!combined.has(attr)) return ''; if (URL_ATTRS.has(attr) && !SAFE_URL_RE.test(val)) return ''; return ` ${attr}="${val}"`; }); return cleaned; }); }