fix(editor): keep table cells intact when cell text ends a backslash run with a pipe

marked's cell splitter honors \| only after an odd backslash run and
truncates rows that split into extra columns, so escaping the pipe alone
in text like a\|b produced \\| — a live delimiter that dropped the
trailing cells on the next load. An odd run + pipe has no exact GFM
encoding: the serializer now pads the run by one backslash, trading a
one-character gain inside the cell for structural integrity, byte-stable
from the first save. Covered by direct round-trips and a random \/|
payload property test.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-20 03:02:25 +07:00
co-authored by Claude Fable 5
parent 5cb0522818
commit 6badfb90db
3 changed files with 126 additions and 2 deletions
@@ -1,5 +1,8 @@
import { Editor } from '@tiptap/core';
import { beforeAll, describe, expect, it } from 'vitest';
import { createMarkdownExtensions } from './markdown-editor-extensions';
import { escapeCellPipes } from './markdown-editor-marked';
import { roundTrip, setupEditorJsdom, structuralCounts } from './markdown-editor-test-setup';
beforeAll(setupEditorJsdom);
@@ -171,10 +174,73 @@ describe('generative content-integrity — atoms survive load↔serialize across
// The atom itself must survive as a code span with its structural pipes escaped for the table — the
// sentinel guards the row's cell count, this guards the pipe-bearing content from silent corruption.
for (const atom of atoms) {
const span = `\`${atom.replace(/\|/g, '\\|')}\``;
const span = `\`${escapeCellPipes(atom)}\``;
expect(out.includes(span), `atom ${span} corrupted (i=${i}):\n${doc}\n-->\n${out}`).toBe(true);
}
}
});
// The doc side of the same class: rich-mode typing can put a backslash run right before a pipe in a cell —
// a sequence no markdown load produces (marked's splitter consumes one `\` per escaped pipe). GFM cannot
// encode an odd run + pipe exactly, so the serializer pads it by one backslash; assert over random `\`/`|`
// payloads that the padded save is byte-stable immediately, both cells survive, and no non-backslash byte
// is lost or reordered.
it('typed backslash/pipe cell payloads keep the table intact and converge on the first save', () => {
const rng = mulberry32(0xe5cade);
const chars = ['a', 'x', '\\', '|', ' '];
const cellNode = (type: string, text: string) => ({
content: [{ content: [{ text, type: 'text' }], type: 'paragraph' }],
type,
});
const tableDoc = (typed: string) => ({
content: [
{
content: [
{ content: [cellNode('tableHeader', 'op'), cellNode('tableHeader', 'note')], type: 'tableRow' },
{ content: [cellNode('tableCell', typed), cellNode('tableCell', 'tail')], type: 'tableRow' },
],
type: 'table',
},
],
type: 'doc',
});
const normalize = (text: string) => text.replace(/\\/g, '').replace(/ +/g, ' ').trim();
for (let i = 0; i < 200; i++) {
const length = 1 + Math.floor(rng() * 8);
const typed = Array.from({ length }, () => chars[Math.floor(rng() * chars.length)]).join('');
const editor = new Editor({ content: tableDoc(typed), extensions: createMarkdownExtensions() });
const save1 = editor.getMarkdown();
editor.destroy();
const save2 = roundTrip(save1);
expect(save2, `not byte-stable (i=${i}, typed ${JSON.stringify(typed)}):\n${save1}\n-->\n${save2}`).toBe(
save1,
);
expect(save2.includes('tail'), `trailing cell dropped (i=${i}, typed ${JSON.stringify(typed)})`).toBe(true);
expect(structuralCounts(save1)).toEqual({ table: 1, tableCell: 2, tableHeader: 2, tableRow: 2 });
const reloaded = new Editor({
content: save1,
contentType: 'markdown',
extensions: createMarkdownExtensions(),
});
const cells: string[] = [];
reloaded.state.doc.descendants((node) => {
if (node.type.name === 'tableCell') {
cells.push(node.textContent);
}
});
reloaded.destroy();
expect(
normalize(cells[0] ?? ''),
`cell content mutated (i=${i}, typed ${JSON.stringify(typed)}):\n${save1}`,
).toBe(normalize(typed));
}
});
});
@@ -220,6 +220,56 @@ describe('TunedTable — cell pipes escaped + alignment preserved, idempotent',
expect(save1).toContain('| :--- | :---: | ---: |');
expect(roundTrip(save1)).toBe(save1);
});
// A doc-side cell text (typed/pasted in rich mode) can hold a backslash run right before a pipe — a
// sequence a markdown LOAD can never produce. Serialize it through the editor and reload to prove the
// escaped pipe survives as cell content instead of becoming a column delimiter that truncates the row.
const saveTableCell = (cellText: string): string => {
const cell = (type: string, text: string) => ({
content: [{ content: [{ text, type: 'text' }], type: 'paragraph' }],
type,
});
const editor = new Editor({
content: {
content: [
{
content: [
{ content: [cell('tableHeader', 'op'), cell('tableHeader', 'note')], type: 'tableRow' },
{ content: [cell('tableCell', cellText), cell('tableCell', 'tail')], type: 'tableRow' },
],
type: 'table',
},
],
type: 'doc',
},
extensions: createMarkdownExtensions(),
});
const out = editor.getMarkdown();
editor.destroy();
return out;
};
it.each([
['single backslash before pipe', 'a\\|b'],
['triple backslash before pipe', 'a\\\\\\|b'],
['lone backslash-pipe cell', '\\|'],
])('typed cell with %s keeps the row and its trailing cell across saves', (_l, cellText) => {
const save1 = saveTableCell(cellText);
const save2 = roundTrip(save1);
expect(save2, `row truncated:\n${save1}\n-->\n${save2}`).toBe(save1);
expect(save2).toContain('tail');
expect(structuralCounts(save2)).toEqual({ table: 1, tableCell: 2, tableHeader: 2, tableRow: 2 });
});
it('round-trips an even backslash run before a pipe byte-identical', () => {
const save1 = saveTableCell('a\\\\|b');
expect(save1).toContain('a\\\\\\|b');
expect(roundTrip(save1)).toBe(save1);
});
});
describe('nesting & sequencing — content preserved and converges (≤2 saves)', () => {
@@ -88,6 +88,14 @@ const TunedMarkdownText = Extension.create({
priority: 50,
});
// marked's cell splitter honors `\|` only after an ODD run of backslashes (it counts them), never collapses
// `\\`, and truncates a row that splits into extra columns — so cell text holding an odd run + pipe (`a\|b`)
// has NO exact GFM encoding: escaping the pipe alone yields `\\|`, a live delimiter that drops the trailing
// cells on the next load. Pad an odd run by one backslash — the cell gains a `\`, the table keeps its cells,
// and the padded form is byte-stable from the first save.
export const escapeCellPipes = (text: string): string =>
text.replace(/(\\*)\|/g, (_, run: string) => `${run}${run.length % 2 ? '\\\\|' : '\\|'}`);
// @tiptap/extension-table's renderTableToMarkdown is alignment-aware but never escapes pipes, so a literal
// `|` a cell emits (even from inside inline code) would re-parse as a column delimiter on the next SAVE and
// drop cells (tiptap PR #7884). renderTableToMarkdown emits cell content only via h.renderChildren, so wrapping
@@ -97,7 +105,7 @@ export const TunedTable = Table.extend({
renderMarkdown(node: JSONContent, helpers: MarkdownRendererHelpers) {
const pipeEscaping: MarkdownRendererHelpers = {
...helpers,
renderChildren: (nodes, separator) => helpers.renderChildren(nodes, separator).replace(/\|/g, '\\|'),
renderChildren: (nodes, separator) => escapeCellPipes(helpers.renderChildren(nodes, separator)),
};
// renderTableToMarkdown joins a multi-block cell's children (e.g. two paragraphs from Enter-in-cell) with