From db85c883d273c5afd75c28d99e9ab3671bcf46b8 Mon Sep 17 00:00:00 2001 From: Hendrik Heil Date: Thu, 11 Jun 2026 16:22:54 +0200 Subject: [PATCH 1/8] fix(tiptap): prevent star accumulation when bold text contains a link MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When a paragraph like `**text **[link](url)**.**` was opened in the TipTap editor and saved, the link gained extra `**` markers on each cycle (visible as `****` in the rendered markdown). Root cause was a three-step chain: 1. `comarkToTiptap` / `createMark`: nested `strong > strong` produced duplicate bold marks on the link text node (e.g. `[link, bold, bold]`). 2. `createParagraphElement` / `getMarkInfo`: a text node with 2+ marks returned `null`, so the link text was split into its own block instead of staying inside the surrounding bold run. 3. The block wrap path returned a raw `ComarkElement` that `.flat()` then spread into individual tokens, corrupting the sibling list. Fixes: - `createMark`: skip adding a mark that is already present in the accumulated set, preventing duplicate bold marks on nested same-type elements (mirrors TipTap's own schema deduplication). - `getMarkInfo`: for a text node whose marks contain exactly one structural (non-link) mark, return that mark — keeping the node grouped with its surrounding bold/italic block. - Mark stripping in the block-wrap path: only remove the block mark (e.g. bold), preserving link and other inline marks so they survive as child elements. - Multi-item block return value: wrap the ComarkElement in an array so the outer `.flat()` treats it as a single sibling, not spreading its contents into the parent list. Fixes nuxt-content/nuxt-studio#470 Co-Authored-By: Claude Sonnet 4.6 --- src/app/src/utils/tiptap/comarkToTiptap.ts | 7 ++- src/app/src/utils/tiptap/tiptapToComark.ts | 22 +++++-- .../unit/utils/tiptap/tiptapToComark.test.ts | 60 +++++++++++++++++++ 3 files changed, 83 insertions(+), 6 deletions(-) create mode 100644 src/app/test/unit/utils/tiptap/tiptapToComark.test.ts diff --git a/src/app/src/utils/tiptap/comarkToTiptap.ts b/src/app/src/utils/tiptap/comarkToTiptap.ts index 1ef4061d..7a6bef44 100644 --- a/src/app/src/utils/tiptap/comarkToTiptap.ts +++ b/src/app/src/utils/tiptap/comarkToTiptap.ts @@ -149,7 +149,12 @@ export function createMark(node: ComarkElement, mark: string, accumulatedMarks: } } - const marks = [...accumulatedMarks, { type: mark, attrs }] + // Deduplicate: nested elements of the same mark type (e.g. strong > strong) + // should not produce two bold marks on the same text node. + const alreadyPresent = accumulatedMarks.some( + m => m.type === mark && JSON.stringify(m.attrs || {}) === JSON.stringify(attrs), + ) + const marks = alreadyPresent ? [...accumulatedMarks] : [...accumulatedMarks, { type: mark, attrs }] function getNodeContent(n: ComarkNode): string { if (typeof n === 'string') return n diff --git a/src/app/src/utils/tiptap/tiptapToComark.ts b/src/app/src/utils/tiptap/tiptapToComark.ts index 2449b6da..f0434b7f 100644 --- a/src/app/src/utils/tiptap/tiptapToComark.ts +++ b/src/app/src/utils/tiptap/tiptapToComark.ts @@ -223,8 +223,16 @@ function createParagraphElement(node: JSONContent, propsArray: Array<[string, st let currentBlockMark: { type: string, attrs?: Record } | null = null const getMarkInfo = (child: JSONContent): { type: string, attrs?: Record } | null => { - if (child.type === 'text' && child.marks?.length === 1 && child.marks?.[0]?.type) { - return child.marks[0] as { type: string, attrs?: Record } + if (child.type === 'text' && child.marks?.length) { + if (child.marks.length === 1) { + return child.marks[0] as { type: string, attrs?: Record } + } + // For text with multiple marks (e.g. bold + link), return the sole structural + // (non-link) mark so the node stays grouped with its surrounding bold/italic block. + const structural = child.marks.filter(m => m.type !== 'link') + if (structural.length === 1) { + return structural[0] as { type: string, attrs?: Record } + } } if ( @@ -269,10 +277,12 @@ function createParagraphElement(node: JSONContent, propsArray: Array<[string, st const children = blocks.map((block) => { // If the block has more than one child and a mark if (block.content.length > 1 && block.mark && markToTag[block.mark.type]) { - // Remove all marks from children + // Remove only the block mark — preserve inline marks such as link so they + // survive as child elements (e.g. bold+link text renders as **[text](url)**). block.content.forEach((child: JSONContent) => { if (child.type === 'text') { - delete child.marks + child.marks = child.marks?.filter(m => m.type !== block.mark!.type) + if (!child.marks?.length) delete child.marks } else if (child.type === 'link-element') { delete child.content![0].marks @@ -280,7 +290,9 @@ function createParagraphElement(node: JSONContent, propsArray: Array<[string, st }) const markAttrs = (block.mark.attrs && Object.keys(block.mark.attrs).length > 0) ? (block.mark.attrs as Record) : {} - return [markToTag[block.mark.type], markAttrs, ...comarkNodesFromTiptap(block.content)] as ComarkElement + // Wrap in an array so the outer .flat() treats this as one ComarkNode entry + // (not spreading the ComarkElement's own children into the sibling list). + return [[markToTag[block.mark.type], markAttrs, ...comarkNodesFromTiptap(block.content)] as ComarkElement] } return comarkNodesFromTiptap(block.content) diff --git a/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts b/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts new file mode 100644 index 00000000..9a1e92b6 --- /dev/null +++ b/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts @@ -0,0 +1,60 @@ +import { describe, expect, test } from 'vitest' +import { tiptapToComark } from '../../../../src/utils/tiptap/tiptapToComark' +import { comarkToTiptap } from '../../../../src/utils/tiptap/comarkToTiptap' +import type { ComarkTree } from 'comark' + +describe('tiptapToComark', () => { + describe('bold + link round-trip', () => { + // Regression: **text **[link](url)**.** used to produce extra ** markers on + // each save cycle because the bold mark accumulated on the link text node, + // causing three separate siblings instead of one. + test('bold paragraph containing a link round-trips without star accumulation', async () => { + const input: ComarkTree = { + frontmatter: {}, + meta: {}, + nodes: [ + ['p', {}, ['strong', {}, 'that contain it ', ['strong', {}, ['a', { href: '/url' }, 'here']], '.']], + ], + } + + const tiptap = comarkToTiptap(input) + const output = await tiptapToComark(tiptap) + + // The paragraph should contain a single strong element wrapping the text + // and the link — NOT three separate strong siblings. + const paragraph = output.nodes[0] as any[] + expect(paragraph[0]).toBe('p') + + const strong = paragraph[2] as any[] + expect(strong[0]).toBe('strong') + + // All content is inside one strong: 'that contain it ', a link, '.' + const strongChildren = strong.slice(2) + expect(strongChildren).toHaveLength(3) + expect(strongChildren[0]).toBe('that contain it ') + expect(Array.isArray(strongChildren[1])).toBe(true) + expect((strongChildren[1] as any[])[0]).toBe('a') + expect(strongChildren[2]).toBe('.') + }) + + test('bold text with link renders to markdown without extra stars', async () => { + const { renderMarkdown } = await import('comark/render') + + const input: ComarkTree = { + frontmatter: {}, + meta: {}, + nodes: [ + ['p', {}, ['strong', {}, 'that contain it ', ['strong', {}, ['a', { href: '/url' }, 'here']], '.']], + ], + } + + const tiptap = comarkToTiptap(input) + const output = await tiptapToComark(tiptap) + const md = (await renderMarkdown(output, { blockAttributesStyle: 'frontmatter' })).trim() + + // Should not contain **** (four stars) — a sign of adjacent bold elements + expect(md).not.toContain('****') + expect(md).toContain('[here](/url)') + }) + }) +}) From 0b7a762c3000dde7965b9d253b68c4d7c12be824 Mon Sep 17 00:00:00 2001 From: Hendrik Heil Date: Thu, 11 Jun 2026 16:29:18 +0200 Subject: [PATCH 2/8] fix(test): replace any types with ComarkElement in tiptapToComark test --- src/app/test/unit/utils/tiptap/tiptapToComark.test.ts | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts b/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts index 9a1e92b6..4f0f0437 100644 --- a/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts +++ b/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts @@ -1,7 +1,7 @@ import { describe, expect, test } from 'vitest' import { tiptapToComark } from '../../../../src/utils/tiptap/tiptapToComark' import { comarkToTiptap } from '../../../../src/utils/tiptap/comarkToTiptap' -import type { ComarkTree } from 'comark' +import type { ComarkTree, ComarkElement } from 'comark' describe('tiptapToComark', () => { describe('bold + link round-trip', () => { @@ -22,10 +22,10 @@ describe('tiptapToComark', () => { // The paragraph should contain a single strong element wrapping the text // and the link — NOT three separate strong siblings. - const paragraph = output.nodes[0] as any[] + const paragraph = output.nodes[0] as ComarkElement expect(paragraph[0]).toBe('p') - const strong = paragraph[2] as any[] + const strong = paragraph[2] as ComarkElement expect(strong[0]).toBe('strong') // All content is inside one strong: 'that contain it ', a link, '.' @@ -33,7 +33,7 @@ describe('tiptapToComark', () => { expect(strongChildren).toHaveLength(3) expect(strongChildren[0]).toBe('that contain it ') expect(Array.isArray(strongChildren[1])).toBe(true) - expect((strongChildren[1] as any[])[0]).toBe('a') + expect((strongChildren[1] as ComarkElement)[0]).toBe('a') expect(strongChildren[2]).toBe('.') }) From 88382ad7a7e46ea155def71930eb5837c61b0655 Mon Sep 17 00:00:00 2001 From: Hendrik Heil Date: Thu, 11 Jun 2026 17:20:51 +0200 Subject: [PATCH 3/8] refactor(tiptap): simplify mark dedup and block-grouping logic - Use `m.type in markToTag` in `getMarkInfo` instead of `m.type !== 'link'`, making markToTag the single source of truth for block-grouping marks - Remove the redundant `length === 1` branch in `getMarkInfo`; the filter handles it uniformly - Simplify `createMark` dedup to a type-only check; structural marks carry no distinguishing attrs so JSON.stringify comparison was unnecessary - Collapse the two-step mark-strip into a single `|| undefined` assignment - Hoist the shared test fixture to describe-block scope Co-Authored-By: Claude Sonnet 4.6 --- src/app/src/utils/tiptap/comarkToTiptap.ts | 7 +++--- src/app/src/utils/tiptap/tiptapToComark.ts | 13 ++++------ .../unit/utils/tiptap/tiptapToComark.test.ts | 24 +++++++------------ 3 files changed, 16 insertions(+), 28 deletions(-) diff --git a/src/app/src/utils/tiptap/comarkToTiptap.ts b/src/app/src/utils/tiptap/comarkToTiptap.ts index 7a6bef44..7619dffe 100644 --- a/src/app/src/utils/tiptap/comarkToTiptap.ts +++ b/src/app/src/utils/tiptap/comarkToTiptap.ts @@ -151,10 +151,9 @@ export function createMark(node: ComarkElement, mark: string, accumulatedMarks: // Deduplicate: nested elements of the same mark type (e.g. strong > strong) // should not produce two bold marks on the same text node. - const alreadyPresent = accumulatedMarks.some( - m => m.type === mark && JSON.stringify(m.attrs || {}) === JSON.stringify(attrs), - ) - const marks = alreadyPresent ? [...accumulatedMarks] : [...accumulatedMarks, { type: mark, attrs }] + const marks = accumulatedMarks.some(m => m.type === mark) + ? [...accumulatedMarks] + : [...accumulatedMarks, { type: mark, attrs }] function getNodeContent(n: ComarkNode): string { if (typeof n === 'string') return n diff --git a/src/app/src/utils/tiptap/tiptapToComark.ts b/src/app/src/utils/tiptap/tiptapToComark.ts index f0434b7f..15b0db4e 100644 --- a/src/app/src/utils/tiptap/tiptapToComark.ts +++ b/src/app/src/utils/tiptap/tiptapToComark.ts @@ -224,12 +224,10 @@ function createParagraphElement(node: JSONContent, propsArray: Array<[string, st const getMarkInfo = (child: JSONContent): { type: string, attrs?: Record } | null => { if (child.type === 'text' && child.marks?.length) { - if (child.marks.length === 1) { - return child.marks[0] as { type: string, attrs?: Record } - } - // For text with multiple marks (e.g. bold + link), return the sole structural - // (non-link) mark so the node stays grouped with its surrounding bold/italic block. - const structural = child.marks.filter(m => m.type !== 'link') + // Keep only marks that participate in block grouping (i.e. those in markToTag). + // This lets a text node carrying both a structural mark (bold) and an inline + // mark (link) stay grouped with its surrounding block instead of being split off. + const structural = child.marks.filter(m => m.type in markToTag) if (structural.length === 1) { return structural[0] as { type: string, attrs?: Record } } @@ -281,8 +279,7 @@ function createParagraphElement(node: JSONContent, propsArray: Array<[string, st // survive as child elements (e.g. bold+link text renders as **[text](url)**). block.content.forEach((child: JSONContent) => { if (child.type === 'text') { - child.marks = child.marks?.filter(m => m.type !== block.mark!.type) - if (!child.marks?.length) delete child.marks + child.marks = child.marks?.filter(m => m.type !== block.mark!.type) || undefined } else if (child.type === 'link-element') { delete child.content![0].marks diff --git a/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts b/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts index 4f0f0437..6251304a 100644 --- a/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts +++ b/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts @@ -8,15 +8,15 @@ describe('tiptapToComark', () => { // Regression: **text **[link](url)**.** used to produce extra ** markers on // each save cycle because the bold mark accumulated on the link text node, // causing three separate siblings instead of one. - test('bold paragraph containing a link round-trips without star accumulation', async () => { - const input: ComarkTree = { - frontmatter: {}, - meta: {}, - nodes: [ - ['p', {}, ['strong', {}, 'that contain it ', ['strong', {}, ['a', { href: '/url' }, 'here']], '.']], - ], - } + const input: ComarkTree = { + frontmatter: {}, + meta: {}, + nodes: [ + ['p', {}, ['strong', {}, 'that contain it ', ['strong', {}, ['a', { href: '/url' }, 'here']], '.']], + ], + } + test('bold paragraph containing a link round-trips without star accumulation', async () => { const tiptap = comarkToTiptap(input) const output = await tiptapToComark(tiptap) @@ -40,14 +40,6 @@ describe('tiptapToComark', () => { test('bold text with link renders to markdown without extra stars', async () => { const { renderMarkdown } = await import('comark/render') - const input: ComarkTree = { - frontmatter: {}, - meta: {}, - nodes: [ - ['p', {}, ['strong', {}, 'that contain it ', ['strong', {}, ['a', { href: '/url' }, 'here']], '.']], - ], - } - const tiptap = comarkToTiptap(input) const output = await tiptapToComark(tiptap) const md = (await renderMarkdown(output, { blockAttributesStyle: 'frontmatter' })).trim() From 43ace60ff5bb20ec4fae80394951e1a7b356a272 Mon Sep 17 00:00:00 2001 From: Hendrik Heil Date: Thu, 11 Jun 2026 17:25:00 +0200 Subject: [PATCH 4/8] style: use descriptive closure param names (existing, mark) over abbreviations --- src/app/src/utils/tiptap/comarkToTiptap.ts | 2 +- src/app/src/utils/tiptap/tiptapToComark.ts | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/src/app/src/utils/tiptap/comarkToTiptap.ts b/src/app/src/utils/tiptap/comarkToTiptap.ts index 7619dffe..2cd2a895 100644 --- a/src/app/src/utils/tiptap/comarkToTiptap.ts +++ b/src/app/src/utils/tiptap/comarkToTiptap.ts @@ -151,7 +151,7 @@ export function createMark(node: ComarkElement, mark: string, accumulatedMarks: // Deduplicate: nested elements of the same mark type (e.g. strong > strong) // should not produce two bold marks on the same text node. - const marks = accumulatedMarks.some(m => m.type === mark) + const marks = accumulatedMarks.some(existing => existing.type === mark) ? [...accumulatedMarks] : [...accumulatedMarks, { type: mark, attrs }] diff --git a/src/app/src/utils/tiptap/tiptapToComark.ts b/src/app/src/utils/tiptap/tiptapToComark.ts index 15b0db4e..98073370 100644 --- a/src/app/src/utils/tiptap/tiptapToComark.ts +++ b/src/app/src/utils/tiptap/tiptapToComark.ts @@ -227,7 +227,7 @@ function createParagraphElement(node: JSONContent, propsArray: Array<[string, st // Keep only marks that participate in block grouping (i.e. those in markToTag). // This lets a text node carrying both a structural mark (bold) and an inline // mark (link) stay grouped with its surrounding block instead of being split off. - const structural = child.marks.filter(m => m.type in markToTag) + const structural = child.marks.filter(mark => mark.type in markToTag) if (structural.length === 1) { return structural[0] as { type: string, attrs?: Record } } @@ -279,7 +279,7 @@ function createParagraphElement(node: JSONContent, propsArray: Array<[string, st // survive as child elements (e.g. bold+link text renders as **[text](url)**). block.content.forEach((child: JSONContent) => { if (child.type === 'text') { - child.marks = child.marks?.filter(m => m.type !== block.mark!.type) || undefined + child.marks = child.marks?.filter(mark => mark.type !== block.mark!.type) || undefined } else if (child.type === 'link-element') { delete child.content![0].marks From d281d8ee890925696451fd5d3101fcd2e4b6e617 Mon Sep 17 00:00:00 2001 From: Hendrik Heil Date: Thu, 11 Jun 2026 17:27:52 +0200 Subject: [PATCH 5/8] style: tighten wrap comment --- src/app/src/utils/tiptap/tiptapToComark.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/src/utils/tiptap/tiptapToComark.ts b/src/app/src/utils/tiptap/tiptapToComark.ts index 98073370..0ff8b7de 100644 --- a/src/app/src/utils/tiptap/tiptapToComark.ts +++ b/src/app/src/utils/tiptap/tiptapToComark.ts @@ -287,8 +287,7 @@ function createParagraphElement(node: JSONContent, propsArray: Array<[string, st }) const markAttrs = (block.mark.attrs && Object.keys(block.mark.attrs).length > 0) ? (block.mark.attrs as Record) : {} - // Wrap in an array so the outer .flat() treats this as one ComarkNode entry - // (not spreading the ComarkElement's own children into the sibling list). + // Wrap so .flat() downstream treats this as one sibling, not a spread. return [[markToTag[block.mark.type], markAttrs, ...comarkNodesFromTiptap(block.content)] as ComarkElement] } From 2d6c453b24482c2cd12e4bdc68e16b92d1d9d012 Mon Sep 17 00:00:00 2001 From: Hendrik Heil Date: Thu, 11 Jun 2026 17:29:02 +0200 Subject: [PATCH 6/8] style: drop flat() comment --- src/app/src/utils/tiptap/tiptapToComark.ts | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/src/utils/tiptap/tiptapToComark.ts b/src/app/src/utils/tiptap/tiptapToComark.ts index 0ff8b7de..f3c811b5 100644 --- a/src/app/src/utils/tiptap/tiptapToComark.ts +++ b/src/app/src/utils/tiptap/tiptapToComark.ts @@ -287,7 +287,6 @@ function createParagraphElement(node: JSONContent, propsArray: Array<[string, st }) const markAttrs = (block.mark.attrs && Object.keys(block.mark.attrs).length > 0) ? (block.mark.attrs as Record) : {} - // Wrap so .flat() downstream treats this as one sibling, not a spread. return [[markToTag[block.mark.type], markAttrs, ...comarkNodesFromTiptap(block.content)] as ComarkElement] } From 94bc962a3d8f3148c448b8a46406a9f6ba17beca Mon Sep 17 00:00:00 2001 From: Hendrik Heil Date: Thu, 11 Jun 2026 17:30:39 +0200 Subject: [PATCH 7/8] test: assert exact markdown output instead of negative containment check --- src/app/test/unit/utils/tiptap/tiptapToComark.test.ts | 4 +--- 1 file changed, 1 insertion(+), 3 deletions(-) diff --git a/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts b/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts index 6251304a..85c14c41 100644 --- a/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts +++ b/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts @@ -44,9 +44,7 @@ describe('tiptapToComark', () => { const output = await tiptapToComark(tiptap) const md = (await renderMarkdown(output, { blockAttributesStyle: 'frontmatter' })).trim() - // Should not contain **** (four stars) — a sign of adjacent bold elements - expect(md).not.toContain('****') - expect(md).toContain('[here](/url)') + expect(md).toBe('**that contain it [here](/url).**') }) }) }) From 39719fe555947b9c47d619f416937ed6a8bfb3c2 Mon Sep 17 00:00:00 2001 From: Hendrik Heil Date: Thu, 11 Jun 2026 17:55:46 +0200 Subject: [PATCH 8/8] fix(tiptap): fix star accumulation on bold+link round-trip Three adjacent siblings (from a bold text, bold link, and bold period) were rendered as **a****b****c** instead of **a b c** because `mergeSiblingsWithSameTag` only merged pairs separated by a single space and did not absorb any further adjacent same-tag siblings that followed. Fix by adding an inner continuation loop after the space-separator merge and a new adjacent-sibling merge path, both using `absorb` (which spreads sibling children into the accumulator to preserve the existing flat-merge semantics). The em/em case (*y **x***) is unaffected. Closes #470 Co-Authored-By: Claude Sonnet 4.6 --- src/app/src/utils/tiptap/tiptapToComark.ts | 62 ++++++++++++------- .../unit/utils/tiptap/tiptapToComark.test.ts | 17 ++--- 2 files changed, 48 insertions(+), 31 deletions(-) diff --git a/src/app/src/utils/tiptap/tiptapToComark.ts b/src/app/src/utils/tiptap/tiptapToComark.ts index f3c811b5..85090446 100644 --- a/src/app/src/utils/tiptap/tiptapToComark.ts +++ b/src/app/src/utils/tiptap/tiptapToComark.ts @@ -223,14 +223,8 @@ function createParagraphElement(node: JSONContent, propsArray: Array<[string, st let currentBlockMark: { type: string, attrs?: Record } | null = null const getMarkInfo = (child: JSONContent): { type: string, attrs?: Record } | null => { - if (child.type === 'text' && child.marks?.length) { - // Keep only marks that participate in block grouping (i.e. those in markToTag). - // This lets a text node carrying both a structural mark (bold) and an inline - // mark (link) stay grouped with its surrounding block instead of being split off. - const structural = child.marks.filter(mark => mark.type in markToTag) - if (structural.length === 1) { - return structural[0] as { type: string, attrs?: Record } - } + if (child.type === 'text' && child.marks?.length === 1 && child.marks?.[0]?.type) { + return child.marks[0] as { type: string, attrs?: Record } } if ( @@ -275,11 +269,9 @@ function createParagraphElement(node: JSONContent, propsArray: Array<[string, st const children = blocks.map((block) => { // If the block has more than one child and a mark if (block.content.length > 1 && block.mark && markToTag[block.mark.type]) { - // Remove only the block mark — preserve inline marks such as link so they - // survive as child elements (e.g. bold+link text renders as **[text](url)**). block.content.forEach((child: JSONContent) => { if (child.type === 'text') { - child.marks = child.marks?.filter(mark => mark.type !== block.mark!.type) || undefined + delete child.marks } else if (child.type === 'link-element') { delete child.content![0].marks @@ -559,6 +551,21 @@ function unwrapDefaultSlot(content: JSONContent[]): JSONContent[] { */ function mergeSiblingsWithSameTag(children: ComarkNode[], allowedTags: string[]): ComarkNode[] { if (!Array.isArray(children)) return children + + const isEl = (n: ComarkNode) => Array.isArray(n) && n[0] !== null + const elTag = (n: ComarkNode) => (n as ComarkElement)[0] as string + const elAttrs = (n: ComarkNode) => (n as ComarkElement)[1] as Record + const elChildren = (n: ComarkNode) => (n as ComarkElement).slice(2) as ComarkNode[] + const sameTag = (a: ComarkNode, b: ComarkNode) => + isEl(a) && isEl(b) + && elTag(a) === elTag(b) + && allowedTags.includes(elTag(a)) + && JSON.stringify(elAttrs(a) || {}) === JSON.stringify(elAttrs(b) || {}) + + // Spread the children of a sibling element into an accumulator (preserves existing behavior). + const absorb = (acc: ComarkElement, sibling: ComarkElement): ComarkElement => + [elTag(acc), elAttrs(acc), ...elChildren(acc), ...elChildren(sibling)] as ComarkElement + const merged: ComarkNode[] = [] let i = 0 while (i < children.length) { @@ -566,27 +573,36 @@ function mergeSiblingsWithSameTag(children: ComarkNode[], allowedTags: string[]) const next = children[i + 1] const afterNext = children[i + 2] - const isEl = (n: ComarkNode) => Array.isArray(n) && n[0] !== null - const elTag = (n: ComarkNode) => (n as ComarkElement)[0] as string - const elAttrs = (n: ComarkNode) => (n as ComarkElement)[1] as Record - const elChildren = (n: ComarkNode) => (n as ComarkElement).slice(2) as ComarkNode[] - if ( current && afterNext - && isEl(current) && isEl(afterNext) - && elTag(current) === elTag(afterNext) - && allowedTags.includes(elTag(current)) - && JSON.stringify(elAttrs(current) || {}) === JSON.stringify(elAttrs(afterNext) || {}) + && sameTag(current, afterNext) && next && typeof next === 'string' && next === ' ' ) { - merged.push([ + // Merge two same-tag elements separated by a single space, then continue + // absorbing any further adjacent same-tag elements that follow immediately. + let acc: ComarkElement = [ elTag(current), elAttrs(current), ...elChildren(current), ' ' as ComarkNode, - ...elChildren(afterNext), - ] as ComarkElement) + ...elChildren(afterNext as ComarkElement), + ] as ComarkElement i += 3 + while (i < children.length && sameTag(acc, children[i])) { + acc = absorb(acc, children[i] as ComarkElement) + i++ + } + merged.push(acc) + } + else if (isEl(current) && allowedTags.includes(elTag(current))) { + // Merge consecutive adjacent same-tag elements (fixes **a****b****c** → **a**b**c**) + let acc = current as ComarkElement + while (i + 1 < children.length && sameTag(acc, children[i + 1])) { + acc = absorb(acc, children[i + 1] as ComarkElement) + i++ + } + merged.push(acc) + i++ } else { merged.push(current) diff --git a/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts b/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts index 85c14c41..6d167eda 100644 --- a/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts +++ b/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts @@ -20,21 +20,22 @@ describe('tiptapToComark', () => { const tiptap = comarkToTiptap(input) const output = await tiptapToComark(tiptap) - // The paragraph should contain a single strong element wrapping the text - // and the link — NOT three separate strong siblings. + // All content merges into a single strong — NOT three separate strong siblings + // (which would render as ****). const paragraph = output.nodes[0] as ComarkElement expect(paragraph[0]).toBe('p') const strong = paragraph[2] as ComarkElement expect(strong[0]).toBe('strong') - // All content is inside one strong: 'that contain it ', a link, '.' + // Single strong: 'that contain it', ' ', a link, '.' const strongChildren = strong.slice(2) - expect(strongChildren).toHaveLength(3) - expect(strongChildren[0]).toBe('that contain it ') - expect(Array.isArray(strongChildren[1])).toBe(true) - expect((strongChildren[1] as ComarkElement)[0]).toBe('a') - expect(strongChildren[2]).toBe('.') + expect(strongChildren).toHaveLength(4) + expect(strongChildren[0]).toBe('that contain it') + expect(strongChildren[1]).toBe(' ') + expect(Array.isArray(strongChildren[2])).toBe(true) + expect((strongChildren[2] as ComarkElement)[0]).toBe('a') + expect(strongChildren[3]).toBe('.') }) test('bold text with link renders to markdown without extra stars', async () => {