ueberdosis/tiptap

renderTableToMarkdown leaks the U+001F cell separator into serialized markdown

Open

#8,152 opened on Aug 2, 2026

 (3 comments) (1 reaction) (0 assignees)TypeScript (1,979 forks)batch import
area: markdowngood first issue

Repository metrics

Stars
 (23,454 stars)
PR merge metrics
 (Avg merge 4d 13h) (46 merged PRs in 30d)

Description

Affected Packages

@tiptap/extension-table, @tiptap/markdown

Tiptap Version

3.27.3

Browser Used

N/A - reproduces in plain Node with MarkdownManager, no DOM involved.

What happened?

Serializing a table whose cell contains more than one block child emits a raw U+001F (UNIT SEPARATOR) control character into the markdown output.

renderTableToMarkdown joins multi-block cells with DEFAULT_CELL_LINE_SEPARATOR = '\u001F' so the parts can be split apart again later, but nothing ever splits them: the value flows straight into collapseWhitespace, whose /\s+/ does not match U+001F, and then into the emitted pipe row.

packages/extension-table/src/table/utilities/markdown.ts:

export const DEFAULT_CELL_LINE_SEPARATOR = '\u001F'

function collapseWhitespace(s: string) {
  return (s || '').replace(/\s+/g, ' ').trim()   // does not match U+001F
}
...
const parts = cellNode.content.map(child => h.renderChildren(child))
raw = parts.join(cellSep)
...
const text = collapseWhitespace(raw)

I grepped the package for another consumer of cellSep / DEFAULT_CELL_LINE_SEPARATOR and found none on the serialize path, so the separator appears to be vestigial.

Practical impact: any app that persists editor.getMarkdown() stores the control character. Ours ended up in a database and in diffs, invisible in most editors.

Reproduction

import { MarkdownManager } from '@tiptap/markdown'
import StarterKit from '@tiptap/starter-kit'
import { Table } from '@tiptap/extension-table'
import { TableRow } from '@tiptap/extension-table-row'
import { TableCell } from '@tiptap/extension-table-cell'
import { TableHeader } from '@tiptap/extension-table-header'

const m = new MarkdownManager({ extensions: [StarterKit, Table, TableRow, TableCell, TableHeader] })
const p = t => ({ type: 'paragraph', content: [{ type: 'text', text: t }] })

const md = m.serialize({ type: 'doc', content: [{ type: 'table', content: [{ type: 'tableRow', content: [
  { type: 'tableCell', content: [p('line one'), p('line two')] },   // two blocks in one cell
  { type: 'tableCell', content: [p('B')] },
]}]}]})

console.log(JSON.stringify(md))
console.log('contains U+001F:', md.includes(String.fromCharCode(31)))

Output:

"\n|                   |     |\n| ----------------- | --- |\n| line one\u001fline two | B   |\n"
contains U+001F: true

Expected Behavior

The serialized markdown should contain no control characters. A multi-block cell should collapse to something a markdown table can hold - a space, or <br>, or whatever the project prefers - but the internal separator should not survive into the output.

Two directions, depending on what the separator was meant for:

  1. If nothing consumes it any more, drop the join or replace cellSep with the intended visible separator.
  2. If it is meant as an internal marker, strip it in collapseWhitespace (e.g. /[\s\u001F]+/) before the text reaches the row.

Additional Context

Found while working around a separate limitation in the same function: columnCount is derived from max(row.length) and colspan/rowspan are never read, so a merged-cell table parsed from HTML is silently flattened on serialize. That one is inherent to GFM pipe syntax and we handled it with our own renderMarkdown override - happy to open it separately if a span-aware HTML fallback would be in scope.

Contributor guide