Scan the QR code or copy the address
{#if lnurl}
-
+
diff --git a/src/lib/components/util/CardActions.svelte b/src/lib/components/util/CardActions.svelte
index dddbb8a..89647a6 100644
--- a/src/lib/components/util/CardActions.svelte
+++ b/src/lib/components/util/CardActions.svelte
@@ -12,6 +12,7 @@
import { userStore } from "$lib/stores/userStore";
import { goto } from "$app/navigation";
import type { NDKEvent } from "$lib/utils/nostrUtils";
+ import LazyImage from "$components/util/LazyImage.svelte";
// Component props
let { event } = $props<{ event: NDKEvent }>();
@@ -191,10 +192,11 @@
-
{/if}
diff --git a/src/lib/utils/markup/embeddedMarkupParser.ts b/src/lib/utils/markup/embeddedMarkupParser.ts
new file mode 100644
index 0000000..548f4ba
--- /dev/null
+++ b/src/lib/utils/markup/embeddedMarkupParser.ts
@@ -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 = /(?"]+)(?!["'])/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 + "";
+ i = consumed;
+ continue;
+ }
+ if (lineIndent < indent || itemType !== type) {
+ break;
+ }
+ html += `
${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 += "";
+ 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) =>
+ `
${text}`,
+ );
+
+ // 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
{
+ 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 (
+ /(]*class=["'][^"']*math-block[^"']*["'])|<(div|h[1-6]|blockquote|table|pre|ul|ol|hr)/i.test(
+ para,
+ )
+ ) {
+ return para;
+ }
+ return `
${para}
`;
+ })
+ .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 `
Error processing markup: ${(e as Error)?.message ?? "Unknown error"}
`;
+ }
+}
diff --git a/src/lib/utils/markup/markupServices.ts b/src/lib/utils/markup/markupServices.ts
index 09157dc..f4ce0a5 100644
--- a/src/lib/utils/markup/markupServices.ts
+++ b/src/lib/utils/markup/markupServices.ts
@@ -1,4 +1,4 @@
-import { processNostrIdentifiers } from "../nostrUtils.ts";
+import { processNostrIdentifiers, NOSTR_PROFILE_REGEX } from "../nostrUtils.ts";
import * as emoji from "node-emoji";
// Media URL patterns
@@ -7,40 +7,30 @@ const VIDEO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp4|webm|mov|avi)(?:[^\s<]*)?/i;
const AUDIO_URL_REGEX = /https?:\/\/[^\s<]+\.(?:mp3|wav|ogg|m4a)(?:[^\s<]*)?/i;
const YOUTUBE_URL_REGEX = /https?:\/\/(?:www\.)?(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})(?:[^\s<]*)?/;
+
+
/**
- * Shared service for processing images with reveal/enlarge functionality
+ * Shared service for processing images with expand functionality
*/
export function processImageWithReveal(src: string, alt: string = "Image"): string {
if (!src || !IMAGE_EXTENSIONS.test(src.split("?")[0])) {
return `

`;
}
- return `
-
-
-
-
-
-
-
+ return `
+

-
-

-
-
-