---
title: Writing a converter
---

A converter is a callable that receives normalized data and returns block arrays — or a reason it declined. That is the whole contract.

Everything else is the engine's job: finding occurrences, splicing output back in, carrying attributes across, validating, backing up, writing, reading back, rolling back.

---

## The shape

```php
function acme_convert_hero( array $data, array $context ): array {
    return [
        'block'   => $block,     // a parsed-block array — or 'blocks' => [ ... ]
        'notices' => [],         // things that converted, but not identically
        'blocked' => '',         // a reason to leave this occurrence alone
        'removed' => '',         // a reason it converts to nothing (post_meta rows only)
        'consumed' => [],        // child blocks absorbed into the output, name => count
    ];
}
```

Every key is optional. A converter that always succeeds returns just `[ 'block' => $block ]`; one that declines returns just `[ 'blocked' => 'why' ]`.

Register it:

```php
add_filter( 'block_alchemy_converters', function ( $converters ) {
    $converters['acme/hero'] = 'acme_convert_hero';

    return $converters;
} );
```

The key is what the converter matches: a **block name** for `post_content`, a **layout name** for `post_meta`, a **shortcode tag** for `shortcode`.

## Return block arrays, never markup strings

`serialize_blocks()` must be the only thing in the process that writes a block delimiter.

A literal `--` inside an attribute value terminates the HTML comment that opens a block, and `serialize_block_attributes()` is the only code that escapes it correctly. A converter that builds its own markup string has to get that right every time, and eventually will not.

So use `Block_Alchemy\Builder\Blocks`:

```php
use Block_Alchemy\Builder\Blocks;

Blocks::group( 'hero', [
    Blocks::heading( $data['title'], 2, 'section-title' ),
    Blocks::paragraph( $data['body'] ),
    Blocks::buttons( [ Blocks::button( $link ) ] ),
] );
```

Builders return `null` for empty input, and containers filter nulls out of their children. So you can build a tree with optional parts and let the empties fall away rather than branching around each one:

```php
// If there is no title, the heading simply is not there.
Blocks::group( 'hero', [
    Blocks::heading( $data['title'] ?? '', 2 ),
    Blocks::paragraph( $data['body'] ?? '' ),
] );
```

A `group` whose children all fall away returns `null` too — which the engine reads as a refusal. When an empty element is genuinely load-bearing (a divider drawn with a CSS pseudo-element has nothing to hang off once it is gone), use `Blocks::empty_group()`.

## Building a section

A field group rendered by a template is usually a nested structure, not one block. `Blocks` has a container for each shape a legacy section takes:

| Builder | Emits |
|---|---|
| `group()` / `empty_group()` | `core/group` — the general wrapper |
| `columns()` + `column()` | `core/columns` — the honest replacement for a percentage-width grid. `empty_column()` keeps a spacer column, which `column()` would drop |
| `cover()` | `core/cover` — an image the content sits *on top of* |
| `media_text()` | `core/media-text` — an image beside the content |
| `gallery()` | `core/gallery`, holding real `core/image` children, with core's own lightbox |
| `embed()` | `core/embed` |
| `list_block()` + `list_item()` | `core/list` |
| `details()` | `core/details` — a disclosure that needs no JavaScript |
| `separator()`, `spacer_block()` | `core/separator`, `core/spacer` |
| `html()`, `shortcode()` | the escape hatches. Report a notice whenever you reach for one |

A section wrapper usually needs to be a real element with a real id, and both have to reach the *markup*, not only the attributes:

```php
Blocks::group(
    'section-hero section-hero-community',
    $children,
    [
        'tagName' => 'section',
        'anchor'  => 'row-' . ( $context['row'] + 1 ),
    ]
);
```

**Carry the anchor across.** Jump navigation, in-page links from other posts and external deep links all point at it, and none of them are visible from the content being converted. A theme that renders `<section id="row-<?php echo get_row_index(); ?>">` has an entire navigation menu depending on ids that only exist because of the row order.

### Presentation is two things that have to agree

A block's appearance lives in the attributes *and* in the classes and inline `style` of the saved HTML. The editor derives the second from the first, in JavaScript, at save time. A block carrying `{"backgroundColor":"green"}` whose markup lacks `has-green-background-color has-background` is one the editor reports as containing unexpected or invalid content.

