Skip to content

Emphasis containing a bold word splits into separate <em> runs instead of nesting <strong> #2

Description

@Nahid-NHB

Summary

An italic span that contains a bold word, like *a **b** c*, does not render as nested emphasis. Instead of one <em> wrapping a <strong>, the parser emits three separate <em> runs and drops the strong entirely. Underscore emphasis is worse: _italic __bold__ tail_ produces mangled, partially unparsed output.

This is plain prose (an italic sentence with one bold word), not a pathological delimiter case. The syntax profile doc lists "Emphasis and strong: Natural prose patterns" as supported, and the reverse nesting (**a *b* c**, bold containing italic) already works, so this looks like an oversight rather than an intentional limitation.

Environment

  • @tanstack/markdown 0.0.12, main
  • Entry point: @tanstack/markdown/html (renderHtml)
  • Node v22.22.3
  • Reproduces at the parser level, so it's renderer-independent (react/octane affected too).

Steps to reproduce

import { renderHtml } from '@tanstack/markdown/html'

console.log(renderHtml('*a **b** c*'))
console.log(renderHtml('*see **the docs** here*'))
console.log(renderHtml('_italic __bold__ tail_'))
console.log(renderHtml('*outer **inner***'))

Expected

<p><em>a <strong>b</strong> c</em></p>
<p><em>see <strong>the docs</strong> here</em></p>
<p><em>italic <strong>bold</strong> tail</em></p>
<p><em>outer <strong>inner</strong></em></p>

Actual

<p><em>a </em><em>b</em><em> c</em></p>
<p><em>see </em><em>the docs</em><em> here</em></p>
<p><em>italic __bold</em>_ tail_</p>
<p><em>outer </em><em>inner</em>**</p>

For comparison, the bold-outer form works today:

renderHtml('**a *b* c**')   // <p><strong>a <em>b</em> c</strong></p>  ✅
renderHtml('***important***') // <p><em><strong>important</strong></em></p>  ✅

Root cause

In src/inline.ts, single-character emphasis scans for the next matching delimiter character:

// src/inline.ts (single * / _ branch)
if (char === '*' || char === '_') {
  const close = char === '_' && !canUseUnderscore(...) ? -1 : findDelimiter(value, index + 1, char, budget)
  ...
}

findDelimiter returns the first unescaped occurrence of the delimiter string. For *a **b** c*, the opening * at index 0 calls findDelimiter(value, 1, '*'), which returns the first * of the inner **b**. Emphasis closes right before the bold marker, and the leftover *b** c* gets re-scanned into more stray <em> runs.

The single-* search doesn't skip a * that is part of a longer run opening a nested strong span. It matches the nearest character greedily. The **/__ branch avoids this because findDelimiter looks for the two-character string and skips lone *, which is exactly why bold-containing-italic works while italic-containing-bold does not. That asymmetry is the tell. The underscore case additionally trips the canUseUnderscore flanking checks, giving the extra-broken <em>italic __bold</em>_ tail_.

Suspected files / functions

  • src/inline.tsparseInlineRaw single-emphasis branch (*/_) and findDelimiter

Suggested fix (optional)

When scanning for a single-* closer, don't accept a * that is the start of a longer run meant to open a nested span. In findDelimiter, for a single */_ delimiter, skip positions where the character is immediately adjacent to the same character (part of a **/*** run) unless that run is a valid closer for the current open delimiter. A fuller fix pairs delimiters by run length the way CommonMark's delimiter stack does. Regression fixtures like *a **b** c* and _italic __bold__ tail_ would lock it down.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions