Skip to content
System nominalUTC --:--:--Articles 085
MODULE 03 Lesson 09 / 18 35 MIN

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.

Lab 30 minutes

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.