9 changed files with 1114 additions and 802 deletions
@ -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 `<h${headingLevel} class="text-2xl font-bold mt-6 mb-4">${text.trim()}</h${headingLevel}>`; |
||||||
|
}); |
||||||
|
|
||||||
|
// Process Setext-style headings (Heading\n====)
|
||||||
|
processedContent = processedContent.replace(ALTERNATE_HEADING_REGEX, (_, text, level) => { |
||||||
|
const headingLevel = level[0] === '=' ? 1 : 2; |
||||||
|
return `<h${headingLevel} class="text-2xl font-bold mt-6 mb-4">${text.trim()}</h${headingLevel}>`; |
||||||
|
}); |
||||||
|
|
||||||
|
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 = '<div class="overflow-x-auto my-4">\n'; |
||||||
|
html += '<table class="min-w-full border-collapse">\n'; |
||||||
|
|
||||||
|
// Add header if exists
|
||||||
|
if (hasHeader) { |
||||||
|
html += '<thead>\n<tr>\n'; |
||||||
|
headerCells.forEach(cell => { |
||||||
|
html += `<th class="py-2 px-4 text-left border-b-2 border-gray-200 dark:border-gray-700 font-semibold">${cell}</th>\n`; |
||||||
|
}); |
||||||
|
html += '</tr>\n</thead>\n'; |
||||||
|
} |
||||||
|
|
||||||
|
// Add body
|
||||||
|
html += '<tbody>\n'; |
||||||
|
bodyRows.forEach(row => { |
||||||
|
const cells = processCells(row); |
||||||
|
html += '<tr>\n'; |
||||||
|
cells.forEach(cell => { |
||||||
|
html += `<td class="py-2 px-4 text-left border-b border-gray-200 dark:border-gray-700">${cell}</td>\n`; |
||||||
|
}); |
||||||
|
html += '</tr>\n'; |
||||||
|
}); |
||||||
|
|
||||||
|
html += '</tbody>\n</table>\n</div>'; |
||||||
|
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,
|
||||||
|
'<img src="$2" alt="$1" class="max-w-full h-auto rounded-lg shadow-lg my-4" loading="lazy">' |
||||||
|
); |
||||||
|
|
||||||
|
// Process links
|
||||||
|
processedContent = processedContent.replace(LINK_REGEX, |
||||||
|
'<a href="$2" class="text-primary-600 hover:underline">$1</a>' |
||||||
|
); |
||||||
|
|
||||||
|
return processedContent; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process horizontal rules |
||||||
|
*/ |
||||||
|
function processHorizontalRules(content: string): string { |
||||||
|
return content.replace(HORIZONTAL_RULE_REGEX, |
||||||
|
'<hr class="my-8 h-px border-0 bg-gray-200 dark:bg-gray-700">' |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process footnotes |
||||||
|
*/ |
||||||
|
function processFootnotes(content: string): string { |
||||||
|
try { |
||||||
|
if (!content) return ''; |
||||||
|
|
||||||
|
// First collect all footnote references and definitions
|
||||||
|
const footnotes = new Map<string, string>(); |
||||||
|
const references = new Map<string, number>(); |
||||||
|
const referenceLocations = new Set<string>(); |
||||||
|
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 `<sup><a href="#fn-${id}" id="fnref-${id}" class="text-primary-600 hover:underline">[${num}]</a></sup>`; |
||||||
|
}); |
||||||
|
|
||||||
|
// Add footnotes section if we have any
|
||||||
|
if (references.size > 0) { |
||||||
|
processedContent += '\n\n<h2 class="text-xl font-bold mt-8 mb-4">Footnotes</h2>\n<ol class="list-decimal list-inside">\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 += `<li id="fn-${id}" value="${num}"><span class="marker">${text}</span> <a href="#fnref-${id}" class="text-primary-600 hover:underline">↩</a></li>\n`; |
||||||
|
} |
||||||
|
processedContent += '</ol>'; |
||||||
|
} |
||||||
|
|
||||||
|
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 `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4 whitespace-pre-wrap">${text}</blockquote>`; |
||||||
|
}); |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process code blocks by finding consecutive code lines and preserving their content |
||||||
|
*/ |
||||||
|
function processCodeBlocks(text: string): { text: string; blocks: Map<string, string> } { |
||||||
|
const lines = text.split('\n'); |
||||||
|
const processedLines: string[] = []; |
||||||
|
const blocks = new Map<string, string>(); |
||||||
|
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, string>): 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 = `<pre class="code-block"><code class="hljs language-${language}">${highlighted}</code></pre>`; |
||||||
|
} catch (e) { |
||||||
|
console.warn('Failed to highlight code block:', e); |
||||||
|
html = `<pre class="code-block"><code class="hljs ${language ? `language-${language}` : ''}">${code}</code></pre>`; |
||||||
|
} |
||||||
|
} else { |
||||||
|
html = `<pre class="code-block"><code class="hljs">${code}</code></pre>`; |
||||||
|
} |
||||||
|
|
||||||
|
result = result.replace(id, html); |
||||||
|
} catch (error) { |
||||||
|
console.error('Error restoring code block:', error); |
||||||
|
result = result.replace(id, '<pre class="code-block"><code class="hljs">Error processing code block</code></pre>'); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
return result; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Parse markdown text with advanced formatting |
||||||
|
*/ |
||||||
|
export async function parseAdvancedMarkdown(text: string): Promise<string> { |
||||||
|
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, '"') |
||||||
|
.replace(/'/g, '''); |
||||||
|
return `<code class="px-1.5 py-0.5 bg-white dark:bg-gray-900 border border-gray-200 dark:border-gray-700 rounded text-sm font-mono">${escapedCode}</code>`; |
||||||
|
}); |
||||||
|
|
||||||
|
// 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 `<div class="text-red-500">Error processing markdown: ${error.message}</div>`; |
||||||
|
} |
||||||
|
return '<div class="text-red-500">An error occurred while processing the markdown</div>'; |
||||||
|
} |
||||||
|
}
|
||||||
@ -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 = /(?<![^\s])#([a-zA-Z0-9_]+)(?!\w)/g; |
||||||
|
const BLOCKQUOTE_REGEX = /^([ \t]*>[ \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 `<p>${lines.join('\n')}</p><br>`; |
||||||
|
}).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, '<strong>$2</strong>'); |
||||||
|
|
||||||
|
// Then process italic, handling both single and double underscores
|
||||||
|
content = content.replace(ITALIC_REGEX, match => { |
||||||
|
const text = match.replace(/^_+|_+$/g, ''); |
||||||
|
return `<em>${text}</em>`; |
||||||
|
}); |
||||||
|
|
||||||
|
// Then process strikethrough, handling both single and double tildes
|
||||||
|
content = content.replace(STRIKETHROUGH_REGEX, (match, doubleText, singleText) => { |
||||||
|
const text = doubleText || singleText; |
||||||
|
return `<del class="line-through">${text}</del>`; |
||||||
|
}); |
||||||
|
|
||||||
|
// Finally process hashtags - style them with a lighter color
|
||||||
|
content = content.replace(HASHTAG_REGEX, '<span class="text-gray-500 dark:text-gray-400">#$1</span>'); |
||||||
|
|
||||||
|
// Process inline code
|
||||||
|
content = content.replace(INLINE_CODE_REGEX, '<code class="bg-gray-200 dark:bg-gray-800/80 px-1.5 py-0.5 rounded text-sm font-mono text-gray-800 dark:text-gray-200 border border-gray-300 dark:border-gray-700">$1</code>'); |
||||||
|
|
||||||
|
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 `<blockquote class="pl-4 border-l-4 border-gray-300 dark:border-gray-600 my-4">${ |
||||||
|
lines.join('\n') |
||||||
|
}</blockquote>`;
|
||||||
|
}); |
||||||
|
} 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 <li class="pl-1">${item}</li>`; |
||||||
|
}); |
||||||
|
listHtml += `\n</${listType}>`; |
||||||
|
|
||||||
|
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<string> { |
||||||
|
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 `<div class="text-red-500">Error processing markdown: ${error.message}</div>`; |
||||||
|
} |
||||||
|
return '<div class="text-red-500">An error occurred while processing the markdown</div>'; |
||||||
|
} |
||||||
|
}
|
||||||
@ -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]; |
||||||
|
}
|
||||||
@ -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 = /(?<![\w/])((nostr:)?(npub|nprofile)[a-zA-Z0-9]{20,})(?![\w/])/g; |
||||||
|
export const NOSTR_NOTE_REGEX = /(?<![\w/])((nostr:)?(note|nevent|naddr)[a-zA-Z0-9]{20,})(?![\w/])/g; |
||||||
|
|
||||||
|
// Cache for npub metadata
|
||||||
|
const npubCache = new Map<string, {name?: string, displayName?: string}>(); |
||||||
|
|
||||||
|
/** |
||||||
|
* 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 `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline" target="_blank">@${escapedText}</a>`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* 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 `<a href="https://njump.me/${escapedId}" class="inline-flex items-center text-primary-600 dark:text-primary-500 hover:underline break-all" target="_blank">${escapedText}</a>`; |
||||||
|
} |
||||||
|
|
||||||
|
/** |
||||||
|
* Process Nostr identifiers in text |
||||||
|
*/ |
||||||
|
export async function processNostrIdentifiers(content: string): Promise<string> { |
||||||
|
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<string | null> { |
||||||
|
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; |
||||||
|
} |
||||||
|
}
|
||||||
Loading…
Reference in new issue