This commit is contained in:
2026-07-02 15:54:39 -06:00
commit 9883323161
17470 changed files with 4470592 additions and 0 deletions
@@ -0,0 +1,127 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack\Boost_Core\Lib\Boost_API;
use Automattic\Jetpack_Boost\Lib\Cornerstone\Cornerstone_Utils;
class LCP_Analyzer {
/** @var LCP_State */
private $state;
/** @var LCP_Storage */
private $storage;
/**
* @since 4.0.0
*/
public function __construct() {
$this->state = new LCP_State();
$this->storage = new LCP_Storage();
}
/**
* Start the LCP analysis process
*
* @return array The current state data
*/
public function start() {
// Get cornerstone pages to analyze
$pages = $this->get_cornerstone_pages();
// Store those pages in the LCP State
$this->state
->prepare_request()
->set_pages( $pages )
->set_pending_pages( $pages )
->save();
// Get the data
$data = $this->state->get();
// Start the analysis process
$this->analyze_pages( $pages );
// Clear previous LCP analysis data from storage
$this->storage->clear();
return $data;
}
/**
* Start a partial analysis for the given pages.
* Sets status to pending and updates only the pages that are in the $pages array.
* Pages that are not in the $pages array will not be updated.
*
* @param array $pages The pages to analyze.
* @return array The current state data
*/
public function start_partial_analysis( $pages ) {
$current_pages = $this->state->get_pages();
$this->state
->prepare_request()
->set_pages( $current_pages )
->set_pending_pages( $pages )
->save();
$this->analyze_pages( $pages );
// @todo - Clear previous LCP analysis data from storage for pages in the $pages array.
// We currently don't have a way to do this.
return $this->state->get();
}
/**
* Get cornerstone pages for analysis
*
* @return array
*/
protected function get_cornerstone_pages() {
$pages = array();
$cornerstone_pages_list = Cornerstone_Utils::get_list();
foreach ( $cornerstone_pages_list as $url ) {
$pages[] = Cornerstone_Utils::prepare_provider_data( $url );
}
return $pages;
}
/**
* Run analysis for the given pages
*
* @param array $pages Pages to analyze
*/
private function analyze_pages( $pages ) {
$payload = array(
'pages' => $pages,
'requestId' => md5(
wp_json_encode(
$pages,
0 // phpcs:ignore Jetpack.Functions.JsonEncodeFlags.ZeroFound -- No `json_encode()` flags because this needs to match whatever is calculating the hash on the other end.
)
),
);
return Boost_API::post( 'lcp', $payload );
}
/**
* Get the current state
*
* @return LCP_State
*/
public function get_state() {
return $this->state;
}
/**
* Get the storage instance
*
* @return LCP_Storage
*/
public function get_storage() {
return $this->storage;
}
}
@@ -0,0 +1,78 @@
<?php
/**
* LCP Invalidator
*
* Reset LCP analysis data on certain events.
*/
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack_Boost\Lib\Cornerstone\Cornerstone_Utils;
class LCP_Invalidator {
public static function init() {
add_action( 'jetpack_boost_deactivate', array( self::class, 'reset_data' ) );
add_action( 'update_option_jetpack_boost_ds_cornerstone_pages_list', array( self::class, 'reset_and_analyze' ) );
add_action( 'jetpack_boost_environment_changed', array( self::class, 'handle_environment_change' ) );
add_action( 'post_updated', array( self::class, 'handle_post_update' ) );
}
/**
* Reset any LCP analysis data (state and storage).
*
* @since 4.0.0
*/
public static function reset_data() {
$state = new LCP_State();
$state->clear();
$storage = new LCP_Storage();
$storage->clear();
}
/**
* Reset the LCP analysis data, and analyze the pages again.
*
* @since 4.0.0
*/
public static function reset_and_analyze() {
self::reset_data();
/**
* Indicate that the latest LCP analysis data has been invalidated.
*/
do_action( 'jetpack_boost_lcp_invalidated' );
}
/**
* Respond to environment changes; deciding whether or not to clear LCP analysis data.
*
* @since 4.0.0
*/
public static function handle_environment_change( $is_major_change ) {
if ( $is_major_change ) {
self::reset_and_analyze();
}
}
/**
* Handle post updates to check if the post is a cornerstone page and schedule preload if needed.
*
* @since 4.0.0
* @param int $post_id The ID of the post being updated.
* @return void
*/
public static function handle_post_update( int $post_id ) {
if ( Cornerstone_Utils::is_cornerstone_page( $post_id ) ) {
$url = get_permalink( $post_id );
$analyzer = new LCP_Analyzer();
$analyzer->start_partial_analysis(
array(
Cornerstone_Utils::prepare_provider_data( $url ),
)
);
}
}
}
@@ -0,0 +1,191 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use WP_HTML_Tag_Processor;
class LCP_Optimization_Util {
/**
* Each LCP data is an array that includes the LCP for a certain viewport.
*
* @var array
*/
private $lcp_data;
public function __construct( $lcp_data ) {
$this->lcp_data = $lcp_data;
}
/**
* Check if LCP optimization should be skipped for the current request.
*
* @since 4.0.0
* @return bool True if optimization should be skipped, false otherwise.
*/
public static function should_skip_optimization() {
/**
* Filters whether to short-circuit LCP optimization.
*
* Returning a value other than null from the filter will short-circuit
* the optimization check, returning that value instead.
*
* @since 4.0.0
*
* @param null|bool $skip Whether to skip optimization. Default null.
*/
$pre = apply_filters( 'jetpack_boost_pre_should_skip_lcp_optimization', null );
if ( null !== $pre ) {
return $pre;
}
// Disable in robots.txt.
if ( isset( $_SERVER['REQUEST_URI'] ) && strpos( home_url( wp_unslash( $_SERVER['REQUEST_URI'] ) ), 'robots.txt' ) !== false ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- This is validating.
return true;
}
// Disable in other possible AJAX requests setting cors related header.
if ( isset( $_SERVER['HTTP_SEC_FETCH_MODE'] ) && 'cors' === strtolower( $_SERVER['HTTP_SEC_FETCH_MODE'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
return true;
}
// Disable in other possible AJAX requests setting XHR related header.
if ( isset( $_SERVER['HTTP_X_REQUESTED_WITH'] ) && 'xmlhttprequest' === strtolower( $_SERVER['HTTP_X_REQUESTED_WITH'] ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput -- This is validating.
return true;
}
// Disable in all XLS (see the WP_Sitemaps_Renderer class).
if ( isset( $_SERVER['REQUEST_URI'] ) &&
(
// phpcs:disable WordPress.Security.ValidatedSanitizedInput -- This is validating.
str_contains( $_SERVER['REQUEST_URI'], '.xsl' ) ||
str_contains( $_SERVER['REQUEST_URI'], 'sitemap-stylesheet=index' ) ||
str_contains( $_SERVER['REQUEST_URI'], 'sitemap-stylesheet=sitemap' )
// phpcs:enable WordPress.Security.ValidatedSanitizedInput
) ) {
return true;
}
// Disable in all POST Requests.
// phpcs:disable WordPress.Security.NonceVerification.Missing
if ( ! empty( $_POST ) ) {
return true;
}
// Disable in customizer previews
if ( is_customize_preview() ) {
return true;
}
// Disable in feeds, AJAX, Cron, XML.
if ( is_feed() || wp_doing_ajax() || wp_doing_cron() || wp_is_xml_request() ) {
return true;
}
// Disable in sitemaps.
if ( ! empty( get_query_var( 'sitemap' ) ) ) {
return true;
}
// Disable in AMP pages.
if ( function_exists( 'amp_is_request' ) && amp_is_request() ) {
return true;
}
return false;
}
/**
* Check if a specific optimization should be applied based on cloud response.
* Returns true if no optimizations object exists (backward compatibility) or if the flag is truthy.
*
* @since 4.5.6
*
* @param array $lcp_data The LCP data array.
* @param string $key The optimization key to check.
* @return bool True if the optimization should be applied.
*/
public static function should_apply_optimization( $lcp_data, $key ) {
return ! isset( $lcp_data['optimizations'] )
|| ! empty( $lcp_data['optimizations'][ $key ] );
}
public function get_lcp_image_url() {
if ( ! $this->can_optimize() ) {
return null;
}
if ( LCP::TYPE_BACKGROUND_IMAGE !== $this->lcp_data['type'] && LCP::TYPE_IMAGE !== $this->lcp_data['type'] ) {
return null;
}
if ( empty( $this->lcp_data['url'] ) ) {
return null;
}
if ( ! wp_http_validate_url( $this->lcp_data['url'] ) ) {
return null;
}
return $this->lcp_data['url'];
}
/**
* Check if the LCP data is valid and can be optimized.
*
* @return bool True if the LCP data is valid and can be optimized, false otherwise.
*
* @since 4.1.0
*/
public function can_optimize() {
if ( empty( $this->lcp_data ) || ! is_array( $this->lcp_data ) ) {
return false;
}
if ( ! isset( $this->lcp_data['success'] ) || ! $this->lcp_data['success'] ) {
return false;
}
return true;
}
/**
* Check if the element is present in the LCP data.
*
* @param string $buffer The HTML to check.
* @param string $tag The tag to check. Default is 'img'.
* @return WP_HTML_Tag_Processor|false The HTML tag processor if the element is present, false otherwise.
*
* @since 4.1.0
*/
public function find_element( $buffer, $tag = 'img' ) {
$html_processor = new WP_HTML_Tag_Processor( $buffer );
$element = new WP_HTML_Tag_Processor( $this->lcp_data['html'] );
// Ensure the LCP HTML is a valid tag before proceeding.
if ( ! $element->next_tag( $tag ) ) {
return false;
}
// Extract attributes from the LCP tag for matching
$lcp_id = $element->get_attribute( 'id' );
$lcp_class = $element->get_attribute( 'class' );
// Perform a quick check to see if the class is present in the HTML.
if ( ! empty( $lcp_class ) && ! str_contains( $buffer, $lcp_class ) ) {
return false;
}
// Loop through all img tags in the buffer with the same class until we find a match.
// We do this because next_tag does not support matching on IDs and sources.
while ( $html_processor->next_tag( $tag ) ) {
// Tag is considered a match if the class and id match.
if ( $lcp_id === $html_processor->get_attribute( 'id' ) &&
$lcp_class === $html_processor->get_attribute( 'class' ) ) {
return $html_processor;
}
}
return false;
}
}
@@ -0,0 +1,229 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack\Image_CDN\Image_CDN_Core;
class LCP_Optimize_Bg_Image {
/**
* The LCP data for optimizing the current page.
*
* @var array
*/
private $lcp_data;
public static function init( $lcp_data ) {
if ( LCP_Optimization_Util::should_skip_optimization() ) {
return;
}
if ( empty( $lcp_data ) ) {
return;
}
$instance = new self( $lcp_data );
// Preload the background image as early as possible.
add_action( 'wp_head', array( $instance, 'preload_background_images' ), 1 );
// Add the background image styling as late as possible.
add_action( 'wp_body_open', array( $instance, 'add_bg_style_override' ), 999999 );
}
public function __construct( $lcp_data ) {
$this->lcp_data = $lcp_data;
}
public function preload_background_images() {
$selectors = array();
foreach ( $this->lcp_data as $lcp_data ) {
$lcp_optimizer = new LCP_Optimization_Util( $lcp_data );
if ( ! $lcp_optimizer->can_optimize() ) {
continue;
}
if ( in_array( $lcp_data['selector'], $selectors, true ) ) {
// If we already printed the styling for this element, skip it.
continue;
}
$selectors[] = $lcp_data['selector'];
$responsive_image_rules = $this->get_responsive_image_rules( $lcp_data );
$this->print_preload_links( $responsive_image_rules );
}
}
private function print_preload_links( $responsive_image_rules ) {
foreach ( $responsive_image_rules as $breakpoint ) {
$image_set = array();
foreach ( $breakpoint['image_set'] as $image ) {
$image_set[] = sprintf( '%s %sx', $image['url'], $image['dpr'] );
}
$image_set_string = implode( ', ', $image_set );
printf(
'<link rel="preload" href="%s" as="image" fetchpriority="high" media="%s" imagesrcset="%s" />' . PHP_EOL,
esc_url( Image_CDN_Core::cdn_url( $breakpoint['base_image'] ) ),
esc_attr( $breakpoint['media_query'] ),
esc_attr( $image_set_string )
);
}
}
public function add_bg_style_override() {
$selectors = array();
foreach ( $this->lcp_data as $lcp_data ) {
$lcp_optimizer = new LCP_Optimization_Util( $lcp_data );
if ( ! $lcp_optimizer->can_optimize() ) {
continue;
}
if ( in_array( $lcp_data['selector'], $selectors, true ) ) {
// If we already printed the styling for this element, skip it.
continue;
}
$selectors[] = $lcp_data['selector'];
$image_url = $lcp_optimizer->get_lcp_image_url();
if ( empty( $image_url ) ) {
continue;
}
$styles = array();
$responsive_image_rules = $this->get_responsive_image_rules( $lcp_data );
// Add responsive image styling.
foreach ( $responsive_image_rules as $breakpoint ) {
$image_set = array();
foreach ( $breakpoint['image_set'] as $image ) {
$image_set[] = sprintf( 'url(%s) %sx', $image['url'], $image['dpr'] );
}
$image_set_string = implode( ', ', $image_set );
$styles[] = sprintf(
'@media %1$s { %2$s { background-image: url(%3$s) !important; background-image: -webkit-image-set(%4$s) !important; background-image: image-set(%4$s) !important; } }',
$breakpoint['media_query'],
$lcp_data['selector'],
$breakpoint['base_image'],
$image_set_string
);
}
// Skip outputting empty style block when cssOverride is disabled.
if ( empty( $styles ) ) {
continue;
}
$bg_styling = PHP_EOL . '<style id="jetpack-boost-lcp-background-image">' . PHP_EOL;
// Ensure no </style> tag (or any HTML tags) in output.
$bg_styling .= wp_strip_all_tags( implode( PHP_EOL, $styles ) ) . PHP_EOL;
$bg_styling .= '</style>' . PHP_EOL;
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
echo $bg_styling;
}
}
private function get_responsive_image_rules( $lcp_data ) {
if ( $lcp_data['type'] !== LCP::TYPE_BACKGROUND_IMAGE || empty( $lcp_data['breakpoints'] ) ) {
return array();
}
// Check optimizations object from cloud.
// If cssOverride is false, skip all background-image optimizations (preload, !important CSS, resize URLs).
// This handles responsive backgrounds and custom focal points - the cloud determines what's safe.
// If no optimizations object exists (old cloud response), default to applying all optimizations.
if ( ! LCP_Optimization_Util::should_apply_optimization( $lcp_data, 'cssOverride' ) ) {
return array();
}
$lcp_optimizer = new LCP_Optimization_Util( $lcp_data );
$image_url = $lcp_optimizer->get_lcp_image_url();
if ( empty( $image_url ) ) {
return array();
}
$styles = array();
// Reverse the array to go from smallest to largest.
foreach ( array_reverse( $lcp_data['breakpoints'] ) as $breakpoint ) {
if ( empty( $breakpoint ) ) {
continue;
}
if ( ! isset( $breakpoint['widthValue'] ) ) {
continue;
}
// The Cloud should always return a fixed pixel width for background images, so catering for that is easy peasy.
if ( empty( $breakpoint['imageDimensions'] ) || ! is_array( $breakpoint['imageDimensions'] ) ) {
continue;
}
$image_dimensions = $breakpoint['imageDimensions'][0];
if ( ! isset( $image_dimensions['width'] ) || ! is_numeric( $image_dimensions['width'] ) ) {
continue;
}
if ( ! isset( $image_dimensions['height'] ) || ! is_numeric( $image_dimensions['height'] ) ) {
continue;
}
// The width and height should already be an integer, but just in case.
$image_width = (int) $image_dimensions['width'];
$image_height = (int) $image_dimensions['height'];
$media_query = array();
if ( isset( $breakpoint['minWidth'] ) ) {
$media_query[] = sprintf( '(min-width: %spx)', $breakpoint['minWidth'] );
}
if ( isset( $breakpoint['maxWidth'] ) ) {
$media_query[] = sprintf( '(max-width: %spx)', $breakpoint['maxWidth'] );
}
$styles[] = array(
'media_query' => empty( $media_query ) ? 'all' : implode( ' and ', $media_query ),
'image_set' => $this->get_image_set( $image_url, $image_width, $image_height ),
'base_image' => Image_CDN_Core::cdn_url(
$image_url,
array(
'resize' => array( $image_width, $image_height ),
)
),
);
}
return $styles;
}
private function get_image_set( $url, $width, $height ) {
$dprs = array( 1, 2 );
// Mobile devices usually have a DPR of 3 which is not common for desktop.
if ( $width <= 480 ) {
$dprs[] = 3;
}
// Accurately reflect the performance improvement in lighthouse by including a 1.75x DPR image for the Moto G Power.
if ( $width === 412 ) {
$dprs[] = 1.75;
}
$image_set = array();
foreach ( $dprs as $dpr ) {
$image_set[] = array(
'url' => Image_CDN_Core::cdn_url(
$url,
array(
'resize' => array( $width * $dpr, $height * $dpr ),
)
),
'dpr' => $dpr,
);
}
return $image_set;
}
}
@@ -0,0 +1,249 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack\Image_CDN\Image_CDN;
use Automattic\Jetpack\Image_CDN\Image_CDN_Core;
use WP_HTML_Tag_Processor;
class LCP_Optimize_Img_Tag {
/**
* LCP data.
*
* @var array
*/
private $lcp_data;
public function __construct( $lcp_data ) {
$this->lcp_data = $lcp_data;
}
/**
* Optimize a viewport's LCP HTML.
*
* @param string $buffer The buffer/html to optimize.
* @return string The optimized buffer, or the original buffer if no optimization was needed
*
* @since 4.0.0
*/
public function optimize_buffer( $buffer ) {
$optimization_util = new LCP_Optimization_Util( $this->lcp_data );
if ( ! $optimization_util->can_optimize() ) {
return $buffer;
}
// Defensive check to ensure the LCP HTML is not empty.
if ( empty( $this->lcp_data['html'] ) ) {
return $buffer;
}
// Only optimize if the type is one we know how to handle.
if ( $this->lcp_data['type'] !== LCP::TYPE_IMAGE ) {
return $buffer;
}
$buffer_processor = $optimization_util->find_element( $buffer );
if ( ! $buffer_processor ) {
return $buffer;
}
// Create the optimized tag with required attributes.
return $this->optimize_image( $buffer_processor );
}
/**
* Optimize an image tag by adding required attributes.
*
* @param WP_HTML_Tag_Processor $buffer_processor The original HTML chunk of the page..
*
* @return string The optimized buffer.
*
* @since 4.0.0
*/
private function optimize_image( $buffer_processor ) {
// Check optimizations object for fetchpriority.
// If no optimizations object exists (old cloud response), default to applying.
$apply_fetchpriority = LCP_Optimization_Util::should_apply_optimization( $this->lcp_data, 'fetchpriority' );
if ( $apply_fetchpriority ) {
$buffer_processor->set_attribute( 'fetchpriority', 'high' );
}
// Check optimizations object for loading.
$apply_loading = LCP_Optimization_Util::should_apply_optimization( $this->lcp_data, 'loading' );
if ( $apply_loading ) {
$buffer_processor->set_attribute( 'loading', 'eager' );
}
$buffer_processor->set_attribute( 'data-jp-lcp-optimized', 'true' );
$image_url = $buffer_processor->get_attribute( 'src' );
// Ensure the image URL is valid.
if ( ! wp_http_validate_url( $image_url ) ) {
return $buffer_processor->get_updated_html();
}
// If the image isn't photonized, it might be resized by WP.
// We need the original image URL, as we'll be generating
// the necessary sizes based on it.
if ( ! Image_CDN_Core::is_cdn_url( $image_url ) ) {
$image_url = Image_CDN::strip_image_dimensions_maybe( $image_url );
} else {
// In case it's Photonized, we need to remove any size change arguments.
$image_url = remove_query_arg( array( 'w', 'h' ), $image_url );
}
// Additional validation after cleaning.
if ( ! wp_http_validate_url( $image_url ) ) {
return $buffer_processor->get_updated_html();
}
// Check optimizations object for cdnUrl.
$apply_cdn = LCP_Optimization_Util::should_apply_optimization( $this->lcp_data, 'cdnUrl' );
if ( $apply_cdn ) {
$buffer_processor->set_attribute( 'src', Image_CDN_Core::cdn_url( $image_url ) );
}
// Check optimizations object for srcset.
// The cloud sets srcset to false when custom focal points are detected
// (resize would use center-crop, losing the author's object-position).
$apply_srcset = LCP_Optimization_Util::should_apply_optimization( $this->lcp_data, 'srcset' );
// srcset uses CDN URLs internally (via Image_CDN_Core::cdn_url), so skip if CDN is disabled.
if ( $apply_srcset && $apply_cdn ) {
$this->add_responsive_image_attributes( $buffer_processor, $image_url );
}
return $buffer_processor->get_updated_html();
}
/**
* Optimize an image tag by adding srcset and sizes attributes.
*
* @param WP_HTML_Tag_Processor $element The original image tag.
* @param string $image_url The image URL.
* @return WP_HTML_Tag_Processor The optimized image tag.
*
* @since 4.0.0
*/
private function add_responsive_image_attributes( $element, $image_url ) {
if ( empty( $this->lcp_data['breakpoints'] ) ) {
return $element;
}
$srcset = $this->get_srcset( $image_url );
if ( ! empty( $srcset ) ) {
$element->set_attribute( 'srcset', $srcset );
}
$sizes = $this->get_sizes();
if ( ! empty( $sizes ) ) {
$element->set_attribute( 'sizes', $sizes );
}
return $element;
}
/**
* Get the srcsets for an image.
*
* @param string $original_url The original image URL.
* @return string The srcset for the image.
*
* @since 4.1.0
*/
private function get_srcset( $original_url ) {
$dimensions = array();
foreach ( $this->lcp_data['breakpoints'] as $breakpoint ) {
foreach ( $breakpoint['imageDimensions'] as $breakpoint_dimensions ) {
if ( ! is_numeric( $breakpoint_dimensions['width'] ) || ! is_numeric( $breakpoint_dimensions['height'] ) ) {
continue;
}
$width = (int) $breakpoint_dimensions['width'];
$height = (int) $breakpoint_dimensions['height'];
$dimensions[ $width ] = $height;
// If it's a Moto G Power, include a 1.75 DPR for accurate lighthouse representation of the optimized image.
if ( isset( $breakpoint['maxWidth'] ) && $breakpoint['maxWidth'] === 412 ) {
$dimensions[ (int) round( $width * 1.75 ) ] = round( $height * 1.75 );
}
// Include 2x DPR.
$dimensions[ $width * 2 ] = $height * 2;
// If it's a mobile breakpoint, include 3x DPR.
if ( isset( $breakpoint['maxWidth'] ) && $breakpoint['maxWidth'] <= 480 ) {
$dimensions[ $width * 3 ] = $height * 3;
}
}
}
// Remove unnecessary widths to save some bytes in the HTML.
$reduced_widths = $this->reduce_widths( array_keys( $dimensions ) );
$srcset = array();
foreach ( $reduced_widths as $width ) {
$srcset[] = Image_CDN_Core::cdn_url(
$original_url,
array(
'resize' => array( $width, $dimensions[ $width ] ),
)
) . " {$width}w";
}
return implode( ', ', $srcset );
}
/**
* Remove any width if we have another higher width that is within 20px.
*
* @param array $widths The widths to reduce.
* @return array The reduced widths.
*/
private function reduce_widths( $widths ) {
rsort( $widths );
$reduced_widths = array();
$previous_width = 999999;
foreach ( $widths as $width ) {
if ( $previous_width - 20 < $width ) {
continue;
}
$previous_width = $width;
$reduced_widths[] = $width;
}
return $reduced_widths;
}
/**
* Get the sizes for an image.
*
* @return string The sizes for the image.
*
* @since 4.1.0
*/
private function get_sizes() {
$sizes = array();
foreach ( $this->lcp_data['breakpoints'] as $breakpoint ) {
// Make sure widthValue is a known format.
if ( ! isset( $breakpoint['widthValue'] ) || ! preg_match( '/^[0-9]+(?:\.[0-9]+)?(?:px|vw)$/', $breakpoint['widthValue'] ) ) {
continue;
}
// Make sure minWidth is valid if set.
if ( isset( $breakpoint['minWidth'] ) && ! is_numeric( $breakpoint['minWidth'] ) ) {
continue;
}
$min_width_query = isset( $breakpoint['minWidth'] ) ? '(min-width: ' . $breakpoint['minWidth'] . 'px) ' : '';
$sizes[] = $min_width_query . $breakpoint['widthValue'];
}
return implode( ', ', $sizes );
}
}
@@ -0,0 +1,236 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use WP_Error;
class LCP_State {
const ANALYSIS_STATES = array(
'not_analyzed' => 'not_analyzed',
'pending' => 'pending',
'analyzed' => 'analyzed',
'error' => 'error',
);
const PAGE_STATES = array(
'pending' => 'pending',
'success' => 'success',
'error' => 'error',
);
/**
* LCP analysis state data
*
* @var array
*/
public $state = array();
/**
* Retrieves and validates the LCP state
*
* @param bool $refresh Whether to refresh the state from storage.
* @return array The validated state
* @since 3.13.1
*/
private function get_state( $refresh = false ) {
if ( $refresh ) {
$stored_state = jetpack_boost_ds_get( 'lcp_state' );
$this->state = is_array( $stored_state ) ? $stored_state : array();
} elseif ( ! is_array( $this->state ) ) {
$this->state = array();
}
return $this->state;
}
public function __construct() {
$this->get_state( true );
}
public function clear() {
jetpack_boost_ds_delete( 'lcp_state' );
}
public function save() {
$this->state['updated'] = microtime( true );
jetpack_boost_ds_set( 'lcp_state', $this->state );
if ( $this->is_analyzed() ) {
/**
* Fires when LCP analysis has successfully completed.
*/
do_action( 'jetpack_boost_lcp_analyzed' );
}
}
public function set_error( $message ) {
if ( empty( $message ) ) {
return $this;
}
$this->state['status_error'] = $message;
$this->state['status'] = self::ANALYSIS_STATES['error'];
return $this;
}
/**
* Update a page's state. The page must already exist in the state to be updated.
*
* @param string $page_key The page key.
* @param array $state An array to overlay over the current state.
* @return bool|\WP_Error True on success, WP_Error on failure.
*/
public function update_page_state( $page_key, $state ) {
if ( empty( $this->state['pages'] ) ) {
return new WP_Error( 'invalid_page_key', 'No pages exist' );
}
$page_index = array_search( $page_key, array_column( $this->state['pages'], 'key' ), true );
if ( $page_index === false ) {
return new WP_Error( 'invalid_page_key', 'Invalid page key' );
}
$this->state['pages'][ $page_index ] = array_merge(
$this->state['pages'][ $page_index ],
$state
);
$this->maybe_set_analyzed();
return true;
}
/**
* Set a page's state to success.
*
* @param string $page_key The page key.
* @return bool|\WP_Error True on success, WP_Error on failure.
*/
public function set_page_success( $page_key ) {
return $this->update_page_state(
$page_key,
array(
'status' => self::PAGE_STATES['success'],
'errors' => null,
)
);
}
/**
* Signifies that the page was not optimized for reason(s) in $errors.
*
* @param string $page_key The page key.
* @param array $errors The errors to set for the page.
* @return bool|\WP_Error True on success, WP_Error on failure.
*/
public function set_page_errors( $page_key, $errors ) {
return $this->update_page_state(
$page_key,
array(
'status' => self::PAGE_STATES['error'],
'errors' => $errors,
)
);
}
/**
* Set the state to analyzed if all pages are done. Should be called wherever
* a page's state is updated.
*/
private function maybe_set_analyzed() {
if ( empty( $this->state['pages'] ) ) {
return;
}
$page_states = array_column( $this->state['pages'], 'status' );
$is_done = ! in_array( self::PAGE_STATES['pending'], $page_states, true );
if ( $is_done ) {
$this->state['status'] = self::ANALYSIS_STATES['analyzed'];
}
}
public function is_analyzed() {
return ! empty( $this->state )
&& isset( $this->state['status'] )
&& self::ANALYSIS_STATES['analyzed'] === $this->state['status'];
}
public function is_pending() {
return ! empty( $this->state )
&& isset( $this->state['status'] )
&& self::ANALYSIS_STATES['pending'] === $this->state['status'];
}
public function prepare_request() {
$this->state = array(
'status' => self::ANALYSIS_STATES['pending'],
'pages' => array(),
'created' => microtime( true ),
'updated' => microtime( true ),
);
return $this;
}
/**
* Get the pages from the state.
*
* @return array The pages from the state.
*
* @since 4.0.0
*/
public function get_pages() {
return $this->state['pages'];
}
/**
* Set the pages in the state.
*
* @param array $pages The pages to set in the state.
* @return $this
*
* @since 4.0.0
*/
public function set_pages( $pages ) {
$this->state['pages'] = $pages;
return $this;
}
/**
* Set the status to pending for the pages that are in the $pages array.
* Pages that are not in the $pages array will not be touched.
*
* @param array $pages The pages to set to pending.
* @return $this
*
* @since 4.0.0
*/
public function set_pending_pages( $pages ) {
$current_pages = $this->state['pages'];
foreach ( $pages as $page ) {
$page_key = $page['key'];
foreach ( $current_pages as $index => $current_page ) {
if ( $current_page['key'] === $page_key ) {
$current_pages[ $index ]['status'] = self::PAGE_STATES['pending'];
break;
}
}
}
$this->state['pages'] = $current_pages;
return $this;
}
/**
* Get fresh state
*
* @return array Current LCP state
* @since 3.13.1
*/
public function get() {
return $this->get_state( true );
}
}
@@ -0,0 +1,71 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Source_Providers\Providers\Cornerstone_Provider;
use Automattic\Jetpack_Boost\Lib\Storage_Post_Type;
/**
* LCP Storage class
*/
class LCP_Storage {
/**
* Storage post type.
*
* @var Storage_Post_Type
*/
protected $storage;
/**
* LCP_Storage constructor.
*/
public function __construct() {
$this->storage = new Storage_Post_Type( 'lcp' );
}
/**
* Store LCP data for a specific page.
*
* @param string $key Page key.
* @param array $data LCP data.
*/
public function store_lcp( $key, $data ) {
$this->storage->set(
$key,
$data
);
}
/**
* Clear the whole LCP storage.
*/
public function clear() {
$this->storage->clear();
}
/**
* Get LCP data for a specific page key.
*
* @param string $page_key Page key.
*
* @return array|false
*/
public function get_lcp( $page_key ) {
return $this->storage->get( $page_key, false );
}
/**
* Get LCP data for the current request.
*
* @return array|false LCP data for the current request, or false if not found.
*/
public function get_current_request_lcp() {
// @TODO: this is a temporary solution to get the LCP data for the current request. Cornerstone Provider should be decoupled from the CSS storage.
$current_url = Cornerstone_Provider::get_request_url();
$key = Cornerstone_Provider::get_provider_key( $current_url );
if ( empty( $key ) ) {
return false;
}
return $this->get_lcp( $key );
}
}
@@ -0,0 +1,32 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack_Boost\Lib\Cornerstone\Cornerstone_Utils;
/**
* Utility class for LCP functionality.
*/
class LCP_Utils {
/**
* Get LCP analysis data for all cornerstone pages.
*
* @return array Array of LCP analysis data keyed by page URL.
*/
public static function get_analysis_data() {
$cornerstone_pages = Cornerstone_Utils::get_list();
$storage = new LCP_Storage();
$analysis_data = array();
foreach ( $cornerstone_pages as $page_url ) {
$key = Cornerstone_Utils::get_provider_key( $page_url );
$lcp_data = $storage->get_lcp( $key );
if ( ! empty( $lcp_data ) ) {
$analysis_data[ $page_url ] = $lcp_data;
}
}
return $analysis_data;
}
}
@@ -0,0 +1,195 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack\Schema\Schema;
use Automattic\Jetpack\WP_JS_Data_Sync\Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Changes_Output_After_Activation;
use Automattic\Jetpack_Boost\Contracts\Feature;
use Automattic\Jetpack_Boost\Contracts\Has_Activate;
use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
use Automattic\Jetpack_Boost\Contracts\Needs_To_Be_Ready;
use Automattic\Jetpack_Boost\Contracts\Needs_Website_To_Be_Public;
use Automattic\Jetpack_Boost\Contracts\Optimization;
use Automattic\Jetpack_Boost\Lib\Output_Filter;
use Automattic\Jetpack_Boost\REST_API\Contracts\Has_Always_Available_Endpoints;
use Automattic\Jetpack_Boost\REST_API\Endpoints\Update_LCP;
class Lcp implements Feature, Changes_Output_After_Activation, Optimization, Has_Activate, Needs_To_Be_Ready, Has_Data_Sync, Has_Always_Available_Endpoints, Needs_Website_To_Be_Public {
/** LCP type for background images. */
const TYPE_BACKGROUND_IMAGE = 'background-image';
/** LCP type for standard images. */
const TYPE_IMAGE = 'img';
/**
* The LCP data of the current request.
*
* @var array|false
*/
private $lcp_data;
public function setup() {
add_action( 'wp', array( $this, 'on_wp_load' ), 1 );
add_action( 'template_redirect', array( $this, 'add_output_filter' ), -999999 );
add_action( 'jetpack_boost_lcp_invalidated', array( $this, 'handle_lcp_invalidated' ) );
LCP_Invalidator::init();
}
public function on_wp_load() {
$this->lcp_data = ( new LCP_Storage() )->get_current_request_lcp();
LCP_Optimize_Bg_Image::init( $this->lcp_data );
}
public function add_output_filter() {
if ( LCP_Optimization_Util::should_skip_optimization() ) {
return;
}
$output_filter = new Output_Filter();
$output_filter->add_callback( array( $this, 'optimize_lcp_img_tag' ) );
}
/**
* Optimize the HTML content by finding the LCP image and adding required attributes.
*
* @param string $buffer_start First part of the buffer.
* @param string $buffer_end Second part of the buffer.
*
* @return array Parts of the buffer.
*
* @since 3.13.1
*/
public function optimize_lcp_img_tag( $buffer_start, $buffer_end ) {
if ( empty( $this->lcp_data ) ) {
return array( $buffer_start, $buffer_end );
}
// Combine the buffers for processing
$combined_buffer = $buffer_start . $buffer_end;
foreach ( $this->lcp_data as $lcp_element ) {
$optimizer = new LCP_Optimize_Img_Tag( $lcp_element );
$combined_buffer = $optimizer->optimize_buffer( $combined_buffer );
}
// Split the modified buffer back into two parts
$buffer_start_length = strlen( $buffer_start );
$new_buffer_start = substr( $combined_buffer, 0, $buffer_start_length );
$new_buffer_end = substr( $combined_buffer, $buffer_start_length );
// Check for successful split
if ( false === $new_buffer_start || false === $new_buffer_end ) {
// If splitting failed, return the original buffers
return array( $buffer_start, $buffer_end );
}
return array( $new_buffer_start, $new_buffer_end );
}
/**
* @since 3.13.1
*/
public static function activate() {
( new LCP_Analyzer() )->start();
}
/**
* @since 3.13.1
*/
public static function get_slug() {
return 'lcp';
}
public function get_always_available_endpoints() {
return array(
new Update_LCP(),
);
}
/**
* @since 3.13.1
*/
public static function is_available() {
return true;
}
/**
* Check if the module is ready and already serving optimized pages.
*
* @return bool
*/
public function is_ready() {
return ( new LCP_State() )->is_analyzed();
}
/**
* Get the action names that will be triggered when the module is ready.
*
* @return string[]
*/
public static function get_change_output_action_names() {
return array( 'jetpack_boost_lcp_invalidated', 'jetpack_boost_lcp_analyzed' );
}
/**
* Register data sync actions.
*
* @param Data_Sync $instance The Data_Sync object.
*/
public function register_data_sync( $instance ) {
$instance->register(
'lcp_state',
Schema::as_assoc_array(
array(
'pages' => Schema::as_array(
Schema::as_assoc_array(
array(
'key' => Schema::as_string(),
'url' => Schema::as_string(),
'status' => Schema::as_string(),
'errors' => Schema::as_array(
Schema::as_assoc_array(
array(
'type' => Schema::as_string(),
'meta' => Schema::as_assoc_array(
array(
'code' => Schema::as_number()->nullable(),
'selector' => Schema::as_string()->nullable(),
)
)->nullable(),
)
)
)->nullable(),
)
)
),
'status' => Schema::enum( array( 'not_analyzed', 'analyzed', 'pending', 'error' ) )->fallback( 'not_analyzed' ),
'created' => Schema::as_float()->nullable(),
'updated' => Schema::as_float()->nullable(),
'status_error' => Schema::as_string()->nullable(),
)
)->fallback(
array(
'pages' => array(),
'status' => 'not_analyzed',
'created' => null,
'updated' => null,
)
)
);
$instance->register_action( 'lcp_state', 'request-analyze', Schema::as_void(), new Optimize_LCP_Endpoint() );
}
/**
* Handle the LCP invalidated action.
*/
public function handle_lcp_invalidated() {
( new LCP_Analyzer() )->start();
}
}
@@ -0,0 +1,43 @@
<?php
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Lcp;
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Data_Sync_Action;
use Automattic\Jetpack_Boost\Modules\Modules_Setup;
class Optimize_LCP_Endpoint implements Data_Sync_Action {
/**
* Handles the optimize LCP action.
*
* @param mixed $_data JSON Data passed to the action.
* @param \WP_REST_Request $_request The request object.
*/
public function handle( $_data, $_request ) {
// Check if the module is enabled first.
$states = ( new Modules_Setup() )->get_status();
if ( ! $states['lcp'] ) {
return array(
'success' => false,
'state' => array(),
);
}
$analyzer = new LCP_Analyzer();
$state = $analyzer->get_state();
if ( $state->is_pending() ) {
// If the analysis is already in progress, return the current state.
return array(
'success' => true,
'state' => $state->get(),
);
}
$state = $analyzer->start();
return array(
'success' => true,
'state' => $state,
);
}
}