cowrite / server /md.js
lvwerra's picture
lvwerra HF Staff
Upload folder using huggingface_hub
2ad2aff verified
Raw
History Blame Contribute Delete
7.56 kB
// 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(/(?<!\\)\|/)
.map(c => 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)
}