@ -7,17 +7,14 @@ import { ndkInstance } from '$lib/ndk';
import { nip19 } from 'nostr-tools' ;
import { nip19 } from 'nostr-tools' ;
// Regular expressions for nostr identifiers - process these first
// Regular expressions for nostr identifiers - process these first
const NOSTR_NPUB_REGEX = /(?:nostr:)?(npub[a-zA-Z0-9]{59,60})/g ;
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
// Regular expressions for markdown elements
const BLOCKQUOTE_REGEX = /^(?:>[ \t]*.+\n?(?:(?:>[ \t]*\n)*(?:>[ \t]*.+\n?))*)+/gm ;
const ORDERED_LIST_REGEX = /^(\d+)\.[ \t]+(.+)$/gm ;
const UNORDERED_LIST_REGEX = /^[-*][ \t]+(.+)$/gm ;
const BOLD_REGEX = /\*\*([^*]+)\*\*|\*([^*]+)\*/g ;
const BOLD_REGEX = /\*\*([^*]+)\*\*|\*([^*]+)\*/g ;
const ITALIC_REGEX = /_([^_]+)_/g ;
const ITALIC_REGEX = /_([^_]+)_/g ;
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm ;
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm ;
const HORIZONTAL_RULE_REGEX = /^(?:---|\*\*\*|___)$/gm ;
const HORIZONTAL_RULE_REGEX = /^(?:---|\*\*\*|___)$/gm ;
const CODE_BLOCK_REGEX = /```([^\n]*)\n([\s\S]*?)```/gm ;
const INLINE_CODE_REGEX = /`([^`\n]+)`/g ;
const INLINE_CODE_REGEX = /`([^`\n]+)`/g ;
const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g ;
const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g ;
const IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g ;
const IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g ;
@ -29,38 +26,49 @@ const FOOTNOTE_DEFINITION_REGEX = /^\[(\^[^\]]+)\]:\s*(.+?)(?:\n(?!\[)|\n\n|$)/g
const npubCache = new Map < string , { name ? : string , displayName ? : string } > ( ) ;
const npubCache = new Map < string , { name ? : string , displayName ? : string } > ( ) ;
/ * *
/ * *
* Get user metadata for an npub
* Get user metadata for a nostr identifier ( npub or nprofile )
* /
* /
async function getUserMetadata ( npub : string ) : Promise < { name? : string , displayName? : string } > {
async function getUserMetadata ( identifier : string ) : Promise < { name? : string , displayName? : string } > {
if ( npubCache . has ( npub ) ) {
if ( npubCache . has ( identifier ) ) {
return npubCache . get ( npub ) ! ;
return npubCache . get ( identifier ) ! ;
}
}
const fallback = { name : ` ${ npub . slice ( 0 , 8 ) } ... ${ npub . slice ( - 4 ) } ` } ;
const fallback = { name : ` ${ identifier . slice ( 0 , 8 ) } ... ${ identifier . slice ( - 4 ) } ` } ;
try {
try {
const ndk = get ( ndkInstance ) ;
const ndk = get ( ndkInstance ) ;
if ( ! ndk ) {
if ( ! ndk ) {
npubCache . set ( npub , fallback ) ;
npubCache . set ( identifier , fallback ) ;
return fallback ;
return fallback ;
}
}
const decoded = nip19 . decode ( npub ) ;
const decoded = nip19 . decode ( identifier ) ;
if ( decoded . type !== 'npub' ) {
if ( ! decoded ) {
npubCache . set ( npub , fallback ) ;
npubCache . set ( identifier , fallback ) ;
return fallback ;
return fallback ;
}
}
const user = ndk . getUser ( { npub : npub } ) ;
// 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 ) {
if ( ! user ) {
npubCache . set ( npub , fallback ) ;
npubCache . set ( identifier , fallback ) ;
return fallback ;
return fallback ;
}
}
try {
try {
const profile = await user . fetchProfile ( ) ;
const profile = await user . fetchProfile ( ) ;
if ( ! profile ) {
if ( ! profile ) {
npubCache . set ( npub , fallback ) ;
npubCache . set ( identifier , fallback ) ;
return fallback ;
return fallback ;
}
}
@ -69,14 +77,14 @@ async function getUserMetadata(npub: string): Promise<{name?: string, displayNam
displayName : profile.displayName
displayName : profile.displayName
} ;
} ;
npubCache . set ( npub , metadata ) ;
npubCache . set ( identifier , metadata ) ;
return metadata ;
return metadata ;
} catch ( e ) {
} catch ( e ) {
npubCache . set ( npub , fallback ) ;
npubCache . set ( identifier , fallback ) ;
return fallback ;
return fallback ;
}
}
} catch ( e ) {
} catch ( e ) {
npubCache . set ( npub , fallback ) ;
npubCache . set ( identifier , fallback ) ;
return fallback ;
return fallback ;
}
}
}
}
@ -84,8 +92,8 @@ async function getUserMetadata(npub: string): Promise<{name?: string, displayNam
/ * *
/ * *
* Process lists ( ordered and unordered )
* Process lists ( ordered and unordered )
* /
* /
function processLists ( html : string ) : string {
function processLists ( content : string ) : string {
const lines = html . split ( '\n' ) ;
const lines = content . split ( '\n' ) ;
let inList = false ;
let inList = false ;
let isOrdered = false ;
let isOrdered = false ;
let currentList : string [ ] = [ ] ;
let currentList : string [ ] = [ ] ;
@ -93,8 +101,8 @@ function processLists(html: string): string {
for ( let i = 0 ; i < lines . length ; i ++ ) {
for ( let i = 0 ; i < lines . length ; i ++ ) {
const line = lines [ i ] ;
const line = lines [ i ] ;
const orderedMatch = ORDERED_LIST_REGEX . exec ( line ) ;
const orderedMatch = line . match ( /^(\d+)\.[ \t]+(.+)$/ ) ;
const unorderedMatch = UNORDERED_LIST_REGEX . exec ( line ) ;
const unorderedMatch = line . match ( /^\*[ \t]+(.+)$/ ) ;
if ( orderedMatch || unorderedMatch ) {
if ( orderedMatch || unorderedMatch ) {
if ( ! inList ) {
if ( ! inList ) {
@ -118,10 +126,6 @@ function processLists(html: string): string {
}
}
processed . push ( line ) ;
processed . push ( line ) ;
}
}
// Reset regex lastIndex
ORDERED_LIST_REGEX . lastIndex = 0 ;
UNORDERED_LIST_REGEX . lastIndex = 0 ;
}
}
if ( inList ) {
if ( inList ) {
@ -138,78 +142,445 @@ function processLists(html: string): string {
}
}
/ * *
/ * *
* Process blockquotes using placeholder approach
* Process blockquotes by finding consecutive quote lines and preserving their structure
* /
* /
function processBlockquotes ( text : string ) : string {
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 } > = [ ] ;
const blockquotes : Array < { id : string , content : string } > = [ ] ;
let processedText = text ;
// Extract and save blockquotes
for ( let i = 0 ; i < lines . length ; i ++ ) {
processedText = processedText . replace ( BLOCKQUOTE_REGEX , ( match ) = > {
const line = lines [ i ] ;
const id = ` BLOCKQUOTE_ ${ blockquotes . length } ` ;
const isQuoteLine = line . startsWith ( '> ' ) ;
const cleanContent = match
. split ( '\n' )
if ( isQuoteLine ) {
. map ( line = > line . replace ( /^>[ \t]*/ , '' ) )
// If we had a gap between quotes, this is a new quote
. join ( '\n' )
if ( ! lastLineWasQuote && currentQuote . length > 0 ) {
. trim ( ) ;
quoteCount ++ ;
const id = ` BLOCKQUOTE_ ${ quoteCount } ` ;
const quoteContent = currentQuote . join ( '<br>' ) ;
blockquotes . push ( {
id ,
content : ` <div class="my-4 border-l-2 border-gray-300 dark:border-gray-600 pl-4 bg-gray-50 dark:bg-gray-800/50 rounded-r py-2"><p class="my-2"> ${ quoteContent } </p></div> `
} ) ;
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 ( '<br>' ) ;
blockquotes . push ( {
blockquotes . push ( {
id ,
id ,
content : ` <blockquote class="pl-4 py-2 my-4 border-l-4 border-gray-300 dark:border-gray-600 bg-gray-50 dark:bg-gray-800 rounded-r"> ${ cleanContent } </blockquote> `
content : ` <div class="my-4 border-l-2 border-gray-300 dark:border-gray-600 pl-4 bg-gray-50 dark:bg-gray-800/50 rounded-r py-2"><p class="my-2"> ${ quoteContent } </p></div > `
} ) ;
} ) ;
return id ;
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 ( '<br>' ) ;
blockquotes . push ( {
id ,
content : ` <div class="my-4 border-l-2 border-gray-300 dark:border-gray-600 pl-4 bg-gray-50 dark:bg-gray-800/50 rounded-r py-2"><p class="my-2"> ${ quoteContent } </p></div> `
} ) ;
} ) ;
processedLines . push ( id ) ;
}
let result = processedLines . join ( '\n' ) ;
// Restore blockquotes
// Restore blockquotes
blockquotes . forEach ( ( { id , content } ) = > {
blockquotes . forEach ( ( { id , content } ) = > {
processedText = processedText . replace ( id , content ) ;
result = resul t. replace ( id , content ) ;
} ) ;
} ) ;
return processedText ;
return resul t;
}
}
/ * *
/ * *
* Process code blocks and inline code before any HTML escaping
* Format code based on language
* /
* /
function processCode ( text : string ) : string {
function formatCodeByLanguage ( code : string , lang : string ) : string {
const blocks : Array < { id : string , content : string } > = [ ] ;
const language = lang . trim ( ) . toLowerCase ( ) ;
const inlineCodes : Array < { id : string , content : string } > = [ ] ;
let processedText = text ;
// First, extract and save code blocks
processedText = processedText . replace ( CODE_BLOCK_REGEX , ( match , lang , code ) = > {
const id = ` CODE_BLOCK_ ${ blocks . length } ` ;
blocks . push ( {
id ,
content : ` <pre><code ${ lang ? ` class="language- ${ lang . trim ( ) } " ` : '' } > ${ escapeHtml ( code ) } </code></pre> `
} ) ;
return id ;
} ) ;
// Then extract and save inline code
// Remove any trailing whitespace or empty lines at start/end
processedText = processedText . replace ( INLINE_CODE_REGEX , ( match , code ) = > {
let formattedCode = code . trim ( ) ;
const id = ` INLINE_CODE_ ${ inlineCodes . length } ` ;
inlineCodes . push ( {
switch ( language ) {
id ,
case 'json' :
content : ` <code> ${ escapeHtml ( code . trim ( ) ) } </code> `
try {
} ) ;
return JSON . stringify ( JSON . parse ( formattedCode ) , null , 2 ) ;
return id ;
} catch ( e ) {
} ) ;
return formattedCode ;
}
case 'javascript' :
case 'js' :
case 'typescript' :
case 'ts' :
try {
// Basic indentation for JS/TS
formattedCode = formattedCode
. split ( '\n' )
. map ( line = > line . trim ( ) )
. join ( '\n' ) ;
// Add line breaks after certain characters
formattedCode = formattedCode
. replace ( /([{([])\s*/g , '$1\n' )
. replace ( /\s*([\]})])/g , '\n$1' )
. replace ( /;\s*/g , ';\n' )
. replace ( /,\s*([^\s])/g , ',\n$1' ) ;
// Indent based on brackets
let indent = 0 ;
return formattedCode
. split ( '\n' )
. map ( line = > {
line = line . trim ( ) ;
if ( line . match ( /[}\])]$/ ) ) indent -- ;
const formatted = ' ' . repeat ( Math . max ( 0 , indent ) ) + line ;
if ( line . match ( /[{([]\s*$/ ) ) indent ++ ;
return formatted ;
} )
. filter ( line = > line . trim ( ) )
. join ( '\n' ) ;
} catch ( e ) {
return formattedCode ;
}
case 'html' :
case 'xml' :
try {
// Basic indentation for HTML/XML
let indent = 0 ;
return formattedCode
. replace ( /></g , '>\n<' )
. split ( '\n' )
. map ( line = > {
line = line . trim ( ) ;
if ( line . match ( /<\/[^>]+>$/ ) ) indent -- ;
const formatted = ' ' . repeat ( Math . max ( 0 , indent ) ) + line ;
if ( line . match ( /<[^/][^>]*>$/ ) && ! line . match ( /<[^>]+\/>/ ) ) indent ++ ;
return formatted ;
} )
. filter ( line = > line . trim ( ) )
. join ( '\n' ) ;
} catch ( e ) {
return formattedCode ;
}
case 'css' :
try {
// Basic indentation for CSS
return formattedCode
. replace ( /\s*{\s*/g , ' {\n' )
. replace ( /;\s*/g , ';\n' )
. replace ( /\s*}\s*/g , '\n}\n' )
. split ( '\n' )
. map ( line = > line . trim ( ) )
. filter ( line = > line )
. map ( line = > line . startsWith ( '}' ) ? line : ' ' + line )
. join ( '\n' ) ;
} catch ( e ) {
return formattedCode ;
}
case 'python' :
case 'py' :
try {
// Basic indentation for Python
let indent = 0 ;
return formattedCode
. split ( '\n' )
. map ( line = > {
line = line . trim ( ) ;
if ( line . match ( /^(return|break|continue|pass|else|elif|except|finally)\b/ ) ) indent -- ;
const formatted = ' ' . repeat ( Math . max ( 0 , indent ) ) + line ;
if ( line . match ( /:\s*$/ ) ) indent ++ ;
return formatted ;
} )
. filter ( line = > line . trim ( ) )
. join ( '\n' ) ;
} catch ( e ) {
return formattedCode ;
}
case 'cpp' :
case 'c' :
case 'rust' :
try {
// Basic indentation for C/C++/Rust
let indent = 0 ;
return formattedCode
. split ( '\n' )
. map ( line = > {
line = line . trim ( ) ;
if ( line . match ( /^[}\])]/ ) || line . match ( /^(public|private|protected):/ ) ) indent -- ;
const formatted = ' ' . repeat ( Math . max ( 0 , indent ) ) + line ;
if ( line . match ( /[{[]$/ ) ) indent ++ ;
return formatted ;
} )
. filter ( line = > line . trim ( ) )
. join ( '\n' ) ;
} catch ( e ) {
return formattedCode ;
}
case 'php' :
try {
// Basic indentation for PHP
let indent = 0 ;
return formattedCode
. split ( '\n' )
. map ( line = > {
line = line . trim ( ) ;
if ( line . match ( /^[}\])]/ ) || line . match ( /^(case|default):/ ) ) indent -- ;
const formatted = ' ' . repeat ( Math . max ( 0 , indent ) ) + line ;
if ( line . match ( /[{[]$/ ) || line . match ( /^(case|default):/ ) ) indent ++ ;
return formatted ;
} )
. filter ( line = > line . trim ( ) )
. join ( '\n' ) ;
} catch ( e ) {
return formattedCode ;
}
case 'bash' :
case 'shell' :
case 'sh' :
try {
// Basic formatting for shell scripts
return formattedCode
. split ( '\n' )
. map ( line = > line . trim ( ) )
. filter ( line = > line )
. map ( line = > {
if ( line . startsWith ( '#' ) ) return line ;
if ( line . endsWith ( '\\' ) ) return line + '\n' ;
if ( line . match ( /^(if|while|for|case)/ ) ) return line ;
if ( line . match ( /^(then|do|else|elif)/ ) ) return ' ' + line ;
if ( line . match ( /^(fi|done|esac)/ ) ) return line ;
return ' ' + line ;
} )
. join ( '\n' ) ;
} catch ( e ) {
return formattedCode ;
}
default :
return formattedCode ;
}
}
/ * *
* Process nostr identifiers
* /
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 || ` ${ identifier . slice ( 0 , 8 ) } ... ${ identifier . slice ( - 4 ) } ` ;
const escapedId = identifier
. replace ( /&/g , '&' )
. replace ( /</g , '<' )
. replace ( />/g , '>' )
. replace ( /"/g , '"' )
. replace ( /'/g , ''' ) ;
const escapedDisplayText = displayText
. replace ( /&/g , '&' )
. replace ( /</g , '<' )
. replace ( />/g , '>' )
. replace ( /"/g , '"' )
. replace ( /'/g , ''' ) ;
// Create a link with standardized styling
const link = ` <a href="https://njump.me/ ${ escapedId } " target="_blank" class="hover:underline text-primary-600 dark:text-primary-500 items-center" title=" ${ escapedId } ">@ ${ escapedDisplayText } </a> ` ;
// 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 , '"' )
. replace ( /'/g , ''' ) ;
// Create a link with standardized styling
const link = ` <a href="https://njump.me/ ${ escapedId } " target="_blank" class="hover:underline text-primary-600 dark:text-primary-500 break-all items-center" title=" ${ escapedId } "> ${ shortId } </a> ` ;
// Replace only the exact match to preserve surrounding text
processedContent = processedContent . replace ( fullMatch , link ) ;
}
return processedContent ;
}
/ * *
* 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' ) ;
blocks . set ( id , JSON . stringify ( {
code ,
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 } ` ;
blocks . set ( id , JSON . stringify ( {
code : currentCode.join ( '\n' ) ,
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 ) {
const { code , language } = JSON . parse ( blockData ) ;
// Preserve code exactly as it was written
const html = ` <div class="w-full my-4">
< pre class = "bg-gray-100 dark:bg-gray-800 p-4 rounded-lg overflow-x-auto" > < code $ { language ? ` class = "language-${language}" ` : ' ' } > $ { code } < / code > < / pre >
< / div > ` ;
result = result . replace ( id , html ) ;
}
return result ;
}
// Now escape HTML in the remaining text
/ * *
processedText = escapeHtml ( processedText ) ;
* 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 , '"' )
. replace ( /'/g , ''' )
. replace ( /\\n/g , '\n' ) ;
// Restore code blocks
return ` <code class="bg-gray-100 dark:bg-gray-800 px-1 py-0.5 rounded"> ${ escapedCode } </code> ` ;
blocks . forEach ( ( { id , content } ) = > {
processedText = processedText . replace ( escapeHtml ( id ) , content ) ;
} ) ;
} ) ;
}
// Restore inline code
/ * *
inlineCodes . forEach ( ( { id , content } ) = > {
* Process other markdown elements ( excluding code )
processedText = processedText . replace ( escapeHtml ( id ) , content ) ;
* /
function processOtherElements ( content : string ) : string {
// Process blockquotes first
content = processBlockquotes ( content ) ;
// Process basic markdown elements
content = content . replace ( BOLD_REGEX , '<strong>$1$2</strong>' ) ;
content = content . replace ( ITALIC_REGEX , '<em>$1</em>' ) ;
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 ` <h ${ level } class=" ${ sizes [ level - 1 ] } font-bold mt-4 mb-2"> ${ content . trim ( ) } </h ${ level } > ` ;
} ) ;
} ) ;
return processedText ;
// Process links and images with standardized styling
content = content . replace ( IMAGE_REGEX , '<img src="$2" alt="$1" class="max-w-full h-auto rounded">' ) ;
content = content . replace ( LINK_REGEX , '<a href="$2" target="_blank" class="hover:underline text-primary-600 dark:text-primary-500">$1</a>' ) ;
// Process hashtags with standardized styling
content = content . replace ( HASHTAG_REGEX , '<span class="text-gray-500 dark:text-gray-400">#$1</span>' ) ;
// Process horizontal rules
content = content . replace ( HORIZONTAL_RULE_REGEX , '<hr class="my-6 border-t-2 border-gray-300 dark:border-gray-600">' ) ;
return content ;
}
}
/ * *
/ * *
@ -226,12 +597,12 @@ function processFootnotes(text: string): { text: string, footnotes: Map<string,
return '' ;
return '' ;
} ) ;
} ) ;
// Replace references
// Replace references with standardized styling
text = text . replace ( FOOTNOTE_REFERENCE_REGEX , ( match , id ) = > {
text = text . replace ( FOOTNOTE_REFERENCE_REGEX , ( match , id ) = > {
const cleanId = id . replace ( '^' , '' ) ;
const cleanId = id . replace ( '^' , '' ) ;
if ( footnotes . has ( cleanId ) ) {
if ( footnotes . has ( cleanId ) ) {
counter ++ ;
counter ++ ;
return ` <sup><a href="#footnote- ${ cleanId } " id="ref- ${ cleanId } " class="text-blue-600 hover:underline scroll-mt-32">[ ${ counter } ]</a></sup> ` ;
return ` <sup><a href="#footnote- ${ cleanId } " id="ref- ${ cleanId } " class="hover:underline text-primary-600 dark:text-primary-500 scroll-mt-32">[ ${ counter } ]</a></sup> ` ;
}
}
return match ;
return match ;
} ) ;
} ) ;
@ -244,7 +615,7 @@ function processFootnotes(text: string): { text: string, footnotes: Map<string,
for ( const [ id , content ] of footnotes . entries ( ) ) {
for ( const [ id , content ] of footnotes . entries ( ) ) {
counter ++ ;
counter ++ ;
text += ` <li id="footnote- ${ id } " class="text-sm text-gray-600 dark:text-gray-400 scroll-mt-32"> ${ content } <a href="#ref- ${ id } " class="text-blue-600 hover:underline ml-1 scroll-mt-32">↩</a></li> ` ;
text += ` <li id="footnote- ${ id } " class="text-sm text-gray-600 dark:text-gray-400 scroll-mt-32"> ${ content } <a href="#ref- ${ id } " class="hover:underline text-primary-600 dark:text-primary-500 ml-1 scroll-mt-32">↩</a></li> ` ;
}
}
text += '</ol></div>' ;
text += '</ol></div>' ;
@ -254,82 +625,45 @@ function processFootnotes(text: string): { text: string, footnotes: Map<string,
}
}
/ * *
/ * *
* Parse markdown text to HTML with special handling for nostr identifiers
* Parse markdown text to content with special handling for nostr identifiers
* /
* /
export async function parseMarkdown ( text : string ) : Promise < string > {
export async function parseMarkdown ( text : string ) : Promise < string > {
if ( ! text ) return '' ;
if ( ! text ) return '' ;
// First, process code blocks (protect these from HTML escaping)
// First extract and save code blocks
let html = processCode ( text ) ; // still escape HTML *inside* code blocks
const { text : withoutCode , blocks } = processCodeBlocks ( text ) ;
// 👉 NEW: process blockquotes *before* the rest of HTML is escaped
html = processBlockquotes ( html ) ;
// Process nostr identifiers
// Process nostr identifiers
const npubMatches = Array . from ( html . matchAll ( NOSTR_NPUB_REGEX ) ) ;
let content = await processNostrIdentifiers ( withoutCode ) ;
const npubPromises = npubMatches . map ( async match = > {
const [ fullMatch , npub ] = match ;
const metadata = await getUserMetadata ( npub ) ;
const displayText = metadata . displayName || metadata . name || ` ${ npub . slice ( 0 , 8 ) } ... ${ npub . slice ( - 4 ) } ` ;
return { fullMatch , npub , displayText } ;
} ) ;
const npubResults = await Promise . all ( npubPromises ) ;
// Process blockquotes
for ( const { fullMatch , npub , displayText } of npubResults ) {
content = processBlockquotes ( content ) ;
html = html . replace (
fullMatch ,
` <a href="https://njump.me/ ${ npub } " target="_blank" class="text-blue-600 hover:underline" title=" ${ npub } ">@ ${ displayText } </a> `
) ;
}
// Process lists
// Process lists
html = processLists ( html ) ;
content = processLists ( content ) ;
// Process footnotes
// Process other markdown elements
const { text : processedHtml } = processFootnotes ( html ) ;
content = processOtherElements ( content ) ;
html = processedHtml ;
// Process basic markdown elements
html = html . replace ( BOLD_REGEX , '<strong>$1$2</strong>' ) ;
html = html . replace ( ITALIC_REGEX , '<em>$1</em>' ) ;
html = html . 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 ` <h ${ level } class=" ${ sizes [ level - 1 ] } font-bold mt-4 mb-2"> ${ content . trim ( ) } </h ${ level } > ` ;
} ) ;
// Process links and images
html = html . replace ( IMAGE_REGEX , '<img src="$2" alt="$1" class="max-w-full h-auto rounded">' ) ;
html = html . replace ( LINK_REGEX , '<a href="$2" target="_blank" class="text-blue-600 hover:underline">$1</a>' ) ;
// Process hashtags
// Process inline code (after other elements to prevent conflicts)
html = html . replace ( HASHTAG_REGEX , '<span class="text-gray-500 dark:text-gray-400">#$1</span>' ) ;
content = processInlineCode ( content ) ;
// Process horizontal rules
html = html . replace ( HORIZONTAL_RULE_REGEX , '<hr class="my-6 border-t-2 border-gray-300 dark:border-gray-600">' ) ;
// Handle paragraphs and line break s
// Process footnotes
html = html . replace ( /\n{2,}/g , '</p><p class="my-4">' ) ;
const { text : processedContent } = processFootnotes ( content ) ;
html = html . replace ( /\n/g , '<br>' ) ;
content = processedContent ;
// Wrap content in paragraph if needed
// Handle paragraphs and line breaks, preserving existing HTML
if ( ! html . startsWith ( '<' ) ) {
content = content
html = ` <p class="my-4"> ${ html } </p> ` ;
. split ( /\n{2,}/ )
}
. map ( para = > para . trim ( ) )
. filter ( para = > para )
. map ( para = > para . startsWith ( '<' ) ? para : ` <p class="my-4 break-words"> ${ para } </p> ` )
. join ( '\n\n' ) ;
return html ;
// Finally, restore code blocks
}
content = restoreCodeBlocks ( content , blocks ) ;
/ * *
return content ;
* Escape HTML special characters to prevent XSS
* /
function escapeHtml ( text : string ) : string {
return text
. replace ( /&/g , '&' )
. replace ( /</g , '<' )
. replace ( />/g , '>' )
. replace ( /"/g , '"' )
. replace ( /'/g , ''' ) ;
}
}
/ * *
/ * *