import createDOMPurify from 'dompurify' import { JSDOM } from 'jsdom' import { toText } from './string.ts' const window = new JSDOM('').window const DOMPurify = createDOMPurify(window) export function escapeHtml(value: unknown): string { return toText(value) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') } export function cleanHtml(html: string): string { return DOMPurify.sanitize(html, { ALLOWED_TAGS: [ 'a', 'abbr', 'address', 'audio', 'b', 'bdi', 'blockquote', 'br', 'button', 'caption', 'circle', 'clipPath', 'cite', 'code', 'data', 'dd', 'del', 'details', 'dfn', 'defs', 'div', 'dl', 'dt', 'em', 'figcaption', 'figure', 'footer', 'g', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'header', 'hr', 'i', 'iframe', 'input', 'ins', 'img', 'kbd', 'li', 'line', 'mark', 'menclose', 'math', 'mfenced', 'mfrac', 'meter', 'mi', 'mjx-assistive-mml', 'mjx-container', 'mn', 'mo', 'mover', 'mpadded', 'mprescripts', 'mroot', 'mrow', 'mspace', 'msqrt', 'mstyle', 'msub', 'msubsup', 'msup', 'mtable', 'mtd', 'mtext', 'mtr', 'munder', 'munderover', 'ol', 'option', 'p', 'path', 'polygon', 'polyline', 'pre', 'progress', 'rect', 'ruby', 'rt', 's', 'samp', 'select', 'small', 'span', 'sub', 'sup', 'svg', 'strong', 'source', 'summary', 'table', 'tbody', 'td', 'text', 'textPath', 'textarea', 'th', 'thead', 'time', 'tr', 'u', 'ul', 'use', 'var', 'video', 'wbr', ], ALLOWED_ATTR: [ 'alt', 'aria-expanded', 'aria-hidden', 'class', 'columnspacing', 'controls', 'cols', 'cx', 'cy', 'd', 'datetime', 'dir', 'fill', 'fill-opacity', 'frameborder', 'height', 'hidden', 'href', 'id', 'jax', 'mathcolor', 'mathvariant', 'max', 'min', 'name', 'opacity', 'open', 'points', 'poster', 'preserveAspectRatio', 'r', 'rel', 'readonly', 'role', 'rows', 'rowspacing', 'scope', 'scriptlevel', 'size', 'src', 'stretchy', 'stroke', 'stroke-linecap', 'stroke-linejoin', 'stroke-opacity', 'stroke-width', 'style', 'target', 'title', 'transform', 'type', 'unselectable', 'value', 'viewBox', 'width', 'xmlns', 'xmlns:xlink', 'x', 'x1', 'x2', 'xlink:href', 'y', 'y1', 'y2', ], ALLOW_DATA_ATTR: true, }) } export function htmlText(html: string): string { const dom = new JSDOM(`
${html}
`) return (dom.window.document.querySelector('main')?.textContent || '') .replace(/\s+/g, ' ') .trim() } function normalizeAttrValue(value: string | null): string { const text = String(value || '').trim() if (!text) return '' const braceMatch = text.match(/^\{([\s\S]*)\}$/) return braceMatch ? braceMatch[1].trim() : text } function parseGap(value: string | null): number { const normalized = normalizeAttrValue(value) const gap = Number(normalized || '8') return Number.isFinite(gap) ? gap : 8 } function transformBadge(node: Element): void { const document = node.ownerDocument if (!document) return const badge = document.createElement('span') const theme = normalizeAttrValue(node.getAttribute('theme')) || 'default' const size = normalizeAttrValue(node.getAttribute('size')) || 'md' const text = normalizeAttrValue(node.getAttribute('text')) || node.textContent || '' badge.className = `j-badge is-${theme} is-${size}` badge.textContent = text node.replaceWith(badge) } function transformGroup(node: Element): void { const document = node.ownerDocument if (!document) return const group = document.createElement('div') group.className = 'vp-group' group.style.display = 'flex' group.style.gap = `${parseGap(node.getAttribute('gap'))}px` while (node.firstChild) { group.appendChild(node.firstChild) } node.replaceWith(group) } export function transformComponentTags(html: string): string { if (!/<(?:\/?)(?:Badge|badge|Group|group)\b/.test(html)) return html const normalized = html.replace(/<(Badge|badge)([^>]*)\/>/g, '<$1$2>') const dom = new JSDOM(`
${normalized}
`) const document = dom.window.document const main = document.querySelector('main') if (!main) return html for (const node of Array.from(main.querySelectorAll('badge'))) { transformBadge(node) } for (const node of Array.from(main.querySelectorAll('group'))) { transformGroup(node) } return main.innerHTML || html }