Skip to content

[lexical-markdown] Bug Fix: Escape block markers at the start of a paragraph line on export - #9419

Open
fixfon wants to merge 2 commits into
meta:mainfrom
fixfon:fix/9401-markdown-export
Open

fixfon wants to merge 2 commits into
meta:mainfrom
fixfon:fix/9401-markdown-export

Conversation

@fixfon

@fixfon fixfon commented Oct 21, 2026

Copy link
Copy Markdown
Contributor

Description

Markdown export wrote the text of a root paragraph as it was, so a paragraph whose text starts like a block (# Hours, > a, - a, + a, 1. a) was read back by the next import as a heading, a quote or a list. The text escape in exportTextFormat only covers the inline characters *_~`, and nothing in the export knew about the start of a line.

A paragraph that no element transformer handles now has the first character of each line escaped when the line starts with a marker that the import recognizes: #s or > followed by whitespace, and an indented -, + or N. followed by whitespace, the way $listExport already escapes 1.. The escape runs on the exported text of the paragraph, not on each TextNode, so a marker split across formatted nodes (# followed by a bold h) is covered as well. Content of a quote, a heading or a list item does not go through it.

Text that is not a marker keeps its export: #hashtag, -1, a - b, 1) x, ---, > - a, and - a followed by a blank line and > q.

I wrote this with help from Sonnet 5.5 (xhigh) via T3 Code, and reviewed the change and the tests myself.

Closes #9401

Test plan

Before

$ pnpm exec vitest --project unit --no-watch packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts

 ❯ |unit| packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts (615 tests | 5 failed)
   ❯ a paragraph line that starts like a block (5)
     × reads "\\# Hours" back as a paragraph
     × reads "\\> a" back as a paragraph
     × reads "\\- a" back as a paragraph
     × reads "\\+ a" back as a paragraph
     × reads "1\\. a" back as a paragraph

AssertionError: expected [ [ 'heading', 'Hours' ] ] to deeply equal [ [ 'paragraph', '# Hours' ] ]
AssertionError: expected [ [ 'quote', 'a' ] ] to deeply equal [ [ 'paragraph', '> a' ] ]
AssertionError: expected [ [ 'list', 'a' ] ] to deeply equal [ [ 'paragraph', '- a' ] ]
AssertionError: expected [ [ 'list', 'a' ] ] to deeply equal [ [ 'paragraph', '+ a' ] ]
AssertionError: expected [ [ 'list', 'a' ] ] to deeply equal [ [ 'paragraph', '1. a' ] ]

 Test Files  1 failed (1)
      Tests  5 failed | 610 passed (615)

After

$ pnpm exec vitest --project unit --no-watch packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts

 Test Files  1 passed (1)
      Tests  615 passed (615)

$ pnpm exec vitest --project unit --no-watch packages/lexical-markdown packages/lexical-mdast

 Test Files  15 passed (15)
      Tests  917 passed (917)

prettier --check and eslint are clean on the two changed files, and tsc is clean on a project holding lexical-markdown and lexical-mdast with their tests.

Not exercised: Node 22.23.2 only (.nvmrc asks for 24.21.0), jsdom unit tests only (no browser or e2e run), no Flow check, and tsc on that slice rather than the whole monorepo.

@vercel

vercel Bot commented Oct 21, 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 2, 2026 7:58am UTC
lexical-playground Ready Ready Preview Nov 2, 2026 7:58am UTC

Request Review

…ragraph line on export

## Description

Markdown export wrote the text of a root paragraph as it was, so a paragraph
whose text starts like a block (`# Hours`, `> a`, `- a`, `+ a`, `1. a`) was
read back by the next import as a heading, a quote or a list. The text escape
in `exportTextFormat` only covers the inline characters `*_`~\`, and nothing
in the export knew about the start of a line.

A paragraph that no element transformer handles now has the first character
of each line escaped when the line starts with a marker that the import
recognizes: `#`s or `>` followed by whitespace, and an indented `-`, `+` or
`N.` followed by whitespace, the way `$listExport` already escapes `1.`. The
escape runs on the exported text of the paragraph, not on each TextNode, so a
marker split across formatted nodes (`#` followed by a bold ` h`) is covered
as well. Content of a quote, a heading or a list item does not go through it.

Text that is not a marker keeps its export: `#hashtag`, `-1`, `a - b`,
`1) x`, `---`, `> - a`, and `- a` followed by a blank line and `  > q`.

Closes meta#9401

## Test plan

### Before

```
$ pnpm exec vitest --project unit --no-watch packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts

 ❯ |unit| packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts (615 tests | 5 failed)
   ❯ a paragraph line that starts like a block (5)
     × reads "\\# Hours" back as a paragraph
     × reads "\\> a" back as a paragraph
     × reads "\\- a" back as a paragraph
     × reads "\\+ a" back as a paragraph
     × reads "1\\. a" back as a paragraph

AssertionError: expected [ [ 'heading', 'Hours' ] ] to deeply equal [ [ 'paragraph', '# Hours' ] ]
AssertionError: expected [ [ 'quote', 'a' ] ] to deeply equal [ [ 'paragraph', '> a' ] ]
AssertionError: expected [ [ 'list', 'a' ] ] to deeply equal [ [ 'paragraph', '- a' ] ]
AssertionError: expected [ [ 'list', 'a' ] ] to deeply equal [ [ 'paragraph', '+ a' ] ]
AssertionError: expected [ [ 'list', 'a' ] ] to deeply equal [ [ 'paragraph', '1. a' ] ]

 Test Files  1 failed (1)
      Tests  5 failed | 610 passed (615)
```

### After

```
$ pnpm exec vitest --project unit --no-watch packages/lexical-markdown/src/__tests__/unit/LexicalMarkdown.test.ts

 Test Files  1 passed (1)
      Tests  615 passed (615)

$ pnpm exec vitest --project unit --no-watch packages/lexical-markdown packages/lexical-mdast

 Test Files  15 passed (15)
      Tests  917 passed (917)
```

`prettier --check` and `eslint` are clean on the two changed files, and `tsc`
is clean on a project holding `lexical-markdown` and `lexical-mdast` with their
tests.

Not exercised: Node 22.23.2 only (`.nvmrc` asks for 24.21.0), jsdom unit tests
only (no browser or e2e run), no Flow check, and `tsc` on that slice rather
than the whole monorepo.
@fixfon
fixfon force-pushed the fix/9401-markdown-export branch from 67ad756 to c9c762d Compare November 2, 2026 07:57
@fixfon

fixfon commented Nov 2, 2026

Copy link
Copy Markdown
Contributor Author

Rebased onto main and resolved the import conflict in MarkdownExport.ts. With this fix, five of the "container marker precedes it" cases from #9600 failed, so I updated their expectations in a separate commit. Their old expected output (for example - [x] done) imports back as a list or quote rather than a paragraph, so those cases were locking in the #9401 behavior.

@pkg-pr-new

pkg-pr-new Bot commented Nov 2, 2026

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: c9c762d

@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 fix, this looks good. Paragraphs whose lines start with # , > , - , + or N. now export with the marker escaped and import back as paragraphs, and the new cases fail without the change and pass with it. Everything below is optional and non-blocking.

Optional suggestions

  1. Escape per \n line instead of with the m flag (packages/lexical-markdown/src/MarkdownExport.ts:414-419). With m, ^ also matches after \r, U+2028 and U+2029, while the import splits lines on \n only (MarkdownImport.ts:56). Because the escape runs over the finished Markdown, a paragraph holding inline code x\r- y now exports as `x\r\- y` and imports back with the backslash inside the code text (CommonMark: backslash escapes do not work in code spans). Escaping each line of markdown.split('\n') avoids that, and lets the indent be \s* like UNORDERED_LIST_REGEX and ORDERED_LIST_REGEX (MarkdownTransformers.ts:226-227), so \u00A0- a or \u30001. a, which still import as list items, are covered too; MarkdownEscaping.test.ts already covers that Unicode indentation for task markers. For example:

    function escapeBlockMarkers(markdown: string): string {
      return markdown
        .split('\n')
        .map(line =>
          line
            .replace(/^(#{1,6}|>)(?=\s)/, '\\$1')
            .replace(/^(\s*)([-+])(?=\s)/, '$1\\$2')
            .replace(/^(\s*\d+)\.(?=\s)/, '$1\\.'),
        )
        .join('\n');
    }
  2. Apply the same escape in $convertSelectionToMarkdownString. The selection exporter's paragraph path (MarkdownExport.ts:212-223) still returns # Hours for a selected # Hours paragraph, which imports as a heading, while $convertToMarkdownString now returns \# Hours.

  3. A few more test rows for what the description relies on: a marker after a line break inside a paragraph, an indented marker ( - a), the # plus bold h split case, and text that must stay as typed (#hashtag, -1, a - b, 1) x, ---, a quote holding - a).

  4. Changeset. CONTRIBUTING.md ("Changesets") asks for one when a published package's output changes, with a Verify: paragraph for a fix like this one, for example .changeset/markdown-escape-block-markers.md:

    ---
    '@lexical/markdown': patch
    ---
    
    [fix] Markdown export escapes `#`, `>`, `-`, `+` and `1.` at the start of a paragraph line, so text such as `# Hours` or `- a` imports again as a paragraph instead of a heading, quote or list
    
    Verify: Markdown export snapshots of paragraphs whose lines start with `# `, `> `, `- `, `+ ` or a number and `. `, which now carry a backslash before the marker (`\# Hours`, `\- a`, `1\. a`), and that whatever renders or stores the exported Markdown reads those escapes as the literal character.

    A maintainer can also add it for you.

Comment on lines +416 to +418
.replace(/^(#{1,6}|>)(?=\s)/gm, '\\$1')
.replace(/^([ \t]*)([-+])(?=\s)/gm, '$1\\$2')
.replace(/^([ \t]*\d+)\.(?=\s)/gm, '$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: with the m flag, ^ also matches after \r, U+2028 and U+2029, but the import splits lines on \n only, so a paragraph holding inline code x\r- y now exports as `x\r\- y` and keeps the backslash on import (backslash escapes do not work in code spans). Escaping each line of markdown.split('\n') without m avoids that, and lets the indent be \s* like UNORDERED_LIST_REGEX/ORDERED_LIST_REGEX, which also covers \u00A0- a and \u30001. a.

undefined,
shouldPreserveNewLines,
);
return $isParagraphNode(node) ? escapeBlockMarkers(markdown) : markdown;

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: the selection exporter's paragraph path ($processNodeForSelection, lines 212-223) doesn't get this escape, so $convertSelectionToMarkdownString still returns # Hours for a selected # Hours paragraph, which imports as a heading.

This branch was successfully deployed

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

Labels

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

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Bug: Markdown export doesn't escape #, >, -, + or 1. at the start of a paragraph line, so text becomes a heading, quote or list

1 participant