From df8f31121412d09c52d0ff402132b1d7452a85a1 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Mon, 13 Apr 2026 21:20:21 -0600 Subject: [PATCH 1/7] webp-uploads: rewrite `` tags from `wp_get_attachment_image()` MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Until now the Modern Image Formats plugin only rewrote images that flowed through `the_content` (via `wp_content_img_tag`) and featured images (via `post_thumbnail_html`). Any `` built by a direct call to `wp_get_attachment_image()` — page builders, archive loops, custom templates, and many plugins — was left as the original JPEG even when WebP/AVIF sub-sizes were available. Add a new `wp_get_attachment_image` filter that dispatches to the existing rewriter pipeline: `webp_uploads_img_tag_update_mime_type()` in default mode, or `webp_uploads_wrap_image_in_picture()` when picture element output is enabled. URL-returning functions (`wp_get_attachment_image_url()`, `wp_get_attachment_image_src()`, `get_the_post_thumbnail_url()`) are intentionally left untouched, since their return values feed OG tags, RSS, JSON APIs, and other non-HTML consumers where silent format substitution is unsafe. Because `the_post_thumbnail()` routes through `wp_get_attachment_image()`, the dedicated `post_thumbnail_html` registration is now redundant and has been removed; `webp_uploads_update_featured_image()` is marked `@deprecated` but kept in place for any third-party callers. Also make `webp_uploads_wrap_image_in_picture()` idempotent so that markup flowing through both `wp_get_attachment_image` and `wp_content_img_tag` isn't double-wrapped, and extend the context whitelist to include the new `wp_get_attachment_image` context. Fixes #523. --- plugins/webp-uploads/hooks.php | 64 +++++++++++- plugins/webp-uploads/picture-element.php | 9 +- plugins/webp-uploads/readme.txt | 6 ++ plugins/webp-uploads/tests/test-load.php | 98 ++++++++++++++++++- .../tests/test-picture-element.php | 76 +++++++++++++- 5 files changed, 242 insertions(+), 11 deletions(-) diff --git a/plugins/webp-uploads/hooks.php b/plugins/webp-uploads/hooks.php index 7f5ef6958f..ec62bb8a46 100644 --- a/plugins/webp-uploads/hooks.php +++ b/plugins/webp-uploads/hooks.php @@ -562,6 +562,59 @@ function webp_uploads_filter_image_tag( string $filtered_image, string $context, return $filtered_image; } +/** + * Filters `wp_get_attachment_image` so `` tags produced outside of post + * content (archive templates, page builders, custom loops, featured-image + * template calls) also use the preferred MIME type where available. + * + * Only rewrites the HTML `` (or `` wrapper in picture-element + * mode). Sibling URL-returning functions such as `wp_get_attachment_image_url()` + * and `wp_get_attachment_image_src()` are intentionally left untouched, since + * their return values feed non-HTML contexts (OG tags, RSS, JSON) where + * silently substituting a modern format is unsafe. + * + * @since 2.7.0 + * + * @param string $html HTML img element or empty string on failure. + * @param int $attachment_id Image attachment ID. + * @param string|int[] $size Requested image size. + * @param bool $icon Whether the image should fall back to a mime type icon. + * @param string[] $attr Array of attribute values for the image markup, keyed by attribute name. + * @return string The filtered HTML. + */ +function webp_uploads_filter_wp_get_attachment_image( string $html, int $attachment_id, $size, bool $icon, array $attr ): string { + if ( '' === $html || 0 === $attachment_id || true === $icon ) { + return $html; + } + + if ( ! webp_uploads_in_frontend_body() ) { + return $html; + } + + /** + * Filters whether the Modern Image Formats plugin should rewrite an image returned by `wp_get_attachment_image()`. + * + * Returning false short-circuits the rewrite and preserves the original HTML. This gives + * integrators a surgical per-call opt-out in addition to `remove_filter()`. + * + * @since 2.7.0 + * + * @param bool $should_filter Whether to apply modern-format rewriting. Default true. + * @param int $attachment_id Image attachment ID. + * @param string|int[] $size Requested image size. + * @param string[] $attr Attribute array passed to `wp_get_attachment_image()`. + */ + if ( ! apply_filters( 'webp_uploads_filter_wp_get_attachment_image', true, $attachment_id, $size, $attr ) ) { + return $html; + } + + if ( webp_uploads_is_picture_element_enabled() ) { + return webp_uploads_wrap_image_in_picture( $html, 'wp_get_attachment_image', $attachment_id ); + } + + return webp_uploads_img_tag_update_mime_type( $html, 'wp_get_attachment_image', $attachment_id ); +} + /** * Finds all the urls with *.jpg and *.jpeg extension and updates with *.webp version for the provided image * for the specified image sizes, the *.webp references are stored inside of each size. @@ -668,6 +721,11 @@ function webp_uploads_img_tag_update_mime_type( string $original_image, string $ * occurs in the_content of a post. * * @since 1.0.0 + * @deprecated 2.7.0 Featured images are now rewritten through the + * `wp_get_attachment_image` filter, since `the_post_thumbnail()` + * routes through `wp_get_attachment_image()`. This function is + * retained for third-party callers only and is no longer + * registered on `post_thumbnail_html`. * * @param string $html The current HTML markup of the featured image. * @param int $post_id The current post ID where the featured image is requested. @@ -680,7 +738,6 @@ function webp_uploads_update_featured_image( string $html, int $post_id, int $at } return webp_uploads_img_tag_update_mime_type( $html, 'post_thumbnail_html', $attachment_id ); } -add_filter( 'post_thumbnail_html', 'webp_uploads_update_featured_image', 10, 3 ); /** * Returns an array of image size names that have secondary mime type output enabled. Core sizes and @@ -854,6 +911,11 @@ function webp_uploads_init(): void { // Filter regular image tags. add_filter( 'wp_content_img_tag', webp_uploads_is_picture_element_enabled() ? 'webp_uploads_wrap_image_in_picture' : 'webp_uploads_filter_image_tag', 10, 3 ); + // Filter `` tags produced by template tags, page builders, and any other code path that calls + // `wp_get_attachment_image()` directly. `the_post_thumbnail()` also routes through this, so it covers + // featured images previously handled by a dedicated `post_thumbnail_html` filter. + add_filter( 'wp_get_attachment_image', 'webp_uploads_filter_wp_get_attachment_image', 10, 5 ); + // Filter blocks that may contain background images. add_filter( 'render_block_core/cover', 'webp_uploads_filter_block_background_images', 10, 2 ); add_filter( 'render_block_core/group', 'webp_uploads_filter_block_background_images', 10, 2 ); diff --git a/plugins/webp-uploads/picture-element.php b/plugins/webp-uploads/picture-element.php index 62bef850d3..bcfb112c07 100644 --- a/plugins/webp-uploads/picture-element.php +++ b/plugins/webp-uploads/picture-element.php @@ -27,7 +27,14 @@ * @return string The new image tag. */ function webp_uploads_wrap_image_in_picture( string $image, string $context, int $attachment_id ): string { - if ( ! in_array( $context, array( 'the_content', 'post_thumbnail_html', 'widget_block_content' ), true ) ) { + if ( ! in_array( $context, array( 'the_content', 'post_thumbnail_html', 'widget_block_content', 'wp_get_attachment_image' ), true ) ) { + return $image; + } + + // Idempotency: bail if the input is already wrapped in a picture element, to + // avoid double-wrapping when multiple rewrite paths fire on the same markup + // (e.g. wp_get_attachment_image -> the_content -> wp_content_img_tag). + if ( false !== stripos( $image, '` tags produced by template tags, page builders, and custom loops use the preferred format — not only images inside `the_content`. Featured images continue to be rewritten through the same pipeline (via `the_post_thumbnail()` routing through `wp_get_attachment_image()`), so the dedicated `post_thumbnail_html` filter registration has been retired. ([523](https://github.com/WordPress/performance/issues/523)) + = 2.6.1 = **Bug Fixes** diff --git a/plugins/webp-uploads/tests/test-load.php b/plugins/webp-uploads/tests/test-load.php index d4c954411e..7ebb67f51f 100644 --- a/plugins/webp-uploads/tests/test-load.php +++ b/plugins/webp-uploads/tests/test-load.php @@ -1272,19 +1272,26 @@ static function () { } /** - * Test that the webp_uploads_update_featured_image function is hooked to the post_thumbnail_html filter. + * Featured images are now rewritten through the `wp_get_attachment_image` + * filter (since `the_post_thumbnail()` routes through `wp_get_attachment_image()`), + * so the direct `post_thumbnail_html` registration should no longer exist. + * + * @covers ::webp_uploads_init */ - public function test_webp_uploads_update_featured_image_hooked_into_post_thumbnail_html(): void { - $this->assertSame( 10, has_filter( 'post_thumbnail_html', 'webp_uploads_update_featured_image' ) ); + public function test_post_thumbnail_html_filter_is_not_registered_directly(): void { + $this->assertFalse( has_filter( 'post_thumbnail_html', 'webp_uploads_update_featured_image' ) ); + $this->assertSame( 10, has_filter( 'wp_get_attachment_image', 'webp_uploads_filter_wp_get_attachment_image' ) ); } /** * Test that the featured image is not wrapped in a picture element. * - * @covers ::webp_uploads_update_featured_image + * @covers ::webp_uploads_filter_wp_get_attachment_image * @covers ::webp_uploads_img_tag_update_mime_type */ public function test_webp_uploads_update_featured_image_picture_element_disabled(): void { + $this->mock_frontend_body_hooks(); + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/car.jpeg' ); $post_id = self::factory()->post->create(); set_post_thumbnail( $post_id, $attachment_id ); @@ -1296,12 +1303,13 @@ public function test_webp_uploads_update_featured_image_picture_element_disabled /** * Test that the featured image is wrapped in a picture element. * - * @covers ::webp_uploads_update_featured_image + * @covers ::webp_uploads_filter_wp_get_attachment_image * @covers ::webp_uploads_wrap_image_in_picture */ public function test_webp_uploads_update_featured_image_picture_element_enabled(): void { update_option( 'perflab_generate_webp_and_jpeg', '1' ); $this->opt_in_to_picture_element(); + $this->mock_frontend_body_hooks(); $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/car.jpeg' ); $post_id = self::factory()->post->create(); @@ -1310,4 +1318,84 @@ public function test_webp_uploads_update_featured_image_picture_element_enabled( $featured_image = get_the_post_thumbnail( $post_id ); $this->assertStringStartsWith( 'opt_in_to_jpeg_and_webp(); + $this->mock_frontend_body_hooks(); + + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' ); + + $html = wp_get_attachment_image( $attachment_id, 'medium', false, array( 'class' => "wp-image-{$attachment_id}" ) ); + + $this->assertStringContainsString( '.webp', $html ); + $this->assertStringNotContainsString( 'leaves.jpg', $html ); + + $processor = new WP_HTML_Tag_Processor( $html ); + $this->assertTrue( $processor->next_tag( array( 'tag_name' => 'IMG' ) ) ); + $this->assertFalse( $processor->next_tag( array( 'tag_name' => 'IMG' ) ), 'Only one IMG tag should be present.' ); + } + + /** + * @covers ::webp_uploads_filter_wp_get_attachment_image + */ + public function test_wp_get_attachment_image_opt_out_filter_returns_original_html(): void { + $this->opt_in_to_jpeg_and_webp(); + $this->mock_frontend_body_hooks(); + add_filter( 'webp_uploads_filter_wp_get_attachment_image', '__return_false' ); + + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' ); + + $html = wp_get_attachment_image( $attachment_id, 'medium', false, array( 'class' => "wp-image-{$attachment_id}" ) ); + + $this->assertStringContainsString( 'leaves.jpg', $html ); + $this->assertStringNotContainsString( '.webp', $html ); + } + + /** + * @covers ::webp_uploads_filter_wp_get_attachment_image + */ + public function test_wp_get_attachment_image_unhook_restores_original_html(): void { + $this->opt_in_to_jpeg_and_webp(); + $this->mock_frontend_body_hooks(); + remove_filter( 'wp_get_attachment_image', 'webp_uploads_filter_wp_get_attachment_image', 10 ); + + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' ); + + $html = wp_get_attachment_image( $attachment_id, 'medium', false, array( 'class' => "wp-image-{$attachment_id}" ) ); + + $this->assertStringContainsString( 'leaves.jpg', $html ); + $this->assertStringNotContainsString( '.webp', $html ); + } + + /** + * @covers ::webp_uploads_filter_wp_get_attachment_image + */ + public function test_wp_get_attachment_image_bails_outside_frontend_body(): void { + $this->opt_in_to_jpeg_and_webp(); + // Intentionally do NOT call mock_frontend_body_hooks(). + + $attachment_id = self::factory()->attachment->create_upload_object( TESTS_PLUGIN_DIR . '/tests/data/images/leaves.jpg' ); + + $html = wp_get_attachment_image( $attachment_id, 'medium', false, array( 'class' => "wp-image-{$attachment_id}" ) ); + + $this->assertStringContainsString( 'leaves.jpg', $html ); + $this->assertStringNotContainsString( '.webp', $html ); + } + + /** + * @covers ::webp_uploads_filter_wp_get_attachment_image + */ + public function test_wp_get_attachment_image_bails_when_icon_placeholder_requested(): void { + $this->mock_frontend_body_hooks(); + + // Directly exercise the filter callback with $icon = true; expect unchanged HTML. + $html = ''; + $this->assertSame( + $html, + webp_uploads_filter_wp_get_attachment_image( $html, 0, 'medium', true, array() ) + ); + } } diff --git a/plugins/webp-uploads/tests/test-picture-element.php b/plugins/webp-uploads/tests/test-picture-element.php index 87ca0282c1..02c0bd41a1 100644 --- a/plugins/webp-uploads/tests/test-picture-element.php +++ b/plugins/webp-uploads/tests/test-picture-element.php @@ -48,6 +48,13 @@ public function set_up(): void { // Run critical hooks to satisfy webp_uploads_in_frontend_body() conditions. $this->mock_frontend_body_hooks(); + + // Many tests in this class rely on `wp_get_attachment_image()` returning + // unfiltered markup so they can exercise the `wp_content_img_tag` path + // manually. Remove the new auto-rewriter by default; tests that want to + // exercise it explicitly can re-register via `opt_in_to_picture_element()` + // (which re-runs `webp_uploads_init()`). + remove_filter( 'wp_get_attachment_image', 'webp_uploads_filter_wp_get_attachment_image', 10 ); } public function tear_down(): void { @@ -102,6 +109,10 @@ public function test_maybe_wrap_images_in_picture_element( bool $fallback_jpeg, // Apply picture element support. if ( $picture_element ) { $this->opt_in_to_picture_element(); + // `opt_in_to_picture_element()` re-runs webp_uploads_init(), which + // re-registers the new wp_get_attachment_image rewriter. Remove it + // again so this test exercises the wp_content_img_tag path only. + remove_filter( 'wp_get_attachment_image', 'webp_uploads_filter_wp_get_attachment_image', 10 ); } // Create some content with the image. @@ -570,26 +581,83 @@ public function test_webp_uploads_wrap_image_in_picture_with_different_context( */ public function data_provider_webp_uploads_wrap_image_in_picture_with_different_context(): array { return array( - 'the_content' => + 'the_content' => array( 'context' => 'the_content', 'expected' => true, ), - 'post_thumbnail_html' => + 'post_thumbnail_html' => array( 'context' => 'post_thumbnail_html', 'expected' => true, ), - 'widget_block_content' => + 'widget_block_content' => array( 'context' => 'widget_block_content', 'expected' => true, ), - 'invalid_context' => + 'wp_get_attachment_image' => + array( + 'context' => 'wp_get_attachment_image', + 'expected' => true, + ), + 'invalid_context' => array( 'context' => 'invalid_context', 'expected' => false, ), ); } + + /** + * `wp_get_attachment_image()` should return a -wrapped image when + * picture-element output is enabled, because the new filter dispatches to + * `webp_uploads_wrap_image_in_picture()`. + * + * @covers ::webp_uploads_filter_wp_get_attachment_image + * @covers ::webp_uploads_wrap_image_in_picture + */ + public function test_wp_get_attachment_image_is_wrapped_in_picture_when_picture_element_enabled(): void { + $this->opt_in_to_picture_element(); + + $image = wp_get_attachment_image( + self::$image_id, + 'large', + false, + array( + 'class' => 'wp-image-' . self::$image_id, + 'alt' => 'Green Leaves', + ) + ); + + $this->assertStringStartsWith( 'assertStringContainsString( 'opt_in_to_picture_element(); + + $image = wp_get_attachment_image( + self::$image_id, + 'large', + false, + array( + 'class' => 'wp-image-' . self::$image_id, + 'alt' => 'Green Leaves', + ) + ); + + $twice = webp_uploads_wrap_image_in_picture( $image, 'wp_get_attachment_image', self::$image_id ); + + $this->assertSame( $image, $twice, 'Re-wrapping an already-wrapped picture should return the input unchanged.' ); + $this->assertSame( 1, substr_count( $twice, ' Date: Tue, 14 Apr 2026 08:54:09 -0600 Subject: [PATCH 2/7] Tests: fix isolation-sensitive assertions in wp_get_attachment_image tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Subsequent test fixtures uploading leaves.jpg get renamed to leaves-NN.jpg when leftover files remain on disk, which made the 'leaves.jpg' substring assertion fail in CI. Match on '.jpg' instead — combined with the existing '.webp' negative assertion, the intent (original format preserved) is still verified. --- plugins/webp-uploads/tests/test-load.php | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/plugins/webp-uploads/tests/test-load.php b/plugins/webp-uploads/tests/test-load.php index 7ebb67f51f..1098f11694 100644 --- a/plugins/webp-uploads/tests/test-load.php +++ b/plugins/webp-uploads/tests/test-load.php @@ -1350,7 +1350,7 @@ public function test_wp_get_attachment_image_opt_out_filter_returns_original_htm $html = wp_get_attachment_image( $attachment_id, 'medium', false, array( 'class' => "wp-image-{$attachment_id}" ) ); - $this->assertStringContainsString( 'leaves.jpg', $html ); + $this->assertStringContainsString( '.jpg', $html ); $this->assertStringNotContainsString( '.webp', $html ); } @@ -1366,7 +1366,7 @@ public function test_wp_get_attachment_image_unhook_restores_original_html(): vo $html = wp_get_attachment_image( $attachment_id, 'medium', false, array( 'class' => "wp-image-{$attachment_id}" ) ); - $this->assertStringContainsString( 'leaves.jpg', $html ); + $this->assertStringContainsString( '.jpg', $html ); $this->assertStringNotContainsString( '.webp', $html ); } @@ -1381,7 +1381,7 @@ public function test_wp_get_attachment_image_bails_outside_frontend_body(): void $html = wp_get_attachment_image( $attachment_id, 'medium', false, array( 'class' => "wp-image-{$attachment_id}" ) ); - $this->assertStringContainsString( 'leaves.jpg', $html ); + $this->assertStringContainsString( '.jpg', $html ); $this->assertStringNotContainsString( '.webp', $html ); } From 785e94502a40786e9efba1b5c620bde0e7578235 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Tue, 14 Apr 2026 11:08:12 -0600 Subject: [PATCH 3/7] Remove webp_uploads_update_featured_image() function Featured images are now rewritten through the wp_get_attachment_image filter (via webp_uploads_filter_wp_get_attachment_image()), making this function obsolete. Document the removal as a breaking change in the changelog for third-party callers, who should switch to webp_uploads_img_tag_update_mime_type() or webp_uploads_wrap_image_in_picture() directly, or rely on the new wp_get_attachment_image filter. --- plugins/webp-uploads/hooks.php | 23 ----------------------- plugins/webp-uploads/readme.txt | 4 ++++ plugins/webp-uploads/tests/test-load.php | 2 +- 3 files changed, 5 insertions(+), 24 deletions(-) diff --git a/plugins/webp-uploads/hooks.php b/plugins/webp-uploads/hooks.php index ec62bb8a46..2c0028be28 100644 --- a/plugins/webp-uploads/hooks.php +++ b/plugins/webp-uploads/hooks.php @@ -716,29 +716,6 @@ function webp_uploads_img_tag_update_mime_type( string $original_image, string $ return $image; } -/** - * Updates the references of the featured image to the new image format if available, in the same way it - * occurs in the_content of a post. - * - * @since 1.0.0 - * @deprecated 2.7.0 Featured images are now rewritten through the - * `wp_get_attachment_image` filter, since `the_post_thumbnail()` - * routes through `wp_get_attachment_image()`. This function is - * retained for third-party callers only and is no longer - * registered on `post_thumbnail_html`. - * - * @param string $html The current HTML markup of the featured image. - * @param int $post_id The current post ID where the featured image is requested. - * @param int $attachment_id The ID of the attachment image. - * @return string The updated HTML markup. - */ -function webp_uploads_update_featured_image( string $html, int $post_id, int $attachment_id ): string { - if ( webp_uploads_is_picture_element_enabled() ) { - return webp_uploads_wrap_image_in_picture( $html, 'post_thumbnail_html', $attachment_id ); - } - return webp_uploads_img_tag_update_mime_type( $html, 'post_thumbnail_html', $attachment_id ); -} - /** * Returns an array of image size names that have secondary mime type output enabled. Core sizes and * core theme sizes are enabled by default. diff --git a/plugins/webp-uploads/readme.txt b/plugins/webp-uploads/readme.txt index 02fbb24ab3..21c9fb7724 100644 --- a/plugins/webp-uploads/readme.txt +++ b/plugins/webp-uploads/readme.txt @@ -71,6 +71,10 @@ By default, the Modern Image Formats plugin will only generate WebP versions of * Serve modern image formats (WebP / AVIF) from `wp_get_attachment_image()` so that `` tags produced by template tags, page builders, and custom loops use the preferred format — not only images inside `the_content`. Featured images continue to be rewritten through the same pipeline (via `the_post_thumbnail()` routing through `wp_get_attachment_image()`), so the dedicated `post_thumbnail_html` filter registration has been retired. ([523](https://github.com/WordPress/performance/issues/523)) +**Breaking Changes** + +* The `webp_uploads_update_featured_image()` function has been removed. It was previously hooked on `post_thumbnail_html` to rewrite featured images; that responsibility now lives in the `wp_get_attachment_image` filter via `webp_uploads_filter_wp_get_attachment_image()`. Third-party code that called `webp_uploads_update_featured_image()` directly should switch to `webp_uploads_img_tag_update_mime_type()` or `webp_uploads_wrap_image_in_picture()`, or rely on the new `wp_get_attachment_image` filter. + = 2.6.1 = **Bug Fixes** diff --git a/plugins/webp-uploads/tests/test-load.php b/plugins/webp-uploads/tests/test-load.php index 1098f11694..e8adbb204d 100644 --- a/plugins/webp-uploads/tests/test-load.php +++ b/plugins/webp-uploads/tests/test-load.php @@ -1279,7 +1279,7 @@ static function () { * @covers ::webp_uploads_init */ public function test_post_thumbnail_html_filter_is_not_registered_directly(): void { - $this->assertFalse( has_filter( 'post_thumbnail_html', 'webp_uploads_update_featured_image' ) ); + $this->assertFalse( has_filter( 'post_thumbnail_html' ) ); $this->assertSame( 10, has_filter( 'wp_get_attachment_image', 'webp_uploads_filter_wp_get_attachment_image' ) ); } From 36c0bcf423d7ee1186d523d1f613452d85fad93a Mon Sep 17 00:00:00 2001 From: Marcin Dudek Date: Fri, 15 May 2026 14:36:22 +0200 Subject: [PATCH 4/7] webp-uploads: restore webp_uploads_update_featured_image() as deprecated shim PR #2451 removed the public function webp_uploads_update_featured_image() outright. It was hooked on `post_thumbnail_html` and is documented `@since 1.0.0`, so deleting it without a deprecation cycle fatal-errors any third-party code that still calls it directly. Restore it in deprecated.php as a thin wrapper that emits a deprecation notice via _deprecated_function() and delegates to the current pipeline (webp_uploads_wrap_image_in_picture() / webp_uploads_img_tag_update_mime_type()). It is no longer registered as a `post_thumbnail_html` filter -- featured images are handled by the new `wp_get_attachment_image` filter -- so this restores backward compatibility without double-processing. readme.txt: move the entry from "Breaking Changes" to "Deprecated". Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/webp-uploads/deprecated.php | 22 ++++++++++++++++++++++ plugins/webp-uploads/readme.txt | 4 ++-- 2 files changed, 24 insertions(+), 2 deletions(-) diff --git a/plugins/webp-uploads/deprecated.php b/plugins/webp-uploads/deprecated.php index b3c51bcb6b..c4057c7727 100644 --- a/plugins/webp-uploads/deprecated.php +++ b/plugins/webp-uploads/deprecated.php @@ -71,3 +71,25 @@ function webp_uploads_media_setting_style(): void { ` tags produced by template tags, page builders, and custom loops use the preferred format — not only images inside `the_content`. Featured images continue to be rewritten through the same pipeline (via `the_post_thumbnail()` routing through `wp_get_attachment_image()`), so the dedicated `post_thumbnail_html` filter registration has been retired. ([523](https://github.com/WordPress/performance/issues/523)) -**Breaking Changes** +**Deprecated** -* The `webp_uploads_update_featured_image()` function has been removed. It was previously hooked on `post_thumbnail_html` to rewrite featured images; that responsibility now lives in the `wp_get_attachment_image` filter via `webp_uploads_filter_wp_get_attachment_image()`. Third-party code that called `webp_uploads_update_featured_image()` directly should switch to `webp_uploads_img_tag_update_mime_type()` or `webp_uploads_wrap_image_in_picture()`, or rely on the new `wp_get_attachment_image` filter. +* The `webp_uploads_update_featured_image()` function is deprecated. It was previously hooked on `post_thumbnail_html` to rewrite featured images; that responsibility now lives in the `wp_get_attachment_image` filter via `webp_uploads_filter_wp_get_attachment_image()`. The function still works as a thin wrapper and emits a deprecation notice. Third-party code that called it directly should switch to `webp_uploads_img_tag_update_mime_type()` or `webp_uploads_wrap_image_in_picture()`, or rely on the new `wp_get_attachment_image` filter. = 2.6.1 = From 943fd7dc2cb16520df0485e2f365c1807c12fb8c Mon Sep 17 00:00:00 2001 From: Marcin Dudek Date: Fri, 15 May 2026 14:36:36 +0200 Subject: [PATCH 5/7] webp-uploads: prevent nested and empty wrappers Two robustness fixes to webp_uploads_wrap_image_in_picture(). Nested (invalid HTML) ------------------------------ When a produced for a wp_get_attachment_image() call is embedded in post content, wp_filter_content_tags() extracts the inner and runs it back through this function via `wp_content_img_tag`. The existing `stripos( $image, ' substring is passed -- so the image is wrapped twice, producing nested .... The inner is now tagged with a `data-wp-picture-wrapped` attribute, and the idempotency guard bails when that marker is present. Context detection (doing_filter('the_content')) was considered but rejected: it would skip page-builder images that render during `the_content`. The marker is documented in the readme changelog. Empty wrapper ----------------------- When no modern-format source resolves for an attachment, the function still emitted `` with no children. It now returns the original when $picture_sources is empty. Adds three tests: a regression test for the in-content double-wrap (embed in content, run `the_content`, assert exactly one ), an empty-wrapper test, and a test for the deprecated webp_uploads_update_featured_image() shim. Co-Authored-By: Claude Opus 4.7 (1M context) --- plugins/webp-uploads/picture-element.php | 39 +++++++- plugins/webp-uploads/readme.txt | 2 +- .../tests/test-picture-element.php | 98 ++++++++++++++++++- 3 files changed, 133 insertions(+), 6 deletions(-) diff --git a/plugins/webp-uploads/picture-element.php b/plugins/webp-uploads/picture-element.php index bcfb112c07..fba2697191 100644 --- a/plugins/webp-uploads/picture-element.php +++ b/plugins/webp-uploads/picture-element.php @@ -31,10 +31,25 @@ function webp_uploads_wrap_image_in_picture( string $image, string $context, int return $image; } - // Idempotency: bail if the input is already wrapped in a picture element, to - // avoid double-wrapping when multiple rewrite paths fire on the same markup - // (e.g. wp_get_attachment_image -> the_content -> wp_content_img_tag). - if ( false !== stripos( $image, '` string is passed in again. + * 2. Only the inner `` is passed in again. This happens when a + * `` produced for a `wp_get_attachment_image()` call is embedded + * in post content: `wp_filter_content_tags()` then extracts that inner + * `` and runs it back through this function via `wp_content_img_tag`. + * The surrounding `` is not visible at that point, so the wrapped + * `` carries a `data-wp-picture-wrapped` marker (added below) to be + * recognised here. + */ + if ( + false !== stripos( $image, '` with no `` children: if every modern-format + // source failed to resolve (e.g. the attachment has no modern sub-sizes), return + // the original markup untouched instead of a pointless empty wrapper element. + if ( '' === $picture_sources ) { + return $image; + } + + // Tag the inner `` so a later rewrite pass (for example `wp_content_img_tag` + // once this markup is embedded in post content) recognises it as already wrapped + // and skips it. See the idempotency guard above. + $marker = new WP_HTML_Tag_Processor( $image ); + if ( $marker->next_tag( array( 'tag_name' => 'IMG' ) ) ) { + $marker->set_attribute( 'data-wp-picture-wrapped', 'true' ); + $image = $marker->get_updated_html(); + } + return sprintf( '%s%s', esc_attr( 'wp-picture-' . $attachment_id ), diff --git a/plugins/webp-uploads/readme.txt b/plugins/webp-uploads/readme.txt index ca4284ca5d..0c6d13b325 100644 --- a/plugins/webp-uploads/readme.txt +++ b/plugins/webp-uploads/readme.txt @@ -69,7 +69,7 @@ By default, the Modern Image Formats plugin will only generate WebP versions of **Enhancements** -* Serve modern image formats (WebP / AVIF) from `wp_get_attachment_image()` so that `` tags produced by template tags, page builders, and custom loops use the preferred format — not only images inside `the_content`. Featured images continue to be rewritten through the same pipeline (via `the_post_thumbnail()` routing through `wp_get_attachment_image()`), so the dedicated `post_thumbnail_html` filter registration has been retired. ([523](https://github.com/WordPress/performance/issues/523)) +* Serve modern image formats (WebP / AVIF) from `wp_get_attachment_image()` so that `` tags produced by template tags, page builders, and custom loops use the preferred format — not only images inside `the_content`. Featured images continue to be rewritten through the same pipeline (via `the_post_thumbnail()` routing through `wp_get_attachment_image()`), so the dedicated `post_thumbnail_html` filter registration has been retired. In picture-element mode the inner `` of a generated `` carries a `data-wp-picture-wrapped` attribute, which keeps the rewrite idempotent if the same markup is reprocessed later (for example by `wp_content_img_tag` once it is embedded in post content). ([523](https://github.com/WordPress/performance/issues/523)) **Deprecated** diff --git a/plugins/webp-uploads/tests/test-picture-element.php b/plugins/webp-uploads/tests/test-picture-element.php index 02c0bd41a1..b9872b0d6b 100644 --- a/plugins/webp-uploads/tests/test-picture-element.php +++ b/plugins/webp-uploads/tests/test-picture-element.php @@ -180,7 +180,7 @@ public function data_provider_it_should_maybe_wrap_images_in_picture_element(): 'jpeg and picture enabled' => array( 'fallback_jpeg' => true, 'picture_element' => true, - 'expected_html' => '{{img-alt}}', + 'expected_html' => '{{img-alt}}', ), 'only picture enabled' => array( 'fallback_jpeg' => false, @@ -660,4 +660,100 @@ public function test_wrap_image_in_picture_is_idempotent(): void { $this->assertSame( $image, $twice, 'Re-wrapping an already-wrapped picture should return the input unchanged.' ); $this->assertSame( 1, substr_count( $twice, '` produced for a `wp_get_attachment_image()` call that is then + * embedded in post content must not be wrapped a second time when the content + * runs through `the_content` (`wp_filter_content_tags()` -> `wp_content_img_tag`). + * + * Regression test: `wp_filter_content_tags()` extracts only the inner `` + * substring, so the `stripos( $image, 'opt_in_to_picture_element(); + + // `wp_get_attachment_image()` now returns a ``; the inner `` + // keeps the `wp-image-{ID}` class so `wp_filter_content_tags()` can resolve + // the attachment on the content pass. + $image = wp_get_attachment_image( + self::$image_id, + 'large', + false, + array( + 'class' => 'wp-image-' . self::$image_id, + 'alt' => 'Green Leaves', + ) + ); + $this->assertStringStartsWith( 'assertSame( + 1, + substr_count( $rendered, ' element.' + ); + $this->assertSame( + 1, + substr_count( $rendered, ' element should remain after the content pass.' + ); + } + + /** + * `webp_uploads_wrap_image_in_picture()` must not emit an empty `` + * wrapper (one with no `` children) when no modern-format source can + * be resolved for the attachment. + * + * @covers ::webp_uploads_wrap_image_in_picture + */ + public function test_wrap_image_in_picture_skips_empty_wrapper_when_no_sources(): void { + $this->opt_in_to_picture_element(); + + // Let the first `wp_calculate_image_srcset()` call (core building the + // ``) succeed so the tag keeps its `srcset`/`sizes`, then force every + // later call (the per-mime-type lookups inside the wrapper) to come back + // empty, leaving no `` to emit. + $call = 0; + add_filter( + 'wp_calculate_image_srcset', + static function ( $sources ) use ( &$call ) { + ++$call; + return $call > 1 ? array() : $sources; + } + ); + + $image = wp_get_attachment_image( + self::$image_id, + 'large', + false, + array( 'class' => 'wp-image-' . self::$image_id ) + ); + + $this->assertStringNotContainsString( ' wrapper should be emitted when there are no elements.' ); + $this->assertStringStartsWith( 'assertTrue( function_exists( 'webp_uploads_update_featured_image' ) ); + + $html = wp_get_attachment_image( self::$image_id, 'large', false, array( 'class' => 'wp-image-' . self::$image_id ) ); + + // @phpstan-ignore function.deprecated (Intentionally exercising the deprecated shim.) + $result = webp_uploads_update_featured_image( $html, 0, self::$image_id ); + + $this->assertStringContainsString( ' Date: Sat, 13 Jun 2026 20:14:27 +0200 Subject: [PATCH 6/7] Update plugins/webp-uploads/hooks.php Co-authored-by: Weston Ruter --- plugins/webp-uploads/hooks.php | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/plugins/webp-uploads/hooks.php b/plugins/webp-uploads/hooks.php index 2c0028be28..15bc21dee3 100644 --- a/plugins/webp-uploads/hooks.php +++ b/plugins/webp-uploads/hooks.php @@ -583,11 +583,7 @@ function webp_uploads_filter_image_tag( string $filtered_image, string $context, * @return string The filtered HTML. */ function webp_uploads_filter_wp_get_attachment_image( string $html, int $attachment_id, $size, bool $icon, array $attr ): string { - if ( '' === $html || 0 === $attachment_id || true === $icon ) { - return $html; - } - - if ( ! webp_uploads_in_frontend_body() ) { + if ( '' === $html || 0 === $attachment_id || true === $icon || ! webp_uploads_in_frontend_body() ) { return $html; } From fd98de476f742f56f9bb8db6fa83a0ba8dea4979 Mon Sep 17 00:00:00 2001 From: adamsilverstein Date: Sat, 13 Jun 2026 20:21:08 +0200 Subject: [PATCH 7/7] webp-uploads: detect prior wrapping via tag processor, not substring match The idempotency guard in webp_uploads_wrap_image_in_picture() matched the raw `` is not visible at that point, so the wrapped * `` carries a `data-wp-picture-wrapped` marker (added below) to be * recognised here. + * + * The markup is parsed with WP_HTML_Tag_Processor rather than matched as a + * raw substring, so a literal `next_tag() ) { + if ( 'PICTURE' === $processor->get_tag() ) { + return $image; + } + if ( 'IMG' === $processor->get_tag() && null !== $processor->get_attribute( 'data-wp-picture-wrapped' ) ) { + return $image; + } } $original_file_mime_type = webp_uploads_get_attachment_file_mime_type( $attachment_id );