diff --git a/.changeset/2026-07-29-collapse-soft-line-breaks.md b/.changeset/2026-07-29-collapse-soft-line-breaks.md new file mode 100644 index 0000000000..9f5d3f8294 --- /dev/null +++ b/.changeset/2026-07-29-collapse-soft-line-breaks.md @@ -0,0 +1,5 @@ +--- +'@tiptap/markdown': patch +--- + +Collapse CommonMark soft line breaks into spaces when parsing, so a soft-wrapped paragraph no longer renders with visible line breaks. diff --git a/packages/markdown/__tests__/conversion-files/soft-break-marks.ts b/packages/markdown/__tests__/conversion-files/soft-break-marks.ts index 8e13c41b27..366f2de756 100644 --- a/packages/markdown/__tests__/conversion-files/soft-break-marks.ts +++ b/packages/markdown/__tests__/conversion-files/soft-break-marks.ts @@ -1,10 +1,8 @@ export const name = 'Soft Break with Marks' -export const expectedInput = `**Speaker:** -John Doe. +export const expectedInput = `**Speaker:** John Doe. -**Speaker:** -**John Doe**`.trim() +**Speaker:** **John Doe**`.trim() export const expectedOutput = { type: 'doc', @@ -13,14 +11,14 @@ export const expectedOutput = { type: 'paragraph', content: [ { type: 'text', marks: [{ type: 'bold' }], text: 'Speaker:' }, - { type: 'text', text: '\nJohn Doe.' }, + { type: 'text', text: ' John Doe.' }, ], }, { type: 'paragraph', content: [ { type: 'text', marks: [{ type: 'bold' }], text: 'Speaker:' }, - { type: 'text', text: '\n' }, + { type: 'text', text: ' ' }, { type: 'text', marks: [{ type: 'bold' }], text: 'John Doe' }, ], }, diff --git a/packages/markdown/__tests__/soft-line-break.spec.ts b/packages/markdown/__tests__/soft-line-break.spec.ts new file mode 100644 index 0000000000..46e992e62c --- /dev/null +++ b/packages/markdown/__tests__/soft-line-break.spec.ts @@ -0,0 +1,63 @@ +import { CodeBlock } from '@tiptap/extension-code-block' +import { Document } from '@tiptap/extension-document' +import { Link } from '@tiptap/extension-link' +import { Paragraph } from '@tiptap/extension-paragraph' +import { Text } from '@tiptap/extension-text' +import { MarkdownManager } from '@tiptap/markdown' +import { beforeEach, describe, expect, it } from 'vitest' + +describe('Soft line breaks', () => { + let markdownManager: MarkdownManager + + beforeEach(() => { + markdownManager = new MarkdownManager({ + extensions: [Document, Paragraph, Text, Link, CodeBlock], + }) + }) + + it('collapses a soft-wrapped paragraph into a single line', () => { + const doc = markdownManager.parse('foo\nbar') + + expect(doc.content).toEqual([ + { type: 'paragraph', content: [{ type: 'text', text: 'foo bar' }] }, + ]) + }) + + it('collapses soft breaks around a link without dropping the flanking spaces', () => { + const doc = markdownManager.parse( + 'You can try CommonMark here. This dingus is powered by\n[commonmark.js](https://github.com/commonmark/commonmark.js), the\nJavaScript reference implementation.', + ) + + expect(doc.content).toEqual([ + { + type: 'paragraph', + content: [ + { type: 'text', text: 'You can try CommonMark here. This dingus is powered by ' }, + { + type: 'text', + text: 'commonmark.js', + marks: [ + { + type: 'link', + attrs: { href: 'https://github.com/commonmark/commonmark.js', title: null }, + }, + ], + }, + { type: 'text', text: ', the JavaScript reference implementation.' }, + ], + }, + ]) + }) + + it('keeps newlines inside a code block', () => { + const doc = markdownManager.parse('```js\nconst a = 1;\nconst b = 2;\n```') + + expect(doc.content).toEqual([ + { + type: 'codeBlock', + attrs: { language: 'js' }, + content: [{ type: 'text', text: 'const a = 1;\nconst b = 2;' }], + }, + ]) + }) +}) diff --git a/packages/markdown/src/MarkdownManager.ts b/packages/markdown/src/MarkdownManager.ts index d6f31168c5..6b0434c6f1 100644 --- a/packages/markdown/src/MarkdownManager.ts +++ b/packages/markdown/src/MarkdownManager.ts @@ -25,6 +25,7 @@ import { type Lexer, type Token, type TokenizerExtension, type TokenizerThis, ma import { closeMarksBeforeNode, + collapseSoftBreaks, extractAbsorbedBlankLines, findMarksToClose, findMarksToCloseAtEnd, @@ -713,7 +714,7 @@ export class MarkdownManager { // Create text node – decode HTML entities so that e.g. `<` displays as `<` in the editor result.push({ type: 'text', - text: decodeHtmlEntities(token.text || ''), + text: decodeHtmlEntities(collapseSoftBreaks(token.text || '')), }) } else if (token.type === 'escape') { // Backslash-escaped character: produce a text node with the escaped character @@ -906,7 +907,7 @@ export class MarkdownManager { case 'text': return { type: 'text', - text: decodeHtmlEntities(token.text || ''), + text: decodeHtmlEntities(collapseSoftBreaks(token.text || '')), } case 'html': diff --git a/packages/markdown/src/utils.ts b/packages/markdown/src/utils.ts index 7660780fb9..0931d9e0bd 100644 --- a/packages/markdown/src/utils.ts +++ b/packages/markdown/src/utils.ts @@ -40,6 +40,24 @@ export function extractAbsorbedBlankLines(tokens: MarkdownToken[]): MarkdownToke }) } +/** + * Collapses CommonMark soft line breaks into single spaces. + * + * marked leaves a soft-wrapped paragraph's newlines inside its `text` tokens, + * relying on HTML whitespace collapsing that ProseMirror text nodes don't have. + * Taking the flanking whitespace matches the spec: spaces at a line's end and + * the next line's start are removed. + * + * @param text The text token value to normalize. + * @returns The text with each soft break collapsed to a single space. + * @example + * collapseSoftBreaks('foo\nbar') + * // => 'foo bar' + */ +export function collapseSoftBreaks(text: string): string { + return text.replace(/[ \t]*\n[ \t]*/g, ' ') +} + /** * Wraps each line of the content with the given prefix. * @param prefix The prefix to wrap each line with.