Skip to content

Instantly share code, notes, and snippets.

@grayayer
Last active March 21, 2026 22:02
Show Gist options
  • Select an option

  • Save grayayer/d9465a2e84ad4f04b22edb54107fffb1 to your computer and use it in GitHub Desktop.

Select an option

Save grayayer/d9465a2e84ad4f04b22edb54107fffb1 to your computer and use it in GitHub Desktop.
A WP-CLI script that removes `<strong>` and `<b>` tags that wrap either partial or the entire content of an `h1`–`h5` heading inside Gutenberg block editor posts.

fix-strong-headings.php

A WP-CLI script that removes <strong> and <b> tags from inside h1h5 headings in Gutenberg block editor posts.

The problem this solves

When editors accidentally apply bold formatting to heading text in the Gutenberg block editor, WordPress wraps it in a <strong> tag:

<!-- Full heading bolded -->
<h2 class="wp-block-heading"><strong>Why We Chose DataHub</strong></h2>

<!-- Partial bold inside a heading -->
<h3 class="wp-block-heading">1. <strong>Quality</strong></h3>

Headings are inherently bold by convention. The redundant <strong> tag adds unintended semantic weight and can interfere with screen readers and SEO. This script removes it cleanly without touching anything else in the post content.

Requirements

  • WP-CLI installed and accessible from the WordPress root directory
  • PHP 7.4+
  • The DOMDocument PHP extension (enabled by default on most hosts)

Configuration

Open the script and set the constants at the top before running:

// Set to true to preview changes without writing anything to the database.
define( 'DRY_RUN', false );

// The post type slug to target.
define( 'TARGET_POST_TYPE', 'dh-stories' );

// Set to true to also strip <strong> and <b> tags that wrap only part of a
// heading, not just those wrapping the entire heading text.
define( 'PARTIAL_STRONG', false );

DRY_RUN

When true, the script scans all posts and logs every heading it would change, but writes nothing to the database. Use this to review the scope of changes before committing.

TARGET_POST_TYPE

The post type slug to target. Only published posts of this type are processed. Change this to reuse the script against other post types.

PARTIAL_STRONG

When false (default), only headings where <strong> or <b> is the sole child wrapping all text are affected:

<!-- Fixed when PARTIAL_STRONG is false or true -->
<h2><strong>Full Heading</strong></h2><h2>Full Heading</h2>

When true, any <strong> or <b> tag found anywhere inside a heading is also removed, regardless of how much text it wraps:

<!-- Fixed only when PARTIAL_STRONG is true -->
<h3>1. <strong>Quality</strong></h3><h3>1. Quality</h3>

Both modes run together when PARTIAL_STRONG is true.

Usage

Place the script in your WordPress root directory, then run from that location.

Dry run — preview what would change, no database writes:

# Set DRY_RUN to true in the script first, then:
wp eval-file fix-strong-headings.php --skip-plugins

Commit — apply changes to the database:

# Set DRY_RUN to false in the script, then:
wp eval-file fix-strong-headings.php --skip-plugins

The --skip-plugins flag is required. Without it, deprecation notices or other output from plugins can be injected into the WP-CLI process and corrupt the content being written to the database.

Example output

--- DRY RUN --- No changes will be written.
Set DRY_RUN to false to apply changes.

PARTIAL_STRONG is enabled — partial bold inside headings will also be removed.

Found 25 published "dh-stories" posts. Scanning...

Post ID 5602 — "Netflix Streams Past Metadata Limitations"
  [netflix] <h2> "Challenge"
  [netflix] <h3> "1. Quality"
  [netflix] <h3> "Impact"

--- Summary ---
Posts with changes found : 1
Headings fixed           : 3
No changes were written. Set DRY_RUN to false to apply.

How it works

The script avoids passing the full post_content through DOMDocument, which would corrupt Gutenberg block comment delimiters and their JSON attributes — a particular problem with complex nested block structures from page builders like Kadence.

Instead it:

  1. Walks the raw post_content string using preg_replace_callback, matching only <!-- wp:heading --> block segments
  2. Passes only the small HTML fragment between the block delimiters to DOMDocument
  3. Checks whether the heading's sole child node is a <strong> or <b> element and unwraps it
  4. If PARTIAL_STRONG is enabled, additionally finds and unwraps any remaining <strong> or <b> tags anywhere inside the heading
  5. Reassembles the block with the original <!-- wp:heading --> comment and attributes completely untouched
  6. Writes the result directly via $wpdb->update() rather than wp_update_post(), bypassing wp_kses_post() and other content filters that strip inner block content from nested block structures

Reusing for other post types

