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.
This commit is contained in:
Dwindi Ramadhana
2026-01-01 21:57:58 +07:00
parent 70006beeb9
commit 5a831ddf9d

View File

@@ -56,27 +56,27 @@ export function blocksToMarkdown(blocks: EmailBlock[]): string {
const cardSyntax = cardBlock.cardType !== 'default' ? `[card:${cardBlock.cardType}]` : '[card]'; const cardSyntax = cardBlock.cardType !== 'default' ? `[card:${cardBlock.cardType}]` : '[card]';
return `${cardSyntax}\n\n${cardBlock.content}\n\n[/card]`; return `${cardSyntax}\n\n${cardBlock.content}\n\n[/card]`;
} }
case 'button': { case 'button': {
const buttonBlock = block as ButtonBlock; const buttonBlock = block as ButtonBlock;
// Use new [button:style](url)Text[/button] syntax // Use new [button:style](url)Text[/button] syntax
const style = buttonBlock.style || 'solid'; const style = buttonBlock.style || 'solid';
return `[button:${style}](${buttonBlock.link})${buttonBlock.text}[/button]`; return `[button:${style}](${buttonBlock.link})${buttonBlock.text}[/button]`;
} }
case 'image': { case 'image': {
const imageBlock = block as ImageBlock; const imageBlock = block as ImageBlock;
return `[image src="${imageBlock.src}" alt="${imageBlock.alt || ''}" width="${imageBlock.widthMode}" align="${imageBlock.align}"]`; return `[image src="${imageBlock.src}" alt="${imageBlock.alt || ''}" width="${imageBlock.widthMode}" align="${imageBlock.align}"]`;
} }
case 'divider': case 'divider':
return '---'; return '---';
case 'spacer': { case 'spacer': {
const spacerBlock = block as SpacerBlock; const spacerBlock = block as SpacerBlock;
return `[spacer height="${spacerBlock.height}"]`; return `[spacer height="${spacerBlock.height}"]`;
} }
default: default:
return ''; return '';
} }
@@ -94,7 +94,7 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
return `[card]\n${block.content}\n[/card]`; return `[card]\n${block.content}\n[/card]`;
} }
return `[card type="${block.cardType}"]\n${block.content}\n[/card]`; return `[card type="${block.cardType}"]\n${block.content}\n[/card]`;
case 'button': { case 'button': {
const buttonClass = block.style === 'solid' ? 'button' : 'button-outline'; const buttonClass = block.style === 'solid' ? 'button' : 'button-outline';
const align = block.align || 'center'; const align = block.align || 'center';
@@ -118,13 +118,13 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
} }
return `<p style="${wrapperStyle}"><img src="${block.src}" alt="${block.alt || ''}" style="${imgStyle}" /></p>`; return `<p style="${wrapperStyle}"><img src="${block.src}" alt="${block.alt || ''}" style="${imgStyle}" /></p>`;
} }
case 'divider': case 'divider':
return `<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />`; return `<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />`;
case 'spacer': case 'spacer':
return `<div style="height: ${block.height}px;"></div>`; return `<div style="height: ${block.height}px;"></div>`;
default: default:
return ''; return '';
} }
@@ -137,39 +137,39 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
export function htmlToBlocks(html: string): EmailBlock[] { export function htmlToBlocks(html: string): EmailBlock[] {
const blocks: EmailBlock[] = []; const blocks: EmailBlock[] = [];
let blockId = 0; let blockId = 0;
// Match both [card] syntax and <div class="card"> HTML // Match both [card] syntax and <div class="card"> HTML
const cardRegex = /(?:\[card([^\]]*)\]([\s\S]*?)\[\/card\]|<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>)/gs; const cardRegex = /(?:\[card([^\]]*)\]([\s\S]*?)\[\/card\]|<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>)/gs;
const parts: string[] = []; const parts: string[] = [];
let lastIndex = 0; let lastIndex = 0;
let match; let match;
while ((match = cardRegex.exec(html)) !== null) { while ((match = cardRegex.exec(html)) !== null) {
// Add content before card // Add content before card
if (match.index > lastIndex) { if (match.index > lastIndex) {
const beforeContent = html.substring(lastIndex, match.index).trim(); const beforeContent = html.substring(lastIndex, match.index).trim();
if (beforeContent) parts.push(beforeContent); if (beforeContent) parts.push(beforeContent);
} }
// Add card // Add card
parts.push(match[0]); parts.push(match[0]);
lastIndex = match.index + match[0].length; lastIndex = match.index + match[0].length;
} }
// Add remaining content // Add remaining content
if (lastIndex < html.length) { if (lastIndex < html.length) {
const remaining = html.substring(lastIndex).trim(); const remaining = html.substring(lastIndex).trim();
if (remaining) parts.push(remaining); if (remaining) parts.push(remaining);
} }
// Process each part // Process each part
for (const part of parts) { for (const part of parts) {
const id = `block-${Date.now()}-${blockId++}`; const id = `block-${Date.now()}-${blockId++}`;
// Check if it's a card - match [card:type], [card type="..."], and <div class="card"> // Check if it's a card - match [card:type], [card type="..."], and <div class="card">
let content = ''; let content = '';
let cardType = 'default'; let cardType = 'default';
// Try new [card:type] syntax first // Try new [card:type] syntax first
let cardMatch = part.match(/\[card:(\w+)\]([\s\S]*?)\[\/card\]/s); let cardMatch = part.match(/\[card:(\w+)\]([\s\S]*?)\[\/card\]/s);
if (cardMatch) { if (cardMatch) {
@@ -185,7 +185,7 @@ export function htmlToBlocks(html: string): EmailBlock[] {
cardType = (typeMatch ? typeMatch[1] : 'default'); cardType = (typeMatch ? typeMatch[1] : 'default');
} }
} }
if (!cardMatch) { if (!cardMatch) {
// <div class="card"> HTML syntax // <div class="card"> HTML syntax
const htmlCardMatch = part.match(/<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>/s); const htmlCardMatch = part.match(/<div class="card(?:\s+card-([^"]+))?"[^>]*>([\s\S]*?)<\/div>/s);
@@ -194,7 +194,7 @@ export function htmlToBlocks(html: string): EmailBlock[] {
content = htmlCardMatch[2].trim(); content = htmlCardMatch[2].trim();
} }
} }
if (content) { if (content) {
// Convert HTML content to markdown for clean editing // Convert HTML content to markdown for clean editing
// But only if it actually contains HTML tags // But only if it actually contains HTML tags
@@ -208,14 +208,14 @@ export function htmlToBlocks(html: string): EmailBlock[] {
}); });
continue; continue;
} }
// Check if it's a button - try new syntax first // Check if it's a button - try new syntax first
let buttonMatch = part.match(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/); let buttonMatch = part.match(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/);
if (buttonMatch) { if (buttonMatch) {
const style = buttonMatch[1] as ButtonStyle; const style = buttonMatch[1] as ButtonStyle;
const url = buttonMatch[2]; const url = buttonMatch[2];
const text = buttonMatch[3].trim(); const text = buttonMatch[3].trim();
blocks.push({ blocks.push({
id, id,
type: 'button', type: 'button',
@@ -227,14 +227,14 @@ export function htmlToBlocks(html: string): EmailBlock[] {
}); });
continue; continue;
} }
// Try old [button url="..."] syntax // Try old [button url="..."] syntax
buttonMatch = part.match(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](\w+)["'])?\]([^\[]+)\[\/button\]/); buttonMatch = part.match(/\[button\s+url=["']([^"']+)["'](?:\s+style=["'](\w+)["'])?\]([^\[]+)\[\/button\]/);
if (buttonMatch) { if (buttonMatch) {
const url = buttonMatch[1]; const url = buttonMatch[1];
const style = (buttonMatch[2] || 'solid') as ButtonStyle; const style = (buttonMatch[2] || 'solid') as ButtonStyle;
const text = buttonMatch[3].trim(); const text = buttonMatch[3].trim();
blocks.push({ blocks.push({
id, id,
type: 'button', type: 'button',
@@ -246,7 +246,7 @@ export function htmlToBlocks(html: string): EmailBlock[] {
}); });
continue; continue;
} }
// Check HTML button syntax // Check HTML button syntax
if (part.includes('class="button"') || part.includes('class="button-outline"')) { if (part.includes('class="button"') || part.includes('class="button-outline"')) {
const buttonMatch = part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*style="([^"]*)"[^>]*>([^<]*)<\/a>/) || const buttonMatch = part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*style="([^"]*)"[^>]*>([^<]*)<\/a>/) ||
@@ -286,13 +286,13 @@ export function htmlToBlocks(html: string): EmailBlock[] {
continue; continue;
} }
} }
// Check if it's a divider // Check if it's a divider
if (part.includes('<hr')) { if (part.includes('<hr')) {
blocks.push({ id, type: 'divider' }); blocks.push({ id, type: 'divider' });
continue; continue;
} }
// Check if it's a spacer // Check if it's a spacer
const spacerMatch = part.match(/height:\s*(\d+)px/); const spacerMatch = part.match(/height:\s*(\d+)px/);
if (spacerMatch && part.includes('<div')) { if (spacerMatch && part.includes('<div')) {
@@ -300,7 +300,7 @@ export function htmlToBlocks(html: string): EmailBlock[] {
continue; continue;
} }
} }
return blocks; return blocks;
} }
@@ -310,30 +310,47 @@ export function htmlToBlocks(html: string): EmailBlock[] {
export function markdownToBlocks(markdown: string): EmailBlock[] { export function markdownToBlocks(markdown: string): EmailBlock[] {
const blocks: EmailBlock[] = []; const blocks: EmailBlock[] = [];
let blockId = 0; let blockId = 0;
// Parse markdown respecting [card]...[/card] and [button]...[/button] boundaries // Parse markdown respecting [card]...[/card] and [button]...[/button] boundaries
let remaining = markdown; let remaining = markdown;
while (remaining.length > 0) { while (remaining.length > 0) {
remaining = remaining.trim(); remaining = remaining.trim();
if (!remaining) break; if (!remaining) break;
const id = `block-${Date.now()}-${blockId++}`; 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\]/); const cardMatch = remaining.match(/^\[card([^\]]*)\]([\s\S]*?)\[\/card\]/);
if (cardMatch) { if (cardMatch) {
const attributes = cardMatch[1].trim(); const attributes = cardMatch[1].trim();
const content = cardMatch[2].trim(); const content = cardMatch[2].trim();
// Extract card type // Extract card type
const typeMatch = attributes.match(/type\s*=\s*["']([^"']+)["']/); const typeMatch = attributes.match(/type\s*=\s*["']([^"']+)["']/);
const cardType = (typeMatch?.[1] || 'default') as CardType; const cardType = (typeMatch?.[1] || 'default') as CardType;
// Extract background // Extract background
const bgMatch = attributes.match(/bg=["']([^"']+)["']/); const bgMatch = attributes.match(/bg=["']([^"']+)["']/);
const bg = bgMatch?.[1]; const bg = bgMatch?.[1];
blocks.push({ blocks.push({
id, id,
type: 'card', type: 'card',
@@ -341,13 +358,30 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
content, content,
bg, bg,
}); });
// Advance past this card // Advance past this card
remaining = remaining.substring(cardMatch[0].length); remaining = remaining.substring(cardMatch[0].length);
continue; 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\]/); const buttonMatch = remaining.match(/^\[button\s+url=["']([^"']+)["'](?:\s+style=["'](solid|outline)["'])?\]([^\[]+)\[\/button\]/);
if (buttonMatch) { if (buttonMatch) {
blocks.push({ blocks.push({
@@ -359,11 +393,11 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
align: 'center', align: 'center',
widthMode: 'fit', widthMode: 'fit',
}); });
remaining = remaining.substring(buttonMatch[0].length); remaining = remaining.substring(buttonMatch[0].length);
continue; continue;
} }
// Check for [image] blocks // Check for [image] blocks
const imageMatch = remaining.match(/^\[image\s+src=["']([^"']+)["'](?:\s+alt=["']([^"']*)["'])?(?:\s+width=["']([^"']+)["'])?(?:\s+align=["']([^"']+)["'])?\]/); const imageMatch = remaining.match(/^\[image\s+src=["']([^"']+)["'](?:\s+alt=["']([^"']*)["'])?(?:\s+width=["']([^"']+)["'])?(?:\s+align=["']([^"']+)["'])?\]/);
if (imageMatch) { if (imageMatch) {
@@ -375,11 +409,11 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
widthMode: (imageMatch[3] || 'fit') as ContentWidth, widthMode: (imageMatch[3] || 'fit') as ContentWidth,
align: (imageMatch[4] || 'center') as ContentAlign, align: (imageMatch[4] || 'center') as ContentAlign,
}); });
remaining = remaining.substring(imageMatch[0].length); remaining = remaining.substring(imageMatch[0].length);
continue; continue;
} }
// Check for [spacer] blocks // Check for [spacer] blocks
const spacerMatch = remaining.match(/^\[spacer\s+height=["'](\d+)["']\]/); const spacerMatch = remaining.match(/^\[spacer\s+height=["'](\d+)["']\]/);
if (spacerMatch) { if (spacerMatch) {
@@ -388,25 +422,25 @@ export function markdownToBlocks(markdown: string): EmailBlock[] {
type: 'spacer', type: 'spacer',
height: parseInt(spacerMatch[1]), height: parseInt(spacerMatch[1]),
}); });
remaining = remaining.substring(spacerMatch[0].length); remaining = remaining.substring(spacerMatch[0].length);
continue; continue;
} }
// Check for horizontal rule // Check for horizontal rule
if (remaining.startsWith('---')) { if (remaining.startsWith('---')) {
blocks.push({ blocks.push({
id, id,
type: 'divider', type: 'divider',
}); });
remaining = remaining.substring(3); remaining = remaining.substring(3);
continue; continue;
} }
// If nothing matches, skip this character to avoid infinite loop // If nothing matches, skip this character to avoid infinite loop
remaining = remaining.substring(1); remaining = remaining.substring(1);
} }
return blocks; return blocks;
} }