Skip to content

[lexical-markdown] Bug Fix: Export formatted text that ends or starts with punctuation next to a letter as Markdown that keeps its format - #9663

Draft
zurfyx wants to merge 2 commits into
mainfrom
zurfyx/markdown-emphasis-flanking
Draft

zurfyx wants to merge 2 commits into
mainfrom
zurfyx/markdown-emphasis-flanking

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 31, 2026 •

Copy link
Copy Markdown
Member

Parked (10/30)

Parked after review. Fixing CommonMark flanking on export conflicts with this package's own import, which does not follow CommonMark around escaped delimiters and counts only ASCII punctuation: a 6,000-case corpus of three nodes around escaped delimiters finds 28 exports that main reads back in this package (while CommonMark readers fail on main there) and that this change breaks for this package's import (italic _, bold x, plain _ exports as *\_***x**\_, which loses the bold and leaves literal asterisks; italic x, bold *, italic x gets italic on the star). The likely way forward is to make the import CommonMark-conformant first (escapes next to runs, Unicode punctuation), then fix the export on top of it, or both together. The tests and the review's repros are kept on this branch; #9660 stays open.

Description

A run of tags with punctuation on its inner side and a letter or digit on its outer side cannot close or open by CommonMark's flanking rules (https://spec.commonmark.org/0.31.2/#left-flanking-delimiter-run). The export wrote such runs as they came: bold Hello, followed by plain world became **Hello,**world, x followed by italic (beta) became x*(beta)*, and $convertFromMarkdownString (and any CommonMark parser) read the asterisks back as text, so a save and reload lost the format.

The export now writes that letter or digit as a numeric character reference, which is punctuation to the run and the same character once imported: **Hello,**world, x*(beta)*. The inline Markdown of a block is built as pieces (tags, the first and last character of each text node, everything else as written), and each run of tags is checked against the characters actually next to it; a run is changed only where it fails CommonMark's flanking rules both as CommonMark classes characters (Unicode P and S are punctuation) and as parsers that read UTF-16 units do (they disagree on emoji). Then the letter or digit on the failing side is written as a reference and the runs next to it are checked again, over the whole block (so a chain of one-character texts of any length works). Where this package's import, which counts only ASCII punctuation, would see a written reference next to a character it takes for a letter (€), that character is written as a reference too. Control characters are never written as references (€ reads as €). Markdown that every reading accepts as written stays byte for byte as it was.

Closes #9660

Review follow-up (second commit)

The first version modelled the tags per text node: it changed exports that already read back (*(beta)***€**), missed runs that close and reopen a tag (****), stopped after 64 one-character texts and could write C1 controls as references. The second commit replaces it with the check on the actual runs described above; details and numbers in its message.

Test plan

unit/EmphasisFlanking.test.ts: the six cases of the issue plus inline code and a shared run, each with its exact Markdown, read back by this package's import and by a CommonMark parser (micromark, through @lexical/mdast); controls that must export byte for byte as main does (including *(beta)***€**, **a.***€*, *—***.**, an emoji run parsers disagree on, a C1 control); and two properties over 1,500 random paragraphs each: through this package's import (ASCII punctuation; bold, italic, strikethrough, highlight, code) and through micromark (Unicode punctuation; bold, italic, strikethrough, code).

Before

On main (c363dd9):

$ vitest --run --project unit packages/lexical-markdown/src/__tests__/unit/EmphasisFlanking.test.ts
     × [["Hello,",["bold"]],["world",[]]] exports as **Hello,**world and imports the same
     × [["Note:",["bold"]],["see",[]]] exports as **Note:**see and imports the same
     × [["x",[]],["(beta)",["italic"]]] exports as x*(beta)* and imports the same
     × [["a.",["italic"]],["b",[]]] exports as *a.*b and imports the same
     × [["(",["bold"]],["b",[]]] exports as **(**b and imports the same
     × [["*",["bold"]],["b",[]]] exports as **\***b and imports the same
     × [["x",[]],["tag",["bold","code"]]] exports as x**`tag`** and imports the same
     × [["€",["italic"]],["5",[]]] exports as *€*5 and imports the same
     × [["a,",["bold"]],["b",["italic"]]] exports as **a,***b* and imports the same
     ✓ control: ... (5)
     × 1500 random paragraphs
      Tests  10 failed | 5 passed (15)

Main's Markdown for the first seven loses the format on import; *€*5 and **a,***b* import back here (this package's import counts only ASCII punctuation and is lenient on the shared run) but not in a CommonMark parser (markdown-it renders them as *€*5 and **a,**<em>b</em>).

