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