Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions plugins/webp-uploads/deprecated.php
Original file line number Diff line number Diff line change
Expand Up @@ -71,3 +71,25 @@ function webp_uploads_media_setting_style(): void {
</style>
<?php
}

/**
* Updates the references of the featured image to the new image format if available.
*
* @since 1.0.0
* @deprecated n.e.x.t Featured images are now rewritten through the `wp_get_attachment_image`
* filter; see webp_uploads_filter_wp_get_attachment_image().
*
* @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 {
_deprecated_function( __FUNCTION__, 'Modern Image Formats n.e.x.t', 'webp_uploads_filter_wp_get_attachment_image()' );

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 );
}
74 changes: 55 additions & 19 deletions plugins/webp-uploads/hooks.php
Original file line number Diff line number Diff line change
Expand Up @@ -562,6 +562,56 @@ function webp_uploads_filter_image_tag( string $filtered_image, string $context,
return $filtered_image;
}

/**
* Filters `wp_get_attachment_image` so `<img>` 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 `<img>` (or `<picture>` 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 n.e.x.t
*
* @param string $html HTML img element or empty string on failure.
* @param int $attachment_id Image attachment ID.
* @param string|array{int, int} $size Requested image size.
* @param bool $icon Whether the image should fall back to a mime type icon.
* @param array<string, string> $attr Array of attribute values for the image markup, keyed by attribute name.
* @phpstan-param int<1, max> $attachment_id
* @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 n.e.x.t
*
* @param bool $should_filter Whether to apply modern-format rewriting. Default true.
* @param int<1, max> $attachment_id Image attachment ID.
* @param string|array{int, int} $size Requested image size.
* @param array<string, 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.
Expand Down Expand Up @@ -663,25 +713,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.
Expand Down Expand Up @@ -854,6 +885,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 `<img>` 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 );
Expand Down
48 changes: 47 additions & 1 deletion plugins/webp-uploads/picture-element.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 `<picture>` string is passed in again.
* 2. Only the inner `<img>` is passed in again. This happens when a
* `<picture>` produced for a `wp_get_attachment_image()` call is embedded
* in post content: `wp_filter_content_tags()` then extracts that inner
* `<img>` and runs it back through this function via `wp_content_img_tag`.
* The surrounding `<picture>` is not visible at that point, so the wrapped
* `<img>` 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 `<picture` or `data-wp-picture-wrapped` string
* appearing inside an attribute value (such as `alt` text) cannot trigger a
* false positive.
*/
$processor = new WP_HTML_Tag_Processor( $image );
while ( $processor->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;
Expand Down Expand Up @@ -171,6 +201,22 @@ function webp_uploads_wrap_image_in_picture( string $image, string $context, int
}
}

// Never emit a `<picture>` with no `<source>` 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 `<img>` 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(
'<picture class="%s" style="display: contents;">%s%s</picture>',
esc_attr( 'wp-picture-' . $attachment_id ),
Expand Down
98 changes: 93 additions & 5 deletions plugins/webp-uploads/tests/test-load.php
Original file line number Diff line number Diff line change
Expand Up @@ -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 );
Expand All @@ -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();
Expand All @@ -1325,6 +1333,86 @@ public function test_webp_uploads_update_featured_image_picture_element_enabled(
$this->assertStringStartsWith( '<picture ', $featured_image );
}

/**
* @covers ::webp_uploads_filter_wp_get_attachment_image
*/
public function test_wp_get_attachment_image_is_rewritten_to_webp(): void {
$this->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 = '<img src="https://example.test/wp-content/uploads/2024/01/something.jpg">';
$this->assertSame(
$html,
webp_uploads_filter_wp_get_attachment_image( $html, 0, 'medium', true, array() )
);
}

/**
* Check if AVIF encoding is supported.
*
Expand Down
Loading
Loading