, made by /speed/preview) shows a page with chosen parts on for that
* request only, uncached and noindex, so Gralus can compare it with the normal page before anything changes.
* Originals are never changed: WebP files sit next to them as "photo.jpg.webp" and Undo deletes them.
* Parts another plugin already does (an image optimiser, a cache plugin) are left to that plugin.
*/
if ( ! defined( 'ABSPATH' ) ) {
exit;
}
class Gralus_Speed {
const OPTION = 'gralus_speed';
/** On an image: the WebP files made for it, relative to the uploads folder. */
const META = '_gralus_webp';
const MARKER = 'Gralus speed';
const PARTS = array( 'webp', 'lcp', 'cache', 'fonts', 'dims', 'delay', 'unload', 'blocks', 'extras' );
/** Parts that change the page's HTML (all but cache). */
const HTML_PARTS = array( 'webp', 'lcp', 'fonts', 'dims', 'delay', 'unload', 'blocks' );
const PREVIEW = 'gralus_speed_preview_';
/** Parts on for this request: the saved ones plus a valid preview's. */
private static $active = null;
const MAX_WIDTH = 1920;
const QUALITY = 80;
/** Seconds one /speed/webp request may spend converting. */
const BUDGET = 20;
/** Images given a width and height per page; each one reads the file's header. */
const MAX_DIMS = 30;
/** Plugins that already do one of the parts: active plugin file => [name, parts it does]. */
const PLUGINS = array(
'litespeed-cache/litespeed-cache.php' => array( 'LiteSpeed Cache', array() ),
'wp-rocket/wp-rocket.php' => array( 'WP Rocket', array( 'cache' ) ),
'w3-total-cache/w3-total-cache.php' => array( 'W3 Total Cache', array( 'cache' ) ),
'wp-fastest-cache/wpFastestCache.php' => array( 'WP Fastest Cache', array( 'cache' ) ),
'wp-super-cache/wp-cache.php' => array( 'WP Super Cache', array() ),
'autoptimize/autoptimize.php' => array( 'Autoptimize', array() ),
'perfmatters/perfmatters.php' => array( 'Perfmatters', array() ),
'ewww-image-optimizer/ewww-image-optimizer.php' => array( 'EWWW Image Optimizer', array( 'webp' ) ),
'shortpixel-image-optimiser/wp-shortpixel.php' => array( 'ShortPixel', array( 'webp' ) ),
'imagify/imagify.php' => array( 'Imagify', array( 'webp' ) ),
'wp-smushit/wp-smush.php' => array( 'Smush', array() ),
'webp-express/webp-express.php' => array( 'WebP Express', array( 'webp' ) ),
'webp-converter-for-media/webp-converter-for-media.php' => array( 'Converter for Media', array( 'webp' ) ),
'optimole-wp/optimole-wp.php' => array( 'Optimole', array( 'webp' ) ),
);
public static function register_routes() {
$auth = array( 'Gralus_Connection', 'verify_request' );
$ns = Gralus_Rest::NS;
register_rest_route( $ns, '/speed/status', array( 'methods' => 'GET', 'callback' => array( __CLASS__, 'status' ), 'permission_callback' => $auth ) );
register_rest_route( $ns, '/speed/apply', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'apply' ), 'permission_callback' => $auth ) );
register_rest_route( $ns, '/speed/undo', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'undo' ), 'permission_callback' => $auth ) );
register_rest_route( $ns, '/speed/webp', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'webp' ), 'permission_callback' => $auth ) );
register_rest_route( $ns, '/speed/webp/remove', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'webp_remove' ), 'permission_callback' => $auth ) );
register_rest_route( $ns, '/speed/preview', array( 'methods' => 'POST', 'callback' => array( __CLASS__, 'preview' ), 'permission_callback' => $auth ) );
}
/**
* A preview link for some parts, valid two hours. Body: { parts: ["delay"], all: false }. With all, only the given parts
* are on (the saved ones off), to compare one change on its own. Returns { token, param }.
*/
public static function preview( WP_REST_Request $request ) {
$p = $request->get_json_params();
$parts = array_values( array_intersect( self::PARTS, isset( $p['parts'] ) && is_array( $p['parts'] ) ? $p['parts'] : array() ) );
$token = strtolower( wp_generate_password( 32, false, false ) );
set_transient( self::PREVIEW . $token, array( 'parts' => $parts, 'only' => ! empty( $p['only'] ) ), 2 * HOUR_IN_SECONDS );
return array( 'ok' => true, 'token' => $token, 'param' => 'gralus-speed-preview', 'parts' => $parts );
}
/** The parts on for this request. */
public static function active() {
if ( null !== self::$active ) {
return self::$active;
}
$s = self::settings();
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- a random token checked against a transient, read-only.
$token = isset( $_GET['gralus-speed-preview'] ) ? sanitize_key( wp_unslash( $_GET['gralus-speed-preview'] ) ) : '';
if ( '' !== $token && strlen( $token ) <= 64 ) {
$preview = get_transient( self::PREVIEW . $token );
if ( is_array( $preview ) && isset( $preview['parts'] ) ) {
if ( ! empty( $preview['only'] ) ) {
$s = array_fill_keys( self::PARTS, false );
}
foreach ( (array) $preview['parts'] as $part ) {
if ( in_array( $part, self::PARTS, true ) ) {
$s[ $part ] = true;
}
}
// A preview is for Gralus's check only: never cached, never indexed.
if ( ! defined( 'DONOTCACHEPAGE' ) ) {
define( 'DONOTCACHEPAGE', true ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedConstantFound -- the constant cache plugins read.
}
do_action( 'litespeed_control_set_nocache', 'gralus speed preview' ); // phpcs:ignore WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- LiteSpeed Cache's own hook.
if ( ! headers_sent() ) {
nocache_headers();
header( 'X-Robots-Tag: noindex, nofollow' );
}
}
}
self::$active = $s;
return $s;
}
/** On init: parts done with WordPress hooks rather than in the HTML (emoji and embed scripts). */
public static function early() {
if ( is_admin() ) {
return;
}
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only reads whether the flag is present.
if ( isset( $_GET['gralus-speed'] ) ) {
return;
}
$s = self::active();
if ( $s['extras'] ) {
Gralus_Speed_Scripts::remove_extras();
}
}
/** The saved parts, all off by default. */
public static function settings() {
$saved = get_option( self::OPTION, array() );
$out = array();
foreach ( self::PARTS as $part ) {
$out[ $part ] = is_array( $saved ) && ! empty( $saved[ $part ] );
}
return $out;
}
/** apache, litespeed, nginx or other. LiteSpeed reads .htaccess like Apache. */
public static function server() {
global $is_apache, $is_nginx;
$soft = isset( $_SERVER['SERVER_SOFTWARE'] ) ? strtolower( sanitize_text_field( wp_unslash( $_SERVER['SERVER_SOFTWARE'] ) ) ) : '';
if ( false !== strpos( $soft, 'litespeed' ) ) {
return 'litespeed';
}
if ( $is_nginx || false !== strpos( $soft, 'nginx' ) ) {
return 'nginx';
}
return $is_apache ? 'apache' : 'other';
}
private static function htaccess_path() {
require_once ABSPATH . 'wp-admin/includes/file.php';
return get_home_path() . '.htaccess';
}
private static function htaccess_writable() {
$path = self::htaccess_path();
return file_exists( $path ) ? wp_is_writable( $path ) : wp_is_writable( dirname( $path ) );
}
/** Speed and image plugins that are active, with the parts each already does. */
public static function plugins() {
$active = (array) get_option( 'active_plugins', array() );
if ( is_multisite() ) {
$active = array_merge( $active, array_keys( (array) get_site_option( 'active_sitewide_plugins', array() ) ) );
}
$out = array();
foreach ( self::PLUGINS as $file => $info ) {
if ( ! in_array( $file, $active, true ) ) {
continue;
}
$does = $info[1];
// LiteSpeed Cache only does these when they are switched on in its settings.
if ( 'litespeed-cache/litespeed-cache.php' === $file ) {
if ( get_option( 'litespeed.conf.cache-browser' ) ) {
$does[] = 'cache';
}
if ( get_option( 'litespeed.conf.img_optm-webp' ) ) {
$does[] = 'webp';
}
}
$out[] = array(
'file' => $file,
'name' => $info[0],
'parts' => $does,
);
}
return $out;
}
/** For each part, the plugin that already does it, or null. */
public static function handled_by() {
$out = array_fill_keys( self::PARTS, null );
foreach ( Gralus_Speed_Scripts::handled() as $part => $name ) {
$out[ $part ] = $name;
}
foreach ( self::plugins() as $p ) {
foreach ( $p['parts'] as $part ) {
if ( null === $out[ $part ] ) {
$out[ $part ] = $p['name'];
}
}
}
return $out;
}
private static function count_images( $converted ) {
$args = array(
'post_type' => 'attachment',
'post_status' => 'inherit',
'post_mime_type' => array( 'image/jpeg', 'image/png' ),
'posts_per_page' => 1,
'fields' => 'ids',
);
if ( null !== $converted ) {
$args['meta_query'] = array( array( 'key' => self::META, 'compare' => $converted ? 'EXISTS' : 'NOT EXISTS' ) ); // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- counted once per request from Gralus.
}
$q = new WP_Query( $args );
return (int) $q->found_posts;
}
/** What this site can do and what is switched on. */
public static function status() {
$block = '';
$path = self::htaccess_path();
if ( file_exists( $path ) ) {
require_once ABSPATH . 'wp-admin/includes/misc.php';
$block = implode( "\n", extract_from_markers( $path, self::MARKER ) );
}
return array(
'settings' => self::settings(),
'server' => self::server(),
'htaccess' => self::htaccess_writable(),
'cacheRules' => '' !== trim( $block ),
'webpSupported' => wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ),
'plugins' => self::plugins(),
'handledBy' => self::handled_by(),
'images' => array(
'total' => self::count_images( null ),
'converted' => self::count_images( true ),
),
'pluginVersion' => GRALUS_VERSION,
);
}
/** The browser cache rules written between the "Gralus speed" markers. */
public static function cache_rules() {
return array(
'',
'AddType image/webp .webp',
'',
'',
'ExpiresActive On',
'ExpiresByType image/jpeg "access plus 1 year"',
'ExpiresByType image/png "access plus 1 year"',
'ExpiresByType image/gif "access plus 1 year"',
'ExpiresByType image/webp "access plus 1 year"',
'ExpiresByType image/avif "access plus 1 year"',
'ExpiresByType image/svg+xml "access plus 1 year"',
'ExpiresByType image/x-icon "access plus 1 year"',
'ExpiresByType font/woff2 "access plus 1 year"',
'ExpiresByType font/woff "access plus 1 year"',
'ExpiresByType font/ttf "access plus 1 year"',
'ExpiresByType application/font-woff2 "access plus 1 year"',
'ExpiresByType text/css "access plus 1 month"',
'ExpiresByType application/javascript "access plus 1 month"',
'ExpiresByType text/javascript "access plus 1 month"',
'',
'',
'',
'Header set Cache-Control "public, max-age=31536000"',
'',
'',
'Header set Cache-Control "public, max-age=2592000"',
'',
'',
);
}
/** Write or remove the cache rules. True when .htaccess now matches what was asked. */
public static function write_cache_rules( $on ) {
$path = self::htaccess_path();
if ( ! $on && ! file_exists( $path ) ) {
return true;
}
if ( ! self::htaccess_writable() ) {
return false;
}
require_once ABSPATH . 'wp-admin/includes/misc.php';
return (bool) insert_with_markers( $path, self::MARKER, $on ? self::cache_rules() : array() );
}
/**
* Turn parts on or off. Body: { settings: { webp, lcp, cache, fonts, dims } } (missing parts keep their value).
* Returns the settings now in force, the previous ones (for Undo) and why any asked-for part stayed off.
*/
public static function apply( WP_REST_Request $request ) {
$p = $request->get_json_params();
return self::set( isset( $p['settings'] ) && is_array( $p['settings'] ) ? $p['settings'] : array() );
}
/** Put back the settings from before. Body: { previous: { … } }. WebP files are removed separately (/speed/webp/remove). */
public static function undo( WP_REST_Request $request ) {
$p = $request->get_json_params();
$prev = isset( $p['previous'] ) && is_array( $p['previous'] ) ? $p['previous'] : array();
return self::set( array_merge( array_fill_keys( self::PARTS, false ), $prev ) );
}
private static function set( $wanted ) {
$previous = self::settings();
$next = $previous;
$skipped = array();
$handled = self::handled_by();
foreach ( self::PARTS as $part ) {
if ( array_key_exists( $part, $wanted ) ) {
$next[ $part ] = (bool) $wanted[ $part ];
}
}
foreach ( self::PARTS as $part ) {
if ( $next[ $part ] && ! $previous[ $part ] && $handled[ $part ] ) {
$next[ $part ] = false;
$skipped[ $part ] = $handled[ $part ] . ' already does this.';
}
}
if ( $next['webp'] && ! wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) ) {
$next['webp'] = false;
$skipped['webp'] = 'The server\'s image library (GD or Imagick) cannot write WebP files.';
}
if ( $next['cache'] !== $previous['cache'] || $next['cache'] ) {
$server = self::server();
if ( $next['cache'] && ! in_array( $server, array( 'apache', 'litespeed' ), true ) ) {
$next['cache'] = false;
$skipped['cache'] = 'nginx' === $server ? 'The server is nginx, which does not read .htaccess. Add the cache times in the nginx settings.' : 'The server does not read .htaccess.';
} elseif ( ! self::write_cache_rules( $next['cache'] ) ) {
if ( $next['cache'] ) {
$skipped['cache'] = 'WordPress cannot write to .htaccess (file permissions).';
}
$next['cache'] = $previous['cache'] && ! $next['cache'];
}
}
update_option( self::OPTION, $next, true );
if ( $next !== $previous ) {
self::purge_caches();
}
return array(
'ok' => true,
'settings' => $next,
'previous' => $previous,
'skipped' => (object) $skipped,
'images' => array(
'total' => self::count_images( null ),
'converted' => self::count_images( true ),
),
);
}
/**
* Empty page caches so visitors get pages with the changes, not copies saved before them.
* Uses each cache plugin's own purge, where one is active.
*/
public static function purge_caches() {
// phpcs:disable WordPress.NamingConventions.PrefixAllGlobals.NonPrefixedHooknameFound -- other plugins' own purge hooks.
do_action( 'litespeed_purge_all' );
do_action( 'wpfc_clear_all_cache' );
// phpcs:enable
if ( function_exists( 'rocket_clean_domain' ) ) {
rocket_clean_domain();
}
if ( function_exists( 'w3tc_flush_all' ) ) {
w3tc_flush_all();
}
if ( function_exists( 'wp_cache_clear_cache' ) ) {
wp_cache_clear_cache();
}
wp_cache_flush();
}
/** Uploads folder path and address. */
private static function uploads() {
$u = wp_get_upload_dir();
return array( trailingslashit( $u['basedir'] ), trailingslashit( $u['baseurl'] ) );
}
/** Files that belong to an image: the file in use (the "-scaled" copy for large uploads) and every size WordPress made. */
private static function image_files( $id ) {
$file = get_attached_file( $id );
if ( ! $file ) {
return array();
}
$files = array( $file );
$meta = wp_get_attachment_metadata( $id );
if ( is_array( $meta ) && ! empty( $meta['sizes'] ) ) {
$dir = trailingslashit( dirname( $file ) );
foreach ( $meta['sizes'] as $size ) {
// Only JPEG and PNG files are converted (convert_file checks the name).
if ( ! empty( $size['file'] ) ) {
$files[] = $dir . wp_basename( $size['file'] );
}
}
}
return array_values( array_unique( $files ) );
}
/** Make "file.jpg.webp" next to the file. Returns the WebP path and bytes saved, or null when it would not be smaller. */
public static function convert_file( $path ) {
if ( ! preg_match( '/\.(jpe?g|png)$/i', $path ) || ! file_exists( $path ) ) {
return null;
}
$dest = $path . '.webp';
if ( file_exists( $dest ) && filemtime( $dest ) >= filemtime( $path ) ) {
return array( $dest, max( 0, filesize( $path ) - filesize( $dest ) ) );
}
try {
$editor = wp_get_image_editor( $path );
if ( is_wp_error( $editor ) ) {
return null;
}
$size = $editor->get_size();
if ( ! empty( $size['width'] ) && $size['width'] > self::MAX_WIDTH ) {
$editor->resize( self::MAX_WIDTH, null, false );
}
$editor->set_quality( self::QUALITY );
$saved = $editor->save( $dest, 'image/webp' );
} catch ( \Throwable $e ) {
return null;
}
if ( is_wp_error( $saved ) || empty( $saved['path'] ) || ! file_exists( $saved['path'] ) ) {
return null;
}
if ( $saved['path'] !== $dest ) {
wp_delete_file( $saved['path'] );
return null;
}
clearstatcache( true, $dest );
$gain = filesize( $path ) - filesize( $dest );
if ( $gain <= 0 ) {
wp_delete_file( $dest );
return null;
}
return array( $dest, $gain );
}
/**
* Convert the next images that have no WebP copies yet. Body: { limit } (1–50, default 15).
* Stops after about 20 seconds. Returns how many were done and how many are left.
*/
public static function webp( WP_REST_Request $request ) {
$p = $request->get_json_params();
$limit = isset( $p['limit'] ) ? max( 1, min( 50, (int) $p['limit'] ) ) : 15;
if ( ! wp_image_editor_supports( array( 'mime_type' => 'image/webp' ) ) ) {
return new WP_Error( 'gralus_webp', 'The server\'s image library cannot write WebP files.', array( 'status' => 400 ) );
}
list( $base ) = self::uploads();
$start = microtime( true );
$ids = get_posts(
array(
'post_type' => 'attachment',
'post_status' => 'inherit',
'post_mime_type' => array( 'image/jpeg', 'image/png' ),
'posts_per_page' => $limit,
'orderby' => 'ID',
'order' => 'DESC',
'fields' => 'ids',
'meta_query' => array( array( 'key' => self::META, 'compare' => 'NOT EXISTS' ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one batch per request from Gralus.
)
);
$images = 0;
$files = 0;
$saved = 0;
foreach ( $ids as $id ) {
if ( microtime( true ) - $start > self::BUDGET ) {
break;
}
$made = array();
foreach ( self::image_files( $id ) as $file ) {
$r = self::convert_file( $file );
if ( $r && 0 === strpos( $r[0], $base ) ) {
$made[] = substr( $r[0], strlen( $base ) );
$saved += $r[1];
}
}
// Recorded even when nothing was made (WebP was not smaller), so the image is not tried again.
update_post_meta( $id, self::META, $made );
++$images;
$files += count( $made );
}
$remaining = self::count_images( false );
// The last batch: pages cached while copies were being made show them now.
if ( 0 === $remaining && $images > 0 && self::settings()['webp'] ) {
self::purge_caches();
}
return array(
'ok' => true,
'images' => $images,
'files' => $files,
'savedBytes' => $saved,
'remaining' => $remaining,
'converted' => self::count_images( true ),
);
}
/** Delete the WebP files Gralus made, in batches. Body: { limit } (default 50). */
public static function webp_remove( WP_REST_Request $request ) {
$p = $request->get_json_params();
$limit = isset( $p['limit'] ) ? max( 1, min( 200, (int) $p['limit'] ) ) : 50;
$done = self::remove_batch( $limit );
return array(
'ok' => true,
'removed' => $done,
'remaining' => self::count_images( true ),
);
}
/** Remove the WebP files of up to $limit images. Only files inside the uploads folder that end in .webp are deleted. */
public static function remove_batch( $limit ) {
list( $base ) = self::uploads();
$ids = get_posts(
array(
'post_type' => 'attachment',
'post_status' => 'any',
'posts_per_page' => $limit,
'fields' => 'ids',
'meta_query' => array( array( 'key' => self::META, 'compare' => 'EXISTS' ) ), // phpcs:ignore WordPress.DB.SlowDBQuery.slow_db_query_meta_query -- one batch per request from Gralus.
)
);
$n = 0;
foreach ( $ids as $id ) {
$made = get_post_meta( $id, self::META, true );
foreach ( is_array( $made ) ? $made : array() as $rel ) {
$rel = (string) $rel;
$path = $base . $rel;
if ( '.webp' === substr( $rel, -5 ) && false === strpos( $rel, '..' ) && file_exists( $path ) ) {
wp_delete_file( $path );
++$n;
}
}
delete_post_meta( $id, self::META );
}
return $n;
}
/** Everything this class leaves on the site goes: settings, cache rules and WebP files. */
public static function uninstall() {
delete_option( self::OPTION );
self::write_cache_rules( false );
for ( $i = 0; $i < 200 && self::remove_batch( 200 ) > 0; $i++ ) {
// Keep going until no image has WebP files left.
continue;
}
delete_post_meta_by_key( self::META );
}
/** The plugin turned off: browsers stop being told to cache for a year. Turned back on: the rules come back. */
public static function deactivate() {
if ( self::settings()['cache'] ) {
self::write_cache_rules( false );
}
}
public static function activate() {
if ( self::settings()['cache'] ) {
self::write_cache_rules( true );
}
}
// ---------- Front end ----------
/** Start filtering the page when a front-end part is on and this is an ordinary page view. */
public static function start() {
$s = self::active();
$on = false;
foreach ( self::HTML_PARTS as $part ) {
$on = $on || $s[ $part ];
}
if ( ! $on ) {
return;
}
if ( is_admin() || wp_doing_ajax() || wp_doing_cron() || ( defined( 'REST_REQUEST' ) && REST_REQUEST ) || is_feed() || is_robots() || is_trackback() || is_embed() || is_customize_preview() ) {
return;
}
$method = isset( $_SERVER['REQUEST_METHOD'] ) ? strtoupper( sanitize_text_field( wp_unslash( $_SERVER['REQUEST_METHOD'] ) ) ) : 'GET';
if ( 'GET' !== $method && 'HEAD' !== $method ) {
return;
}
// Page builders' editing views, and Gralus's own before-and-after check (?gralus-speed=off).
// phpcs:ignore WordPress.Security.NonceVerification.Recommended -- only reads whether these flags are present.
foreach ( array( 'gralus-speed', 'elementor-preview', 'et_fb', 'fl_builder', 'ct_builder', 'brizy-edit-iframe', 'vc_editable', 'tve', 'bricks', 'breakdance_iframe' ) as $flag ) {
if ( isset( $_GET[ $flag ] ) ) { // phpcs:ignore WordPress.Security.NonceVerification.Recommended
return;
}
}
ob_start( array( __CLASS__, 'filter_html' ) );
}
/** The page's HTML with the parts that are on applied. Anything unexpected returns the page unchanged. */
public static function filter_html( $html ) {
if ( ! is_string( $html ) || strlen( $html ) < 200 || false === stripos( $html, '' ) ) {
return $html;
}
try {
$s = self::active();
$out = $html;
if ( $s['webp'] ) {
$out = self::swap_webp( $out );
}
if ( $s['dims'] ) {
$out = self::add_dimensions( $out );
}
if ( $s['lcp'] ) {
$out = self::prioritise_first_image( $out );
}
if ( $s['fonts'] ) {
$out = self::fonts( $out );
}
if ( $s['unload'] ) {
$out = Gralus_Speed_Scripts::unload( $out );
}
if ( $s['blocks'] ) {
$out = Gralus_Speed_Scripts::blocks( $out );
}
if ( $s['delay'] ) {
$out = Gralus_Speed_Scripts::delay( $out );
}
return is_string( $out ) && '' !== $out ? $out : $html;
} catch ( \Throwable $e ) {
return $html;
}
}
/** Uploads addresses that have a WebP copy next to them, swapped for the copy. Meta tags (social previews) are left alone. */
public static function swap_webp( $html ) {
list( $base, $url ) = self::uploads();
$host_path = preg_replace( '#^https?:#i', '', untrailingslashit( $url ) );
$keep = array();
$html = preg_replace_callback(
'#]*>#i',
function ( $m ) use ( &$keep ) {
$keep[] = $m[0];
return '';
},
$html
);
$seen = array();
$pattern = '#(?:https?:)?' . preg_quote( $host_path, '#' ) . '/([^\s"\'()<>,?]+?\.(?:jpe?g|png))(?=[\s"\'()<>,?&]|$)#i';
$html = preg_replace_callback(
$pattern,
function ( $m ) use ( $base, &$seen ) {
$rel = rawurldecode( $m[1] );
if ( false !== strpos( $rel, '..' ) ) {
return $m[0];
}
if ( ! isset( $seen[ $rel ] ) ) {
$seen[ $rel ] = count( $seen ) < 400 && file_exists( $base . $rel . '.webp' );
}
return $seen[ $rel ] ? $m[0] . '.webp' : $m[0];
},
$html
);
foreach ( $keep as $i => $tag ) {
$html = str_replace( '', $tag, $html );
}
return $html;
}
/** A local file behind an uploads address, or ''. */
private static function local_file( $src ) {
list( $base, $url ) = self::uploads();
$src = preg_replace( '#[?\#].*$#', '', html_entity_decode( $src ) );
$host_path = preg_replace( '#^https?:#i', '', $url );
$plain = preg_replace( '#^https?:#i', '', $src );
if ( 0 !== strpos( $plain, $host_path ) ) {
return '';
}
$rel = rawurldecode( substr( $plain, strlen( $host_path ) ) );
if ( false !== strpos( $rel, '..' ) ) {
return '';
}
$rel = preg_replace( '/\.webp$/i', '', $rel );
return file_exists( $base . $rel ) ? $base . $rel : '';
}
/** Images from the Media Library without width and height attributes get them. */
public static function add_dimensions( $html ) {
$count = 0;
return preg_replace_callback(
'#
]*>#i',
function ( $m ) use ( &$count ) {
$tag = $m[0];
if ( $count >= self::MAX_DIMS || preg_match( '#\s(width|height)\s*=#i', $tag ) || ! preg_match( '#\ssrc\s*=\s*(["\'])([^"\']+)\1#i', $tag, $src ) ) {
return $tag;
}
$file = self::local_file( $src[2] );
if ( '' === $file || preg_match( '/\.svg$/i', $file ) ) {
return $tag;
}
++$count;
$size = @getimagesize( $file ); // phpcs:ignore WordPress.PHP.NoSilencedErrors.Discouraged -- a broken file only means no size is added.
if ( ! $size || empty( $size[0] ) || empty( $size[1] ) ) {
return $tag;
}
return preg_replace( '#^
]*\sfetchpriority\s*=\s*["\']?high#i', $rest ) ) {
return $html;
}
if ( ! preg_match_all( '#
]*>#i', $rest, $all, PREG_OFFSET_CAPTURE ) ) {
return $html;
}
foreach ( array_slice( $all[0], 0, 15 ) as $found ) {
list( $tag, $at ) = $found;
if ( preg_match( '#logo|icon|avatar|emoji|gravatar#i', $tag ) || preg_match( '#\ssrc\s*=\s*["\'](data:|[^"\']+\.svg)#i', $tag ) ) {
continue;
}
if ( preg_match( '#\swidth\s*=\s*["\']?(\d+)#i', $tag, $w ) && (int) $w[1] < 200 ) {
continue;
}
// A lazy-load script swaps data-src in later; changing that tag would show nothing.
if ( preg_match( '#\sdata-(src|lazy-src)\s*=#i', $tag ) && ! preg_match( '#\ssrc\s*=\s*["\'](?!data:)#i', $tag ) ) {
return $html;
}
$new = preg_replace( '#\sloading\s*=\s*(["\']?)lazy\1#i', '', $tag );
// sizes="auto" only works on lazy-loaded images.
$new = preg_replace( '#(\ssizes\s*=\s*["\'])auto,\s*#i', '$1', $new );
$new = preg_replace( '#^
]*\shref\s*=\s*["\'])(https?:)?(//fonts\.googleapis\.com/css2?\?[^"\']*)(["\'])#i',
function ( $m ) {
if ( preg_match( '#[?&;]display=#i', $m[3] ) ) {
return $m[0];
}
return $m[1] . $m[2] . $m[3] . '&display=swap' . $m[4];
},
$html
);
if ( false !== stripos( $html, 'fonts.googleapis.com' ) && ! preg_match( '#]*preconnect[^>]*fonts\.gstatic\.com#i', $html ) ) {
$html = preg_replace( '#
]*>#i', '$0', $html, 1 );
}
return preg_replace_callback(
'#()#is',
function ( $m ) {
if ( false === stripos( $m[2], '@font-face' ) ) {
return $m[0];
}
return $m[1] . preg_replace( '#@font-face\s*\{(?![^}]*font-display)#i', '@font-face{font-display:swap;', $m[2] ) . $m[3];
},
$html
);
}
}