Skip to content
Merged

Release #1222

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
20 changes: 10 additions & 10 deletions composer.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions inc/lazyload_replacer.php
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,13 @@ final class Optml_Lazyload_Replacer extends Optml_App_Replacer {
const IFRAME_PLACEHOLDER_CLASS = '
iframe[data-opt-src]:not([data-opt-lazy-loaded]) {
background-color: #ffffff;
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2240%22%20height%3D%2240%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20style%3D%22-webkit-transform-origin%3A50%25%2050%25%3B-webkit-animation%3Aspin%201.5s%20linear%20infinite%3B-webkit-backface-visibility%3Ahidden%3Banimation%3Aspin%201.5s%20linear%20infinite%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cdefs%3E%3Cstyle%3E%3C%21%5BCDATA%5B%40-webkit-keyframes%20spin%7Bfrom%7B-webkit-transform%3Arotate%280deg%29%7Dto%7B-webkit-transform%3Arotate%28-359deg%29%7D%7D%40keyframes%20spin%7Bfrom%7Btransform%3Arotate%280deg%29%7Dto%7Btransform%3Arotate%28-359deg%29%7D%7D%5D%5D%3E%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22outer%22%3E%3Cpath%20d%3D%22M20%200a3.994%203.994%200%20110%207.988A3.994%203.994%200%200120%200z%22%2F%3E%3Cpath%20d%3D%22M5.858%205.858a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23d2d2d2%22%2F%3E%3Cpath%20d%3D%22M20%2032.012A3.994%203.994%200%201120%2040a3.994%203.994%200%20010-7.988z%22%20fill%3D%22%23828282%22%2F%3E%3Cpath%20d%3D%22M28.494%2028.494a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23656565%22%2F%3E%3Cpath%20d%3D%22M3.994%2016.006a3.994%203.994%200%20110%207.988%203.994%203.994%200%20010-7.988z%22%20fill%3D%22%23bbb%22%2F%3E%3Cpath%20d%3D%22M5.858%2028.494a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23a4a4a4%22%2F%3E%3Cpath%20d%3D%22M36.006%2016.006a3.994%203.994%200%20110%207.988%203.994%203.994%200%20010-7.988z%22%20fill%3D%22%234a4a4a%22%2F%3E%3Cpath%20d%3D%22M28.494%205.858a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23323232%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
background-image: url(data:image/svg+xml,%3Csvg%20width%3D%2240%22%20height%3D%2240%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20style%3D%22-webkit-transform-origin%3A50%25%2050%25%3B-webkit-animation%3Aspin%201.5s%20linear%20infinite%3B-webkit-backface-visibility%3Ahidden%3Banimation%3Aspin%201.5s%20linear%20infinite%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cdefs%3E%3Cstyle%3E%3C%21%5BCDATA%5B%40-webkit-keyframes%20spin%7Bfrom%7B-webkit-transform%3Arotate%280deg%29%7Dto%7B-webkit-transform%3Arotate%28-359deg%29%7D%7D%40keyframes%20spin%7Bfrom%7Btransform%3Arotate%280deg%29%7Dto%7Btransform%3Arotate%28-359deg%29%7D%7D%5D%5D%3E%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22outer%22%3E%3Cpath%20d%3D%22M20%200a3.994%203.994%200%20110%207.988A3.994%203.994%200%200120%200z%22%2F%3E%3Cpath%20d%3D%22M5.858%205.858a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23d2d2d2%22%2F%3E%3Cpath%20d%3D%22M20%2032.012A3.994%203.994%200%201120%2040a3.994%203.994%200%20010-7.988z%22%20fill%3D%22%23828282%22%2F%3E%3Cpath%20d%3D%22M28.494%2028.494a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23656565%22%2F%3E%3Cpath%20d%3D%22M3.994%2016.006a3.994%203.994%200%20110%207.988%203.994%203.994%200%20010-7.988z%22%20fill%3D%22%23bbb%22%2F%3E%3Cpath%20d%3D%22M5.858%2028.494a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23a4a4a4%22%2F%3E%3Cpath%20d%3D%22M36.006%2016.006a3.994%203.994%200%20110%207.988%203.994%203.994%200%20010-7.988z%22%20fill%3D%22%234a4a4a%22%2F%3E%3Cpath%20d%3D%22M28.494%205.858a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23323232%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E);
background-repeat: no-repeat;
background-position: 50% 50%;
}
video[data-opt-src]:not([data-opt-lazy-loaded]) {
background-color: #ffffff;
background-image: url("data:image/svg+xml,%3Csvg%20width%3D%2240%22%20height%3D%2240%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20style%3D%22-webkit-transform-origin%3A50%25%2050%25%3B-webkit-animation%3Aspin%201.5s%20linear%20infinite%3B-webkit-backface-visibility%3Ahidden%3Banimation%3Aspin%201.5s%20linear%20infinite%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cdefs%3E%3Cstyle%3E%3C%21%5BCDATA%5B%40-webkit-keyframes%20spin%7Bfrom%7B-webkit-transform%3Arotate%280deg%29%7Dto%7B-webkit-transform%3Arotate%28-359deg%29%7D%7D%40keyframes%20spin%7Bfrom%7Btransform%3Arotate%280deg%29%7Dto%7Btransform%3Arotate%28-359deg%29%7D%7D%5D%5D%3E%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22outer%22%3E%3Cpath%20d%3D%22M20%200a3.994%203.994%200%20110%207.988A3.994%203.994%200%200120%200z%22%2F%3E%3Cpath%20d%3D%22M5.858%205.858a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23d2d2d2%22%2F%3E%3Cpath%20d%3D%22M20%2032.012A3.994%203.994%200%201120%2040a3.994%203.994%200%20010-7.988z%22%20fill%3D%22%23828282%22%2F%3E%3Cpath%20d%3D%22M28.494%2028.494a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23656565%22%2F%3E%3Cpath%20d%3D%22M3.994%2016.006a3.994%203.994%200%20110%207.988%203.994%203.994%200%20010-7.988z%22%20fill%3D%22%23bbb%22%2F%3E%3Cpath%20d%3D%22M5.858%2028.494a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23a4a4a4%22%2F%3E%3Cpath%20d%3D%22M36.006%2016.006a3.994%203.994%200%20110%207.988%203.994%203.994%200%20010-7.988z%22%20fill%3D%22%234a4a4a%22%2F%3E%3Cpath%20d%3D%22M28.494%205.858a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23323232%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E");
background-image: url(data:image/svg+xml,%3Csvg%20width%3D%2240%22%20height%3D%2240%22%20xmlns%3D%22http%3A%2F%2Fwww.w3.org%2F2000%2Fsvg%22%20style%3D%22-webkit-transform-origin%3A50%25%2050%25%3B-webkit-animation%3Aspin%201.5s%20linear%20infinite%3B-webkit-backface-visibility%3Ahidden%3Banimation%3Aspin%201.5s%20linear%20infinite%22%20fill-rule%3D%22evenodd%22%20clip-rule%3D%22evenodd%22%20stroke-linejoin%3D%22round%22%20stroke-miterlimit%3D%221.414%22%3E%3Cdefs%3E%3Cstyle%3E%3C%21%5BCDATA%5B%40-webkit-keyframes%20spin%7Bfrom%7B-webkit-transform%3Arotate%280deg%29%7Dto%7B-webkit-transform%3Arotate%28-359deg%29%7D%7D%40keyframes%20spin%7Bfrom%7Btransform%3Arotate%280deg%29%7Dto%7Btransform%3Arotate%28-359deg%29%7D%7D%5D%5D%3E%3C%2Fstyle%3E%3C%2Fdefs%3E%3Cg%20id%3D%22outer%22%3E%3Cpath%20d%3D%22M20%200a3.994%203.994%200%20110%207.988A3.994%203.994%200%200120%200z%22%2F%3E%3Cpath%20d%3D%22M5.858%205.858a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23d2d2d2%22%2F%3E%3Cpath%20d%3D%22M20%2032.012A3.994%203.994%200%201120%2040a3.994%203.994%200%20010-7.988z%22%20fill%3D%22%23828282%22%2F%3E%3Cpath%20d%3D%22M28.494%2028.494a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23656565%22%2F%3E%3Cpath%20d%3D%22M3.994%2016.006a3.994%203.994%200%20110%207.988%203.994%203.994%200%20010-7.988z%22%20fill%3D%22%23bbb%22%2F%3E%3Cpath%20d%3D%22M5.858%2028.494a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23a4a4a4%22%2F%3E%3Cpath%20d%3D%22M36.006%2016.006a3.994%203.994%200%20110%207.988%203.994%203.994%200%20010-7.988z%22%20fill%3D%22%234a4a4a%22%2F%3E%3Cpath%20d%3D%22M28.494%205.858a3.994%203.994%200%20115.648%205.648%203.994%203.994%200%2001-5.648-5.648z%22%20fill%3D%22%23323232%22%2F%3E%3C%2Fg%3E%3C%2Fsvg%3E);
background-repeat: no-repeat;
background-position: 50% 50%;
}';
Expand Down
30 changes: 25 additions & 5 deletions inc/manager.php
Original file line number Diff line number Diff line change
Expand Up @@ -499,6 +499,30 @@ public function should_send_temporary_cache_header( $sent_headers = null, $do_no
*/
return apply_filters( 'optml_send_temporary_cache_header', $send ) === true;
}

/**
* Expand the iframe placeholder marker printed in <head>, or inject the placeholder style there.
*
* Copies of the marker after the head come from page content and are left untouched.
* The head ends at </head>, or at <body when the optional </head> is omitted.
*
* @param string $html Page HTML.
*
* @return string HTML with the iframe placeholder CSS added.
*/
private function add_iframe_placeholder_css( string $html ): string {
if ( ! preg_match( '/<\/head>|<body[\s>]/i', $html, $head_end, PREG_OFFSET_CAPTURE ) ) {
return $html;
}

$head = substr( $html, 0, $head_end[0][1] );
if ( strpos( $head, Optml_Lazyload_Replacer::IFRAME_TEMP_COMMENT ) !== false ) {
return str_replace( Optml_Lazyload_Replacer::IFRAME_TEMP_COMMENT, Optml_Lazyload_Replacer::IFRAME_PLACEHOLDER_CLASS, $head ) . substr( $html, $head_end[0][1] );
}

return preg_replace( '/<head>(.*)<\/head>/ism', '<head> $1' . Optml_Lazyload_Replacer::IFRAME_PLACEHOLDER_STYLE . '</head>', $html ) ?? $html;
}

/**
* Filter raw HTML content for urls.
*
Expand Down Expand Up @@ -553,11 +577,7 @@ public function replace_content( $html, $partial = false ) {
if ( $this->settings->get( 'video_lazyload' ) === 'enabled' ) {
$html = apply_filters( 'optml_video_replace', $html );
if ( Optml_Lazyload_Replacer::found_iframe() === true ) {
if ( strpos( $html, Optml_Lazyload_Replacer::IFRAME_TEMP_COMMENT ) !== false ) {
$html = str_replace( Optml_Lazyload_Replacer::IFRAME_TEMP_COMMENT, Optml_Lazyload_Replacer::IFRAME_PLACEHOLDER_CLASS, $html );
} else {
$html = preg_replace( '/<head>(.*)<\/head>/ism', '<head> $1' . Optml_Lazyload_Replacer::IFRAME_PLACEHOLDER_STYLE . '</head>', $html );
}
$html = $this->add_iframe_placeholder_css( $html );
}
}

Expand Down
2 changes: 1 addition & 1 deletion inc/rest.php
Original file line number Diff line number Diff line change
Expand Up @@ -1051,7 +1051,7 @@ function ( $url ) {
if ( ! empty( $missing_srcsets ) ) {
foreach ( $missing_srcsets as $id => $srcset ) {
foreach ( $srcset as $size ) {
if ( ! isset( $size['w'], $size['h'], $size['d'], $size['s'], $size['b'] ) ) {
if ( ! isset( $size['w'], $size['h'], $size['d'], $size['s'], $size['b'] ) || ! Optml_Tag_Replacer::is_srcset_descriptor( $size['s'] ) ) {
continue;
}
$sanitized_missing_srcsets[ intval( $id ) ][ intval( $size['w'] ) ] = [ 'h' => intval( $size['h'] ), 'w' => intval( $size['w'] ), 'd' => intval( $size['d'] ), 's' => sanitize_text_field( $size['s'] ), 'b' => intval( $size['b'] ) ];
Expand Down
36 changes: 32 additions & 4 deletions inc/tag_replacer.php
Original file line number Diff line number Diff line change
Expand Up @@ -312,23 +312,51 @@ public function process_image_tags( $content, $images = [] ) {
}

if ( strpos( $image_tag, 'decoding=' ) === false ) {
$image_tag = str_replace( 'data-opt-id=', 'decoding=async data-opt-id=', $image_tag );
$image_tag = $this->add_image_attribute( $image_tag, 'decoding=async' );
}
if ( strpos( $image_tag, 'width=' ) === false || strpos( $image_tag, 'height=' ) === false ) {
$missing_dimensions = Optml_Manager::instance()->page_profiler->get_missing_dimensions( $image_id );
if ( ! empty( $missing_dimensions ) ) {
$dimensions = [];
if ( strpos( $image_tag, 'width=' ) === false ) {
$image_tag = str_replace( 'data-opt-id=', 'width="' . $missing_dimensions['w'] . '" data-opt-id=', $image_tag );
$dimensions[] = 'width="' . (int) $missing_dimensions['w'] . '"';
}
if ( strpos( $image_tag, 'height=' ) === false ) {
$image_tag = str_replace( 'data-opt-id=', 'height="' . $missing_dimensions['h'] . '" data-opt-id=', $image_tag );
$dimensions[] = 'height="' . (int) $missing_dimensions['h'] . '"';
}
$image_tag = $this->add_image_attribute( $image_tag, implode( ' ', $dimensions ) );
}
}
$content = str_replace( $images['img_tag'][ $index ], $image_tag, $content );
}
return $content;
}
/**
* Add an attribute to each image tag in the markup: the image and, when lazyloaded, its noscript fallback.
*
* The image parser ends a tag at its first ">", so "<img" at the start or right after a ">" always opens a tag
* and is never text inside an attribute value.
*
* @param string $image_tag The image markup.
* @param string $attribute The attribute, such as width="300".
*
* @return string The markup with the attribute added.
*/
private function add_image_attribute( string $image_tag, string $attribute ): string {
return preg_replace( '/(^|>)(\s*)<img\b/i', '$1$2<img ' . $attribute, $image_tag ) ?? $image_tag;
}

/**
* Check whether a value is a srcset width descriptor, the only kind the page profiler reports.
*
* @param mixed $descriptor The descriptor, such as "400w".
*
* @return bool True for a width descriptor.
*/
public static function is_srcset_descriptor( $descriptor ): bool {
return is_string( $descriptor ) && 1 === preg_match( '/^\d+w\z/', $descriptor );
}

/**
* Check if we should lazyload a gif.
*
Expand Down Expand Up @@ -508,7 +536,7 @@ public function add_missing_srcset_attributes( $tag, $missing_srcsets, $new_url,

foreach ( $missing_srcsets as $srcset_data ) {
// Validate required fields
if ( ! isset( $srcset_data['w'] ) || ! isset( $srcset_data['h'] ) || ! isset( $srcset_data['s'] ) ) {
if ( ! isset( $srcset_data['w'] ) || ! isset( $srcset_data['h'] ) || ! self::is_srcset_descriptor( $srcset_data['s'] ?? null ) ) {
continue;
}

Expand Down
63 changes: 63 additions & 0 deletions tests/test-dimension-replacement.php
Original file line number Diff line number Diff line change
Expand Up @@ -27,13 +27,19 @@ public function setUp() : void {
'cdn_secret' => '12345',
'whitelist' => ['example.com'],
]);
$settings->update( 'lazyload', 'disabled' );
Optml_Manager::instance()->init();
// Get the tag replacer instance
$this->tag_replacer = Optml_Tag_Replacer::instance();

// Mock profile data for testing
$this->setup_mock_profile_data();
}

public function tearDown() : void {
Profile::reset_current_profile();
parent::tearDown();
}

/**
* Setup mock profile data with dimension information.
Expand Down Expand Up @@ -128,6 +134,63 @@ public function test_dimensions_injected_when_data_src_placeholder_comes_first()
$this->assertMatchesRegularExpression( '/width="1920".*height="1080".*data-opt-id=' . $this->tag_replacer->get_id_by_url( self::IMAGE_URL ) . '/', $result );
}

/**
* Text that looks like the image id attribute inside an attribute value stays in that value when dimensions are added.
*/
public function test_dimensions_do_not_break_out_of_attribute_values(): void {
$alt = 'a data-opt-id= x onerror=alert(1) ';
$html_input = '<img src="' . self::IMAGE_URL . '" alt="' . $alt . '">';

$result = $this->tag_replacer->process_image_tags( $html_input, $this->extract_images_from_html( $html_input ) );
$img = $this->get_img_element( $result );

$this->assertFalse( $img->hasAttribute( 'onerror' ) );
$this->assertSame( $alt, $img->getAttribute( 'alt' ) );
$this->assertSame( '1920', $img->getAttribute( 'width' ) );
$this->assertSame( '1080', $img->getAttribute( 'height' ) );
}

/**
* A stored srcset entry whose descriptor carries attributes never turns them into real ones.
*/
public function test_stored_srcset_descriptor_cannot_add_attributes(): void {
$image_id = $this->tag_replacer->get_id_by_url( self::IMAGE_URL );
Profile::reset_current_profile();
$profile = Optml_Manager::instance()->page_profiler;
$profile->store(
'test-profile',
Profile::DEVICE_TYPE_DESKTOP,
[],
[],
[],
[ $image_id => [ 'w' => 1920, 'h' => 1080 ] ],
[ $image_id => [ 333 => [ 'w' => 333, 'h' => 222, 'd' => 1, 's' => '2x data-opt-id=Q onload=alert(1) z', 'b' => 0 ] ] ]
);
Profile::set_current_profile_id( 'test-profile' );
$profile->set_current_profile_data();
$html_input = '<img src="' . self::IMAGE_URL . '" alt="Test image">';

$result = $this->tag_replacer->process_image_tags( $html_input, $this->extract_images_from_html( $html_input ) );
$img = $this->get_img_element( $result );

$this->assertFalse( $img->hasAttribute( 'onload' ) );
$this->assertSame( '1920', $img->getAttribute( 'width' ) );
}

/**
* Parse the first image element out of the markup.
*
* @param string $html The markup.
*/
private function get_img_element( string $html ): DOMElement {
$dom = new DOMDocument();
$previous_setting = libxml_use_internal_errors( true );
$dom->loadHTML( $html );
libxml_use_internal_errors( $previous_setting );

return $dom->getElementsByTagName( 'img' )->item( 0 );
}

/**
* Helper method to extract images from HTML for testing.
* Uses the actual manager method to ensure correct structure.
Expand Down
Loading
Loading