tags: * Elementor writes them to uploads/elementor/css/post-123.css (or a )#is', static function ( $m ) use ( $page_url, $rewrite ) { if ( false === stripos( $m[2], 'url(' ) ) { return $m[0]; } self::merge_backgrounds( self::collect_backgrounds( $m[2], $page_url ) ); return $rewrite ? $m[1] . self::rewrite_css( $m[2], $page_url ) . $m[3] : $m[0]; }, $html ); if ( null === $out ) { return $html; } $out2 = preg_replace_callback( '#]*>#i', static function ( $m ) use ( $rewrite ) { $tag = $m[0]; if ( ! preg_match( '/\srel\s*=\s*["\']?stylesheet/i', $tag ) ) { return $tag; } $href = Spinix_Image_Optimization_Frontend::get_attr( $tag, 'href' ); if ( ! $href ) { return $tag; } $copy = self::copy_for( html_entity_decode( $href, ENT_QUOTES ), $rewrite ); return ( $copy && $rewrite ) ? Spinix_Image_Optimization_Frontend::set_attr( $tag, 'href', $copy ) : $tag; }, $out ); self::save_cache(); return null === $out2 ? $out : $out2; } /** * Replace image URLs in CSS with WebP versions. * * @param string $css CSS. * @param string $base_url URL the CSS was loaded from (to resolve relative URLs). * @param int $changed Number of images swapped (by reference). * @return string */ public static function rewrite_css( $css, $base_url, &$changed = 0 ) { $res = preg_replace_callback( '#url\(\s*([\'"]?)([^\'")]+)\1\s*\)#i', static function ( $m ) use ( $base_url, &$changed ) { $raw = trim( $m[2] ); if ( '' === $raw || 0 === stripos( $raw, 'data:' ) || '#' === $raw[0] ) { return $m[0]; } $abs = self::absolute( html_entity_decode( $raw, ENT_QUOTES ), $base_url ); $webp = preg_match( '/\.(jpe?g|png)(\?.*)?$/i', $abs ) ? Spinix_Image_Optimization_Storage::webp_url_for( $abs ) : false; if ( $webp ) { ++$changed; return 'url("' . $webp . '")'; } // Keep the reference working from the copy's new location. return $abs !== $raw ? 'url("' . $abs . '")' : $m[0]; }, $css ); if ( null === $res ) { return $css; } // @import "file.css" (string form). $res2 = preg_replace_callback( '#@import\s+([\'"])([^\'"]+)\1#i', static function ( $m ) use ( $base_url ) { return '@import ' . $m[1] . self::absolute( $m[2], $base_url ) . $m[1]; }, $res ); return null === $res2 ? $res : $res2; } /** * Resolve a (possibly relative) URL. * * @param string $url URL. * @param string $base Base URL. * @return string */ public static function absolute( $url, $base ) { if ( preg_match( '#^(?:[a-z][a-z0-9+.-]*:)?//#i', $url ) ) { return $url; } $parts = wp_parse_url( $base ); if ( empty( $parts['host'] ) ) { return $url; } $origin = ( isset( $parts['scheme'] ) ? $parts['scheme'] . ':' : '' ) . '//' . $parts['host'] . ( isset( $parts['port'] ) ? ':' . $parts['port'] : '' ); if ( '/' === $url[0] ) { return $origin . $url; } $path = isset( $parts['path'] ) ? $parts['path'] : '/'; $dir = substr( $path, 0, strrpos( $path, '/' ) + 1 ); $full = $dir . $url; // Normalize ./ and ../ segments. $segments = array(); foreach ( explode( '/', $full ) as $seg ) { if ( '..' === $seg ) { array_pop( $segments ); } elseif ( '.' !== $seg ) { $segments[] = $seg; } } return $origin . implode( '/', $segments ); } /** * URL of a WebP-ready copy of a local stylesheet, or false when not needed. * Also records the background images the stylesheet sets (see backgrounds()). * * @param string $href Stylesheet URL. * @param bool $write Create the WebP copy (false = only read the backgrounds). * @return string|false */ public static function copy_for( $href, $write = true ) { $clean = preg_replace( '/[?#].*$/', '', $href ); $content = Spinix_Image_Optimization_Storage::content(); $test = Spinix_Image_Optimization_Storage::schemeless( $clean ); $base = Spinix_Image_Optimization_Storage::schemeless( $content['url'] ) . '/'; if ( 0 !== strpos( $test, $base ) || ! preg_match( '/\.css$/i', $clean ) || false !== strpos( $test, '/' . Spinix_Image_Optimization_Storage::DIRNAME . '/' ) ) { return false; } $rel = rawurldecode( substr( $test, strlen( $base ) ) ); $path = $content['dir'] . '/' . $rel; if ( false !== strpos( $rel, '..' ) || ! is_file( $path ) ) { return false; } $size = (int) filesize( $path ); $key = md5( $path . '|' . filemtime( $path ) . '|' . $size . '|' . SPINIX_IMAGE_OPTIMIZATION_VERSION ); self::load_cache(); $entry = isset( self::$cache[ $key ] ) && is_array( self::$cache[ $key ] ) ? self::$cache[ $key ] : null; if ( $entry && ( ! $write || null !== $entry['c'] ) ) { self::merge_backgrounds( $entry['b'] ); if ( ! $write || 'none' === $entry['c'] ) { return false; } if ( file_exists( Spinix_Image_Optimization_Storage::abs( $entry['c'] ) ) ) { return Spinix_Image_Optimization_Storage::url( $entry['c'] ); } } $result = $write ? 'none' : null; $bg = array(); if ( $size > 0 && $size <= self::MAX_CSS ) { $css = (string) Spinix_Image_Optimization_Filesystem::read( $path ); if ( false !== stripos( $css, 'url(' ) ) { $bg = self::collect_backgrounds( $css, $clean ); if ( $write ) { $changed = 0; $new = self::rewrite_css( $css, $clean, $changed ); if ( $changed > 0 ) { $copy_rel = self::SUBDIR . '/' . sanitize_file_name( pathinfo( $path, PATHINFO_FILENAME ) ) . '-' . substr( $key, 0, 10 ) . '.css'; $abs = Spinix_Image_Optimization_Storage::abs( $copy_rel ); if ( Spinix_Image_Optimization_Filesystem::write( $abs, '/* WebP copy by Spinix Image Optimization of ' . esc_url_raw( $clean ) . " */\n" . $new ) ) { $result = $copy_rel; } } } } } self::merge_backgrounds( $bg ); self::$cache[ $key ] = array( 'c' => $result, 'b' => $bg, ); self::$dirty = true; return ( $write && 'none' !== $result ) ? Spinix_Image_Optimization_Storage::url( $result ) : false; } /** * Add backgrounds to the page map (later stylesheets win, like in the browser). * * @param array $bg Map from collect_backgrounds(). */ private static function merge_backgrounds( $bg ) { foreach ( (array) $bg as $key => $set ) { if ( ! isset( self::$bg[ $key ] ) ) { self::$bg[ $key ] = array( 'base' => '', 'max' => array(), ); } if ( ! empty( $set['base'] ) ) { self::$bg[ $key ]['base'] = $set['base']; } foreach ( (array) ( isset( $set['max'] ) ? $set['max'] : array() ) as $w => $url ) { self::$bg[ $key ]['max'][ (int) $w ] = $url; } } } /** * Find the background images a stylesheet sets on elements that can be found in * the HTML: Elementor elements (.elementor-element-{id}) and simple #id / .class * selectors. Rules inside @media (max-width: N px) are kept per width, so phones, * tablets and desktops can each get their own image. * * @param string $css CSS. * @param string $base_url URL the CSS was loaded from. * @return array 'key' => array( 'base' => url, 'max' => array( px => url ) ). */ public static function collect_backgrounds( $css, $base_url ) { $out = array(); if ( false === stripos( $css, 'url(' ) ) { return $out; } $css = preg_replace( '#/\*.*?\*/#s', '', $css ); $rules = array(); $pos = 0; self::parse_block( (string) $css, $pos, 0, $rules ); foreach ( $rules as $rule ) { list( $media, $selector, $decls ) = $rule; if ( ! preg_match( '/background(?:-image)?\s*:[^;]*?url\(\s*([\'"]?)([^\'")]+)\1\s*\)/i', $decls, $u ) ) { continue; } $url = trim( $u[2] ); if ( '' === $url || 0 === stripos( $url, 'data:' ) || ! preg_match( '/\.(jpe?g|png|webp|avif)(\?.*)?$/i', $url ) ) { continue; } $url = self::absolute( html_entity_decode( $url, ENT_QUOTES ), $base_url ); foreach ( self::split_selector( $selector ) as $part ) { $key = self::selector_key( $part ); if ( '' === $key ) { continue; } if ( ! isset( $out[ $key ] ) ) { if ( count( $out ) >= 400 ) { break 2; } $out[ $key ] = array( 'base' => '', 'max' => array(), ); } if ( 0 === $media ) { $out[ $key ]['base'] = $url; } else { $out[ $key ]['max'][ $media ] = $url; } } } return $out; } /** * Read CSS rules recursively. $media: 0 = no media query, N = max-width N px, * -1 = a media query we do not use (print, min-width only, ...). * * @param string $css CSS. * @param int $pos Position (by reference). * @param int $media Media context. * @param array $out Rules (by reference): array( media, selector, declarations ). */ private static function parse_block( $css, &$pos, $media, &$out ) { $len = strlen( $css ); $max = 20000; while ( $pos < $len && $max-- > 0 ) { $open = strpos( $css, '{', $pos ); $close = strpos( $css, '}', $pos ); $semi = strpos( $css, ';', $pos ); if ( false !== $close && ( false === $open || $close < $open ) && ( false === $semi || $close < $semi ) ) { $pos = $close + 1; return; // End of the current block. } if ( false === $open ) { $pos = $len; return; } if ( false !== $semi && $semi < $open ) { $pos = $semi + 1; // At-rule without a block (@import, @charset). continue; } $prelude = trim( substr( $css, $pos, $open - $pos ) ); $pos = $open + 1; if ( 0 === stripos( $prelude, '@media' ) ) { $inner = $media; if ( -1 !== $media ) { if ( preg_match( '/max-width\s*:\s*(\d+(?:\.\d+)?)px/i', $prelude, $mw ) && ! preg_match( '/min-width|print/i', $prelude ) ) { $inner = (int) floor( (float) $mw[1] ); } elseif ( ! preg_match( '/^@media\s+(screen|all)\s*$/i', $prelude ) ) { $inner = -1; } } self::parse_block( $css, $pos, $inner, $out ); } elseif ( preg_match( '/^@(supports|layer|container|document)\b/i', $prelude ) ) { self::parse_block( $css, $pos, $media, $out ); } elseif ( '@' === substr( $prelude, 0, 1 ) ) { $skip = array(); self::parse_block( $css, $pos, -1, $skip ); // Skip font-face and keyframes blocks. } else { $end = strpos( $css, '}', $pos ); if ( false === $end ) { $pos = $len; return; } if ( -1 !== $media ) { $out[] = array( $media, $prelude, substr( $css, $pos, $end - $pos ) ); } $pos = $end + 1; } } } /** * Split a selector list on commas that are not inside parentheses. * * @param string $selector Selector list. * @return string[] */ private static function split_selector( $selector ) { $parts = array(); $depth = 0; $cur = ''; $len = strlen( $selector ); for ( $i = 0; $i < $len; $i++ ) { $c = $selector[ $i ]; if ( '(' === $c ) { ++$depth; } elseif ( ')' === $c ) { --$depth; } elseif ( ',' === $c && 0 === $depth ) { $parts[] = trim( $cur ); $cur = ''; continue; } $cur .= $c; } $parts[] = trim( $cur ); return array_filter( $parts ); } /** * Key of the element a selector styles: 'e:{id}' for Elementor elements, '#id' or * '.class' for simple selectors, '' when the element cannot be found in the HTML. * * @param string $part One selector (no commas). * @return string */ private static function selector_key( $part ) { if ( false !== strpos( $part, '::' ) || preg_match( '/:(before|after)\b/i', $part ) ) { return ''; // Pseudo-elements are decorations, not the element itself. } // Last compound selector (what the rule actually styles). $plain = preg_replace( '/\([^()]*\)/', '', $part ); $bits = preg_split( '/\s*[\s>+~]\s*/', trim( (string) $plain ) ); $last = (string) end( $bits ); if ( preg_match( '/\.elementor-element-([a-z0-9]+)/i', $last, $m ) ) { return 'e:' . strtolower( $m[1] ); } if ( ! preg_match( '/^[a-z0-9]*([#.][\w-]+)(?::not)?$/i', $last, $m ) ) { return ''; } // An #id is unique. A class only counts in a one-part selector (".hero"), because // in ".hero .layer" the class "layer" can be used by other elements too. return ( '#' === $m[1][0] || 1 === count( $bits ) ) ? $m[1] : ''; } /** * Load cache. */ private static function load_cache() { if ( null === self::$cache ) { $c = get_option( self::CACHE, array() ); self::$cache = is_array( $c ) ? $c : array(); } } /** * Persist cache. */ private static function save_cache() { if ( self::$dirty ) { update_option( self::CACHE, array_slice( self::$cache, -300, null, true ), false ); self::$dirty = false; } } /** * Forget all copies (after re-optimizing or deleting WebP files). */ public static function flush() { delete_option( self::CACHE ); self::$cache = null; foreach ( Spinix_Image_Optimization_Filesystem::files( Spinix_Image_Optimization_Storage::abs( self::SUBDIR ), 'css' ) as $f ) { Spinix_Image_Optimization_Filesystem::delete( $f ); } } }