diff --git a/src/app/src/utils/tiptap/comarkToTiptap.ts b/src/app/src/utils/tiptap/comarkToTiptap.ts index 1ef4061d..2cd2a895 100644 --- a/src/app/src/utils/tiptap/comarkToTiptap.ts +++ b/src/app/src/utils/tiptap/comarkToTiptap.ts @@ -149,7 +149,11 @@ 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 marks = accumulatedMarks.some(existing => existing.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 2449b6da..85090446 100644 --- a/src/app/src/utils/tiptap/tiptapToComark.ts +++ b/src/app/src/utils/tiptap/tiptapToComark.ts @@ -269,7 +269,6 @@ 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 block.content.forEach((child: JSONContent) => { if (child.type === 'text') { delete child.marks @@ -280,7 +279,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) : {} - return [markToTag[block.mark.type], markAttrs, ...comarkNodesFromTiptap(block.content)] as ComarkElement + return [[markToTag[block.mark.type], markAttrs, ...comarkNodesFromTiptap(block.content)] as ComarkElement] } return comarkNodesFromTiptap(block.content) @@ -552,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) { @@ -559,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 new file mode 100644 index 00000000..6d167eda --- /dev/null +++ b/src/app/test/unit/utils/tiptap/tiptapToComark.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, test } from 'vitest' +import { tiptapToComark } from '../../../../src/utils/tiptap/tiptapToComark' +import { comarkToTiptap } from '../../../../src/utils/tiptap/comarkToTiptap' +import type { ComarkTree, ComarkElement } 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. + 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) + + // 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') + + // Single strong: 'that contain it', ' ', a link, '.' + const strongChildren = strong.slice(2) + 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 () => { + const { renderMarkdown } = await import('comark/render') + + const tiptap = comarkToTiptap(input) + const output = await tiptapToComark(tiptap) + const md = (await renderMarkdown(output, { blockAttributesStyle: 'frontmatter' })).trim() + + expect(md).toBe('**that contain it [here](/url).**') + }) + }) +})