Skip to content

Commit

Permalink
MDL-44936 csslib: Chunking supports @media queries and commas in rules
Browse files Browse the repository at this point in the history
  • Loading branch information
Frederic Massart committed Apr 11, 2014
1 parent 1a727e1 commit eddc146
Show file tree
Hide file tree
Showing 2 changed files with 212 additions and 76 deletions.
163 changes: 125 additions & 38 deletions lib/csslib.php
Original file line number Diff line number Diff line change
Expand Up @@ -107,59 +107,146 @@ function css_write_file($filename, $content) {
/**
* Takes CSS and chunks it if the number of selectors within it exceeds $maxselectors.
*
* The chunking will not split a group of selectors, or a media query. That means that
* if n > $maxselectors and there are n selectors grouped together,
* they will not be chunked and you could end up with more selectors than desired.
* The same applies for a media query that has more than n selectors.
*
* Also, as we do not split group of selectors or media queries, the chunking might
* not be as optimal as it could be, having files with less selectors than it could
* potentially contain.
*
* String functions used here are not compliant with unicode characters. But that is
* not an issue as the syntax of CSS is using ASCII codes. Even if we have unicode
* characters in comments, or in the property 'content: ""', it will behave correcly.
*
* Please note that this strips out the comments if chunking happens.
*
* @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.
* @param int $buffer Not used any more.
* @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;
}
$chunks = array(); // The final chunks.
$offsets = array(); // The indexes to chunk at.
$offset = 0; // The current offset.
$selectorcount = 0; // The number of selectors since the last split.
$lastvalidoffset = 0; // The last valid index to split at.
$lastvalidoffsetselectorcount = 0; // The number of selectors used at the time were could split.
$inrule = 0; // The number of rules we are in, should not be greater than 1.
$inmedia = false; // Whether or not we are in a media query.
$mediacoming = false; // Whether or not we are expeting a media query.
$currentoffseterror = null; // Not null when we have recorded an error for the current split.
$offseterrors = array(); // The offsets where we found errors.

// Remove the comments. Because it's easier, safer and probably a lot of other good reasons.
$css = preg_replace('#/\*(.*?)\*/#s', '', $css);
$strlen = strlen($css);

// Walk through the CSS content character by character.
for ($i = 1; $i <= $strlen; $i++) {
$char = $css[$i - 1];
$offset = $i;

// Is that a media query that I see coming towards us?
if ($char === '@') {
if (!$inmedia && substr($css, $offset, 5) === 'media') {
$mediacoming = true;
}
}

// So we are entering a rule or a media query...
if ($char === '{') {
if ($mediacoming) {
$inmedia = true;
$mediacoming = false;
} else {
$inrule++;
$selectorcount++;
}
}

// Let's count the number of selectors, but only if we are not in a rule as they
// can contain commas too.
if (!$inrule && $char === ',') {
$selectorcount++;
}

// We reached the end of something.
if ($char === '}') {
// Oh, we are in a media query.
if ($inmedia) {
if (!$inrule) {
// This is the end of the media query.
$inmedia = false;
} else {
// We were in a rule, in the media query.
$inrule--;
}
} else {
$inrule--;
}

// We are not in a media query, and there is no pending rule, it is safe to split here.
if (!$inmedia && !$inrule) {
$lastvalidoffset = $offset;
$lastvalidoffsetselectorcount = $selectorcount;
}
if ($styles === false) {
$styles = '/** Error chunking CSS **/';
}

// Alright, this is splitting time...
if ($selectorcount > $maxselectors) {
if (!$lastvalidoffset) {
// We must have reached more selectors into one set than we were allowed. That means that either
// the chunk size value is too small, or that we have a gigantic group of selectors, or that a media
// query contains more selectors than the chunk size. We have to ignore this because we do not
// support split inside a group of selectors or media query.
if ($currentoffseterror === null) {
$currentoffseterror = $offset;
$offseterrors[] = $currentoffseterror;
}
} else {
$styles .= '}';
// We identify the offset to split at and reset the number of selectors found from there.
$offsets[] = $lastvalidoffset;
$selectorcount = $selectorcount - $lastvalidoffsetselectorcount;
$lastvalidoffset = 0;
$currentoffseterror = null;
}
array_pop($chunk);
array_push($chunk, $styles);
}
$css[] = join('', $chunk);
}
// The array $css now contains CSS split into perfect sized chunks.

// Report offset errors.
if (!empty($offseterrors)) {
debugging('Could not find a safe place to split at offset(s): ' . implode(', ', $offseterrors) . '. Those were ignored.',
DEBUG_DEVELOPER);
}

// Now that we have got the offets, we can chunk the CSS.
$offsetcount = count($offsets);
foreach ($offsets as $key => $index) {
$start = 0;
if ($key > 0) {
$start = $offsets[$key - 1];
}
// From somewhere up to the offset.
$chunks[] = substr($css, $start, $index - $start);
}
// Add the last chunk (if there is one), from the last offset to the end of the string.
if (end($offsets) != $strlen) {
$chunks[] = substr($css, end($offsets));
}

// The array $chunks 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.
Expand All @@ -170,18 +257,18 @@ function css_chunk_by_selector_count($css, $importurl, $maxselectors = 4095, $bu
// followed by the contents of the final chunk in the actual sheet.
$importcss = '';
$slashargs = strpos($importurl, '.php?') === false;
$parts = count($css);
$parts = count($chunks);
for ($i = 1; $i < $parts; $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;
$importcss .= end($chunks);
$chunks[key($chunks)] = $importcss;

return $css;
return $chunks;
}

/**
Expand Down
125 changes: 87 additions & 38 deletions lib/tests/csslib_test.php
Original file line number Diff line number Diff line change
Expand Up @@ -1082,7 +1082,7 @@ public function test_css_optimisation_ordering() {
public function test_css_chunking() {
// Test with an even number of styles.
$css = 'a{}b{}c{}d{}e{}f{}';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2, 0);
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2);
$this->assertInternalType('array', $chunks);
$this->assertCount(3, $chunks);
$this->assertArrayHasKey(0, $chunks);
Expand All @@ -1094,7 +1094,7 @@ public function test_css_chunking() {

// Test with an odd number of styles.
$css = 'a{}b{}c{}d{}e{}';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2, 0);
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2);
$this->assertInternalType('array', $chunks);
$this->assertCount(3, $chunks);
$this->assertArrayHasKey(0, $chunks);
Expand All @@ -1104,21 +1104,9 @@ public function test_css_chunking() {
$this->assertSame('c{}d{}', $chunks[1]);
$this->assertSame("@import url(styles.php?type=test&chunk=1);\n@import url(styles.php?type=test&chunk=2);\ne{}", $chunks[2]);

// Test buffering. Set a buffer that will reduce the effective sheet size back to two.
$css = 'a{}b{}c{}d{}e{}f{}';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 6, 4);
$this->assertInternalType('array', $chunks);
$this->assertCount(3, $chunks);
$this->assertArrayHasKey(0, $chunks);
$this->assertArrayHasKey(1, $chunks);
$this->assertArrayHasKey(2, $chunks);
$this->assertSame('a{}b{}', $chunks[0]);
$this->assertSame('c{}d{}', $chunks[1]);
$this->assertSame("@import url(styles.php?type=test&chunk=1);\n@import url(styles.php?type=test&chunk=2);\ne{}f{}", $chunks[2]);

// Test well placed commas.
$css = 'a,b{}c,d{}e,f{}';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2, 0);
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2);
$this->assertInternalType('array', $chunks);
$this->assertCount(3, $chunks);
$this->assertArrayHasKey(0, $chunks);
Expand All @@ -1130,56 +1118,117 @@ public function test_css_chunking() {

// Test unfortunately placed commas.
$css = 'a{}b,c{color:red;}d{}e{}f{}';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2, 0);
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2);
$this->assertInternalType('array', $chunks);
$this->assertCount(3, $chunks);
$this->assertCount(4, $chunks);
$this->assertArrayHasKey(0, $chunks);
$this->assertArrayHasKey(1, $chunks);
$this->assertArrayHasKey(2, $chunks);
$this->assertSame('a{}b{color:red;}', $chunks[0]);
$this->assertSame('c{color:red;}d{}', $chunks[1]);
$this->assertSame("@import url(styles.php?type=test&chunk=1);\n@import url(styles.php?type=test&chunk=2);\ne{}f{}", $chunks[2]);
$this->assertArrayHasKey(3, $chunks);
$this->assertSame('a{}', $chunks[0]);
$this->assertSame('b,c{color:red;}', $chunks[1]);
$this->assertSame('d{}e{}', $chunks[2]);
$this->assertSame("@import url(styles.php?type=test&chunk=1);\n@import url(styles.php?type=test&chunk=2);\n@import url(styles.php?type=test&chunk=3);\nf{}", $chunks[3]);

// Test unfortunate CSS.
$css = 'a,b,c,d,e,f{color:red;}';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2, 0);
$this->assertInternalType('array', $chunks);
$this->assertCount(3, $chunks);
$this->assertCount(1, $chunks);
$this->assertArrayHasKey(0, $chunks);
$this->assertArrayHasKey(1, $chunks);
$this->assertArrayHasKey(2, $chunks);
$this->assertSame('a,b{color:red;}', $chunks[0]);
$this->assertSame('c,d{color:red;}', $chunks[1]);
$this->assertSame("@import url(styles.php?type=test&chunk=1);\n@import url(styles.php?type=test&chunk=2);\ne,f{color:red;}", $chunks[2]);
$this->assertSame('a,b,c,d,e,f{color:red;}', $chunks[0]);
$this->assertDebuggingCalled('Could not find a safe place to split at offset(s): 6. Those were ignored.');

// Test to make sure invalid CSS isn't totally ruined.
$css = 'a{},,,e{},';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2, 0);
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2);
// Believe it or not we want to care what comes out here as this will be parsed correctly
// by a browser.
$this->assertInternalType('array', $chunks);
$this->assertCount(2, $chunks);
$this->assertCount(3, $chunks);
$this->assertArrayHasKey(0, $chunks);
$this->assertArrayHasKey(1, $chunks);
$this->assertSame('a{},{}', $chunks[0]);
$this->assertSame("@import url(styles.php?type=test&chunk=1);\n,e{}/** Error chunking CSS **/", $chunks[1]);
$this->assertArrayHasKey(2, $chunks);
$this->assertSame('a{}', $chunks[0]);
$this->assertSame(',,,e{}', $chunks[1]);
$this->assertSame("@import url(styles.php?type=test&chunk=1);\n@import url(styles.php?type=test&chunk=2);\n,", $chunks[2]);
$this->assertDebuggingCalled('Could not find a safe place to split at offset(s): 6. Those were ignored.');

// Test utter crap CSS to make sure we don't loop to our deaths.
$css = 'a,b,c,d,e,f';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2, 0);
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2);
$this->assertInternalType('array', $chunks);
$this->assertCount(3, $chunks);
$this->assertCount(1, $chunks);
$this->assertArrayHasKey(0, $chunks);
$this->assertArrayHasKey(1, $chunks);
$this->assertArrayHasKey(2, $chunks);
$this->assertSame('a,b/** Error chunking CSS **/', $chunks[0]);
$this->assertSame('c,d/** Error chunking CSS **/', $chunks[1]);
$this->assertSame("@import url(styles.php?type=test&chunk=1);\n@import url(styles.php?type=test&chunk=2);\ne,f", $chunks[2]);
$this->assertSame($css, $chunks[0]);
$this->assertDebuggingCalled('Could not find a safe place to split at offset(s): 6. Those were ignored.');

// Test another death situation to make sure we're invincible.
$css = 'a,,,,,e';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2, 0);
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2);
$this->assertInternalType('array', $chunks);
$this->assertDebuggingCalled('Could not find a safe place to split at offset(s): 4. Those were ignored.');
// I don't care what the outcome is, I just want to make sure it doesn't die.

