Supporting CJK-friendly emphasis in @lezer/markdown

CommonMark’s flanking rules work poorly for Chinese, Japanese and Korean text. These languages don’t put spaces between words, so emphasis often ends with punctuation that is directly followed by a letter. The closing delimiter is then not right-flanking:

**该星号不会被识别,而是直接显示。**这是因为它没有被识别为强调符号。

The closing ** is preceded by 。 (punctuation) and followed by 这 (a letter), so both delimiters stay literal text.

The CJK-friendly amendments fix this by changing one condition of the flanking rules: a delimiter run next to a CJK character or CJK punctuation still counts as flanking. They are proposed for CommonMark itself in commonmark-spec#650. There are plugins for markdown-it, micromark/remark and marked, and Comrak, goldmark and Markdig implement them. VitePress and Rspress turn them on by default. Text without CJK characters parses exactly as it does under plain CommonMark.

I use @lezer/markdown for the source view of a Markdown editor whose files follow these amendments. I’d like its syntax tree to agree with them, both for highlighting and for commands that read the tree, such as toggling bold.

Replacing the Emphasis inline parser by name works, and computing the amended flanking is easy. The problem is the delimiters the replacement adds. resolveMarkers applies its emphasis logic (splitting runs by length, the rule of 3, Emphasis vs. StrongEmphasis) only to delimiters whose type is EmphasisAsterisk or EmphasisUnderscore, and those types aren’t exported. So a replacement parser can’t produce delimiters that resolve as emphasis.

For now I run a second Markdown parser over each inline section to find which delimiters pair. I then give each pair a DelimiterType of its own, with resolve: "Emphasis" or "StrongEmphasis". This works, but every inline section is parsed twice, and the second parser repeats work Lezer already does well.

Would you consider exposing the two types, the way InlineContext.linkStart and InlineContext.imageStart are exposed? For example, as InlineContext.emphasisAsterisk and InlineContext.emphasisUnderscore. A replacement parser could then be:

import {InlineContext} from "@lezer/markdown"

const CJKFriendlyEmphasis = {
  parseInline: [{
    name: "Emphasis",
    parse(cx, next, start) {
      if (next != 42 /* '*' */ && next != 95 /* '_' */) return -1
      let end = start + 1
      while (cx.char(end) == next) end++
      let {canOpen, canClose} = cjkFriendlyFlanking(cx, start, end, next == 42)
      let type = next == 42 ? InlineContext.emphasisAsterisk : InlineContext.emphasisUnderscore
      return cx.addDelimiter(type, start, end, canOpen, canClose)
    }
  }]
}

If you’d rather support the amendments directly, as an optional extension like Strikethrough, that would work for me too.

1 Like

I think I prefer the solution of exporting these. Can you check whether the patch below works for you?

Thanks, the patch works: the replacement parser from my first post now produces delimiters that resolve as emphasis, and I can drop the second parse.

While testing it, I found an unrelated deviation from the spec. resolveMarkers applies the rule of 3 to what is left of a partially used delimiter, but the spec’s algorithm uses the original lengths of the opening and closing delimiter runs. For example, a****b***c:

  • @lezer/markdown: a**<strong>b</strong>*c
  • commonmark.js, cmark, markdown-it and most others: a*<em><strong>b</strong></em>c (commonmark.js demo)

After the strong emphasis, the leftover ** and * add up to 3, so Lezer doesn’t pair them, while the original runs (4 and 3) add up to 7. Keeping the original run length on InlineDelimiter and checking that instead fixed it in my tests.

Does the patch below align with how you understand this should work?

Yes, that’s how I understand it, and it works in my tests. Thanks!

Great. I’ve tagged a version 1.8.0

1 Like