Skip to content
6 changes: 5 additions & 1 deletion src/app/src/utils/tiptap/comarkToTiptap.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
51 changes: 37 additions & 14 deletions src/app/src/utils/tiptap/tiptapToComark.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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<string, unknown>) : {}
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)
Expand Down Expand Up @@ -552,34 +551,58 @@ 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<string, unknown>
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) {
const current = children[i]
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<string, unknown>
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)
Expand Down
51 changes: 51 additions & 0 deletions src/app/test/unit/utils/tiptap/tiptapToComark.test.ts
Original file line number Diff line number Diff line change
@@ -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 <strong> 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).**')
})
})
})
Loading