From 0ae185c9d34ad2dd9d2926be042cf0ce9cfba299 Mon Sep 17 00:00:00 2001 From: Frederic Massart Date: Mon, 7 Apr 2014 16:30:58 +0800 Subject: [PATCH] MDL-44936 csslib: Chunking supports @media queries and commas in rules --- lib/csslib.php | 167 +++++++++++++++++++++++++++++--------- lib/tests/csslib_test.php | 131 ++++++++++++++++++++---------- 2 files changed, 217 insertions(+), 81 deletions(-) diff --git a/lib/csslib.php b/lib/csslib.php index 2f9eeb7a43f..03fcb15629f 100644 --- a/lib/csslib.php +++ b/lib/csslib.php @@ -134,14 +134,29 @@ 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) { @@ -149,44 +164,116 @@ function css_chunk_by_selector_count($css, $importurl, $maxselectors = 4095, $bu 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; + } + } + + // 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 { + // 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; } - 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. + + // 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. @@ -197,7 +284,7 @@ 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"; @@ -205,10 +292,10 @@ function css_chunk_by_selector_count($css, $importurl, $maxselectors = 4095, $bu $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; } /** diff --git a/lib/tests/csslib_test.php b/lib/tests/csslib_test.php index a798e20c827..a27749af32f 100644 --- a/lib/tests/csslib_test.php +++ b/lib/tests/csslib_test.php @@ -1082,7 +1082,7 @@ CSS; 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); @@ -1094,7 +1094,7 @@ CSS; // 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); @@ -1104,21 +1104,9 @@ CSS; $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); @@ -1130,56 +1118,117 @@ CSS; // 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->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]); - - // 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); - $this->assertInternalType('array', $chunks); $this->assertCount(3, $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('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); + $this->assertInternalType('array', $chunks); + $this->assertCount(1, $chunks); + $this->assertArrayHasKey(0, $chunks); + $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, true); + $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]); } /**