import { EmailBlock, CardBlock, ButtonBlock, ImageBlock, SpacerBlock, CardType, ButtonStyle, ContentWidth, ContentAlign } from './types'; /** * Convert HTML tags to markdown */ function convertHtmlToMarkdown(html: string): string { let markdown = html; // Headings markdown = markdown.replace(/]*>(.*?)<\/h1>/gi, '# $1\n\n'); markdown = markdown.replace(/]*>(.*?)<\/h2>/gi, '## $1\n\n'); markdown = markdown.replace(/]*>(.*?)<\/h3>/gi, '### $1\n\n'); markdown = markdown.replace(/]*>(.*?)<\/h4>/gi, '#### $1\n\n'); // 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(/]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, '[$2]($1)'); // Paragraphs markdown = markdown.replace(/]*>(.*?)<\/p>/gi, '$1\n\n'); // Line breaks markdown = markdown.replace(//gi, '\n'); // Lists markdown = markdown.replace(/]*>([\s\S]*?)<\/ul>/gi, (match, content) => { return content.replace(/]*>(.*?)<\/li>/gi, '- $1\n'); }); markdown = markdown.replace(/]*>([\s\S]*?)<\/ol>/gi, (match, content) => { let counter = 1; return content.replace(/]*>(.*?)<\/li>/gi, () => `${counter++}. $1\n`); }); // Clean up extra newlines markdown = markdown.replace(/\n{3,}/g, '\n\n'); return markdown.trim(); } /** * Convert blocks directly to clean markdown (no HTML pollution) */ export function blocksToMarkdown(blocks: EmailBlock[]): string { return blocks.map(block => { switch (block.type) { case 'card': { const cardBlock = block as CardBlock; // Use new [card:type] syntax const cardSyntax = cardBlock.cardType !== 'default' ? `[card:${cardBlock.cardType}]` : '[card]'; return `${cardSyntax}\n\n${cardBlock.content}\n\n[/card]`; } case 'button': { const buttonBlock = block as ButtonBlock; // Use new [button:style](url)Text[/button] syntax const style = buttonBlock.style || 'solid'; return `[button:${style}](${buttonBlock.link})${buttonBlock.text}[/button]`; } case 'image': { const imageBlock = block as ImageBlock; return `[image src="${imageBlock.src}" alt="${imageBlock.alt || ''}" width="${imageBlock.widthMode}" align="${imageBlock.align}"]`; } case 'divider': return '---'; case 'spacer': { const spacerBlock = block as SpacerBlock; return `[spacer height="${spacerBlock.height}"]`; } default: return ''; } }).join('\n\n'); } /** * Convert blocks to [card] syntax HTML */ export function blocksToHTML(blocks: EmailBlock[]): string { return blocks.map(block => { switch (block.type) { case 'card': if (block.cardType === 'default') { return `[card]\n${block.content}\n[/card]`; } return `[card type="${block.cardType}"]\n${block.content}\n[/card]`; case 'button': { const buttonClass = block.style === 'solid' ? 'button' : 'button-outline'; const align = block.align || 'center'; let linkStyle = ''; if (block.widthMode === 'full') { linkStyle = 'display:block;width:100%;text-align:center;'; } else if (block.widthMode === 'custom' && block.customMaxWidth) { linkStyle = `display:block;max-width:${block.customMaxWidth}px;width:100%;margin:0 auto;`; } const styleAttr = linkStyle ? ` style="${linkStyle}"` : ''; return `

${block.text}

`; } case 'image': { let wrapperStyle = `text-align: ${block.align};`; let imgStyle = ''; if (block.widthMode === 'full') { imgStyle = 'display:block;width:100%;height:auto;'; } else if (block.widthMode === 'custom' && block.customMaxWidth) { imgStyle = `display:block;max-width:${block.customMaxWidth}px;width:100%;height:auto;margin:0 auto;`; } return `

${block.alt || ''}

`; } case 'divider': return `
`; case 'spacer': return `
`; default: return ''; } }).join('\n\n'); } /** * Convert [card] syntax HTML or
HTML to blocks */ export function htmlToBlocks(html: string): EmailBlock[] { const blocks: EmailBlock[] = []; let blockId = 0; // Match both [card] syntax and
HTML const cardRegex = /(?:\[card([^\]]*)\]([\s\S]*?)\[\/card\]|
]*>([\s\S]*?)<\/div>)/gs; const parts: string[] = []; let lastIndex = 0; let match; while ((match = cardRegex.exec(html)) !== null) { // Add content before card if (match.index > lastIndex) { const beforeContent = html.substring(lastIndex, match.index).trim(); if (beforeContent) parts.push(beforeContent); } // Add card parts.push(match[0]); lastIndex = match.index + match[0].length; } // Add remaining content if (lastIndex < html.length) { const remaining = html.substring(lastIndex).trim(); if (remaining) parts.push(remaining); } // Process each part for (const part of parts) { const id = `block-${Date.now()}-${blockId++}`; // Check if it's a card - match [card:type], [card type="..."], and
let content = ''; let cardType = 'default'; // Try new [card:type] syntax first let cardMatch = part.match(/\[card:(\w+)\]([\s\S]*?)\[\/card\]/s); if (cardMatch) { cardType = cardMatch[1]; content = cardMatch[2].trim(); } else { // Try old [card type="..."] syntax cardMatch = part.match(/\[card([^\]]*)\]([\s\S]*?)\[\/card\]/s); if (cardMatch) { const attributes = cardMatch[1]; content = cardMatch[2].trim(); const typeMatch = attributes.match(/type=["']([^"']+)["']/); cardType = (typeMatch ? typeMatch[1] : 'default'); } } if (!cardMatch) { //
HTML syntax const htmlCardMatch = part.match(/
]*>([\s\S]*?)<\/div>/s); if (htmlCardMatch) { cardType = (htmlCardMatch[1] || 'default'); content = htmlCardMatch[2].trim(); } } if (content) { // Convert HTML content to markdown for clean editing // But only if it actually contains HTML tags const hasHtmlTags = /<[^>]+>/.test(content); const markdownContent = hasHtmlTags ? convertHtmlToMarkdown(content) : content; blocks.push({ id, type: 'card', cardType: cardType as any, content: markdownContent }); continue; } // Check if it's a button - try new syntax first let buttonMatch = part.match(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/); if (buttonMatch) { const style = buttonMatch[1] as ButtonStyle; const url = buttonMatch[2]; const text = buttonMatch[3].trim(); blocks.push({ id, type: 'button', link: url, text: text, style: style, align: 'center', widthMode: 'fit' }); continue; } // Try old [button url="..."] syntax buttonMatch = part.match(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](\w+)["'])?\]([^\[]+)\[\/button\]/); if (buttonMatch) { const url = buttonMatch[1]; const style = (buttonMatch[2] || 'solid') as ButtonStyle; const text = buttonMatch[3].trim(); blocks.push({ id, type: 'button', link: url, text: text, style: style, align: 'center', widthMode: 'fit' }); continue; } // Check HTML button syntax if (part.includes('class="button"') || part.includes('class="button-outline"')) { const buttonMatch = part.match(/]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*style="([^"]*)"[^>]*>([^<]*)<\/a>/) || part.match(/]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*>([^<]*)<\/a>/); if (buttonMatch) { const hasStyle = buttonMatch.length === 5; const styleAttr = hasStyle ? buttonMatch[3] : ''; const textIndex = hasStyle ? 4 : 3; const styleClassIndex = 2; let widthMode: any = 'fit'; let customMaxWidth: number | undefined = undefined; if (styleAttr.includes('width:100%') && !styleAttr.includes('max-width')) { widthMode = 'full'; } else if (styleAttr.includes('max-width')) { widthMode = 'custom'; const maxMatch = styleAttr.match(/max-width:(\d+)px/); if (maxMatch) { customMaxWidth = parseInt(maxMatch[1], 10); } } // Extract alignment from parent

tag if present const alignMatch = part.match(/text-align:\s*(left|center|right)/); const align = alignMatch ? alignMatch[1] as any : 'center'; blocks.push({ id, type: 'button', text: buttonMatch[textIndex], link: buttonMatch[1], style: buttonMatch[styleClassIndex].includes('outline') ? 'outline' : 'solid', widthMode, customMaxWidth, align, }); continue; } } // Check if it's a divider if (part.includes(' 0) { remaining = remaining.trim(); if (!remaining) break; const id = `block-${Date.now()}-${blockId++}`; // Check for [card] blocks - NEW syntax [card:type]...[/card] const newCardMatch = remaining.match(/^\[card:(\w+)\]([\s\S]*?)\[\/card\]/); if (newCardMatch) { const cardType = newCardMatch[1] as CardType; const content = newCardMatch[2].trim(); blocks.push({ id, type: 'card', cardType, content, }); remaining = remaining.substring(newCardMatch[0].length); continue; } // Check for [card] blocks - OLD syntax [card type="..."]...[/card] const cardMatch = remaining.match(/^\[card([^\]]*)\]([\s\S]*?)\[\/card\]/); if (cardMatch) { const attributes = cardMatch[1].trim(); const content = cardMatch[2].trim(); // Extract card type const typeMatch = attributes.match(/type\s*=\s*["']([^"']+)["']/); const cardType = (typeMatch?.[1] || 'default') as CardType; // Extract background const bgMatch = attributes.match(/bg=["']([^"']+)["']/); const bg = bgMatch?.[1]; blocks.push({ id, type: 'card', cardType, content, bg, }); // Advance past this card remaining = remaining.substring(cardMatch[0].length); continue; } // Check for [button] blocks - NEW syntax [button:style](url)Text[/button] const newButtonMatch = remaining.match(/^\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/); if (newButtonMatch) { blocks.push({ id, type: 'button', text: newButtonMatch[3].trim(), link: newButtonMatch[2], style: newButtonMatch[1] as ButtonStyle, align: 'center', widthMode: 'fit', }); remaining = remaining.substring(newButtonMatch[0].length); continue; } // Check for [button] blocks - OLD syntax [button url="..." style="..."]Text[/button] const buttonMatch = remaining.match(/^\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/); if (buttonMatch) { blocks.push({ id, type: 'button', text: buttonMatch[3].trim(), link: buttonMatch[1], style: (buttonMatch[2] || 'solid') as ButtonStyle, align: 'center', widthMode: 'fit', }); remaining = remaining.substring(buttonMatch[0].length); continue; } // Check for [image] blocks const imageMatch = remaining.match(/^\[image\s+src=["']([^"']+)["'](?:\s+alt=["']([^"']*)["'])?(?:\s+width=["']([^"']+)["'])?(?:\s+align=["']([^"']+)["'])?\]/); if (imageMatch) { blocks.push({ id, type: 'image', src: imageMatch[1], alt: imageMatch[2] || '', widthMode: (imageMatch[3] || 'fit') as ContentWidth, align: (imageMatch[4] || 'center') as ContentAlign, }); remaining = remaining.substring(imageMatch[0].length); continue; } // Check for [spacer] blocks const spacerMatch = remaining.match(/^\[spacer\s+height=["'](\d+)["']\]/); if (spacerMatch) { blocks.push({ id, type: 'spacer', height: parseInt(spacerMatch[1]), }); remaining = remaining.substring(spacerMatch[0].length); continue; } // Check for horizontal rule if (remaining.startsWith('---')) { blocks.push({ id, type: 'divider', }); remaining = remaining.substring(3); continue; } // If nothing matches, skip this character to avoid infinite loop remaining = remaining.substring(1); } return blocks; }