You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
1697 lines
66 KiB
1697 lines
66 KiB
<script lang="ts"> |
|
import { marked } from 'marked'; |
|
import { sanitizeMarkdown } from '../../services/security/sanitizer.js'; |
|
import { findNIP21Links, parseNIP21 } from '../../services/nostr/nip21-parser.js'; |
|
import { nip19 } from 'nostr-tools'; |
|
import ProfileBadge from '../layout/ProfileBadge.svelte'; |
|
import { fetchEmojiSet, resolveEmojiShortcode } from '../../services/nostr/nip30-emoji.js'; |
|
import { renderAsciiDoc } from '../../services/content/asciidoctor-renderer.js'; |
|
import HighlightOverlay from './HighlightOverlay.svelte'; |
|
import { getHighlightsForEvent, findHighlightMatches, type Highlight } from '../../services/nostr/highlight-service.js'; |
|
import { mountComponent } from './mount-component-action.js'; |
|
import type { NostrEvent } from '../../types/nostr.js'; |
|
// @ts-ignore - highlight.js default export works at runtime |
|
import hljs from 'highlight.js'; |
|
// Use VS Code theme for IDE-like appearance |
|
import 'highlight.js/styles/vs2015.css'; |
|
import { getCachedMarkdown, cacheMarkdown } from '../../services/cache/markdown-cache.js'; |
|
|
|
import EmbeddedEvent from './EmbeddedEvent.svelte'; |
|
import EmbeddedEventBlurb from './EmbeddedEventBlurb.svelte'; |
|
import TTSControls from './TTSControls.svelte'; |
|
import { KIND } from '../../types/kind-lookup.js'; |
|
let mountingEmbeddedEvents = $state(false); // Guard for mounting |
|
let mountingEmbeddedBlurbs = $state(false); // Guard for mounting blurbs |
|
|
|
interface Props { |
|
content: string; |
|
event?: NostrEvent; // Optional event for emoji resolution |
|
excludeMediaUrls?: string[]; // URLs already displayed by MediaAttachments - skip rendering these |
|
} |
|
|
|
let { content, event, excludeMediaUrls = [] }: Props = $props(); |
|
|
|
// Ensure excludeMediaUrls is always an array (safety check) |
|
let normalizedExcludeMediaUrls = $derived.by(() => { |
|
if (!excludeMediaUrls || !Array.isArray(excludeMediaUrls)) { |
|
return []; |
|
} |
|
return excludeMediaUrls; |
|
}); |
|
let containerRef = $state<HTMLElement | null>(null); |
|
let emojiUrls = $state<Map<string, string>>(new Map()); |
|
let highlights = $state<Highlight[]>([]); |
|
let highlightMatches = $state<Array<{ start: number; end: number; highlight: Highlight }>>([]); |
|
let highlightsLoaded = $state(false); |
|
|
|
// Cache for rendered markdown to avoid re-rendering same content |
|
// Version 2: Added wikilink conversion support |
|
const MARKDOWN_CACHE_VERSION = 2; |
|
const markdownCache = new Map<string, string>(); |
|
const MAX_CACHE_SIZE = 100; // Limit cache size to prevent memory bloat |
|
|
|
// Validate if a string is a valid bech32 or hex string |
|
function isValidNostrId(str: string): boolean { |
|
if (!str || typeof str !== 'string') return false; |
|
// Check for HTML tags or other invalid characters |
|
if (/<[^>]+>/.test(str)) return false; |
|
// Check if it's hex (64 hex characters) |
|
if (/^[0-9a-f]{64}$/i.test(str)) return true; |
|
// Check if it's bech32 (npub1..., note1..., nevent1..., naddr1..., nprofile1...) |
|
if (/^(npub|note|nevent|naddr|nprofile)1[a-z0-9]+$/.test(str)) return true; |
|
return false; |
|
} |
|
|
|
// Extract pubkey from npub or nprofile |
|
function getPubkeyFromNIP21(parsed: ReturnType<typeof parseNIP21>): string | null { |
|
if (!parsed) return null; |
|
|
|
// Validate before decoding |
|
if (!isValidNostrId(parsed.data)) { |
|
console.warn('Invalid NIP-21 data format:', parsed.data); |
|
return null; |
|
} |
|
|
|
try { |
|
// parsed.data is the bech32 string (e.g., "npub1..." or "nprofile1...") |
|
// We need to decode it to get the actual pubkey |
|
const decoded = nip19.decode(parsed.data); |
|
|
|
if (parsed.type === 'npub') { |
|
// npub decodes directly to pubkey (hex string) |
|
if (decoded.type === 'npub') { |
|
return String(decoded.data); |
|
} |
|
} else if (parsed.type === 'nprofile') { |
|
// nprofile decodes to object with pubkey property |
|
if (decoded.type === 'nprofile' && decoded.data && typeof decoded.data === 'object' && 'pubkey' in decoded.data) { |
|
return String(decoded.data.pubkey); |
|
} |
|
} |
|
} catch (error) { |
|
console.error('Error decoding NIP-21 URI:', error, parsed); |
|
return null; |
|
} |
|
|
|
return null; |
|
} |
|
|
|
// Extract event identifier from note, nevent, or naddr |
|
// Returns the bech32 string (note1..., nevent1..., naddr1...) which EmbeddedEvent can decode |
|
function getEventIdFromNIP21(parsed: ReturnType<typeof parseNIP21>): string | null { |
|
if (!parsed) return null; |
|
|
|
if (parsed.type === 'note' || parsed.type === 'nevent' || parsed.type === 'naddr') { |
|
// Return the bech32 string - EmbeddedEvent will decode it |
|
return parsed.data; |
|
} |
|
|
|
return null; |
|
} |
|
|
|
// Escape HTML to prevent XSS |
|
function escapeHtml(text: string): string { |
|
return text |
|
.replace(/&/g, '&') |
|
.replace(/</g, '<') |
|
.replace(/>/g, '>') |
|
.replace(/"/g, '"') |
|
.replace(/'/g, '''); |
|
} |
|
|
|
// Normalize URL for comparison (same logic as MediaAttachments) |
|
function normalizeUrl(url: string): string { |
|
if (!url || typeof url !== 'string') return url; |
|
try { |
|
const parsed = new URL(url); |
|
// Remove query params and fragments for comparison |
|
// Normalize trailing slashes and convert to lowercase for comparison |
|
const normalized = `${parsed.protocol}//${parsed.host.toLowerCase()}${parsed.pathname}`.replace(/\/$/, ''); |
|
return normalized; |
|
} catch { |
|
// If URL parsing fails, try basic normalization |
|
return url.trim().replace(/\/$/, '').toLowerCase(); |
|
} |
|
} |
|
|
|
|
|
// Resolve custom emojis in content |
|
async function resolveContentEmojis(text: string): Promise<void> { |
|
if (!event) return; // Need event to resolve emojis |
|
|
|
// Find all :shortcode: patterns |
|
const emojiPattern = /:([a-zA-Z0-9_+-]+):/g; |
|
const matches: Array<{ shortcode: string; fullMatch: string }> = []; |
|
let match; |
|
while ((match = emojiPattern.exec(text)) !== null) { |
|
const shortcode = match[1]; |
|
const fullMatch = match[0]; |
|
if (!matches.find(m => m.fullMatch === fullMatch)) { |
|
matches.push({ shortcode, fullMatch }); |
|
} |
|
} |
|
|
|
if (matches.length === 0) return; |
|
|
|
// Collect all pubkeys to check: event author and p tags |
|
const pubkeysToCheck = new Set<string>(); |
|
pubkeysToCheck.add(event.pubkey); |
|
for (const tag of event.tags) { |
|
if (tag[0] === 'p' && tag[1]) { |
|
pubkeysToCheck.add(tag[1]); |
|
} |
|
} |
|
|
|
// Resolve emojis - only try specific pubkeys (don't search broadly to avoid background fetching) |
|
// Broad search should only happen when emoji picker is opened |
|
const resolvedUrls = new Map<string, string>(); |
|
for (const { shortcode, fullMatch } of matches) { |
|
// Only try specific pubkeys (event author, p tags) - don't search broadly |
|
const url = await resolveEmojiShortcode(shortcode, Array.from(pubkeysToCheck), false); |
|
|
|
if (url) { |
|
resolvedUrls.set(fullMatch, url); |
|
} |
|
} |
|
|
|
emojiUrls = resolvedUrls; |
|
} |
|
|
|
// Replace emoji shortcodes with images in text, but skip code blocks |
|
function replaceEmojis(text: string): string { |
|
let processed = text; |
|
|
|
// Find all code blocks (Markdown and AsciiDoc syntax) |
|
const codeBlockRanges: Array<{ start: number; end: number }> = []; |
|
|
|
// First, match AsciiDoc source blocks (---- ... ----) |
|
// AsciiDoc source blocks can have attributes like [source, json] before the dashes |
|
// Match: 4+ dashes on a line, content, then 4+ dashes on a line |
|
// We'll match the dashes and then find the closing dashes |
|
const asciidocSourceBlockPattern = /^----+$/gm; |
|
const lines = text.split(/\r?\n/); |
|
let inAsciidocBlock = false; |
|
let blockStart = -1; |
|
let lineIndex = 0; |
|
let charIndex = 0; |
|
|
|
for (let i = 0; i < lines.length; i++) { |
|
const line = lines[i]; |
|
const lineStart = charIndex; |
|
const lineEnd = charIndex + line.length; |
|
|
|
// Check if this line is 4+ dashes |
|
if (/^----+$/.test(line.trim())) { |
|
if (!inAsciidocBlock) { |
|
// Starting a new block |
|
inAsciidocBlock = true; |
|
blockStart = lineStart; |
|
} else { |
|
// Ending a block |
|
codeBlockRanges.push({ start: blockStart, end: lineEnd }); |
|
inAsciidocBlock = false; |
|
blockStart = -1; |
|
} |
|
} |
|
|
|
charIndex = lineEnd + 1; // +1 for newline |
|
} |
|
|
|
// If we're still in a block at the end, close it |
|
if (inAsciidocBlock && blockStart >= 0) { |
|
codeBlockRanges.push({ start: blockStart, end: text.length }); |
|
} |
|
|
|
// Match Markdown/AsciiDoc fenced code blocks (```...```) |
|
// Match triple backticks with optional language identifier |
|
let match; |
|
const fencedCodeBlockPattern = /```[a-zA-Z]*\n?[\s\S]*?```/g; |
|
while ((match = fencedCodeBlockPattern.exec(text)) !== null) { |
|
const start = match.index; |
|
const end = start + match[0].length; |
|
// Only add if not already inside an AsciiDoc source block |
|
const isInsideAsciidoc = codeBlockRanges.some(range => start >= range.start && end <= range.end); |
|
if (!isInsideAsciidoc) { |
|
codeBlockRanges.push({ start, end }); |
|
} |
|
} |
|
|
|
// Then match inline code (`code`) - but exclude those already inside other blocks |
|
// Match single backtick, but not if it's part of triple backticks |
|
const inlineCodePattern = /`[^`\n]+`/g; |
|
while ((match = inlineCodePattern.exec(text)) !== null) { |
|
const start = match.index; |
|
const end = start + match[0].length; |
|
|
|
// Check if this inline code is already inside another code block |
|
const isInsideBlock = codeBlockRanges.some(range => start >= range.start && end <= range.end); |
|
if (!isInsideBlock) { |
|
codeBlockRanges.push({ start, end }); |
|
} |
|
} |
|
|
|
// Sort ranges by start position |
|
codeBlockRanges.sort((a, b) => a.start - b.start); |
|
|
|
// Helper function to check if a position is inside a code block |
|
function isInCodeBlock(index: number): boolean { |
|
return codeBlockRanges.some(range => index >= range.start && index < range.end); |
|
} |
|
|
|
// Replace from end to start to preserve indices |
|
const sortedEntries = Array.from(emojiUrls.entries()).sort((a, b) => { |
|
const indexA = processed.lastIndexOf(a[0]); |
|
const indexB = processed.lastIndexOf(b[0]); |
|
return indexB - indexA; // Sort by last index descending |
|
}); |
|
|
|
for (const [shortcode, url] of sortedEntries) { |
|
const escapedUrl = escapeHtml(url); |
|
const escapedShortcode = escapeHtml(shortcode); |
|
// Replace with img tag, preserving the shortcode as alt text |
|
const imgTag = `<img src="${escapedUrl}" alt="${escapedShortcode}" class="emoji-inline" style="display: inline-block; width: 1.6em; height: 1.6em; vertical-align: middle; object-fit: contain;" />`; |
|
|
|
// Find all occurrences and only replace those outside code blocks |
|
let searchIndex = 0; |
|
while (true) { |
|
const index = processed.indexOf(shortcode, searchIndex); |
|
if (index === -1) break; |
|
|
|
if (!isInCodeBlock(index)) { |
|
// Replace this occurrence |
|
processed = processed.substring(0, index) + imgTag + processed.substring(index + shortcode.length); |
|
searchIndex = index + imgTag.length; |
|
} else { |
|
// Skip this occurrence (it's in a code block) |
|
searchIndex = index + shortcode.length; |
|
} |
|
} |
|
} |
|
|
|
return processed; |
|
} |
|
|
|
// Convert hashtags (#hashtag) to links |
|
function convertHashtags(text: string): string { |
|
// Match hashtags: # followed by alphanumeric characters, underscores, and hyphens |
|
// Don't match if it's already in a link or markdown |
|
const hashtagPattern = /(^|\s)(#[a-zA-Z0-9_-]+)/g; |
|
|
|
return text.replace(hashtagPattern, (match, prefix, hashtag) => { |
|
// Skip if it's already in a link or markdown |
|
const before = text.substring(Math.max(0, text.indexOf(match) - 20), text.indexOf(match)); |
|
if (before.includes('<a') || before.includes('](') || before.includes('href=')) { |
|
return match; |
|
} |
|
|
|
const tagName = hashtag.substring(1); // Remove # |
|
const escapedTag = escapeHtml(tagName); |
|
const escapedHashtag = escapeHtml(hashtag); |
|
return `${prefix}<a href="/topics/${escapedTag}" class="hashtag-link">${escapedHashtag}</a>`; |
|
}); |
|
} |
|
|
|
// Convert greentext (>text with no space) to styled spans |
|
// Groups consecutive greentext lines into a single block, preserving line breaks |
|
function convertGreentext(text: string): string { |
|
// Split by lines and process |
|
const lines = text.split('\n'); |
|
const processedLines: string[] = []; |
|
let greentextBlock: string[] = []; |
|
|
|
const greentextPattern = /^(>|>)([^\s>].*)$/; |
|
|
|
for (let i = 0; i < lines.length; i++) { |
|
const line = lines[i]; |
|
const match = line.match(greentextPattern); |
|
|
|
if (match) { |
|
// This is greentext - add to current block |
|
const greentextContent = escapeHtml(match[2]); |
|
greentextBlock.push(greentextContent); |
|
} else { |
|
// Not greentext - flush any accumulated greentext block |
|
if (greentextBlock.length > 0) { |
|
// Join with <br> to preserve line breaks, no extra spacing |
|
const blockContent = greentextBlock.map(content => `>${content}`).join('<br>'); |
|
processedLines.push(`<span class="greentext">${blockContent}</span>`); |
|
greentextBlock = []; |
|
} |
|
// Add the non-greentext line as-is |
|
processedLines.push(line); |
|
} |
|
} |
|
|
|
// Flush any remaining greentext block at the end |
|
if (greentextBlock.length > 0) { |
|
// Join with <br> to preserve line breaks, no extra spacing |
|
const blockContent = greentextBlock.map(content => `>${content}`).join('<br>'); |
|
processedLines.push(`<span class="greentext">${blockContent}</span>`); |
|
} |
|
|
|
return processedLines.join('\n'); |
|
} |
|
|
|
// Normalize d-tag according to NIP-54 |
|
function normalizeDTag(text: string): string { |
|
let normalized = text; |
|
|
|
// Convert to lowercase (preserving non-ASCII characters) |
|
normalized = normalized.toLowerCase(); |
|
|
|
// Convert whitespace to `-` |
|
normalized = normalized.replace(/\s+/g, '-'); |
|
|
|
// Remove punctuation and symbols (but preserve non-ASCII letters and numbers) |
|
normalized = normalized.replace(/[^\p{L}\p{N}-]/gu, ''); |
|
|
|
// Collapse multiple consecutive `-` to a single `-` |
|
normalized = normalized.replace(/-+/g, '-'); |
|
|
|
// Remove leading and trailing `-` |
|
normalized = normalized.replace(/^-+|-+$/g, ''); |
|
|
|
return normalized; |
|
} |
|
|
|
// Convert wikilinks [[target]] or [[target|display]] to markdown links |
|
// Skips wikilinks inside code blocks |
|
function convertWikilinks(text: string): string { |
|
// Find all code blocks (fenced and inline) to exclude from processing |
|
const codeBlockRanges: Array<{ start: number; end: number }> = []; |
|
|
|
// Match fenced code blocks (```...```) |
|
const fencedCodeBlockPattern = /```[a-zA-Z]*\n?[\s\S]*?```/g; |
|
let match; |
|
while ((match = fencedCodeBlockPattern.exec(text)) !== null) { |
|
codeBlockRanges.push({ start: match.index, end: match.index + match[0].length }); |
|
} |
|
|
|
// Match inline code (`code`) |
|
const inlineCodePattern = /`[^`\n]+`/g; |
|
while ((match = inlineCodePattern.exec(text)) !== null) { |
|
const start = match.index; |
|
const end = start + match[0].length; |
|
// Only add if not already inside a fenced code block |
|
const isInsideFenced = codeBlockRanges.some(range => start >= range.start && end <= range.end); |
|
if (!isInsideFenced) { |
|
codeBlockRanges.push({ start, end }); |
|
} |
|
} |
|
|
|
const wikilinkRegex = /\[\[([^\]]+)\]\]/g; |
|
let result = text; |
|
const replacements: Array<{ start: number; end: number; replacement: string }> = []; |
|
|
|
// Find all wikilinks |
|
while ((match = wikilinkRegex.exec(text)) !== null) { |
|
const start = match.index; |
|
const end = start + match[0].length; |
|
|
|
// Skip if inside a code block |
|
const isInsideCode = codeBlockRanges.some(range => start >= range.start && end <= range.end); |
|
if (isInsideCode) { |
|
continue; |
|
} |
|
|
|
const content = match[1]; |
|
// Check if it has pipe syntax: [[target|display]] |
|
const pipeIndex = content.indexOf('|'); |
|
let targetText: string; |
|
let displayText: string; |
|
|
|
if (pipeIndex !== -1) { |
|
targetText = content.slice(0, pipeIndex).trim(); |
|
displayText = content.slice(pipeIndex + 1).trim(); |
|
} else { |
|
targetText = content.trim(); |
|
displayText = content.trim(); |
|
} |
|
|
|
// Normalize the d-tag |
|
const normalizedDTag = normalizeDTag(targetText); |
|
const encodedDTag = encodeURIComponent(normalizedDTag); |
|
const escapedDisplay = displayText.replace(/\]/g, '\\]'); |
|
|
|
// Convert to markdown link format |
|
replacements.push({ |
|
start, |
|
end, |
|
replacement: `[${escapedDisplay}](/find?q=${encodedDTag})` |
|
}); |
|
} |
|
|
|
// Apply replacements in reverse order to preserve indices |
|
for (let i = replacements.length - 1; i >= 0; i--) { |
|
const { start, end, replacement } = replacements[i]; |
|
result = result.slice(0, start) + replacement + result.slice(end); |
|
} |
|
|
|
return result; |
|
} |
|
|
|
// Process and convert media URLs: handles markdown, AsciiDoc, and plain URLs |
|
// Removes excluded URLs (displayed by MediaAttachments) and converts others to HTML tags |
|
function processMediaUrls(text: string): string { |
|
const excludeUrls = normalizedExcludeMediaUrls; |
|
// Normalize exclude URLs for comparison |
|
const normalizedExcludeUrls = excludeUrls.length > 0 |
|
? new Set(excludeUrls.map(url => normalizeUrl(url))) |
|
: new Set<string>(); |
|
|
|
// Debug: Log excluded URLs and check if they're being found in text |
|
if (normalizedExcludeUrls.size > 0 && typeof console !== 'undefined') { |
|
console.debug('MarkdownRenderer: Excluding URLs:', Array.from(normalizedExcludeUrls)); |
|
console.debug('MarkdownRenderer: Original text:', text.substring(0, 200)); |
|
// Check if any excluded URLs appear in the text |
|
for (const excludedUrl of normalizedExcludeUrls) { |
|
// Try to find the URL in the text (check both normalized and original) |
|
const urlInText = text.includes(excludedUrl) || |
|
excludeUrls.some(orig => text.includes(orig)); |
|
if (urlInText) { |
|
console.debug('MarkdownRenderer: Found excluded URL in text:', excludedUrl); |
|
// Find where it appears |
|
const index = text.indexOf(excludeUrls.find(orig => text.includes(orig)) || ''); |
|
if (index >= 0) { |
|
console.debug('MarkdownRenderer: URL found at index:', index, 'context:', text.substring(Math.max(0, index - 20), Math.min(text.length, index + 100))); |
|
} |
|
} |
|
} |
|
} |
|
|
|
let result = text; |
|
const replacements: Array<{ fullMatch: string; replacement: string; index: number }> = []; |
|
const processedIndices = new Set<number>(); // Track processed indices to avoid duplicates |
|
|
|
// 1. Match markdown image syntax:  |
|
const markdownImageRegex = /!\[([^\]]*)\]\(([^)]+)\)/g; |
|
let match; |
|
while ((match = markdownImageRegex.exec(text)) !== null) { |
|
const fullMatch = match[0]; |
|
const alt = match[1]; |
|
const url = match[2]; |
|
const index = match.index; |
|
|
|
if (processedIndices.has(index)) continue; |
|
processedIndices.add(index); |
|
|
|
const normalizedUrl = normalizeUrl(url); |
|
if (normalizedExcludeUrls.has(normalizedUrl)) { |
|
// Remove excluded markdown images, leaving just alt text |
|
replacements.push({ fullMatch, replacement: alt || '', index }); |
|
} else { |
|
// Convert non-excluded markdown images to HTML (marked will handle this, but we ensure it's preserved) |
|
// Marked will convert this, so we don't need to do anything here |
|
} |
|
} |
|
|
|
// 2. Match AsciiDoc image syntax: image::url[] or image:url[] |
|
const asciidocImageRegex = /image::?([^\s\[\]]+)(\[[^\]]*\])?/g; |
|
asciidocImageRegex.lastIndex = 0; |
|
let asciidocMatch; |
|
while ((asciidocMatch = asciidocImageRegex.exec(text)) !== null) { |
|
const fullMatch = asciidocMatch[0]; |
|
const url = asciidocMatch[1]; |
|
const index = asciidocMatch.index; |
|
|
|
if (processedIndices.has(index)) continue; |
|
|
|
// Only process if it's a URL (starts with http:// or https://) |
|
if (url.startsWith('http://') || url.startsWith('https://')) { |
|
processedIndices.add(index); |
|
const normalizedUrl = normalizeUrl(url); |
|
if (normalizedExcludeUrls.has(normalizedUrl)) { |
|
// Remove excluded AsciiDoc images |
|
replacements.push({ fullMatch, replacement: '', index }); |
|
} |
|
// Non-excluded AsciiDoc images will be handled by AsciiDoc renderer |
|
} |
|
} |
|
|
|
// 3. Match plain media URLs (using the SAME pattern as getMediaAttachmentUrls) |
|
// IMPORTANT: This must match the exact same regex pattern used in FeedPost.mediaAttachmentUrls |
|
const urlRegex = /(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp|mp4|webm|ogg|mov|avi|mkv|mp3|wav|flac|aac|m4a)(\?[^\s<>"{}|\\^`\[\]]*)?)/gi; |
|
urlRegex.lastIndex = 0; |
|
while ((match = urlRegex.exec(text)) !== null) { |
|
const url = match[1]; // The URL part (group 1) |
|
const fullMatch = match[0]; // Full match |
|
const index = match.index; |
|
|
|
if (processedIndices.has(index)) continue; |
|
processedIndices.add(index); |
|
|
|
// Check if it's already in markdown or HTML |
|
const before = text.substring(Math.max(0, index - 10), index); |
|
const after = text.substring(index + fullMatch.length, Math.min(text.length, index + fullMatch.length + 10)); |
|
|
|
if (before.includes(' || after.startsWith('</img>') || after.startsWith('</video>') || after.startsWith('</audio>')) { |
|
continue; // Already handled by markdown/HTML |
|
} |
|
|
|
const normalizedUrl = normalizeUrl(url); |
|
// Check exclusion - this is critical for preventing duplicate images |
|
if (normalizedExcludeUrls.has(normalizedUrl)) { |
|
// Remove excluded plain URLs - they're already displayed by MediaAttachments |
|
if (typeof console !== 'undefined') { |
|
console.debug('MarkdownRenderer: Removing excluded URL from content:', url, 'normalized:', normalizedUrl); |
|
} |
|
replacements.push({ fullMatch, replacement: '', index }); |
|
} else { |
|
if (typeof console !== 'undefined' && normalizedExcludeUrls.size > 0) { |
|
console.debug('MarkdownRenderer: URL not in exclusion list, will convert to HTML:', url, 'normalized:', normalizedUrl, 'excluded set:', Array.from(normalizedExcludeUrls)); |
|
} |
|
// Convert non-excluded plain URLs to HTML tags |
|
const ext = match[2].toLowerCase(); |
|
let htmlTag: string; |
|
if (['mp4', 'webm', 'ogg', 'mov', 'avi', 'mkv'].includes(ext)) { |
|
htmlTag = `<video src="${escapeHtml(url)}" controls preload="none" style="max-width: 600px; width: 100%; height: auto; max-height: 500px;"></video>`; |
|
} else if (['mp3', 'wav', 'ogg', 'flac', 'aac', 'm4a'].includes(ext)) { |
|
htmlTag = `<audio src="${escapeHtml(url)}" controls preload="none" style="max-width: 600px; width: 100%;"></audio>`; |
|
} else { |
|
htmlTag = `<img src="${escapeHtml(url)}" alt="" style="max-width: 600px; width: 100%; height: auto;" />`; |
|
} |
|
replacements.push({ fullMatch, replacement: htmlTag, index }); |
|
} |
|
} |
|
|
|
// Apply all replacements from end to start to preserve indices |
|
replacements.sort((a, b) => b.index - a.index); |
|
for (const { fullMatch, replacement, index } of replacements) { |
|
result = result.substring(0, index) + replacement + result.substring(index + fullMatch.length); |
|
} |
|
|
|
return result; |
|
} |
|
|
|
// Process content: replace nostr URIs with HTML span elements and convert media URLs |
|
function processContent(text: string): string { |
|
// Process media URLs (removes excluded ones, converts others to HTML) |
|
let processed = processMediaUrls(text); |
|
|
|
// Then, convert greentext (must be before markdown processing) |
|
processed = convertGreentext(processed); |
|
|
|
// Convert wikilinks to markdown links (before other processing) |
|
processed = convertWikilinks(processed); |
|
|
|
// Then, replace emoji shortcodes with images if resolved |
|
processed = replaceEmojis(processed); |
|
|
|
// Convert hashtags to links |
|
processed = convertHashtags(processed); |
|
|
|
// Find all NIP-21 links (nostr:npub, nostr:nprofile, nostr:nevent, etc.) |
|
const links = findNIP21Links(processed); |
|
|
|
// Separate into profile links and event links |
|
const profileLinks = links.filter(link => |
|
link.parsed.type === 'npub' || link.parsed.type === 'nprofile' |
|
); |
|
|
|
const eventLinks = links.filter(link => |
|
link.parsed.type === 'note' || link.parsed.type === 'nevent' || link.parsed.type === 'naddr' |
|
); |
|
|
|
// Replace event links with HTML div elements for embedded blurbs (block-level display) |
|
// Process from end to start to preserve indices |
|
for (let i = eventLinks.length - 1; i >= 0; i--) { |
|
const link = eventLinks[i]; |
|
const eventId = getEventIdFromNIP21(link.parsed); |
|
// Validate event ID before creating placeholder to prevent invalid fetches |
|
if (eventId && isValidNostrId(eventId)) { |
|
// Escape event ID to prevent XSS |
|
const escapedEventId = escapeHtml(eventId); |
|
// Create a div element for embedded event blurbs (block-level, styled differently) |
|
const div = `<div data-nostr-event-blurb data-event-id="${escapedEventId}"></div>`; |
|
processed = |
|
processed.slice(0, link.start) + |
|
div + |
|
processed.slice(link.end); |
|
} |
|
} |
|
|
|
// 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 |
|
// Note: Code highlighting is done post-render in the effect below, not via marked options |
|
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 |
|
}); |
|
|
|
// Resolve emojis when content or event changes |
|
$effect(() => { |
|
if (content && event) { |
|
// Run async resolution without blocking |
|
resolveContentEmojis(content).catch(err => { |
|
console.warn('Error resolving content emojis:', err); |
|
}); |
|
} else { |
|
emojiUrls = new Map(); |
|
} |
|
}); |
|
|
|
// Check if event should use Asciidoctor (kinds 30818 and 30041) |
|
const useAsciidoctor = $derived(event?.kind === 30818 || event?.kind === 30041); |
|
const isKind30040 = $derived(event?.kind === 30040); |
|
const isKind30023 = $derived(event?.kind === KIND.LONG_FORM_NOTE); |
|
|
|
// Load highlights for event |
|
// Highlights are loaded for all content events (kind 30041, kind 1, etc.) |
|
// but NOT for kind 30040 index events (which have no content) |
|
async function loadHighlights(abortSignal: AbortSignal) { |
|
if (!event || !content) return; |
|
|
|
// Skip highlights for kind 30040 index events (they have no content) |
|
// Highlights should be loaded for the indexed sections (kind 30041) instead |
|
if (isKind30040 && !content.trim()) { |
|
if (!abortSignal.aborted) { |
|
highlightsLoaded = true; |
|
} |
|
return; |
|
} |
|
|
|
// For kind 30040 sections, wait a bit for full publication load |
|
if (isKind30040) { |
|
await new Promise(resolve => setTimeout(resolve, 2000)); |
|
|
|
// Check if operation was aborted after delay |
|
if (abortSignal.aborted) return; |
|
} |
|
|
|
try { |
|
// Load highlights for this specific event (kind 30041, kind 1, etc.) |
|
// Highlights will be displayed with user badges and links to the highlight events |
|
const dTag = event.tags.find(t => t[0] === 'd' && t[1])?.[1]; |
|
const eventHighlights = await getHighlightsForEvent( |
|
event.id, |
|
event.kind, |
|
event.pubkey, |
|
dTag |
|
); |
|
|
|
// Check if operation was aborted before updating state |
|
if (abortSignal.aborted) return; |
|
|
|
highlights = eventHighlights; |
|
highlightMatches = findHighlightMatches(content, eventHighlights); |
|
highlightsLoaded = true; |
|
} catch (error) { |
|
// Only update state if not aborted |
|
if (abortSignal.aborted) return; |
|
|
|
console.error('Error loading highlights:', error); |
|
highlightsLoaded = true; |
|
} |
|
} |
|
|
|
// Load highlights when content or event changes |
|
$effect(() => { |
|
if (content && event) { |
|
// Create abort controller to track effect lifecycle |
|
const abortController = new AbortController(); |
|
|
|
// Load highlights with abort signal |
|
loadHighlights(abortController.signal); |
|
|
|
// Cleanup: abort the async operation if effect re-runs or component unmounts |
|
return () => { |
|
abortController.abort(); |
|
}; |
|
} |
|
}); |
|
|
|
// Post-process HTML to convert blockquotes that are actually greentext |
|
// Merges consecutive greentext blockquotes into a single block, preserving line breaks |
|
function postProcessGreentext(html: string): string { |
|
// Pattern to match one or more consecutive greentext blockquotes |
|
// Matches: <blockquote><p>>text</p></blockquote> (with optional whitespace between) |
|
// where there's no space after > (greentext pattern) |
|
// The (?:...) part matches zero or more additional consecutive blockquotes |
|
const consecutiveGreentextPattern = /(<blockquote[^>]*>\s*<p[^>]*>>([^\s<].*?)<\/p>\s*<\/blockquote>)(\s*<blockquote[^>]*>\s*<p[^>]*>>([^\s<].*?)<\/p>\s*<\/blockquote>)*/g; |
|
|
|
return html.replace(consecutiveGreentextPattern, (match) => { |
|
// Extract all greentext contents from the match |
|
const contentPattern = /<blockquote[^>]*>\s*<p[^>]*>>([^\s<].*?)<\/p>\s*<\/blockquote>/g; |
|
const contents: string[] = []; |
|
let contentMatch; |
|
while ((contentMatch = contentPattern.exec(match)) !== null) { |
|
contents.push(contentMatch[1]); |
|
} |
|
|
|
if (contents.length === 0) { |
|
return match; // Shouldn't happen, but safety check |
|
} |
|
|
|
// Join all contents with <br> to preserve line breaks, no extra spacing |
|
const combinedContent = contents.map(c => escapeHtml(c)).map(content => `>${content}`).join('<br>'); |
|
return `<span class="greentext">${combinedContent}</span>`; |
|
}); |
|
} |
|
|
|
// Helper function to apply exclusion filtering to HTML (used for both fresh and cached content) |
|
function applyExclusionFiltering(htmlContent: string): string { |
|
const excludeUrls = normalizedExcludeMediaUrls; |
|
if (!excludeUrls || excludeUrls.length === 0) return htmlContent; |
|
|
|
const normalizedExcludeUrls = new Set(excludeUrls.map(url => normalizeUrl(url))); |
|
let filtered = htmlContent; |
|
|
|
// Remove ALL img tags with excluded URLs (aggressive cleanup) |
|
// This is critical for kind 11 events to prevent duplicate images |
|
const beforeCount = (filtered.match(/<img[^>]*>/gi) || []).length; |
|
filtered = filtered.replace(/<img[^>]*>/gi, (match) => { |
|
const srcMatch = match.match(/src=["']([^"']+)["']/i); |
|
if (srcMatch) { |
|
const src = srcMatch[1]; |
|
const normalizedSrc = normalizeUrl(src); |
|
if (normalizedExcludeUrls.has(normalizedSrc)) { |
|
if (typeof console !== 'undefined') { |
|
console.debug('MarkdownRenderer: Removing excluded img tag from HTML:', src, 'normalized:', normalizedSrc, 'excluded set:', Array.from(normalizedExcludeUrls)); |
|
} |
|
return ''; // Remove the img tag completely |
|
} |
|
} |
|
return match; |
|
}); |
|
const afterCount = (filtered.match(/<img[^>]*>/gi) || []).length; |
|
if (typeof console !== 'undefined' && beforeCount !== afterCount) { |
|
console.debug('MarkdownRenderer: Exclusion filtering removed', beforeCount - afterCount, 'img tags'); |
|
} |
|
|
|
// Remove links pointing to excluded image URLs |
|
filtered = filtered.replace(/<a\s+([^>]*?)href=["']([^"']+)["']([^>]*?)>([^<]*?)<\/a>/gi, (match, beforeAttrs, url, afterAttrs, linkText) => { |
|
if (normalizedExcludeUrls.has(normalizeUrl(url)) && /\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?|$)/i.test(url)) { |
|
return linkText; |
|
} |
|
return match; |
|
}); |
|
|
|
// Remove excluded image URLs from text nodes |
|
filtered = filtered.replace(/>([^<]*?)(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?[^\s<>"{}|\\^`\[\]]*)?)([^<]*?)</gi, (match, before, url, ext, query, after) => { |
|
if (normalizedExcludeUrls.has(normalizeUrl(url))) { |
|
return `>${before}${after}<`; |
|
} |
|
return match; |
|
}); |
|
|
|
return filtered; |
|
} |
|
|
|
// Render markdown or AsciiDoc to HTML |
|
async function renderMarkdown(text: string): Promise<string> { |
|
if (!content) return ''; |
|
|
|
// Ensure content is defined (TypeScript narrowing) |
|
const contentToRender: string = content; |
|
|
|
// Include cache version in cache key to invalidate old cached versions |
|
const cacheKey = `${MARKDOWN_CACHE_VERSION}:${contentToRender}`; |
|
|
|
// Check IndexedDB cache first (persistent) |
|
const cachedFromDB = await getCachedMarkdown(cacheKey); |
|
if (cachedFromDB) { |
|
// Also update in-memory cache for faster subsequent access |
|
if (markdownCache.size >= MAX_CACHE_SIZE) { |
|
// Remove oldest entry (simple FIFO) |
|
const firstKey = markdownCache.keys().next().value; |
|
if (firstKey !== undefined) { |
|
markdownCache.delete(firstKey); |
|
} |
|
} |
|
markdownCache.set(cacheKey, cachedFromDB); |
|
// Apply exclusion filtering to cached content (exclusion list may have changed) |
|
return applyExclusionFiltering(cachedFromDB); |
|
} |
|
|
|
// Check in-memory cache (faster for same session) |
|
const cached = markdownCache.get(cacheKey); |
|
if (cached !== undefined) { |
|
// Apply exclusion filtering to cached content (exclusion list may have changed) |
|
return applyExclusionFiltering(cached); |
|
} |
|
|
|
const processed = processContent(contentToRender); |
|
|
|
let html: string; |
|
|
|
if (useAsciidoctor) { |
|
// Use Asciidoctor for kinds 30818 and 30041 |
|
try { |
|
html = renderAsciiDoc(processed); |
|
} catch (error) { |
|
console.error('Asciidoctor parsing error:', error); |
|
return processed; // Fallback to raw text if parsing fails |
|
} |
|
} else { |
|
// Use marked for all other kinds |
|
try { |
|
html = marked.parse(processed) as string; |
|
} catch (error) { |
|
console.error('Marked parsing error:', error); |
|
return processed; // Fallback to raw text if parsing fails |
|
} |
|
} |
|
|
|
// Post-process to fix any greentext that markdown converted to blockquotes |
|
html = postProcessGreentext(html); |
|
|
|
// Apply exclusion filtering to the rendered HTML |
|
html = applyExclusionFiltering(html); |
|
|
|
// Remove emoji images that are inside code blocks (they should be plain text) |
|
// This handles cases where emojis were replaced before markdown/AsciiDoc parsing |
|
// Handle <code> tags (inline code) |
|
html = html.replace(/<code[^>]*>([\s\S]*?)<\/code>/gi, (match, codeContent) => { |
|
// Remove any emoji img tags inside code blocks and restore the original shortcode |
|
return match.replace(/<img[^>]*class="emoji-inline"[^>]*alt="([^"]*)"[^>]*>/gi, '$1'); |
|
}); |
|
|
|
// Handle <pre> tags (code blocks and AsciiDoc source blocks) |
|
html = html.replace(/<pre[^>]*>([\s\S]*?)<\/pre>/gi, (match, preContent) => { |
|
// Remove any emoji img tags inside pre blocks and restore the original shortcode |
|
return match.replace(/<img[^>]*class="emoji-inline"[^>]*alt="([^"]*)"[^>]*>/gi, '$1'); |
|
}); |
|
|
|
// Handle AsciiDoc source blocks (they use <div class="listingblock"> with <pre> inside) |
|
html = html.replace(/<div[^>]*class="listingblock"[^>]*>([\s\S]*?)<\/div>/gi, (match, divContent) => { |
|
// Remove emoji images from AsciiDoc listing blocks |
|
return match.replace(/<img[^>]*class="emoji-inline"[^>]*alt="([^"]*)"[^>]*>/gi, '$1'); |
|
}); |
|
|
|
// Also handle any other code-related elements that might contain emojis |
|
html = html.replace(/<[^>]*class="[^"]*code[^"]*"[^>]*>([\s\S]*?)<\/[^>]+>/gi, (match, content) => { |
|
// Remove emoji images from any element with "code" in its class |
|
return match.replace(/<img[^>]*class="emoji-inline"[^>]*alt="([^"]*)"[^>]*>/gi, '$1'); |
|
}); |
|
|
|
// Normalize exclude URLs for comparison |
|
const excludeUrls = normalizedExcludeMediaUrls; |
|
const normalizedExcludeUrls = new Set(excludeUrls.map(url => normalizeUrl(url))); |
|
|
|
// AGGRESSIVE CLEANUP: Remove ALL img tags with excluded URLs first (before any other processing) |
|
// This is the most important step - it catches images regardless of how they were created |
|
if (normalizedExcludeUrls.size > 0) { |
|
const beforeCleanup = html; |
|
html = html.replace(/<img[^>]*>/gi, (match) => { |
|
// Extract src attribute |
|
const srcMatch = match.match(/src=["']([^"']+)["']/i); |
|
if (srcMatch) { |
|
const src = srcMatch[1]; |
|
const normalizedSrc = normalizeUrl(src); |
|
// Remove if this image is already displayed by MediaAttachments |
|
if (normalizedExcludeUrls.has(normalizedSrc)) { |
|
if (typeof console !== 'undefined') { |
|
console.debug('MarkdownRenderer: Removing excluded img tag:', src, 'normalized:', normalizedSrc); |
|
} |
|
return ''; // Remove the img tag completely |
|
} |
|
} |
|
return match; |
|
}); |
|
if (typeof console !== 'undefined' && beforeCleanup !== html) { |
|
console.debug('MarkdownRenderer: Cleaned up img tags. Before:', beforeCleanup.match(/<img[^>]*>/gi)?.length || 0, 'After:', html.match(/<img[^>]*>/gi)?.length || 0); |
|
} |
|
} |
|
|
|
// Fix malformed image tags - ensure all img src attributes are absolute URLs |
|
// This prevents the browser from trying to fetch markdown syntax or malformed tags as relative URLs |
|
// Also filter out images that are already displayed by MediaAttachments (defensive check) |
|
html = html.replace(/<img\s+([^>]*?)>/gi, (match, attributes) => { |
|
// Extract src attribute |
|
const srcMatch = attributes.match(/src=["']([^"']+)["']/i); |
|
if (srcMatch) { |
|
const src = srcMatch[1]; |
|
|
|
// Skip if this image is already displayed by MediaAttachments |
|
if (normalizedExcludeUrls.has(normalizeUrl(src))) { |
|
return ''; // Remove the img tag |
|
} |
|
|
|
// If src doesn't start with http:// or https://, it might be malformed |
|
// Check if it looks like it should be a URL but isn't properly formatted |
|
if (!src.startsWith('http://') && !src.startsWith('https://') && !src.startsWith('data:') && !src.startsWith('/')) { |
|
// This might be a malformed URL - try to extract a valid URL from it |
|
const urlMatch = src.match(/(https?:\/\/[^\s<>"']+)/i); |
|
if (urlMatch) { |
|
// Replace with the valid URL |
|
return match.replace(srcMatch[0], `src="${urlMatch[1]}"`); |
|
} |
|
// If no valid URL found, remove the img tag to prevent 404s |
|
return ''; |
|
} |
|
} |
|
// If no src attribute or src is empty, remove the img tag |
|
if (!srcMatch || !srcMatch[1]) { |
|
return ''; |
|
} |
|
return match; |
|
}); |
|
|
|
// Fix markdown image syntax that wasn't properly converted (e.g.,  as text) |
|
// This should be rare, but if marked didn't convert it, we need to handle it |
|
// Also filter out images that are already displayed by MediaAttachments |
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, (match, alt, url) => { |
|
// Skip if this image is already displayed by MediaAttachments |
|
if (normalizedExcludeUrls.has(normalizeUrl(url))) { |
|
return ''; // Remove the markdown image syntax |
|
} |
|
|
|
// Only convert if it's a valid URL |
|
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) { |
|
const escapedUrl = escapeHtml(url); |
|
const escapedAlt = escapeHtml(alt); |
|
return `<img src="${escapedUrl}" alt="${escapedAlt}" style="max-width: 600px; width: 100%; height: auto;" />`; |
|
} |
|
// If not a valid URL, remove the markdown syntax to prevent 404s |
|
return alt || ''; |
|
}); |
|
|
|
// Remove any escaped HTML that looks like img tags (e.g., <img src="...">) |
|
// These might be causing the browser to try to fetch them as URLs |
|
html = html.replace(/<img\s+[^&]*src=["']([^"']+)["'][^&]*>/gi, (match, url) => { |
|
// If it's a valid image URL, convert to a proper img tag |
|
if (url.startsWith('http://') || url.startsWith('https://') || url.startsWith('data:')) { |
|
const escapedUrl = escapeHtml(url); |
|
return `<img src="${escapedUrl}" alt="" style="max-width: 600px; width: 100%; height: auto;" />`; |
|
} |
|
// Otherwise, remove to prevent 404s |
|
return ''; |
|
}); |
|
|
|
// Remove any plain text patterns that look like markdown image syntax or HTML img tags |
|
// These patterns in text nodes can cause the browser to try to fetch them as relative URLs |
|
// We match text between HTML tags (not inside tags) that contains these patterns |
|
html = html.replace(/>([^<]*?)(!\[[^\]]*\]\(https?:\/\/[^)]+\)|img\s+src=["']https?:\/\/[^"']+["'])([^<]*?)</gi, (match, before, pattern, after) => { |
|
// Extract URL from the pattern |
|
const urlMatch = pattern.match(/(https?:\/\/[^\s<>"')]+)/i); |
|
if (urlMatch) { |
|
const url = urlMatch[1]; |
|
// Skip if this URL is already displayed by MediaAttachments |
|
if (normalizedExcludeUrls.has(normalizeUrl(url))) { |
|
// Remove the URL pattern completely |
|
return `>${before}${after}<`; |
|
} |
|
// If it's an image URL, convert to proper img tag |
|
if (/\.(png|jpg|jpeg|gif|webp|svg|bmp|ico)(\?[^\s<>"']*)?$/i.test(url)) { |
|
const escapedUrl = escapeHtml(url); |
|
return `>${before}<img src="${escapedUrl}" alt="" />${after}<`; |
|
} |
|
} |
|
// Otherwise, remove the problematic pattern to prevent 404s |
|
return `>${before}${after}<`; |
|
}); |
|
|
|
// Final aggressive cleanup: Remove ANY reference to excluded URLs from the HTML |
|
// This catches URLs that might have been converted to links by the markdown renderer |
|
// or appeared in any other form |
|
if (normalizedExcludeUrls.size > 0) { |
|
// Remove links that point to excluded image URLs (marked might auto-link them) |
|
html = html.replace(/<a\s+([^>]*?)href=["']([^"']+)["']([^>]*?)>([^<]*?)<\/a>/gi, (match, beforeAttrs, url, afterAttrs, linkText) => { |
|
if (normalizedExcludeUrls.has(normalizeUrl(url))) { |
|
// Remove the link but keep just the text content |
|
return linkText; |
|
} |
|
return match; |
|
}); |
|
|
|
// Remove excluded image URLs from text nodes (final safety net) |
|
// Match URLs in text content (between > and <) |
|
html = html.replace(/>([^<]*?)(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?[^\s<>"{}|\\^`\[\]]*)?)([^<]*?)</gi, (match, before, url, ext, query, after) => { |
|
// Check if this URL is excluded (already displayed by MediaAttachments) |
|
if (normalizedExcludeUrls.has(normalizeUrl(url))) { |
|
// Remove the URL from the text completely |
|
return `>${before}${after}<`; |
|
} |
|
// Otherwise, leave it as-is |
|
return match; |
|
}); |
|
} |
|
|
|
// Filter out invalid relative links (like /a, /b, etc.) that cause 404s |
|
// These are likely malformed markdown links |
|
html = html.replace(/<a\s+href="\/([a-z]|\.)"[^>]*>.*?<\/a>/gi, (match, char) => { |
|
// Only filter single-character or dot links that aren't valid routes |
|
const validSingleCharRoutes = ['r', 'f', 'w', 't', 'c']; // /rss, /feed, /write, /topics, /cache |
|
if (validSingleCharRoutes.includes(char.toLowerCase())) { |
|
return match; // Keep valid routes |
|
} |
|
// Remove the link but keep the text content |
|
const textMatch = match.match(/>(.*?)<\/a>/); |
|
return textMatch ? textMatch[1] : ''; |
|
}); |
|
|
|
// FINAL PASS: Remove any remaining references to excluded URLs |
|
// This is the absolute last step before sanitization - catches anything we might have missed |
|
if (normalizedExcludeUrls.size > 0) { |
|
// Remove any img tags with excluded URLs (one more time, just to be sure) |
|
html = html.replace(/<img[^>]*src=["']([^"']+)["'][^>]*>/gi, (match, src) => { |
|
if (normalizedExcludeUrls.has(normalizeUrl(src))) { |
|
return ''; |
|
} |
|
return match; |
|
}); |
|
|
|
// Remove any links pointing to excluded image URLs |
|
html = html.replace(/<a\s+[^>]*href=["']([^"']+)["'][^>]*>([^<]*?)<\/a>/gi, (match, href, linkText) => { |
|
if (normalizedExcludeUrls.has(normalizeUrl(href))) { |
|
// Check if it's an image URL (ends with image extension) |
|
if (/\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?|$)/i.test(href)) { |
|
return linkText; // Remove link, keep text |
|
} |
|
} |
|
return match; |
|
}); |
|
|
|
// Remove excluded image URLs from any remaining text content |
|
html = html.replace(/(https?:\/\/[^\s<>"{}|\\^`\[\]]+\.(jpg|jpeg|png|gif|webp|svg|bmp)(\?[^\s<>"{}|\\^`\[\]]*)?)/gi, (match, url) => { |
|
if (normalizedExcludeUrls.has(normalizeUrl(url))) { |
|
return ''; |
|
} |
|
return match; |
|
}); |
|
} |
|
|
|
// Sanitize HTML (but preserve our data attributes and image src) |
|
let sanitized = sanitizeMarkdown(html); |
|
|
|
// ONE MORE FINAL PASS after sanitization - remove any excluded URLs that might have survived |
|
// This is the absolute last chance to catch excluded images |
|
if (normalizedExcludeUrls.size > 0) { |
|
sanitized = sanitized.replace(/<img[^>]*>/gi, (match) => { |
|
const srcMatch = match.match(/src=["']([^"']+)["']/i); |
|
if (srcMatch && normalizedExcludeUrls.has(normalizeUrl(srcMatch[1]))) { |
|
return ''; |
|
} |
|
return match; |
|
}); |
|
} |
|
|
|
// Cache the result (with size limit to prevent memory bloat) |
|
if (markdownCache.size >= MAX_CACHE_SIZE) { |
|
// Remove oldest entry (simple FIFO) |
|
const firstKey = markdownCache.keys().next().value; |
|
if (firstKey !== undefined) { |
|
markdownCache.delete(firstKey); |
|
} |
|
} |
|
markdownCache.set(cacheKey, sanitized); |
|
|
|
// Cache in IndexedDB asynchronously (don't await to avoid blocking) |
|
cacheMarkdown(cacheKey, sanitized).catch(err => { |
|
console.debug('Failed to cache markdown in IndexedDB:', err); |
|
}); |
|
|
|
return sanitized; |
|
} |
|
|
|
// Rendered HTML state (async rendering with cache) |
|
let renderedHtml = $state<string>(''); |
|
|
|
// Render markdown when content changes (with async cache support) |
|
$effect(() => { |
|
if (!content) { |
|
renderedHtml = ''; |
|
return; |
|
} |
|
|
|
// Start with in-memory cache for instant display |
|
const cacheKey = `${MARKDOWN_CACHE_VERSION}:${content}`; |
|
const cached = markdownCache.get(cacheKey); |
|
if (cached) { |
|
renderedHtml = cached; |
|
} |
|
|
|
// Then check IndexedDB and re-render if needed (only if content is defined) |
|
if (content) { |
|
renderMarkdown(content).then(html => { |
|
if (html !== renderedHtml) { |
|
renderedHtml = html; |
|
} |
|
}).catch(err => { |
|
console.error('Error rendering markdown:', err); |
|
renderedHtml = content || ''; // Fallback to raw content |
|
}); |
|
} |
|
}); |
|
|
|
// Mount ProfileBadge components after rendering |
|
function mountProfileBadges() { |
|
if (!containerRef) return; |
|
|
|
// Find all profile placeholders and mount ProfileBadge components |
|
const placeholders = containerRef.querySelectorAll('[data-nostr-profile]:not([data-mounted])'); |
|
|
|
if (placeholders.length > 0) { |
|
console.debug(`Mounting ${placeholders.length} ProfileBadge components`); |
|
|
|
placeholders.forEach((placeholder) => { |
|
const pubkey = placeholder.getAttribute('data-pubkey'); |
|
if (pubkey && /^[0-9a-f]{64}$/i.test(pubkey)) { |
|
placeholder.setAttribute('data-mounted', 'true'); |
|
|
|
try { |
|
// Clear and mount component |
|
placeholder.innerHTML = ''; |
|
// Use inline mode for profile badges in markdown content |
|
const instance = mountComponent(placeholder as HTMLElement, ProfileBadge as any, { pubkey, inline: true }); |
|
|
|
if (!instance) { |
|
console.warn('ProfileBadge mount returned null', { pubkey }); |
|
// Fallback |
|
try { |
|
const npub = nip19.npubEncode(pubkey); |
|
placeholder.textContent = npub.slice(0, 12) + '...'; |
|
} catch { |
|
placeholder.textContent = pubkey.slice(0, 12) + '...'; |
|
} |
|
} |
|
} catch (error) { |
|
console.error('Error mounting ProfileBadge:', error, { pubkey }); |
|
// Show fallback |
|
try { |
|
const npub = nip19.npubEncode(pubkey); |
|
placeholder.textContent = npub.slice(0, 12) + '...'; |
|
} catch { |
|
placeholder.textContent = pubkey.slice(0, 12) + '...'; |
|
} |
|
} |
|
} else if (pubkey) { |
|
console.warn('Invalid pubkey format:', pubkey); |
|
placeholder.textContent = pubkey.slice(0, 12) + '...'; |
|
} |
|
}); |
|
} |
|
} |
|
|
|
// Mount EmbeddedEventBlurb components after rendering (for nostr: links in content) |
|
function mountEmbeddedBlurbs() { |
|
if (!containerRef || mountingEmbeddedBlurbs) return; |
|
|
|
// Find all event blurb placeholders and mount EmbeddedEventBlurb components |
|
const placeholders = containerRef.querySelectorAll('[data-nostr-event-blurb]:not([data-mounted])'); |
|
|
|
if (placeholders.length > 0) { |
|
mountingEmbeddedBlurbs = true; |
|
try { |
|
// Validate event IDs before mounting to prevent invalid fetches |
|
const validPlaceholders: Element[] = []; |
|
placeholders.forEach((placeholder) => { |
|
const eventId = placeholder.getAttribute('data-event-id'); |
|
// Use strict validation to prevent invalid fetches |
|
if (eventId && isValidNostrId(eventId)) { |
|
validPlaceholders.push(placeholder); |
|
} else if (eventId) { |
|
// Invalid event ID - mark as mounted to prevent retries |
|
placeholder.setAttribute('data-mounted', 'true'); |
|
placeholder.textContent = ''; // Don't show invalid IDs |
|
console.debug('Skipping invalid event ID in MarkdownRenderer:', eventId); |
|
} |
|
}); |
|
|
|
if (validPlaceholders.length > 0) { |
|
console.debug(`Mounting ${validPlaceholders.length} EmbeddedEventBlurb components`); |
|
|
|
validPlaceholders.forEach((placeholder) => { |
|
const eventId = placeholder.getAttribute('data-event-id'); |
|
if (eventId) { |
|
placeholder.setAttribute('data-mounted', 'true'); |
|
|
|
try { |
|
// Clear and mount component |
|
placeholder.innerHTML = ''; |
|
// Mount EmbeddedEventBlurb component - it will decode and fetch the event |
|
const instance = mountComponent(placeholder as HTMLElement, EmbeddedEventBlurb as any, { eventId }); |
|
|
|
if (!instance) { |
|
console.warn('EmbeddedEventBlurb mount returned null', { eventId }); |
|
// Fallback: show the event ID |
|
placeholder.textContent = eventId.slice(0, 20) + '...'; |
|
} |
|
} catch (error) { |
|
console.error('Error mounting EmbeddedEventBlurb:', error, { eventId }); |
|
// Show fallback |
|
placeholder.textContent = eventId.slice(0, 20) + '...'; |
|
} |
|
} |
|
}); |
|
} |
|
} finally { |
|
mountingEmbeddedBlurbs = false; |
|
} |
|
} |
|
} |
|
|
|
// Mount EmbeddedEvent components after rendering (for other embedded events) |
|
function mountEmbeddedEvents() { |
|
if (!containerRef || mountingEmbeddedEvents) return; |
|
|
|
// Find all event placeholders and mount EmbeddedEvent components |
|
const placeholders = containerRef.querySelectorAll('[data-nostr-event]:not([data-mounted])'); |
|
|
|
if (placeholders.length > 0) { |
|
mountingEmbeddedEvents = true; |
|
try { |
|
// Validate event IDs before mounting to prevent invalid fetches |
|
const validPlaceholders: Element[] = []; |
|
placeholders.forEach((placeholder) => { |
|
const eventId = placeholder.getAttribute('data-event-id'); |
|
// Use strict validation to prevent invalid fetches |
|
if (eventId && isValidNostrId(eventId)) { |
|
validPlaceholders.push(placeholder); |
|
} else if (eventId) { |
|
// Invalid event ID - mark as mounted to prevent retries |
|
placeholder.setAttribute('data-mounted', 'true'); |
|
placeholder.textContent = ''; // Don't show invalid IDs |
|
console.debug('Skipping invalid event ID in MarkdownRenderer:', eventId); |
|
} |
|
}); |
|
|
|
if (validPlaceholders.length > 0) { |
|
console.debug(`Mounting ${validPlaceholders.length} EmbeddedEvent components`); |
|
|
|
validPlaceholders.forEach((placeholder) => { |
|
const eventId = placeholder.getAttribute('data-event-id'); |
|
if (eventId) { |
|
placeholder.setAttribute('data-mounted', 'true'); |
|
|
|
try { |
|
// Clear and mount component |
|
placeholder.innerHTML = ''; |
|
// Mount EmbeddedEvent component - it will decode and fetch the event |
|
const instance = mountComponent(placeholder as HTMLElement, EmbeddedEvent as any, { eventId }); |
|
|
|
if (!instance) { |
|
console.warn('EmbeddedEvent mount returned null', { eventId }); |
|
// Fallback: show the event ID |
|
placeholder.textContent = eventId.slice(0, 20) + '...'; |
|
} |
|
} catch (error) { |
|
console.error('Error mounting EmbeddedEvent:', error, { eventId }); |
|
// Show fallback |
|
placeholder.textContent = eventId.slice(0, 20) + '...'; |
|
} |
|
} |
|
}); |
|
} |
|
} finally { |
|
mountingEmbeddedEvents = false; |
|
} |
|
} |
|
} |
|
|
|
|
|
$effect(() => { |
|
if (!containerRef || !renderedHtml) return; |
|
|
|
// Use requestAnimationFrame + setTimeout to ensure DOM is ready |
|
const frameId = requestAnimationFrame(() => { |
|
const timeoutId = setTimeout(() => { |
|
if (!containerRef) return; |
|
|
|
// Highlight code blocks (both Markdown and AsciiDoc) |
|
// Markdown: <pre><code> |
|
const codeBlocks = containerRef.querySelectorAll('pre code'); |
|
codeBlocks.forEach((block) => { |
|
if (!block.classList.contains('hljs')) { |
|
const code = block.textContent || ''; |
|
const className = block.className || ''; |
|
const langMatch = className.match(/language-(\w+)/); |
|
const lang = langMatch ? langMatch[1] : ''; |
|
|
|
if (lang && hljs.getLanguage(lang)) { |
|
try { |
|
block.innerHTML = hljs.highlight(code, { language: lang }).value; |
|
block.className = `hljs ${className}`; |
|
} catch (err) { |
|
console.warn('Highlight.js error:', err); |
|
} |
|
} else { |
|
try { |
|
block.innerHTML = hljs.highlightAuto(code).value; |
|
block.className = `hljs ${className}`; |
|
} catch (err) { |
|
console.warn('Highlight.js auto-detect error:', err); |
|
} |
|
} |
|
} |
|
}); |
|
|
|
// AsciiDoc: <div class="listingblock"><pre><code> or <pre class="highlight"><code> |
|
if (!containerRef) return; |
|
const asciidocBlocks = containerRef.querySelectorAll('.listingblock pre code, pre.highlight code'); |
|
asciidocBlocks.forEach((block) => { |
|
if (!block.classList.contains('hljs')) { |
|
const code = block.textContent || ''; |
|
// AsciiDoc might have language in data-lang or class |
|
const preElement = block.closest('pre'); |
|
const lang = preElement?.getAttribute('data-lang') || |
|
preElement?.className.match(/(?:^|\s)language-(\w+)/)?.[1] || |
|
block.className.match(/(?:^|\s)language-(\w+)/)?.[1] || ''; |
|
|
|
if (lang && hljs.getLanguage(lang)) { |
|
try { |
|
block.innerHTML = hljs.highlight(code, { language: lang }).value; |
|
block.className = `hljs ${block.className}`; |
|
} catch (err) { |
|
console.warn('Highlight.js error:', err); |
|
} |
|
} else { |
|
try { |
|
block.innerHTML = hljs.highlightAuto(code).value; |
|
block.className = `hljs ${block.className}`; |
|
} catch (err) { |
|
console.warn('Highlight.js auto-detect error:', err); |
|
} |
|
} |
|
} |
|
}); |
|
|
|
mountProfileBadges(); |
|
mountEmbeddedBlurbs(); |
|
mountEmbeddedEvents(); |
|
}, 150); |
|
|
|
return () => clearTimeout(timeoutId); |
|
}); |
|
|
|
return () => cancelAnimationFrame(frameId); |
|
}); |
|
|
|
// Also use MutationObserver to catch any placeholders added later |
|
// Debounce to prevent excessive re-mounts |
|
let mutationDebounceTimeout: ReturnType<typeof setTimeout> | null = null; |
|
|
|
$effect(() => { |
|
if (!containerRef) return; |
|
|
|
const observer = new MutationObserver(() => { |
|
// Debounce mutations to prevent excessive re-mounts |
|
if (mutationDebounceTimeout) { |
|
clearTimeout(mutationDebounceTimeout); |
|
} |
|
mutationDebounceTimeout = setTimeout(() => { |
|
mountProfileBadges(); |
|
mountEmbeddedBlurbs(); |
|
mountEmbeddedEvents(); |
|
mutationDebounceTimeout = null; |
|
}, 300); // 300ms debounce |
|
}); |
|
|
|
observer.observe(containerRef, { |
|
childList: true, |
|
subtree: true |
|
}); |
|
|
|
return () => { |
|
observer.disconnect(); |
|
if (mutationDebounceTimeout) { |
|
clearTimeout(mutationDebounceTimeout); |
|
mutationDebounceTimeout = null; |
|
} |
|
}; |
|
}); |
|
</script> |
|
|
|
<HighlightOverlay highlights={highlightMatches} content={content} event={event!}> |
|
<div |
|
bind:this={containerRef} |
|
class="markdown-content prose prose-sm dark:prose-invert max-w-none" |
|
> |
|
{@html renderedHtml} |
|
</div> |
|
{#if isKind30023 && containerRef} |
|
<TTSControls text={containerRef} /> |
|
{/if} |
|
</HighlightOverlay> |
|
|
|
<style> |
|
:global(.markdown-content) { |
|
word-wrap: break-word; |
|
overflow-wrap: break-word; |
|
} |
|
|
|
/* Increased paragraph spacing for better readability */ |
|
:global(.markdown-content p) { |
|
margin-bottom: 1.25em; |
|
margin-top: 0; |
|
} |
|
|
|
/* Ensure consistent spacing between paragraphs and other elements */ |
|
:global(.markdown-content p + p) { |
|
margin-top: 0; |
|
} |
|
|
|
:global(.markdown-content p + ul), |
|
:global(.markdown-content p + ol), |
|
:global(.markdown-content p + blockquote), |
|
:global(.markdown-content p + pre) { |
|
margin-top: 1em; |
|
} |
|
|
|
:global(.markdown-content img):not(.emoji-inline) { |
|
max-width: 600px; |
|
width: 100%; |
|
height: auto; |
|
border-radius: 0.25rem; |
|
margin: 0.5rem 0; |
|
display: block; |
|
visibility: visible !important; |
|
opacity: 1 !important; |
|
/* Content images should be prominent - no grayscale filters */ |
|
filter: none !important; |
|
} |
|
|
|
:global(.markdown-content img.emoji-inline) { |
|
display: inline-block; |
|
margin: 0; |
|
vertical-align: middle; |
|
width: 1.6em; |
|
height: 1.6em; |
|
object-fit: contain; |
|
max-width: none; /* Emojis should keep their fixed size */ |
|
/* Emojis should be in full color, no grayscale filter */ |
|
} |
|
|
|
:global(.markdown-content video) { |
|
max-width: 600px; |
|
width: 100%; |
|
height: auto; |
|
border-radius: 0.25rem; |
|
margin: 0.5rem 0; |
|
display: block; |
|
/* Content videos should be prominent - no grayscale filters */ |
|
filter: none !important; |
|
} |
|
|
|
:global(.markdown-content audio) { |
|
max-width: 600px; |
|
width: 100%; |
|
margin: 0.5rem 0; |
|
display: block; |
|
/* Content audio should be prominent - no grayscale filters */ |
|
filter: none !important; |
|
} |
|
|
|
:global(.markdown-content a) { |
|
color: var(--fog-accent, #64748b); |
|
text-decoration: underline; |
|
} |
|
|
|
:global(.markdown-content a:hover) { |
|
text-decoration: none; |
|
} |
|
|
|
:global(.markdown-content a.hashtag-link) { |
|
color: var(--fog-accent, #64748b); |
|
text-decoration: none; |
|
font-weight: 500; |
|
} |
|
|
|
:global(.markdown-content a.hashtag-link:hover) { |
|
text-decoration: underline; |
|
} |
|
|
|
:global(.dark .markdown-content a.hashtag-link) { |
|
color: var(--fog-dark-accent, #94a3b8); |
|
} |
|
|
|
: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 code) { |
|
background: var(--fog-dark-border, #374151); |
|
} |
|
|
|
:global(.markdown-content pre) { |
|
background: var(--fog-border, #e5e7eb); |
|
padding: 1rem; |
|
border-radius: 0.5rem; |
|
overflow-x: auto; |
|
margin: 0.5rem 0; |
|
} |
|
|
|
:global(.dark .markdown-content pre) { |
|
background: var(--fog-dark-border, #374151); |
|
} |
|
|
|
:global(.markdown-content pre code) { |
|
background: transparent; |
|
padding: 0; |
|
} |
|
|
|
/* IDE-style code block styling - always dark/black background like VS Code/JetBrains */ |
|
:global(.markdown-content pre) { |
|
background: #1e1e1e !important; /* VS Code dark background */ |
|
border: 1px solid #3e3e3e; |
|
border-radius: 4px; |
|
padding: 1rem; |
|
margin: 1rem 0; |
|
overflow-x: auto; |
|
position: relative; |
|
} |
|
|
|
:global(.markdown-content pre code.hljs) { |
|
display: block; |
|
overflow-x: auto; |
|
padding: 0; |
|
background: transparent !important; |
|
color: #d4d4d4; /* VS Code text color */ |
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; |
|
font-size: 0.9em; |
|
line-height: 1.5; |
|
} |
|
|
|
/* Inline code - keep light styling but make it subtle */ |
|
:global(.markdown-content code.hljs:not(pre code)) { |
|
padding: 0.2em 0.4em; |
|
border-radius: 0.25rem; |
|
background: var(--fog-border, #e5e7eb); |
|
color: inherit; |
|
} |
|
|
|
:global(.dark .markdown-content code.hljs:not(pre code)) { |
|
background: var(--fog-dark-border, #374151); |
|
} |
|
|
|
/* Ensure pre blocks always have dark background regardless of theme */ |
|
:global(.markdown-content pre) { |
|
background: #1e1e1e !important; |
|
border-color: #3e3e3e !important; |
|
} |
|
|
|
/* AsciiDoc code blocks - same styling */ |
|
:global(.markdown-content .listingblock pre) { |
|
background: #1e1e1e !important; |
|
border: 1px solid #3e3e3e; |
|
border-radius: 4px; |
|
padding: 1rem; |
|
margin: 1rem 0; |
|
overflow-x: auto; |
|
} |
|
|
|
:global(.markdown-content .listingblock pre code.hljs) { |
|
background: transparent !important; |
|
color: #d4d4d4; |
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; |
|
font-size: 0.9em; |
|
line-height: 1.5; |
|
} |
|
|
|
:global(.markdown-content blockquote) { |
|
border-left: 4px solid var(--fog-border, #e5e7eb); |
|
padding-left: 1rem; |
|
margin: 0.5rem 0; |
|
color: var(--fog-text-light, #52667a); |
|
} |
|
|
|
:global(.dark .markdown-content blockquote) { |
|
border-color: var(--fog-dark-border, #374151); |
|
color: var(--fog-dark-text-light, #a8b8d0); |
|
} |
|
|
|
/* Greentext styling - 4chan style */ |
|
:global(.markdown-content .greentext) { |
|
color: #4a7c3a; /* Deeper, darker green for better readability in light mode */ |
|
display: block; |
|
margin: 0.25rem 0; |
|
font-family: inherit; |
|
} |
|
|
|
:global(.dark .markdown-content .greentext) { |
|
color: #8fbc8f; /* Lighter green for dark mode */ |
|
} |
|
|
|
/* Ensure greentext lines appear on their own line even if markdown processes them */ |
|
:global(.markdown-content p .greentext), |
|
:global(.markdown-content .greentext) { |
|
display: block; |
|
margin: 0.25rem 0; |
|
} |
|
|
|
/* Profile badges in markdown content should align with text baseline */ |
|
:global(.markdown-content [data-nostr-profile]), |
|
:global(.markdown-content .profile-badge) { |
|
vertical-align: middle; |
|
display: inline-flex; |
|
align-items: center; |
|
} |
|
|
|
/* Ensure profile pictures in markdown content maintain circular shape and don't stretch */ |
|
:global(.markdown-content .profile-badge .profile-picture), |
|
:global(.markdown-content .profile-badge .profile-placeholder) { |
|
width: 1.5rem !important; |
|
height: 1.5rem !important; |
|
max-width: 1.5rem !important; |
|
max-height: 1.5rem !important; |
|
min-width: 1.5rem !important; |
|
min-height: 1.5rem !important; |
|
aspect-ratio: 1 / 1 !important; |
|
border-radius: 50% !important; |
|
object-fit: cover !important; |
|
flex-shrink: 0 !important; |
|
} |
|
|
|
/* Embedded events should be block-level */ |
|
:global(.markdown-content [data-nostr-event]) { |
|
display: block; |
|
margin: 1rem 0; |
|
} |
|
|
|
/* Ensure normal Unicode emojis are displayed correctly */ |
|
:global(.markdown-content) { |
|
font-family: 'SF Mono', 'Monaco', 'Inconsolata', 'Fira Code', 'Droid Sans Mono', 'Source Code Pro', monospace; |
|
/* Ensure emojis are not filtered or hidden */ |
|
} |
|
|
|
:global(.markdown-content *) { |
|
/* Normal emojis (Unicode characters) should not have filters applied */ |
|
/* Only emoji images (custom emojis) should have the grayscale filter */ |
|
} |
|
</style> |