diff --git a/packages/prosemirror-markdown/src/lib/markdown-converter.ts b/packages/prosemirror-markdown/src/lib/markdown-converter.ts
index fdf54afd..8a9b933a 100644
--- a/packages/prosemirror-markdown/src/lib/markdown-converter.ts
+++ b/packages/prosemirror-markdown/src/lib/markdown-converter.ts
@@ -624,6 +624,23 @@ export function convertProseMirrorToMarkdown(
// escape. A real footnoteReference node emits `^[body]` from its own
// case, never through here.
textContent = textContent.replace(/\^\[/g, "^\\[");
+ // #554: a LITERAL inline-HTML break tag typed as prose text (`
`,
+ // `
`, `
`, case-insensitive / optional whitespace) would be
+ // parsed by marked as an inline-HTML line break on re-import, silently
+ // turning the user's literal text into a hardBreak node. HTML-entity-
+ // encode only the angle brackets of a break-tag sequence so it lands
+ // in the markdown as `<br>`: marked passes the entities through
+ // and the importer decodes them back to the literal characters `
`,
+ // so the run round-trips as text and NEVER materializes a hardBreak.
+ // Scoped strictly to the `
` pattern (not every `<`/`>`), so stray
+ // angle brackets in ordinary prose (`a < b > c`) are untouched. This
+ // is the text-content path ONLY; a real hardBreak node serializes from
+ // its own case (` \n`, or `
` via inlineToHtml on the raw-HTML
+ // path), so the serializer's own emitted breaks are never escaped.
+ textContent = textContent.replace(
+ /<(\s*br\s*\/?\s*)>/gi,
+ "<$1>",
+ );
}
// Apply marks (bold, italic, code, etc.)
if (node.marks) {
diff --git a/packages/prosemirror-markdown/test/roundtrip.test.ts b/packages/prosemirror-markdown/test/roundtrip.test.ts
index 3132f904..35123c8d 100644
--- a/packages/prosemirror-markdown/test/roundtrip.test.ts
+++ b/packages/prosemirror-markdown/test/roundtrip.test.ts
@@ -127,6 +127,67 @@ describe('mention round-trip', () => {
});
});
+describe('#554 literal
in text is not converted to a hardBreak', () => {
+ // Count hardBreak nodes anywhere in a doc tree.
+ const countHardBreaks = (n: any): number => {
+ if (!n) return 0;
+ let c = n.type === 'hardBreak' ? 1 : 0;
+ for (const ch of n.content || []) c += countHardBreaks(ch);
+ return c;
+ };
+ // Concatenate every text run's content in a doc tree.
+ const collectText = (n: any): string => {
+ if (!n) return '';
+ if (n.type === 'text') return n.text || '';
+ let s = '';
+ for (const ch of n.content || []) s += collectText(ch);
+ return s;
+ };
+
+ // Each literal break-tag variant a user could TYPE into prose. On import
+ // marked would otherwise parse these as an inline-HTML line break, silently
+ // turning the typed text into a hardBreak node and dropping the text.
+ for (const literal of ['a
b', 'a
b', 'a
b']) {
+ it(`"${literal}" round-trips as literal text with ZERO hardBreaks`, async () => {
+ const source = para(text(literal));
+ const { md1, doc2, md2 } = await roundTrip(source);
+
+ // The break tag's angle brackets are HTML-entity-encoded in the markdown
+ // so marked passes them through as literal text instead of a break.
+ expect(md1).toContain('<br');
+ expect(md1).not.toMatch(/<\s*br/i);
+ // Byte-stable second export.
+ expect(md2).toBe(md1);
+
+ // The re-imported doc has NO hardBreak and its text still contains the
+ // literal `
` the user typed.
+ expect(countHardBreaks(doc2)).toBe(0);
+ expect(collectText(doc2)).toContain('
{
+ const source = para(text('a'), { type: 'hardBreak' }, text('b'));
+ const { md1, doc2, md2 } = await roundTrip(source);
+
+ // The hardBreak case emits the two-space markdown form, NOT a `
`, so
+ // the literal-text escaping above never touches the serializer's own break.
+ expect(md1).toBe('a \nb');
+ expect(md2).toBe(md1);
+ expect(countHardBreaks(doc2)).toBe(1);
+ });
+
+ it('ordinary stray angle brackets in prose are left untouched (no over-escape)', async () => {
+ const source = para(text('a < b > c'));
+ const { md1, doc2, md2 } = await roundTrip(source);
+
+ // Only the `
` pattern is escaped; a lone `<`/`>` is not.
+ expect(md1).toBe('a < b > c');
+ expect(md2).toBe(md1);
+ expect(collectText(doc2)).toContain('a < b > c');
+ });
+});
+
describe('details open-attribute round-trip', () => {
it('the markdown details fence never carries an open flag and stays byte-stable', async () => {
// Source details is OPEN (attrs.open: ''), but the top-level markdown path