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