oont-contents/plugins/wp-rocket/inc/Engine/Optimization/CSSTrait.php
2025-02-08 15:10:23 +01:00

594 lines
14 KiB
PHP

<?php
declare(strict_types=1);
namespace WP_Rocket\Engine\Optimization;
use WP_Rocket\Dependencies\PathConverter\ConverterInterface;
use WP_Rocket\Dependencies\PathConverter\Converter;
trait CSSTrait {
/**
* Currently imported files.
*
* @var array
*/
private $imports = [];
/**
* Found charset on CSS.
*
* @var null|string
*/
private $found_charset = null;
/**
* Rewrites the paths inside the CSS file content
*
* @since 3.1
*
* @param string $source Source filepath.
* @param string $target Target filepath.
* @param string $content File content.
* @return string
*/
public function rewrite_paths( $source, $target, $content ) {
/**
* Filters the source path for an asset inside a CSS file
*
* @since 3.3.1
*
* @param string $source Source filepath.
*/
$source = apply_filters( 'rocket_css_asset_source_path', $source );
/**
* Filters the target path for an asset inside a CSS file
*
* @since 3.3.1
*
* @param string $target Target filepath.
*/
$target = apply_filters( 'rocket_css_asset_target_path', $target );
$content = $this->move( $this->get_converter( $source, $target ), $content, $source );
$this->set_cached_import( $source );
$content = $this->combine_imports( $content, $target );
/**
* Filters the content of a CSS file
*
* @since 3.4
*
* @param string $content CSS content.
* @param string $source Source filepath.
* @param string $target Target filepath.
*/
return apply_filters( 'rocket_css_content', $content, $source, $target );
}
/**
* Get an instance of the Converter class
*
* @param string $source Source filepath.
* @param string $target Destination filepath.
* @return Converter
*/
protected function get_converter( $source, $target ) {
return new Converter( $source, $target );
}
/**
* Moving a css file should update all relative urls.
* Relative references (e.g. ../images/image.gif) in a certain css file,
* will have to be updated when a file is being saved at another location
* (e.g. ../../images/image.gif, if the new CSS file is 1 folder deeper).
*
* Method copied from WP_Rocket\Dependencies\Minify\CSS;
*
* @param ConverterInterface $converter Relative path converter.
* @param string $content The CSS content to update relative urls for.
* @param string $source The source path or URL for the CSS file.
*
* @return string
*/
protected function move( ConverterInterface $converter, $content, $source ) {
/*
* Relative path references will usually be enclosed by url(). @import
* is an exception, where url() is not necessary around the path (but is
* allowed).
* This *could* be 1 regular expression, where both regular expressions
* in this array are on different sides of a |. But we're using named
* patterns in both regexes, the same name on both regexes. This is only
* possible with a (?J) modifier, but that only works after a fairly
* recent PCRE version. That's why I'm doing 2 separate regular
* expressions & combining the matches after executing of both.
*/
$relative_regexes = [
// url(xxx).
'/
# open url()
url\(
\s*
# open path enclosure
(?P<quotes>["\'])?
# fetch path
(?P<path>.+?)
# close path enclosure
(?(quotes)(?P=quotes))
\s*
# close url()
\)
/ix',
// @import "xxx"
'/
# import statement
@import
# whitespace
\s+
# we don\'t have to check for @import url(), because the
# condition above will already catch these
# open path enclosure
(?P<quotes>["\'])
# fetch path
(?P<path>.+?)
# close path enclosure
(?P=quotes)
/ix',
];
// find all relative urls in css.
$matches = [];
foreach ( $relative_regexes as $relative_regex ) {
if ( preg_match_all( $relative_regex, $content, $regex_matches, PREG_SET_ORDER ) ) {
$matches = array_merge( $matches, $regex_matches );
}
}
$search = [];
$replace = [];
// loop all urls.
foreach ( $matches as $match ) {
// determine if it's a url() or an @import match.
$type = ( strpos( $match[0], '@import' ) === 0 ? 'import' : 'url' );
$url = $match['path'];
if ( preg_match( '/^#/', $url ) ) {
continue;
}
if ( ! preg_match( '/^(data:|https?:|\\/)/', $url ) ) {
// attempting to interpret GET-params makes no sense, so let's discard them for awhile.
$params = strrchr( $url, '?' );
$url = $params ? substr( $url, 0, -strlen( $params ) ) : $url;
// fix relative url.
$url = filter_var( $source, FILTER_VALIDATE_URL ) ? dirname( $source ) . '/' . ltrim( $url, '/' ) : $converter->convert( $url );
// now that the path has been converted, re-apply GET-params.
$url .= $params;
}
/*
* Urls with control characters above 0x7e should be quoted.
* According to Mozilla's parser, whitespace is only allowed at the
* end of unquoted urls.
* Urls with `)` (as could happen with data: uris) should also be
* quoted to avoid being confused for the url() closing parentheses.
* And urls with a # have also been reported to cause issues.
* Urls with quotes inside should also remain escaped.
*
* @see https://developer.mozilla.org/nl/docs/Web/CSS/url#The_url()_functional_notation
* @see https://hg.mozilla.org/mozilla-central/rev/14abca4e7378
* @see https://github.com/matthiasmullie/minify/issues/193
*/
$url = trim( $url );
if ( preg_match( '/[\s\)\'"#\x{7f}-\x{9f}]/u', $url ) ) {
$url = $match['quotes'] . $url . $match['quotes'];
}
// build replacement.
$search[] = $match[0];
if ( 'url' === $type ) {
$replace[] = 'url(' . $url . ')';
} elseif ( 'import' === $type ) {
$replace[] = '@import "' . $url . '"';
}
}
// replace urls.
return str_replace( $search, $replace, $content );
}
/**
* Replace local imports with their contents recursively.
*
* @since 3.8.6
*
* @param string $content CSS Content.
* @param string $target Target CSS file path.
*
* @return string
*/
protected function combine_imports( $content, $target ) {
$import_regexes = [
// @import url(xxx)
'/
# import statement
@import
# whitespace
\s+
# open url()
url\(
# (optional) open path enclosure
(?P<quotes>["\']?)
# fetch path
(?P<path>.+?)
# (optional) close path enclosure
(?P=quotes)
# close url()
\)
# (optional) trailing whitespace
\s*
# (optional) media statement(s)
(?P<media>[^;]*)
# (optional) trailing whitespace
\s*
# (optional) closing semi-colon
;?
/ix',
// @import 'xxx'
'/
# import statement
@import
# whitespace
\s+
# open path enclosure
(?P<quotes>["\'])
# fetch path
(?P<path>.+?)
# close path enclosure
(?P=quotes)
# (optional) trailing whitespace
\s*
# (optional) media statement(s)
(?P<media>[^;]*)
# (optional) trailing whitespace
\s*
# (optional) closing semi-colon
;?
/ix',
];
// find all relative imports in css.
$matches = [];
foreach ( $import_regexes as $import_regexe ) {
if ( preg_match_all( $import_regexe, $content, $regex_matches, PREG_SET_ORDER ) ) {
$matches = array_merge( $matches, $regex_matches );
}
}
if ( empty( $matches ) ) {
return $content;
}
$search = [];
$replace = [];
// loop the matches.
foreach ( $matches as $match ) {
/**
* Filter Skip import replacement for one file.
*
* @since 3.8.6
*
* @param bool Skipped or not (Default not skipped).
* @param string $file_path Matched import path.
* @param string $import_match Full import match.
*/
if ( apply_filters( 'rocket_skip_import_replacement', false, $match['path'], $match ) ) {
continue;
}
list( $import_path, $import_content ) = $this->get_internal_file_contents( $match['path'], dirname( $target ) );
if ( empty( $import_content ) ) {
continue;
}
if ( $this->check_cached_import( $import_path ) ) {
$search[] = $match[0];
$replace[] = '';
continue;
}
$this->set_cached_import( $import_path );
// check if this is only valid for certain media.
if ( ! empty( $match['media'] ) ) {
$import_content = '@media ' . $match['media'] . '{' . $import_content . '}';
}
// Use recursion to rewrite paths and combine imports again for imported content.
$import_content = $this->rewrite_paths( $import_path, $target, $import_content );
// add to replacement array.
$search[] = $match[0];
$replace[] = $import_content;
}
// replace the import statements.
return str_replace( $search, $replace, $content );
}
/**
* Applies font-display:swap to all font-family rules without a previously set font-display property.
*
* @since 3.7
*
* @param string $css_file_content CSS file content to modify.
*
* @return string Modified CSS content.
*/
private function apply_font_display_swap( $css_file_content ) {
$css_file_content = (string) $css_file_content;
return preg_replace_callback(
'/(?:@font-face)\s*{(?<value>[^}]+)}/i',
function ( $matches ) {
if ( preg_match( '/font-display:\s*(?<swap_value>\w*);?/i', $matches['value'], $attribute ) ) {
if ( 'swap' === strtolower( $attribute['swap_value'] ) ) {
return $matches[0];
}
$swap = str_replace( $attribute['swap_value'], 'swap', $attribute[0] );
return preg_replace( '/font-display:\s*(?<swap_value>\w*);?/i', $swap, $matches[0] );
}
return str_replace( $matches['value'], "font-display:swap;{$matches['value']}", $matches[0] );
},
$css_file_content
);
}
/**
* Get internal file full path and contents.
*
* @since 3.8.6
*
* @param string $file Internal file path (maybe external url or relative path).
* @param string $base_path Base path as reference for relative paths.
*
* @return array Array of two values ( full path, contents )
*/
private function get_internal_file_contents( $file, $base_path ) {
if ( $this->is_external_path( $file ) && wp_http_validate_url( $file ) ) {
return [ $file, false ];
}
// Remove query strings.
$file = str_replace( '?' . wp_parse_url( $file, PHP_URL_QUERY ), '', $file );
// Check if this file is readable or it's relative path so we add base_path at it's start.
if ( ! rocket_direct_filesystem()->is_readable( $this->get_local_path( $file ) ) ) {
$ds = rocket_get_constant( 'DIRECTORY_SEPARATOR' );
$file = $base_path . $ds . str_replace( '/', $ds, $file );
}else {
$file = $this->get_local_path( $file );
}
$file_type = wp_check_filetype( $file, [ 'css' => 'text/css' ] );
if ( 'css' !== $file_type['ext'] ) {
return [ $file, null ];
}
$import_content = rocket_direct_filesystem()->get_contents( $file );
return [ $file, $import_content ];
}
/**
* Determines if the file is external.
*
* @since 3.8.6
*
* @param string $url URL of the file.
* @return bool True if external, false otherwise.
*/
protected function is_external_path( $url ) {
$file = get_rocket_parse_url( $url );
if ( empty( $file['path'] ) ) {
return true;
}
$parsed_site_url = wp_parse_url( site_url() );
if ( empty( $parsed_site_url['host'] ) ) {
return true;
}
// This filter is documented in inc/Engine/Admin/Settings/Settings.php.
$hosts = (array) apply_filters( 'rocket_cdn_hosts', [], [ 'all' ] );
$hosts[] = $parsed_site_url['host'];
$langs = get_rocket_i18n_uri();
// Get host for all langs.
foreach ( $langs as $lang ) {
$url_host = wp_parse_url( $lang, PHP_URL_HOST );
if ( ! isset( $url_host ) ) {
continue;
}
$hosts[] = $url_host;
}
$hosts = array_unique( $hosts );
if ( empty( $hosts ) ) {
return true;
}
// URL has domain and domain is part of the internal domains.
if ( ! empty( $file['host'] ) ) {
foreach ( $hosts as $host ) {
if ( false !== strpos( $url, $host ) ) {
return false;
}
}
return true;
}
return false;
}
/**
* Get local absolute path for image.
*
* @since 3.8.6
*
* @param string $url Image url.
*
* @return string Image absolute local path.
*/
private function get_local_path( $url ) {
$url = $this->normalize_url( $url );
$path = rocket_url_to_path( $url );
if ( $path ) {
return $path;
}
$relative_url = ltrim( wp_make_link_relative( $url ), '/' );
$ds = rocket_get_constant( 'DIRECTORY_SEPARATOR' );
$base_path = isset( $_SERVER['DOCUMENT_ROOT'] ) ? ( sanitize_text_field( wp_unslash( $_SERVER['DOCUMENT_ROOT'] ) ) . $ds ) : '';
return $base_path . str_replace( '/', $ds, $relative_url );
}
/**
* Normalize relative url to full url.
*
* @since 3.8.6
*
* @param string $url Url to be normalized.
*
* @return string Normalized url.
*/
private function normalize_url( $url ) {
$url_host = wp_parse_url( $url, PHP_URL_HOST );
if ( ! empty( $url_host ) ) {
return $url;
}
$relative_url = ltrim( wp_make_link_relative( $url ), '/' );
$site_url_components = wp_parse_url( site_url( '/' ) );
return $site_url_components['scheme'] . '://' . $site_url_components['host'] . '/' . $relative_url;
}
/**
* Set cached import locally not to imported again.
*
* @param string $path Path to be cached.
*/
private function set_cached_import( string $path ) {
$real_path = rocket_realpath( $path );
$this->imports[ md5( $real_path ) ] = $real_path;
}
/**
* Check if path imported before.
*
* @param string $path Path to be checked.
*
* @return bool
*/
private function check_cached_import( string $path ) : bool {
return isset( $this->imports[ md5( rocket_realpath( $path ) ) ] );
}
/**
* Move charset to top of CSS file OR remove all charsets for internal CSS.
*
* @param string $content CSS content.
* @param bool $keep_first_charset Keep first charset if true, otherwise remove all charsets.
*
* @return string
*/
public function handle_charsets( string $content, bool $keep_first_charset = true ) : string {
$new_content = preg_replace_callback( '/@charset\s+["|\'](.*?)["|\'];?/i', [ $this, 'match_charsets' ], $content );
if ( ! $keep_first_charset ) {
return $new_content;
}
if ( is_null( $this->found_charset ) ) {
return $content;
}
return "@charset \"{$this->found_charset}\";" . $new_content;
}
/**
* Match each charset on the CSS file.
*
* @param array $match Match array.
*
* @return string
*/
private function match_charsets( array $match ) : string {
if ( is_null( $this->found_charset ) ) {
$this->found_charset = $match[1];
}
return '';
}
}