Files
KabundiNjuki ff743a1397 Initial commit
2026-10-05 16:40:36 +03:00

46 lines
1.8 KiB
TypeScript

// Renders formatted text from the content module through an allow-list: paragraphs, line breaks,
// bold, italic, lists and safe links only. Everything else is dropped, so pasted content can
// never inject scripts or styles into the site.
const ALLOWED = new Set(['p', 'br', 'strong', 'b', 'em', 'i', 'ul', 'ol', 'li', 'a'])
const SAFE_LINK = /^(\/|#|https:\/\/|mailto:|tel:)/i
export function sanitize(html: string): string {
return (html || '')
.replace(/<(script|style|iframe|object|embed|svg|math)[\s\S]*?<\/\1>/gi, '')
.replace(/<!--[\s\S]*?-->/g, '')
.replace(/<\s*(\/?)\s*([a-z0-9]+)([^>]*)>/gi, (_m, close: string, tag: string, attrs: string) => {
const t = tag.toLowerCase()
if (!ALLOWED.has(t)) return ''
if (close) return t === 'br' ? '' : `</${t}>`
if (t === 'a') {
const href = (attrs.match(/href\s*=\s*"([^"]*)"/i) || attrs.match(/href\s*=\s*'([^']*)'/i) || [])[1] || ''
if (!SAFE_LINK.test(href.trim())) return '<a>'
const external = /^https:\/\//i.test(href)
const safe = href.replace(/"/g, '&quot;')
return external ? `<a href="${safe}" target="_blank" rel="noopener noreferrer">` : `<a href="${safe}">`
}
return `<${t}>`
})
}
export function RichText({ html, className }: { html: string; className?: string }) {
if (!html) return null
return <div className={className} dangerouslySetInnerHTML={{ __html: sanitize(html) }} />
}
// Plain text with \n turned into line breaks (for headings that allow two lines).
export function Lines({ text }: { text: string }) {
const parts = (text || '').split(/\\n|\n/)
return (
<>
{parts.map((part, i) => (
<span key={i}>
{part}
{i < parts.length - 1 && <br />}
</span>
))}
</>
)
}