fix(markdown-editor): normalize CRLF in escapeTablePipes so tables keep cells

The pre-lex pipe protection split on '\n', leaving a trailing '\r' on every
line. The '$'-anchored TABLE_DELIMITER_LINE then failed to match '| --- |\r',
so the whole module no-op'd on CRLF input and marked dropped cells whose code
spans / templates / URLs held a pipe — silently losing data on the first load
of Windows- or API-authored content.

Normalize '\r\n' and lone '\r' to '\n' up front (marked re-normalizes the
returned string anyway). Original bytes are preserved when nothing is escaped.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
Sergey Kozyrenko
2026-07-07 18:53:11 +07:00
co-authored by Claude Opus 4.8
parent b015f3ba98
commit 81e448cf05
2 changed files with 26 additions and 1 deletions
@@ -143,6 +143,27 @@ describe('table cell with a pipe inside a URL — content survives load and conv
});
});
describe('CRLF line endings — tables still protected', () => {
it('escapes a code-span pipe in a CRLF table row', () => {
expect(escapeTablePipes('| a | b |\r\n| --- | --- |\r\n| `x | y` | z |\r\n')).toBe(
'| a | b |\n| --- | --- |\n| `x \\| y` | z |\n',
);
});
it('keeps the trailing cell of a CRLF table on round-trip', () => {
const out = roundTrip('| a | b |\r\n| --- | --- |\r\n| `x | y` | z |\r\n');
expect(out).toContain('z');
expect(out).toContain('`x \\| y`');
});
it('leaves CRLF bytes untouched when there is no table to escape', () => {
const doc = 'line one\r\nline `a | b` two\r\n';
expect(escapeTablePipes(doc)).toBe(doc);
});
});
describe('TABLE_DELIMITER_LINE is linear (ReDoS guard)', () => {
it('scans a crafted delimiter-looking line with a long trailing space run in linear time', () => {
// A `|`-line followed by "dashes + many spaces + non-matching tail" was O(n²) on the old regex
@@ -137,7 +137,11 @@ export const escapeTablePipes = (markdown: string): string => {
return markdown;
}
const lines = markdown.split('\n');
// marked normalizes CRLF itself, but this pre-pass runs BEFORE it: a trailing `\r` left by split('\n')
// defeats the `$`-anchored TABLE_DELIMITER_LINE, so a CRLF document's tables would go unprotected here and
// lose cells. Normalize first; the return below keeps the original bytes when nothing was escaped.
const source = markdown.includes('\r') ? markdown.replace(/\r\n?/g, '\n') : markdown;
const lines = source.split('\n');
let openFence: null | string = null;
let isChanged = false;