/** * Markdown Detection and Conversion Utilities * * Handles detection of markdown vs HTML content and conversion between formats */ /** * Detect if content is markdown or HTML * * @param content - The content to check * @returns 'markdown' | 'html' */ export function detectContentType(content: string): 'markdown' | 'html' { if (!content || content.trim() === '') { return 'html'; } // Check for markdown-specific patterns const markdownPatterns = [ /^\*\*[^*]+\*\*/m, // **bold** /^__[^_]+__/m, // __bold__ /^\*[^*]+\*/m, // *italic* /^_[^_]+_/m, // _italic_ /^#{1,6}\s/m, // # headings /^\[card[^\]]*\]/m, // [card] syntax /^\[button\s+url=/m, // [button url=...] syntax /^---$/m, // horizontal rules /^[\*\-•✓]\s/m, // bullet points ]; // Check for HTML-specific patterns const htmlPatterns = [ /<[a-z][\s\S]*>/i, // HTML tags /<\/[a-z]+>/i, // Closing tags /&[a-z]+;/i, // HTML entities ]; // Count markdown vs HTML indicators let markdownScore = 0; let htmlScore = 0; for (const pattern of markdownPatterns) { if (pattern.test(content)) { markdownScore++; } } for (const pattern of htmlPatterns) { if (pattern.test(content)) { htmlScore++; } } // If content has [card] or [button] syntax, it's definitely our markdown format if (/\[card[^\]]*\]/.test(content) || /\[button\s+url=/.test(content)) { return 'markdown'; } // If content has HTML tags but no markdown, it's HTML if (htmlScore > 0 && markdownScore === 0) { return 'html'; } // If content has markdown indicators, it's markdown if (markdownScore > 0) { return 'markdown'; } // Default to HTML for safety return 'html'; } /** * Convert markdown to HTML for display * * @param markdown - Markdown content * @returns HTML content */ export function markdownToHtml(markdown: string): string { if (!markdown) return ''; let html = markdown; // Parse [card:type] blocks (new syntax) html = html.replace(/\[card:(\w+)\]([\s\S]*?)\[\/card\]/g, (match, type, content) => { const cardClass = `card card-${type}`; const parsedContent = parseMarkdownBasics(content.trim()); return `
${parsedContent}
`; }); // Parse [card type="..."] blocks (old syntax - backward compatibility) html = html.replace(/\[card(?:\s+type="([^"]+)")?\]([\s\S]*?)\[\/card\]/g, (match, type, content) => { const cardClass = type ? `card card-${type}` : 'card'; const parsedContent = parseMarkdownBasics(content.trim()); return `
${parsedContent}
`; }); // Parse [button:style](url)Text[/button] (new syntax) html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => { const buttonClass = style === 'outline' ? 'button-outline' : 'button'; return `

${text.trim()}

`; }); // Parse [button url="..."] shortcodes (old syntax - backward compatibility) html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => { const buttonClass = style === 'outline' ? 'button-outline' : 'button'; return `

${text.trim()}

`; }); // Parse remaining markdown html = parseMarkdownBasics(html); return html; } /** * Parse basic markdown syntax to HTML (exported for use in components) * * @param text - Markdown text * @returns HTML text */ export function parseMarkdownBasics(text: string): string { let html = text; // Protect variables from markdown parsing by temporarily replacing them const variables: { [key: string]: string } = {}; let varIndex = 0; html = html.replace(/\{([^}]+)\}/g, (match, varName) => { const placeholder = ``; variables[placeholder] = match; varIndex++; return placeholder; }); // Headings html = html.replace(/^#### (.*$)/gim, '

$1

'); html = html.replace(/^### (.*$)/gim, '

$1

'); html = html.replace(/^## (.*$)/gim, '

$1

'); html = html.replace(/^# (.*$)/gim, '

$1

'); // Bold (don't match across newlines) html = html.replace(/\*\*([^\n*]+?)\*\*/g, '$1'); html = html.replace(/__([^\n_]+?)__/g, '$1'); // Italic (don't match across newlines) html = html.replace(/\*([^\n*]+?)\*/g, '$1'); html = html.replace(/_([^\n_]+?)_/g, '$1'); // Horizontal rules html = html.replace(/^---$/gm, '
'); // Parse [button:style](url)Text[/button] (new syntax) - must come before images // Allow whitespace and newlines between parts html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([\s\S]*?)\[\/button\]/g, (match, style, url, text) => { const buttonClass = style === 'outline' ? 'button-outline' : 'button'; return `

${text.trim()}

`; }); // Parse [button url="..."] shortcodes (old syntax - backward compatibility) html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => { const buttonClass = style === 'outline' ? 'button-outline' : 'button'; return `

${text.trim()}

`; }); // Images (must come before links) html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '$1'); // Links (but not button syntax) html = html.replace(/\[(?!button)([^\]]+)\]\(([^)]+)\)/g, '$1'); // Process lines for paragraphs and lists const lines = html.split('\n'); let inList = false; let paragraphContent = ''; const processedLines: string[] = []; const closeParagraph = () => { if (paragraphContent) { processedLines.push(`

${paragraphContent}

`); paragraphContent = ''; } }; for (let i = 0; i < lines.length; i++) { const line = lines[i]; const trimmed = line.trim(); // Empty line - close paragraph or list if (!trimmed) { if (inList) { processedLines.push(''); inList = false; } closeParagraph(); processedLines.push(''); continue; } // Check if line is a list item if (/^[\*\-•✓]\s/.test(trimmed)) { closeParagraph(); const content = trimmed.replace(/^[\*\-•✓]\s/, ''); if (!inList) { processedLines.push(''); inList = false; } // Block-level HTML tags - don't wrap in paragraph // But inline tags like , , should be part of paragraph const blockTags = /^<(div|h1|h2|h3|h4|h5|h6|p|ul|ol|li|hr|table|blockquote)/i; if (blockTags.test(trimmed)) { closeParagraph(); processedLines.push(line); continue; } // Regular text line - accumulate in paragraph if (paragraphContent) { // Add line break before continuation paragraphContent += '
' + trimmed; } else { // Start new paragraph paragraphContent = trimmed; } } // Close any open tags if (inList) { processedLines.push(''); } closeParagraph(); html = processedLines.join('\n'); // Restore variables Object.entries(variables).forEach(([placeholder, original]) => { html = html.replace(new RegExp(placeholder, 'g'), original); }); return html; } /** * Convert HTML back to markdown (for editing) * * @param html - HTML content * @returns Markdown content */ export function htmlToMarkdown(html: string): string { if (!html) return ''; let markdown = html; // Convert
back to [card] markdown = markdown.replace(/
([\s\S]*?)<\/div>/g, (match, type, content) => { const mdContent = parseHtmlToMarkdownBasics(content.trim()); return type ? `[card type="${type}"]\n${mdContent}\n[/card]` : `[card]\n${mdContent}\n[/card]`; }); // Convert buttons back to [button] syntax markdown = markdown.replace(/]*>]*>([^<]+)<\/a><\/p>/g, (match, url, className, text) => { const style = className.includes('outline') ? ' style="outline"' : ''; return `[button url="${url}"${style}]${text.trim()}[/button]`; }); // Convert remaining HTML to markdown markdown = parseHtmlToMarkdownBasics(markdown); return markdown; } /** * Parse HTML back to basic markdown * * @param html - HTML text * @returns Markdown text */ function parseHtmlToMarkdownBasics(html: string): string { let markdown = html; // Headings markdown = markdown.replace(/

(.*?)<\/h1>/gi, '# $1'); markdown = markdown.replace(/

(.*?)<\/h2>/gi, '## $1'); markdown = markdown.replace(/

(.*?)<\/h3>/gi, '### $1'); markdown = markdown.replace(/

(.*?)<\/h4>/gi, '#### $1'); // Bold markdown = markdown.replace(/(.*?)<\/strong>/gi, '**$1**'); markdown = markdown.replace(/(.*?)<\/b>/gi, '**$1**'); // Italic markdown = markdown.replace(/(.*?)<\/em>/gi, '*$1*'); markdown = markdown.replace(/(.*?)<\/i>/gi, '*$1*'); // Links markdown = markdown.replace(/]*>(.*?)<\/a>/gi, '[$2]($1)'); // Horizontal rules markdown = markdown.replace(//gi, '\n---\n'); // Lists markdown = markdown.replace(/
    ([\s\S]*?)<\/ul>/gi, (match, content) => { return content.replace(/
  • (.*?)<\/li>/gi, '- $1\n'); }); // Paragraphs markdown = markdown.replace(/

    (.*?)<\/p>/gi, '$1\n\n'); // Clean up extra newlines markdown = markdown.replace(/\n{3,}/g, '\n\n'); return markdown.trim(); }