Skip to content

[lexical-markdown] Feature: Export option to keep ambiguous < and & as the author typed them - #9722

Closed
potatowagon wants to merge 2 commits into
mainfrom
markdown-escape-ambiguous-html-option
Closed

potatowagon wants to merge 2 commits into
mainfrom
markdown-escape-ambiguous-html-option

Conversation

@potatowagon

Copy link
Copy Markdown
Contributor

Description

Since v0.53.0 (#9481) the exporter writes a literal < or & that could read
as raw HTML or as a character reference as a numeric character reference, so
that re-importing the Markdown gives back the text the editor held. That is
the right default, but it assumes the Markdown is read back by Lexical. An app
whose renderer is meant to read the inline HTML and the character references
an author writes gets &#60;u>under&#60;/u> and &#38;#0036;5 on the page
instead; several surfaces inside Meta render exactly that way and have no
knob to ask for the pre-v0.53.0 output.

$convertToMarkdownString and $convertSelectionToMarkdownString take a
MarkdownExportOptions object as their last argument:

$convertToMarkdownString(TRANSFORMERS, undefined, false, {
  escapeAmbiguousHtml: false,
});

The default is unchanged. Brackets that would start a link, an image, a link
reference definition or a check list item are still escaped, since nothing
else keeps that text readable, and the format characters keep their backslash
escapes. The option rides on the context the export already carries, so no
signature below the entry points changes.

Changeset

.changeset/markdown-escape-ambiguous-html-option.md, [feature] on
@lexical/markdown.

Test plan

New cases in MarkdownEscaping.test.ts cover the option across single and
split text nodes, the selection export, what it still escapes, and the
default.

Before

$ pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/MarkdownEscaping.test.ts
 Test Files  1 passed (1)
      Tests  169 passed (169)

(the option did not exist)

After

$ pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/MarkdownEscaping.test.ts
 Test Files  1 passed (1)
      Tests  199 passed (199)

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

$ pnpm run tsc
(exit 0)

$ pnpm run flow
Found 0 errors

$ pnpm exec prettier --check <changed files>
All matched files use Prettier code style!

$ pnpm exec eslint <changed files>
(no findings)

pnpm run ci-check as a whole was not run here: this environment installs
with --ignore-scripts, so @lexical/devtools has no wxt-generated types and
its tsc --noEmit fails on browser and defineBackground regardless of
this change.

…&` as the author typed them

## Description

Since v0.53.0 (#9481) the exporter writes a literal `<` or `&` that could read
as raw HTML or as a character reference as a numeric character reference, so
that re-importing the Markdown gives back the text the editor held. That is
the right default, but it assumes the Markdown is read back by Lexical. An app
whose renderer is meant to read the inline HTML and the character references
an author writes gets `&#60;u>under&#60;/u>` and `&#38;#36;5` on the page
instead; several surfaces inside Meta render exactly that way and have no
knob to ask for the pre-v0.53.0 output.

`$convertToMarkdownString` and `$convertSelectionToMarkdownString` take a
`MarkdownExportOptions` object as their last argument:

```ts
$convertToMarkdownString(TRANSFORMERS, undefined, false, {
  escapeAmbiguousHtml: false,
});
```

The default is unchanged. Brackets that would start a link, an image, a link
reference definition or a check list item are still escaped, since nothing
else keeps that text readable, and the format characters keep their backslash
escapes. The option rides on the context the export already carries, so no
signature below the entry points changes.

## Changeset

`.changeset/markdown-escape-ambiguous-html-option.md`, `[feature]` on
`@lexical/markdown`.

## Test plan

New cases in `MarkdownEscaping.test.ts` cover the option across single and
split text nodes, the selection export, what it still escapes, and the
default.

### Before

```
$ pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/MarkdownEscaping.test.ts
 Test Files  1 passed (1)
      Tests  169 passed (169)
```

(the option did not exist)

### After

```
$ pnpm exec vitest run --project unit packages/lexical-markdown/src/__tests__/unit/MarkdownEscaping.test.ts
 Test Files  1 passed (1)
      Tests  199 passed (199)

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

$ pnpm run tsc
(exit 0)

$ pnpm run flow
Found 0 errors

$ pnpm exec prettier --check <changed files>
All matched files use Prettier code style!

$ pnpm exec eslint <changed files>
(no findings)
```

`pnpm run ci-check` as a whole was not run here: this environment installs
with `--ignore-scripts`, so `@lexical/devtools` has no wxt-generated types and
its `tsc --noEmit` fails on `browser` and `defineBackground` regardless of
this change.
@vercel

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

Request Review

@meta-code-review

meta-code-review Bot commented Nov 2, 2026 •

Copy link
Copy Markdown

AI review status for this pull request.

Review status Updated
🟢 Reviewed (for maintainers only) Nov 2, 2026, 9:32 AM UTC
@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 Nov 2, 2026
@pkg-pr-new

pkg-pr-new Bot commented Nov 2, 2026 •

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: daf2cda

@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. The option is additive, the default output is unchanged, and the new cases in MarkdownEscaping.test.ts fail without the change and pass with it. The suggestions below are optional and non-blocking.

  1. Nested exports lose the option (packages/lexical-markdown/src/MarkdownExport.ts:998-999). withBracketContext builds a new context from its own options. A transformer that exports nested content through $convertToMarkdownString has no way to pass them, because ElementTransformer.export does not receive options. The playground's TABLE transformer does exactly this for each cell (packages/lexical-playground/src/plugins/MarkdownTransformers/index.ts:226). So with {escapeAmbiguousHtml: false}, paragraphs come out as typed while table cells are still escaped (| &#60;u>x&#60;/u> |). Inheriting from the enclosing export when options is undefined would cover it:
    escapeAmbiguousHtml:
      options === undefined
        ? previous === null || previous.escapeAmbiguousHtml
        : options.escapeAmbiguousHtml !== false,
    A test with a transformer that makes a nested call would pin it.
  2. Docs caveats (packages/lexical-markdown/README.md:102-107 and the JSDoc at MarkdownExport.ts:43-53). CONTRIBUTING.md asks for documentation with API changes, and three caveats would help readers:
    • the renderer is responsible for sanitizing, since anything typed or pasted as plain text now reaches it as raw HTML;
    • the output is not meant to be read back with $convertFromMarkdownString, which decodes numeric references (&#0036;5 re-imports as $5);
    • the option applies only when shouldPreserveNewLines is false, because that other mode never escapes <, & or brackets.
Comment on lines +998 to +999
escapeAmbiguousHtml:
options === undefined || options.escapeAmbiguousHtml !== 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: a transformer that exports nested content through $convertToMarkdownString (the playground's TABLE does this per cell) lands here with options === undefined, so table cells keep escaping while the rest of the document is written as typed. Inheriting from the enclosing export would cover it:

    escapeAmbiguousHtml:
      options === undefined
        ? previous === null || previous.escapeAmbiguousHtml
        : options.escapeAmbiguousHtml !== false,

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in daf2cda — a nested export now inherits the option from the export it runs inside, with your spelling of the default. A new case in MarkdownEscaping.test.ts exports a cell-like container through a second $convertToMarkdownString and asserts both directions: | <u>under</u> | with the option, | &#60;u>under&#60;/u> | without it.

Comment thread packages/lexical-markdown/README.md Outdated
Comment on lines +102 to +107
`$convertToMarkdownString` and `$convertSelectionToMarkdownString` take a
`MarkdownExportOptions` object as their last argument. Passing
`{escapeAmbiguousHtml: false}` writes `<` and `&` as the author typed them, for
an app whose renderer is meant to read the inline HTML and the character
references written in the editor; brackets and format characters are escaped
either way.

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: it would help to add that (1) the renderer must sanitize, since typed or pasted text now reaches it as raw HTML; (2) this output is not meant for $convertFromMarkdownString, which decodes numeric references (&#0036;5 re-imports as $5); and (3) the option applies only when shouldPreserveNewLines is false.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Done in daf2cda — the README paragraph now names all three: the renderer has to sanitize, since text typed or pasted into the editor reaches it as raw HTML; the output is not meant for $convertFromMarkdownString, which decodes numeric references, so &#0036;5 re-imports as $5; and the option only applies to the default serializer, since shouldPreserveNewLines escapes nothing beyond the format characters. It also records that a nested export inherits the option.

…ort, and document what the option costs

## Description

Review follow-ups on the `escapeAmbiguousHtml` option.

A transformer that exports nested content calls `$convertToMarkdownString`
again without options — the playground's table does this per cell — so that
export opened a context of its own and escaped, while the document around it
was written as typed. A nested export now inherits the option from the export
it runs inside; a top-level export with no options still escapes.

The README says what comes with the option: the renderer has to sanitize,
since text typed or pasted into the editor reaches it as raw HTML; the output
is not meant for `$convertFromMarkdownString`, which decodes numeric
references, so `&#36;5` re-imports as `$5`; and it applies only to the
default serializer, since `shouldPreserveNewLines` escapes nothing beyond the
format characters.

## Test plan

A new case exports a cell-like container through a second
`$convertToMarkdownString`, and asserts the nested content both ways:

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

$ pnpm run tsc
(exit 0)

$ pnpm run flow
Found 0 errors

$ pnpm exec prettier --check <changed files>
All matched files use Prettier code style!

$ pnpm exec eslint <changed files>
(no findings)
```

@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. The option is additive, every existing call keeps its output, and both earlier suggestions (nested exports inheriting the option, and the README caveats) are in, with a test that checks both directions. The suggestions below are optional and non-blocking.

  1. {} and null options (packages/lexical-markdown/src/MarkdownExport.ts:1001-1004). The option is inherited only when options is undefined. Inside an export run with {escapeAmbiguousHtml: false}, a nested $convertToMarkdownString(TRANSFORMERS, node, false, {}) goes back to escaping (| &#60;u>in&#60;/u> |). So a transformer that always forwards an options object loses the inheritance the README describes, and a JavaScript caller passing null gets a TypeError. Deciding per field covers both:
    escapeAmbiguousHtml:
      options == null || options.escapeAmbiguousHtml === undefined
        ? previous === null || previous.escapeAmbiguousHtml
        : options.escapeAmbiguousHtml !== false,
    This is worth settling before the option ships, since changing what {} means later would change output.
  2. Sanitizing caveat in the JSDoc (MarkdownExport.ts:43-53). The README now says the renderer has to sanitize. The escapeAmbiguousHtml JSDoc, which is what editors show at the call site, doesn't say it, and one sentence there would carry it.
  3. Test plan output. AGENTS.md ("Commits and Pull Requests") asks the Test plan to carry the real output of the command that was run. The pasted counts (169 before and 199 after for MarkdownEscaping.test.ts, 944 for the package) predate the latest commit. On the current base the file has 179 tests; at this head it has 200, and the package has 945.
Comment on lines +1001 to +1004
escapeAmbiguousHtml:
options === undefined
? previous === null || previous.escapeAmbiguousHtml
: options.escapeAmbiguousHtml !== 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: inheritance only happens when options is undefined, so a nested export passed {} (or {escapeAmbiguousHtml: undefined}) goes back to escaping inside an export run with the option off, and null throws a TypeError. Deciding per field covers both:

    escapeAmbiguousHtml:
      options == null || options.escapeAmbiguousHtml === undefined
        ? previous === null || previous.escapeAmbiguousHtml
        : options.escapeAmbiguousHtml !== false,
Comment on lines +48 to +52
* Set it to `false` when the export feeds a renderer that is meant to read
* the inline HTML and the character references an author writes, which is
* how the editor behaved before v0.53.0. The brackets that would start a
* link, an image, a link reference definition or a check list item are
* escaped either way, since nothing else keeps that text readable.

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 README says the renderer has to sanitize when this is false; one sentence here would put that caveat where callers see it in their editor.

@potatowagon

Copy link
Copy Markdown
Contributor Author

Closing this: the breakage it was written for is being solved the other way round, and that leaves the option without a consumer.

#9600 narrowed the bracket escaping to the places a bracket would actually start Markdown syntax, which covers the citation text ([1][2], array[0]) these surfaces write. For the remaining < and &, the two Meta surfaces that wanted the characters raw now decode the numeric references in their own renderers rather than asking the exporter to stop writing them, so the encoding stays in place for every other consumer of the same output. That is the better shape: an export flag that turns output encoding off is a foot-gun for an app whose renderer reads raw HTML, and a reviewer on the internal side raised exactly that.

What is on the branch markdown-escape-ambiguous-html-option if the need comes back: MarkdownExportOptions.escapeAmbiguousHtml threaded through the export context, inherited by a nested export a transformer starts (the table case), with tests for single and split text nodes, selections, what stays escaped, and the default.

The sibling fix for the same sync, #9567, is unrelated to this and still open.

@potatowagon potatowagon closed this Nov 3, 2026

This branch was successfully deployed

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

1 participant