Files
Momento/memento-note/lib/markdown-to-html.ts
Sepehr Ramezani dbd49d6fcb
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m25s
feat: 8 AI providers, rich text editor, agent notifications, UI contrast & font settings
- Add DeepSeek, OpenRouter, Mistral, Z.AI, LM Studio as AI providers
  with editable model names via Combobox in admin settings
- Fix OpenRouter broken by normalizeProvider bug in config.ts
- Convert agent-created notes from Markdown to HTML (TipTap rich text)
- Add Notification model + in-app notifications for agent results
- Agent notification click opens the created note directly
- Add note count display on notebook and inbox headers
- Fix checklist toggle in card view (persist state via localCheckItems)
- Add checklist creation option in tabs/list view (dropdown on + button)
- Fix image description ENOENT error with HTTP fallback
- Improve UI contrast across all themes (input, border, checkbox visibility)
- Add font family setting (Inter vs System Default) in Appearance settings
- Fix CSS font-sans variable conflict (removed dead Geist references)
- Update README with new features and 8 providers

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-05-01 16:14:07 +02:00

226 lines
6.4 KiB
TypeScript

/**
* 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, '&amp;')
html = html.replace(/</g, '&lt;')
html = html.replace(/>/g, '&gt;')
// 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(`<pre><code class="language-${lang || 'plaintext'}">${code.trim()}</code></pre>`)
return `%%CODEBLOCK_${idx}%%`
})
// Inline code (`...`)
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
// Images (![alt](url))
html = html.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '<img src="$2" alt="$1" />')
// Links ([text](url))
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, '<a href="$2" target="_blank" rel="noopener noreferrer">$1</a>')
// Headings (h1-h6)
html = html.replace(/^######\s+(.+)$/gm, '<h6>$1</h6>')
html = html.replace(/^#####\s+(.+)$/gm, '<h5>$1</h5>')
html = html.replace(/^####\s+(.+)$/gm, '<h4>$1</h4>')
html = html.replace(/^###\s+(.+)$/gm, '<h3>$1</h3>')
html = html.replace(/^##\s+(.+)$/gm, '<h2>$1</h2>')
html = html.replace(/^#\s+(.+)$/gm, '<h1>$1</h1>')
// Bold (**text** or __text__)
html = html.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
html = html.replace(/__([^_]+)__/g, '<strong>$1</strong>')
// Italic (*text* or _text_)
html = html.replace(/(?<!\*)\*([^*]+)\*(?!\*)/g, '<em>$1</em>')
html = html.replace(/(?<!_)_([^_]+)_(?!_)/g, '<em>$1</em>')
// Strikethrough (~~text~~)
html = html.replace(/~~([^~]+)~~/g, '<s>$1</s>')
// Horizontal rules (---, ***, ___)
html = html.replace(/^(-{3,}|\*{3,}|_{3,})$/gm, '<hr />')
// Tables
html = convertTables(html)
// Blockquotes (> text)
html = html.replace(/^&gt;\s+(.+)$/gm, '<blockquote><p>$1</p></blockquote>')
// Merge consecutive blockquotes
html = html.replace(/<\/blockquote>\n<blockquote>/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 in <p> tags
html = wrapParagraphs(html)
// Clean up empty paragraphs
html = html.replace(/<p>\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 = '<table>'
// Header row
const headers = parseTableRow(rows[0])
if (headers.length > 0) {
table += '<thead><tr>'
headers.forEach(h => { table += `<th>${h}</th>` })
table += '</tr></thead>'
}
// Body rows (skip separator)
const bodyRows = rows.slice(2)
if (bodyRows.length > 0) {
table += '<tbody>'
bodyRows.forEach(row => {
const cells = parseTableRow(row)
table += '<tr>'
cells.forEach(c => { table += `<td>${c}</td>` })
table += '</tr>'
})
table += '</tbody>'
}
table += '</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('<ul>')
inList = true
}
result.push(`<li>${listMatch[2]}</li>`)
} else {
if (inList) {
result.push('</ul>')
inList = false
}
result.push(line)
}
}
if (inList) result.push('</ul>')
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('<ol>')
inList = true
}
result.push(`<li>${listMatch[2]}</li>`)
} else {
if (inList) {
result.push('</ol>')
inList = false
}
result.push(line)
}
}
if (inList) result.push('</ol>')
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(`<p>${text}</p>`)
}
}
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')
}