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' => '{{img-alt}}', + 'expected_html' => '{{img-alt}}', ), '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( '