5 changed files with 336 additions and 274 deletions
@ -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); |
||||
return null; |
||||
} |
||||
|
||||
// Escape HTML to prevent XSS |
||||
function escapeHtml(text: string): string { |
||||
return text |
||||
.replace(/&/g, '&') |
||||
.replace(/</g, '<') |
||||
.replace(/>/g, '>') |
||||
.replace(/"/g, '"') |
||||
.replace(/'/g, '''); |
||||
} |
||||
|
||||
// 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  or HTML <img> tags |
||||
const imageExtensions = /\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s<>"']*)?$/i; |
||||
const urlPattern = /https?:\/\/[^\s<>"']+/g; |
||||
|
||||
// Replace links with placeholders before markdown parsing |
||||
let processed = content; |
||||
const placeholders: Map<string, { uri: string; parsed: any }> = new Map(); |
||||
let offset = 0; |
||||
let result = text; |
||||
const matches: Array<{ url: string; index: number; endIndex: number }> = []; |
||||
|
||||
// Process in reverse order to maintain indices |
||||
const sortedLinks = [...links].sort((a, b) => b.start - a.start); |
||||
// 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)); |
||||
|
||||
// Skip if it's in markdown image syntax  or already in <img> tag |
||||
if (!before.includes(' && !after.startsWith('</img>')) { |
||||
matches.push({ url, index, endIndex }); |
||||
} |
||||
} |
||||
} |
||||
|
||||
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++; |
||||
// 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); |
||||
} |
||||
|
||||
return result; |
||||
} |
||||
|
||||
// Parse markdown |
||||
const parseResult = marked.parse(processed); |
||||
// 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); |
||||
|
||||
const processHtml = (html: string) => { |
||||
let finalHtml = sanitizeMarkdown(html); |
||||
|
||||
// Process media elements |
||||
finalHtml = finalHtml.replace(/<img([^>]*)>/gi, (match, attrs) => { |
||||
if (/loading\s*=/i.test(attrs)) return match; |
||||
return `<img${attrs} loading="lazy">`; |
||||
}); |
||||
|
||||
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"'); |
||||
} |
||||
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"'); |
||||
} |
||||
if (!/autoplay\s*=/i.test(newAttrs)) { |
||||
newAttrs += ' autoplay="false"'; |
||||
} |
||||
return `<audio${newAttrs}>`; |
||||
}); |
||||
|
||||
// Replace placeholders with component placeholders |
||||
for (const [placeholder, { uri, parsed }] of placeholders.entries()) { |
||||
let replacement = ''; |
||||
|
||||
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); |
||||
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>`; |
||||
} |
||||
} |
||||
} 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); |
||||
} |
||||
|
||||
// Clean up any remaining placeholders |
||||
finalHtml = finalHtml.replace(/<code>NIP21PLACEHOLDER\d+<\/code>/g, ''); |
||||
finalHtml = finalHtml.replace(/NIP21PLACEHOLDER\d+/g, ''); |
||||
|
||||
return finalHtml; |
||||
}; |
||||
// 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' |
||||
); |
||||
|
||||
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(); |
||||
}); |
||||
// 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) { |
||||
// 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); |
||||
} |
||||
} |
||||
|
||||
return processed; |
||||
} |
||||
|
||||
// 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 |
||||
}); |
||||
|
||||
// Mount components into placeholder elements |
||||
function mountComponents() { |
||||
if (!containerElement) return; |
||||
// Render markdown to HTML |
||||
function renderMarkdown(text: string): string { |
||||
if (!content) return ''; |
||||
|
||||
// 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); |
||||
} |
||||
}); |
||||
const processed = processContent(content); |
||||
|
||||
// Render markdown - this should convert  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 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); |
||||
} |
||||
}); |
||||
// Sanitize HTML (but preserve our data attributes and image src) |
||||
const sanitized = sanitizeMarkdown(html); |
||||
|
||||
return sanitized; |
||||
} |
||||
|
||||
// Mount components when container is available and rendered changes |
||||
const renderedHtml = $derived(renderMarkdown(content)); |
||||
|
||||
// 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(.markdown-content a:hover) { |
||||
text-decoration: none; |
||||
} |
||||
|
||||
:global(.dark) .markdown-content :global(.nostr-link:hover) { |
||||
color: var(--fog-dark-text, #cbd5e1); |
||||
:global(.markdown-content code) { |
||||
background: var(--fog-border, #e5e7eb); |
||||
padding: 0.125rem 0.25rem; |
||||
border-radius: 0.25rem; |
||||
font-size: 0.875em; |
||||
} |
||||
|
||||
.markdown-content :global(code) { |
||||
background: #e2e8f0; |
||||
padding: 0.2em 0.4em; |
||||
border-radius: 3px; |
||||
font-family: monospace; |
||||
color: #475569; |
||||
:global(.dark .markdown-content code) { |
||||
background: var(--fog-dark-border, #374151); |
||||
} |
||||
|
||||
:global(.dark) .markdown-content :global(code) { |
||||
background: #475569; |
||||
color: #cbd5e1; |
||||
} |
||||
|
||||
.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> |
||||
|
||||
Loading…
Reference in new issue