diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-content-integrity.test.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-content-integrity.test.ts index ee07e248..1fb639ca 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-content-integrity.test.ts +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-content-integrity.test.ts @@ -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)); + } + }); }); diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.test.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.test.ts index ec222f41..d70c2dfd 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.test.ts +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-extensions.test.ts @@ -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)', () => { diff --git a/frontend/src/components/shared/markdown-editor/markdown-editor-marked.ts b/frontend/src/components/shared/markdown-editor/markdown-editor-marked.ts index 0dc09219..f64633dc 100644 --- a/frontend/src/components/shared/markdown-editor/markdown-editor-marked.ts +++ b/frontend/src/components/shared/markdown-editor/markdown-editor-marked.ts @@ -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