// Test media queries.
$css = '@media (min-width: 980px) { .a,.b{} }';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2);
$this->assertCount(1, $chunks);
$this->assertSame('@media (min-width: 980px) { .a,.b{} }', $chunks[0]);

// Test special rules.
$css = 'a,b{ background-image: linear-gradient(to bottom, #ffffff, #cccccc);}d,e{}';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2);
$this->assertCount(2, $chunks);
$this->assertSame('a,b{ background-image: linear-gradient(to bottom, #ffffff, #cccccc);}', $chunks[0]);
$this->assertSame("@import url(styles.php?type=test&chunk=1);\nd,e{}", $chunks[1]);

// Test media queries with too many selectors.
$css = '@media (min-width: 980px) { a,b,c,d{} }';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2);
$this->assertCount(1, $chunks);
$this->assertSame('@media (min-width: 980px) { a,b,c,d{} }', $chunks[0]);
$this->assertDebuggingCalled('Could not find a safe place to split at offset(s): 34. Those were ignored.');

// Complex test.
$css = '@media (a) {b{}} c{} d,e{} f,g,h{} i,j{x:a,b,c} k,l{} @media(x){l,m{ y: a,b,c}} n{}';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 3);
$this->assertCount(6, $chunks);
$this->assertSame('@media (a) {b{}} c{}', $chunks[0]);
$this->assertSame(' d,e{}', $chunks[1]);
$this->assertSame(' f,g,h{}', $chunks[2]);
$this->assertSame(' i,j{x:a,b,c}', $chunks[3]);
$this->assertSame(' k,l{}', $chunks[4]);
$this->assertSame("@import url(styles.php?type=test&chunk=1);\n@import url(styles.php?type=test&chunk=2);\n@import url(styles.php?type=test&chunk=3);\n@import url(styles.php?type=test&chunk=4);\n@import url(styles.php?type=test&chunk=5);\n @media(x){l,m{ y: a,b,c}} n{}", $chunks[5]);

