diff --git a/package.json b/package.json index 55087c2..96db5de 100644 --- a/package.json +++ b/package.json @@ -19,7 +19,7 @@ "@popperjs/core": "2.11.x", "@tailwindcss/forms": "0.5.x", "@tailwindcss/typography": "0.5.x", - "@types/highlight.js": "^9.12.4", + "@types/highlight.js": "^11.11.1", "asciidoctor": "3.0.x", "d3": "^7.9.0", "he": "1.2.x", diff --git a/src/app.css b/src/app.css index ba7dfdb..a87e1a7 100644 --- a/src/app.css +++ b/src/app.css @@ -2,40 +2,38 @@ @import './styles/publications.css'; @import './styles/visualize.css'; -@layer components { - /* General */ +/* Custom styles */ +@layer base { .leather { - @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300; + @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-200; } .btn-leather.text-xs { - @apply w-7 h-7; + @apply px-2 py-1; } .btn-leather.text-xs svg { - @apply w-3 h-3; + @apply h-3 w-3; } .btn-leather.text-sm { - @apply w-8 h-8; + @apply px-3 py-2; } .btn-leather.text-sm svg { - @apply w-4 h-4; + @apply h-4 w-4; } div[role='tooltip'] button.btn-leather { @apply hover:text-primary-400 dark:hover:text-primary-500 hover:border-primary-400 dark:hover:border-primary-500 hover:bg-gray-200 dark:hover:bg-gray-700; } - /* Images */ .image-border { @apply border border-primary-700; } - /* Card */ div.card-leather { - @apply shadow-none text-primary-1000 border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700; + @apply shadow-none text-primary-1000 border-s-4 bg-highlight border-primary-200 has-[:hover]:border-primary-700; @apply dark:bg-primary-1000 dark:border-primary-800 dark:has-[:hover]:bg-primary-950 dark:has-[:hover]:border-primary-500; } @@ -52,7 +50,6 @@ @apply text-gray-900 hover:text-primary-600 dark:text-gray-200 dark:hover:text-primary-200; } - /* Content */ main { @apply max-w-full; } @@ -74,7 +71,6 @@ @apply hover:bg-primary-100 dark:hover:bg-primary-800; } - /* Section headers */ h1.h-leather, h2.h-leather, h3.h-leather, @@ -108,7 +104,6 @@ @apply text-base font-semibold; } - /* Modal */ div.modal-leather > div { @apply bg-primary-0 dark:bg-primary-950 border-b-[1px] border-primary-100 dark:border-primary-600; } @@ -126,7 +121,6 @@ @apply bg-primary-0 hover:bg-primary-0 dark:bg-primary-950 dark:hover:bg-primary-950 text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; } - /* Navbar */ nav.navbar-leather { @apply bg-primary-0 dark:bg-primary-1000 z-10; } @@ -144,23 +138,20 @@ @apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; } - /* Sidebar */ aside.sidebar-leather > div { - @apply bg-gray-100 dark:bg-gray-900; + @apply bg-primary-0 dark:bg-primary-1000; } a.sidebar-item-leather { @apply hover:bg-primary-100 dark:hover:bg-primary-800; } - /* Skeleton */ div.skeleton-leather div { - @apply bg-gray-400 dark:bg-gray-600; + @apply bg-primary-100 dark:bg-primary-800; } - /* Textarea */ div.textarea-leather { - @apply bg-gray-200 dark:bg-gray-800 border-gray-400 dark:border-gray-600; + @apply bg-primary-0 dark:bg-primary-1000; } div.textarea-leather > div:nth-child(1), @@ -169,7 +160,7 @@ } div.textarea-leather > div:nth-child(2) { - @apply bg-gray-100 dark:bg-gray-900; + @apply bg-primary-0 dark:bg-primary-1000; } div.textarea-leather, @@ -177,60 +168,66 @@ @apply text-gray-800 dark:text-gray-300; } - /* Tooltip */ div.tooltip-leather { @apply text-gray-800 dark:text-gray-300; } div[role='tooltip'] button.btn-leather .tooltip-leather { - @apply bg-gray-200 dark:bg-gray-700; + @apply bg-primary-100 dark:bg-primary-800; } - - /* Unordered list */ + .ul-leather li a { @apply text-gray-800 hover:text-primary-400 dark:text-gray-300 dark:hover:text-primary-500; } + .network-link-leather { - @apply stroke-gray-400 fill-gray-400; - } - .network-node-leather { - @apply stroke-gray-800; - } - .network-node-content { - @apply fill-[#d6c1a8]; + @apply stroke-primary-200 fill-primary-200; } - /* Code blocks */ - .code-block { - @apply relative w-full max-w-[95%] overflow-x-auto rounded-lg bg-gray-100 dark:bg-gray-800 p-4 my-4 font-mono text-sm whitespace-pre; - scrollbar-width: thin; - scrollbar-color: rgba(156, 163, 175, 0.5) transparent; + .network-node-leather { + @apply stroke-primary-600; } - .code-block::-webkit-scrollbar { - height: 8px; + .network-node-content { + @apply fill-primary-100; } - .code-block::-webkit-scrollbar-track { - @apply bg-transparent rounded-b-lg; + /* Code block styling - using highlight.js github-dark theme only */ + pre code.hljs { + display: block; + overflow-x: auto; + padding: 1em; } - .code-block::-webkit-scrollbar-thumb { - @apply bg-gray-400 dark:bg-gray-600 rounded-full; + .code-block { + @apply font-mono text-sm rounded-lg p-4 my-4 overflow-x-auto; } /* Inline code */ .inline-code { - @apply font-mono text-sm bg-gray-100 dark:bg-gray-800 px-1.5 py-0.5 rounded; + @apply font-mono text-sm rounded px-1.5 py-0.5; + @apply bg-primary-900 text-gray-200; } -} -@layer components { .leather-legend { @apply flex-shrink-0 p-4 bg-primary-0 dark:bg-primary-1000 rounded-lg shadow border border-gray-200 dark:border-gray-800; } + .tooltip-leather { - @apply bg-primary-0 dark:bg-primary-1000 text-gray-800 dark:text-gray-300; + @apply bg-gray-100 dark:bg-gray-900; + } + + /* Adjusting text styles for better contrast */ + em, i { + @apply text-gray-700 dark:text-gray-200; /* Darker in light mode, lighter in dark mode */ + } + + strong, b { + @apply text-gray-900 dark:text-gray-100; /* Darker in light mode, lighter in dark mode */ + } + + code { + @apply text-gray-800 dark:text-gray-200; /* Adjusted for better contrast */ } } diff --git a/src/lib/utils/advancedMarkdownParser.ts b/src/lib/utils/advancedMarkdownParser.ts new file mode 100644 index 0000000..6ddfe56 --- /dev/null +++ b/src/lib/utils/advancedMarkdownParser.ts @@ -0,0 +1,416 @@ +import { parseBasicMarkdown } from './basicMarkdownParser'; +import hljs from 'highlight.js'; +import 'highlight.js/lib/common'; // Import common languages +import 'highlight.js/styles/github-dark.css'; // Dark theme only +import { processNostrIdentifiers } from './nostrUtils'; + +// Register common languages +hljs.configure({ + ignoreUnescapedHTML: true +}); + +// Regular expressions for advanced markdown elements +const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm; +const ALTERNATE_HEADING_REGEX = /^([^\n]+)\n(=+|-+)\n/gm; +const INLINE_CODE_REGEX = /`([^`\n]+)`/g; +const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g; +const IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g; +const HORIZONTAL_RULE_REGEX = /^(?:[-*_]\s*){3,}$/gm; +const FOOTNOTE_REFERENCE_REGEX = /\[\^([^\]]+)\]/g; +const FOOTNOTE_DEFINITION_REGEX = /^\[\^([^\]]+)\]:\s*(.+)$/gm; + +interface Footnote { + id: string; + text: string; + referenceCount: number; +} + +interface FootnoteReference { + id: string; + count: number; +} + +/** + * Process headings (both styles) + */ +function processHeadings(content: string): string { + // Process ATX-style headings (# Heading) + let processedContent = content.replace(HEADING_REGEX, (_, level, text) => { + const headingLevel = level.length; + return `${text.trim()}`; + }); + + // Process Setext-style headings (Heading\n====) + processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => { + const headingLevel = level[0] === '=' ? 1 : 2; + return `${text.trim()}`; + }); + + return processedContent; +} + +/** + * Process tables + */ +function processTables(content: string): string { + try { + if (!content) return ''; + + return content.replace(/^\|(.*(?:\n\|.*)*)/gm, (match) => { + try { + // Split into rows and clean up + const rows = match.split('\n').filter(row => row.trim()); + if (rows.length < 1) return match; + + // Helper to process a row into cells + const processCells = (row: string): string[] => { + return row + .split('|') + .slice(1, -1) // Remove empty cells from start/end + .map(cell => cell.trim()); + }; + + // Check if second row is a delimiter row (only hyphens) + const hasHeader = rows.length > 1 && rows[1].trim().match(/^\|[-\s|]+\|$/); + + // Extract header and body rows + let headerCells: string[] = []; + let bodyRows: string[] = []; + + if (hasHeader) { + // If we have a header, first row is header, skip delimiter, rest is body + headerCells = processCells(rows[0]); + bodyRows = rows.slice(2); + } else { + // No header, all rows are body + bodyRows = rows; + } + + // Build table HTML + let html = '
\n'; + html += '\n'; + + // Add header if exists + if (hasHeader) { + html += '\n\n'; + headerCells.forEach(cell => { + html += `\n`; + }); + html += '\n\n'; + } + + // Add body + html += '\n'; + bodyRows.forEach(row => { + const cells = processCells(row); + html += '\n'; + cells.forEach(cell => { + html += `\n`; + }); + html += '\n'; + }); + + html += '\n
${cell}
${cell}
\n
'; + return html; + } catch (error) { + console.error('Error processing table row:', error); + return match; + } + }); + } catch (error) { + console.error('Error in processTables:', error); + return content; + } +} + +/** + * Process links and images + */ +function processLinksAndImages(content: string): string { + // Process images first to avoid conflicts with links + let processedContent = content.replace(IMAGE_REGEX, + '$1' + ); + + // Process links + processedContent = processedContent.replace(LINK_REGEX, + '$1' + ); + + return processedContent; +} + +/** + * Process horizontal rules + */ +function processHorizontalRules(content: string): string { + return content.replace(HORIZONTAL_RULE_REGEX, + '
' + ); +} + +/** + * Process footnotes + */ +function processFootnotes(content: string): string { + try { + if (!content) return ''; + + // First collect all footnote references and definitions + const footnotes = new Map(); + const references = new Map(); + const referenceLocations = new Set(); + let nextNumber = 1; + + // First pass: collect all references to establish order + let processedContent = content.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => { + if (!referenceLocations.has(id) && !references.has(id)) { + references.set(id, nextNumber++); + } + referenceLocations.add(id); + return match; // Keep the reference for now + }); + + // Second pass: collect all definitions + processedContent = processedContent.replace(FOOTNOTE_DEFINITION_REGEX, (match, id, text) => { + footnotes.set(id, text.trim()); + return ''; // Remove the definition + }); + + // Third pass: process references with collected information + processedContent = processedContent.replace(FOOTNOTE_REFERENCE_REGEX, (match, id) => { + if (!footnotes.has(id)) { + console.warn(`Footnote reference [^${id}] found but no definition exists`); + return match; + } + + const num = references.get(id)!; + return `[${num}]`; + }); + + // Add footnotes section if we have any + if (references.size > 0) { + processedContent += '\n\n

Footnotes

\n
    \n'; + + // Sort footnotes by their reference number + const sortedFootnotes = Array.from(references.entries()) + .sort((a, b) => a[1] - b[1]) + .filter(([id]) => footnotes.has(id)); // Only include footnotes that have definitions + + // Add each footnote in order + for (const [id, num] of sortedFootnotes) { + const text = footnotes.get(id) || ''; + processedContent += `
  1. ${text}
  2. \n`; + } + processedContent += '
'; + } + + return processedContent; + } catch (error) { + console.error('Error processing footnotes:', error); + return content; + } +} + +/** + * Process blockquotes + */ +function processBlockquotes(content: string): string { + // Match blockquotes that might span multiple lines + const blockquoteRegex = /^>[ \t]?(.+(?:\n>[ \t]?.+)*)/gm; + + return content.replace(blockquoteRegex, (match) => { + // Remove the '>' prefix from each line and preserve line breaks + const text = match + .split('\n') + .map(line => line.replace(/^>[ \t]?/, '')) + .join('\n') + .trim(); + + return `
${text}
`; + }); +} + +/** + * 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; + let lastWasCodeBlock = false; + + 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 = []; + lastWasCodeBlock = true; + } else { + // Ending current code block + blockCount++; + const id = `CODE_BLOCK_${blockCount}`; + const code = currentCode.join('\n'); + + // Try to format JSON if specified + let formattedCode = code; + if (currentLanguage.toLowerCase() === 'json') { + try { + formattedCode = JSON.stringify(JSON.parse(code), null, 2); + } catch (e) { + formattedCode = code; + } + } + + blocks.set(id, JSON.stringify({ + code: formattedCode, + language: currentLanguage, + raw: true + })); + + processedLines.push(''); // Add spacing before code block + processedLines.push(id); + processedLines.push(''); // Add spacing after code block + inCodeBlock = false; + currentCode = []; + currentLanguage = ''; + } + } else if (inCodeBlock) { + currentCode.push(line); + } else { + if (lastWasCodeBlock && line.trim()) { + processedLines.push(''); + lastWasCodeBlock = false; + } + processedLines.push(line); + } + } + + // Handle unclosed code block + if (inCodeBlock && currentCode.length > 0) { + blockCount++; + const id = `CODE_BLOCK_${blockCount}`; + const code = currentCode.join('\n'); + + // Try to format JSON if specified + let formattedCode = code; + if (currentLanguage.toLowerCase() === 'json') { + try { + formattedCode = JSON.stringify(JSON.parse(code), null, 2); + } catch (e) { + formattedCode = code; + } + } + + blocks.set(id, JSON.stringify({ + code: formattedCode, + language: currentLanguage, + raw: true + })); + processedLines.push(''); + processedLines.push(id); + processedLines.push(''); + } + + return { + text: processedLines.join('\n'), + blocks + }; +} + +/** + * Restore code blocks with proper formatting + */ +function restoreCodeBlocks(text: string, blocks: Map): string { + let result = text; + + for (const [id, blockData] of blocks) { + try { + const { code, language } = JSON.parse(blockData); + + let html; + if (language && hljs.getLanguage(language)) { + try { + const highlighted = hljs.highlight(code, { + language, + ignoreIllegals: true + }).value; + html = `
${highlighted}
`; + } catch (e) { + console.warn('Failed to highlight code block:', e); + html = `
${code}
`; + } + } else { + html = `
${code}
`; + } + + result = result.replace(id, html); + } catch (error) { + console.error('Error restoring code block:', error); + result = result.replace(id, '
Error processing code block
'); + } + } + + return result; +} + +/** + * Parse markdown text with advanced formatting + */ +export async function parseAdvancedMarkdown(text: string): Promise { + try { + if (!text) return ''; + + // Step 1: Extract and save code blocks first + const { text: withoutCode, blocks } = processCodeBlocks(text); + + // Step 2: Process all other markdown + let processedText = withoutCode; + + // Process block-level elements + processedText = processTables(processedText); + processedText = processBlockquotes(processedText); + processedText = processHeadings(processedText); + processedText = processHorizontalRules(processedText); + processedText = processLinksAndImages(processedText); + + // Process inline elements + processedText = processedText.replace(INLINE_CODE_REGEX, (_, code) => { + const escapedCode = code + .trim() + .replace(/&/g, '&') + .replace(//g, '>') + .replace(/"/g, '"') + .replace(/'/g, '''); + return `${escapedCode}`; + }); + + // Process footnotes before basic markdown to prevent unwanted paragraph tags + processedText = processFootnotes(processedText); + + // Process async elements + processedText = await processNostrIdentifiers(processedText); + processedText = await parseBasicMarkdown(processedText); + + // Step 3: Restore code blocks + processedText = restoreCodeBlocks(processedText, blocks); + + return processedText; + } catch (error) { + console.error('Error in parseAdvancedMarkdown:', error); + if (error instanceof Error) { + return `
Error processing markdown: ${error.message}
`; + } + return '
An error occurred while processing the markdown
'; + } +} \ No newline at end of file diff --git a/src/lib/utils/basicMarkdownParser.ts b/src/lib/utils/basicMarkdownParser.ts new file mode 100644 index 0000000..ab27bf9 --- /dev/null +++ b/src/lib/utils/basicMarkdownParser.ts @@ -0,0 +1,235 @@ +import { processNostrIdentifiers } from './nostrUtils'; + +// Regular expressions for basic markdown elements +const BOLD_REGEX = /(\*\*|[*])((?:[^*\n]|\*(?!\*))+)\1/g; +const ITALIC_REGEX = /\b(_[^_\n]+_|\b__[^_\n]+__)\b/g; +const STRIKETHROUGH_REGEX = /~~([^~\n]+)~~|~([^~\n]+)~/g; +const HASHTAG_REGEX = /(?[ \t]?.*)(?:\n\1[ \t]*(?!>).*)*$/gm; +const INLINE_CODE_REGEX = /`([^`\n]+)`/g; + +interface ListItem { + type: 'ul' | 'ol'; + indent: number; + content: string; + marker: string; +} + +// HTML escape function +function escapeHtml(text: string): string { + const htmlEscapes: { [key: string]: string } = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, char => htmlEscapes[char]); +} + +/** + * Process paragraphs and line breaks + */ +function processParagraphs(content: string): string { + try { + if (!content) return ''; + + // Split content into paragraphs (double line breaks) + const paragraphs = content.split(/\n\s*\n/); + + // Process each paragraph + return paragraphs.map(para => { + if (!para.trim()) return ''; + + // Handle single line breaks within paragraphs + const lines = para.split('\n'); + + // Join lines with normal line breaks and add br after paragraph + return `

${lines.join('\n')}


`; + }).filter(Boolean).join('\n'); + } catch (error) { + console.error('Error in processParagraphs:', error); + return content; + } +} + +/** + * Process basic text formatting (bold, italic, strikethrough, hashtags, inline code) + */ +function processBasicFormatting(content: string): string { + try { + if (!content) return ''; + + // Process bold first to avoid conflicts + content = content.replace(BOLD_REGEX, '$2'); + + // Then process italic, handling both single and double underscores + content = content.replace(ITALIC_REGEX, match => { + const text = match.replace(/^_+|_+$/g, ''); + return `${text}`; + }); + + // Then process strikethrough, handling both single and double tildes + content = content.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => { + const text = doubleText || singleText; + return `${text}`; + }); + + // Finally process hashtags - style them with a lighter color + content = content.replace(HASHTAG_REGEX, '#$1'); + + // Process inline code + content = content.replace(INLINE_CODE_REGEX, '$1'); + + return content; + } catch (error) { + console.error('Error in processBasicFormatting:', error); + return content; + } +} + +/** + * Process blockquotes + */ +function processBlockquotes(content: string): string { + try { + if (!content) return ''; + + return content.replace(BLOCKQUOTE_REGEX, match => { + // Split into lines and process each line + const lines = match.split('\n').map(line => { + // Remove the '>' marker and trim any whitespace after it + return line.replace(/^[ \t]*>[ \t]?/, '').trim(); + }); + + // Join the lines with proper spacing and wrap in blockquote + return `
${ + lines.join('\n') + }
`; + }); + } catch (error) { + console.error('Error in processBlockquotes:', error); + return content; + } +} + +/** + * Calculate indentation level from spaces + */ +function getIndentLevel(spaces: string): number { + return Math.floor(spaces.length / 2); +} + +/** + * Process lists (ordered and unordered) + */ +function processLists(content: string): string { + const lines = content.split('\n'); + const processed: string[] = []; + const listStack: { type: 'ol' | 'ul', items: string[], level: number }[] = []; + + function closeList() { + if (listStack.length > 0) { + const list = listStack.pop()!; + const listType = list.type; + const listClass = listType === 'ol' ? 'list-decimal' : 'list-disc'; + const indentClass = list.level > 0 ? 'ml-6' : 'ml-4'; + let listHtml = `<${listType} class="${listClass} ${indentClass} my-2 space-y-2">`; + list.items.forEach(item => { + listHtml += `\n
  • ${item}
  • `; + }); + listHtml += `\n`; + + if (listStack.length > 0) { + // If we're in a nested list, add this as an item to the parent + const parentList = listStack[listStack.length - 1]; + const lastItem = parentList.items.pop()!; + parentList.items.push(lastItem + '\n' + listHtml); + } else { + processed.push(listHtml); + } + } + } + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Count leading spaces to determine nesting level + const leadingSpaces = line.match(/^(\s*)/)?.[0]?.length ?? 0; + const effectiveLevel = Math.floor(leadingSpaces / 2); // 2 spaces per level + + // Trim the line and check for list markers + const trimmedLine = line.trim(); + const orderedMatch = trimmedLine.match(/^(\d+)\.[ \t]+(.+)$/); + const unorderedMatch = trimmedLine.match(/^[-*][ \t]+(.+)$/); + + if (orderedMatch || unorderedMatch) { + const content = orderedMatch ? orderedMatch[2] : (unorderedMatch && unorderedMatch[1]) || ''; + const type = orderedMatch ? 'ol' : 'ul'; + + // Close any lists that are at a deeper level + while (listStack.length > 0 && listStack[listStack.length - 1].level > effectiveLevel) { + closeList(); + } + + // If we're at a new level, start a new list + if (listStack.length === 0 || listStack[listStack.length - 1].level < effectiveLevel) { + listStack.push({ type, items: [], level: effectiveLevel }); + } + // If we're at the same level but different type, close the current list and start a new one + else if (listStack[listStack.length - 1].type !== type && listStack[listStack.length - 1].level === effectiveLevel) { + closeList(); + listStack.push({ type, items: [], level: effectiveLevel }); + } + + // Add the item to the current list + listStack[listStack.length - 1].items.push(content); + } else { + // Not a list item - close all open lists and add the line + while (listStack.length > 0) { + closeList(); + } + processed.push(line); + } + } + + // Close any remaining open lists + while (listStack.length > 0) { + closeList(); + } + + return processed.join('\n'); +} + +/** + * Parse markdown text with basic formatting + */ +export async function parseBasicMarkdown(text: string): Promise { + try { + if (!text) return ''; + + let processedText = text; + + // Process lists first to handle indentation properly + processedText = processLists(processedText); + + // Process blockquotes next + processedText = processBlockquotes(processedText); + + // Process paragraphs + processedText = processParagraphs(processedText); + + // Process basic text formatting + processedText = processBasicFormatting(processedText); + + // Process Nostr identifiers last + processedText = await processNostrIdentifiers(processedText); + + return processedText; + } catch (error) { + console.error('Error in parseBasicMarkdown:', error); + if (error instanceof Error) { + return `
    Error processing markdown: ${error.message}
    `; + } + return '
    An error occurred while processing the markdown
    '; + } +} \ No newline at end of file diff --git a/src/lib/utils/markdownParser.ts b/src/lib/utils/markdownParser.ts index 5456b49..a257599 100644 --- a/src/lib/utils/markdownParser.ts +++ b/src/lib/utils/markdownParser.ts @@ -1,16 +1,11 @@ /** - * Markdown parser with special handling for nostr identifiers + * Process inline code */ - -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; +function processInlineCode(text: string): string { + return text.replace(INLINE_CODE_REGEX, (match, code) => { + return `${code}`; + }); +} // Regular expressions for markdown elements const BOLD_REGEX = /\*\*([^*]+)\*\*|\*([^*]+)\*/g; @@ -28,614 +23,4 @@ const TABLE_REGEX = /^\|(.+)\|\r?\n\|([-|\s]+)\|\r?\n((?:\|.+\|\r?\n?)+)$/gm; const TABLE_ROW_REGEX = /^\|(.+)\|$/gm; const TABLE_DELIMITER_REGEX = /^[\s-]+$/; -// Cache for npub metadata -const npubCache = new Map(); - -/** - * 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'); - const processed: string[] = []; - const listStack: { type: 'ol' | 'ul', items: string[], level: number }[] = []; - - function closeList() { - if (listStack.length > 0) { - const list = listStack.pop()!; - const listType = list.type; - const listClass = listType === 'ol' ? 'list-decimal' : 'list-disc'; - const indentClass = list.level > 0 ? 'ml-6' : 'ml-4'; - let listHtml = `<${listType} class="${listClass} ${indentClass} my-2 space-y-2">`; - list.items.forEach(item => { - listHtml += `\n
  • ${item}
  • `; - }); - listHtml += `\n`; - - if (listStack.length > 0) { - // If we're in a nested list, add this as an item to the parent - const parentList = listStack[listStack.length - 1]; - const lastItem = parentList.items.pop()!; - parentList.items.push(lastItem + '\n' + listHtml); - } else { - processed.push(listHtml); - } - } - } - - for (let i = 0; i < lines.length; i++) { - const line = lines[i]; - // Count leading spaces to determine nesting level - const leadingSpaces = line.match(/^(\s*)/)?.[0]?.length ?? 0; - const effectiveLevel = Math.floor(leadingSpaces / 2); // 2 spaces per level - - // Trim the line and check for list markers - const trimmedLine = line.trim(); - const orderedMatch = trimmedLine.match(/^(\d+)\.[ \t]+(.+)$/); - const unorderedMatch = trimmedLine.match(/^[-*][ \t]+(.+)$/); - - if (orderedMatch || unorderedMatch) { - const content = orderedMatch ? orderedMatch[2] : (unorderedMatch && unorderedMatch[1]) || ''; - const type = orderedMatch ? 'ol' : 'ul'; - - // Close any lists that are at a deeper level - while (listStack.length > 0 && listStack[listStack.length - 1].level > effectiveLevel) { - closeList(); - } - - // If we're at a new level, start a new list - if (listStack.length === 0 || listStack[listStack.length - 1].level < effectiveLevel) { - listStack.push({ type, items: [], level: effectiveLevel }); - } - // If we're at the same level but different type, close the current list and start a new one - else if (listStack[listStack.length - 1].type !== type && listStack[listStack.length - 1].level === effectiveLevel) { - closeList(); - listStack.push({ type, items: [], level: effectiveLevel }); - } - - // Add the item to the current list - listStack[listStack.length - 1].items.push(content); - } else { - // Not a list item - close all open lists and add the line - while (listStack.length > 0) { - closeList(); - } - processed.push(line); - } - } - - // Close any remaining open lists - while (listStack.length > 0) { - closeList(); - } - - 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; - - // Format JSON if the language is specified as json - if (language === 'json') { - try { - const jsonObj = JSON.parse(code.trim()); - processedCode = JSON.stringify(jsonObj, null, 2); - } catch (e) { - console.warn('Failed to parse JSON:', e); - } - } - - // Apply syntax highlighting if language is specified - if (language) { - 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) => { - return `${code}`; - }); -} - -/** - * 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; -} +// ... existing code ... \ No newline at end of file diff --git a/src/lib/utils/markdownTestfile.md b/src/lib/utils/markdownTestfile.md index c73a4dd..cb35194 100644 --- a/src/lib/utils/markdownTestfile.md +++ b/src/lib/utils/markdownTestfile.md @@ -3,7 +3,9 @@ This is a test ### Disclaimer -It is _only_ a test. I just wanted to see if the markdown renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1] +It is _only_ a test, for __sure__. I just wanted to see if the markdown renders correctly on the page, even if I use **two asterisks** for bold text, instead of *one asterisk*.[^1] + +This file is full of ~errors~ opportunities to ~~mess up the formatting~~ check your markdown parser. npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z wrote this. That's the same person as nostr:npub1l5sga6xg72phsz5422ykujprejwud075ggrr3z2hwyrfgr7eylqstegx9z and nprofile1qydhwumn8ghj7argv4nx7un9wd6zumn0wd68yvfwvdhk6tcpr3mhxue69uhkx6rjd9ehgurfd3kzumn0wd68yvfwvdhk6tcqyr7jprhgeregx7q2j4fgjmjgy0xfm34l63pqvwyf2acsd9q0mynuzp4qva3. That is a different person from npub1s3ht77dq4zqnya8vjun5jp3p44pr794ru36d0ltxu65chljw8xjqd975wz. @@ -40,6 +42,9 @@ Let's nest that: 3. third 4. fourth indented 5. fifth indented even more + 6. sixth under the fourth + 7. seventh under the sixth +8. eighth under the third This is ordered and unordered mixed: 1. first @@ -67,7 +72,7 @@ nostr:naddr1qvzqqqr4gupzplfq3m5v3u5r0q9f255fdeyz8nyac6lagssx8zy4wugxjs8ajf7pqydh This is an implementation of [Nostr-flavored Markdown](https://github.com/nostrability/nostrability/issues/146) for #gitstuff issue notes. -You can even include `code inline` or +You can even include `code inline`, like `
    ` or ``` in a code block @@ -130,7 +135,6 @@ package main input := scanner.Text() fmt.Println("You entered:", input) } - ``` or even Markdown: @@ -144,7 +148,7 @@ Paragraphs are separated by a blank line. 2nd paragraph. *Italic*, **bold**, and `monospace`. Itemized lists look like: - * this one + * this one[^some reference text] * that one * the other one @@ -164,7 +168,7 @@ content starts at 4-columns in. ### I went ahead and implemented tables, too. -A neat table: +A neat table[^some reference text]: | Syntax | Description | | ----------- | ----------- | @@ -178,5 +182,12 @@ A messy table (should render the same as above): | Header | Title | | Paragraph | Text | +Here is a table without a header row: + +| Sometimes | you don't | +| need a | header | +| just | pipes | + [^1]: this is a footnote -[^2]: so is this \ No newline at end of file +[^2]: so is this +[^some reference text]: this is a footnote that isn't a number \ No newline at end of file diff --git a/src/lib/utils/mime.ts b/src/lib/utils/mime.ts new file mode 100644 index 0000000..878a744 --- /dev/null +++ b/src/lib/utils/mime.ts @@ -0,0 +1,96 @@ +/** + * Determine the type of Nostr event based on its kind number + * Following NIP specification for kind ranges: + * - Replaceable: 0, 3, 10000-19999 (only latest stored) + * - Ephemeral: 20000-29999 (not stored) + * - Addressable: 30000-39999 (latest per d-tag stored) + * - Regular: all other kinds (stored by relays) + */ +function getEventType(kind: number): 'regular' | 'replaceable' | 'ephemeral' | 'addressable' { + // Check special ranges first + if (kind >= 30000 && kind < 40000) { + return 'addressable'; + } + + if (kind >= 20000 && kind < 30000) { + return 'ephemeral'; + } + + if ((kind >= 10000 && kind < 20000) || kind === 0 || kind === 3) { + return 'replaceable'; + } + + // Everything else is regular + return 'regular'; +} + +/** + * Get MIME tags for a Nostr event based on its kind number + * Returns an array of tags: [["m", mime-type], ["M", nostr-mime-type]] + * Following NKBIP-06 and NIP-94 specifications + */ +export function getMimeTags(kind: number): [string, string][] { + // Default tags for unknown kinds + let mTag: [string, string] = ["m", "text/plain"]; + let MTag: [string, string] = ["M", "note/generic/nonreplaceable"]; + + // Determine replaceability based on event type + const eventType = getEventType(kind); + const replaceability = (eventType === 'replaceable' || eventType === 'addressable') + ? "replaceable" + : "nonreplaceable"; + + switch (kind) { + // Short text note + case 1: + mTag = ["m", "text/plain"]; + MTag = ["M", `note/microblog/${replaceability}`]; + break; + + // Generic reply + case 1111: + mTag = ["m", "text/plain"]; + MTag = ["M", `note/comment/${replaceability}`]; + break; + + // Issue + case 1621: + mTag = ["m", "text/markdown"]; + MTag = ["M", `git/issue/${replaceability}`]; + break; + + // Issue comment + case 1622: + mTag = ["m", "text/markdown"]; + MTag = ["M", `git/comment/${replaceability}`]; + break; + + // Book metadata + case 30040: + mTag = ["m", "application/json"]; + MTag = ["M", `meta-data/index/${replaceability}`]; + break; + + // Book content + case 30041: + mTag = ["m", "text/asciidoc"]; + MTag = ["M", `article/publication-content/${replaceability}`]; + break; + + // Wiki page + case 30818: + mTag = ["m", "text/asciidoc"]; + MTag = ["M", `article/wiki/${replaceability}`]; + break; + + // Long-form note + case 30023: + mTag = ["m", "text/markdown"]; + MTag = ["M", `article/long-form/${replaceability}`]; + break; + + // Add more cases as needed... + } + + return [mTag, MTag]; +} \ No newline at end of file diff --git a/src/lib/utils/nostrUtils.ts b/src/lib/utils/nostrUtils.ts new file mode 100644 index 0000000..60db82f --- /dev/null +++ b/src/lib/utils/nostrUtils.ts @@ -0,0 +1,161 @@ +import { get } from 'svelte/store'; +import { nip19 } from 'nostr-tools'; +import { ndkInstance } from '$lib/ndk'; + +// Regular expressions for Nostr identifiers - match the entire identifier including any prefix +export const NOSTR_PROFILE_REGEX = /(?(); + +/** + * HTML escape a string + */ +function escapeHtml(text: string): string { + const htmlEscapes: { [key: string]: string } = { + '&': '&', + '<': '<', + '>': '>', + '"': '"', + "'": ''' + }; + return text.replace(/[&<>"']/g, char => htmlEscapes[char]); +} + +/** + * Get user metadata for a nostr identifier (npub or nprofile) + */ +export async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string}> { + // Remove nostr: prefix if present + const cleanId = identifier.replace(/^nostr:/, ''); + + if (npubCache.has(cleanId)) { + return npubCache.get(cleanId)!; + } + + const fallback = { name: `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}` }; + + try { + const ndk = get(ndkInstance); + if (!ndk) { + npubCache.set(cleanId, fallback); + return fallback; + } + + const decoded = nip19.decode(cleanId); + if (!decoded) { + npubCache.set(cleanId, 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(cleanId, fallback); + return fallback; + } + + const user = ndk.getUser({ pubkey: pubkey }); + if (!user) { + npubCache.set(cleanId, fallback); + return fallback; + } + + try { + const profile = await user.fetchProfile(); + if (!profile) { + npubCache.set(cleanId, fallback); + return fallback; + } + + const metadata = { + name: profile.name || fallback.name, + displayName: profile.displayName + }; + + npubCache.set(cleanId, metadata); + return metadata; + } catch (e) { + npubCache.set(cleanId, fallback); + return fallback; + } + } catch (e) { + npubCache.set(cleanId, fallback); + return fallback; + } +} + +/** + * Create a profile link element + */ +function createProfileLink(identifier: string, displayText: string | undefined): string { + const cleanId = identifier.replace(/^nostr:/, ''); + const escapedId = escapeHtml(cleanId); + const defaultText = `${cleanId.slice(0, 8)}...${cleanId.slice(-4)}`; + const escapedText = escapeHtml(displayText || defaultText); + + return `@${escapedText}`; +} + +/** + * Create a note link element + */ +function createNoteLink(identifier: string): string { + const cleanId = identifier.replace(/^nostr:/, ''); + const shortId = `${cleanId.slice(0, 12)}...${cleanId.slice(-8)}`; + const escapedId = escapeHtml(cleanId); + const escapedText = escapeHtml(shortId); + + return `${escapedText}`; +} + +/** + * Process Nostr identifiers in text + */ +export 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; + const link = createProfileLink(identifier, displayText); + 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 link = createNoteLink(identifier); + processedContent = processedContent.replace(fullMatch, link); + } + + return processedContent; +} + +export async function getNpubFromNip05(nip05: string): Promise { + try { + const ndk = get(ndkInstance); + if (!ndk) { + console.error('NDK not initialized'); + return null; + } + + const user = await ndk.getUser({ nip05 }); + if (!user || !user.npub) { + return null; + } + return user.npub; + } catch (error) { + console.error('Error getting npub from nip05:', error); + return null; + } +} \ No newline at end of file diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte index 895dff9..b2798e0 100644 --- a/src/routes/contact/+page.svelte +++ b/src/routes/contact/+page.svelte @@ -1,13 +1,14 @@