Change TARGET_POST_TYPE to the slug of any other post type and re-run. No other changes needed.

Notes

  • Only published posts are processed. Drafts, scheduled posts, and trashed posts are skipped.
  • $wpdb->update() bypasses WordPress save hooks, so cache invalidation from plugins will not fire automatically. Flush your cache manually after running if your site uses a caching layer.
  • Always test on development and staging environments before running on production. Restore from a backup or post revision if anything looks wrong after a dry run.
<?php
/**
* fix-strong-headings.php
*
* Removes <strong> and <b> tags from inside h1–h5 headings in Gutenberg
* block editor posts of a given post type.
*
* Two modes, both always active together when PARTIAL_STRONG is true:
*
* Full-wrap (always on):
* Removes <strong> or <b> when it is the sole child of the heading,
* wrapping all of the heading text.
* e.g. <h2><strong>Heading Text</strong></h2>
* → <h2>Heading Text</h2>
*
* Partial (PARTIAL_STRONG = true):
* Also removes <strong> or <b> tags that wrap only part of the heading.
* e.g. <h3>1. <strong>Quality</strong></h3>
* → <h3>1. Quality</h3>
*
* Only the HTML fragment inside each <!-- wp:heading --> block is parsed.
* All surrounding block markup (Kadence, ACF, etc.) is left completely untouched.
*
* Uses $wpdb->update() directly to bypass wp_kses_post() and other content
* filters that wp_update_post() applies, which strip inner block content from
* nested block structures like Kadence columns.
*
* Usage:
* Dry run — set DRY_RUN to true, then:
* wp eval-file fix-strong-headings.php --skip-plugins
*
* Commit changes — set DRY_RUN to false, then:
* wp eval-file fix-strong-headings.php --skip-plugins
*
* Must be run from the WordPress root directory.
* --skip-plugins is required to prevent plugin output corrupting content.
*/
// Set to true to preview changes without writing anything to the database.
define( 'DRY_RUN', false );
// The post type slug to target.
define( 'TARGET_POST_TYPE', 'dh-stories' );
// Set to true to also strip <strong> and <b> tags that wrap only part of a
// heading, not just those wrapping the entire heading text.
define( 'PARTIAL_STRONG', false );
// ---------------------------------------------------------------------------
$dry_run = DRY_RUN;
if ( $dry_run ) {
WP_CLI::log( '' );
WP_CLI::log( '--- DRY RUN --- No changes will be written.' );
WP_CLI::log( 'Set DRY_RUN to false to apply changes.' );
WP_CLI::log( '' );
} else {
WP_CLI::log( '' );
WP_CLI::log( '--- COMMIT MODE --- Changes will be written to the database.' );
WP_CLI::log( '' );
}
if ( PARTIAL_STRONG ) {
WP_CLI::log( 'PARTIAL_STRONG is enabled — partial bold inside headings will also be removed.' );
WP_CLI::log( '' );
}
$posts = get_posts(
array(
'post_type' => TARGET_POST_TYPE,
'post_status' => 'publish',
'posts_per_page' => -1,
'fields' => 'all',
)
);
if ( empty( $posts ) ) {
WP_CLI::warning( sprintf( 'No published "%s" posts found.', TARGET_POST_TYPE ) );
return;
}
WP_CLI::log( sprintf( 'Found %d published "%s" posts. Scanning...', count( $posts ), TARGET_POST_TYPE ) );
WP_CLI::log( '' );
/**
* Process a single heading HTML fragment.
*
* Always removes <strong>/<b> when it is the sole child of the heading.
* When $partial is true, also removes any remaining <strong>/<b> tags
* anywhere inside the heading, regardless of how much text they wrap.
*
* Returns the modified HTML, a flag indicating whether a change was made,
* and the original heading text for logging.
*
* @param string $fragment e.g. '<h2 class="wp-block-heading"><strong>Foo</strong></h2>'
* @param string $tag The heading tag name, e.g. 'h2'.
* @param bool $partial Whether to also strip partial bold tags.
* @return array{html: string, fixed: bool, before: string}
*/
function maybe_unwrap_bold_heading( $fragment, $tag, $partial = false ) {
$dom = new DOMDocument();
libxml_use_internal_errors( true );
$dom->loadHTML(
'<meta http-equiv="content-type" content="text/html; charset=utf-8">' . $fragment,
LIBXML_HTML_NOIMPLIED | LIBXML_HTML_NODEFDTD
);
libxml_clear_errors();
$headings = $dom->getElementsByTagName( $tag );
if ( 0 === $headings->length ) {
return array( 'html' => $fragment, 'fixed' => false, 'before' => '' );
}
$heading = $headings->item( 0 );
$before = $heading->ownerDocument->saveHTML( $heading );
$bold_tags = array( 'strong', 'b' );
$fixed = false;
// Full-wrap check: sole child is a <strong> or <b>.
if ( 1 === $heading->childNodes->length ) {
$child = $heading->childNodes->item( 0 );
if (
XML_ELEMENT_NODE === $child->nodeType &&
in_array( strtolower( $child->nodeName ), $bold_tags, true )
) {
while ( $child->firstChild ) {
$heading->insertBefore( $child->firstChild, $child );
}
$heading->removeChild( $child );
$fixed = true;
}
}
// Partial check: find any remaining <strong> or <b> anywhere in the heading.
if ( $partial ) {
foreach ( $bold_tags as $bold_tag ) {
// Re-query after each pass since unwrapping alters the live NodeList.
while ( true ) {
$bold_nodes = $heading->getElementsByTagName( $bold_tag );
if ( 0 === $bold_nodes->length ) {
break;
}
$node = $bold_nodes->item( 0 );
while ( $node->firstChild ) {
$node->parentNode->insertBefore( $node->firstChild, $node );
}
$node->parentNode->removeChild( $node );
$fixed = true;
}
}
}
if ( ! $fixed ) {
return array( 'html' => $fragment, 'fixed' => false, 'before' => '' );
}
$new_html = $dom->saveHTML( $heading );
return array( 'html' => $new_html, 'fixed' => true, 'before' => trim( strip_tags( $before ) ) );
}
$posts_changed = 0;
$headings_fixed = 0;
foreach ( $posts as $post ) {
$original_content = $post->post_content;
$post_fixes = array();
// Match each wp:heading block, capturing the opening comment, HTML fragment,
// and closing comment separately so only the HTML portion is ever touched.
$pattern = '/
( # capture group 1: opening block comment
<!--\s*wp:heading # block opening tag
[^-]* # optional block attributes JSON
-->
)
( # capture group 2: the heading HTML fragment
\s*<h[1-5][\s\S]*?<\/h[1-5]>\s*
)
( # capture group 3: closing block comment
<!--\s*\/wp:heading\s*-->
)
/xi';
$updated_content = preg_replace_callback(
$pattern,
function( $matches ) use ( &$post_fixes, &$headings_fixed, $post ) {
$open_comment = $matches[1];
$html_fragment = $matches[2];
$close_comment = $matches[3];
// Detect which heading level this block contains.
if ( ! preg_match( '/<(h[1-5])[\s>]/i', $html_fragment, $tag_match ) ) {
return $matches[0];
}
$tag = strtolower( $tag_match[1] );
$result = maybe_unwrap_bold_heading( trim( $html_fragment ), $tag, PARTIAL_STRONG );
if ( ! $result['fixed'] ) {
return $matches[0];
}
$headings_fixed++;
$post_fixes[] = sprintf(
' [%s] <%s> "%s"',
$post->post_name,
$tag,
$result['before']
);
// Reassemble with the original block comments intact.
return $open_comment . "\n" . $result['html'] . "\n" . $close_comment;
},
$original_content
);
if ( $updated_content !== $original_content ) {
$posts_changed++;
WP_CLI::log( sprintf( 'Post ID %d — "%s"', $post->ID, get_the_title( $post ) ) );
foreach ( $post_fixes as $fix_line ) {
WP_CLI::log( $fix_line );
}
WP_CLI::log( '' );
if ( ! $dry_run ) {
global $wpdb;
// Use a direct database write to bypass wp_kses_post() and other
// content filters that wp_update_post() applies, which were stripping
// inner block content from Kadence column blocks on save.
$rows_affected = $wpdb->update(
$wpdb->posts,
array( 'post_content' => $updated_content ),
array( 'ID' => $post->ID ),
array( '%s' ),
array( '%d' )
);
if ( false === $rows_affected ) {
WP_CLI::warning( sprintf( 'Failed to update post ID %d: %s', $post->ID, $wpdb->last_error ) );
} else {
// Clear the object cache so subsequent reads reflect the updated content.
clean_post_cache( $post->ID );
WP_CLI::success( sprintf( 'Updated post ID %d.', $post->ID ) );
}
}
}
}
WP_CLI::log( '--- Summary ---' );
WP_CLI::log( sprintf( 'Posts with changes found : %d', $posts_changed ) );
WP_CLI::log( sprintf( 'Headings fixed : %d', $headings_fixed ) );
if ( $dry_run ) {
WP_CLI::log( 'No changes were written. Set DRY_RUN to false to apply.' );
} else {
WP_CLI::log( 'Changes have been written to the database.' );
}
WP_CLI::log( '' );
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment