Moving WordPress Block Theme Inline Styles to a Tag
WordPress block themes are notorious for dumping large amounts of CSS directly into the <head> as inline <style> tags. This writeup covers the problem in depth, the technique used to eliminate nearly all of it, the code required, and the implications of doing so.
A default WordPress block theme outputs up to 30+ inline <style> tags in the <head>, totalling ~43,000+ characters of inline CSS. These come from several distinct sources:
| Handle | Source | Size |
|---|---|---|
wp-block-navigation-inline-css |
wp-includes/blocks/navigation/style.min.css |
~17 KB |
global-styles-inline-css |
Generated from theme.json + WP layout rules |
~6–8 KB |
wp-block-library-inline-css |
wp-includes/css/dist/block-library/common.min.css |
~3.5 KB |
wp-block-heading-inline-css |
wp-includes/blocks/heading/style.min.css |
~1.2 KB |
wp-block-table-inline-css |
wp-includes/blocks/table/style.min.css |
~4 KB |
wp-block-media-text-inline-css |
wp-includes/blocks/media-text/style.min.css |
~2.5 KB |
wp-block-comments-inline-css |
wp-includes/blocks/comments/style.min.css |
~3 KB |
wp-block-post-comments-form-inline-css |
wp-includes/blocks/post-comments-form/style.min.css |
~2 KB |
wp-block-buttons-inline-css |
wp-includes/blocks/buttons/style.min.css |
~1.5 KB |
wp-block-button-inline-css |
wp-includes/blocks/button/style.min.css |
~2 KB |
wp-block-search-inline-css |
wp-includes/blocks/search/style.min.css |
~2 KB |
wp-block-gallery-inline-css |
wp-includes/blocks/gallery/style.min.css |
~16 KB |
wp-block-image-inline-css |
wp-includes/blocks/image/style.min.css |
~9 KB |
wp-block-embed-inline-css |
wp-includes/blocks/embed/style.min.css |
~1 KB |
wp-img-auto-sizes-contain-inline-css |
wp-includes/media.php (inline string, not a .css file) |
~0.1 KB |
| … and more block handles | ||
core-block-supports-inline-css |
Per-request hashed layout classes | ~1 KB |
Note:
wp-block-gallery,wp-block-image, andwp-block-embedare among the largest and easiest to miss — they only show up on pages/posts that actually contain a Gallery, Image, or Embed block, so a spot-check of a text-only page won't catch them. Audit at least one post containing each block type.
Block themes use template pre-rendering to collect styles. Before outputting the <head>, WordPress renders the entire block template to discover which blocks are present, then dumps the CSS for each block as an inline <style> tag. This avoids loading CSS for blocks that aren't on the page — but the cost is that all those styles are inline rather than in a cacheable linked file.
Critically, clearing an inline blob does NOT cause WordPress to fall back to a linked file. When you clear a handle's inline data, that CSS simply disappears. There is no automatic <link> fallback for block style handles in WordPress 6.x/7.x block themes.
Not all inline CSS output goes through $wp_styles. Some is produced by standalone functions hooked directly to wp_head that echo CSS without touching the stylesheet registration system at all. wp_print_font_faces (priority 50) is the main example — it outputs @font-face rules from theme.json's fontFace declarations. Clearing ->extra has no effect on these; they require remove_action to suppress.
The approach: port the CSS for every block handle into the theme's style.css, then clear those handles' inline blobs so WordPress no longer outputs them. The theme's single linked stylesheet becomes the sole delivery mechanism.
Inline blobs are populated during template pre-rendering, which happens before wp_head fires. The correct intercept point is wp_head at priority 1 — after pre-rendering has populated the blobs but before wp_print_styles outputs them.
function editorial_convert_block_css_to_links() {
global $wp_styles;
// Clearing a handle's inline blob removes it entirely.
// WordPress does NOT fall back to a linked file.
// All handles listed here have their CSS ported into style.css.
$clear = array(
'wp-block-navigation',
'wp-block-site-title',
'wp-block-page-list',
'wp-block-group',
'wp-block-paragraph',
'wp-block-post-title',
'wp-block-post-excerpt',
'wp-block-post-date',
'wp-block-read-more',
'wp-block-post-template',
'wp-block-columns',
'wp-block-post-terms',
'wp-block-navigation-link',
'wp-block-heading',
'wp-block-list',
'wp-block-quote',
'wp-block-pullquote',
'wp-block-preformatted',
'wp-block-verse',
'wp-block-table',
'wp-block-media-text',
'wp-block-template-skip-link',
// Added in WP 7.0 — comment blocks and form-related blocks
'wp-block-post-content',
'wp-block-avatar',
'wp-block-comment-author-name',
'wp-block-comment-date',
'wp-block-comment-content',
'wp-block-comment-reply-link',
'wp-block-comment-template',
'wp-block-comments-pagination',
'wp-block-comments',
'wp-block-post-comments-form',
'wp-block-buttons',
'wp-block-button',
// Added in WP 7.0 — archive/search/404 templates
'wp-block-query-title',
'wp-block-query-pagination',
'wp-block-search',
// Easy to miss — only appear on pages that actually contain the block,
// so they won't show up in a spot-check of a text-only template.
'wp-block-image',
'wp-block-embed',
'wp-block-gallery',
// Not a block handle — a fixed, non-dynamic CSS fix registered by
// wp_enqueue_img_auto_sizes_contain_css_fix() in wp-includes/media.php.
// Despite the name, it is NOT in the same category as
// core-block-supports (see "The One Unavoidable Inline Tag" below) —
// it uses the same wp_register_style()/wp_add_inline_style()/->extra
// mechanism as every other handle in this list and can be cleared
// the same way.
'wp-img-auto-sizes-contain',
);
foreach ( $clear as $handle ) {
if ( isset( $wp_styles->registered[ $handle ] ) ) {
$wp_styles->registered[ $handle ]->extra = array();
}
}
// wp-block-library: clear admin colour custom properties and legacy
// colour utility classes added as inline CSS.
if ( isset( $wp_styles->registered['wp-block-library'] ) ) {
$wp_styles->registered['wp-block-library']->extra = array();
}
// wp_print_font_faces bypasses $wp_styles entirely — it's a standalone wp_head
// action (priority 50) that directly echoes @font-face rules from theme.json
// fontFace declarations. Must be removed via remove_action, not ->extra.
// Placed here so all inline CSS suppression lives in one function.
// theme.json fontFace declarations still register fonts with the block editor.
remove_action( 'wp_head', 'wp_print_font_faces', 50 );
}
// Priority 1: runs after pre-rendering populates blobs, before wp_print_styles outputs them.
add_action( 'wp_head', 'editorial_convert_block_css_to_links', 1 );The global-styles handle delivers CSS generated at runtime from theme.json. It has no static file equivalent, so it must be dequeued entirely.
// Dequeue global-styles (theme.json → inline CSS).
// We replace it in style.css with static equivalents (Step 4 below).
add_action( 'wp_enqueue_scripts', function() {
wp_dequeue_style( 'global-styles' );
}, 100 );
remove_action( 'wp_footer', 'wp_enqueue_global_styles', 1 );For every handle in the $clear list, copy the verbatim contents of the corresponding file from wp-includes/ into the theme stylesheet. For example:
wp-includes/blocks/navigation/style.min.css→ themestyle.css§ Navigation blockwp-includes/blocks/site-title/style.min.css→ themestyle.css§ Site Title blockwp-includes/css/dist/block-library/common.min.css→ themestyle.css§ Block Library common
This must be verbatim — do not modify the CSS. WordPress block behaviour depends on these exact selectors and values.
Exception: wp-block-image and core's native lightbox. wp-includes/blocks/image/style.min.css bundles two unrelated things: the base image/figure/caption rules, and the full .wp-lightbox-container / .wp-lightbox-overlay implementation (click-to-zoom, plus the lightbox-zoom-in/lightbox-zoom-out keyframes) that backs core's native Image block "Lightbox" setting. If your theme ships its own lightbox (a custom JS modal, a plugin, etc.) and doesn't use core's, it is safe — and preferable — to port only the non-lightbox rules and drop the .wp-lightbox-* block entirely. Keep the show-content-image keyframe though; it's the image-load hide/show transition and is unrelated to the lightbox feature despite living in the same file. If your theme does rely on core's native lightbox, port the whole file verbatim instead.
/* === WordPress Block Base Styles ===
Source: wp-includes/blocks/{block}/style.min.css
These were previously output as inline <style> tags.
================================================= */
/* --- Site Title block --- */
.wp-block-site-title { box-sizing: border-box; }
.wp-block-site-title :where(a) { color: inherit; font-family: inherit; … }
/* --- Navigation block (verbatim from wp-includes/blocks/navigation/style.min.css) --- */
.wp-block-navigation { position: relative; }
/* … full contents of style.min.css … */
/* --- Block Library common (screen-reader-text, image sizing, alignment utils) ---
Source: wp-includes/css/dist/block-library/common.min.css */
.screen-reader-text { border: 0; clip-path: inset(50%); height: 1px; … }
/* … */This is the most subtle step and the most common source of regressions.
global-styles does two distinct jobs:
- Theme preset variables — CSS custom properties from
theme.json(--wp--preset--font-family--lora,--wp--preset--font-size--medium,--wp--preset--color--cream, etc.) - WordPress layout support CSS — the rules that make
is-layout-flex,is-layout-grid,is-layout-constrained, andis-layout-flowactually work
If you dequeue global-styles and only port the preset variables, every block that uses flex or grid layout will silently collapse to vertical stacking because the critical display: flex rule is missing.
The full global-styles layout CSS to port into style.css:
/* === WordPress Preset Variables & Layout Support CSS ===
Replaces the global-styles inline stylesheet.
====================================================== */
:root {
/* Layout */
--wp--style--global--content-size: 800px;
--wp--style--global--wide-size: 1200px;
--wp--style--block-gap: 24px;
/* Font families (from theme.json) */
--wp--preset--font-family--lora: 'Lora', Georgia, serif;
--wp--preset--font-family--inter: 'Inter', system-ui, -apple-system, sans-serif;
/* Font sizes (from theme.json) */
--wp--preset--font-size--small: 0.875rem;
--wp--preset--font-size--normal: 1rem;
--wp--preset--font-size--medium: 1.0625rem;
--wp--preset--font-size--large: 1.25rem;
--wp--preset--font-size--x-large: 1.75rem;
--wp--preset--font-size--xx-large: clamp(2rem, 4vw, 3rem);
--wp--preset--font-size--display: clamp(2.25rem, 4.5vw, 3.5rem);
/* Colors (from theme.json palette) */
--wp--preset--color--cream: #faf9f7;
/* … all palette entries … */
/* Spacing (from theme.json spacingSizes) */
--wp--preset--spacing--xs: 0.5rem;
/* … */
}
/* Site-level block alignment */
:where(body) { margin: 0; }
.wp-site-blocks > .alignleft { float: left; margin-right: 2em; }
.wp-site-blocks > .alignright { float: right; margin-left: 2em; }
.wp-site-blocks > .aligncenter { justify-content: center; margin-left: auto; margin-right: auto; }
:where(.wp-site-blocks) > * { margin-block-start: 24px; margin-block-end: 0; }
:where(.wp-site-blocks) > :first-child { margin-block-start: 0; }
:where(.wp-site-blocks) > :last-child { margin-block-end: 0; }
/* Flow layout — vertical rhythm */
:root :where(.is-layout-flow) > :first-child { margin-block-start: 0; }
:root :where(.is-layout-flow) > :last-child { margin-block-end: 0; }
:root :where(.is-layout-flow) > * { margin-block-start: 24px; margin-block-end: 0; }
/* Constrained layout — max-width content column */
:root :where(.is-layout-constrained) > :first-child { margin-block-start: 0; }
:root :where(.is-layout-constrained) > :last-child { margin-block-end: 0; }
:root :where(.is-layout-constrained) > * { margin-block-start: 24px; margin-block-end: 0; }
.is-layout-constrained > :where(:not(.alignleft):not(.alignright):not(.alignfull)) {
max-width: var(--wp--style--global--content-size);
margin-left: auto !important;
margin-right: auto !important;
}
.is-layout-constrained > .alignwide { max-width: var(--wp--style--global--wide-size); }
/* Flex/grid gap defaults */
:root :where(.is-layout-flex) { gap: 24px; }
:root :where(.is-layout-grid) { gap: 24px; }
/* CRITICAL: Flex and grid display — without these all flex/grid blocks stack */
body .is-layout-flex { display: flex; }
.is-layout-flex { flex-wrap: wrap; align-items: center; }
.is-layout-flex > :is(*, div) { margin: 0; }
body .is-layout-grid { display: grid; }
.is-layout-grid > :is(*, div) { margin: 0; }Erratum: an earlier version of this writeup listed two "unavoidable" tags —
core-block-supports-inline-cssandwp-img-auto-sizes-contain-inline-css. That was wrong about the second one. Onlycore-block-supports-inline-cssis actually unavoidable;wp-img-auto-sizes-contain-inline-csswas simply never added to the$cleararray, not technically unportable. See the correction below.
After applying the above technique, one inline <style> tag remains and genuinely cannot be eliminated:
| ID | Why it cannot be removed |
|---|---|
core-block-supports-inline-css |
Contains per-request hashed layout class rules like .wp-container-content-c0dd7891 { flex-wrap: nowrap; justify-content: space-between; }. These hashes are generated at render time and have no static file equivalent. |
The technical root cause, traced through core source: wp_enqueue_stored_styles() (wp-includes/script-loader.php) builds the core-block-supports handle by pulling from a WP_Style_Engine_CSS_Rules_Store context named block-supports. Several block-supports files — wp-includes/block-supports/layout.php, position.php, elements.php, block-visibility.php — push CSS rules into that store during block rendering (on render_block), not from a static file.
The part that makes it truly unportable: in layout.php, the selector itself is generated by wp_unique_id_from_values(), producing classes like wp-container-content-{hash} where the hash is derived from that specific block instance's live attribute values on that specific render. The CSS doesn't exist until the page renders, and its class names differ per block instance and per page load — there is no fixed file to copy into style.css, because the selector you'd need to target is itself computed from runtime data.
This one turned out to be portable after all. Source, wp-includes/media.php:
$handle = 'wp-img-auto-sizes-contain';
wp_register_style( $handle, false );
wp_add_inline_style( $handle, 'img:is([sizes=auto i],[sizes^="auto," i]){contain-intrinsic-size:3000px 1500px}' );That's one fixed, static CSS rule — not per-request generated — registered via the exact same wp_register_style() + wp_add_inline_style() mechanism as every block handle already in the $clear array. It's hooked at wp_head priority 0 (wp-includes/default-filters.php), which runs before the theme's priority-1 suppression function, so by the time that function runs, ->extra is already populated and clearable — same pattern as everything else. It was originally left alone because it's tiny (~133 bytes) and got lumped in with core-block-supports without checking the source. It should have been in the $clear array from the start.
Result: 30+ inline <style> tags (~43,655+ chars) reduced to 1 inline tag (~700 chars) — a ~99% reduction. All remaining styles load via the single theme <link> stylesheet, which is cacheable and CDN-friendly.
- The theme's
style.cssgrows (it now contains all the block CSS), but it is a single HTTP request that browsers and CDNs cache aggressively. - Inline CSS cannot be cached by the browser — every page load re-delivers it.
- The
<head>is significantly smaller, reducing HTML document size on every page.
Block CSS is now loaded before core-block-supports-inline-css in the cascade (linked <link> comes before the inline <style> in document order). The per-container hashed rules (e.g. flex-wrap: nowrap) still override the generic layout rules correctly because they have equal specificity and appear later.
This is the most important implication. When you update WordPress core:
- Any changes to block CSS in
wp-includes/blocks/*/style.min.csswill not be reflected in your theme until you manually re-port them. - Any new CSS custom properties added to
global-styleswill be absent until you add them tostyle.css. - WordPress may begin generating inline CSS for new block handles that weren't present in the version you first set this up on. These will slip through until you detect and add them.
Mitigation: run a diff against the relevant wp-includes files after each WordPress core update, and audit for new inline tags in the page source.
diff wp-includes/blocks/navigation/style.min.css \
wp-content/themes/your-theme/style.cssWordPress has a should_load_separate_core_block_assets filter that sounds like it would convert inline block CSS to linked files. In WordPress 6.x/7.x block themes it does not — block themes pre-render the template and always inline the result. This filter is relevant to classic themes, not block themes.
Attempting to clear inline blobs at wp_enqueue_scripts (priority 100) fails silently — the blobs haven't been populated yet at that point. The correct hook is wp_head at priority 1, which runs after pre-rendering has populated the blobs but before wp_print_styles outputs them to the page.
There are three distinct mechanisms for suppressing inline CSS output, and they are not interchangeable:
wp_dequeue_style('wp-block-navigation') removes the handle from the queue — including its linked <link> tag. That is not what we want.
Clearing $wp_styles->registered[$handle]->extra = array() removes only the inline blob from the registered handle, leaving the linked file intact. For the block handles targeted here, the linked files are empty or contain only editor styles, so the visual result is the same — but the mechanism matters.
remove_action( 'wp_head', 'wp_print_font_faces', 50 ) is required for output that bypasses $wp_styles entirely. wp_print_font_faces is a standalone function hooked to wp_head at priority 50 that directly echoes an inline <style> tag containing @font-face rules from theme.json's fontFace declarations. Clearing ->extra on any handle has no effect on it. Because editorial_convert_block_css_to_links already runs at priority 1, the remove_action call can live inside that function — making it the single location for all inline CSS suppression.
- Hook into
wp_headat priority 1 to clear inline blobs - Clear
extraon each block handle (do NOT dequeue) - Dequeue
global-stylesviawp_enqueue_scriptspriority 100 +remove_actiononwp_footer - Port verbatim block CSS from
wp-includes/blocks/*/style.min.cssintostyle.css - Port
wp-includes/css/dist/block-library/common.min.cssintostyle.css - Port
global-stylespreset variables and layout support CSS (includingbody .is-layout-flex { display: flex }) intostyle.css - If using
fontFaceintheme.json: suppresswp_print_font_facesviaremove_action( 'wp_head', 'wp_print_font_faces', 50 )inside the priority-1 function (it bypasses$wp_styles—->extraclearing has no effect on it) - Port
wp-block-image(excluding.wp-lightbox-*rules if the theme has its own lightbox),wp-block-gallery, andwp-block-embed— easy to miss since they only appear on pages containing those blocks - Add
wp-img-auto-sizes-containto the$cleararray and port its one-line rule — it is portable, not unavoidable - Accept that only
core-block-supports-inline-csscannot be removed (its selectors are content hashes generated at render time — see "The One Unavoidable Inline Tag") - After every WordPress update, check for new inline
<style>tags in page source and add new handles as needed - Establish a process to diff
wp-includesblock CSS after every WordPress core update
Every ported file is a verbatim copy of a file that lives in wp-includes. WordPress core updates those files. Without a process to detect drift, your theme silently serves stale block CSS until something looks wrong.
WordPress may begin generating inline CSS for block handles that didn't exist (or weren't present in your templates) in earlier versions. WP 7.0 is a concrete example: it started emitting 15 new handles that weren't present in 6.x — all comment-related blocks (wp-block-comments, wp-block-comment-template, wp-block-post-comments-form, etc.) plus wp-block-buttons, wp-block-button, wp-block-query-title, wp-block-query-pagination, and wp-block-search.
The checksum script below detects changes to existing ported files, but it won't alert you to new handles. After every WordPress update, spot-check the page source of each template type (homepage, single post, archive, search, 404, static page) for unexpected <style id="wp-block-*"> tags:
curl -s https://yoursite.com/ | grep -o '<style id="[^"]*"'
curl -s https://yoursite.com/some-post/ | grep -o '<style id="[^"]*"'
curl -s https://yoursite.com/category/something/ | grep -o '<style id="[^"]*"'Any handle other than core-block-supports-inline-css needs to be added to the $clear array and its CSS ported to style.css. Make sure at least one audited page/post actually contains an Image, Gallery, and Embed block — those handles (wp-block-image, wp-block-gallery, wp-block-embed) only render when the block is present, so a homepage-only spot-check will miss them.
Add check-block-css.sh to the theme root. It stores a SHA-256 checksum for each ported source file and compares them after every WordPress update.
#!/usr/bin/env bash
# check-block-css.sh — run after every WordPress core update
#
# Usage:
# ./check-block-css.sh # check for changes
# ./check-block-css.sh --update # regenerate checksums after updating style.css
set -euo pipefail
THEME_DIR="$(cd "$(dirname "$0")" && pwd)"
WP_ROOT="$(cd "$THEME_DIR/../../../" && pwd)"
CHECKSUMS_FILE="$THEME_DIR/.wp-ported-css-checksums"
SOURCES=(
"wp-includes/blocks/navigation/style.min.css"
"wp-includes/blocks/navigation-link/style.min.css"
"wp-includes/blocks/site-title/style.min.css"
"wp-includes/blocks/page-list/style.min.css"
"wp-includes/blocks/group/style.min.css"
"wp-includes/blocks/paragraph/style.min.css"
"wp-includes/blocks/post-title/style.min.css"
"wp-includes/blocks/post-excerpt/style.min.css"
"wp-includes/blocks/post-date/style.min.css"
"wp-includes/blocks/read-more/style.min.css"
"wp-includes/blocks/post-template/style.min.css"
"wp-includes/blocks/columns/style.min.css"
"wp-includes/blocks/post-terms/style.min.css"
"wp-includes/blocks/heading/style.min.css"
"wp-includes/blocks/list/style.min.css"
"wp-includes/blocks/quote/style.min.css"
"wp-includes/blocks/pullquote/style.min.css"
"wp-includes/blocks/preformatted/style.min.css"
"wp-includes/blocks/verse/style.min.css"
"wp-includes/blocks/table/style.min.css"
"wp-includes/blocks/media-text/style.min.css"
"wp-includes/css/dist/block-library/common.min.css"
"wp-includes/css/wp-block-template-skip-link.min.css"
# Added in WP 7.0
"wp-includes/blocks/post-content/style.min.css"
"wp-includes/blocks/avatar/style.min.css"
"wp-includes/blocks/comment-author-name/style.min.css"
"wp-includes/blocks/comment-date/style.min.css"
"wp-includes/blocks/comment-content/style.min.css"
"wp-includes/blocks/comment-reply-link/style.min.css"
"wp-includes/blocks/comment-template/style.min.css"
"wp-includes/blocks/comments-pagination/style.min.css"
"wp-includes/blocks/comments/style.min.css"
"wp-includes/blocks/post-comments-form/style.min.css"
"wp-includes/blocks/buttons/style.min.css"
"wp-includes/blocks/button/style.min.css"
"wp-includes/blocks/query-title/style.min.css"
"wp-includes/blocks/query-pagination/style.min.css"
"wp-includes/blocks/search/style.min.css"
# Easy to miss — only appear on pages containing the block
"wp-includes/blocks/image/style.min.css"
"wp-includes/blocks/embed/style.min.css"
"wp-includes/blocks/gallery/style.min.css"
)
# wp-img-auto-sizes-contain is NOT in SOURCES: its CSS is a one-line PHP string
# inside wp-includes/media.php (wp_enqueue_img_auto_sizes_contain_css_fix()),
# not a standalone .css file, so it isn't checksum-diffable the same way.
# It's a single fixed rule and low-risk; spot-check media.php after major
# WP releases if you're being thorough.
wp_version() {
grep "wp_version\s*=" "$WP_ROOT/wp-includes/version.php" \
| head -1 | grep -oE "'[0-9]+\.[0-9]+(\.[0-9]+)?'" | tr -d "'"
}
file_hash() { shasum -a 256 "$1" 2>/dev/null | cut -d' ' -f1; }
if [[ "${1:-}" == "--update" ]]; then
VERSION=$(wp_version)
{
echo "# WordPress ported CSS checksums"
echo "# Last updated: $(date -u '+%Y-%m-%d %H:%M UTC')"
echo "# WP version: $VERSION"
echo "#"
for src in "${SOURCES[@]}"; do
echo "$(file_hash "$WP_ROOT/$src") $src"
done
} > "$CHECKSUMS_FILE"
echo "✅ Checksums updated (WP $VERSION, ${#SOURCES[@]} files)"
exit 0
fi
if [[ ! -f "$CHECKSUMS_FILE" ]]; then
echo "No checksums file. Run '$0 --update' once to create it."; exit 1
fi
STORED_WP=$(grep '^# WP version:' "$CHECKSUMS_FILE" | sed 's/.*WP version: *//')
CURRENT_WP=$(wp_version)
echo "Stored WP: $STORED_WP | Current WP: $CURRENT_WP"
echo ""
CHANGED=0
while IFS= read -r line; do
[[ "$line" =~ ^# ]] && continue; [[ -z "$line" ]] && continue
stored_hash=$(echo "$line" | awk '{print $1}')
src=$(echo "$line" | awk '{print $2}')
full_path="$WP_ROOT/$src"
if [[ ! -f "$full_path" ]]; then
echo "❓ MISSING: $src"; CHANGED=$((CHANGED+1)); continue
fi
current_hash=$(file_hash "$full_path")
if [[ "$stored_hash" != "$current_hash" ]]; then
echo "⚠️ CHANGED: $src"; CHANGED=$((CHANGED+1))
else
echo "✅ OK: $src"
fi
done < "$CHECKSUMS_FILE"
echo ""
if [[ $CHANGED -eq 0 ]]; then
echo "All files in sync. ✅"
else
echo "$CHANGED file(s) changed. Update style.css, then run: $0 --update"
exit 1
fiCommit .wp-ported-css-checksums alongside the script — it's the baseline that makes the comparison possible.
Note on this snippet: the script above is trimmed to the core
--update/ default-check logic for readability. The full version I run in production also has two extra flags worth knowing about if you're recreating this setup from scratch:
--audit— dumps every$wp_styleshandle currently injecting inline CSS (size in bytes, sorted largest-first) plus a "known-safe" / "known-cleared" reference list, so you can spot new handles WordPress starts emitting after an update without manually diffing page source. It shells out viawp evalto introspect$wp_stylesat runtime — in a WordPress Studio project specifically, that command needs thestudioprefix (studio wp eval), not plainwp eval. Don't port thestudioprefix into a non-Studio project.--diff— a lighter add-on to the default check: for each CHANGED file it also prints the current vs. stored SHA-256 and the full path to review, rather than just the⚠️ CHANGED line. It doesn't do a real content diff (there's no stored copy of the old file to diff against — just a hash), so the name is a bit generous; it's really "show me where to look."Neither flag changes what gets ported or checksummed — both are pure convenience for the audit/update workflow. If asked to recreate this setup, ask whether the target is a WordPress Studio project (use
studio wp eval) or a standard WP-CLI setup (use plainwp eval) before adding--audit.
After running --update, the file looks like this:
# WordPress ported CSS checksums
# Last updated: 2026-06-10 14:19 UTC
# WP version: 7.0
#
65433802260eba9a7ba737a21be6f679ab25480a3c6cc4e7f109041360cfb52e wp-includes/blocks/navigation/style.min.css
eebbf4533110d92d4d5dbb60284b6476db0ac9689191885cad2c90ce2eba01e2 wp-includes/blocks/site-title/style.min.css
…
1. Update WordPress (Studio, WP-CLI, dashboard — however you normally do it)
2. Spot-check page source across all template types for new <style id="wp-block-*"> tags:
curl -s https://yoursite.com/ | grep -o '<style id="[^"]*"'
curl -s https://yoursite.com/some-post/ | grep -o '<style id="[^"]*"'
curl -s https://yoursite.com/category/something/ | grep -o '<style id="[^"]*"'
curl -s https://yoursite.com/some-post-with-an-image-or-gallery/ | grep -o '<style id="[^"]*"'
(Any result beyond core-block-supports-inline-css needs to be added.)
3. cd wp-content/themes/your-theme
bash check-block-css.sh
4. For each ⚠️ CHANGED file:
- Open the source: wp-includes/blocks/{block}/style.min.css
- Find the matching section in style.css (each has a "Source:" comment)
- Copy in whatever changed
5. For the global-styles layout section (not file-tracked, generated at runtime):
studio wp eval 'echo WP_Theme_JSON_Resolver::get_merged_data()
->get_stylesheet(["styles","base-layout-styles"]);'
Compare against the "WordPress Preset Variables & Layout Support CSS"
section in style.css. Only needs attention on major WP releases.
6. Regenerate checksums and commit:
bash check-block-css.sh --update
git add style.css .wp-ported-css-checksums
git commit -m "Sync ported block CSS with WP x.y.z"
Block CSS changes infrequently. WordPress uses a stability policy for block styles — patch releases (7.0.x) almost never touch style.min.css files; minor releases may add a rule or two; major releases are where more significant changes land. WP 7.0 is a clear example: it began generating inline CSS for 15 new block handles that weren't emitted in 6.x at all. In practice, running check-block-css.sh after a patch update usually shows all ✅ and takes under five seconds. After a major version, budget a few minutes to audit both the checksum script and the page source.
Dequeuing global-styles does not make theme.json inert — but it splits its behavior in two.
The settings section drives the block editor UI independently of the global-styles pipeline. WordPress reads theme.json settings directly to populate:
- Color palette choices (and
defaultPalette: falseto suppress WP defaults) - Font size, font family, and spacing pickers
appearanceTools: true/ individual tool toggles- Layout
contentSizeandwideSizeconstraints in the editor
These work regardless of whether global-styles is enqueued.
The styles section — body color, typography, elements.heading, elements.link, elements.button, and any styles.blocks entries — produces no CSS output when global-styles is dequeued. WordPress generates CSS from styles solely through the global-styles pipeline. Remove that pipeline and those declarations never become rules on the frontend.
The equivalent CSS must be hand-written in style.css and kept in sync with the theme.json declarations manually.
This second removal is a separate cut. wp_enqueue_global_styles in the footer outputs user customizations stored in the database — changes made in Appearance → Editor → Styles. Without it, Site Editor customizations are silently ignored: they're saved to the database but never reach the frontend as CSS.
The styles block in theme.json becomes documentation of intent rather than functional CSS. It mirrors the hand-written rules in style.css and describes what the theme would do if the pipeline were active. If global-styles is ever re-enabled, the two sources would need to be reconciled to avoid duplicated or conflicting rules.