Skip to content

[lexical-mdast] Feature: Add a horizontal rule shortcut for ---, *** and ___ - #9484

Open
conghuydever wants to merge 6 commits into
meta:mainfrom
conghuydever:feat/mdast-horizontal-rule-shortcut
Open

conghuydever wants to merge 6 commits into
meta:mainfrom
conghuydever:feat/mdast-horizontal-rule-shortcut

Conversation

@conghuydever

@conghuydever conghuydever commented Oct 28, 2026 •

Copy link
Copy Markdown

Description

MdastShortcutsExtension has typing shortcuts for headings, quotes, lists and code blocks, but none for a thematic break. A line of ---, *** or ___ stays text, and the export escapes it:

Typed Exported on main Exported with this PR
--- Space after \--- after ---\n\nafter
*** Space after \*\*\* after ***\n\nafter
> Space --- Space after > \--- after > ---\n>\n> after

With this PR, ---, *** or ___ alone on a line, followed by Space or Enter, turns the line into a HorizontalRuleNode, and the caret goes to a new empty paragraph under it.

  • MarkdownStreamScanner.scanBlock now returns a thematicBreak match, with the whole line as the marker, as for a code fence. Like the other block shortcuts, it only does so when the registry imports thematicBreak, so when MdastHorizontalRuleExtension (part of MdastCommonMarkExtension) is in the editor.
  • Space and Enter go through the existing block paths. The new $applyThematicBreak puts the rule in place of the line, and adds the empty paragraph for the new line the key asked for.
  • The rule keeps the typed marker in hrMarkerState, the same state import sets, so a typed ___ is exported as ___.
  • At the start of a quote made with > , the rule goes into the quote, through the same $placeBlock a heading or list uses.
  • A rule has no length limit (CommonMark 4.1), so a space typed after a line that is only a thematic break is scanned even past MAX_BLOCK_MARKER_LENGTH (24). A rule of 24 or more characters converts on Space as it does on Enter.

The line stays text:

  • When text follows the marker. --- text is a paragraph in Markdown. The space path only scans the text up to the caret, so a space typed between --- and text would match without its own check.
  • Anywhere but the top level or a quote, where Markdown can write a rule. In a table cell, for example, GFM has no rule, and the table export writes one after the table, so a --- typed in a cell, a common placeholder, would move out of the table on export. The check looks at the ancestors instead of for a table cell, so @lexical/table stays out of editors that don't use tables.
  • When the editor has no HorizontalRuleNode, for example because it imports thematicBreak as its own node without HorizontalRuleExtension. Without this check, typing --- there would report Node HorizontalRuleNode has not been registered to onError.
  • With Shift+Enter, in a code block or in inline code, through the checks the other shortcuts already have.

* * * and - - - don't convert when typed, because * and - make a list first.

The size probe (pnpm run build-prod, then pnpm run size in dev-examples/mdast-editor) gives 484,315 bytes minified for the mdast bundle with this PR, against 483,772 on main as measured in the review.

AI disclosure: the code, tests and this description were prepared with AI assistance.

Changeset

.changeset/mdast-thematic-break-shortcut.md: @lexical/mdast patch, [feature], with a Verify: paragraph.

Test plan

New cases in packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts:

  • ---, *** and ___, each with Space and with Enter: the root holds the rule and then the paragraph with the caret, and the export is the marker, an empty line and after.
  • --- at the start of a quote: the rule and the next line are in the quote, and the export is > ---\n>\n> after.
  • A rule of 30 characters converts on Space and on Enter.
  • Undo after Space and after Enter gives back the typed marker (with HistoryExtension, disabled: false).
  • Stays text: -- and Enter, a space typed between --- and text, --- with Space or Enter in a table cell, in a code block and in inline code, --- with Shift+Enter, and --- with Space or Enter in an editor without HorizontalRuleNode (no error reported to onError).

Before

The tests with MdastShortcuts.ts and MdastStream.ts from main (e28f4dd):

$ pnpm exec vitest run packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts --reporter=verbose

