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:
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user