Created
October 29, 2025 02:07
-
-
Save joshualyon/64afc0d92def21469cd02a038df941cc to your computer and use it in GitHub Desktop.
TipTap ProseMirror Markdown Serializer with ABBA inner-outer nesting
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * 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