, 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 ); } }