/** * Server-side Markdown → HTML converter. * Converts AI-generated markdown notes into TipTap-compatible rich text HTML. * Uses a lightweight regex-based approach to avoid heavy remark/rehype dependencies. * * Handles: headings, bold, italic, strikethrough, code blocks, inline code, * links, images, lists (ul/ol), blockquotes, horizontal rules, tables, paragraphs. */ export function markdownToHtml(markdown: string): string { if (!markdown || !markdown.trim()) return '' let html = markdown // Escape HTML entities (but preserve markdown) html = html.replace(/&/g, '&') html = html.replace(//g, '>') // Code blocks (``` ... ```) — protect from further processing const codeBlocks: string[] = [] html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_match, lang, code) => { const idx = codeBlocks.length codeBlocks.push(`
${code.trim()}`)
return `%%CODEBLOCK_${idx}%%`
})
// Inline code (`...`)
html = html.replace(/`([^`]+)`/g, '$1')
// Images ()
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '') // Merge consecutive blockquotes html = html.replace(/<\/blockquote>\n$1
/g, '\n') // Unordered lists (- item or * item) html = convertUnorderedLists(html) // Ordered lists (1. item) html = convertOrderedLists(html) // Restore code blocks codeBlocks.forEach((block, idx) => { html = html.replace(`%%CODEBLOCK_${idx}%%`, block) }) // Paragraphs — wrap remaining loose text intags html = wrapParagraphs(html) // Clean up empty paragraphs html = html.replace(/
\s*<\/p>/g, '') return html.trim() } function convertTables(html: string): string { // Simple table conversion: | header | header |\n| --- | --- |\n| cell | cell | const tableRegex = /(?:^|\n)((?:\|[^\n]+\|\n)+)/g return html.replace(tableRegex, (match) => { const rows = match.trim().split('\n').filter(r => r.trim()) if (rows.length < 2) return match // Check if second row is separator const separator = rows[1].trim() if (!/^[\s|:-]+$/.test(separator)) return match let table = '
' // Header row const headers = parseTableRow(rows[0]) if (headers.length > 0) { table += '
' return '\n' + table + '\n' }) } function parseTableRow(row: string): string[] { return row.split('|') .map(cell => cell.trim()) .filter((_, i, arr) => i > 0 && i < arr.length) // Skip first and last empty from leading/trailing | } function convertUnorderedLists(html: string): string { const lines = html.split('\n') const result: string[] = [] let inList = false for (const line of lines) { const listMatch = line.match(/^(\s*)[-*]\s+(.+)$/) if (listMatch) { if (!inList) { result.push('' headers.forEach(h => { table += ` ' } // Body rows (skip separator) const bodyRows = rows.slice(2) if (bodyRows.length > 0) { table += '' bodyRows.forEach(row => { const cells = parseTableRow(row) table += '${h} ` }) table += '' cells.forEach(c => { table += ` ' }) table += '' } table += '${c} ` }) table += '') inList = true } result.push(`
') inList = false } result.push(line) } } if (inList) result.push('') return result.join('\n') } function convertOrderedLists(html: string): string { const lines = html.split('\n') const result: string[] = [] let inList = false for (const line of lines) { const listMatch = line.match(/^(\s*)\d+\.\s+(.+)$/) if (listMatch) { if (!inList) { result.push('- ${listMatch[2]}
`) } else { if (inList) { result.push('') inList = true } result.push(`
') inList = false } result.push(line) } } if (inList) result.push('') return result.join('\n') } function wrapParagraphs(html: string): string { const blockTags = new Set(['h1', 'h2', 'h3', 'h4', 'h5', 'h6', 'ul', 'ol', 'li', 'blockquote', 'pre', 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'p', 'div', 'img']) const lines = html.split('\n') const result: string[] = [] let buffer: string[] = [] const flushBuffer = () => { const text = buffer.join('\n').trim() if (text) { // Don't double-wrap if already starts with a block tag const firstTag = text.match(/^<(\w+)/) if (firstTag && blockTags.has(firstTag[1].toLowerCase())) { result.push(text) } else { result.push(`- ${listMatch[2]}
`) } else { if (inList) { result.push('${text}
`) } } buffer = [] } for (const line of lines) { const trimmed = line.trim() // Check if this line is a block-level element const isBlockLine = trimmed.startsWith('<') && (() => { const tag = trimmed.match(/^<(\w+)/) return tag ? blockTags.has(tag[1].toLowerCase()) : false })() if (isBlockLine || trimmed === '') { flushBuffer() if (isBlockLine) result.push(trimmed) } else { buffer.push(trimmed) } } flushBuffer() return result.join('\n') }