Skip to content

fix(tiptap): prevent star accumulation when bold text contains a link#481

Draft
hendrikheil wants to merge 8 commits into
nuxt-content:mainfrom
hendrikheil:fix/bold-link-star-accumulation
Draft

fix(tiptap): prevent star accumulation when bold text contains a link#481
hendrikheil wants to merge 8 commits into
nuxt-content:mainfrom
hendrikheil:fix/bold-link-star-accumulation

Conversation

@hendrikheil

Copy link
Copy Markdown
Contributor

Problem

Opening and saving a file containing a pattern like **text **[link](url)**.** caused ** markers to multiply on each save cycle. After one round-trip through the editor the link gained extra stars, producing **** sequences in the rendered markdown.

Reported in #470 ("MDC autoformatter constantly breaking links and formatting").

Root cause

A three-step chain in the TipTap ↔ ComarkTree conversion layer:

Step 1 — comarkToTiptap / createMark
When traversing ["strong", {}, "text", ["strong", {}, ["a", ...]]], the outer strong adds a bold mark, and the inner strong adds a second bold mark. The link text node ended up with [link, bold, bold] marks instead of [link, bold].

Step 2 — tiptapToComark / createParagraphElement / getMarkInfo
getMarkInfo only returned a mark when a text node had exactly one mark. With [link, bold] (after TipTap's own deduplication), the link text returned null, so it was split into a separate block instead of staying inside the surrounding bold run. Three separate strong siblings were produced instead of one.

Step 3 — .flat() on the block result
The multi-item block path returned a raw ComarkElement. .flat() then spread its children into the parent list, corrupting the node structure.

Fixes

Location Change
comarkToTiptap createMark Deduplicate: skip adding a mark already present in the accumulated set
tiptapToComark getMarkInfo For text with 2+ marks and exactly one structural (non-link) mark, return that mark — keeps the node in its bold/italic block
tiptapToComark mark-stripping Only strip the block mark (e.g. bold), preserving link and other inline marks
tiptapToComark block-wrap return Wrap the ComarkElement in an array so .flat() treats it as one sibling

Result

**that contain it **[here](/url)**.** now round-trips stably to **that contain it [here](/url).** — one bold run, link preserved, no extra stars.

Tests

New test file tiptapToComark.test.ts:

  • Asserts the ComarkTree structure: one strong parent with text + link + text as direct children
  • Asserts the rendered markdown does not contain ****

All 337 tests pass.


Note: the companion fix for backslash-escaped brackets being silently converted to real links is tracked in comarkdown/comark#240.

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#470

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@vercel

vercel Bot commented Jun 11, 2026

Copy link
Copy Markdown
Contributor

@hendrikheil is attempting to deploy a commit to the Nuxt Team on Vercel.

A member of the Team first needs to authorize it.

@pkg-pr-new

pkg-pr-new Bot commented Jun 11, 2026

Copy link
Copy Markdown
npm i https://pkg.pr.new/nuxt-studio@481

commit: 39719fe

hendrikheil and others added 6 commits June 11, 2026 17:20
- 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 <noreply@anthropic.com>
Three adjacent <strong> 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 nuxt-content#470

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant