Skip to content
Merged
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
2 changes: 1 addition & 1 deletion composer.json
Original file line number Diff line number Diff line change
Expand Up @@ -111,7 +111,7 @@
"Yoast\\WP\\SEO\\Composer\\Actions::check_coding_standards"
],
"check-cs-thresholds": [
"@putenv YOASTCS_THRESHOLD_ERRORS=2393",
"@putenv YOASTCS_THRESHOLD_ERRORS=2392",
"@putenv YOASTCS_THRESHOLD_WARNINGS=257",
"Yoast\\WP\\SEO\\Composer\\Actions::check_cs_thresholds"
],
Expand Down
128 changes: 77 additions & 51 deletions src/integrations/blocks/structured-data-blocks.php
Original file line number Diff line number Diff line change
Expand Up @@ -246,57 +246,7 @@ private function optimize_images( $elements, $key, $content ) {
// Then replace all images with optimized versions in the content.
$content = \preg_replace_callback(
'/<img[^>]+>/',
function ( $matches ) {
\preg_match( '/src="([^"]+)"/', $matches[0], $src_matches );
if ( ! $src_matches || ! isset( $src_matches[1] ) ) {
return $matches[0];
}
$attachment_id = $this->attachment_src_to_id( $src_matches[1] );
if ( $attachment_id === 0 ) {
return $matches[0];
}
$image_size = 'full';
$image_style = [ 'style' => 'max-width: 100%; height: auto;' ];
\preg_match( '/style="[^"]*width:\s*(\d+)px[^"]*"/', $matches[0], $style_matches );
if ( $style_matches && isset( $style_matches[1] ) ) {
$width = (int) $style_matches[1];
$meta_data = \wp_get_attachment_metadata( $attachment_id );
if ( isset( $meta_data['height'] ) && isset( $meta_data['width'] ) && $meta_data['height'] > 0 && $meta_data['width'] > 0 ) {
$aspect_ratio = ( $meta_data['height'] / $meta_data['width'] );
$height = ( $width * $aspect_ratio );
$image_size = [ $width, $height ];
}
$image_style = '';
}

/**
* Filter: 'wpseo_structured_data_blocks_image_size' - Allows adjusting the image size in structured data blocks.
*
* @since 18.2
*
* @param string|int[] $image_size The image size. Accepts any registered image size name, or an array of width and height values in pixels (in that order).
* @param int $attachment_id The id of the attachment.
* @param string $attachment_src The attachment src.
*/
$image_size = \apply_filters(
'wpseo_structured_data_blocks_image_size',
$image_size,
$attachment_id,
$src_matches[1],
);
$image_html = \wp_get_attachment_image(
$attachment_id,
$image_size,
false,
$image_style,
);

if ( empty( $image_html ) ) {
return $matches[0];
}

return $image_html;
},
[ $this, 'replace_image_with_optimized_version' ],
$content,
);

Expand All @@ -308,6 +258,82 @@ function ( $matches ) {
return $content;
}

/**
* Replaces an image tag with an optimized version while preserving inline alt text.
*
* @param string[] $matches The regex matches from preg_replace_callback.
*
* @return string The optimized image HTML or original if optimization fails.
*/
private function replace_image_with_optimized_version( $matches ) {
\preg_match( '/src="([^"]+)"/', $matches[0], $src_matches );
if ( ! $src_matches || ! isset( $src_matches[1] ) ) {
return $matches[0];
}
$attachment_id = $this->attachment_src_to_id( $src_matches[1] );
if ( $attachment_id === 0 ) {
return $matches[0];
}

// Extract the alt text from the original image HTML, only if an alt attribute is present.
$has_alt = (bool) \preg_match( '/alt="([^"]*)"/', $matches[0], $alt_matches );

$image_size = 'full';
$image_attrs = [
'style' => 'max-width: 100%; height: auto;',
];
Comment on lines +282 to +284
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

$image_attrs always includes an 'alt' key, so when no alt is present in the original <img> this will pass alt => '' into wp_get_attachment_image() and override the attachment’s stored alt text (previously WordPress would fall back to media-library alt when alt isn’t provided). To keep backward compatibility, only add the 'alt' attribute to $image_attrs when the original HTML contains an alt attribute (while still preserving an explicitly empty alt="").

Copilot uses AI. Check for mistakes.

// Only override alt when the original image had an explicit alt attribute.
if ( $has_alt ) {
// Decode HTML entities since wp_get_attachment_image() will encode them again.
$image_attrs['alt'] = \html_entity_decode( $alt_matches[1], ( \ENT_QUOTES | \ENT_HTML5 ), 'UTF-8' );
}

\preg_match( '/style="[^"]*width:\s*(\d+)px[^"]*"/', $matches[0], $style_matches );
if ( $style_matches && isset( $style_matches[1] ) ) {
$width = (int) $style_matches[1];
$meta_data = \wp_get_attachment_metadata( $attachment_id );
if ( isset( $meta_data['height'] ) && isset( $meta_data['width'] ) && $meta_data['height'] > 0 && $meta_data['width'] > 0 ) {
$aspect_ratio = ( $meta_data['height'] / $meta_data['width'] );
$height = ( $width * $aspect_ratio );
$image_size = [ $width, $height ];
}
// When using a specific image size, don't include the style attribute.
$image_attrs = [];
if ( $has_alt ) {
$image_attrs['alt'] = \html_entity_decode( $alt_matches[1], ( \ENT_QUOTES | \ENT_HTML5 ), 'UTF-8' );
}
}

/**
* Filter: 'wpseo_structured_data_blocks_image_size' - Allows adjusting the image size in structured data blocks.
*
* @since 18.2
*
* @param string|int[] $image_size The image size. Accepts any registered image size name, or an array of width and height values in pixels (in that order).
* @param int $attachment_id The id of the attachment.
* @param string $attachment_src The attachment src.
*/
$image_size = \apply_filters(
'wpseo_structured_data_blocks_image_size',
$image_size,
$attachment_id,
$src_matches[1],
);
$image_html = \wp_get_attachment_image(
$attachment_id,
$image_size,
false,
$image_attrs,
);

if ( empty( $image_html ) ) {
return $matches[0];
}

return $image_html;
}

/**
* If the caches of structured data block images have been changed, saves them.
*
Expand Down
238 changes: 238 additions & 0 deletions tests/Unit/Integrations/Blocks/Structured_Data_Blocks_Test.php
Original file line number Diff line number Diff line change
Expand Up @@ -273,4 +273,242 @@ public function test_optimize_how_to_images( $expected, $attributes, $content, $
$message,
);
}

/**
* Tests that custom inline alt text is preserved.
*
* @covers ::optimize_how_to_images
*
* @return void
*/
public function test_optimize_how_to_images_preserves_custom_inline_alt_text() {
global $post;
$post = (object) [ 'ID' => 42 ];

$src = 'https://example.com/image.jpg';
$content = '<p><img src="' . $src . '" alt="Custom inline alt text" /></p>';

Monkey\Functions\expect( 'register_shutdown_function' )
->once()
->withAnyArgs()
->andReturn( true );

Monkey\Functions\expect( 'apply_filters' )
->once()
->with( 'wpseo_structured_data_blocks_image_size', 'full', 123, $src )
->andReturn( 'full' );

Monkey\Functions\expect( 'wp_get_attachment_image' )
->once()
->with(
123,
'full',
false,
[
'style' => 'max-width: 100%; height: auto;',
'alt' => 'Custom inline alt text',
],
)
->andReturn( '<img src="' . $src . '" alt="Custom inline alt text" />' );

$attributes = [
'steps' => [
[
'images' => [
[
'type' => 'img',
'key' => 123,
'props' => [
'src' => $src,
],
],
],
],
],
];

$this->assertSame(
'<p><img src="' . $src . '" alt="Custom inline alt text" /></p>',
$this->instance->optimize_how_to_images( $attributes, $content ),
);
}

/**
* Tests that entities in alt text are decoded before image generation and re-encoded in output.
*
* @covers ::optimize_how_to_images
*
* @return void
*/
public function test_optimize_how_to_images_decodes_and_reencodes_alt_entities() {
global $post;
$post = (object) [ 'ID' => 43 ];

$src = 'https://example.com/image.jpg';
$encoded_alt = 'Tom &amp; Jerry&#039;s &quot;Guide&quot;';
$decoded_alt = 'Tom & Jerry\'s "Guide"';
$re_encoded_alt = \htmlspecialchars( $decoded_alt, ( \ENT_QUOTES | \ENT_HTML5 ), 'UTF-8' );
$content = '<p><img src="' . $src . '" alt="' . $encoded_alt . '" /></p>';

Monkey\Functions\expect( 'register_shutdown_function' )
->once()
->withAnyArgs()
->andReturn( true );

Monkey\Functions\expect( 'apply_filters' )
->once()
->with( 'wpseo_structured_data_blocks_image_size', 'full', 123, $src )
->andReturn( 'full' );

Monkey\Functions\expect( 'wp_get_attachment_image' )
->once()
->with(
123,
'full',
false,
[
'style' => 'max-width: 100%; height: auto;',
'alt' => $decoded_alt,
],
)
->andReturn( '<img src="' . $src . '" alt="' . $re_encoded_alt . '" />' );

$attributes = [
'steps' => [
[
'images' => [
[
'type' => 'img',
'key' => 123,
'props' => [
'src' => $src,
],
],
],
],
],
];

$this->assertSame(
'<p><img src="' . $src . '" alt="' . $re_encoded_alt . '" /></p>',
$this->instance->optimize_how_to_images( $attributes, $content ),
);
}

/**
* Tests that an empty alt attribute is preserved.
*
* @covers ::optimize_how_to_images
*
* @return void
*/
public function test_optimize_how_to_images_preserves_empty_alt_attribute() {
global $post;
$post = (object) [ 'ID' => 44 ];

$src = 'https://example.com/image.jpg';
$content = '<p><img src="' . $src . '" alt="" /></p>';

Monkey\Functions\expect( 'register_shutdown_function' )
->once()
->withAnyArgs()
->andReturn( true );

Monkey\Functions\expect( 'apply_filters' )
->once()
->with( 'wpseo_structured_data_blocks_image_size', 'full', 123, $src )
->andReturn( 'full' );

Monkey\Functions\expect( 'wp_get_attachment_image' )
->once()
->with(
123,
'full',
false,
[
'style' => 'max-width: 100%; height: auto;',
'alt' => '',
],
)
->andReturn( '<img src="' . $src . '" alt="" />' );

$attributes = [
'steps' => [
[
'images' => [
[
'type' => 'img',
'key' => 123,
'props' => [
'src' => $src,
],
],
],
],
],
];

$this->assertSame(
'<p><img src="' . $src . '" alt="" /></p>',
$this->instance->optimize_how_to_images( $attributes, $content ),
);
}

/**
* Tests that missing alt attribute falls back to media library alt text.
*
* @covers ::optimize_how_to_images
*
* @return void
*/
public function test_optimize_how_to_images_missing_alt_falls_back_to_media_library() {
global $post;
$post = (object) [ 'ID' => 45 ];

$src = 'https://example.com/image.jpg';
$content = '<p><img src="' . $src . '" /></p>';

Monkey\Functions\expect( 'register_shutdown_function' )
->once()
->withAnyArgs()
->andReturn( true );

Monkey\Functions\expect( 'apply_filters' )
->once()
->with( 'wpseo_structured_data_blocks_image_size', 'full', 123, $src )
->andReturn( 'full' );

Monkey\Functions\expect( 'wp_get_attachment_image' )
->once()
->with(
123,
'full',
false,
[
'style' => 'max-width: 100%; height: auto;',
],
Comment on lines +486 to +489
Copy link

Copilot AI Mar 20, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This test codifies that when the original <img> has no alt attribute, the code should pass 'alt' => '' into wp_get_attachment_image(). That would override (and potentially erase) a non-empty attachment alt from the media library. For backward compatibility, the expectation should be that no alt key is passed when missing in the original HTML, letting WordPress fall back to attachment meta.

Copilot uses AI. Check for mistakes.
)
->andReturn( '<img src="' . $src . '" alt="Media library alt" />' );

$attributes = [
'steps' => [
[
'images' => [
[
'type' => 'img',
'key' => 123,
'props' => [
'src' => $src,
],
],
],
],
],
];

$this->assertSame(
'<p><img src="' . $src . '" alt="Media library alt" /></p>',
$this->instance->optimize_how_to_images( $attributes, $content ),
);
}
}
Loading