diff --git a/scss.inc.php b/scss.inc.php index d8b667bd..9feac84b 100644 --- a/scss.inc.php +++ b/scss.inc.php @@ -25,7 +25,6 @@ class scssc { "function" => "^", ); - static protected $numberPrecision = 3; static protected $unitTable = array( "in" => array( "in" => 1, @@ -71,9 +70,7 @@ function compile($code, $name=null) { $this->compileRoot($tree); $this->flattenSelectors($this->scope); - ob_start(); - $this->formatter->block($this->scope); - $out = ob_get_clean(); + $out = $this->formatter->block($this->scope); setlocale(LC_NUMERIC, $locale); return $out; @@ -329,7 +326,7 @@ protected function evalSelectorPart($piece) { protected function compileSelector($selector) { if (!is_array($selector)) return $selector; // media and the like - return implode(" ", array_map( + return $this->formatter->implodeSelectors(array_map( array($this, "compileSelectorPart"), $selector)); } @@ -457,7 +454,9 @@ protected function compileChild($child, $out) { $this->compileValue($child[2])); break; case "comment": - $out->lines[] = $child[1]; + if(!$this->formatter->stripComments){ + $out->lines[] = $child[1]; + } break; case "mixin": case "function": @@ -965,21 +964,11 @@ protected function compileValue($value) { $r = round($r); $g = round($g); $b = round($b); + $a = (count($value) == 5) ? max(0,min($value[4],1)) : 1; - if (count($value) == 5 && $value[4] != 1) { // rgba - return 'rgba('.$r.', '.$g.', '.$b.', '.$value[4].')'; - } - - $h = sprintf("#%02x%02x%02x", $r, $g, $b); - - // Converting hex color to short notation (e.g. #003399 to #039) - if ($h[1] === $h[2] && $h[3] === $h[4] && $h[5] === $h[6]) { - $h = '#' . $h[1] . $h[3] . $h[5]; - } - - return $h; + return $this->formatter->color($r, $g, $b, $a); case "number": - return round($value[1], self::$numberPrecision) . $value[2]; + return $this->formatter->number($value[1],$value[2]); case "string": return $value[1] . $this->compileStringContent($value) . $value[1]; case "function": @@ -993,7 +982,7 @@ protected function compileValue($value) { foreach ($items as &$item) { $item = $this->compileValue($item); } - return implode("$delim ", $items); + return $this->formatter->implodeList($delim, $items); case "interpolated": # node created by extractInterpolation list(, $interpolate, $left, $right) = $value; list(,, $whiteLeft, $whiteRight) = $interpolate; @@ -1417,10 +1406,12 @@ protected function coerceColor($value) { switch ($value[0]) { case "color": return $value; case "keyword": - $name = $value[1]; + $name = strtolower($value[1]); if (isset(self::$cssColors[$name])) { - list($r, $g, $b) = explode(',', self::$cssColors[$name]); - return array('color', $r, $g, $b); + $color = explode(',', self::$cssColors[$name]); + // array('color', $r, $g, $b); + array_unshift($color, 'color'); + return $color; } return null; } @@ -2043,7 +2034,8 @@ protected function lib_comparable($args) { return true; // TODO: THIS } - static protected $cssColors = array( + public static $cssColors = array( + 'transparent' => '0,0,0,0', 'aliceblue' => '240,248,255', 'antiquewhite' => '250,235,215', 'aqua' => '0,255,255', @@ -3515,6 +3507,13 @@ class scss_formatter { public $close = "}"; public $tagSeparator = ", "; public $assignSeparator = ": "; + + public $removeTrailingSemicolon = false; + public $replaceColorNames = false; + public $omitZeroUnit = false; + public $omitZeroLeading = false; + public $stripComments = false; + public $numberPrecision = 3; public function __construct() { $this->indentLevel = 0; @@ -3528,13 +3527,64 @@ public function property($name, $value) { return $name . $this->assignSeparator . $value . ";"; } + public function implodeList($delim, $items) { + return implode("$delim ", $items); + } + + public function implodeSelectors($selectors) { + return implode(" ", $selectors); + } + + public function color($r, $g, $b, $a) { + if (($a = $this->number($a, null)) != 1) { // rgba + if ($a == 0 && $this->replaceColorNames) { + return 'transparent'; + } + return 'rgba('.$r.$this->tagSeparator.$g.$this->tagSeparator.$b.$this->tagSeparator.$a.')'; + } + + $h = sprintf("#%02x%02x%02x", $r, $g, $b); + + // Converting hex color to short notation (e.g. #003399 to #039) + if ($h[1] === $h[2] && $h[3] === $h[4] && $h[5] === $h[6]) { + $h = '#' . $h[1] . $h[3] . $h[5]; + } + + if ($this->replaceColorNames) { + // Convert hex color to css color name if shorter (e.g. #f00 to red) + $name = array_search($r.','.$g.','.$b, scssc::$cssColors, true); + if ($name !== false && strlen($name) < strlen($h)) { + $h = $name; + } + } + + return $h; + } + + public function number($num, $unit) { + $num = round($num, $this->numberPrecision); + if ($this->omitZeroUnit && $num == 0) { + return 0; + } + if ($this->omitZeroLeading) { + if ($num > 0 && $num < 1) { + $num = substr($num, 1); + } else if ($num < 0 && $num > -1) { + $num = '-' . substr($num, 2); + } + } + return $num . $unit; + } + public function block($block) { - if (empty($block->lines) && empty($block->children)) return; + if (empty($block->lines) && empty($block->children)) return ''; $inner = $pre = $this->indentStr(); + + $ret = ''; if (!empty($block->selectors)) { - echo $pre . + $ret .= $pre . implode($this->tagSeparator, $block->selectors) . $this->open . $this->break; $this->indentLevel++; @@ -3543,21 +3593,25 @@ public function block($block) { if (!empty($block->lines)) { $glue = $this->break.$inner; - echo $inner . implode($glue, $block->lines); + $ret .= $inner . implode($glue, $block->lines); + if (!empty($block->children)) { - echo $this->break; + $ret .= $this->break; + } else if ($this->removeTrailingSemicolon && substr($ret, -1) === ';') { + $ret = substr($ret, 0, -1); } } foreach ($block->children as $child) { - $this->block($child); + $ret .= $this->block($child); } if (!empty($block->selectors)) { $this->indentLevel--; - if (empty($block->children)) echo $this->break; - echo $pre . $this->close . $this->break; + if (empty($block->children)) $ret .= $this->break; + $ret .= $pre . $this->close . $this->break; } + return $ret; } } @@ -3591,10 +3645,12 @@ public function block($block) { if ($block->type == "root") { $this->adjustAllChildren($block); } + + $ret = ''; $inner = $pre = $this->indentStr($block->depth - 1); if (!empty($block->selectors)) { - echo $pre . + $ret .= $pre . implode($this->tagSeparator, $block->selectors) . $this->open . $this->break; $this->indentLevel++; @@ -3603,20 +3659,25 @@ public function block($block) { if (!empty($block->lines)) { $glue = $this->break.$inner; - echo $inner . implode($glue, $block->lines); - if (!empty($block->children)) echo $this->break; + $ret .= $inner . implode($glue, $block->lines); + + if (!empty($block->children)) { + $ret .= $this->break; + } else if ($this->removeTrailingSemicolon && substr($ret, -1) === ';') { + $ret = substr($ret, 0, -1); + } } foreach ($block->children as $i => $child) { // echo "*** block: ".$block->depth." child: ".$child->depth."\n"; - $this->block($child); + $ret .= $this->block($child); if ($i < count($block->children) - 1) { - echo $this->break; + $ret .= $this->break; if (isset($block->children[$i + 1])) { $next = $block->children[$i + 1]; if ($next->depth == max($block->depth, 1) && $child->depth >= $next->depth) { - echo $this->break; + $ret .= $this->break; } } } @@ -3624,12 +3685,13 @@ public function block($block) { if (!empty($block->selectors)) { $this->indentLevel--; - echo $this->close; + $ret .= $this->close; } if ($block->type == "root") { - echo $this->break; + $ret .= $this->break; } + return $ret; } } @@ -3638,10 +3700,42 @@ class scss_formatter_compressed extends scss_formatter { public $tagSeparator = ","; public $assignSeparator = ":"; public $break = ""; + public $removeTrailingSemicolon = true; + public $replaceColorNames = true; + public $omitZeroUnit = true; + public $omitZeroLeading = true; + public $stripComments = true; public function indentStr($n = 0) { return ""; } + + public function implodeList($delim, $list) { + // no delimiter => actually a whitespace separated list (as in "margin") + if ($delim == "") { + $delim = " "; + } + return implode($delim, $list); + } + + public function implodeSelectors($selectors){ + $ret = ''; + $ws = false; + foreach($selectors as $selector){ + // do not use whitespace around descendant selectors + if (in_array($selector,array('+','~','>'),true)) { + $ws = false; + }else{ + if ($ws) { + $ret .= ' '; + } else { + $ws = true; + } + } + $ret .= $selector; + } + return $ret; + } } class scss_server { diff --git a/tests/ApiTest.php b/tests/ApiTest.php index b4406784..333d23e6 100644 --- a/tests/ApiTest.php +++ b/tests/ApiTest.php @@ -14,14 +14,14 @@ public function testUserFunction() { }); $this->assertEquals( - $this->compile("result: add-two(10, 20);"), - "result: 30;"); + "result: 30;", + $this->compile("result: add-two(10, 20);")); } public function testImportMissing(){ $this->assertEquals( - $this->compile('@import "missing";'), - '@import "missing";'); + '@import "missing";', + $this->compile('@import "missing";')); } public function testImportCustomCallback(){ @@ -30,8 +30,8 @@ public function testImportCustomCallback(){ }); $this->assertEquals( - $this->compile('@import "variables.css";'), - trim(file_get_contents(__DIR__.'/outputs/variables.css'))); + trim(file_get_contents(__DIR__.'/outputs/variables.css')), + $this->compile('@import "variables.css";')); } public function compile($str) { diff --git a/tests/CompressTest.php b/tests/CompressTest.php new file mode 100644 index 00000000..82f638fc --- /dev/null +++ b/tests/CompressTest.php @@ -0,0 +1,120 @@ +scss = new scssc(); + $this->scss->setFormatter(new scss_formatter_compressed()); + } + + public function testCompressTwice(){ + $code = file_get_contents(__DIR__.'/inputs/variables.scss'); + + $result = $this->scss->compile($code); + + $this->assertEquals($result,$this->scss->compile($result)); + } + + public function testCompressFormat(){ + // check removing trailing semicolons + $this->assertEquals('@import "missing";a{border:0;content:";";border:0}b{border:0}',$this->scss->compile('@import "missing";a{border:0;content:";";border:0;}b{border:0;}')); + + // check removing excessive whitespace + $this->assertEquals('body,a,a:hover{border:0}',$this->scss->compile('body, a, a:hover { border: 0 ; }')); + + // check removing empty blocks and comments + $this->assertEquals('a{display:hidden}',$this->scss->compile(' /* comment */ b{a{ }} strong{;;;} a{display:hidden;;;} b{/*inner comment*/}')); + + // do not mess around with attribute selectors + // http://www.w3.org/TR/CSS2/selector.html#attribute-selectors + $this->assertEquals('input[type="image"][disabled]{opacity:.5}',$this->scss->compile(' input[type="image"][disabled] { opacity: 0.5; } ')); + + // remove optional whitespace for child selectors, but keep whitespace for descendant selectors + // http://www.w3.org/TR/CSS2/selector.html#child-selectors + $this->assertEquals('a+b,c>d,e f{display:none}a.b>c d:not(.e){border:0}',$this->scss->compile(' a + b , c > d , e f { display: none; } a.b > c d:not(.e) { border: 0; }')); + + // remove whitespace around list delimiter, but keep whitespace between space separated values + $this->assertEquals('*{a:red,green,blue}a{margin:0 0 0 0}',$this->scss->compile('* { a: red, green , blue ; } a { margin: 0 0 0 0; }')); + } + + /** + * @dataProvider equalMediaProvider + */ + public function testMediaQueries($in,$out){ + $this->assertEquals('@media '.$out.'{a{border:0}}',$this->scss->compile('@media '.$in.' {a{border:0}}')); + } + + public function equalMediaProvider() { + // http://www.w3.org/TR/css3-mediaqueries/ + return $this->prepareSet(array( + 'only screen' => 'only screen', // unchanged + ' only screen ' => 'only screen', // remove optional whitespace + 'screen, print' => 'screen,print', // remove whitespace between OR'ed queries + '(min-width: 100px )' => '(min-width:100px)', // check media features + '(min-width: 0px)' => '(min-width:0)', // remove zero unit + 'only screen and (min-width: 0px) and (max-width: 1000px)' => 'only screen and (min-width:0) and (max-width:1000px)', + 'handheld, only screen and (max-width: 1000px)' => 'handheld,only screen and (max-width:1000px)', + 'screen and (device-aspect-ratio: 16/9) , print and (min-resolution: 300dpi)' => 'screen and (device-aspect-ratio:16/9),print and (min-resolution:300dpi)' + )); + } + + /** + * @dataProvider equalColorsProvider + */ + public function testCompressColor($in,$out) { + $this->assertEquals('color:'.$out,$this->scss->compile('color:'.$in)); + } + + public function equalColorsProvider() { + return $this->prepareSet(array( + 'red' => 'red', // unchanged color keyword as shortest + '#000' => '#000', // unchanged short hex + '#00ff00' => '#0f0', // short hex + '#7abAaA' => '#7abaaa', // always use lowercase hex + 'rgb(255,255,255)' => '#fff', // short hex + 'rgb(123,121,121)' => '#7b7979', // hex instead of RGB notation + 'rgb( 0 , 0 , 255 )' => '#00f', // short hex for excessive whitespace + 'rgba(123,121,121,1)' => '#7b7979', // full alpha => use hex + 'rgba(123,121,121,2)' => '#7b7979', // cap excessive alpha to full alpha + 'rgba(123,121,121,0.9999)' => '#7b7979', // round alpha component + 'rgba(123,121,121,0.5)' => 'rgba(123,121,121,.5)', // remove useless leading zero + 'rgba(123,121,121,0)' => 'transparent', // zero alpha => shorthand transparent color keyword + 'opacify(transparent, 1)' => '#000', // fully opaque 'transparent' is actually black + 'opacify(transparent, 0.3)' => 'rgba(0,0,0,.3)', // work with transparent color keyword + 'opacify(WhiTe,1)' => '#fff' // color keywords are actually case insensitive + )); + } + + /** + * @dataProvider equalNumbersProvider + */ + public function testCompressNumber($in,$out){ + $this->assertEquals('padding:'.$out,$this->scss->compile('padding:'.$in)); + } + + public function equalNumbersProvider(){ + return $this->prepareSet(array( + '14px' => '14px', // unchanged + '0px' => '0', // remove zero unit + '0em' => '0', + '0%' => '0', + '1.5em' => '1.5em', + '0.5em' => '.5em', // remove leading zero + '0.50pt' => '.5pt', // remove leading and trailing zero and keep unit + '-4.0' => '-4', // remove useless fraction + '4.000px' => '4px', + '4.9999' => '5', // round to next number + '4.99' => '4.99', + '-0.2pt' => '-.2pt', // remove leading zero for negative numbers + '-0pt' => '0', // negative zero is still zero + '+4%' => '4%', // remove useless postive number marker + '+0em' => '0' + )); + } + + private function prepareSet($set){ + return array_map(function($in, $out) { return array($in, $out); }, array_keys($set), $set); + } + +}