46 lines
1.8 KiB
TypeScript
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, '"')
|
|
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>
|
|
))}
|
|
</>
|
|
)
|
|
}
|