5 changed files with 336 additions and 274 deletions
@ -1,304 +1,268 @@ |
|||||||
<script lang="ts"> |
<script lang="ts"> |
||||||
import * as marked from 'marked'; |
import { marked } from 'marked'; |
||||||
import { sanitizeMarkdown } from '../../services/security/sanitizer.js'; |
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 { nip19 } from 'nostr-tools'; |
||||||
import { tick } from 'svelte'; |
|
||||||
import ProfileBadge from '../layout/ProfileBadge.svelte'; |
import ProfileBadge from '../layout/ProfileBadge.svelte'; |
||||||
import EmbeddedEvent from './EmbeddedEvent.svelte'; |
|
||||||
import { mountComponent } from './mount-component-action.js'; |
import { mountComponent } from './mount-component-action.js'; |
||||||
|
|
||||||
interface Props { |
interface Props { |
||||||
content?: string; |
content: string; |
||||||
} |
} |
||||||
|
|
||||||
let { content = '' }: Props = $props(); |
let { content }: Props = $props(); |
||||||
|
let containerRef = $state<HTMLElement | null>(null); |
||||||
|
|
||||||
let rendered = $state(''); |
// Extract pubkey from npub or nprofile |
||||||
let containerElement: HTMLDivElement | null = $state(null); |
function getPubkeyFromNIP21(parsed: ReturnType<typeof parseNIP21>): string | null { |
||||||
let mountedElements = $state<Set<HTMLElement>>(new Set()); |
if (!parsed) return null; |
||||||
|
|
||||||
// Process content and render markdown |
if (parsed.type === 'npub') { |
||||||
$effect(() => { |
// npub decodes directly to pubkey |
||||||
if (!content) { |
try { |
||||||
rendered = ''; |
const decoded = nip19.decode(parsed.data); |
||||||
return; |
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 |
return null; |
||||||
const links = findNIP21Links(content); |
} |
||||||
|
|
||||||
// Replace links with placeholders before markdown parsing |
// Escape HTML to prevent XSS |
||||||
let processed = content; |
function escapeHtml(text: string): string { |
||||||
const placeholders: Map<string, { uri: string; parsed: any }> = new Map(); |
return text |
||||||
let offset = 0; |
.replace(/&/g, '&') |
||||||
|
.replace(/</g, '<') |
||||||
|
.replace(/>/g, '>') |
||||||
|
.replace(/"/g, '"') |
||||||
|
.replace(/'/g, '''); |
||||||
|
} |
||||||
|
|
||||||
// Process in reverse order to maintain indices |
// Convert plain image URLs to img tags |
||||||
const sortedLinks = [...links].sort((a, b) => b.start - a.start); |
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; |
||||||
|
|
||||||
|
let result = text; |
||||||
|
const matches: Array<{ url: string; index: number; endIndex: number }> = []; |
||||||
|
|
||||||
|
// 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) { |
// Replace from end to start to preserve indices |
||||||
const placeholder = `\`NIP21PLACEHOLDER${offset}\``; |
for (let i = matches.length - 1; i >= 0; i--) { |
||||||
const before = processed.slice(0, link.start); |
const { url, index, endIndex } = matches[i]; |
||||||
const after = processed.slice(link.end); |
const escapedUrl = escapeHtml(url); |
||||||
processed = before + placeholder + after; |
result = result.substring(0, index) + `<img src="${escapedUrl}" alt="" loading="lazy" />` + result.substring(endIndex); |
||||||
placeholders.set(placeholder, { uri: link.uri, parsed: link.parsed }); |
|
||||||
offset++; |
|
||||||
} |
} |
||||||
|
|
||||||
// Parse markdown |
return result; |
||||||
const parseResult = marked.parse(processed); |
} |
||||||
|
|
||||||
const processHtml = (html: string) => { |
// Process content: replace nostr URIs with HTML span elements and convert image URLs |
||||||
let finalHtml = sanitizeMarkdown(html); |
function processContent(text: string): string { |
||||||
|
// First, convert plain image URLs to img tags |
||||||
|
let processed = convertImageUrls(text); |
||||||
|
|
||||||
// Process media elements |
// Find all NIP-21 links (nostr:npub, nostr:nprofile, etc.) |
||||||
finalHtml = finalHtml.replace(/<img([^>]*)>/gi, (match, attrs) => { |
const links = findNIP21Links(processed); |
||||||
if (/loading\s*=/i.test(attrs)) return match; |
|
||||||
return `<img${attrs} loading="lazy">`; |
|
||||||
}); |
|
||||||
|
|
||||||
finalHtml = finalHtml.replace(/<video([^>]*)>/gi, (match, attrs) => { |
// Only process npub and nprofile for profile badges |
||||||
let newAttrs = attrs.replace(/\s*autoplay\s*/gi, ' '); |
const profileLinks = links.filter(link => |
||||||
if (!/preload\s*=/i.test(newAttrs)) { |
link.parsed.type === 'npub' || link.parsed.type === 'nprofile' |
||||||
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) => { |
// Replace profile links with HTML span elements that have data attributes |
||||||
let newAttrs = attrs.replace(/\s*autoplay\s*/gi, ' '); |
// Process from end to start to preserve indices |
||||||
if (!/preload\s*=/i.test(newAttrs)) { |
for (let i = profileLinks.length - 1; i >= 0; i--) { |
||||||
newAttrs += ' preload="none"'; |
const link = profileLinks[i]; |
||||||
} else { |
const pubkey = getPubkeyFromNIP21(link.parsed); |
||||||
newAttrs = newAttrs.replace(/preload\s*=\s*["']?[^"'\s>]+["']?/gi, 'preload="none"'); |
if (pubkey) { |
||||||
} |
// Escape pubkey to prevent XSS (though pubkeys should be safe hex strings) |
||||||
if (!/autoplay\s*=/i.test(newAttrs)) { |
const escapedPubkey = escapeHtml(pubkey); |
||||||
newAttrs += ' autoplay="false"'; |
// Create a span element that will survive markdown rendering |
||||||
} |
// Markdown won't process HTML tags, so this will remain as-is |
||||||
return `<audio${newAttrs}>`; |
const span = `<span data-nostr-profile data-pubkey="${escapedPubkey}"></span>`; |
||||||
}); |
processed = |
||||||
|
processed.slice(0, link.start) + |
||||||
|
span + |
||||||
|
processed.slice(link.end); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
// Replace placeholders with component placeholders |
return processed; |
||||||
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 |
// Configure marked once - ensure images are rendered and HTML is preserved |
||||||
const placeholderText = placeholder.replace(/`/g, ''); |
marked.setOptions({ |
||||||
const codePlaceholder = `<code>${placeholderText}</code>`; |
breaks: true, // Convert line breaks to <br> |
||||||
const escapedCodePlaceholder = codePlaceholder.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
gfm: true, // GitHub Flavored Markdown |
||||||
finalHtml = finalHtml.replace(new RegExp(escapedCodePlaceholder, 'g'), replacement); |
silent: false // Don't suppress errors |
||||||
const escapedPlaceholder = placeholderText.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); |
// HTML tags (like <img>) pass through by default in marked |
||||||
finalHtml = finalHtml.replace(new RegExp(escapedPlaceholder, 'g'), replacement); |
}); |
||||||
} |
|
||||||
|
|
||||||
// Clean up any remaining placeholders |
// Render markdown to HTML |
||||||
finalHtml = finalHtml.replace(/<code>NIP21PLACEHOLDER\d+<\/code>/g, ''); |
function renderMarkdown(text: string): string { |
||||||
finalHtml = finalHtml.replace(/NIP21PLACEHOLDER\d+/g, ''); |
if (!content) return ''; |
||||||
|
|
||||||
return finalHtml; |
const processed = processContent(content); |
||||||
}; |
|
||||||
|
// Render markdown - this should convert  to <img src="url" alt="alt"> |
||||||
if (parseResult instanceof Promise) { |
let html: string; |
||||||
parseResult.then(processHtml).then(html => { |
try { |
||||||
rendered = html; |
html = marked.parse(processed) as string; |
||||||
// Clear mounted elements when content changes |
} catch (error) { |
||||||
mountedElements.clear(); |
console.error('Marked parsing error:', error); |
||||||
// Mount components after a delay |
return processed; // Fallback to raw text if parsing fails |
||||||
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(); |
|
||||||
}); |
|
||||||
} |
} |
||||||
}); |
|
||||||
|
|
||||||
// Mount components into placeholder elements |
// Sanitize HTML (but preserve our data attributes and image src) |
||||||
function mountComponents() { |
const sanitized = sanitizeMarkdown(html); |
||||||
if (!containerElement) return; |
|
||||||
|
|
||||||
// Mount profile badges |
return sanitized; |
||||||
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); |
|
||||||
} |
|
||||||
}); |
|
||||||
|
|
||||||
// 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); |
|
||||||
} |
|
||||||
}); |
|
||||||
} |
} |
||||||
|
|
||||||
// Mount components when container is available and rendered changes |
const renderedHtml = $derived(renderMarkdown(content)); |
||||||
|
|
||||||
|
// Mount ProfileBadge components after rendering |
||||||
$effect(() => { |
$effect(() => { |
||||||
if (containerElement && rendered) { |
if (!containerRef || !renderedHtml) return; |
||||||
tick().then(() => tick()).then(() => { |
|
||||||
mountComponents(); |
// 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> |
</script> |
||||||
|
|
||||||
<div class="markdown-content anon-content" bind:this={containerElement}> |
<div |
||||||
{@html rendered} |
bind:this={containerRef} |
||||||
|
class="markdown-content prose prose-sm dark:prose-invert max-w-none" |
||||||
|
> |
||||||
|
{@html renderedHtml} |
||||||
</div> |
</div> |
||||||
|
|
||||||
<style> |
<style> |
||||||
.markdown-content { |
:global(.markdown-content) { |
||||||
line-height: var(--line-height); |
word-wrap: break-word; |
||||||
} |
overflow-wrap: break-word; |
||||||
|
|
||||||
.markdown-content :global(p) { |
|
||||||
margin: 0.5em 0; |
|
||||||
} |
} |
||||||
|
|
||||||
.markdown-content :global(a) { |
:global(.markdown-content img) { |
||||||
color: #64748b; |
max-width: 100%; |
||||||
text-decoration: underline; |
height: auto; |
||||||
} |
border-radius: 0.25rem; |
||||||
|
margin: 0.5rem 0; |
||||||
:global(.dark) .markdown-content :global(a) { |
display: block; |
||||||
color: #94a3b8; |
visibility: visible !important; |
||||||
|
opacity: 1 !important; |
||||||
} |
} |
||||||
|
|
||||||
.markdown-content :global(a:hover) { |
:global(.markdown-content video) { |
||||||
color: #475569; |
max-width: 100%; |
||||||
|
height: auto; |
||||||
|
border-radius: 0.25rem; |
||||||
|
margin: 0.5rem 0; |
||||||
} |
} |
||||||
|
|
||||||
:global(.dark) .markdown-content :global(a:hover) { |
:global(.markdown-content audio) { |
||||||
color: #cbd5e1; |
width: 100%; |
||||||
|
margin: 0.5rem 0; |
||||||
} |
} |
||||||
|
|
||||||
.markdown-content :global(.nostr-link) { |
:global(.markdown-content a) { |
||||||
color: var(--fog-accent, #64748b); |
color: var(--fog-accent, #64748b); |
||||||
text-decoration: underline; |
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) { |
:global(.markdown-content a:hover) { |
||||||
color: var(--fog-dark-text, #cbd5e1); |
text-decoration: none; |
||||||
} |
} |
||||||
|
|
||||||
.markdown-content :global(code) { |
:global(.markdown-content code) { |
||||||
background: #e2e8f0; |
background: var(--fog-border, #e5e7eb); |
||||||
padding: 0.2em 0.4em; |
padding: 0.125rem 0.25rem; |
||||||
border-radius: 3px; |
border-radius: 0.25rem; |
||||||
font-family: monospace; |
font-size: 0.875em; |
||||||
color: #475569; |
|
||||||
} |
} |
||||||
|
|
||||||
:global(.dark) .markdown-content :global(code) { |
:global(.dark .markdown-content code) { |
||||||
background: #475569; |
background: var(--fog-dark-border, #374151); |
||||||
color: #cbd5e1; |
|
||||||
} |
} |
||||||
|
|
||||||
.markdown-content :global(pre) { |
:global(.markdown-content pre) { |
||||||
background: #e2e8f0; |
background: var(--fog-border, #e5e7eb); |
||||||
padding: 1em; |
padding: 1rem; |
||||||
border-radius: 5px; |
border-radius: 0.5rem; |
||||||
overflow-x: auto; |
overflow-x: auto; |
||||||
border: 1px solid #cbd5e1; |
margin: 0.5rem 0; |
||||||
} |
|
||||||
|
|
||||||
: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); |
|
||||||
} |
} |
||||||
|
|
||||||
:global(.dark) .markdown-content :global(img) { |
:global(.dark .markdown-content pre) { |
||||||
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); |
background: var(--fog-dark-border, #374151); |
||||||
} |
} |
||||||
|
|
||||||
.markdown-content :global(.nostr-profile-badge-placeholder), |
:global(.markdown-content pre code) { |
||||||
.markdown-content :global(.nostr-embedded-event-placeholder) { |
background: transparent; |
||||||
display: inline-block; |
padding: 0; |
||||||
vertical-align: middle; |
|
||||||
} |
} |
||||||
|
|
||||||
.markdown-content :global(span[role="img"]), |
:global(.markdown-content blockquote) { |
||||||
.markdown-content :global(.emoji) { |
border-left: 4px solid var(--fog-border, #e5e7eb); |
||||||
filter: grayscale(100%) sepia(15%) hue-rotate(200deg) saturate(60%) brightness(0.95); |
padding-left: 1rem; |
||||||
display: inline-block; |
margin: 0.5rem 0; |
||||||
|
color: var(--fog-text-light, #6b7280); |
||||||
} |
} |
||||||
|
|
||||||
:global(.dark) .markdown-content :global(span[role="img"]), |
:global(.dark .markdown-content blockquote) { |
||||||
:global(.dark) .markdown-content :global(.emoji) { |
border-color: var(--fog-dark-border, #374151); |
||||||
filter: grayscale(100%) sepia(20%) hue-rotate(200deg) saturate(70%) brightness(0.9); |
color: var(--fog-dark-text-light, #9ca3af); |
||||||
} |
} |
||||||
</style> |
</style> |
||||||
|
|||||||
Loading…
Reference in new issue