Skip to main content
Builder’s Journal

Why Your WordPress Blockquote Styling Bleeds to the End of the Article

· · 6 min read

Why Your WordPress Blockquote Styling Bleeds to the End of the Article

**If a <blockquote> on your WordPress site renders with its quote styling — the left border, the italic, the indent — running all the way to the bottom of the article, but the saved post content has a perfectly balanced </blockquote>, then something is stripping the closing tag at render time. On my site it wasn’t wpautop (the usual suspect, and it was innocent). It was an in-content ad injector** that splits the article on closing block tags and throws away any piece with no text — which quietly deleted the standalone </blockquote>.

What you’ll learn: why the closing tag vanishes even though your database has it, how a common ad/related-content injector pattern corrupts HTML, and a one-branch fix — plus the technique for isolating exactly which the_content filter is to blame.

Evidence note: based on a real bug on my own WordPress site. I reproduced it, traced it through two separate injectors, fixed both, and confirmed the render. Code is the live implementation, lightly generalised.

The symptom

An "Evidence note" blockquote near the top of an article was styled correctly — and then everything after it was also inside the quote: the next heading, every paragraph, the code blocks, to the end of the page. The tell-tale sign it’s a tag problem and not CSS: view the rendered source and count the tags.

# rendered HTML
<blockquote>   → 1
</blockquote>  → 0

One open, zero close. But the stored content was fine:

wp post get 123 --field=content | grep -oE '</?blockquote>' | sort | uniq -c
#   1 <blockquote>
#   1 </blockquote>

So the database is balanced. Something between the database and the browser is dropping the </blockquote>.

The false leads

I burned time on the wrong suspects — worth naming so you can skip them:

  • wpautop — WordPress’s auto-formatter is notorious for mangling block tags, so it was the obvious culprit. It wasn’t: wpautop($content) preserved the balanced blockquote (1 open, 1 close). Don’t assume; test it directly.
  • Caching / OPcache — the live page kept showing the old broken output after my first fix, which looked like a stale cache. It wasn’t the root cause; it just masked whether my edits were live. (Force it: opcache_invalidate($file, true) through php-fpm, not the CLI, which has a separate OPcache.)

The root cause

The site has an in-content ad injector — a the_content filter that inserts ad units between sections of long posts. To place ads "between blocks, not mid-element," it splits the content on closing block tags:

$parts = preg_split(
    '/(</p>|</h[1-6]>|</ul>|</ol>|</blockquote>|</figure>|</table>)/i',
    $content, -1, PREG_SPLIT_DELIM_CAPTURE
);
$blocks = [];
for ( $i = 0; $i < count( $parts ); $i += 2 ) {
    $chunk = ( $parts[ $i ] ?? '' ) . ( $parts[ $i + 1 ] ?? '' );
    if ( trim( wp_strip_all_tags( $chunk ) ) !== '' ) {   // <-- the bug
        $blocks[] = $chunk;
    }
}

Read that last line carefully. It keeps a chunk only if it contains visible text. Now feed it a blockquote whose content is wrapped in a paragraph — which is exactly what well-formed HTML (and wpautop) produces:

<blockquote><p>Evidence note: …</p></blockquote>

Splitting on </p> and </blockquote> turns that into three pieces:

  1. <blockquote><p>Evidence note: … + </p> → has text → kept
  2. (empty) + </blockquote> → strip tags → ""dropped
  3. n<h2>The symptom + </h2> → has text → kept

Piece 2 is a lone </blockquote> with no text of its own, so the "keep only if it has text" guard throws the closing tag away. The quote is never closed, and the browser applies blockquote styling to everything that follows.

And there were two injectors with the identical bug — one in the theme, one in a plugin — running at different the_content priorities. The plugin’s ran first (priority 20) and did the damage; the theme’s (priority 30) compounded it. Fixing one wasn’t enough.

The fix

Don’t discard a tag-only chunk — reattach it to the previous block so the tag survives:

for ( $i = 0; $i < count( $parts ); $i += 2 ) {
    $chunk = ( $parts[ $i ] ?? '' ) . ( $parts[ $i + 1 ] ?? '' );
    if ( trim( wp_strip_all_tags( $chunk ) ) !== '' ) {
        $blocks[] = $chunk;
    } elseif ( trim( $chunk ) !== '' && $blocks ) {
        // Tag-only chunk (e.g. a lone </blockquote> after the quote's inner </p>).
        // Dropping it leaves the tag unclosed; reattach it to the previous block.
        $blocks[ count( $blocks ) - 1 ] .= $chunk;
    }
}

That one elseif fixes it for any content with a blockquote (or a <figure>, or any block whose closing tag can end up textless) — not just the one post. Apply it to every injector that uses this split-on-closing-tags pattern.

The technique: which the_content filter is guilty?

The hard part was proving where the tag died, because the injectors don’t run under WP-CLI — they gate on is_main_query(), which is false in wp eval. Two things cracked it:

1. Force the main query so the filters actually run:

query_posts( [ 'p' => 123, 'post_type' => 'post' ] );
$GLOBALS['wp_the_query'] = $GLOBALS['wp_query'];   // make is_main_query() true
the_post();
$rendered = apply_filters( 'the_content', get_post( 123 )->post_content );
// now count <blockquote> vs </blockquote> in $rendered

Run it once with all plugins loaded and once with --skip-plugins / --skip-themes to bisect whether a plugin or the theme is responsible.

2. Find the exact file a function lives in (when the same function name might exist in backups or a child theme):

$r = new ReflectionFunction( 'the_injector_function' );
echo $r->getFileName() . ':' . $r->getStartLine();

That pointed straight at the offending file and line.

Why the obvious fixes fail

SuspectWhy it wasn’t the cause
wpautopPreserves the balanced blockquote — verified with wpautop($content).
Caching / CloudflareMasks whether your fix is live, but doesn’t drop the tag.
OPcacheSame — invalidate it through php-fpm to rule it out; CLI OPcache is separate.
The stored contentIt’s balanced; the corruption is at render, in a the_content filter.

A reusable checklist

  1. Count <blockquote> vs </blockquote> in the rendered HTML and in the stored content. Open-but-not-closed in render only = a render-time filter.
  2. Rule out wpautop directly: wpautop($content) and re-count.
  3. List your the_content filters (grep -rn "add_filter.*the_content"), including plugins.
  4. Reproduce the filter chain in wp eval by forcing is_main_query(); bisect theme vs plugins.
  5. Any injector that preg_splits on closing tags and drops text-less chunks is the bug — reattach tag-only chunks instead of dropping them.
  6. Force an OPcache reset through php-fpm before deciding your fix "didn’t work."

Takeaway

The instinct behind these injectors is reasonable — split on block boundaries so an ad never lands mid-element. The mistake is treating a piece of HTML as disposable just because it has no visible text: a closing tag is the content. If your blockquote (or figure, or table) styling ever bleeds down the page while the database looks fine, don’t chase wpautop — find the filter that’s splitting your HTML and quietly deleting the empty-looking bits.

AI assisted with drafting; the bug, the two-injector root cause, the fix, and the debugging steps are from my own site.

Share this article

Leave a Reply

Your email address will not be published. Required fields are marked *