`Builder\Style` owns that derivation, and every container builder routes through it — so passing the attribute is enough:

```php
use Block_Alchemy\Builder\Layout;
use Block_Alchemy\Builder\Style;

Blocks::group(
    'section-one-column-text',
    $children,
    [
        'tagName'         => 'section',
        'backgroundColor' => 'light-gray',
        'layout'          => Layout::constrained(),
        'style'           => Style::merge(
            Style::background( $accent, 'cover', '50% 100%', 'no-repeat' ),
            Style::spacing( [ 'top' => 'var:preset|spacing|50' ] )
        ),
    ]
);
```

`Style::inline()` hands the style object to core's own `wp_style_engine_get_styles()`, so the CSS is what WordPress writes rather than a reimplementation that can drift.

**One exception is deliberate: a background image never reaches saved markup.** Core applies it through a `render_block` filter rather than any block's save function, so it is absent from saved HTML by design. `Style::background()` builds the attribute — the front end still gets the background — and `Style::presentation()` steps over it. Emitting it would add an inline style and a `has-background` class core's own output does not have.

### Match the block, not your memory of it

Every container builder reproduces a specific save function, and the details are not cosmetic:

- `core/cover` omits its dim class at `dimRatio: 50` — core's own default — while still writing `has-background-dim`.
- `core/columns` classes only the *opt-out* from mobile stacking, because stacking is the default.
- `core/embed` puts its URL on a line of its own inside the wrapper.
- `core/cover`'s `focalPoint` becomes `object-position`, which is the core equivalent of a template writing that property by hand — so an image crop-bias field maps straight onto it.

If you need a block with no builder, read its `save()` in `wp-includes/js/dist/block-library.min.js` before writing markup for it, and add a builder here rather than in your converter.

### A gap the theme will not render

A `blockGap` attribute is only rendered when the theme enables `settings.spacing.blockGap` — directly, or through `appearanceTools: true`, which opts it in. Core ships it null, and on a theme that leaves it there a gap you write is saved, ignored, and replaced by the block type's default — 2em for `core/columns`. Ask `Layout::gap_support()` rather than reading the theme's file, which does not show the opt-in:

```php
use Block_Alchemy\Builder\Layout;

if ( Layout::GAP_BLOCK !== Layout::gap_support() && '2em' !== $gap ) {
    return [ 'blocked' => 'a ' . $gap . ' column gap needs block gap support, which this theme does not enable' ];
}
```

Gap support is a property of the site, not the content, so the same block can convert on one theme and be refused on another. That is correct: the refusal names the setting that would change the answer.

## Normalizing the data

`Block_Alchemy\Builder\Fields` handles the storage formats field plugins actually use, as opposed to the one they document.

```php
use Block_Alchemy\Builder\Fields;

$image = Fields::resolve_attachment( $data['image'] ?? null );  // id, url, alt
$link  = Fields::normalize_link( $data['cta'] ?? null, 'Read more' );
$file  = Fields::resolve_file( $data['brochure'] ?? null );
$terms = Fields::resolve_terms( $data['category'] ?? null );
```

`Fields::unflatten()` expands a flattened key namespace into nested arrays. The `post_meta` source calls it for you; you need it if you are reading a repeater out of block attributes yourself.

Images are stored **three** ways — the full array, a bare attachment ID, or an empty string — and all three turn up in real content, sometimes within one field across post types. `resolve_attachment()` handles all three, which is why it belongs there and not in your converter.

## Refuse rather than convert approximately

This is the rule the whole plugin is built around. A refusal is cheap: the block stays as it was, and the reason appears in `scan` grouped by frequency, so ninety identical refusals read as one decision to make.

An approximate conversion is not cheap. It is a silent content change on a live page that nothing downstream will catch.

```php
// Populated but unresolvable. Converting would silently drop an image from a live page.
if ( ! empty( $data['image'] ) && '' === $image['url'] ) {
    return [ 'blocked' => 'the hero image is set but its attachment no longer exists' ];
}

// Something we do not understand. Better left alone than guessed at.
if ( ! in_array( $data['layout'] ?? '', [ 'wide', 'narrow' ], true ) ) {
    return [
        'blocked' => sprintf( 'unrecognised layout "%s"', $data['layout'] ?? '' ),
    ];
}
```

