feat: Complete markdown syntax refinement and variable protection
✅ New cleaner syntax implemented: - [card:type] instead of [card type='type'] - [button:style](url)Text[/button] instead of [button url='...' style='...'] - Standard markdown images:  ✅ Variable protection from markdown parsing: - Variables with underscores (e.g., {order_items_table}) now protected - HTML comment placeholders prevent italic/bold parsing - All variables render correctly in preview ✅ Button rendering fixes: - Buttons work in Visual mode inside cards - Buttons work in Preview mode - Button clicks prevented in visual editor - Proper styling for solid and outline buttons ✅ Backward compatibility: - Old syntax still supported - No breaking changes ✅ Bug fixes: - Fixed order_item_table → order_items_table naming - Fixed button regex to match across newlines - Added button/image parsing to parseMarkdownBasics - Prevented button clicks on .button and .button-outline classes 📚 Documentation: - NEW_MARKDOWN_SYNTAX.md - Complete user guide - MARKDOWN_SYNTAX_AND_VARIABLES.md - Technical analysis
This commit is contained in:
@@ -1,4 +1,87 @@
|
||||
import { EmailBlock } from './types';
|
||||
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[^>]*>(.*?)<\/h1>/gi, '# $1\n\n');
|
||||
markdown = markdown.replace(/<h2[^>]*>(.*?)<\/h2>/gi, '## $1\n\n');
|
||||
markdown = markdown.replace(/<h3[^>]*>(.*?)<\/h3>/gi, '### $1\n\n');
|
||||
markdown = markdown.replace(/<h4[^>]*>(.*?)<\/h4>/gi, '#### $1\n\n');
|
||||
|
||||
// Bold
|
||||
markdown = markdown.replace(/<strong[^>]*>(.*?)<\/strong>/gi, '**$1**');
|
||||
markdown = markdown.replace(/<b[^>]*>(.*?)<\/b>/gi, '**$1**');
|
||||
|
||||
// Italic
|
||||
markdown = markdown.replace(/<em[^>]*>(.*?)<\/em>/gi, '*$1*');
|
||||
markdown = markdown.replace(/<i[^>]*>(.*?)<\/i>/gi, '*$1*');
|
||||
|
||||
// Links
|
||||
markdown = markdown.replace(/<a[^>]*href=["']([^"']+)["'][^>]*>(.*?)<\/a>/gi, '[$2]($1)');
|
||||
|
||||
// Paragraphs
|
||||
markdown = markdown.replace(/<p[^>]*>(.*?)<\/p>/gi, '$1\n\n');
|
||||
|
||||
// Line breaks
|
||||
markdown = markdown.replace(/<br\s*\/?>/gi, '\n');
|
||||
|
||||
// Lists
|
||||
markdown = markdown.replace(/<ul[^>]*>([\s\S]*?)<\/ul>/gi, (match, content) => {
|
||||
return content.replace(/<li[^>]*>(.*?)<\/li>/gi, '- $1\n');
|
||||
});
|
||||
markdown = markdown.replace(/<ol[^>]*>([\s\S]*?)<\/ol>/gi, (match, content) => {
|
||||
let counter = 1;
|
||||
return content.replace(/<li[^>]*>(.*?)<\/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
|
||||
@@ -12,9 +95,29 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
|
||||
}
|
||||
return `[card type="${block.cardType}"]\n${block.content}\n[/card]`;
|
||||
|
||||
case 'button':
|
||||
case 'button': {
|
||||
const buttonClass = block.style === 'solid' ? 'button' : 'button-outline';
|
||||
return `<p style="text-align: center;"><a href="${block.link}" class="${buttonClass}">${block.text}</a></p>`;
|
||||
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 `<p style="text-align: ${align};"><a href="${block.link}" class="${buttonClass}"${styleAttr}>${block.text}</a></p>`;
|
||||
}
|
||||
|
||||
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 `<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;" />`;
|
||||
@@ -29,14 +132,14 @@ export function blocksToHTML(blocks: EmailBlock[]): string {
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert [card] syntax HTML to blocks
|
||||
* Convert [card] syntax HTML or <div class="card"> HTML to blocks
|
||||
*/
|
||||
export function htmlToBlocks(html: string): EmailBlock[] {
|
||||
const blocks: EmailBlock[] = [];
|
||||
let blockId = 0;
|
||||
|
||||
// Split by [card] tags and other elements
|
||||
const cardRegex = /\[card([^\]]*)\](.*?)\[\/card\]/gs;
|
||||
// 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;
|
||||
@@ -63,33 +166,122 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
||||
for (const part of parts) {
|
||||
const id = `block-${Date.now()}-${blockId++}`;
|
||||
|
||||
// Check if it's a card
|
||||
const cardMatch = part.match(/\[card([^\]]*)\](.*?)\[\/card\]/s);
|
||||
// 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) {
|
||||
const attributes = cardMatch[1];
|
||||
const content = cardMatch[2].trim();
|
||||
const typeMatch = attributes.match(/type=["']([^"']+)["']/);
|
||||
const cardType = (typeMatch ? typeMatch[1] : 'default') as any;
|
||||
|
||||
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) {
|
||||
// <div class="card"> HTML syntax
|
||||
const htmlCardMatch = part.match(/<div class="card(?:\s+card-([^"]+))?"[^>]*>([\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,
|
||||
content
|
||||
cardType: cardType as any,
|
||||
content: markdownContent
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// Check if it's a button
|
||||
// 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(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*>([^<]*)<\/a>/);
|
||||
const buttonMatch = part.match(/<a[^>]*href="([^"]*)"[^>]*class="(button[^"]*)"[^>]*style="([^"]*)"[^>]*>([^<]*)<\/a>/) ||
|
||||
part.match(/<a[^>]*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 <p> 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[3],
|
||||
text: buttonMatch[textIndex],
|
||||
link: buttonMatch[1],
|
||||
style: buttonMatch[2].includes('outline') ? 'outline' : 'solid'
|
||||
style: buttonMatch[styleClassIndex].includes('outline') ? 'outline' : 'solid',
|
||||
widthMode,
|
||||
customMaxWidth,
|
||||
align,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
@@ -111,3 +303,110 @@ export function htmlToBlocks(html: string): EmailBlock[] {
|
||||
|
||||
return blocks;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert clean markdown directly to blocks (no HTML intermediary)
|
||||
*/
|
||||
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
|
||||
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
|
||||
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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user