✅ 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
323 lines
10 KiB
TypeScript
323 lines
10 KiB
TypeScript
/**
|
|
* Markdown Detection and Conversion Utilities
|
|
*
|
|
* Handles detection of markdown vs HTML content and conversion between formats
|
|
*/
|
|
|
|
/**
|
|
* Detect if content is markdown or HTML
|
|
*
|
|
* @param content - The content to check
|
|
* @returns 'markdown' | 'html'
|
|
*/
|
|
export function detectContentType(content: string): 'markdown' | 'html' {
|
|
if (!content || content.trim() === '') {
|
|
return 'html';
|
|
}
|
|
|
|
// Check for markdown-specific patterns
|
|
const markdownPatterns = [
|
|
/^\*\*[^*]+\*\*/m, // **bold**
|
|
/^__[^_]+__/m, // __bold__
|
|
/^\*[^*]+\*/m, // *italic*
|
|
/^_[^_]+_/m, // _italic_
|
|
/^#{1,6}\s/m, // # headings
|
|
/^\[card[^\]]*\]/m, // [card] syntax
|
|
/^\[button\s+url=/m, // [button url=...] syntax
|
|
/^---$/m, // horizontal rules
|
|
/^[\*\-•✓]\s/m, // bullet points
|
|
];
|
|
|
|
// Check for HTML-specific patterns
|
|
const htmlPatterns = [
|
|
/<[a-z][\s\S]*>/i, // HTML tags
|
|
/<\/[a-z]+>/i, // Closing tags
|
|
/&[a-z]+;/i, // HTML entities
|
|
];
|
|
|
|
// Count markdown vs HTML indicators
|
|
let markdownScore = 0;
|
|
let htmlScore = 0;
|
|
|
|
for (const pattern of markdownPatterns) {
|
|
if (pattern.test(content)) {
|
|
markdownScore++;
|
|
}
|
|
}
|
|
|
|
for (const pattern of htmlPatterns) {
|
|
if (pattern.test(content)) {
|
|
htmlScore++;
|
|
}
|
|
}
|
|
|
|
// If content has [card] or [button] syntax, it's definitely our markdown format
|
|
if (/\[card[^\]]*\]/.test(content) || /\[button\s+url=/.test(content)) {
|
|
return 'markdown';
|
|
}
|
|
|
|
// If content has HTML tags but no markdown, it's HTML
|
|
if (htmlScore > 0 && markdownScore === 0) {
|
|
return 'html';
|
|
}
|
|
|
|
// If content has markdown indicators, it's markdown
|
|
if (markdownScore > 0) {
|
|
return 'markdown';
|
|
}
|
|
|
|
// Default to HTML for safety
|
|
return 'html';
|
|
}
|
|
|
|
/**
|
|
* Convert markdown to HTML for display
|
|
*
|
|
* @param markdown - Markdown content
|
|
* @returns HTML content
|
|
*/
|
|
export function markdownToHtml(markdown: string): string {
|
|
if (!markdown) return '';
|
|
|
|
let html = markdown;
|
|
|
|
// Parse [card:type] blocks (new syntax)
|
|
html = html.replace(/\[card:(\w+)\]([\s\S]*?)\[\/card\]/g, (match, type, content) => {
|
|
const cardClass = `card card-${type}`;
|
|
const parsedContent = parseMarkdownBasics(content.trim());
|
|
return `<div class="${cardClass}">${parsedContent}</div>`;
|
|
});
|
|
|
|
// Parse [card type="..."] blocks (old syntax - backward compatibility)
|
|
html = html.replace(/\[card(?:\s+type="([^"]+)")?\]([\s\S]*?)\[\/card\]/g, (match, type, content) => {
|
|
const cardClass = type ? `card card-${type}` : 'card';
|
|
const parsedContent = parseMarkdownBasics(content.trim());
|
|
return `<div class="${cardClass}">${parsedContent}</div>`;
|
|
});
|
|
|
|
// Parse [button:style](url)Text[/button] (new syntax)
|
|
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([^\[]+)\[\/button\]/g, (match, style, url, text) => {
|
|
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
|
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
|
});
|
|
|
|
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
|
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
|
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
|
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
|
});
|
|
|
|
// Parse remaining markdown
|
|
html = parseMarkdownBasics(html);
|
|
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* Parse basic markdown syntax to HTML (exported for use in components)
|
|
*
|
|
* @param text - Markdown text
|
|
* @returns HTML text
|
|
*/
|
|
export function parseMarkdownBasics(text: string): string {
|
|
let html = text;
|
|
|
|
// Protect variables from markdown parsing by temporarily replacing them
|
|
const variables: { [key: string]: string } = {};
|
|
let varIndex = 0;
|
|
html = html.replace(/\{([^}]+)\}/g, (match, varName) => {
|
|
const placeholder = `<!--VAR${varIndex}-->`;
|
|
variables[placeholder] = match;
|
|
varIndex++;
|
|
return placeholder;
|
|
});
|
|
|
|
// 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 (don't match across newlines)
|
|
html = html.replace(/\*\*([^\n*]+?)\*\*/g, '<strong>$1</strong>');
|
|
html = html.replace(/__([^\n_]+?)__/g, '<strong>$1</strong>');
|
|
|
|
// Italic (don't match across newlines)
|
|
html = html.replace(/\*([^\n*]+?)\*/g, '<em>$1</em>');
|
|
html = html.replace(/_([^\n_]+?)_/g, '<em>$1</em>');
|
|
|
|
// Horizontal rules
|
|
html = html.replace(/^---$/gm, '<hr>');
|
|
|
|
// Parse [button:style](url)Text[/button] (new syntax) - must come before images
|
|
// Allow whitespace and newlines between parts
|
|
html = html.replace(/\[button:(\w+)\]\(([^)]+)\)([\s\S]*?)\[\/button\]/g, (match, style, url, text) => {
|
|
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
|
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
|
});
|
|
|
|
// Parse [button url="..."] shortcodes (old syntax - backward compatibility)
|
|
html = html.replace(/\[button\s+url="([^"]+)"(?:\s+style="([^"]+)")?\]([^\[]+)\[\/button\]/g, (match, url, style, text) => {
|
|
const buttonClass = style === 'outline' ? 'button-outline' : 'button';
|
|
return `<p style="text-align: center;"><a href="${url}" class="${buttonClass}">${text.trim()}</a></p>`;
|
|
});
|
|
|
|
// Images (must come before links)
|
|
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" style="max-width: 100%; height: auto; display: block; margin: 16px 0;">');
|
|
|
|
// Links (but not button syntax)
|
|
html = html.replace(/\[(?!button)([^\]]+)\]\(([^)]+)\)/g, '<a href="$2">$1</a>');
|
|
|
|
// Process lines for paragraphs and lists
|
|
const lines = html.split('\n');
|
|
let inList = false;
|
|
let paragraphContent = '';
|
|
const processedLines: string[] = [];
|
|
|
|
const closeParagraph = () => {
|
|
if (paragraphContent) {
|
|
processedLines.push(`<p>${paragraphContent}</p>`);
|
|
paragraphContent = '';
|
|
}
|
|
};
|
|
|
|
for (let i = 0; i < lines.length; i++) {
|
|
const line = lines[i];
|
|
const trimmed = line.trim();
|
|
|
|
// Empty line - close paragraph or list
|
|
if (!trimmed) {
|
|
if (inList) {
|
|
processedLines.push('</ul>');
|
|
inList = false;
|
|
}
|
|
closeParagraph();
|
|
processedLines.push('');
|
|
continue;
|
|
}
|
|
|
|
// Check if line is a list item
|
|
if (/^[\*\-•✓]\s/.test(trimmed)) {
|
|
closeParagraph();
|
|
const content = trimmed.replace(/^[\*\-•✓]\s/, '');
|
|
if (!inList) {
|
|
processedLines.push('<ul>');
|
|
inList = true;
|
|
}
|
|
processedLines.push(`<li>${content}</li>`);
|
|
continue;
|
|
}
|
|
|
|
// Close list if we're in one
|
|
if (inList) {
|
|
processedLines.push('</ul>');
|
|
inList = false;
|
|
}
|
|
|
|
// Block-level HTML tags - don't wrap in paragraph
|
|
// But inline tags like <strong>, <em>, <a> should be part of paragraph
|
|
const blockTags = /^<(div|h1|h2|h3|h4|h5|h6|p|ul|ol|li|hr|table|blockquote)/i;
|
|
if (blockTags.test(trimmed)) {
|
|
closeParagraph();
|
|
processedLines.push(line);
|
|
continue;
|
|
}
|
|
|
|
// Regular text line - accumulate in paragraph
|
|
if (paragraphContent) {
|
|
// Add line break before continuation
|
|
paragraphContent += '<br>' + trimmed;
|
|
} else {
|
|
// Start new paragraph
|
|
paragraphContent = trimmed;
|
|
}
|
|
}
|
|
|
|
// Close any open tags
|
|
if (inList) {
|
|
processedLines.push('</ul>');
|
|
}
|
|
closeParagraph();
|
|
|
|
html = processedLines.join('\n');
|
|
|
|
// Restore variables
|
|
Object.entries(variables).forEach(([placeholder, original]) => {
|
|
html = html.replace(new RegExp(placeholder, 'g'), original);
|
|
});
|
|
|
|
return html;
|
|
}
|
|
|
|
/**
|
|
* Convert HTML back to markdown (for editing)
|
|
*
|
|
* @param html - HTML content
|
|
* @returns Markdown content
|
|
*/
|
|
export function htmlToMarkdown(html: string): string {
|
|
if (!html) return '';
|
|
|
|
let markdown = html;
|
|
|
|
// Convert <div class="card"> back to [card]
|
|
markdown = markdown.replace(/<div class="card(?:\s+card-([^"]+))?">([\s\S]*?)<\/div>/g, (match, type, content) => {
|
|
const mdContent = parseHtmlToMarkdownBasics(content.trim());
|
|
return type ? `[card type="${type}"]\n${mdContent}\n[/card]` : `[card]\n${mdContent}\n[/card]`;
|
|
});
|
|
|
|
// Convert buttons back to [button] syntax
|
|
markdown = markdown.replace(/<p[^>]*><a href="([^"]+)" class="(button[^"]*)"[^>]*>([^<]+)<\/a><\/p>/g, (match, url, className, text) => {
|
|
const style = className.includes('outline') ? ' style="outline"' : '';
|
|
return `[button url="${url}"${style}]${text.trim()}[/button]`;
|
|
});
|
|
|
|
// Convert remaining HTML to markdown
|
|
markdown = parseHtmlToMarkdownBasics(markdown);
|
|
|
|
return markdown;
|
|
}
|
|
|
|
/**
|
|
* Parse HTML back to basic markdown
|
|
*
|
|
* @param html - HTML text
|
|
* @returns Markdown text
|
|
*/
|
|
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)');
|
|
|
|
// Horizontal rules
|
|
markdown = markdown.replace(/<hr\s*\/?>/gi, '\n---\n');
|
|
|
|
// Lists
|
|
markdown = markdown.replace(/<ul>([\s\S]*?)<\/ul>/gi, (match, content) => {
|
|
return content.replace(/<li>(.*?)<\/li>/gi, '- $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();
|
|
}
|