diff --git a/packages/prosemirror-markdown/src/lib/markdown-converter.ts b/packages/prosemirror-markdown/src/lib/markdown-converter.ts index 3f647a75..fdf54afd 100644 --- a/packages/prosemirror-markdown/src/lib/markdown-converter.ts +++ b/packages/prosemirror-markdown/src/lib/markdown-converter.ts @@ -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 `
` FIRST (GFM cells allow inline + // `
`, which re-imports to a hardBreak) so intra-cell breaks survive. return nodeContent .map(processNode) .join(" ") + .replace(/ {2}\n/g, "
") .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 `
` 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] = "
"; + } + } + } return parts.join(""); }; diff --git a/packages/prosemirror-markdown/test/roundtrip-all-nodes.test.ts b/packages/prosemirror-markdown/test/roundtrip-all-nodes.test.ts index 6bc634a9..dc971532 100644 --- a/packages/prosemirror-markdown/test/roundtrip-all-nodes.test.ts +++ b/packages/prosemirror-markdown/test/roundtrip-all-nodes.test.ts @@ -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 => + 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); + }); +});