15 changed files with 1067 additions and 137 deletions
@ -0,0 +1,100 @@
@@ -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 @@
@@ -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 @@
@@ -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