| |
| |
| |
|
|
| |
|
|
| 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 `` |
| 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(' | ') + ' |' |
| |
| 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('') |
| } |
|
|
| |
| |
|
|
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
| 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(/(?<!\\)\|/) |
| .map(c => c.replace(/\\\|/g, '|').trim()) |
| const rows = [splitCells(line)] |
| i += 2 |
| 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 |
| } |
|
|
| |
| 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 |
| } |
|
|
| |
|
|
| 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) |
| } |
|
|