mirror of
https://github.com/vxcontrol/pentagi.git
synced 2026-08-25 12:36:30 +00:00
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:
co-authored by
Claude Fable 5
parent
5cb0522818
commit
6badfb90db
+67
-1
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user