/** Detect empty TipTap / legacy plain description values. */ export function isSceneDescriptionEmpty(html: string | null | undefined): boolean { if (html == null) return true; const trimmed = html.trim(); if (trimmed === '') return true; const text = trimmed .replace(//gi, ' ') .replace(/<\/(p|div|h[1-6]|li|blockquote)>/gi, ' ') .replace(/<[^>]+>/g, '') .replace(/ /gi, ' ') .replace(/ /g, ' ') .replace(/\s+/g, ' ') .trim(); return text.length === 0; } /** Persist empty editor as '' instead of empty paragraph markup. */ export function normalizeSceneDescriptionHtml(html: string): string { return isSceneDescriptionEmpty(html) ? '' : html.trim(); } const ALLOWED_TAGS = new Set([ 'P', 'BR', 'STRONG', 'B', 'EM', 'I', 'U', 'S', 'H2', 'H3', 'UL', 'OL', 'LI', 'BLOCKQUOTE', 'A', 'CODE', 'SPAN', ]); /** Sanitize TipTap HTML for safe preview rendering. */ export function sanitizeSceneDescriptionHtml(html: string): string { if (typeof document === 'undefined') return html; if (isSceneDescriptionEmpty(html)) return ''; if (!/<[a-z][\s\S]*>/i.test(html)) { const esc = document.createElement('div'); esc.textContent = html; return esc.innerHTML; } const template = document.createElement('template'); template.innerHTML = html.trim(); const container = document.createElement('div'); container.appendChild(template.content.cloneNode(true)); const walk = (node: Node) => { if (node.nodeType === Node.ELEMENT_NODE) { const el = node as HTMLElement; const tag = el.tagName; if (!ALLOWED_TAGS.has(tag)) { const parent = el.parentNode; if (parent) { while (el.firstChild) parent.insertBefore(el.firstChild, el); parent.removeChild(el); } return; } for (const attr of [...el.attributes]) { const name = attr.name.toLowerCase(); if (tag === 'A' && (name === 'href' || name === 'target' || name === 'rel')) { if (name === 'href') { const href = attr.value.trim(); if (!/^(https?:|mailto:)/i.test(href)) { el.removeAttribute(attr.name); } } continue; } el.removeAttribute(attr.name); } if (tag === 'A') { el.setAttribute('rel', 'noopener noreferrer'); el.setAttribute('target', '_blank'); } } for (const child of [...node.childNodes]) walk(child); }; // Walk children only — never treat the container as a removable tag // (that would unwrap it and leave container.innerHTML empty). for (const child of [...container.childNodes]) walk(child); return container.innerHTML; }