renderTableToMarkdown leaks the U+001F cell separator into serialized markdown
#8.152 geöffnet am 02.08.2026
Repository-Metriken
- Stars
- (23.454 Sterne)
- PR-Merge-Metriken
- (Durchschn. Merge 4T 13h) (46 gemergte PRs in 30 T)
Beschreibung
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:
- If nothing consumes it any more, drop the join or replace
cellSepwith the intended visible separator. - 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.