Skip to content

Commit

Permalink
MDL-38441 css: implemented chunking of large sheets
Browse files Browse the repository at this point in the history
  • Loading branch information
Sam Hemelryk committed Apr 18, 2013
1 parent 3a8c438 commit e107502
Show file tree
Hide file tree
Showing 2 changed files with 134 additions and 11 deletions.
118 changes: 111 additions & 7 deletions lib/csslib.php
Expand Up @@ -35,8 +35,12 @@
* @param theme_config $theme The theme that the CSS belongs to.
* @param string $csspath The path to store the CSS at.
* @param array $cssfiles The CSS files to store.
* @param bool $chunk If set to true these files will be chunked to ensure
* that no one file contains more than 4095 selectors.
* @param string $chunkurl If the CSS is be chunked then we need to know the URL
* to use for the chunked files.
*/
function css_store_css(theme_config $theme, $csspath, array $cssfiles) {
function css_store_css(theme_config $theme, $csspath, array $cssfiles, $chunk = false, $chunkurl = null) {
global $CFG;

// Check if both the CSS optimiser is enabled and the theme supports it.
Expand Down Expand Up @@ -72,6 +76,13 @@ function css_store_css(theme_config $theme, $csspath, array $cssfiles) {
$css = $theme->post_process(css_minify_css($cssfiles));
}

if ($chunk) {
// Chunk the CSS if requried.
$css = css_chunk_by_selector_count($css, $chunkurl);
} else {
$css = array($css);
}

clearstatcache();
if (!file_exists(dirname($csspath))) {
@mkdir(dirname($csspath), $CFG->directorypermissions, true);
Expand All @@ -80,19 +91,112 @@ function css_store_css(theme_config $theme, $csspath, array $cssfiles) {
// Prevent serving of incomplete file from concurrent request,
// the rename() should be more atomic than fwrite().
ignore_user_abort(true);
if ($fp = fopen($csspath.'.tmp', 'xb')) {
fwrite($fp, $css);
fclose($fp);
rename($csspath.'.tmp', $csspath);
@chmod($csspath, $CFG->filepermissions);
@unlink($csspath.'.tmp'); // just in case anything fails

$files = count($css);
$count = 0;
foreach ($css as $content) {
if ($files > 1 && ($count+1) !== $files) {
// If there is more than one file and this is not the last file.
$filename = preg_replace('#\.css$#', '.'.$count.'.css', $csspath);
$count++;
} else {
$filename = $csspath;
}
if ($fp = fopen($filename.'.tmp', 'xb')) {
fwrite($fp, $content);
fclose($fp);
rename($filename.'.tmp', $filename);
@chmod($filename, $CFG->filepermissions);
@unlink($filename.'.tmp'); // just in case anything fails
}
}

ignore_user_abort(false);
if (connection_aborted()) {
die;
}
}

/**
* Takes CSS and chunks it if the number of selectors within it exceeds $maxselectors.
*
* @param string $css The CSS to chunk.
* @param string $importurl The URL to use for import statements.
* @param int $maxselectors The number of selectors to limit a chunk to.
* @param int $buffer The buffer size to use when chunking. You shouldn't need to reduce this
* unless you are lowering the maximum selectors.
* @return array An array of CSS chunks.
*/
function css_chunk_by_selector_count($css, $importurl, $maxselectors = 4095, $buffer = 50) {
// Check if we need to chunk this CSS file.
$count = substr_count($css, ',') + substr_count($css, '{');
if ($count < $maxselectors) {
// The number of selectors is less then the max - we're fine.
return array($css);
}

// Chunk time ?!
// Split the CSS by array, making sure to save the delimiter in the process.
$parts = preg_split('#([,\}])#', $css, null, PREG_SPLIT_DELIM_CAPTURE + PREG_SPLIT_NO_EMPTY);
// We need to chunk the array. Each delimiter is stored separately so we multiple by 2.
// We also subtract 100 to give us a small buffer just in case.
$parts = array_chunk($parts, $maxselectors * 2 - $buffer * 2);
$css = array();
$partcount = count($parts);
foreach ($parts as $key => $chunk) {
if (end($chunk) === ',') {
// Damn last element was a comma.
// Pretty much the only way to deal with this is to take the styles from the end of the
// comma separated chain of selectors and apply it to the last selector we have here in place
// of the comma.
// Unit tests are essential for making sure this works.
$styles = false;
$i = $key;
while ($styles === false && $i < ($partcount - 1)) {
$i++;
$nextpart = $parts[$i];
foreach ($nextpart as $style) {
if (strpos($style, '{') !== false) {
$styles = preg_replace('#^[^\{]+#', '', $style);
break;
}
}
}
if ($styles === false) {
$styles = '/** Error chunking CSS **/';
} else {
$styles .= '}';
}
array_pop($chunk);
array_push($chunk, $styles);
}
$css[] = join('', $chunk);
}
// The array $css now contains CSS split into perfect sized chunks.
// Import statements can only appear at the very top of a CSS file.
// Imported sheets are applied in the the order they are imported and
// are followed by the contents of the CSS.
// This is terrible for performance.
// It means we must put the import statements at the top of the last chunk
// to ensure that things are always applied in the correct order.
// This way the chunked files are included in the order they were chunked
// followed by the contents of the final chunk in the actual sheet.
$importcss = '';
$slashargs = strpos($importurl, '.php?') === false;
$parts = count($css);
for ($i = 0; $i < $parts - 1; $i++) {
if ($slashargs) {
$importcss .= "@import url({$importurl}/chunk{$i});\n";
} else {
$importcss .= "@import url({$importurl}&chunk={$i});\n";
}
}
$importcss .= end($css);
$css[key($css)] = $importcss;

return $css;
}

/**
* Sends IE specific CSS
*
Expand Down
27 changes: 23 additions & 4 deletions theme/styles.php
Expand Up @@ -36,7 +36,7 @@
if ($slashargument = min_get_slash_argument()) {
$slashargument = ltrim($slashargument, '/');
if (substr_count($slashargument, '/') < 2) {
image_not_found();
css_send_css_not_found();
}

if (strpos($slashargument, '_s/') === 0) {
Expand All @@ -47,7 +47,12 @@
$usesvg = true;
}

// image must be last because it may contain "/"
$chunk = null;
if (preg_match('#/(chunk(\d+)(/|$))#', $slashargument, $matches)) {
$chunk = (int)$matches[2];
$slashargument = str_replace($matches[1], '', $slashargument);
}

list($themename, $rev, $type) = explode('/', $slashargument, 3);
$themename = min_clean_param($themename, 'SAFEDIR');
$rev = min_clean_param($rev, 'INT');
Expand All @@ -57,6 +62,7 @@
$themename = min_optional_param('theme', 'standard', 'SAFEDIR');
$rev = min_optional_param('rev', 0, 'INT');
$type = min_optional_param('type', 'all', 'SAFEDIR');
$chunk = min_optional_param('chunk', null, 'INT');
$usesvg = (bool)min_optional_param('svg', '1', 'INT');
}

Expand All @@ -80,12 +86,17 @@

$candidatedir = "$CFG->cachedir/theme/$themename/css";
$etag = "$themename/$rev/$type";
$candidatename = $type;
if (!$usesvg) {
// Add to the sheet name, one day we'll be able to just drop this.
$candidatedir .= '/nosvg';
$etag .= '/nosvg';
}
$candidatesheet = "$candidatedir/$type.css";
if ($chunk !== null) {
$etag .= '/chunk'.$chunk;
$candidatename .= '.'.$chunk;
}
$candidatesheet = "$candidatedir/$candidatename.css";
$etag = sha1($etag);

if (file_exists($candidatesheet)) {
Expand Down Expand Up @@ -122,13 +133,21 @@
$cssfiles = $theme->editor_css_files();
css_store_css($theme, $candidatesheet, $cssfiles);
} else {
// IE requests plugins/parents/theme instead of all at once.
$chunk = in_array($type, array('plugins', 'parents', 'theme'));
$basedir = "$CFG->cachedir/theme/$themename/css";
if (!$usesvg) {
$basedir .= '/nosvg';
}
$css = $theme->css_files();
$allfiles = array();
$relroot = preg_replace('|^http.?://[^/]+|', '', $CFG->wwwroot);
foreach ($css as $key=>$value) {
if (!empty($slashargument)) {
$chunkurl = "{$relroot}/theme/styles.php/{$themename}/{$rev}/{$key}";
} else {
$chunkurl = "{$relroot}/theme/styles.php?theme={$themename}&rev={$rev}&type={$key}";
}
$cssfiles = array();
foreach($value as $val) {
if (is_array($val)) {
Expand All @@ -140,7 +159,7 @@
}
}
$cssfile = "$basedir/$key.css";
css_store_css($theme, $cssfile, $cssfiles);
css_store_css($theme, $cssfile, $cssfiles, true, $chunkurl);
$allfiles = array_merge($allfiles, $cssfiles);
}
$cssfile = "$basedir/all.css";
Expand Down

0 comments on commit e107502

Please sign in to comment.