Building a theme by prompt
Reading Generated CSS Before It Ships
Why a green wp-scripts build proves nothing about WordPress, and how to read the emitted bundle, the asset manifest and the theme presets it will actually render.
The prompt was fine. The build was green. The plugin activates. Then an editor opens a post, types a slash, and the block is not in the inserter, and the browser console has a stack trace that names a function nobody wrote.
This is the failure that costs the most time, because every gate an agent has access
to says the code is correct. wp-scripts build exits 0.
wp-scripts lint-js exits 0. php -l says no syntax errors. None
of those three tools has ever loaded WordPress, and none of them knows whether the
WordPress function in the bundle exists.
So the skill this lesson teaches is reading the output rather than trusting the exit code.
The build manifest is the real contract, not the source
Build the block from the plugin lesson and look at what lands in
build/notice/:
build/notice/
├── block.json copied straight from src, with file: paths rewritten
├── index.asset.php the dependency manifest, generated
├── index.css editor styles, from editor.scss
├── index-rtl.css the same, right-to-left
├── index.js the editor bundle
├── style-index.css front-end styles, from style.scss
├── style-index-rtl.css
├── view.asset.php
└── view.js the front-end script
Four of those are decisions the build made for you, and the most important is the
smallest file. index.asset.php is a PHP array that
register_block_type() reads to decide which WordPress scripts must be
loaded before the block's script runs. For the clean build in this lab it contained:
<?php return array(
'dependencies' => array(
'react-jsx-runtime',
'wp-block-editor',
'wp-blocks',
'wp-components',
'wp-i18n'
),
'version' => '6f4fe7fa83f679fff3f0'
);
That list is the honest answer to "what does this block need from WordPress". Read
it before you read index.js. If a handle in that list is not registered in
the WordPress you are deploying to, the block will not work, and no build step will tell
you.
@wordpress packages leave the bundle and become globals
The reason the source looks reasonable and the output is fragile is externalisation.
wp-scripts configures webpack so that anything imported from
@wordpress/* is not bundled. It is replaced by a reference to a global.
The built index.js from this lab contains, literally:
window.wp.blocks
window.wp.blockEditor
window.wp.components
window.ReactJSXRuntime
There is no copy of the block editor in the file. The source said
import { RichText } from '@wordpress/block-editor' and the artefact says
window.wp.blockEditor.RichText. WordPress is expected to have put that
global there, and the only thing that makes it do so is the dependency list in
index.asset.php.
You can read the rule that does this in the installed package, which is worth doing once because it removes the mystery:
// node_modules/@wordpress/dependency-extraction-webpack-plugin/lib/util.js
const WORDPRESS_NAMESPACE = '@wordpress/';
function defaultRequestToExternal( request ) {
if ( request.startsWith( WORDPRESS_NAMESPACE ) ) {
return [ 'wp', camelCaseDash( request.substring( WORDPRESS_NAMESPACE.length ) ) ];
}
}
Any module whose name starts with @wordpress/ becomes a
wp.* global, and the transformed name is added to the dependency array. A
short list of packages is excluded from that treatment and bundled instead, which is why
@wordpress/icons disappeared into the bundle and added
wp-primitives to the manifest when I tested it. The list is in the
BUNDLED_PACKAGES constant in the same file.
What the plugin never does
It never asks WordPress what exists. There is no check against the installed core, no lookup in the script registry, and no warning. The transformation is a string operation on the module name, and its output is a promise that the runtime will provide what it named.
A fabricated import built green, and linted green too
Here is the demonstration. I added one import to the block's entry file:
import { registerAcmeInserterCategory } from '@wordpress/inserter-categories';
registerAcmeInserterCategory( 'acme' );
That package does not exist in any WordPress release. Nothing in this lab's
WordPress 7.0.7 install registers a wp-inserter-categories handle, and no
such directory exists in the npm registry either. The build:
webpack 5.112.0 compiled successfully in 514 ms
$ echo $?
0
And the manifest, with the invented handle in it:
<?php return array(
'dependencies' => array(
'react-jsx-runtime',
'wp-block-editor',
'wp-blocks',
'wp-components',
'wp-i18n',
'wp-inserter-categories'
),
'version' => 'c7c580c8c4e2520fa038'
);
And the bundle, with the global reference the browser will try to call:
window.wp.inserterCategories
The build does not fail because it never needed to resolve the module. The
externalisation rule matched the name and handed it to WordPress. Another writer ran the
same experiment with a fabricated named export from a package that does exist, and
wp-scripts lint-js on ESLint 9.39.5 exited 0 as well, emitting
n.DefinitelyNotARealExport into the bundle. No linter in this toolchain
knows what WordPress exports.
What WordPress does with the unknown dependency is the last piece, and it is not a crash. Querying the script registry for the block's editor handle shows the dependency declared and nothing behind it:
$ wp eval '
$r = WP_Block_Type_Registry::get_instance()->get_registered( "acme-notice/notice" );
$h = $r->editor_script_handles[0];
$o = wp_scripts()->query( $h );
echo $h, "\n", implode( ", ", $o->deps ), "\n";
foreach ( $o->deps as $d ) {
echo " ", $d, " registered: ", ( wp_scripts()->query( $d ) ? "yes" : "NO" ), "\n";
}' 2>&1 | grep -v Deprecated
acme-notice-notice-editor-script
react-jsx-runtime, wp-block-editor, wp-blocks, wp-components, wp-i18n, wp-inserter-categories
react-jsx-runtime registered: yes
wp-block-editor registered: yes
wp-blocks registered: yes
wp-components registered: yes
wp-i18n registered: yes
wp-inserter-categories registered: NO
The script still enqueues. WordPress does not refuse to load a script because one of its dependencies is missing; it just never provides the missing piece. The result is a block that loads, runs one line, and throws.
That last command is the check worth adopting. It is cheap, it needs no browser, and it fails on exactly the class of mistake a build cannot see. A reader who runs it before opening the editor catches invented packages and renames in the same step.
Generated CSS hides the same kind of silence
The CSS half of a block build is less dangerous but equally worth reading, because the mapping from source to output is not one-to-one. The block's two stylesheets compiled into two different files, and the smaller one carries a rule nobody wrote:
/* build/notice/style-index.css - from style.scss, loaded on the front end and in the editor */
.wp-block-acme-notice-notice{border-left:4px solid rgba(0,0,0,.25);border-radius:4px;
padding:1em 2.5em 1em 1.25em;position:relative}
.wp-block-acme-notice-notice .acme-notice__message{margin:0}
.wp-block-acme-notice-notice .acme-notice__dismiss{background:transparent;border:0;
color:inherit;cursor:pointer;font-size:1.25em;line-height:1;padding:.25em;
position:absolute;right:.5em;top:.25em}
/* build/notice/index.css - from editor.scss, loaded in the editor only */
.wp-block-acme-notice-notice .acme-notice__message{margin:0}
.wp-block-acme-notice-notice .acme-notice__dismiss{display:none}
Both files contain the margin: 0 rule, because it appears in both
sources, and both got a -rtl.css sibling that nobody asked for. The editor
copy hides the dismiss button, which is what the block author intended: in the editor you
want to see the notice, not dismiss it. You would not know that from reading
block.json, which only says which file each field points at.
Two generated properties are worth knowing generally. The
-rtl.css files are produced for every stylesheet so that right-to-left
locales do not need a second build, and the class name in both selectors is derived from
the block name acme-notice/notice with the slash replaced by a hyphen. If a
block is registered under a name whose slug does not match the stylesheet's selectors,
the CSS loads and applies to nothing, and the build stays green.
A theme preset can lose to a core preset and say nothing
The same silence exists one layer up, in theme.json, and this one is
nastier because a theme that looks wrong is usually blamed on the CSS. I added a swatch
to the block theme from the previous lesson with a slug that WordPress already uses:
"palette": [
{ "slug": "ink", "color": "#1a1a1a", "name": "Ink" },
{ "slug": "vivid-red", "color": "#123456", "name": "My red" }
]
The theme renders #cf2e2e, which is core's vivid-red, not
the colour in the file. Reading the resolved settings shows why the theme's own value is
invisible:
$ wp eval '
$p = wp_get_global_settings( array("color","palette") );
foreach ( array_keys($p) as $o ) {
foreach ( $p[$o] as $x ) { if ( "vivid-red" === $x["slug"] ) echo $o, " => ", $x["color"], "\n"; }
}' 2>&1 | grep -v Deprecated
default => #cf2e2e
One entry, from the default origin. Mine is gone. The mechanism is in
wp-includes/class-wp-theme-json.php: the presets metadata records a
prevent_override path for each preset type, and for colours that path is
settings.color.defaultPalette. When the theme does not disable the core
palette, a theme preset whose slug matches a core preset is dropped by
filter_slugs() while the theme is merged into core. Setting
settings.color.defaultPalette to false flips that, and the
theme's vivid-red reaches the front end as #123456. I ran both
and read the generated inline CSS each time, so this is a comparison and not a reading of
the source.
Font sizes behave the same way with settings.typography.defaultFontSizes
as the switch, and the core slugs to avoid are the obvious ones:
small, medium, large, x-large. A
theme that defines its own small is declaring a size of 13px, whether it
meant to or not.
The practical rule for reviewing agent-written theme.json is to list the
slugs you are introducing and compare them against what core already ships. On a clean
WordPress 7.0.7 the core colour slugs are black,
cyan-bluish-gray, white, pale-pink,
vivid-red, luminous-vivid-orange,
luminous-vivid-amber, light-green-cyan,
vivid-green-cyan, pale-cyan-blue,
vivid-cyan-blue and vivid-purple. Prefixing your own slugs with
the theme or project name, or disabling the defaults deliberately, avoids the whole
class of problem. Doing neither is how a theme ends up with a brand colour that only
appears on the developer's machine.
Everything in this lesson came from a command line: the build ran, WP-CLI read the
registry and the resolved settings, and curl fetched the rendered page. I did
not open the editor, so I have not seen the broken block throw. What I can tell you is
that the bundle contains a call to window.wp.inserterCategories, that the
handle it depends on is not registered, and that a browser running that file cannot
succeed. The console message is the part I am inferring.
Take a block you have already built and audit its generated output before you change a line of source.
Print the manifest, list the WordPress globals in the bundle, and check every declared dependency against the script registry:
cat build/notice/index.asset.php
grep -oE "window\.wp\.[A-Za-z]+" build/notice/index.js | sort -u
wp eval '$r = WP_Block_Type_Registry::get_instance()->get_registered( "acme-notice/notice" );
$o = wp_scripts()->query( $r->editor_script_handles[0] );
foreach ( $o->deps as $d ) {
printf( "%-28s %s\n", $d, wp_scripts()->query( $d ) ? "registered" : "MISSING" );
}' 2>&1 | grep -v Deprecated
Then do two things. First, add an import from a
@wordpress/ package you know is not in this WordPress, rebuild, and
record what the build said, what the manifest gained, and what the registry check
reported. Second, add a colour preset with the slug vivid-red to your
theme's theme.json, read the resolved palette back, and then set
settings.color.defaultPalette to false and read it again.
Two experiments, four outputs.
Show the solution and the reasoning
The first experiment reproduces the build-green-and-broken result, and the exact package name does not matter as long as it is not one of the bundled ones. What matters is the shape of the three outputs, because that shape is the lesson:
$ npx wp-scripts build 2>&1 | tail -1
webpack 5.112.0 compiled successfully in 514 ms
$ grep -oE "window\.wp\.[A-Za-z]+" build/notice/index.js | sort -u
window.wp.blockEditor
window.wp.blocks
window.wp.components
window.wp.i
window.wp.inserterCategories
$ cat build/notice/index.asset.php | grep inserter
'wp-inserter-categories'
The bundle gained one global, the manifest gained one handle, and the exit code
stayed 0. The registry check then reports MISSING for that handle and
registered for the four real ones.
The second experiment produces a two-line comparison that is easy to remember:
# theme.json does not mention defaultPalette
$ wp eval '$p = wp_get_global_settings( array("color","palette") );
foreach ( $p as $o => $items ) {
foreach ( $items as $x ) { if ( "vivid-red" === $x["slug"] ) echo $o, " ", $x["color"], "\n"; } }' \
2>&1 | grep -v Deprecated
default #cf2e2e
# theme.json sets settings.color.defaultPalette = false
$ wp eval '... same again ...' 2>&1 | grep -v Deprecated
default #cf2e2e
theme #123456
With the default enabled, only core's entry survives, so the swatch in the editor is
core's red. With the default disabled, both entries survive and the theme's colour is
the one rendered. The reasoning behind the whole lesson is in those two results: both
theme.json files are valid, both themes activate, and theme.json contains a
colour that the site does not use. Nothing short of reading the resolved settings
distinguishes them.
The habit worth taking away is not the specific fix for either problem. It is that every claim about generated output has a read-back, and the read-back is a command whose output would differ if the claim were false. "The build succeeded" is not a read-back, because it is true of broken code. "The manifest lists five handles and all five are registered" is.
Official reference
The WordPress developer reference for what this lesson uses.
Progress
Mark this lesson done and it stays marked in this browser.