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 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 || ! 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.
@@ -663,25 +712,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
- *
- * @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 );
-}
-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
* core theme sizes are enabled by default.
@@ -854,6 +884,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..1c1a57c4f9 100644
--- a/plugins/webp-uploads/picture-element.php
+++ b/plugins/webp-uploads/picture-element.php
@@ -27,10 +27,40 @@
* @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 this markup has already been processed, to avoid
+ * double-wrapping when more than one rewrite path fires on the same image.
+ *
+ * Two distinct cases are guarded:
+ *
+ * 1. The full `` 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.
+ *
+ * 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 );
if ( '' === $original_file_mime_type ) {
return $image;
@@ -171,6 +201,22 @@ function webp_uploads_wrap_image_in_picture( string $image, string $context, int
}
}
+ // Never emit a `` 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 5d6f3ccde9..0c6d13b325 100644
--- a/plugins/webp-uploads/readme.txt
+++ b/plugins/webp-uploads/readme.txt
@@ -65,6 +65,16 @@ By default, the Modern Image Formats plugin will only generate WebP versions of
== Changelog ==
+= n.e.x.t =
+
+**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. 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**
+
+* 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 =
**Bug Fixes**
diff --git a/plugins/webp-uploads/tests/test-load.php b/plugins/webp-uploads/tests/test-load.php
index 0a90d88023..5e09c47bc4 100644
--- a/plugins/webp-uploads/tests/test-load.php
+++ b/plugins/webp-uploads/tests/test-load.php
@@ -1286,19 +1286,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' ) );
+ $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 );
@@ -1310,12 +1317,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();
@@ -1325,6 +1333,86 @@ public function test_webp_uploads_update_featured_image_picture_element_enabled(
$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( '.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( '.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( '.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() )
+ );
+ }
+
/**
* Check if AVIF encoding is supported.
*
diff --git a/plugins/webp-uploads/tests/test-picture-element.php b/plugins/webp-uploads/tests/test-picture-element.php
index 87ca0282c1..b9872b0d6b 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.
@@ -169,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' => '',
+ 'expected_html' => '',
),
'only picture enabled' => array(
'fallback_jpeg' => false,
@@ -570,26 +581,179 @@ 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, '` 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( '