Skip to content

[lexical-markdown] Bug Fix: Follow CommonMark's Unicode punctuation and whitespace for emphasis flanking - #9430

Merged
etrepum merged 76 commits into
meta:mainfrom
haripetrov-stack:markdown-unicode-punctuation
Nov 3, 2026
Merged

etrepum merged 76 commits into
meta:mainfrom
haripetrov-stack:markdown-unicode-punctuation

Conversation

@haripetrov-stack

@haripetrov-stack haripetrov-stack commented Oct 22, 2026 •

Copy link
Copy Markdown
Contributor

Description

Markdown import decides whether a *, _, ~~ or == run can open or close formatting by looking at the characters on either side of it. The punctuation and whitespace classes listed only ASCII characters, so an en dash, a curly quote, a currency sign or an emoji next to a run counted as a letter. See –**[here](https://example.com)** now. imported as plain text, and its next export escaped the asterisks.

CommonMark 0.31.2 counts every character in the Unicode P and S categories as punctuation, and Zs plus tab, LF, FF and CR as whitespace. Import and the typing shortcuts now use those sets, reading whole code points so astral characters such as 😀 are classified correctly. The backslash, missing from the old ASCII list, now counts as punctuation too.

Export has to follow: Lexical wrote shapes like foo**“bar”**baz, whose markers no longer flank, and 7 of the MarkdownEscaping.test.ts round trips from #9481 failed. The exporter now writes the letter beside such a marker as a numeric character reference (foo**“bar”**baz), which every CommonMark reader decodes to the same text. It writes none inside code or transformer output. A raw backslash right before a marker, or before a character that export escapes with shouldPreserveNewLines, is escaped too. The references are chosen so that micromark, which @lexical/mdast imports with and which reads an emoji as a letter, formats the result the same way (😀**“b”**).

A bare URL, www. host or email that GFM readers would link needs care: a reference inside it changes the link, and GFM readers take markers and code spans after it into the link. When either would happen, export writes the . of www. or the : after the scheme as a reference, so that no reader links it and the text and formatting come through (**www.x.co**a, see https://x.com/a**“x”**). An email that holds a reference has its @ written as @, so that micromark links the whole decoded address.

Import fixes that the new flanking exposed:

  • Emphasis that closes before a later code span is applied before that span, so its markers no longer split apart (**a.**x**`c`**).
  • A link or other text match no longer takes part of any code span, not only the first (Bug: Equation $ parsing should not bind tighter than code formatting #8687), and the link search stays bounded on lines with many [ or code spans.
  • A delimiter run cut by an earlier match reads as it did in the whole text (*)**a*). Text beside a text match that does not cut a run still reads the match's edge as the edge of a line, as on main, so a custom syntax that ends in a letter (user:bob**(admin)**) keeps the formatting after it.
  • An escaped backtick before more backticks no longer stops the rest of the run from opening a code span (x\``bb`x), which export writes for a plain backtick before inline code.

Import now finds a line's code spans, emphasis and links, and the matches of other text-match transformers whose patterns do not look behind, once rather than again after each match, and imports the text after each match in a loop, in the same order as before. Long lines import faster than on main, for example 4 s instead of 9 s for 'a **b** [c](d) `e` _f_ ' repeated 1,600 times, and very long paragraphs no longer overflow the stack.

Markdown such as **你好。**世界, which main read as bold, now imports with literal asterisks, as CommonMark, GFM, micromark and markdown-it show it; a CJK-friendly rule for both importers is left for a follow-up.

Known limitations:

  • The rule of 3 still allows pairings by remaining run length, as on main (Bug: Markdown export produces invalid markdown for overlapping formats #4895).
  • Some overlapping formats around escaped punctuation on both sides of a marker do not round-trip.
  • Emphasis can still pair across a link's label, as in x~~[_](u)[~~](u), as on main.
  • A literal == in text is not escaped, so it can pair with a highlight marker that both opens and closes. With Unicode punctuation that now includes a highlight next to an emoji or symbol, not only next to ASCII punctuation as on main.
  • Escapes and references that export writes inside a bare URL, such as \_, become part of the link in GFM readers, as on main.
  • \v beside a marker cannot be written so that both CommonMark and micromark readers agree.

Reported in sveltia/sveltia-cms#1107.

Closes #9407
Closes #9410
Closes #9443

Changeset

.changeset/markdown-unicode-punctuation-flanking.md, a [fix] patch for @lexical/markdown. Its Verify: paragraph covers stored Markdown such as **“bar”**baz and a**\_x**, which now imports with literal asterisks as CommonMark readers show it, and the new character references in export.

Test plan

Command: pnpm exec vitest run --project unit packages/lexical-markdown

Before

With main merged into the original PR head (5debdb3):

     × preserves existing formatted markers after plain text: bold 15ms
     × preserves existing formatted markers after plain text: italic 2ms
     × preserves existing formatted markers after plain text: strikethrough 3ms
     × preserves formatted backslashes next to plain text: bold 4ms
     × preserves formatted backslashes next to plain text: italic 2ms
     × preserves formatted backslashes next to plain text: strikethrough 2ms
     × preserves formatted backslashes next to plain text: bold+italic 2ms
 Test Files  1 failed | 8 passed (9)
      Tests  7 failed | 844 passed (851)

After

 Test Files  9 passed (9)
      Tests  1055 passed (1055)

The new tests cover Unicode and astral punctuation, spec whitespace, character references read back by both @lexical/markdown and @lexical/mdast, formatting beside bare URLs and emails, code-span and link precedence, cut delimiter runs and custom text-match transformers. Import was also compared with markdown-it on random inputs, where it agrees on many more of them than main does, and export round trips were checked through both importers and cmark-gfm on random paragraphs, with none that main got right and this gets wrong.

…punctuation in emphasis flanking

## Description

Markdown import decides whether a `*`, `_` or `__` run can open or close emphasis by looking at the characters on either side of it. `PUNCTUATION` and `PUNCTUATION_OR_SPACE` in `packages/lexical-markdown/src/utils.ts` list only ASCII characters, so an en dash, a curly quote or a currency sign next to a run counts as a letter.

Before this change:

- `See –**[here](https://example.com)** now.` imports as plain text. The next export escapes the asterisks: ``See –\*\*[here](https://example.com)\*\* now.``
- `See “**[here](https://example.com)**” now.` has the same problem.

After this change both import as a bold link. The exporter writes the bold inside the link, so the first one exports as `See –[**here**](https://example.com) now.`

CommonMark treats every character in the Unicode P (punctuation) and S (symbol) general categories as punctuation ([Unicode punctuation character](https://spec.commonmark.org/0.31.2/#unicode-punctuation-character), spec 0.31.2). Both constants now use `\p{P}\p{S}` with the `u` flag.

- `PUNCTUATION` is read by `isFlanking` and `canEmphasis` in `importTextFormatTransformer.ts`.
- `PUNCTUATION_OR_SPACE` is read by the typing shortcuts for underscore formats in `MarkdownShortcuts.ts`, so typing `_here_` after an en dash now formats the same way importing it does.

Every ASCII punctuation character is in P or S, so ASCII results do not change, with one exception. `PUNCTUATION` did not contain the backslash (U+005C, category Po) because of the escaped `]` in the class. CommonMark counts it, and the new class includes it. meta#9407 and meta#9414 fix only that character, so this change overlaps with them on the `PUNCTUATION` line.

Not changed: characters outside the Basic Multilingual Plane, such as most emoji, are still read one UTF-16 code unit at a time and still count as letters.

Reported in sveltia/sveltia-cms#1107.

## Test plan

Command: `pnpm exec vitest run --project unit packages/lexical-markdown`

### Before

`main` at 4f385cd with only the new tests added:

```
 Test Files  2 failed | 6 passed (8)
      Tests  12 failed | 702 passed (714)
```

The 12 failures are 8 imports, 2 exports and 2 typed shortcuts. Three of them:

```
FAIL  LexicalMarkdown.test.ts > emphasis next to Unicode punctuation and symbols > imports "See –**[here](https://example.com)** now."
AssertionError: expected [ [ 'See –**', 'plain' ], …(2) ] to deeply equal [ [ 'See –', 'plain' ], …(2) ]

FAIL  LexicalMarkdown.test.ts > emphasis next to Unicode punctuation and symbols > exports "See –**[here](https://example.com)** now." without escaped asterisks
AssertionError: expected 'See –\*\*[here](https://example.com)\…' to be 'See –[**here**](https://example.com) …'

FAIL  MarkdownTransformers.test.ts > UNDERSCORE_AFTER_UNICODE_PUNCTUATION > typing "See –_here_" formats the word between the underscores
AssertionError: expected [ 'See –_here_' ] to deeply equal [ 'See –', 'here' ]
```

The other 8 new tests are controls and pass before and after: ASCII `-` and a space before `**`, `a**b**`, `**a**b`, `snake_case_word`, a Latin and a Cyrillic letter before `**`, and a letter before a typed `_`.

### After

```
 Test Files  8 passed (8)
      Tests  714 passed (714)
```

`prettier --check` and `eslint` are clean on the three changed files. Not run: the e2e suite, Flow and the repo-wide `tsc`.
@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 22, 2026
@vercel

vercel Bot commented Oct 22, 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 Nov 3, 2026 12:09am UTC
lexical-playground Ready Ready Preview Nov 3, 2026 12:09am UTC

Request Review

@pkg-pr-new

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

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: 7184aaa

…ctuation, and read astral characters whole

## Description

With Unicode punctuation (and the backslash) counted as punctuation on import, the exporter's intraword markers no longer round-trip when the formatted text starts or ends with punctuation next to a letter: plain `a` then bold `*` exported as `a**\***`, which CommonMark (and now the importer) reads as literal asterisks. Seven MarkdownEscaping tests from meta#9481 failed after merging main.

The exporter now writes the letter beside such a marker as a numeric character reference (`&meta#97;**\***`), whose `&` or `;` is punctuation, so the marker flanks. Flanking checks in import and the typing shortcuts also read the characters around a marker by code point, so an emoji (category So) outside the Basic Multilingual Plane counts as punctuation.

## Changeset

`.changeset/markdown-unicode-punctuation-flanking.md`

## Test plan

`./node_modules/.bin/vitest run --project unit packages/lexical-markdown`

### Before

```
 Test Files  1 failed | 8 passed (9)
      Tests  7 failed | 844 passed (851)
```

(`MarkdownEscaping.test.ts > preserves existing formatted markers after plain text` and `preserves formatted backslashes next to plain text`, e.g. `expected 'a*****' to be 'a*'`.)

### After

```
 Test Files  9 passed (9)
      Tests  860 passed (860)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
@etrepum etrepum added the extended-tests Run extended e2e tests on a PR label Oct 30, 2026
claude added 14 commits October 30, 2026 06:48
…arkers, and use CommonMark's Unicode whitespace

## Description

The previous fix guessed where markers fall from neighbouring text nodes' formats, so it missed a tag closed and reopened around an outer tag's end (`***b****“y”*` lost the italic on “y”), text inside an inline element without a transformer, and inline decorators, and it fired where an unclosable tag wrote nothing.

`$exportTextFormat` now writes each marker between sentinel noncharacters (U+FDD0 to U+FDD2, escaped as references when they appear in text), and the export resolves them once on the joined output: for each delimiter run, it looks at the characters a reader will see on either side and writes the word character beside a run that could not otherwise open or close as a numeric reference. Markers of different characters count as separate runs, as in CommonMark.

`WHITESPACE` and `PUNCTUATION_OR_SPACE` now use CommonMark's Unicode whitespace (Zs plus tab, line feed, form feed and carriage return) instead of JavaScript's `\s`, which also counts U+FEFF and others.

## Test plan

`./node_modules/.bin/vitest run --project unit packages/lexical-markdown`

### Before

`***b****“y”*` export case: `expected '***b****“y”*' to be '***&meta#98;****“y”*'` (markdown-it renders the first with literal asterisks around “y”).

### After

```
 Test Files  9 passed (9)
      Tests  865 passed (865)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…iding flanking

## Description

The previous commit marked each format marker with sentinel noncharacters in the exported string. Those leaked into the strings that `exportChildren` and `exportFormat` hand to custom transformers, collided with the same characters in code spans, URLs and decorator text, and let the guard rewrite characters that a transformer wrote.

`$exportTextFormat` and the child exporters now return pieces (escaped text, raw output, openers and closers), and `joinPieces` turns them into Markdown wherever a string leaves the exporter: the transformer callbacks and the element exports. Only text this export escaped is written as a reference. When newlines are preserved, a raw backslash before the referenced character goes out as `&meta#92;` so that it does not escape the reference. Unpaired surrogates are left alone, since their references read back as U+FFFD. `escapeLinkPart` shares `toCharacterReference`.

## Test plan

`./node_modules/.bin/vitest run --project unit packages/lexical-markdown`

### Before

The new preserve-newlines case exported `x\&meta#98;**“y”**`, which re-imports as `x&meta#98;`.

### After

```
 Test Files  9 passed (9)
      Tests  866 passed (866)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…hanges its neighbour

## Description

`joinPieces` decided each marker run against the characters before any reference was written. A one-character piece written as a reference for one run reads as punctuation at both ends, which could stop the run on its other side from opening or closing (`x**&meta#97;***“b”*` lost the bold). Runs beside such a piece are now decided again, through a worklist so each piece is revisited at most twice and the join stays linear.

Decorator text now counts as text a reference can stand in for, U+0000 is left alone like unpaired surrogates (both read back as U+FFFD), and the changeset no longer implies every emoji counts as punctuation.

## Test plan

`./node_modules/.bin/vitest run --project unit packages/lexical-markdown`

### Before

`[plain 'x', bold 'a', italic '“b”']` exported as `x**&meta#97;***“b”*`, which re-imports with the bold lost.

### After

```
 Test Files  9 passed (9)
      Tests  869 passed (869)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…e, and note the import change

## Description

Export placed tags inside JavaScript whitespace (`trimStart`/`trimEnd`, `/\S/`) while import now uses CommonMark's Unicode whitespace, so a bold `x` went out as `**x**`. Export now scans for the same whitespace (still linear in long runs). U+0000 can be written as a reference again, since CommonMark reads a raw one as U+FFFD too; the backslash check reuses `isEscaped`; `joinPieces` keeps dense arrays and computes each piece's length check once. The changeset's Verify paragraph now covers Markdown that used to import as bold beside Unicode punctuation.

## Test plan

`./node_modules/.bin/vitest run --project unit packages/lexical-markdown`

### After

```
 Test Files  9 passed (9)
      Tests  870 passed (870)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…d export, and guard raw backslashes before markers

## Description

Export now decides whether a reference is needed with the same `isFlankedBy` rule that import's `isFlanking` uses, instead of a copy of it. When newlines are preserved, a raw backslash right before a marker went out as is and escaped it (`a\**b**` lost the bold); it now goes out as `&meta#92;`. `joinPieces` skips its setup when there are no markers, and the trailing-whitespace scan has an explicit bound.

## Test plan

`./node_modules/.bin/vitest run --project unit packages/lexical-markdown`

### After

```
 Test Files  9 passed (9)
      Tests  871 passed (871)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…pace, and guard raw backslashes before any markup

## Description

The previous commit placed export tags by CommonMark whitespace only, so a tag could land beside U+000B, which markdown-it counts as whitespace (`**\vx\v**` lost the bold there). Export goes back to trimming JavaScript whitespace, which holds CommonMark's and the characters some readers add; import keeps the spec's set. When newlines are preserved, a trailing raw backslash before any following markup (a marker, a reference, a code span, a link) now goes out as `&meta#92;`, in one place, and the reference analysis runs only when there are markers.

## Test plan

`./node_modules/.bin/vitest run --project unit packages/lexical-markdown`

### After

```
 Test Files  9 passed (9)
      Tests  871 passed (871)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ost format for another

## Description

A reference for a one-character piece makes both of its edges punctuation, which the run on its other side may need repaired with another reference, and so on along a chain of one-character pieces. When that chain ended at raw output (a transformer's `@x`) the last run could not be repaired, so the reference only swapped which format was lost. `decideReferences` now walks the chain first and writes nothing when it is blocked; answers are remembered per piece and direction, so each piece is walked at most twice.

U+0000 is left alone again (Lexical's importer keeps a raw one but reads `&#0;` as U+FFFD), the preserve-newlines backslash guard is limited to what `joinPieces` writes (markers and references), and the changeset's Verify paragraph mentions it.

## Test plan

`./node_modules/.bin/vitest run --project unit packages/lexical-markdown`

### Before

```
     × writes no reference that would break a run beside raw output
      Tests  1 failed | 695 skipped (696)
```

### After

```
 Test Files  9 passed (9)
      Tests  872 passed (872)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…eping

## Description

`decideReferences` keeps one character per delimiter group instead of joined marker strings, decodes each piece's edge characters once, and remembers chain answers in one tri-state table per direction. The comment on that cache now says how an answer can go stale (only toward writing fewer references), and `appendPieces` says why it loops.

## Test plan

`./node_modules/.bin/vitest run --project unit packages/lexical-markdown`

### After

```
 Test Files  9 passed (9)
      Tests  872 passed (872)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…s raw beside markers

## Description

A reference for a control character (U+0001 to U+001F, U+007F to U+009F) or a noncharacter reads back as U+FFFD (or `…` for `&meta#133;`) in markdown-it and HTML decoders, so the export no longer writes one for them; they stay raw as on main. `canEmphasis` reads the characters around a delimiter once for both its open and close answers, and `isPunctuationOrSpace` replaces the third hand-written character class.

## Test plan

`./node_modules/.bin/vitest run --project unit packages/lexical-markdown`

### After

```
 Test Files  9 passed (9)
      Tests  872 passed (872)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t, email or mention

## Description

A reference for the letter at the edge of `www.example.com`, `me@example.com` or `https://example.com/a` changed what an autolinking reader links to (`www.example.co&#109;` links to `www.example.co`). The export now writes none inside such a word, so only the format beside it is lost, as before. `canSpread` models the reference's closing `;` when walking rightward, and the changeset says the raw-backslash guard also covers decorator text.

## Test plan

`./node_modules/.bin/vitest run --project unit packages/lexical-markdown`

### After

```
 Test Files  9 passed (9)
      Tests  875 passed (875)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…rences

## Description

The export kept every marker and text piece as its own object, decoded edge characters with `String.fromCodePoint`, ran Unicode property regular expressions for ASCII characters and scanned words for autolinks before cheaper checks. Exporting a 40,000-node paragraph took 112 ms (best of 25) against 40 ms on main with heavy formatting beside punctuation, and 39 ms against 28 ms for ordinary prose.

Pieces are now two parallel arrays, markers go back to concatenated tag strings, `characterAt`/`characterBefore` slice only for surrogate pairs, flanking classifies ASCII without a regular expression, the autolink check skips pieces with no `://`, `www.` or `@`, and the bookkeeping uses typed arrays. With the same probe: 63 ms (main 40 ms) and 38 ms (main 28 ms). `decideReferences` returns its results, and `isPunctuationOrSpace` shares the classifier.

The changeset also lists U+000B, U+2028 and U+2029 with U+FEFF as characters that no longer count as whitespace beside a marker.

## Test plan

`./node_modules/.bin/vitest run --project unit packages/lexical-markdown`

### After

```
 Test Files  9 passed (9)
      Tests  875 passed (875)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t only

## Description

The autolink check took the whole run of non-whitespace at a piece's edge, so in spaceless CJK text or after a closing parenthesis an `@` anywhere in it stopped the reference (`联系我@微博你好` before a bold `“重要”` lost the bold), and it rescanned the whole piece on every call. It now scans at most 2,048 URL characters back from the edge and asks whether a URL, `www.` host, email or mention ends there (or starts there, after a closer).

`CharacterClass` is plain numeric constants instead of a `const enum`, which Babel does not inline, and the importer's `_` rule asks the shared `isPunctuation` instead of a second regular expression.

## Test plan

`./node_modules/.bin/vitest run --project unit packages/lexical-markdown`

### After

```
 Test Files  9 passed (9)
      Tests  877 passed (877)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…write nested pieces in place

## Description

The export's autolink check stopped at the backslash that escapes `_`, `~` or `*`, so a mention like `@john_doe` or a URL path like `/a_b` next to formatted punctuation got a reference written inside it (`@john\_do&meta#101;**(x)**`). The check now steps over escaping backslashes. Nested inline elements also wrote their pieces into a fresh array that the parent copied; they now write into the caller's pieces, in both the full and the selection export.

## Test plan

### Before

```
pnpm vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t autolink
     × writes no reference inside "https://example.com/a_b", which readers may autolink
     × writes no reference inside "@john_doe", which readers may autolink
      Tests  2 failed | 3 passed | 698 skipped (703)
```

### After

```
pnpm vitest run --project unit packages/lexical-markdown
      Tests  879 passed (879)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…elimiter run scan

## Description

The export now works out whether a piece edge is a URL, email or mention once per edge instead of on every reference decision, scans a delimiter run's two ends with one helper, and names the reference check `isReferenceable`. `characterAt` uses `codePointAt`. The changeset's Verify paragraph now also names stored Markdown such as `a**\_x**`, which imports with literal markers now that the backslash counts as punctuation.

## Test plan

### After

```
pnpm vitest run --project unit packages/lexical-markdown
      Tests  879 passed (879)
pnpm run check-changesets
Changeset check passed: 32 pending changeset(s) valid.
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…ext format, and read a run a format's tag cuts beside a line edge

## Description

Two inputs imported otherwise than on main and @lexical/mdast:

- `` [a](u)*`c`)** `` imported italic code and an italic `)`. The run after a link, read beside the edge of a line, was paired across the code span that the line's scan had found next. It now pairs only before that format, which is imported first as it would be with the link before it.
- `` **)*(.`*b!****_ `` lost both italics. The bold's content read the `**` that its closing tag cut beside what lies after the whole run, so it could open, and the rule of 3 then measured it without the part the tag took. A run that the tag cuts now reads beside the edge of a line, as on main; one it does not cut still reads beside the tag when its marker is one CommonMark or GFM define.

## Test plan

### Before

```
pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "beside another marker"
     × reads a run beside another marker as CommonMark does in "[a](u)*`c`)**"
     × reads a run beside another marker as CommonMark does in "**)*(.`*b!****_"
      Tests  2 failed | 4 passed | 788 skipped (794)
```

### After

```
pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "beside another marker"
      Tests  6 passed | 788 skipped (794)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…t an ASCII table

## Description

`classify` filled a lazy 128-entry table so that ASCII characters needed no regular expression. It is called once per delimiter run and per marker, not per character, so the table saved nothing measurable, and it cost 186 bytes minified (73 gzipped). It now tests the two category expressions directly.

## Test plan

### Before

```
$ esbuild packages/lexical-markdown/src/index.ts --bundle --minify --external:lexical --external:'@lexical/*' | wc -c; ... | gzip -9 | wc -c
43701
16621
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-markdown
 Test Files  9 passed (9)
      Tests  1063 passed (1063)
$ esbuild packages/lexical-markdown/src/index.ts --bundle --minify --external:lexical --external:'@lexical/*' | wc -c; ... | gzip -9 | wc -c
43515
16548
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…nk search in less code

## Description

The same output in less code, from 43,515 to 42,305 bytes minified (16,548 to 16,152 gzipped) for the package bundle:

- Export marks the code units that go out as references on one array over the joined text, which the URL spoiling marks too, and writes them in one pass, rather than keeping per-piece lengths and a list of spoiled positions. A marker run is a tuple, and one function decides whether a reference helps a run open or close.
- A link whose label holds its real opener is found by searching on from that opener, which finds the same link, so `moveMatch` no longer cuts a match and a found link needs no separate position.
- `characterAt` and `characterBefore` use `codePointAt`, and the cut-run walk reads the scan's delimiter set instead of a cached string.

## Test plan

### Before

```
$ esbuild packages/lexical-markdown/src/index.ts --bundle --minify --external:lexical --external:'@lexical/*' | wc -c; ... | gzip -9 | wc -c
43515
16548
```

### After

```
$ pnpm exec vitest run --project unit packages/lexical-markdown packages/lexical-mdast
 Test Files  17 passed (17)
      Tests  1348 passed (1348)
$ esbuild packages/lexical-markdown/src/index.ts --bundle --minify --external:lexical --external:'@lexical/*' | wc -c; ... | gzip -9 | wc -c
42305
16152
```

The export and import output was also compared with the previous commit on 210,000 random paragraphs and 830,000 random lines (formats, links, line breaks, raw transformer output, URLs, emails, references, code spans and brackets), with no difference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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.

Thanks for this. The change looks good to me: classifying punctuation and whitespace with the Unicode sets follows CommonMark 0.31.2, the new tests fail on main and pass here, and the exported references read back through both @lexical/markdown and @lexical/mdast. A few suggestions, all optional and non-blocking:

  1. Stored CJK Markdown (a call for maintainers). Markdown that main exported and read back as bold, such as **注意:**请先登录, now imports with literal asterisks, and saving it again writes \*\*注意:\*\*请先登录. That is what CommonMark specifies, and the changeset's Verify: already says so. Bold labels that end in full-width punctuation right before CJK text are common in Chinese and Japanese writing, so it may be worth shipping the CJK-friendly follow-up in the same release. CONTRIBUTING.md files a behaviour-changing fix as [fix] with Verify:, as you did. AGENTS.md (Backwards Compatibility) describes [breaking] with Migration:/Detect: for changes callers depend on, if maintainers prefer that category here.
  2. Edge case right after a link (packages/lexical-markdown/src/importTextFormatTransformer.ts, findPairAtEdge, lines 161–195). [x](u)**:*a.** imports on main as x, *, italic :a., reading the run after the link beside the edge of a line as the description intends, but here as x, **:, italic a., *. The search for the pair that the run after the link opens stops before the scan's next emphasis (end), but that pair can contain it. Returning false (so the rest is scanned on its own) when end comes from the next emphasis or code span would keep the edge reading. It is a narrow shape.
  3. Export cost (packages/lexical-markdown/src/MarkdownExport.ts, findSpoiledLinks, line 897). Any paragraph whose text contains www., :// or @ builds a masked copy and three typed arrays, even when no reference or marker touches a link. For a 300-paragraph document with a bare URL in each paragraph, $convertToMarkdownString takes about 30 ms against about 7.5 ms on main (12.6 ms without the URLs). An early exit when no link run sits next to a marker or reference would help apps that export on every change.
  4. Test plan. The ### Before block shows the original head failing the MarkdownEscaping.test.ts round trips. AGENTS.md (Commits and Pull Requests) asks for the real failing output before the change, so pasting main's run of the new tests would show what they catch.

};
return true;
}
if (length === end) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: when no pair is found in prefixes that end before end, this keeps the whole-line scan, but the pair the edge run opens can contain the next emphasis. [x](u)**:*a.** imports on main as x, *, italic :a. (the run after the link read beside the edge of a line, as the description intends) and here as x, **:, italic a., *. Returning false when end came from the next emphasis or code span, so the rest is scanned on its own, would keep the edge reading.

) => void,
): number[] {
// Every URL and email starts with one of these.
if (!values.some((value, i) => kinds[i] === TEXT && LINKISH.test(value))) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: any text piece with www., :// or @ sends the whole paragraph through the masked copy and typed arrays below, even when no reference or marker touches a link. A 300-paragraph document with one bare URL per paragraph exports in about 30 ms against about 7.5 ms on main. An early exit when no link run is next to a marker or reference would recover most of that.

… no URL can need spoiling

## Description

Export scanned every paragraph whose text holds `www.`, `://` or `@` for bare URLs to spoil, building a masked copy of the paragraph and running counts over it, even where nothing could need it. A URL is spoiled only for a reference that is written, or for one that runs on to the end of its text into markers or raw output, so the scan now runs only for a paragraph that writes a reference or has such a URL. Output is unchanged.

## Test plan

### Before

A 300-paragraph document, each paragraph with a bare URL and one bold word, median of 15 runs of `$convertToMarkdownString`:

```
url=true cur 7.9ms
url=true main 3.0ms
```

### After

```
url=true cur 4.2ms
url=true main 3.0ms
$ pnpm exec vitest run --project unit packages/lexical-markdown
 Test Files  9 passed (9)
      Tests  1063 passed (1063)
```

The export was also compared with the previous commit on 45,000 random paragraphs with URLs, emails, references, markers and raw output, with no difference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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.

Thanks for this! The change looks good to me: classifying punctuation and whitespace with the Unicode sets follows CommonMark 0.31.2, the new tests fail on main and pass here, and the exported references read back through both @lexical/markdown and @lexical/mdast. A few suggestions, all optional and non-blocking:

  1. Quadratic escape with shouldPreserveNewLines (packages/lexical-markdown/src/MarkdownExport.ts, line 566). /\\+(?=[*_`~])|[*_`~]/g retries \\+ from every backslash of a run that no marker follows, so exporting a text node of 16,000 backslashes takes about 570 ms, against 0.1 ms on main. Matching the whole run and checking the character after it gives the same output in linear time:

    output = output.replace(
      /\\+|[*_`~]/g,
      (escaped, offset: number, text: string) =>
        escaped[0] !== '\\'
          ? `\\${escaped}`
          : /[*_`~]/.test(text[offset + escaped.length] ?? '')
            ? escaped + escaped
            : escaped,
    );
  2. Stored CJK Markdown (a call for maintainers). Markdown that main exported and read back as bold, such as **注意:**请先登录, now imports with literal asterisks, and saving it again writes \*\*注意:\*\*请先登录. That is what CommonMark specifies, and the changeset's Verify: says so. Bold labels that end in full-width punctuation right before CJK text are common in Chinese and Japanese writing, so it may be worth shipping the CJK-friendly follow-up in the same release.

  3. Export cost beside bare URLs (MarkdownExport.ts, spoilLinks, line 872). Any paragraph whose text contains www., :// or @ builds the masked copy and the prefix sums even when no reference or marker touches a link; 300 such paragraphs export in about 11 ms against about 2 ms on main. An early exit for that case would help apps that export on every change.

  4. A run right after a link (packages/lexical-markdown/src/importTextFormatTransformer.ts, findPairAtEdge, lines 153–197). [x](u)**:*a.** imports on main as x, *, italic :a., reading the run after the link beside the edge of a line as the description says, but here as x, **:, italic a., *. Returning false when end comes from the next emphasis or code span, so that the rest is scanned on its own, would keep the edge reading. It is a narrow shape.

  5. Test plan. The ### Before block shows the original head merged with main failing the MarkdownEscaping.test.ts round trips. AGENTS.md (Commits and Pull Requests) asks for the real failing output of the command before the change, so pasting main's run of the new tests would show what they catch.

Comment on lines +566 to +568
output = output.replace(/\\+(?=[*_`~])|[*_`~]/g, escaped =>
escaped[0] === '\\' ? escaped + escaped : `\\${escaped}`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: \\+(?=[*_~])is retried from every backslash of a run that no marker follows and backtracks through the rest of it each time, so this is quadratic in the run (16,000 backslashes: about 570 ms, against 0.1 ms onmain`). Matching the whole run and checking the next character gives the same output in linear time:

Suggested change
output = output.replace(/\\+(?=[*_`~])|[*_`~]/g, escaped =>
escaped[0] === '\\' ? escaped + escaped : `\\${escaped}`,
);
output = output.replace(
/\\+|[*_`~]/g,
(escaped, offset: number, text: string) =>
escaped[0] !== '\\'
? `\\${escaped}`
: /[*_`~]/.test(text[offset + escaped.length] ?? '')
? escaped + escaped
: escaped,
);

};
return true;
}
if (length === end) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Nit: when no pair is found before end and end comes from the next emphasis or code span, this keeps the whole-line scan, which read the edge run beside the text match. So [x](u)**:*a.** imports as x, **:, italic a., * instead of main's x, *, italic :a.. Returning false here in that case (so the rest is scanned on its own) would keep the edge reading the description promises.

@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.

Thanks for this! The change looks good to me: classifying punctuation and whitespace with CommonMark 0.31.2's Unicode sets is what the spec asks for, the new tests fail on main and pass here, and the exported references read back through both @lexical/markdown and @lexical/mdast. A few suggestions, all optional and non-blocking:

  1. Quadratic escape with shouldPreserveNewLines (packages/lexical-markdown/src/MarkdownExport.ts, line 566). /\\+(?=[*_`~])|[*_`~]/g retries \\+ from every backslash of a run that no marker follows, so exporting a text node of 16,000 backslashes takes about half a second, against well under a millisecond on main. Matching the whole run and checking the character after it gives the same output in linear time:

    output = output.replace(
      /\\+|[*_`~]/g,
      (escaped, offset: number, text: string) =>
        escaped[0] !== '\\'
          ? `\\${escaped}`
          : /[*_`~]/.test(text[offset + escaped.length] ?? '')
            ? escaped + escaped
            : escaped,
    );
  2. Stored CJK Markdown (a call for maintainers). Markdown that main exported and read back as bold, such as **注意:**请先登录, now imports with literal asterisks, and saving it again writes \*\*注意:\*\*请先登录. That is what CommonMark specifies and how @lexical/mdast already reads it, and the changeset's Verify: paragraph says so. Bold labels that end in full-width punctuation right before CJK text are common in Chinese and Japanese writing, so it may be worth shipping the CJK-friendly follow-up in the same release.

  3. Test plan. The ### Before block shows the original head merged with main failing the MarkdownEscaping.test.ts round trips. AGENTS.md (Commits and Pull Requests) asks for the real failing output of the command before the change, so pasting main's run of the new tests would show what they catch.

The CI workflows have not run on this head yet; they are waiting for a maintainer to approve them for the fork.

Comment on lines +566 to +568
output = output.replace(/\\+(?=[*_`~])|[*_`~]/g, escaped =>
escaped[0] === '\\' ? escaped + escaped : `\\${escaped}`,
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional, non-blocking: \\+(?=[*_~])is retried from every backslash of a run that no marker follows, so this is quadratic in the run (16,000 backslashes take about half a second to export, against well under a millisecond onmain`). Matching the whole run and checking the character after it gives the same output in linear time:

output = output.replace(
  /\\+|[*_`~]/g,
  (escaped, offset: number, text: string) =>
    escaped[0] !== '\\'
      ? `\\${escaped}`
      : /[*_`~]/.test(text[offset + escaped.length] ?? '')
        ? escaped + escaped
        : escaped,
);

… linear time when preserving newlines

## Description

With `shouldPreserveNewLines`, export doubled a run of backslashes before a marker with `/\\+(?=[*_`~])/`, which a run that no marker follows retries from each of its backslashes, so a text node of 16,000 backslashes took 400 ms to export. The whole run is now matched once and the character after it checked, which gives the same output in linear time.

## Test plan

### Before

Exporting a text node of 16,000 backslashes and a letter with `shouldPreserveNewLines`:

```
ref 400.0ms
```

### After

```
cur 1.8ms
$ pnpm exec vitest run --project unit packages/lexical-markdown
 Test Files  9 passed (9)
      Tests  1063 passed (1063)
```

The export was also compared with the previous commit on 25,000 random paragraphs of backslashes, markers, letters and newlines in both modes, with no difference.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
…line edge up to a code span only

## Description

Text right after a text match reads the match's edge as the edge of a line, as on main, but the search for the pair that the run there opens stopped at the next emphasis the scan had found. A pair that holds that emphasis was then never found: with a `user:(\w+)` mention transformer, `user:bob**(text *more*)**`, which Lexical exports for a mention followed by bold text holding italic, re-imported with literal `**` markers, and `[x](u)**:*a.**` lost its bold the same way. The search now stops only at a code span that comes before the next emphasis, which is imported first as it is on main, so both read as they do on main.

## Test plan

### Before

```
pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "nested after"
     × reads formatting nested after a text match whose syntax has a letter beside it 43ms
AssertionError: expected [ [ 'bob', +0 ], …(3) ] to deep equally contain [ 'more', 3 ]
      Tests  1 failed | 794 skipped (795)
```

### After

```
pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts -t "a text match whose syntax"
      Tests  5 passed | 790 skipped (795)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

@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.

Thanks for the careful work here. Following CommonMark's Unicode punctuation and whitespace fixes real import bugs (See –**[here](https://example.com)**, **a.**\x, the _ shortcut after – or “), and the new tests cover both importers and the round trip.

A maintainer decision before merge: CJK content that main imports as bold

Stored Markdown such as **注意:**请先登录, - **要点:**说明文字 or 请**“注意”**这里 imports as bold on main, and it is what main's exporter writes for that formatting. With this change it imports with literal **, and the next export escapes them (\*\*注意:\*\*请先登录), so opening and saving such a document turns the bold into visible asterisks. This follows CommonMark 0.31.2 (: is Unicode punctuation, so the closing ** between : and 请 is not right-flanking), matches GFM, markdown-it and @lexical/mdast, and the changeset's Verify: paragraph names it, so it is not a bug in the implementation. Because AGENTS.md's Backwards Compatibility section asks that existing content keep working, a maintainer should choose one of: merge it as the [fix] patch and leave the CJK-friendly rule to a follow-up; add the CJK-friendly amendment first (as commit 454bf81 did before it was reverted); or keep strict reading and ship it as a [breaking] minor changeset with Migration: and Detect:. (packages/lexical-markdown/src/utils.ts:66)

Optional, non-blocking suggestions

  • Add one import test that pins whichever CJK behaviour is chosen. The tests export bold 你好。 with a reference, but none imports **注意:**请先登录.
  • The Test plan's Before block shows MarkdownEscaping.test.ts failures from merging main into the original head rather than the new tests failing on main; AGENTS.md (Commits and Pull Requests) asks for the real failing output of the command that was run.
  • Export now does more work per paragraph (joinPieces, decideReferences and spoilLinks in MarkdownExport.ts), and @lexical/markdown grows by roughly a quarter. It stays linear, but apps that export a large document on every change may notice it, so it may be worth a profile.

No public API, TypeScript or Flow declaration changes, and the module-scope additions keep the package tree-shakeable.

// U+FEFF. Delimiter flanking depends on both.
// https://spec.commonmark.org/0.31.2/#unicode-punctuation-character
// https://spec.commonmark.org/0.31.2/#unicode-whitespace-character
const PUNCTUATION = /[\p{P}\p{S}]/u;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

With every P and S character counted as punctuation, stored CJK Markdown that main reads as bold, such as **注意:**请先登录 (which main's exporter also writes), now imports with literal ** and re-exports as \*\*注意:\*\*请先登录. That is what CommonMark 0.31.2 says; whether it ships before a CJK-friendly rule, or as a [breaking] change per AGENTS.md's Backwards Compatibility section, is a maintainer decision.

@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. Following CommonMark's Unicode punctuation and whitespace fixes real import bugs (See –**[here](https://example.com)** now., **a.**&#120;**`c`** reading correctly, the _ shortcut after –, “ or an emoji), the exporter's character references keep formatting through both @lexical/markdown and @lexical/mdast, and the new tests fail on main and pass here. Everything below is optional and non-blocking.

CJK content that main imports as bold (acknowledged follow-up)

Stored Markdown such as **注意:**请先登录, - **要点:**说明文字 or 请**“注意”**这里 imports as bold on main, and it is what main's exporter writes for that formatting; with this change it imports with literal ** and the next export escapes them. That is what CommonMark 0.31.2 requires (: is Unicode punctuation, so the closing ** between : and 请 is not right-flanking), it matches GFM, markdown-it and @lexical/mdast, and the PR description and the changeset's Verify: paragraph already name it and leave a CJK-friendly rule to a follow-up, so I'm not treating it as a blocker. Two small suggestions around it:

  • Add one import test that pins the current behaviour, e.g. **注意:**请先登录 → literal ** (packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts); today the tests export bold 注意: with a reference but never import the shape main wrote, so the follow-up will have a test to flip.
  • AGENTS.md's Backwards Compatibility section says a change that stops existing content working ships as a [breaking] minor changeset with Migration: and Detect:. The current [fix] entry with Verify: follows CONTRIBUTING.md's rule for a fix that changes output; whether stored CJK content counts as breaking is a maintainer call, and either category is fine to merge with.

Other optional suggestions

  • The Test plan's Before block shows the MarkdownEscaping.test.ts failures from merging main into the original head, not the new tests failing on main; AGENTS.md (Commits and Pull Requests) asks for the real failing output of the command that was run.
  • Export now does more work per paragraph (joinPieces, decideReferences and spoilLinks in packages/lexical-markdown/src/MarkdownExport.ts): roughly twice the time for a 300-paragraph export, and the minified package grows by about a quarter. It stays linear and long-line import got much faster, so this is just worth keeping an eye on for apps that export on every change.

No public API, TypeScript or Flow declaration changes, and the new module-scope code keeps the package tree-shakeable.

// U+FEFF. Delimiter flanking depends on both.
// https://spec.commonmark.org/0.31.2/#unicode-punctuation-character
// https://spec.commonmark.org/0.31.2/#unicode-whitespace-character
const PUNCTUATION = /[\p{P}\p{S}]/u;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Optional: with every P and S character counted as punctuation, **注意:**请先登录 (which main imported as bold and wrote on export) now imports with literal **, as CommonMark 0.31.2 requires and as the changeset's Verify: says. An import test pinning that shape in LexicalMarkdown.test.ts would document the current behaviour and give the CJK-friendly follow-up a case to flip.

@etrepum
etrepum added this pull request to the merge queue Nov 3, 2026
Merged via the queue into meta:main with commit 4d8cc44 Nov 3, 2026
53 checks passed
etrepum pushed a commit to etrepum/lexical that referenced this pull request Nov 3, 2026
…dly punctuation mode for Markdown import and export

## Description

CommonMark doesn't let `**` open or close between a letter and punctuation, so `**你好。**世界` reads with literal asterisks in both packages once meta#9430 follows CommonMark's Unicode punctuation.

Import in both packages now follows the CommonMark CJK-friendly amendment (https://github.com/tats-u/markdown-cjk-friendly) by default, and export still writes Markdown for strict CommonMark/GFM readers by default. Both are selectable:

- `@lexical/markdown`: the import and export functions take an options object in place of `shouldPreserveNewLines` (`MarkdownImportOptions` / `MarkdownExportOptions`) with `punctuation: 'cjk-friendly' | 'strict-commonmark'`. Flanking uses its own classifier with the amendment's CJK table (East Asian Width F/H/W without emoji, plus Hangul, from get-east-asian-width 1.7), ideographic variation selectors and the variation-selector lookback. A strict export is checked against CJK-friendly readers too.
- `@lexical/mdast`: `MdastExtension` takes `importPunctuation` and `exportPunctuation`. CJK-friendly import uses `micromark-extension-cjk-friendly` and its strikethrough counterpart. CJK-friendly export writes the strict export's references beside `*`/`~` runs to CJK characters as typed. `mdast-util-gfm-strikethrough` 2.0.1 serializes `~~` as attention, so strict export now writes references beside strikethrough too.

## Changeset

`.changeset/markdown-cjk-friendly-punctuation.md`, a `[feature]` patch for `@lexical/markdown` and `@lexical/mdast`.

## Test plan

### Before

New tests did not exist; `**你好。**世界` imported with literal asterisks in both packages.

### After

```
$ vitest run --project unit packages/lexical-mdast packages/lexical-markdown
 Test Files  19 passed (19)
      Tests  1368 passed (1368)
```

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

This branch was successfully deployed

2 active deployments
Preview – lexical — 7184aaaa Deployed Nov 3, 2026 by vercel[bot]
Preview – lexical-playground — 7184aaaa Deployed Nov 3, 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. extended-tests Run extended e2e tests on a PR

Projects

None yet

3 participants