// Multiple offset errors.
$css = 'a,b,c{} d,e,f{}';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2);
$this->assertCount(2, $chunks);
$this->assertSame('a,b,c{}', $chunks[0]);
$this->assertSame("@import url(styles.php?type=test&chunk=1);\n d,e,f{}", $chunks[1]);
$this->assertDebuggingCalled('Could not find a safe place to split at offset(s): 6, 14. Those were ignored.');

// Test the split according to IE.
$css = str_repeat('a{}', 4100);
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test');
$this->assertCount(2, $chunks);
$this->assertSame(str_repeat('a{}', 4095), $chunks[0]);
$this->assertSame("@import url(styles.php?type=test&chunk=1);\n" . str_repeat('a{}', 5), $chunks[1]);

// Test strip out comments.
$css = ".a {/** a\nb\nc */} /** a\nb\nc */ .b{} /** .c,.d{} */ e{}";
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2);
$this->assertCount(2, $chunks);
$this->assertSame('.a {} .b{}', $chunks[0]);
$this->assertSame("@import url(styles.php?type=test&chunk=1);\n e{}", $chunks[1]);

// Test something with unicode characters.
$css = 'a,b{} nav a:hover:after { content: "↓"; } b{ color:test;}';
$chunks = css_chunk_by_selector_count($css, 'styles.php?type=test', 2);
$this->assertCount(2, $chunks);
$this->assertSame('a,b{}', $chunks[0]);
$this->assertSame("@import url(styles.php?type=test&chunk=1);\n nav a:hover:after { content: \"\"; } b{ color:test;}", $chunks[1]);
}

/**
Expand Down

0 comments on commit eddc146

Please sign in to comment.