Compare commits

...

1 Commits

Author SHA1 Message Date
agent_coder c18b4c132c fix(md-canon): preserve hardBreak in trailing/consecutive/table-cell round-trips (#549)
The markdown canon lost `hardBreak` nodes in three pm->md->pm sub-cases where
the two-space `  \n` form does not survive re-import via marked:

1. Trailing at end of a paragraph — the top-level `.trim()` strips the trailing
   `  \n`, and even a non-last paragraph's trailing break sits before the `\n\n`
   block gap, so marked drops it.
2. Consecutive breaks (`a<hardBreak><hardBreak>b`) — the whitespace-only middle
   line reads as a paragraph separator, splitting the run into two paragraphs.
3. Inside a GFM table cell — the single-line cell collapses the break to a space.

Emit `<br>` (inline HTML break) in exactly those positions: marked passes it
through and generateJSON rebuilds a hardBreak in every context, including table
cells. renderInlineChildren now picks `<br>` for a break that is the last inline
child or is followed by another break; a safe mid-paragraph break keeps the
byte-stable `  \n` form (golden output unchanged). Footnote bodies are skipped
(their `^[…]` form already collapses breaks to spaces). The table-cell case
converts the `  \n` marker to `<br>` before the newline collapse.

Adds pm->md->pm count/position round-trip tests for all three sub-cases plus a
mid-paragraph regression guard.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-12 07:10:40 +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);
});
});