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]';
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 `<p style="${wrapperStyle}"><img src="${block.src}" alt="${block.alt || ''}" style="${imgStyle}" /></p>`;
}
case 'divider':
return `<hr style="border: none; border-top: 1px solid #ddd; margin: 20px 0;" />`;
case 'spacer':
return `<div style="height: ${block.height}px;"></div>`;
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 <div class="card"> HTML
const cardRegex = /(?:\[card([^\]]*)\]([\s\S]*?)\[\/card\]|<div class="card(?:\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 <div class="card">
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) {
// <div class="card"> HTML syntax
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();
}
}
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(/<a[^>]*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('<hr')) {
blocks.push({ id, type: 'divider' });
continue;
}
// Check if it's a spacer
const spacerMatch = part.match(/height:\s*(\d+)px/);
if (spacerMatch && part.includes('<div')) {
@@ -300,7 +300,7 @@ export function htmlToBlocks(html: string): EmailBlock[] {
continue;
}
}
return blocks;
}
@@ -310,30 +310,47 @@ export function htmlToBlocks(html: string): EmailBlock[] {
export function markdownToBlocks(markdown: string): EmailBlock[] {
const blocks: EmailBlock[] = [];
let blockId = 0;
// Parse markdown respecting [card]...[/card] and [button]...[/button] boundaries
let remaining = markdown;
while (remaining.length > 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;
}