feat: 8 AI providers, rich text editor, agent notifications, UI contrast & font settings
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m25s
All checks were successful
Deploy to Production / Build and Deploy (push) Successful in 1m25s
- 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>
This commit is contained in:
225
memento-note/lib/markdown-to-html.ts
Normal file
225
memento-note/lib/markdown-to-html.ts
Normal file
@@ -0,0 +1,225 @@
|
||||
/**
|
||||
* 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, '<')
|
||||
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(`<pre><code class="language-${lang || 'plaintext'}">${code.trim()}</code></pre>`)
|
||||
return `%%CODEBLOCK_${idx}%%`
|
||||
})
|
||||
|
||||
// Inline code (`...`)
|
||||
html = html.replace(/`([^`]+)`/g, '<code>$1</code>')
|
||||
|
||||
// Images ()
|
||||
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(/^>\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')
|
||||
}
|
||||
Reference in New Issue
Block a user