Files
WooNooW/admin-spa/src/lib/markdown-utils.ts
dwindown 4471cd600f 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: ![alt](url)

 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
2025-11-15 20:05:50 +07:00

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();
}