Getting Markdown into a Lexical editor without the copy-paste dance
I write in Markdown. My blog runs on Payload, which stores post bodies as a Lexical node tree. Those two facts don't get along, and for a while my "publishing workflow" was pasting formatted text into the admin, watching the code blocks turn to mush, and fixing them by hand. Here's how I closed that gap, and the handful of bugs I earned doing it.
The problem
A Payload rich-text field doesn't hold Markdown or HTML. It holds a serialized Lexical document, a JSON tree of typed nodes. That's great for structured editing and terrible for the way I actually draft, which is Markdown in whatever editor is already open.
I also lean on custom blocks. Code samples aren't plain <pre> tags; they're a Code Snippet block with server-side Shiki highlighting. Diagrams are a Mermaid block. Command-line examples are a Terminal block. Pasting rendered content into the editor produces none of those, just a wall of text I then have to re-block by hand.
So the goal was easy to state: paste a Markdown body, get real rich text plus my custom blocks, dropped in where the cursor is.
The part that isn't trivial
Payload ships a convertMarkdownToLexical helper, so I assumed this was a one-liner. It isn't. The default editor config has no transformer for fenced code blocks. Feed it a triple-backtick block and it comes back as literal text: the backticks and the code, verbatim, inside a paragraph. For a blog that's mostly code, that's a dealbreaker.
The fix was to stop treating the Markdown as one blob. I split fenced code out first, hand the prose segments to Payload's converter, and build the custom block nodes myself for the fences, then interleave everything back in document order.
for (const segment of splitSegments(markdown)) {
if (segment.type === "prose") {
children.push(...convertProse(segment.text))
} else {
// ```mermaid / ```terminal / ```<lang> -> the matching custom block
children.push(blockNodeFor(segment))
}
}The fence's info string decides the block: mermaid becomes a Mermaid block, terminal becomes a Terminal block, and everything else becomes a Code Snippet with the language mapped to one Shiki actually knows (js to javascript, sh to bash, and so on).
I gave that converter two front doors that share the same code:
The button is a custom Lexical toolbar feature: click it, paste into a modal, and it sends the Markdown to an auth-gated endpoint that runs the converter and returns nodes to insert at the cursor. The CLI runs the same conversion for a whole post from a Markdown file. One converter, two ways in, and the output is identical either way.
The learnings
The feature itself was straightforward. The interesting part was everything I got wrong.
A modal steals your cursor. "Insert at the cursor" sounds obvious until you notice that opening a modal focuses its textarea, which blurs the editor and clears Lexical's selection. By the time you insert, there's no selection left, and the naive fallback drops the content at the end of the post. The fix is to snapshot the selection the moment the button is pressed, on mousedown, before focus moves, and restore it right before inserting.
Then there's testing on the easy case. I "verified" the first version by pasting into an empty post and watching it work. It did work, because in an empty post the end of the document is the cursor. The bug stayed invisible until there was existing content for it to land in front of. A reviewer caught it immediately. A passing test on a degenerate input is not a passing test.
One fix is a lump under the rug. Snapshotting on mousedown fixed the mouse path and quietly broke two others. Keyboard activation (Enter or Space) opens the modal without a mousedown, so the snapshot went stale. Clearing the snapshot on close then introduced a race: dismiss the modal while the conversion request is still in flight and the insert lands at the end again. Every one was real, every one showed up in review, and each was a reminder that selection state has more edges than it looks.
Verify through the app, not through pixels. Confirming the fix was harder than writing it. Coordinate-based browser automation kept missing, because the screenshots were scaled and the keyboard shortcuts I used to move the cursor were silently doing nothing. What finally worked was driving the page through its own JavaScript: grab the Lexical editor handle, set the selection through its API, run the real insert, then read the text content and check the order. An inserted node before my sentinel text meant it landed at the cursor. After it would have meant the bug survived.
Prefer an honest type over a cast. Assigning my converted content to Payload's generated Post["content"] needed an as cast, and a cast is usually a note to yourself that a type is lying. This one was: my loose node type left version optional, tucked under an index signature, even though every node I build has a version. Declaring version: number on the type let the whole thing assign cleanly, with no cast and no runtime guard. When you control how a value is built, make the type tell the truth instead of casting past it.
Pin to the exact dependency version. The custom editor feature needed lexical as a direct dependency, and it has to match the version Payload already resolves. A second copy of Lexical means a second React context, and the editor stops talking to itself in ways that are miserable to debug.
The benefits
The copy-paste dance is gone. I draft in Markdown wherever I want, paste it into the editor, and get headings, lists, links, and my Code Snippet, Mermaid, and Terminal blocks, inserted where the cursor is. The CLI covers the other case: turn a Markdown file into a full draft post in one command.
Because both paths run the same converter, there's one behavior to reason about and one place to fix anything. It shipped with no schema change and no migration. And the code came out better typed than it went in.
Mostly, though, the win is smaller than all of that: I get to keep writing the way I already write, and the tool meets me there. I drafted this post in Markdown too.
Comments
No comments yet. Be the first to comment.