Write the reason for a person deciding what to do about it, not for a log. "unrecognised layout \"split-hero\"" is actionable; "invalid data" is not.

### Remove a row that renders nothing

A `post_meta` row whose layout prints nothing today has no block to become. Its converter returns `[ 'removed' => 'why' ]`, and the row converts to nothing. It counts as converted, the reason is reported as a notice, and the field is deleted with the rest. A field whose every row is removed is deleted and adds nothing to the content.

```php
// The layout left the field group, so ACF finds none of its fields and the band prints empty.
return [ 'removed' => 'the layout is no longer in the field group, and prints an empty band' ];
```

Only a row that renders nothing should be removed, and only a meta row can be. Removing a block from post content would need the validator told what went missing, so anywhere else a removal is read as a refusal.

### Report what you did not consume

```php
return [
    'block'   => $block,
    'notices' => Fields::report_unmapped(
        $data,
        [ 'title', 'body', 'image' ],   // consumed
        [ 'legacy_toggle' ]             // populated but renders nothing today
    ),
];
```

A converter that silently drops a populated field loses editorial content with no trace. `report_unmapped()` surfaces it so a person decides.

Use `$ignored` for fields that are genuinely dead — defined on a field group and never read by the template that renders it. Those display nothing today, so dropping them changes nothing, and listing them keeps the real findings visible.

## Converting WYSIWYG HTML

```php
use Block_Alchemy\Builder\Html;

$notices = [];
$blocks  = Html::to_blocks( $data['body'] ?? '', $notices );
```

`wpautop()` is applied first, because that is what the front end does. Real content depends on it: a single field routinely mixes proper `<p>` tags with bare inline runs separated by newlines, and skipping it produces a structure the live page never had.

Anything `Html` cannot represent as a core block is preserved verbatim in a `core/html` block and reported. Tables are the common case — `core/table` builds its markup from attributes rather than saved HTML, so a table emitted as inner HTML is a block the editor flags as invalid.

