import NDK from "@nostr-dev-kit/ndk"; import { createProfileLink, getUserMetadata, NOSTR_PROFILE_REGEX, } from "../nostrUtils.ts"; import * as emoji from "node-emoji"; // Media URL patterns const IMAGE_EXTENSIONS = /\.(jpg|jpeg|gif|png|webp|svg)$/i; 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 expand functionality */ export function processImageWithReveal( src: string, alt: string = "Image", ): string { if (!src || !IMAGE_EXTENSIONS.test(src.split("?")[0])) { return `${alt}`; } return `
${alt}
`; } /** * Shared service for processing media URLs */ export function processMediaUrl(url: string, alt?: string): string { const clean = stripTrackingParams(url); if (YOUTUBE_URL_REGEX.test(clean)) { const videoId = extractYouTubeVideoId(clean); if (videoId) { return ``; } } if (VIDEO_URL_REGEX.test(clean)) { return ``; } if (AUDIO_URL_REGEX.test(clean)) { return ``; } if (IMAGE_EXTENSIONS.test(clean.split("?")[0])) { return processImageWithReveal(clean, alt || "Embedded media"); } // Default to clickable link return `${clean}`; } /** * Shared service for processing nostr identifiers */ export async function processNostrIdentifiersInText( text: string, ndk?: NDK, ): Promise { let processedText = text; // Find all profile-related nostr addresses (only npub and nprofile) const matches = Array.from(processedText.matchAll(NOSTR_PROFILE_REGEX)); // Process them in reverse order to avoid index shifting issues for (let i = matches.length - 1; i >= 0; i--) { const match = matches[i]; const [fullMatch] = match; const matchIndex = match.index ?? 0; // Skip if part of a URL const before = processedText.slice( Math.max(0, matchIndex - 12), matchIndex, ); if (/https?:\/\/$|www\.$/i.test(before)) { continue; } // Process the nostr identifier directly let identifier = fullMatch; if (!identifier.startsWith("nostr:")) { identifier = "nostr:" + identifier; } // Get user metadata and create link let metadata; if (ndk) { metadata = await getUserMetadata(identifier, ndk); } else { // Fallback when NDK is not available - just use the identifier metadata = { name: identifier.slice(0, 8) + "..." + identifier.slice(-4) }; } const displayText = metadata.displayName || metadata.name; const link = createProfileLink(identifier, displayText); // Replace the match in the text processedText = processedText.slice(0, matchIndex) + link + processedText.slice(matchIndex + fullMatch.length); } return processedText; } /** * Shared service for processing nostr identifiers with embedded events * Replaces nostr: links with embedded event placeholders * Only processes event-related identifiers (nevent, naddr, note), not profile identifiers (npub, nprofile) */ export function processNostrIdentifiersWithEmbeddedEvents( text: string, nestingLevel: number = 0, ): string { const eventPattern = /nostr:(note|nevent|naddr)[a-zA-Z0-9]{20,}/g; let processedText = text; // Maximum nesting level allowed const MAX_NESTING_LEVEL = 3; // Find all event-related nostr addresses const matches = Array.from(processedText.matchAll(eventPattern)); // Process them in reverse order to avoid index shifting issues for (let i = matches.length - 1; i >= 0; i--) { const match = matches[i]; const [fullMatch] = match; const matchIndex = match.index ?? 0; let replacement: string; if (nestingLevel >= MAX_NESTING_LEVEL) { // At max nesting level, just show the link replacement = `${fullMatch}`; } else { // Create a placeholder for embedded event const componentId = `embedded-event-${ Math.random().toString(36).substr(2, 9) }`; replacement = `
`; } // Replace the match in the text processedText = processedText.slice(0, matchIndex) + replacement + processedText.slice(matchIndex + fullMatch.length); } return processedText; } /** * Shared service for processing emoji shortcodes */ export function processEmojiShortcodes(text: string): string { return emoji.emojify(text); } /** * Shared service for processing WebSocket URLs */ export function processWebSocketUrls(text: string): string { const wssUrlRegex = /wss:\/\/[^\s<>"]+/g; return text.replace(wssUrlRegex, (match) => { const cleanUrl = match.slice(6).replace(/\/+$/, ""); return `${match}`; }); } /** * Shared service for processing hashtags */ export function processHashtags(text: string): string { const hashtagRegex = /(?#$1', ); } /** * Shared service for processing basic text formatting */ export function processBasicTextFormatting(text: string): string { // Bold: **text** or *text* text = text.replace( /(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g, "$2", ); // Italic: _text_ or __text__ text = text.replace(/\b(_[^_\n]+_|\b__[^_\n]+__)\b/g, (match) => { const text = match.replace(/^_+|_+$/g, ""); return `${text}`; }); // Strikethrough: ~~text~~ or ~text~ text = text.replace( /~~([^~\n]+)~~|~([^~\n]+)~/g, (_match, doubleText, singleText) => { const text = doubleText || singleText; return `${text}`; }, ); return text; } /** * Shared service for processing blockquotes */ export function processBlockquotes(text: string): string { const blockquoteRegex = /^([ \t]*>[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm; return text.replace(blockquoteRegex, (match) => { const lines = match.split("\n").map((line) => { return line.replace(/^[ \t]*>[ \t]?/, "").trim(); }); return `
${ lines.join("\n") }
`; }); } // Helper functions export function stripTrackingParams(url: string): string { try { const urlObj = new URL(url); // Remove common tracking parameters const trackingParams = [ "utm_source", "utm_medium", "utm_campaign", "utm_term", "utm_content", "fbclid", "gclid", ]; trackingParams.forEach((param) => urlObj.searchParams.delete(param)); return urlObj.toString(); } catch { return url; } } function extractYouTubeVideoId(url: string): string | null { const match = url.match( /(?:youtube\.com\/(?:watch\?v=|embed\/)|youtu\.be\/|youtube-nocookie\.com\/embed\/)([a-zA-Z0-9_-]{11})/, ); return match ? match[1] : null; } /** * Normalizes a string for use as a d-tag by converting to lowercase, * replacing non-alphanumeric characters with dashes, and removing * leading/trailing dashes. */ function normalizeDTag(input: string): string { return input .toLowerCase() .replace(/[^\p{L}\p{N}]/gu, "-") .replace(/-+/g, "-") .replace(/^-|-$/g, ""); } /** * Shared service for processing wikilinks in the format [[target]] or [[target|display]] */ export function processWikilinks(text: string): string { // [[target page]] or [[target page|display text]] return text.replace( /\[\[([^\]|]+)(?:\|([^\]]+))?\]\]/g, (_match, target, label) => { const normalized = normalizeDTag(target.trim()); const display = (label || target).trim(); const url = `/events?d=${normalized}`; return `${display}`; }, ); } /** * Shared service for processing AsciiDoc anchor tags */ export function processAsciiDocAnchors(text: string): string { return text.replace(/<\/a>/g, (_match, id) => { const normalized = normalizeDTag(id.trim()); const url = `/events?d=${normalized}`; return `${id}`; }); }