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 `
`;
}
return `
`;
}
/**
* 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}`;
});
}