From 479601383470cc508f09ef020c22d55d46c38631 Mon Sep 17 00:00:00 2001 From: bdbch <6538827+bdbch@users.noreply.github.com> Date: Mon, 4 May 2026 16:55:02 +0200 Subject: [PATCH] fix(markdown): preserve mark order when serializing link label emphasis (#7792) * fix(markdown): fix marks not being ordered by extension load order * Potential fix for pull request finding Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> * fix(markdown): remove unused extensionRanks and improve marks serialization logic * fix(markdown): enhance serialization of overlapping marks in link labels * fix(markdown): improve serialization of marks in link labels and simplify test cases * fix(markdown): improve mark serialization order to ensure correct nesting and boundary handling * fix(markdown): streamline MarkdownManager initialization by directly passing extensions * fix(markdown): streamline MarkdownManager initialization by directly passing extensions * fix(markdown): ensure LIFO order for closing previously-active marks in MarkdownManager --------- Co-authored-by: Copilot Autofix powered by AI <175728472+Copilot@users.noreply.github.com> --- .changeset/twelve-bags-mix.md | 5 + .../markdown/__tests__/conversion.spec.ts | 11 +- .../__tests__/extensions/blockquote.spec.ts | 7 +- packages/markdown/__tests__/manager.spec.ts | 49 ++- .../__tests__/overlapping-marks.spec.ts | 298 +++++++++++++++++- packages/markdown/__tests__/paragraph.spec.ts | 16 +- packages/markdown/src/MarkdownManager.ts | 116 +++++-- 7 files changed, 423 insertions(+), 79 deletions(-) create mode 100644 .changeset/twelve-bags-mix.md diff --git a/.changeset/twelve-bags-mix.md b/.changeset/twelve-bags-mix.md new file mode 100644 index 0000000000..d0b87e0727 --- /dev/null +++ b/.changeset/twelve-bags-mix.md @@ -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. diff --git a/packages/markdown/__tests__/conversion.spec.ts b/packages/markdown/__tests__/conversion.spec.ts index 99b7f9241d..e55673d92d 100644 --- a/packages/markdown/__tests__/conversion.spec.ts +++ b/packages/markdown/__tests__/conversion.spec.ts @@ -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) { @@ -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 = ` diff --git a/packages/markdown/__tests__/extensions/blockquote.spec.ts b/packages/markdown/__tests__/extensions/blockquote.spec.ts index 9ccfdbace8..87543269d5 100644 --- a/packages/markdown/__tests__/extensions/blockquote.spec.ts +++ b/packages/markdown/__tests__/extensions/blockquote.spec.ts @@ -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' @@ -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], }) }) diff --git a/packages/markdown/__tests__/manager.spec.ts b/packages/markdown/__tests__/manager.spec.ts index 7f2502e6f6..a4a506bb02 100644 --- a/packages/markdown/__tests__/manager.spec.ts +++ b/packages/markdown/__tests__/manager.spec.ts @@ -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', () => { @@ -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 ::: @@ -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++**` @@ -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*++~~**` @@ -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', () => { @@ -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', () => { @@ -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: [ @@ -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', () => { @@ -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 = [ @@ -742,15 +733,13 @@ 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') @@ -758,8 +747,7 @@ Final paragraph.` }) 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 = [ @@ -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', () => { diff --git a/packages/markdown/__tests__/overlapping-marks.spec.ts b/packages/markdown/__tests__/overlapping-marks.spec.ts index d8ae43e92d..a6eb17df0e 100644 --- a/packages/markdown/__tests__/overlapping-marks.spec.ts +++ b/packages/markdown/__tests__/overlapping-marks.spec.ts @@ -1,16 +1,19 @@ +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 'vitest' import { MarkdownManager } from '../src/MarkdownManager.js' describe('Overlapping marks serialization', () => { - const extensions = [Document, Paragraph, Text, Bold, Italic] + const extensions = [Document, Paragraph, Text, Bold, Italic, Link] const markdownManager = new MarkdownManager({ extensions }) const normalizeMarks = (node: any): any => { if (Array.isArray(node)) { @@ -147,6 +150,74 @@ describe('Overlapping marks serialization', () => { 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', @@ -234,4 +305,229 @@ describe('Overlapping marks serialization', () => { 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)) + }) }) diff --git a/packages/markdown/__tests__/paragraph.spec.ts b/packages/markdown/__tests__/paragraph.spec.ts index 15c526b8f0..739131acbf 100644 --- a/packages/markdown/__tests__/paragraph.spec.ts +++ b/packages/markdown/__tests__/paragraph.spec.ts @@ -1,4 +1,3 @@ -import type { AnyExtension } from '@tiptap/core' import { Blockquote } from '@tiptap/extension-blockquote' import { Document } from '@tiptap/extension-document' import { Heading } from '@tiptap/extension-heading' @@ -12,19 +11,8 @@ describe('Paragraph Markdown Rendering', () => { let markdownManager: MarkdownManager beforeEach(() => { - markdownManager = new MarkdownManager() - const extensions: AnyExtension[] = [ - Document, - Paragraph, - Text, - Heading, - Blockquote, - BulletList, - OrderedList, - ListItem, - ] - extensions.forEach(extension => { - markdownManager.registerExtension(extension) + markdownManager = new MarkdownManager({ + extensions: [Document, Paragraph, Text, Heading, Blockquote, BulletList, OrderedList, ListItem], }) }) diff --git a/packages/markdown/src/MarkdownManager.ts b/packages/markdown/src/MarkdownManager.ts index 331ce6875b..7591d4a937 100644 --- a/packages/markdown/src/MarkdownManager.ts +++ b/packages/markdown/src/MarkdownManager.ts @@ -16,6 +16,7 @@ import { flattenExtensions, generateJSON, getExtensionField, + sortExtensions, } from '@tiptap/core' import { type Lexer, type Token, type TokenizerExtension, type TokenizerThis, marked } from 'marked' @@ -34,6 +35,17 @@ export class MarkdownManager { private activeParseLexer: Lexer | null = null private registry: Map private nodeTypeRegistry: Map + /** + * Order in which extensions were registered. Used to resolve mark nesting + * deterministically when several marks open on the same text node. + * + * The flattened extensions passed to the manager are pre-sorted by Tiptap's + * extension priority (descending), which is also the order ProseMirror uses + * to assign mark ranks. Recording that index here lets the serializer place + * higher-priority / lower-rank marks (e.g. link with priority 1000) on the + * outside without inspecting any rendered markdown output. + */ + private extensionRanks: Map = new Map() private indentStyle: 'space' | 'tab' private indentSize: number private baseExtensions: AnyExtension[] = [] @@ -66,10 +78,13 @@ export class MarkdownManager { this.registry = new Map() this.nodeTypeRegistry = new Map() - // If extensions were provided, register them now + // If extensions were provided, register them now. Sort by Tiptap priority + // first (matching how the editor builds its schema) so the registration + // index lines up with ProseMirror's mark rank — this is what the + // serializer relies on to nest higher-priority marks like link outermost. if (options?.extensions) { this.baseExtensions = options.extensions - const flattened = flattenExtensions(options.extensions) + const flattened = sortExtensions(flattenExtensions(options.extensions)) flattened.forEach(ext => this.registerExtension(ext)) } } @@ -112,6 +127,10 @@ export class MarkdownManager { if (isCode) { this.codeTypes.add(name) } + + if (!this.extensionRanks.has(name)) { + this.extensionRanks.set(name, this.extensionRanks.size) + } const tokenName = (getExtensionField(extension, 'markdownTokenName') as ExtendableConfig['markdownTokenName']) || name const parseMarkdown = getExtensionField(extension, 'parseMarkdown') as ExtendableConfig['parseMarkdown'] | undefined @@ -1022,7 +1041,7 @@ export class MarkdownManager { const currentMarks = new Map((node.marks || []).map(mark => [mark.type, mark])) // Find marks that need to be closed and opened - const marksToOpen = findMarksToOpen(activeMarks, currentMarks) + const marksToOpen = this.getMarksToOpenForSerialization(activeMarks, currentMarks, nextNode) const marksToClose = findMarksToClose(currentMarks, nextNode) // When marks simultaneously close (old) AND open (new) at this boundary, the naive @@ -1051,22 +1070,26 @@ export class MarkdownManager { } if (!hasCrossedBoundary) { - // Normal path: close marks that are ending here (no new marks opening simultaneously) - marksToClose.forEach(markType => { - if (!activeMarks.has(markType)) { - return - } + // Normal path: close marks that are ending here (no new marks opening simultaneously). + // Reverse so the last-opened mark closes first (LIFO), preserving valid nesting. + marksToClose + .slice() + .reverse() + .forEach(markType => { + if (!activeMarks.has(markType)) { + return + } - const mark = currentMarks.get(markType) - const closeMarkdown = this.getMarkClosing(markType, mark, markOpeningModes.get(markType)) - if (closeMarkdown) { - textContent += closeMarkdown - } - if (activeMarks.has(markType)) { - activeMarks.delete(markType) - markOpeningModes.delete(markType) - } - }) + const mark = currentMarks.get(markType) + const closeMarkdown = this.getMarkClosing(markType, mark, markOpeningModes.get(markType)) + if (closeMarkdown) { + textContent += closeMarkdown + } + if (activeMarks.has(markType)) { + activeMarks.delete(markType) + markOpeningModes.delete(markType) + } + }) } // Open new marks (should be at the beginning) @@ -1120,9 +1143,17 @@ export class MarkdownManager { } }) + // Sort the previously-active closures in LIFO order: the mark that + // was opened last (innermost) must close first. activeMarks preserves + // insertion order, so a higher indexOf means opened later = inner. + const activeMarkKeys = Array.from(activeMarks.keys()) + const activeMarksClosingHereLifo = activeMarksClosingHere + .slice() + .sort((a, b) => activeMarkKeys.indexOf(b) - activeMarkKeys.indexOf(a)) + marksToCloseAtEnd = [ ...marksToOpen.map(m => m.type), // inner (opened here) — close first - ...activeMarksClosingHere, // outer (were active before) — close last + ...activeMarksClosingHereLifo, // outer (were active before) — close last, LIFO ] } else { marksToCloseAtEnd = findMarksToCloseAtEnd(activeMarks, currentMarks, nextNode, this.markSetsEqual.bind(this)) @@ -1294,6 +1325,53 @@ export class MarkdownManager { return Array.from(marks1.keys()).every(type => marks2.has(type)) } + + /** + * Decide the order in which marks open on the current text node. + * + * The returned array is iterated head-first when prepending opening + * delimiters, so the first entry becomes the innermost mark in the emitted + * markdown and the last becomes the outermost. Two stable signals drive + * the order — neither one inspects any rendered markdown: + * + * 1. Marks that end on this node must be inner relative to marks that + * continue into the next node, otherwise the delimiters interleave + * instead of nesting. + * 2. Within each lifetime group, marks are sorted so that lower + * registration ranks (i.e. higher Tiptap extension priorities) end up + * outermost. ProseMirror assigns mark ranks in the same priority-aware + * order Tiptap uses when building the schema, so link (priority 1000) + * naturally wraps bold/italic without the serializer needing to peek + * at how any particular mark renders. + */ + private getMarksToOpenForSerialization(activeMarks: Map, currentMarks: Map, nextNode: any) { + const marksToOpen = findMarksToOpen(activeMarks, currentMarks) + + if (marksToOpen.length <= 1) { + return marksToOpen + } + + const nextMarkTypes = new Set((nextNode?.marks || []).map((mark: any) => mark.type)) + + // Higher rank → earlier in the array → innermost mark. Marks without a + // recorded rank fall back to MAX_SAFE_INTEGER so they sort innermost, + // matching the implicit "registered last" assumption for ad-hoc marks. + const byRankInnerFirst = (a: { type: string }, b: { type: string }) => { + const rankA = this.extensionRanks.get(a.type) ?? Number.MAX_SAFE_INTEGER + const rankB = this.extensionRanks.get(b.type) ?? Number.MAX_SAFE_INTEGER + + if (rankA !== rankB) { + return rankB - rankA + } + + return a.type.localeCompare(b.type) + } + + const endingHere = marksToOpen.filter(mark => !nextMarkTypes.has(mark.type)).sort(byRankInnerFirst) + const continuing = marksToOpen.filter(mark => nextMarkTypes.has(mark.type)).sort(byRankInnerFirst) + + return [...endingHere, ...continuing] + } } export default MarkdownManager