Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/2026-07-29-collapse-soft-line-breaks.md
Original file line number Diff line number Diff line change
@@ -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.
Original file line number Diff line number Diff line change
@@ -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',
Expand All @@ -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' },
],
},
Expand Down
63 changes: 63 additions & 0 deletions packages/markdown/__tests__/soft-line-break.spec.ts
Original file line number Diff line number Diff line change
@@ -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;' }],
},
])
})
})
5 changes: 3 additions & 2 deletions packages/markdown/src/MarkdownManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import { type Lexer, type Token, type TokenizerExtension, type TokenizerThis, ma

import {
closeMarksBeforeNode,
collapseSoftBreaks,
extractAbsorbedBlankLines,
findMarksToClose,
findMarksToCloseAtEnd,
Expand Down Expand Up @@ -713,7 +714,7 @@ export class MarkdownManager {
// Create text node – decode HTML entities so that e.g. `&lt;` 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
Expand Down Expand Up @@ -906,7 +907,7 @@ export class MarkdownManager {
case 'text':
return {
type: 'text',
text: decodeHtmlEntities(token.text || ''),
text: decodeHtmlEntities(collapseSoftBreaks(token.text || '')),
}

case 'html':
Expand Down
18 changes: 18 additions & 0 deletions packages/markdown/src/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down