Skip to content
Merged
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/twelve-bags-mix.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@tiptap/markdown': patch
---

Fixed two Markdown serialization bugs: overlapping marks (e.g. bold+italic that start and end at different positions) now serialize with the correct delimiter order, and marks that all close on the same node now close in LIFO order to produce valid nesting.
11 changes: 2 additions & 9 deletions packages/markdown/__tests__/conversion.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,12 +49,7 @@ describe('Markdown Conversion Tests', () => {
}),
]

const markdownManager = new MarkdownManager()
extensions.forEach(extension => {
markdownManager.registerExtension(extension as Extension)
})

const conversionExtensions = [] as Extension[]
const conversionExtensions: Extension[] = []

Object.values(conversionfiles).forEach((file: any) => {
if (!file?.extensions) {
Expand All @@ -64,9 +59,7 @@ describe('Markdown Conversion Tests', () => {
conversionExtensions.push(...file.extensions)
})

conversionExtensions.forEach(extension => {
markdownManager.registerExtension(extension as Extension)
})
const markdownManager = new MarkdownManager({ extensions: [...extensions, ...conversionExtensions] })

describe('convert simple taskList from and to markdown', () => {
const simpleMarkdown = `
Expand Down
7 changes: 2 additions & 5 deletions packages/markdown/__tests__/extensions/blockquote.spec.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@
import type { Extension } from '@tiptap/core'
import { Blockquote } from '@tiptap/extension-blockquote'
import { Document } from '@tiptap/extension-document'
import { Heading } from '@tiptap/extension-heading'
Expand All @@ -11,10 +10,8 @@ describe('Blockquote Markdown Conversion', () => {
let markdownManager: MarkdownManager

beforeEach(() => {
markdownManager = new MarkdownManager()
const extensions = [Document, Paragraph, Text, Blockquote, Heading]
extensions.forEach(extension => {
markdownManager.registerExtension(extension as Extension)
markdownManager = new MarkdownManager({
extensions: [Document, Paragraph, Text, Blockquote, Heading],
})
})

Expand Down
49 changes: 18 additions & 31 deletions packages/markdown/__tests__/manager.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,9 +133,7 @@ describe('MarkdownManager Direct Tests', () => {

describe('Basic Markdown Parsing', () => {
beforeEach(() => {
markdownManager = new MarkdownManager()

basicExtensions.forEach(ext => markdownManager.registerExtension(ext))
markdownManager = new MarkdownManager({ extensions: basicExtensions })
})

it('should parse simple text', () => {
Expand Down Expand Up @@ -210,17 +208,14 @@ Second paragraph.`
**After** ordered list`
const isolatedManager = new MarkdownManager({
marked: new Marked() as unknown as typeof import('marked').marked,
extensions: [],
extensions: basicExtensions,
})

basicExtensions.forEach(ext => isolatedManager.registerExtension(ext))

expect(markdownManager.parse(markdown)).toEqual(isolatedManager.parse(markdown))
})

it('keeps later inline parsing stable after a custom tokenizer uses inlineTokens', () => {
const manager = new MarkdownManager()
;[...basicExtensions, TestProbe].forEach(ext => manager.registerExtension(ext))
const manager = new MarkdownManager({ extensions: [...basicExtensions, TestProbe] })

const markdown = `:::probe **Probe** text :::

Expand Down Expand Up @@ -261,9 +256,7 @@ Second paragraph.`
})
describe('simple nested Marks parsing', () => {
beforeEach(() => {
markdownManager = new MarkdownManager()

basicExtensions.forEach(ext => markdownManager.registerExtension(ext))
markdownManager = new MarkdownManager({ extensions: basicExtensions })
})
const nestedMarkdown = `**...++abc++**`

Expand Down Expand Up @@ -299,9 +292,7 @@ Second paragraph.`
})
describe('complex nested Marks parsing', () => {
beforeEach(() => {
markdownManager = new MarkdownManager()

basicExtensions.forEach(ext => markdownManager.registerExtension(ext))
markdownManager = new MarkdownManager({ extensions: basicExtensions })
})
const nestedMarkdown = `**...~~++abc*abc*++~~**`

Expand Down Expand Up @@ -342,8 +333,7 @@ Second paragraph.`
})
describe('Extended Markdown Parsing', () => {
beforeEach(() => {
markdownManager = new MarkdownManager()
extendedExtensions.forEach(ext => markdownManager.registerExtension(ext))
markdownManager = new MarkdownManager({ extensions: extendedExtensions })
})

it('should parse mentions', () => {
Expand Down Expand Up @@ -391,8 +381,7 @@ Final paragraph.`

describe('Markdown Rendering', () => {
beforeEach(() => {
markdownManager = new MarkdownManager()
extendedExtensions.forEach(ext => markdownManager.registerExtension(ext))
markdownManager = new MarkdownManager({ extensions: extendedExtensions })
})

it('should render simple text', () => {
Expand Down Expand Up @@ -584,7 +573,10 @@ Final paragraph.`
})

it('should render nested marks with correct tag order', () => {
// Test case: bold inside strike should render as ~~**text**~~
// Marks come in ProseMirror's canonical order (rank ascending) — Bold is
// registered before Strike so it gets the lower rank. The serializer
// places lower-rank marks outermost, mirroring how a real editor would
// wrap the text: bold outer, strike inner.
const doc = {
type: 'doc',
content: [
Expand Down Expand Up @@ -633,8 +625,8 @@ Final paragraph.`
],
}

expect(markdownManager.renderNodes(doc)).to.equal('Test: ~~**abcd**~~ end.')
expect(markdownManager.renderNodes(docAtEnd)).to.equal('~~**abcd**~~')
expect(markdownManager.renderNodes(doc)).to.equal('Test: **~~abcd~~** end.')
expect(markdownManager.renderNodes(docAtEnd)).to.equal('**~~abcd~~**')
})

it('should render headings', () => {
Expand Down Expand Up @@ -697,8 +689,7 @@ Final paragraph.`

describe('Round-trip Tests', () => {
beforeEach(() => {
markdownManager = new MarkdownManager()
extendedExtensions.forEach(ext => markdownManager.registerExtension(ext))
markdownManager = new MarkdownManager({ extensions: extendedExtensions })
})

const testCases = [
Expand Down Expand Up @@ -742,24 +733,21 @@ Final paragraph.`
it('should work with different extension combinations', () => {
// Test with minimal extensions
const minimalExtensions = [Document, Paragraph, Text]
const minimalManager = new MarkdownManager()
minimalExtensions.forEach(ext => minimalManager.registerExtension(ext))
const minimalManager = new MarkdownManager({ extensions: minimalExtensions })

const simpleDoc = minimalManager.parse('Hello world')
expect(simpleDoc.content![0].type).toBe('paragraph')

// Test with extended extensions
const extendedManager = new MarkdownManager()
extendedExtensions.forEach(ext => extendedManager.registerExtension(ext))
const extendedManager = new MarkdownManager({ extensions: extendedExtensions })

const complexDoc = extendedManager.parse('# Title\n\n[@ id="user" label="User"]')
expect(complexDoc.content![0].type).toBe('heading')
expect(complexDoc.content![1].content![0].type).toBe('mention')
})

it('should handle unknown markdown gracefully', () => {
const manager = new MarkdownManager()
basicExtensions.forEach(ext => manager.registerExtension(ext))
const manager = new MarkdownManager({ extensions: basicExtensions })

// Test various unknown markdown patterns that should be preserved as text
const testCases = [
Expand All @@ -781,8 +769,7 @@ Final paragraph.`

describe('Performance Tests', () => {
beforeEach(() => {
markdownManager = new MarkdownManager()
extendedExtensions.forEach(ext => markdownManager.registerExtension(ext))
markdownManager = new MarkdownManager({ extensions: extendedExtensions })
})

it('should handle large documents efficiently', () => {
Expand Down
Loading
Loading