More powerful CSS with Preprocessing
This post is a follow up to my previous post entitled Less annoying CSS using PHP Preprocessing. It’s a refactoring of the original code that’s evolved through various projects I’ve thrown it at.
I’d like to think that the quality of the code is higher since I now consider it far more maintainable. Strikingly, it now is implemented as a class since I never liked having the guts of the implementation visible through all of its helper functions and callbacks.
It is not, mind you, an attempt to make it faster, just more maintainable and powerful, so caching is absolutely necessary on a high traffic website (when is it not though).
So, the complete feature set is now:
- Allows for nested selectors
- The following is valid code:
#navbar { border: 1px solid black; h2 { background-color: black; } li { text-decoration: none; } }
- Constants
- Constants may be defined, and may be overridden like CSS rules.
//Example: [Background Color=#000] body { background-color: [Background Color] }
- Imports are performed
- CSS imports are now expanded inline with some restrictions. They need to be relative paths, and they need to be relative to the “$base_path” specified while calling the processor. Also, because I didn’t want to fiddle with the Regex anymore, I’ve made it require double quotes around the path. If a file can’t be imported, it’s treated as an empty CSS file.
For example, the following piece of code would perform the imports and then perform preprocessing:
@import url("header.css"); @import url("content.css"); @import url("footer.css");
- Single Line Comments
- I found this too annoying to allow. I found myself using single line comments (of the double forward slash variety) and getting CSS errors. Never again
- Minification (Compression)
- I’m stripping everything out that I think isn’t necessary and doesn’t seem to affect the final rendered page (whitespace, empty rules, etc). I haven’t yet encountered anything that’s troublesome, but please let me know if you do.
Without Further Ado… he’s the code (css.php.txt):
<?php // For the purists who don't like enforcing Object Orientation onto the general public function renderCSS($raw_css, $base_path) { $cssProcessor = new CSSProcessor($raw_css, $base_path); return $cssProcessor->process(); } class CSSProcessor { private $base_path = ''; private $constants = array(); private $content; public function __construct($content, $base_path) { $this->content = $content; $this->base_path = $base_path; } /** * Processes CSS so that: * 1. imports are expanded inline * @import url("testing.css") would be loaded in place * and any imports it has would also be imported * 2. constants may be defined. For example [Background Color=#000] * would allow for the use of [Background Color] instead of the hard coded #000 * 3. Single line comments are allows ala //this is a comment style * 4. The following is stripped out: comments, unnecessary whitespace, empty rules (a {}) * 5. Nesting of CSS rules. * For example #navbar a { ... } could be rewritten as #navbar { a{ ... } } */ public function process() { $this->expandImports(); $this->expandConstants(); $this->fixNesting(); $this->minify(); return $this->content; } private function expandConstants() { $css = preg_replace_callback( '/\[([^\]]+)=([^\]]+)\]/', array($this, 'extractConstantsCallback'), $this->content); $css = preg_replace_callback( '/\[([^\]]+)\]/', array($this, 'substituteConstantsCallback'), $css); $this->content = $css; } private function extractConstantsCallback(array $matches) { $constant = $matches[1]; $value = $matches[2]; $this->constants[$constant] = $value; return ''; } private function substituteConstantsCallback(array $matches) { $constant_name = $matches[1]; if (!isset($this->constants[$constant_name])) throw new Exception("Reference to unknown CSS Constant: $constant_name"); return $this->constants[$constant_name]; } private function expandImports($max_depth = 10) { $cssText = $this->content; $imported = $cssText; $import_depth = 0; do { $import_depth ++; $cssText = $imported; $imported = preg_replace_callback( '#@import +url\("([a-z0-9_\-]+\.x?css)"\);?#', array($this, 'importCallback'), $cssText); } while ($imported != $cssText && $import_depth < 10); if ($import_depth == $max_depth) { $this->content = '/*Recursive import problem*/'; } else { $this->content = $cssText; } } private function importCallback(array $matches) { $filename = $matches[1]; return file_get_contents($this->base_path.'/'.$filename); } private function fixNesting() { $result = array(); $pieces = split("{", $this->content); $selectorStackIndex = 0; $selectorStack = array(); $currentPieceIndex = 0; for ($i = 0; $i<count($pieces); $i++) { $piece = $pieces[$i]; while (($closeBracketPos = strpos($piece, "}")) !== false) { if ($closeBracketPos > 0) $result[] = substr($piece, 0, $closeBracketPos); $result[] = "}"; $selectorStackIndex--; if ($selectorStackIndex > 0 ) { $result[] = $this->fixNestingSelector($selectorStack, $selectorStackIndex); $result[] = "{"; } $piece = substr($piece, $closeBracketPos+1); } if (trim($piece) === '') continue; // Inner Rule $endOfLastProperty = strrpos($piece, ";"); if ($endOfLastProperty !== false) { $result[] = substr($piece, 0, $endOfLastProperty+1); $piece = substr($piece, $endOfLastProperty+1); } if ($selectorStackIndex > 0) $result[] = "}"; // Whole piece is the selector $selector = $piece; $selectorStack[$selectorStackIndex++] = $selector; $result[] = $this->fixNestingSelector($selectorStack, $selectorStackIndex); $result[] = '{'; } $this->content = join($result); } private function fixNestingSelector($selectors, $depth) { $newSelector = $selectors[0]; for($j = 1; $j<$depth; $j++) $newSelector = $this->fixNestingBuildSelector($newSelector, $selectors[$j]); return $newSelector; } private function fixNestingBuildSelector($outer, $inner) { $outerPieces = split(",", $outer); $innerPieces = split(",", $inner); $resultPieces = array(); foreach ($outerPieces as $o) { foreach ($innerPieces as $i) { $resultPieces[] = trim($o) . " " . trim($i); } } return join(",", $resultPieces); } /** * Minifies the CSS content by removing things that are unnecessary */ private function minify() { // remove single line comments, like this, from // to \\n $data = preg_replace('/(\/\/.*\n)/', '', $this->content); // remove new lines \\n, tabs and \\r $data = preg_replace('/(\t|\r|\n)/', '', $data); // remove multi-line comments $data = preg_replace('/(\/\*[^*]*\*\/)/', '', $data); // remove multi-line comments $data = preg_replace('/(\/\*[^\/]*\*\/)/', '', $data); // replace multi spaces with singles $data = preg_replace('/(\s+)/', ' ', $data); //Remove empty rules $data = preg_replace('/[^}{]+{\s?}/', '', $data); // Remove whitespace around selectors and braces $data = preg_replace('/\s*{\s*/', '{', $data); // Remove whitespace at end of rule $data = preg_replace('/\s*}\s*/', '}', $data); // Just for clarity, make every rules 1 line tall $data = preg_replace('/}/', "}\n", $data); $this->content = $data; } }