import { Editor } from '@tiptap/core'
import { Bold } from '@tiptap/extension-bold'
import { Document } from '@tiptap/extension-document'
import { HardBreak } from '@tiptap/extension-hard-break'
import { Italic } from '@tiptap/extension-italic'
import { Link } from '@tiptap/extension-link'
import { Paragraph } from '@tiptap/extension-paragraph'
import Strike from '@tiptap/extension-strike'
import { Text } from '@tiptap/extension-text'
import { Markdown } from '@tiptap/markdown'
import { describe, expect, it } from 'vite-plus/test'
import { MarkdownManager } from '../../src/MarkdownManager.js'
/**
* Normalize marks order for deterministic comparison. Marks in the same position
* can be stored in any order by ProseMirror; sorting by type ensures
* deep-equality assertions don't flake on array ordering.
*/
const normalizeMarks = (node: any): any => {
if (Array.isArray(node)) {
return node.map(normalizeMarks)
}
if (!node || typeof node !== 'object') {
return node
}
return {
...node,
marks: node.marks
? [...node.marks].sort((a: any, b: any) => a.type.localeCompare(b.type))
: node.marks,
content: node.content ? node.content.map(normalizeMarks) : node.content,
}
}
describe('Overlapping marks serialization', () => {
const extensions = [Document, Paragraph, Text, Bold, Italic, Link]
const markdownManager = new MarkdownManager({ extensions })
/**
* Regression test for the original bug report.
*
* Steps to reproduce:
* 1. Toggle bold on
* 2. Type "123"
* 3. Toggle italic on
* 4. Type "456"
* 5. Toggle bold off
* 6. Type "789"
* 7. Toggle italic off
*
* Expected: **123*456***789
* Actual (buggy): **123*456**789*
*/
it('should correctly serialize overlapping bold and italic marks', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: '123',
marks: [{ type: 'bold' }],
},
{
type: 'text',
text: '456',
marks: [{ type: 'bold' }, { type: 'italic' }],
},
{
type: 'text',
text: '789',
marks: [{ type: 'italic' }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// The output **123*456**789* is invalid markdown because bold closes
// while italic is still open, breaking the nesting order.
// The valid markdown closes the nested italic inside bold first, then
// reopens the remaining italic segment with inline HTML to avoid ambiguous `***` parsing.
expect(result).toBe('**123*456***789')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
/**
* Mirror of the original bug: italic starts first, bold is added, then italic ends.
*
* Steps: italic on → "abc" → bold on → "def" → italic off → "ghi" → bold off
*/
it('should correctly serialize italic starting before bold', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'abc',
marks: [{ type: 'italic' }],
},
{
type: 'text',
text: 'def',
marks: [{ type: 'italic' }, { type: 'bold' }],
},
{
type: 'text',
text: 'ghi',
marks: [{ type: 'bold' }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// italic opens first with *, bold opens inside it, then the remaining bold span
// reopens with inline HTML after italic closes so the markdown remains unambiguous.
expect(result).toBe('*abc**def***ghi')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
/**
* Bold and italic both start and end together on the same node (no overlap).
*/
it('should correctly serialize bold and italic on the same text', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'hello',
marks: [{ type: 'bold' }, { type: 'italic' }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// Both marks open and close on the same node — ***hello*** is valid CommonMark for bold+italic
expect(result).toBe('***hello***')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
// Mark order matches what a real editor emits: Link has priority 1000, so
// ProseMirror assigns it the lowest rank and stores it first in the marks
// array. The serializer must still place link as the outermost wrapper.
it('serializes italic inside a same-node link label', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
{ type: 'italic' },
],
},
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('[*google*](https://google.com)')
expect(result).not.toBe('*[google](https://google.com)*')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('serializes bold inside a same-node link label', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
{ type: 'bold' },
],
},
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('[**google**](https://google.com)')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('keeps html-reopened italic open across later text nodes before closing', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '123', marks: [{ type: 'bold' }] },
{ type: 'text', text: '456', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: '789', marks: [{ type: 'italic' }] },
{ type: 'text', text: 'xyz', marks: [{ type: 'italic' }] },
{ type: 'text', text: ' plain' },
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('**123*456***789xyz plain')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(
normalizeMarks({
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '123', marks: [{ type: 'bold' }] },
{ type: 'text', text: '456', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: '789xyz', marks: [{ type: 'italic' }] },
{ type: 'text', text: ' plain' },
],
},
],
}),
)
})
it('closes html-reopened italic correctly before a hard break', () => {
const markdownManagerWithBreak = new MarkdownManager({
extensions: [Document, Paragraph, Text, Bold, Italic, HardBreak],
})
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: '123', marks: [{ type: 'bold' }] },
{ type: 'text', text: '456', marks: [{ type: 'bold' }, { type: 'italic' }] },
{ type: 'text', text: '789', marks: [{ type: 'italic' }] },
{ type: 'hardBreak' },
{ type: 'text', text: 'after' },
],
},
],
}
const result = markdownManagerWithBreak.serialize(json)
expect(result).toBe('**123*456***789 \nafter')
expect(normalizeMarks(markdownManagerWithBreak.parse(result))).toEqual(normalizeMarks(json))
})
it('does not switch non-bold-italic marks to html reopen mode', () => {
const markdownManagerWithStrike = new MarkdownManager({
extensions: [Document, Paragraph, Text, Italic, Strike],
})
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'abc', marks: [{ type: 'italic' }] },
{ type: 'text', text: 'def', marks: [{ type: 'italic' }, { type: 'strike' }] },
{ type: 'text', text: 'ghi', marks: [{ type: 'strike' }] },
],
},
],
}
const result = markdownManagerWithStrike.serialize(json)
expect(result).toBe('*abc~~def~~*~~ghi~~')
expect(normalizeMarks(markdownManagerWithStrike.parse(result))).toEqual(normalizeMarks(json))
})
it('serializes italic on the leading subset of link text inside the link label', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{ type: 'italic' },
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
{
type: 'text',
text: ' search',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('[*google* search](https://google.com)')
expect(result).not.toBe('*[google* search](https://google.com)')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('serializes bold on the leading subset of link text inside the link label', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{ type: 'bold' },
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
{
type: 'text',
text: ' search',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
],
},
],
}
const result = markdownManager.serialize(json)
expect(result).toBe('[**google** search](https://google.com)')
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('serializes partial italic in link text correctly from editor commands', () => {
const editor = new Editor({
extensions: [Document, Paragraph, Text, Bold, Italic, Link, Markdown],
content: {
type: 'doc',
content: [
{
type: 'paragraph',
content: [{ type: 'text', text: 'google search' }],
},
],
},
})
editor.commands.selectAll()
editor.commands.setLink({ href: 'https://google.com' })
editor.commands.setTextSelection({ from: 1, to: 7 })
editor.commands.setItalic()
const json = editor.getJSON()
const result = editor.getMarkdown()
const directResult = markdownManager.serialize(json)
const expectedRoundtripJson = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'google',
marks: [
{ type: 'italic' },
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
{
type: 'text',
text: ' search',
marks: [
{
type: 'link',
attrs: {
href: 'https://google.com',
title: null,
},
},
],
},
],
},
],
}
expect(result).toBe('[*google* search](https://google.com)')
expect(result).not.toBe('*[google* search](https://google.com)')
expect(directResult).toBe(result)
expect(normalizeMarks(editor.markdown?.parse(result))).toEqual(
normalizeMarks(expectedRoundtripJson),
)
editor.destroy()
})
/**
* Regression test for issue #7728.
* When bold, italic, and strike all close on the same node they must close
* LIFO (last-opened first-closed) to produce valid nesting.
*/
it('closes multiple simultaneously-ending marks in LIFO order (simple path)', () => {
const markdownManagerWithStrike = new MarkdownManager({
extensions: [Document, Paragraph, Text, Bold, Italic, Strike],
})
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'bold ', marks: [{ type: 'bold' }] },
{ type: 'text', text: 'italics ', marks: [{ type: 'bold' }, { type: 'italic' }] },
{
type: 'text',
text: 'strike',
marks: [{ type: 'bold' }, { type: 'italic' }, { type: 'strike' }],
},
],
},
],
}
const result = markdownManagerWithStrike.serialize(json)
expect(result).toBe('**bold *italics ~~strike~~***')
expect(normalizeMarks(markdownManagerWithStrike.parse(result))).toEqual(normalizeMarks(json))
})
/**
* Regression test for the crossed-boundary LIFO bug.
* Bold (outer) and strike (inner) are both active; italic opens while both
* close at the same boundary. Previously-active marks must still close in
* LIFO order (strike first, bold last) even though a new mark is opening.
*
* Buggy output: **~~abc*def***~~*ghi* (bold closes before strike — invalid)
* Correct output: **~~abc*def*~~***ghi* (strike closes before bold — valid)
*/
it('closes previously-active marks in LIFO order on a crossed boundary', () => {
const markdownManagerWithStrike = new MarkdownManager({
extensions: [Document, Paragraph, Text, Bold, Italic, Strike],
})
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{ type: 'text', text: 'abc', marks: [{ type: 'bold' }, { type: 'strike' }] },
{
type: 'text',
text: 'def',
marks: [{ type: 'bold' }, { type: 'strike' }, { type: 'italic' }],
},
{ type: 'text', text: 'ghi', marks: [{ type: 'italic' }] },
],
},
],
}
const result = markdownManagerWithStrike.serialize(json)
// Strike must close before bold (LIFO). Bold then closes, and italic for
// "ghi" reopens with to avoid the ambiguous *** sequence.
expect(result).toBe('**~~abc*def*~~**ghi')
// Buggy output (bold closes before strike): '**~~abc*def***~~ghi'
expect(result).not.toBe('**~~abc*def***~~ghi')
expect(normalizeMarks(markdownManagerWithStrike.parse(result))).toEqual(normalizeMarks(json))
})
})
/**
* Regression tests for issue #7682.
*
* Adjacent marks of the same type (e.g. links) with different attributes must
* be serialized as separate marks instead of being merged into one.
*/
describe('adjacent marks with different attributes', () => {
const extensions = [Document, Paragraph, Text, Link]
const markdownManager = new MarkdownManager({ extensions })
it('should serialize adjacent links with different href values as separate links', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'example',
marks: [{ type: 'link', attrs: { href: 'https://example.com', title: null } }],
},
{
type: 'text',
text: 'github',
marks: [{ type: 'link', attrs: { href: 'https://github.com', title: null } }],
},
{
type: 'text',
text: 'tiptap',
marks: [{ type: 'link', attrs: { href: 'https://tiptap.dev', title: null } }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// Each adjacent link must be rendered with its own URL — not merged into one
expect(result).toBe(
'[example](https://example.com)[github](https://github.com)[tiptap](https://tiptap.dev)',
)
// Verify round-trip fidelity
expect(normalizeMarks(markdownManager.parse(result))).toEqual(normalizeMarks(json))
})
it('should keep links with the same href and attributes merged', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'the same',
marks: [{ type: 'link', attrs: { href: 'https://example.com', title: null } }],
},
{
type: 'text',
text: ' link',
marks: [{ type: 'link', attrs: { href: 'https://example.com', title: null } }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// Links with the same href and attributes are merged into one
expect(result).toBe('[the same link](https://example.com)')
// Verify re-serialization stability: parse then re-serialize produces the same output
const parsed = markdownManager.parse(result)
expect(markdownManager.serialize(parsed)).toBe(result)
})
it('should handle adjacent links with titles alongside links without titles', () => {
const json = {
type: 'doc',
content: [
{
type: 'paragraph',
content: [
{
type: 'text',
text: 'with title',
marks: [{ type: 'link', attrs: { href: 'https://example.com', title: 'Example' } }],
},
{
type: 'text',
text: ' no title',
marks: [{ type: 'link', attrs: { href: 'https://example.org', title: null } }],
},
],
},
],
}
const result = markdownManager.serialize(json)
// Note: the leading space in " no title" is extracted outside the link
// brackets by the serializer's whitespace handling, resulting in a space
// between the two link markdown fragments.
expect(result).toBe(
'[with title](https://example.com "Example") [no title](https://example.org)',
)
// Verify re-serialization stability: parse then re-serialize produces the same output
const parsed = markdownManager.parse(result)
expect(markdownManager.serialize(parsed)).toBe(result)
})
})