From 5a831ddf9d28eaf58a45518a3a10b48e9ff05d8e Mon Sep 17 00:00:00 2001 From: Dwindi Ramadhana Date: Thu, 1 Jan 2026 21:57:58 +0700 Subject: [PATCH] fix: button/card syntax mismatch between blocksToMarkdown and markdownToBlocks ROOT CAUSE: Complete flow trace revealed syntax mismatch: - blocksToMarkdown outputs NEW syntax: [card:type], [button:style](url)Text[/button] - markdownToBlocks ONLY parsed OLD syntax: [card type="..."], [button url="..."] This caused buttons/cards to be lost when: 1. User adds button in Visual mode 2. blocksToMarkdown converts to [button:solid]({url})Text[/button] 3. handleBlocksChange stores this in markdownContent 4. When switching tabs/previewing, markdownToBlocks runs 5. It FAILED to parse new syntax, buttons disappear! FIX: Added handlers for NEW syntax in markdownToBlocks (converter.ts): - [card:type]...[/card] pattern (before old syntax) - [button:style](url)Text[/button] pattern (before old syntax) Now both syntaxes work correctly in round-trip conversion. --- .../src/components/EmailBuilder/converter.ts | 126 +++++++++++------- 1 file changed, 80 insertions(+), 46 deletions(-) diff --git a/admin-spa/src/components/EmailBuilder/converter.ts b/admin-spa/src/components/EmailBuilder/converter.ts index f0a4b72..ad1ad0e 100644 --- a/admin-spa/src/components/EmailBuilder/converter.ts +++ b/admin-spa/src/components/EmailBuilder/converter.ts @@ -56,27 +56,27 @@ export function blocksToMarkdown(blocks: EmailBlock[]): string { 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 ''; } @@ -94,7 +94,7 @@ export function blocksToHTML(blocks: EmailBlock[]): string { 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'; @@ -118,13 +118,13 @@ export function blocksToHTML(blocks: EmailBlock[]): string { } return `

${block.alt || ''}

`; } - + case 'divider': return `
`; - + case 'spacer': return `
`; - + default: return ''; } @@ -137,39 +137,39 @@ export function blocksToHTML(blocks: EmailBlock[]): string { 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) { @@ -185,7 +185,7 @@ export function htmlToBlocks(html: string): EmailBlock[] { cardType = (typeMatch ? typeMatch[1] : 'default'); } } - + if (!cardMatch) { //
HTML syntax const htmlCardMatch = part.match(/
]*>([\s\S]*?)<\/div>/s); @@ -194,7 +194,7 @@ export function htmlToBlocks(html: string): EmailBlock[] { content = htmlCardMatch[2].trim(); } } - + if (content) { // Convert HTML content to markdown for clean editing // But only if it actually contains HTML tags @@ -208,14 +208,14 @@ export function htmlToBlocks(html: string): EmailBlock[] { }); 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', @@ -227,14 +227,14 @@ export function htmlToBlocks(html: string): EmailBlock[] { }); 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', @@ -246,7 +246,7 @@ export function htmlToBlocks(html: string): EmailBlock[] { }); continue; } - + // Check HTML button syntax if (part.includes('class="button"') || part.includes('class="button-outline"')) { const buttonMatch = part.match(/]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*style="([^"]*)"[^>]*>([^<]*)<\/a>/) || @@ -286,13 +286,13 @@ export function htmlToBlocks(html: string): EmailBlock[] { 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 - match with proper boundaries + + // 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', @@ -341,13 +358,30 @@ export function markdownToBlocks(markdown: string): EmailBlock[] { content, bg, }); - + // Advance past this card remaining = remaining.substring(cardMatch[0].length); continue; } - - // Check for [button] blocks + + // 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({ @@ -359,11 +393,11 @@ export function markdownToBlocks(markdown: string): EmailBlock[] { 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) { @@ -375,11 +409,11 @@ export function markdownToBlocks(markdown: string): EmailBlock[] { 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) { @@ -388,25 +422,25 @@ export function markdownToBlocks(markdown: string): EmailBlock[] { 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; }