After

$ vitest --run --project unit packages/lexical-markdown/src/__tests__/unit/EmphasisFlanking.test.ts
      Tests  20 passed (20)

$ LEXICAL_MARKDOWN_SEEDS=6000 vitest --run --project unit packages/lexical-markdown/src/__tests__/unit/EmphasisFlanking.test.ts
      Tests  20 passed (20)

$ pnpm run ci-check
EXIT 0

$ vitest --run --project unit --project scripts-unit
 Test Files  657 passed | 4 skipped (661)
      Tests  28189 passed | 424 expected fail | 135 skipped (28748)

A corpus of 6,000 random paragraphs (ASCII and Unicode punctuation, emoji, *, _, a C1 control; bold, italic, code), compared with main's export and read by commonmark.js, markdown-it, micromark and marked: 939 exports differ, none where main's export read back in all four parsers and this package's import; 0 parser regressions; 847 to 867 fixed per parser; this package's import fixed in 531 and broken in 1 (*—\_***&#66;c:…, where main's export fails all four parsers and this package's import misreads the run after the escaped \_). The playground's Markdown e2e specs were not run.

… with punctuation next to a letter as Markdown that keeps its format

## Description

A run of tags with punctuation on its inner side and a letter or digit on its outer side cannot close or open by CommonMark's flanking rules (https://spec.commonmark.org/0.31.2/#left-flanking-delimiter-run). The export wrote such runs as they came: bold `Hello,` followed by plain `world` became `**Hello,**world`, `x` followed by italic `(beta)` became `x*(beta)*`, and `$convertFromMarkdownString` (and any CommonMark parser) read the asterisks back as text, so a save and reload lost the format.

The export now writes that letter or digit as a numeric character reference, which is punctuation to the run and the same character once imported: `**Hello,**&#119;orld`, `&#120;*(beta)*`. The text a reader sees and every other export stay as they were; only the character that touches a run that could not flank changes. It covers:

- one side's tags closing or opening next to the other side's text (the cases above), by both definitions of punctuation in play: CommonMark's (Unicode P and S, so `*€*5` becomes `*€*&#53;`) and this package's import (ASCII);
- tags of two kinds closing or opening together (`**==a,==**`), where the outer run has the inner run's delimiter on its inner side, so only white space or punctuation may touch it;
- one run that closes one side's tags and opens the other's (`**a,***b*`), which flanks both ways only with the same kind of character on both sides.

A character written as a reference changes both ends of a text of one character, so the decision runs over the text siblings around a node up to the first text of more than one character (bounded at 64 nodes, so a long chain stays linear).

Closes #9660

## Test plan

`unit/EmphasisFlanking.test.ts`: the six cases of the issue plus inline code, Unicode punctuation and a shared run, each with its exact Markdown and an import that gives back every character with its formats; five controls that export as before; and a property over 1,500 random paragraphs of two to four text nodes (letters, digits, ASCII and Unicode punctuation, spaces; bold, italic, strikethrough, highlight, code), round-tripped through export and import.

### Before

On main (c363dd9):

```
$ vitest --run --project unit packages/lexical-markdown/src/__tests__/unit/EmphasisFlanking.test.ts
     × [["Hello,",["bold"]],["world",[]]] exports as **Hello,**&#119;orld and imports the same
     × [["Note:",["bold"]],["see",[]]] exports as **Note:**&#115;ee and imports the same
     × [["x",[]],["(beta)",["italic"]]] exports as &#120;*(beta)* and imports the same
     × [["a.",["italic"]],["b",[]]] exports as *a.*&#98; and imports the same
     × [["(",["bold"]],["b",[]]] exports as **(**&#98; and imports the same
     × [["*",["bold"]],["b",[]]] exports as **\***&#98; and imports the same
     × [["x",[]],["tag",["bold","code"]]] exports as &#120;**`tag`** and imports the same
     × [["€",["italic"]],["5",[]]] exports as *€*&#53; and imports the same
     × [["a,",["bold"]],["b",["italic"]]] exports as **a,***&#98;* and imports the same
     ✓ control: ... (5)
     × 1500 random paragraphs
      Tests  10 failed | 5 passed (15)
```

Main's Markdown for the first seven loses the format on import; `*€*5` and `**a,***b*` import back here (this package's import counts only ASCII punctuation and is lenient on the shared run) but not in a CommonMark parser (markdown-it renders them as `*€*5` and `**a,**<em>b</em>`).