When your site knows what that markup is, say so with a filter rather than post-processing the blocks `Html` returns: [`block_alchemy_html_verbatim`](filters.md#block_alchemy_html_verbatim) for an iframe or a widget snippet that has a block, [`block_alchemy_pre_html_element`](filters.md#block_alchemy_pre_html_element) for your theme's own wrapper divs, and [`block_alchemy_shortcode_block`](filters.md#block_alchemy_shortcode_block) for a plugin shortcode with a block of its own.

## Returning several blocks

Classic content becomes a run of headings, paragraphs and lists, not one block. Return `blocks`:

```php
return [ 'blocks' => Html::to_blocks( $html, $notices ) ];
```

The engine interleaves them with the whitespace entries the parser produces between siblings, so the output looks like something the editor wrote.

There is one constraint. If the block being replaced carries an attribute that described *it* — a `className`, an alignment, a visibility setting — a split is **refused**, because there is no honest place to put that attribute across several siblings. A class that styled one container cannot be split across three paragraphs, and a background colour on a group is not that colour on each of its children.

If you hit that refusal, wrap your output in a single container block so there is somewhere for the attribute to live.

## Consuming child blocks

A block whose children have no counterpart of their own — tabs and their panes, Genesis Blocks' columns and each `gb-column` — is converted by its parent, which reads the children and builds their replacements itself. The children then vanish from the content, and the validator correctly reports them as lost. Declare them:

```php
return [
    'block'    => Blocks::group( 'tabs', $panes ),
    'consumed' => [ 'acme/tab' => count( $panes ) ],
];
```

The validator subtracts what you declare and nothing more, so a pane lost anywhere else on the page is still an error. A claim to have consumed more than the block contained is refused as a bug, because it would excuse a real loss elsewhere.

The engine carries the replaced block's own attributes — visibility, metadata, its class — onto the replacement. It knows nothing about the children you consume. **Carry theirs yourself**, through the same `block_alchemy_preserved_attributes` filter, or a pane hidden by Block Visibility is published by the conversion.

## Nested matches

Every block left in the content after a replacement is walked again, so a match your converter carries through untouched — an Advanced Columns block inside a column of another — is converted where it lands, in the same pass. A refused block is walked too: the refusal is about that block, and blocks inside it are judged on their own.

Your converter always receives the block as it was parsed. Its children are never converted first, so a converter that reads them gets what it was written for.

Two consequences:

- **Do not convert nested matches yourself.** Leave them in the output and let the walk reach them; it applies attribute preservation and the validator's accounting to each, which a converter recursing on its own skips.
- **Never put the block you are replacing back into your output.** Wrapping a block in a group around itself would recurse forever. The walk stops at 32 nested conversions and marks the post unwritable, but the converter is still wrong.

## Reading a source's context

The second argument carries what the source knows:

```php
function acme_convert_hero( array $data, array $context ): array {
    $context['source'];   // 'post_content', 'post_meta', 'shortcode'
    $context['post_id'];  // 0 for a fixture

    // post_content
    $context['block'];    // the full parsed block, including innerHTML
    $context['attrs'];    // its attributes

    // post_meta
    $context['meta_key'];
    $context['row'];      // row index
    $context['layout'];
}
```

A converter for a block whose content is in its inner HTML rather than its attributes — classic content, for instance — reads `$context['block']['innerHTML']`.

## Determinism

Conversion must be idempotent, so nothing in a converter may vary between runs.

Use `Blocks::stable_uuid( $seed )` where a block needs a UUID. A random one makes the same input produce different output every time, which breaks fixtures and makes a re-run rewrite every post it has already converted for no change.

## Fixtures

Record real content and lock the output in:

```
tests/fixtures/acme-hero/00.json     input
tests/fixtures/acme-hero/00.html     expected output
```

```json
{
  "description": "real instance from staging; covers: nested repeater, empty image",
  "content": "<!-- wp:acme/hero {\"data\":{\"title\":\"Welcome\"}} /-->",
  "meta":        { "page_sections": ["hero"], "page_sections_0_title": "From meta" },
  "attachments": { "27610": { "url": "https://example.test/a.jpg", "alt": "A" } },
  "terms":       { "45": "Undergraduate" },
  "converters":  ["acme/hero"]
}
```

`meta`, `attachments` and `terms` are what let a fixture run with **no database** — including for a `post_meta` converter, whose data is only in meta. A converter that can only be tested against a live install is a converter that will not be tested.

### Asserting a refusal

A refusal deserves a fixture as much as a conversion does — it is the behaviour this tool is built around. Add `expect_blocked` and record no `.html` file:

```json
{
  "description": "populated image field pointing at a deleted attachment",
  "content": "<!-- wp:acme/hero {\"data\":{\"image\":27610}} /-->",
  "expect_blocked": true
}
```

The case passes only when the converter refused **and** converted nothing, and the HTML comparison is skipped entirely.

Assert it explicitly rather than recording the output. A refusal leaves the content untouched, so a refusing case with a recorded `.html` passes whether or not it refuses — the output equals the input equals the expectation, and the one thing the fixture exists to prove is never checked.

Register the directory:

```php
add_filter( 'block_alchemy_fixture_paths', function ( $paths ) {
    $paths[] = get_stylesheet_directory() . '/tests/fixtures';

    return $paths;
} );
```

Then `wp block-alchemy fixtures --update` to record, and `wp block-alchemy fixtures` to check.

### What fixtures cannot catch

They compare output against a previous run of the same code. They lock in behaviour; they cannot tell you the behaviour is *correct*.

In particular they cannot detect block-validity problems, because invalid markup round-trips through the parser perfectly well.

**Open a converted page in the block editor.** It is the only check that finds "this block contains unexpected or invalid content", and it is how the `core/table` limitation documented above was found.

## Checklist

1. Write the converter. Return block arrays, never markup.
2. Register it on `block_alchemy_converters`.
3. Refuse anything you cannot convert faithfully, with a reason a person can act on.
4. Report every populated field you did not consume.
5. Record fixtures and check them in.
6. `wp block-alchemy convert --post=<id> --diff`, then `--write` on one post.
7. `wp block-alchemy verify --post=<id>`.
8. **Open that post in the block editor.**
9. Only then convert in bulk.
