Skip to content

[lexical-markdown] Bug Fix: Escape a literal bracket only where it would start a link - #9600

Merged
zurfyx merged 10 commits into
mainfrom
zurfyx/markdown-narrow-bracket-escapes
Oct 31, 2026
Merged

zurfyx merged 10 commits into
mainfrom
zurfyx/markdown-narrow-bracket-escapes

Conversation

@zurfyx

@zurfyx zurfyx commented Oct 30, 2026 •

Copy link
Copy Markdown
Member

Description

Since #9481 the Markdown export writes every literal [ and ] as [ and ], so ordinary text such as cleared review [1][2], done [x] or array[0] changes on every save to cleared review [1][2]. CommonMark renders both forms the same, but the stored text changes, which breaks callers that read the exported string (citation regexes such as /\[(\d+)\]/, exact round-trip checks).

A literal bracket only needs escaping where it would otherwise become syntax, and escaping its ] is enough in each case:

A [ is escaped only when the label it starts at a line start would close at a ] inside inline code followed by :, which cannot be escaped.

On import, LINK now starts a link at the [ that really opens its text. Its lazy label pattern runs from the first [ on the line, and the text before the real opener used to be kept as raw source, so [1] **bold** [link](url) lost its bold, whether written by export or by hand. That text now goes through the other transformers.

Inside link text, brackets are allowed as matched pairs (https://spec.commonmark.org/0.31.2/#link-text); unmatched brackets, and a pair followed by ( (which would read as a nested link), are escaped, and other matched pairs stay as typed, so [[1]](url) exports unchanged.

The text on either side of a bracket is read the way it is exported: across text nodes and through inline elements whose children are exported in place, such as comment marks and autolinks (or every link when LINK is not among the transformers); a line break as a newline; a link written as [text](url) as text that cannot hold a label; inline code with its brackets masked; and the start of an exported selection as a line start. Whether a node is written in its own syntax is decided by asking the export's text match transformers. A bracket next to a decorator, or next to a node a transformer writes in its own syntax, is escaped. The state of the text before each node is computed once per export and reused, so export stays linear: 8,000 styled xa]: nodes export in tens of milliseconds (53 ms on main, 39.8 s on the first version of this PR).

The < and & escaping from #9481, and import of the &#91;/&#93; output written since v0.53.0, are unchanged.

Changeset

.changeset/markdown-narrow-bracket-escapes.md ([fix], @lexical/markdown patch, with a Verify: paragraph).

Test plan

Before

pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/MarkdownEscaping.test.ts -t 'brackets that cannot become Markdown syntax' on main:

× leaves "cleared review [1][2]" as typed
× leaves "done [x] and [ ] todo" as typed
× writes link text "[1]" as "[[1]](uri)"
...
Expected: "cleared review [1][2]"
Received: "cleared review &#91;1&#93;&#91;2&#93;"

After

$ pnpm exec vitest run --project unit packages/lexical-markdown
 Test Files  9 passed (9)
      Tests  910 passed (910)

$ pnpm run ci-check
Changeset check passed: 34 pending changeset(s) valid.
Found 0 errors
(tsc, tsc-test, tsc-scripts, tsc-extension, tsc-website, flow, prettier, lint: exit 0)

$ pnpm exec vitest run --project unit packages/lexical-markdown packages/lexical-playground packages/lexical-react packages/lexical-mdast
 Test Files  91 passed (91)
      Tests  1865 passed (1865)

The new MarkdownEscaping.test.ts cases cover each review point:

  • brackets inside and around an inline element, including a mark inside link text;
  • definition labels split across nodes, after a doubled backslash, after an escaped ], across a line break and at the start of an exported selection;
  • task markers in paragraphs and bullet items (read back with CHECK_LIST as text, while a real check list still exports as - [ ] todo) and after block markers (- [x] done, > [a]: b);
  • autolinks, and links exported without LINK;
  • inline decorators inside a bracket pair;
  • a label that inline code would close, and typed control characters;
  • round trips of text with brackets, formatting and code before a link (Claim [1] is *important*, see [source](url)), checking the imported nodes' formats and links;
  • linear-time exports of 8,000 styled nodes and 80,000-character nodes.

Randomized checks against micromark 4.0.2 (local scripts, not committed):

random paragraphs with bold/italic/code, brackets, inline elements, links and line breaks: checked 112784, 0 failures
random selections export a valid standalone document: checked 15387, 0 failures
random paragraphs (with LINK), tokens incl. "- ", "> ", "1. ", "[x]", "](", "]:", autolinks, decorators, inline code: checked 95482, 0 failures
random paragraphs (without LINK): checked 95481, 0 failures

Each paragraph must render as its original text, with no definition swallowing text, no task list input (GFM task list extension), and one anchor per link written in brackets. It must also read back through $convertFromMarkdownString with CHECK_LIST with no check list, the same links and the same text. main passes the same checks; the first revision of this PR fails the token check within a few thousand cases.

In Meta's internal www, with this build in the next Lexical sync, 2,898 Lexical-dependent Jest suites run. Two fail, and they also fail on the base revision. MetaCRM's and Rhythm's citation round trips pass again; they fail with v0.53.0 alone. Rhythm's own renderer is being updated separately to read a task marker written as [ &#93;.

Browser and E2E suites were not run; the change is limited to Markdown export string generation.

…uld start a link

## Description

Since #9481 the Markdown export writes every literal `[` and `]` as `&#91;` and `&#93;`, so ordinary text such as `cleared review [1][2]`, `[x] done` or `array[0]` changes on every save to `cleared review &#91;1&#93;&#91;2&#93;`. CommonMark renders both forms the same, but the stored text changes, which breaks callers that read the exported string (citation regexes such as `/\[(\d+)\]/`, exact round-trip checks, checklist detection).

CommonMark only needs a bracket escaped where it would otherwise form a link:

- Outside a link, a link, image or link reference definition needs a `]` followed by `(` (https://spec.commonmark.org/0.31.2/#inline-link), or by `:` after a label that starts a line (https://spec.commonmark.org/0.31.2/#link-reference-definitions). Escaping that `]` is enough, whatever precedes it, so only that `]` is escaped. A `]` at the end of a text node is checked against the next text sibling, so constructs split across differently styled nodes are still caught.
- Inside link text, brackets are allowed as matched pairs (https://spec.commonmark.org/0.31.2/#link-text). Unmatched brackets, and a pair followed by `(` (which would read as a nested link), are escaped. Other matched pairs stay as typed, so `[[1]](url)` exports unchanged.

The `<` and `&` escaping from #9481, and import of the old `&#91;`/`&#93;` output, are unchanged.

## Changeset

`.changeset/markdown-narrow-bracket-escapes.md` (`[fix]`, `@lexical/markdown` patch, with a `Verify:` paragraph).

## Test plan

### Before

`pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/MarkdownEscaping.test.ts -t 'brackets that cannot become Markdown syntax'` on `main`:

```
× leaves "cleared review [1][2]" as typed
× leaves "[x] done and [ ] todo" as typed
× writes link text "[1]" as "[[1]](uri)"
...
Expected: "cleared review [1][2]"
Received: "cleared review &#91;1&#93;&#91;2&#93;"
Expected: "[x] done and [ ] todo"
Received: "&#91;x&#93; done and &#91; &#93; todo"
```

### After

- `pnpm exec vitest run --project unit packages/lexical-markdown`: 858 tests pass, including 28 new cases. Each plain-text case is exported split at every offset across two styled text nodes, checked against the expected Markdown, and imported back to the same text with no link.
- CommonMark check (a local script, not committed): every export was rendered with micromark 4.0.2 and compared with the source. 50,000 random plain-text strings over `[]()!: a1\<&#;*`, split at a random offset across two styled nodes, render as the same text with no link or image. 30,000 random link labels render as exactly one link with the same text. `main` passes the same check: both versions are spec-correct, and this one escapes less.
- `pnpm run test-unit`: 587 files, 18,207 tests pass.
- `pnpm run ci-check` (tsc, flow, prettier, lint, changesets) passes.
- Meta-internal www: with this build, three www test suites that #9481 broke (citation round trips and checklist rendering) pass again.

Browser and E2E suites were not run; the change is limited to Markdown export string generation.
@vercel

vercel Bot commented Oct 30, 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 3:40am UTC
lexical-playground Ready Ready Preview Oct 31, 2026 3:40am 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 30, 2026
@pkg-pr-new

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

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: d721896

@zurfyx
zurfyx marked this pull request as ready for review October 30, 2026 16:42
@meta-code-review

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

Copy link
Copy Markdown

AI review status for this pull request.

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

@etrepum etrepum left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Claude review:

Review of #9600 (head ab23dd0)

Requesting changes, but the approach is right. Escaping only the ] that would close a link label is the minimal rule CommonMark needs, and it fixes the real breakage from #9481 ([1], [x] and array[0] were rewritten on every save). I checked the remaining gaps by rendering each export with micromark 4.0.2. Most of them come from the line-start check and the next-character check reading something other than what is actually exported. Fixes and tests are in lexical-9600-label-and-neighbours.patch, which applies on ab23dd0 (@lexical/markdown unit tests and tsc -p tsconfig.test.json pass).

1. $endsDefinitionLabel misses labels, so the text turns into a definition and is lost. It reads earlier siblings' raw getTextContent(), but it reads this node's text after escaping, and its regex treats \x as an escape. Each case below exports a valid link reference definition, which micromark renders as nothing:

  • [a\ + ]: /url in two styled nodes exports [a\\]: /url. The raw \ looks like it escapes the ], but the export doubles it, so the ] really closes the label.
  • [a](x]: /url in one node exports [a&#93;(x]: /url. The first ] is escaped to &#93;, so it no longer closes anything, but the check still sees it as a bracket.
  • $convertSelectionToMarkdownString with only [a]: /url selected after an unselected intro exports [a]: /url. The walk reads unselected siblings, so it sees intro [a as the line.

The patch replaces the regex with a small state machine (up to three spaces, then [, then no further [). It keeps ] inside the label, which only over-escapes, and it stops the walk at the start of the selection, using $exportedText for siblings.

2. The export is now quadratic. For every ]:, $endsDefinitionLabel slices the node's text from 0 and walks back through every earlier sibling until it finds a newline. A paragraph of 8,000 styled xa]: nodes takes 53 ms on main and 39.8 s on this PR, and an 80,000-character single node takes 60 ms on main and 4.4 s here. Text from paste, import and collab can be this large. The patch computes the line state once per node, and only for nodes with a ] that : might follow. It bounds the walk at 1,003 characters, since a label holds at most 999 characters. With the patch those inputs take 534 ms and 12 ms.

3. Inline elements hide the neighbouring text. getTextSibling returns null at the edge of a mark or another inline element that exports only its children, and the line-start walk stops there too. Comment marks are the common case. [a] inside a mark followed by (b) exports [a](b), which is a link to b. [a followed by a mark around ]: /url exports as a definition. The patch adds $adjacentInline, which looks through inline elements other than links, for both checks.

4. A leading [ ] or [x] becomes a checklist again (please decide). On main, #9481 also kept these from re-importing as task items. With this PR, a bullet item whose text is [ ] todo exports as - [ ] todo, and a paragraph [x] done exports as [x] done. With CHECK_LIST in the transformers, both import as check list items, because CHECK_LIST_REGEX matches even without a list marker. For the bullet item, GFM renders a task list item too. Escaping only the ] of a [ ]/[x]/[X] followed by whitespace at the start of a block would keep the rest of the PR's plain-text output unchanged. If the www checklist suites depend on [x] done coming out raw, please say so in the description, so the trade-off is explicit.

@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 narrowing this. Exporting [1], [x] and array[0] as typed again is a real improvement, and keeping the old &#91;/&#93; output importable is the right call.

I agree with the earlier review on this head: the cases below confirm its points and add one, and the first needs a fix before this lands.

1. A ] next to an inline element boundary is not escaped (blocking): packages/lexical-markdown/src/MarkdownExport.ts:671-675

The character after a node's last ] comes only from a sibling text node (getTextSibling(node, false)), and $endsDefinitionLabel only walks siblings in the same parent (:738-748). When the ] and the following ( or : sit on either side of an inline element such as a comment MarkNode:

  • a comment on [a] followed by (b) exports as [a](b), which $convertFromMarkdownString reads back as a link a → b;
  • a link to url whose text has a comment on [x] followed by (y) exports as [[x](y)](url), which reads back as a link to y;
  • a comment on [a] followed by : b at the start of a paragraph exports as [a]: b, a link reference definition, so CommonMark renderers show nothing.

The package README promises that default export "protects ... link syntax in text, including constructs split across text nodes". Finding the neighbouring exported character in document order within the block (out of an inline parent, into the next inline element's first text, a line break read as a newline, and a conservative escape next to a decorator) would cover these. Please add them beside the existing split cases in MarkdownEscaping.test.ts.

2. The definition-label check reads text that differs from the output (suggestion): MarkdownExport.ts:737-751

Besides the escaped ] ([a](b]: c exports as [a&#93;(b]: c), the doubled backslash of an earlier sibling (a node ending in \ before a ]: b node exports as [a\\]: b) and the unselected text read under $convertSelectionToMarkdownString, a link label may span lines: [foo, a line break, then bar]: /url exports unchanged and is a valid definition, so the paragraph renders as nothing. Checking the label against the escaped output from the start of the block, not the line, covers all four. The same loop rebuilds and rescans the line for every ]:, which is the super-linear export the earlier review measured; scanning back from the ] and stopping at the first bracket or the line start keeps it linear.

3. Task markers at the start of list items (please decide)

A bullet item whose text is [x] done now exports as - [x] done and a numbered item [ ] todo as 1. [ ] todo; GFM renders both as task list items, and with CHECK_LIST among the transformers the bullet reads back as a check-list item. Because CHECK_LIST_REGEX (MarkdownTransformers.ts:223) makes the list marker optional, a paragraph starting with [ ] reads back as a check list too. Escaping a leading task marker in list items, or stating the trade-off in the changeset, would make this deliberate.

4. Changeset and README wording (nit)

Once the above is settled, the changeset's sentence that escaping ]( and a line-start ]: "is enough to keep literal text from parsing as a link, image or link reference definition", and the README line saying literal brackets use numeric references, should describe the final rule; .changeset/README.md notes the entry becomes the CHANGELOG text people upgrading read.

Comment on lines +671 to +675
const nextSibling = getTextSibling(node, false);
const nextChar =
nextSibling && (!selection || nextSibling.isSelected(selection))
? $exportedText(nextSibling, selection).charAt(0)
: '';

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Blocking: the character after a node's last ] only comes from a sibling text node, so a ] that ends the text inside an inline element (a comment MarkNode, say) gets '' here. A comment on [a] followed by (b) then exports as [a](b), which $convertFromMarkdownString reads back as a link a → b, and a comment on [a] followed by : b at the start of a paragraph exports as [a]: b, a CommonMark link reference definition that renders as nothing. Walking to the next exported character in document order within the block (out of an inline parent, into the next inline element's first text, a line break as a newline) and escaping conservatively next to a decorator would cover it; $endsDefinitionLabel needs the same walk backwards.

Comment on lines +737 to +751
let line = text.slice(0, index);
for (
let sibling = node.getPreviousSibling();
sibling !== null && !line.includes('\n');
sibling = sibling.getPreviousSibling()
) {
if ($isLineBreakNode(sibling)) {
line = '\n' + line;
} else {
line = sibling.getTextContent() + line;
}
}
return /^ {0,3}\[(?:[^[\]\\]|\\.)*$/.test(
line.slice(line.lastIndexOf('\n') + 1),
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

The label is tested against text that differs from the output: a ] this pass escapes still ends it ([a](b]: c exports as [a&#93;(b]: c), earlier siblings contribute raw text (a node ending in \ before a ]: b node exports as [a\\]: b), only the current line is read although a CommonMark label may span lines ([foo, a line break, bar]: /url), and selection export reads unselected text. Each is a valid link reference definition, so the paragraph renders as nothing. Rebuilding and rescanning the line for every ]: is also super-linear on a long line with many styled runs; scanning back from the ] and stopping at the first bracket or the line start would address both.

…ine elements, check definition labels and task markers on exported text, in linear time

Addresses review on #9600.
@zurfyx

zurfyx commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Thanks for both reviews. All points are addressed in 29954b9:

  1. Inline element boundaries (blocking). The character after a bracket now comes from the next exported text in the block, read through inline elements other than links (a comment mark, say): a line break reads as a newline, a link as [, the end of a link's text as ], and a decorator as unknown, which escapes. The text before a bracket is read the same way backwards. Tests cover [a] in a mark followed by (b), [x] in a mark inside link text followed by (y) (now [&#91;x&#93;(y)](url)), and a definition split by a mark in either direction.
  2. Definition labels read the exported text. The label check no longer rebuilds the line or treats \x as an escape. Any ] between the label's [ and its closing ] is ignored rather than ending the search, so a ] this pass escapes and one written after a doubled backslash both still count as inside the label, which errs towards escaping. The label may span line breaks, and the walk stops at the start of the exported selection, using $exportedText for siblings. [a\ + ]: /url, [a](x]: /url, [foo + line break + bar]: /url, and a selection starting at [a]: /url are all tests now.
  3. Linear time. The state of the text before each leaf (nearest [, link or decorator within 1,000 characters, and whether only whitespace precedes it on its line) is computed once per export and cached by node key for that export only, so each leaf's text is read once. 8,000 styled xa]: nodes export in 38 ms (53 ms on main), and an 80,000-character node in about 1 ms.
  4. Task markers: escaped. [ ], [x] or [] at the start of a line followed by whitespace now has its ] escaped, in paragraphs and in list items, as [lexical-markdown] Bug Fix: Preserve literal text and escaped link brackets in Markdown round trips #9481 did, so it no longer reads back as a check list with CHECK_LIST or as a GFM task item. A real check list still exports as - [ ] todo. In www, Rhythm's tracker renderer showed bullet text starting with [ ] as a checklist; I'm updating that renderer to read [ &#93; too, so www does not need the raw form.
  5. Changeset and README now describe the final rule.

I also ran a randomized check against micromark: 246,925 paragraphs mixing text, inline elements, line breaks and links, plus 15,239 selections, all render as the original text with one anchor per link and read back to the same document. Details are in the updated description.

@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 narrowing this. Escaping only the ] that would close a link, a definition label or a task marker fixes the v0.53.0 regression for text like [1][2], array[0] and done [x], and the new tests cover the rule thoroughly.

Requested change: links exported without brackets (packages/lexical-markdown/src/MarkdownExport.ts: $isInsideLink L829, $isTransparentInline L840, $nextExportedChar L859 and L881, $leafText L944)

These helpers assume every LinkNode is exported as [text](url). The LINK transformer returns null for an AutoLinkNode (MarkdownTransformers.ts:1247), so an autolink is exported as its bare text, and when LINK is not in the transformer list every link is. Text that main escapes then becomes Markdown syntax:

  • With AutoLinkExtension and the stock autoLinkUrlMatcher, the plain paragraph [see https://example.com today]: https://example.com gets two autolinks and exports unescaped. That is a link reference definition, so CommonMark and GFM renderers show nothing for the paragraph; main exports &#91;see … today&#93;: ….
  • An autolink whose text ends in [a], followed by (b), exports as …[a](b), which $convertFromMarkdownString reads back as a link.
  • With a transformer list that lacks LINK, a link whose text is [a] followed by (b) exports as [a](b) and re-imports as a link, and [ + link + ]: /url exports as a definition.

The README paragraph this PR rewrites says a bracket is escaped wherever it would start a link or a link reference definition, so these cases contradict it. Treating an AutoLinkNode like a transparent inline element in those four places ($isLinkNode(node) && !$isAutoLinkNode(node), matching LINK) fixes the autolink cases without changing any existing expectation. For lists without LINK, pass whether links are written in brackets, or apply the literal rules to link text when they are not. A few $createAutoLinkNode cases next to the across inline elements tests would cover both.

Optional, non-blocking: the task-marker rule only fires when [ starts the line, but CHECK_LIST_REGEX (MarkdownTransformers.ts:223) also accepts a leading - or + marker. A paragraph whose text is - [x] done now exports raw and re-imports with CHECK_LIST as a checked item done, where main kept [x] done as bullet text. Counting an optional -/+ and one space as part of the line start for that rule would keep the earlier result.


/** Whether `node` is an inline element whose exported text is its children's. */
function $isTransparentInline(node: LexicalNode | null): node is ElementNode {
return $isElementNode(node) && node.isInline() && !$isLinkNode(node);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

LINK returns null for an AutoLinkNode (MarkdownTransformers.ts:1247), so an autolink is exported as its bare children, exactly like a transparent inline element, but this check (and $isInsideLink, $nextExportedChar and $leafText) treats it as [text](url). With AutoLinkExtension and the stock autoLinkUrlMatcher, the plain paragraph [see https://example.com today]: https://example.com exports unescaped, and CommonMark reads it as a link reference definition that renders nothing. Suggest $isLinkNode(node) && !$isAutoLinkNode(node) in all four places, plus $createAutoLinkNode tests. A transformer list without LINK has the same problem for every LinkNode.

const atLineStart =
distance !== -1 &&
state.openerChar !== BARRIER &&
state.openerIndent !== -1;

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: CHECK_LIST_REGEX (MarkdownTransformers.ts:223) also accepts a leading - or + marker, so a paragraph whose text is - [x] done now re-imports as a checked check-list item done, where main kept [x] done as bullet text. Counting an optional -/+ and one space as part of the line start for the task-marker rule would keep that.

…ts as text, and count block markers as a line start

Addresses the second review on #9600.
@zurfyx

zurfyx commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Thanks, both addressed in the latest push:

  1. Links not written in brackets. Whether a node is exported in its own syntax is now decided by asking the export's text match transformers, once per node per export, instead of assuming every LinkNode is [text](url). An AutoLinkNode, or any LinkNode when LINK is not in the list, is read as plain text through its children, so [see https://example.com today]: https://example.com with autolinks now exports …today&#93;: …, an autolink ending in [a] before (b) exports …[a&#93;(b), and without LINK, [a] link text before (b) and [ + link + ]: /url are escaped. A non-link element or a text node that a transformer writes in its own syntax counts as unknown text, which escapes. Tests cover each case.
  2. Block markers before a task marker or label. A line now still counts as starting while it holds only whitespace and container markers (>, -, *, +, 1., 1), each followed by whitespace), so - [x] done, 1. [x] done, > [a]: b and 2) [a]: b in a paragraph are escaped. \* [] todo stays unescaped, because the export already escapes that *.

I extended the randomized micromark check to cover these: 92,000+ paragraphs each with and without LINK, built from tokens such as - , > , 1. , [x], ](, ]:, autolinks, links, inline elements and line breaks. Each must render with no definition swallowing text, no task list input, one anchor per bracket-written link, and read back through $convertFromMarkdownString with CHECK_LIST with no check list and the same links. The previous commit fails this within a few thousand cases, and main and this commit pass. The earlier 246,925-paragraph and selection checks still pass, and so do ci-check and the @lexical/markdown, playground and React unit tests.

@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 narrowing the bracket escaping. Exporting [1][2], done [x] and array[0] as typed is the right fix, and computing the line state once per export keeps it linear. One case still needs a change before this lands.

Requested change

A ] after an inline decorator inside a line-start label is not escaped (packages/lexical-markdown/src/MarkdownExport.ts:784-789). When unknown text (a decorator, or a node a text match transformer writes in its own syntax) follows the [, advanceLineState makes it the new opener and recomputes openerAtLineStart from the current phase, which is already text, so the [ that opened the line is forgotten. A paragraph [ + inline decorator (text foo) + ]: done exports [foo]: done, which CommonMark reads as a link reference definition (spec): the paragraph renders as nothing, and a later paragraph see [foo] here renders as a link to the URL typed in the first one. With a decorator whose text is x, [ + decorator + ] todo exports [x] todo, which reads back as a check list item with CHECK_LIST. The PR description and the changeset say a bracket next to a decorator is escaped. Keeping the line start through unknown text fixes all of these:

if (character === '[' || character === BARRIER || character === UNKNOWN) {
  const keepsLineStart =
    character === UNKNOWN &&
    state.openerDistance !== -1 &&
    state.openerChar !== BARRIER &&
    state.openerAtLineStart;
  state.openerDistance = 0;
  state.openerChar = character;
  state.openerNext = '';
  state.openerAtLineStart = state.phase === 'start' || keepsLineStart;
}

Please add cases with an inline DecoratorNode inside a bracket pair to MarkdownEscaping.test.ts, next to the TestInlineNode cases.

Optional, non-blocking

  • Inline code holding ]: after a line-start [ (MarkdownExport.ts:1045): [a + inline code ]:b exports [a`]:b` , which is also a definition, because a link label ends at the first ] that is not backslash-escaped and a code span's ] cannot be escaped. Only escaping the opening [ avoids it, so this needs a look-ahead from a line-start [ to a following code span that holds ].
  • Extra transformer calls (MarkdownExport.ts:904-918): $exportKind calls every text match transformer's export with stub callbacks on the parent block and on decorators, whose answer is then ignored. Checking the node type first would keep block nodes away from custom transformers whose export records state.
  • Sentinels in typed text (MarkdownExport.ts:698, :703): U+0001 and U+0002 in a text node are read as a link or unknown text, so [a + U+0001 + ]: /url exports unescaped.
  • Changeset wording: .changeset/markdown-narrow-bracket-escapes.md says a bracket next to a decorator is escaped; that holds once the fix above lands. The entry otherwise follows CONTRIBUTING.md.
  • Test plan: AGENTS.md asks the After section to carry the pasted output of the commands that were run rather than a description.

Comment on lines +784 to +789
if (character === '[' || character === BARRIER || character === UNKNOWN) {
state.openerDistance = 0;
state.openerChar = character;
state.openerNext = '';
state.openerAtLineStart = state.phase === 'start';
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Unknown text (a decorator, or a node a transformer writes in its own syntax) replaces the opener here and takes openerAtLineStart from the current phase, which is already text after a [. So [ + inline decorator (text foo) + ]: done exports [foo]: done, a link reference definition: the paragraph renders as nothing and a later [foo] renders as a link. With decorator text x, [x] todo reads back as a check list item with CHECK_LIST. Keeping the line start through unknown text fixes it:

Suggested change
if (character === '[' || character === BARRIER || character === UNKNOWN) {
state.openerDistance = 0;
state.openerChar = character;
state.openerNext = '';
state.openerAtLineStart = state.phase === 'start';
}
if (character === '[' || character === BARRIER || character === UNKNOWN) {
// Unknown text inside a line-start label keeps the label's line start.
const keepsLineStart =
character === UNKNOWN &&
state.openerDistance !== -1 &&
state.openerChar !== BARRIER &&
state.openerAtLineStart;
state.openerDistance = 0;
state.openerChar = character;
state.openerNext = '';
state.openerAtLineStart = state.phase === 'start' || keepsLineStart;
}

return UNKNOWN;
} else if ($isTextNode(leaf)) {
const text = $exportedText(leaf, selection);
return leaf.hasFormat('code') ? text.replace(/[[\] \t]/g, '`') : text;

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: masking a code span's ] hides a label end that cannot be escaped. [a + inline code ]:b exports [a`]:b` ; a link label ends at the first ] that is not backslash-escaped and definitions are read before code spans, so this is a definition and the paragraph renders as nothing. Only escaping the opening [ (a look-ahead to a following code span holding ]) avoids it.

Comment on lines +904 to +918
const isWritten = context
? context.textMatchTransformers.some(transformer => {
try {
return (
transformer.export !== undefined &&
transformer.export(
node,
() => '',
() => '',
) != null
);
} catch {
return true;
}
})

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: this runs every text match transformer's export with stub callbacks on whatever node is asked about, including the parent block (from $isInsideLink and $nextExportedChar) and decorators, where the answer is ignored below. Checking $isTextNode / inline element first would keep block nodes and extra calls away from custom transformers whose export records state.

…ator, escape a [ that inline code or a later link would close, and read typed control characters as text

Addresses the third review on #9600.
@zurfyx

zurfyx commented Oct 30, 2026

Copy link
Copy Markdown
Member Author

Addressed in d1fd7cf:

  • Decorator inside a line-start label (requested). Applied your suggestion: unknown text inside a label that starts a line keeps the label's line start. [ + decorator + ]: done, [ + decorator (text x) + ] todo and [ + decorator + ](b) now all escape. Because the decorator could itself hold ]:, the [ before it is escaped too: &#91;foo&#93;: done. A bracket away from a decorator is untouched: foo cleared review [1][2].
  • Inline code holding ]: after a line-start [. A line-start [ whose label would close at a ] inside inline code followed by : is now escaped, since that ] cannot be: [a + code ]:b exports &#91;a`]:b` . The look-ahead stops at the first bracket, a link or 1,000 characters, and answers yes for unknown text.
  • Transformer calls. $exportKind now asks the transformers only about text nodes and inline elements. Blocks and line breaks are plain text, and decorators are unknown, without calling anything.
  • Typed U+0001/U+0002 are read as ordinary characters, so [a + U+0001 + ]: /url escapes.

My randomized check also found one more round trip issue: a [ still open where a link starts on the same line was read into that link by LINK on import, and a code span in that prefix came back as literal backticks. Such a [ is escaped again, as #9481 did, so [h[ello](…) and [foo [bar](/uri)](/uri) export as on main. Brackets closed before the link (see [1] and [link](u)) stay as typed.

Tests: 901 @lexical/markdown unit tests pass, along with ci-check. The micromark checks (246,925 paragraphs with inline elements, links and line breaks, plus 15,239 selections, and 95,000+ token-built paragraphs with block markers, autolinks, decorators and inline code, each with and without LINK) render the original text with one anchor per written link, and read back through $convertFromMarkdownString with CHECK_LIST with no check list, the same links and the same text. 8,000 styled xa]: nodes export in 80 ms on a loaded laptop.

@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, this looks good. Escaping a bracket only where it would become syntax fixes the [1] to &#91;1&#93; churn introduced in v0.53.0, the new MarkdownEscaping.test.ts cases fail on main and pass here, and starting LINK at the real opener is a worthwhile import fix on its own. Everything below is optional and non-blocking; the first two are small and would be nice to close before merge.

1. Inline code holding ]( after a raw [ (packages/lexical-markdown/src/MarkdownExport.ts:868-876). A paragraph of see [ + inline code ]( + x) now now exports see [`](`x) now. A CommonMark reader keeps the text, because code spans bind tighter than link brackets (https://spec.commonmark.org/0.31.2/#code-spans), but $convertFromMarkdownString reads [`](`x) as a link to `x, so the round trip becomes see ` now plus a link. The export only looks into inline code for a line-start ]:, and matchLink (importTextMatchTransformer.ts:32) cannot see the code span because only its opening backtick sits inside the lazy label. Either escape a raw [ whose next bracket on the line is a ] inside code followed by (, or have matchLink reject a match whose ]( falls inside a code span of the whole line.

2. An escaped ] does not end the inline-code label check (MarkdownExport.ts:894-896). [a](b) + inline code ]:x exports [a&#93;(b) `]:x` , which a CommonMark reader takes as a link reference definition (definitions are read before inline content, https://spec.commonmark.org/0.31.2/#link-reference-definitions), so the paragraph renders as nothing. The same happens with [] or [x] see + code ]:url, and with [a]: b + code ]:c. $labelClosesInCode stops at the first raw ] even when this pass escapes it; deciding the ] escapes first and skipping escaped positions would match how the ] check already treats an escaped ] as still inside the label. A test for each of these two cases would pin the fix.

3. Probe calls to TextMatchTransformer.export (MarkdownExport.ts:992-1006). Export now calls each text match transformer with placeholder callbacks to classify nodes, on top of the real call, so a transformer that keeps state in export changes its output (for example, one that numbers reference-style links counts each link twice). The JSDoc now asks for a side-effect-free export; since AGENTS.md's Backwards Compatibility section asks not to change existing public API behavior in ways that can break callers, please also name this in the changeset's Verify: paragraph so people upgrading see it.

4. Bracket syntax written by a custom transformer (MarkdownExport.ts:1012-1015). Only a LinkNode counts as [text](url). Text inside a non-link inline element that a custom transformer writes as [children](url) gets the outside-a-link rules, so child text a] exports [a]](url) and the link is lost (it was [a&#93;](url) before). In the other direction, a LinkNode that a custom transformer writes in another syntax, such as an HTML anchor, still counts as a barrier, so [ + that link + ]: /x becomes a definition. Classifying by the probe's returned string (a leading [) and applying the link-text rules inside such an element would cover both.

Nits

  • nextLinePhase assumes unknown text holds no line break (MarkdownExport.ts:751-754), so an inline decorator whose text ends in a newline, followed by [x] done, re-imports as a check list with CHECK_LIST.
  • The linear-time cases in MarkdownEscaping.test.ts assert a 2,000 ms wall-clock bound, which can flake on a busy CI runner.

Comment on lines +868 to +876
} else if (
character === '[' &&
state.phase === 'start' &&
$labelClosesInCode(text, index, $labelClosesAfter)
) {
// The `]` that would close this label is in inline code, where it
// cannot be escaped, so the `[` is.
escaped.add(index);
}

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: this only looks into inline code for a line-start ]:. A paragraph of see [ + inline code ]( + x) now exports see [`](`x) now, which a CommonMark reader keeps as text (code spans bind tighter than link brackets, https://spec.commonmark.org/0.31.2/#code-spans), but $convertFromMarkdownString reads [`](`x) as a link to `x, so the round trip becomes see ` now plus a link. Escaping a raw [ whose next bracket on the line is a ] inside code followed by ( (or rejecting such a match in matchLink) would keep the round trip.

Comment on lines +894 to +896
for (let next = index + 1; next < text.length; next++) {
if (text[next] === '[' || text[next] === ']') {
return false;

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: this stops at the first raw ] even when this pass escapes it. [a](b) + inline code ]:x exports [a&#93;(b) `]:x` , which a CommonMark reader takes as a link reference definition, so the paragraph renders as nothing; [] or [x] see + code ]:url behave the same. Deciding the ] escapes first and skipping escaped positions here would match how the ] check treats an escaped ] as still inside the label.

Comment on lines +1012 to +1015
: isWritten
? $isLinkNode(node)
? 'link'
: 'unknown'

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: only a LinkNode counts as written in [text](url). A non-link inline element that a custom transformer writes as [children](url) now leaves child text such as a] unescaped ([a]](url), the link is lost), and a LinkNode written in another syntax still counts as a barrier. The probe already returns the written string, so classifying by its shape (a leading [) would cover both.

…re links inside code spans on import, and classify transformer output by its syntax

Addresses the optional points of the fifth review on #9600.
@zurfyx

zurfyx commented Oct 31, 2026

Copy link
Copy Markdown
Member Author

Thanks for the approval. I took the optional points as well, in 18e9a95:

  1. Inline code holding ]( after a raw [. matchLink now skips a match whose opening [, closing ] or final ) falls inside a code span of the line, since code spans bind tighter than link brackets. see [`](`x) now imports with no link and round trips.
  2. An escaped ] no longer ends the inline-code label check. The ] escapes are decided first, and the look-ahead from a line-start [ skips them. In later plain text it only stops at a [, so [a](b) + code ]:x, [] and [x] see + code ]:url, and [a]: b + code ]:c all escape the [.
  3. Probe calls are now named in the changeset's Verify: paragraph as well as in the JSDoc.
  4. Custom transformers. The kind now comes from what the probe returns: an element written as [children](...) gets the link text rules whatever its class ([a&#93;](custom)), and a LinkNode written in another syntax, such as an HTML anchor, counts as unknown, which escapes.
  • Nits. Unknown text now counts as a possible line end, so [x] done after an inline decorator escapes. The linear-time tests allow 10 s (a quadratic export takes tens of seconds), which leaves room for a busy runner.

All eight new cases fail on 97a7d0e and pass here. 918 @lexical/markdown unit tests, ci-check and 1,873 markdown, mdast, playground and React unit tests pass, and so do all three randomized micromark checks.

@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. Escaping only the brackets that would otherwise become syntax brings [1], array[0] and done [x] back as typed. The new MarkdownEscaping.test.ts cases fail on main and pass here, and starting LINK import at the real opener fixes formatting before a link. The two suggestions below are optional and non-blocking.

1. Text inside an inline element that a custom transformer writes (optional). When $nextExportedChar leaves a parent that is neither transparent nor a link, it returns '' (packages/lexical-markdown/src/MarkdownExport.ts:1080-1081). $previousLeaf returns null at the start of such a parent (:1147). Either way, the text there is read as if the block ended or began. Take a text match transformer that writes an inline element as ${exportChildren(node)}(note): see [ followed by that element holding a] exports as see [a](note), and $convertFromMarkdownString reads that back as a link (on main it was see &#91;a&#93;(note)). With a : note suffix, an element holding [a] at the start of a paragraph exports as [a]: note, which CommonMark reads as a link reference definition (https://spec.commonmark.org/0.31.2/#link-reference-definitions) and renders as nothing. If crossing such a parent reported UNKNOWN, the PR's own rule for brackets next to a node written in its own syntax would apply from the inside too.

2. The code-span check in matchLink (optional). isInCode scans every code span for each candidate match (packages/lexical-markdown/src/importTextMatchTransformer.ts:31-48), and the matcher runs again on the rest of the line after each code span is applied. A single line with thousands of code spans that hold link syntax therefore imports much more slowly than on main: [a`](x)` repeated 4,000 times takes about 3 s on main and about 40 s here. scanCodeSpans returns spans in order, so a binary search or an advancing pointer would keep this at main's cost. A timing case next to the export ones would keep it there.

Thanks for documenting the extra export calls with placeholder callbacks in the TextMatchTransformer.export JSDoc and in the changeset's Verify: paragraph, as CONTRIBUTING.md asks for a fix that changes observable behavior. I'm treating that as an intentional, disclosed trade-off.

return ']';
}
if (!$isTransparentInline(parent)) {
return '';

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: leaving a parent that a text match transformer writes in its own syntax (neither transparent nor a link) returns '' here, as if the block ended, and $previousLeaf does the same at its start (line 1147). Take a transformer that writes an inline element as ${exportChildren(node)}(note): see [ followed by that element holding a] exports as see [a](note) and imports back as a link. Returning UNKNOWN when the walk crosses such a parent would escape that edge the way a decorator's is.

// falls inside one is not a link.
const codeSpans = text.includes('`') ? scanCodeSpans(text) : [];
const isInCode = (index: number) =>
codeSpans.some(

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: codeSpans.some is a linear scan for each check, and matchLink runs again on the rest of the line after each code span is applied. A line with thousands of code spans holding link syntax therefore imports much more slowly than on main ([a`](x)` repeated 4,000 times: about 3 s on main, about 40 s here). scanCodeSpans returns spans in order, so a binary search would make each check logarithmic.

…sformer writes as unknown text, and find code spans by binary search on import

Addresses the optional points of the sixth review on #9600.
… keeps the parent's type

Fixes the type check of dec73bd.
@zurfyx

zurfyx commented Oct 31, 2026

Copy link
Copy Markdown
Member Author

Took both optional points in dec73bd (plus db8da5b, which fixes a type narrowing error in dec73bd):

  1. Edges of an inline element a transformer writes in its own syntax. The walk now treats such an element as unknown text when it crosses it. $nextExportedChar returns UNKNOWN when it leaves the element, and $previousLeaf/$nextLeaf return the element itself, which reads as unknown. A transformer writing ${children}(note) now gives see [a&#93;(note), and with ${children}: note an element holding [a] at the start of a paragraph gives &#91;a&#93;: note.
  2. Code-span check in matchLink. It is now a binary search over the ordered spans. A new import case with [a`](x)` repeated 2,000 times takes 2.2 s locally, against 1.7 s on main with that laptop under load, and asserts that no link is created.

918 → 921 @lexical/markdown unit tests pass, along with ci-check, 1,876 markdown, mdast, playground and React unit tests, and the randomized micromark checks.

@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. Escaping only the ] that would actually close a link, a line-start definition label or a task marker fixes the v0.53.0 rewrite of [1], [x] and array[0], and starting LINK import at the real opener fixes formatting before a link. The new tests pin both directions. The points below are optional and non-blocking.

  1. Lines that start with Unicode whitespace (packages/lexical-markdown/src/MarkdownExport.ts:755). nextLinePhase treats only a space or a tab as indentation, but CHECK_LIST_REGEX (MarkdownTransformers.ts:228) matches any \s. A paragraph \u00A0[x] done or \u3000[ ] todo (a full-width indent, common in CJK text), or a bullet item whose text is \u00A0[x] done, now exports with the marker raw and re-imports with CHECK_LIST as a check list item whose text is just done or todo; on main all three round-trip. Changing that line to const isSpace = /\s/.test(character); escapes them (\u00A0[x&#93; done) and keeps the existing tests passing. Adding the three cases to the task-marker tests would pin it.
  2. Code spans full of [ on import (packages/lexical-markdown/src/importTextMatchTransformer.ts:60). When a match's [ is inside a code span, the loop retries from match.index + 1, and each retry re-scans to the same ](. A single line holding a code span of 80,000 [ followed by ](u) takes about 3 s to import, against about 1 ms on main. Moving lastIndex to the end of the code span that holds match.index (the binary search already finds it) makes this linear.
  3. Extra calls into custom transformers. Export now calls each TextMatchTransformer.export with placeholder callbacks to classify a bracket's neighbours. The JSDoc and the changeset's Verify: paragraph already say so, as AGENTS.md's backwards-compatibility section asks; worth keeping visible in the release notes, since a transformer that numbers links or footnotes inside export will see extra calls.

// Unknown text may end with a line break, so what follows may start a line.
return 'start';
}
const isSpace = character === ' ' || character === '\t';

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: CHECK_LIST_REGEX (MarkdownTransformers.ts:228) accepts any \s as indentation, so a paragraph \u00A0[x] done or \u3000[ ] todo, or a bullet item \u00A0[x] done, now exports with the marker raw and re-imports with CHECK_LIST as a check list item (done/todo); main round-trips them. const isSpace = /\s/.test(character); escapes them and keeps the existing tests green.

isInCode(labelEnd) ||
isInCode(match.index + match[0].length - 1)
) {
search.lastIndex = match.index + 1;

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 match.index is inside a code span, each retry from match.index + 1 re-scans to the same ](, so a code span of 80,000 [ before ](u) takes about 3 s to import (about 1 ms on main). Jumping lastIndex to that span's endIndex makes it linear.

… and skip a whole code span when a link candidate opens inside it

Addresses the optional points of the seventh review on #9600.
@zurfyx

zurfyx commented Oct 31, 2026 •

Copy link
Copy Markdown
Member Author

Both taken in 8189fcc:

  1. Unicode whitespace indentation. nextLinePhase now treats any \s as indentation, matching CHECK_LIST_REGEX and the list transformers, so \u00A0[x] done and \u3000[ ] todo export as \u00A0[x&#93; done / \u3000[ &#93; todo and read back with CHECK_LIST as text. Both cases are now in the tests and fail on db8da5b.
  2. A code span full of [. When a candidate opens inside a code span, matchLink now resumes after that span (the binary search returns its end). The new case, a code span of 80,000 [ before ](u), imports in 1 ms, against 1.4 s on db8da5b.

924 @lexical/markdown unit tests, ci-check, 1,879 markdown, mdast, playground and React unit tests, and the randomized micromark checks pass.

…ts in the test

Avoids a CodeQL incomplete-replacement finding on the test helper.

@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. Narrowing the bracket escapes to the places where CommonMark or GFM would read a bracket as syntax fixes the [1] / [x] / array[0] churn on every save, and starting an imported link at the [ that really opens it fixes the formatting lost before such links. The new tests cover both directions. The suggestion below is optional and non-blocking.

Optional: treat a lone CR as a line ending (packages/lexical-markdown/src/MarkdownExport.ts:789)

advanceLineState returns to line start only on \n, and nextLinePhase reads \r as ordinary whitespace, but CommonMark counts a bare carriage return as a line ending (https://spec.commonmark.org/0.31.2/#line-ending). A lone \r can sit inside a TextNode (plain-text paste splits only on \n and \r\n), and for such text the export now leaves brackets raw where other CommonMark readers see syntax:

  • foo\r\r[a]: /url is read as a link reference definition, so the line disappears;
  • see [a]\r> [a]: https://example.com turns [a] into a link;
  • notes\r- [x] done becomes a GFM task list item.

Lexical's own import is unaffected, since it splits lines on \n. Treating \r like \n in advanceLineState, with a test for each case above, would close the gap.

Thanks also for documenting the new placeholder export calls in the TextMatchTransformer.export JSDoc and in the changeset's Verify: paragraph; that is what authors of custom transformers need to know.

state.openerDistance = -1;
}
}
if (character === '\n') {

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: CommonMark treats a bare \r as a line ending (https://spec.commonmark.org/0.31.2/#line-ending), and a lone \r can sit inside a TextNode (plain-text paste splits only on \n and \r\n). Only \n resets the line here, so notes\r- [x] done exports unescaped and renders as a task list item, and foo\r\r[a]: /url as a link reference definition. Treating \r like \n (if (character === '\n' || character === '\r')) would close it.

Merged via the queue into main with commit 1a71b56 Oct 31, 2026
57 checks passed
zurfyx added a commit that referenced this pull request Oct 31, 2026
## Description

Merges main at c0c7e42 (#9640, #9600, #9652, #9659, #9648 and #9657). The one conflict is in the link list of `packages/lexical-y/DECISIONS.md`, where both sides added a reference (#9597 here, #9606 on main); both are kept.

## Test plan

### Before

The branch was on main f59f74d.

### After

`pnpm run ci-check` and `pnpm exec vitest run --project unit --project scripts-unit` pass on the merged tree (outputs in the pull request).

This branch was successfully deployed

2 active deployments
Preview – lexical — d721896f Deployed Oct 31, 2026 by vercel[bot]
Preview – lexical-playground — d721896f 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.

3 participants