Karyla
EngineeringWordPressProseMirror

How we built bidirectional Gutenberg ↔ ProseMirror conversion

Converting WordPress Gutenberg blocks to ProseMirror JSON and back, without losing your columns, shortcodes, or sanity.

Karyla Team·

If you edit a WordPress post outside of WordPress and paste it back in, you lose things. Columns flatten. Buttons become text. Shortcodes vanish. Custom blocks from Kadence or GenerateBlocks turn into empty paragraphs.

This is the core engineering problem behind Karyla. We need to pull a WordPress post into our ProseMirror-based editor, let users edit it with AI, and push it back with every Gutenberg block intact. Including blocks from plugins we've never heard of.

This post covers how we built that and what broke along the way.

The format gap

Gutenberg stores content as HTML with special comment markers. Block type and attributes live in JSON embedded in HTML comments:

<!-- wp:columns {"verticalAlignment":"center"} -->
<div class="wp-block-columns">
  <!-- wp:column {"width":"70%"} -->
  <div class="wp-block-column" style="flex-basis:70%">
    <!-- wp:paragraph -->
    <p>Left column</p>
    <!-- /wp:paragraph -->
  </div>
  <!-- /wp:column -->
  <!-- wp:column {"width":"30%"} -->
  <div class="wp-block-column" style="flex-basis:30%">
    <!-- wp:image {"id":42} -->
    <figure class="wp-block-image">
      <img src="/photo.jpg" alt="Sidebar" />
    </figure>
    <!-- /wp:image -->
  </div>
  <!-- /wp:column -->
</div>
<!-- /wp:columns -->

ProseMirror (which powers our TipTap editor) represents the same content as a typed JSON tree. Nodes have a type, optional attrs, and children. Inline formatting is expressed as marks on text nodes:

{
  "type": "columnsBlock",
  "attrs": { "blockId": "blk_x7k2m9p1", "layout": "70-30" },
  "content": [
    {
      "type": "columnBlock",
      "attrs": { "width": "70%" },
      "content": [{
        "type": "paragraph",
        "content": [{ "type": "text", "text": "Left column" }]
      }]
    },
    {
      "type": "columnBlock",
      "attrs": { "width": "30%" },
      "content": [{
        "type": "image",
        "attrs": { "src": "/photo.jpg", "alt": "Sidebar" }
      }]
    }
  ]
}

Two very different data structures. One is annotated HTML with implicit nesting. The other is an explicit tree. Converting between them without losing information is the whole problem.

Our approach: a converter registry

We went through a few iterations on this. The first version was a big switch statement. That lasted about a week before it became obvious that every block type has enough quirks to deserve its own module.

We landed on a registry of bidirectional converters. Each one handles one or more Gutenberg block types and implements both directions: parse (Gutenberg to ProseMirror) and serialize (back to Gutenberg):

interface BlockConverter {
  blockTypes: string[]   // Gutenberg types: ['paragraph', 'core/paragraph']
  nodeTypes: string[]    // ProseMirror types: ['paragraph']
  parse(block: GutenbergBlock, blockId: string, cmsBlocks: CMSBlockMetadata[]):
    ProseMirrorNode | ProseMirrorNode[] | null
  serializeGutenberg(node: ProseMirrorNode, cmsBlockMap: Map<string, CMSBlockMetadata>): string
  serializeHtml(node: ProseMirrorNode): string
}

Converters register at startup. When we encounter a Gutenberg block, we look up its converter by type. If nothing matches, a fallback handles it. We currently have about 35 converters.

Putting parse and serialize in the same object was deliberate. You can't add a new block type in one direction and forget the other.

Parsing: harder than it looks

Gutenberg blocks come in three forms: opening/closing pairs (<!-- wp:paragraph -->...<!-- /wp:paragraph -->), self-closing blocks (<!-- wp:spacer /-->), and arbitrarily nested structures (columns inside columns inside paragraphs).

WordPress has its own parser for this in PHP and JS, but we needed a standalone TypeScript version that runs on our backend, in the WordPress plugin, and in the browser. Ours is a single-pass tokenizer that extracts all comment markers with a regex, then matches openers with closers using a stack. Only top-level blocks get emitted. Nested blocks are handled recursively: each converter can re-invoke the parser on its inner content to go one level deeper.

The tricky part is same-type nesting. A wp:group can contain another wp:group. Naive string matching breaks here. The stack handles it because you always match with the most recent unmatched opener of the same type.

Columns: where recursion earns its keep

The columns converter is the most complex one we have, and a good example of why the recursive architecture pays off.

A columns block contains column blocks, which contain arbitrary content. The converter extracts the inner wp:column blocks, then for each column, extracts its inner blocks and recursively converts each one through the registry. The columns converter doesn't need to know what's inside. Paragraphs, images, embeds, nested columns, whatever. They all dispatch through the same path.

One thing that took a few tries: layout detection. WordPress stores individual column widths as percentages, but our editor uses layout presets (50-50, 70-30, 33-33-33, etc.). We match against known presets with 2% tolerance for float rounding. Without that tolerance, columns at 33.33%, 33.33%, and 33.34% would fall through to a custom layout instead of matching 33-33-33.

Shortcodes: the thing we got wrong first

Our first approach tried to parse shortcodes into semantic ProseMirror nodes. That didn't work.

Shortcodes are opaque. [gallery ids="1,2,3" columns="2"] could mean anything depending on which plugin handles it, and plugins can modify shortcode behavior with filters. There's no reliable way to build a semantic representation and round-trip it back.

