Index: includes/common.inc =================================================================== RCS file: /cvs/drupal/drupal/includes/common.inc,v retrieving revision 1.1082 diff -u -p -r1.1082 common.inc --- includes/common.inc 8 Jan 2010 06:07:03 -0000 1.1082 +++ includes/common.inc 8 Jan 2010 18:57:58 -0000 @@ -3250,23 +3250,19 @@ function drupal_add_css($data = NULL, $o * A string of XHTML CSS tags. */ function drupal_get_css($css = NULL) { - $output = ''; if (!isset($css)) { $css = drupal_add_css(); } - $preprocess_css = (variable_get('preprocess_css', FALSE) && (!defined('MAINTENANCE_MODE') || MAINTENANCE_MODE != 'update')); - $directory = file_directory_path('public'); - $is_writable = is_dir($directory) && is_writable($directory); - - // 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 = '?' . substr(variable_get('css_js_query_string', '0'), 0, 1); - - // Allow modules to alter the css items. - drupal_alter('css', $css); + // Allow modules to alter the css items. Also allow them to override the + // default handlers that will be used to group, aggregate, and render the css. + $handlers = array(); + drupal_alter('css', $css, $handlers); + $handlers += array( + 'group' => '_drupal_css_group', + 'aggregate' => '_drupal_css_aggregate', + 'render' => '_drupal_css_render', + ); // Sort css items according to their weights. uasort($css, 'drupal_sort_weight'); @@ -3284,75 +3280,349 @@ function drupal_get_css($css = NULL) { } } - // If CSS preprocessing is off, we still need to output the styles. - // Additionally, go through any remaining styles if CSS preprocessing is on - // and output the non-cached ones. - $css_element = array( + // Group the items, aggregate the groups, and render the tags. + $css_groups = $handlers['group']($css); + $handlers['aggregate']($css_groups); + return $handlers['render']($css_groups); +} + +/** + * Default handler to group CSS items. + * + * Once drupal_get_css() has an array of css items, the items then need to be + * grouped. Grouping allows files to be aggregated into fewer files which + * significantly reduces network traffic and improves page loading speed. + * When aggregation is disabled, grouping allows multiple files to be loaded + * from a single STYLE tag, enabling us to stay within IE's limit of 31 CSS + * inclusion tags. + * + * The grouping logic implemented by this function is to group all files that + * are eligible for aggregation and are for the same media type into a single + * group. This results in the fewest possible aggregate files when aggregation + * is enabled. However, in some cases, it can result in a change to the + * relative order of css items from what is specified in the $css array (for + * example, when a file that is ineligible for aggregation is between two files + * that are). A module wanting to always preserve relative order or wanting more + * control of which files get aggregated together can register an alternate + * group handler in hook_css_alter(). + * + * Although grouping and aggregation are related, they are different. Grouping + * should be performed the same way whether or not aggregation is enabled. This + * ensures that if CSS order is changed when aggregation is enabled, that it is + * also changed when aggregation is disabled. The site administrator should be + * free to enable aggregation without worrying that it will change the look of + * the website. + * + * @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 properties as a css + * item, where the property value applies to the group as a whole. Each group + * also contains an 'items' property which is an array of css items, like + * $css, but only with the items in the group. + * + * @see drupal_get_css() + * @see _drupal_css_aggregate() + */ +function _drupal_css_group($css) { + // Regardless of the order of the items in the $css array, we order all the + // 'file' types ahead of the 'external' types, and the 'external' types ahead + // of the 'inline' types. + // @todo Why? We need a comment explaining why we do this. It's not at all + // clear what the benefit is. + $file_groups = array(); + $inline_groups = array(); + $external_groups = array(); + foreach ($css as $item) { + switch ($item['type']) { + // If a file can be aggregated (whether or not the site-wide aggregation + // setting is enabled), put the item into the group for its media type. + // Otherwise, put it into its own group. + case 'file': + if ($item['preprocess']) { + $group_key = 'preprocess_' . $item['media']; + if (!isset($file_groups[$group_key])) { + $file_groups[$group_key] = array( + 'preprocess' => $item['preprocess'], + 'media' => $item['media'], + 'type' => $item['type'], + 'items' => array(), + ); + } + $file_groups[$group_key]['items'][] = $item; + } + else { + $file_groups[] = array( + 'preprocess' => $item['preprocess'], + 'media' => $item['media'], + 'type' => $item['type'], + 'items' => array($item), + ); + } + break; + // Put all inline items of the same media into the same group. + case 'inline': + $group_key = 'inline_' . $item['media']; + if (!isset($inline_groups[$group_key])) { + $inline_groups[$group_key] = array( + 'preprocess' => $item['preprocess'], + 'type' => $item['type'], + 'media' => $item['media'], + 'items' => array(), + ); + } + $inline_groups[$group_key]['items'][] = $item; + break; + // Put each external item in its own group. + case 'external': + $external_groups[] = array( + 'preprocess' => $item['preprocess'], + 'media' => $item['media'], + 'type' => $item['type'], + 'items' => array($item), + ); + break; + } + } + return array_merge($file_groups, $external_groups, $inline_groups); +} + +/** + * Default handler to aggregate 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). It also aggregates + * inline content together. This function also compresses the aggregate files + * (removes comments, whitespace, and other unnecessary content) for faster + * download. + * + * A module wanting to implement custom aggregation functionality can register + * an alternate aggregate handler in hook_css_alter(). + * + * @param $css_groups + * An array of css groups as returned by _drupal_css_group() or a function + * that overrides it. This function modifies the group's 'data' property for + * each group that is aggregated. + * + * @see _drupal_css_group() + */ +function _drupal_css_aggregate(&$css_groups) { + $preprocess_css = (variable_get('preprocess_css', FALSE) && (!defined('MAINTENANCE_MODE') || MAINTENANCE_MODE != 'update')); + $directory = file_directory_path('public'); + $is_writable = is_dir($directory) && is_writable($directory); + + // For non-aggregated files, a dummy query string is added to the URL (@see + // _drupal_css_render()). For aggregated files, it is instead added to the + // data from which an md5 hash is generated, so that a changed query string + // triggers a new aggregate file to be created. + $query_string = '?' . substr(variable_get('css_js_query_string', '0'), 0, 1); + + // 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 && $is_writable) { + $filename = 'css_' . md5(serialize($group['items']) . $query_string) . '.css'; + $css_groups[$key]['data'] = drupal_build_css_cache($group['items'], $filename); + } + 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; + } + } +} + +/** + * Default handler to render CSS tags. + * + * For production websites, LINK tags are preferable to STYLE tags with @import + * statements, because: + * - They are the standard tag intended for linking to a resource. + * - On Firefox 2 and perhaps other browsers, CSS files included with @import + * statements don't get saved when saving the complete web page for offline + * use: @see http://drupal.org/node/145218. + * - On IE, if only LINK tags and no @import statements are used, all the CSS + * files are downloaded in parallel, resulting in faster page load, but if + * @import statements are used and span across multiple STYLE tags, all the + * ones from one STYLE tag must be downloaded before downloading begins for + * the next STYLE tag. Furthermore, IE7 does not support media declaration on + * the @import statement, so multiple STYLE tags must be used when different + * files are for different media types. Non-IE browsers always download in + * parallel, so this is an IE-specific performance quirk. @see + * http://www.stevesouders.com/blog/2009/04/09/dont-use-import/. + * + * However, IE has an annoying limit of 31 total CSS inclusion tags (@see + * http://drupal.org/node/228818) and LINK tags are limited to one file per tag, + * whereas STYLE tags can contain multiple @import statements allowing multiple + * files to be loaded per tag. When CSS aggregation is disabled, a Drupal site + * can easily have more than 31 CSS files that need to be loaded, so using LINK + * tags exclusively would result in a site that would display incorrectly in IE. + * Depending on different needs, different strategies can be employed to deal + * with this problem. This function implements the default strategy. A module + * wanting to implement a different strategy can register its own render handler + * in hook_css_alter(). + * + * The strategy employed by this function is to use LINK tags for all files when + * aggregation is enabled, since this is the optimal choice, and when + * aggregation is enabled, is extremely unlikely to encroach on IE's tag limit. + * When aggregation is disabled, for files that would be aggregated if it were + * enabled, this function outputs a STYLE tag for each set of files that would + * be aggregated together (or if necessary, multiple STYLE tags per set, but + * with an attempt to output as few STYLE tags as possible). This results in + * either exactly the same number of tags (or possibly just 1 or 2 more) + * output when aggregation is disabled relative to when it's enabled, so in the + * unlikely event that the site uses many files that are ineligible for + * aggregation or can only be aggregated in small groups and is therefore in + * danger of hitting IE's tag limit even with aggregation enabled, it would be + * noticed when aggregation is disabled as well, providing the site builder an + * opportunity to fix the problem prior to site launch. + * + * This function evaluates the aggregation enabled/disabled condition on a group + * by group basis, so that modules can implement more sophisticated aggregation + * logic than the site-wide setting provided by default. Since disabling + * aggregation is primarily targeted for developers, this approach also has the + * side-benefit of allowing developers to easily see from the HTML source which + * files will and will not be aggregated (and in which groups) when aggregation + * gets enabled. + * + * @param $css_groups + * An array of css groups as returned by _drupal_css_group() or a function + * that overrides it. + * @return + * A string of XHTML CSS tags. + * + * @see _drupal_css_group() + * @see _drupal_css_aggregate() + */ +function _drupal_css_render($css_groups) { + $output = ''; + + // 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 = '?' . substr(variable_get('css_js_query_string', '0'), 0, 1); + + // Defaults for LINK and STYLE elements. + $link_element_defaults = array( '#tag' => 'link', '#attributes' => array( 'type' => 'text/css', 'rel' => 'stylesheet', ), ); - $rendered_css = array(); - $inline_css = ''; - $external_css = ''; - $preprocess_items = array(); - foreach ($css as $data => $item) { - // Loop through each of the stylesheets, including them appropriately based - // on their type. - switch ($item['type']) { + $style_element_defaults = array( + '#tag' => 'style', + '#attributes' => array( + 'type' => 'text/css', + ), + ); + + // Loop through each group. + foreach ($css_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': - // Depending on whether aggregation is desired, include the file. - if (!$item['preprocess'] || !($is_writable && $preprocess_css)) { - $element = $css_element; - $element['#attributes']['media'] = $item['media']; - $element['#attributes']['href'] = file_create_url($item['data']) . $query_string; - $rendered_css[] = theme('html_tag', array('element' => $element)); + // 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; + // The aggregate file name already incorporates the dummy query + // string information. The query string does not need to be added to + // the URL. + $element['#attributes']['href'] = file_create_url($group['data']); + $element['#attributes']['media'] = $group['media']; + $output .= theme('html_tag', array('element' => $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("' . 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; + $element['#value'] = implode("\n", $import_batch); + $element['#attributes']['media'] = $group['media']; + $output .= theme('html_tag', array('element' => $element)); + } } + // The group contains items ineligible for aggregation: output a LINK + // tag for each file. else { - $preprocess_items[$item['media']][] = $item; - // Mark the position of the preprocess element, - // it should be at the position of the first preprocessed file. - $rendered_css['preprocess'] = ''; + foreach ($group['items'] as $item) { + $element = $link_element_defaults; + // The dummy query string needs to be added to the URL to control + // browser-caching. + $element['#attributes']['href'] = file_create_url($item['data']) . $query_string; + $element['#attributes']['media'] = $item['media']; + $output .= theme('html_tag', array('element' => $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': - // Include inline stylesheets. - $inline_css .= drupal_load_stylesheet_content($item['data'], $item['preprocess']); + if (isset($group['data'])) { + $element = $style_element_defaults; + $element['#value'] = $group['data']; + $element['#attributes']['media'] = $group['media']; + $output .= "\n" . theme('html_tag', array('element' => $element)); + } + else { + foreach ($group['items'] as $item) { + $element = $style_element_defaults; + $element['#value'] = $item['data']; + $element['#attributes']['media'] = $item['media']; + $output .= "\n" . theme('html_tag', array('element' => $element)); + } + } break; + // Output a LINK tag for each external item. The item's 'data' property + // contains the full URL. case 'external': - // Preprocessing for external CSS files is ignored. - $element = $css_element; - $element['#attributes']['media'] = $item['media']; - $element['#attributes']['href'] = $item['data']; - $external_css .= theme('html_tag', array('element' => $element)); + foreach ($group['items'] as $item) { + $element = $link_element_defaults; + $element['#attributes']['href'] = $item['data']; + $element['#attributes']['media'] = $item['media']; + $output .= theme('html_tag', array('element' => $element)); + } break; } } - if (!empty($preprocess_items)) { - foreach ($preprocess_items as $media => $items) { - // Prefix filename to prevent blocking by firewalls which reject files - // starting with "ad*". - $element = $css_element; - $element['#attributes']['media'] = $media; - $filename = 'css_' . md5(serialize($items) . $query_string) . '.css'; - $element['#attributes']['href'] = file_create_url(drupal_build_css_cache($items, $filename)); - $rendered_css['preprocess'] .= theme('html_tag', array('element' => $element)); - } - } - // Enclose the inline CSS with the style tag if required. - if (!empty($inline_css)) { - $element = $css_element; - $element['#tag'] = 'style'; - $element['#value'] = $inline_css; - unset($element['#attributes']['rel']); - $inline_css = "\n" . theme('html_tag', array('element' => $element)); - } - - // Output all the CSS files with the inline stylesheets showing up last. - return implode($rendered_css) . $external_css . $inline_css; + return $output; } /** Index: modules/simpletest/tests/common.test =================================================================== RCS file: /cvs/drupal/drupal/modules/simpletest/tests/common.test,v retrieving revision 1.100 diff -u -p -r1.100 common.test --- modules/simpletest/tests/common.test 8 Jan 2010 06:07:03 -0000 1.100 +++ modules/simpletest/tests/common.test 8 Jan 2010 18:58:01 -0000 @@ -558,7 +558,9 @@ class CascadingStylesheetsTestCase exten $css = 'http://example.com/style.css'; drupal_add_css($css, 'external'); $styles = drupal_get_css(); - $this->assertTrue(strpos($styles, 'href="' . $css) > 0, t('Rendering an external CSS file.')); + // Stylesheet URL may be the href of a LINK tag or in an @import statement + // of a STYLE tag. + $this->assertTrue(strpos($styles, 'href="' . $css) > 0 || strpos($styles, '@import url("' . $css . '")') > 0, t('Rendering an external CSS file.')); } /** @@ -566,7 +568,7 @@ class CascadingStylesheetsTestCase exten */ function testRenderInlinePreprocess() { $css = 'body { padding: 0px; }'; - $css_preprocessed = ''; + $css_preprocessed = ''; drupal_add_css($css, 'inline'); $styles = drupal_get_css(); $this->assertEqual($styles, "\n" . $css_preprocessed . "\n", t('Rendering preprocessed inline CSS adds it to the page.')); @@ -631,8 +633,10 @@ class CascadingStylesheetsTestCase exten $styles = drupal_get_css(); - if (preg_match_all('/href="' . preg_quote($GLOBALS['base_url'] . '/', '/') . '([^?]+)\?/', $styles, $matches)) { - $result = $matches[1]; + // Stylesheet URL may be the href of a LINK tag or in an @import statement + // of a STYLE tag. + if (preg_match_all('/(href="|url\(")' . preg_quote($GLOBALS['base_url'] . '/', '/') . '([^?]+)\?/', $styles, $matches)) { + $result = $matches[2]; } else { $result = array(); Index: modules/system/system.api.php =================================================================== RCS file: /cvs/drupal/drupal/modules/system/system.api.php,v retrieving revision 1.116 diff -u -p -r1.116 system.api.php --- modules/system/system.api.php 8 Jan 2010 11:07:01 -0000 1.116 +++ modules/system/system.api.php 8 Jan 2010 18:58:01 -0000 @@ -551,13 +551,28 @@ function hook_library_alter(&$libraries, * Alter CSS files before they are output on the page. * * @param $css - * An array of all CSS items (files and inline CSS) being requested on the page. + * An array of all CSS items (files and inline CSS) being requested on the + * page. + * @param $handlers + * An array of handlers to override Drupal's default implementation of how + * to group CSS items, aggregate the files within a group, and render the CSS + * tags. Can contain the following keys: + * - group: A function to run instead of _drupal_css_group(). + * - aggregate: A function to run instead of _drupal_css_aggregate(). + * - render: A function to run instead of _drupal_css_render(). + * * @see drupal_add_css() * @see drupal_get_css() */ -function hook_css_alter(&$css) { +function hook_css_alter(&$css, &$handlers) { // Remove defaults.css file. unset($css[drupal_get_path('module', 'system') . '/defaults.css']); + + // This site will definitely have few enough CSS files to not encroach on IE's + // limit, but for some reason needs to run with aggregation disabled, and we + // want all CSS files included with LINK tags instead @import statements, so + // override _drupal_css_render(). + $handlers['render'] = 'mymodule_css_render'; } /**