initial
This commit is contained in:
+53
@@ -0,0 +1,53 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Cloud_CSS;
|
||||
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_State;
|
||||
|
||||
class Cloud_CSS_Followup {
|
||||
|
||||
const SCHEDULER_HOOK = 'jetpack_boost_cloud_css_followup';
|
||||
|
||||
/**
|
||||
* Initiate the scheduler
|
||||
*
|
||||
* Whenever Cloud CSS module is setup, it will call this method.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function init() {
|
||||
/*
|
||||
* Run the scheduled job
|
||||
*/
|
||||
add_action( self::SCHEDULER_HOOK, array( self::class, 'run' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Run the cron job.
|
||||
*/
|
||||
public static function run() {
|
||||
$state = new Critical_CSS_State();
|
||||
if ( $state->has_errors() ) {
|
||||
$cloud_css = new Cloud_CSS();
|
||||
$cloud_css->regenerate_cloud_css( Cloud_CSS::REGENERATE_REASON_FOLLOWUP, $cloud_css->get_existing_sources() );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a cron-job to maintain cloud CSS
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function schedule() {
|
||||
// Remove any existing schedule
|
||||
self::unschedule();
|
||||
wp_schedule_single_event( time() + HOUR_IN_SECONDS, self::SCHEDULER_HOOK );
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the cron-job
|
||||
*/
|
||||
public static function unschedule() {
|
||||
wp_clear_scheduled_hook( self::SCHEDULER_HOOK );
|
||||
}
|
||||
}
|
||||
+272
@@ -0,0 +1,272 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Cloud_CSS;
|
||||
|
||||
use Automattic\Jetpack\Boost_Core\Lib\Boost_API;
|
||||
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\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\Cornerstone\Cornerstone_Utils;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Admin_Bar_Compatibility;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_Invalidator;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_State;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_Storage;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Display_Critical_CSS;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Generator;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Regenerate;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Source_Providers\Source_Providers;
|
||||
use Automattic\Jetpack_Boost\Lib\Premium_Features;
|
||||
use Automattic\Jetpack_Boost\REST_API\Contracts\Has_Always_Available_Endpoints;
|
||||
use Automattic\Jetpack_Boost\REST_API\Endpoints\Update_Cloud_CSS;
|
||||
|
||||
class Cloud_CSS implements Feature, Has_Activate, Has_Always_Available_Endpoints, Changes_Output_After_Activation, Optimization, Needs_To_Be_Ready, Needs_Website_To_Be_Public {
|
||||
|
||||
/** User has requested regeneration manually or through activating the module. */
|
||||
const REGENERATE_REASON_USER_REQUEST = 'user_request';
|
||||
|
||||
/** A post was updated/created. */
|
||||
const REGENERATE_REASON_SAVE_POST = 'save_post';
|
||||
|
||||
/** A cornerstone page or the list of cornerstone pages was updated. */
|
||||
const REGENERATE_REASON_CORNERSTONE_UPDATE = 'cornerstone_update';
|
||||
|
||||
/** Existing critical CSS invalidated because of a significant change, e.g. Theme changed. */
|
||||
const REGENERATE_REASON_INVALIDATED = 'invalidated';
|
||||
|
||||
/** Requesting a regeneration because the previous request had failed and this is a followup attempt to regenerate Critical CSS. */
|
||||
const REGENERATE_REASON_FOLLOWUP = 'followup';
|
||||
|
||||
/**
|
||||
* Critical CSS storage class instance.
|
||||
*
|
||||
* @var Critical_CSS_Storage
|
||||
*/
|
||||
protected $storage;
|
||||
|
||||
/**
|
||||
* Critical CSS Provider Paths.
|
||||
*
|
||||
* @var Source_Providers
|
||||
*/
|
||||
protected $paths;
|
||||
|
||||
public function __construct() {
|
||||
$this->storage = new Critical_CSS_Storage();
|
||||
$this->paths = new Source_Providers();
|
||||
}
|
||||
|
||||
public function setup() {
|
||||
add_action( 'wp', array( $this, 'display_critical_css' ) );
|
||||
add_action( 'save_post', array( $this, 'handle_save_post' ), 10, 2 );
|
||||
add_action( 'jetpack_boost_critical_css_invalidated', array( $this, 'handle_critical_css_invalidated' ) );
|
||||
add_filter( 'jetpack_boost_total_problem_count', array( $this, 'update_total_problem_count' ) );
|
||||
|
||||
Generator::init();
|
||||
Critical_CSS_Invalidator::init();
|
||||
Cloud_CSS_Followup::init();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function activate() {
|
||||
( new Regenerate() )->start();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the module is ready and already serving critical CSS.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_ready() {
|
||||
return ( new Critical_CSS_State() )->is_generated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the action names that will be triggered when the module is ready and serving critical CSS.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function get_change_output_action_names() {
|
||||
return array( 'jetpack_boost_critical_css_invalidated', 'jetpack_boost_critical_css_generated' );
|
||||
}
|
||||
|
||||
public static function is_available() {
|
||||
return true === Premium_Features::has_feature( Premium_Features::CLOUD_CSS );
|
||||
}
|
||||
|
||||
public static function get_slug() {
|
||||
return 'cloud_css';
|
||||
}
|
||||
|
||||
public function get_always_available_endpoints() {
|
||||
return array(
|
||||
new Update_Cloud_CSS(),
|
||||
);
|
||||
}
|
||||
|
||||
public function display_critical_css() {
|
||||
|
||||
// Don't look for Critical CSS in the dashboard.
|
||||
if ( is_admin() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show Critical CSS in customizer previews.
|
||||
if ( is_customize_preview() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't display Critical CSS, if current page load is by the Critical CSS generator.
|
||||
if ( Generator::is_generating_critical_css() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the Critical CSS to show.
|
||||
$critical_css = $this->paths->get_current_request_css();
|
||||
if ( ! $critical_css ) {
|
||||
$keys = $this->paths->get_current_request_css_keys();
|
||||
$pending = ( new Critical_CSS_State() )->has_pending_provider( $keys );
|
||||
|
||||
// If Cloud CSS is still generating and the user is logged in, render the status information in a comment.
|
||||
if ( $pending && is_user_logged_in() ) {
|
||||
$display = new Display_Critical_CSS( '/* ' . __( 'Jetpack Boost is currently generating critical css for this page', 'jetpack-boost' ) . ' */' );
|
||||
add_action( 'wp_head', array( $display, 'display_critical_css' ), 0 );
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG === true ) {
|
||||
$critical_css = "/* Critical CSS Key: {$this->paths->get_current_critical_css_key()} */\n" . $critical_css;
|
||||
}
|
||||
|
||||
$display = new Display_Critical_CSS( $critical_css );
|
||||
add_action( 'wp_head', array( $display, 'display_critical_css' ), 0 );
|
||||
add_filter( 'style_loader_tag', array( $display, 'asynchronize_stylesheets' ), 10, 4 );
|
||||
add_action( 'wp_footer', array( $display, 'onload_flip_stylesheets' ) );
|
||||
|
||||
// Ensure admin bar compatibility.
|
||||
Admin_Bar_Compatibility::init();
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a Cloud CSS requests for provider groups.
|
||||
*
|
||||
* Initialize the Cloud CSS request. Provide $post parameter to limit generating to provider groups only associated
|
||||
* with a specific post.
|
||||
*/
|
||||
public function generate_cloud_css( $reason, $providers = array() ) {
|
||||
$grouped_urls = array();
|
||||
$grouped_ratios = array();
|
||||
|
||||
foreach ( $providers as $source ) {
|
||||
$provider = $source['key'];
|
||||
$grouped_urls[ $provider ] = $source['urls'];
|
||||
$grouped_ratios[ $provider ] = $source['success_ratio'];
|
||||
}
|
||||
|
||||
// Send the request to the Cloud.
|
||||
$payload = array(
|
||||
'providers' => $grouped_urls,
|
||||
'successRatios' => $grouped_ratios,
|
||||
);
|
||||
$payload['requestId'] = md5(
|
||||
wp_json_encode(
|
||||
$payload,
|
||||
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.
|
||||
) . time()
|
||||
);
|
||||
$payload['reason'] = $reason;
|
||||
return Boost_API::post( 'cloud-css', $payload );
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle regeneration of Cloud CSS when a post is saved.
|
||||
*/
|
||||
public function handle_save_post( $post_id, $post ) {
|
||||
if ( ! $post || ! isset( $post->post_type ) || ! is_post_publicly_viewable( $post ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( Cornerstone_Utils::is_cornerstone_page( $post_id ) ) {
|
||||
$this->regenerate_cloud_css( self::REGENERATE_REASON_CORNERSTONE_UPDATE, $this->get_all_providers() );
|
||||
return;
|
||||
}
|
||||
|
||||
// This checks against the latest providers list, not the list
|
||||
// stored in the database because newly added posts are always
|
||||
// included in the providers list that will be used to generate
|
||||
// the Cloud CSS.
|
||||
if ( $this->is_post_in_latest_providers_list( $post ) ) {
|
||||
$this->regenerate_cloud_css( self::REGENERATE_REASON_SAVE_POST, $this->get_all_providers( array( $post ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
public function regenerate_cloud_css( $reason, $providers ) {
|
||||
$result = $this->generate_cloud_css( $reason, $providers );
|
||||
if ( is_wp_error( $result ) ) {
|
||||
$state = new Critical_CSS_State();
|
||||
$state->set_error( $result->get_error_message() )->save();
|
||||
}
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the post is in the latest providers list.
|
||||
*
|
||||
* @param int|\WP_Post $post The post to check.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_post_in_latest_providers_list( $post ) {
|
||||
$post_link = get_permalink( $post );
|
||||
$providers = $this->get_all_providers();
|
||||
|
||||
foreach ( $providers as $provider ) {
|
||||
if ( in_array( $post_link, $provider['urls'], true ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when stored Critical CSS has been invalidated. Triggers a new Cloud CSS request.
|
||||
*/
|
||||
public function handle_critical_css_invalidated() {
|
||||
$this->regenerate_cloud_css( self::REGENERATE_REASON_INVALIDATED, $this->get_all_providers() );
|
||||
Cloud_CSS_Followup::schedule();
|
||||
}
|
||||
|
||||
public function get_all_providers( $context_posts = array() ) {
|
||||
$source_providers = new Source_Providers();
|
||||
return $source_providers->get_provider_sources( $context_posts );
|
||||
}
|
||||
|
||||
public function get_existing_sources() {
|
||||
$state = new Critical_CSS_State();
|
||||
$data = $state->get();
|
||||
if ( ! empty( $data['providers'] ) ) {
|
||||
$providers = $data['providers'];
|
||||
} else {
|
||||
$providers = $this->get_all_providers();
|
||||
}
|
||||
|
||||
return $providers;
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the total problem count for Boost if something's
|
||||
* wrong with Cloud CSS.
|
||||
*
|
||||
* @param int $count The current problem count.
|
||||
*
|
||||
* @return int
|
||||
*/
|
||||
public function update_total_problem_count( $count ) {
|
||||
return ( new Critical_CSS_State() )->has_errors() ? ++$count : $count;
|
||||
}
|
||||
}
|
||||
+142
@@ -0,0 +1,142 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Critical_CSS;
|
||||
|
||||
use Automattic\Jetpack\Schema\Schema;
|
||||
use Automattic\Jetpack\WP_JS_Data_Sync\Data_Sync;
|
||||
use Automattic\Jetpack_Boost\Admin\Regenerate_Admin_Notice;
|
||||
use Automattic\Jetpack_Boost\Contracts\Changes_Output_After_Activation;
|
||||
use Automattic\Jetpack_Boost\Contracts\Feature;
|
||||
use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
|
||||
use Automattic\Jetpack_Boost\Contracts\Needs_To_Be_Ready;
|
||||
use Automattic\Jetpack_Boost\Contracts\Optimization;
|
||||
use Automattic\Jetpack_Boost\Data_Sync\Critical_CSS_Meta_Entry;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Admin_Bar_Compatibility;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_Invalidator;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_State;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_Storage;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Data_Sync\Data_Sync_Schema;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Data_Sync_Actions\Regenerate_CSS;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Data_Sync_Actions\Set_Provider_CSS;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Data_Sync_Actions\Set_Provider_Error_Dismissed;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Data_Sync_Actions\Set_Provider_Errors;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Display_Critical_CSS;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Generator;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Source_Providers\Source_Providers;
|
||||
use Automattic\Jetpack_Boost\Lib\Premium_Features;
|
||||
|
||||
class Critical_CSS implements Feature, Changes_Output_After_Activation, Optimization, Has_Data_Sync, Needs_To_Be_Ready {
|
||||
|
||||
/**
|
||||
* Critical CSS storage class instance.
|
||||
*
|
||||
* @var Critical_CSS_Storage
|
||||
*/
|
||||
protected $storage;
|
||||
|
||||
/**
|
||||
* Critical CSS Provider Paths.
|
||||
*
|
||||
* @var Source_Providers
|
||||
*/
|
||||
protected $paths;
|
||||
|
||||
/**
|
||||
* Prepare module. This is run irrespective of the module activation status.
|
||||
*/
|
||||
public function __construct() {
|
||||
$this->storage = new Critical_CSS_Storage();
|
||||
$this->paths = new Source_Providers();
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the module is ready and already serving critical CSS.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_ready() {
|
||||
return ( new Critical_CSS_State() )->is_generated();
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the action names that will be triggered when the module is ready and serving critical CSS.
|
||||
*
|
||||
* @return string[]
|
||||
*/
|
||||
public static function get_change_output_action_names() {
|
||||
return array( 'jetpack_boost_critical_css_invalidated', 'jetpack_boost_critical_css_generated' );
|
||||
}
|
||||
|
||||
public static function is_available() {
|
||||
return true !== Premium_Features::has_feature( Premium_Features::CLOUD_CSS );
|
||||
}
|
||||
|
||||
/**
|
||||
* This is only run if Critical CSS module has been activated.
|
||||
*/
|
||||
public function setup() {
|
||||
add_action( 'wp', array( $this, 'display_critical_css' ) );
|
||||
add_filter( 'jetpack_boost_total_problem_count', array( $this, 'update_total_problem_count' ) );
|
||||
|
||||
Generator::init();
|
||||
Critical_CSS_Invalidator::init();
|
||||
CSS_Proxy::init();
|
||||
|
||||
// Admin Notices
|
||||
Regenerate_Admin_Notice::init();
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public function register_data_sync( Data_Sync $instance ) {
|
||||
$instance->register( 'critical_css_state', Data_Sync_Schema::critical_css_state() );
|
||||
$instance->register( 'critical_css_meta', Data_Sync_Schema::critical_css_meta(), new Critical_CSS_Meta_Entry() );
|
||||
$instance->register( 'critical_css_suggest_regenerate', Data_Sync_Schema::critical_css_suggest_regenerate() );
|
||||
$instance->register_action( 'critical_css_state', 'request-regenerate', Schema::as_void(), new Regenerate_CSS() );
|
||||
$instance->register_action( 'critical_css_state', 'set-provider-css', Data_Sync_Schema::critical_css_set_provider(), new Set_Provider_CSS() );
|
||||
$instance->register_action( 'critical_css_state', 'set-provider-errors', Data_Sync_Schema::critical_css_set_provider_errors(), new Set_Provider_Errors() );
|
||||
$instance->register_action( 'critical_css_state', 'set-provider-errors-dismissed', Data_Sync_Schema::critical_css_set_provider_errors_dismissed(), new Set_Provider_Error_Dismissed() );
|
||||
}
|
||||
|
||||
public static function get_slug() {
|
||||
return 'critical_css';
|
||||
}
|
||||
|
||||
public function display_critical_css() {
|
||||
// Don't look for Critical CSS in the dashboard.
|
||||
if ( is_admin() ) {
|
||||
return;
|
||||
}
|
||||
// Don't display Critical CSS when generating Critical CSS.
|
||||
if ( Generator::is_generating_critical_css() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Don't show Critical CSS in customizer previews.
|
||||
if ( is_customize_preview() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Get the Critical CSS to show.
|
||||
$critical_css = $this->paths->get_current_request_css();
|
||||
if ( ! $critical_css ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG === true ) {
|
||||
$critical_css = "/* Critical CSS Key: {$this->paths->get_current_critical_css_key()} */\n" . $critical_css;
|
||||
}
|
||||
|
||||
$display = new Display_Critical_CSS( $critical_css );
|
||||
add_action( 'wp_head', array( $display, 'display_critical_css' ), 0 );
|
||||
add_filter( 'style_loader_tag', array( $display, 'asynchronize_stylesheets' ), 10, 4 );
|
||||
add_action( 'wp_footer', array( $display, 'onload_flip_stylesheets' ) );
|
||||
|
||||
// Ensure admin bar compatibility.
|
||||
Admin_Bar_Compatibility::init();
|
||||
}
|
||||
|
||||
public function update_total_problem_count( $count ) {
|
||||
return ( new Critical_CSS_State() )->has_errors() ? ++$count : $count;
|
||||
}
|
||||
}
|
||||
+153
@@ -0,0 +1,153 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Critical_CSS;
|
||||
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Critical_CSS_State;
|
||||
use Automattic\Jetpack_Boost\Lib\Critical_CSS\Display_Critical_CSS;
|
||||
|
||||
/**
|
||||
* Add an ajax endpoint to proxy external CSS files.
|
||||
*/
|
||||
class CSS_Proxy {
|
||||
const NONCE_ACTION = 'jb-generate-proxy-nonce';
|
||||
|
||||
public static function init() {
|
||||
$instance = new self();
|
||||
|
||||
if ( is_admin() ) {
|
||||
add_action( 'wp_ajax_boost_proxy_css', array( $instance, 'handle_css_proxy' ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* AJAX handler to handle proxying of external CSS resources.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function handle_css_proxy() {
|
||||
|
||||
// Verify valid nonce.
|
||||
if ( empty( $_POST['nonce'] ) || ! wp_verify_nonce( sanitize_key( $_POST['nonce'] ), self::NONCE_ACTION ) ) {
|
||||
wp_die( '', 400 );
|
||||
}
|
||||
|
||||
// Make sure currently logged in as admin.
|
||||
if ( ! current_user_can( 'manage_options' ) ) {
|
||||
wp_die( '', 400 );
|
||||
}
|
||||
|
||||
// Reject any request made when not generating.
|
||||
if ( ! ( new Critical_CSS_State() )->is_requesting() ) {
|
||||
wp_die( '', 400 );
|
||||
}
|
||||
|
||||
// Validate URL and fetch.
|
||||
$proxy_url = filter_var( wp_unslash( $_POST['proxy_url'] ?? '' ), FILTER_VALIDATE_URL );
|
||||
if ( ! wp_http_validate_url( $proxy_url ) ) {
|
||||
die( 'Invalid URL' );
|
||||
}
|
||||
|
||||
$url_path = wp_parse_url( $proxy_url, PHP_URL_PATH );
|
||||
if ( ! $url_path || substr( strtolower( $url_path ), -4 ) !== '.css' ) {
|
||||
wp_die( 'Invalid CSS file URL', 400 );
|
||||
}
|
||||
|
||||
$css = $this->get_proxied_css( $proxy_url );
|
||||
|
||||
if ( $css ) {
|
||||
$this->serve_proxied_css( $css );
|
||||
die( 0 );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Output the proxied CSS body with the appropriate headers.
|
||||
*
|
||||
* Separated from handle_css_proxy() so the output (and its </style
|
||||
* neutralization) is testable without the request teardown (die()).
|
||||
*
|
||||
* @param string $css CSS body to serve.
|
||||
*/
|
||||
protected function serve_proxied_css( $css ) {
|
||||
if ( ! headers_sent() ) {
|
||||
header( 'Content-type: text/css' );
|
||||
header( 'X-Content-Type-Options: nosniff' );
|
||||
}
|
||||
|
||||
/*
|
||||
* Outputting proxied CSS contents unescaped. Do not strip tags here;
|
||||
* valid CSS values may contain markup (e.g. inline SVGs in data: URIs),
|
||||
* and stripping them corrupts the CSS fed to the generator. The
|
||||
* text/css + nosniff headers stop a browser from sniffing this body as
|
||||
* HTML; neutralizing </style is defense-in-depth in case the body is
|
||||
* ever embedded inside a <style> element downstream.
|
||||
*/
|
||||
// phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
echo Display_Critical_CSS::neutralize_style_closing_tags( $css );
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the CSS for a proxied URL from cache, or by fetching and caching it.
|
||||
*
|
||||
* Separated from handle_css_proxy() so the cache/fetch logic is unit-testable
|
||||
* without the surrounding request teardown (die()).
|
||||
*
|
||||
* @param string $proxy_url Validated external CSS URL.
|
||||
* @return string The CSS body, or '' when there is none to serve.
|
||||
*/
|
||||
protected function get_proxied_css( $proxy_url ) {
|
||||
$cache_key = 'jb_css_proxy_' . md5( $proxy_url );
|
||||
$response = get_transient( $cache_key );
|
||||
|
||||
if ( is_array( $response ) && isset( $response['error'] ) ) {
|
||||
wp_die( esc_html( $response['error'] ), 400 );
|
||||
}
|
||||
|
||||
if ( is_string( $response ) ) {
|
||||
// Cache hit: the transient stores the CSS body from a previous
|
||||
// successful fetch. Without this branch a cached body was ignored
|
||||
// (the handler saw no CSS), so it returned nothing to the Critical
|
||||
// CSS generator.
|
||||
return $response;
|
||||
}
|
||||
|
||||
// Anything other than a missing entry (false) has been handled above.
|
||||
if ( false !== $response ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$response = wp_safe_remote_get( $proxy_url );
|
||||
|
||||
// A transport failure must fail loudly with a 5xx. Falling through would
|
||||
// either mis-cache it as a bad content type or (via die()) emit an HTTP
|
||||
// 200 the Critical CSS generator would happily consume as a stylesheet,
|
||||
// silently corrupting that provider's Critical CSS.
|
||||
if ( is_wp_error( $response ) ) {
|
||||
wp_die( '', 502 );
|
||||
}
|
||||
|
||||
// Likewise, only a 2xx response is usable: an upstream 404/500 served with
|
||||
// a text/css content type must not be cached and served as valid CSS.
|
||||
$status_code = (int) wp_remote_retrieve_response_code( $response );
|
||||
if ( $status_code < 200 || $status_code >= 300 ) {
|
||||
wp_die( '', 502 );
|
||||
}
|
||||
|
||||
$content_type = wp_remote_retrieve_header( $response, 'content-type' );
|
||||
if ( strpos( $content_type, 'text/css' ) === false ) {
|
||||
set_transient( $cache_key, array( 'error' => 'Invalid content type. Expected CSS.' ), HOUR_IN_SECONDS );
|
||||
wp_die( 'Invalid content type. Expected CSS.', 400 );
|
||||
}
|
||||
|
||||
$css = wp_remote_retrieve_body( $response );
|
||||
|
||||
// Only cache a non-empty body. Caching an empty string would make
|
||||
// is_string() cache hits replay it for the full TTL, pinning empty
|
||||
// CSS for an hour after a single transient empty/failed fetch.
|
||||
if ( '' !== $css ) {
|
||||
set_transient( $cache_key, $css, HOUR_IN_SECONDS );
|
||||
}
|
||||
|
||||
return $css;
|
||||
}
|
||||
}
|
||||
+24
@@ -0,0 +1,24 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Image_CDN;
|
||||
|
||||
use Automattic\Jetpack\Image_CDN\Image_CDN_Setup;
|
||||
use Automattic\Jetpack_Boost\Contracts\Changes_Output_On_Activation;
|
||||
use Automattic\Jetpack_Boost\Contracts\Feature;
|
||||
use Automattic\Jetpack_Boost\Contracts\Needs_Website_To_Be_Public;
|
||||
use Automattic\Jetpack_Boost\Contracts\Optimization;
|
||||
|
||||
class Image_CDN implements Feature, Changes_Output_On_Activation, Needs_Website_To_Be_Public, Optimization {
|
||||
|
||||
public function setup() {
|
||||
Image_CDN_Setup::load();
|
||||
}
|
||||
|
||||
public static function get_slug() {
|
||||
return 'image_cdn';
|
||||
}
|
||||
|
||||
public static function is_available() {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Image_CDN;
|
||||
|
||||
use Automattic\Jetpack_Boost\Contracts\Changes_Output_On_Activation;
|
||||
use Automattic\Jetpack_Boost\Contracts\Sub_Feature;
|
||||
use Automattic\Jetpack_Boost\Lib\Premium_Features;
|
||||
|
||||
class Liar implements Sub_Feature, Changes_Output_On_Activation {
|
||||
|
||||
public function setup() {
|
||||
add_action( 'wp_footer', array( $this, 'inject_image_cdn_liar_script' ) );
|
||||
}
|
||||
|
||||
public static function get_slug() {
|
||||
return 'image_cdn_liar';
|
||||
}
|
||||
|
||||
public static function is_available() {
|
||||
return Premium_Features::has_feature( Premium_Features::IMAGE_CDN_LIAR );
|
||||
}
|
||||
|
||||
/**
|
||||
* Injects the image-cdn-liar.js script as an inline script in the footer.
|
||||
*/
|
||||
public function inject_image_cdn_liar_script() {
|
||||
$file = __DIR__ . '/dist/inline-liar.js';
|
||||
if ( file_exists( $file ) ) {
|
||||
// Include the JavaScript directly inline.
|
||||
// phpcs:ignore
|
||||
$data = file_get_contents( $file );
|
||||
// There's no meaningful way to escape JavaScript in this context.
|
||||
// phpcs:ignore
|
||||
echo wp_get_inline_script_tag( $data, array( 'async' => true ) );
|
||||
}
|
||||
}
|
||||
|
||||
public static function get_parent_features(): array {
|
||||
return array(
|
||||
Image_CDN::class,
|
||||
);
|
||||
}
|
||||
}
|
||||
+135
@@ -0,0 +1,135 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Image_CDN;
|
||||
|
||||
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\Has_Data_Sync;
|
||||
use Automattic\Jetpack_Boost\Contracts\Is_Always_On;
|
||||
use Automattic\Jetpack_Boost\Contracts\Sub_Feature;
|
||||
use Automattic\Jetpack_Boost\Lib\Premium_Features;
|
||||
|
||||
class Quality_Settings implements Sub_Feature, Is_Always_On, Has_Data_Sync, Changes_Output_After_Activation {
|
||||
|
||||
public function setup() {
|
||||
add_filter( 'jetpack_photon_pre_args', array( $this, 'add_quality_args' ), 10, 2 );
|
||||
}
|
||||
|
||||
public static function get_slug() {
|
||||
return 'image_cdn_quality';
|
||||
}
|
||||
|
||||
public static function is_available() {
|
||||
return Premium_Features::has_feature( Premium_Features::IMAGE_CDN_QUALITY );
|
||||
}
|
||||
|
||||
public function register_data_sync( Data_Sync $instance ) {
|
||||
$image_cdn_quality_schema = Schema::as_assoc_array(
|
||||
array(
|
||||
'jpg' => Schema::as_assoc_array(
|
||||
array(
|
||||
'quality' => Schema::as_number(),
|
||||
'lossless' => Schema::as_boolean(),
|
||||
)
|
||||
),
|
||||
'png' => Schema::as_assoc_array(
|
||||
array(
|
||||
'quality' => Schema::as_number(),
|
||||
'lossless' => Schema::as_boolean(),
|
||||
)
|
||||
),
|
||||
'webp' => Schema::as_assoc_array(
|
||||
array(
|
||||
'quality' => Schema::as_number(),
|
||||
'lossless' => Schema::as_boolean(),
|
||||
)
|
||||
),
|
||||
)
|
||||
)->fallback(
|
||||
array(
|
||||
'jpg' => array(
|
||||
'quality' => 89,
|
||||
'lossless' => false,
|
||||
),
|
||||
'png' => array(
|
||||
'quality' => 80,
|
||||
'lossless' => false,
|
||||
),
|
||||
'webp' => array(
|
||||
'quality' => 80,
|
||||
'lossless' => false,
|
||||
),
|
||||
)
|
||||
);
|
||||
|
||||
$instance->register( 'image_cdn_quality', $image_cdn_quality_schema );
|
||||
}
|
||||
|
||||
public static function get_change_output_action_names() {
|
||||
return array( 'update_option_' . JETPACK_BOOST_DATASYNC_NAMESPACE . '_image_cdn_quality' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add quality arg to existing photon args.
|
||||
*
|
||||
* @param array $args - Existing photon args.
|
||||
*
|
||||
* @return mixed
|
||||
*/
|
||||
public function add_quality_args( $args, $image_url ) {
|
||||
$quality = $this->get_quality_for_image( $image_url );
|
||||
|
||||
if ( $quality !== null ) {
|
||||
$args['quality'] = $quality;
|
||||
}
|
||||
|
||||
return $args;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the quality for an image based on the extension.
|
||||
*/
|
||||
private function get_quality_for_image( $image_url ) {
|
||||
static $quality_cache = array();
|
||||
|
||||
// Extract the file extension from the URL
|
||||
$file_extension = strtolower( pathinfo( $image_url, PATHINFO_EXTENSION ) );
|
||||
|
||||
static $extension_to_type = array(
|
||||
'jpg' => 'jpg',
|
||||
'jpeg' => 'jpg',
|
||||
'webp' => 'webp',
|
||||
'png' => 'png',
|
||||
);
|
||||
|
||||
if ( ! isset( $extension_to_type[ $file_extension ] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$type = $extension_to_type[ $file_extension ];
|
||||
|
||||
if ( ! isset( $quality_cache[ $type ] ) ) {
|
||||
$quality_cache[ $type ] = $this->get_quality_for_type( $type );
|
||||
}
|
||||
|
||||
return $quality_cache[ $type ];
|
||||
}
|
||||
|
||||
private function get_quality_for_type( $image_type ) {
|
||||
$quality_settings = jetpack_boost_ds_get( 'image_cdn_quality' );
|
||||
|
||||
if ( ! isset( $quality_settings[ $image_type ] ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Passing 100 to photon will result in a lossless image
|
||||
return $quality_settings[ $image_type ]['lossless'] ? 100 : $quality_settings[ $image_type ]['quality'];
|
||||
}
|
||||
|
||||
public static function get_parent_features(): array {
|
||||
return array(
|
||||
Image_CDN::class,
|
||||
);
|
||||
}
|
||||
}
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
<?php return array('dependencies' => array('wp-polyfill'), 'version' => '31700564e390fbbc6384');
|
||||
+1
@@ -0,0 +1 @@
|
||||
(()=>{"use strict";var t={164(t,e,r){function n(){return window.devicePixelRatio||1}function i(t){const e=new URL(t).searchParams.get("resize");return e?function(t){const[e,r]=t.split(",").map(Number);return isNaN(e)||isNaN(r)?null:{width:e,height:r}}(e):null}function s(t,e){if(e<=0)return!1;const r=e-t;if(r<0)return!1;if(r<50)return!0;const n=t/e;return n>.9&&n<=1}function o(t){if(!(t.getAttribute("width")&&t.getAttribute("height")&&t.srcset&&t.src&&t.src.includes(".wp.com")))return;const e=function(t){const e=n(),r=t.width/t.height,i=10*Math.ceil(t.width*e/10);return{width:i,height:Math.ceil(i/r)}}(t.getBoundingClientRect()),r=t.srcset.split(","),o=function(t,e){for(const r of t){const[t,n]=r.trim().split(" ");if(!n?.trim().endsWith("w"))continue;const o=i(t);if(o&&s(e,o.width))return{url:new URL(t),...o}}}([`${t.src} 0w`,...r],e.width);if(o)o.url.searchParams.set("_jb","closest"),r.push(`${o.url} ${window.innerWidth*n()}w`),t.srcset=r.join(","),t.sizes="auto";else{const i=function(t,e){const r=new URL(t);return r.searchParams.set("resize",`${e.width},${e.height}`),r}(t.src,e);i.searchParams.set("_jb","custom"),r.push(`${i} ${window.innerWidth*n()}w`),t.srcset=r.join(","),t.sizes="auto"}}r.d(e,{Er:()=>o})}},e={};function r(n){var i=e[n];if(void 0!==i)return i.exports;var s=e[n]={exports:{}};return t[n](s,s.exports,r),s.exports}r.d=(t,e)=>{for(var n in e)r.o(e,n)&&!r.o(t,n)&&Object.defineProperty(t,n,{enumerable:!0,get:e[n]})},r.o=(t,e)=>Object.prototype.hasOwnProperty.call(t,e);var n=r(164);document.querySelectorAll("img[loading=lazy]").forEach(n.Er)})();
|
||||
@@ -0,0 +1,6 @@
|
||||
import { dynamicSrcset } from './srcset';
|
||||
|
||||
( function () {
|
||||
const lazyImages = document.querySelectorAll< HTMLImageElement >( 'img[loading=lazy]' );
|
||||
lazyImages.forEach( dynamicSrcset );
|
||||
} )();
|
||||
+308
@@ -0,0 +1,308 @@
|
||||
import {
|
||||
calculateTargetSize,
|
||||
dynamicSrcset,
|
||||
findClosestImageSize,
|
||||
getImageSizeFromUrl,
|
||||
isSizeReusable,
|
||||
parseImageSize,
|
||||
} from './srcset';
|
||||
|
||||
function createImageSize( resize: string, mq: string ) {
|
||||
const url = new URL( 'https://i0.wp.com/example.com/image.jpg' );
|
||||
const [ width, height ] = resize.split( ',' ).map( item => parseInt( item, 10 ) );
|
||||
const size = calculateTargetSize( { width, height } );
|
||||
url.searchParams.set( 'resize', `${ size.width },${ size.height }` );
|
||||
return `${ url } ${ mq }`;
|
||||
}
|
||||
|
||||
function setBoundingRect( img: HTMLImageElement, width: number, height: number ) {
|
||||
Object.defineProperty( img, 'getBoundingClientRect', {
|
||||
value: () => ( {
|
||||
width,
|
||||
height,
|
||||
top: 0,
|
||||
right: width,
|
||||
bottom: height,
|
||||
left: 0,
|
||||
} ),
|
||||
writable: true,
|
||||
} );
|
||||
}
|
||||
|
||||
describe( 'parse image resize param', () => {
|
||||
it( 'should parse valid resize param', () => {
|
||||
expect( parseImageSize( '100,200' ) ).toEqual( { width: 100, height: 200 } );
|
||||
} );
|
||||
|
||||
it( 'should return null for invalid resize param', () => {
|
||||
expect( parseImageSize( 'invalid' ) ).toBeNull();
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'getImageSizeFromUrl', () => {
|
||||
it( 'should extract image size from URL', () => {
|
||||
const url = 'https://i0.wp.com/example.com/image.jpg?resize=100,200';
|
||||
expect( getImageSizeFromUrl( url ) ).toEqual( { width: 100, height: 200 } );
|
||||
} );
|
||||
|
||||
it( 'should return null if resize param is missing', () => {
|
||||
const url = 'https://i0.wp.com/example.com/image.jpg';
|
||||
expect( getImageSizeFromUrl( url ) ).toBeNull();
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'calculateTargetSize', () => {
|
||||
it( 'should calculate target size based on bounding rect and dpr', () => {
|
||||
const rect: DOMRect = {
|
||||
width: 500,
|
||||
height: 250,
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
toJSON: () => ( {} ),
|
||||
};
|
||||
window.devicePixelRatio = 2;
|
||||
expect( calculateTargetSize( rect ) ).toEqual( { width: 1000, height: 500 } );
|
||||
} );
|
||||
|
||||
it( 'should round up the target size to the nearest 10', () => {
|
||||
const ratio = 600 / 200;
|
||||
const rect: DOMRect = {
|
||||
width: 600 - 9,
|
||||
height: ( 600 - 9 ) / ratio, // = 197px
|
||||
x: 0,
|
||||
y: 0,
|
||||
top: 0,
|
||||
right: 0,
|
||||
bottom: 0,
|
||||
left: 0,
|
||||
toJSON: () => ( {} ),
|
||||
};
|
||||
window.devicePixelRatio = 1;
|
||||
expect( calculateTargetSize( rect ) ).toEqual( {
|
||||
width: 600,
|
||||
height: Math.ceil( 600 / ratio ),
|
||||
} );
|
||||
|
||||
window.devicePixelRatio = 2;
|
||||
expect( calculateTargetSize( rect ) ).toEqual( {
|
||||
width: 1190,
|
||||
height: Math.ceil( 1190 / ratio ),
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'isSizeReusable', () => {
|
||||
it( 'should return true if the target width is close to the width', () => {
|
||||
expect( isSizeReusable( 100, 100 ) ).toBe( true );
|
||||
expect( isSizeReusable( 90, 100 ) ).toBe( true );
|
||||
expect( isSizeReusable( 51, 100 ) ).toBe( true );
|
||||
expect( isSizeReusable( 901, 1000 ) ).toBe( true );
|
||||
expect( isSizeReusable( 50, 100 ) ).toBe( false );
|
||||
expect( isSizeReusable( 101, 100 ) ).toBe( false );
|
||||
expect( isSizeReusable( 150, 100 ) ).toBe( false );
|
||||
expect( isSizeReusable( 900, 1000 ) ).toBe( false );
|
||||
} );
|
||||
} );
|
||||
|
||||
describe( 'findClosestImageSize', () => {
|
||||
beforeEach( () => {
|
||||
window.devicePixelRatio = 1;
|
||||
} );
|
||||
|
||||
const srcset = [
|
||||
createImageSize( '100,50', '200w' ),
|
||||
createImageSize( '400,250', '800w' ),
|
||||
createImageSize( '1400,700', '2400w' ),
|
||||
];
|
||||
|
||||
it( 'should return undefined if the urls are invalid', () => {
|
||||
expect( findClosestImageSize( [ 'foo.com', 'bar.com' ], 500 ) ).toBeUndefined();
|
||||
} );
|
||||
|
||||
it( "should find the closest image that's larger than the target width", () => {
|
||||
expect( findClosestImageSize( srcset, 51 ) ).toEqual( {
|
||||
url: new URL( srcset[ 0 ].split( ' ' )[ 0 ] ),
|
||||
width: 100,
|
||||
height: 50,
|
||||
} );
|
||||
|
||||
expect( findClosestImageSize( srcset, 1300 ) ).toEqual( {
|
||||
url: new URL( srcset[ 2 ].split( ' ' )[ 0 ] ),
|
||||
width: 1400,
|
||||
height: 700,
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'should find the closest image even if the srcset is unordered', () => {
|
||||
const unorderedSrcset = [
|
||||
createImageSize( '1400,700', '1400w' ),
|
||||
createImageSize( '100,50', '100w' ),
|
||||
createImageSize( '400,250', '400w' ),
|
||||
];
|
||||
expect( findClosestImageSize( unorderedSrcset, 1300 ) ).toEqual( {
|
||||
url: new URL( unorderedSrcset[ 0 ].split( ' ' )[ 0 ] ),
|
||||
width: 1400,
|
||||
height: 700,
|
||||
} );
|
||||
|
||||
expect( findClosestImageSize( unorderedSrcset, 51 ) ).toEqual( {
|
||||
url: new URL( unorderedSrcset[ 1 ].split( ' ' )[ 0 ] ),
|
||||
width: 100,
|
||||
height: 50,
|
||||
} );
|
||||
|
||||
expect( findClosestImageSize( unorderedSrcset, 351 ) ).toEqual( {
|
||||
url: new URL( unorderedSrcset[ 2 ].split( ' ' )[ 0 ] ),
|
||||
width: 400,
|
||||
height: 250,
|
||||
} );
|
||||
} );
|
||||
|
||||
it( "shouldn't find closest image if the closest image isn't close enough in size", () => {
|
||||
expect( findClosestImageSize( srcset, 50 ) ).toBeUndefined();
|
||||
expect( findClosestImageSize( srcset, 110 ) ).toBeUndefined();
|
||||
expect( findClosestImageSize( srcset, 1100 ) ).toBeUndefined();
|
||||
expect( findClosestImageSize( srcset, 1200 ) ).toBeUndefined();
|
||||
} );
|
||||
} );
|
||||
|
||||
function untrackedDynamicSrcset( img: HTMLImageElement ) {
|
||||
dynamicSrcset( img );
|
||||
img.srcset = img.srcset.replaceAll( /&_jb=\w+/gim, '' );
|
||||
}
|
||||
|
||||
describe( 'dynamicSrcset', () => {
|
||||
let img: HTMLImageElement;
|
||||
|
||||
it( '[manual validation] should create a new srcset entry for the target size', () => {
|
||||
window.innerWidth = 9999;
|
||||
window.devicePixelRatio = 1;
|
||||
const manualValidationImg = document.createElement( 'img' );
|
||||
|
||||
manualValidationImg.setAttribute( 'src', 'https://i0.wp.com/example.com/image.jpg' );
|
||||
manualValidationImg.setAttribute(
|
||||
'srcset',
|
||||
`https://i0.wp.com/example.com/image.jpg?resize=100%2C200 1000w, https://i0.wp.com/example.com/image.jpg?resize=400%2C200 400w, https://i0.wp.com/example.com/image.jpg?resize=1400%2C700 1400w`
|
||||
);
|
||||
manualValidationImg.setAttribute( 'width', '800' );
|
||||
manualValidationImg.setAttribute( 'height', '400' );
|
||||
setBoundingRect( manualValidationImg, 800, 400 );
|
||||
|
||||
untrackedDynamicSrcset( manualValidationImg );
|
||||
expect( manualValidationImg.srcset ).toContain(
|
||||
`https://i0.wp.com/example.com/image.jpg?resize=800%2C400 9999w`
|
||||
);
|
||||
|
||||
window.devicePixelRatio = 2;
|
||||
untrackedDynamicSrcset( manualValidationImg );
|
||||
expect( manualValidationImg.srcset ).toContain(
|
||||
`https://i0.wp.com/example.com/image.jpg?resize=1600%2C800 19998w`
|
||||
);
|
||||
} );
|
||||
|
||||
const testDevicePixelRatios = [ 1, 1.5 ];
|
||||
|
||||
testDevicePixelRatios.forEach( devicePixelRatio => {
|
||||
const w = ( value: number ) => `${ value * devicePixelRatio }w`;
|
||||
describe( `with devicePixelRatio ${ devicePixelRatio }`, () => {
|
||||
beforeEach( () => {
|
||||
window.devicePixelRatio = devicePixelRatio;
|
||||
window.innerWidth = 5000;
|
||||
img = document.createElement( 'img' );
|
||||
img.src = 'https://i0.wp.com/example.com/image.jpg?resize=4444%2C2222';
|
||||
const srcset = [
|
||||
createImageSize( '100,50', '100w' ),
|
||||
createImageSize( '400,250', '400w' ),
|
||||
createImageSize( '1400,700', '1400w' ),
|
||||
];
|
||||
|
||||
img.srcset = srcset.join( ',' );
|
||||
img.setAttribute( 'width', '1000' );
|
||||
img.setAttribute( 'height', '500' );
|
||||
|
||||
// Mocking the bounding rect of the image
|
||||
setBoundingRect( img, 1000, 500 );
|
||||
} );
|
||||
|
||||
it( 'srcset should include all original image sizes', () => {
|
||||
untrackedDynamicSrcset( img );
|
||||
expect( img.srcset ).toContain( createImageSize( '100,50', '100w' ) );
|
||||
expect( img.srcset ).toContain( createImageSize( '400,250', '400w' ) );
|
||||
expect( img.srcset ).toContain( createImageSize( '1400,700', '1400w' ) );
|
||||
} );
|
||||
|
||||
it( 'should create a new srcset entry for the target size', () => {
|
||||
untrackedDynamicSrcset( img );
|
||||
expect( img.srcset ).toContain( createImageSize( '1000,500', w( window.innerWidth ) ) );
|
||||
} );
|
||||
|
||||
it( 'should reuse existing srcset entry if the target size is close enough', () => {
|
||||
setBoundingRect( img, 396, 248 );
|
||||
untrackedDynamicSrcset( img );
|
||||
expect( img.srcset ).toContain( createImageSize( '400,250', w( window.innerWidth ) ) );
|
||||
} );
|
||||
|
||||
it( "shouldn't upscale the image when reusing an srcset entry", () => {
|
||||
setBoundingRect( img, 420, 260 );
|
||||
untrackedDynamicSrcset( img );
|
||||
expect( img.srcset ).toContain( createImageSize( '420,260', w( window.innerWidth ) ) );
|
||||
} );
|
||||
|
||||
it( 'should not update attributes if conditions are not met', () => {
|
||||
const image = document.createElement( 'img' );
|
||||
image.src = 'https://i0.wp.com/example.com/image.jpg';
|
||||
|
||||
untrackedDynamicSrcset( image );
|
||||
|
||||
expect( image.srcset ).toBe( '' );
|
||||
expect( image.sizes ).toBe( '' );
|
||||
} );
|
||||
} );
|
||||
} );
|
||||
|
||||
it( 'should reuse existing src image if the target size is close enough', () => {
|
||||
window.innerWidth = 5000;
|
||||
window.devicePixelRatio = 1;
|
||||
setBoundingRect( img, 4400, 2200 );
|
||||
untrackedDynamicSrcset( img );
|
||||
expect( img.srcset ).toContain(
|
||||
`https://i0.wp.com/example.com/image.jpg?resize=4444%2C2222 5000w`
|
||||
);
|
||||
window.devicePixelRatio = 1.5;
|
||||
untrackedDynamicSrcset( img );
|
||||
expect( img.srcset ).toContain(
|
||||
`https://i0.wp.com/example.com/image.jpg?resize=6600%2C3300 7500w`
|
||||
);
|
||||
} );
|
||||
|
||||
it( 'should reuse existing src image after accounting for DPR > 1', () => {
|
||||
window.innerWidth = 5000;
|
||||
|
||||
window.devicePixelRatio = 1;
|
||||
// Fixed DPR 1 sizes as they'd appear in DOM
|
||||
const srcset = [
|
||||
createImageSize( '100,50', '100w' ),
|
||||
createImageSize( '400,250', '400w' ),
|
||||
createImageSize( '1400,700', '1400w' ),
|
||||
];
|
||||
|
||||
// Pretend this is a dense display rendering a small image
|
||||
window.devicePixelRatio = 3;
|
||||
img.srcset = srcset.join( ',' );
|
||||
setBoundingRect( img, 460, 230 );
|
||||
untrackedDynamicSrcset( img );
|
||||
expect( img.srcset ).toContain(
|
||||
`https://i0.wp.com/example.com/image.jpg?resize=1400%2C700 15000w`
|
||||
);
|
||||
|
||||
setBoundingRect( img, 500, 250 );
|
||||
untrackedDynamicSrcset( img );
|
||||
expect( img.srcset ).toContain(
|
||||
`https://i0.wp.com/example.com/image.jpg?resize=1500%2C750 15000w`
|
||||
);
|
||||
} );
|
||||
} );
|
||||
@@ -0,0 +1,113 @@
|
||||
type Dimensions = {
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
type ImageMeta = {
|
||||
url: URL;
|
||||
width: number;
|
||||
height: number;
|
||||
};
|
||||
|
||||
function getDpr() {
|
||||
return window.devicePixelRatio || 1;
|
||||
}
|
||||
|
||||
export function parseImageSize( resizeParam: string ): Dimensions | null {
|
||||
const [ width, height ] = resizeParam.split( ',' ).map( Number );
|
||||
if ( isNaN( width ) || isNaN( height ) ) {
|
||||
return null;
|
||||
}
|
||||
return { width, height };
|
||||
}
|
||||
|
||||
export function getImageSizeFromUrl( url: string ): Dimensions | null {
|
||||
const resizeParam = new URL( url ).searchParams.get( 'resize' );
|
||||
if ( ! resizeParam ) {
|
||||
return null;
|
||||
}
|
||||
return parseImageSize( resizeParam );
|
||||
}
|
||||
|
||||
export function calculateTargetSize( dimensions: Dimensions ): Dimensions {
|
||||
const dpr = getDpr();
|
||||
const ratio = dimensions.width / dimensions.height;
|
||||
const targetWidth = Math.ceil( ( dimensions.width * dpr ) / 10 ) * 10;
|
||||
const targetHeight = Math.ceil( targetWidth / ratio );
|
||||
return {
|
||||
width: targetWidth,
|
||||
height: targetHeight,
|
||||
};
|
||||
}
|
||||
|
||||
export function isSizeReusable( desiredWidth: number, existingWidth: number ) {
|
||||
if ( existingWidth <= 0 ) {
|
||||
return false;
|
||||
}
|
||||
const diff = existingWidth - desiredWidth;
|
||||
if ( diff < 0 ) {
|
||||
return false;
|
||||
}
|
||||
if ( diff < 50 ) {
|
||||
return true;
|
||||
}
|
||||
const ratio = desiredWidth / existingWidth;
|
||||
return ratio > 0.9 && ratio <= 1;
|
||||
}
|
||||
|
||||
export function findClosestImageSize( urls: string[], targetWidth: number ): ImageMeta | undefined {
|
||||
for ( const src of urls ) {
|
||||
const [ url, widthStr ] = src.trim().split( ' ' );
|
||||
if ( ! widthStr?.trim().endsWith( 'w' ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const imageSize = getImageSizeFromUrl( url );
|
||||
if ( ! imageSize ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if ( isSizeReusable( targetWidth, imageSize.width ) ) {
|
||||
return { url: new URL( url ), ...imageSize };
|
||||
}
|
||||
}
|
||||
|
||||
return undefined;
|
||||
}
|
||||
|
||||
function resizeImage( imageUrl: string, targetSize: Dimensions ): URL {
|
||||
const newUrl = new URL( imageUrl );
|
||||
newUrl.searchParams.set( 'resize', `${ targetSize.width },${ targetSize.height }` );
|
||||
return newUrl;
|
||||
}
|
||||
|
||||
export function dynamicSrcset( img: HTMLImageElement ) {
|
||||
if (
|
||||
! img.getAttribute( 'width' ) ||
|
||||
! img.getAttribute( 'height' ) ||
|
||||
! img.srcset ||
|
||||
! img.src ||
|
||||
! img.src.includes( '.wp.com' )
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const rect = img.getBoundingClientRect();
|
||||
const targetSize = calculateTargetSize( rect );
|
||||
|
||||
const srcset = img.srcset.split( ',' );
|
||||
const closestImage = findClosestImageSize( [ `${ img.src } 0w`, ...srcset ], targetSize.width );
|
||||
|
||||
if ( closestImage ) {
|
||||
closestImage.url.searchParams.set( '_jb', 'closest' );
|
||||
srcset.push( `${ closestImage.url } ${ window.innerWidth * getDpr() }w` );
|
||||
img.srcset = srcset.join( ',' );
|
||||
img.sizes = 'auto';
|
||||
} else {
|
||||
const newUrl = resizeImage( img.src, targetSize );
|
||||
newUrl.searchParams.set( '_jb', 'custom' );
|
||||
srcset.push( `${ newUrl } ${ window.innerWidth * getDpr() }w` );
|
||||
img.srcset = srcset.join( ',' );
|
||||
img.sizes = 'auto';
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
<?php
|
||||
/**
|
||||
* Empty file.
|
||||
*/
|
||||
|
||||
// Silence is golden.
|
||||
+127
@@ -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;
|
||||
}
|
||||
}
|
||||
+78
@@ -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 ),
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
+191
@@ -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;
|
||||
}
|
||||
}
|
||||
+229
@@ -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;
|
||||
}
|
||||
}
|
||||
+249
@@ -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();
|
||||
}
|
||||
}
|
||||
+43
@@ -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,
|
||||
);
|
||||
}
|
||||
}
|
||||
+81
@@ -0,0 +1,81 @@
|
||||
<?php
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Minify;
|
||||
|
||||
use Automattic\Jetpack\Schema\Schema;
|
||||
use Automattic\Jetpack\WP_JS_Data_Sync\Data_Sync;
|
||||
use Automattic\Jetpack_Boost\Contracts\Has_Activate;
|
||||
use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
|
||||
use Automattic\Jetpack_Boost\Contracts\Has_Deactivate;
|
||||
use Automattic\Jetpack_Boost\Contracts\Is_Always_On;
|
||||
use Automattic\Jetpack_Boost\Contracts\Optimization;
|
||||
use Automattic\Jetpack_Boost\Contracts\Sub_Feature;
|
||||
|
||||
class Minify_Common implements Sub_Feature, Optimization, Is_Always_On, Has_Activate, Has_Deactivate, Has_Data_Sync {
|
||||
|
||||
/**
|
||||
* Setup the module. This runs on every page load.
|
||||
*/
|
||||
public function setup() {
|
||||
require_once JETPACK_BOOST_DIR_PATH . '/app/lib/minify/functions-helpers.php';
|
||||
|
||||
jetpack_boost_minify_init();
|
||||
}
|
||||
|
||||
public static function get_slug() {
|
||||
return 'minify_common';
|
||||
}
|
||||
|
||||
public function register_data_sync( Data_Sync $instance ) {
|
||||
$instance->register_readonly(
|
||||
'minify_legacy_notice',
|
||||
Schema::as_unsafe_any(),
|
||||
array( self::class, 'show_legacy_notice' )
|
||||
);
|
||||
}
|
||||
|
||||
public static function is_available() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function show_legacy_notice() {
|
||||
// If the JETPACK_BOOST_DISABLE_404_TESTER is set and true, we don't need to show the legacy notice.
|
||||
if ( defined( 'JETPACK_BOOST_DISABLE_404_TESTER' ) && JETPACK_BOOST_DISABLE_404_TESTER ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If this is a multisite, and the user is not a super admin, don't show the legacy notice, as they won't be able to do anything about it.
|
||||
if ( is_multisite() && ! current_user_can( 'manage_network_options' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If the static minfification has not ran yet, don't show the legacy notice.
|
||||
$static_minification_enabled = get_site_option( 'jetpack_boost_static_minification', 'na' );
|
||||
if ( $static_minification_enabled === 'na' ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// Otherwise show it if the 404 tester determined it can't be used.
|
||||
return ! (bool) $static_minification_enabled;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is called when either minify module is activated
|
||||
*/
|
||||
public static function activate() {
|
||||
jetpack_boost_minify_activation();
|
||||
}
|
||||
|
||||
/**
|
||||
* This is called when either minify module is deactivated.
|
||||
*/
|
||||
public static function deactivate() {
|
||||
jetpack_boost_minify_clear_scheduled_events();
|
||||
}
|
||||
|
||||
public static function get_parent_features(): array {
|
||||
return array(
|
||||
Minify_JS::class,
|
||||
Minify_CSS::class,
|
||||
);
|
||||
}
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Minify;
|
||||
|
||||
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\Changes_Output_On_Activation;
|
||||
use Automattic\Jetpack_Boost\Contracts\Feature;
|
||||
use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
|
||||
use Automattic\Jetpack_Boost\Contracts\Has_Deactivate;
|
||||
use Automattic\Jetpack_Boost\Contracts\Optimization;
|
||||
use Automattic\Jetpack_Boost\Data_Sync\Minify_Excludes_State_Entry;
|
||||
use Automattic\Jetpack_Boost\Lib\Minify\Concatenate_CSS;
|
||||
|
||||
class Minify_CSS implements Feature, Changes_Output_On_Activation, Changes_Output_After_Activation, Optimization, Has_Deactivate, Has_Data_Sync {
|
||||
|
||||
public static $default_excludes = array( 'admin-bar', 'dashicons', 'elementor-app' );
|
||||
|
||||
/**
|
||||
* Setup the module. This runs on every page load.
|
||||
*/
|
||||
public function setup() {
|
||||
if ( jetpack_boost_page_optimize_bail() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_action( 'init', array( $this, 'init_minify' ) );
|
||||
}
|
||||
|
||||
public function register_data_sync( Data_Sync $instance ) {
|
||||
$parser = Schema::as_array( Schema::as_string() )->fallback( self::$default_excludes );
|
||||
|
||||
$instance->register( 'minify_css_excludes', $parser, new Minify_Excludes_State_Entry( 'minify_css_excludes' ) );
|
||||
|
||||
$instance->register_readonly(
|
||||
'minify_css_excludes_default',
|
||||
Schema::as_unsafe_any(),
|
||||
function () {
|
||||
return Minify_CSS::$default_excludes;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public static function get_slug() {
|
||||
return 'minify_css';
|
||||
}
|
||||
|
||||
public static function get_change_output_action_names() {
|
||||
return array( 'update_option_' . JETPACK_BOOST_DATASYNC_NAMESPACE . '_minify_css_excludes' );
|
||||
}
|
||||
|
||||
public static function is_available() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function init_minify() {
|
||||
global $wp_styles;
|
||||
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
|
||||
$wp_styles = new Concatenate_CSS( $wp_styles );
|
||||
$wp_styles->allow_gzip_compression = true; // @todo - used constant ALLOW_GZIP_COMPRESSION = true if not defined.
|
||||
}
|
||||
|
||||
/**
|
||||
* This is called only when the module is deactivated.
|
||||
*/
|
||||
public static function deactivate() {
|
||||
jetpack_boost_page_optimize_cleanup_cache( 'css' );
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Minify;
|
||||
|
||||
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\Changes_Output_On_Activation;
|
||||
use Automattic\Jetpack_Boost\Contracts\Feature;
|
||||
use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
|
||||
use Automattic\Jetpack_Boost\Contracts\Has_Deactivate;
|
||||
use Automattic\Jetpack_Boost\Contracts\Optimization;
|
||||
use Automattic\Jetpack_Boost\Data_Sync\Minify_Excludes_State_Entry;
|
||||
use Automattic\Jetpack_Boost\Lib\Minify\Concatenate_JS;
|
||||
|
||||
class Minify_JS implements Feature, Changes_Output_On_Activation, Changes_Output_After_Activation, Optimization, Has_Deactivate, Has_Data_Sync {
|
||||
|
||||
public static $default_excludes = array( 'jquery', 'jquery-core', 'underscore', 'backbone' );
|
||||
|
||||
/**
|
||||
* Setup the module. This runs on every page load.
|
||||
*/
|
||||
public function setup() {
|
||||
if ( jetpack_boost_page_optimize_bail() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
add_action( 'init', array( $this, 'init_minify' ) );
|
||||
}
|
||||
|
||||
public function register_data_sync( Data_Sync $instance ) {
|
||||
$parser = Schema::as_array( Schema::as_string() )->fallback( self::$default_excludes );
|
||||
|
||||
$instance->register( 'minify_js_excludes', $parser, new Minify_Excludes_State_Entry( 'minify_js_excludes' ) );
|
||||
|
||||
$instance->register_readonly(
|
||||
'minify_js_excludes_default',
|
||||
Schema::as_unsafe_any(),
|
||||
function () {
|
||||
return Minify_JS::$default_excludes;
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
public static function get_slug() {
|
||||
return 'minify_js';
|
||||
}
|
||||
|
||||
public static function get_change_output_action_names() {
|
||||
return array( 'update_option_' . JETPACK_BOOST_DATASYNC_NAMESPACE . '_minify_js_excludes' );
|
||||
}
|
||||
|
||||
public static function is_available() {
|
||||
return true;
|
||||
}
|
||||
|
||||
public function init_minify() {
|
||||
global $wp_scripts;
|
||||
|
||||
// phpcs:ignore WordPress.WP.GlobalVariablesOverride.Prohibited
|
||||
$wp_scripts = new Concatenate_JS( $wp_scripts );
|
||||
$wp_scripts->allow_gzip_compression = true; // @todo - used constant ALLOW_GZIP_COMPRESSION = true if not defined.
|
||||
}
|
||||
|
||||
/**
|
||||
* This is called only when the module is deactivated.
|
||||
*/
|
||||
public static function deactivate() {
|
||||
jetpack_boost_page_optimize_cleanup_cache( 'js' );
|
||||
}
|
||||
}
|
||||
+235
@@ -0,0 +1,235 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache;
|
||||
|
||||
use Automattic\Jetpack_Boost\Contracts\Has_Activate;
|
||||
use Automattic\Jetpack_Boost\Contracts\Has_Deactivate;
|
||||
use Automattic\Jetpack_Boost\Contracts\Is_Always_On;
|
||||
use Automattic\Jetpack_Boost\Contracts\Sub_Feature;
|
||||
use Automattic\Jetpack_Boost\Lib\Cornerstone\Cornerstone_Utils;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
|
||||
|
||||
/**
|
||||
* Class Cache_Preload
|
||||
*
|
||||
* Handles the rebuilding/preloading of cache for pages, currently only for Cornerstone Pages.
|
||||
* This module automagically preloads the cache after cache invalidation events such as rebuilds, or when
|
||||
* Cornerstone Pages are updated, to ensure that important pages always have a cache.
|
||||
*
|
||||
* @since 3.11.0
|
||||
* @package Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache
|
||||
*/
|
||||
class Cache_Preload implements Sub_Feature, Has_Activate, Has_Deactivate, Is_Always_On {
|
||||
|
||||
/**
|
||||
* @since 3.11.0
|
||||
*/
|
||||
public function setup() {
|
||||
add_action( 'update_option_jetpack_boost_ds_cornerstone_pages_list', array( $this, 'schedule_cornerstone' ) );
|
||||
add_action( 'jetpack_boost_preload_cornerstone', array( $this, 'preload_cornerstone' ) );
|
||||
add_action( 'jetpack_boost_preload', array( $this, 'preload' ) );
|
||||
|
||||
add_action( 'post_updated', array( $this, 'handle_post_update' ) );
|
||||
add_action( 'jetpack_boost_invalidate_cache_success', array( $this, 'handle_cache_invalidation' ), 10, 3 );
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 3.11.0
|
||||
*/
|
||||
public static function get_slug() {
|
||||
return 'cache_preload';
|
||||
}
|
||||
|
||||
/**
|
||||
* @since 3.11.0
|
||||
*/
|
||||
public static function is_available() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* As this is a submodule, this activate is triggered when the parent module is activated,
|
||||
* despite the module having Is_Always_On.
|
||||
*
|
||||
* @since 3.12.0
|
||||
*/
|
||||
public static function activate() {
|
||||
$instance = new self();
|
||||
$instance->schedule_cornerstone_cronjob();
|
||||
}
|
||||
|
||||
/**
|
||||
*
|
||||
* @since 3.12.0
|
||||
*/
|
||||
public static function deactivate() {
|
||||
wp_unschedule_hook( 'jetpack_boost_preload_cornerstone' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule the cronjob to preload the cache for Cornerstone Pages.
|
||||
*
|
||||
* @since 3.12.0
|
||||
* @return void
|
||||
*/
|
||||
public function schedule_cornerstone_cronjob() {
|
||||
if ( ! wp_next_scheduled( 'jetpack_boost_preload_cornerstone' ) ) {
|
||||
wp_schedule_event( time(), 'twicehourly', 'jetpack_boost_preload_cornerstone' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule Preload for all Cornerstone Pages.
|
||||
*
|
||||
* This method is triggered when the Cornerstone Pages list is updated,
|
||||
* ensuring all Cornerstone Pages have their cache rebuilt.
|
||||
*
|
||||
* @since 3.12.0
|
||||
* @return void
|
||||
*/
|
||||
public function schedule_cornerstone() {
|
||||
wp_schedule_single_event( time(), 'jetpack_boost_preload_cornerstone' );
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule a rebuild for the given URLs.
|
||||
*
|
||||
* @since 3.12.0
|
||||
* @param array $urls The URLs of the Cornerstone Pages to rebuild.
|
||||
* @return void
|
||||
*/
|
||||
public function schedule( array $urls ) {
|
||||
Logger::debug( sprintf( 'Scheduling preload for %d pages', count( $urls ) ) );
|
||||
wp_schedule_single_event( time(), 'jetpack_boost_preload', array( $urls ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the cache for all Cornerstone Pages.
|
||||
*
|
||||
* @since 3.12.0
|
||||
* @return void
|
||||
*/
|
||||
public function preload_cornerstone() {
|
||||
$urls = Cornerstone_Utils::get_list();
|
||||
$this->preload( $urls );
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the cache for the given URLs.
|
||||
*
|
||||
* @since 3.12.0
|
||||
* @param array $urls The URLs of the pages to preload.
|
||||
* @return void
|
||||
*/
|
||||
public function preload( array $urls ) {
|
||||
Logger::debug( sprintf( 'Preload started for %d pages', count( $urls ) ) );
|
||||
|
||||
// Pause the hook here to avoid an infinite loop of: invalidation → preload → invalidation.
|
||||
remove_action( 'jetpack_boost_invalidate_cache_success', array( $this, 'handle_cache_invalidation' ) );
|
||||
$boost_cache = new Boost_Cache();
|
||||
foreach ( $urls as $url ) {
|
||||
$boost_cache->rebuild_page( $url );
|
||||
$this->request_page( $url );
|
||||
}
|
||||
add_action( 'jetpack_boost_invalidate_cache_success', array( $this, 'handle_cache_invalidation' ), 10, 3 );
|
||||
|
||||
Logger::debug( sprintf( 'Preload completed for %d pages', count( $urls ) ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Requests the pages scheduled for preload.
|
||||
*
|
||||
* @since 3.11.0
|
||||
* @param array $posts The posts to preload.
|
||||
* @return void
|
||||
*/
|
||||
public function request_pages( $posts ) {
|
||||
if ( empty( $posts ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
foreach ( $posts as $url ) {
|
||||
$this->request_page( $url );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Make an HTTP request to the specified URL to generate a fresh cache entry.
|
||||
*
|
||||
* @since 3.11.0
|
||||
* @param string $page The URL of the page to preload.
|
||||
* @return void
|
||||
*/
|
||||
private function request_page( string $page ) {
|
||||
// Add a cache-busting header to ensure our response is fresh.
|
||||
$args = array(
|
||||
'headers' => array(
|
||||
'Cache-Control' => 'no-cache, no-store, must-revalidate, max-age=0',
|
||||
'Pragma' => 'no-cache',
|
||||
'Expires' => '0',
|
||||
),
|
||||
);
|
||||
|
||||
$response = wp_remote_get( $page, $args );
|
||||
|
||||
if ( is_wp_error( $response ) ) {
|
||||
Logger::debug( 'Error preloading page: ' . $response->get_error_message() );
|
||||
return;
|
||||
}
|
||||
|
||||
$status_code = wp_remote_retrieve_response_code( $response );
|
||||
if ( $status_code !== 200 ) {
|
||||
Logger::debug( sprintf( 'Error preloading page %s: HTTP status code %d', $page, $status_code ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle post updates to check if the post is a cornerstone page and schedule preload if needed.
|
||||
*
|
||||
* @since 3.11.0
|
||||
* @param int $post_id The ID of the post being updated.
|
||||
* @return void
|
||||
*/
|
||||
public function handle_post_update( int $post_id ) {
|
||||
if ( Cornerstone_Utils::is_cornerstone_page( $post_id ) ) {
|
||||
$this->schedule( array( get_permalink( $post_id ) ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handle cache invalidation events to schedule preloading for affected pages.
|
||||
*
|
||||
* If cache for Cornerstone Pages is invalidated, this method schedules those pages
|
||||
* for preloading to ensure they have fresh cache.
|
||||
*
|
||||
* @since 3.11.0
|
||||
* @param string $path The path that was invalidated.
|
||||
* @param string $type The type of invalidation that occurred (rebuild or delete).
|
||||
* @param string $scope The scope of the invalidation (page or recursive).
|
||||
* @return void
|
||||
*/
|
||||
public function handle_cache_invalidation( string $path, string $type, string $scope ) {
|
||||
if ( $path === home_url() && $scope === 'recursive' ) {
|
||||
Logger::debug( 'Invalidating all files, scheduling preload for all Cornerstone Pages.' );
|
||||
// If the cache is invalidated for all files, schedule preload for all Cornerstone Pages.
|
||||
$this->schedule_cornerstone();
|
||||
return;
|
||||
}
|
||||
|
||||
// Otherwise identify if a Cornerstone Page cache file is being deleted and schedule preload that page if it is.
|
||||
Logger::debug( 'Invalidating a specific page, scheduling preload for that page if it is a Cornerstone Page.' );
|
||||
$cornerstone_pages = Cornerstone_Utils::get_list();
|
||||
$cornerstone_pages = array_map( 'untrailingslashit', $cornerstone_pages );
|
||||
// If the $path is in the Cornerstone Page list, add it to the preload list.
|
||||
if ( in_array( untrailingslashit( $path ), $cornerstone_pages, true ) ) {
|
||||
$this->schedule( array( $path ) );
|
||||
}
|
||||
}
|
||||
|
||||
public static function get_parent_features(): array {
|
||||
return array(
|
||||
Page_Cache::class,
|
||||
);
|
||||
}
|
||||
}
|
||||
+64
@@ -0,0 +1,64 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache;
|
||||
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
|
||||
|
||||
class Garbage_Collection {
|
||||
const ACTION = 'jetpack_boost_cache_garbage_collection';
|
||||
|
||||
/**
|
||||
* Register hooks.
|
||||
*/
|
||||
public static function setup() {
|
||||
add_action( self::ACTION, array( self::class, 'garbage_collect' ) );
|
||||
|
||||
// Clear old log files when garbage collection is run. Do not pass the $older_than parameter to the method as it's not supported.
|
||||
add_action( self::ACTION, array( Logger::class, 'delete_old_logs' ), 10, 0 );
|
||||
}
|
||||
|
||||
public static function schedule_single_garbage_collection() {
|
||||
$older_than = time();
|
||||
wp_schedule_single_event( $older_than, self::ACTION, array( 'older_than' => $older_than ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Garbage collect expired files.
|
||||
*
|
||||
* @param int|null $older_than The timestamp before which files should be deleted. If not provided, the files older than default cache duration will be deleted.
|
||||
*/
|
||||
public static function garbage_collect( $older_than = null ) {
|
||||
$cache_ttl = JETPACK_BOOST_CACHE_DURATION;
|
||||
|
||||
/*
|
||||
* If an $older_than value is specified, use it to calculate the cache TTL.
|
||||
* By specifying $older_than, you can instruct garbage collection to apply on files created before a certain point in time.
|
||||
* This ensures garbage collection is not clearing files that were created after the request was made. Useful to avoid race conditions.
|
||||
*/
|
||||
if ( $older_than !== null ) {
|
||||
$cache_ttl = time() - $older_than;
|
||||
}
|
||||
|
||||
$cache = new Boost_Cache();
|
||||
$cache->get_storage()->garbage_collect( $cache_ttl );
|
||||
}
|
||||
|
||||
/**
|
||||
* Setup the garbage collection cron job.
|
||||
*/
|
||||
public static function activate() {
|
||||
self::setup();
|
||||
|
||||
if ( ! wp_next_scheduled( self::ACTION ) ) {
|
||||
wp_schedule_event( time(), 'hourly', self::ACTION );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove the garbage collection cron job.
|
||||
*/
|
||||
public static function deactivate() {
|
||||
wp_clear_scheduled_hook( self::ACTION );
|
||||
}
|
||||
}
|
||||
+449
@@ -0,0 +1,449 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache;
|
||||
|
||||
use Automattic\Jetpack_Boost\Lib\Analytics;
|
||||
use Automattic\Jetpack_Boost\Lib\Super_Cache_Config_Compatibility;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Error;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Settings;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Filesystem_Utils;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
|
||||
|
||||
class Page_Cache_Setup {
|
||||
|
||||
private static $notices = array();
|
||||
|
||||
/**
|
||||
* Runs setup steps and returns whether setup was successful or not.
|
||||
*
|
||||
* @return bool|\WP_Error
|
||||
*/
|
||||
public static function run_setup() {
|
||||
// Steps that are only for cache system verification. They don't change anything.
|
||||
$verification_steps = array(
|
||||
'verify_wp_content_writable',
|
||||
'verify_permalink_setting',
|
||||
);
|
||||
|
||||
foreach ( $verification_steps as $step ) {
|
||||
$result = self::run_step( $step );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* Steps that may change something to setup the cache system.
|
||||
* Each of them should return the result in following format:
|
||||
* - true if the step was successful and changes were made
|
||||
* - false if the step was successful but no changes were made
|
||||
* - WP_Error if the step failed
|
||||
*/
|
||||
$setup_steps = array(
|
||||
'create_settings_file',
|
||||
'create_advanced_cache',
|
||||
'add_wp_cache_define',
|
||||
'enable_caching',
|
||||
);
|
||||
|
||||
$changes_made = false;
|
||||
foreach ( $setup_steps as $step ) {
|
||||
$result = self::run_step( $step );
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
return $result;
|
||||
}
|
||||
|
||||
if ( $result === true ) {
|
||||
$changes_made = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ( $changes_made ) {
|
||||
Analytics::record_user_event( 'page_cache_setup_succeeded' );
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function get_notices() {
|
||||
return self::$notices;
|
||||
}
|
||||
|
||||
private static function add_notice( $title, $message ) {
|
||||
self::$notices[] = array(
|
||||
'title' => $title,
|
||||
'message' => $message,
|
||||
);
|
||||
}
|
||||
|
||||
private static function run_step( $step ) {
|
||||
$result = self::$step();
|
||||
|
||||
if ( $result instanceof Boost_Cache_Error ) {
|
||||
Analytics::record_user_event( 'page_cache_setup_failed', array( 'error_code' => $result->get_error_code() ) );
|
||||
return $result->to_wp_error();
|
||||
}
|
||||
|
||||
if ( is_wp_error( $result ) ) {
|
||||
Analytics::record_user_event( 'page_cache_setup_failed', array( 'error_code' => $result->get_error_code() ) );
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Enable caching step of setup.
|
||||
*
|
||||
* @return Boost_Cache_Error|bool - True on success, false if it was already enabled, error otherwise.
|
||||
*/
|
||||
private static function enable_caching() {
|
||||
$settings = Boost_Cache_Settings::get_instance();
|
||||
$previous_value = $settings->get_enabled();
|
||||
|
||||
if ( $previous_value === true ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$enabled_result = $settings->set( array( 'enabled' => true ) );
|
||||
|
||||
if ( $enabled_result === true ) {
|
||||
Logger::debug( 'Caching enabled in cache config' );
|
||||
}
|
||||
|
||||
return $enabled_result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the wp-content directory is writeable.
|
||||
*/
|
||||
private static function verify_wp_content_writable() {
|
||||
$filename = WP_CONTENT_DIR . '/' . uniqid() . '.txt';
|
||||
$result = @file_put_contents( $filename, 'test' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
wp_delete_file( $filename );
|
||||
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error( 'wp-content-not-writable' );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if WordPress is using a proper permalink setup. WP_Error if not.
|
||||
*/
|
||||
private static function verify_permalink_setting() {
|
||||
global $wp_rewrite;
|
||||
|
||||
if ( ! $wp_rewrite || ! $wp_rewrite->using_permalinks() ) {
|
||||
return new \WP_Error( 'not-using-permalinks' );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a settings file, if one does not already exist.
|
||||
*
|
||||
* @return bool|\WP_Error - True if the file was created, WP_Error if there was a problem, or false if the file already exists.
|
||||
*/
|
||||
private static function create_settings_file() {
|
||||
$result = Boost_Cache_Settings::get_instance()->create_settings_file();
|
||||
return $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the path to the advanced-cache.php file.
|
||||
*
|
||||
* @return string The full path to the advanced-cache.php file.
|
||||
*/
|
||||
public static function get_advanced_cache_path() {
|
||||
return WP_CONTENT_DIR . '/advanced-cache.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the advanced-cache.php file.
|
||||
*
|
||||
* Returns true if the files were setup correctly, or WP_Error if there was a problem.
|
||||
*
|
||||
* @return bool|\WP_Error
|
||||
*/
|
||||
private static function create_advanced_cache() {
|
||||
$advanced_cache_filename = self::get_advanced_cache_path();
|
||||
|
||||
if ( file_exists( $advanced_cache_filename ) ) {
|
||||
$content = file_get_contents( $advanced_cache_filename ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
|
||||
|
||||
if ( strpos( $content, 'WP SUPER CACHE' ) !== false ) {
|
||||
// advanced-cache.php is already in use by WP Super Cache.
|
||||
|
||||
if ( Super_Cache_Config_Compatibility::is_compatible() ) {
|
||||
$deactivation = new Data_Sync_Actions\Deactivate_WPSC();
|
||||
$deactivation->handle();
|
||||
self::add_notice(
|
||||
__( 'WP Super Cache Has Been Deactivated', 'jetpack-boost' ),
|
||||
__( 'To ensure optimal performance, WP Super Cache has been automatically deactivated because Jetpack Boost\'s Cache is now active. Only one caching system can be used at a time.', 'jetpack-boost' )
|
||||
);
|
||||
|
||||
Analytics::record_user_event(
|
||||
'switch_to_boost_cache',
|
||||
array(
|
||||
'type' => 'silent',
|
||||
'reason' => 'super_cache_compatible',
|
||||
)
|
||||
);
|
||||
} else {
|
||||
return new \WP_Error( 'advanced-cache-for-super-cache' );
|
||||
}
|
||||
} elseif ( strpos( $content, Page_Cache::ADVANCED_CACHE_SIGNATURE ) === false ) {
|
||||
// advanced-cache.php is in use by another plugin.
|
||||
return new \WP_Error( 'advanced-cache-incompatible' );
|
||||
}
|
||||
|
||||
if ( strpos( $content, Page_Cache::ADVANCED_CACHE_VERSION ) !== false ) {
|
||||
// The advanced-cache.php file belongs to current version of Boost Cache.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
$plugin_dir_name = untrailingslashit( str_replace( JETPACK_BOOST_PLUGIN_FILENAME, '', JETPACK_BOOST_PLUGIN_BASE ) );
|
||||
$boost_cache_filename = WP_CONTENT_DIR . '/plugins/' . $plugin_dir_name . '/app/modules/optimizations/page-cache/pre-wordpress/class-boost-cache.php';
|
||||
if ( ! file_exists( $boost_cache_filename ) ) {
|
||||
return new \WP_Error( 'boost-cache-file-not-found' );
|
||||
}
|
||||
$contents = '<?php
|
||||
// ' . Page_Cache::ADVANCED_CACHE_SIGNATURE . ' - ' . Page_Cache::ADVANCED_CACHE_VERSION . '
|
||||
if ( ! file_exists( \'' . $boost_cache_filename . '\' ) ) {
|
||||
return;
|
||||
}
|
||||
require_once( \'' . $boost_cache_filename . '\');
|
||||
$boost_cache = new Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache();
|
||||
$boost_cache->init_actions();
|
||||
$boost_cache->serve();
|
||||
';
|
||||
|
||||
$write_advanced_cache = Filesystem_Utils::write_to_file( $advanced_cache_filename, $contents );
|
||||
if ( $write_advanced_cache instanceof Boost_Cache_Error ) {
|
||||
return new \WP_Error( 'unable-to-write-to-advanced-cache', $write_advanced_cache->get_error_code() );
|
||||
}
|
||||
self::clear_opcache( $advanced_cache_filename );
|
||||
|
||||
Logger::debug( 'Advanced cache file created' );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the WP_CACHE define to wp-config.php
|
||||
*/
|
||||
private static function add_wp_cache_define() {
|
||||
$config_file = self::find_wp_config();
|
||||
if ( $config_file === false ) {
|
||||
return new \WP_Error( 'wp-config-not-found' );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
|
||||
$content = file_get_contents( $config_file );
|
||||
if ( preg_match( '#^\s*(define\s*\(\s*[\'"]WP_CACHE[\'"]|const\s+WP_CACHE\s*=)#m', $content ) === 1 ) {
|
||||
/*
|
||||
* wp-settings.php checks "if ( WP_CACHE )" so it may be truthy and
|
||||
* not === true to pass that check.
|
||||
* Later, it is defined as false in default-constants.php, but
|
||||
* it may have been defined manually as true using "true", 1, or "1"
|
||||
* in wp-config.php.
|
||||
*/
|
||||
if ( defined( 'WP_CACHE' ) && ! WP_CACHE ) {
|
||||
return new \WP_Error( 'wp-cache-defined-not-true' );
|
||||
}
|
||||
|
||||
return false; // WP_CACHE already added.
|
||||
}
|
||||
$content = preg_replace(
|
||||
'#^<\?php#',
|
||||
'<?php
|
||||
define( \'WP_CACHE\', true ); // ' . Page_Cache::ADVANCED_CACHE_SIGNATURE,
|
||||
$content
|
||||
);
|
||||
|
||||
$result = self::write_to_file_direct( $config_file, $content );
|
||||
if ( $result === false ) {
|
||||
return new \WP_Error( 'wp-config-not-writable' );
|
||||
}
|
||||
self::clear_opcache( $config_file );
|
||||
|
||||
Logger::debug( 'WP_CACHE constant added to wp-config.php' );
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if page cache can be run or not.
|
||||
*
|
||||
* @return bool True if the advanced-cache.php file doesn't exist or belongs to Boost, false otherwise.
|
||||
*/
|
||||
public static function can_run_cache() {
|
||||
$advanced_cache_path = self::get_advanced_cache_path();
|
||||
if ( ! file_exists( $advanced_cache_path ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$content = file_get_contents( $advanced_cache_path ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
|
||||
return strpos( $content, Page_Cache::ADVANCED_CACHE_SIGNATURE ) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the advanced-cache.php file and the WP_CACHE define from wp-config.php
|
||||
* Fired when the plugin is deactivated.
|
||||
*/
|
||||
public static function deactivate() {
|
||||
$advanced_cache_deleted = self::delete_advanced_cache();
|
||||
// Only remove constant if Boost was the last to run caching.
|
||||
// This is to avoid breaking caching for other plugins.
|
||||
if ( $advanced_cache_deleted ) {
|
||||
self::delete_wp_cache_constant();
|
||||
} else {
|
||||
self::cleanup_wp_cache_constant();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the boost-cache directory, removing all cached files and the config file.
|
||||
* Fired when the plugin is uninstalled.
|
||||
*/
|
||||
public static function uninstall() {
|
||||
self::deactivate();
|
||||
// Call the Cache Preload module deactivation here to ensure it's cleaned up properly.
|
||||
Cache_Preload::deactivate();
|
||||
$result = Filesystem_Utils::delete_directory( WP_CONTENT_DIR . '/boost-cache' );
|
||||
if ( $result instanceof Boost_Cache_Error ) {
|
||||
return $result->to_wp_error();
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the file advanced-cache.php if it exists.
|
||||
*/
|
||||
public static function delete_advanced_cache() {
|
||||
$advanced_cache_filename = self::get_advanced_cache_path();
|
||||
|
||||
if ( ! file_exists( $advanced_cache_filename ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$deleted_file = false;
|
||||
$content = file_get_contents( $advanced_cache_filename ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
|
||||
if ( strpos( $content, Page_Cache::ADVANCED_CACHE_SIGNATURE ) !== false ) {
|
||||
wp_delete_file( $advanced_cache_filename );
|
||||
|
||||
// wp_delete_file doesn't return anything
|
||||
// so we manually check if the file was deleted.
|
||||
$deleted_file = ! file_exists( $advanced_cache_filename );
|
||||
}
|
||||
|
||||
self::clear_opcache( $advanced_cache_filename );
|
||||
|
||||
return $deleted_file;
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes the WP_CACHE define from wp-config.php
|
||||
*
|
||||
* @return \WP_Error if an error occurred.
|
||||
*/
|
||||
public static function delete_wp_cache_constant() {
|
||||
$config_file = self::find_wp_config();
|
||||
if ( $config_file === false ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = file( $config_file );
|
||||
$found = false;
|
||||
foreach ( $lines as $key => $line ) {
|
||||
if ( preg_match( '#define\s*\(\s*[\'"]WP_CACHE[\'"]#', $line ) === 1 && strpos( $line, Page_Cache::ADVANCED_CACHE_SIGNATURE ) !== false ) {
|
||||
unset( $lines[ $key ] );
|
||||
$found = true;
|
||||
}
|
||||
}
|
||||
if ( ! $found ) {
|
||||
return;
|
||||
}
|
||||
$content = implode( '', $lines );
|
||||
Filesystem_Utils::write_to_file( $config_file, $content );
|
||||
self::clear_opcache( $config_file );
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the comment after WP_CACHE defined in wp-config.php (if any).
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public static function cleanup_wp_cache_constant() {
|
||||
$config_file = self::find_wp_config();
|
||||
if ( $config_file === false ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = file( $config_file );
|
||||
$found = false;
|
||||
foreach ( $lines as $key => $line ) {
|
||||
if ( preg_match( '#define\s*\(\s*[\'"]WP_CACHE[\'"]#', $line ) === 1 && strpos( $line, Page_Cache::ADVANCED_CACHE_SIGNATURE ) !== false ) {
|
||||
$lines[ $key ] = preg_replace( '#\s*?\/\/.*$#', '', $line );
|
||||
$found = true;
|
||||
}
|
||||
}
|
||||
if ( ! $found ) {
|
||||
return;
|
||||
}
|
||||
$content = implode( '', $lines );
|
||||
Filesystem_Utils::write_to_file( $config_file, $content );
|
||||
self::clear_opcache( $config_file );
|
||||
}
|
||||
|
||||
/**
|
||||
* Find location of wp-config.php file.
|
||||
*
|
||||
* @return string|false - The path to the wp-config.php file, or false if it was not found.
|
||||
*/
|
||||
private static function find_wp_config() {
|
||||
if ( file_exists( ABSPATH . 'wp-config.php' ) ) {
|
||||
return ABSPATH . 'wp-config.php';
|
||||
} elseif ( file_exists( dirname( ABSPATH ) . '/wp-config.php' ) && ! file_exists( dirname( ABSPATH ) . '/wp-settings.php' ) ) {
|
||||
// While checking one directory up, check for wp-settings.php as well similar to WordPress core, to avoid nested WordPress installations.
|
||||
return dirname( ABSPATH ) . '/wp-config.php';
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Clear opcache for a file.
|
||||
*/
|
||||
private static function clear_opcache( $file ) {
|
||||
// If API functions are restricted, we can't do anything.
|
||||
if ( ini_get( 'opcache.restrict_api' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( function_exists( 'opcache_invalidate' ) ) {
|
||||
opcache_invalidate( $file, true );
|
||||
}
|
||||
}
|
||||
|
||||
private static function write_to_file_direct( $file, $content ) {
|
||||
$filesystem = self::get_wp_filesystem();
|
||||
$chmod = $filesystem->getchmod( $file );
|
||||
if ( $chmod === false ) {
|
||||
$chmod = 0644; // Default to a common permission for files
|
||||
} else {
|
||||
$chmod = intval( '0' . $chmod, 8 ); // Ensure leading zero
|
||||
}
|
||||
return $filesystem->put_contents( $file, $content, $chmod );
|
||||
}
|
||||
|
||||
private static function get_wp_filesystem() {
|
||||
require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-base.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/class-wp-filesystem-direct.php';
|
||||
return new \WP_Filesystem_Direct( null );
|
||||
}
|
||||
}
|
||||
+127
@@ -0,0 +1,127 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache;
|
||||
|
||||
use Automattic\Jetpack\Schema\Schema;
|
||||
use Automattic\Jetpack\Status\Host;
|
||||
use Automattic\Jetpack\WP_JS_Data_Sync\Data_Sync;
|
||||
use Automattic\Jetpack_Boost\Contracts\Feature;
|
||||
use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
|
||||
use Automattic\Jetpack_Boost\Contracts\Has_Deactivate;
|
||||
use Automattic\Jetpack_Boost\Contracts\Needs_To_Be_Ready;
|
||||
use Automattic\Jetpack_Boost\Contracts\Optimization;
|
||||
use Automattic\Jetpack_Boost\Lib\Cache_Compatibility;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync\Page_Cache_Entry;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync_Actions\Clear_Page_Cache;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync_Actions\Deactivate_WPSC;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync_Actions\Run_Setup;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Settings;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
|
||||
|
||||
class Page_Cache implements Feature, Has_Deactivate, Has_Data_Sync, Optimization, Needs_To_Be_Ready {
|
||||
/**
|
||||
* @var array - The errors that occurred when removing the cache.
|
||||
*/
|
||||
private $removal_errors = array();
|
||||
|
||||
/**
|
||||
* The signature used to identify the advanced-cache.php file owned by Jetpack Boost.
|
||||
*/
|
||||
const ADVANCED_CACHE_SIGNATURE = 'Boost Cache Plugin';
|
||||
|
||||
/**
|
||||
* The full signature including the current version, to verify the Advanced-cache file is current.
|
||||
*/
|
||||
const ADVANCED_CACHE_VERSION = 'v0.0.3';
|
||||
|
||||
/**
|
||||
* @var Boost_Cache_Settings - The settings for the page cache.
|
||||
*/
|
||||
private $settings;
|
||||
|
||||
public function __construct() {
|
||||
$this->settings = Boost_Cache_Settings::get_instance();
|
||||
}
|
||||
|
||||
public function setup() {
|
||||
Garbage_Collection::setup();
|
||||
|
||||
add_action( 'jetpack_boost_page_output_changed', array( $this, 'handle_page_output_change' ) );
|
||||
}
|
||||
|
||||
public function register_data_sync( Data_Sync $instance ) {
|
||||
$page_cache_schema = Schema::as_assoc_array(
|
||||
array(
|
||||
'bypass_patterns' => Schema::as_array( Schema::as_string() ),
|
||||
'logging' => Schema::as_boolean(),
|
||||
)
|
||||
);
|
||||
$page_cache_error_schema = Schema::as_assoc_array(
|
||||
array(
|
||||
'code' => Schema::as_string(),
|
||||
'message' => Schema::as_string(),
|
||||
'dismissed' => Schema::as_boolean()->fallback( false ),
|
||||
)
|
||||
)->nullable();
|
||||
|
||||
$instance->register_readonly( 'cache_debug_log', Schema::as_unsafe_any(), array( Logger::class, 'read' ) );
|
||||
$instance->register_readonly( 'cache_engine_loading', Schema::as_unsafe_any(), array( Boost_Cache::class, 'is_loaded' ) );
|
||||
|
||||
$instance->register( 'page_cache', $page_cache_schema, new Page_Cache_Entry() );
|
||||
// Page Cache error
|
||||
$instance->register( 'page_cache_error', $page_cache_error_schema );
|
||||
|
||||
$instance->register_action( 'page_cache', 'run-setup', Schema::as_void(), new Run_Setup() );
|
||||
|
||||
$instance->register_action( 'page_cache', 'clear-page-cache', Schema::as_void(), new Clear_Page_Cache() );
|
||||
$instance->register_action( 'page_cache', 'deactivate-wpsc', Schema::as_void(), new Deactivate_WPSC() );
|
||||
}
|
||||
|
||||
public function handle_page_output_change() {
|
||||
Garbage_Collection::schedule_single_garbage_collection();
|
||||
|
||||
// Remove the action so it doesn't run again during the same request.
|
||||
remove_action( 'jetpack_boost_page_output_changed', array( $this, 'handle_page_output_change' ) );
|
||||
}
|
||||
/**
|
||||
* Runs cleanup when the feature is deactivated.
|
||||
*/
|
||||
public static function deactivate() {
|
||||
Garbage_Collection::deactivate();
|
||||
Boost_Cache_Settings::get_instance()->set( array( 'enabled' => false ) );
|
||||
Page_Cache_Setup::delete_advanced_cache();
|
||||
}
|
||||
|
||||
/**
|
||||
* The module is active if cache engine is loaded.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_ready() {
|
||||
return Boost_Cache::is_loaded();
|
||||
}
|
||||
|
||||
public static function is_available() {
|
||||
// Disable Page Cache on WoA and WP Cloud clients.
|
||||
// They already have caching enabled.
|
||||
if ( ( new Host() )->is_woa_site() || ( new Host() )->is_atomic_platform() ) {
|
||||
if ( Page_Cache_Setup::can_run_cache() ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
// Disable Page Cache on sites that have their own caching service.
|
||||
if ( Cache_Compatibility::has_cache() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
public static function get_slug() {
|
||||
return 'page_cache';
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync_Actions;
|
||||
|
||||
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Data_Sync_Action;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache;
|
||||
|
||||
/**
|
||||
* Page Cache: Clear page cache
|
||||
*/
|
||||
class Clear_Page_Cache implements Data_Sync_Action {
|
||||
|
||||
/**
|
||||
* Handles the action logic.
|
||||
*
|
||||
* @param mixed $_data JSON Data passed to the action.
|
||||
* @param \WP_REST_Request $_request The request object.
|
||||
*/
|
||||
public function handle( $_data, $_request ) {
|
||||
$cache = new Boost_Cache();
|
||||
$delete = $cache->delete_recursive( home_url() );
|
||||
|
||||
if ( $delete === null || is_wp_error( $delete ) ) {
|
||||
return array(
|
||||
'message' => __( 'Cache already cleared.', 'jetpack-boost' ),
|
||||
);
|
||||
}
|
||||
|
||||
if ( $delete ) {
|
||||
return array(
|
||||
'message' => __( 'Cache cleared.', 'jetpack-boost' ),
|
||||
);
|
||||
}
|
||||
|
||||
return new \WP_Error( 'unable-to-clear-cache', __( 'Unable to clear cache.', 'jetpack-boost' ) );
|
||||
}
|
||||
}
|
||||
+37
@@ -0,0 +1,37 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync_Actions;
|
||||
|
||||
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Data_Sync_Action;
|
||||
|
||||
/**
|
||||
* Cache Action: Disable Super Cache
|
||||
*/
|
||||
class Deactivate_WPSC implements Data_Sync_Action {
|
||||
|
||||
/**
|
||||
* Handles the action logic.
|
||||
*
|
||||
* @param mixed $_data JSON Data passed to the action.
|
||||
* @param null|\WP_REST_Request $_request The request object.
|
||||
*/
|
||||
public function handle( $_data = null, $_request = null ) {
|
||||
// Super Cache will define WPCACHEHOME if it's active.
|
||||
if ( ! defined( 'WPCACHEHOME' ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Find the plugin base path for super cache.
|
||||
$super_cache_dir = basename( WPCACHEHOME );
|
||||
$plugin = $super_cache_dir . '/wp-cache.php';
|
||||
|
||||
// Load the necessary files to deactivate the plugin.
|
||||
require_once ABSPATH . 'wp-admin/includes/misc.php';
|
||||
require_once ABSPATH . 'wp-admin/includes/file.php';
|
||||
deactivate_plugins( $plugin );
|
||||
|
||||
$success = ! is_plugin_active( $plugin );
|
||||
|
||||
return $success;
|
||||
}
|
||||
}
|
||||
+36
@@ -0,0 +1,36 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync_Actions;
|
||||
|
||||
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Data_Sync_Action;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Garbage_Collection;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Page_Cache_Setup;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Settings;
|
||||
|
||||
/**
|
||||
* Critical CSS Action: request regeneration.
|
||||
*/
|
||||
class Run_Setup implements Data_Sync_Action {
|
||||
|
||||
/**
|
||||
* Handles the action logic.
|
||||
*
|
||||
* @param mixed $_data JSON Data passed to the action.
|
||||
* @param \WP_REST_Request $_request The request object.
|
||||
*/
|
||||
public function handle( $_data, $_request ) {
|
||||
$setup_result = Page_Cache_Setup::run_setup();
|
||||
|
||||
if ( is_wp_error( $setup_result ) ) {
|
||||
return $setup_result;
|
||||
}
|
||||
|
||||
Garbage_Collection::activate();
|
||||
Boost_Cache_Settings::get_instance()->set( array( 'enabled' => true ) );
|
||||
|
||||
return array(
|
||||
'success' => true,
|
||||
'notices' => Page_Cache_Setup::get_notices(),
|
||||
);
|
||||
}
|
||||
}
|
||||
+105
@@ -0,0 +1,105 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Data_Sync;
|
||||
|
||||
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Get;
|
||||
use Automattic\Jetpack\WP_JS_Data_Sync\Contracts\Entry_Can_Set;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Settings;
|
||||
|
||||
class Page_Cache_Entry implements Entry_Can_Get, Entry_Can_Set {
|
||||
public function get( $_fallback = false ) {
|
||||
$cache_settings = Boost_Cache_Settings::get_instance();
|
||||
|
||||
$settings = array(
|
||||
'bypass_patterns' => $cache_settings->get_bypass_patterns(),
|
||||
'logging' => $cache_settings->get_logging(),
|
||||
);
|
||||
|
||||
return $settings;
|
||||
}
|
||||
|
||||
public function set( $value ) {
|
||||
$cache_settings = Boost_Cache_Settings::get_instance();
|
||||
|
||||
$value['bypass_patterns'] = $this->sanitize_value( $value['bypass_patterns'] );
|
||||
|
||||
$cache_settings->set( $value );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitizes the given value, ensuring that it is list of valid patterns.
|
||||
*
|
||||
* @param mixed $value The value to sanitize.
|
||||
*
|
||||
* @return array The sanitized value.
|
||||
*/
|
||||
private function sanitize_value( $value ) {
|
||||
if ( is_array( $value ) ) {
|
||||
$value = array_values( array_unique( array_filter( array_map( 'trim', array_map( 'strtolower', $value ) ) ) ) );
|
||||
|
||||
$home_url = home_url( '/' );
|
||||
|
||||
foreach ( $value as &$path ) {
|
||||
// Strip home URL (both secure and non-secure).
|
||||
$path = str_ireplace(
|
||||
array(
|
||||
$home_url,
|
||||
str_replace( 'http:', 'https:', $home_url ),
|
||||
),
|
||||
array(
|
||||
'/',
|
||||
'/',
|
||||
),
|
||||
$path
|
||||
);
|
||||
|
||||
// Remove double shashes.
|
||||
$path = str_replace( '//', '/', $path );
|
||||
|
||||
// Remove symbols, as they are included in the regex check.
|
||||
$path = ltrim( $path, '^' );
|
||||
$path = rtrim( $path, '$' );
|
||||
$path = preg_replace( '/\/\?$/', '', $path );
|
||||
|
||||
// Make sure there's a leading slash.
|
||||
$path = '/' . ltrim( $path, '/' );
|
||||
|
||||
// Fix up any wildcards.
|
||||
$path = $this->sanitize_wildcards( $path );
|
||||
}
|
||||
|
||||
$value = array_values( array_unique( array_filter( $value ) ) );
|
||||
} else {
|
||||
$value = array();
|
||||
}
|
||||
|
||||
return $value;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize wildcards in a given path.
|
||||
*
|
||||
* @param string $path The path to sanitize.
|
||||
* @return string The sanitized path.
|
||||
*/
|
||||
private function sanitize_wildcards( $path ) {
|
||||
if ( ! $path ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
$path_components = explode( '/', $path );
|
||||
$arr = array(
|
||||
'.*' => '(.*)',
|
||||
'*' => '(.*)',
|
||||
'(*)' => '(.*)',
|
||||
'(.*)' => '(.*)',
|
||||
);
|
||||
|
||||
foreach ( $path_components as &$path_component ) {
|
||||
$path_component = strtr( $path_component, $arr );
|
||||
}
|
||||
$path = implode( '/', $path_components );
|
||||
|
||||
return $path;
|
||||
}
|
||||
}
|
||||
+11
@@ -0,0 +1,11 @@
|
||||
### Pre_WordPress namespace
|
||||
|
||||
Everything in this directory / namespace contains code which can execute before WordPress is fully
|
||||
initialized. It can be called from `advanced-cache.php`, but it can also be called directly from
|
||||
the main Boost code-base.
|
||||
|
||||
Nothing in the `Pre_WordPress` namespace may rely on autolaoding to load things; you must include
|
||||
an explicit `require_once` instruction in the entrypoint file `Boost_Cache.php`. It also must not rely on any WordPress functionality that is
|
||||
unavailable at the time that `advanced-cache.php` is executed.
|
||||
|
||||
You can use this code from elsewhere in Boost, and you can autoload it from outside this namespace, though.
|
||||
+89
@@ -0,0 +1,89 @@
|
||||
<?php
|
||||
/**
|
||||
* This file contains all the public actions for the Page Cache module.
|
||||
* This file is loaded before WordPress is fully initialized.
|
||||
*/
|
||||
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
|
||||
|
||||
/**
|
||||
* Delete all cache.
|
||||
*
|
||||
* Allow third-party plugins to clear all cache.
|
||||
*/
|
||||
add_action( 'jetpack_boost_clear_page_cache_all', 'jetpack_boost_delete_cache' );
|
||||
|
||||
/**
|
||||
* Delete cache for homepage and paged archives.
|
||||
*
|
||||
* Allow third-party plugins to clear front-page cache.
|
||||
*/
|
||||
add_action( 'jetpack_boost_clear_page_cache_home', 'jetpack_boost_delete_cache_for_home' );
|
||||
|
||||
/**
|
||||
* Delete cache for a specific URL.
|
||||
*
|
||||
* Allow third-party plugins to clear the cache for a specific URL.
|
||||
*
|
||||
* @param string $url - The URL to delete the cache for.
|
||||
*/
|
||||
add_action( 'jetpack_boost_clear_page_cache_url', 'jetpack_boost_delete_cache_for_url' );
|
||||
|
||||
/**
|
||||
* Delete cache for a specific post.
|
||||
*
|
||||
* Allow third-party plugins to clear the cache for a specific post.
|
||||
*
|
||||
* @param int $post_id - The ID of the post to delete the cache for.
|
||||
*/
|
||||
add_action( 'jetpack_boost_clear_page_cache_post', 'jetpack_boost_delete_cache_by_post_id' );
|
||||
|
||||
/**
|
||||
* Delete all cache files.
|
||||
*/
|
||||
function jetpack_boost_delete_cache() {
|
||||
$boost_cache = new Boost_Cache();
|
||||
$boost_cache->delete_recursive( home_url() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cache for homepage and paged archives.
|
||||
*/
|
||||
function jetpack_boost_delete_cache_for_home() {
|
||||
$boost_cache = new Boost_Cache();
|
||||
$boost_cache->delete_page( home_url() );
|
||||
|
||||
Logger::debug( 'jetpack_boost_delete_cache_for_home: deleting front page cache' );
|
||||
if ( get_option( 'show_on_front' ) === 'page' ) {
|
||||
$posts_page_id = get_option( 'page_for_posts' ); // posts page
|
||||
if ( $posts_page_id ) {
|
||||
$boost_cache->delete_recursive( get_permalink( $posts_page_id ) );
|
||||
Logger::debug( 'jetpack_boost_delete_cache_for_home: deleting posts page cache' );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cache for a specific URL.
|
||||
*
|
||||
* @param string $url - The URL to delete the cache for.
|
||||
*/
|
||||
function jetpack_boost_delete_cache_for_url( $url ) {
|
||||
$boost_cache = new Boost_Cache();
|
||||
$boost_cache->delete_recursive( $url );
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cache for a specific post.
|
||||
*
|
||||
* @param int $post_id - The ID of the post to delete the cache for.
|
||||
*/
|
||||
function jetpack_boost_delete_cache_by_post_id( $post_id ) {
|
||||
$post = get_post( (int) $post_id );
|
||||
|
||||
Logger::debug( 'invalidate_cache_for_post: ' . $post->ID );
|
||||
|
||||
$boost_cache = new Boost_Cache();
|
||||
$boost_cache->delete_post_cache( $post );
|
||||
}
|
||||
+69
@@ -0,0 +1,69 @@
|
||||
<?php
|
||||
/*
|
||||
* This file may be called before WordPress is fully initialized. See the README file for info.
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
|
||||
|
||||
/**
|
||||
* A replacement for WP_Error when working in a Pre_WordPress setting.
|
||||
*
|
||||
* This class deliberately offers a similar API to WP_Error for familiarity. All Pre_WordPress functions
|
||||
* which may return an error object use this class to represent an Error state.
|
||||
*
|
||||
* If you call a Pre_WordPress function after loading WordPress, use to_wp_error to convert these
|
||||
* objects to a standard WP_Error object.
|
||||
*/
|
||||
class Boost_Cache_Error {
|
||||
|
||||
/**
|
||||
* @var string - The error code.
|
||||
*/
|
||||
private $code;
|
||||
|
||||
/**
|
||||
* @var string - The error message.
|
||||
*/
|
||||
private $message;
|
||||
|
||||
/**
|
||||
* Create a Boost_Cache_Error object, with a code and a message.
|
||||
*/
|
||||
public function __construct( $code, $message ) {
|
||||
$this->code = $code;
|
||||
$this->message = $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the error message.
|
||||
*/
|
||||
public function get_error_message() {
|
||||
return $this->message;
|
||||
}
|
||||
|
||||
/**
|
||||
* Return the error code.
|
||||
*/
|
||||
public function get_error_code() {
|
||||
return $this->code;
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert to a WP_Error.
|
||||
*
|
||||
* When calling a Pre_WordPress function from a WordPress context, use this method to convert
|
||||
* any resultant errors to WP_Errors for interfacing with other WordPress APIs.
|
||||
*
|
||||
* **Warning** - this function should only be called if WordPress has been loaded!
|
||||
*/
|
||||
public function to_wp_error() {
|
||||
if ( ! class_exists( '\WP_Error' ) ) {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log( 'Warning: Boost_Cache_Error::to_wp_error called from a Pre-WordPress context' );
|
||||
}
|
||||
}
|
||||
|
||||
return new \WP_Error( $this->get_error_code(), $this->get_error_message() );
|
||||
}
|
||||
}
|
||||
+197
@@ -0,0 +1,197 @@
|
||||
<?php
|
||||
/*
|
||||
* This file may be called before WordPress is fully initialized. See the README file for info.
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
|
||||
|
||||
/**
|
||||
* Cache settings class.
|
||||
* Settings are stored in a file in the boost-cache directory.
|
||||
*/
|
||||
class Boost_Cache_Settings {
|
||||
private static $instance = null;
|
||||
private $settings = array();
|
||||
private $config_file_path;
|
||||
private $config_file;
|
||||
|
||||
/**
|
||||
* An uninitialized config holds these settings.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
private $default_settings = array(
|
||||
'enabled' => false,
|
||||
'bypass_patterns' => array(),
|
||||
'logging' => false,
|
||||
);
|
||||
|
||||
private function __construct() {
|
||||
$this->config_file_path = WP_CONTENT_DIR . '/boost-cache/';
|
||||
$this->config_file = $this->config_file_path . 'config.php';
|
||||
}
|
||||
|
||||
/**
|
||||
* Gets the instance of the class.
|
||||
*
|
||||
* @return Boost_Cache_Settings The instance of the class.
|
||||
*/
|
||||
public static function get_instance() {
|
||||
if ( self::$instance === null ) {
|
||||
self::$instance = new Boost_Cache_Settings();
|
||||
self::$instance->init_settings();
|
||||
}
|
||||
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure a settings file exists, if one isn't there already.
|
||||
*
|
||||
* @return Boost_Cache_Error|bool - True if it was changed, or a Boost_Cache_Error on failure, false if it was already created.
|
||||
*/
|
||||
public function create_settings_file() {
|
||||
if ( file_exists( $this->config_file ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( ! file_exists( $this->config_file_path ) ) {
|
||||
if ( ! Filesystem_Utils::create_directory( $this->config_file_path ) ) {
|
||||
return new Boost_Cache_Error( 'failed-settings-write', 'Failed to create settings directory at ' . $this->config_file_path );
|
||||
}
|
||||
}
|
||||
|
||||
$write_result = $this->set( $this->default_settings );
|
||||
if ( $write_result instanceof Boost_Cache_Error ) {
|
||||
return $write_result;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* If an error occurs while reading the options, it will be impossible to ever log this to the Boost Cache logs.
|
||||
* So, if WP_DEBUG is enabled write it to the error_log instead.
|
||||
*
|
||||
* @param string $message - The message to log.
|
||||
*/
|
||||
private function log_init_error( $message ) {
|
||||
if ( defined( 'WP_DEBUG' ) && WP_DEBUG ) {
|
||||
error_log( $message ); // phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Load the settings from the config file, if available. Falls back to defaults if not.
|
||||
*/
|
||||
private function init_settings() {
|
||||
$this->settings = $this->default_settings;
|
||||
|
||||
// If no settings file exists yet, don't try to create one until we are writing a value.
|
||||
if ( ! file_exists( $this->config_file ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$lines = @file( $this->config_file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
if ( empty( $lines ) || count( $lines ) < 4 ) {
|
||||
$this->log_init_error( 'Invalid config file at ' . $this->config_file );
|
||||
return;
|
||||
}
|
||||
|
||||
$file_settings = null;
|
||||
foreach ( $lines as $line ) {
|
||||
if ( strpos( $line, '{' ) !== false ) {
|
||||
$file_settings = json_decode( $line, true );
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! is_array( $file_settings ) ) {
|
||||
$this->log_init_error( 'Invalid config file at ' . $this->config_file );
|
||||
return false;
|
||||
}
|
||||
|
||||
$this->settings = $file_settings;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the value of the given setting.
|
||||
*
|
||||
* @param string $setting - The setting to get.
|
||||
* @return mixed - The value of the setting, or the default if the setting does not exist.
|
||||
*/
|
||||
public function get( $setting, $default = false ) {
|
||||
if ( ! isset( $this->settings[ $setting ] ) ) {
|
||||
return $default;
|
||||
}
|
||||
|
||||
return $this->settings[ $setting ];
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the cache is enabled.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function get_enabled() {
|
||||
return $this->get( 'enabled', false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns an array of URLs that should not be cached.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
public function get_bypass_patterns() {
|
||||
return $this->get( 'bypass_patterns', array() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns whether logging is enabled or not.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function get_logging() {
|
||||
return $this->get( 'logging', false );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets the given settings, and saves them to the config file.
|
||||
*
|
||||
* @param array $settings - The settings to set in a key => value associative
|
||||
* array. This will be merged with the existing settings.
|
||||
* Example:
|
||||
* $result = $this->set( array( 'enabled' => true ) );
|
||||
*
|
||||
* @return Boost_Cache_Error|true - true if the settings were saved, Boost_Cache_Error otherwise.
|
||||
*/
|
||||
public function set( $settings ) {
|
||||
// If the settings file does not exist, attempt to create one.
|
||||
if ( ! file_exists( $this->config_file_path ) ) {
|
||||
$result = $this->create_settings_file();
|
||||
if ( $result instanceof Boost_Cache_Error ) {
|
||||
return $result;
|
||||
}
|
||||
}
|
||||
|
||||
if ( ! is_writable( $this->config_file_path ) ) { // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
|
||||
$error = new Boost_Cache_Error( 'failed-settings-write', 'Could not write to the config file at ' . $this->config_file_path );
|
||||
Logger::debug( $error->get_error_message() );
|
||||
return $error;
|
||||
}
|
||||
|
||||
$this->settings = array_merge( $this->settings, $settings );
|
||||
|
||||
$contents = "<?php die();\n/*\n * Configuration data for Jetpack Boost Cache. Do not edit.\n" . json_encode( // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
|
||||
$this->settings,
|
||||
JSON_HEX_TAG // Need to escape slashes because this is, for some reason, going into a PHP comment and we need to guard against `*/`.
|
||||
) . "\n */";
|
||||
$result = Filesystem_Utils::write_to_file( $this->config_file, $contents );
|
||||
if ( $result instanceof Boost_Cache_Error ) {
|
||||
Logger::debug( $result->get_error_message() );
|
||||
return new Boost_Cache_Error( 'failed-settings-write', 'Failed to write settings file: ' . $result->get_error_message() );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+100
@@ -0,0 +1,100 @@
|
||||
<?php
|
||||
/*
|
||||
* This file may be called before WordPress is fully initialized. See the README file for info.
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
|
||||
|
||||
use WP_Post;
|
||||
|
||||
class Boost_Cache_Utils {
|
||||
|
||||
/**
|
||||
* Performs a deep string replace operation to ensure the values in $search are no longer present.
|
||||
* Copied from wp-includes/formatting.php
|
||||
*
|
||||
* Repeats the replacement operation until it no longer replaces anything to remove "nested" values
|
||||
* e.g. $subject = '%0%0%0DDD', $search ='%0D', $result ='' rather than the '%0%0DD' that
|
||||
* str_replace would return
|
||||
*
|
||||
* @param string|array $search The value being searched for, otherwise known as the needle.
|
||||
* An array may be used to designate multiple needles.
|
||||
* @param string $subject The string being searched and replaced on, otherwise known as the haystack.
|
||||
* @return string The string with the replaced values.
|
||||
*/
|
||||
public static function deep_replace( $search, $subject ) {
|
||||
$subject = (string) $subject;
|
||||
|
||||
$count = 1;
|
||||
while ( $count ) {
|
||||
$subject = str_replace( $search, '', $subject, $count );
|
||||
}
|
||||
|
||||
return $subject;
|
||||
}
|
||||
|
||||
public static function trailingslashit( $string ) {
|
||||
return rtrim( $string, '/' ) . '/';
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a sanitized directory path.
|
||||
*
|
||||
* @param string $path - The path to sanitize.
|
||||
* @return string
|
||||
*/
|
||||
public static function sanitize_file_path( $path ) {
|
||||
$path = self::trailingslashit( $path );
|
||||
$path = self::deep_replace(
|
||||
array( '..', '\\' ),
|
||||
preg_replace(
|
||||
'/[ <>\'\"\r\n\t\(\)]/',
|
||||
'',
|
||||
preg_replace(
|
||||
'/(\?.*)?(#.*)?$/',
|
||||
'',
|
||||
$path
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Normalize the request uri so it can be used for caching purposes.
|
||||
* It removes the query string and the trailing slash, and characters
|
||||
* that might cause problems with the filesystem.
|
||||
*
|
||||
* **THIS DOES NOT SANITIZE THE VARIABLE IN ANY WAY.**
|
||||
* Only use it for comparison purposes or to generate an MD5 hash.
|
||||
*
|
||||
* @param string $request_uri - The request uri to normalize.
|
||||
* @return string - The normalized request uri.
|
||||
*/
|
||||
public static function normalize_request_uri( $request_uri ) {
|
||||
// get path from request uri
|
||||
$request_uri = parse_url( $request_uri, PHP_URL_PATH ); // phpcs:ignore WordPress.WP.AlternativeFunctions.parse_url_parse_url
|
||||
if ( empty( $request_uri ) ) {
|
||||
$request_uri = '/';
|
||||
} elseif ( substr( $request_uri, -1 ) !== '/' && ! is_file( ABSPATH . $request_uri ) ) {
|
||||
$request_uri .= '/';
|
||||
}
|
||||
|
||||
return $request_uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the post type is public.
|
||||
*
|
||||
* @param WP_Post $post - The post to check.
|
||||
* @return bool - True if the post type is public.
|
||||
*/
|
||||
public static function is_visible_post_type( $post ) {
|
||||
$post_type = is_a( $post, 'WP_Post' ) ? get_post_type_object( $post->post_type ) : null;
|
||||
if ( empty( $post_type ) || ! $post_type->public ) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+728
@@ -0,0 +1,728 @@
|
||||
<?php
|
||||
/*
|
||||
* This file is loaded by advanced-cache.php, and so cannot rely on autoloading.
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
|
||||
|
||||
use WP_Comment;
|
||||
use WP_Post;
|
||||
|
||||
/*
|
||||
* Require all pre-wordpress files here. These files aren't autoloaded as they are loaded before WordPress is fully initialized.
|
||||
* pre-wordpress files assume all other pre-wordpress files are loaded here.
|
||||
*/
|
||||
require_once __DIR__ . '/path-actions/interface-path-action.php';
|
||||
require_once __DIR__ . '/path-actions/class-filter-older.php';
|
||||
require_once __DIR__ . '/path-actions/class-rebuild-file.php';
|
||||
require_once __DIR__ . '/path-actions/class-simple-delete.php';
|
||||
require_once __DIR__ . '/boost-cache-actions.php';
|
||||
require_once __DIR__ . '/class-boost-cache-error.php';
|
||||
require_once __DIR__ . '/class-boost-cache-settings.php';
|
||||
require_once __DIR__ . '/class-boost-cache-utils.php';
|
||||
require_once __DIR__ . '/class-filesystem-utils.php';
|
||||
require_once __DIR__ . '/class-logger.php';
|
||||
require_once __DIR__ . '/class-request.php';
|
||||
require_once __DIR__ . '/storage/interface-storage.php';
|
||||
require_once __DIR__ . '/storage/class-file-storage.php';
|
||||
|
||||
// Define how many seconds the cache should last for each cached page.
|
||||
if ( ! defined( 'JETPACK_BOOST_CACHE_DURATION' ) ) {
|
||||
define( 'JETPACK_BOOST_CACHE_DURATION', HOUR_IN_SECONDS );
|
||||
}
|
||||
|
||||
// Define how many seconds the rebuild cache should be considered stale, but usable, for each cached page.
|
||||
if ( ! defined( 'JETPACK_BOOST_CACHE_REBUILD_DURATION' ) ) {
|
||||
define( 'JETPACK_BOOST_CACHE_REBUILD_DURATION', 10 );
|
||||
}
|
||||
|
||||
class Boost_Cache {
|
||||
/**
|
||||
* @var Boost_Cache_Settings - The settings for the page cache.
|
||||
*/
|
||||
private $settings;
|
||||
|
||||
/**
|
||||
* @var Storage\Storage - The storage system used by Boost Cache.
|
||||
*/
|
||||
private $storage;
|
||||
|
||||
/**
|
||||
* @var Request - The request object that provides utility for the current request.
|
||||
*/
|
||||
private $request = null;
|
||||
|
||||
/**
|
||||
* @var bool - Indicates whether the cache engine has been loaded.
|
||||
*/
|
||||
private static $cache_engine_loaded = false;
|
||||
|
||||
/**
|
||||
* @var bool - Indicates whether WordPress initialized correctly and we can cache the page.
|
||||
*/
|
||||
private $do_cache = false;
|
||||
|
||||
/**
|
||||
* @var string - The ignored cookies that were removed from the cache parameters.
|
||||
*/
|
||||
private $ignored_cookies = '';
|
||||
|
||||
/**
|
||||
* @var string - The ignored GET parameters that were removed from the cache parameters.
|
||||
*/
|
||||
private $ignored_get_parameters = '';
|
||||
|
||||
/**
|
||||
* @param ?Storage\Storage $storage - Optionally provide a Storage subclass to handle actually storing and retrieving cached content. Defaults to a new instance of File_Storage.
|
||||
*/
|
||||
public function __construct( $storage = null ) {
|
||||
$this->settings = Boost_Cache_Settings::get_instance();
|
||||
$home = isset( $_SERVER['HTTP_HOST'] ) ? strtolower( $_SERVER['HTTP_HOST'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
|
||||
$this->storage = $storage ?? new Storage\File_Storage( $home );
|
||||
$this->request = Request::current();
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the actions for the cache.
|
||||
*/
|
||||
public function init_actions() {
|
||||
add_action( 'transition_post_status', array( $this, 'invalidate_on_post_transition' ), 10, 3 );
|
||||
add_action( 'transition_comment_status', array( $this, 'invalidate_on_comment_transition' ), 10, 3 );
|
||||
add_action( 'comment_post', array( $this, 'rebuild_on_comment_post' ), 10, 3 );
|
||||
add_action( 'edit_comment', array( $this, 'rebuild_on_comment_edit' ), 10, 2 );
|
||||
add_action( 'switch_theme', array( $this, 'rebuild_all' ) );
|
||||
add_action( 'wp_trash_post', array( $this, 'delete_on_post_trash' ), 10, 2 );
|
||||
add_filter( 'wp_php_error_message', array( $this, 'disable_caching_on_error' ) );
|
||||
add_filter( 'init', array( $this, 'init_do_cache' ) );
|
||||
add_filter( 'jetpack_boost_cache_parameters', array( $this, 'ignore_cookies' ) );
|
||||
add_filter( 'jetpack_boost_cache_parameters', array( $this, 'ignore_get_parameters' ) );
|
||||
$this->load_extra();
|
||||
}
|
||||
|
||||
private function load_extra() {
|
||||
if ( file_exists( WP_CONTENT_DIR . '/boost-cache-extra.php' ) ) {
|
||||
include_once WP_CONTENT_DIR . '/boost-cache-extra.php';
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve the cached page if it exists, otherwise start output buffering.
|
||||
*/
|
||||
public function serve() {
|
||||
if ( ! $this->settings->get_enabled() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Indicate that the cache engine has been loaded.
|
||||
self::$cache_engine_loaded = true;
|
||||
|
||||
if ( ! $this->request->is_cacheable() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! $this->serve_cached() ) {
|
||||
$this->ob_start();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the cache engine has been loaded.
|
||||
*
|
||||
* @return bool - True if the cache engine has been loaded, false otherwise.
|
||||
*/
|
||||
public static function is_loaded() {
|
||||
return self::$cache_engine_loaded;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the storage instance used by Boost Cache.
|
||||
*
|
||||
* @return Storage\Storage
|
||||
*/
|
||||
public function get_storage() {
|
||||
return $this->storage;
|
||||
}
|
||||
|
||||
/**
|
||||
* Serve cached content, if any is available for the current request. Will terminate if it does so.
|
||||
* Otherwise, returns false.
|
||||
*/
|
||||
public function serve_cached() {
|
||||
if ( ! $this->request->is_cacheable() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// check if rebuild file exists and rename it to the correct file
|
||||
$rebuild_found = $this->storage->reset_rebuild_file( $this->request->get_uri(), $this->request->get_parameters() );
|
||||
if ( $rebuild_found ) {
|
||||
Logger::debug( 'Rebuild file found. Will be used for cache until new file created.' );
|
||||
$cached = false;
|
||||
} else {
|
||||
$cached = $this->storage->read( $this->request->get_uri(), $this->request->get_parameters() );
|
||||
}
|
||||
|
||||
if ( is_string( $cached ) ) {
|
||||
$this->send_header( 'X-Jetpack-Boost-Cache: hit' );
|
||||
$ignored_cookies_message = $this->ignored_cookies === '' ? '' : " and ignored cookies: {$this->ignored_cookies}";
|
||||
$ignored_get_message = $this->ignored_get_parameters === '' ? '' : " and ignored GET parameters: {$this->ignored_get_parameters}";
|
||||
Logger::debug( 'Serving cached page' . $ignored_cookies_message . $ignored_get_message );
|
||||
echo $cached; // phpcs:ignore WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
die( 0 );
|
||||
}
|
||||
|
||||
$cache_status = $rebuild_found ? 'rebuild' : 'miss';
|
||||
$this->send_header( 'X-Jetpack-Boost-Cache: ' . $cache_status );
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
private function send_header( $header ) {
|
||||
if ( ! headers_sent() ) {
|
||||
header( $header );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts output buffering and sets the callback to save the cache file.
|
||||
*
|
||||
* @return bool - false if page is not cacheable.
|
||||
*/
|
||||
public function ob_start() {
|
||||
if ( ! $this->request->is_cacheable() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
ob_start( array( $this, 'ob_callback' ) );
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Callback function from output buffer. This function saves the output
|
||||
* buffer to a cache file and then returns the buffer so PHP will send it
|
||||
* to the browser.
|
||||
*
|
||||
* @param string $buffer - The output buffer to save to the cache file.
|
||||
* @return string - The output buffer.
|
||||
*/
|
||||
public function ob_callback( $buffer ) {
|
||||
if ( strlen( $buffer ) > 0 && $this->request->is_cacheable() ) {
|
||||
|
||||
// Do not cache the page as WordPress did not initialize correctly.
|
||||
if ( ! $this->do_cache ) {
|
||||
Logger::debug( 'Page exited early. Do not cache.' );
|
||||
return $buffer;
|
||||
}
|
||||
|
||||
if ( false === stripos( $buffer, '</html>' ) ) {
|
||||
Logger::debug( 'Closing HTML tag not found, not caching' );
|
||||
return $buffer;
|
||||
}
|
||||
|
||||
$result = $this->storage->write( $this->request->get_uri(), $this->request->get_parameters(), $buffer );
|
||||
|
||||
if ( $result instanceof Boost_Cache_Error ) {
|
||||
Logger::debug( 'Error writing cache file: ' . $result->get_error_message() );
|
||||
} else {
|
||||
$ignored_cookies_message = $this->ignored_cookies === '' ? '' : " and ignored cookies: {$this->ignored_cookies}";
|
||||
$ignored_get_message = $this->ignored_get_parameters === '' ? '' : " and ignored GET parameters: {$this->ignored_get_parameters}";
|
||||
Logger::debug( 'Cache file created' . $ignored_cookies_message . $ignored_get_message );
|
||||
}
|
||||
}
|
||||
|
||||
return $buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete/rebuild the cache for the front page and paged archives.
|
||||
* This is called when a post is edited, deleted, or published.
|
||||
*/
|
||||
public function rebuild_front_page() {
|
||||
if ( get_option( 'show_on_front' ) === 'page' ) {
|
||||
$this->rebuild_page( home_url() );
|
||||
$posts_page_id = get_option( 'page_for_posts' ); // posts page
|
||||
if ( $posts_page_id ) {
|
||||
Logger::debug( 'rebuild_front_page: deleting posts page cache' );
|
||||
$this->rebuild_post_cache( get_post( $posts_page_id ) );
|
||||
}
|
||||
} else {
|
||||
$this->rebuild_page( home_url() );
|
||||
Logger::debug( 'delete front page cache ' . Boost_Cache_Utils::normalize_request_uri( home_url() ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the cache for the post if the comment transitioned from one state to another.
|
||||
*
|
||||
* @param string $new_status - The new status of the comment.
|
||||
* @param string $old_status - The old status of the comment.
|
||||
* @param WP_Comment $comment - The comment that transitioned.
|
||||
*/
|
||||
public function invalidate_on_comment_transition( $new_status, $old_status, $comment ) {
|
||||
if ( $new_status === $old_status ) {
|
||||
return;
|
||||
}
|
||||
Logger::debug( "invalidate_on_comment_transition: $new_status, $old_status" );
|
||||
|
||||
if ( $new_status !== 'approved' && $old_status !== 'approved' ) {
|
||||
Logger::debug( 'invalidate_on_comment_transition: comment not approved' );
|
||||
return;
|
||||
}
|
||||
|
||||
$post = get_post( (int) $comment->comment_post_ID );
|
||||
$this->rebuild_post_cache( $post );
|
||||
}
|
||||
|
||||
/**
|
||||
* After editing a comment, rebuild the cache for the post if the comment is approved.
|
||||
* If changing state and editing, both actions will be called, but the cache will only be rebuilt once.
|
||||
*
|
||||
* @param int $comment_id - The id of the comment.
|
||||
* @param array $commentdata - The comment data.
|
||||
*/
|
||||
public function rebuild_on_comment_edit( $comment_id, $commentdata ) {
|
||||
$post = get_post( $commentdata['comment_post_ID'] );
|
||||
|
||||
if ( (int) $commentdata['comment_approved'] === 1 ) {
|
||||
$this->rebuild_post_cache( $post );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* After a comment is posted, rebuild the cache for the post if the comment is approved.
|
||||
* If the comment is not approved, only rebuild the cache for this post for this visitor.
|
||||
*
|
||||
* @param int $comment_id - The id of the comment.
|
||||
* @param int $comment_approved - The approval status of the comment.
|
||||
* @param array $commentdata - The comment data.
|
||||
*/
|
||||
public function rebuild_on_comment_post( $comment_id, $comment_approved, $commentdata ) {
|
||||
$post = get_post( $commentdata['comment_post_ID'] );
|
||||
Logger::debug( "rebuild_on_comment_post: $comment_id, $comment_approved, {$post->ID}" );
|
||||
/**
|
||||
* If a comment is not approved, we only need to delete the cache for
|
||||
* this post for this visitor so the unmoderated comment is shown to them.
|
||||
*/
|
||||
if ( $comment_approved !== 1 ) {
|
||||
$parameters = $this->request->get_parameters();
|
||||
|
||||
/*
|
||||
* If there are no cookies, then visitor did not click "remember me".
|
||||
* They'll be redirected to a page with a hash in the URL for the
|
||||
* moderation message.
|
||||
* Only delete the cache for visitors who clicked "remember me".
|
||||
*/
|
||||
if ( isset( $parameters['cookies'] ) && ! empty( $parameters['cookies'] ) ) {
|
||||
$this->delete_page( get_permalink( $post->ID ), $parameters );
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
$this->rebuild_post_cache( $post );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the post is published or private.
|
||||
*
|
||||
* @param string $status - The status of the post.
|
||||
* @return bool
|
||||
*/
|
||||
private function is_published( $status ) {
|
||||
return $status === 'publish' || $status === 'private';
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the cached post if it transitioned from one state to another.
|
||||
*
|
||||
* @param string $new_status - The new status of the post.
|
||||
* @param string $old_status - The old status of the post.
|
||||
* @param WP_Post $post - The post that transitioned.
|
||||
*/
|
||||
public function invalidate_on_post_transition( $new_status, $old_status, $post ) {
|
||||
// Special case: Delete cache if the post type can effect the whole site.
|
||||
$special_post_types = array( 'wp_template', 'wp_template_part', 'wp_global_styles' );
|
||||
if ( in_array( $post->post_type, $special_post_types, true ) ) {
|
||||
Logger::debug( 'invalidate_on_post_transition: special post type ' . $post->post_type );
|
||||
$this->rebuild_all();
|
||||
return;
|
||||
}
|
||||
|
||||
if ( ! Boost_Cache_Utils::is_visible_post_type( $post ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $new_status === 'trash' ) {
|
||||
return;
|
||||
}
|
||||
|
||||
Logger::debug( "invalidate_on_post_transition: $new_status, $old_status, {$post->ID}" );
|
||||
|
||||
// Don't delete the cache for posts that weren't published and aren't published now
|
||||
if ( ! $this->is_published( $new_status ) && ! $this->is_published( $old_status ) ) {
|
||||
Logger::debug( 'invalidate_on_post_transition: not published' );
|
||||
return;
|
||||
}
|
||||
|
||||
// delete the cache files entirely if the post was unpublished
|
||||
if ( 'publish' === $old_status && 'publish' !== $new_status ) {
|
||||
Logger::debug( 'invalidate_on_post_transition: delete cache on new private page' );
|
||||
$this->delete_on_post_trash( $post->ID, $old_status );
|
||||
return;
|
||||
}
|
||||
Logger::debug( "invalidate_on_post_transition: rebuilding post {$post->ID}" );
|
||||
|
||||
$this->rebuild_post_cache( $post );
|
||||
$this->rebuild_post_terms_cache( $post );
|
||||
$this->rebuild_front_page();
|
||||
$this->rebuild_author_page( (int) $post->post_author );
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the cache for the post if it was trashed.
|
||||
*
|
||||
* @param int $post_id - The id of the post.
|
||||
* @param string $old_status - The old status of the post.
|
||||
*/
|
||||
public function delete_on_post_trash( $post_id, $old_status ) {
|
||||
if ( $this->is_published( $old_status ) ) {
|
||||
$post = get_post( $post_id );
|
||||
$post_path = $this->get_post_path_for_invalidation( $post );
|
||||
if ( $post_path ) {
|
||||
$this->delete_recursive( $post_path );
|
||||
}
|
||||
$this->rebuild_post_terms_cache( $post );
|
||||
$this->rebuild_front_page();
|
||||
$this->rebuild_author_page( (int) $post->post_author );
|
||||
}
|
||||
}
|
||||
|
||||
private function get_post_path_for_invalidation( $post ) {
|
||||
static $already_deleted = -1;
|
||||
if ( $already_deleted === $post->ID ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Don't invalidate the cache for post types that are not public.
|
||||
*/
|
||||
if ( ! Boost_Cache_Utils::is_visible_post_type( $post ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$already_deleted = $post->ID;
|
||||
|
||||
/**
|
||||
* If a post is unpublished, the permalink will be deleted. In that case,
|
||||
* get_sample_permalink() will return a permalink with ?p=123 instead of
|
||||
* the post name. We need to get the post name from the post object.
|
||||
*/
|
||||
$permalink = get_permalink( $post->ID );
|
||||
if ( strpos( $permalink, '?p=' ) !== false || strpos( $permalink, '?page_id=' ) !== false ) {
|
||||
if ( $post->post_type === 'page' ) {
|
||||
$permalink = get_page_link( $post->ID, false, true );
|
||||
} else {
|
||||
if ( ! function_exists( 'get_sample_permalink' ) ) {
|
||||
require_once ABSPATH . 'wp-admin/includes/post.php';
|
||||
}
|
||||
list( $permalink, $post_name ) = get_sample_permalink( $post->ID );
|
||||
$permalink = str_replace( '%postname%', $post_name, $permalink );
|
||||
}
|
||||
}
|
||||
return $permalink;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the cache for terms associated with this post.
|
||||
*
|
||||
* @param WP_Post $post - The post to delete the cache for.
|
||||
*/
|
||||
public function rebuild_post_terms_cache( $post ) {
|
||||
$categories = get_the_category( $post->ID );
|
||||
if ( is_array( $categories ) ) {
|
||||
foreach ( $categories as $category ) {
|
||||
$link = trailingslashit( get_category_link( $category->term_id ) );
|
||||
$this->rebuild_recursive( $link );
|
||||
}
|
||||
}
|
||||
|
||||
$tags = get_the_tags( $post->ID );
|
||||
if ( is_array( $tags ) ) {
|
||||
foreach ( $tags as $tag ) {
|
||||
$link = trailingslashit( get_tag_link( $tag->term_id ) );
|
||||
$this->rebuild_recursive( $link );
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete the entire cache for the author's archive page.
|
||||
*
|
||||
* @param int $author_id - The id of the author.
|
||||
*/
|
||||
public function rebuild_author_page( $author_id ) {
|
||||
$author = get_userdata( $author_id );
|
||||
if ( ! $author ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$author_link = get_author_posts_url( $author_id, $author->user_nicename );
|
||||
$this->rebuild_recursive( $author_link );
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild the entire cache.
|
||||
*/
|
||||
public function rebuild_all() {
|
||||
$this->rebuild_recursive( home_url() );
|
||||
}
|
||||
|
||||
public function delete_post_cache( $post ) {
|
||||
$post_path = $this->get_post_path_for_invalidation( $post );
|
||||
if ( null === $post_path ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( Boost_Cache_Utils::trailingslashit( $post_path ) !== Boost_Cache_Utils::trailingslashit( home_url() ) ) {
|
||||
$this->delete_recursive( $post_path );
|
||||
} else {
|
||||
$this->delete_page( $post_path );
|
||||
}
|
||||
}
|
||||
|
||||
public function rebuild_post_cache( $post ) {
|
||||
$post_path = $this->get_post_path_for_invalidation( $post );
|
||||
if ( null === $post_path ) {
|
||||
return;
|
||||
}
|
||||
|
||||
if ( Boost_Cache_Utils::trailingslashit( $post_path ) !== Boost_Cache_Utils::trailingslashit( home_url() ) ) {
|
||||
$this->rebuild_recursive( $post_path );
|
||||
} else {
|
||||
$this->rebuild_page( $post_path );
|
||||
}
|
||||
}
|
||||
|
||||
public function rebuild_page( $path, $parameters = false ) {
|
||||
$this->storage->clear(
|
||||
$path,
|
||||
array(
|
||||
'rebuild' => true,
|
||||
'parameters' => $parameters,
|
||||
)
|
||||
);
|
||||
$this->invalidate_cache_success( $path, 'rebuild', 'page' );
|
||||
}
|
||||
|
||||
public function delete_page( $path, $parameters = false ) {
|
||||
$this->storage->clear(
|
||||
$path,
|
||||
array(
|
||||
'rebuild' => false,
|
||||
'parameters' => $parameters,
|
||||
)
|
||||
);
|
||||
$this->invalidate_cache_success( $path, 'delete', 'page' );
|
||||
}
|
||||
|
||||
public function rebuild_recursive( $path ) {
|
||||
$this->storage->clear(
|
||||
$path,
|
||||
array(
|
||||
'rebuild' => true,
|
||||
'recursive' => true,
|
||||
)
|
||||
);
|
||||
$this->invalidate_cache_success( $path, 'rebuild', 'recursive' );
|
||||
}
|
||||
|
||||
public function delete_recursive( $path ) {
|
||||
$this->storage->clear(
|
||||
$path,
|
||||
array(
|
||||
'rebuild' => false,
|
||||
'recursive' => true,
|
||||
)
|
||||
);
|
||||
$this->invalidate_cache_success( $path, 'delete', 'recursive' );
|
||||
}
|
||||
|
||||
private function invalidate_cache_success( $path, $type, $scope ) {
|
||||
do_action( 'jetpack_boost_invalidate_cache_success', $path, $type, $scope );
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore certain GET parameters in the cache parameters so cached pages can be served to these visitors.
|
||||
*
|
||||
* @param array $parameters - The parameters with the GET array to filter.
|
||||
* @return array - The parameters with GET parameters removed.
|
||||
*/
|
||||
public function ignore_get_parameters( $parameters ) {
|
||||
static $params = false;
|
||||
|
||||
// Only run this once as it may be called multiple times on uncached pages.
|
||||
if ( $params ) {
|
||||
return $params;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the GET parameters so cached pages can be served to these visitors.
|
||||
* The list is an array of regex patterns. The default list contains the
|
||||
* most common GET parameters used by analytics services.
|
||||
*
|
||||
* @since 3.8.0
|
||||
*
|
||||
* @param array $get_parameters An array of regexes to remove items from the GET parameter list.
|
||||
*/
|
||||
$get_parameters = apply_filters(
|
||||
'jetpack_boost_ignore_get_parameters',
|
||||
array( 'utm_source', 'utm_medium', 'utm_campaign', 'utm_content', 'utm_term', 'ysclid', 'srsltid', 'yclid' )
|
||||
);
|
||||
|
||||
$get_parameters = array_unique(
|
||||
array_map(
|
||||
'trim',
|
||||
$get_parameters
|
||||
)
|
||||
);
|
||||
|
||||
foreach ( $get_parameters as $get_parameter ) {
|
||||
foreach ( array_keys( $parameters['get'] ) as $get_parameter_name ) {
|
||||
if ( preg_match( '/^' . $get_parameter . '$/', $get_parameter_name ) ) {
|
||||
unset( $parameters['get'][ $get_parameter_name ] );
|
||||
$this->ignored_get_parameters .= $get_parameter_name . ',';
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( $this->ignored_get_parameters !== '' ) {
|
||||
$this->ignored_get_parameters = rtrim( $this->ignored_get_parameters, ',' );
|
||||
}
|
||||
|
||||
$params = $parameters;
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
/**
|
||||
* Ignore certain cookies in the cache parameters so cached pages can be served to these visitors.
|
||||
*
|
||||
* @param array $parameters - The parameters with the cookies array to filter.
|
||||
* @return array - The parameters with cookies removed.
|
||||
*/
|
||||
public function ignore_cookies( $parameters ) {
|
||||
static $params = false;
|
||||
|
||||
// Only run this once as it may be called multiple times on uncached pages.
|
||||
if ( $params ) {
|
||||
return $params;
|
||||
}
|
||||
|
||||
$default_cookies = array(
|
||||
'cf_clearance',
|
||||
'cf_chl_rc_i',
|
||||
'cf_chl_rc_ni',
|
||||
'cf_chl_rc_m',
|
||||
'_cfuvid',
|
||||
'__cfruid',
|
||||
'__cfwaitingroom',
|
||||
'cf_ob_info',
|
||||
'cf_use_ob',
|
||||
'__cfseq',
|
||||
'__cf_bm',
|
||||
'__cflb',
|
||||
|
||||
// Sourcebuster
|
||||
'sbjs_(.*)',
|
||||
|
||||
// Google Analytics
|
||||
'_ga(?:_[A-Z0-9]*)?',
|
||||
|
||||
// AWS Load Balancer
|
||||
'AWSELB',
|
||||
'AWSELBCORS',
|
||||
'AWSALB',
|
||||
'AWSALBCORS',
|
||||
);
|
||||
$jetpack_cookies = array( 'tk_ai', 'tk_qs' );
|
||||
$cookies = array_merge( $default_cookies, $jetpack_cookies );
|
||||
|
||||
/**
|
||||
* Filters the browser cookies so cached pages can be served to these visitors.
|
||||
* The list is an array of regex patterns. The default list contains the
|
||||
* cookies used by Cloudflare, and the regex pattern for the sbjs_ cookies
|
||||
* used by sourcebuster.js
|
||||
*
|
||||
* @since 3.8.0
|
||||
*
|
||||
* @param array $cookies An array of regexes to remove items from the cookie list.
|
||||
*/
|
||||
$cookies = apply_filters(
|
||||
'jetpack_boost_ignore_cookies',
|
||||
$cookies
|
||||
);
|
||||
|
||||
$cookies = array_unique(
|
||||
array_map(
|
||||
'trim',
|
||||
$cookies
|
||||
)
|
||||
);
|
||||
|
||||
/**
|
||||
* The Jetpack Cookie Banner plugin sets a cookie to indicate that the
|
||||
* user has accepted the cookie policy.
|
||||
* The value of the cookie is the expiry date of the cookie, which means
|
||||
* that everyone who has accepted the cookie policy will use a different
|
||||
* cache file.
|
||||
* Set it to 1 here so those visitors will use the same cache file.
|
||||
*/
|
||||
if ( isset( $parameters['cookies']['eucookielaw'] ) ) {
|
||||
$parameters['cookies']['eucookielaw'] = 1;
|
||||
}
|
||||
|
||||
/**
|
||||
* This is for the personalized ads consent cookie.
|
||||
*/
|
||||
if ( isset( $parameters['cookies']['personalized-ads-consent'] ) ) {
|
||||
$parameters['cookies']['personalized-ads-consent'] = 1;
|
||||
}
|
||||
|
||||
$cookie_keys = array();
|
||||
if ( isset( $parameters['cookies'] ) && is_array( $parameters['cookies'] ) ) {
|
||||
$cookie_keys = array_keys( $parameters['cookies'] );
|
||||
} else {
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
foreach ( $cookies as $cookie ) {
|
||||
foreach ( $cookie_keys as $cookie_name ) {
|
||||
if ( preg_match( '/^' . $cookie . '$/', $cookie_name ) ) {
|
||||
unset( $parameters['cookies'][ $cookie_name ] );
|
||||
$this->ignored_cookies .= $cookie_name . ',';
|
||||
}
|
||||
}
|
||||
}
|
||||
if ( $this->ignored_cookies !== '' ) {
|
||||
$this->ignored_cookies = rtrim( $this->ignored_cookies, ',' );
|
||||
}
|
||||
|
||||
$params = $parameters;
|
||||
|
||||
return $parameters;
|
||||
}
|
||||
|
||||
public function disable_caching_on_error( $message ) {
|
||||
if ( ! defined( 'DONOTCACHEPAGE' ) ) {
|
||||
define( 'DONOTCACHEPAGE', true );
|
||||
}
|
||||
Logger::debug( 'Fatal error detected, caching disabled' );
|
||||
return $message;
|
||||
}
|
||||
|
||||
/**
|
||||
* This function is called after WordPress is loaded, on "init".
|
||||
* It is used to indicate that it is safe to cache and that no
|
||||
* fatal errors occurred.
|
||||
*/
|
||||
public function init_do_cache() {
|
||||
$this->do_cache = true;
|
||||
}
|
||||
}
|
||||
+382
@@ -0,0 +1,382 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
|
||||
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Path_Action;
|
||||
use SplFileInfo;
|
||||
|
||||
class Filesystem_Utils {
|
||||
|
||||
const DELETE_ALL = 'delete-all'; // delete all files and directories in a given directory, recursively.
|
||||
const DELETE_FILE = 'delete-single'; // delete a single file or recursively delete a single directory in a given directory.
|
||||
const DELETE_FILES = 'delete-files'; // delete all files in a given directory.
|
||||
const REBUILD_ALL = 'rebuild-all'; // rebuild all files and directories in a given directory, recursively.
|
||||
const REBUILD_FILE = 'rebuild-single'; // rebuild a single file or recursively rebuild a single directory in a given directory.
|
||||
const REBUILD_FILES = 'rebuild-files'; // rebuild all files in a given directory.
|
||||
const REBUILD = 'rebuild'; // rebuild mode for managing expired files
|
||||
const DELETE = 'delete'; // delete mode for managing expired files
|
||||
const REBUILD_FILE_EXTENSION = '.rebuild.html'; // The extension used for rebuilt files.
|
||||
|
||||
/**
|
||||
* Iterate over a directory and apply an action to each file.
|
||||
*
|
||||
* This applies the action to all files and subdirectories in the given directory.
|
||||
*
|
||||
* @param string $path - The directory to iterate over.
|
||||
* @param Path_Action $action - The action to apply to each file.
|
||||
* @return int|Boost_Cache_Error - The number of files processed, or Boost_Cache_Error on failure.
|
||||
*/
|
||||
public static function iterate_directory( $path, Path_Action $action ) {
|
||||
clearstatcache();
|
||||
$validation_error = self::validate_path( $path );
|
||||
if ( $validation_error instanceof Boost_Cache_Error ) {
|
||||
return $validation_error;
|
||||
}
|
||||
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator( $path, \RecursiveDirectoryIterator::SKIP_DOTS ),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST
|
||||
);
|
||||
|
||||
$count = 0;
|
||||
foreach ( $iterator as $file ) {
|
||||
$count += $action->apply_to_path( new SplFileInfo( $file ) );
|
||||
}
|
||||
|
||||
$count += $action->apply_to_path( new SplFileInfo( $path ) );
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Iterate over a directory and apply an action to each file.
|
||||
*
|
||||
* This applies the action to all files in the directory, except index.html. And doesn't go into subdirectories.
|
||||
*
|
||||
* @param string $path - The directory to iterate over.
|
||||
* @param Path_Action $action - The action to apply to each file.
|
||||
* @return int|Boost_Cache_Error - The number of files processed, or Boost_Cache_Error on failure.
|
||||
*/
|
||||
public static function iterate_files( $path, Path_Action $action ) {
|
||||
clearstatcache();
|
||||
$validation_error = self::validate_path( $path );
|
||||
if ( $validation_error instanceof Boost_Cache_Error ) {
|
||||
return $validation_error;
|
||||
}
|
||||
|
||||
$path = Boost_Cache_Utils::trailingslashit( $path );
|
||||
// Files to delete are all files in the given directory, except index.html. index.html is used to prevent directory listing.
|
||||
$files = array_diff( scandir( $path ), array( '.', '..', 'index.html' ) );
|
||||
$count = 0;
|
||||
foreach ( $files as $file ) {
|
||||
$fileinfo = new SplFileInfo( $path . $file );
|
||||
$count += (int) $action->apply_to_path( $fileinfo );
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Recursively delete a directory and everything in it, including cache files,
|
||||
* index.html placeholder files, subdirectories and the directory itself.
|
||||
*
|
||||
* Unlike iterate_directory() with a Simple_Delete action, this does not keep
|
||||
* index.html placeholder files, does not log each deletion, and removes each
|
||||
* entry as the iterator visits it instead of building a file list in memory,
|
||||
* so it stays time- and memory-efficient even for very large caches. Used to
|
||||
* completely remove the boost-cache directory when the plugin is uninstalled.
|
||||
*
|
||||
* @param string $path - The directory to delete.
|
||||
* @return bool|Boost_Cache_Error - True on success (or if the directory is already gone), Boost_Cache_Error on failure.
|
||||
*/
|
||||
public static function delete_directory( $path ) {
|
||||
clearstatcache();
|
||||
|
||||
// Strip a trailing slash so the is_link() guard below sees the link itself.
|
||||
// is_link( 'foo/' ) is false on POSIX, which would let a trailing-slash path
|
||||
// slip past the symlink-root check; rtrim() closes that for this public,
|
||||
// destructive primitive even though the current caller passes no slash.
|
||||
$path = rtrim( $path, '/' );
|
||||
|
||||
// Refuse to follow a symlinked cache root. realpath() resolves a symlink
|
||||
// to its target, so a boost-cache symlink pointing outside wp-content would
|
||||
// resolve identically to $cache_root below and pass the containment check,
|
||||
// causing the target tree to be deleted. Boost never creates boost-cache as
|
||||
// a symlink, so a symlinked root is unexpected and we refuse it outright.
|
||||
// This is checked on the literal $path, not the resolved target, and only
|
||||
// guards the root itself; symlinks encountered inside the tree are unlinked
|
||||
// (never followed) by the deletion loop below.
|
||||
if ( is_link( $path ) ) {
|
||||
return new Boost_Cache_Error( 'invalid-directory', 'Refusing to delete a symlinked directory: ' . $path );
|
||||
}
|
||||
|
||||
$resolved = realpath( $path );
|
||||
if ( false === $resolved ) {
|
||||
// Nothing to delete if the directory is already gone.
|
||||
return true;
|
||||
}
|
||||
|
||||
// Strict containment check. is_boost_cache_directory() only does a substring
|
||||
// match, which would also accept sibling paths like boost-cache-old; since
|
||||
// this helper deletes whole trees during uninstall, only the cache root
|
||||
// itself or paths inside it are accepted, compared on resolved paths.
|
||||
$cache_root = realpath( WP_CONTENT_DIR . '/boost-cache' );
|
||||
if ( false === $cache_root || ( $resolved !== $cache_root && strpos( $resolved, $cache_root . '/' ) !== 0 ) ) {
|
||||
return new Boost_Cache_Error( 'invalid-directory', 'Invalid directory ' . $path );
|
||||
}
|
||||
|
||||
if ( ! is_dir( $resolved ) ) {
|
||||
return new Boost_Cache_Error( 'not-a-directory', 'Not a directory' );
|
||||
}
|
||||
|
||||
// Deleting a large cache can take a while; try not to time out half-way through.
|
||||
if ( function_exists( 'set_time_limit' ) ) {
|
||||
@set_time_limit( 0 ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
}
|
||||
|
||||
try {
|
||||
// CATCH_GET_CHILD keeps the walk best-effort: an unreadable subdirectory
|
||||
// is skipped instead of throwing and aborting the whole cleanup, so the
|
||||
// rest of the tree is still deleted. Anything left behind is reported by
|
||||
// the final is_dir() re-check below.
|
||||
$iterator = new \RecursiveIteratorIterator(
|
||||
new \RecursiveDirectoryIterator( $resolved, \RecursiveDirectoryIterator::SKIP_DOTS ),
|
||||
\RecursiveIteratorIterator::CHILD_FIRST,
|
||||
\RecursiveIteratorIterator::CATCH_GET_CHILD
|
||||
);
|
||||
|
||||
// Errors for individual entries are suppressed so a single failure doesn't abort the cleanup.
|
||||
foreach ( $iterator as $file ) {
|
||||
if ( $file->isDir() && ! $file->isLink() ) {
|
||||
@rmdir( $file->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
} else {
|
||||
@unlink( $file->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
}
|
||||
}
|
||||
} catch ( \Throwable $e ) {
|
||||
// The iterator itself can throw (e.g. an unreadable subdirectory).
|
||||
// Uninstall cleanup must fail with a controlled error, not an
|
||||
// uncaught exception.
|
||||
return new Boost_Cache_Error( 'could-not-delete-directory', 'Could not completely delete directory: ' . $e->getMessage() );
|
||||
}
|
||||
|
||||
@rmdir( $resolved ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
|
||||
// Re-check against the filesystem, not a stale stat cache, so a successful
|
||||
// removal is not misreported as a failure.
|
||||
clearstatcache();
|
||||
if ( is_dir( $resolved ) ) {
|
||||
return new Boost_Cache_Error( 'could-not-delete-directory', 'Could not completely delete directory: ' . $path );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
private static function validate_path( $path ) {
|
||||
$path = realpath( $path );
|
||||
if ( ! $path ) {
|
||||
// translators: %s is the directory that does not exist.
|
||||
return new Boost_Cache_Error( 'directory-missing', 'Directory does not exist: ' . $path ); // realpath returns false if a file does not exist.
|
||||
}
|
||||
|
||||
// make sure that $dir is a directory inside WP_CONTENT . '/boost-cache/';
|
||||
if ( self::is_boost_cache_directory( $path ) === false ) {
|
||||
// translators: %s is the directory that is invalid.
|
||||
return new Boost_Cache_Error( 'invalid-directory', 'Invalid directory %s' . $path );
|
||||
}
|
||||
|
||||
if ( ! is_dir( $path ) ) {
|
||||
return new Boost_Cache_Error( 'not-a-directory', 'Not a directory' );
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given directory is inside the boost-cache directory.
|
||||
*
|
||||
* @param string $dir - The directory to check.
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_boost_cache_directory( $dir ) {
|
||||
$dir = Boost_Cache_Utils::sanitize_file_path( $dir );
|
||||
return strpos( $dir, WP_CONTENT_DIR . '/boost-cache' ) !== false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a request_uri and its parameters, return the filename to use for this cached data. Does not include the file path.
|
||||
*
|
||||
* @param array $parameters - An associative array of all the things that make this request special/different. Includes GET parameters and COOKIEs normally.
|
||||
*/
|
||||
public static function get_request_filename( $parameters ) {
|
||||
|
||||
/**
|
||||
* Filters the components used to generate the cache key.
|
||||
*
|
||||
* @param array $parameters The array of components, url, cookies, get parameters, etc.
|
||||
*
|
||||
* @since 1.0.0
|
||||
* @deprecated 3.8.0
|
||||
*/
|
||||
$key_components = apply_filters_deprecated( 'boost_cache_key_components', array( $parameters ), '3.8.0', 'jetpack_boost_cache_parameters' );
|
||||
|
||||
return md5(
|
||||
json_encode( // phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
|
||||
$key_components,
|
||||
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.
|
||||
)
|
||||
) . '.html';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a file is a rebuild file.
|
||||
*
|
||||
* @param string $file - The file to check.
|
||||
* @return bool - True if the file is a rebuild file, false otherwise.
|
||||
*/
|
||||
public static function is_rebuild_file( $file ) {
|
||||
return substr( $file, -strlen( self::REBUILD_FILE_EXTENSION ) ) === self::REBUILD_FILE_EXTENSION;
|
||||
}
|
||||
|
||||
/**
|
||||
* Creates the directory if it doesn't exist.
|
||||
*
|
||||
* @param string $path - The path to the directory to create.
|
||||
*/
|
||||
public static function create_directory( $path ) {
|
||||
if ( ! is_dir( $path ) ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.dir_mkdir_dirname, WordPress.WP.AlternativeFunctions.file_system_operations_mkdir, WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
$dir_created = @mkdir( $path, 0755, true );
|
||||
|
||||
if ( $dir_created ) {
|
||||
self::create_empty_index_files( $path );
|
||||
}
|
||||
|
||||
return $dir_created;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create an empty index.html file in the given directory.
|
||||
* This is done to prevent directory listing.
|
||||
*/
|
||||
private static function create_empty_index_files( $path ) {
|
||||
if ( self::is_boost_cache_directory( $path ) ) {
|
||||
self::write_to_file( $path . '/index.html', '' );
|
||||
|
||||
// Create an empty index.html file in the parent directory as well.
|
||||
self::create_empty_index_files( dirname( $path ) );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Rebuild a file. Make a copy of the file with a different extension instead of deleting it.
|
||||
*
|
||||
* @param string $file_path - The file to rebuild.
|
||||
* @return bool - True if the file was rebuilt, false otherwise.
|
||||
*/
|
||||
public static function rebuild_file( $file_path ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
|
||||
if ( is_writable( $file_path ) ) {
|
||||
// only rename the file if it is not already a rebuild file.
|
||||
if ( ! self::is_rebuild_file( $file_path ) ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@rename( $file_path, $file_path . self::REBUILD_FILE_EXTENSION );
|
||||
@touch( $file_path . self::REBUILD_FILE_EXTENSION ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_touch, WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Restore a file that was rebuilt so the cache file can be used for other visitors.
|
||||
*
|
||||
* @param string $file_path - The rebuilt file
|
||||
* @return bool - True if the file was restored, false otherwise.
|
||||
*/
|
||||
public static function restore_file( $file_path ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
|
||||
if ( is_writable( $file_path ) ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename, WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
return @rename( $file_path, str_replace( self::REBUILD_FILE_EXTENSION, '', $file_path ) );
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete a file.
|
||||
*
|
||||
* @param string $file_path - The file to delete.
|
||||
* @return bool - True if the file was deleted, false otherwise.
|
||||
*/
|
||||
public static function delete_file( $file_path ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_is_writable
|
||||
$deletable = is_writable( $file_path );
|
||||
|
||||
if ( $deletable ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
return @unlink( $file_path );
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete an empty cache directory.
|
||||
*
|
||||
* @param string $dir - The directory to delete.
|
||||
* @return int - 1 if the directory was deleted, 0 otherwise.
|
||||
*
|
||||
* This function will delete the index.html file and the directory itself.
|
||||
*/
|
||||
public static function delete_empty_dir( $dir ) {
|
||||
if ( self::is_dir_empty( $dir ) ) {
|
||||
@unlink( $dir . '/index.html' ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
@rmdir( $dir ); // phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_rmdir, WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
return 1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if a directory is empty.
|
||||
*
|
||||
* @param string $dir - The directory to check.
|
||||
*/
|
||||
public static function is_dir_empty( $dir ) {
|
||||
if ( ! is_readable( $dir ) ) {
|
||||
return new Boost_Cache_Error( 'directory_not_readable', 'Directory is not readable' );
|
||||
}
|
||||
|
||||
$files = array_diff( scandir( $dir ), array( '.', '..', 'index.html' ) );
|
||||
return empty( $files );
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes data to a file.
|
||||
* This creates a temporary file first, then renames the file to the final filename.
|
||||
* This is done to prevent the file from being read while it is being written to.
|
||||
*
|
||||
* @param string $filename - The filename to write to.
|
||||
* @param string $data - The data to write to the file.
|
||||
* @return bool|Boost_Cache_Error - true on sucess or Boost_Cache_Error on failure.
|
||||
*/
|
||||
public static function write_to_file( $filename, $data ) {
|
||||
$tmp_filename = $filename . uniqid( uniqid(), true ) . '.tmp';
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_system_operations_file_put_contents, WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
if ( false === @file_put_contents( $tmp_filename, $data ) ) {
|
||||
return new Boost_Cache_Error( 'could-not-write', 'Could not write to tmp file: ' . $tmp_filename );
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.rename_rename
|
||||
if ( ! rename( $tmp_filename, $filename ) ) {
|
||||
return new Boost_Cache_Error( 'could-not-rename', 'Could not rename tmp file to final file: ' . $filename );
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+195
@@ -0,0 +1,195 @@
|
||||
<?php
|
||||
/*
|
||||
* This file may be called before WordPress is fully initialized. See the README file for info.
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
|
||||
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Filter_Older;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Simple_Delete;
|
||||
|
||||
/**
|
||||
* A utility that manages logging for the boost cache.
|
||||
*/
|
||||
class Logger {
|
||||
/**
|
||||
* The singleton instance of the logger.
|
||||
*
|
||||
* @var self
|
||||
*/
|
||||
private static $instance = null;
|
||||
|
||||
/**
|
||||
* The header to place on top of every log file.
|
||||
*/
|
||||
const LOG_HEADER = "<?php die(); // This file is not intended to be accessed directly. ?>\n\n";
|
||||
|
||||
/**
|
||||
* The directory where log files are stored.
|
||||
*/
|
||||
const LOG_DIRECTORY = WP_CONTENT_DIR . '/boost-cache/logs';
|
||||
|
||||
/**
|
||||
* The Process Identifier used by this Logger instance.
|
||||
*
|
||||
* @var int|float
|
||||
*/
|
||||
private $pid = null;
|
||||
|
||||
/**
|
||||
* Get the singleton instance of the logger.
|
||||
*/
|
||||
public static function get_instance() {
|
||||
if ( self::$instance !== null ) {
|
||||
return self::$instance;
|
||||
}
|
||||
|
||||
$instance = new Logger();
|
||||
$prepared_log_file = $instance->prepare_file();
|
||||
if ( $prepared_log_file instanceof Boost_Cache_Error ) {
|
||||
return $prepared_log_file;
|
||||
}
|
||||
|
||||
self::$instance = $instance;
|
||||
return $instance;
|
||||
}
|
||||
|
||||
private function __construct() {
|
||||
if ( function_exists( 'getmypid' ) ) {
|
||||
$this->pid = getmypid();
|
||||
} else {
|
||||
// Where PID is not available, use the microtime of the first log of the session.
|
||||
$this->pid = microtime( true );
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Ensure that the log file exists, and if not, create it.
|
||||
*/
|
||||
private function prepare_file() {
|
||||
$log_file = $this->get_log_file();
|
||||
if ( file_exists( $log_file ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$directory = dirname( $log_file );
|
||||
if ( ! Filesystem_Utils::create_directory( $directory ) ) {
|
||||
return new Boost_Cache_Error( 'could-not-create-log-dir', 'Could not create boost cache log directory' );
|
||||
}
|
||||
|
||||
return Filesystem_Utils::write_to_file( $log_file, self::LOG_HEADER );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add a debug message to the log file after doing necessary checks.
|
||||
*/
|
||||
public static function debug( $message ) {
|
||||
$settings = Boost_Cache_Settings::get_instance();
|
||||
if ( ! $settings->get_logging() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logger = self::get_instance();
|
||||
|
||||
// TODO: Check to make sure that current request IP is allowed to create logs.
|
||||
|
||||
if ( $logger instanceof Boost_Cache_Error ) {
|
||||
return;
|
||||
}
|
||||
|
||||
$logger->log( $message );
|
||||
}
|
||||
|
||||
/**
|
||||
* Writes a message to the log file.
|
||||
*
|
||||
* @param string $message - The message to write to the log file.
|
||||
*/
|
||||
public function log( $message ) {
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
|
||||
$request_uri = htmlspecialchars( $_SERVER['REQUEST_URI'] ?? '<unknown request uri>', ENT_QUOTES, 'UTF-8' );
|
||||
|
||||
// don't log the ABSPATH constant. Logs may be copied to a public forum.
|
||||
$message = str_replace( ABSPATH, '[...]/', $message );
|
||||
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.json_encode_json_encode
|
||||
$line = json_encode(
|
||||
array(
|
||||
'time' => gmdate( 'Y-m-d H:i:s' ),
|
||||
'pid' => $this->pid,
|
||||
'uri' => $request_uri,
|
||||
'msg' => $message,
|
||||
'uid' => uniqid(), // Uniquely identify this log line.
|
||||
),
|
||||
JSON_UNESCAPED_SLASHES
|
||||
);
|
||||
|
||||
// phpcs:ignore WordPress.PHP.DevelopmentFunctions.error_log_error_log
|
||||
error_log( $line . PHP_EOL, 3, $this->get_log_file() );
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads the log file and returns the contents.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function read() {
|
||||
$instance = self::get_instance();
|
||||
|
||||
// If we failed to set up a Logger instance (e.g.: unwriteable directory), return the error as log content.
|
||||
if ( $instance instanceof Boost_Cache_Error ) {
|
||||
return $instance->get_error_message();
|
||||
}
|
||||
|
||||
$log_file = $instance->get_log_file();
|
||||
if ( ! file_exists( $log_file ) ) {
|
||||
return '';
|
||||
}
|
||||
|
||||
// Get the content after skipping the LOG_HEADER.
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents
|
||||
$logs = file_get_contents( $log_file, false, null, strlen( self::LOG_HEADER ) ) ?? '';
|
||||
$logs = explode( PHP_EOL, $logs );
|
||||
$lines = array();
|
||||
|
||||
foreach ( $logs as $log ) {
|
||||
$line = json_decode( $log, true );
|
||||
if ( json_last_error() !== JSON_ERROR_NONE || ! is_array( $line ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
// The current log format requires time, pid, uri, and msg.
|
||||
if ( ! isset( $line['time'] ) || ! isset( $line['pid'] ) || ! isset( $line['uri'] ) || ! isset( $line['msg'] ) ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$info = sprintf(
|
||||
'[%s] [%s] ',
|
||||
$line['time'],
|
||||
$line['pid']
|
||||
);
|
||||
|
||||
$formatted = $info . $line['uri'];
|
||||
// Add msg to the next line offset by the length of the info string.
|
||||
$formatted .= PHP_EOL . str_repeat( ' ', strlen( $info ) ) . $line['msg'];
|
||||
|
||||
$lines[] = $formatted;
|
||||
}
|
||||
|
||||
return implode( PHP_EOL, $lines );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the path to the log file.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function get_log_file() {
|
||||
$today = gmdate( 'Y-m-d' );
|
||||
return self::LOG_DIRECTORY . "/log-{$today}.log.php";
|
||||
}
|
||||
|
||||
public static function delete_old_logs() {
|
||||
Filesystem_Utils::iterate_directory( self::LOG_DIRECTORY, new Filter_Older( time() - 24 * 60 * 60, new Simple_Delete() ) );
|
||||
}
|
||||
}
|
||||
+345
@@ -0,0 +1,345 @@
|
||||
<?php
|
||||
/*
|
||||
* This file may be called before WordPress is fully initialized. See the README file for info.
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress;
|
||||
|
||||
class Request {
|
||||
/**
|
||||
* @var Request - The request instance for current request.
|
||||
*/
|
||||
private static $current_request = null;
|
||||
|
||||
/**
|
||||
* @var string - The normalized path for the current request. This is not sanitized. Only to be used for comparison purposes.
|
||||
*/
|
||||
private $request_uri = false;
|
||||
|
||||
/**
|
||||
* @var array - The GET parameters and cookies for the current request. Everything considered in the cache key.
|
||||
*/
|
||||
private $request_parameters;
|
||||
|
||||
/**
|
||||
* Gets the singleton request instance.
|
||||
*
|
||||
* @return Request The instance of the class.
|
||||
*/
|
||||
public static function current() {
|
||||
if ( self::$current_request === null ) {
|
||||
self::$current_request = new self(
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
|
||||
isset( $_SERVER['REQUEST_URI'] ) ? Boost_Cache_Utils::normalize_request_uri( $_SERVER['REQUEST_URI'] ) : false,
|
||||
// Set the cookies and get parameters for the current request. Sometimes these arrays are modified by WordPress or other plugins.
|
||||
// We need to cache them here so they can be used for the cache key later. We don't need to sanitize them, as they are only used for comparison.
|
||||
array(
|
||||
'cookies' => $_COOKIE,
|
||||
'get' => $_GET, // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return self::$current_request;
|
||||
}
|
||||
|
||||
public function __construct( $uri, $parameters ) {
|
||||
$this->request_uri = $uri;
|
||||
$this->request_parameters = $parameters;
|
||||
}
|
||||
|
||||
public function get_uri() {
|
||||
return $this->request_uri;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the parameters for the current request.
|
||||
*
|
||||
* @return array The parameters for the current request, made up of cookies and get parameters.
|
||||
*/
|
||||
public function get_parameters() {
|
||||
/**
|
||||
* Filters the parameters for the current request to identify the cache key.
|
||||
*
|
||||
* @since 3.8.0
|
||||
*
|
||||
* @param array $parameters The parameters for the current request, made up of cookies and get parameters.
|
||||
*/
|
||||
return apply_filters( 'jetpack_boost_cache_parameters', $this->request_parameters );
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the current request has a fatal error.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function is_fatal_error() {
|
||||
$error = error_get_last();
|
||||
if ( $error === null ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$fatal_errors = array(
|
||||
E_ERROR,
|
||||
E_PARSE,
|
||||
E_CORE_ERROR,
|
||||
E_COMPILE_ERROR,
|
||||
E_USER_ERROR,
|
||||
);
|
||||
|
||||
return in_array( $error['type'], $fatal_errors, true );
|
||||
}
|
||||
|
||||
public function is_url_excluded( $request_uri = '' ) {
|
||||
if ( $request_uri === '' ) {
|
||||
$request_uri = $this->request_uri;
|
||||
}
|
||||
|
||||
// Check if the query parameters `jb-disable-modules` or `jb-generate-critical-css` exist.
|
||||
$request_parameters = $this->get_parameters();
|
||||
$query_params = $request_parameters['get'] ?? array();
|
||||
if ( isset( $query_params['jb-disable-modules'] ) || isset( $query_params['jb-generate-critical-css'] ) ) {
|
||||
return true;
|
||||
}
|
||||
|
||||
$bypass_patterns = Boost_Cache_Settings::get_instance()->get_bypass_patterns();
|
||||
|
||||
/**
|
||||
* Filters the bypass patterns for the page cache.
|
||||
* If you need to sanitize them, do it before passing them to this filter,
|
||||
* as there's no sanitization done after this filter.
|
||||
*
|
||||
* @since 3.2.0
|
||||
*
|
||||
* @param array $bypass_patterns An array of regex patterns that define URLs that bypass caching.
|
||||
*/
|
||||
$bypass_patterns = apply_filters( 'jetpack_boost_cache_bypass_patterns', $bypass_patterns );
|
||||
|
||||
$bypass_patterns[] = 'wp-.*\.php';
|
||||
foreach ( $bypass_patterns as $expr ) {
|
||||
if ( ! empty( $expr ) && preg_match( "~^$expr/?$~", $request_uri ) ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the request is cacheable.
|
||||
*
|
||||
* If a request is in the backend, or is a POST request, or is not an
|
||||
* html request, it is not cacheable.
|
||||
* The filter boost_cache_cacheable can be used to override this.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_cacheable() {
|
||||
/**
|
||||
* Determines if the request is considered cacheable.
|
||||
*
|
||||
* Can be used to prevent a request from being cached.
|
||||
*
|
||||
* @since 3.2.0
|
||||
*
|
||||
* @param bool $default_status The default cacheability status (true for cacheable).
|
||||
* @param string $request_uri The request URI to be evaluated for cacheability.
|
||||
*/
|
||||
if ( ! apply_filters( 'jetpack_boost_cache_request_cacheable', true, $this->request_uri ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( defined( 'DONOTCACHEPAGE' ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// do not cache post previews or customizer previews
|
||||
if ( ! empty( $_GET ) && ( isset( $_GET['preview'] ) || isset( $_GET['customize_changeset_uuid'] ) ) ) { // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.NonceVerification.Recommended
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( $this->is_fatal_error() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( function_exists( 'is_user_logged_in' ) && is_user_logged_in() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( $this->is_404() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( $this->is_feed() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( $this->is_backend() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( $this->is_bypassed_extension() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( isset( $_SERVER['REQUEST_METHOD'] ) && $_SERVER['REQUEST_METHOD'] !== 'GET' ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( $this->is_url_excluded() ) {
|
||||
Logger::debug( 'Url excluded, not cached!' );
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( $this->is_module_disabled() ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Filters the accept headers to determine if the request should be cached.
|
||||
*
|
||||
* This filter allows modification of the content types that browsers send
|
||||
* to the server during a request. If the acceptable browser content type header (HTTP_ACCEPT)
|
||||
* matches one of these content types the request will not be cached,
|
||||
* or a cached file served to this visitor.
|
||||
*
|
||||
* @since 3.2.0
|
||||
*
|
||||
* @param array $accept_headers An array of header values that should prevent a request from being cached.
|
||||
*/
|
||||
$accept_headers = apply_filters( 'jetpack_boost_cache_accept_headers', array( 'application/json', 'application/activity+json', 'application/ld+json' ) );
|
||||
$accept_headers = array_map( 'strtolower', $accept_headers );
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.MissingUnslash -- $accept is checked and set below.
|
||||
$accept = isset( $_SERVER['HTTP_ACCEPT'] ) ? strtolower( filter_var( $_SERVER['HTTP_ACCEPT'] ) ) : '';
|
||||
|
||||
if ( $accept !== '' ) {
|
||||
foreach ( $accept_headers as $header ) {
|
||||
if ( str_contains( $accept, $header ) ) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the request appears to be for something with a known file extension that is not
|
||||
* usually HTML. e.g.:
|
||||
* - *.txt (including robots.txt, license.txt)
|
||||
* - *.ico (favicon.ico)
|
||||
* - *.jpg, *.png, *.webm (image files).
|
||||
*/
|
||||
public function is_bypassed_extension() {
|
||||
$file_extension = pathinfo( $this->request_uri, PATHINFO_EXTENSION );
|
||||
|
||||
return in_array(
|
||||
$file_extension,
|
||||
array(
|
||||
'txt',
|
||||
'ico',
|
||||
'jpg',
|
||||
'jpeg',
|
||||
'png',
|
||||
'webp',
|
||||
'gif',
|
||||
),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the current request is one of the following:
|
||||
* 1. wp-admin
|
||||
* 2. wp-login.php, xmlrpc.php or wp-cron.php/cron request
|
||||
* 3. WP_CLI
|
||||
* 4. REST request.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_backend() {
|
||||
|
||||
$is_backend = is_admin();
|
||||
if ( $is_backend ) {
|
||||
return $is_backend;
|
||||
}
|
||||
|
||||
$script = isset( $_SERVER['PHP_SELF'] ) ? basename( $_SERVER['PHP_SELF'] ) : ''; // phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized, WordPress.Security.ValidatedSanitizedInput.MissingUnslash
|
||||
if ( $script !== 'index.php' ) {
|
||||
if ( in_array( $script, array( 'wp-login.php', 'xmlrpc.php', 'wp-cron.php' ), true ) ) {
|
||||
$is_backend = true;
|
||||
}
|
||||
}
|
||||
|
||||
if ( defined( 'DOING_CRON' ) && DOING_CRON ) {
|
||||
$is_backend = true;
|
||||
}
|
||||
|
||||
if ( PHP_SAPI === 'cli' || ( defined( 'WP_CLI' ) && constant( 'WP_CLI' ) ) ) {
|
||||
$is_backend = true;
|
||||
}
|
||||
|
||||
if ( defined( 'REST_REQUEST' ) ) {
|
||||
$is_backend = true;
|
||||
}
|
||||
|
||||
return $is_backend;
|
||||
}
|
||||
|
||||
/**
|
||||
* "Safe" version of WordPress' is_404 method. When called before WordPress' query is run, returns
|
||||
* `null` (a falsey value) instead of outputting a _doing_it_wrong warning.
|
||||
*/
|
||||
public function is_404() {
|
||||
global $wp_query;
|
||||
|
||||
if ( ! isset( $wp_query ) || ! function_exists( '\is_404' ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return \is_404();
|
||||
}
|
||||
|
||||
/**
|
||||
* "Safe" version of WordPress' is_feed method. When called before WordPress' query is run, returns
|
||||
* `null` (a falsey value) instead of outputting a _doing_it_wrong warning.
|
||||
*/
|
||||
public function is_feed() {
|
||||
global $wp_query;
|
||||
|
||||
if ( ! isset( $wp_query ) || ! function_exists( '\is_feed' ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return \is_feed();
|
||||
}
|
||||
|
||||
/**
|
||||
* Return true if the Page Cache module is disabled, or null if we don't know yet.
|
||||
*
|
||||
* If Status and Page_Cache are not available, it means the plugin is not loaded.
|
||||
* This function will be called later when writing a cache file to disk.
|
||||
* It's then that we can check if the module is active.
|
||||
*
|
||||
* @return null|bool
|
||||
*/
|
||||
public function is_module_disabled() {
|
||||
|
||||
// A simple check to make sure we're in the output buffer callback.
|
||||
if ( ! function_exists( '\is_feed' ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (
|
||||
class_exists( '\Automattic\Jetpack_Boost\Lib\Status' ) &&
|
||||
class_exists( '\Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Page_Cache' )
|
||||
) {
|
||||
$page_cache_status = new \Automattic\Jetpack_Boost\Lib\Status(
|
||||
\Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Page_Cache::get_slug()
|
||||
);
|
||||
return ! $page_cache_status->get();
|
||||
} else {
|
||||
return true; // if the classes aren't available, the plugin isn't loaded.
|
||||
}
|
||||
}
|
||||
}
|
||||
+38
@@ -0,0 +1,38 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions;
|
||||
|
||||
use SplFileInfo;
|
||||
|
||||
/**
|
||||
* Apply a given sub-action to all files in the path that are older than a given timestamp.
|
||||
*/
|
||||
class Filter_Older implements Path_Action {
|
||||
private $timestamp;
|
||||
private $sub_action;
|
||||
|
||||
public function __construct( $timestamp, Path_Action $action ) {
|
||||
$this->timestamp = $timestamp;
|
||||
$this->sub_action = $action;
|
||||
}
|
||||
|
||||
public function apply_to_path( SplFileInfo $file ) {
|
||||
$file_path = $file->getPathname();
|
||||
$filemtime = @filemtime( $file_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
|
||||
if ( ! $filemtime ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
/*
|
||||
* if the file is a directory, then we process it, regardless of age.
|
||||
* Any modification of items in the directory will update the filemtime of the directory.
|
||||
* That's why we always process directories.
|
||||
*/
|
||||
if ( $file->isDir() || $filemtime <= $this->timestamp ) {
|
||||
return $this->sub_action->apply_to_path( $file );
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
+31
@@ -0,0 +1,31 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions;
|
||||
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Filesystem_Utils;
|
||||
use SplFileInfo;
|
||||
|
||||
/**
|
||||
* Rebuild a file.
|
||||
*/
|
||||
class Rebuild_File implements Path_Action {
|
||||
public function apply_to_path( SplFileInfo $file ) {
|
||||
if ( $file->isDir() && Filesystem_Utils::is_dir_empty( $file->getPathname() ) ) {
|
||||
Filesystem_Utils::delete_empty_dir( $file->getPathname() );
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( $file->isDir() || $file->getFilename() === 'index.html' ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// If it's already a rebuild file, delete it because it expired long ago.
|
||||
if ( Filesystem_Utils::is_rebuild_file( $file->getFilename() ) ) {
|
||||
$action = new Simple_Delete();
|
||||
return $action->apply_to_path( $file );
|
||||
}
|
||||
|
||||
$rebuilt = Filesystem_Utils::rebuild_file( $file->getPathname() );
|
||||
return $rebuilt ? 1 : false;
|
||||
}
|
||||
}
|
||||
+52
@@ -0,0 +1,52 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions;
|
||||
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Filesystem_Utils;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
|
||||
use SplFileInfo;
|
||||
|
||||
/**
|
||||
* Delete a file or directory, non-recursively.
|
||||
*/
|
||||
class Simple_Delete implements Path_Action {
|
||||
/**
|
||||
* Delete a file or directory.
|
||||
*
|
||||
* @param SplFileInfo $file The file or directory to delete.
|
||||
* @return int The number of files or directories deleted.
|
||||
*/
|
||||
public function apply_to_path( SplFileInfo $file ) {
|
||||
if ( $file->isDir() && Filesystem_Utils::is_dir_empty( $file->getPathname() ) ) {
|
||||
Logger::debug( 'rmdir: ' . $file->getPathname() );
|
||||
return $this->delete_dir( $file );
|
||||
} elseif ( $file->isFile() ) {
|
||||
// Do not delete index.html files independently. We will only delete them when the directory is empty.
|
||||
if ( $file->getFilename() === 'index.html' ) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Delete a file in the directory
|
||||
Logger::debug( 'unlink: ' . $file->getPathname() );
|
||||
$this->delete_file( $file );
|
||||
return 1;
|
||||
}
|
||||
|
||||
return 0;
|
||||
}
|
||||
|
||||
private function delete_dir( SplFileInfo $file ) {
|
||||
$count = 0;
|
||||
if ( Filesystem_Utils::is_dir_empty( $file->getPathname() ) ) {
|
||||
// An empty directory will still have an index.html file, which we will delete with the directory.
|
||||
$count += Filesystem_Utils::delete_empty_dir( $file->getPathname() );
|
||||
}
|
||||
|
||||
return $count;
|
||||
}
|
||||
|
||||
private function delete_file( SplFileInfo $file ) {
|
||||
@unlink( $file->getPathname() ); // phpcs:ignore WordPress.WP.AlternativeFunctions.unlink_unlink, WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
return true;
|
||||
}
|
||||
}
|
||||
+15
@@ -0,0 +1,15 @@
|
||||
<?php
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions;
|
||||
|
||||
use SplFileInfo;
|
||||
|
||||
interface Path_Action {
|
||||
/**
|
||||
* Apply the action to the path.
|
||||
*
|
||||
* @param SplFileInfo $path The path to apply the action to.
|
||||
* @return false|int False if nothing was done, or the number of files deleted.
|
||||
*/
|
||||
public function apply_to_path( SplFileInfo $path );
|
||||
}
|
||||
+211
@@ -0,0 +1,211 @@
|
||||
<?php
|
||||
/*
|
||||
* This file may be called before WordPress is fully initialized. See the README file for info.
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Storage;
|
||||
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Error;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Boost_Cache_Utils;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Filesystem_Utils;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Logger;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Filter_Older;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Rebuild_File;
|
||||
use Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Path_Actions\Simple_Delete;
|
||||
use SplFileInfo;
|
||||
|
||||
/**
|
||||
* File Storage - handles writing to disk, reading from disk, purging and pruning old content.
|
||||
*/
|
||||
class File_Storage implements Storage {
|
||||
|
||||
/**
|
||||
* @var string - The root path where all cached files go.
|
||||
*/
|
||||
private $root_path;
|
||||
|
||||
public function __construct( $root_path ) {
|
||||
$this->root_path = WP_CONTENT_DIR . '/boost-cache/cache/' . Boost_Cache_Utils::sanitize_file_path( Boost_Cache_Utils::trailingslashit( $root_path ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a request_uri and its parameters, store the given data in the cache.
|
||||
*
|
||||
* @param string $request_uri - The URI of this request (excluding GET parameters)
|
||||
* @param array $parameters - An associative array of all the things that make this request special/different. Includes GET parameters and COOKIEs normally.
|
||||
* @param string $data - The data to write to disk.
|
||||
*/
|
||||
public function write( $request_uri, $parameters, $data ) {
|
||||
$directory = self::get_uri_directory( $request_uri );
|
||||
$filename = Filesystem_Utils::get_request_filename( $parameters );
|
||||
|
||||
if ( ! Filesystem_Utils::create_directory( $directory ) ) {
|
||||
return new Boost_Cache_Error( 'cannot-create-cache-dir', 'Could not create cache directory' );
|
||||
}
|
||||
|
||||
return Filesystem_Utils::write_to_file( $directory . $filename, $data );
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a request_uri and its parameters, reset the filename of a rebuild
|
||||
* cache file and return true, or false otherwise.
|
||||
* If a rebuild file is too old, it will be deleted and false will be returned.
|
||||
*
|
||||
* @param string $request_uri - The URI of this request (excluding GET parameters)
|
||||
* @param array $parameters - An associative array of all the things that make this request special/different. Includes GET parameters and COOKIEs normally.
|
||||
*/
|
||||
public function reset_rebuild_file( $request_uri, $parameters ) {
|
||||
$directory = self::get_uri_directory( $request_uri );
|
||||
$filename = Filesystem_Utils::get_request_filename( $parameters ) . Filesystem_Utils::REBUILD_FILE_EXTENSION;
|
||||
$hash_path = $directory . $filename;
|
||||
|
||||
if ( file_exists( $hash_path ) ) {
|
||||
$expired = ( @filemtime( $hash_path ) + JETPACK_BOOST_CACHE_REBUILD_DURATION ) <= time(); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
|
||||
if ( $expired ) {
|
||||
if ( Filesystem_Utils::delete_file( $hash_path ) ) {
|
||||
Logger::debug( "Deleted expired rebuilt file: $hash_path" );
|
||||
} else {
|
||||
Logger::debug( "Could not delete expired rebuilt file: $hash_path" );
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
if ( Filesystem_Utils::restore_file( $hash_path ) ) {
|
||||
Logger::debug( "Restored rebuilt file: $hash_path" );
|
||||
return true;
|
||||
} else {
|
||||
Logger::debug( "Could not restore rebuilt file: $hash_path" );
|
||||
return false;
|
||||
}
|
||||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a request_uri and its parameters, return any stored data from the cache, or false otherwise.
|
||||
*
|
||||
* @param string $request_uri - The URI of this request (excluding GET parameters)
|
||||
* @param array $parameters - An associative array of all the things that make this request special/different. Includes GET parameters and COOKIEs normally.
|
||||
*/
|
||||
public function read( $request_uri, $parameters ) {
|
||||
$directory = self::get_uri_directory( $request_uri );
|
||||
$filename = Filesystem_Utils::get_request_filename( $parameters );
|
||||
$hash_path = $directory . $filename;
|
||||
|
||||
if ( file_exists( $hash_path ) ) {
|
||||
$filemtime = @filemtime( $hash_path ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged
|
||||
$expired = ( $filemtime + JETPACK_BOOST_CACHE_DURATION ) <= time();
|
||||
|
||||
// If file exists and is not expired, return the file contents.
|
||||
if ( ! $expired ) {
|
||||
// phpcs:ignore WordPress.WP.AlternativeFunctions.file_get_contents_file_get_contents, WordPress.Security.EscapeOutput.OutputNotEscaped
|
||||
return file_get_contents( $hash_path );
|
||||
}
|
||||
|
||||
// If file exists but is expired, delete it.
|
||||
if ( Filesystem_Utils::delete_file( $hash_path ) ) {
|
||||
Logger::debug( "Deleted expired file: $hash_path" );
|
||||
} else {
|
||||
Logger::debug( "Could not delete expired file: $hash_path" );
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Garbage collect expired files.
|
||||
*
|
||||
* @param int $cache_ttl If file is is created before this specified number of seconds, it will be deleted.
|
||||
*/
|
||||
public function garbage_collect( $cache_ttl = JETPACK_BOOST_CACHE_DURATION ) {
|
||||
if ( $cache_ttl === -1 ) {
|
||||
// Garbage collection is disabled.
|
||||
return false;
|
||||
}
|
||||
|
||||
$created_before = time() - $cache_ttl;
|
||||
|
||||
$count = Filesystem_Utils::iterate_directory( $this->root_path, new Filter_Older( $created_before, new Rebuild_File() ) );
|
||||
if ( $count instanceof Boost_Cache_Error ) {
|
||||
Logger::debug( 'Garbage collection failed: ' . $count->get_error_message() );
|
||||
return false;
|
||||
}
|
||||
|
||||
Logger::debug( "Garbage collected $count files" );
|
||||
return $count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Given a request_uri, return the filesystem path where it should get stored. Handles sanitization.
|
||||
* Note that the directory path does not take things like GET parameters or cookies into account, for easy cache purging.
|
||||
*
|
||||
* @param string $request_uri - The URI of this request (excluding GET parameters)
|
||||
*/
|
||||
private function get_uri_directory( $request_uri ) {
|
||||
return Boost_Cache_Utils::trailingslashit( $this->root_path . self::sanitize_path( $request_uri ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Sanitize a path for safe usage on the local filesystem.
|
||||
*
|
||||
* @param string $path - The path to sanitize.
|
||||
*/
|
||||
private function sanitize_path( $path ) {
|
||||
static $_cache = array();
|
||||
if ( isset( $_cache[ $path ] ) ) {
|
||||
return $_cache[ $path ];
|
||||
}
|
||||
|
||||
$path = Boost_Cache_Utils::sanitize_file_path( $path );
|
||||
|
||||
$_cache[ $path ] = $path;
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Delete cache based on given parameters.
|
||||
*
|
||||
* @param string $path - The path to delete the cache for.
|
||||
* @param array $args - The parameters defining the cache filename.
|
||||
* Example:
|
||||
* array(
|
||||
* 'rebuild' => (boolean) default true - If true, cache files will be rebuilt instead of being deleted.
|
||||
* 'parameters' => false | array, default false - If array is provided, the files created for that specific request matching the parameter will be effected. If parameter is provided, recursive is ignored and considered as false.
|
||||
* 'recursive' => (boolean) default false - If true, the cache will be deleted recursively in subdirectories.
|
||||
* )
|
||||
*/
|
||||
public function clear( $path, $args = array() ) {
|
||||
$normalized_path = Boost_Cache_Utils::normalize_request_uri( $this->sanitize_path( $path ) );
|
||||
$normalized_path = Boost_Cache_Utils::trailingslashit( $this->root_path . $normalized_path );
|
||||
|
||||
// Ensure the path is within the cache directory
|
||||
if ( strpos( $normalized_path, $this->root_path ) !== 0 ) {
|
||||
Logger::debug( 'Attempted to delete cache for path outside of cache directory: ' . $path );
|
||||
return;
|
||||
}
|
||||
|
||||
$recursive = $args['recursive'] ?? false;
|
||||
$rebuild = $args['rebuild'] ?? true;
|
||||
$parameters = $args['parameters'] ?? false;
|
||||
|
||||
if ( $rebuild ) {
|
||||
$action = new Rebuild_File();
|
||||
} else {
|
||||
$action = new Simple_Delete();
|
||||
}
|
||||
|
||||
// If parameters are provided, delete the specific file and skip any iteration.
|
||||
if ( $parameters ) {
|
||||
$action->apply_to_path( new SplFileInfo( $normalized_path . Filesystem_Utils::get_request_filename( $parameters ) ) );
|
||||
return;
|
||||
}
|
||||
|
||||
if ( $recursive ) {
|
||||
Filesystem_Utils::iterate_directory( $normalized_path, $action );
|
||||
} else {
|
||||
Filesystem_Utils::iterate_files( $normalized_path, $action );
|
||||
}
|
||||
}
|
||||
}
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
<?php
|
||||
/*
|
||||
* This file may be called before WordPress is fully initialized. See the README file for info.
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Page_Cache\Pre_WordPress\Storage;
|
||||
|
||||
/**
|
||||
* Interface for Cache storage - a system for storing and purging caches.
|
||||
*/
|
||||
interface Storage {
|
||||
|
||||
public function write( $request_uri, $parameters, $data );
|
||||
public function read( $request_uri, $parameters );
|
||||
public function reset_rebuild_file( $request_uri, $parameters );
|
||||
|
||||
public function clear( $path, $args = array() );
|
||||
public function garbage_collect( $cache_ttl = JETPACK_BOOST_CACHE_DURATION );
|
||||
}
|
||||
+722
@@ -0,0 +1,722 @@
|
||||
<?php
|
||||
/**
|
||||
* Implements the system to avoid render blocking JS execution.
|
||||
*
|
||||
* @link https://automattic.com
|
||||
* @since 0.2
|
||||
* @package automattic/jetpack-boost
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Render_Blocking_JS;
|
||||
|
||||
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\Changes_Output_On_Activation;
|
||||
use Automattic\Jetpack_Boost\Contracts\Feature;
|
||||
use Automattic\Jetpack_Boost\Contracts\Has_Data_Sync;
|
||||
use Automattic\Jetpack_Boost\Contracts\Optimization;
|
||||
use Automattic\Jetpack_Boost\Data_Sync\Minify_Excludes_State_Entry;
|
||||
use Automattic\Jetpack_Boost\Lib\Output_Filter;
|
||||
|
||||
/**
|
||||
* Class Render_Blocking_JS
|
||||
*/
|
||||
class Render_Blocking_JS implements Feature, Changes_Output_On_Activation, Changes_Output_After_Activation, Optimization, Has_Data_Sync {
|
||||
/**
|
||||
* Substring that marks an inline script as producing position-dependent
|
||||
* output (document.write()/document.writeln()). Such scripts must stay where
|
||||
* they are rather than being moved to the end of the document. The fast-path
|
||||
* guard and the per-script check must use the same needle to stay in lockstep.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private const POSITION_DEPENDENT_OUTPUT_NEEDLE = 'document.write';
|
||||
|
||||
/**
|
||||
* Holds the script tags removed from the output buffer.
|
||||
*
|
||||
* @var array
|
||||
*/
|
||||
protected $buffered_script_tags = array();
|
||||
|
||||
/**
|
||||
* HTML attribute name to be added to <script> tag to make it
|
||||
* ignored by this class.
|
||||
*
|
||||
* @var string|null
|
||||
*/
|
||||
private $ignore_attribute;
|
||||
|
||||
/**
|
||||
* HTML attribute value to be added to <script> tag to make it
|
||||
* ignored by this class.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $ignore_value = 'ignore';
|
||||
|
||||
/**
|
||||
* Utility class that supports output filtering.
|
||||
*
|
||||
* @var Output_Filter
|
||||
*/
|
||||
private $output_filter = null;
|
||||
|
||||
/**
|
||||
* Flag indicating an opened <script> tag in output.
|
||||
*
|
||||
* @var string
|
||||
*/
|
||||
private $is_opened_script = false;
|
||||
|
||||
public function setup() {
|
||||
$this->output_filter = new Output_Filter();
|
||||
|
||||
/**
|
||||
* Filters the ignore attribute
|
||||
*
|
||||
* @param $string $ignore_attribute The string used to ignore elements of the page.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
$this->ignore_attribute = apply_filters( 'jetpack_boost_render_blocking_js_ignore_attribute', 'data-jetpack-boost' );
|
||||
|
||||
add_action( 'template_redirect', array( $this, 'start_output_filtering' ), -999999 );
|
||||
|
||||
/**
|
||||
* Shortcodes can sometimes output script to embed widget. It's safer to ignore them.
|
||||
*/
|
||||
add_filter( 'do_shortcode_tag', array( $this, 'add_ignore_attribute' ) );
|
||||
}
|
||||
|
||||
public static function is_available() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Register the data sync entry holding the list of URL patterns
|
||||
* excluded from JS deferring.
|
||||
*
|
||||
* @param Data_Sync $instance The data sync instance.
|
||||
*/
|
||||
public function register_data_sync( Data_Sync $instance ) {
|
||||
$instance->register(
|
||||
'render_blocking_js_excludes',
|
||||
Schema::as_array( Schema::as_string() )->fallback( array() ),
|
||||
new Minify_Excludes_State_Entry( 'render_blocking_js_excludes' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Cached pages need to be invalidated when the exclusion list changes.
|
||||
*
|
||||
* @return string[] Action names fired when the exclusion list is updated.
|
||||
*/
|
||||
public static function get_change_output_action_names() {
|
||||
$option = JETPACK_BOOST_DATASYNC_NAMESPACE . '_render_blocking_js_excludes';
|
||||
|
||||
// `add_option_*` covers the very first save, when the option is created
|
||||
// rather than updated, so the cache is invalidated on that write too.
|
||||
return array(
|
||||
'add_option_' . $option,
|
||||
'update_option_' . $option,
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Set up an output filtering callback.
|
||||
*
|
||||
* @return void
|
||||
*/
|
||||
public function start_output_filtering() {
|
||||
/**
|
||||
* We're doing heavy output filtering in this module
|
||||
* by using output buffering.
|
||||
*
|
||||
* Here are a few scenarios when we shouldn't do it:
|
||||
*/
|
||||
|
||||
/**
|
||||
* Filter to disable defer blocking JS
|
||||
*
|
||||
* @param bool $defer return false to disable defer blocking
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
if ( false === apply_filters( 'jetpack_boost_should_defer_js', '__return_true' ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
// Disable in all XLS (see the WP_Sitemaps_Renderer class which is responsible for rendering Sitemaps data to XML
|
||||
// in accordance with sitemap protocol).
|
||||
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;
|
||||
}
|
||||
|
||||
// Disable in all POST Requests.
|
||||
// phpcs:disable WordPress.Security.NonceVerification.Missing
|
||||
if ( ! empty( $_POST ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable in customizer previews
|
||||
if ( is_customize_preview() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable in feeds, AJAX, Cron, XML.
|
||||
if ( is_feed() || wp_doing_ajax() || wp_doing_cron() || wp_is_xml_request() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable in sitemaps.
|
||||
if ( ! empty( get_query_var( 'sitemap' ) ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable in AMP pages.
|
||||
if ( function_exists( 'amp_is_request' ) && amp_is_request() ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Disable on URLs excluded by the user.
|
||||
if ( $this->is_current_request_excluded() ) {
|
||||
// Leave the page output completely untouched, as if the module was off.
|
||||
remove_filter( 'do_shortcode_tag', array( $this, 'add_ignore_attribute' ) );
|
||||
return;
|
||||
}
|
||||
|
||||
// Print the filtered script tags to the very end of the page.
|
||||
add_filter( 'jetpack_boost_output_filtering_last_buffer', array( $this, 'append_script_tags' ), 10, 1 );
|
||||
|
||||
// Handle exclusions.
|
||||
add_filter( 'script_loader_tag', array( $this, 'handle_exclusions' ), 10, 2 );
|
||||
|
||||
$this->output_filter->add_callback( array( $this, 'handle_output_stream' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Remove all inline and external <script> tags from the default output.
|
||||
*
|
||||
* @param string $buffer_start First part of the buffer.
|
||||
* @param string $buffer_end Second part of the buffer.
|
||||
*
|
||||
* For explanation on why there are two parts of a buffer here, see
|
||||
* the comments and examples in the Output_Filter class.
|
||||
*
|
||||
* @return array Parts of the buffer.
|
||||
*/
|
||||
public function handle_output_stream( $buffer_start, $buffer_end ) {
|
||||
$joint_buffer = $this->ignore_exclusion_scripts( $buffer_start . $buffer_end );
|
||||
$script_tags = $this->get_script_tags( $joint_buffer );
|
||||
|
||||
if ( ! $script_tags ) {
|
||||
if ( $this->is_opened_script ) {
|
||||
// We have an opened script tag, move everything to the second buffer to avoid printing it to the page.
|
||||
// We will do this until the </script> closing tag is encountered.
|
||||
return array( '', $joint_buffer );
|
||||
}
|
||||
|
||||
// No script tags detected, return both chunks unaltered.
|
||||
return array( $buffer_start, $buffer_end );
|
||||
}
|
||||
|
||||
// Makes sure all whole <script>...</script> tags are in $buffer_start.
|
||||
list( $buffer_start, $buffer_end ) = $this->recalculate_buffer_split( $joint_buffer, $script_tags );
|
||||
|
||||
foreach ( $script_tags as $script_tag ) {
|
||||
$this->buffered_script_tags[] = $script_tag[0];
|
||||
$buffer_start = str_replace( $script_tag[0], '', $buffer_start );
|
||||
}
|
||||
|
||||
// Detect a lingering opened script.
|
||||
$this->is_opened_script = $this->is_opened_script( $buffer_start . $buffer_end );
|
||||
|
||||
return array( $buffer_start, $buffer_end );
|
||||
}
|
||||
|
||||
/**
|
||||
* Matches <script> tags with their content in a string buffer.
|
||||
*
|
||||
* @param string $buffer Captured piece of output buffer.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function get_script_tags( $buffer ) {
|
||||
$regex = '~<script' . $this->ignore_attribute_lookahead() . '([^>]*)>[\s\S]*?<\/script>~si';
|
||||
preg_match_all( $regex, $buffer, $script_tags, PREG_OFFSET_CAPTURE );
|
||||
|
||||
// No script_tags in the joint buffer.
|
||||
if ( empty( $script_tags[0] ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
/**
|
||||
* Filter to remove any scripts that should not be moved to the end of the document.
|
||||
*
|
||||
* @param array $script_tags array of script tags. Remove any scripts that should not be moved to the end of the documents.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
return apply_filters( 'jetpack_boost_render_blocking_js_exclude_scripts', $script_tags[0] );
|
||||
}
|
||||
|
||||
/**
|
||||
* Adds the ignore attribute to scripts in the exclusion list.
|
||||
*
|
||||
* @param string $buffer Captured piece of output buffer.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
protected function ignore_exclusion_scripts( $buffer ) {
|
||||
$exclusions = array(
|
||||
// Scripts inside HTML comments.
|
||||
'~<!--.*?-->~si',
|
||||
|
||||
// Scripts with types that do not execute complex code. Moving them down can be dangerous
|
||||
// and does not benefit performance. Includes types: application/json, application/ld+json and importmap.
|
||||
'~<script\s+[^\>]*type=(?<q>["\']*)(application\/(ld\+)?json|importmap)\k<q>.*?>.*?<\/script>~si',
|
||||
);
|
||||
|
||||
$excluded = preg_replace_callback(
|
||||
$exclusions,
|
||||
function ( $script_match ) {
|
||||
return $this->add_ignore_attribute( $script_match[0] );
|
||||
},
|
||||
$buffer
|
||||
);
|
||||
// preg_replace_callback() returns null on PCRE failure; keep the original
|
||||
// buffer in that case rather than propagating null downstream.
|
||||
if ( null !== $excluded ) {
|
||||
$buffer = $excluded;
|
||||
}
|
||||
|
||||
return $this->pin_position_dependent_scripts( $buffer );
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep inline scripts whose output is position-dependent in their original place.
|
||||
*
|
||||
* Scripts using document.write()/document.writeln() insert markup at the script's
|
||||
* location, so moving such a script to the end of the document renders its output
|
||||
* after the footer instead of inside the content (e.g. a Custom HTML block).
|
||||
* Marking the script with the ignore attribute keeps the rest of the pipeline
|
||||
* from moving it. Scripts that already carry the ignore attribute are skipped so
|
||||
* their behavior and markup are unchanged.
|
||||
*
|
||||
* Best-effort and deliberately conservative: it pins the common case (an inline
|
||||
* script that calls document.write) and otherwise leaves the script to the
|
||||
* default move behavior. It does not pin scripts that write their own
|
||||
* '<script ...>' markup (no safe in-place edit exists), nor exotic call forms a
|
||||
* substring check cannot see. A miss never corrupts the page — worst case is a
|
||||
* script that still moves, exactly as it does without this method.
|
||||
*
|
||||
* @param string $buffer Captured piece of output buffer.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private function pin_position_dependent_scripts( $buffer ) {
|
||||
// Fast path: skip the inline-script scan entirely when the buffer cannot
|
||||
// contain a position-dependent script.
|
||||
if ( false === stripos( $buffer, self::POSITION_DEPENDENT_OUTPUT_NEEDLE ) ) {
|
||||
return $buffer;
|
||||
}
|
||||
|
||||
// Match inline scripts only (no src attribute) that do not already carry
|
||||
// the ignore attribute. This runs on the buffer Output_Filter hands us
|
||||
// (a bounded window), not a whole page; the lazy [\s\S]*? would be
|
||||
// superlinear on a multi-megabyte single buffer, so keep it window-scoped.
|
||||
$inline_script_regex = '~<script\b(?![^>]*\ssrc\s*=)' . $this->ignore_attribute_lookahead() . '[^>]*>[\s\S]*?</script>~i';
|
||||
|
||||
$result = preg_replace_callback(
|
||||
$inline_script_regex,
|
||||
function ( $script_match ) {
|
||||
// Intentionally conservative: a simple case-insensitive substring check
|
||||
// for "document.write" (which also covers "document.writeln"). It does
|
||||
// not parse JS, so exotic call forms it cannot see — document['write'](),
|
||||
// "document . write()", or an uppercase <SCRIPT> tag that
|
||||
// add_ignore_attribute()'s lowercase replace won't touch — simply fall
|
||||
// back to the default behavior (the script is moved, as it is today).
|
||||
// That is the safe direction: a miss never corrupts the page.
|
||||
if ( false === stripos( $script_match[0], self::POSITION_DEPENDENT_OUTPUT_NEEDLE ) ) {
|
||||
return $script_match[0];
|
||||
}
|
||||
|
||||
// Do not touch a script that writes its own '<script ...>' markup. There
|
||||
// is no safe in-place edit for it: add_ignore_attribute() does a global
|
||||
// str_replace() on '<script', which rewrites the inner literal and can
|
||||
// break the quoting of the string the script writes; tagging only the
|
||||
// outer tag would instead let get_script_tags() match and move that inner
|
||||
// literal. Such scripts keep the default behavior rather than risk
|
||||
// corrupting the page.
|
||||
if ( substr_count( strtolower( $script_match[0] ), '<script' ) > 1 ) {
|
||||
return $script_match[0];
|
||||
}
|
||||
|
||||
return $this->add_ignore_attribute( $script_match[0] );
|
||||
},
|
||||
$buffer
|
||||
);
|
||||
|
||||
// preg_replace_callback() returns null on PCRE failure (e.g. backtrack limit
|
||||
// on a pathological buffer); fall back to the unmodified buffer so the page is
|
||||
// never blanked. Mirrors the guard in is_opened_script().
|
||||
return null === $result ? $buffer : $result;
|
||||
}
|
||||
|
||||
/**
|
||||
* Negative lookahead asserting a <script> tag does not already carry the
|
||||
* ignore attribute. Shared by the regexes that select movable scripts so the
|
||||
* attribute-matching rule lives in one place.
|
||||
*
|
||||
* @return string Regex fragment (uses named group "q"; safe to use once per pattern).
|
||||
*/
|
||||
private function ignore_attribute_lookahead() {
|
||||
return sprintf(
|
||||
'(?![^>]*%s=(?<q>["\']*)%s\k<q>)',
|
||||
preg_quote( $this->ignore_attribute, '~' ),
|
||||
preg_quote( $this->ignore_value, '~' )
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits the buffer into two parts.
|
||||
*
|
||||
* First part contains all whole <script> tags, the second part
|
||||
* contains the rest of the buffer.
|
||||
*
|
||||
* @param string $buffer Captured piece of output buffer.
|
||||
* @param array $script_tags Matched <script> tags.
|
||||
*
|
||||
* @return array
|
||||
*/
|
||||
protected function recalculate_buffer_split( $buffer, $script_tags ) {
|
||||
$last_script_tag_index = count( $script_tags ) - 1;
|
||||
$last_script_tag_end_position = strrpos( $buffer, $script_tags[ $last_script_tag_index ][0] ) + strlen( $script_tags[ $last_script_tag_index ][0] );
|
||||
|
||||
// Bundle all script tags into the first buffer.
|
||||
$buffer_start = substr( $buffer, 0, $last_script_tag_end_position );
|
||||
|
||||
// Leave the rest of the data in the second buffer.
|
||||
$buffer_end = substr( $buffer, $last_script_tag_end_position );
|
||||
|
||||
return array( $buffer_start, $buffer_end );
|
||||
}
|
||||
|
||||
/**
|
||||
* Insert the buffered script tags just before the body tag if possible in the last buffer
|
||||
* otherwise at append it at the end.
|
||||
*
|
||||
* @param string $buffer String buffer.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function append_script_tags( $buffer ) {
|
||||
$script_tags = implode( '', $this->buffered_script_tags );
|
||||
// Reset tags in case there's another buffer after this one.
|
||||
$this->buffered_script_tags = array();
|
||||
|
||||
if ( str_contains( $buffer, '</body>' ) ) {
|
||||
$buffer = str_replace( '</body>', $script_tags . '</body>', $buffer );
|
||||
} else {
|
||||
$buffer .= $script_tags;
|
||||
}
|
||||
|
||||
return $buffer;
|
||||
}
|
||||
|
||||
/**
|
||||
* Exclude certain scripts from being processed by this class.
|
||||
*
|
||||
* @param string $tag <script> opening tag.
|
||||
* @param string $handle Script handle from register_ or enqueue_ methods.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function handle_exclusions( $tag, $handle ) {
|
||||
/**
|
||||
* Filter to provide an array of registered script handles that should not be moved to the end of the document.
|
||||
*
|
||||
* @param array $script_handles array of script handles. Remove any scripts that should not be moved to the end of the documents.
|
||||
*
|
||||
* @since 1.0.0
|
||||
*/
|
||||
$exclude_handles = apply_filters( 'jetpack_boost_render_blocking_js_exclude_handles', array() );
|
||||
|
||||
if ( ! in_array( $handle, $exclude_handles, true ) ) {
|
||||
return $tag;
|
||||
}
|
||||
|
||||
return $this->add_ignore_attribute( $tag );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add the ignore attribute to the script tags.
|
||||
*
|
||||
* Case-insensitive so uppercase/mixed-case tags (`<SCRIPT>`, valid HTML and
|
||||
* common in hand-written Custom HTML / legacy embeds) are handled too; a
|
||||
* case-sensitive match would silently no-op on them and leave them movable.
|
||||
*
|
||||
* @param string $html HTML code possibly containing a <script> opening tag.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public function add_ignore_attribute( $html ) {
|
||||
return str_ireplace( '<script', sprintf( '<script %s="%s"', esc_html( $this->ignore_attribute ), esc_attr( $this->ignore_value ) ), $html );
|
||||
}
|
||||
|
||||
/**
|
||||
* Detects an unclosed script tag in a buffer.
|
||||
*
|
||||
* @param string $buffer Joint buffer.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public function is_opened_script( $buffer ) {
|
||||
// Strip fully-paired ignored <script>...</script> blocks so the counts below are symmetric.
|
||||
$ignored_pair_regex = sprintf(
|
||||
'~<script[^>]*%s=(?<q>["\']*)%s\k<q>[^>]*>[\s\S]*?</script>~si',
|
||||
preg_quote( $this->ignore_attribute, '~' ),
|
||||
preg_quote( $this->ignore_value, '~' )
|
||||
);
|
||||
$stripped = preg_replace( $ignored_pair_regex, '', $buffer );
|
||||
if ( null === $stripped ) {
|
||||
$stripped = $buffer;
|
||||
}
|
||||
|
||||
// Strip HTML comments so a commented-out </script> doesn't skew the count.
|
||||
$stripped = preg_replace( '~<!--[\s\S]*?-->~', '', $stripped ) ?? $stripped;
|
||||
|
||||
$opening_tags_count = preg_match_all( '~<\s*script(\s[^>]*)?>~i', $stripped );
|
||||
$closing_tags_count = preg_match_all( '~<\s*/\s*script\s*>~i', $stripped );
|
||||
|
||||
return $opening_tags_count > $closing_tags_count;
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks if the current request URL matches one of the exclusion patterns
|
||||
* configured by the user.
|
||||
*
|
||||
* Runs at template_redirect time, when REQUEST_URI is available.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
private function is_current_request_excluded() {
|
||||
if ( ! isset( $_SERVER['REQUEST_URI'] ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
$patterns = function_exists( 'jetpack_boost_ds_get' ) ? jetpack_boost_ds_get( 'render_blocking_js_excludes' ) : array();
|
||||
if ( empty( $patterns ) || ! is_array( $patterns ) ) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// phpcs:ignore WordPress.Security.ValidatedSanitizedInput.InputNotSanitized -- Only used for comparison.
|
||||
return self::is_url_excluded( wp_unslash( $_SERVER['REQUEST_URI'] ), $patterns );
|
||||
}
|
||||
|
||||
/**
|
||||
* Checks whether a request URI matches any of the given exclusion patterns.
|
||||
*
|
||||
* Patterns follow the semantics documented for Page Cache bypass patterns:
|
||||
* they are compared against the URL path (query strings are ignored),
|
||||
* a `(.*)` or `*` wildcard matches any part of the path, trailing slashes
|
||||
* are optional and the comparison is case-insensitive.
|
||||
*
|
||||
* Two things differ from Page Cache, so keep them in mind before unifying the
|
||||
* two implementations: every character outside the wildcard tokens is escaped
|
||||
* via preg_quote() and matched literally (a pattern like `page.html` never
|
||||
* acts as a regular expression), and the path is percent-decoded so a pattern
|
||||
* typed as it appears in the address bar matches an encoded request path.
|
||||
*
|
||||
* @param string $request_uri The request URI to check.
|
||||
* @param array $patterns List of URL patterns.
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_url_excluded( $request_uri, $patterns ) {
|
||||
$path = self::normalize_url_path( $request_uri );
|
||||
|
||||
foreach ( $patterns as $pattern ) {
|
||||
$regex = self::get_exclusion_regex( $pattern );
|
||||
if ( null === $regex ) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$matched = preg_match( $regex, $path );
|
||||
|
||||
/*
|
||||
* preg_match() returns false when PCRE cannot evaluate the pattern —
|
||||
* e.g. a pathological pattern with several literal-separated wildcards
|
||||
* hits the backtrack limit on a long URL. Treat that as a match so a
|
||||
* deliberate exclusion is honoured (defer stays off on the page)
|
||||
* rather than silently ignored.
|
||||
*/
|
||||
if ( 1 === $matched || false === $matched ) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts a normalized path from a URL or request URI.
|
||||
*
|
||||
* Drops the query string, ensures a leading slash and removes trailing
|
||||
* slashes (except for the root path).
|
||||
*
|
||||
* @param string $url URL or request URI.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function normalize_url_path( $url ) {
|
||||
$path = (string) wp_parse_url( $url, PHP_URL_PATH );
|
||||
|
||||
// Decode percent-encoding so a pattern typed as it appears in the address
|
||||
// bar (e.g. `foo bar`, or a non-ASCII slug) matches the encoded request
|
||||
// path (`/foo%20bar`). Both the pattern and the request pass through here,
|
||||
// so the two sides stay symmetric.
|
||||
$path = rawurldecode( $path );
|
||||
|
||||
$path = '/' . ltrim( $path, '/' );
|
||||
|
||||
if ( '/' !== $path ) {
|
||||
$path = rtrim( $path, '/' );
|
||||
}
|
||||
|
||||
return self::strip_home_path( $path );
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the site's home directory prefix from a path.
|
||||
*
|
||||
* On a subdirectory install (e.g. a site at `/blog/`) the request URI
|
||||
* includes the subdirectory but user-entered patterns generally do not.
|
||||
* Stripping the home directory from both sides makes the comparison relative
|
||||
* to the home root, so a `checkout` pattern matches `/blog/checkout`.
|
||||
*
|
||||
* @param string $path A normalized URL path (leading slash, no query/trailing slash).
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
private static function strip_home_path( $path ) {
|
||||
$home_path = rtrim( (string) wp_parse_url( home_url( '/' ), PHP_URL_PATH ), '/' );
|
||||
|
||||
if ( '' === $home_path ) {
|
||||
return $path;
|
||||
}
|
||||
|
||||
if ( 0 === strcasecmp( $path, $home_path ) ) {
|
||||
return '/';
|
||||
}
|
||||
|
||||
if ( 0 === strncasecmp( $path, $home_path . '/', strlen( $home_path ) + 1 ) ) {
|
||||
return substr( $path, strlen( $home_path ) );
|
||||
}
|
||||
|
||||
return $path;
|
||||
}
|
||||
|
||||
/**
|
||||
* Turns a single exclusion pattern into an anchored regular expression.
|
||||
*
|
||||
* @param mixed $pattern A user-provided URL pattern.
|
||||
*
|
||||
* @return string|null The regular expression, or null if the pattern is empty.
|
||||
*/
|
||||
private static function get_exclusion_regex( $pattern ) {
|
||||
if ( ! is_string( $pattern ) ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$pattern = trim( $pattern );
|
||||
if ( '' === $pattern ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
/*
|
||||
* Reject malformed URL patterns. A full URL with a scheme but no path
|
||||
* (e.g. a typo'd `http://[::1`) would otherwise collapse to `/` and
|
||||
* silently exclude only the homepage. A pathless URL that points at this
|
||||
* site (e.g. the home URL pasted as `https://example.com`) is allowed
|
||||
* through, since it legitimately means the homepage.
|
||||
*/
|
||||
$parsed = wp_parse_url( $pattern );
|
||||
if ( false === $parsed ) {
|
||||
return null;
|
||||
}
|
||||
if ( isset( $parsed['scheme'] ) && empty( $parsed['path'] ) ) {
|
||||
$home_host = wp_parse_url( home_url( '/' ), PHP_URL_HOST );
|
||||
if ( empty( $parsed['host'] ) || 0 !== strcasecmp( $parsed['host'], (string) $home_host ) ) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
// Allow full URLs by stripping the home URL prefix (both secure and non-secure).
|
||||
$home_url = home_url( '/' );
|
||||
$pattern = str_ireplace(
|
||||
array(
|
||||
$home_url,
|
||||
str_replace( 'http:', 'https:', $home_url ),
|
||||
),
|
||||
'/',
|
||||
$pattern
|
||||
);
|
||||
|
||||
$pattern = self::normalize_url_path( $pattern );
|
||||
|
||||
/*
|
||||
* Split on wildcard tokens, treating any run of adjacent wildcards as a
|
||||
* single split point. The possessive `++` is important: without coalescing,
|
||||
* a pattern such as `****` would expand to one `.*` group per character, and
|
||||
* thousands of wildcards would compile to thousands of groups and exhaust
|
||||
* memory when matched against every front-end request. Possessive (rather
|
||||
* than greedy `+`) keeps the split itself linear, so a pathological run of
|
||||
* thousands of adjacent wildcards cannot exhaust the PCRE backtrack/JIT
|
||||
* stack and make preg_split() return false.
|
||||
*/
|
||||
$tokens = preg_split( '/(?:\(\.\*\)|\(\*\)|\.\*|\*)++/', $pattern );
|
||||
if ( false === $tokens ) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Everything between wildcards is matched literally; only the wildcards
|
||||
// become a (non-capturing) `.*` group.
|
||||
$quoted = array_map(
|
||||
function ( $token ) {
|
||||
return preg_quote( $token, '~' );
|
||||
},
|
||||
$tokens
|
||||
);
|
||||
|
||||
return '~^' . implode( '(?:.*)', $quoted ) . '/?$~i';
|
||||
}
|
||||
|
||||
public static function get_slug() {
|
||||
return 'render_blocking_js';
|
||||
}
|
||||
}
|
||||
+101
@@ -0,0 +1,101 @@
|
||||
<?php
|
||||
/**
|
||||
* Speculation Rules implementation for cornerstone pages
|
||||
*
|
||||
* @package Boost
|
||||
* @since 3.13.0
|
||||
*/
|
||||
|
||||
namespace Automattic\Jetpack_Boost\Modules\Optimizations\Speculation_Rules;
|
||||
|
||||
use Automattic\Jetpack_Boost\Contracts\Changes_Output_On_Activation;
|
||||
use Automattic\Jetpack_Boost\Contracts\Feature;
|
||||
use Automattic\Jetpack_Boost\Contracts\Optimization;
|
||||
use Automattic\Jetpack_Boost\Lib\Cornerstone\Cornerstone_Utils;
|
||||
/**
|
||||
* Class to handle speculation rules for cornerstone pages
|
||||
*/
|
||||
class Speculation_Rules implements Feature, Changes_Output_On_Activation, Optimization {
|
||||
|
||||
/**
|
||||
* Get the slug for this module.
|
||||
*
|
||||
* @return string
|
||||
*/
|
||||
public static function get_slug() {
|
||||
return 'speculation_rules';
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if the feature is available
|
||||
*
|
||||
* @return bool
|
||||
*/
|
||||
public static function is_available() {
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Initialize the speculation rules
|
||||
*
|
||||
* @since 3.13.0
|
||||
* @return void
|
||||
*/
|
||||
public function setup() {
|
||||
// Use WP core action to add speculation rules
|
||||
add_action( 'wp_load_speculation_rules', array( $this, 'add_cornerstone_rules' ) );
|
||||
}
|
||||
|
||||
/**
|
||||
* Add speculation rules for cornerstone pages
|
||||
*
|
||||
* @param \WP_Speculation_Rules $speculation_rules The speculation rules instance.
|
||||
* @since 3.13.0
|
||||
* @return void
|
||||
*/
|
||||
public function add_cornerstone_rules( $speculation_rules ) {
|
||||
$cornerstone_urls = $this->get_cornerstone_urls();
|
||||
if ( empty( $cornerstone_urls ) ) {
|
||||
return;
|
||||
}
|
||||
|
||||
// remove the protocol and domain from the list of cornerstone urls
|
||||
$home_url = wp_parse_url( home_url() );
|
||||
$domain = $home_url['host'];
|
||||
$protocol = $home_url['scheme'];
|
||||
$cornerstone_urls = array_map(
|
||||
function ( $url ) use ( $protocol, $domain ) {
|
||||
return trailingslashit( str_replace( $protocol . '://' . $domain, '', $url ) );
|
||||
},
|
||||
$cornerstone_urls
|
||||
);
|
||||
|
||||
// Add prerender rule for cornerstone pages with moderate eagerness
|
||||
$speculation_rules->add_rule(
|
||||
'prerender',
|
||||
'cornerstone-pages-prerender',
|
||||
array(
|
||||
'source' => 'document',
|
||||
'where' => array(
|
||||
'href_matches' => $cornerstone_urls,
|
||||
),
|
||||
'eagerness' => 'moderate',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the list of cornerstone page URLs
|
||||
*
|
||||
* @since 3.13.0
|
||||
* @return array Array of cornerstone page URLs
|
||||
*/
|
||||
private function get_cornerstone_urls() {
|
||||
$cornerstone_urls = Cornerstone_Utils::get_list();
|
||||
if ( empty( $cornerstone_urls ) ) {
|
||||
return array();
|
||||
}
|
||||
|
||||
return $cornerstone_urls;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user