× block shortcuts inside a quote (#7407) > --- -> horizontal rule nested in the quote
× horizontal rule (on space or Enter) > --- > + space -> horizontal rule, caret on a new line under it
× horizontal rule (on space or Enter) > --- > + Enter -> horizontal rule, caret on a new line under it
× horizontal rule (on space or Enter) > *** > + space -> horizontal rule, caret on a new line under it
× horizontal rule (on space or Enter) > *** > + Enter -> horizontal rule, caret on a new line under it
× horizontal rule (on space or Enter) > ___ > + space -> horizontal rule, caret on a new line under it
× horizontal rule (on space or Enter) > ___ > + Enter -> horizontal rule, caret on a new line under it
× horizontal rule (on space or Enter) > a rule longer than 24 characters converts on space
× horizontal rule (on space or Enter) > a rule longer than 24 characters converts on Enter
× horizontal rule (on space or Enter) > undo after space gives back the typed marker
× horizontal rule (on space or Enter) > undo after Enter gives back the typed marker
Tests  11 failed | 38 passed (49)

The stays-text cases pass on main too, since nothing converts there. With this PR but without the ancestor check, both table cell cases fail; without the HorizontalRuleNode check, both no-HorizontalRuleNode cases fail; and without the scan past 24 characters, the long rule on Space fails.

After

$ pnpm exec vitest run packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts
Tests  49 passed (49)

$ pnpm exec vitest run packages/lexical-mdast
Test Files  8 passed (8)
     Tests  307 passed (307)

$ pnpm run test-unit
 FAIL  |unit| packages/lexical-devtools/src/__tests__/unit/assertTrustedRPCCaller.test.ts
 FAIL  |unit| packages/lexical-devtools/src/__tests__/unit/contentScriptPing.test.ts
 FAIL  |unit| packages/lexical-devtools/src/__tests__/unit/serializeEditorState.test.ts
 FAIL  |unit| packages/lexical-devtools/src/__tests__/unit/storeRetention.test.ts
 FAIL  |scripts-unit| scripts/__tests__/unit/changelog.test.ts > release > refuses while the previous release is missing from CHANGELOG.md
 FAIL  |scripts-unit| scripts/__tests__/unit/changelog.test.ts > release > finds the previous stable release from tags and LATEST_RELEASE
 FAIL  |scripts-unit| scripts/__tests__/unit/changelog.test.ts > release > resolveOrigin > prefers a PR: line and does not credit the committer then
Test Files  5 failed | 580 passed | 3 skipped (588)
     Tests  3 failed | 18161 passed | 46 expected fail | 6 skipped (18216)

$ (cd packages/lexical-devtools && pnpm exec wxt prepare)
$ pnpm exec vitest run packages/lexical-devtools/src/__tests__/unit
Test Files  4 passed (4)
     Tests  16 passed (16)

$ pnpm run flow
Found 0 errors

The four lexical-devtools files could not load in my checkout because .wxt/tsconfig.json, which wxt prepare writes on install, was missing; after wxt prepare they pass, as above. changelog.test.ts fails on and off in my checkout, with ENOTEMPTY while removing its own temp folder or a failed git commit in its temp repository, a different case each run; this PR changes no file under scripts/.

pnpm run tsc is clean, and pnpm exec eslint on packages/lexical-mdast/src, pnpm exec prettier --check and pnpm run check-changesets are clean.

Tested on macOS with Node 24.21.0. Browser tests, E2E and the playground were not run. The playground does not use MdastShortcutsExtension.

🤖 Generated with Claude Code

…and ___

MdastShortcutsExtension had typing shortcuts for headings, quotes, lists and code blocks, but none for a horizontal rule. A line of ---, *** or ___ stayed text and was exported escaped, as \---.

Now ---, *** or ___ alone on a line, followed by Space or Enter, becomes a HorizontalRuleNode, with the caret in a new paragraph under it. The rule keeps the typed marker, so export writes the same one. Like the other block shortcuts, it needs MdastHorizontalRuleExtension in the editor. At the start of a quote the rule goes into the quote. With text after the marker, or in a table cell, the line stays text.

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

vercel Bot commented Oct 28, 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 6:56am UTC
lexical-playground Ready Ready Preview Nov 2, 2026 6:56am 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 28, 2026
@pkg-pr-new

pkg-pr-new Bot commented Oct 29, 2026

Copy link
Copy Markdown
lexical

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

@lexical/a11y

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

@lexical/clipboard

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

@lexical/code

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

@lexical/code-core

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

@lexical/code-prism

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

@lexical/code-shiki

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

@lexical/compiler

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

@lexical/devtools-core

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

@lexical/dragon

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

@lexical/eslint-plugin

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

@lexical/extension

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

@lexical/fast-check

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

@lexical/file

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

@lexical/hashtag

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

@lexical/headless

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

@lexical/history

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

@lexical/html

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

@lexical/internal

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

@lexical/link

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

@lexical/list

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

@lexical/mark

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

@lexical/markdown

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

@lexical/mdast

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

@lexical/offset

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

@lexical/overflow

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

@lexical/plain-text

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

@lexical/react

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

@lexical/rich-text

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

@lexical/selection

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

@lexical/table

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

@lexical/tailwind

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

@lexical/text

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

@lexical/utils

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

@lexical/y

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

@lexical/yjs

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

commit: 334581b

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

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Thanks for this! The shortcut behaves as described: in the mdast-editor dev example, ---, *** or ___ converts on Space, on Enter and at the start of a > quote, and Undo restores the typed marker. The tests are solid too: the seven conversion cases fail on main and pass here, and the table-cell cases really guard their check.

Please change before merge

  1. @lexical/table now ends up in every bundle that uses the shortcuts (packages/lexical-mdast/src/MdastShortcuts.ts:27, used at line 116). $isTableCellNode keeps TableCellNode reachable from MdastShortcutsExtension, so editors without tables ship it too. MdastTableExtension is documented as opt-in "because it pulls in the @lexical/table nodes it ships" (packages/lexical-mdast/src/MdastTableExtension.ts), and packages/lexical-website/docs/serialization/markdown-mdast.md says each feature extension ships the nodes it needs. The repository's own size probe (dev-examples/mdast-editor: pnpm run build-prod, then pnpm run size; CommonMark, strikethrough, task lists and shortcuts, no tables) grows from 483,772 to 493,377 bytes minified and from 122,600 to 125,252 bytes gzipped. One way to keep the behavior without the import is to convert only where Markdown can write a rule, at the top level or inside a quote, replacing the cell check with

    $findMatchingParent(container.getParentOrThrow(), node => !$isQuoteNode(node))

    and dropping the @lexical/table import. With that change all 38 tests in MdastShortcuts.test.ts still pass, both table-cell cases included, and the table code leaves the bundle. Letting MdastTableExtension contribute the cell check would work too.

  2. Add a changeset. CONTRIBUTING.md requires one for every user-facing change, and this changes what @lexical/mdast does when someone types ---. AGENTS.md also asks for a Verify: paragraph when a change alters editing behavior. For example .changeset/mdast-thematic-break-shortcut.md:

    ---
    '@lexical/mdast': patch
    ---
    
    [feature] Type `---`, `***` or `___` on its own line, then Space or Enter, to insert a horizontal rule with `MdastShortcutsExtension`
    
    Verify: with `MdastHorizontalRuleExtension` (part of `MdastCommonMarkExtension`), a line that is only `---`, `***` or `___` becomes a horizontal rule on Space or Enter, with the caret on a new line below; a line with text after the marker stays text.

Smaller suggestions

  • MAX_BLOCK_MARKER_LENGTH (MdastShortcuts.ts:57) still skips the Space scan past column 24, so a rule of 24 or more characters converts on Enter but not on Space, while CommonMark (section 4.1) accepts "three or more matching -, _, or * characters". Either let a line of one repeated marker character past that cap or note the Enter-only behavior in the comment there.
  • The description notes inline code and Shift+Enter were not tested. Consider adding Undo cases for Space and Enter (the test editor needs HistoryExtension with disabled: false, since it is off when there is no window) and a --- inside a code block case to MdastShortcuts.test.ts, so the undo step and the code exclusion stay pinned.

The CI workflows for this head are waiting for maintainer approval, so none of the test suites have run on it yet.

if (
line.length !== markerLength ||
!container.getChildren().every($isTextNode) ||
$findMatchingParent(container, $isTableCellNode)

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 check is what pulls @lexical/table (TableCellNode and its generated JSON schema) into every bundle that uses MdastShortcutsExtension, editors without tables included, while MdastTableExtension is documented as opt-in to keep those nodes out. The repository's size probe (dev-examples/mdast-editor, pnpm run size after pnpm run build-prod) grows by 9,605 bytes minified and 2,652 gzipped with this PR.

One option that needs no import: convert only where Markdown can write a rule, at the top level or in a quote, and drop the import on line 27:

    $findMatchingParent(container.getParentOrThrow(), node => !$isQuoteNode(node))

All 38 tests in MdastShortcuts.test.ts still pass with it, both table-cell cases included.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in 5c6917a, as suggested. The rule now converts only at the top level or inside a quote, and the @lexical/table import is gone.

With the size probe (pnpm run build-prod, then pnpm run size in dev-examples/mdast-editor), the mdast bundle goes from 493,377 to 484,294 bytes minified (481.8 kB to 472.9 kB as the script prints it, and 122.3 kB to 119.9 kB gzipped), and "tablecell" no longer appears in it. Both table-cell tests still pass, and they fail if the check is removed.

This also means --- typed in a container that isn't a quote (a layout or collapsible block, for example) stays text, since Markdown can't write a rule there either.

…le shortcut

Convert a typed rule only at the top level or inside a quote, the places Markdown can write one, instead of checking for a table cell. This drops the @lexical/table import, so editors without tables no longer ship it. Let a space convert a rule longer than 24 characters, as Enter already does. Add tests for undo, a code block, inline code and Shift+Enter, and a changeset.

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

Copy link
Copy Markdown
Author

Thanks for the review. All points are handled in 5c6917a:

  1. @lexical/table in every bundle: fixed as suggested, see the inline thread.
  2. Changeset: added .changeset/mdast-thematic-break-shortcut.md, with a Verify: paragraph.
  3. MAX_BLOCK_MARKER_LENGTH: a space typed after a line that is only a thematic break (THEMATIC_BREAK_LINE, the CommonMark 4.1 shape) is now scanned past the cap, so a rule of 24 or more characters converts on Space as it does on Enter. A new test covers both keys; the Space case failed before.
  4. Tests: added undo after Space and after Enter (with HistoryExtension, disabled: false), --- in a code block and in inline code (each with Space and Enter), and Shift+Enter.

Checks: on the PR head, pnpm exec vitest run packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts gives 47 passed (47), and pnpm run tsc, pnpm run flow, eslint, prettier and pnpm run check-changesets are clean. With these changes on top of the current main (e0f13db), pnpm exec vitest run packages/lexical-mdast gives 305 passed (305).

@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, thanks for the follow-up. ---, *** and ___ convert on Space and on Enter, also at the start of a > quote and past 24 characters; the caret lands in the new paragraph, one Undo gives back the typed marker, and the export keeps the marker. The @lexical/table import is gone and the changeset follows CONTRIBUTING.md.

Two optional, non-blocking suggestions:

  1. Decline quietly when HorizontalRuleNode isn't registered (packages/lexical-mdast/src/MdastShortcuts.ts:135). scanBlock offers the match whenever some import rule handles thematicBreak, so an editor that maps thematicBreak to its own node without HorizontalRuleExtension now gets Node HorizontalRuleNode has not been registered in onError when someone types --- ; on main the line just stayed text. Adding !$getEditor().hasNode(HorizontalRuleNode) to the early-return checks in $applyThematicBreak would avoid it. The heading shortcut has the same shape today, so this is fine to leave for later.
  2. Refresh the Test plan output. AGENTS.md (Commits and Pull Requests) asks for the real Before and After output of the command you ran. The description still shows Tests 38 passed (38) and says Shift+Enter and inline code aren't tested, but MdastShortcuts.test.ts now has 47 tests, those cases included, and 11 of them fail on main.

The CI workflows for this commit are waiting for maintainer approval, so none of the test suites have run on it yet.

return false;
}
$stripLeading(container, markerLength);
const rule = $createHorizontalRuleNode();

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: scanBlock offers this match whenever some import rule handles thematicBreak, so an editor that maps it to its own node without HorizontalRuleExtension gets Node HorizontalRuleNode has not been registered in onError when --- is typed. Adding !$getEditor().hasNode(HorizontalRuleNode) to the checks above would let the shortcut decline quietly. The heading shortcut behaves the same way today, so this can wait.

Copy link
Copy Markdown
Author

Choose a reason for hiding this comment

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

Done in f785646. $applyThematicBreak now returns early when !$getEditor().hasNode(HorizontalRuleNode), so the line stays text.

I reproduced it first: an editor with MdastRichTextExtension, MdastExtension configured to import thematicBreak as a paragraph, and MdastShortcutsExtension, but no HorizontalRuleExtension, reported Node HorizontalRuleNode has not been registered. Ensure node has been passed to createEditor. to onError when --- was typed. A new test does this with Space and with Enter and checks that onError gets nothing; both cases fail without the check. I left the heading shortcut as it is.

…as no HorizontalRuleNode

## Description

An editor can import thematicBreak as its own node, without HorizontalRuleExtension. Typing --- and then Space or Enter there reported "Node HorizontalRuleNode has not been registered" to onError, while on main the line stays text. The horizontal rule shortcut now checks that the editor has HorizontalRuleNode first, and leaves the line as text when it doesn't.

## Test plan

### Before

$ pnpm exec vitest run packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts
× --- + space stays text when the editor has no HorizontalRuleNode
× --- + Enter stays text when the editor has no HorizontalRuleNode
Tests  2 failed | 47 passed (49)

### After

$ pnpm exec vitest run packages/lexical-mdast/src/__tests__/unit/MdastShortcuts.test.ts
Tests  49 passed (49)

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

Copy link
Copy Markdown
Author

Thanks for the approval. Both suggestions are handled:

  1. Decline quietly when HorizontalRuleNode isn't registered: done in f785646, see the inline thread.
  2. Refresh the Test plan output: the description now has the real Before output (the 49 tests with MdastShortcuts.ts and MdastStream.ts from main: 11 failed, 38 passed) and After output (49 in the file, 307 in the package, a full pnpm run test-unit run, flow), lists the Shift+Enter, code block and inline code cases, and has the ## Changeset section from the template.

@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, thanks for the follow-up. ---, *** and ___ convert on Space and on Enter, also at the start of a > quote and past 24 characters; the caret lands in the new paragraph, one Undo gives back the typed marker, and the export keeps the marker. The new hasNode(HorizontalRuleNode) check keeps the line as text in editors that import thematicBreak as their own node, the Test plan now carries real Before and After output as AGENTS.md asks, and the changeset follows CONTRIBUTING.md.

One optional, non-blocking suggestion:

  • Pin the all-text-children guard with a test (packages/lexical-mdast/src/MdastShortcuts.ts:135). Every other guard in $applyThematicBreak has a test that fails without it, but removing !container.getChildren().every($isTextNode) leaves all 49 tests in MdastShortcuts.test.ts passing. That check is what keeps --- followed by an inline node with no text (an inline decorator) from converting and dropping that node along with the paragraph; a Space case and an Enter case with such a node would keep it from regressing.

The CI workflows for this commit are waiting for maintainer approval, so none of the test suites have run on it yet.

if (
!$getEditor().hasNode(HorizontalRuleNode) ||
line.length !== markerLength ||
!container.getChildren().every($isTextNode) ||

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: this is the one guard in $applyThematicBreak that no test pins; removing it leaves all 49 tests in MdastShortcuts.test.ts passing. It keeps --- followed by an inline node with no text (an inline decorator) from converting and dropping that node along with the paragraph, so a Space case and an Enter case with such a node would keep it covered.

This branch was successfully deployed

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

2 participants