Building blocks
block.json and Block Registration
How the block.json manifest reaches the block registry, which keys become block type properties, and what happens when the manifest and your arguments disagree.
You write a block.json, point register_block_type() at the
folder, and the block appears in the inserter with a title you did not write. Or it does
not appear at all, and there is no error in the log, because the function returned
false and you did not check. The manifest is read on every request, so the
mistake repeats silently forever.
There are two behaviours behind that. One is that register_block_type()
accepts a path, a block name, or a WP_Block_Type, and which one you handed it
decides whether block.json is read at all. The other is that a manifest and a
second argument array are merged in an order that is not obvious, and the winner of any
disagreement depends on which field is in question.
Everything below was run against WordPress 7.0.7, with a plugin that registers the same manifest twice under different names to observe the differences. Where a number appears, it came out of that install.
block.json is a manifest for WP_Block_Type, not a second configuration format
The three keys the schema requires are apiVersion, name and
title. The schema is fetched from
https://schemas.wp.org/trunk/block.json, and in the current revision
apiVersion has "const": 3, so there is exactly one legal value.
WordPress itself does not validate a manifest against that schema at runtime. The schema
serves editors and build tooling. A misspelled key is ignored on the server.
What the server does is a mapping. register_block_type_from_metadata()
holds a table of camelCase manifest keys to snake_case properties and copies each one it
finds into a settings array that ends up on a WP_Block_Type instance. The
table is short, and knowing which keys are in it saves an afternoon:
| Manifest key | Block type property |
|---|---|
apiVersion | api_version |
name | name |
title | title |
category | category |
parent, ancestor | parent, ancestor |
icon, description, keywords | Same names |
attributes | attributes |
providesContext, usesContext | provides_context, uses_context |
selectors, supports, styles | Same names |
variations, example, allowedBlocks | variations, example, allowed_blocks |
textdomain | textdomain |
version is not in that table and is not a property of the block type. It is
read later when asset handles are registered, as the cache-busting version for the script
and style files. Leave it out and the version passed to wp_register_script()
is false, which makes WordPress fall back to its own version string.
blockHooks is also not a property. It goes through its own loop that maps
before, after, firstChild and lastChild
onto the block hooks API, and refuses a hook that names the block itself. A position string
that is not in those four is skipped without a message.
Textdomain does more than label the block
When the manifest declares a textdomain, every translatable field in the
manifest is run through the block i18n schema before it reaches the block type, and any
script registered from that manifest which lists wp-i18n in its dependencies
gets wp_set_script_translations() called on it. A manifest copied from a
tutorial that still says the old project's text domain will leave your editor strings
looking for translation files that are not installed.
register_block_type() checks the filesystem before it decides what it was given
The signature accepts a name, a path to a folder, a path to a block.json, or
a WP_Block_Type. The branch is one file_exists() call: if the
first argument is a string and a file or directory exists at that path, it is treated as
metadata. Otherwise it is treated as a block name and the arguments are used directly.
// Reads blocks/callout/block.json.
register_block_type( __DIR__ . '/blocks/callout' );
// Registers a block from PHP only, no manifest anywhere.
register_block_type( 'mytheme/callout', array(
'title' => 'Callout',
'category' => 'widgets',
'api_version' => 3,
) );
That check is why a typo in the path is quiet. The second form is what actually runs when
the first argument is a path that does not resolve: your path string becomes the block name,
and a block called /var/www/wp-content/themes/acme/blocks/callout is registered
for the rest of the request. It has no title, no category, no editor script. Nothing about
it works and nothing about it throws.
Two more shapes return false rather than registering anything. A path that
exists but has no block.json in it, and a manifest whose name is
missing while the argument array has no name either. In both cases the
registry is left untouched, which is the better failure, but you only see it if you look at
the return value. register_block_type() returns the
WP_Block_Type on success, so var_dump() on the result answers the
question in one line.
Passing the file rather than the folder works, with one constraint: the path has to end
in block.json. The function appends block.json to anything that
does not, so a path to some other JSON file gets the suffix added and then fails to exist.
The argument array wins, but only for the keys it sets
Metadata is read first, translated, filtered, and then array_merge( $settings,
$args ) runs. Every key you pass explicitly replaces the manifest value outright.
There is no deep merge. Register a manifest that declares
"supports": { "anchor": true, "align": true } and pass
'supports' => array( 'align' => false ), and the registered block has
exactly one support entry. The anchor support is gone, and so is the anchor
attribute that block support would have added. The same applies to
attributes and to render: a render_callback passed
in the arguments overwrites the closure built from the manifest's render key,
and the file is never included.
Script and style fields behave differently depending on where they are declared
For the six asset fields, the argument array and the manifest are not interchangeable. The loop that turns a field into registered handles reads the merged settings and writes its result into the metadata array it is iterating over:
foreach ( $script_fields as $metadata_field_name => $settings_field_name ) {
if ( ! empty( $settings[ $metadata_field_name ] ) ) {
$metadata[ $metadata_field_name ] = $settings[ $metadata_field_name ];
}
// ...register each entry and replace $settings[$settings_field_name]
}
So a field that exists in the manifest is always processed, whether the value came from
the manifest or from your arguments. A field that does not exist in the manifest is never
processed, and whatever you passed passes through untouched. Register a manifest with no
style key and pass 'style' => 'wp-components' and you get
exactly that handle. Pass 'style' => 'file:./style.css' to the same manifest
and the block's style handle is the literal string file:./style.css, which
nothing resolves and nothing loads.
A path resolves to a handle name built from the block name and the field:
mytheme/callout plus editorScript becomes
mytheme-callout-editor-script. A style.asset.php next to the file
supplies dependencies and version; otherwise the dependencies are
empty and the version comes from the manifest's version key. A handle that is
already registered is returned as-is, which is how two blocks can share one editor script.
apiVersion 3 is what buys you a working editor context
WordPress 6.3 introduced the iframed post editor and made it conditional: it is used when every block in the post content reports API version 3 or higher, and skipped otherwise. WordPress 7.0 changed the input to that decision from every registered block to the blocks actually present in the content, so an old block that is registered but never used no longer drags the whole editor out of the iframe.
That condition is scheduled to disappear. The Gutenberg documentation states that 23.6
and WordPress 7.1 always iframe the post editor regardless of what any block reports, so
version 2 or lower stops being a fallback and becomes a bug that only shows up in the
iframe. Set apiVersion to 3 and test the block inside a mobile or tablet
preview, which iframes in 7.0 without waiting for 7.1.
What the iframe gives the block is an independent document. Admin CSS does not reach the
content, so theme and block styles do not need a prefix to survive the admin stylesheet, and
viewport units become correct because vw is relative to the editor canvas
rather than the admin page. The cost is that document and window
inside the content are no longer the ones your script was loaded in, so code that reaches
for document.querySelector() to find block markup has to go through a ref and
its ownerDocument.
A block with no JavaScript is a legitimate shape in 7.0
A block registered from PHP with a render_callback and the
supports.autoRegister flag set does not need a JavaScript registration at all.
The server collects those block names and hands them to the editor in
window.__unstableAutoRegisterBlocks, and the editor registers them with a
ServerSideRender preview. Attribute definitions in the manifest can also drive
generated inspector controls, which is what the 7.0 auto-register.php support
file exists for.
This is a real reduction in shipped code for blocks whose editing experience is "fill in three fields and see the server output". It is the wrong shape for a block that needs rich-text editing in place, because the editor output is the server's HTML and there is nothing to type into. Not every block should become a form in the sidebar.
In a plugin, create blocks/callout/block.json with
apiVersion, a namespaced name, a title, a
category, an editorScript pointing at a local
index.js through a file: path, a version, a
textdomain, and "supports" containing
anchor and align.
Write a wrapper around register_block_type() that registers the
manifest twice under two names, once with no second argument and once with a second
argument that sets title, category and supports.
Then dump for both: the title, the supports array, the attribute key list, and the
registered script handles.
Answer these before you tidy up. Which of the two blocks has an
anchor attribute, and in what order did the two registrations run? What is
the handle name the editorScript path produced, and what determines it?
What does the block type report for api_version if you delete the key from
the manifest and register again?
Show the solution and the reasoning
The wrapper registers one manifest twice by renaming it in the
block_type_metadata filter and removing the filter again, so the second
registration is the only one affected:
function acme_register_callout( $suffix = '' ) {
$rename = function ( $metadata ) use ( $suffix ) {
if ( '' !== $suffix && 'acme/callout' === ( $metadata['name'] ?? '' ) ) {
$metadata['name'] = 'acme/callout' . $suffix;
}
return $metadata;
};
add_filter( 'block_type_metadata', $rename );
$type = register_block_type( __DIR__ . '/blocks/callout' );
remove_filter( 'block_type_metadata', $rename );
return $type;
}
The filter has to run before the mapping loop, which
block_type_metadata does, and the name in the settings has to survive to the
end, which it does because the registry call uses the metadata name when the settings
array has none. Both registrations return a WP_Block_Type, so
$one->name and $two->name are the fastest way to confirm
the rename worked before you look at anything else.
The block registered without arguments has anchor and align
in its supports and therefore gets an anchor attribute added at
init priority 22, along with className and style.
The block registered with a supports argument has whatever that array
contains and nothing from the manifest, because the merge replaces the value instead of
joining it. If the argument array only set align, then
anchor is absent from the attribute list as well, and nothing warns about the
missing support.
The handle comes from generate_block_asset_handle(), which replaces the
slash in the block name with a dash and appends the field name in kebab case. For
acme/callout and editorScript that is
acme-callout-editor-script. Change the block name and the handle changes,
which is the mechanism behind the classic bug of a stale cached script: the browser keys
on the URL, the URL is derived from the name, and the version argument is what tells it
the file changed.
Deleting apiVersion leaves api_version at its default of
1, because a manifest that does not set it produces a
WP_Block_Type that never had the property assigned. That default is the
reason a working copy-paste can still be wrong. A block at API version 1 is a block the
editor treats as pre-iframe, and in 7.0 a single one of them in the post content is enough
to move the whole editor out of the iframe for that post.
The honest limit of the whole manifest system: the server reads it but does not audit
it. Unknown keys, wrong types, a supports value that is not a real support,
and a path in an asset field missing its file: prefix all pass through
without complaint. The schema catches those in your editor or in a build step, and
nothing catches them at runtime.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.