/**
* Markdown parser with special handling for nostr identifiers
*/
import { get } from 'svelte/store';
import { ndkInstance } from '$lib/ndk';
import { nip19 } from 'nostr-tools';
import hljs from 'highlight.js';
import 'highlight.js/styles/github-dark.css';
// Regular expressions for nostr identifiers - process these first
const NOSTR_PROFILE_REGEX = /(?:nostr:)?((?:npub|nprofile)[a-zA-Z0-9]{20,})/g;
const NOSTR_NOTE_REGEX = /(?:nostr:)?((?:nevent|note|naddr)[a-zA-Z0-9]{20,})/g;
// Regular expressions for markdown elements
const BOLD_REGEX = /\*\*([^*]+)\*\*|\*([^*]+)\*/g;
const ITALIC_REGEX = /_([^_]+)_/g;
const HEADING_REGEX = /^(#{1,6})\s+(.+)$/gm;
const ALTERNATE_HEADING_REGEX = /^(.+)\n([=]{3,}|-{3,})$/gm;
const HORIZONTAL_RULE_REGEX = /^(?:---|\*\*\*|___)$/gm;
const INLINE_CODE_REGEX = /`([^`\n]+)`/g;
const LINK_REGEX = /\[([^\]]+)\]\(([^)]+)\)/g;
const IMAGE_REGEX = /!\[([^\]]*)\]\(([^)]+)\)/g;
const HASHTAG_REGEX = /(?();
/**
* Get user metadata for a nostr identifier (npub or nprofile)
*/
async function getUserMetadata(identifier: string): Promise<{name?: string, displayName?: string}> {
if (npubCache.has(identifier)) {
return npubCache.get(identifier)!;
}
const fallback = { name: `${identifier.slice(0, 8)}...${identifier.slice(-4)}` };
try {
const ndk = get(ndkInstance);
if (!ndk) {
npubCache.set(identifier, fallback);
return fallback;
}
const decoded = nip19.decode(identifier);
if (!decoded) {
npubCache.set(identifier, fallback);
return fallback;
}
// Handle different identifier types
let pubkey: string;
if (decoded.type === 'npub') {
pubkey = decoded.data;
} else if (decoded.type === 'nprofile') {
pubkey = decoded.data.pubkey;
} else {
npubCache.set(identifier, fallback);
return fallback;
}
const user = ndk.getUser({ pubkey: pubkey });
if (!user) {
npubCache.set(identifier, fallback);
return fallback;
}
try {
const profile = await user.fetchProfile();
if (!profile) {
npubCache.set(identifier, fallback);
return fallback;
}
const metadata = {
name: profile.name || fallback.name,
displayName: profile.displayName
};
npubCache.set(identifier, metadata);
return metadata;
} catch (e) {
npubCache.set(identifier, fallback);
return fallback;
}
} catch (e) {
npubCache.set(identifier, fallback);
return fallback;
}
}
/**
* Process lists (ordered and unordered)
*/
function processLists(content: string): string {
const lines = content.split('\n');
let inList = false;
let isOrdered = false;
let currentList: string[] = [];
const processed: string[] = [];
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const orderedMatch = line.match(/^(\d+)\.[ \t]+(.+)$/);
const unorderedMatch = line.match(/^\*[ \t]+(.+)$/);
if (orderedMatch || unorderedMatch) {
if (!inList) {
inList = true;
isOrdered = !!orderedMatch;
currentList = [];
}
const content = orderedMatch ? orderedMatch[2] : unorderedMatch![1];
currentList.push(content);
} else {
if (inList) {
const listType = isOrdered ? 'ol' : 'ul';
const listClass = isOrdered ? 'list-decimal' : 'list-disc';
processed.push(`<${listType} class="${listClass} pl-6 my-4 space-y-1">`);
currentList.forEach(item => {
processed.push(`
${item}`);
});
processed.push(`${listType}>`);
inList = false;
currentList = [];
}
processed.push(line);
}
}
if (inList) {
const listType = isOrdered ? 'ol' : 'ul';
const listClass = isOrdered ? 'list-decimal' : 'list-disc';
processed.push(`<${listType} class="${listClass} pl-6 my-4 space-y-1">`);
currentList.forEach(item => {
processed.push(` ${item}`);
});
processed.push(`${listType}>`);
}
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: ``
});
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: ``
});
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: ``
});
processedLines.push(id);
}
let result = processedLines.join('\n');
// Restore blockquotes
blockquotes.forEach(({id, content}) => {
result = result.replace(id, content);
});
return result;
}
/**
* Process code blocks by finding consecutive code lines and preserving their content
*/
function processCodeBlocks(text: string): { text: string; blocks: Map } {
const lines = text.split('\n');
const processedLines: string[] = [];
const blocks = new Map();
let inCodeBlock = false;
let currentCode: string[] = [];
let currentLanguage = '';
let blockCount = 0;
for (let i = 0; i < lines.length; i++) {
const line = lines[i];
const codeBlockStart = line.match(/^```(\w*)$/);
if (codeBlockStart) {
if (!inCodeBlock) {
// Starting a new code block
inCodeBlock = true;
currentLanguage = codeBlockStart[1];
currentCode = [];
} else {
// Ending current code block
blockCount++;
const id = `CODE-BLOCK-${blockCount}`;
const code = currentCode.join('\n');
// Store the raw code and language for later processing
blocks.set(id, JSON.stringify({
code,
language: currentLanguage
}));
processedLines.push(id);
inCodeBlock = false;
currentLanguage = '';
currentCode = [];
}
} else if (inCodeBlock) {
currentCode.push(line);
} else {
processedLines.push(line);
}
}
// Handle unclosed code block
if (inCodeBlock && currentCode.length > 0) {
blockCount++;
const id = `CODE-BLOCK-${blockCount}`;
const code = currentCode.join('\n');
blocks.set(id, JSON.stringify({
code,
language: currentLanguage
}));
processedLines.push(id);
}
return {
text: processedLines.join('\n'),
blocks
};
}
/**
* Restore code blocks with proper formatting
*/
function restoreCodeBlocks(text: string, blocks: Map): string {
let result = text;
blocks.forEach((blockContent, id) => {
const { code, language } = JSON.parse(blockContent);
let processedCode = code;
// First escape HTML characters
processedCode = processedCode
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
// Format and highlight based on language
if (language === 'json') {
try {
// Parse and format JSON
const parsed = JSON.parse(code);
processedCode = JSON.stringify(parsed, null, 2)
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
// Apply JSON syntax highlighting
processedCode = processedCode
// Match JSON keys (including colons)
.replace(/("[^"]+"):/g, '$1:')
// Match string values (after colons and in arrays)
.replace(/: ("[^"]+")/g, ': $1')
.replace(/\[("[^"]+")/g, '[$1')
.replace(/, ("[^"]+")/g, ', $1')
// Match numbers
.replace(/: (-?\d+\.?\d*)/g, ': $1')
.replace(/\[(-?\d+\.?\d*)/g, '[$1')
.replace(/, (-?\d+\.?\d*)/g, ', $1')
// Match booleans
.replace(/: (true|false)\b/g, ': $1')
// Match null
.replace(/: (null)\b/g, ': $1');
} catch (e) {
// If JSON parsing fails, use the original escaped code
console.warn('Failed to parse JSON:', e);
}
} else if (language) {
// Use highlight.js for other languages
try {
if (hljs.getLanguage(language)) {
const highlighted = hljs.highlight(processedCode, { language });
processedCode = highlighted.value;
}
} catch (e) {
console.warn('Failed to apply syntax highlighting:', e);
}
}
const languageClass = language ? ` language-${language}` : '';
const replacement = `${processedCode}
`;
result = result.replace(id, replacement);
});
return result;
}
/**
* Process inline code
*/
function processInlineCode(text: string): string {
return text.replace(INLINE_CODE_REGEX, (match, code) => {
const escapedCode = code
.replace(/&/g, '&')
.replace(//g, '>')
.replace(/"/g, '"')
.replace(/'/g, ''');
return `${escapedCode}`;
});
}
/**
* Process markdown tables
*/
function processTables(content: string): string {
return content.replace(TABLE_REGEX, (match, headerRow, delimiterRow, bodyRows) => {
// Process header row
const headers: string[] = headerRow
.split('|')
.map((cell: string) => cell.trim())
.filter((cell: string) => cell.length > 0);
// Validate delimiter row (should contain only dashes and spaces)
const delimiters: string[] = delimiterRow
.split('|')
.map((cell: string) => cell.trim())
.filter((cell: string) => cell.length > 0);
if (!delimiters.every(d => TABLE_DELIMITER_REGEX.test(d))) {
return match;
}
// Process body rows
const rows: string[][] = bodyRows
.trim()
.split('\n')
.map((row: string) => {
return row
.split('|')
.map((cell: string) => cell.trim())
.filter((cell: string) => cell.length > 0);
})
.filter((row: string[]) => row.length > 0);
// Generate HTML table with leather theme styling and thicker grid lines
let table = '\n';
table += '
\n';
// Add header with leather theme styling
table += '\n\n';
headers.forEach((header: string) => {
table += `| ${header} | \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 += `| ${cell} | \n`;
});
table += '
\n';
});
table += '\n
\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, '
');
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';
}
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;
}