Skip to content

Instantly share code, notes, and snippets.

@joshualyon
Created October 29, 2025 02:07
Show Gist options
  • Select an option

  • Save joshualyon/64afc0d92def21469cd02a038df941cc to your computer and use it in GitHub Desktop.

Select an option

Save joshualyon/64afc0d92def21469cd02a038df941cc to your computer and use it in GitHub Desktop.
TipTap ProseMirror Markdown Serializer with ABBA inner-outer nesting
/**
* ProseMirror JSON to Markdown Serializer
*
* Converts TipTap/ProseMirror JSONContent (ProseMirror document format) to
* markdown text with proper mark nesting.
*
* Fixes the mark nesting bug in @tiptap/markdown where marks are serialized
* in AABB pattern (++**text++**) instead of proper ABBA nesting (++**text**++)
*
* Usage:
* // From editor
* const json: JSONContent = editor.getJSON()
* const markdown = serializeToMarkdown(json)
*
* // From database
* const communication: Communication = await Communications.get(id)
* const markdown = serializeToMarkdown(communication.contentJson)
*
* @param doc - ProseMirror JSONContent document structure
* @returns Markdown string with proper nesting
*/
import type { JSONContent } from '@tiptap/core'
interface MarkSerializer {
open: string | ((attrs?: any) => string)
close: string | ((attrs?: any) => string)
}
const markSerializers: Record<string, MarkSerializer> = {
bold: {
open: '**',
close: '**'
},
italic: {
open: '*',
close: '*'
},
underline: {
open: '++',
close: '++'
},
strike: {
open: '~~',
close: '~~'
},
code: {
open: '`',
close: '`'
},
link: {
open: '[',
close: (attrs) => `](${attrs?.href || ''})`
}
}
/**
* Serialize a text node with its marks properly nested
*
* ProseMirror stores marks in an array where the order is determined by the
* extension priority defined in TipTap. Higher priority extensions come first.
*
* Key insight: Since we PREPEND opening tags and APPEND closing tags, iterating
* in the SAME forward direction for both creates proper ABBA nesting:
*
* - marks[0] = highest priority = innermost (opens last, closes first)
* - marks[n] = lowest priority = outermost (opens first, closes last)
*
* Example with marks = [bold(priority 101), underline(priority 50)]:
* - Open forward: prepend bold(**) → prepend underline(++) → ++**text
* - Close forward: append bold(**) → append underline(++) → ++**text**++
*
* This correctly produces: ++**text**++ (ABBA nesting)
* Not the broken: ++**text++** (AABB nesting from TipTap's built-in serializer)
*/
function serializeTextWithMarks(node: JSONContent): string {
if (!node.marks || node.marks.length === 0) {
return node.text || ''
}
let result = node.text || ''
// Open marks in FORWARD array order (0 to n)
// Since we PREPEND, marks[0] becomes innermost, marks[n] becomes outermost
// Example: [bold, underline] → prepend bold → prepend underline → ++**text
for (let i = 0; i < node.marks.length; i++) {
const mark = node.marks[i]
if (!mark) continue
const serializer = markSerializers[mark.type]
if (serializer) {
const open = typeof serializer.open === 'function'
? serializer.open(mark.attrs)
: serializer.open
result = open + result
}
}
// Close marks in FORWARD array order (0 to n) - SAME as opening
// Since we APPEND, marks[0] closes first (innermost), marks[n] closes last (outermost)
// Example: [bold, underline] → append close[0]=** → append close[1]=++ → text**++
// Combined with opening: ++**text**++
for (let i = 0; i < node.marks.length; i++) {
const mark = node.marks[i]
if (!mark) continue
const serializer = markSerializers[mark.type]
if (serializer) {
const close = typeof serializer.close === 'function'
? serializer.close(mark.attrs)
: serializer.close
result = result + close
}
}
return result
}
/**
* Serialize a single node to markdown
*/
function serializeNode(node: JSONContent): string {
if (!node) return ''
switch (node.type) {
case 'text':
return serializeTextWithMarks(node)
case 'paragraph':
const paragraphContent = node.content?.map(serializeNode).join('') || ''
return paragraphContent ? paragraphContent + '\n\n' : ''
case 'heading':
const level = node.attrs?.level || 1
const hashes = '#'.repeat(level)
const headingContent = node.content?.map(serializeNode).join('') || ''
return `${hashes} ${headingContent}\n\n`
case 'bulletList':
return (node.content?.map(serializeBulletListItem).join('') || '') + '\n'
case 'orderedList':
return (node.content?.map((item, index) => serializeOrderedListItem(item, index + 1)).join('') || '') + '\n'
case 'listItem':
// This shouldn't be called directly, but handle it just in case
return node.content?.map(serializeNode).join('') || ''
case 'codeBlock':
const code = node.content?.map(n => n.text || '').join('') || ''
const language = node.attrs?.language || ''
return '```' + language + '\n' + code + '\n```\n\n'
case 'blockquote':
const blockquoteLines = (node.content?.map(serializeNode).join('') || '')
.split('\n')
.filter(line => line.trim())
.map(line => `> ${line}`)
.join('\n')
return blockquoteLines + '\n\n'
case 'horizontalRule':
return '---\n\n'
case 'hardBreak':
return ' \n' // Two spaces + newline for hard break
case 'doc':
// Root document node
return node.content?.map(serializeNode).join('') || ''
default:
// Unknown node type - try to serialize content
console.warn(`[serializeToMarkdown] Unknown node type: ${node.type}`)
return node.content?.map(serializeNode).join('') || ''
}
}
function serializeBulletListItem(item: JSONContent): string {
const content = item.content?.map(node => {
if (node.type === 'paragraph') {
// For list items, don't add extra newlines after paragraphs
return node.content?.map(serializeNode).join('') || ''
}
// Trim trailing newlines from nested content to avoid double spacing
return serializeNode(node).replace(/\n+$/, '')
}).join('\n') || ''
// Indent continuation lines with 2 spaces to keep them in the list item
const lines = content.split('\n')
const firstLine = lines[0]
const continuationLines = lines.slice(1).map(line => ' ' + line).join('\n')
return `- ${firstLine}${continuationLines ? '\n' + continuationLines : ''}\n`
}
function serializeOrderedListItem(item: JSONContent, number: number): string {
const content = item.content?.map(node => {
if (node.type === 'paragraph') {
return node.content?.map(serializeNode).join('') || ''
}
// Trim trailing newlines from nested content to avoid double spacing
return serializeNode(node).replace(/\n+$/, '')
}).join('\n') || ''
// Indent continuation lines with 3 spaces (to align after "1. ")
const lines = content.split('\n')
const firstLine = lines[0]
const continuationLines = lines.slice(1).map(line => ' ' + line).join('\n')
return `${number}. ${firstLine}${continuationLines ? '\n' + continuationLines : ''}\n`
}
/**
* Main entry point: Serialize TipTap/ProseMirror JSON to Markdown
*/
export function serializeToMarkdown(doc: JSONContent | null | undefined): string {
if (!doc) return ''
// Handle both editor.getJSON() format (doc node) and direct content array
if (doc.type === 'doc') {
return (doc.content?.map(serializeNode).join('') || '').trim()
}
// Single node
return serializeNode(doc).trim()
}
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment