/** * 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 `
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => { if (style === 'link') { return `${text.trim()}`; } 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) => { if (style === 'link') { return `${text.trim()}`; } 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, '
${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(']*>]*>([^<]+)<\/a><\/p>/g, (match, url, className, text) => {
const style = className.includes('outline') ? ' style="outline"' : ' style="solid"';
return `[button url="${url}"${style}]${text.trim()}[/button]`;
});
// Direct button links without p wrapper
markdown = markdown.replace(/]*>([^<]+)<\/a>/g, (match, url, className, text) => {
const style = className.includes('outline') ? ' style="outline"' : ' style="solid"';
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(/ (.*?)<\/p>/gi, '$1\n\n');
// Clean up extra newlines
markdown = markdown.replace(/\n{3,}/g, '\n\n');
return markdown.trim();
}
(.*?)<\/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(/