// src/utils/markdown.ts // Simple markdown parser for client-side preview functionality // Note: For production, consider using a proper markdown library like marked or markdown-it export interface MarkdownParseOptions { sanitize?: boolean; breaks?: boolean; linkTarget?: string; } export class SimpleMarkdownParser { private options: MarkdownParseOptions; constructor(options: MarkdownParseOptions = {}) { this.options = { sanitize: true, breaks: true, linkTarget: '_blank', ...options }; } /** * Parse markdown to HTML */ parse(markdown: string): string { if (!markdown || markdown.trim().length === 0) { return ''; } let html = markdown; // Handle code blocks first (to prevent processing content inside them) html = this.parseCodeBlocks(html); // Parse headers html = this.parseHeaders(html); // Parse bold and italic html = this.parseEmphasis(html); // Parse links and images html = this.parseLinksAndImages(html); // Parse inline code html = this.parseInlineCode(html); // Parse lists html = this.parseLists(html); // Parse blockquotes html = this.parseBlockquotes(html); // Parse horizontal rules html = this.parseHorizontalRules(html); // Parse line breaks and paragraphs html = this.parseLineBreaks(html); // Sanitize if needed if (this.options.sanitize) { html = this.sanitizeHtml(html); } return html.trim(); } private parseCodeBlocks(html: string): string { // Replace code blocks with placeholders to protect them const codeBlocks: string[] = []; // Match ```code``` blocks html = html.replace(/```([\s\S]*?)```/g, (match, code) => { const index = codeBlocks.length; const lang = code.split('\n')[0].trim(); const content = code.includes('\n') ? code.substring(code.indexOf('\n') + 1) : code; codeBlocks.push(`
${this.escapeHtml(content.trim())}
`);
return `__CODEBLOCK_${index}__`;
});
// Restore code blocks at the end
codeBlocks.forEach((block, index) => {
html = html.replace(`__CODEBLOCK_${index}__`, block);
});
return html;
}
private parseHeaders(html: string): string {
// H1-H6 headers
for (let i = 6; i >= 1; i--) {
const headerRegex = new RegExp(`^#{${i}}\\s+(.+)$`, 'gm');
html = html.replace(headerRegex, `$1
');
return html;
}
private parseLists(html: string): string {
// Unordered lists
html = html.replace(/^[\s]*[-*+]\s+(.+)$/gm, '$1'); // Merge consecutive blockquotes html = html.replace(/(<\/blockquote>)\s*(
)/g, ' '); return html; } private parseHorizontalRules(html: string): string { // Horizontal rules: --- or *** html = html.replace(/^[-*]{3,}$/gm, '
'); return html; } private parseLineBreaks(html: string): string { if (!this.options.breaks) { return html; } // Split into paragraphs (double line breaks) const paragraphs = html.split(/\n\s*\n/); const processedParagraphs = paragraphs.map(paragraph => { const trimmed = paragraph.trim(); // Skip if already wrapped in HTML tag if (trimmed.startsWith('<') && trimmed.endsWith('>')) { return trimmed; } // Single line breaks become
const withBreaks = trimmed.replace(/\n/g, '
'); // Wrap in paragraph if not empty and not already a block element if (withBreaks && !this.isBlockElement(withBreaks)) { return `${withBreaks}
`; } return withBreaks; }); return processedParagraphs.filter(p => p.trim()).join('\n\n'); } private isBlockElement(html: string): boolean { const blockTags = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'div', 'ul', 'ol', 'li', 'blockquote', 'pre', 'hr']; return blockTags.some(tag => html.startsWith(`<${tag}`)); } private sanitizeHtml(html: string): string { // Very basic HTML sanitization - for production use a proper library const allowedTags = [ 'h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'p', 'br', 'strong', 'em', 'code', 'pre', 'a', 'img', 'ul', 'ol', 'li', 'blockquote', 'hr' ]; // Remove script tags and event handlers html = html.replace(/