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 repository | Now comes from the plugin |
|---|---|
block-*.php — your converters | the block-tree builders |
| your fixtures | HTML → blocks |
| any post-meta or block-binding registration | field normalization and resolvers |
| project-specific validation rules | the 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 $contextonly 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
| Was | Now |
|---|---|
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(), notvoid()—voidis a reserved word.Blocks::button()now delegates tobutton_with_markup(). They were byte-identical apart from the label fallback, so the duplicate is gone. Behaviour is unchanged.
Field normalization
| Was | Now |
|---|---|
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
| Was | Now |
|---|---|
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 else | internal |
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
| Was | Now |
|---|---|
wp neit acf-to-blocks scan | wp block-alchemy scan |
wp neit acf-to-blocks convert | wp block-alchemy convert |
wp neit acf-to-blocks verify | wp block-alchemy verify |
wp neit acf-to-blocks rollback | wp block-alchemy rollback |
wp neit acf-to-blocks fixtures | wp block-alchemy fixtures |
--block=<names> | --converter=<names> |
migrate-terms | not 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:
- 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.
- Position matters. Output is appended by default. If the theme rendered the field above
the_content, useblock_alchemy_post_meta_positionto prepend, or the page reorders the moment the meta stops rendering. - 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
composer require linchpin/block-alchemy.- Delete
blocks.php,normalize.php,html-to-blocks.php,engine.phpand the CLI file from the theme. - Find-and-replace the helper names in
block-*.php, per the tables above. - Add the
usestatements. - Replace the registry function with a
block_alchemy_convertersfilter. - Move your shortcode list to
block_alchemy_known_shortcodes. - Point
block_alchemy_fixture_pathsat your fixture directory. - Rename the backup meta key.
wp block-alchemy fixtures— expect green with no changes.wp block-alchemy scan— expect the same counts as before.- Convert one post, verify it, and open it in the block editor.