So now we just treat them as raw text. Shortcode blocks become codeBlock nodes with a cmsBlockType attribute marking their origin. The user sees the raw shortcode text and can edit it. On serialize, we check that attribute and reconstruct the original Gutenberg shortcode block verbatim. No escaping, no reformatting. Custom HTML blocks work the same way.

Lesson learned: not everything needs to be semantically understood. Sometimes "opaque blob you can edit as text" is the right call.

Third-party blocks: the normalization problem

WordPress has over 60,000 plugins. Many of them define custom block types that do the same thing as core blocks but store data differently.

Kadence, GenerateBlocks, Stackable, and Ultimate Addons all ship their own heading blocks. kadence/advancedheading stores the level in attrs.level. generateblocks/headline stores it in attrs.element as the string "h2". Same output, different storage.

We wrote converters that normalize these into standard ProseMirror nodes. All four heading variants become a heading with a level. All button variants become a buttonBlock. The editor doesn't care which page builder created the content.

The same problem shows up with structured data blocks. Yoast SEO and Rank Math define How-To blocks with different schemas:

// Yoast: steps with jsonName/jsonText fallbacks
{ "steps": [{ "name": "Step 1", "text": "Do this", "jsonName": "Step 1", "jsonText": "Do this" }] }

// Rank Math: steps with title/content
{ "steps": [{ "title": "Step 1", "content": "Do this" }] }

We normalize both into our FAQ structure so users can edit the step content. On serialize, the converter checks which plugin the block came from and reconstructs the original format. Yoast blocks come back as Yoast blocks. Rank Math comes back as Rank Math. We keep the original block data around as a safety net, but the dedicated serializers handle the round-trip correctly on their own.

The fallback: preserving what you can't understand

This might be the most important design decision in the whole system.

For any block type without an explicit converter (WooCommerce product grids, niche plugin blocks, things we've never seen), the fallback stores the entire original block: comment attributes, inner HTML, all of it. It creates a cmsBlock node that the editor shows as a visible but non-editable placeholder. On serialize, it reconstructs the exact original Gutenberg block from the stored data.

No data loss. No mangled markup. Unknown blocks come out the other side byte-for-byte identical.

We went back and forth on whether to show these blocks in the editor or silently pass them through. Showing them won. People need to see that their WooCommerce product grid is still there, even if they can't edit it in Karyla.

The block identity problem

This one caught us off guard when we started building the AI suggestion system.

Our AI targets specific blocks: "rewrite the paragraph with blockId blk_a1b2c3d4." But Gutenberg clientId values change on every page load, and ProseMirror generates fresh IDs on every parse. If IDs keep changing, suggestions can't find their target.

We fixed this by assigning a persistent karylaBlockId to every Gutenberg block. It's stored as a block attribute, so it survives in the comment JSON:

<!-- wp:paragraph {"karylaBlockId":"blk_a1b2c3d4"} -->
<p>This block has a stable identity.</p>
<!-- /wp:paragraph -->

After parsing to ProseMirror, we walk both trees in document order and swap ProseMirror's generated IDs with the stable karylaBlockId values. Same ID everywhere.

The timing matters though. We only assign IDs when the Karyla sidebar opens, not on every page load. Otherwise we'd be modifying posts that the user never intended to edit with Karyla.

Rust on the backend

The TypeScript converter handles the WordPress plugin and client-side editor. On the backend we also analyze ProseMirror documents for text stats, readability scoring, and content indexing. This runs on every save.

For that we wrote a Rust crate that mirrors the ProseMirror JSON structure: a PmNode type and a walk() visitor for tree traversal. It's a dependency of our WASM text-analysis module, which runs in the browser (Web Workers) and on our Convex serverless backend (base64-encoded WASM). One implementation, all environments.

Could we have done this in TypeScript? Sure. But the Rust version is faster for large documents, and since we already compile to WASM for other analysis work (readability, content scoring), sharing the ProseMirror types across crates made sense.

What we'd do differently

Start with the fallback. We built converters for common blocks first and added the fallback later. Should have been the other way around. The fallback is what makes the system safe on real WordPress sites with unpredictable plugin combinations. That safety net should have been there from day one.

Don't be clever with opaque content. The shortcode mistake cost us a week. Not everything needs to be semantically understood. "Store it, show it, give it back unchanged" is a perfectly fine strategy.

Test against real sites. Our test suite started with hand-crafted Gutenberg HTML. The real world is messier: plugins that emit slightly malformed block comments, blocks nested in unexpected ways, attribute schemas that don't match their own documentation. We eventually built a scraper that pulls content from real WordPress sites for test fixtures. That's when the bug count really started dropping.

What's still hard

The long tail. We have converters for the popular plugins, but there's always another one with another block format. The fallback handles them, but expanding editable coverage is ongoing.

Collaborative sync. We use ProseMirror's operational transformation for real-time editing. OT merges are correct at the ProseMirror level, but mapping merged operations back to Gutenberg block semantics (block splits and joins especially) still needs careful handling.

WordPress keeps moving. Gutenberg adds new block types and changes attribute schemas across releases. The registry makes this manageable: write a converter, register it, done. But it never stops.


Karyla preserves your Gutenberg blocks, shortcodes, and third-party plugin content during round-trip editing. Write with AI in your brand voice, publish to WordPress, layout intact. Start free.

Try the version of AI that sounds like you.

Connect WordPress. Karyla learns your voice from what you’ve published. Get back to writing.