// Markdown <-> ProseMirror JSON helpers. Deliberately minimal: paragraphs, // headings 1-3, bullet/ordered lists (flat), code blocks, blockquotes, // horizontal rules; inline bold/italic/code/strike/links. // --- ProseMirror JSON -> markdown --- export function pmToMarkdown(pmDoc) { return (pmDoc.content || []).map(blockToMarkdown).filter(s => s !== null).join('\n\n') } export function blockToMarkdown(node) { switch (node.type) { case 'paragraph': return inlineToMarkdown(node.content) case 'heading': return '#'.repeat(node.attrs?.level || 1) + ' ' + inlineToMarkdown(node.content) case 'bulletList': return (node.content || []).map(li => '- ' + listItemText(li)).join('\n') case 'orderedList': return (node.content || []).map((li, i) => `${i + 1}. ` + listItemText(li)).join('\n') case 'taskList': return (node.content || []).map(li => `- [${li.attrs?.checked ? 'x' : ' '}] ` + listItemText(li)).join('\n') case 'table': return tableToMarkdown(node) case 'codeBlock': return '```' + (node.attrs?.language || '') + '\n' + textOf(node) + '\n```' case 'blockquote': return (node.content || []).map(blockToMarkdown).join('\n\n').split('\n').map(l => '> ' + l).join('\n') case 'horizontalRule': return '---' case 'image': return `![${node.attrs?.alt || ''}](${node.attrs?.src || ''})` default: return textOf(node) || null } } function listItemText(li) { return (li.content || []).map(blockToMarkdown).join(' ') } function cellText(cell) { return (cell.content || []) .map(b => inlineToMarkdown(b.content)) .join(' ') .replace(/\|/g, '\\|') .trim() } function tableToMarkdown(node) { const rows = (node.content || []).map(r => (r.content || []).map(cellText)) if (!rows.length) return '' const cols = Math.max(...rows.map(r => r.length)) const pad = r => { const c = r.slice() while (c.length < cols) c.push('') return c } const line = cells => '| ' + pad(cells).join(' | ') + ' |' // first row is the header (GFM requires one); a delimiter row follows it const out = [line(rows[0]), '| ' + Array(cols).fill('---').join(' | ') + ' |'] for (const r of rows.slice(1)) out.push(line(r)) return out.join('\n') } function textOf(node) { if (node.text) return node.text return (node.content || []).map(textOf).join('') } function inlineToMarkdown(content) { if (!content) return '' return content.map(n => { if (n.type === 'hardBreak') return ' \n' let text = n.text || '' for (const mark of n.marks || []) { if (mark.type === 'bold') text = `**${text}**` else if (mark.type === 'italic') text = `*${text}*` else if (mark.type === 'code') text = '`' + text + '`' else if (mark.type === 'strike') text = `~~${text}~~` else if (mark.type === 'link') text = `[${text}](${mark.attrs?.href || ''})` } return text }).join('') } // --- markdown -> block descriptors --- // Each descriptor: { type, attrs?, inline? (segments), items? (arrays of segments), text? } export function markdownToBlocks(md) { const blocks = [] const lines = (md || '').replace(/\r\n/g, '\n').split('\n') let i = 0 while (i < lines.length) { const line = lines[i] if (!line.trim()) { i++; continue } const fence = line.match(/^```(\w*)\s*$/) if (fence) { const body = [] i++ while (i < lines.length && !/^```\s*$/.test(lines[i])) { body.push(lines[i]); i++ } i++ blocks.push({ type: 'codeBlock', attrs: fence[1] ? { language: fence[1] } : {}, text: body.join('\n') }) continue } if (/^(---|\*\*\*)\s*$/.test(line.trim())) { blocks.push({ type: 'horizontalRule' }); i++; continue } const image = line.match(/^!\[([^\]]*)\]\(([^)\s]+)\)\s*$/) if (image) { blocks.push({ type: 'image', attrs: { alt: image[1] || null, src: image[2] } }) i++ continue } const heading = line.match(/^(#{1,6})\s+(.*)$/) if (heading) { blocks.push({ type: 'heading', attrs: { level: Math.min(heading[1].length, 3) }, inline: tokenizeInline(heading[2]) }) i++ continue } // task list: "- [ ] text" / "- [x] text" (checked before plain bullets) if (/^\s*[-*]\s+\[[ xX]\]\s+/.test(line)) { const items = [] while (i < lines.length && /^\s*[-*]\s+\[[ xX]\]\s+/.test(lines[i])) { const m = lines[i].match(/^\s*[-*]\s+\[([ xX])\]\s+(.*)$/) items.push({ checked: m[1].toLowerCase() === 'x', inline: tokenizeInline(m[2]) }) i++ } blocks.push({ type: 'taskList', items }) continue } if (/^\s*[-*]\s+/.test(line)) { const items = [] while (i < lines.length && /^\s*[-*]\s+/.test(lines[i]) && !/^\s*[-*]\s+\[[ xX]\]\s+/.test(lines[i])) { items.push(tokenizeInline(lines[i].replace(/^\s*[-*]\s+/, ''))) i++ } blocks.push({ type: 'bulletList', items }) continue } // GFM table: a "| ... |" row immediately followed by a delimiter row if (line.includes('|') && i + 1 < lines.length && /^\s*\|?[\s:|-]*-[\s:|-]*\|?\s*$/.test(lines[i + 1]) && lines[i + 1].includes('-')) { const splitCells = l => l .trim() .replace(/^\||\|$/g, '') .split(/(? c.replace(/\\\|/g, '|').trim()) const rows = [splitCells(line)] i += 2 // header + delimiter while (i < lines.length && lines[i].includes('|') && lines[i].trim()) { rows.push(splitCells(lines[i])) i++ } blocks.push({ type: 'table', rows: rows.map(cells => cells.map(tokenizeInline)) }) continue } if (/^\s*\d+[.)]\s+/.test(line)) { const items = [] while (i < lines.length && /^\s*\d+[.)]\s+/.test(lines[i])) { items.push(tokenizeInline(lines[i].replace(/^\s*\d+[.)]\s+/, ''))) i++ } blocks.push({ type: 'orderedList', items }) continue } if (/^>\s?/.test(line)) { const body = [] while (i < lines.length && /^>\s?/.test(lines[i])) { body.push(lines[i].replace(/^>\s?/, '')) i++ } blocks.push({ type: 'blockquote', inline: tokenizeInline(body.join(' ')) }) continue } // paragraph: consume until blank line or block marker const body = [] while (i < lines.length && lines[i].trim() && !/^(#{1,6}\s|```|\s*[-*]\s|\s*\d+[.)]\s|>\s?|---\s*$)/.test(lines[i])) { body.push(lines[i]) i++ } blocks.push({ type: 'paragraph', inline: tokenizeInline(body.join(' ')) }) } return blocks } // --- inline tokenizer: returns [{ text, attrs }] where attrs maps mark name -> attrs object --- const INLINE_RE = /(\*\*([^*]+)\*\*)|(\*([^*]+)\*)|(~~([^~]+)~~)|(`([^`]+)`)|(\[([^\]]+)\]\(([^)\s]+)\))/ export function tokenizeInline(text) { const segments = [] let rest = text while (rest) { const m = rest.match(INLINE_RE) if (!m) { segments.push({ text: rest, attrs: {} }); break } if (m.index > 0) segments.push({ text: rest.slice(0, m.index), attrs: {} }) if (m[1]) segments.push({ text: m[2], attrs: { bold: {} } }) else if (m[3]) segments.push({ text: m[4], attrs: { italic: {} } }) else if (m[5]) segments.push({ text: m[6], attrs: { strike: {} } }) else if (m[7]) segments.push({ text: m[8], attrs: { code: {} } }) else if (m[9]) segments.push({ text: m[10], attrs: { link: { href: m[11] } } }) rest = rest.slice(m.index + m[0].length) } return segments.filter(s => s.text) }