### After

```
$ vitest --run --project unit packages/lexical-markdown/src/__tests__/unit/EmphasisFlanking.test.ts
      Tests  15 passed (15)

$ LEXICAL_MARKDOWN_SEEDS=8000 vitest --run --project unit packages/lexical-markdown/src/__tests__/unit/EmphasisFlanking.test.ts
      Tests  15 passed (15)

$ vitest --run --project unit packages/lexical-markdown
 Test Files  10 passed (10)
      Tests  939 passed (939)

$ pnpm run ci-check
EXIT 0

$ vitest --run --project unit --project scripts-unit
 Test Files  657 passed | 4 skipped (661)
      Tests  28184 passed | 424 expected fail | 135 skipped (28743)
```

The exported Markdown of 2,137 random paragraphs (the property without highlight, which CommonMark lacks) also parses in markdown-it (CommonMark) to the same characters and formats, 0 mismatches (a scratch check, not committed: markdown-it is not a dependency of this package). The playground's Markdown e2e specs were not run.
@vercel

vercel Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated
lexical Ready Ready Preview Oct 31, 2026 6:01am UTC
lexical-playground Ready Ready Preview Oct 31, 2026 6:01am UTC

Request Review

@meta-cla meta-cla Bot added the CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed. label Oct 31, 2026
@meta-code-review

meta-code-review Bot commented Oct 31, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Oct 31, 2026, 6:21 AM UTC

@pkg-pr-new

pkg-pr-new Bot commented Oct 31, 2026 •

Copy link
Copy Markdown
lexical

npm i https://pkg.pr.new/lexical@9663

@lexical/a11y

npm i https://pkg.pr.new/@lexical/a11y@9663

@lexical/clipboard

npm i https://pkg.pr.new/@lexical/clipboard@9663

@lexical/code

npm i https://pkg.pr.new/@lexical/code@9663

@lexical/code-core

npm i https://pkg.pr.new/@lexical/code-core@9663

@lexical/code-prism

npm i https://pkg.pr.new/@lexical/code-prism@9663

@lexical/code-shiki

npm i https://pkg.pr.new/@lexical/code-shiki@9663

@lexical/compiler

npm i https://pkg.pr.new/@lexical/compiler@9663

@lexical/devtools-core

npm i https://pkg.pr.new/@lexical/devtools-core@9663

@lexical/dragon

npm i https://pkg.pr.new/@lexical/dragon@9663

@lexical/eslint-plugin

npm i https://pkg.pr.new/@lexical/eslint-plugin@9663

@lexical/extension

npm i https://pkg.pr.new/@lexical/extension@9663

@lexical/fast-check

npm i https://pkg.pr.new/@lexical/fast-check@9663

@lexical/file

npm i https://pkg.pr.new/@lexical/file@9663

@lexical/hashtag

npm i https://pkg.pr.new/@lexical/hashtag@9663

@lexical/headless

npm i https://pkg.pr.new/@lexical/headless@9663

@lexical/history

npm i https://pkg.pr.new/@lexical/history@9663

@lexical/html

npm i https://pkg.pr.new/@lexical/html@9663

@lexical/internal

npm i https://pkg.pr.new/@lexical/internal@9663

@lexical/link

npm i https://pkg.pr.new/@lexical/link@9663

@lexical/list

npm i https://pkg.pr.new/@lexical/list@9663

@lexical/mark

