/** * Markdown parser with special handling for nostr identifiers */ import { get } from 'svelte/store'; import { ndkInstance } from '$lib/ndk'; import { nip19 } from 'nostr-tools'; import hljs from 'highlight.js'; import 'highlight.js/styles/github-dark.css'; // Regular expressions for nostr identifiers - process these first const NOSTR_PROFILE_REGEX = /(?:nostr:)?((?:npub|nprofile)[a-zA-Z0-9]{20,})/g; const NOSTR_NOTE_REGEX = /(?:nostr:)?((?:nevent|note|naddr)[a-zA-Z0-9]{20,})/g; // Regular expressions for markdown elements const BOLD_REGEX = /\*\*([^*]+)\*\*|\*([^*]+)\*/g; const ITALIC_REGEX = /_([^_]+)_/g; const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm; const ALTERNATE_HEADING_REGEX = /^(.+)\n([=]{3,}|-{3,})$/gm; const HORIZONTAL_RULE_REGEX = /^(?:---|\*\*\*|___)$/gm; const INLINE_CODE_REGEX = /`([^`\n]+)`/g; const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g; const IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g; const HASHTAG_REGEX = /(?(); /** * Get user metadata for a nostr identifier (npub or nprofile) */ async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string}> { if (npubCache.has(identifier)) { return npubCache.get(identifier)!; } const fallback = { name: `${identifier.slice(0, 8)}...${identifier.slice(-4)}` }; try { const ndk = get(ndkInstance); if (!ndk) { npubCache.set(identifier, fallback); return fallback; } const decoded = nip19.decode(identifier); if (!decoded) { npubCache.set(identifier, fallback); return fallback; } // Handle different identifier types let pubkey: string; if (decoded.type === 'npub') { pubkey = decoded.data; } else if (decoded.type === 'nprofile') { pubkey = decoded.data.pubkey; } else { npubCache.set(identifier, fallback); return fallback; } const user = ndk.getUser({ pubkey: pubkey }); if (!user) { npubCache.set(identifier, fallback); return fallback; } try { const profile = await user.fetchProfile(); if (!profile) { npubCache.set(identifier, fallback); return fallback; } const metadata = { name: profile.name || fallback.name, displayName: profile.displayName }; npubCache.set(identifier, metadata); return metadata; } catch (e) { npubCache.set(identifier, fallback); return fallback; } } catch (e) { npubCache.set(identifier, fallback); return fallback; } } /** * Process lists (ordered and unordered) */ function processLists(content: string): string { const lines = content.split('\n'); let inList = false; let isOrdered = false; let currentList: string[] = []; const processed: string[] = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const orderedMatch = line.match(/^(\d+)\.[ \t]+(.+)$/); const unorderedMatch = line.match(/^\*[ \t]+(.+)$/); if (orderedMatch || unorderedMatch) { if (!inList) { inList = true; isOrdered = !!orderedMatch; currentList = []; } const content = orderedMatch ? orderedMatch[2] : unorderedMatch![1]; currentList.push(content); } else { if (inList) { const listType = isOrdered ? 'ol' : 'ul'; const listClass = isOrdered ? 'list-decimal' : 'list-disc'; processed.push(`<${listType} class="${listClass} pl-6 my-4 space-y-1">`); currentList.forEach(item => { processed.push(`
  • ${item}
  • `); }); processed.push(``); inList = false; currentList = []; } processed.push(line); } } if (inList) { const listType = isOrdered ? 'ol' : 'ul'; const listClass = isOrdered ? 'list-decimal' : 'list-disc'; processed.push(`<${listType} class="${listClass} pl-6 my-4 space-y-1">`); currentList.forEach(item => { processed.push(`
  • ${item}
  • `); }); processed.push(``); } return processed.join('\n'); } /** * Process blockquotes by finding consecutive quote lines and preserving their structure */ function processBlockquotes(text: string): string { const lines = text.split('\n'); const processedLines: string[] = []; let currentQuote: string[] = []; let quoteCount = 0; let lastLineWasQuote = false; const blockquotes: Array<{id: string, content: string}> = []; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const isQuoteLine = line.startsWith('> '); if (isQuoteLine) { // If we had a gap between quotes, this is a new quote if (!lastLineWasQuote && currentQuote.length > 0) { quoteCount++; const id = `BLOCKQUOTE_${quoteCount}`; const quoteContent = currentQuote.join('
    '); blockquotes.push({ id, content: `

    ${quoteContent}

    ` }); processedLines.push(id); currentQuote = []; } // Add to current quote currentQuote.push(line.substring(2)); lastLineWasQuote = true; } else { // If we were in a quote and now we're not, process it if (currentQuote.length > 0) { quoteCount++; const id = `BLOCKQUOTE_${quoteCount}`; const quoteContent = currentQuote.join('
    '); blockquotes.push({ id, content: `

    ${quoteContent}

    ` }); processedLines.push(id); currentQuote = []; } processedLines.push(line); lastLineWasQuote = false; } } // Handle any remaining quote if (currentQuote.length > 0) { quoteCount++; const id = `BLOCKQUOTE_${quoteCount}`; const quoteContent = currentQuote.join('
    '); blockquotes.push({ id, content: `

    ${quoteContent}

    ` }); processedLines.push(id); } let result = processedLines.join('\n'); // Restore blockquotes blockquotes.forEach(({id, content}) => { result = result.replace(id, content); }); return result; } /** * Process code blocks by finding consecutive code lines and preserving their content */ function processCodeBlocks(text: string): { text: string; blocks: Map } { const lines = text.split('\n'); const processedLines: string[] = []; const blocks = new Map(); let inCodeBlock = false; let currentCode: string[] = []; let currentLanguage = ''; let blockCount = 0; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const codeBlockStart = line.match(/^```(\w*)$/); if (codeBlockStart) { if (!inCodeBlock) { // Starting a new code block inCodeBlock = true; currentLanguage = codeBlockStart[1]; currentCode = []; } else { // Ending current code block blockCount++; const id = `CODE-BLOCK-${blockCount}`; const code = currentCode.join('\n'); // Store the raw code and language for later processing blocks.set(id, JSON.stringify({ code, language: currentLanguage })); processedLines.push(id); inCodeBlock = false; currentLanguage = ''; currentCode = []; } } else if (inCodeBlock) { currentCode.push(line); } else { processedLines.push(line); } } // Handle unclosed code block if (inCodeBlock && currentCode.length > 0) { blockCount++; const id = `CODE-BLOCK-${blockCount}`; const code = currentCode.join('\n'); blocks.set(id, JSON.stringify({ code, language: currentLanguage })); processedLines.push(id); } return { text: processedLines.join('\n'), blocks }; } /** * Restore code blocks with proper formatting */ function restoreCodeBlocks(text: string, blocks: Map): string { let result = text; blocks.forEach((blockContent, id) => { const { code, language } = JSON.parse(blockContent); let processedCode = code; // First escape HTML characters processedCode = processedCode .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // Format and highlight based on language if (language === 'json') { try { // Parse and format JSON const parsed = JSON.parse(code); processedCode = JSON.stringify(parsed, null, 2) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // Apply JSON syntax highlighting processedCode = processedCode // Match JSON keys (including colons) .replace(/("[^"]+"):/g, '$1:') // Match string values (after colons and in arrays) .replace(/: ("[^"]+")/g, ': $1') .replace(/\[("[^"]+")/g, '[$1') .replace(/, ("[^"]+")/g, ', $1') // Match numbers .replace(/: (-?\d+\.?\d*)/g, ': $1') .replace(/\[(-?\d+\.?\d*)/g, '[$1') .replace(/, (-?\d+\.?\d*)/g, ', $1') // Match booleans .replace(/: (true|false)\b/g, ': $1') // Match null .replace(/: (null)\b/g, ': $1'); } catch (e) { // If JSON parsing fails, use the original escaped code console.warn('Failed to parse JSON:', e); } } else if (language) { // Use highlight.js for other languages try { if (hljs.getLanguage(language)) { const highlighted = hljs.highlight(processedCode, { language }); processedCode = highlighted.value; } } catch (e) { console.warn('Failed to apply syntax highlighting:', e); } } const languageClass = language ? ` language-${language}` : ''; const replacement = `
    ${processedCode}
    `; result = result.replace(id, replacement); }); return result; } /** * Process inline code */ function processInlineCode(text: string): string { return text.replace(INLINE_CODE_REGEX, (match, code) => { const escapedCode = code .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); return `${escapedCode}`; }); } /** * Process markdown tables */ function processTables(content: string): string { return content.replace(TABLE_REGEX, (match, headerRow, delimiterRow, bodyRows) => { // Process header row const headers: string[] = headerRow .split('|') .map((cell: string) => cell.trim()) .filter((cell: string) => cell.length > 0); // Validate delimiter row (should contain only dashes and spaces) const delimiters: string[] = delimiterRow .split('|') .map((cell: string) => cell.trim()) .filter((cell: string) => cell.length > 0); if (!delimiters.every(d => TABLE_DELIMITER_REGEX.test(d))) { return match; } // Process body rows const rows: string[][] = bodyRows .trim() .split('\n') .map((row: string) => { return row .split('|') .map((cell: string) => cell.trim()) .filter((cell: string) => cell.length > 0); }) .filter((row: string[]) => row.length > 0); // Generate HTML table with leather theme styling and thicker grid lines let table = '
    \n'; table += '\n'; // Add header with leather theme styling table += '\n\n'; headers.forEach((header: string) => { table += `\n`; }); table += '\n\n'; // Add body with leather theme styling table += '\n'; rows.forEach((row: string[], index: number) => { table += `\n`; row.forEach((cell: string) => { table += `\n`; }); table += '\n'; }); table += '\n
    ${header}
    ${cell}
    \n
    '; return table; }); } /** * Process other markdown elements (excluding code) */ function processOtherElements(content: string): string { // Process blockquotes first content = processBlockquotes(content); // Process tables before other elements content = processTables(content); // Process basic markdown elements content = content.replace(BOLD_REGEX, '$1$2'); content = content.replace(ITALIC_REGEX, '$1'); // Process alternate heading syntax first (=== or ---) content = content.replace(ALTERNATE_HEADING_REGEX, (match, content, level) => { const headingLevel = level.startsWith('=') ? 1 : 2; const sizes = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-xs']; return `${content.trim()}`; }); // Process standard heading syntax (#) content = content.replace(HEADING_REGEX, (match, hashes, content) => { const level = hashes.length; const sizes = ['text-2xl', 'text-xl', 'text-lg', 'text-base', 'text-sm', 'text-xs']; return `${content.trim()}`; }); // Process links and images with standardized styling content = content.replace(IMAGE_REGEX, '$1'); content = content.replace(LINK_REGEX, '$1'); // Process hashtags with standardized styling content = content.replace(HASHTAG_REGEX, '#$1'); // Process horizontal rules content = content.replace(HORIZONTAL_RULE_REGEX, '
    '); return content; } /** * Process footnotes with minimal spacing */ function processFootnotes(text: string): { text: string, footnotes: Map } { const footnotes = new Map(); let counter = 0; // Extract footnote definitions text = text.replace(FOOTNOTE_DEFINITION_REGEX, (match, id, content) => { const cleanId = id.replace('^', ''); footnotes.set(cleanId, content.trim()); return ''; }); // Replace references with standardized styling text = text.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => { const cleanId = id.replace('^', ''); if (footnotes.has(cleanId)) { counter++; return `[${counter}]`; } return match; }); // Add footnotes section if we have any if (footnotes.size > 0) { text += '\n
    '; text += '
      '; counter = 0; for (const [id, content] of footnotes.entries()) { counter++; text += `
    1. ${content}
    2. `; } text += '
    '; } return { text, footnotes }; } /** * Process nostr identifiers */ async function processNostrIdentifiers(content: string): Promise { let processedContent = content; // Process profiles (npub and nprofile) const profileMatches = Array.from(content.matchAll(NOSTR_PROFILE_REGEX)); for (const match of profileMatches) { const [fullMatch, identifier] = match; const metadata = await getUserMetadata(identifier); const displayText = metadata.displayName || metadata.name || `${identifier.slice(0, 8)}...${identifier.slice(-4)}`; const escapedId = identifier .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); const escapedDisplayText = displayText .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // Create a link with standardized styling const link = `@${escapedDisplayText}`; // Replace only the exact match to preserve surrounding text processedContent = processedContent.replace(fullMatch, link); } // Process notes (nevent, note, naddr) const noteMatches = Array.from(processedContent.matchAll(NOSTR_NOTE_REGEX)); for (const match of noteMatches) { const [fullMatch, identifier] = match; const shortId = identifier.slice(0, 12) + '...' + identifier.slice(-8); const escapedId = identifier .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"') .replace(/'/g, '''); // Create a link with standardized styling const link = `${shortId}`; // Replace only the exact match to preserve surrounding text processedContent = processedContent.replace(fullMatch, link); } return processedContent; } /** * Parse markdown text to content with special handling for nostr identifiers */ export async function parseMarkdown(text: string): Promise { if (!text) return ''; // First extract and save code blocks const { text: withoutCode, blocks } = processCodeBlocks(text); // Process nostr identifiers let content = await processNostrIdentifiers(withoutCode); // Process blockquotes content = processBlockquotes(content); // Process lists content = processLists(content); // Process other markdown elements content = processOtherElements(content); // Process inline code (after other elements to prevent conflicts) content = processInlineCode(content); // Process footnotes const { text: processedContent } = processFootnotes(content); content = processedContent; // Handle paragraphs and line breaks, preserving existing HTML content = content .split(/\n{2,}/) .map((para: string) => para.trim()) .filter((para: string) => para) .map((para: string) => para.startsWith('<') ? para : `

    ${para}

    `) .join('\n\n'); // Finally, restore code blocks content = restoreCodeBlocks(content, blocks); return content; } /** * Escape special characters in a string for use in a regular expression */ function escapeRegExp(string: string): string { return string.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); } function processCode(text: string): string { // Process code blocks with language specification text = text.replace(/```(\w+)?\n([\s\S]+?)\n```/g, (match, lang, code) => { if (lang === 'json') { try { const parsed = JSON.parse(code.trim()); code = JSON.stringify(parsed, null, 2); // Add syntax highlighting classes for JSON code = code.replace(/"([^"]+)":/g, '"$1":') // keys .replace(/"([^"]+)"/g, '"$1"') // strings .replace(/\b(true|false)\b/g, '$1') // booleans .replace(/\b(null)\b/g, '$1') // null .replace(/\b(\d+\.?\d*)\b/g, '$1'); // numbers } catch (e) { // If JSON parsing fails, use the original code } } return `
    ${code}
    `; }); // Process inline code text = text.replace(/`([^`]+)`/g, '$1'); return text; }