feat: Code Mode Button Position & Markdown Support! 📝
## ✅ 3. Code Mode Button Moved to Left **Problem:** Inconsistent layout, tabs on right should be Editor/Preview only **Solution:** - Moved Code Mode button next to "Message Body" label - Editor/Preview tabs stay on the right - Consistent, logical layout **Before:** ``` Message Body [Editor|Preview] [Code Mode] ``` **After:** ``` Message Body [Code Mode] [Editor|Preview] ``` ## ✅ 4. Markdown Support in Code Mode! 🎉 **Problem:** HTML is verbose, not user-friendly for tech-savvy users **Solution:** - Added Markdown parser with ::: syntax for cards - Toggle between HTML and Markdown modes - Full bidirectional conversion **Markdown Syntax:** ```markdown :::card # Heading Your content here ::: :::card[success] ✅ Success message ::: [button](https://example.com){Click Here} [button style="outline"](url){Secondary Button} ``` **Features:** - Standard Markdown: headings, bold, italic, lists, links - Card blocks: :::card or :::card[type] - Button blocks: [button](url){text} - Variables: {order_url}, {customer_name} - Bidirectional conversion (HTML ↔ Markdown) **Files:** - `lib/markdown-parser.ts` - Parser implementation - `components/ui/code-editor.tsx` - Mode toggle - `routes/Settings/Notifications/EditTemplate.tsx` - Enable support - `DEPENDENCIES.md` - Add @codemirror/lang-markdown **Note:** Requires `npm install @codemirror/lang-markdown` Ready for remaining improvements (5-6)!
This commit is contained in:
134
admin-spa/src/lib/markdown-parser.ts
Normal file
134
admin-spa/src/lib/markdown-parser.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/**
|
||||
* Markdown to Email HTML Parser
|
||||
*
|
||||
* Supports:
|
||||
* - Standard Markdown (headings, bold, italic, lists, links)
|
||||
* - Card blocks with ::: syntax
|
||||
* - Button blocks with [button] syntax
|
||||
* - Variables with {variable_name}
|
||||
*/
|
||||
|
||||
export function parseMarkdownToEmail(markdown: string): string {
|
||||
let html = markdown;
|
||||
|
||||
// Parse card blocks first (:::card or :::card[type])
|
||||
html = html.replace(/:::card(?:\[(\w+)\])?\n([\s\S]*?):::/g, (match, type, content) => {
|
||||
const cardType = type || 'default';
|
||||
const parsedContent = parseMarkdownBasics(content.trim());
|
||||
return `[card${type ? ` type="${cardType}"` : ''}]\n${parsedContent}\n[/card]`;
|
||||
});
|
||||
|
||||
// Parse button blocks [button](url) or [button style="outline"](url)
|
||||
html = html.replace(/\[button(?:\s+style="(solid|outline)")?\]\((.*?)\)\s*\{([^}]+)\}/g, (match, style, url, text) => {
|
||||
return `[button link="${url}"${style ? ` style="${style}"` : ' style="solid"'}]${text}[/button]`;
|
||||
});
|
||||
|
||||
// Parse remaining markdown (outside cards)
|
||||
html = parseMarkdownBasics(html);
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
function parseMarkdownBasics(text: string): string {
|
||||
let html = text;
|
||||
|
||||
// Headings
|
||||
html = html.replace(/^#### (.*$)/gim, '<h4>$1</h4>');
|
||||
html = html.replace(/^### (.*$)/gim, '<h3>$1</h3>');
|
||||
html = html.replace(/^## (.*$)/gim, '<h2>$1</h2>');
|
||||
html = html.replace(/^# (.*$)/gim, '<h1>$1</h1>');
|
||||
|
||||
// Bold
|
||||
html = html.replace(/\*\*(.*?)\*\*/g, '<strong>$1</strong>');
|
||||
html = html.replace(/__(.*?)__/g, '<strong>$1</strong>');
|
||||
|
||||
// Italic
|
||||
html = html.replace(/\*(.*?)\*/g, '<em>$1</em>');
|
||||
html = html.replace(/_(.*?)_/g, '<em>$1</em>');
|
||||
|
||||
// Links (but not button syntax)
|
||||
html = html.replace(/\[(?!button)([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
||||
|
||||
// Unordered lists
|
||||
html = html.replace(/^\* (.*$)/gim, '<li>$1</li>');
|
||||
html = html.replace(/^- (.*$)/gim, '<li>$1</li>');
|
||||
html = html.replace(/(<li>.*<\/li>)/s, '<ul>$1</ul>');
|
||||
|
||||
// Ordered lists
|
||||
html = html.replace(/^\d+\. (.*$)/gim, '<li>$1</li>');
|
||||
|
||||
// Paragraphs (lines not already in tags)
|
||||
const lines = html.split('\n');
|
||||
const processedLines = lines.map(line => {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed) return '';
|
||||
if (trimmed.startsWith('<') || trimmed.startsWith('[')) return line;
|
||||
return `<p>${line}</p>`;
|
||||
});
|
||||
html = processedLines.join('\n');
|
||||
|
||||
return html;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert email HTML back to Markdown
|
||||
*/
|
||||
export function parseEmailToMarkdown(html: string): string {
|
||||
let markdown = html;
|
||||
|
||||
// Convert [card] blocks to ::: syntax
|
||||
markdown = markdown.replace(/\[card(?:\s+type="(\w+)")?\]([\s\S]*?)\[\/card\]/g, (match, type, content) => {
|
||||
const mdContent = parseHtmlToMarkdownBasics(content.trim());
|
||||
return type ? `:::card[${type}]\n${mdContent}\n:::` : `:::card\n${mdContent}\n:::`;
|
||||
});
|
||||
|
||||
// Convert [button] blocks to markdown syntax
|
||||
markdown = markdown.replace(/\[button link="([^"]+)"(?:\s+style="(solid|outline)")?\]([^[]+)\[\/button\]/g, (match, url, style, text) => {
|
||||
return style && style !== 'solid'
|
||||
? `[button style="${style}"](${url}){${text.trim()}}`
|
||||
: `[button](${url}){${text.trim()}}`;
|
||||
});
|
||||
|
||||
// Convert remaining HTML to markdown
|
||||
markdown = parseHtmlToMarkdownBasics(markdown);
|
||||
|
||||
return markdown;
|
||||
}
|
||||
|
||||
function parseHtmlToMarkdownBasics(html: string): string {
|
||||
let markdown = html;
|
||||
|
||||
// Headings
|
||||
markdown = markdown.replace(/<h1>(.*?)<\/h1>/gi, '# $1');
|
||||
markdown = markdown.replace(/<h2>(.*?)<\/h2>/gi, '## $1');
|
||||
markdown = markdown.replace(/<h3>(.*?)<\/h3>/gi, '### $1');
|
||||
markdown = markdown.replace(/<h4>(.*?)<\/h4>/gi, '#### $1');
|
||||
|
||||
// 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)');
|
||||
|
||||
// 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`);
|
||||
});
|
||||
|
||||
// Paragraphs
|
||||
markdown = markdown.replace(/<p>(.*?)<\/p>/gi, '$1\n\n');
|
||||
|
||||
// Clean up extra newlines
|
||||
markdown = markdown.replace(/\n{3,}/g, '\n\n');
|
||||
|
||||
return markdown.trim();
|
||||
}
|
||||
Reference in New Issue
Block a user