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>
226 lines
6.4 KiB
TypeScript
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, '&')
|
|
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')
|
|
}
|