diff --git a/inc/Engine/Optimization/CSSTrait.php b/inc/Engine/Optimization/CSSTrait.php index c0499e6bf9..f159c61552 100644 --- a/inc/Engine/Optimization/CSSTrait.php +++ b/inc/Engine/Optimization/CSSTrait.php @@ -4,6 +4,7 @@ use WP_Rocket\Dependencies\PathConverter\ConverterInterface; use WP_Rocket\Dependencies\PathConverter\Converter; +use WP_Rocket\Logger\Logger; trait CSSTrait { /** @@ -35,6 +36,10 @@ public function rewrite_paths( $source, $target, $content ) { */ $target = apply_filters( 'rocket_css_asset_target_path', $target ); + $content = $this->move( $this->get_converter( $source, $target ), $content, $source ); + + $content = $this->combine_imports( $content, $target ); + /** * Filters the content of a CSS file * @@ -44,7 +49,7 @@ public function rewrite_paths( $source, $target, $content ) { * @param string $source Source filepath. * @param string $target Target filepath. */ - return apply_filters( 'rocket_css_content', $this->move( $this->get_converter( $source, $target ), $content, $source ), $source, $target ); + return apply_filters( 'rocket_css_content', $content, $source, $target ); } /** @@ -191,6 +196,141 @@ protected function move( ConverterInterface $converter, $content, $source ) { 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["\']?) + + # fetch path + (?P.+?) + + # (optional) close path enclosure + (?P=quotes) + + # close url() + \) + + # (optional) trailing whitespace + \s* + + # (optional) media statement(s) + (?P[^;]*) + + # (optional) trailing whitespace + \s* + + # (optional) closing semi-colon + ;? + + /ix', + + // @import 'xxx' + '/ + + # import statement + @import + + # whitespace + \s+ + + # open path enclosure + (?P["\']) + + # fetch path + (?P.+?) + + # close path enclosure + (?P=quotes) + + # (optional) trailing whitespace + \s* + + # (optional) media statement(s) + (?P[^;]*) + + # (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; + } + + // 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. * @@ -219,4 +359,145 @@ function ( $matches ) { $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; + } + } diff --git a/tests/Fixtures/inc/Engine/Optimization/Minify/CSS/Combine/combine.php b/tests/Fixtures/inc/Engine/Optimization/Minify/CSS/Combine/combine.php index 3541d1a89d..ee9e01ee71 100644 --- a/tests/Fixtures/inc/Engine/Optimization/Minify/CSS/Combine/combine.php +++ b/tests/Fixtures/inc/Engine/Optimization/Minify/CSS/Combine/combine.php @@ -64,7 +64,53 @@ 'wp-content/cache/min/1/b6dcf622d68835c7b1cd01e3cb339560.css', 'wp-content/cache/min/1/b6dcf622d68835c7b1cd01e3cb339560.css.gz', ], - 'css' => '@import url(vfs://public/wp-content/themes/twentytwenty/style.css);body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}', + 'css' => 'body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}', + ], + + 'cdn_host' => [], + 'cdn_url' => 'http://example.org', + 'site_url' => 'http://example.org', + ], + + 'combineCssFilesWithImportJSFile' => [ + 'original' => + 'Sample Page' . + '' . + '', + + 'expected' => [ + 'html' => 'Sample Page' . + '' . + '', + 'files' => [ + 'wp-content/cache/min/1/c78ec42dba4ed16fe23582b1e3d03895.css', + 'wp-content/cache/min/1/c78ec42dba4ed16fe23582b1e3d03895.css.gz', + ], + 'css' => '@import url(vfs://public/wp-content/themes/twentytwenty/assets/script.js);', + ], + + 'cdn_host' => [], + 'cdn_url' => 'http://example.org', + 'site_url' => 'http://example.org', + ], + + 'combineCssFilesWithNestedImport' => [ + 'original' => + 'Sample Page' . + '' . + '' . + '' . + '', + + 'expected' => [ + 'html' => 'Sample Page' . + '' . + '', + 'files' => [ + 'wp-content/cache/min/1/a41edef8114680bb60b530fa32be3ca5.css', + 'wp-content/cache/min/1/a41edef8114680bb60b530fa32be3ca5.css.gz', + ], + 'css' => '@import "http://www.google.com/style.css";.style-import-external{color:green}.style-another-import2{color:green}.style-another-import{color:red}body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}', ], 'cdn_host' => [], @@ -88,7 +134,7 @@ 'wp-content/cache/min/1/afeb29591023f7eb6314ad594ca01138.css', 'wp-content/cache/min/1/afeb29591023f7eb6314ad594ca01138.css.gz', ], - 'css' => '@import url(vfs://public/wp-content/themes/twentytwenty/style.css);body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}', + 'css' => 'body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}', ], 'cdn_host' => [], @@ -247,7 +293,7 @@ 'wp-content/cache/min/1/074f89d1546ea3c6df831e874538e908.css', 'wp-content/cache/min/1/074f89d1546ea3c6df831e874538e908.css.gz', ], - 'css' => "@import url(vfs://public/wp-content/themes/twentytwenty/style.css);@font-face{font-display:swap;font-family:'FontAwesome';src:url(https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/../fonts/fontawesome-webfont.eot?v=4.7.0);src:url('https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/../fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'),url(https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/../fonts/fontawesome-webfont.woff2?v=4.7.0) format('woff2'),url(https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/../fonts/fontawesome-webfont.woff?v=4.7.0) format('woff'),url(https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/../fonts/fontawesome-webfont.ttf?v=4.7.0) format('truetype'),url('https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');font-weight:400;font-style:normal}body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}@font-face{font-display:swap;font-family:Helvetica}footer{color:red}", + 'css' => "@font-face{font-display:swap;font-family:'FontAwesome';src:url(https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/../fonts/fontawesome-webfont.eot?v=4.7.0);src:url('https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/../fonts/fontawesome-webfont.eot?#iefix&v=4.7.0') format('embedded-opentype'),url(https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/../fonts/fontawesome-webfont.woff2?v=4.7.0) format('woff2'),url(https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/../fonts/fontawesome-webfont.woff?v=4.7.0) format('woff'),url(https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/../fonts/fontawesome-webfont.ttf?v=4.7.0) format('truetype'),url('https://stackpath.bootstrapcdn.com/font-awesome/4.7.0/css/../fonts/fontawesome-webfont.svg?v=4.7.0#fontawesomeregular') format('svg');font-weight:400;font-style:normal}body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}@font-face{font-display:swap;font-family:Helvetica}body{font-family:Helvetica,Arial,sans-serif;text-align:center}footer{color:red}", ], 'cdn_host' => [], diff --git a/tests/Fixtures/inc/Engine/Optimization/Minify/CSS/Subscriber/process.php b/tests/Fixtures/inc/Engine/Optimization/Minify/CSS/Subscriber/process.php index 4851fb4df6..27536f2c14 100644 --- a/tests/Fixtures/inc/Engine/Optimization/Minify/CSS/Subscriber/process.php +++ b/tests/Fixtures/inc/Engine/Optimization/Minify/CSS/Subscriber/process.php @@ -377,7 +377,68 @@ 'wp-content/cache/min/1/5619350d0ac97fc96b40673a7e395900.css', 'wp-content/cache/min/1/5619350d0ac97fc96b40673a7e395900.css.gz', ], - 'css' => '@import url(vfs://public/wp-content/themes/twentytwenty/style.css);body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}', + 'css' => 'body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}', + ], + + 'settings' => [ + 'minify_concatenate_css' => 1, + 'cdn' => 0, + 'cdn_cnames' => [], + 'cdn_zone' => [], + ], + ], + + 'combineCssFilesWithImportJSFile' => [ + 'original' => '' . + '' . + 'Sample Page' . + '' . + '' . + '' . + '' . + '', + + 'expected' => [ + 'html' => '' . + '' . + 'Sample Page' . + '' . + '' . + '' . + '' . + '', + 'files' => [ + 'wp-content/cache/min/1/005e912f43deb4a5c82ff794ba94b288.css', + 'wp-content/cache/min/1/005e912f43deb4a5c82ff794ba94b288.css.gz', + ], + 'css' => '@import url(vfs://public/wp-content/themes/twentytwenty/assets/script.js);', + ], + + 'settings' => [ + 'minify_concatenate_css' => 1, + 'cdn' => 0, + 'cdn_cnames' => [], + 'cdn_zone' => [], + ], + ], + + 'combineCssFilesWithNestedImport' => [ + 'original' => + 'Sample Page' . + '' . + '' . + '' . + '', + + 'expected' => [ + 'html' => 'Sample Page' . + '' . + '', + 'files' => [ + 'wp-content/cache/min/1/29cb84c177f1a73204fd92c3d4dae284.css', + 'wp-content/cache/min/1/29cb84c177f1a73204fd92c3d4dae284.css.gz', + ], + 'css' => '@import "http://www.google.com/style.css";.style-import-external{color:green}.style-another-import2{color:green}.style-another-import{color:red}body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}', ], 'settings' => [ @@ -413,7 +474,7 @@ 'wp-content/cache/min/1/77d8f7c4cbcc265ddf66e8e60dab3e7c.css', 'wp-content/cache/min/1/77d8f7c4cbcc265ddf66e8e60dab3e7c.css.gz', ], - 'css' => '@import url(vfs://public/wp-content/themes/twentytwenty/style.css);body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}', + 'css' => 'body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}body{font-family:Helvetica,Arial,sans-serif;text-align:center}', ], 'settings' => [ diff --git a/tests/Fixtures/vfs-structure/optimizeMinify.php b/tests/Fixtures/vfs-structure/optimizeMinify.php index a3e9805709..da48bc06f3 100644 --- a/tests/Fixtures/vfs-structure/optimizeMinify.php +++ b/tests/Fixtures/vfs-structure/optimizeMinify.php @@ -26,6 +26,11 @@ 'script.js' => 'test', ], 'style-import.css' => '@import url(style.css)', + 'style-import2.css' => '@import url(style-another-import.css)', + 'style-another-import.css' => '@import url(style-another-import2.css);.style-another-import{color:red;}', + 'style-another-import2.css' => '@import "style-import-external.css";.style-another-import2{color:green;}', + 'style-import-external.css' => '@import "http://www.google.com/style.css";.style-import-external{color:green;}', + 'style-import-jsfile.css' => '@import url(assets/script.js)', 'new-style.css' => 'footer{color:red;}', 'final-style.css' => 'header{color:red;}' ], diff --git a/tests/Integration/inc/Engine/Optimization/Minify/CSS/Subscriber/process.php b/tests/Integration/inc/Engine/Optimization/Minify/CSS/Subscriber/process.php index 3326b36f92..6411fb112f 100644 --- a/tests/Integration/inc/Engine/Optimization/Minify/CSS/Subscriber/process.php +++ b/tests/Integration/inc/Engine/Optimization/Minify/CSS/Subscriber/process.php @@ -3,6 +3,7 @@ namespace WP_Rocket\Tests\Integration\inc\Engine\Optimization\Minify\CSS\Subscriber; use WP_Rocket\Tests\Integration\inc\Engine\Optimization\TestCase; +use Brain\Monkey\Functions; /** * @covers \WP_Rocket\Engine\Optimization\Minify\CSS\Subscriber::process diff --git a/tests/Unit/inc/Engine/Optimization/Minify/CSS/Combine/optimize.php b/tests/Unit/inc/Engine/Optimization/Minify/CSS/Combine/optimize.php index 8fb8ac9dc7..44ef680d93 100644 --- a/tests/Unit/inc/Engine/Optimization/Minify/CSS/Combine/optimize.php +++ b/tests/Unit/inc/Engine/Optimization/Minify/CSS/Combine/optimize.php @@ -62,6 +62,47 @@ public function testShouldCombineCSS( $original, $expected, $cdn_host, $cdn_url, Functions\when( 'esc_url' )->returnArg(); + Functions\when( 'site_url' )->alias( function( $path = '') { + return 'http://example.org/' . ltrim( $path, '/' ); + } ); + + Functions\when( 'wp_http_validate_url' )->alias( function( $path = '') { + if ( false !== strpos( 'vfs://', $path ) ) { + return $path; + } + return false; + } ); + + Functions\expect( 'wp_make_link_relative' )->andReturnUsing( function( $url ) { + return preg_replace( '|^(https?:)?//[^/]+(/?.*)|i', '$2', $url ); + } ); + + Functions\when( 'sanitize_text_field' )->returnArg(); + + Functions\when( 'wp_unslash' )->alias( + function ( $value ) { + return stripslashes( $value ); + } + ); + + Functions\expect( 'wp_check_filetype' )->andReturnUsing( function( $file, $mimes ) { + $filename_array = explode( '.', $file ); + $ext = false; + $type = false; + if ( $filename_array ) { + $ext = end( $filename_array ); + if ( isset( $mimes[$ext] ) ) { + $type = $mimes[$ext]; + }else{ + $type = $ext = false; + } + } + return [ + 'ext' => $ext, + 'type' => $type, + ]; + } ); + $this->assertSame( $this->format_the_html($expected['html']), $this->format_the_html( $this->combine->optimize( $original ) )