npm i https://pkg.pr.new/@lexical/mark@9663

@lexical/markdown

npm i https://pkg.pr.new/@lexical/markdown@9663

@lexical/mdast

npm i https://pkg.pr.new/@lexical/mdast@9663

@lexical/offset

npm i https://pkg.pr.new/@lexical/offset@9663

@lexical/overflow

npm i https://pkg.pr.new/@lexical/overflow@9663

@lexical/plain-text

npm i https://pkg.pr.new/@lexical/plain-text@9663

@lexical/react

npm i https://pkg.pr.new/@lexical/react@9663

@lexical/rich-text

npm i https://pkg.pr.new/@lexical/rich-text@9663

@lexical/selection

npm i https://pkg.pr.new/@lexical/selection@9663

@lexical/table

npm i https://pkg.pr.new/@lexical/table@9663

@lexical/tailwind

npm i https://pkg.pr.new/@lexical/tailwind@9663

@lexical/text

npm i https://pkg.pr.new/@lexical/text@9663

@lexical/utils

npm i https://pkg.pr.new/@lexical/utils@9663

@lexical/y

npm i https://pkg.pr.new/@lexical/y@9663

@lexical/yjs

npm i https://pkg.pr.new/@lexical/yjs@9663

commit: 40a5102

…flank, as written, and leave every other export byte for byte as it was

## Description

Review follow-up. The first version decided per text node, from a model of the tags the export would write, which character to write as a reference. The model missed tags the export closes and opens again in one run (`****` between two nodes that share some formats), counted ASCII-only punctuation as CommonMark does not, stopped following one-character texts after 64 nodes, and could write a C1 control as a reference (`&#128;`, which CommonMark reads as `€`). So it changed exports that already read back (`*(beta)***€**` got `&#8364;`) and broke one (`***😀****a\_b*`).

Now the export of a block's inline content is built as pieces (tags, the first and last character of each text node, everything else as written), and each run of tags is checked against the characters actually next to it. A run is changed only where it fails CommonMark's flanking rules both as CommonMark classes characters and as parsers that read UTF-16 units do (they disagree on emoji and other characters outside the Basic Multilingual Plane); then the letter or digit on the failing side is written as a reference, and the runs next to that character are checked again, over the whole block. Where this package's import, which counts only ASCII punctuation, would then see a reference next to a character it takes for a letter (`€`), that character is written as a reference too. Only letters, marks, digits, symbols and punctuation are ever written as references (never control characters). Markdown that every reading accepts as written is unchanged.

## Test plan

`EmphasisFlanking.test.ts` now reads every case back with this package's import and with a CommonMark parser (micromark, through @lexical/mdast), and adds the review's byte-identity controls (`*(beta)***€**`, `**a.***€*`, `*—***.**`), the emoji run parsers disagree on (unchanged), and a C1 control (unchanged); the properties run 1,500 random paragraphs through this package's import (ASCII punctuation, all formats) and through micromark (Unicode punctuation; bold, italic, strikethrough, code).

### Before

On 3f30041, with the review's repros and six parsers (commonmark.js, markdown-it, marked, micromark, micromark with GFM):

```
review Regressions: byte-identity controls fail (`*(beta)***&#8364;**` and two more), shared-format reference fails commonmark.js
review C1: `**Hello,**&#128;` reads back as `€`
```

On main (c363dd9): review Contexts 9 of 15 and Chain 7 of 7 fail (the issue).

### After

```
$ vitest --run --project unit packages/lexical-markdown/src/__tests__/unit/EmphasisFlanking.test.ts
      Tests  20 passed (20)
$ LEXICAL_MARKDOWN_SEEDS=6000 vitest --run ... EmphasisFlanking.test.ts
      Tests  20 passed (20)

