diff --git a/src/wp-includes/media.php b/src/wp-includes/media.php index 8a4277a2f57b9..82ea8f27edc09 100644 --- a/src/wp-includes/media.php +++ b/src/wp-includes/media.php @@ -1137,6 +1137,16 @@ function wp_get_attachment_image( $attachment_id, $size = 'thumbnail', $icon = f } } + // Adds 'auto' to the sizes attribute if applicable. + if ( + isset( $attr['loading'] ) && + 'lazy' === $attr['loading'] && + isset( $attr['sizes'] ) && + ! wp_sizes_attribute_includes_valid_auto( $attr['sizes'] ) + ) { + $attr['sizes'] = 'auto, ' . $attr['sizes']; + } + /** * Filters the list of attachment image attributes. * @@ -1917,6 +1927,9 @@ function wp_filter_content_tags( $content, $context = null ) { // Add loading optimization attributes if applicable. $filtered_image = wp_img_tag_add_loading_optimization_attrs( $filtered_image, $context ); + // Adds 'auto' to the sizes attribute if applicable. + $filtered_image = wp_img_tag_add_auto_sizes( $filtered_image ); + /** * Filters an img tag within the content for a given context. * @@ -1963,6 +1976,59 @@ function wp_filter_content_tags( $content, $context = null ) { return $content; } +/** + * Adds 'auto' to the sizes attribute to the image, if the image is lazy loaded and does not already include it. + * + * @since 6.7.0 + * + * @param string $image The image tag markup being filtered. + * @return string The filtered image tag markup. + */ +function wp_img_tag_add_auto_sizes( string $image ): string { + $processor = new WP_HTML_Tag_Processor( $image ); + + // Bail if there is no IMG tag. + if ( ! $processor->next_tag( array( 'tag_name' => 'IMG' ) ) ) { + return $image; + } + + // Bail early if the image is not lazy-loaded. + $value = $processor->get_attribute( 'loading' ); + if ( ! is_string( $value ) || 'lazy' !== strtolower( trim( $value, " \t\f\r\n" ) ) ) { + return $image; + } + + $sizes = $processor->get_attribute( 'sizes' ); + + // Bail early if the image is not responsive. + if ( ! is_string( $sizes ) ) { + return $image; + } + + // Don't add 'auto' to the sizes attribute if it already exists. + if ( wp_sizes_attribute_includes_valid_auto( $sizes ) ) { + return $image; + } + + $processor->set_attribute( 'sizes', "auto, $sizes" ); + return $processor->get_updated_html(); +} + +/** + * Checks whether the given 'sizes' attribute includes the 'auto' keyword as the first item in the list. + * + * Per the HTML spec, if present it must be the first entry. + * + * @since 6.7.0 + * + * @param string $sizes_attr The 'sizes' attribute value. + * @return bool True if the 'auto' keyword is present, false otherwise. + */ +function wp_sizes_attribute_includes_valid_auto( string $sizes_attr ): bool { + list( $first_size ) = explode( ',', $sizes_attr, 2 ); + return 'auto' === strtolower( trim( $first_size, " \t\f\r\n" ) ); +} + /** * Adds optimization attributes to an `img` HTML tag. * diff --git a/tests/phpunit/tests/media.php b/tests/phpunit/tests/media.php index 12d037961255e..d2edbc53747b1 100644 --- a/tests/phpunit/tests/media.php +++ b/tests/phpunit/tests/media.php @@ -2467,6 +2467,9 @@ public function test_wp_calculate_image_srcset_animated_gifs() { * @requires function imagejpeg */ public function test_wp_filter_content_tags_schemes() { + // Disable lazy loading attribute to not add the 'auto' keyword to the `sizes` attribute. + add_filter( 'wp_img_tag_add_loading_attr', '__return_false' ); + $image_meta = wp_get_attachment_metadata( self::$large_id ); $size_array = $this->get_image_size_array_from_meta( $image_meta, 'medium' ); @@ -2680,7 +2683,7 @@ public function test_wp_get_attachment_image_should_use_wp_get_attachment_metada 'src="' . $uploads_url . 'test-image-testsize-999x999.jpg" ' . 'class="attachment-testsize size-testsize" alt="" decoding="async" loading="lazy" ' . 'srcset="' . $uploads_url . 'test-image-testsize-999x999.jpg 999w, ' . $uploads_url . $basename . '-150x150.jpg 150w" ' . - 'sizes="(max-width: 999px) 100vw, 999px" />'; + 'sizes="auto, (max-width: 999px) 100vw, 999px" />'; $actual = wp_get_attachment_image( self::$large_id, 'testsize' ); @@ -5117,6 +5120,9 @@ static function ( $loading_attrs ) { } ); + // Do not calculate sizes attribute as it is irrelevant for this test. + add_filter( 'wp_calculate_image_sizes', '__return_false' ); + // Add shortcode that prints a large image, and a block type that wraps it. add_shortcode( 'full_image', @@ -6028,6 +6034,277 @@ static function ( $loading_attrs ) { ); } + /** + * Test generated markup for an image with lazy loading gets auto-sizes. + * + * @ticket 61847 + */ + public function test_image_with_lazy_loading_has_auto_sizes() { + $this->assertStringContainsString( + 'sizes="auto, ', + wp_get_attachment_image( self::$large_id, 'large', false, array( 'loading' => 'lazy' ) ), + 'Failed asserting that the sizes attribute for a lazy-loaded image includes "auto".' + ); + } + + /** + * Test generated markup for an image without lazy loading does not get auto-sizes. + * + * @ticket 61847 + */ + public function test_image_without_lazy_loading_does_not_have_auto_sizes() { + $this->assertStringNotContainsString( + 'sizes="auto, ', + wp_get_attachment_image( self::$large_id, 'large', false, array( 'loading' => false ) ), + 'Failed asserting that the sizes attribute for an image without lazy loading does not include "auto".' + ); + } + + /** + * Test content filtered markup with lazy loading gets auto-sizes. + * + * @ticket 61847 + * + * @covers ::wp_img_tag_add_auto_sizes + */ + public function test_content_image_with_lazy_loading_has_auto_sizes() { + // Force lazy loading attribute. + add_filter( 'wp_img_tag_add_loading_attr', '__return_true' ); + + $this->assertStringContainsString( + 'sizes="auto, (max-width: 1024px) 100vw, 1024px"', + wp_filter_content_tags( get_image_tag( self::$large_id, '', '', '', 'large' ) ), + 'Failed asserting that the sizes attribute for a content image with lazy loading includes "auto" with the expected sizes.' + ); + } + + /** + * Test content filtered markup without lazy loading does not get auto-sizes. + * + * @ticket 61847 + * + * @covers ::wp_img_tag_add_auto_sizes + */ + public function test_content_image_without_lazy_loading_does_not_have_auto_sizes() { + // Disable lazy loading attribute. + add_filter( 'wp_img_tag_add_loading_attr', '__return_false' ); + + $this->assertStringNotContainsString( + 'sizes="auto, ', + wp_filter_content_tags( get_image_tag( self::$large_id, '', '', '', 'large' ) ), + 'Failed asserting that the sizes attribute for a content image without lazy loading does not include "auto" with the expected sizes.' + ); + } + + /** + * Test generated markup for an image with 'auto' keyword already present in sizes does not receive it again. + * + * @ticket 61847 + * + * @covers ::wp_img_tag_add_auto_sizes + * @covers ::wp_sizes_attribute_includes_valid_auto + * + * @dataProvider data_image_with_existing_auto_sizes + * + * @param string $initial_sizes The initial sizes attribute to test. + * @param bool $expected_processed Whether the auto sizes should be processed or not. + */ + public function test_image_with_existing_auto_sizes_is_not_processed_again( string $initial_sizes, bool $expected_processed ) { + $image_tag = wp_get_attachment_image( + self::$large_id, + 'large', + false, + array( + // Force pre-existing 'sizes' attribute and lazy-loading. + 'sizes' => $initial_sizes, + 'loading' => 'lazy', + ) + ); + if ( $expected_processed ) { + $this->assertStringContainsString( + 'sizes="auto, ' . $initial_sizes . '"', + $image_tag, + 'Failed asserting that "auto" keyword is not added to sizes attribute when it already exists.' + ); + } else { + $this->assertStringContainsString( + 'sizes="' . $initial_sizes . '"', + $image_tag, + 'Failed asserting that "auto" keyword is not added to sizes attribute when it already exists.' + ); + } + } + + /** + * Test content filtered markup with 'auto' keyword already present in sizes does not receive it again. + * + * @ticket 61847 + * + * @covers ::wp_img_tag_add_auto_sizes + * @covers ::wp_sizes_attribute_includes_valid_auto + * + * @dataProvider data_image_with_existing_auto_sizes + * + * @param string $initial_sizes The initial sizes attribute to test. + * @param bool $expected_processed Whether the auto sizes should be processed or not. + */ + public function test_content_image_with_existing_auto_sizes_is_not_processed_again( string $initial_sizes, bool $expected_processed ) { + // Force lazy loading attribute. + add_filter( 'wp_img_tag_add_loading_attr', '__return_true' ); + + add_filter( + 'get_image_tag', + static function ( $html ) use ( $initial_sizes ) { + return str_replace( + '" />', + '" sizes="' . $initial_sizes . '" />', + $html + ); + } + ); + + $image_content = wp_filter_content_tags( get_image_tag( self::$large_id, '', '', '', 'large' ) ); + if ( $expected_processed ) { + $this->assertStringContainsString( + 'sizes="auto, ' . $initial_sizes . '"', + $image_content, + 'Failed asserting that "auto" keyword is not added to sizes attribute in filtered content when it already exists.' + ); + } else { + $this->assertStringContainsString( + 'sizes="' . $initial_sizes . '"', + $image_content, + 'Failed asserting that "auto" keyword is not added to sizes attribute in filtered content when it already exists.' + ); + } + } + + /** + * Returns data for the above test methods to assert correct behavior with a pre-existing sizes attribute. + * + * @return array Arguments for the test scenarios. + */ + public function data_image_with_existing_auto_sizes() { + return array( + 'not present' => array( + '(max-width: 1024px) 100vw, 1024px', + true, + ), + 'in beginning, without space' => array( + 'auto,(max-width: 1024px) 100vw, 1024px', + false, + ), + 'in beginning, with space' => array( + 'auto, (max-width: 1024px) 100vw, 1024px', + false, + ), + 'sole keyword' => array( + 'auto', + false, + ), + 'with space before' => array( + ' auto, (max-width: 1024px) 100vw, 1024px', + false, + ), + 'with uppercase' => array( + 'AUTO, (max-width: 1024px) 100vw, 1024px', + false, + ), + + /* + * The following scenarios technically include the 'auto' keyword, + * but it is in the wrong place, as per the HTML spec it must be + * the first entry in the list. + * Therefore in these invalid cases the 'auto' keyword should still + * be added to the beginning of the list. + */ + 'within, without space' => array( + '(max-width: 1024px) 100vw, auto,1024px', + true, + ), + 'within, with space' => array( + '(max-width: 1024px) 100vw, auto, 1024px', + true, + ), + 'at the end, without space' => array( + '(max-width: 1024px) 100vw,auto', + true, + ), + 'at the end, with space' => array( + '(max-width: 1024px) 100vw, auto', + true, + ), + ); + } + + /** + * Data provider for test_wp_img_tag_add_auto_sizes(). + * + * @return array + */ + public function data_provider_to_test_wp_img_tag_add_auto_sizes() { + return array( + 'expected_with_single_quoted_attributes' => array( + 'input' => "", + 'expected' => "", + ), + 'expected_with_data_sizes_attribute' => array( + 'input' => '', + 'expected' => '', + ), + 'expected_with_data_sizes_attribute_already_present' => array( + 'input' => '', + 'expected' => '', + ), + 'not_expected_with_loading_lazy_in_attr_value' => array( + 'input' => '\'This', + 'expected' => '\'This', + ), + 'not_expected_with_data_loading_attribute_present' => array( + 'input' => '', + 'expected' => '', + ), + 'expected_when_attributes_have_spaces_after_them' => array( + 'input' => '', + 'expected' => '', + ), + 'expected_when_attributes_are_upper_case' => array( + 'input' => '', + 'expected' => '', + ), + 'expected_when_loading_lazy_lacks_quotes' => array( + 'input' => '', + 'expected' => '', + ), + 'expected_when_loading_lazy_has_whitespace' => array( + 'input' => '', + 'expected' => '', + ), + 'not_expected_when_sizes_auto_lacks_quotes' => array( + 'input' => '', + 'expected' => '', + ), + ); + } + + /** + * @ticket 61847 + * + * @covers ::wp_img_tag_add_auto_sizes + * + * @dataProvider data_provider_to_test_wp_img_tag_add_auto_sizes + * + * @param string $input The input HTML string. + * @param string $expected The expected output HTML string. + */ + public function test_wp_img_tag_add_auto_sizes( string $input, string $expected ) { + $this->assertSame( + $expected, + wp_img_tag_add_auto_sizes( $input ), + 'Failed asserting that "auto" keyword is correctly added or not added to sizes attribute in the image tag.' + ); + } + /** * Helper method to keep track of the last context returned by the 'wp_get_attachment_image_context' filter. *