15 changed files with 1067 additions and 137 deletions
@ -0,0 +1,100 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { onMount } from "svelte"; |
||||||
|
import { parseBasicmarkup } from "$lib/utils/markup/basicMarkupParser"; |
||||||
|
import EmbeddedEvent from "./EmbeddedEvent.svelte"; |
||||||
|
|
||||||
|
const { |
||||||
|
content, |
||||||
|
nestingLevel = 0, |
||||||
|
} = $props<{ |
||||||
|
content: string; |
||||||
|
nestingLevel?: number; |
||||||
|
}>(); |
||||||
|
|
||||||
|
let parsedContent = $state(""); |
||||||
|
let embeddedEvents = $state<Array<{ |
||||||
|
id: string; |
||||||
|
nostrId: string; |
||||||
|
nestingLevel: number; |
||||||
|
}>>([]); |
||||||
|
|
||||||
|
// Maximum nesting level allowed |
||||||
|
const MAX_NESTING_LEVEL = 3; |
||||||
|
|
||||||
|
// AI-NOTE: 2025-01-24 - Component for rendering content with embedded Nostr events |
||||||
|
// Processes content and replaces nostr: links with EmbeddedEvent components |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (content) { |
||||||
|
processContent(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
async function processContent() { |
||||||
|
try { |
||||||
|
// First parse the basic markup |
||||||
|
parsedContent = await parseBasicmarkup(content); |
||||||
|
|
||||||
|
// Then find and extract embedded events |
||||||
|
extractEmbeddedEvents(); |
||||||
|
} catch (error) { |
||||||
|
console.error("Error processing content:", error); |
||||||
|
parsedContent = content; // Fallback to raw content |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function extractEmbeddedEvents() { |
||||||
|
const nostrPattern = /nostr:(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/g; |
||||||
|
const events: Array<{ |
||||||
|
id: string; |
||||||
|
nostrId: string; |
||||||
|
nestingLevel: number; |
||||||
|
}> = []; |
||||||
|
|
||||||
|
let match; |
||||||
|
while ((match = nostrPattern.exec(parsedContent)) !== null) { |
||||||
|
const nostrId = match[0]; |
||||||
|
const componentId = `embedded-event-${Math.random().toString(36).substr(2, 9)}`; |
||||||
|
|
||||||
|
events.push({ |
||||||
|
id: componentId, |
||||||
|
nostrId, |
||||||
|
nestingLevel: nestingLevel, |
||||||
|
}); |
||||||
|
|
||||||
|
// Replace the nostr: link with a placeholder |
||||||
|
parsedContent = parsedContent.replace( |
||||||
|
nostrId, |
||||||
|
`<div class="embedded-event-placeholder" data-component-id="${componentId}"></div>` |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
embeddedEvents = events; |
||||||
|
} |
||||||
|
|
||||||
|
function renderEmbeddedEvent(eventInfo: { id: string; nostrId: string; nestingLevel: number }) { |
||||||
|
if (eventInfo.nestingLevel >= MAX_NESTING_LEVEL) { |
||||||
|
// At max nesting level, just show the link |
||||||
|
return `<a href="/events?id=${eventInfo.nostrId}" class="text-primary-600 dark:text-primary-500 hover:underline break-all">${eventInfo.nostrId}</a>`; |
||||||
|
} |
||||||
|
|
||||||
|
// Return a placeholder that will be replaced by the component |
||||||
|
return `<div class="embedded-event-placeholder" data-component-id="${eventInfo.id}"></div>`; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="content-with-embedded-events min-w-0 overflow-hidden"> |
||||||
|
{@html parsedContent} |
||||||
|
|
||||||
|
<!-- Render embedded events --> |
||||||
|
{#each embeddedEvents as eventInfo} |
||||||
|
<div class="my-4 min-w-0 overflow-hidden" data-component-id={eventInfo.id}> |
||||||
|
<EmbeddedEvent |
||||||
|
nostrIdentifier={eventInfo.nostrId} |
||||||
|
nestingLevel={eventInfo.nestingLevel} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,83 @@ |
|||||||
|
<script lang="ts"> |
||||||
|
import { onMount } from "svelte"; |
||||||
|
import EmbeddedEvent from "./EmbeddedEvent.svelte"; |
||||||
|
|
||||||
|
const { |
||||||
|
content, |
||||||
|
nestingLevel = 0, |
||||||
|
} = $props<{ |
||||||
|
content: string; |
||||||
|
nestingLevel?: number; |
||||||
|
}>(); |
||||||
|
|
||||||
|
let embeddedEvents = $state<Array<{ |
||||||
|
id: string; |
||||||
|
nostrId: string; |
||||||
|
nestingLevel: number; |
||||||
|
}>>([]); |
||||||
|
|
||||||
|
// AI-NOTE: 2025-01-24 - Component that renders content and replaces embedded event placeholders |
||||||
|
// with actual EmbeddedEvent components |
||||||
|
|
||||||
|
$effect(() => { |
||||||
|
if (content) { |
||||||
|
extractEmbeddedEvents(); |
||||||
|
} |
||||||
|
}); |
||||||
|
|
||||||
|
function extractEmbeddedEvents() { |
||||||
|
const placeholderPattern = /<div class="embedded-event-placeholder" data-nostr-id="([^"]+)" data-nesting-level="(\d+)" id="([^"]+)"><\/div>/g; |
||||||
|
const events: Array<{ |
||||||
|
id: string; |
||||||
|
nostrId: string; |
||||||
|
nestingLevel: number; |
||||||
|
}> = []; |
||||||
|
|
||||||
|
let match; |
||||||
|
while ((match = placeholderPattern.exec(content)) !== null) { |
||||||
|
const nostrId = match[1]; |
||||||
|
const level = parseInt(match[2], 10); |
||||||
|
const componentId = match[3]; |
||||||
|
|
||||||
|
// Only process event-related identifiers (note, nevent, naddr) |
||||||
|
if (nostrId.match(/^nostr:(note|nevent|naddr)/)) { |
||||||
|
events.push({ |
||||||
|
id: componentId, |
||||||
|
nostrId, |
||||||
|
nestingLevel: level, |
||||||
|
}); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
embeddedEvents = events; |
||||||
|
} |
||||||
|
|
||||||
|
function renderContent() { |
||||||
|
let renderedContent = content; |
||||||
|
|
||||||
|
// Replace placeholders with component references |
||||||
|
embeddedEvents.forEach(eventInfo => { |
||||||
|
const placeholder = `<div class="embedded-event-placeholder" data-nostr-id="${eventInfo.nostrId}" data-nesting-level="${eventInfo.nestingLevel}" id="${eventInfo.id}"></div>`; |
||||||
|
const componentRef = `<div class="embedded-event-component" data-component-id="${eventInfo.id}"></div>`; |
||||||
|
renderedContent = renderedContent.replace(placeholder, componentRef); |
||||||
|
}); |
||||||
|
|
||||||
|
return renderedContent; |
||||||
|
} |
||||||
|
</script> |
||||||
|
|
||||||
|
<div class="embedded-event-renderer"> |
||||||
|
{@html renderContent()} |
||||||
|
|
||||||
|
<!-- Render embedded events --> |
||||||
|
{#each embeddedEvents as eventInfo} |
||||||
|
<div class="my-4" data-component-id={eventInfo.id}> |
||||||
|
<EmbeddedEvent |
||||||
|
nostrIdentifier={eventInfo.nostrId} |
||||||
|
nestingLevel={eventInfo.nestingLevel} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
{/each} |
||||||
|
</div> |
||||||
|
|
||||||
|
|
||||||
@ -0,0 +1,263 @@ |
|||||||
|
import * as emoji from "node-emoji"; |
||||||
|
import { nip19 } from "nostr-tools"; |
||||||
|
import {
|
||||||
|
processImageWithReveal,
|
||||||
|
processMediaUrl,
|
||||||
|
processNostrIdentifiersInText, |
||||||
|
processEmojiShortcodes, |
||||||
|
processWebSocketUrls, |
||||||
|
processHashtags, |
||||||
|
processBasicTextFormatting, |
||||||
|
processBlockquotes, |
||||||
|
processWikilinks, |
||||||
|
processNostrIdentifiersWithEmbeddedEvents, |
||||||
|
stripTrackingParams |
||||||
|
} from "./markupServices"; |
||||||
|
|
||||||
|
/* Regex constants for basic markup parsing */ |
||||||
|
|
||||||
|
// Links and media
|
||||||
|
const MARKUP_LINK = /\[([^\]]+)\]\(([^)]+)\)/g; |
||||||
|
const MARKUP_IMAGE = /!\[([^\]]*)\]\(([^)]+)\)/g; |
||||||
|
const DIRECT_LINK = /(?<!["'=])(https?:\/\/[^\s<>"]+)(?!["'])/g; |
||||||
|
|
||||||
|
// Add this helper function near the top:
|
||||||
|
function replaceAlexandriaNostrLinks(text: string): string { |
||||||
|
// Regex for Alexandria/localhost URLs
|
||||||
|
const alexandriaPattern = |
||||||
|
/^https?:\/\/((next-)?alexandria\.gitcitadel\.(eu|com)|localhost(:\d+)?)/i; |
||||||
|
// Regex for bech32 Nostr identifiers
|
||||||
|
const bech32Pattern = /(npub|nprofile|note|nevent|naddr)[a-zA-Z0-9]{20,}/; |
||||||
|
// Regex for 64-char hex
|
||||||
|
const hexPattern = /\b[a-fA-F0-9]{64}\b/; |
||||||
|
|
||||||
|
// 1. Alexandria/localhost markup links
|
||||||
|
text = text.replace( |
||||||
|
/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, |
||||||
|
(match, _label, url) => { |
||||||
|
if (alexandriaPattern.test(url)) { |
||||||
|
if (/[?&]d=/.test(url)) return match; |
||||||
|
const hexMatch = url.match(hexPattern); |
||||||
|
if (hexMatch) { |
||||||
|
try { |
||||||
|
const nevent = nip19.neventEncode({ id: hexMatch[0] }); |
||||||
|
return `nostr:${nevent}`; |
||||||
|
} catch { |
||||||
|
return match; |
||||||
|
} |
||||||
|
} |
||||||
|
const bech32Match = url.match(bech32Pattern); |
||||||
|
if (bech32Match) { |
||||||
|
return `nostr:${bech32Match[0]}`; |
||||||
|
} |
||||||
|
} |
||||||
|
return match; |
||||||
|
}, |
||||||
|
); |
||||||
|
|
||||||
|
// 2. Alexandria/localhost bare URLs and non-Alexandria/localhost URLs with Nostr identifiers
|
||||||
|
text = text.replace(/https?:\/\/[^\s)\]]+/g, (url) => { |
||||||
|
if (alexandriaPattern.test(url)) { |
||||||
|
if (/[?&]d=/.test(url)) return url; |
||||||
|
const hexMatch = url.match(hexPattern); |
||||||
|
if (hexMatch) { |
||||||
|
try { |
||||||
|
const nevent = nip19.neventEncode({ id: hexMatch[0] }); |
||||||
|
return `nostr:${nevent}`; |
||||||
|
} catch { |
||||||
|
return url; |
||||||
|
} |
||||||
|
} |
||||||
|
const bech32Match = url.match(bech32Pattern); |
||||||
|
if (bech32Match) { |
||||||
|
return `nostr:${bech32Match[0]}`; |
||||||
|
} |
||||||
|
} |
||||||
|
// For non-Alexandria/localhost URLs, just return the URL as-is
|
||||||
|
return url; |
||||||
|
}); |
||||||
|
|
||||||
|
return text; |
||||||
|
} |
||||||
|
|
||||||
|
function renderListGroup(lines: string[], typeHint?: "ol" | "ul"): string { |
||||||
|
function parseList( |
||||||
|
start: number, |
||||||
|
indent: number, |
||||||
|
type: "ol" | "ul", |
||||||
|
): [string, number] { |
||||||
|
let html = ""; |
||||||
|
let i = start; |
||||||
|
html += `<${type} class="${type === "ol" ? "list-decimal" : "list-disc"} ml-6 mb-2">`; |
||||||
|
while (i < lines.length) { |
||||||
|
const line = lines[i]; |
||||||
|
const match = line.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+(.*)$/); |
||||||
|
if (!match) break; |
||||||
|
const lineIndent = match[1].replace(/\t/g, " ").length; |
||||||
|
const isOrdered = /\d+\./.test(match[2]); |
||||||
|
const itemType = isOrdered ? "ol" : "ul"; |
||||||
|
if (lineIndent > indent) { |
||||||
|
// Nested list
|
||||||
|
const [nestedHtml, consumed] = parseList(i, lineIndent, itemType); |
||||||
|
html = html.replace(/<\/li>$/, "") + nestedHtml + "</li>"; |
||||||
|
i = consumed; |
||||||
|
continue; |
||||||
|
} |
||||||
|
if (lineIndent < indent || itemType !== type) { |
||||||
|
break; |
||||||
|
} |
||||||
|
html += `<li class="mb-1">${match[3]}`; |
||||||
|
// Check for next line being a nested list
|
||||||
|
if (i + 1 < lines.length) { |
||||||
|
const nextMatch = lines[i + 1].match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); |
||||||
|
if (nextMatch) { |
||||||
|
const nextIndent = nextMatch[1].replace(/\t/g, " ").length; |
||||||
|
const nextType = /\d+\./.test(nextMatch[2]) ? "ol" : "ul"; |
||||||
|
if (nextIndent > lineIndent) { |
||||||
|
const [nestedHtml, consumed] = parseList( |
||||||
|
i + 1, |
||||||
|
nextIndent, |
||||||
|
nextType, |
||||||
|
); |
||||||
|
html += nestedHtml; |
||||||
|
i = consumed - 1; |
||||||
|
} |
||||||
|
} |
||||||
|
} |
||||||
|
html += "</li>"; |
||||||
|
i++; |
||||||
|
} |
||||||
|
html += `</${type}>`; |
||||||
|
return [html, i]; |
||||||
|
} |
||||||
|
if (!lines.length) return ""; |
||||||
|
const firstLine = lines[0]; |
||||||
|
const match = firstLine.match(/^([ \t]*)([*+-]|\d+\.)[ \t]+/); |
||||||
|
const indent = match ? match[1].replace(/\t/g, " ").length : 0; |
||||||
|
const type = typeHint || (match && /\d+\./.test(match[2]) ? "ol" : "ul"); |
||||||
|
const [html] = parseList(0, indent, type); |
||||||
|
return html; |
||||||
|
} |
||||||
|
|
||||||
|
function processBasicFormatting(content: string): string { |
||||||
|
if (!content) return ""; |
||||||
|
|
||||||
|
let processedText = content; |
||||||
|
|
||||||
|
try { |
||||||
|
// Sanitize Alexandria Nostr links before further processing
|
||||||
|
processedText = replaceAlexandriaNostrLinks(processedText); |
||||||
|
|
||||||
|
// Process markup images first
|
||||||
|
processedText = processedText.replace(MARKUP_IMAGE, (_match, alt, url) => { |
||||||
|
return processImageWithReveal(url, alt); |
||||||
|
}); |
||||||
|
|
||||||
|
// Process markup links
|
||||||
|
processedText = processedText.replace( |
||||||
|
MARKUP_LINK, |
||||||
|
(_match, text, url) => |
||||||
|
`<a href="${stripTrackingParams(url)}" class="text-primary-600 dark:text-primary-500 hover:underline" target="_blank" rel="noopener noreferrer">${text}</a>`, |
||||||
|
); |
||||||
|
|
||||||
|
// Process WebSocket URLs using shared services
|
||||||
|
processedText = processWebSocketUrls(processedText); |
||||||
|
|
||||||
|
// Process direct media URLs and auto-link all URLs
|
||||||
|
processedText = processedText.replace(DIRECT_LINK, (match) => { |
||||||
|
return processMediaUrl(match); |
||||||
|
}); |
||||||
|
|
||||||
|
// Process text formatting using shared services
|
||||||
|
processedText = processBasicTextFormatting(processedText); |
||||||
|
|
||||||
|
// Process hashtags using shared services
|
||||||
|
processedText = processHashtags(processedText); |
||||||
|
|
||||||
|
// --- Improved List Grouping and Parsing ---
|
||||||
|
const lines = processedText.split("\n"); |
||||||
|
let output = ""; |
||||||
|
let buffer: string[] = []; |
||||||
|
let inList = false; |
||||||
|
for (let i = 0; i < lines.length; i++) { |
||||||
|
const line = lines[i]; |
||||||
|
if (/^([ \t]*)([*+-]|\d+\.)[ \t]+/.test(line)) { |
||||||
|
buffer.push(line); |
||||||
|
inList = true; |
||||||
|
} else { |
||||||
|
if (inList) { |
||||||
|
const firstLine = buffer[0]; |
||||||
|
const isOrdered = /^\s*\d+\.\s+/.test(firstLine); |
||||||
|
output += renderListGroup(buffer, isOrdered ? "ol" : "ul"); |
||||||
|
buffer = []; |
||||||
|
inList = false; |
||||||
|
} |
||||||
|
output += (output && !output.endsWith("\n") ? "\n" : "") + line + "\n"; |
||||||
|
} |
||||||
|
} |
||||||
|
if (buffer.length) { |
||||||
|
const firstLine = buffer[0]; |
||||||
|
const isOrdered = /^\s*\d+\.\s+/.test(firstLine); |
||||||
|
output += renderListGroup(buffer, isOrdered ? "ol" : "ul"); |
||||||
|
} |
||||||
|
processedText = output; |
||||||
|
// --- End Improved List Grouping and Parsing ---
|
||||||
|
} catch (e: unknown) { |
||||||
|
console.error("Error in processBasicFormatting:", e); |
||||||
|
} |
||||||
|
|
||||||
|
return processedText; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse markup with support for embedded Nostr events |
||||||
|
* AI-NOTE: 2025-01-24 - Enhanced markup parser that supports nested Nostr event embedding |
||||||
|
* Up to 3 levels of nesting are supported, after which events are shown as links |
||||||
|
*/ |
||||||
|
export async function parseEmbeddedMarkup(text: string, nestingLevel: number = 0): Promise<string> { |
||||||
|
if (!text) return ""; |
||||||
|
|
||||||
|
try { |
||||||
|
// Process basic text formatting first
|
||||||
|
let processedText = processBasicFormatting(text); |
||||||
|
|
||||||
|
// Process emoji shortcuts
|
||||||
|
processedText = processEmojiShortcodes(processedText); |
||||||
|
|
||||||
|
// Process blockquotes
|
||||||
|
processedText = processBlockquotes(processedText); |
||||||
|
|
||||||
|
// Process paragraphs - split by double newlines and wrap in p tags
|
||||||
|
// Skip wrapping if content already contains block-level elements
|
||||||
|
processedText = processedText |
||||||
|
.split(/\n\n+/) |
||||||
|
.map((para) => para.trim()) |
||||||
|
.filter((para) => para.length > 0) |
||||||
|
.map((para) => { |
||||||
|
// Skip wrapping if para already contains block-level elements or math blocks
|
||||||
|
if ( |
||||||
|
/(<div[^>]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr)/i.test( |
||||||
|
para, |
||||||
|
) |
||||||
|
) { |
||||||
|
return para; |
||||||
|
} |
||||||
|
return `<p class="my-1">${para}</p>`; |
||||||
|
}) |
||||||
|
.join("\n"); |
||||||
|
|
||||||
|
// Process profile identifiers (npub, nprofile) first using the regular processor
|
||||||
|
processedText = await processNostrIdentifiersInText(processedText); |
||||||
|
|
||||||
|
// Then process event identifiers with embedded events (only event-related identifiers)
|
||||||
|
processedText = processNostrIdentifiersWithEmbeddedEvents(processedText, nestingLevel); |
||||||
|
|
||||||
|
// Replace wikilinks
|
||||||
|
processedText = processWikilinks(processedText); |
||||||
|
|
||||||
|
return processedText; |
||||||
|
} catch (e: unknown) { |
||||||
|
console.error("Error in parseEmbeddedMarkup:", e); |
||||||
|
return `<div class="text-red-500">Error processing markup: ${(e as Error)?.message ?? "Unknown error"}</div>`; |
||||||
|
} |
||||||
|
} |
||||||
Loading…
Reference in new issue