review repros on this head: Contexts 15/15, Chain 7/7 (16 to 256 one-character nodes), C1 5/5, Reference 1/1, Regressions: the three byte-identity controls and commonmark.js pass; micromark, markdown-it and marked read the shared-format case (`***😀****a\_b*`, byte for byte main's export) as main does.

$ pnpm run ci-check
EXIT 0
$ vitest --run --project unit --project scripts-unit
 Test Files  657 passed | 4 skipped (661)
      Tests  28189 passed | 424 expected fail | 135 skipped (28748)
```

A corpus of 6,000 random paragraphs (ASCII and Unicode punctuation, emoji, `*`, `_`, a C1 control; bold, italic, code), compared with main's export: 939 differ, none where main's export read back in all four parsers and this package's import; 0 parser regressions; 847 to 867 cases fixed per parser; this package's import fixed in 531 and broken in 1 (`*—\_***&#66;c:…`, where main's export failed every parser; this package's import misreads the run after the escaped `\_`; not investigated further).

@meta-code-review meta-code-review Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This looks good to me. A numeric character reference keeps the text identical while putting punctuation on the run's outer side, which is exactly what CommonMark's flanking rules (§6.2) need, and the new tests fail on main and pass here. A few optional, non-blocking suggestions:

  1. Preserve-newlines mode and a literal backslash (packages/lexical-markdown/src/MarkdownExport.ts:1329). With shouldPreserveNewLines set, backslashes are written as typed, but textPieces treats \ as part of a unit only when ASCII punctuation follows it. So plain a\x followed by italic (beta) exports as a\&#120;*(beta)*. Both $convertFromMarkdownString and a CommonMark parser then read the text back as a&#120;, because \& is an escaped ampersand (§2.4). Matching \\[\s\S] in place of \\[!-/:-@[-`{-~] leaves default mode unchanged, since every backslash there already precedes the character it escapes, and stops encode from rewriting a character a backslash precedes.
  2. Export cost on large documents (same function). text.match(/…|[\s\S]/gu) allocates one string per character of every text node, although only the first and last units are used. In a quick measurement, exporting 2,000 paragraphs (2.6 MB of Markdown) went from 43 ms to 159 ms, and a single 2.2 MB text node from 1 ms to 114 ms. Taking the first unit with an anchored match and the last by scanning back from the end would avoid that.
  3. Tests for the other entry points. EmphasisFlanking.test.ts covers $convertToMarkdownString with default options. One case through $convertSelectionToMarkdownString and one with shouldPreserveNewLines, which would also cover point 1, would pin the paths that share the new pass.
  4. This package's own import on a few mixed-format shapes. In a 20,000-paragraph random comparison, three exports that $convertFromMarkdownString read back on main are misread on this branch, even though CommonMark parsers now read them correctly. For example, bold €7, italic _a~, italic+highlight ±*: and bold+strikethrough « export as **€&#55;***\_a\~==±\*:==***~~«~~**, and the import makes _a~ bold as well. The cause is the importer's pairing of a both-flanking *** run, like the *—\_***&#66;c:… case you noted, so a follow-up issue seems enough.

The changeset follows .changeset/README.md and includes the Verify: paragraph that CONTRIBUTING.md asks for on fixes that change output.

*/
function textPieces(text: string): Piece[] {
const units =
text.match(/\\[!-/:-@[-`{-~]|&#(?:\d+|[xX][\da-fA-F]+);|[\s\S]/gu) || [];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two optional suggestions for this line:

  • With shouldPreserveNewLines, backslashes are written as typed, so plain a\x + italic (beta) becomes a\&#120;*(beta)*, and that reads back as a&#120; (\& is an escaped ampersand, CommonMark §2.4). Taking \\[\s\S] as one unit, in place of \\[!-/:-@[-`{-~], leaves default mode unchanged and keeps encode from touching a character a backslash precedes.
  • match(...gu) allocates one string per character of every text node, although only the first and last units are used. Exporting 2.6 MB of Markdown went from 43 ms to 159 ms in a quick measurement. An anchored match for the first unit and a backward scan for the last would avoid that.

This branch was successfully deployed

2 active deployments
Preview – lexical — 40a5102c Deployed Oct 31, 2026 by vercel[bot]
Preview – lexical-playground — 40a5102c Deployed Oct 31, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

CLA Signed This label is managed by the Facebook bot. Authors need to sign the CLA before a PR can be reviewed.

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: [lexical-markdown] Bold or italic text that ends in punctuation right before a letter exports as Markdown that imports as plain text

1 participant