Browse Source

bug-fixes

master
Silberengel 1 month ago
parent
commit
8382af8af3
  1. 418
      src/lib/components/content/MarkdownRenderer.svelte
  2. 27
      src/lib/components/content/mount-component-action.ts
  3. 19
      src/lib/components/layout/ProfileBadge.svelte
  4. 87
      src/lib/services/nostr/nip21-parser.ts
  5. 19
      src/lib/services/security/sanitizer.ts

418
src/lib/components/content/MarkdownRenderer.svelte

@ -1,304 +1,268 @@ @@ -1,304 +1,268 @@
<script lang="ts">
import * as marked from 'marked';
import { marked } from 'marked';
import { sanitizeMarkdown } from '../../services/security/sanitizer.js';
import { findNIP21Links } from '../../services/nostr/nip21-parser.js';
import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js';
import { nip19 } from 'nostr-tools';
import { tick } from 'svelte';
import ProfileBadge from '../layout/ProfileBadge.svelte';
import EmbeddedEvent from './EmbeddedEvent.svelte';
import { mountComponent } from './mount-component-action.js';
interface Props {
content?: string;
content: string;
}
let { content = '' }: Props = $props();
let { content }: Props = $props();
let containerRef = $state<HTMLElement | null>(null);
let rendered = $state('');
let containerElement: HTMLDivElement | null = $state(null);
let mountedElements = $state<Set<HTMLElement>>(new Set());
// Extract pubkey from npub or nprofile
function getPubkeyFromNIP21(parsed: ReturnType<typeof parseNIP21>): string | null {
if (!parsed) return null;
// Process content and render markdown
$effect(() => {
if (!content) {
rendered = '';
return;
if (parsed.type === 'npub') {
// npub decodes directly to pubkey
try {
const decoded = nip19.decode(parsed.data);
if (decoded.type === 'npub') {
return String(decoded.data);
}
} catch {
return null;
}
} else if (parsed.type === 'nprofile') {
// nprofile decodes to object with pubkey property
try {
const decoded = nip19.decode(parsed.data);
if (decoded.type === 'nprofile' && decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) {
return String(decoded.data.pubkey);
}
} catch {
return null;
}
}
// Find NIP-21 links
const links = findNIP21Links(content);
// Replace links with placeholders before markdown parsing
let processed = content;
const placeholders: Map<string, { uri: string; parsed: any }> = new Map();
let offset = 0;
// Process in reverse order to maintain indices
const sortedLinks = [...links].sort((a, b) => b.start - a.start);
return null;
}
for (const link of sortedLinks) {
const placeholder = `\`NIP21PLACEHOLDER${offset}\``;
const before = processed.slice(0, link.start);
const after = processed.slice(link.end);
processed = before + placeholder + after;
placeholders.set(placeholder, { uri: link.uri, parsed: link.parsed });
offset++;
// Escape HTML to prevent XSS
function escapeHtml(text: string): string {
return text
.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
// Parse markdown
const parseResult = marked.parse(processed);
// Convert plain image URLs to img tags
function convertImageUrls(text: string): string {
// Match image URLs (http/https URLs ending in image extensions)
// Pattern: http(s)://... followed by image extension, optionally with query params
// Don't match URLs that are already in markdown ![alt](url) or HTML <img> tags
const imageExtensions = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s<>"']*)?$/i;
const urlPattern = /https?:\/\/[^\s<>"']+/g;
const processHtml = (html: string) => {
let finalHtml = sanitizeMarkdown(html);
let result = text;
const matches: Array<{ url: string; index: number; endIndex: number }> = [];
// Process media elements
finalHtml = finalHtml.replace(/<img([^>]*)>/gi, (match, attrs) => {
if (/loading\s*=/i.test(attrs)) return match;
return `<img${attrs} loading="lazy">`;
});
// Find all URLs
let match;
while ((match = urlPattern.exec(text)) !== null) {
const url = match[0];
if (imageExtensions.test(url)) {
const index = match.index;
const endIndex = index + url.length;
// Check if this URL is already in markdown or HTML
const before = text.substring(Math.max(0, index - 10), index);
const after = text.substring(endIndex, Math.min(text.length, endIndex + 10));
finalHtml = finalHtml.replace(/<video([^>]*)>/gi, (match, attrs) => {
let newAttrs = attrs.replace(/\s*autoplay\s*/gi, ' ');
if (!/preload\s*=/i.test(newAttrs)) {
newAttrs += ' preload="none"';
} else {
newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"');
// Skip if it's in markdown image syntax ![alt](url) or already in <img> tag
if (!before.includes('![') && !before.includes('<img') && !after.startsWith('](') && !after.startsWith('</img>')) {
matches.push({ url, index, endIndex });
}
}
if (!/autoplay\s*=/i.test(newAttrs)) {
newAttrs += ' autoplay="false"';
}
return `<video${newAttrs}>`;
});
finalHtml = finalHtml.replace(/<audio([^>]*)>/gi, (match, attrs) => {
let newAttrs = attrs.replace(/\s*autoplay\s*/gi, ' ');
if (!/preload\s*=/i.test(newAttrs)) {
newAttrs += ' preload="none"';
} else {
newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"');
// Replace from end to start to preserve indices
for (let i = matches.length - 1; i >= 0; i--) {
const { url, index, endIndex } = matches[i];
const escapedUrl = escapeHtml(url);
result = result.substring(0, index) + `<img src="${escapedUrl}" alt="" loading="lazy" />` + result.substring(endIndex);
}
if (!/autoplay\s*=/i.test(newAttrs)) {
newAttrs += ' autoplay="false"';
return result;
}
return `<audio${newAttrs}>`;
});
// Replace placeholders with component placeholders
for (const [placeholder, { uri, parsed }] of placeholders.entries()) {
let replacement = '';
// Process content: replace nostr URIs with HTML span elements and convert image URLs
function processContent(text: string): string {
// First, convert plain image URLs to img tags
let processed = convertImageUrls(text);
try {
if (parsed.type === 'hexID') {
const eventId = parsed.data;
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}"></span>`;
} else {
const decoded: any = nip19.decode(parsed.data);
if (decoded.type === 'npub' || decoded.type === 'nprofile') {
const pubkey = decoded.type === 'npub'
? String(decoded.data)
: (decoded.data?.pubkey ? String(decoded.data.pubkey) : null);
// Find all NIP-21 links (nostr:npub, nostr:nprofile, etc.)
const links = findNIP21Links(processed);
// Only process npub and nprofile for profile badges
const profileLinks = links.filter(link =>
link.parsed.type === 'npub' || link.parsed.type === 'nprofile'
);
// Replace profile links with HTML span elements that have data attributes
// Process from end to start to preserve indices
for (let i = profileLinks.length - 1; i >= 0; i--) {
const link = profileLinks[i];
const pubkey = getPubkeyFromNIP21(link.parsed);
if (pubkey) {
replacement = `<span class="nostr-profile-badge-placeholder inline-block" data-pubkey="${pubkey}"></span>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} else if (decoded.type === 'note' || decoded.type === 'nevent') {
const eventId = decoded.type === 'note'
? String(decoded.data)
: (decoded.data?.id ? String(decoded.data.id) : null);
if (eventId) {
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${eventId}"></span>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
} else if (decoded.type === 'naddr') {
// For naddr, store the bech32 string
replacement = `<span class="nostr-embedded-event-placeholder inline-block" data-event-id="${parsed.data}"></span>`;
} else {
replacement = `<span class="nostr-link nostr-${parsed.type}">${uri}</span>`;
}
// Escape pubkey to prevent XSS (though pubkeys should be safe hex strings)
const escapedPubkey = escapeHtml(pubkey);
// Create a span element that will survive markdown rendering
// Markdown won't process HTML tags, so this will remain as-is
const span = `<span data-nostr-profile data-pubkey="${escapedPubkey}"></span>`;
processed =
processed.slice(0, link.start) +
span +
processed.slice(link.end);
}
} catch {
replacement = `<span class="nostr-link nostr-${parsed.type || 'unknown'}">${uri}</span>`;
}
// Replace placeholder in HTML
const placeholderText = placeholder.replace(/`/g, '');
const codePlaceholder = `<code>${placeholderText}</code>`;
const escapedCodePlaceholder = codePlaceholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedCodePlaceholder, 'g'), replacement);
const escapedPlaceholder = placeholderText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement);
return processed;
}
// Clean up any remaining placeholders
finalHtml = finalHtml.replace(/<code>NIP21PLACEHOLDER\d+<\/code>/g, '');
finalHtml = finalHtml.replace(/NIP21PLACEHOLDER\d+/g, '');
// Configure marked once - ensure images are rendered and HTML is preserved
marked.setOptions({
breaks: true, // Convert line breaks to <br>
gfm: true, // GitHub Flavored Markdown
silent: false // Don't suppress errors
// HTML tags (like <img>) pass through by default in marked
});
return finalHtml;
};
// Render markdown to HTML
function renderMarkdown(text: string): string {
if (!content) return '';
if (parseResult instanceof Promise) {
parseResult.then(processHtml).then(html => {
rendered = html;
// Clear mounted elements when content changes
mountedElements.clear();
// Mount components after a delay
tick().then(() => tick()).then(() => {
mountComponents();
});
});
} else {
rendered = processHtml(parseResult);
// Clear mounted elements when content changes
mountedElements.clear();
// Mount components after a delay
tick().then(() => tick()).then(() => {
mountComponents();
});
const processed = processContent(content);
// Render markdown - this should convert ![alt](url) to <img src="url" alt="alt">
let html: string;
try {
html = marked.parse(processed) as string;
} catch (error) {
console.error('Marked parsing error:', error);
return processed; // Fallback to raw text if parsing fails
}
});
// Mount components into placeholder elements
function mountComponents() {
if (!containerElement) return;
// Sanitize HTML (but preserve our data attributes and image src)
const sanitized = sanitizeMarkdown(html);
// Mount profile badges
const badgeElements = containerElement.querySelectorAll('.nostr-profile-badge-placeholder:not([data-mounted])');
badgeElements.forEach((el) => {
if (mountedElements.has(el as HTMLElement)) return;
const pubkey = el.getAttribute('data-pubkey');
if (pubkey) {
mountComponent(el as HTMLElement, ProfileBadge as any, { pubkey });
el.setAttribute('data-mounted', 'true');
mountedElements.add(el as HTMLElement);
return sanitized;
}
});
// Mount embedded events
const eventElements = containerElement.querySelectorAll('.nostr-embedded-event-placeholder:not([data-mounted])');
eventElements.forEach((el) => {
if (mountedElements.has(el as HTMLElement)) return;
const eventId = el.getAttribute('data-event-id');
if (eventId) {
mountComponent(el as HTMLElement, EmbeddedEvent as any, { eventId });
el.setAttribute('data-mounted', 'true');
mountedElements.add(el as HTMLElement);
}
});
}
const renderedHtml = $derived(renderMarkdown(content));
// Mount components when container is available and rendered changes
// Mount ProfileBadge components after rendering
$effect(() => {
if (containerElement && rendered) {
tick().then(() => tick()).then(() => {
mountComponents();
});
if (!containerRef || !renderedHtml) return;
// Use a small delay to ensure DOM is updated
const timeoutId = setTimeout(() => {
if (!containerRef) return;
// Find all profile placeholders and mount ProfileBadge components
const placeholders = containerRef.querySelectorAll('[data-nostr-profile]');
placeholders.forEach((placeholder) => {
const pubkey = placeholder.getAttribute('data-pubkey');
if (pubkey && !placeholder.hasAttribute('data-mounted')) {
// Mark as mounted to avoid double-mounting
placeholder.setAttribute('data-mounted', 'true');
mountComponent(placeholder as HTMLElement, ProfileBadge as any, { pubkey });
}
});
}, 0);
return () => clearTimeout(timeoutId);
});
</script>
<div class="markdown-content anon-content" bind:this={containerElement}>
{@html rendered}
<div
bind:this={containerRef}
class="markdown-content prose prose-sm dark:prose-invert max-w-none"
>
{@html renderedHtml}
</div>
<style>
.markdown-content {
line-height: var(--line-height);
}
.markdown-content :global(p) {
margin: 0.5em 0;
:global(.markdown-content) {
word-wrap: break-word;
overflow-wrap: break-word;
}
.markdown-content :global(a) {
color: #64748b;
text-decoration: underline;
}
:global(.dark) .markdown-content :global(a) {
color: #94a3b8;
:global(.markdown-content img) {
max-width: 100%;
height: auto;
border-radius: 0.25rem;
margin: 0.5rem 0;
display: block;
visibility: visible !important;
opacity: 1 !important;
}
.markdown-content :global(a:hover) {
color: #475569;
:global(.markdown-content video) {
max-width: 100%;
height: auto;
border-radius: 0.25rem;
margin: 0.5rem 0;
}
:global(.dark) .markdown-content :global(a:hover) {
color: #cbd5e1;
:global(.markdown-content audio) {
width: 100%;
margin: 0.5rem 0;
}
.markdown-content :global(.nostr-link) {
:global(.markdown-content a) {
color: var(--fog-accent, #64748b);
text-decoration: underline;
cursor: pointer;
}
:global(.dark) .markdown-content :global(.nostr-link) {
color: var(--fog-dark-accent, #64748b);
}
.markdown-content :global(.nostr-link:hover) {
color: var(--fog-text, #475569);
}
:global(.dark) .markdown-content :global(.nostr-link:hover) {
color: var(--fog-dark-text, #cbd5e1);
:global(.markdown-content a:hover) {
text-decoration: none;
}
.markdown-content :global(code) {
background: #e2e8f0;
padding: 0.2em 0.4em;
border-radius: 3px;
font-family: monospace;
color: #475569;
:global(.markdown-content code) {
background: var(--fog-border, #e5e7eb);
padding: 0.125rem 0.25rem;
border-radius: 0.25rem;
font-size: 0.875em;
}
:global(.dark) .markdown-content :global(code) {
background: #475569;
color: #cbd5e1;
:global(.dark .markdown-content code) {
background: var(--fog-dark-border, #374151);
}
.markdown-content :global(pre) {
background: #e2e8f0;
padding: 1em;
border-radius: 5px;
:global(.markdown-content pre) {
background: var(--fog-border, #e5e7eb);
padding: 1rem;
border-radius: 0.5rem;
overflow-x: auto;
border: 1px solid #cbd5e1;
}
:global(.dark) .markdown-content :global(pre) {
background: #475569;
border-color: #64748b;
}
.markdown-content :global(img) {
max-width: 100%;
height: auto;
display: block;
margin: 0.5em 0;
filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95);
margin: 0.5rem 0;
}
:global(.dark) .markdown-content :global(img) {
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9);
:global(.dark .markdown-content pre) {
background: var(--fog-dark-border, #374151);
}
.markdown-content :global(.nostr-profile-badge-placeholder),
.markdown-content :global(.nostr-embedded-event-placeholder) {
display: inline-block;
vertical-align: middle;
:global(.markdown-content pre code) {
background: transparent;
padding: 0;
}
.markdown-content :global(span[role="img"]),
.markdown-content :global(.emoji) {
filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95);
display: inline-block;
:global(.markdown-content blockquote) {
border-left: 4px solid var(--fog-border, #e5e7eb);
padding-left: 1rem;
margin: 0.5rem 0;
color: var(--fog-text-light, #6b7280);
}
:global(.dark) .markdown-content :global(span[role="img"]),
:global(.dark) .markdown-content :global(.emoji) {
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9);
:global(.dark .markdown-content blockquote) {
border-color: var(--fog-dark-border, #374151);
color: var(--fog-dark-text-light, #9ca3af);
}
</style>

27
src/lib/components/content/mount-component-action.ts

@ -14,26 +14,49 @@ export function mountComponent( @@ -14,26 +14,49 @@ export function mountComponent(
if (component && typeof component === 'function') {
// Using Svelte 4 component API (enabled via compatibility mode in svelte.config.js)
try {
// Clear the node first to ensure clean mounting
node.innerHTML = '';
// Create a new instance
// In Svelte 5 with compatibility mode, components are instantiated with the Svelte 4 API
instance = new (component as any)({
target: node,
props
props,
// Ensure the component is hydrated and rendered
hydrate: false,
intro: false
});
// Verify the component was mounted
if (!instance) {
console.warn('[mountComponent] Component instance not created', { component, props });
}
} catch (e) {
console.error('Failed to mount component:', e);
console.error('[mountComponent] Failed to mount component:', e, { component, props, node });
}
} else {
console.warn('[mountComponent] Invalid component provided', { component, props });
}
return {
update(newProps: Record<string, any>) {
if (instance && typeof instance.$set === 'function') {
try {
instance.$set(newProps);
} catch (e) {
console.error('[mountComponent] Failed to update component:', e);
}
}
},
destroy() {
if (instance && typeof instance.$destroy === 'function') {
try {
instance.$destroy();
} catch (e) {
console.error('[mountComponent] Failed to destroy component:', e);
}
}
instance = null;
}
};
}

19
src/lib/components/layout/ProfileBadge.svelte

@ -15,30 +15,13 @@ @@ -15,30 +15,13 @@
let activityMessage = $state<string | null>(null);
let imageError = $state(false);
// Debounce requests to allow batching from parent components
let loadTimeout: ReturnType<typeof setTimeout> | null = null;
$effect(() => {
if (pubkey) {
imageError = false; // Reset image error when pubkey changes
// Clear any pending timeout
if (loadTimeout) {
clearTimeout(loadTimeout);
}
// Debounce requests by 200ms to allow parent components to batch fetch
loadTimeout = setTimeout(() => {
// Load immediately - no debounce
loadProfile();
loadStatus();
updateActivityStatus();
}, 200);
return () => {
if (loadTimeout) {
clearTimeout(loadTimeout);
}
};
}
});

87
src/lib/services/nostr/nip21-parser.ts

@ -60,19 +60,28 @@ export function parseNIP21(uri: string): ParsedNIP21 | null { @@ -60,19 +60,28 @@ export function parseNIP21(uri: string): ParsedNIP21 | null {
/**
* Find all NIP-21 URIs in text
* Also finds plain bech32 mentions (npub1..., note1..., etc.) without nostr: prefix
*/
export function findNIP21Links(text: string): Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> {
const links: Array<{ uri: string; start: number; end: number; parsed: ParsedNIP21 }> = [];
const seenPositions = new Set<string>(); // Track positions to avoid duplicates
const seenEntities = new Map<string, { start: number; end: number }>(); // Track entities to prefer nostr: versions
// Match nostr: URIs (case-insensitive)
// First, match nostr: URIs (case-insensitive) - these take priority
// Also match hex event IDs (64 hex characters) as nostr:hexID
const regex = /nostr:((npub|note|nevent|naddr|nprofile)1[a-z0-9]+|[0-9a-f]{64})/gi;
const nostrUriRegex = /nostr:((npub|note|nevent|naddr|nprofile)1[a-z0-9]+|[0-9a-f]{64})/gi;
let match;
while ((match = regex.exec(text)) !== null) {
while ((match = nostrUriRegex.exec(text)) !== null) {
const uri = match[0];
const parsed = parseNIP21(uri);
if (parsed) {
const key = `${match.index}-${match.index + uri.length}`;
if (!seenPositions.has(key)) {
seenPositions.add(key);
// Extract the entity identifier (without nostr: prefix)
const entityId = uri.slice(6); // Remove 'nostr:' prefix
seenEntities.set(entityId.toLowerCase(), { start: match.index, end: match.index + uri.length });
links.push({
uri,
start: match.index,
@ -81,6 +90,78 @@ export function findNIP21Links(text: string): Array<{ uri: string; start: number @@ -81,6 +90,78 @@ export function findNIP21Links(text: string): Array<{ uri: string; start: number
});
}
}
}
// Also match plain bech32 mentions (npub1..., note1..., nevent1..., naddr1..., nprofile1...)
// and hex event IDs (64 hex characters) without nostr: prefix
// Use word boundaries to avoid matching partial strings
// BUT skip if we already found a nostr: version of the same entity
const bech32Regex = /\b((npub|note|nevent|naddr|nprofile)1[a-z0-9]{58,})\b/gi;
while ((match = bech32Regex.exec(text)) !== null) {
const bech32String = match[1];
const key = `${match.index}-${match.index + bech32String.length}`;
// Skip if this position overlaps with a nostr: URI we already found
if (seenPositions.has(key)) continue;
// Skip if we already found a nostr: version of this entity
const existing = seenEntities.get(bech32String.toLowerCase());
if (existing) {
// Check if positions overlap
if (!(match.index >= existing.end || match.index + bech32String.length <= existing.start)) {
continue; // Overlaps with nostr: version, skip
}
}
seenPositions.add(key);
// Create a nostr: URI for parsing
const uri = `nostr:${bech32String}`;
const parsed = parseNIP21(uri);
if (parsed) {
links.push({
uri: bech32String, // Store without nostr: prefix for display
start: match.index,
end: match.index + bech32String.length,
parsed
});
}
}
// Match hex event IDs (64 hex characters) without nostr: prefix
// BUT skip if we already found a nostr: version
const hexIdRegex = /\b([0-9a-f]{64})\b/gi;
while ((match = hexIdRegex.exec(text)) !== null) {
const hexId = match[1];
const key = `${match.index}-${match.index + hexId.length}`;
// Skip if this position overlaps with a nostr: URI we already found
if (seenPositions.has(key)) continue;
// Skip if we already found a nostr: version of this hex ID
const existing = seenEntities.get(hexId.toLowerCase());
if (existing) {
// Check if positions overlap
if (!(match.index >= existing.end || match.index + hexId.length <= existing.start)) {
continue; // Overlaps with nostr: version, skip
}
}
seenPositions.add(key);
// Create a nostr: URI for parsing
const uri = `nostr:${hexId}`;
const parsed = parseNIP21(uri);
if (parsed) {
links.push({
uri: hexId, // Store without nostr: prefix
start: match.index,
end: match.index + hexId.length,
parsed
});
}
}
// Sort by start position
links.sort((a, b) => a.start - b.start);
return links;
}

19
src/lib/services/security/sanitizer.ts

@ -3,12 +3,18 @@ @@ -3,12 +3,18 @@
*/
import DOMPurify from 'dompurify';
import { browser } from '$app/environment';
/**
* Sanitize HTML content
*/
export function sanitizeHtml(dirty: string): string {
return DOMPurify.sanitize(dirty, {
// Only sanitize in browser - during SSR, return as-is (will be sanitized on client)
if (!browser) {
return dirty;
}
const config = {
ALLOWED_TAGS: [
'p',
'br',
@ -35,10 +41,15 @@ export function sanitizeHtml(dirty: string): string { @@ -35,10 +41,15 @@ export function sanitizeHtml(dirty: string): string {
'div',
'span'
],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload', 'loading', 'autoplay', 'data-pubkey', 'data-event-id', 'data-placeholder'],
ALLOWED_ATTR: ['href', 'src', 'alt', 'title', 'class', 'controls', 'preload', 'loading', 'autoplay', 'width', 'height', 'data-pubkey', 'data-event-id', 'data-placeholder', 'data-nostr-profile', 'data-mounted'],
ALLOW_DATA_ATTR: true,
KEEP_CONTENT: true
});
KEEP_CONTENT: true,
// Ensure images are preserved
FORBID_TAGS: [],
FORBID_ATTR: []
};
return DOMPurify.sanitize(dirty, config);
}
/**

Loading…
Cancel
Save