core/includes/common.inc | 550 +------------------ .../Core/Asset/AssetCollectionGrouperInterface.php | 24 + .../Asset/AssetCollectionOptimizerInterface.php | 24 + .../Asset/AssetCollectionRendererInterface.php | 24 + .../lib/Drupal/Core/Asset/AssetDumperInterface.php | 24 + .../Drupal/Core/Asset/AssetOptimizerInterface.php | 24 + .../lib/Drupal/Core/Asset/CssCollectionGrouper.php | 86 +++ .../Drupal/Core/Asset/CssCollectionOptimizer.php | 159 ++++++ .../Drupal/Core/Asset/CssCollectionRenderer.php | 173 ++++++ core/lib/Drupal/Core/Asset/CssDumper.php | 52 ++ core/lib/Drupal/Core/Asset/CssOptimizer.php | 214 ++++++++ .../Core/Asset/CssCollectionGrouperUnitTest.php | 216 ++++++++ .../Core/Asset/CssCollectionRendererUnitTest.php | 572 ++++++++++++++++++++ .../Tests/Core/Asset/CssOptimizerUnitTest.php | 199 +++++++ core/tests/Drupal/Tests/Core/Asset/bar.css | 14 + core/tests/Drupal/Tests/Core/Asset/foo.css | 5 + 16 files changed, 1823 insertions(+), 537 deletions(-) diff --git a/core/includes/common.inc b/core/includes/common.inc index bee692e..ccd3913 100644 --- a/core/includes/common.inc +++ b/core/includes/common.inc @@ -20,6 +20,12 @@ use Drupal\Core\SystemListingInfo; use Drupal\Core\Template\Attribute; +use Drupal\Core\Asset\CssCollectionRenderer; +use Drupal\Core\Asset\CssCollectionOptimizer; +use Drupal\Core\Asset\CssCollectionGrouper; +use Drupal\Core\Asset\CssOptimizer; +use Drupal\Core\Asset\CssDumper; + /** * @file * Common functions that many Drupal modules will need to reference. @@ -2018,154 +2024,6 @@ function drupal_sort_css_js($a, $b) { } /** - * Grouping callback: Groups CSS items by their types, media, and browsers. - * - * This function arranges the CSS items that are in the #items property of the - * styles element into groups. Arranging the CSS items into groups serves two - * purposes. When aggregation is enabled, files within a group are aggregated - * into a single file, significantly improving page loading performance by - * minimizing network traffic overhead. When aggregation is disabled, grouping - * allows multiple files to be loaded from a single STYLE tag, enabling sites - * with many modules enabled or a complex theme being used to stay within IE's - * 31 CSS inclusion tag limit: http://drupal.org/node/228818. - * - * This function puts multiple items into the same group if they are groupable - * and if they are for the same 'media' and 'browsers'. Items of the 'file' type - * are groupable if their 'preprocess' flag is TRUE, items of the 'inline' type - * are always groupable, and items of the 'external' type are never groupable. - * This function also ensures that the process of grouping items does not change - * their relative order. This requirement may result in multiple groups for the - * same type, media, and browsers, if needed to accommodate other items in - * between. - * - * @param $css - * An array of CSS items, as returned by drupal_add_css(), but after - * alteration performed by drupal_get_css(). - * - * @return - * An array of CSS groups. Each group contains the same keys (e.g., 'media', - * 'data', etc.) as a CSS item from the $css parameter, with the value of - * each key applying to the group as a whole. Each group also contains an - * 'items' key, which is the subset of items from $css that are in the group. - * - * @see drupal_pre_render_styles() - * @see system_element_info() - */ -function drupal_group_css($css) { - $groups = array(); - // If a group can contain multiple items, we track the information that must - // be the same for each item in the group, so that when we iterate the next - // item, we can determine if it can be put into the current group, or if a - // new group needs to be made for it. - $current_group_keys = NULL; - // When creating a new group, we pre-increment $i, so by initializing it to - // -1, the first group will have index 0. - $i = -1; - foreach ($css as $item) { - // The browsers for which the CSS item needs to be loaded is part of the - // information that determines when a new group is needed, but the order of - // keys in the array doesn't matter, and we don't want a new group if all - // that's different is that order. - ksort($item['browsers']); - - // If the item can be grouped with other items, set $group_keys to an array - // of information that must be the same for all items in its group. If the - // item can't be grouped with other items, set $group_keys to FALSE. We - // put items into a group that can be aggregated together: whether they will - // be aggregated is up to the _drupal_css_aggregate() function or an - // override of that function specified in hook_css_alter(), but regardless - // of the details of that function, a group represents items that can be - // aggregated. Since a group may be rendered with a single HTML tag, all - // items in the group must share the same information that would need to be - // part of that HTML tag. - switch ($item['type']) { - case 'file': - // Group file items if their 'preprocess' flag is TRUE. - // Help ensure maximum reuse of aggregate files by only grouping - // together items that share the same 'group' value and 'every_page' - // flag. See drupal_add_css() for details about that. - $group_keys = $item['preprocess'] ? array($item['type'], $item['group'], $item['every_page'], $item['media'], $item['browsers']) : FALSE; - break; - case 'inline': - // Always group inline items. - $group_keys = array($item['type'], $item['media'], $item['browsers']); - break; - case 'external': - // Do not group external items. - $group_keys = FALSE; - break; - } - - // If the group keys don't match the most recent group we're working with, - // then a new group must be made. - if ($group_keys !== $current_group_keys) { - $i++; - // Initialize the new group with the same properties as the first item - // being placed into it. The item's 'data' and 'weight' properties are - // unique to the item and should not be carried over to the group. - $groups[$i] = $item; - unset($groups[$i]['data'], $groups[$i]['weight']); - $groups[$i]['items'] = array(); - $current_group_keys = $group_keys ? $group_keys : NULL; - } - - // Add the item to the current group. - $groups[$i]['items'][] = $item; - } - return $groups; -} - -/** - * Aggregation callback: Aggregates CSS files and inline content. - * - * Having the browser load fewer CSS files results in much faster page loads - * than when it loads many small files. This function aggregates files within - * the same group into a single file unless the site-wide setting to do so is - * disabled (commonly the case during site development). To optimize download, - * it also compresses the aggregate files by removing comments, whitespace, and - * other unnecessary content. Additionally, this functions aggregates inline - * content together, regardless of the site-wide aggregation setting. - * - * @param $css_groups - * An array of CSS groups as returned by drupal_group_css(). This function - * modifies the group's 'data' property for each group that is aggregated. - * - * @see drupal_group_css() - * @see drupal_pre_render_styles() - * @see system_element_info() - */ -function drupal_aggregate_css(&$css_groups) { - // Only aggregate during normal site operation. - if (defined('MAINTENANCE_MODE')) { - $preprocess_css = FALSE; - } - else { - $config = config('system.performance'); - $preprocess_css = $config->get('css.preprocess'); - } - - // For each group that needs aggregation, aggregate its items. - foreach ($css_groups as $key => $group) { - switch ($group['type']) { - // If a file group can be aggregated into a single file, do so, and set - // the group's data property to the file path of the aggregate file. - case 'file': - if ($group['preprocess'] && $preprocess_css) { - $css_groups[$key]['data'] = drupal_build_css_cache($group['items']); - } - break; - // Aggregate all inline CSS content into the group's data property. - case 'inline': - $css_groups[$key]['data'] = ''; - foreach ($group['items'] as $item) { - $css_groups[$key]['data'] .= drupal_load_stylesheet_content($item['data'], $item['preprocess']); - } - break; - } - } -} - -/** * Pre-render callback: Adds the elements needed for CSS tags to be rendered. * * For production websites, LINK tags are preferable to STYLE tags with @import @@ -2215,11 +2073,6 @@ function drupal_aggregate_css(&$css_groups) { * A render array containing: * - '#items': The CSS items as returned by drupal_add_css() and altered by * drupal_get_css(). - * - '#group_callback': A function to call to group #items to enable the use - * of fewer tags by aggregating files and/or using multiple @import - * statements within a single tag. - * - '#aggregate_callback': A function to call to aggregate the items within - * the groups arranged by the #group_callback function. * * @return * A render array that will render to a string of XHTML CSS tags. @@ -2227,393 +2080,16 @@ function drupal_aggregate_css(&$css_groups) { * @see drupal_get_css() */ function drupal_pre_render_styles($elements) { - // Group and aggregate the items. - if (isset($elements['#group_callback'])) { - $elements['#groups'] = $elements['#group_callback']($elements['#items']); - } - if (isset($elements['#aggregate_callback'])) { - $elements['#aggregate_callback']($elements['#groups']); - } - - // A dummy query-string is added to filenames, to gain control over - // browser-caching. The string changes on every update or full cache - // flush, forcing browsers to load a new copy of the files, as the - // URL changed. - $query_string = variable_get('css_js_query_string', '0'); - - // For inline CSS to validate as XHTML, all CSS containing XHTML needs to be - // wrapped in CDATA. To make that backwards compatible with HTML 4, we need to - // comment out the CDATA-tag. - $embed_prefix = "\n/* */\n"; - - // Defaults for LINK and STYLE elements. - $link_element_defaults = array( - '#type' => 'html_tag', - '#tag' => 'link', - '#attributes' => array( - 'rel' => 'stylesheet', - ), - ); - $style_element_defaults = array( - '#type' => 'html_tag', - '#tag' => 'style', - ); - - // Loop through each group. - foreach ($elements['#groups'] as $group) { - switch ($group['type']) { - // For file items, there are three possibilites. - // - The group has been aggregated: in this case, output a LINK tag for - // the aggregate file. - // - The group can be aggregated but has not been (most likely because - // the site administrator disabled the site-wide setting): in this case, - // output as few STYLE tags for the group as possible, using @import - // statement for each file in the group. This enables us to stay within - // IE's limit of 31 total CSS inclusion tags. - // - The group contains items not eligible for aggregation (their - // 'preprocess' flag has been set to FALSE): in this case, output a LINK - // tag for each file. - case 'file': - // The group has been aggregated into a single file: output a LINK tag - // for the aggregate file. - if (isset($group['data'])) { - $element = $link_element_defaults; - $element['#attributes']['href'] = file_create_url($group['data']); - $element['#attributes']['media'] = $group['media']; - $element['#browsers'] = $group['browsers']; - $elements[] = $element; - } - // The group can be aggregated, but hasn't been: combine multiple items - // into as few STYLE tags as possible. - elseif ($group['preprocess']) { - $import = array(); - foreach ($group['items'] as $item) { - // The dummy query string needs to be added to the URL to control - // browser-caching. IE7 does not support a media type on the - // @import statement, so we instead specify the media for the - // group on the STYLE tag. - $import[] = '@import url("' . check_plain(file_create_url($item['data']) . '?' . $query_string) . '");'; - } - // In addition to IE's limit of 31 total CSS inclusion tags, it also - // has a limit of 31 @import statements per STYLE tag. - while (!empty($import)) { - $import_batch = array_slice($import, 0, 31); - $import = array_slice($import, 31); - $element = $style_element_defaults; - // This simplifies the JavaScript regex, allowing each line - // (separated by \n) to be treated as a completely different string. - // This means that we can use ^ and $ on one line at a time, and not - // worry about style tags since they'll never match the regex. - $element['#value'] = "\n" . implode("\n", $import_batch) . "\n"; - $element['#attributes']['media'] = $group['media']; - $element['#browsers'] = $group['browsers']; - $elements[] = $element; - } - } - // The group contains items ineligible for aggregation: output a LINK - // tag for each file. - else { - foreach ($group['items'] as $item) { - $element = $link_element_defaults; - // The dummy query string needs to be added to the URL to control - // browser-caching. - $query_string_separator = (strpos($item['data'], '?') !== FALSE) ? '&' : '?'; - $element['#attributes']['href'] = file_create_url($item['data']) . $query_string_separator . $query_string; - $element['#attributes']['media'] = $item['media']; - $element['#browsers'] = $group['browsers']; - $elements[] = $element; - } - } - break; - // For inline content, the 'data' property contains the CSS content. If - // the group's 'data' property is set, then output it in a single STYLE - // tag. Otherwise, output a separate STYLE tag for each item. - case 'inline': - if (isset($group['data'])) { - $element = $style_element_defaults; - $element['#value'] = $group['data']; - $element['#value_prefix'] = $embed_prefix; - $element['#value_suffix'] = $embed_suffix; - $element['#attributes']['media'] = $group['media']; - $element['#browsers'] = $group['browsers']; - $elements[] = $element; - } - else { - foreach ($group['items'] as $item) { - $element = $style_element_defaults; - $element['#value'] = $item['data']; - $element['#value_prefix'] = $embed_prefix; - $element['#value_suffix'] = $embed_suffix; - $element['#attributes']['media'] = $item['media']; - $element['#browsers'] = $group['browsers']; - $elements[] = $element; - } - } - break; - // Output a LINK tag for each external item. The item's 'data' property - // contains the full URL. - case 'external': - foreach ($group['items'] as $item) { - $element = $link_element_defaults; - $element['#attributes']['href'] = $item['data']; - $element['#attributes']['media'] = $item['media']; - $element['#browsers'] = $group['browsers']; - $elements[] = $element; - } - break; - } - } - - return $elements; -} + $renderer = new CssCollectionRenderer(); + $css_assets = $elements['#items']; -/** - * Aggregates and optimizes CSS files into a cache file in the files directory. - * - * The file name for the CSS cache file is generated from the hash of the - * aggregated contents of the files in $css. This forces proxies and browsers - * to download new CSS when the CSS changes. - * - * The cache file name is retrieved on a page load via a lookup variable that - * contains an associative array. The array key is the hash of the file names - * in $css while the value is the cache file name. The cache file is generated - * in two cases. First, if there is no file name value for the key, which will - * happen if a new file name has been added to $css or after the lookup - * variable is emptied to force a rebuild of the cache. Second, the cache file - * is generated if it is missing on disk. Old cache files are not deleted - * immediately when the lookup variable is emptied, but are deleted after a set - * period by drupal_delete_file_if_stale(). This ensures that files referenced - * by a cached page will still be available. - * - * @param $css - * An array of CSS files to aggregate and compress into one file. - * - * @return - * The URI of the CSS cache file, or FALSE if the file could not be saved. - */ -function drupal_build_css_cache($css) { - $data = ''; - $uri = ''; - $map = Drupal::state()->get('drupal_css_cache_files') ?: array(); - // Create a new array so that only the file names are used to create the hash. - // This prevents new aggregates from being created unnecessarily. - $css_data = array(); - foreach ($css as $css_file) { - $css_data[] = $css_file['data']; - } - $key = hash('sha256', serialize($css_data)); - if (isset($map[$key])) { - $uri = $map[$key]; + // Aggregate the CSS if necessary, but only during normal site operation. + if (!defined('MAINTENANCE_MODE') && config('system.performance')->get('css.preprocess')) { + $optimizer = new CssCollectionOptimizer(new CssCollectionGrouper(), new CssOptimizer(), new CssDumper()); + $css_assets = $optimizer->optimize($css_assets); } - if (empty($uri) || !file_exists($uri)) { - // Build aggregate CSS file. - foreach ($css as $stylesheet) { - // Only 'file' stylesheets can be aggregated. - if ($stylesheet['type'] == 'file') { - $contents = drupal_load_stylesheet($stylesheet['data'], TRUE); - - // Get the parent directory of this file, relative to the Drupal root. - $css_base_url = substr($stylesheet['data'], 0, strrpos($stylesheet['data'], '/')); - _drupal_build_css_path(NULL, $css_base_url . '/'); - // Anchor all paths in the CSS with its base URL, ignoring external and absolute paths. - $data .= preg_replace_callback('/url\(\s*[\'"]?(?![a-z]+:|\/+)([^\'")]+)[\'"]?\s*\)/i', '_drupal_build_css_path', $contents); - } - } - - // Per the W3C specification at http://www.w3.org/TR/REC-CSS2/cascade.html#at-import, - // @import rules must proceed any other style, so we move those to the top. - $regexp = '/@import[^;]+;/i'; - preg_match_all($regexp, $data, $matches); - $data = preg_replace($regexp, '', $data); - $data = implode('', $matches[0]) . $data; - - // Prefix filename to prevent blocking by firewalls which reject files - // starting with "ad*". - $filename = 'css_' . Crypt::hashBase64($data) . '.css'; - // Create the css/ within the files folder. - $csspath = 'public://css'; - $uri = $csspath . '/' . $filename; - // Create the CSS file. - file_prepare_directory($csspath, FILE_CREATE_DIRECTORY); - if (!file_exists($uri) && !file_unmanaged_save_data($data, $uri, FILE_EXISTS_REPLACE)) { - return FALSE; - } - // If CSS gzip compression is enabled and the zlib extension is available - // then create a gzipped version of this file. This file is served - // conditionally to browsers that accept gzip using .htaccess rules. - // It's possible that the rewrite rules in .htaccess aren't working on this - // server, but there's no harm (other than the time spent generating the - // file) in generating the file anyway. Sites on servers where rewrite rules - // aren't working can set css.gzip to FALSE in order to skip - // generating a file that won't be used. - if (config('system.performance')->get('css.gzip') && extension_loaded('zlib')) { - if (!file_exists($uri . '.gz') && !file_unmanaged_save_data(gzencode($data, 9, FORCE_GZIP), $uri . '.gz', FILE_EXISTS_REPLACE)) { - return FALSE; - } - } - // Save the updated map. - $map[$key] = $uri; - Drupal::state()->set('drupal_css_cache_files', $map); - } - return $uri; -} - -/** - * Prefixes all paths within a CSS file for drupal_build_css_cache(). - */ -function _drupal_build_css_path($matches, $base = NULL) { - $_base = &drupal_static(__FUNCTION__); - // Store base path for preg_replace_callback. - if (isset($base)) { - $_base = $base; - } - - // Prefix with base and remove '../' segments where possible. - $path = $_base . $matches[1]; - $last = ''; - while ($path != $last) { - $last = $path; - $path = preg_replace('`(^|/)(?!\.\./)([^/]+)/\.\./`', '$1', $path); - } - return 'url(' . file_create_url($path) . ')'; -} - -/** - * Loads the stylesheet and resolves all @import commands. - * - * Loads a stylesheet and replaces @import commands with the contents of the - * imported file. Use this instead of file_get_contents when processing - * stylesheets. - * - * The returned contents are compressed removing white space and comments only - * when CSS aggregation is enabled. This optimization will not apply for - * color.module enabled themes with CSS aggregation turned off. - * - * @param $file - * Name of the stylesheet to be processed. - * @param $optimize - * Defines if CSS contents should be compressed or not. - * @param $reset_basepath - * Used internally to facilitate recursive resolution of @import commands. - * - * @return - * Contents of the stylesheet, including any resolved @import commands. - */ -function drupal_load_stylesheet($file, $optimize = NULL, $reset_basepath = TRUE) { - // These statics are not cache variables, so we don't use drupal_static(). - static $_optimize, $basepath; - if ($reset_basepath) { - $basepath = ''; - } - // Store the value of $optimize for preg_replace_callback with nested - // @import loops. - if (isset($optimize)) { - $_optimize = $optimize; - } - - // Stylesheets are relative one to each other. Start by adding a base path - // prefix provided by the parent stylesheet (if necessary). - if ($basepath && !file_uri_scheme($file)) { - $file = $basepath . '/' . $file; - } - $basepath = dirname($file); - - // Load the CSS stylesheet. We suppress errors because themes may specify - // stylesheets in their .info.yml file that don't exist in the theme's path, - // but are merely there to disable certain module CSS files. - if ($contents = @file_get_contents($file)) { - // Return the processed stylesheet. - return drupal_load_stylesheet_content($contents, $_optimize); - } - - return ''; -} - -/** - * Processes the contents of a stylesheet for aggregation. - * - * @param $contents - * The contents of the stylesheet. - * @param $optimize - * (optional) Boolean whether CSS contents should be minified. Defaults to - * FALSE. - * - * @return - * Contents of the stylesheet including the imported stylesheets. - */ -function drupal_load_stylesheet_content($contents, $optimize = FALSE) { - // Remove multiple charset declarations for standards compliance (and fixing Safari problems). - $contents = preg_replace('/^@charset\s+[\'"](\S*)\b[\'"];/i', '', $contents); - - if ($optimize) { - // Perform some safe CSS optimizations. - // Regexp to match comment blocks. - $comment = '/\*[^*]*\*+(?:[^/*][^*]*\*+)*/'; - // Regexp to match double quoted strings. - $double_quot = '"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"'; - // Regexp to match single quoted strings. - $single_quot = "'[^'\\\\]*(?:\\\\.[^'\\\\]*)*'"; - // Strip all comment blocks, but keep double/single quoted strings. - $contents = preg_replace( - "<($double_quot|$single_quot)|$comment>Ss", - "$1", - $contents - ); - // Remove certain whitespace. - // There are different conditions for removing leading and trailing - // whitespace. - // @see http://php.net/manual/regexp.reference.subpatterns.php - $contents = preg_replace('< - # Strip leading and trailing whitespace. - \s*([@{};,])\s* - # Strip only leading whitespace from: - # - Closing parenthesis: Retain "@media (bar) and foo". - | \s+([\)]) - # Strip only trailing whitespace from: - # - Opening parenthesis: Retain "@media (bar) and foo". - # - Colon: Retain :pseudo-selectors. - | ([\(:])\s+ - >xS', - // Only one of the three capturing groups will match, so its reference - // will contain the wanted value and the references for the - // two non-matching groups will be replaced with empty strings. - '$1$2$3', - $contents - ); - // End the file with a new line. - $contents = trim($contents); - $contents .= "\n"; - } - - // Replaces @import commands with the actual stylesheet content. - // This happens recursively but omits external files. - $contents = preg_replace_callback('/@import\s*(?:url\(\s*)?[\'"]?(?![a-z]+:)([^\'"\()]+)[\'"]?\s*\)?\s*;/', '_drupal_load_stylesheet', $contents); - return $contents; -} - -/** - * Loads stylesheets recursively and returns contents with corrected paths. - * - * This function is used for recursive loading of stylesheets and - * returns the stylesheet content with all url() paths corrected. - */ -function _drupal_load_stylesheet($matches) { - $filename = $matches[1]; - // Load the imported stylesheet and replace @import commands in there as well. - $file = drupal_load_stylesheet($filename, NULL, FALSE); - - // Determine the file's directory. - $directory = dirname($filename); - // If the file is in the current directory, make sure '.' doesn't appear in - // the url() path. - $directory = $directory == '.' ? '' : $directory .'/'; - - // Alter all internal url() paths. Leave external paths alone. We don't need - // to normalize absolute paths here (i.e. remove folder/... segments) because - // that will be done later. - return preg_replace('/url\(\s*([\'"]?)(?![a-z]+:|\/+)/i', 'url(\1'. $directory, $file); + return $renderer->render($css_assets); } /** diff --git a/core/lib/Drupal/Core/Asset/AssetCollectionGrouperInterface.php b/core/lib/Drupal/Core/Asset/AssetCollectionGrouperInterface.php new file mode 100644 index 0000000..00d427f --- /dev/null +++ b/core/lib/Drupal/Core/Asset/AssetCollectionGrouperInterface.php @@ -0,0 +1,24 @@ +grouper = $grouper; + $this->optimizer = $optimizer; + $this->dumper = $dumper; + } + + /** + * {@inheritdoc} + * + * The cache file name is retrieved on a page load via a lookup variable that + * contains an associative array. The array key is the hash of the file names + * in $css while the value is the cache file name. The cache file is generated + * in two cases. First, if there is no file name value for the key, which will + * happen if a new file name has been added to $css or after the lookup + * variable is emptied to force a rebuild of the cache. Second, the cache file + * is generated if it is missing on disk. Old cache files are not deleted + * immediately when the lookup variable is emptied, but are deleted after a + * set period by drupal_delete_file_if_stale(). This ensures that files + * referenced by a cached page will still be available. + */ + public function optimize(array $css_assets) { + // Group the assets. + $css_groups = $this->grouper->group($css_assets); + + // Now optimize (concatenate + minify) and dump each asset group, unless + // that was already done, in which case it should appear in + // drupal_css_cache_files. + // Drupal contrib can override this default CSS aggregator to keep the same + // grouping, optimizing and dumping, but change the strategy that is used to + // determine when the aggregate should be rebuilt (e.g. mtime, HTTPS …). + $map = Drupal::state()->get('drupal_css_cache_files') ?: array(); + $css_assets = array(); + foreach ($css_groups as $order => $css_group) { + // We have to return a single asset, not a group of assets. It is now up + // to one of the pieces of code in the switch statement below to set the + // 'data' property to the appropriate value. + $css_assets[$order] = $css_group; + unset($css_assets[$order]['items']); + + switch ($css_group['type']) { + case 'file': + // No preprocessing, single CSS asset: just use the existing URI. + if (!$css_group['preprocess']) { + $uri = $css_group['items'][0]['data']; + $css_assets[$order]['data'] = $uri; + } + // Preprocess (aggregate), unless the aggregate file already exists. + else { + $key = $this->generateHash($css_group); + $uri = ''; + if (isset($map[$key])) { + $uri = $map[$key]; + } + if (empty($uri) || !file_exists($uri)) { + // Optimize each asset within the group. + $data = ''; + foreach ($css_group['items'] as $css_asset) { + $data .= $this->optimizer->optimize($css_asset); + } + // Per the W3C specification at http://www.w3.org/TR/REC-CSS2/cascade.html#at-import, + // @import rules must proceed any other style, so we move those to the top. + $regexp = '/@import[^;]+;/i'; + preg_match_all($regexp, $data, $matches); + $data = preg_replace($regexp, '', $data); + $data = implode('', $matches[0]) . $data; + // Dump the optimized CSS for this group into an aggregate file. + $uri = $this->dumper->dump($data); + // Set the URI for this group's aggregate file. + $css_assets[$order]['data'] = $uri; + // Persist the URI for this aggregate file. + $map[$key] = $uri; + Drupal::state()->set('drupal_css_cache_files', $map); + } + else { + // Use the persisted URI for the optimized CSS file. + $css_assets[$order]['data'] = $uri; + } + } + break; + + case 'inline': + // We don't do any caching for inline CSS assets. + $data = ''; + foreach ($css_group['items'] as $css_asset) { + $data .= $this->optimizer->optimize($css_asset); + } + unset($css_assets[$order]['data']['items']); + $css_assets[$order]['data'] = $data; + break; + + case 'external': + // We don't do any aggregation and hence also no caching for external + // CSS assets. + $css_assets[$order] = $css_group; + break; + } + } + + return $css_assets; + } + + /** + * Generate a hash for a given group of CSS assets. + */ + protected function generateHash($css_group) { + $css_data = array(); + foreach ($css_group['items'] as $css_file) { + $css_data[] = $css_file['data']; + } + return hash('sha256', serialize($css_data)); + } +} diff --git a/core/lib/Drupal/Core/Asset/CssCollectionRenderer.php b/core/lib/Drupal/Core/Asset/CssCollectionRenderer.php new file mode 100644 index 0000000..3690c47 --- /dev/null +++ b/core/lib/Drupal/Core/Asset/CssCollectionRenderer.php @@ -0,0 +1,173 @@ + 'html_tag', + '#tag' => 'link', + '#attributes' => array( + 'rel' => 'stylesheet', + ), + ); + $style_element_defaults = array( + '#type' => 'html_tag', + '#tag' => 'style', + ); + + // For filthy IE hack. + $current_ie_group_keys = NULL; + $get_ie_group_key = function ($css_asset) { + return array($css_asset['type'], $css_asset['preprocess'], $css_asset['group'], $css_asset['every_page'], $css_asset['media'], $css_asset['browsers']); + }; + + // Loop through all CSS assets, by key, to allow for the special IE workaround. + $css_assets_keys = array_keys($css_assets); + for ($i = 0; $i < count($css_assets_keys); $i++) { + $css_asset = $css_assets[$css_assets_keys[$i]]; + switch ($css_asset['type']) { + // For file items, there are three possibilites. + // - There are up to 31 CSS assets on the page (some of which may be + // aggregated). In this case, output a LINK tag for file CSS assets. + // - There are more than 31 CSS assets on the page, yet we must stay + // below IE<10's limit of 31 total CSS inclussion tags, we handle this + // in two ways: + // - file CSS assets that are not eligible for aggregation (their + // 'preprocess' flag has been set to FALSE): in this case, output a + // LINK tag. + // - file CSS assets that can be aggregated (and possibly have been): + // in this case, figure out which subsequent file CSS assets share + // the same key properties ('group', 'every_page', 'media' and + // 'browsers') and output this group into as few STYLE tags as + // possible (a STYLE tag may contain only 31 @import statements). + case 'file': + // As long as the current page will not run into IE's limit for CSS + // assets: output a LINK tag for a file CSS asset. + if (count($css_assets) <= 31) { + $element = $link_element_defaults; + $element['#attributes']['href'] = file_create_url($css_asset['data']); + $element['#attributes']['media'] = $css_asset['media']; + $element['#browsers'] = $css_asset['browsers']; + $elements[] = $element; + } + // The current page will run into IE's limits for CSS assets: work + // around these limits by performing a light form of grouping. + // Once Drupal only needs to support IE10 and later, we can drop this. + else { + // The file CSS asset is ineligible for aggregation: output it in a + // LINK tag. + if (!$css_asset['preprocess']) { + $element = $link_element_defaults; + // The dummy query string needs to be added to the URL to control + // browser-caching. + $query_string_separator = (strpos($css_asset['data'], '?') !== FALSE) ? '&' : '?'; + $element['#attributes']['href'] = file_create_url($css_asset['data']) . $query_string_separator . $query_string; + $element['#attributes']['media'] = $css_asset['media']; + $element['#browsers'] = $css_asset['browsers']; + $elements[] = $element; + } + // The file CSS asset can be aggregated, but hasn't been: combine + // multiple items into as few STYLE tags as possible. + else { + $import = array(); + // Start with the current CSS asset, iterate over subsequent CSS + // assets and find which ones have the same 'type', 'group', + // 'every_page', 'preprocess', 'media' and 'browsers' properties. + $j = $i; + $next_css_asset = $css_asset; + $current_ie_group_key = $get_ie_group_key($css_asset); + do { + // The dummy query string needs to be added to the URL to control + // browser-caching. IE7 does not support a media type on the + // @import statement, so we instead specify the media for the + // group on the STYLE tag. + $import[] = '@import url("' . check_plain(file_create_url($next_css_asset['data']) . '?' . $query_string) . '");'; + // Move the outer for loop skip the next item, since we + // processed it here. + $i = $j; + // Retrieve next CSS asset, unless there is none: then break. + if ($j + 1 < count($css_assets_keys)) { + $j++; + $next_css_asset = $css_assets[$css_assets_keys[$j]]; + } + else { + break; + } + } while ($get_ie_group_key($next_css_asset) == $current_ie_group_key); + + // In addition to IE's limit of 31 total CSS inclusion tags, it also + // has a limit of 31 @import statements per STYLE tag. + while (!empty($import)) { + $import_batch = array_slice($import, 0, 31); + $import = array_slice($import, 31); + $element = $style_element_defaults; + // This simplifies the JavaScript regex, allowing each line + // (separated by \n) to be treated as a completely different string. + // This means that we can use ^ and $ on one line at a time, and not + // worry about style tags since they'll never match the regex. + $element['#value'] = "\n" . implode("\n", $import_batch) . "\n"; + $element['#attributes']['media'] = $css_asset['media']; + $element['#browsers'] = $css_asset['browsers']; + $elements[] = $element; + } + } + } + break; + + // Output a STYLE tag for an inline CSS asset. The asset's 'data' + // property contains the CSS content. + case 'inline': + $element = $style_element_defaults; + $element['#value'] = $css_asset['data']; + $element['#attributes']['media'] = $css_asset['media']; + $element['#browsers'] = $css_asset['browsers']; + // For inline CSS to validate as XHTML, all CSS containing XHTML needs to be + // wrapped in CDATA. To make that backwards compatible with HTML 4, we need to + // comment out the CDATA-tag. + $element['#value_prefix'] = "\n/* */\n"; + $elements[] = $element; + break; + + // Output a LINK tag for an external CSS asset. The asset's 'data' + // property contains the full URL. + case 'external': + $element = $link_element_defaults; + $element['#attributes']['href'] = $css_asset['data']; + $element['#attributes']['media'] = $css_asset['media']; + $element['#browsers'] = $css_asset['browsers']; + $elements[] = $element; + break; + + default: + throw new \Exception('Invalid CSS asset type.'); + } + } + + return $elements; + } + +} diff --git a/core/lib/Drupal/Core/Asset/CssDumper.php b/core/lib/Drupal/Core/Asset/CssDumper.php new file mode 100644 index 0000000..06fe54c --- /dev/null +++ b/core/lib/Drupal/Core/Asset/CssDumper.php @@ -0,0 +1,52 @@ +get('css.gzip') && extension_loaded('zlib')) { + if (!file_exists($uri . '.gz') && !file_unmanaged_save_data(gzencode($data, 9, FORCE_GZIP), $uri . '.gz', FILE_EXISTS_REPLACE)) { + return FALSE; + } + } + return $uri; + } + +} diff --git a/core/lib/Drupal/Core/Asset/CssOptimizer.php b/core/lib/Drupal/Core/Asset/CssOptimizer.php new file mode 100644 index 0000000..b43ab09 --- /dev/null +++ b/core/lib/Drupal/Core/Asset/CssOptimizer.php @@ -0,0 +1,214 @@ +processFile($css_asset); + } + else { + return $this->processCss($css_asset['data'], $css_asset['preprocess']); + } + } + + /** + * Build aggregate CSS file. + */ + // @see drupal_build_css_cache() + protected function processFile($css_asset) { + $contents = $this->loadFile($css_asset['data'], TRUE); + + // Get the parent directory of this file, relative to the Drupal root. + $css_base_url = substr($css_asset['data'], 0, strrpos($css_asset['data'], '/')); + $this->rewriteFileURI(NULL, $css_base_url . '/'); + + // Anchor all paths in the CSS with its base URL, ignoring external and absolute paths. + return preg_replace_callback('/url\(\s*[\'"]?(?![a-z]+:|\/+)([^\'")]+)[\'"]?\s*\)/i', array($this, 'rewriteFileURI'), $contents); + } + + /** + * Loads the stylesheet and resolves all @import commands. + * + * Loads a stylesheet and replaces @import commands with the contents of the + * imported file. Use this instead of file_get_contents when processing + * stylesheets. + * + * The returned contents are compressed removing white space and comments only + * when CSS aggregation is enabled. This optimization will not apply for + * color.module enabled themes with CSS aggregation turned off. + * + * @param $file + * Name of the stylesheet to be processed. + * @param $optimize + * Defines if CSS contents should be compressed or not. + * @param $reset_basepath + * Used internally to facilitate recursive resolution of @import commands. + * + * @return + * Contents of the stylesheet, including any resolved @import commands. + */ + // @see drupal_load_stylesheet() + protected function loadFile($file, $optimize = NULL, $reset_basepath = TRUE) { + // These statics are not cache variables, so we don't use drupal_static(). + static $_optimize, $basepath; + if ($reset_basepath) { + $basepath = ''; + } + // Store the value of $optimize for preg_replace_callback with nested + // @import loops. + if (isset($optimize)) { + $_optimize = $optimize; + } + + // Stylesheets are relative one to each other. Start by adding a base path + // prefix provided by the parent stylesheet (if necessary). + if ($basepath && !file_uri_scheme($file)) { + $file = $basepath . '/' . $file; + } + $basepath = dirname($file); + + // Load the CSS stylesheet. We suppress errors because themes may specify + // stylesheets in their .info.yml file that don't exist in the theme's path, + // but are merely there to disable certain module CSS files. + if ($contents = @file_get_contents($file)) { + // Return the processed stylesheet. + return $this->processCss($contents, $_optimize); + } + + return ''; + } + + /** + * Loads stylesheets recursively and returns contents with corrected paths. + * + * This function is used for recursive loading of stylesheets and + * returns the stylesheet content with all url() paths corrected. + * + * @see loadFile() + */ + // @see _drupal_load_stylesheet() + protected function loadNestedFile($matches) { + $filename = $matches[1]; + // Load the imported stylesheet and replace @import commands in there as + // well. + $file = $this->loadFile($filename, NULL, FALSE); + + // Determine the file's directory. + $directory = dirname($filename); + // If the file is in the current directory, make sure '.' doesn't appear in + // the url() path. + $directory = $directory == '.' ? '' : $directory .'/'; + + // Alter all internal url() paths. Leave external paths alone. We don't need + // to normalize absolute paths here (i.e. remove folder/... segments) + // because that will be done later. + return preg_replace('/url\(\s*([\'"]?)(?![a-z]+:|\/+)/i', 'url(\1'. $directory, $file); + } + + /** + * Processes the contents of a stylesheet for aggregation. + * + * @param $contents + * The contents of the stylesheet. + * @param $optimize + * (optional) Boolean whether CSS contents should be minified. Defaults to + * FALSE. + * + * @return + * Contents of the stylesheet including the imported stylesheets. + */ + // @see drupal_load_stylesheet_content() + protected function processCss($contents, $optimize = FALSE) { + // Remove multiple charset declarations for standards compliance (and fixing Safari problems). + $contents = preg_replace('/^@charset\s+[\'"](\S*)\b[\'"];/i', '', $contents); + + if ($optimize) { + // Perform some safe CSS optimizations. + // Regexp to match comment blocks. + $comment = '/\*[^*]*\*+(?:[^/*][^*]*\*+)*/'; + // Regexp to match double quoted strings. + $double_quot = '"[^"\\\\]*(?:\\\\.[^"\\\\]*)*"'; + // Regexp to match single quoted strings. + $single_quot = "'[^'\\\\]*(?:\\\\.[^'\\\\]*)*'"; + // Strip all comment blocks, but keep double/single quoted strings. + $contents = preg_replace( + "<($double_quot|$single_quot)|$comment>Ss", + "$1", + $contents + ); + // Remove certain whitespace. + // There are different conditions for removing leading and trailing + // whitespace. + // @see http://php.net/manual/regexp.reference.subpatterns.php + $contents = preg_replace('< + # Strip leading and trailing whitespace. + \s*([@{};,])\s* + # Strip only leading whitespace from: + # - Closing parenthesis: Retain "@media (bar) and foo". + | \s+([\)]) + # Strip only trailing whitespace from: + # - Opening parenthesis: Retain "@media (bar) and foo". + # - Colon: Retain :pseudo-selectors. + | ([\(:])\s+ + >xS', + // Only one of the three capturing groups will match, so its reference + // will contain the wanted value and the references for the + // two non-matching groups will be replaced with empty strings. + '$1$2$3', + $contents + ); + // End the file with a new line. + $contents = trim($contents); + $contents .= "\n"; + } + + // Replaces @import commands with the actual stylesheet content. + // This happens recursively but omits external files. + $contents = preg_replace_callback('/@import\s*(?:url\(\s*)?[\'"]?(?![a-z]+:)([^\'"\()]+)[\'"]?\s*\)?\s*;/', array($this, 'loadNestedFile'), $contents); + + return $contents; + } + + /** + * Prefixes all paths within a CSS file for aggregateFile(). + */ + protected function rewriteFileURI($matches, $base = NULL) { + static $_base; + // Store base path for preg_replace_callback. + if (isset($base)) { + $_base = $base; + } + + // Prefix with base and remove '../' segments where possible. + $path = $_base . $matches[1]; + $last = ''; + while ($path != $last) { + $last = $path; + $path = preg_replace('`(^|/)(?!\.\./)([^/]+)/\.\./`', '$1', $path); + } + return 'url(' . file_create_url($path) . ')'; + } + +} diff --git a/core/tests/Drupal/Tests/Core/Asset/CssCollectionGrouperUnitTest.php b/core/tests/Drupal/Tests/Core/Asset/CssCollectionGrouperUnitTest.php new file mode 100644 index 0000000..adf5e88 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Asset/CssCollectionGrouperUnitTest.php @@ -0,0 +1,216 @@ + 'CSS asset collection grouper functionality', + 'description' => 'Tests the CSS asset collection grouper.', + 'group' => 'Asset handling', + ); + } + + function setUp() { + parent::setUp(); + + $this->grouper = new CssCollectionGrouper(); + } + + /** + * Tests \Drupal\Core\Asset\CssCollectionGrouper. + */ + function testGrouper() { + $css_assets = array( + 'system.base.css' => array( + 'group' => -100, + 'every_page' => TRUE, + 'type' => 'file', + 'weight' => 0.012, + 'media' => 'all', + 'preprocess' => TRUE, + 'data' => 'core/modules/system/system.base.css', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + 'basename' => 'system.base.css', + ), + 'system.theme.css' => array( + 'group' => -100, + 'every_page' => TRUE, + 'type' => 'file', + 'weight' => 0.013, + 'media' => 'all', + 'preprocess' => TRUE, + 'data' => 'core/modules/system/system.theme.css', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + 'basename' => 'system.theme.css', + ), + 'jquery.ui.core.css' => array( + 'group' => -100, + 'type' => 'file', + 'weight' => 0.004, + 'every_page' => FALSE, + 'media' => 'all', + 'preprocess' => TRUE, + 'data' => 'core/misc/ui/themes/base/jquery.ui.core.css', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + 'basename' => 'jquery.ui.core.css', + ), + 0 => array( + 'type' => 'inline', + 'group' => 0, + 'weight' => 0.007, + 'every_page' => FALSE, + 'media' => 'all', + 'preprocess' => TRUE, + 'data' => 'body { padding: 0px; }', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + ), + 1 => array( + 'type' => 'inline', + 'group' => 0, + 'weight' => 0.007, + 'every_page' => FALSE, + 'media' => 'all', + 'preprocess' => FALSE, + 'data' => 'body { margin: 0px; }', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + ), + 'field.css' => array( + 'every_page' => TRUE, + 'group' => 0, + 'type' => 'file', + 'weight' => 0.011, + 'media' => 'all', + 'preprocess' => TRUE, + 'data' => 'core/modules/field/theme/field.css', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + 'basename' => 'field.css', + ), + 'external.css' => array( + 'every_page' => FALSE, + 'group' => 0, + 'type' => 'external', + 'weight' => 0.009, + 'media' => 'all', + 'preprocess' => TRUE, + 'data' => 'http://example.com/external.css', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + 'basename' => 'external.css', + ), + 'style.css' => array( + 'group' => 100, + 'every_page' => TRUE, + 'media' => 'all', + 'type' => 'file', + 'weight' => 0.001, + 'preprocess' => TRUE, + 'data' => 'core/themes/bartik/css/style.css', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + 'basename' => 'style.css', + ), + 'print.css' => array( + 'group' => 100, + 'every_page' => TRUE, + 'media' => 'print', + 'type' => 'file', + 'weight' => 0.003, + 'preprocess' => TRUE, + 'data' => 'core/themes/bartik/css/print.css', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + 'basename' => 'print.css', + ), + ); + + $groups = $this->grouper->group($css_assets); + + $this->assertSame(count($groups), 7, "7 groups created."); + + // Check group 1. + $this->assertSame($groups[0]['group'], -100); + $this->assertSame($groups[0]['every_page'], TRUE); + $this->assertSame($groups[0]['type'], 'file'); + $this->assertSame($groups[0]['media'], 'all'); + $this->assertSame($groups[0]['preprocess'], TRUE); + $this->assertSame(count($groups[0]['items']), 2); + $this->assertContains($css_assets['system.base.css'], $groups[0]['items']); + $this->assertContains($css_assets['system.theme.css'], $groups[0]['items']); + + // Check group 2. + $this->assertSame($groups[1]['group'], -100); + $this->assertSame($groups[1]['every_page'], FALSE); + $this->assertSame($groups[1]['type'], 'file'); + $this->assertSame($groups[1]['media'], 'all'); + $this->assertSame($groups[1]['preprocess'], TRUE); + $this->assertSame(count($groups[1]['items']), 1); + $this->assertContains($css_assets['jquery.ui.core.css'], $groups[1]['items']); + + // Check group 3. + $this->assertSame($groups[2]['group'], 0); + $this->assertSame($groups[2]['every_page'], FALSE); + $this->assertSame($groups[2]['type'], 'inline'); + $this->assertSame($groups[2]['media'], 'all'); + $this->assertSame($groups[2]['preprocess'], TRUE); + $this->assertSame(count($groups[2]['items']), 2); + $this->assertContains($css_assets[0], $groups[2]['items']); + $this->assertContains($css_assets[1], $groups[2]['items']); + + // Check group 4. + $this->assertSame($groups[3]['group'], 0); + $this->assertSame($groups[3]['every_page'], TRUE); + $this->assertSame($groups[3]['type'], 'file'); + $this->assertSame($groups[3]['media'], 'all'); + $this->assertSame($groups[3]['preprocess'], TRUE); + $this->assertSame(count($groups[3]['items']), 1); + $this->assertContains($css_assets['field.css'], $groups[3]['items']); + + // Check group 5. + $this->assertSame($groups[4]['group'], 0); + $this->assertSame($groups[4]['every_page'], FALSE); + $this->assertSame($groups[4]['type'], 'external'); + $this->assertSame($groups[4]['media'], 'all'); + $this->assertSame($groups[4]['preprocess'], TRUE); + $this->assertSame(count($groups[4]['items']), 1); + $this->assertContains($css_assets['external.css'], $groups[4]['items']); + + // Check group 6. + $this->assertSame($groups[5]['group'], 100); + $this->assertSame($groups[5]['every_page'], TRUE); + $this->assertSame($groups[5]['type'], 'file'); + $this->assertSame($groups[5]['media'], 'all'); + $this->assertSame($groups[5]['preprocess'], TRUE); + $this->assertSame(count($groups[5]['items']), 1); + $this->assertContains($css_assets['style.css'], $groups[5]['items']); + + // Check group 7. + $this->assertSame($groups[6]['group'], 100); + $this->assertSame($groups[6]['every_page'], TRUE); + $this->assertSame($groups[6]['type'], 'file'); + $this->assertSame($groups[6]['media'], 'print'); + $this->assertSame($groups[6]['preprocess'], TRUE); + $this->assertSame(count($groups[6]['items']), 1); + $this->assertContains($css_assets['print.css'], $groups[6]['items']); + } + +} diff --git a/core/tests/Drupal/Tests/Core/Asset/CssCollectionRendererUnitTest.php b/core/tests/Drupal/Tests/Core/Asset/CssCollectionRendererUnitTest.php new file mode 100644 index 0000000..aebc434 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Asset/CssCollectionRendererUnitTest.php @@ -0,0 +1,572 @@ + 'CSS asset collection renderer functionality', + 'description' => 'Tests the CSS asset collection renderer.', + 'group' => 'Asset handling', + ); + } + + function setUp() { + parent::setUp(); + + $this->renderer = new CssCollectionRenderer(); + $this->file_css_group = array( + 'group' => -100, + 'every_page' => TRUE, + 'type' => 'file', + 'media' => 'all', + 'preprocess' => TRUE, + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + 'items' => array( + 0 => array( + 'group' => -100, + 'every_page' => TRUE, + 'type' => 'file', + 'weight' => 0.012, + 'media' => 'all', + 'preprocess' => TRUE, + 'data' => 'tests/Drupal/Tests/Core/Asset/foo.css', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + 'basename' => 'foo.css', + ), + 1 => array( + 'group' => -100, + 'every_page' => TRUE, + 'type' => 'file', + 'weight' => 0.013, + 'media' => 'all', + 'preprocess' => TRUE, + 'data' => 'tests/Drupal/Tests/Core/Asset/bar.css', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + 'basename' => 'bar.css', + ), + ), + ); + $this->inline_css_group = array( + 'group' => 0, + 'every_page' => FALSE, + 'type' => 'inline', + 'media' => 'all', + 'preprocess' => TRUE, + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + 'items' => array( + 0 => array( + 'group' => 0, + 'every_page' => FALSE, + 'type' => 'inline', + 'weight' => 0.012, + 'media' => 'all', + 'preprocess' => TRUE, + 'data' => '.girlfriend { display: none; }', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + ), + 1 => array( + 'group' => 0, + 'every_page' => FALSE, + 'type' => 'file', + 'weight' => 0.013, + 'media' => 'all', + 'preprocess' => FALSE, + 'data' => '#home body { position: fixed; }', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + ), + ), + ); + } + + /** + * Provides data for the CSS asset rendering test. + * + * @see testRender + */ + function testRenderProvider() { + // Default for 'browsers' key in CSS asset. + $browsers_default = array('IE' => TRUE, '!IE' => TRUE); + + // Defaults for LINK and STYLE elements. + $link_element_defaults = array( + + ); + $style_element_defaults = array( + '#type' => 'html_tag', + '#tag' => 'style', + ); + + $create_link_element = function($href, $media = 'all', $browsers = NULL) { + return array( + '#type' => 'html_tag', + '#tag' => 'link', + '#attributes' => array( + 'rel' => 'stylesheet', + 'href' => $href, + 'media' => $media, + ), + '#browsers' => $browsers, + ); + }; + $create_style_element = function($value, $media, $browsers = NULL, $wrap_in_cdata = FALSE) { + $style_element = array( + '#type' => 'html_tag', + '#tag' => 'style', + '#value' => $value, + '#attributes' => array( + 'media' => $media + ), + '#browsers' => $browsers, + ); + if ($wrap_in_cdata) { + $style_element['#value_prefix'] = "\n/* */\n"; + } + return $style_element; + }; + + $create_file_css_asset = function($data, $media = 'all', $preprocess = TRUE) { + return array('group' => 0, 'every_page' => FALSE, 'type' => 'file', 'media' => $media, 'preprocess' => $preprocess, 'data' => $data); + }; + + return array( + // Single external CSS asset. + 0 => array( + // CSS assets. + array( + 0 => array('group' => 0, 'every_page' => TRUE, 'type' => 'external', 'media' => 'all', 'preprocess' => TRUE, 'data' => 'http://example.com/popular.js'), + ), + // Render elements. + array( + 0 => $create_link_element('http://example.com/popular.js', 'all'), + ), + ), + // Single inline CSS asset. + 1 => array( + array( + 0 => array('group' => 0, 'every_page' => FALSE, 'type' => 'inline', 'media' => 'all', 'preprocess' => FALSE, 'data' => '.girlfriend { display: none; }'), + ), + array( + 0 => $create_style_element('.girlfriend { display: none; }', 'all', NULL, TRUE), + ), + ), + // Single file CSS asset. + 2 => array( + array( + 0 => array('group' => 0, 'every_page' => TRUE, 'type' => 'file', 'media' => 'all', 'preprocess' => TRUE, 'data' => 'public://css/file-every_page-all'), + ), + array( + 0 => $create_link_element(file_create_url('public://css/file-every_page-all'), 'all'), + ), + ), + // 31 file CSS assets: expect 31 link elements. + 3 => array( + array( + 0 => $create_file_css_asset('public://css/1.css'), + 1 => $create_file_css_asset('public://css/2.css'), + 2 => $create_file_css_asset('public://css/3.css'), + 3 => $create_file_css_asset('public://css/4.css'), + 4 => $create_file_css_asset('public://css/5.css'), + 5 => $create_file_css_asset('public://css/6.css'), + 6 => $create_file_css_asset('public://css/7.css'), + 7 => $create_file_css_asset('public://css/8.css'), + 8 => $create_file_css_asset('public://css/9.css'), + 9 => $create_file_css_asset('public://css/10.css'), + 10 => $create_file_css_asset('public://css/11.css'), + 11 => $create_file_css_asset('public://css/12.css'), + 12 => $create_file_css_asset('public://css/13.css'), + 13 => $create_file_css_asset('public://css/14.css'), + 14 => $create_file_css_asset('public://css/15.css'), + 15 => $create_file_css_asset('public://css/16.css'), + 16 => $create_file_css_asset('public://css/17.css'), + 17 => $create_file_css_asset('public://css/18.css'), + 18 => $create_file_css_asset('public://css/19.css'), + 19 => $create_file_css_asset('public://css/20.css'), + 20 => $create_file_css_asset('public://css/21.css'), + 21 => $create_file_css_asset('public://css/22.css'), + 22 => $create_file_css_asset('public://css/23.css'), + 23 => $create_file_css_asset('public://css/24.css'), + 24 => $create_file_css_asset('public://css/25.css'), + 25 => $create_file_css_asset('public://css/26.css'), + 26 => $create_file_css_asset('public://css/27.css'), + 27 => $create_file_css_asset('public://css/28.css'), + 28 => $create_file_css_asset('public://css/29.css'), + 29 => $create_file_css_asset('public://css/30.css'), + 30 => $create_file_css_asset('public://css/31.css'), + ), + array( + 0 => $create_link_element(file_create_url('public://css/1.css')), + 1 => $create_link_element(file_create_url('public://css/2.css')), + 2 => $create_link_element(file_create_url('public://css/3.css')), + 3 => $create_link_element(file_create_url('public://css/4.css')), + 4 => $create_link_element(file_create_url('public://css/5.css')), + 5 => $create_link_element(file_create_url('public://css/6.css')), + 6 => $create_link_element(file_create_url('public://css/7.css')), + 7 => $create_link_element(file_create_url('public://css/8.css')), + 8 => $create_link_element(file_create_url('public://css/9.css')), + 9 => $create_link_element(file_create_url('public://css/10.css')), + 10 => $create_link_element(file_create_url('public://css/11.css')), + 11 => $create_link_element(file_create_url('public://css/12.css')), + 12 => $create_link_element(file_create_url('public://css/13.css')), + 13 => $create_link_element(file_create_url('public://css/14.css')), + 14 => $create_link_element(file_create_url('public://css/15.css')), + 15 => $create_link_element(file_create_url('public://css/16.css')), + 16 => $create_link_element(file_create_url('public://css/17.css')), + 17 => $create_link_element(file_create_url('public://css/18.css')), + 18 => $create_link_element(file_create_url('public://css/19.css')), + 19 => $create_link_element(file_create_url('public://css/20.css')), + 20 => $create_link_element(file_create_url('public://css/21.css')), + 21 => $create_link_element(file_create_url('public://css/22.css')), + 22 => $create_link_element(file_create_url('public://css/23.css')), + 23 => $create_link_element(file_create_url('public://css/24.css')), + 24 => $create_link_element(file_create_url('public://css/25.css')), + 25 => $create_link_element(file_create_url('public://css/26.css')), + 26 => $create_link_element(file_create_url('public://css/27.css')), + 27 => $create_link_element(file_create_url('public://css/28.css')), + 28 => $create_link_element(file_create_url('public://css/29.css')), + 29 => $create_link_element(file_create_url('public://css/30.css')), + 30 => $create_link_element(file_create_url('public://css/31.css')), + ), + ), + // 32 file CSS assets with the same properties: expect 2 style elements. + 4 => array( + array( + 0 => $create_file_css_asset('public://css/1.css'), + 1 => $create_file_css_asset('public://css/2.css'), + 2 => $create_file_css_asset('public://css/3.css'), + 3 => $create_file_css_asset('public://css/4.css'), + 4 => $create_file_css_asset('public://css/5.css'), + 5 => $create_file_css_asset('public://css/6.css'), + 6 => $create_file_css_asset('public://css/7.css'), + 7 => $create_file_css_asset('public://css/8.css'), + 8 => $create_file_css_asset('public://css/9.css'), + 9 => $create_file_css_asset('public://css/10.css'), + 10 => $create_file_css_asset('public://css/11.css'), + 11 => $create_file_css_asset('public://css/12.css'), + 12 => $create_file_css_asset('public://css/13.css'), + 13 => $create_file_css_asset('public://css/14.css'), + 14 => $create_file_css_asset('public://css/15.css'), + 15 => $create_file_css_asset('public://css/16.css'), + 16 => $create_file_css_asset('public://css/17.css'), + 17 => $create_file_css_asset('public://css/18.css'), + 18 => $create_file_css_asset('public://css/19.css'), + 19 => $create_file_css_asset('public://css/20.css'), + 20 => $create_file_css_asset('public://css/21.css'), + 21 => $create_file_css_asset('public://css/22.css'), + 22 => $create_file_css_asset('public://css/23.css'), + 23 => $create_file_css_asset('public://css/24.css'), + 24 => $create_file_css_asset('public://css/25.css'), + 25 => $create_file_css_asset('public://css/26.css'), + 26 => $create_file_css_asset('public://css/27.css'), + 27 => $create_file_css_asset('public://css/28.css'), + 28 => $create_file_css_asset('public://css/29.css'), + 29 => $create_file_css_asset('public://css/30.css'), + 30 => $create_file_css_asset('public://css/31.css'), + 31 => $create_file_css_asset('public://css/32.css'), + ), + array( + 0 => $create_style_element(' +@import url("' . file_create_url('public://css/1.css') . '?0"); +@import url("' . file_create_url('public://css/2.css') . '?0"); +@import url("' . file_create_url('public://css/3.css') . '?0"); +@import url("' . file_create_url('public://css/4.css') . '?0"); +@import url("' . file_create_url('public://css/5.css') . '?0"); +@import url("' . file_create_url('public://css/6.css') . '?0"); +@import url("' . file_create_url('public://css/7.css') . '?0"); +@import url("' . file_create_url('public://css/8.css') . '?0"); +@import url("' . file_create_url('public://css/9.css') . '?0"); +@import url("' . file_create_url('public://css/10.css') . '?0"); +@import url("' . file_create_url('public://css/11.css') . '?0"); +@import url("' . file_create_url('public://css/12.css') . '?0"); +@import url("' . file_create_url('public://css/13.css') . '?0"); +@import url("' . file_create_url('public://css/14.css') . '?0"); +@import url("' . file_create_url('public://css/15.css') . '?0"); +@import url("' . file_create_url('public://css/16.css') . '?0"); +@import url("' . file_create_url('public://css/17.css') . '?0"); +@import url("' . file_create_url('public://css/18.css') . '?0"); +@import url("' . file_create_url('public://css/19.css') . '?0"); +@import url("' . file_create_url('public://css/20.css') . '?0"); +@import url("' . file_create_url('public://css/21.css') . '?0"); +@import url("' . file_create_url('public://css/22.css') . '?0"); +@import url("' . file_create_url('public://css/23.css') . '?0"); +@import url("' . file_create_url('public://css/24.css') . '?0"); +@import url("' . file_create_url('public://css/25.css') . '?0"); +@import url("' . file_create_url('public://css/26.css') . '?0"); +@import url("' . file_create_url('public://css/27.css') . '?0"); +@import url("' . file_create_url('public://css/28.css') . '?0"); +@import url("' . file_create_url('public://css/29.css') . '?0"); +@import url("' . file_create_url('public://css/30.css') . '?0"); +@import url("' . file_create_url('public://css/31.css') . '?0"); +', 'all'), + 1 => $create_style_element(' +@import url("' . file_create_url('public://css/32.css') . '?0"); +', 'all'), + ), + ), + // 32 file CSS assets with the same properties, except for the 10th and + // 20th files, they have different 'media' properties. Expect 5 style + // elements. + 5 => array( + array( + 0 => $create_file_css_asset('public://css/1.css'), + 1 => $create_file_css_asset('public://css/2.css'), + 2 => $create_file_css_asset('public://css/3.css'), + 3 => $create_file_css_asset('public://css/4.css'), + 4 => $create_file_css_asset('public://css/5.css'), + 5 => $create_file_css_asset('public://css/6.css'), + 6 => $create_file_css_asset('public://css/7.css'), + 7 => $create_file_css_asset('public://css/8.css'), + 8 => $create_file_css_asset('public://css/9.css'), + 9 => $create_file_css_asset('public://css/10.css', 'screen'), + 10 => $create_file_css_asset('public://css/11.css'), + 11 => $create_file_css_asset('public://css/12.css'), + 12 => $create_file_css_asset('public://css/13.css'), + 13 => $create_file_css_asset('public://css/14.css'), + 14 => $create_file_css_asset('public://css/15.css'), + 15 => $create_file_css_asset('public://css/16.css'), + 16 => $create_file_css_asset('public://css/17.css'), + 17 => $create_file_css_asset('public://css/18.css'), + 18 => $create_file_css_asset('public://css/19.css'), + 19 => $create_file_css_asset('public://css/20.css', 'print'), + 20 => $create_file_css_asset('public://css/21.css'), + 21 => $create_file_css_asset('public://css/22.css'), + 22 => $create_file_css_asset('public://css/23.css'), + 23 => $create_file_css_asset('public://css/24.css'), + 24 => $create_file_css_asset('public://css/25.css'), + 25 => $create_file_css_asset('public://css/26.css'), + 26 => $create_file_css_asset('public://css/27.css'), + 27 => $create_file_css_asset('public://css/28.css'), + 28 => $create_file_css_asset('public://css/29.css'), + 29 => $create_file_css_asset('public://css/30.css'), + 30 => $create_file_css_asset('public://css/31.css'), + 31 => $create_file_css_asset('public://css/32.css'), + ), + array( + 0 => $create_style_element(' +@import url("' . file_create_url('public://css/1.css') . '?0"); +@import url("' . file_create_url('public://css/2.css') . '?0"); +@import url("' . file_create_url('public://css/3.css') . '?0"); +@import url("' . file_create_url('public://css/4.css') . '?0"); +@import url("' . file_create_url('public://css/5.css') . '?0"); +@import url("' . file_create_url('public://css/6.css') . '?0"); +@import url("' . file_create_url('public://css/7.css') . '?0"); +@import url("' . file_create_url('public://css/8.css') . '?0"); +@import url("' . file_create_url('public://css/9.css') . '?0"); +', 'all'), + 1 => $create_style_element(' +@import url("' . file_create_url('public://css/10.css') . '?0"); +', 'screen'), + 2 => $create_style_element(' +@import url("' . file_create_url('public://css/11.css') . '?0"); +@import url("' . file_create_url('public://css/12.css') . '?0"); +@import url("' . file_create_url('public://css/13.css') . '?0"); +@import url("' . file_create_url('public://css/14.css') . '?0"); +@import url("' . file_create_url('public://css/15.css') . '?0"); +@import url("' . file_create_url('public://css/16.css') . '?0"); +@import url("' . file_create_url('public://css/17.css') . '?0"); +@import url("' . file_create_url('public://css/18.css') . '?0"); +@import url("' . file_create_url('public://css/19.css') . '?0"); +', 'all'), + 3 => $create_style_element(' +@import url("' . file_create_url('public://css/20.css') . '?0"); +', 'print'), + 4 => $create_style_element(' +@import url("' . file_create_url('public://css/21.css') . '?0"); +@import url("' . file_create_url('public://css/22.css') . '?0"); +@import url("' . file_create_url('public://css/23.css') . '?0"); +@import url("' . file_create_url('public://css/24.css') . '?0"); +@import url("' . file_create_url('public://css/25.css') . '?0"); +@import url("' . file_create_url('public://css/26.css') . '?0"); +@import url("' . file_create_url('public://css/27.css') . '?0"); +@import url("' . file_create_url('public://css/28.css') . '?0"); +@import url("' . file_create_url('public://css/29.css') . '?0"); +@import url("' . file_create_url('public://css/30.css') . '?0"); +@import url("' . file_create_url('public://css/31.css') . '?0"); +@import url("' . file_create_url('public://css/32.css') . '?0"); +', 'all'), + ), + ), + // 32 file CSS assets with the same properties, except for the 15th, which + // has 'preprocess' = FALSE. Expect 1 link element and 2 style elements. + 6 => array( + array( + 0 => $create_file_css_asset('public://css/1.css'), + 1 => $create_file_css_asset('public://css/2.css'), + 2 => $create_file_css_asset('public://css/3.css'), + 3 => $create_file_css_asset('public://css/4.css'), + 4 => $create_file_css_asset('public://css/5.css'), + 5 => $create_file_css_asset('public://css/6.css'), + 6 => $create_file_css_asset('public://css/7.css'), + 7 => $create_file_css_asset('public://css/8.css'), + 8 => $create_file_css_asset('public://css/9.css'), + 9 => $create_file_css_asset('public://css/10.css'), + 10 => $create_file_css_asset('public://css/11.css'), + 11 => $create_file_css_asset('public://css/12.css'), + 12 => $create_file_css_asset('public://css/13.css'), + 13 => $create_file_css_asset('public://css/14.css'), + 14 => $create_file_css_asset('public://css/15.css', 'all', FALSE), + 15 => $create_file_css_asset('public://css/16.css'), + 16 => $create_file_css_asset('public://css/17.css'), + 17 => $create_file_css_asset('public://css/18.css'), + 18 => $create_file_css_asset('public://css/19.css'), + 19 => $create_file_css_asset('public://css/20.css'), + 20 => $create_file_css_asset('public://css/21.css'), + 21 => $create_file_css_asset('public://css/22.css'), + 22 => $create_file_css_asset('public://css/23.css'), + 23 => $create_file_css_asset('public://css/24.css'), + 24 => $create_file_css_asset('public://css/25.css'), + 25 => $create_file_css_asset('public://css/26.css'), + 26 => $create_file_css_asset('public://css/27.css'), + 27 => $create_file_css_asset('public://css/28.css'), + 28 => $create_file_css_asset('public://css/29.css'), + 29 => $create_file_css_asset('public://css/30.css'), + 30 => $create_file_css_asset('public://css/31.css'), + 31 => $create_file_css_asset('public://css/32.css'), + ), + array( + 0 => $create_style_element(' +@import url("' . file_create_url('public://css/1.css') . '?0"); +@import url("' . file_create_url('public://css/2.css') . '?0"); +@import url("' . file_create_url('public://css/3.css') . '?0"); +@import url("' . file_create_url('public://css/4.css') . '?0"); +@import url("' . file_create_url('public://css/5.css') . '?0"); +@import url("' . file_create_url('public://css/6.css') . '?0"); +@import url("' . file_create_url('public://css/7.css') . '?0"); +@import url("' . file_create_url('public://css/8.css') . '?0"); +@import url("' . file_create_url('public://css/9.css') . '?0"); +@import url("' . file_create_url('public://css/10.css') . '?0"); +@import url("' . file_create_url('public://css/11.css') . '?0"); +@import url("' . file_create_url('public://css/12.css') . '?0"); +@import url("' . file_create_url('public://css/13.css') . '?0"); +@import url("' . file_create_url('public://css/14.css') . '?0"); +', 'all'), + 1 => $create_link_element(file_create_url('public://css/15.css') . '?0'), + 2 => $create_style_element(' +@import url("' . file_create_url('public://css/16.css') . '?0"); +@import url("' . file_create_url('public://css/17.css') . '?0"); +@import url("' . file_create_url('public://css/18.css') . '?0"); +@import url("' . file_create_url('public://css/19.css') . '?0"); +@import url("' . file_create_url('public://css/20.css') . '?0"); +@import url("' . file_create_url('public://css/21.css') . '?0"); +@import url("' . file_create_url('public://css/22.css') . '?0"); +@import url("' . file_create_url('public://css/23.css') . '?0"); +@import url("' . file_create_url('public://css/24.css') . '?0"); +@import url("' . file_create_url('public://css/25.css') . '?0"); +@import url("' . file_create_url('public://css/26.css') . '?0"); +@import url("' . file_create_url('public://css/27.css') . '?0"); +@import url("' . file_create_url('public://css/28.css') . '?0"); +@import url("' . file_create_url('public://css/29.css') . '?0"); +@import url("' . file_create_url('public://css/30.css') . '?0"); +@import url("' . file_create_url('public://css/31.css') . '?0"); +@import url("' . file_create_url('public://css/32.css') . '?0"); +', 'all'), + ), + ), + ); + } + + /** + * Tests CSS asset rendering. + * + * @dataProvider testRenderProvider + */ + function testRender(array $css_assets, array $render_elements) { + $this->assertSame($render_elements, $this->renderer->render($css_assets)); + } + + /** + * Tests a CSS asset group with the invalid 'type' => 'internal'. + */ + function testRenderInvalidType() { + $this->setExpectedException('Exception', 'Invalid CSS asset type.'); + + $css_group = array( + 'group' => 0, + 'every_page' => TRUE, + 'type' => 'internal', + 'media' => 'all', + 'preprocess' => TRUE, + 'browsers' => $browsers_default, + 'data' => 'http://example.com/popular.js' + ); + $this->renderer->render($css_group); + } +} +} diff --git a/core/tests/Drupal/Tests/Core/Asset/CssOptimizerUnitTest.php b/core/tests/Drupal/Tests/Core/Asset/CssOptimizerUnitTest.php new file mode 100644 index 0000000..f74697f --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Asset/CssOptimizerUnitTest.php @@ -0,0 +1,199 @@ + 'CSS asset optimizer functionality', + 'description' => 'Tests the CSS asset optimizer.', + 'group' => 'Asset handling', + ); + } + + function setUp() { + parent::setUp(); + + $this->optimizer = new CssOptimizer(); + } + + /** + * Provides data for the CSS asset optimizing test. + */ + function testOptimizeProvider() { + return array( + // File. Contains a background image with relative paths, which must be + // rewritten; the end result must be passed through file_create_url(), + // which we've mocked. + 0 => array( + // CSS asset + array( + 'group' => -100, + 'every_page' => TRUE, + 'type' => 'file', + 'weight' => 0.012, + 'media' => 'all', + 'preprocess' => TRUE, + 'data' => 'tests/Drupal/Tests/Core/Asset/foo.css', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + 'basename' => 'foo.css', + ), + // Expected. + ".js input.form-autocomplete{background-image:url(" . file_create_url('tests/Drupal/Tests/misc/throbber.gif') . ");background-position:100% 2px;background-repeat:no-repeat;}\n", + ), + // File. Contains lots of whitespace and comments that should be stripped. + 1 => array( + array( + 'group' => -100, + 'every_page' => TRUE, + 'type' => 'file', + 'weight' => 0.013, + 'media' => 'all', + 'preprocess' => TRUE, + 'data' => 'tests/Drupal/Tests/Core/Asset/bar.css', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + 'basename' => 'bar.css', + ), + ".js input.form-autocomplete{background-color:blue;}\n" + ), + // Inline. Preprocessing enabled. + 2 => array( + array( + 'group' => 0, + 'every_page' => FALSE, + 'type' => 'inline', + 'weight' => 0.012, + 'media' => 'all', + 'preprocess' => TRUE, + 'data' => '.girlfriend { display: none; }', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + ), + ".girlfriend{display:none;}\n", + ), + // Inline. Preprocessing disabled. + 3 => array( + array( + 'group' => 0, + 'every_page' => FALSE, + 'type' => 'inline', + 'weight' => 0.013, + 'media' => 'all', + 'preprocess' => FALSE, + 'data' => '#home body { position: fixed; }', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + ), + '#home body { position: fixed; }', + ) + ); + } + + /** + * Tests optimizing a CSS asset group containing 'type' => 'file'. + * + * @dataProvider testOptimizeProvider + */ + function testOptimize($css_asset, $expected) { + $this->assertEquals($expected, $this->optimizer->optimize($css_asset), 'Group of file CSS assets optimized correctly.'); + } + + /** + * Tests a file CSS asset with preprocessing disabled. + */ + function testTypeFilePreprocessingDisabled() { + $this->setExpectedException('Exception', 'Only file CSS assets with preprocessing enabled can be optimized.'); + + $css_asset = array( + 'group' => -100, + 'every_page' => TRUE, + 'type' => 'file', + 'weight' => 0.012, + 'media' => 'all', + // Preprocessing disabled. + 'preprocess' => FALSE, + 'data' => 'tests/Drupal/Tests/Core/Asset/foo.css', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + 'basename' => 'foo.css', + ); + $this->optimizer->optimize($css_asset); + } + + /** + * Tests a CSS asset with 'type' => 'external'. + */ + function testTypeExternal() { + $this->setExpectedException('Exception', 'Only file or inline CSS assets can be optimized.'); + + $css_asset = array( + 'group' => -100, + 'every_page' => TRUE, + // Type external. + 'type' => 'external', + 'weight' => 0.012, + 'media' => 'all', + 'preprocess' => TRUE, + 'data' => 'http://example.com/foo.js', + 'browsers' => array('IE' => TRUE, '!IE' => TRUE), + ); + $this->optimizer->optimize($css_asset); + } + +} +} diff --git a/core/tests/Drupal/Tests/Core/Asset/bar.css b/core/tests/Drupal/Tests/Core/Asset/bar.css new file mode 100644 index 0000000..e6a5f23 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Asset/bar.css @@ -0,0 +1,14 @@ +/** + * Comment here. + */ + + + + +/* Idiotic whitespace. */ + + + +.js input.form-autocomplete { + background-color: blue; +} diff --git a/core/tests/Drupal/Tests/Core/Asset/foo.css b/core/tests/Drupal/Tests/Core/Asset/foo.css new file mode 100644 index 0000000..f907737 --- /dev/null +++ b/core/tests/Drupal/Tests/Core/Asset/foo.css @@ -0,0 +1,5 @@ +.js input.form-autocomplete { + background-image: url(../../misc/throbber.gif); + background-position: 100% 2px; /* LTR */ + background-repeat: no-repeat; +}