fix(prosemirror-markdown): hardBreak переживает round-trip (trailing/consecutive/table-cell) (#549) #553

Merged
vvzvlad merged 1 commits from fix/549-hardbreak-md-canon into develop 2026-07-12 17:40:19 +03:00
2 changed files with 67 additions and 0 deletions
@@ -987,9 +987,14 @@ export function convertProseMirrorToMarkdown(
// like a paragraph followed by a list don't collide into "line1- a".
// Then collapse newlines and escape pipes so a cell containing "|" or a
// line break cannot corrupt the surrounding GFM row.
// #549: a GFM pipe cell is single-line — the generic ` \n`/newline
// collapse below flattens a hardBreak into a space, dropping it. Convert
// the two-space hardBreak marker to `<br>` FIRST (GFM cells allow inline
// `<br>`, which re-imports to a hardBreak) so intra-cell breaks survive.
return nodeContent
.map(processNode)
.join(" ")
.replace(/ {2}\n/g, "<br>")
.replace(/\r?\n/g, " ")
.replace(/\|/g, "\\|");
}
@@ -1338,6 +1343,27 @@ export function convertProseMirrorToMarkdown(
parts[i] = mathInlineHtml(nodes[i].attrs?.text || "");
}
}
// #549: position-aware hardBreak. The two-space ` \n` form (emitted by the
// processNode hardBreak case) only re-imports to a hardBreak when it is
// FOLLOWED by same-paragraph text. It is silently lost when the break is
// (a) the last inline child — the top-level `.trim()` strips the trailing
// ` \n`, and even a non-last paragraph's trailing break sits before the
// `\n\n` block gap — or (b) immediately followed by another hardBreak — the
// intervening whitespace-only line reads as a paragraph separator on
// re-parse, splitting the run. Emit `<br>` there instead: marked passes the
// inline HTML break through and generateJSON rebuilds a hardBreak in every
// position. A safe mid-paragraph break keeps the ` \n` form so the existing
// golden output stays byte-stable. Skipped inside footnote bodies, whose
// `^[…]` inline form collapses breaks to spaces (unchanged; already lossy).
if (!inFootnoteBody) {
for (let i = 0; i < nodes.length; i++) {
if (nodes[i]?.type !== "hardBreak") continue;
const next = nodes[i + 1];
if (!next || next.type === "hardBreak" || (parts[i + 1] ?? "") === "") {
parts[i] = "<br>";
}
}
}
return parts.join("");
};
@@ -295,3 +295,44 @@ describe('git-sync converter: lose-prone atoms keep their VALUES across a round
expect(allText(src)).toContain('shared body');
});
});
// ---------------------------------------------------------------------------
// #549: hardBreak COUNT/position preservation across a pm -> md -> pm round
// trip. The FIXTURES block above only proves a mid-paragraph hardBreak survives
// as a node; these pin the three sub-cases whose ` \n` two-space form was
// silently dropped on re-import (trailing / consecutive / table cell), plus a
// mid-paragraph regression guard so the byte-stable ` \n` form is not lost.
// ---------------------------------------------------------------------------
describe('#549: hardBreak survives every round-trip position', () => {
const HB = { type: 'hardBreak' };
const countHardBreaks = (n: any): number => {
let c = n?.type === 'hardBreak' ? 1 : 0;
if (Array.isArray(n?.content)) for (const ch of n.content) c += countHardBreaks(ch);
return c;
};
const roundTripCount = async (d: any): Promise<number> =>
countHardBreaks(await markdownToProseMirror(convertProseMirrorToMarkdown(d)));
it('mid-paragraph break is still preserved (regression guard)', async () => {
expect(await roundTripCount(doc(P(T('a'), HB, T('b'))))).toBe(1);
});
it('(1) trailing break at the end of the last paragraph survives', async () => {
expect(await roundTripCount(doc(P(T('a'), HB)))).toBe(1);
});
it('(2) two consecutive breaks in one paragraph survive', async () => {
expect(await roundTripCount(doc(P(T('a'), HB, HB, T('b'))))).toBe(2);
});
it('(3) a break inside a table cell survives', async () => {
const table = doc({
type: 'table',
content: [
{ type: 'tableRow', content: [{ type: 'tableHeader', content: [P(T('h'))] }] },
{ type: 'tableRow', content: [{ type: 'tableCell', content: [P(T('a'), HB, T('b'))] }] },
],
});
expect(await roundTripCount(table)).toBe(1);
});
});