Porting converters out of a theme

Last updatedView as Markdown

Written for the case this plugin was extracted from: a theme carrying a WP-CLI conversion command in functions/, with per-block converters beside it. It applies to any project where the engine and the converters currently live together.

The short version: the converters port almost unchanged. What moves is the engine underneath them.


What ships in the plugin, and what stays with you

Stays in your repositoryNow comes from the plugin
block-*.php — your convertersthe block-tree builders
your fixturesHTML → blocks
any post-meta or block-binding registrationfield normalization and resolvers
project-specific validation rulesthe registry, walk, splice and attribute preservation
selection, the slash-safe write, backups, rollback
the WP-CLI command and the fixture harness

Your converters keep living next to the content they were recorded from, and next to their fixtures. That is the point of the split.

The converter contract did not change

An existing converter has this shape:

function neit_acf_convert_intro_text( $data ) {
    return [
        'block'   => $block,
        'notices' => $notices,
        'blocked' => $reason,
    ];
}

That is exactly what the plugin expects. Two things follow:

  • The return value needs no change at all.
  • The signature needs no change. The engine calls converters with two arguments — data and context — and PHP passes extra arguments to a one-parameter function harmlessly. Add array $context only where you want it.

So porting a converter is a rename of the helpers it calls. Nothing structural.

Renaming the helpers

use Block_Alchemy\Builder\Blocks;
use Block_Alchemy\Builder\Fields;
use Block_Alchemy\Builder\Html;

Block builders

WasNow
neit_acf_leaf()Blocks::leaf()
neit_acf_void()Blocks::void_block()
neit_acf_container()Blocks::container()
neit_acf_dynamic_block()Blocks::dynamic()
neit_acf_query_block()Blocks::query()
neit_acf_bound_block()Blocks::bound()
neit_acf_group()Blocks::group()
neit_acf_empty_group()Blocks::empty_group()
neit_acf_heading()Blocks::heading()
neit_acf_paragraph()Blocks::paragraph()
neit_acf_image()Blocks::image()
neit_acf_button()Blocks::button()
neit_acf_button_with_markup()Blocks::button_with_markup()
neit_acf_button_wrapper_notice()Blocks::button_wrapper_notice()
neit_acf_buttons()Blocks::buttons()
neit_acf_attribute_text()Blocks::attribute_text()
neit_acf_stable_uuid()Blocks::stable_uuid()
—Blocks::spacer() (new)

Two notes:

  • void_block(), not void() — void is a reserved word.
  • Blocks::button() now delegates to button_with_markup(). They were byte-identical apart from the label fallback, so the duplicate is gone. Behaviour is unchanged.

Field normalization

WasNow
neit_acf_unflatten()Fields::unflatten()
neit_acf_resolve_attachment()Fields::resolve_attachment()
neit_acf_attachment_url()Fields::attachment_url()
neit_acf_resolve_file()Fields::resolve_file()
neit_acf_resolve_terms()Fields::resolve_terms()
neit_acf_normalize_link()Fields::normalize_link()
neit_acf_field_has_content()Fields::has_content()
neit_acf_report_unmapped()Fields::report_unmapped()
neit_acf_attachment_overrides( $map )Fields::set_attachment_overrides( $map )
neit_acf_attachment_overrides()Fields::attachment_overrides()
neit_acf_term_overrides( $map )Fields::set_term_overrides( $map )
neit_acf_term_overrides()Fields::term_overrides()
—Fields::reset_overrides() (new)

The dual-purpose getter/setter is split, because a function whose behaviour depends on whether you passed an argument is easy to call wrongly.

One behaviour change, and it is a fix. The old resolve_attachment() consulted the override map only in the bare-attachment-ID branch — an image stored as a full array could not be overridden, so a fixture covering one silently fell through to a database lookup that was not there. Both branches now consult it.

HTML conversion

WasNow
neit_acf_html_to_blocks( $html, $notice )Html::to_blocks( $html, $notices )
neit_acf_known_shortcodes()Html::known_shortcodes()
neit_acf_enclosing_shortcodes()internal — now per-conversion state
everything elseinternal

Html is instantiated per conversion, so the shortcode state computed from one field cannot leak into the next.

The default known-shortcode list is now WordPress's own (embed, caption, gallery, playlist, audio, video), since a general-purpose plugin cannot ship one site's list. Move yours into a filter:

add_filter( 'block_alchemy_known_shortcodes', function ( $known ) {
    return array_merge( $known, [
        'expand', 'gravityform', 'su_note', 'su_shadow', 'su_button',
        'su_box', 'su_spoiler', 'cp_popup', 'event-details',
    ] );
} );

This one matters. A tag that is neither listed nor registered is treated as bracketed prose and left as text. Forget it and every one of those shortcodes silently stops converting.

Registering the converters

The registry array becomes a filter:

add_filter( 'block_alchemy_converters', function ( $converters ) {
    $converters['acf/intro-text']    = 'neit_acf_convert_intro_text';
    $converters['acf/content-media'] = 'neit_acf_convert_content_media';
    $converters['acf/styled-links']  = 'neit_acf_convert_styled_links';
    $converters['acf/accordion']     = 'neit_acf_convert_accordion';
    // ...

    return $converters;
} );

Keep the block-*.php files and their loader exactly as they are; only the registry function goes away.

Command and meta-key changes

WasNow
wp neit acf-to-blocks scanwp block-alchemy scan
wp neit acf-to-blocks convertwp block-alchemy convert
wp neit acf-to-blocks verifywp block-alchemy verify
wp neit acf-to-blocks rollbackwp block-alchemy rollback
wp neit acf-to-blocks fixtureswp block-alchemy fixtures
--block=<names>--converter=<names>
migrate-termsnot ported — see below
_neit_acf_migration_backup_block_alchemy_backup

Posts already converted

Existing backups are under the old meta key, so rollback would report that there is nothing to restore. Either point the plugin at the old key:

add_filter( 'block_alchemy_backup_meta_key', function () {
    return '_neit_acf_migration_backup';
} );

…or rename the meta once and drop the filter:

wp db query "UPDATE $(wp db prefix)postmeta
             SET meta_key = '_block_alchemy_backup'
             WHERE meta_key = '_neit_acf_migration_backup'"

Prefer the rename. The filter works, but it leaves a project carrying a permanent shim for a one-off.

migrate-terms

Deliberately not ported. Copying a field's stored term IDs onto posts as real taxonomy terms is a field-plugin concern, not a block-conversion one, and it does not read or write post_content at all. Leave it in the theme.

What this unblocks

Flexible content in post meta

The post_meta source is the reason to extract now rather than later. A field group that lives in meta and is rendered by a template — rather than sitting in post_content — has a first-class home:

add_filter( 'block_alchemy_converters', function ( $converters ) {
    foreach ( [ 'hero', 'cards', 'quote', 'stats', 'cta' ] as $layout ) {
        $converters[ $layout ] = [
            'source'   => 'post_meta',
            'meta_key' => 'flexible_components',
            'callback' => 'neit_convert_component_' . str_replace( '-', '_', $layout ),
            'label'    => ucfirst( $layout ),
        ];
    }

    return $converters;
} );

The source reads the layout list, expands each row's flattened keys, hands each to its converter, appends the result to the content, and records the meta key for deletion on write.

Three things to know before you use it:

  1. It is all-or-nothing per meta key. One unconvertible row holds the whole field back and every other row is reported as refused. The rows render as a single ordered sequence from a single meta value, so converting part of it either drops the refused row or renders the converted ones twice. There is no third option.
  2. Position matters. Output is appended by default. If the theme rendered the field above the_content, use block_alchemy_post_meta_position to prepend, or the page reorders the moment the meta stops rendering.
  3. Unhook the template render yourself. The plugin deletes the meta on write and backs it up first, but it does not know which templates call your render function. That is your change to make, in the same release.

Shortcodes

The shortcode source converts a shortcode that is the entire content of the block holding it. A shortcode mid-sentence is refused, with the sentence quoted, because converting it would split the paragraph and reflow the copy — and it renders perfectly well where it is.

In practice this splits a site's usage cleanly: layout shortcodes are almost always on their own line and convert; inline ones are almost always mid-sentence and are exactly the ones that should stay.

One bug the extraction fixed

The original validator checked for newly introduced core/freeform blocks, to catch markup the parser could not make sense of.

That check never fired. parse_blocks() gives classic content a blockName of null, not core/freeform — the name only appears when content carries literal <!-- wp:freeform --> delimiters, which is rare. So the check matched nothing, silently, for its entire life.

It is fixed here by Core\Block_Name, which resolves a parsed block's effective name: core/freeform for classic content, and null for the whitespace between siblings — because parse_blocks() uses a null name for both of those, and telling them apart is not optional.

The same fix is what makes the built-in classic-content converter work at all, and it is worth checking any project that matched on the string core/freeform.

Porting checklist

  1. composer require linchpin/block-alchemy.
  2. Delete blocks.php, normalize.php, html-to-blocks.php, engine.php and the CLI file from the theme.
  3. Find-and-replace the helper names in block-*.php, per the tables above.
  4. Add the use statements.
  5. Replace the registry function with a block_alchemy_converters filter.
  6. Move your shortcode list to block_alchemy_known_shortcodes.
  7. Point block_alchemy_fixture_paths at your fixture directory.
  8. Rename the backup meta key.
  9. wp block-alchemy fixtures — expect green with no changes.
  10. wp block-alchemy scan — expect the same counts as before.
  11. Convert one post, verify it, and open it in the block editor.

Have a question about this page?

Linchpin assistant

Was this helpful?