diff --git a/README.md b/README.md index c7637bc..133bd11 100644 --- a/README.md +++ b/README.md @@ -1,58 +1,46 @@ -MinifyX-for-Evolution +CcsJs-for-Evolution ===================== -Component MinifyX for MODX Evolution +Component CssJs for MODX Evolution -Базовая настройка +Описание ---------- -- Залить папку assets в корень вашего сайта -- Создать сниппет MinifyX с кодом из файла **snippet.txt** +Сниппеты основанны РЅР° компоненте MinifyX РїРѕРґ MODX EVO, решат РІРѕРїСЂРѕСЃ работы СЃ файлами ститей Рё скриптов. +- Обновление версии файла (основанной РЅР° дате последнего обновления) +- Минификация файлов +- Соединение всех файлов РІ 1. -Расширенная настройка + +Установка ---------- -- Создать плагин MinifyX с кодом из файла **plugin.txt** и привязать к событию OnDocFormSave -- Создать модуль MinifyX с кодом из файал **module.txt** -- Создать новый документ MinifyX для кастомных CSS стилей с шаблоном blank и типом содержимого text/css -- Создать новый документ MinifyX для кастомных JS скриптов с шаблоном blank и типом содержимого text/javascript -- В настройках модуля на вкладке конфигурация добавить параметр &CSSfile=CSS файлы;textarea; &JSfile=JS файлы;textarea; &CSSdoc=ID CSS документа;int; &JSdoc=ID JS документа;int; -- В параметры ID JS документа и ID CSS документа вписать ID ранее созданных документов -- В параметры JS файлы и CSS файлы вписать полный путь от корня сайта к файлам которые необходимо сжимать. Если несколько файлов одного типа, то их следует разделять запятой -- Поставить галочку "общие параметры" и сохранить модуль -- Опять открыть модуль на редактирование и зайти на вкладку зависимости. Затем запустить управление зависимостями и добавить плагин MinifyX в зависимость. - -Возможности при базовой настройке +- Установить через Extras или PackageManager +- Ручная установка: залить РЅР° сервер папку Assets, создать 2 сниппета js Рё css СЃ РєРѕРґРѕРј РёР· файлов(istall/assets/snippets) + +Пример вызова ---------- -Запуск сниппета с параметрами **не рекомендуется** - [!MinifyX? &CSSfile=`assets/templates/tpl/css/bootstrap.css,assets/js/prettify/prettify.css` &CSSdoc=`2` &JSfile=`assets/js/jquery-1.8.3.min.js,assets/templates/tpl/js/modernizr.custom.28468.js,assets/js/jquery.validate.js,assets/js/jquery.form.min.js,assets/js/prettify/prettify.js` &JSdoc=`3` &parse=`1`!] -Параметры сниппета + [!css? &files=`assets/templates/tpl/css/bootstrap.css, + assets/js/prettify/prettify.css` + &minify=`1`!] + + [!js? &files=`assets/js/jquery-1.8.3.min.js, + assets/templates/tpl/js/modernizr.custom.28468.js, + assets/js/jquery.validate.js, + assets/js/jquery.form.min.js, + assets/js/prettify/prettify.js` + &minify=`1`!] + + + +Параметры сниппета ------- -- **CSSfile** Список файлов с CSS стилями, которые нужно включить в конечный файл и сжать -- **CSSdoc** ID документа в дереве документов с CSS стилями -- **JSfile** Список файлов с JavaScript кодом, которые нужно включить в конечный файл и сжать -- **JSdoc** ID документа в дереве документов с JavaScript кодом -- **parse** Обрабатывать ли MODX теги в документах созданных в дереве документов -- **cssFile** имя CSS файла на выходе. По умолчанию style.css -- **jsFile** имя JS файла на выходе. По умолчанию script.js -- **outFolder** в какую папку сохранять сжатый файл. По умолчанию assets/cache/ -- **API** режим запуска сниппета. По умолчанию происходит API отключен и в случае успешного выполнения кода сжатые скрипты добавляются в секцию head вашего веб-сайта. Если же режим API включен (для включение необходимо указать любое значение этому параметру), то результат своей работы сниппет отдает в виде массива array('js'=>'путь к сжатому JS скрипту','css'=>'путь к не сжатому CSS скрипту'); В случае если сжать файл не удалось или какой-то тип файлов не требовалось сжимать, то этот элемент массива будет пуст. - -Возможности при расширенной настройке --------- -- Автоматическое пересоздание CSS или JS файла при изменении документа с CSS стилями и JS кодом соответственно -**ВАЖНО!!!** Если в этих документах используются MODX теги и значение этих тегов зависит от других документов, то необходима донастройка плагина и изменение контролирующего события, чтобы файлы пересоздавались при каждой отчистке кеша MODX. Но делать этого я не рекомендую. Лучше просто исключите этот документ из настроек MinifyX и подключайте его в шаблоне как src="[~id~]" -- Возможность ручного пересоздания сжатых файлов при помощи запуска модуля - -К сведению ---------- -- В разделе отчеты->просмотр событий плагин создает новые уведомления о результате своей работы. Туда же помещается отладочная информация с сериализованым стеком вызова при возникновени ошибок -- При расширенной конфигурации в шаблонах необходимо самостоятельно прописать подключение сжатых JS и CSS файлов. По умолчанию это /assets/cache/script.js и /assets/cache/style.css - +- **files** РЎРїРёСЃРѕРє файлов СЃ CSS стилями, которые РЅСѓР¶РЅРѕ включить РІ конечный файл Рё сжать +- **minify** - сжимать Рё обьеденять файлик +- **inhtml** - разместить сразу РІ HTML, РІ тегах +- **folder** РІ какую папку сохранять сжатый файл. РџРѕ умолчанию корень сайта + TODO --------- -- В имя файла добавлять хеш для принудительной загрузки измененого файла -- Если файл существует и хеш имени совпадает с текущим хешем документов, то сразу делать return данных, а не сжимать повторно -- Вынесение всех опций в параметры сниппета и на вкладку конфигурация в модуль -- Больше валидаций в MinifyX.core.php -- Поддержка сразу нескольких документов одного типа из дерева документов -- Инсталлятор, т.к. очень много кнопочек нужно кликнуть для полной настройки \ No newline at end of file +------- +- Добавить обработку LESS +- Добавить обработку SASS +- Добавить обработку inline css Рё js diff --git a/assets/snippets/MinifyX/MinifyX.core.php b/assets/snippets/MinifyX/MinifyX.core.php deleted file mode 100644 index 5a58024..0000000 --- a/assets/snippets/MinifyX/MinifyX.core.php +++ /dev/null @@ -1,192 +0,0 @@ -modx=$modx; - }else{ - throw new Exception('MODX value is not DocumentParser class'); - } - }catch(Exception $e){ - $this->debug($e->getMessage(),$e->getFile(),$e->getLine(),$e->getTrace(),3); - } - $this->dir=dirname(__FILE__)."/"; - } - public function minCSS($css='',$doc=0){ - $flag=false; - try{ - if(file_exists($this->dir."cssmin.class.php")){ - include_once($this->dir."cssmin.class.php"); - }else{ - throw new Exception("No file ".$this->dir."cssmin.class.php"); - } - }catch(Exception $e){ - $this->debug($e->getMessage(),$e->getFile(),$e->getLine(),$e->getTrace(),3); - } - try{ - if(($css=$this->collectFile($css,'css'))!==false || (int)$doc>0){ - if(($data=$this->collectContents($css,$doc,'css'))!=''){ - if($this->writeFile($this->cssFile,$data)){ - $flag=true; - }else{ - $die=3; - throw new Exception('Error write data CSS'); - } - }else{ - $die=1; - throw new Exception('No data CSS'); - } - - }else{ - $die=1; - throw new Exception('No file CSS to compress'); - } - - }catch(Exception $e){ - $this->debug($e->getMessage(),$e->getFile(),$e->getLine(),$e->getTrace(),$die); - } - return $flag; - } - - public function minJS($js='',$doc=0){ - $flag=false; - try{ - if(file_exists($this->dir."jsmin.class.php")){ - include_once($this->dir."jsmin.class.php"); - }else{ - throw new Exception("No file ".$this->dir."jsmin.class.php"); - } - }catch(Exception $e){ - $this->debug($e->getMessage(),$e->getFile(),$e->getLine(),$e->getTrace(),3); - } - try{ - if(($js=$this->collectFile($js,'js'))!==false || (int)$doc>0){ - if(($data=$this->collectContents($js,$doc,'js'))!=''){ - if($this->writeFile($this->jsFile,$data)){ - $flag=true; - }else{ - $die=3; - throw new Exception('Error write data JS'); - } - }else{ - $die=1; - throw new Exception('No data JS'); - } - - }else{ - $die=1; - throw new Exception('No file JS to compress'); - } - - }catch(Exception $e){ - $this->debug($e->getMessage(),$e->getFile(),$e->getLine(),$e->getTrace(),$die); - } - return $flag; - } - private function getPath($file){ - $file=dirname($file)."/"; - $file=str_replace($this->modx->config['base_path'],'/',$file)."/"; - $file=str_replace('//','/',$file); - return $file; - } - - private function collectContents($data,$id,$type){ - $out=''; - - foreach($data as $f){ - $f = trim($f); - $fp = $this->modx->config['base_path'].$f; - if (is_readable($fp)){ - $out .= "\r\n\r\n/* " . $f . " */ \r\n"; - $content = file_get_contents($fp); - switch($type){ - case 'css':{ - //@see: http://stackoverflow.com/questions/9798378/preg-replace-regex-to-match-relative-url-paths-in-css-files - $content=preg_replace('#url\((?!\s*[\'"]?(?:https?:)?/)\s*([\'"])?#i', "url($1{$this->getPath($fp)}", $content); - $out .= Minify_CSS_Compressor::process($content); - break; - }case 'js':{ - $out .= JSMin::minify($content); - break; - } - } - } - } - if((int)$id>0){ - $sql=$this->modx->db->query("SELECT content FROM ".$this->modx->getFullTableName("site_content")." WHERE id=".(int)$id); - $out .= "\r\n\r\n/* DB */ \r\n"; - $content = $this->modx->db->getValue($sql); - - $minParserPasses= empty ($this->modx->minParserPasses) ? 2 : $this->modx->minParserPasses; - $maxParserPasses= empty ($this->modx->maxParserPasses) ? 10 : $this->modx->maxParserPasses; - $passes = $minParserPasses; - - if($this->modx->parse){ - for ($i= 0; $i < $passes; $i++) { - if ($i == ($passes -1)) $st = strlen($content); - - $content=$this->modx->mergeDocumentContent($content); - $content=$this->modx->mergeSettingsContent($content); - $content=$this->modx->mergeChunkContent($content); - $content=$this->modx->evalSnippets($content); - $content=$this->modx->mergePlaceholderContent($content); - - if ($i == ($passes -1) && $i < ($maxParserPasses - 1)) { - $et = strlen($content); - if ($st != $et) $passes++; - } - } - } - $out .= $content; - } - return $out; - } - private function sanitar($file,$type){ - $file=preg_replace(array('/\.*[\/|\\\]/i', '/[\/|\\\]+/i'), array('/', '/'),$file); - $tmp=explode(".",$file); - return (end($tmp)==$type) ? $file : ''; - } - private function collectFile($str,$type){ - $out=array(); - $str=explode(",",$str); - foreach($str as $f){ - if(($file=$this->sanitar($f,$type))!=''){ - $out[]=$file; - } - } - return (count($out)>0) ? $out : false; - } - private function writeFile($file,$data){ - $flag=false; - $handle = fopen($this->modx->config['base_path'].$this->outFolder.'/'.$file,"w+"); - if ($handle && flock($handle, LOCK_EX)) { - fputs($handle, $data); - flock($handle, LOCK_UN); - $flag=true; - fclose($handle); - } - return $flag; - } - private function debug($message,$file,$line,$trace,$die){ - $msg=''; - $msg="

".$message."

"; - $msg.="

".$file.":".$line."

"; - $msg.="
Trace serialize
"; - $msg.=""; - $this->modx->logEvent(46,(int)$die,$msg,"MinifyX"); - if((int)$die==3){ - die("ERROR IN MinifyX. See log"); - } - } -} diff --git a/assets/snippets/MinifyX/cssmin.class.php b/assets/snippets/MinifyX/cssmin.class.php deleted file mode 100644 index 054b9fc..0000000 --- a/assets/snippets/MinifyX/cssmin.class.php +++ /dev/null @@ -1,249 +0,0 @@ - - * @author http://code.google.com/u/1stvamp/ (Issue 64 patch) - */ -class Minify_CSS_Compressor { - - /** - * Minify a CSS string - * - * @param string $css - * - * @param array $options (currently ignored) - * - * @return string - */ - public static function process($css, $options = array()) - { - $obj = new Minify_CSS_Compressor($options); - return $obj->_process($css); - } - - /** - * @var array - */ - protected $_options = null; - - /** - * Are we "in" a hack? I.e. are some browsers targetted until the next comment? - * - * @var bool - */ - protected $_inHack = false; - - - /** - * Constructor - * - * @param array $options (currently ignored) - */ - private function __construct($options) { - $this->_options = $options; - } - - /** - * Minify a CSS string - * - * @param string $css - * - * @return string - */ - protected function _process($css) - { - $css = str_replace("\r\n", "\n", $css); - - // preserve empty comment after '>' - // http://www.webdevout.net/css-hacks#in_css-selectors - $css = preg_replace('@>/\\*\\s*\\*/@', '>/*keep*/', $css); - - // preserve empty comment between property and value - // http://css-discuss.incutio.com/?page=BoxModelHack - $css = preg_replace('@/\\*\\s*\\*/\\s*:@', '/*keep*/:', $css); - $css = preg_replace('@:\\s*/\\*\\s*\\*/@', ':/*keep*/', $css); - - // apply callback to all valid comments (and strip out surrounding ws - $css = preg_replace_callback('@\\s*/\\*([\\s\\S]*?)\\*/\\s*@' - ,array($this, '_commentCB'), $css); - - // remove ws around { } and last semicolon in declaration block - $css = preg_replace('/\\s*{\\s*/', '{', $css); - $css = preg_replace('/;?\\s*}\\s*/', '}', $css); - - // remove ws surrounding semicolons - $css = preg_replace('/\\s*;\\s*/', ';', $css); - - // remove ws around urls - $css = preg_replace('/ - url\\( # url( - \\s* - ([^\\)]+?) # 1 = the URL (really just a bunch of non right parenthesis) - \\s* - \\) # ) - /x', 'url($1)', $css); - - // remove ws between rules and colons - $css = preg_replace('/ - \\s* - ([{;]) # 1 = beginning of block or rule separator - \\s* - ([\\*_]?[\\w\\-]+) # 2 = property (and maybe IE filter) - \\s* - : - \\s* - (\\b|[#\'"-]) # 3 = first character of a value - /x', '$1$2:$3', $css); - - // remove ws in selectors - $css = preg_replace_callback('/ - (?: # non-capture - \\s* - [^~>+,\\s]+ # selector part - \\s* - [,>+~] # combinators - )+ - \\s* - [^~>+,\\s]+ # selector part - { # open declaration block - /x' - ,array($this, '_selectorsCB'), $css); - - // minimize hex colors - $css = preg_replace('/([^=])#([a-f\\d])\\2([a-f\\d])\\3([a-f\\d])\\4([\\s;\\}])/i' - , '$1#$2$3$4$5', $css); - - // remove spaces between font families - $css = preg_replace_callback('/font-family:([^;}]+)([;}])/' - ,array($this, '_fontFamilyCB'), $css); - - $css = preg_replace('/@import\\s+url/', '@import url', $css); - - // replace any ws involving newlines with a single newline - $css = preg_replace('/[ \\t]*\\n+\\s*/', "\n", $css); - - // separate common descendent selectors w/ newlines (to limit line lengths) - $css = preg_replace('/([\\w#\\.\\*]+)\\s+([\\w#\\.\\*]+){/', "$1\n$2{", $css); - - // Use newline after 1st numeric value (to limit line lengths). - $css = preg_replace('/ - ((?:padding|margin|border|outline):\\d+(?:px|em)?) # 1 = prop : 1st numeric value - \\s+ - /x' - ,"$1\n", $css); - - // prevent triggering IE6 bug: http://www.crankygeek.com/ie6pebug/ - $css = preg_replace('/:first-l(etter|ine)\\{/', ':first-l$1 {', $css); - - return trim($css); - } - - /** - * Replace what looks like a set of selectors - * - * @param array $m regex matches - * - * @return string - */ - protected function _selectorsCB($m) - { - // remove ws around the combinators - return preg_replace('/\\s*([,>+~])\\s*/', '$1', $m[0]); - } - - /** - * Process a comment and return a replacement - * - * @param array $m regex matches - * - * @return string - */ - protected function _commentCB($m) - { - $hasSurroundingWs = (trim($m[0]) !== $m[1]); - $m = $m[1]; - // $m is the comment content w/o the surrounding tokens, - // but the return value will replace the entire comment. - if ($m === 'keep') { - return '/**/'; - } - if ($m === '" "') { - // component of http://tantek.com/CSS/Examples/midpass.html - return '/*" "*/'; - } - if (preg_match('@";\\}\\s*\\}/\\*\\s+@', $m)) { - // component of http://tantek.com/CSS/Examples/midpass.html - return '/*";}}/* */'; - } - if ($this->_inHack) { - // inversion: feeding only to one browser - if (preg_match('@ - ^/ # comment started like /*/ - \\s* - (\\S[\\s\\S]+?) # has at least some non-ws content - \\s* - /\\* # ends like /*/ or /**/ - @x', $m, $n)) { - // end hack mode after this comment, but preserve the hack and comment content - $this->_inHack = false; - return "/*/{$n[1]}/**/"; - } - } - if (substr($m, -1) === '\\') { // comment ends like \*/ - // begin hack mode and preserve hack - $this->_inHack = true; - return '/*\\*/'; - } - if ($m !== '' && $m[0] === '/') { // comment looks like /*/ foo */ - // begin hack mode and preserve hack - $this->_inHack = true; - return '/*/*/'; - } - if ($this->_inHack) { - // a regular comment ends hack mode but should be preserved - $this->_inHack = false; - return '/**/'; - } - // Issue 107: if there's any surrounding whitespace, it may be important, so - // replace the comment with a single space - return $hasSurroundingWs // remove all other comments - ? ' ' - : ''; - } - - /** - * Process a font-family listing and return a replacement - * - * @param array $m regex matches - * - * @return string - */ - protected function _fontFamilyCB($m) - { - // Issue 210: must not eliminate WS between words in unquoted families - $pieces = preg_split('/(\'[^\']+\'|"[^"]+")/', $m[1], null, PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY); - $out = 'font-family:'; - while (null !== ($piece = array_shift($pieces))) { - if ($piece[0] !== '"' && $piece[0] !== "'") { - $piece = preg_replace('/\\s+/', ' ', $piece); - $piece = preg_replace('/\\s?,\\s?/', ',', $piece); - } - $out .= $piece; - } - return $out . $m[2]; - } -} \ No newline at end of file diff --git a/assets/snippets/MinifyX/jsmin.class.php b/assets/snippets/MinifyX/jsmin.class.php deleted file mode 100644 index e79f8db..0000000 --- a/assets/snippets/MinifyX/jsmin.class.php +++ /dev/null @@ -1,374 +0,0 @@ - - * @copyright 2002 Douglas Crockford (jsmin.c) - * @copyright 2008 Ryan Grove (PHP port) - * @license http://opensource.org/licenses/mit-license.php MIT License - * @version 1.1.1 (2008-03-02) - * @link https://github.com/rgrove/jsmin-php/ - */ - -class JSMin { - const ORD_LF = 10; - const ORD_SPACE = 32; - const ACTION_KEEP_A = 1; - const ACTION_DELETE_A = 2; - const ACTION_DELETE_A_B = 3; - - protected $a = ''; - protected $b = ''; - protected $input = ''; - protected $inputIndex = 0; - protected $inputLength = 0; - protected $lookAhead = null; - protected $output = ''; - - // -- Public Static Methods -------------------------------------------------- - - /** - * Minify Javascript - * - * @uses __construct() - * @uses min() - * @param string $js Javascript to be minified - * @return string - */ - public static function minify($js) { - $jsmin = new JSMin($js); - return $jsmin->min(); - } - - // -- Public Instance Methods ------------------------------------------------ - - /** - * Constructor - * - * @param string $input Javascript to be minified - */ - public function __construct($input) { - $this->input = str_replace("\r\n", "\n", $input); - $this->inputLength = strlen($this->input); - } - - // -- Protected Instance Methods --------------------------------------------- - - /** - * Action -- do something! What to do is determined by the $command argument. - * - * action treats a string as a single character. Wow! - * action recognizes a regular expression if it is preceded by ( or , or =. - * - * @uses next() - * @uses get() - * @throws JSMinException If parser errors are found: - * - Unterminated string literal - * - Unterminated regular expression set in regex literal - * - Unterminated regular expression literal - * @param int $command One of class constants: - * ACTION_KEEP_A Output A. Copy B to A. Get the next B. - * ACTION_DELETE_A Copy B to A. Get the next B. (Delete A). - * ACTION_DELETE_A_B Get the next B. (Delete B). - */ - protected function action($command) { - switch($command) { - case self::ACTION_KEEP_A: - $this->output .= $this->a; - - case self::ACTION_DELETE_A: - $this->a = $this->b; - - if ($this->a === "'" || $this->a === '"') { - for (;;) { - $this->output .= $this->a; - $this->a = $this->get(); - - if ($this->a === $this->b) { - break; - } - - if (ord($this->a) <= self::ORD_LF) { - throw new JSMinException('Unterminated string literal.'); - } - - if ($this->a === '\\') { - $this->output .= $this->a; - $this->a = $this->get(); - } - } - } - - case self::ACTION_DELETE_A_B: - $this->b = $this->next(); - - if ($this->b === '/' && ( - $this->a === '(' || $this->a === ',' || $this->a === '=' || - $this->a === ':' || $this->a === '[' || $this->a === '!' || - $this->a === '&' || $this->a === '|' || $this->a === '?' || - $this->a === '{' || $this->a === '}' || $this->a === ';' || - $this->a === "\n" )) { - - $this->output .= $this->a . $this->b; - - for (;;) { - $this->a = $this->get(); - - if ($this->a === '[') { - /* - inside a regex [...] set, which MAY contain a '/' itself. Example: mootools Form.Validator near line 460: - return Form.Validator.getValidator('IsEmpty').test(element) || (/^(?:[a-z0-9!#$%&'*+/=?^_`{|}~-]\.?){0,63}[a-z0-9!#$%&'*+/=?^_`{|}~-]@(?:(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)*[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?|\[(?:(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\.){3}(?:25[0-5]|2[0-4][0-9]|[01]?[0-9][0-9]?)\])$/i).test(element.get('value')); - */ - for (;;) { - $this->output .= $this->a; - $this->a = $this->get(); - - if ($this->a === ']') { - break; - } elseif ($this->a === '\\') { - $this->output .= $this->a; - $this->a = $this->get(); - } elseif (ord($this->a) <= self::ORD_LF) { - throw new JSMinException('Unterminated regular expression set in regex literal.'); - } - } - } elseif ($this->a === '/') { - break; - } elseif ($this->a === '\\') { - $this->output .= $this->a; - $this->a = $this->get(); - } elseif (ord($this->a) <= self::ORD_LF) { - throw new JSMinException('Unterminated regular expression literal.'); - } - - $this->output .= $this->a; - } - - $this->b = $this->next(); - } - } - } - - /** - * Get next char. Convert ctrl char to space. - * - * @return string|null - */ - protected function get() { - $c = $this->lookAhead; - $this->lookAhead = null; - - if ($c === null) { - if ($this->inputIndex < $this->inputLength) { - $c = substr($this->input, $this->inputIndex, 1); - $this->inputIndex += 1; - } else { - $c = null; - } - } - - if ($c === "\r") { - return "\n"; - } - - if ($c === null || $c === "\n" || ord($c) >= self::ORD_SPACE) { - return $c; - } - - return ' '; - } - - /** - * Is $c a letter, digit, underscore, dollar sign, or non-ASCII character. - * - * @return bool - */ - protected function isAlphaNum($c) { - return ord($c) > 126 || $c === '\\' || preg_match('/^[\w\$]$/', $c) === 1; - } - - /** - * Perform minification, return result - * - * @uses action() - * @uses isAlphaNum() - * @return string - */ - protected function min() { - $this->a = "\n"; - $this->action(self::ACTION_DELETE_A_B); - - while ($this->a !== null) { - switch ($this->a) { - case ' ': - if ($this->isAlphaNum($this->b)) { - $this->action(self::ACTION_KEEP_A); - } else { - $this->action(self::ACTION_DELETE_A); - } - break; - - case "\n": - switch ($this->b) { - case '{': - case '[': - case '(': - case '+': - case '-': - $this->action(self::ACTION_KEEP_A); - break; - - case ' ': - $this->action(self::ACTION_DELETE_A_B); - break; - - default: - if ($this->isAlphaNum($this->b)) { - $this->action(self::ACTION_KEEP_A); - } - else { - $this->action(self::ACTION_DELETE_A); - } - } - break; - - default: - switch ($this->b) { - case ' ': - if ($this->isAlphaNum($this->a)) { - $this->action(self::ACTION_KEEP_A); - break; - } - - $this->action(self::ACTION_DELETE_A_B); - break; - - case "\n": - switch ($this->a) { - case '}': - case ']': - case ')': - case '+': - case '-': - case '"': - case "'": - $this->action(self::ACTION_KEEP_A); - break; - - default: - if ($this->isAlphaNum($this->a)) { - $this->action(self::ACTION_KEEP_A); - } - else { - $this->action(self::ACTION_DELETE_A_B); - } - } - break; - - default: - $this->action(self::ACTION_KEEP_A); - break; - } - } - } - - return $this->output; - } - - /** - * Get the next character, skipping over comments. peek() is used to see - * if a '/' is followed by a '/' or '*'. - * - * @uses get() - * @uses peek() - * @throws JSMinException On unterminated comment. - * @return string - */ - protected function next() { - $c = $this->get(); - - if ($c === '/') { - switch($this->peek()) { - case '/': - for (;;) { - $c = $this->get(); - - if (ord($c) <= self::ORD_LF) { - return $c; - } - } - - case '*': - $this->get(); - - for (;;) { - switch($this->get()) { - case '*': - if ($this->peek() === '/') { - $this->get(); - return ' '; - } - break; - - case null: - throw new JSMinException('Unterminated comment.'); - } - } - - default: - return $c; - } - } - - return $c; - } - - /** - * Get next char. If is ctrl character, translate to a space or newline. - * - * @uses get() - * @return string|null - */ - protected function peek() { - $this->lookAhead = $this->get(); - return $this->lookAhead; - } -} - -// -- Exceptions --------------------------------------------------------------- -class JSMinException extends Exception {} diff --git a/assets/snippets/MinifyX/.htaccess b/assets/snippets/cssjs/.htaccess similarity index 100% rename from assets/snippets/MinifyX/.htaccess rename to assets/snippets/cssjs/.htaccess diff --git a/assets/snippets/cssjs/class.magic-min.php b/assets/snippets/cssjs/class.magic-min.php new file mode 100755 index 0000000..b9965ed --- /dev/null +++ b/assets/snippets/cssjs/class.magic-min.php @@ -0,0 +1,820 @@ +minify( '[source filename]', '[output filename (optional)]', '[version (optional)]' ); ?>"> +** +** +** Usage example for merge and minify: +** $min->merge( 'output filename and location', 'directory', 'type (js or css)', array( of items to exclude ) ); +** $min->merge( 'js/one-file-merged.js', 'js', 'js', array( 'js/inline-edit.js', 'js/autogrow.js' ) ); +** +** Normalized output example using merge and minify: +** +** +** Adding gzip, base64 image encoding, or returning rather than echo: +** $vars = array( +** 'echo' => false, +** 'encode' => true, +** 'timer' => true, +** 'gzip' => true +** ); +** $minified = new Minifier( $vars ); +** +** Using JShrink for js minification as opposed to google closure (default set to google closure) +** $vars = array( +** 'closure' => false, +** 'gzip' => true, +** 'encode' => true +** ); +** $minified = new Minifier( $vars ); +** +**------------------------------------------------------------------------------ */ + +class Minifier { + + public $content; + public $output_file; + public $extension; + private $type; + private $directory; + private $create_new; + private $compilation; + private $file; + private $compressed; + private $prequel; + private $new_file; + private $compiled; + private $source; + private $output; + private $handle; + //Max image size for inclusion + const IMAGE_MAX_SIZE = 5; + //For script execution time (src: http://bit.ly/18O3VWw) + private $mtime; + //Sum of output messages + private $messages = array(); + //array of settings to add-to/adjust + private $settings = array(); + //List of available config keys that can be set via init + private $config_keys = array( + 'echo' => false, //Return or echo the values + 'encode' => false, //base64 images from CSS and include as part of the file? + 'timer' => true, //Ouput script execution time + 'gzip' => false, //Output as php with gzip? + 'closure' => true, //Use google closure (utilizes cURL) + 'remove_comments' => true // remove comments + ); + + + /** + * Construct function + * @access public + * @param array $vars + * @return mixed + */ + public function __construct( $vars = array() ) + { + global $messages; + $this->mtime = microtime( true ); + foreach( $this->config_keys as $key => $default ) + { + if( isset( $vars[$key] ) ) + { + $this->messages[]['Minifier Log'] = $key .': '. $vars[$key]; + $this->settings[$key] = $vars[$key]; + } + else + { + $this->messages[]['Minifier Log'] = $key .': '. $default; + $this->settings[$key] = $default; + } + } + + } //end __construct() + + + /** + * Private function to strip directory names from TOC output + * Used for make_min() + * + * @access private + * @param array $input + * @return array $output + */ + private function strip_directory( $input ) + { + return basename( $input ); + } + + + /** + * Private function to determine if files are local or remote + * Used for merge_images() and minify() to determine if filemtime can be used + * + * @access private + * @param string $file + * @return bool + */ + private function remote_file( $file ) + { + //It is a remote file + if( preg_match( "/(http|https)/", $file ) ) + { + return true; + } + //Local file + else + { + return false; + } + } + + + /** + * Function to seek out and replace image references within CSS with base64_encoded data streams + * Used in minify_contents function IF global for $this->encode + * This function will retrieve the contents of local OR remote images, and is based on + * Matthias Mullie 's function, "importFiles" from the JavaScript and CSS minifier + * http://www.phpclasses.org/package/7519-PHP-Optimize-JavaScript-and-CSS-files.html + * + * @access private + * @param string $source_file (used for location) + * @param string $contents + * @return string $updated_style + */ + private function merge_images( $source_file, $contents ) + { + global $messages; + + $this->directory = dirname( $source_file ) .'/'; + + if( preg_match_all( '/url\((["\']?)((?!["\']?data:).*?\.(gif|png|jpg|jpeg))\\1\)/i', $contents, $this->matches, PREG_SET_ORDER ) ) + { + $this->find = array(); + $this->replace = array(); + + foreach( $this->matches as $this->graphic ) + { + + $this->extension = pathinfo( $this->graphic[2], PATHINFO_EXTENSION ); + + $this->image_file = ''; + + //See if the file is remote or local + if( $this->remote_file( $this->graphic[2] ) ) + { + + //It's remote, and CURL is pretty fast + $ch = curl_init(); + curl_setopt( $ch, CURLOPT_URL, $this->graphic[2] ); + curl_setopt( $ch, CURLOPT_NOBODY, 1 ); + curl_setopt( $ch, CURLOPT_FAILONERROR, 1 ); + curl_setopt( $ch, CURLOPT_RETURNTRANSFER, 1 ); + + //And it WAS remote, and it DOES exist + if( curl_exec( $ch ) !== FALSE ) + { + + //Get the image file + $cd = curl_init( $this->graphic[2] ); + curl_setopt( $cd, CURLOPT_HEADER, 0 ); + curl_setopt( $cd, CURLOPT_RETURNTRANSFER, 1 ); + curl_setopt( $cd, CURLOPT_BINARYTRANSFER, 1 ); + $this->image_file = curl_exec( $cd ); + //Get the remote filesize + $this->filesize = curl_getinfo( $cd, CURLINFO_CONTENT_LENGTH_DOWNLOAD ); + curl_close( $cd ); + + if( $this->filesize <= Minifier::IMAGE_MAX_SIZE * 1024 ) + { + //Assign the find and replace + $this->find[] = $this->graphic[0]; + $this->replace[] = 'url(data:'.$this->extension.';base64,'.base64_encode( $this->image_file ).')'; + } + + } //End file exists + curl_close( $ch ); + + } //End remote file + + elseif( file_exists( $this->directory . $this->graphic[2] ) ) + { + //File DOES exist locally, get the contents + + //Check the filesize + $this->filesize = filesize( $this->directory . $this->graphic[2] ); + + if( $this->filesize <= Minifier::IMAGE_MAX_SIZE * 1024 ) + { + //File is within the filesize requirements so add it + $this->image_file = file_get_contents( $this->directory . $this->graphic[2] ); + + //Assign the find and replace + $this->find[] = $this->graphic[0]; + $this->replace[] = 'url(data:'.$this->extension.';base64,'.base64_encode( $this->image_file ).')'; + } + + } //End local file + + } + + //Log the number of replacements to the console + $this->messages[]['Minifier Log: merge_images'] = count( $this->replace ) .' files base64_encoded into ' . $source_file; + + //Find and replace all the images with the base64 data + $this->updated_style = str_replace( $this->find, $this->replace, $contents ); + + return $this->updated_style; + + } //End if( regex for images) + else + { + //No images found in the sheet, just return the contents + return $contents; + } + + } //end merge_images() + + + /** + * Private function to handle minification of file contents + * Supports CSS and JS files + * + * @access private + * @param string $src_file + * @param bool $run_minification (default true) + * @return string $content + */ + private function minify_contents( $src_file, $run_minification = true ) + { + global $messages; + + $this->source = @file_get_contents( $src_file ); + + //Log the error and continue if we can't get the file contents + if( !$this->source ) + { + $this->messages[]['Minifier ERROR'] = 'Unable to retrieve the contents of '. $src_file . '. Skipping at '. __LINE__ .' in '. basename( __FILE__ ); + + //This will cause potential js errors, but allow the script to continue processing while notifying the user via console + $this->source = ''; + } + + $this->type = strtolower( pathinfo( $src_file, PATHINFO_EXTENSION ) ); + + $this->output = ''; + + /** + * If the filename indicates that the contents are already minified, we'll just return the contents + * If the switch is flipped (useful for loading things such as jquery via google cdn) + */ + if( preg_match( '/\.min\./i', $src_file ) || $run_minification === false ) + { + return $this->source; + } + else + { + if( !empty( $this->type ) && $this->type == 'css' ) + { + $this->content = $this->source; + //If the param is set to merge images into the css before minifying... + if( $this->settings['encode'] ) + { + $this->content = $this->merge_images( $src_file, $this->content ); + } + + /* remove comments */ + if( $this->settings['remove_comments'] ) + { + $this->content = preg_replace( '!/\*[^*]*\*+([^/][^*]*\*+)*/!', '', $this->content ); + } + + /* remove tabs, spaces, newlines, etc. */ + $this->content = preg_replace( '/(\s\s+|\t|\n)/', ' ', $this->content ); + /* remove other spaces before/after ; */ + $this->content = preg_replace( array('(( )+{)','({( )+)'), '{', $this->content ); + $this->content = preg_replace( array('(( )+})','(}( )+)','(;( )*})'), '}', $this->content ); + $this->content = preg_replace( array('(;( )+)','(( )+;)'), ';', $this->content ); + + } //end $this->type == 'css' + + if( !empty( $this->type ) && $this->type == 'js' ) + { + $this->content = $this->source; + + /** + * Migrated preg_replace and str_replace custom minification to use google closure API + * OR jsMin on 15-Jun-2013 due to js minification irregularities with most regex's: + * https://github.com/tedivm/JShrink + * https://developers.google.com/closure/compiler/ + * Accomodates lack of local file for JShrink by getting contents from github + * and writing to a local file for the class (just in case) + * If bool is passed for 'closure' => true during class initiation, cURL request processes + */ + if( $this->settings['closure'] ) + { + + //Build the data array + $data = array( + 'compilation_level' => 'SIMPLE_OPTIMIZATIONS', + 'output_format' => 'text', + 'output_info' => 'compiled_code', + 'js_code' => urlencode( $this->content ) + ); + + //Compile it into a post compatible format + $fields_string = ''; + foreach( $data as $key => $value ) + { + $fields_string .= $key . '=' . $value . '&'; + } + rtrim( $fields_string, '&' ); + + //Initiate and execute the curl request + $h = curl_init(); + curl_setopt( $h, CURLOPT_URL, 'http://closure-compiler.appspot.com/compile' ); + curl_setopt( $h, CURLOPT_POST, true ); + curl_setopt( $h, CURLOPT_POSTFIELDS, $fields_string ); + curl_setopt( $h, CURLOPT_HEADER, false ); + curl_setopt( $h, CURLOPT_RETURNTRANSFER, 1 ); + $result = curl_exec( $h ); + $this->content = $result; + + //close connection + curl_close( $h ); + + } //end if( $this->settings['closure'] ) + else + { + //Not using google closure, default to JShrink but make sure the file exists + if( !file_exists( dirname( __FILE__ ) .'/jShrink.php' ) ) + { + $this->messages[]['Minifier Log'] = 'jShrink does not exist locally. Retrieving...'; + + $this->handle = fopen( dirname( __FILE__ ) .'/jShrink.php', 'w' ); + $this->jshrink = file_get_contents( 'https://raw.github.com/tedivm/JShrink/master/src/JShrink/Minifier.php' ); + fwrite( $this->handle, $this->jshrink ); + fclose( $this->handle ); + } + + //Include jsmin + require_once( dirname( __FILE__ ) .'/jShrink.php' ); + + //Minify the javascript + $this->content = JShrink\Minifier::minify( $this->content, array( 'flaggedComments' => $this->settings['remove_comments'] ) ); + + } //end if( !$this->settings['closure'] ) + + } //end $this->type == 'js' + + //Add to the output and return it + $this->output .= $this->content; + return $this->output; + } + + } //end minify_contents() + + + /** + * Private function to create file with, or without minified contents + * + * @access private + * @param string path to, and name of source file + * @param string path to, and name of new minified file + * @param bool $do_minify (default is true) (used for remote files) + * @return string new filename/location (same as path to variable) + */ + private function make_min( $src_file, $new_file, $do_minify = true ) + { + global $messages; + + $this->messages[]['Minifier note'] = 'Writing new file to '. dirname( $new_file ); + + //Make sure the directory exists and is writable + if( !is_dir( dirname( $new_file ) ) || !is_writeable( dirname( $new_file ) ) ) + { + $this->messages[]['Minifier ERROR'] = dirname( $new_file ) . ' is not writable. Cannot create minified file.'; + trigger_error( dirname( $new_file ) . ' is not writable. Cannot create minified file.' ); + return false; + } + + //Output gzip data as needed, but default to none + //Lengthy line usage is intentional to provide cleanly formatted fwrite contents + $this->prequel = ''; + if( $this->settings['gzip'] ) + { + $this->prequel = 'prequel .= 'if( extension_loaded( "zlib" ) )' . PHP_EOL; + $this->prequel .= '{' . PHP_EOL; + $this->prequel .= ' ob_start( "ob_gzhandler" );' . PHP_EOL; + $this->prequel .= '}' . PHP_EOL; + $this->prequel .= 'else' . PHP_EOL; + $this->prequel .= '{' . PHP_EOL; + $this->prequel .= ' ob_start();' . PHP_EOL; + $this->prequel .= '}' . PHP_EOL; + + //Get the actual file type for header + $this->extension = strtolower( pathinfo( $new_file, PATHINFO_EXTENSION ) ); + + /** + * If gzip is enabled, the .php extension is added automatically + * and must be accounted for to prevent files from being recreated + */ + if( $this->extension != 'php' ) + { + $new_file = $new_file. '.php'; + } + + //Close out the php row so we can continue with normal content + $offset = 60 * 60 * 24 * 31; + $this->prequel .= 'header( \'Content-Encoding: gzip\' );' . PHP_EOL; + $this->prequel .= 'header( \'Cache-Control: max-age=' . $offset.'\' );' . PHP_EOL; + $this->prequel .= 'header( \'Expires: ' . gmdate( "D, d M Y H:i:s", time() + $offset ) . ' GMT\' );' . PHP_EOL; + $this->prequel .= 'header( \'Last-Modified: ' . gmdate( "D, d M Y H:i:s", filemtime( __FILE__ ) ) . ' GMT\' );' . PHP_EOL; + + //Add the header content type output for correct rendering + if( $this->extension == 'css' || ( strpos( $new_file, 'css' ) !== false ) ) + { + $this->prequel .= 'header( \'Content-type: text/css; charset: UTF-8\' );' . PHP_EOL; + } + if( $this->extension == 'js' || ( strpos( $new_file, 'js' ) !== false ) ) + { + $this->prequel .= 'header( \'Content-type: application/javascript; charset: UTF-8\' );' . PHP_EOL; + } + + //Close out the php tag that gets written to the file + $this->prequel .= '?>' . PHP_EOL; + + } //End if( $this->gzip ) + + //Single files + if( !is_array( $src_file ) ) + { + $this->filetag = '/**' . PHP_EOL; + $this->filetag .= ' * Filename: '. basename( $src_file ) . PHP_EOL; + $this->filetag .= ' * Generated by MagicMin '.date('Y-m-d'). ' at '. date('h:i:s A') . PHP_EOL; + $this->filetag .= ' */' . PHP_EOL; + $this->content = $this->prequel . $this->filetag . $this->minify_contents( $src_file, $do_minify ); + } + else + { + //Strip the directory names from the $src_file array for security + $filenames = array_map( array( $this, 'strip_directory' ), $src_file ); + + //Make a temporary var to store the data and write a TOC + $this->compiled = '/**' . PHP_EOL; + $this->compiled .= ' * Table of contents: ' . PHP_EOL; + $this->compiled .= ' * '. implode( PHP_EOL. ' * ', $filenames ) . PHP_EOL; + $this->compiled .= ' * Generated by MagicMin: ' . date( 'Y-m-d h:i:s' ). PHP_EOL; + $this->compiled .= ' */' . PHP_EOL; + + //Loop through an array of files to write to the new file + foreach( $src_file as $this->new_file ) + { + + /** + * It's relatively safe to assume that remote files being retrieved + * already have minified contents (ie. Google CDN hosted jquery) + * so prevent re-minification, but default to $do_minify = true; + */ + $do_minify = true; + if( $this->remote_file( $this->new_file ) ) + { + //Remote files should not have compressed content + $do_minify = false; + } + + //Add the sourcefile minified content + $this->compiled .= PHP_EOL . PHP_EOL . '/* Filename: '. basename( $this->new_file ) . ' */' . PHP_EOL; + $this->compiled .= $this->minify_contents( $this->new_file, $do_minify ); + } + + //Write the temporary contents to the full contents + $this->content = trim( $this->prequel . $this->compiled ); + + //Remove the temporary data + unset( $this->compiled ); + + } //End $src_file is_array + + //If the file already exists, open it and empty it + if( file_exists( $new_file ) && is_writeable( $new_file ) ) + { + $f = fopen( $new_file, 'w' ); + fclose( $f ); + } + + //Create the new file + $this->handle = fopen( $new_file, 'w' ); + + //Log any error messages from the new file creation + if( !$this->handle ) + { + $this->messages[]['Minifier ERROR'] = 'Unable to open file: '.$new_file; + trigger_error( 'Unable to open file: '.$new_file ); + return false; + } + else + { + //Write the minified contents to it + fwrite( $this->handle, $this->content ); + fclose( $this->handle ); + + //Log to the console + $this->messages[]['Minifier Log: New file'] = 'Successfully created '. $new_file; + + //Return filename and location + return $new_file; + } + + } //end make_min() + + + /** + * Get contents of JS or CSS script, create minified version + * Idea and partial adaptation from: http://davidwalsh.name/php-cache-function + * Dependent on "make_min" function + * + * Example usage: + * + * + * $min->minify( 'source file', 'output file', 'version' ); + * + * @access public + * @param string $src_file (filename and location for original file) + * @param string $file (filename and location for output file. Empty defaults to [filename].min.[extension]) + * @param string $version + * @return string $output_file (includes provided location) + */ + public function minify( $src_file, $file = '', $version = '' ) + { + global $messages; + + //Since the $file (output) filename is optional, if empty, just add .min.[ext] + if( empty( $file ) ) + { + //Get the pathinfo + $ext = pathinfo( $src_file ); + //Create a new filename + $file = $ext['dirname'] . '/' . $ext['filename'] . '.min.' . $ext['extension']; + + } + + //If we have gzip enabled, we must account for the .php extension + if( $this->settings['gzip'] && ( strtolower( pathinfo( $file, PATHINFO_EXTENSION ) ) != '.php' ) ) + { + $file .= '.php'; + } + + //The source file is remote, and we can't check for an updated version anyway + if( $this->remote_file( $src_file ) && file_exists( $file ) ) + { + $this->output_file = $file; + } + //The local version doesn't exist, but we don't need to minify + elseif( $this->remote_file( $src_file ) && !file_exists( $file ) ) + { + $this->output_file = $this->make_min( $src_file, $file, false ); + + //Add the filename to the output log + $this->messages[]['Minifier Log: minify'] = 'Retrieving contents of '.$src_file .' to add to '.$file; + } + //The file already exists and doesn't need to be recreated + elseif( ( file_exists( $file ) && file_exists( $src_file ) ) && ( filemtime( $src_file ) < filemtime( $file ) ) ) + { + //No change, so the output is the same as the input + $this->output_file = $file; + + } + //The file exists, but the development version is newer + elseif( ( file_exists( $file ) && file_exists( $src_file ) ) && ( filemtime( $src_file ) > filemtime( $file ) ) ) + { + //Remove the file so we can do a clean recreate + chmod( $file, 0777 ); + unlink( $file ); + + //Make the cached version + $this->output_file = $this->make_min( $src_file, $file ); + + //Add to the console.log output + $this->messages[]['Minifier Log: minify'] = 'Made new version of '.$src_file.' into '.$file; + } + //The minified file doesn't exist, make one + else + { + //Make the cached version + $this->output_file = $this->make_min( $src_file, $file ); + + //Add to the console.log output if desired + $this->messages[]['Minifier Log: minify'] = 'Made new version of '.$src_file.' into '.$file; + } + + //Add the ? params if they exist + if( !empty( $version ) ) + { + $this->output_file .= '?v='. $version; + } + + //Return the output filename or echo + if( $this->settings['echo'] ) + { + echo $this->output_file; + } + else + { + return $this->output_file; + } + + } //end minify() + + + /** + * Get the contents of js or css files, minify, and merge into a single file + * + * Example usage: + * + * + * + * + * @access public + * @param string $output_filename + * @param string $directory to loop through + * @param mixed $list_or_type (css, js, selective - default is js) + **** $list_or_type will also accept "selective" array which overrides glob and only includes specified files + **** $list_or_type array passed files are included in order, and no other files will be included + **** files must all be the same type in order to prevent eronious output contents (js and css do not mix) + * @param array $exclude files to exclude + * @param array $order to specify output order + * @return string new filenae + */ + public function merge( $output_filename, $directory, $list_or_type = 'js', $exclude = array(), $order = array() ) + { + global $messages; + + /** + * Added selective inclusion to override glob and exclusion 13-Jun-2013 ala Ray Beriau + * This assumes the user has passed an array of filenames, in order rather than a file type + * By doing so, we'll set the directory to indicate no contents, and priorize directly into $order + */ + if( is_array( $list_or_type ) && !empty( $list_or_type ) ) + { + //Direct the directory to be an empty array + $this->directory = array(); + //Utilize the $order variable + $order = $list_or_type; + } + else + { + //Open the directory for looping and seek out files of appropriate type + $this->directory = glob( $directory .'/*.'.$list_or_type ); + } + + /** + * Reassign the $output_filename if gzip is enabled as we must account for the .php + * extension in order to prevent the file from being recreated + */ + if( $this->settings['gzip'] && ( strtolower( pathinfo( $output_filename, PATHINFO_EXTENSION ) ) != '.php' ) ) + { + $output_filename .= '.php'; + } + + //Create a bool to determine if a new file needs to be created + $this->create_new = false; + + //Start the array of files to add to the cache + $this->compilation = array(); + + //Determine if a specific order is needed, if so remove only those files from glob seek + if( !empty( $order ) ) + { + + $this->messages[]['Minifier Log: Merge order'] = 'Order specified with '. count( $order ) .' files'; + + foreach( $order as $this->file ) + { + + //Check each file for modification greater than the output file if it exists + if( file_exists( $output_filename ) && ( $this->file != $output_filename ) && ( !$this->remote_file( $this->file ) ) && ( filemtime( $this->file ) > filemtime( $output_filename ) ) ) + { + $this->messages[]['Minifier Log: New File Flagged'] = 'Flagged for update by '. $this->file; + $this->create_new = true; + } + + //Add the specified files to the beginning of the use array passed to $this->make_min + $this->compilation[] = $this->file; + + } + + //Now remove the same files from the glob directory + $this->directory = array_diff( $this->directory, $this->compilation ); + + } //End !empty( $order ) + + //Loop through the directory grabbing files along the way + foreach( $this->directory as $this->file ) + { + + //Make sure we didn't want to exclude this file before adding it + if( !in_array( $this->file, $exclude ) && ( $this->file != $output_filename ) ) + { + //Check each file for modification greater than the output file if it exists + if( file_exists( $output_filename ) && ( !$this->remote_file( $this->file ) ) && ( filemtime( $this->file ) > filemtime( $output_filename ) ) ) + { + $this->messages[]['Minifier Log: New File Flagged'] = 'Flagged for update by '. $this->file; + $this->create_new = true; + } + + $this->compilation[] = $this->file; + } + + } //End foreach( $this->directory ) + + //Only recreate the file as needed + if( $this->create_new || !file_exists( $output_filename ) ) + { + //Group and minify the contents + $this->compressed = $this->make_min( $this->compilation, $output_filename ); + } + else + { + $this->compressed = $output_filename; + } + + //Echo or return + if( $this->settings['echo'] ) + { + echo $this->compressed; + } + else + { + return $this->compressed; + } + + } //end merge() + + + /** + * Output any return data to the javascript console/source of page + * Usage (assuming minifier is initiated as $minifier): + * logs(); ?> + * + * @param none + * @return string + */ + public function logs() + { + global $messages; + + //Add the timer the console.log output if desired + if( $this->settings['timer'] ) + { + $this->messages[]['Minifier Log: timer'] = 'MagicMin processed and loaded in '. ( microtime( true ) - $this->mtime ) .' seconds'; + } + + if( !empty( $this->messages ) ) + { + + echo ''; + + } //end !empty( $this-messages ) + + } //end logs() + + +} //End class Minifier diff --git a/assets/snippets/cssjs/lessc.inc.php b/assets/snippets/cssjs/lessc.inc.php new file mode 100644 index 0000000..0699de5 --- /dev/null +++ b/assets/snippets/cssjs/lessc.inc.php @@ -0,0 +1,3675 @@ + + * Licensed under MIT or GPLv3, see LICENSE + */ + + +/** + * The less compiler and parser. + * + * Converting LESS to CSS is a three stage process. The incoming file is parsed + * by `lessc_parser` into a syntax tree, then it is compiled into another tree + * representing the CSS structure by `lessc`. The CSS tree is fed into a + * formatter, like `lessc_formatter` which then outputs CSS as a string. + * + * During the first compile, all values are *reduced*, which means that their + * types are brought to the lowest form before being dump as strings. This + * handles math equations, variable dereferences, and the like. + * + * The `parse` function of `lessc` is the entry point. + * + * In summary: + * + * The `lessc` class creates an intstance of the parser, feeds it LESS code, + * then transforms the resulting tree to a CSS tree. This class also holds the + * evaluation context, such as all available mixins and variables at any given + * time. + * + * The `lessc_parser` class is only concerned with parsing its input. + * + * The `lessc_formatter` takes a CSS tree, and dumps it to a formatted string, + * handling things like indentation. + */ +class lessc { + static public $VERSION = "v0.4.0"; + static protected $TRUE = array("keyword", "true"); + static protected $FALSE = array("keyword", "false"); + + protected $libFunctions = array(); + protected $registeredVars = array(); + protected $preserveComments = false; + + public $vPrefix = '@'; // prefix of abstract properties + public $mPrefix = '$'; // prefix of abstract blocks + public $parentSelector = '&'; + + public $importDisabled = false; + public $importDir = ''; + + protected $numberPrecision = null; + + protected $allParsedFiles = array(); + + // set to the parser that generated the current line when compiling + // so we know how to create error messages + protected $sourceParser = null; + protected $sourceLoc = null; + + static public $defaultValue = array("keyword", ""); + + static protected $nextImportId = 0; // uniquely identify imports + + // attempts to find the path of an import url, returns null for css files + protected function findImport($url) { + foreach ((array)$this->importDir as $dir) { + $full = $dir.(substr($dir, -1) != '/' ? '/' : '').$url; + if ($this->fileExists($file = $full.'.less') || $this->fileExists($file = $full)) { + return $file; + } + } + + return null; + } + + protected function fileExists($name) { + return is_file($name); + } + + static public function compressList($items, $delim) { + if (!isset($items[1]) && isset($items[0])) return $items[0]; + else return array('list', $delim, $items); + } + + static public function preg_quote($what) { + return preg_quote($what, '/'); + } + + protected function tryImport($importPath, $parentBlock, $out) { + if ($importPath[0] == "function" && $importPath[1] == "url") { + $importPath = $this->flattenList($importPath[2]); + } + + $str = $this->coerceString($importPath); + if ($str === null) return false; + + $url = $this->compileValue($this->lib_e($str)); + + // don't import if it ends in css + if (substr_compare($url, '.css', -4, 4) === 0) return false; + + $realPath = $this->findImport($url); + + if ($realPath === null) return false; + + if ($this->importDisabled) { + return array(false, "/* import disabled */"); + } + + if (isset($this->allParsedFiles[realpath($realPath)])) { + return array(false, null); + } + + $this->addParsedFile($realPath); + $parser = $this->makeParser($realPath); + $root = $parser->parse(file_get_contents($realPath)); + + // set the parents of all the block props + foreach ($root->props as $prop) { + if ($prop[0] == "block") { + $prop[1]->parent = $parentBlock; + } + } + + // copy mixins into scope, set their parents + // bring blocks from import into current block + // TODO: need to mark the source parser these came from this file + foreach ($root->children as $childName => $child) { + if (isset($parentBlock->children[$childName])) { + $parentBlock->children[$childName] = array_merge( + $parentBlock->children[$childName], + $child); + } else { + $parentBlock->children[$childName] = $child; + } + } + + $pi = pathinfo($realPath); + $dir = $pi["dirname"]; + + list($top, $bottom) = $this->sortProps($root->props, true); + $this->compileImportedProps($top, $parentBlock, $out, $parser, $dir); + + return array(true, $bottom, $parser, $dir); + } + + protected function compileImportedProps($props, $block, $out, $sourceParser, $importDir) { + $oldSourceParser = $this->sourceParser; + + $oldImport = $this->importDir; + + // TODO: this is because the importDir api is stupid + $this->importDir = (array)$this->importDir; + array_unshift($this->importDir, $importDir); + + foreach ($props as $prop) { + $this->compileProp($prop, $block, $out); + } + + $this->importDir = $oldImport; + $this->sourceParser = $oldSourceParser; + } + + /** + * Recursively compiles a block. + * + * A block is analogous to a CSS block in most cases. A single LESS document + * is encapsulated in a block when parsed, but it does not have parent tags + * so all of it's children appear on the root level when compiled. + * + * Blocks are made up of props and children. + * + * Props are property instructions, array tuples which describe an action + * to be taken, eg. write a property, set a variable, mixin a block. + * + * The children of a block are just all the blocks that are defined within. + * This is used to look up mixins when performing a mixin. + * + * Compiling the block involves pushing a fresh environment on the stack, + * and iterating through the props, compiling each one. + * + * See lessc::compileProp() + * + */ + protected function compileBlock($block) { + switch ($block->type) { + case "root": + $this->compileRoot($block); + break; + case null: + $this->compileCSSBlock($block); + break; + case "media": + $this->compileMedia($block); + break; + case "directive": + $name = "@" . $block->name; + if (!empty($block->value)) { + $name .= " " . $this->compileValue($this->reduce($block->value)); + } + + $this->compileNestedBlock($block, array($name)); + break; + default: + $this->throwError("unknown block type: $block->type\n"); + } + } + + protected function compileCSSBlock($block) { + $env = $this->pushEnv(); + + $selectors = $this->compileSelectors($block->tags); + $env->selectors = $this->multiplySelectors($selectors); + $out = $this->makeOutputBlock(null, $env->selectors); + + $this->scope->children[] = $out; + $this->compileProps($block, $out); + + $block->scope = $env; // mixins carry scope with them! + $this->popEnv(); + } + + protected function compileMedia($media) { + $env = $this->pushEnv($media); + $parentScope = $this->mediaParent($this->scope); + + $query = $this->compileMediaQuery($this->multiplyMedia($env)); + + $this->scope = $this->makeOutputBlock($media->type, array($query)); + $parentScope->children[] = $this->scope; + + $this->compileProps($media, $this->scope); + + if (count($this->scope->lines) > 0) { + $orphanSelelectors = $this->findClosestSelectors(); + if (!is_null($orphanSelelectors)) { + $orphan = $this->makeOutputBlock(null, $orphanSelelectors); + $orphan->lines = $this->scope->lines; + array_unshift($this->scope->children, $orphan); + $this->scope->lines = array(); + } + } + + $this->scope = $this->scope->parent; + $this->popEnv(); + } + + protected function mediaParent($scope) { + while (!empty($scope->parent)) { + if (!empty($scope->type) && $scope->type != "media") { + break; + } + $scope = $scope->parent; + } + + return $scope; + } + + protected function compileNestedBlock($block, $selectors) { + $this->pushEnv($block); + $this->scope = $this->makeOutputBlock($block->type, $selectors); + $this->scope->parent->children[] = $this->scope; + + $this->compileProps($block, $this->scope); + + $this->scope = $this->scope->parent; + $this->popEnv(); + } + + protected function compileRoot($root) { + $this->pushEnv(); + $this->scope = $this->makeOutputBlock($root->type); + $this->compileProps($root, $this->scope); + $this->popEnv(); + } + + protected function compileProps($block, $out) { + foreach ($this->sortProps($block->props) as $prop) { + $this->compileProp($prop, $block, $out); + } + + $out->lines = array_values(array_unique($out->lines)); + } + + protected function sortProps($props, $split = false) { + $vars = array(); + $imports = array(); + $other = array(); + + foreach ($props as $prop) { + switch ($prop[0]) { + case "assign": + if (isset($prop[1][0]) && $prop[1][0] == $this->vPrefix) { + $vars[] = $prop; + } else { + $other[] = $prop; + } + break; + case "import": + $id = self::$nextImportId++; + $prop[] = $id; + $imports[] = $prop; + $other[] = array("import_mixin", $id); + break; + default: + $other[] = $prop; + } + } + + if ($split) { + return array(array_merge($vars, $imports), $other); + } else { + return array_merge($vars, $imports, $other); + } + } + + protected function compileMediaQuery($queries) { + $compiledQueries = array(); + foreach ($queries as $query) { + $parts = array(); + foreach ($query as $q) { + switch ($q[0]) { + case "mediaType": + $parts[] = implode(" ", array_slice($q, 1)); + break; + case "mediaExp": + if (isset($q[2])) { + $parts[] = "($q[1]: " . + $this->compileValue($this->reduce($q[2])) . ")"; + } else { + $parts[] = "($q[1])"; + } + break; + case "variable": + $parts[] = $this->compileValue($this->reduce($q)); + break; + } + } + + if (count($parts) > 0) { + $compiledQueries[] = implode(" and ", $parts); + } + } + + $out = "@media"; + if (!empty($parts)) { + $out .= " " . + implode($this->formatter->selectorSeparator, $compiledQueries); + } + return $out; + } + + protected function multiplyMedia($env, $childQueries = null) { + if (is_null($env) || + !empty($env->block->type) && $env->block->type != "media") + { + return $childQueries; + } + + // plain old block, skip + if (empty($env->block->type)) { + return $this->multiplyMedia($env->parent, $childQueries); + } + + $out = array(); + $queries = $env->block->queries; + if (is_null($childQueries)) { + $out = $queries; + } else { + foreach ($queries as $parent) { + foreach ($childQueries as $child) { + $out[] = array_merge($parent, $child); + } + } + } + + return $this->multiplyMedia($env->parent, $out); + } + + protected function expandParentSelectors(&$tag, $replace) { + $parts = explode("$&$", $tag); + $count = 0; + foreach ($parts as &$part) { + $part = str_replace($this->parentSelector, $replace, $part, $c); + $count += $c; + } + $tag = implode($this->parentSelector, $parts); + return $count; + } + + protected function findClosestSelectors() { + $env = $this->env; + $selectors = null; + while ($env !== null) { + if (isset($env->selectors)) { + $selectors = $env->selectors; + break; + } + $env = $env->parent; + } + + return $selectors; + } + + + // multiply $selectors against the nearest selectors in env + protected function multiplySelectors($selectors) { + // find parent selectors + + $parentSelectors = $this->findClosestSelectors(); + if (is_null($parentSelectors)) { + // kill parent reference in top level selector + foreach ($selectors as &$s) { + $this->expandParentSelectors($s, ""); + } + + return $selectors; + } + + $out = array(); + foreach ($parentSelectors as $parent) { + foreach ($selectors as $child) { + $count = $this->expandParentSelectors($child, $parent); + + // don't prepend the parent tag if & was used + if ($count > 0) { + $out[] = trim($child); + } else { + $out[] = trim($parent . ' ' . $child); + } + } + } + + return $out; + } + + // reduces selector expressions + protected function compileSelectors($selectors) { + $out = array(); + + foreach ($selectors as $s) { + if (is_array($s)) { + list(, $value) = $s; + $out[] = trim($this->compileValue($this->reduce($value))); + } else { + $out[] = $s; + } + } + + return $out; + } + + protected function eq($left, $right) { + return $left == $right; + } + + protected function patternMatch($block, $orderedArgs, $keywordArgs) { + // match the guards if it has them + // any one of the groups must have all its guards pass for a match + if (!empty($block->guards)) { + $groupPassed = false; + foreach ($block->guards as $guardGroup) { + foreach ($guardGroup as $guard) { + $this->pushEnv(); + $this->zipSetArgs($block->args, $orderedArgs, $keywordArgs); + + $negate = false; + if ($guard[0] == "negate") { + $guard = $guard[1]; + $negate = true; + } + + $passed = $this->reduce($guard) == self::$TRUE; + if ($negate) $passed = !$passed; + + $this->popEnv(); + + if ($passed) { + $groupPassed = true; + } else { + $groupPassed = false; + break; + } + } + + if ($groupPassed) break; + } + + if (!$groupPassed) { + return false; + } + } + + if (empty($block->args)) { + return $block->isVararg || empty($orderedArgs) && empty($keywordArgs); + } + + $remainingArgs = $block->args; + if ($keywordArgs) { + $remainingArgs = array(); + foreach ($block->args as $arg) { + if ($arg[0] == "arg" && isset($keywordArgs[$arg[1]])) { + continue; + } + + $remainingArgs[] = $arg; + } + } + + $i = -1; // no args + // try to match by arity or by argument literal + foreach ($remainingArgs as $i => $arg) { + switch ($arg[0]) { + case "lit": + if (empty($orderedArgs[$i]) || !$this->eq($arg[1], $orderedArgs[$i])) { + return false; + } + break; + case "arg": + // no arg and no default value + if (!isset($orderedArgs[$i]) && !isset($arg[2])) { + return false; + } + break; + case "rest": + $i--; // rest can be empty + break 2; + } + } + + if ($block->isVararg) { + return true; // not having enough is handled above + } else { + $numMatched = $i + 1; + // greater than becuase default values always match + return $numMatched >= count($orderedArgs); + } + } + + protected function patternMatchAll($blocks, $orderedArgs, $keywordArgs, $skip=array()) { + $matches = null; + foreach ($blocks as $block) { + // skip seen blocks that don't have arguments + if (isset($skip[$block->id]) && !isset($block->args)) { + continue; + } + + if ($this->patternMatch($block, $orderedArgs, $keywordArgs)) { + $matches[] = $block; + } + } + + return $matches; + } + + // attempt to find blocks matched by path and args + protected function findBlocks($searchIn, $path, $orderedArgs, $keywordArgs, $seen=array()) { + if ($searchIn == null) return null; + if (isset($seen[$searchIn->id])) return null; + $seen[$searchIn->id] = true; + + $name = $path[0]; + + if (isset($searchIn->children[$name])) { + $blocks = $searchIn->children[$name]; + if (count($path) == 1) { + $matches = $this->patternMatchAll($blocks, $orderedArgs, $keywordArgs, $seen); + if (!empty($matches)) { + // This will return all blocks that match in the closest + // scope that has any matching block, like lessjs + return $matches; + } + } else { + $matches = array(); + foreach ($blocks as $subBlock) { + $subMatches = $this->findBlocks($subBlock, + array_slice($path, 1), $orderedArgs, $keywordArgs, $seen); + + if (!is_null($subMatches)) { + foreach ($subMatches as $sm) { + $matches[] = $sm; + } + } + } + + return count($matches) > 0 ? $matches : null; + } + } + if ($searchIn->parent === $searchIn) return null; + return $this->findBlocks($searchIn->parent, $path, $orderedArgs, $keywordArgs, $seen); + } + + // sets all argument names in $args to either the default value + // or the one passed in through $values + protected function zipSetArgs($args, $orderedValues, $keywordValues) { + $assignedValues = array(); + + $i = 0; + foreach ($args as $a) { + if ($a[0] == "arg") { + if (isset($keywordValues[$a[1]])) { + // has keyword arg + $value = $keywordValues[$a[1]]; + } elseif (isset($orderedValues[$i])) { + // has ordered arg + $value = $orderedValues[$i]; + $i++; + } elseif (isset($a[2])) { + // has default value + $value = $a[2]; + } else { + $this->throwError("Failed to assign arg " . $a[1]); + $value = null; // :( + } + + $value = $this->reduce($value); + $this->set($a[1], $value); + $assignedValues[] = $value; + } else { + // a lit + $i++; + } + } + + // check for a rest + $last = end($args); + if ($last[0] == "rest") { + $rest = array_slice($orderedValues, count($args) - 1); + $this->set($last[1], $this->reduce(array("list", " ", $rest))); + } + + // wow is this the only true use of PHP's + operator for arrays? + $this->env->arguments = $assignedValues + $orderedValues; + } + + // compile a prop and update $lines or $blocks appropriately + protected function compileProp($prop, $block, $out) { + // set error position context + $this->sourceLoc = isset($prop[-1]) ? $prop[-1] : -1; + + switch ($prop[0]) { + case 'assign': + list(, $name, $value) = $prop; + if ($name[0] == $this->vPrefix) { + $this->set($name, $value); + } else { + $out->lines[] = $this->formatter->property($name, + $this->compileValue($this->reduce($value))); + } + break; + case 'block': + list(, $child) = $prop; + $this->compileBlock($child); + break; + case 'mixin': + list(, $path, $args, $suffix) = $prop; + + $orderedArgs = array(); + $keywordArgs = array(); + foreach ((array)$args as $arg) { + $argval = null; + switch ($arg[0]) { + case "arg": + if (!isset($arg[2])) { + $orderedArgs[] = $this->reduce(array("variable", $arg[1])); + } else { + $keywordArgs[$arg[1]] = $this->reduce($arg[2]); + } + break; + + case "lit": + $orderedArgs[] = $this->reduce($arg[1]); + break; + default: + $this->throwError("Unknown arg type: " . $arg[0]); + } + } + + $mixins = $this->findBlocks($block, $path, $orderedArgs, $keywordArgs); + + if ($mixins === null) { + // fwrite(STDERR,"failed to find block: ".implode(" > ", $path)."\n"); + break; // throw error here?? + } + + foreach ($mixins as $mixin) { + if ($mixin === $block && !$orderedArgs) { + continue; + } + + $haveScope = false; + if (isset($mixin->parent->scope)) { + $haveScope = true; + $mixinParentEnv = $this->pushEnv(); + $mixinParentEnv->storeParent = $mixin->parent->scope; + } + + $haveArgs = false; + if (isset($mixin->args)) { + $haveArgs = true; + $this->pushEnv(); + $this->zipSetArgs($mixin->args, $orderedArgs, $keywordArgs); + } + + $oldParent = $mixin->parent; + if ($mixin != $block) $mixin->parent = $block; + + foreach ($this->sortProps($mixin->props) as $subProp) { + if ($suffix !== null && + $subProp[0] == "assign" && + is_string($subProp[1]) && + $subProp[1]{0} != $this->vPrefix) + { + $subProp[2] = array( + 'list', ' ', + array($subProp[2], array('keyword', $suffix)) + ); + } + + $this->compileProp($subProp, $mixin, $out); + } + + $mixin->parent = $oldParent; + + if ($haveArgs) $this->popEnv(); + if ($haveScope) $this->popEnv(); + } + + break; + case 'raw': + $out->lines[] = $prop[1]; + break; + case "directive": + list(, $name, $value) = $prop; + $out->lines[] = "@$name " . $this->compileValue($this->reduce($value)).';'; + break; + case "comment": + $out->lines[] = $prop[1]; + break; + case "import"; + list(, $importPath, $importId) = $prop; + $importPath = $this->reduce($importPath); + + if (!isset($this->env->imports)) { + $this->env->imports = array(); + } + + $result = $this->tryImport($importPath, $block, $out); + + $this->env->imports[$importId] = $result === false ? + array(false, "@import " . $this->compileValue($importPath).";") : + $result; + + break; + case "import_mixin": + list(,$importId) = $prop; + $import = $this->env->imports[$importId]; + if ($import[0] === false) { + if (isset($import[1])) { + $out->lines[] = $import[1]; + } + } else { + list(, $bottom, $parser, $importDir) = $import; + $this->compileImportedProps($bottom, $block, $out, $parser, $importDir); + } + + break; + default: + $this->throwError("unknown op: {$prop[0]}\n"); + } + } + + + /** + * Compiles a primitive value into a CSS property value. + * + * Values in lessphp are typed by being wrapped in arrays, their format is + * typically: + * + * array(type, contents [, additional_contents]*) + * + * The input is expected to be reduced. This function will not work on + * things like expressions and variables. + */ + protected function compileValue($value) { + switch ($value[0]) { + case 'list': + // [1] - delimiter + // [2] - array of values + return implode($value[1], array_map(array($this, 'compileValue'), $value[2])); + case 'raw_color': + if (!empty($this->formatter->compressColors)) { + return $this->compileValue($this->coerceColor($value)); + } + return $value[1]; + case 'keyword': + // [1] - the keyword + return $value[1]; + case 'number': + list(, $num, $unit) = $value; + // [1] - the number + // [2] - the unit + if ($this->numberPrecision !== null) { + $num = round($num, $this->numberPrecision); + } + return $num . $unit; + case 'string': + // [1] - contents of string (includes quotes) + list(, $delim, $content) = $value; + foreach ($content as &$part) { + if (is_array($part)) { + $part = $this->compileValue($part); + } + } + return $delim . implode($content) . $delim; + case 'color': + // [1] - red component (either number or a %) + // [2] - green component + // [3] - blue component + // [4] - optional alpha component + list(, $r, $g, $b) = $value; + $r = round($r); + $g = round($g); + $b = round($b); + + if (count($value) == 5 && $value[4] != 1) { // rgba + return 'rgba('.$r.','.$g.','.$b.','.$value[4].')'; + } + + $h = sprintf("#%02x%02x%02x", $r, $g, $b); + + if (!empty($this->formatter->compressColors)) { + // 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; + + case 'function': + list(, $name, $args) = $value; + return $name.'('.$this->compileValue($args).')'; + default: // assumed to be unit + $this->throwError("unknown value type: $value[0]"); + } + } + + protected function lib_pow($args) { + list($base, $exp) = $this->assertArgs($args, 2, "pow"); + return pow($this->assertNumber($base), $this->assertNumber($exp)); + } + + protected function lib_pi() { + return pi(); + } + + protected function lib_mod($args) { + list($a, $b) = $this->assertArgs($args, 2, "mod"); + return $this->assertNumber($a) % $this->assertNumber($b); + } + + protected function lib_tan($num) { + return tan($this->assertNumber($num)); + } + + protected function lib_sin($num) { + return sin($this->assertNumber($num)); + } + + protected function lib_cos($num) { + return cos($this->assertNumber($num)); + } + + protected function lib_atan($num) { + $num = atan($this->assertNumber($num)); + return array("number", $num, "rad"); + } + + protected function lib_asin($num) { + $num = asin($this->assertNumber($num)); + return array("number", $num, "rad"); + } + + protected function lib_acos($num) { + $num = acos($this->assertNumber($num)); + return array("number", $num, "rad"); + } + + protected function lib_sqrt($num) { + return sqrt($this->assertNumber($num)); + } + + protected function lib_extract($value) { + list($list, $idx) = $this->assertArgs($value, 2, "extract"); + $idx = $this->assertNumber($idx); + // 1 indexed + if ($list[0] == "list" && isset($list[2][$idx - 1])) { + return $list[2][$idx - 1]; + } + } + + protected function lib_isnumber($value) { + return $this->toBool($value[0] == "number"); + } + + protected function lib_isstring($value) { + return $this->toBool($value[0] == "string"); + } + + protected function lib_iscolor($value) { + return $this->toBool($this->coerceColor($value)); + } + + protected function lib_iskeyword($value) { + return $this->toBool($value[0] == "keyword"); + } + + protected function lib_ispixel($value) { + return $this->toBool($value[0] == "number" && $value[2] == "px"); + } + + protected function lib_ispercentage($value) { + return $this->toBool($value[0] == "number" && $value[2] == "%"); + } + + protected function lib_isem($value) { + return $this->toBool($value[0] == "number" && $value[2] == "em"); + } + + protected function lib_isrem($value) { + return $this->toBool($value[0] == "number" && $value[2] == "rem"); + } + + protected function lib_rgbahex($color) { + $color = $this->coerceColor($color); + if (is_null($color)) + $this->throwError("color expected for rgbahex"); + + return sprintf("#%02x%02x%02x%02x", + isset($color[4]) ? $color[4]*255 : 255, + $color[1],$color[2], $color[3]); + } + + protected function lib_argb($color){ + return $this->lib_rgbahex($color); + } + + // utility func to unquote a string + protected function lib_e($arg) { + switch ($arg[0]) { + case "list": + $items = $arg[2]; + if (isset($items[0])) { + return $this->lib_e($items[0]); + } + return self::$defaultValue; + case "string": + $arg[1] = ""; + return $arg; + case "keyword": + return $arg; + default: + return array("keyword", $this->compileValue($arg)); + } + } + + protected function lib__sprintf($args) { + if ($args[0] != "list") return $args; + $values = $args[2]; + $string = array_shift($values); + $template = $this->compileValue($this->lib_e($string)); + + $i = 0; + if (preg_match_all('/%[dsa]/', $template, $m)) { + foreach ($m[0] as $match) { + $val = isset($values[$i]) ? + $this->reduce($values[$i]) : array('keyword', ''); + + // lessjs compat, renders fully expanded color, not raw color + if ($color = $this->coerceColor($val)) { + $val = $color; + } + + $i++; + $rep = $this->compileValue($this->lib_e($val)); + $template = preg_replace('/'.self::preg_quote($match).'/', + $rep, $template, 1); + } + } + + $d = $string[0] == "string" ? $string[1] : '"'; + return array("string", $d, array($template)); + } + + protected function lib_floor($arg) { + $value = $this->assertNumber($arg); + return array("number", floor($value), $arg[2]); + } + + protected function lib_ceil($arg) { + $value = $this->assertNumber($arg); + return array("number", ceil($value), $arg[2]); + } + + protected function lib_round($arg) { + $value = $this->assertNumber($arg); + return array("number", round($value), $arg[2]); + } + + protected function lib_unit($arg) { + if ($arg[0] == "list") { + list($number, $newUnit) = $arg[2]; + return array("number", $this->assertNumber($number), + $this->compileValue($this->lib_e($newUnit))); + } else { + return array("number", $this->assertNumber($arg), ""); + } + } + + /** + * Helper function to get arguments for color manipulation functions. + * takes a list that contains a color like thing and a percentage + */ + protected function colorArgs($args) { + if ($args[0] != 'list' || count($args[2]) < 2) { + return array(array('color', 0, 0, 0), 0); + } + list($color, $delta) = $args[2]; + $color = $this->assertColor($color); + $delta = floatval($delta[1]); + + return array($color, $delta); + } + + protected function lib_darken($args) { + list($color, $delta) = $this->colorArgs($args); + + $hsl = $this->toHSL($color); + $hsl[3] = $this->clamp($hsl[3] - $delta, 100); + return $this->toRGB($hsl); + } + + protected function lib_lighten($args) { + list($color, $delta) = $this->colorArgs($args); + + $hsl = $this->toHSL($color); + $hsl[3] = $this->clamp($hsl[3] + $delta, 100); + return $this->toRGB($hsl); + } + + protected function lib_saturate($args) { + list($color, $delta) = $this->colorArgs($args); + + $hsl = $this->toHSL($color); + $hsl[2] = $this->clamp($hsl[2] + $delta, 100); + return $this->toRGB($hsl); + } + + protected function lib_desaturate($args) { + list($color, $delta) = $this->colorArgs($args); + + $hsl = $this->toHSL($color); + $hsl[2] = $this->clamp($hsl[2] - $delta, 100); + return $this->toRGB($hsl); + } + + protected function lib_spin($args) { + list($color, $delta) = $this->colorArgs($args); + + $hsl = $this->toHSL($color); + + $hsl[1] = $hsl[1] + $delta % 360; + if ($hsl[1] < 0) $hsl[1] += 360; + + return $this->toRGB($hsl); + } + + protected function lib_fadeout($args) { + list($color, $delta) = $this->colorArgs($args); + $color[4] = $this->clamp((isset($color[4]) ? $color[4] : 1) - $delta/100); + return $color; + } + + protected function lib_fadein($args) { + list($color, $delta) = $this->colorArgs($args); + $color[4] = $this->clamp((isset($color[4]) ? $color[4] : 1) + $delta/100); + return $color; + } + + protected function lib_hue($color) { + $hsl = $this->toHSL($this->assertColor($color)); + return round($hsl[1]); + } + + protected function lib_saturation($color) { + $hsl = $this->toHSL($this->assertColor($color)); + return round($hsl[2]); + } + + protected function lib_lightness($color) { + $hsl = $this->toHSL($this->assertColor($color)); + return round($hsl[3]); + } + + // get the alpha of a color + // defaults to 1 for non-colors or colors without an alpha + protected function lib_alpha($value) { + if (!is_null($color = $this->coerceColor($value))) { + return isset($color[4]) ? $color[4] : 1; + } + } + + // set the alpha of the color + protected function lib_fade($args) { + list($color, $alpha) = $this->colorArgs($args); + $color[4] = $this->clamp($alpha / 100.0); + return $color; + } + + protected function lib_percentage($arg) { + $num = $this->assertNumber($arg); + return array("number", $num*100, "%"); + } + + // mixes two colors by weight + // mix(@color1, @color2, [@weight: 50%]); + // http://sass-lang.com/docs/yardoc/Sass/Script/Functions.html#mix-instance_method + protected function lib_mix($args) { + if ($args[0] != "list" || count($args[2]) < 2) + $this->throwError("mix expects (color1, color2, weight)"); + + list($first, $second) = $args[2]; + $first = $this->assertColor($first); + $second = $this->assertColor($second); + + $first_a = $this->lib_alpha($first); + $second_a = $this->lib_alpha($second); + + if (isset($args[2][2])) { + $weight = $args[2][2][1] / 100.0; + } else { + $weight = 0.5; + } + + $w = $weight * 2 - 1; + $a = $first_a - $second_a; + + $w1 = (($w * $a == -1 ? $w : ($w + $a)/(1 + $w * $a)) + 1) / 2.0; + $w2 = 1.0 - $w1; + + $new = array('color', + $w1 * $first[1] + $w2 * $second[1], + $w1 * $first[2] + $w2 * $second[2], + $w1 * $first[3] + $w2 * $second[3], + ); + + if ($first_a != 1.0 || $second_a != 1.0) { + $new[] = $first_a * $weight + $second_a * ($weight - 1); + } + + return $this->fixColor($new); + } + + protected function lib_contrast($args) { + if ($args[0] != 'list' || count($args[2]) < 3) { + return array(array('color', 0, 0, 0), 0); + } + + list($inputColor, $darkColor, $lightColor) = $args[2]; + + $inputColor = $this->assertColor($inputColor); + $darkColor = $this->assertColor($darkColor); + $lightColor = $this->assertColor($lightColor); + $hsl = $this->toHSL($inputColor); + + if ($hsl[3] > 50) { + return $darkColor; + } + + return $lightColor; + } + + protected function assertColor($value, $error = "expected color value") { + $color = $this->coerceColor($value); + if (is_null($color)) $this->throwError($error); + return $color; + } + + protected function assertNumber($value, $error = "expecting number") { + if ($value[0] == "number") return $value[1]; + $this->throwError($error); + } + + protected function assertArgs($value, $expectedArgs, $name="") { + if ($expectedArgs == 1) { + return $value; + } else { + if ($value[0] !== "list" || $value[1] != ",") $this->throwError("expecting list"); + $values = $value[2]; + $numValues = count($values); + if ($expectedArgs != $numValues) { + if ($name) { + $name = $name . ": "; + } + + $this->throwError("${name}expecting $expectedArgs arguments, got $numValues"); + } + + return $values; + } + } + + protected function toHSL($color) { + if ($color[0] == 'hsl') return $color; + + $r = $color[1] / 255; + $g = $color[2] / 255; + $b = $color[3] / 255; + + $min = min($r, $g, $b); + $max = max($r, $g, $b); + + $L = ($min + $max) / 2; + if ($min == $max) { + $S = $H = 0; + } else { + if ($L < 0.5) + $S = ($max - $min)/($max + $min); + else + $S = ($max - $min)/(2.0 - $max - $min); + + if ($r == $max) $H = ($g - $b)/($max - $min); + elseif ($g == $max) $H = 2.0 + ($b - $r)/($max - $min); + elseif ($b == $max) $H = 4.0 + ($r - $g)/($max - $min); + + } + + $out = array('hsl', + ($H < 0 ? $H + 6 : $H)*60, + $S*100, + $L*100, + ); + + if (count($color) > 4) $out[] = $color[4]; // copy alpha + return $out; + } + + protected function toRGB_helper($comp, $temp1, $temp2) { + if ($comp < 0) $comp += 1.0; + elseif ($comp > 1) $comp -= 1.0; + + if (6 * $comp < 1) return $temp1 + ($temp2 - $temp1) * 6 * $comp; + if (2 * $comp < 1) return $temp2; + if (3 * $comp < 2) return $temp1 + ($temp2 - $temp1)*((2/3) - $comp) * 6; + + return $temp1; + } + + /** + * Converts a hsl array into a color value in rgb. + * Expects H to be in range of 0 to 360, S and L in 0 to 100 + */ + protected function toRGB($color) { + if ($color[0] == 'color') return $color; + + $H = $color[1] / 360; + $S = $color[2] / 100; + $L = $color[3] / 100; + + if ($S == 0) { + $r = $g = $b = $L; + } else { + $temp2 = $L < 0.5 ? + $L*(1.0 + $S) : + $L + $S - $L * $S; + + $temp1 = 2.0 * $L - $temp2; + + $r = $this->toRGB_helper($H + 1/3, $temp1, $temp2); + $g = $this->toRGB_helper($H, $temp1, $temp2); + $b = $this->toRGB_helper($H - 1/3, $temp1, $temp2); + } + + // $out = array('color', round($r*255), round($g*255), round($b*255)); + $out = array('color', $r*255, $g*255, $b*255); + if (count($color) > 4) $out[] = $color[4]; // copy alpha + return $out; + } + + protected function clamp($v, $max = 1, $min = 0) { + return min($max, max($min, $v)); + } + + /** + * Convert the rgb, rgba, hsl color literals of function type + * as returned by the parser into values of color type. + */ + protected function funcToColor($func) { + $fname = $func[1]; + if ($func[2][0] != 'list') return false; // need a list of arguments + $rawComponents = $func[2][2]; + + if ($fname == 'hsl' || $fname == 'hsla') { + $hsl = array('hsl'); + $i = 0; + foreach ($rawComponents as $c) { + $val = $this->reduce($c); + $val = isset($val[1]) ? floatval($val[1]) : 0; + + if ($i == 0) $clamp = 360; + elseif ($i < 3) $clamp = 100; + else $clamp = 1; + + $hsl[] = $this->clamp($val, $clamp); + $i++; + } + + while (count($hsl) < 4) $hsl[] = 0; + return $this->toRGB($hsl); + + } elseif ($fname == 'rgb' || $fname == 'rgba') { + $components = array(); + $i = 1; + foreach ($rawComponents as $c) { + $c = $this->reduce($c); + if ($i < 4) { + if ($c[0] == "number" && $c[2] == "%") { + $components[] = 255 * ($c[1] / 100); + } else { + $components[] = floatval($c[1]); + } + } elseif ($i == 4) { + if ($c[0] == "number" && $c[2] == "%") { + $components[] = 1.0 * ($c[1] / 100); + } else { + $components[] = floatval($c[1]); + } + } else break; + + $i++; + } + while (count($components) < 3) $components[] = 0; + array_unshift($components, 'color'); + return $this->fixColor($components); + } + + return false; + } + + protected function reduce($value, $forExpression = false) { + switch ($value[0]) { + case "interpolate": + $reduced = $this->reduce($value[1]); + $var = $this->compileValue($reduced); + $res = $this->reduce(array("variable", $this->vPrefix . $var)); + + if ($res[0] == "raw_color") { + $res = $this->coerceColor($res); + } + + if (empty($value[2])) $res = $this->lib_e($res); + + return $res; + case "variable": + $key = $value[1]; + if (is_array($key)) { + $key = $this->reduce($key); + $key = $this->vPrefix . $this->compileValue($this->lib_e($key)); + } + + $seen =& $this->env->seenNames; + + if (!empty($seen[$key])) { + $this->throwError("infinite loop detected: $key"); + } + + $seen[$key] = true; + $out = $this->reduce($this->get($key, self::$defaultValue)); + $seen[$key] = false; + return $out; + case "list": + foreach ($value[2] as &$item) { + $item = $this->reduce($item, $forExpression); + } + return $value; + case "expression": + return $this->evaluate($value); + case "string": + foreach ($value[2] as &$part) { + if (is_array($part)) { + $strip = $part[0] == "variable"; + $part = $this->reduce($part); + if ($strip) $part = $this->lib_e($part); + } + } + return $value; + case "escape": + list(,$inner) = $value; + return $this->lib_e($this->reduce($inner)); + case "function": + $color = $this->funcToColor($value); + if ($color) return $color; + + list(, $name, $args) = $value; + if ($name == "%") $name = "_sprintf"; + $f = isset($this->libFunctions[$name]) ? + $this->libFunctions[$name] : array($this, 'lib_'.$name); + + if (is_callable($f)) { + if ($args[0] == 'list') + $args = self::compressList($args[2], $args[1]); + + $ret = call_user_func($f, $this->reduce($args, true), $this); + + if (is_null($ret)) { + return array("string", "", array( + $name, "(", $args, ")" + )); + } + + // convert to a typed value if the result is a php primitive + if (is_numeric($ret)) $ret = array('number', $ret, ""); + elseif (!is_array($ret)) $ret = array('keyword', $ret); + + return $ret; + } + + // plain function, reduce args + $value[2] = $this->reduce($value[2]); + return $value; + case "unary": + list(, $op, $exp) = $value; + $exp = $this->reduce($exp); + + if ($exp[0] == "number") { + switch ($op) { + case "+": + return $exp; + case "-": + $exp[1] *= -1; + return $exp; + } + } + return array("string", "", array($op, $exp)); + } + + if ($forExpression) { + switch ($value[0]) { + case "keyword": + if ($color = $this->coerceColor($value)) { + return $color; + } + break; + case "raw_color": + return $this->coerceColor($value); + } + } + + return $value; + } + + + // coerce a value for use in color operation + protected function coerceColor($value) { + switch($value[0]) { + case 'color': return $value; + case 'raw_color': + $c = array("color", 0, 0, 0); + $colorStr = substr($value[1], 1); + $num = hexdec($colorStr); + $width = strlen($colorStr) == 3 ? 16 : 256; + + for ($i = 3; $i > 0; $i--) { // 3 2 1 + $t = $num % $width; + $num /= $width; + + $c[$i] = $t * (256/$width) + $t * floor(16/$width); + } + + return $c; + case 'keyword': + $name = $value[1]; + if (isset(self::$cssColors[$name])) { + $rgba = explode(',', self::$cssColors[$name]); + + if(isset($rgba[3])) + return array('color', $rgba[0], $rgba[1], $rgba[2], $rgba[3]); + + return array('color', $rgba[0], $rgba[1], $rgba[2]); + } + return null; + } + } + + // make something string like into a string + protected function coerceString($value) { + switch ($value[0]) { + case "string": + return $value; + case "keyword": + return array("string", "", array($value[1])); + } + return null; + } + + // turn list of length 1 into value type + protected function flattenList($value) { + if ($value[0] == "list" && count($value[2]) == 1) { + return $this->flattenList($value[2][0]); + } + return $value; + } + + protected function toBool($a) { + if ($a) return self::$TRUE; + else return self::$FALSE; + } + + // evaluate an expression + protected function evaluate($exp) { + list(, $op, $left, $right, $whiteBefore, $whiteAfter) = $exp; + + $left = $this->reduce($left, true); + $right = $this->reduce($right, true); + + if ($leftColor = $this->coerceColor($left)) { + $left = $leftColor; + } + + if ($rightColor = $this->coerceColor($right)) { + $right = $rightColor; + } + + $ltype = $left[0]; + $rtype = $right[0]; + + // operators that work on all types + if ($op == "and") { + return $this->toBool($left == self::$TRUE && $right == self::$TRUE); + } + + if ($op == "=") { + return $this->toBool($this->eq($left, $right) ); + } + + if ($op == "+" && !is_null($str = $this->stringConcatenate($left, $right))) { + return $str; + } + + // type based operators + $fname = "op_${ltype}_${rtype}"; + if (is_callable(array($this, $fname))) { + $out = $this->$fname($op, $left, $right); + if (!is_null($out)) return $out; + } + + // make the expression look it did before being parsed + $paddedOp = $op; + if ($whiteBefore) $paddedOp = " " . $paddedOp; + if ($whiteAfter) $paddedOp .= " "; + + return array("string", "", array($left, $paddedOp, $right)); + } + + protected function stringConcatenate($left, $right) { + if ($strLeft = $this->coerceString($left)) { + if ($right[0] == "string") { + $right[1] = ""; + } + $strLeft[2][] = $right; + return $strLeft; + } + + if ($strRight = $this->coerceString($right)) { + array_unshift($strRight[2], $left); + return $strRight; + } + } + + + // make sure a color's components don't go out of bounds + protected function fixColor($c) { + foreach (range(1, 3) as $i) { + if ($c[$i] < 0) $c[$i] = 0; + if ($c[$i] > 255) $c[$i] = 255; + } + + return $c; + } + + protected function op_number_color($op, $lft, $rgt) { + if ($op == '+' || $op == '*') { + return $this->op_color_number($op, $rgt, $lft); + } + } + + protected function op_color_number($op, $lft, $rgt) { + if ($rgt[0] == '%') $rgt[1] /= 100; + + return $this->op_color_color($op, $lft, + array_fill(1, count($lft) - 1, $rgt[1])); + } + + protected function op_color_color($op, $left, $right) { + $out = array('color'); + $max = count($left) > count($right) ? count($left) : count($right); + foreach (range(1, $max - 1) as $i) { + $lval = isset($left[$i]) ? $left[$i] : 0; + $rval = isset($right[$i]) ? $right[$i] : 0; + switch ($op) { + case '+': + $out[] = $lval + $rval; + break; + case '-': + $out[] = $lval - $rval; + break; + case '*': + $out[] = $lval * $rval; + break; + case '%': + $out[] = $lval % $rval; + break; + case '/': + if ($rval == 0) $this->throwError("evaluate error: can't divide by zero"); + $out[] = $lval / $rval; + break; + default: + $this->throwError('evaluate error: color op number failed on op '.$op); + } + } + return $this->fixColor($out); + } + + function lib_red($color){ + $color = $this->coerceColor($color); + if (is_null($color)) { + $this->throwError('color expected for red()'); + } + + return $color[1]; + } + + function lib_green($color){ + $color = $this->coerceColor($color); + if (is_null($color)) { + $this->throwError('color expected for green()'); + } + + return $color[2]; + } + + function lib_blue($color){ + $color = $this->coerceColor($color); + if (is_null($color)) { + $this->throwError('color expected for blue()'); + } + + return $color[3]; + } + + + // operator on two numbers + protected function op_number_number($op, $left, $right) { + $unit = empty($left[2]) ? $right[2] : $left[2]; + + $value = 0; + switch ($op) { + case '+': + $value = $left[1] + $right[1]; + break; + case '*': + $value = $left[1] * $right[1]; + break; + case '-': + $value = $left[1] - $right[1]; + break; + case '%': + $value = $left[1] % $right[1]; + break; + case '/': + if ($right[1] == 0) $this->throwError('parse error: divide by zero'); + $value = $left[1] / $right[1]; + break; + case '<': + return $this->toBool($left[1] < $right[1]); + case '>': + return $this->toBool($left[1] > $right[1]); + case '>=': + return $this->toBool($left[1] >= $right[1]); + case '=<': + return $this->toBool($left[1] <= $right[1]); + default: + $this->throwError('parse error: unknown number operator: '.$op); + } + + return array("number", $value, $unit); + } + + + /* environment functions */ + + protected function makeOutputBlock($type, $selectors = null) { + $b = new stdclass; + $b->lines = array(); + $b->children = array(); + $b->selectors = $selectors; + $b->type = $type; + $b->parent = $this->scope; + return $b; + } + + // the state of execution + protected function pushEnv($block = null) { + $e = new stdclass; + $e->parent = $this->env; + $e->store = array(); + $e->block = $block; + + $this->env = $e; + return $e; + } + + // pop something off the stack + protected function popEnv() { + $old = $this->env; + $this->env = $this->env->parent; + return $old; + } + + // set something in the current env + protected function set($name, $value) { + $this->env->store[$name] = $value; + } + + + // get the highest occurrence entry for a name + protected function get($name, $default=null) { + $current = $this->env; + + $isArguments = $name == $this->vPrefix . 'arguments'; + while ($current) { + if ($isArguments && isset($current->arguments)) { + return array('list', ' ', $current->arguments); + } + + if (isset($current->store[$name])) + return $current->store[$name]; + else { + $current = isset($current->storeParent) ? + $current->storeParent : $current->parent; + } + } + + return $default; + } + + // inject array of unparsed strings into environment as variables + protected function injectVariables($args) { + $this->pushEnv(); + $parser = new lessc_parser($this, __METHOD__); + foreach ($args as $name => $strValue) { + if ($name{0} != '@') $name = '@'.$name; + $parser->count = 0; + $parser->buffer = (string)$strValue; + if (!$parser->propertyValue($value)) { + throw new Exception("failed to parse passed in variable $name: $strValue"); + } + + $this->set($name, $value); + } + } + + /** + * Initialize any static state, can initialize parser for a file + * $opts isn't used yet + */ + public function __construct($fname = null) { + if ($fname !== null) { + // used for deprecated parse method + $this->_parseFile = $fname; + } + } + + public function compile($string, $name = null) { + $locale = setlocale(LC_NUMERIC, 0); + setlocale(LC_NUMERIC, "C"); + + $this->parser = $this->makeParser($name); + $root = $this->parser->parse($string); + + $this->env = null; + $this->scope = null; + + $this->formatter = $this->newFormatter(); + + if (!empty($this->registeredVars)) { + $this->injectVariables($this->registeredVars); + } + + $this->sourceParser = $this->parser; // used for error messages + $this->compileBlock($root); + + ob_start(); + $this->formatter->block($this->scope); + $out = ob_get_clean(); + setlocale(LC_NUMERIC, $locale); + return $out; + } + + public function compileFile($fname, $outFname = null) { + if (!is_readable($fname)) { + throw new Exception('load error: failed to find '.$fname); + } + + $pi = pathinfo($fname); + + $oldImport = $this->importDir; + + $this->importDir = (array)$this->importDir; + $this->importDir[] = $pi['dirname'].'/'; + + $this->addParsedFile($fname); + + $out = $this->compile(file_get_contents($fname), $fname); + + $this->importDir = $oldImport; + + if ($outFname !== null) { + return file_put_contents($outFname, $out); + } + + return $out; + } + + // compile only if changed input has changed or output doesn't exist + public function checkedCompile($in, $out) { + if (!is_file($out) || filemtime($in) > filemtime($out)) { + $this->compileFile($in, $out); + return true; + } + return false; + } + + /** + * Execute lessphp on a .less file or a lessphp cache structure + * + * The lessphp cache structure contains information about a specific + * less file having been parsed. It can be used as a hint for future + * calls to determine whether or not a rebuild is required. + * + * The cache structure contains two important keys that may be used + * externally: + * + * compiled: The final compiled CSS + * updated: The time (in seconds) the CSS was last compiled + * + * The cache structure is a plain-ol' PHP associative array and can + * be serialized and unserialized without a hitch. + * + * @param mixed $in Input + * @param bool $force Force rebuild? + * @return array lessphp cache structure + */ + public function cachedCompile($in, $force = false) { + // assume no root + $root = null; + + if (is_string($in)) { + $root = $in; + } elseif (is_array($in) and isset($in['root'])) { + if ($force or ! isset($in['files'])) { + // If we are forcing a recompile or if for some reason the + // structure does not contain any file information we should + // specify the root to trigger a rebuild. + $root = $in['root']; + } elseif (isset($in['files']) and is_array($in['files'])) { + foreach ($in['files'] as $fname => $ftime ) { + if (!file_exists($fname) or filemtime($fname) > $ftime) { + // One of the files we knew about previously has changed + // so we should look at our incoming root again. + $root = $in['root']; + break; + } + } + } + } else { + // TODO: Throw an exception? We got neither a string nor something + // that looks like a compatible lessphp cache structure. + return null; + } + + if ($root !== null) { + // If we have a root value which means we should rebuild. + $out = array(); + $out['root'] = $root; + $out['compiled'] = $this->compileFile($root); + $out['files'] = $this->allParsedFiles(); + $out['updated'] = time(); + return $out; + } else { + // No changes, pass back the structure + // we were given initially. + return $in; + } + + } + + // parse and compile buffer + // This is deprecated + public function parse($str = null, $initialVariables = null) { + if (is_array($str)) { + $initialVariables = $str; + $str = null; + } + + $oldVars = $this->registeredVars; + if ($initialVariables !== null) { + $this->setVariables($initialVariables); + } + + if ($str == null) { + if (empty($this->_parseFile)) { + throw new exception("nothing to parse"); + } + + $out = $this->compileFile($this->_parseFile); + } else { + $out = $this->compile($str); + } + + $this->registeredVars = $oldVars; + return $out; + } + + protected function makeParser($name) { + $parser = new lessc_parser($this, $name); + $parser->writeComments = $this->preserveComments; + + return $parser; + } + + public function setFormatter($name) { + $this->formatterName = $name; + } + + protected function newFormatter() { + $className = "lessc_formatter_lessjs"; + if (!empty($this->formatterName)) { + if (!is_string($this->formatterName)) + return $this->formatterName; + $className = "lessc_formatter_$this->formatterName"; + } + + return new $className; + } + + public function setPreserveComments($preserve) { + $this->preserveComments = $preserve; + } + + public function registerFunction($name, $func) { + $this->libFunctions[$name] = $func; + } + + public function unregisterFunction($name) { + unset($this->libFunctions[$name]); + } + + public function setVariables($variables) { + $this->registeredVars = array_merge($this->registeredVars, $variables); + } + + public function unsetVariable($name) { + unset($this->registeredVars[$name]); + } + + public function setImportDir($dirs) { + $this->importDir = (array)$dirs; + } + + public function addImportDir($dir) { + $this->importDir = (array)$this->importDir; + $this->importDir[] = $dir; + } + + public function allParsedFiles() { + return $this->allParsedFiles; + } + + protected function addParsedFile($file) { + $this->allParsedFiles[realpath($file)] = filemtime($file); + } + + /** + * Uses the current value of $this->count to show line and line number + */ + protected function throwError($msg = null) { + if ($this->sourceLoc >= 0) { + $this->sourceParser->throwError($msg, $this->sourceLoc); + } + throw new exception($msg); + } + + // compile file $in to file $out if $in is newer than $out + // returns true when it compiles, false otherwise + public static function ccompile($in, $out, $less = null) { + if ($less === null) { + $less = new self; + } + return $less->checkedCompile($in, $out); + } + + public static function cexecute($in, $force = false, $less = null) { + if ($less === null) { + $less = new self; + } + return $less->cachedCompile($in, $force); + } + + static protected $cssColors = array( + 'aliceblue' => '240,248,255', + 'antiquewhite' => '250,235,215', + 'aqua' => '0,255,255', + 'aquamarine' => '127,255,212', + 'azure' => '240,255,255', + 'beige' => '245,245,220', + 'bisque' => '255,228,196', + 'black' => '0,0,0', + 'blanchedalmond' => '255,235,205', + 'blue' => '0,0,255', + 'blueviolet' => '138,43,226', + 'brown' => '165,42,42', + 'burlywood' => '222,184,135', + 'cadetblue' => '95,158,160', + 'chartreuse' => '127,255,0', + 'chocolate' => '210,105,30', + 'coral' => '255,127,80', + 'cornflowerblue' => '100,149,237', + 'cornsilk' => '255,248,220', + 'crimson' => '220,20,60', + 'cyan' => '0,255,255', + 'darkblue' => '0,0,139', + 'darkcyan' => '0,139,139', + 'darkgoldenrod' => '184,134,11', + 'darkgray' => '169,169,169', + 'darkgreen' => '0,100,0', + 'darkgrey' => '169,169,169', + 'darkkhaki' => '189,183,107', + 'darkmagenta' => '139,0,139', + 'darkolivegreen' => '85,107,47', + 'darkorange' => '255,140,0', + 'darkorchid' => '153,50,204', + 'darkred' => '139,0,0', + 'darksalmon' => '233,150,122', + 'darkseagreen' => '143,188,143', + 'darkslateblue' => '72,61,139', + 'darkslategray' => '47,79,79', + 'darkslategrey' => '47,79,79', + 'darkturquoise' => '0,206,209', + 'darkviolet' => '148,0,211', + 'deeppink' => '255,20,147', + 'deepskyblue' => '0,191,255', + 'dimgray' => '105,105,105', + 'dimgrey' => '105,105,105', + 'dodgerblue' => '30,144,255', + 'firebrick' => '178,34,34', + 'floralwhite' => '255,250,240', + 'forestgreen' => '34,139,34', + 'fuchsia' => '255,0,255', + 'gainsboro' => '220,220,220', + 'ghostwhite' => '248,248,255', + 'gold' => '255,215,0', + 'goldenrod' => '218,165,32', + 'gray' => '128,128,128', + 'green' => '0,128,0', + 'greenyellow' => '173,255,47', + 'grey' => '128,128,128', + 'honeydew' => '240,255,240', + 'hotpink' => '255,105,180', + 'indianred' => '205,92,92', + 'indigo' => '75,0,130', + 'ivory' => '255,255,240', + 'khaki' => '240,230,140', + 'lavender' => '230,230,250', + 'lavenderblush' => '255,240,245', + 'lawngreen' => '124,252,0', + 'lemonchiffon' => '255,250,205', + 'lightblue' => '173,216,230', + 'lightcoral' => '240,128,128', + 'lightcyan' => '224,255,255', + 'lightgoldenrodyellow' => '250,250,210', + 'lightgray' => '211,211,211', + 'lightgreen' => '144,238,144', + 'lightgrey' => '211,211,211', + 'lightpink' => '255,182,193', + 'lightsalmon' => '255,160,122', + 'lightseagreen' => '32,178,170', + 'lightskyblue' => '135,206,250', + 'lightslategray' => '119,136,153', + 'lightslategrey' => '119,136,153', + 'lightsteelblue' => '176,196,222', + 'lightyellow' => '255,255,224', + 'lime' => '0,255,0', + 'limegreen' => '50,205,50', + 'linen' => '250,240,230', + 'magenta' => '255,0,255', + 'maroon' => '128,0,0', + 'mediumaquamarine' => '102,205,170', + 'mediumblue' => '0,0,205', + 'mediumorchid' => '186,85,211', + 'mediumpurple' => '147,112,219', + 'mediumseagreen' => '60,179,113', + 'mediumslateblue' => '123,104,238', + 'mediumspringgreen' => '0,250,154', + 'mediumturquoise' => '72,209,204', + 'mediumvioletred' => '199,21,133', + 'midnightblue' => '25,25,112', + 'mintcream' => '245,255,250', + 'mistyrose' => '255,228,225', + 'moccasin' => '255,228,181', + 'navajowhite' => '255,222,173', + 'navy' => '0,0,128', + 'oldlace' => '253,245,230', + 'olive' => '128,128,0', + 'olivedrab' => '107,142,35', + 'orange' => '255,165,0', + 'orangered' => '255,69,0', + 'orchid' => '218,112,214', + 'palegoldenrod' => '238,232,170', + 'palegreen' => '152,251,152', + 'paleturquoise' => '175,238,238', + 'palevioletred' => '219,112,147', + 'papayawhip' => '255,239,213', + 'peachpuff' => '255,218,185', + 'peru' => '205,133,63', + 'pink' => '255,192,203', + 'plum' => '221,160,221', + 'powderblue' => '176,224,230', + 'purple' => '128,0,128', + 'red' => '255,0,0', + 'rosybrown' => '188,143,143', + 'royalblue' => '65,105,225', + 'saddlebrown' => '139,69,19', + 'salmon' => '250,128,114', + 'sandybrown' => '244,164,96', + 'seagreen' => '46,139,87', + 'seashell' => '255,245,238', + 'sienna' => '160,82,45', + 'silver' => '192,192,192', + 'skyblue' => '135,206,235', + 'slateblue' => '106,90,205', + 'slategray' => '112,128,144', + 'slategrey' => '112,128,144', + 'snow' => '255,250,250', + 'springgreen' => '0,255,127', + 'steelblue' => '70,130,180', + 'tan' => '210,180,140', + 'teal' => '0,128,128', + 'thistle' => '216,191,216', + 'tomato' => '255,99,71', + 'transparent' => '0,0,0,0', + 'turquoise' => '64,224,208', + 'violet' => '238,130,238', + 'wheat' => '245,222,179', + 'white' => '255,255,255', + 'whitesmoke' => '245,245,245', + 'yellow' => '255,255,0', + 'yellowgreen' => '154,205,50' + ); +} + +// responsible for taking a string of LESS code and converting it into a +// syntax tree +class lessc_parser { + static protected $nextBlockId = 0; // used to uniquely identify blocks + + static protected $precedence = array( + '=<' => 0, + '>=' => 0, + '=' => 0, + '<' => 0, + '>' => 0, + + '+' => 1, + '-' => 1, + '*' => 2, + '/' => 2, + '%' => 2, + ); + + static protected $whitePattern; + static protected $commentMulti; + + static protected $commentSingle = "//"; + static protected $commentMultiLeft = "/*"; + static protected $commentMultiRight = "*/"; + + // regex string to match any of the operators + static protected $operatorString; + + // these properties will supress division unless it's inside parenthases + static protected $supressDivisionProps = + array('/border-radius$/i', '/^font$/i'); + + protected $blockDirectives = array("font-face", "keyframes", "page", "-moz-document", "viewport", "-moz-viewport", "-o-viewport", "-ms-viewport"); + protected $lineDirectives = array("charset"); + + /** + * if we are in parens we can be more liberal with whitespace around + * operators because it must evaluate to a single value and thus is less + * ambiguous. + * + * Consider: + * property1: 10 -5; // is two numbers, 10 and -5 + * property2: (10 -5); // should evaluate to 5 + */ + protected $inParens = false; + + // caches preg escaped literals + static protected $literalCache = array(); + + public function __construct($lessc, $sourceName = null) { + $this->eatWhiteDefault = true; + // reference to less needed for vPrefix, mPrefix, and parentSelector + $this->lessc = $lessc; + + $this->sourceName = $sourceName; // name used for error messages + + $this->writeComments = false; + + if (!self::$operatorString) { + self::$operatorString = + '('.implode('|', array_map(array('lessc', 'preg_quote'), + array_keys(self::$precedence))).')'; + + $commentSingle = lessc::preg_quote(self::$commentSingle); + $commentMultiLeft = lessc::preg_quote(self::$commentMultiLeft); + $commentMultiRight = lessc::preg_quote(self::$commentMultiRight); + + self::$commentMulti = $commentMultiLeft.'.*?'.$commentMultiRight; + self::$whitePattern = '/'.$commentSingle.'[^\n]*\s*|('.self::$commentMulti.')\s*|\s+/Ais'; + } + } + + public function parse($buffer) { + $this->count = 0; + $this->line = 1; + + $this->env = null; // block stack + $this->buffer = $this->writeComments ? $buffer : $this->removeComments($buffer); + $this->pushSpecialBlock("root"); + $this->eatWhiteDefault = true; + $this->seenComments = array(); + + // trim whitespace on head + // if (preg_match('/^\s+/', $this->buffer, $m)) { + // $this->line += substr_count($m[0], "\n"); + // $this->buffer = ltrim($this->buffer); + // } + $this->whitespace(); + + // parse the entire file + $lastCount = $this->count; + while (false !== $this->parseChunk()); + + if ($this->count != strlen($this->buffer)) + $this->throwError(); + + // TODO report where the block was opened + if (!is_null($this->env->parent)) + throw new exception('parse error: unclosed block'); + + return $this->env; + } + + /** + * Parse a single chunk off the head of the buffer and append it to the + * current parse environment. + * Returns false when the buffer is empty, or when there is an error. + * + * This function is called repeatedly until the entire document is + * parsed. + * + * This parser is most similar to a recursive descent parser. Single + * functions represent discrete grammatical rules for the language, and + * they are able to capture the text that represents those rules. + * + * Consider the function lessc::keyword(). (all parse functions are + * structured the same) + * + * The function takes a single reference argument. When calling the + * function it will attempt to match a keyword on the head of the buffer. + * If it is successful, it will place the keyword in the referenced + * argument, advance the position in the buffer, and return true. If it + * fails then it won't advance the buffer and it will return false. + * + * All of these parse functions are powered by lessc::match(), which behaves + * the same way, but takes a literal regular expression. Sometimes it is + * more convenient to use match instead of creating a new function. + * + * Because of the format of the functions, to parse an entire string of + * grammatical rules, you can chain them together using &&. + * + * But, if some of the rules in the chain succeed before one fails, then + * the buffer position will be left at an invalid state. In order to + * avoid this, lessc::seek() is used to remember and set buffer positions. + * + * Before parsing a chain, use $s = $this->seek() to remember the current + * position into $s. Then if a chain fails, use $this->seek($s) to + * go back where we started. + */ + protected function parseChunk() { + if (empty($this->buffer)) return false; + $s = $this->seek(); + + // setting a property + if ($this->keyword($key) && $this->assign() && + $this->propertyValue($value, $key) && $this->end()) + { + $this->append(array('assign', $key, $value), $s); + return true; + } else { + $this->seek($s); + } + + + // look for special css blocks + if ($this->literal('@', false)) { + $this->count--; + + // media + if ($this->literal('@media')) { + if (($this->mediaQueryList($mediaQueries) || true) + && $this->literal('{')) + { + $media = $this->pushSpecialBlock("media"); + $media->queries = is_null($mediaQueries) ? array() : $mediaQueries; + return true; + } else { + $this->seek($s); + return false; + } + } + + if ($this->literal("@", false) && $this->keyword($dirName)) { + if ($this->isDirective($dirName, $this->blockDirectives)) { + if (($this->openString("{", $dirValue, null, array(";")) || true) && + $this->literal("{")) + { + $dir = $this->pushSpecialBlock("directive"); + $dir->name = $dirName; + if (isset($dirValue)) $dir->value = $dirValue; + return true; + } + } elseif ($this->isDirective($dirName, $this->lineDirectives)) { + if ($this->propertyValue($dirValue) && $this->end()) { + $this->append(array("directive", $dirName, $dirValue)); + return true; + } + } + } + + $this->seek($s); + } + + // setting a variable + if ($this->variable($var) && $this->assign() && + $this->propertyValue($value) && $this->end()) + { + $this->append(array('assign', $var, $value), $s); + return true; + } else { + $this->seek($s); + } + + if ($this->import($importValue)) { + $this->append($importValue, $s); + return true; + } + + // opening parametric mixin + if ($this->tag($tag, true) && $this->argumentDef($args, $isVararg) && + ($this->guards($guards) || true) && + $this->literal('{')) + { + $block = $this->pushBlock($this->fixTags(array($tag))); + $block->args = $args; + $block->isVararg = $isVararg; + if (!empty($guards)) $block->guards = $guards; + return true; + } else { + $this->seek($s); + } + + // opening a simple block + if ($this->tags($tags) && $this->literal('{')) { + $tags = $this->fixTags($tags); + $this->pushBlock($tags); + return true; + } else { + $this->seek($s); + } + + // closing a block + if ($this->literal('}', false)) { + try { + $block = $this->pop(); + } catch (exception $e) { + $this->seek($s); + $this->throwError($e->getMessage()); + } + + $hidden = false; + if (is_null($block->type)) { + $hidden = true; + if (!isset($block->args)) { + foreach ($block->tags as $tag) { + if (!is_string($tag) || $tag{0} != $this->lessc->mPrefix) { + $hidden = false; + break; + } + } + } + + foreach ($block->tags as $tag) { + if (is_string($tag)) { + $this->env->children[$tag][] = $block; + } + } + } + + if (!$hidden) { + $this->append(array('block', $block), $s); + } + + // this is done here so comments aren't bundled into he block that + // was just closed + $this->whitespace(); + return true; + } + + // mixin + if ($this->mixinTags($tags) && + ($this->argumentDef($argv, $isVararg) || true) && + ($this->keyword($suffix) || true) && $this->end()) + { + $tags = $this->fixTags($tags); + $this->append(array('mixin', $tags, $argv, $suffix), $s); + return true; + } else { + $this->seek($s); + } + + // spare ; + if ($this->literal(';')) return true; + + return false; // got nothing, throw error + } + + protected function isDirective($dirname, $directives) { + // TODO: cache pattern in parser + $pattern = implode("|", + array_map(array("lessc", "preg_quote"), $directives)); + $pattern = '/^(-[a-z-]+-)?(' . $pattern . ')$/i'; + + return preg_match($pattern, $dirname); + } + + protected function fixTags($tags) { + // move @ tags out of variable namespace + foreach ($tags as &$tag) { + if ($tag{0} == $this->lessc->vPrefix) + $tag[0] = $this->lessc->mPrefix; + } + return $tags; + } + + // a list of expressions + protected function expressionList(&$exps) { + $values = array(); + + while ($this->expression($exp)) { + $values[] = $exp; + } + + if (count($values) == 0) return false; + + $exps = lessc::compressList($values, ' '); + return true; + } + + /** + * Attempt to consume an expression. + * @link http://en.wikipedia.org/wiki/Operator-precedence_parser#Pseudo-code + */ + protected function expression(&$out) { + if ($this->value($lhs)) { + $out = $this->expHelper($lhs, 0); + + // look for / shorthand + if (!empty($this->env->supressedDivision)) { + unset($this->env->supressedDivision); + $s = $this->seek(); + if ($this->literal("/") && $this->value($rhs)) { + $out = array("list", "", + array($out, array("keyword", "/"), $rhs)); + } else { + $this->seek($s); + } + } + + return true; + } + return false; + } + + /** + * recursively parse infix equation with $lhs at precedence $minP + */ + protected function expHelper($lhs, $minP) { + $this->inExp = true; + $ss = $this->seek(); + + while (true) { + $whiteBefore = isset($this->buffer[$this->count - 1]) && + ctype_space($this->buffer[$this->count - 1]); + + // If there is whitespace before the operator, then we require + // whitespace after the operator for it to be an expression + $needWhite = $whiteBefore && !$this->inParens; + + if ($this->match(self::$operatorString.($needWhite ? '\s' : ''), $m) && self::$precedence[$m[1]] >= $minP) { + if (!$this->inParens && isset($this->env->currentProperty) && $m[1] == "/" && empty($this->env->supressedDivision)) { + foreach (self::$supressDivisionProps as $pattern) { + if (preg_match($pattern, $this->env->currentProperty)) { + $this->env->supressedDivision = true; + break 2; + } + } + } + + + $whiteAfter = isset($this->buffer[$this->count - 1]) && + ctype_space($this->buffer[$this->count - 1]); + + if (!$this->value($rhs)) break; + + // peek for next operator to see what to do with rhs + if ($this->peek(self::$operatorString, $next) && self::$precedence[$next[1]] > self::$precedence[$m[1]]) { + $rhs = $this->expHelper($rhs, self::$precedence[$next[1]]); + } + + $lhs = array('expression', $m[1], $lhs, $rhs, $whiteBefore, $whiteAfter); + $ss = $this->seek(); + + continue; + } + + break; + } + + $this->seek($ss); + + return $lhs; + } + + // consume a list of values for a property + public function propertyValue(&$value, $keyName = null) { + $values = array(); + + if ($keyName !== null) $this->env->currentProperty = $keyName; + + $s = null; + while ($this->expressionList($v)) { + $values[] = $v; + $s = $this->seek(); + if (!$this->literal(',')) break; + } + + if ($s) $this->seek($s); + + if ($keyName !== null) unset($this->env->currentProperty); + + if (count($values) == 0) return false; + + $value = lessc::compressList($values, ', '); + return true; + } + + protected function parenValue(&$out) { + $s = $this->seek(); + + // speed shortcut + if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] != "(") { + return false; + } + + $inParens = $this->inParens; + if ($this->literal("(") && + ($this->inParens = true) && $this->expression($exp) && + $this->literal(")")) + { + $out = $exp; + $this->inParens = $inParens; + return true; + } else { + $this->inParens = $inParens; + $this->seek($s); + } + + return false; + } + + // a single value + protected function value(&$value) { + $s = $this->seek(); + + // speed shortcut + if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] == "-") { + // negation + if ($this->literal("-", false) && + (($this->variable($inner) && $inner = array("variable", $inner)) || + $this->unit($inner) || + $this->parenValue($inner))) + { + $value = array("unary", "-", $inner); + return true; + } else { + $this->seek($s); + } + } + + if ($this->parenValue($value)) return true; + if ($this->unit($value)) return true; + if ($this->color($value)) return true; + if ($this->func($value)) return true; + if ($this->string($value)) return true; + + if ($this->keyword($word)) { + $value = array('keyword', $word); + return true; + } + + // try a variable + if ($this->variable($var)) { + $value = array('variable', $var); + return true; + } + + // unquote string (should this work on any type? + if ($this->literal("~") && $this->string($str)) { + $value = array("escape", $str); + return true; + } else { + $this->seek($s); + } + + // css hack: \0 + if ($this->literal('\\') && $this->match('([0-9]+)', $m)) { + $value = array('keyword', '\\'.$m[1]); + return true; + } else { + $this->seek($s); + } + + return false; + } + + // an import statement + protected function import(&$out) { + $s = $this->seek(); + if (!$this->literal('@import')) return false; + + // @import "something.css" media; + // @import url("something.css") media; + // @import url(something.css) media; + + if ($this->propertyValue($value)) { + $out = array("import", $value); + return true; + } + } + + protected function mediaQueryList(&$out) { + if ($this->genericList($list, "mediaQuery", ",", false)) { + $out = $list[2]; + return true; + } + return false; + } + + protected function mediaQuery(&$out) { + $s = $this->seek(); + + $expressions = null; + $parts = array(); + + if (($this->literal("only") && ($only = true) || $this->literal("not") && ($not = true) || true) && $this->keyword($mediaType)) { + $prop = array("mediaType"); + if (isset($only)) $prop[] = "only"; + if (isset($not)) $prop[] = "not"; + $prop[] = $mediaType; + $parts[] = $prop; + } else { + $this->seek($s); + } + + + if (!empty($mediaType) && !$this->literal("and")) { + // ~ + } else { + $this->genericList($expressions, "mediaExpression", "and", false); + if (is_array($expressions)) $parts = array_merge($parts, $expressions[2]); + } + + if (count($parts) == 0) { + $this->seek($s); + return false; + } + + $out = $parts; + return true; + } + + protected function mediaExpression(&$out) { + $s = $this->seek(); + $value = null; + if ($this->literal("(") && + $this->keyword($feature) && + ($this->literal(":") && $this->expression($value) || true) && + $this->literal(")")) + { + $out = array("mediaExp", $feature); + if ($value) $out[] = $value; + return true; + } elseif ($this->variable($variable)) { + $out = array('variable', $variable); + return true; + } + + $this->seek($s); + return false; + } + + // an unbounded string stopped by $end + protected function openString($end, &$out, $nestingOpen=null, $rejectStrs = null) { + $oldWhite = $this->eatWhiteDefault; + $this->eatWhiteDefault = false; + + $stop = array("'", '"', "@{", $end); + $stop = array_map(array("lessc", "preg_quote"), $stop); + // $stop[] = self::$commentMulti; + + if (!is_null($rejectStrs)) { + $stop = array_merge($stop, $rejectStrs); + } + + $patt = '(.*?)('.implode("|", $stop).')'; + + $nestingLevel = 0; + + $content = array(); + while ($this->match($patt, $m, false)) { + if (!empty($m[1])) { + $content[] = $m[1]; + if ($nestingOpen) { + $nestingLevel += substr_count($m[1], $nestingOpen); + } + } + + $tok = $m[2]; + + $this->count-= strlen($tok); + if ($tok == $end) { + if ($nestingLevel == 0) { + break; + } else { + $nestingLevel--; + } + } + + if (($tok == "'" || $tok == '"') && $this->string($str)) { + $content[] = $str; + continue; + } + + if ($tok == "@{" && $this->interpolation($inter)) { + $content[] = $inter; + continue; + } + + if (!empty($rejectStrs) && in_array($tok, $rejectStrs)) { + break; + } + + $content[] = $tok; + $this->count+= strlen($tok); + } + + $this->eatWhiteDefault = $oldWhite; + + if (count($content) == 0) return false; + + // trim the end + if (is_string(end($content))) { + $content[count($content) - 1] = rtrim(end($content)); + } + + $out = array("string", "", $content); + return true; + } + + protected function string(&$out) { + $s = $this->seek(); + if ($this->literal('"', false)) { + $delim = '"'; + } elseif ($this->literal("'", false)) { + $delim = "'"; + } else { + return false; + } + + $content = array(); + + // look for either ending delim , escape, or string interpolation + $patt = '([^\n]*?)(@\{|\\\\|' . + lessc::preg_quote($delim).')'; + + $oldWhite = $this->eatWhiteDefault; + $this->eatWhiteDefault = false; + + while ($this->match($patt, $m, false)) { + $content[] = $m[1]; + if ($m[2] == "@{") { + $this->count -= strlen($m[2]); + if ($this->interpolation($inter, false)) { + $content[] = $inter; + } else { + $this->count += strlen($m[2]); + $content[] = "@{"; // ignore it + } + } elseif ($m[2] == '\\') { + $content[] = $m[2]; + if ($this->literal($delim, false)) { + $content[] = $delim; + } + } else { + $this->count -= strlen($delim); + break; // delim + } + } + + $this->eatWhiteDefault = $oldWhite; + + if ($this->literal($delim)) { + $out = array("string", $delim, $content); + return true; + } + + $this->seek($s); + return false; + } + + protected function interpolation(&$out) { + $oldWhite = $this->eatWhiteDefault; + $this->eatWhiteDefault = true; + + $s = $this->seek(); + if ($this->literal("@{") && + $this->openString("}", $interp, null, array("'", '"', ";")) && + $this->literal("}", false)) + { + $out = array("interpolate", $interp); + $this->eatWhiteDefault = $oldWhite; + if ($this->eatWhiteDefault) $this->whitespace(); + return true; + } + + $this->eatWhiteDefault = $oldWhite; + $this->seek($s); + return false; + } + + protected function unit(&$unit) { + // speed shortcut + if (isset($this->buffer[$this->count])) { + $char = $this->buffer[$this->count]; + if (!ctype_digit($char) && $char != ".") return false; + } + + if ($this->match('([0-9]+(?:\.[0-9]*)?|\.[0-9]+)([%a-zA-Z]+)?', $m)) { + $unit = array("number", $m[1], empty($m[2]) ? "" : $m[2]); + return true; + } + return false; + } + + // a # color + protected function color(&$out) { + if ($this->match('(#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3}))', $m)) { + if (strlen($m[1]) > 7) { + $out = array("string", "", array($m[1])); + } else { + $out = array("raw_color", $m[1]); + } + return true; + } + + return false; + } + + // consume an argument definition list surrounded by () + // each argument is a variable name with optional value + // or at the end a ... or a variable named followed by ... + // arguments are separated by , unless a ; is in the list, then ; is the + // delimiter. + protected function argumentDef(&$args, &$isVararg) { + $s = $this->seek(); + if (!$this->literal('(')) return false; + + $values = array(); + $delim = ","; + $method = "expressionList"; + + $isVararg = false; + while (true) { + if ($this->literal("...")) { + $isVararg = true; + break; + } + + if ($this->$method($value)) { + if ($value[0] == "variable") { + $arg = array("arg", $value[1]); + $ss = $this->seek(); + + if ($this->assign() && $this->$method($rhs)) { + $arg[] = $rhs; + } else { + $this->seek($ss); + if ($this->literal("...")) { + $arg[0] = "rest"; + $isVararg = true; + } + } + + $values[] = $arg; + if ($isVararg) break; + continue; + } else { + $values[] = array("lit", $value); + } + } + + + if (!$this->literal($delim)) { + if ($delim == "," && $this->literal(";")) { + // found new delim, convert existing args + $delim = ";"; + $method = "propertyValue"; + + // transform arg list + if (isset($values[1])) { // 2 items + $newList = array(); + foreach ($values as $i => $arg) { + switch($arg[0]) { + case "arg": + if ($i) { + $this->throwError("Cannot mix ; and , as delimiter types"); + } + $newList[] = $arg[2]; + break; + case "lit": + $newList[] = $arg[1]; + break; + case "rest": + $this->throwError("Unexpected rest before semicolon"); + } + } + + $newList = array("list", ", ", $newList); + + switch ($values[0][0]) { + case "arg": + $newArg = array("arg", $values[0][1], $newList); + break; + case "lit": + $newArg = array("lit", $newList); + break; + } + + } elseif ($values) { // 1 item + $newArg = $values[0]; + } + + if ($newArg) { + $values = array($newArg); + } + } else { + break; + } + } + } + + if (!$this->literal(')')) { + $this->seek($s); + return false; + } + + $args = $values; + + return true; + } + + // consume a list of tags + // this accepts a hanging delimiter + protected function tags(&$tags, $simple = false, $delim = ',') { + $tags = array(); + while ($this->tag($tt, $simple)) { + $tags[] = $tt; + if (!$this->literal($delim)) break; + } + if (count($tags) == 0) return false; + + return true; + } + + // list of tags of specifying mixin path + // optionally separated by > (lazy, accepts extra >) + protected function mixinTags(&$tags) { + $s = $this->seek(); + $tags = array(); + while ($this->tag($tt, true)) { + $tags[] = $tt; + $this->literal(">"); + } + + if (count($tags) == 0) return false; + + return true; + } + + // a bracketed value (contained within in a tag definition) + protected function tagBracket(&$parts, &$hasExpression) { + // speed shortcut + if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] != "[") { + return false; + } + + $s = $this->seek(); + + $hasInterpolation = false; + + if ($this->literal("[", false)) { + $attrParts = array("["); + // keyword, string, operator + while (true) { + if ($this->literal("]", false)) { + $this->count--; + break; // get out early + } + + if ($this->match('\s+', $m)) { + $attrParts[] = " "; + continue; + } + if ($this->string($str)) { + // escape parent selector, (yuck) + foreach ($str[2] as &$chunk) { + $chunk = str_replace($this->lessc->parentSelector, "$&$", $chunk); + } + + $attrParts[] = $str; + $hasInterpolation = true; + continue; + } + + if ($this->keyword($word)) { + $attrParts[] = $word; + continue; + } + + if ($this->interpolation($inter, false)) { + $attrParts[] = $inter; + $hasInterpolation = true; + continue; + } + + // operator, handles attr namespace too + if ($this->match('[|-~\$\*\^=]+', $m)) { + $attrParts[] = $m[0]; + continue; + } + + break; + } + + if ($this->literal("]", false)) { + $attrParts[] = "]"; + foreach ($attrParts as $part) { + $parts[] = $part; + } + $hasExpression = $hasExpression || $hasInterpolation; + return true; + } + $this->seek($s); + } + + $this->seek($s); + return false; + } + + // a space separated list of selectors + protected function tag(&$tag, $simple = false) { + if ($simple) + $chars = '^@,:;{}\][>\(\) "\''; + else + $chars = '^@,;{}["\''; + + $s = $this->seek(); + + $hasExpression = false; + $parts = array(); + while ($this->tagBracket($parts, $hasExpression)); + + $oldWhite = $this->eatWhiteDefault; + $this->eatWhiteDefault = false; + + while (true) { + if ($this->match('(['.$chars.'0-9]['.$chars.']*)', $m)) { + $parts[] = $m[1]; + if ($simple) break; + + while ($this->tagBracket($parts, $hasExpression)); + continue; + } + + if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] == "@") { + if ($this->interpolation($interp)) { + $hasExpression = true; + $interp[2] = true; // don't unescape + $parts[] = $interp; + continue; + } + + if ($this->literal("@")) { + $parts[] = "@"; + continue; + } + } + + if ($this->unit($unit)) { // for keyframes + $parts[] = $unit[1]; + $parts[] = $unit[2]; + continue; + } + + break; + } + + $this->eatWhiteDefault = $oldWhite; + if (!$parts) { + $this->seek($s); + return false; + } + + if ($hasExpression) { + $tag = array("exp", array("string", "", $parts)); + } else { + $tag = trim(implode($parts)); + } + + $this->whitespace(); + return true; + } + + // a css function + protected function func(&$func) { + $s = $this->seek(); + + if ($this->match('(%|[\w\-_][\w\-_:\.]+|[\w_])', $m) && $this->literal('(')) { + $fname = $m[1]; + + $sPreArgs = $this->seek(); + + $args = array(); + while (true) { + $ss = $this->seek(); + // this ugly nonsense is for ie filter properties + if ($this->keyword($name) && $this->literal('=') && $this->expressionList($value)) { + $args[] = array("string", "", array($name, "=", $value)); + } else { + $this->seek($ss); + if ($this->expressionList($value)) { + $args[] = $value; + } + } + + if (!$this->literal(',')) break; + } + $args = array('list', ',', $args); + + if ($this->literal(')')) { + $func = array('function', $fname, $args); + return true; + } elseif ($fname == 'url') { + // couldn't parse and in url? treat as string + $this->seek($sPreArgs); + if ($this->openString(")", $string) && $this->literal(")")) { + $func = array('function', $fname, $string); + return true; + } + } + } + + $this->seek($s); + return false; + } + + // consume a less variable + protected function variable(&$name) { + $s = $this->seek(); + if ($this->literal($this->lessc->vPrefix, false) && + ($this->variable($sub) || $this->keyword($name))) + { + if (!empty($sub)) { + $name = array('variable', $sub); + } else { + $name = $this->lessc->vPrefix.$name; + } + return true; + } + + $name = null; + $this->seek($s); + return false; + } + + /** + * Consume an assignment operator + * Can optionally take a name that will be set to the current property name + */ + protected function assign($name = null) { + if ($name) $this->currentProperty = $name; + return $this->literal(':') || $this->literal('='); + } + + // consume a keyword + protected function keyword(&$word) { + if ($this->match('([\w_\-\*!"][\w\-_"]*)', $m)) { + $word = $m[1]; + return true; + } + return false; + } + + // consume an end of statement delimiter + protected function end() { + if ($this->literal(';')) { + return true; + } elseif ($this->count == strlen($this->buffer) || $this->buffer[$this->count] == '}') { + // if there is end of file or a closing block next then we don't need a ; + return true; + } + return false; + } + + protected function guards(&$guards) { + $s = $this->seek(); + + if (!$this->literal("when")) { + $this->seek($s); + return false; + } + + $guards = array(); + + while ($this->guardGroup($g)) { + $guards[] = $g; + if (!$this->literal(",")) break; + } + + if (count($guards) == 0) { + $guards = null; + $this->seek($s); + return false; + } + + return true; + } + + // a bunch of guards that are and'd together + // TODO rename to guardGroup + protected function guardGroup(&$guardGroup) { + $s = $this->seek(); + $guardGroup = array(); + while ($this->guard($guard)) { + $guardGroup[] = $guard; + if (!$this->literal("and")) break; + } + + if (count($guardGroup) == 0) { + $guardGroup = null; + $this->seek($s); + return false; + } + + return true; + } + + protected function guard(&$guard) { + $s = $this->seek(); + $negate = $this->literal("not"); + + if ($this->literal("(") && $this->expression($exp) && $this->literal(")")) { + $guard = $exp; + if ($negate) $guard = array("negate", $guard); + return true; + } + + $this->seek($s); + return false; + } + + /* raw parsing functions */ + + protected function literal($what, $eatWhitespace = null) { + if ($eatWhitespace === null) $eatWhitespace = $this->eatWhiteDefault; + + // shortcut on single letter + if (!isset($what[1]) && isset($this->buffer[$this->count])) { + if ($this->buffer[$this->count] == $what) { + if (!$eatWhitespace) { + $this->count++; + return true; + } + // goes below... + } else { + return false; + } + } + + if (!isset(self::$literalCache[$what])) { + self::$literalCache[$what] = lessc::preg_quote($what); + } + + return $this->match(self::$literalCache[$what], $m, $eatWhitespace); + } + + protected function genericList(&$out, $parseItem, $delim="", $flatten=true) { + $s = $this->seek(); + $items = array(); + while ($this->$parseItem($value)) { + $items[] = $value; + if ($delim) { + if (!$this->literal($delim)) break; + } + } + + if (count($items) == 0) { + $this->seek($s); + return false; + } + + if ($flatten && count($items) == 1) { + $out = $items[0]; + } else { + $out = array("list", $delim, $items); + } + + return true; + } + + + // advance counter to next occurrence of $what + // $until - don't include $what in advance + // $allowNewline, if string, will be used as valid char set + protected function to($what, &$out, $until = false, $allowNewline = false) { + if (is_string($allowNewline)) { + $validChars = $allowNewline; + } else { + $validChars = $allowNewline ? "." : "[^\n]"; + } + if (!$this->match('('.$validChars.'*?)'.lessc::preg_quote($what), $m, !$until)) return false; + if ($until) $this->count -= strlen($what); // give back $what + $out = $m[1]; + return true; + } + + // try to match something on head of buffer + protected function match($regex, &$out, $eatWhitespace = null) { + if ($eatWhitespace === null) $eatWhitespace = $this->eatWhiteDefault; + + $r = '/'.$regex.($eatWhitespace && !$this->writeComments ? '\s*' : '').'/Ais'; + if (preg_match($r, $this->buffer, $out, null, $this->count)) { + $this->count += strlen($out[0]); + if ($eatWhitespace && $this->writeComments) $this->whitespace(); + return true; + } + return false; + } + + // match some whitespace + protected function whitespace() { + if ($this->writeComments) { + $gotWhite = false; + while (preg_match(self::$whitePattern, $this->buffer, $m, null, $this->count)) { + if (isset($m[1]) && empty($this->commentsSeen[$this->count])) { + $this->append(array("comment", $m[1])); + $this->commentsSeen[$this->count] = true; + } + $this->count += strlen($m[0]); + $gotWhite = true; + } + return $gotWhite; + } else { + $this->match("", $m); + return strlen($m[0]) > 0; + } + } + + // match something without consuming it + protected function peek($regex, &$out = null, $from=null) { + if (is_null($from)) $from = $this->count; + $r = '/'.$regex.'/Ais'; + $result = preg_match($r, $this->buffer, $out, null, $from); + + return $result; + } + + // seek to a spot in the buffer or return where we are on no argument + protected function seek($where = null) { + if ($where === null) return $this->count; + else $this->count = $where; + return true; + } + + /* misc functions */ + + public function throwError($msg = "parse error", $count = null) { + $count = is_null($count) ? $this->count : $count; + + $line = $this->line + + substr_count(substr($this->buffer, 0, $count), "\n"); + + if (!empty($this->sourceName)) { + $loc = "$this->sourceName on line $line"; + } else { + $loc = "line: $line"; + } + + // TODO this depends on $this->count + if ($this->peek("(.*?)(\n|$)", $m, $count)) { + throw new exception("$msg: failed at `$m[1]` $loc"); + } else { + throw new exception("$msg: $loc"); + } + } + + protected function pushBlock($selectors=null, $type=null) { + $b = new stdclass; + $b->parent = $this->env; + + $b->type = $type; + $b->id = self::$nextBlockId++; + + $b->isVararg = false; // TODO: kill me from here + $b->tags = $selectors; + + $b->props = array(); + $b->children = array(); + + $this->env = $b; + return $b; + } + + // push a block that doesn't multiply tags + protected function pushSpecialBlock($type) { + return $this->pushBlock(null, $type); + } + + // append a property to the current block + protected function append($prop, $pos = null) { + if ($pos !== null) $prop[-1] = $pos; + $this->env->props[] = $prop; + } + + // pop something off the stack + protected function pop() { + $old = $this->env; + $this->env = $this->env->parent; + return $old; + } + + // remove comments from $text + // todo: make it work for all functions, not just url + protected function removeComments($text) { + $look = array( + 'url(', '//', '/*', '"', "'" + ); + + $out = ''; + $min = null; + while (true) { + // find the next item + foreach ($look as $token) { + $pos = strpos($text, $token); + if ($pos !== false) { + if (!isset($min) || $pos < $min[1]) $min = array($token, $pos); + } + } + + if (is_null($min)) break; + + $count = $min[1]; + $skip = 0; + $newlines = 0; + switch ($min[0]) { + case 'url(': + if (preg_match('/url\(.*?\)/', $text, $m, 0, $count)) + $count += strlen($m[0]) - strlen($min[0]); + break; + case '"': + case "'": + if (preg_match('/'.$min[0].'.*?(?indentLevel = 0; + } + + public function indentStr($n = 0) { + return str_repeat($this->indentChar, max($this->indentLevel + $n, 0)); + } + + public function property($name, $value) { + return $name . $this->assignSeparator . $value . ";"; + } + + protected function isEmpty($block) { + if (empty($block->lines)) { + foreach ($block->children as $child) { + if (!$this->isEmpty($child)) return false; + } + + return true; + } + return false; + } + + public function block($block) { + if ($this->isEmpty($block)) return; + + $inner = $pre = $this->indentStr(); + + $isSingle = !$this->disableSingle && + is_null($block->type) && count($block->lines) == 1; + + if (!empty($block->selectors)) { + $this->indentLevel++; + + if ($this->breakSelectors) { + $selectorSeparator = $this->selectorSeparator . $this->break . $pre; + } else { + $selectorSeparator = $this->selectorSeparator; + } + + echo $pre . + implode($selectorSeparator, $block->selectors); + if ($isSingle) { + echo $this->openSingle; + $inner = ""; + } else { + echo $this->open . $this->break; + $inner = $this->indentStr(); + } + + } + + if (!empty($block->lines)) { + $glue = $this->break.$inner; + echo $inner . implode($glue, $block->lines); + if (!$isSingle && !empty($block->children)) { + echo $this->break; + } + } + + foreach ($block->children as $child) { + $this->block($child); + } + + if (!empty($block->selectors)) { + if (!$isSingle && empty($block->children)) echo $this->break; + + if ($isSingle) { + echo $this->closeSingle . $this->break; + } else { + echo $pre . $this->close . $this->break; + } + + $this->indentLevel--; + } + } +} + +class lessc_formatter_compressed extends lessc_formatter_classic { + public $disableSingle = true; + public $open = "{"; + public $selectorSeparator = ","; + public $assignSeparator = ":"; + public $break = ""; + public $compressColors = true; + + public function indentStr($n = 0) { + return ""; + } +} + +class lessc_formatter_lessjs extends lessc_formatter_classic { + public $disableSingle = true; + public $breakSelectors = true; + public $assignSeparator = ": "; + public $selectorSeparator = ","; +} + + diff --git a/assets/snippets/cssjs/scss.inc.php b/assets/snippets/cssjs/scss.inc.php new file mode 100644 index 0000000..e60349c --- /dev/null +++ b/assets/snippets/cssjs/scss.inc.php @@ -0,0 +1,4574 @@ + + */ +class scssc { + static public $VERSION = 'v0.0.12'; + + static protected $operatorNames = array( + '+' => "add", + '-' => "sub", + '*' => "mul", + '/' => "div", + '%' => "mod", + + '==' => "eq", + '!=' => "neq", + '<' => "lt", + '>' => "gt", + + '<=' => "lte", + '>=' => "gte", + ); + + static protected $namespaces = array( + "special" => "%", + "mixin" => "@", + "function" => "^", + ); + + static protected $unitTable = array( + "in" => array( + "in" => 1, + "pt" => 72, + "pc" => 6, + "cm" => 2.54, + "mm" => 25.4, + "px" => 96, + ) + ); + + static public $true = array("keyword", "true"); + static public $false = array("keyword", "false"); + static public $null = array("null"); + + static public $defaultValue = array("keyword", ""); + static public $selfSelector = array("self"); + + protected $importPaths = array(""); + protected $importCache = array(); + + protected $userFunctions = array(); + protected $registeredVars = array(); + + protected $numberPrecision = 5; + + protected $formatter = "scss_formatter_nested"; + + /** + * Compile scss + * + * @param string $code + * @param string $name + * + * @return string + */ + public function compile($code, $name = null) + { + $this->indentLevel = -1; + $this->commentsSeen = array(); + $this->extends = array(); + $this->extendsMap = array(); + $this->parsedFiles = array(); + $this->env = null; + $this->scope = null; + + $locale = setlocale(LC_NUMERIC, 0); + setlocale(LC_NUMERIC, "C"); + + $this->parser = new scss_parser($name); + + $tree = $this->parser->parse($code); + + $this->formatter = new $this->formatter(); + + $this->pushEnv($tree); + $this->injectVariables($this->registeredVars); + $this->compileRoot($tree); + $this->popEnv(); + + $out = $this->formatter->format($this->scope); + + setlocale(LC_NUMERIC, $locale); + + return $out; + } + + protected function isSelfExtend($target, $origin) { + foreach ($origin as $sel) { + if (in_array($target, $sel)) { + return true; + } + } + + return false; + } + + protected function pushExtends($target, $origin) { + if ($this->isSelfExtend($target, $origin)) { + return; + } + + $i = count($this->extends); + $this->extends[] = array($target, $origin); + + foreach ($target as $part) { + if (isset($this->extendsMap[$part])) { + $this->extendsMap[$part][] = $i; + } else { + $this->extendsMap[$part] = array($i); + } + } + } + + protected function makeOutputBlock($type, $selectors = null) { + $out = new stdClass; + $out->type = $type; + $out->lines = array(); + $out->children = array(); + $out->parent = $this->scope; + $out->selectors = $selectors; + $out->depth = $this->env->depth; + + return $out; + } + + protected function matchExtendsSingle($single, &$outOrigin) { + $counts = array(); + foreach ($single as $part) { + if (!is_string($part)) return false; // hmm + + if (isset($this->extendsMap[$part])) { + foreach ($this->extendsMap[$part] as $idx) { + $counts[$idx] = + isset($counts[$idx]) ? $counts[$idx] + 1 : 1; + } + } + } + + $outOrigin = array(); + $found = false; + + foreach ($counts as $idx => $count) { + list($target, $origin) = $this->extends[$idx]; + + // check count + if ($count != count($target)) continue; + + // check if target is subset of single + if (array_diff(array_intersect($single, $target), $target)) continue; + + $rem = array_diff($single, $target); + + foreach ($origin as $j => $new) { + // prevent infinite loop when target extends itself + foreach ($new as $new_selector) { + if (!array_diff($single, $new_selector)) { + continue 2; + } + } + + $origin[$j][count($origin[$j]) - 1] = $this->combineSelectorSingle(end($new), $rem); + } + + $outOrigin = array_merge($outOrigin, $origin); + + $found = true; + } + + return $found; + } + + protected function combineSelectorSingle($base, $other) { + $tag = null; + $out = array(); + + foreach (array($base, $other) as $single) { + foreach ($single as $part) { + if (preg_match('/^[^\[.#:]/', $part)) { + $tag = $part; + } else { + $out[] = $part; + } + } + } + + if ($tag) { + array_unshift($out, $tag); + } + + return $out; + } + + protected function matchExtends($selector, &$out, $from = 0, $initial=true) { + foreach ($selector as $i => $part) { + if ($i < $from) continue; + + if ($this->matchExtendsSingle($part, $origin)) { + $before = array_slice($selector, 0, $i); + $after = array_slice($selector, $i + 1); + + foreach ($origin as $new) { + $k = 0; + + // remove shared parts + if ($initial) { + foreach ($before as $k => $val) { + if (!isset($new[$k]) || $val != $new[$k]) { + break; + } + } + } + + $result = array_merge( + $before, + $k > 0 ? array_slice($new, $k) : $new, + $after); + + + if ($result == $selector) continue; + $out[] = $result; + + // recursively check for more matches + $this->matchExtends($result, $out, $i, false); + + // selector sequence merging + if (!empty($before) && count($new) > 1) { + $result2 = array_merge( + array_slice($new, 0, -1), + $k > 0 ? array_slice($before, $k) : $before, + array_slice($new, -1), + $after); + + $out[] = $result2; + } + } + } + } + } + + protected function flattenSelectors($block, $parentKey = null) { + if ($block->selectors) { + $selectors = array(); + foreach ($block->selectors as $s) { + $selectors[] = $s; + if (!is_array($s)) continue; + // check extends + if (!empty($this->extendsMap)) { + $this->matchExtends($s, $selectors); + } + } + + $block->selectors = array(); + $placeholderSelector = false; + foreach ($selectors as $selector) { + if ($this->hasSelectorPlaceholder($selector)) { + $placeholderSelector = true; + continue; + } + $block->selectors[] = $this->compileSelector($selector); + } + + if ($placeholderSelector && 0 == count($block->selectors) && null !== $parentKey) { + unset($block->parent->children[$parentKey]); + return; + } + } + + foreach ($block->children as $key => $child) { + $this->flattenSelectors($child, $key); + } + } + + protected function compileRoot($rootBlock) + { + $this->scope = $this->makeOutputBlock('root'); + + $this->compileChildren($rootBlock->children, $this->scope); + $this->flattenSelectors($this->scope); + } + + protected function compileMedia($media) { + $this->pushEnv($media); + + $mediaQuery = $this->compileMediaQuery($this->multiplyMedia($this->env)); + + if (!empty($mediaQuery)) { + + $this->scope = $this->makeOutputBlock("media", array($mediaQuery)); + + $parentScope = $this->mediaParent($this->scope); + + $parentScope->children[] = $this->scope; + + // top level properties in a media cause it to be wrapped + $needsWrap = false; + foreach ($media->children as $child) { + $type = $child[0]; + if ($type !== 'block' && $type !== 'media' && $type !== 'directive') { + $needsWrap = true; + break; + } + } + + if ($needsWrap) { + $wrapped = (object)array( + "selectors" => array(), + "children" => $media->children + ); + $media->children = array(array("block", $wrapped)); + } + + $this->compileChildren($media->children, $this->scope); + + $this->scope = $this->scope->parent; + } + + $this->popEnv(); + } + + protected function mediaParent($scope) { + while (!empty($scope->parent)) { + if (!empty($scope->type) && $scope->type != "media") { + break; + } + $scope = $scope->parent; + } + + return $scope; + } + + // TODO refactor compileNestedBlock and compileMedia into same thing + protected function compileNestedBlock($block, $selectors) { + $this->pushEnv($block); + + $this->scope = $this->makeOutputBlock($block->type, $selectors); + $this->scope->parent->children[] = $this->scope; + $this->compileChildren($block->children, $this->scope); + + $this->scope = $this->scope->parent; + $this->popEnv(); + } + + /** + * Recursively compiles a block. + * + * A block is analogous to a CSS block in most cases. A single SCSS document + * is encapsulated in a block when parsed, but it does not have parent tags + * so all of its children appear on the root level when compiled. + * + * Blocks are made up of selectors and children. + * + * The children of a block are just all the blocks that are defined within. + * + * Compiling the block involves pushing a fresh environment on the stack, + * and iterating through the props, compiling each one. + * + * @see scss::compileChild() + * + * @param \StdClass $block + */ + protected function compileBlock($block) { + $env = $this->pushEnv($block); + + $env->selectors = + array_map(array($this, "evalSelector"), $block->selectors); + + $out = $this->makeOutputBlock(null, $this->multiplySelectors($env)); + $this->scope->children[] = $out; + $this->compileChildren($block->children, $out); + + $this->popEnv(); + } + + // joins together .classes and #ids + protected function flattenSelectorSingle($single) { + $joined = array(); + foreach ($single as $part) { + if (empty($joined) || + !is_string($part) || + preg_match('/[\[.:#%]/', $part)) + { + $joined[] = $part; + continue; + } + + if (is_array(end($joined))) { + $joined[] = $part; + } else { + $joined[count($joined) - 1] .= $part; + } + } + + return $joined; + } + + // replaces all the interpolates + protected function evalSelector($selector) { + return array_map(array($this, "evalSelectorPart"), $selector); + } + + protected function evalSelectorPart($piece) { + foreach ($piece as &$p) { + if (!is_array($p)) continue; + + switch ($p[0]) { + case "interpolate": + $p = $this->compileValue($p); + break; + case "string": + $p = $this->compileValue($p); + break; + } + } + + return $this->flattenSelectorSingle($piece); + } + + // compiles to string + // self(&) should have been replaced by now + protected function compileSelector($selector) { + if (!is_array($selector)) return $selector; // media and the like + + return implode(" ", array_map( + array($this, "compileSelectorPart"), $selector)); + } + + protected function compileSelectorPart($piece) { + foreach ($piece as &$p) { + if (!is_array($p)) continue; + + switch ($p[0]) { + case "self": + $p = "&"; + break; + default: + $p = $this->compileValue($p); + break; + } + } + + return implode($piece); + } + + protected function hasSelectorPlaceholder($selector) + { + if (!is_array($selector)) return false; + + foreach ($selector as $parts) { + foreach ($parts as $part) { + if ('%' == $part[0]) { + return true; + } + } + } + + return false; + } + + protected function compileChildren($stms, $out) { + foreach ($stms as $stm) { + $ret = $this->compileChild($stm, $out); + if (isset($ret)) return $ret; + } + } + + protected function compileMediaQuery($queryList) { + $out = "@media"; + $first = true; + foreach ($queryList as $query){ + $type = null; + $parts = array(); + foreach ($query as $q) { + switch ($q[0]) { + case "mediaType": + if ($type) { + $type = $this->mergeMediaTypes($type, array_map(array($this, "compileValue"), array_slice($q, 1))); + if (empty($type)) { // merge failed + return null; + } + } else { + $type = array_map(array($this, "compileValue"), array_slice($q, 1)); + } + break; + case "mediaExp": + if (isset($q[2])) { + $parts[] = "(". $this->compileValue($q[1]) . $this->formatter->assignSeparator . $this->compileValue($q[2]) . ")"; + } else { + $parts[] = "(" . $this->compileValue($q[1]) . ")"; + } + break; + } + } + if ($type) { + array_unshift($parts, implode(' ', array_filter($type))); + } + if (!empty($parts)) { + if ($first) { + $first = false; + $out .= " "; + } else { + $out .= $this->formatter->tagSeparator; + } + $out .= implode(" and ", $parts); + } + } + return $out; + } + + protected function mergeMediaTypes($type1, $type2) { + if (empty($type1)) { + return $type2; + } + if (empty($type2)) { + return $type1; + } + $m1 = ''; + $t1 = ''; + if (count($type1) > 1) { + $m1= strtolower($type1[0]); + $t1= strtolower($type1[1]); + } else { + $t1 = strtolower($type1[0]); + } + $m2 = ''; + $t2 = ''; + if (count($type2) > 1) { + $m2 = strtolower($type2[0]); + $t2 = strtolower($type2[1]); + } else { + $t2 = strtolower($type2[0]); + } + if (($m1 == 'not') ^ ($m2 == 'not')) { + if ($t1 == $t2) { + return null; + } + return array( + $m1 == 'not' ? $m2 : $m1, + $m1 == 'not' ? $t2 : $t1 + ); + } elseif ($m1 == 'not' && $m2 == 'not') { + # CSS has no way of representing "neither screen nor print" + if ($t1 != $t2) { + return null; + } + return array('not', $t1); + } elseif ($t1 != $t2) { + return null; + } else { // t1 == t2, neither m1 nor m2 are "not" + return array(empty($m1)? $m2 : $m1, $t1); + } + } + + // returns true if the value was something that could be imported + protected function compileImport($rawPath, $out) { + if ($rawPath[0] == "string") { + $path = $this->compileStringContent($rawPath); + if ($path = $this->findImport($path)) { + $this->importFile($path, $out); + return true; + } + return false; + } + if ($rawPath[0] == "list") { + // handle a list of strings + if (count($rawPath[2]) == 0) return false; + foreach ($rawPath[2] as $path) { + if ($path[0] != "string") return false; + } + + foreach ($rawPath[2] as $path) { + $this->compileImport($path, $out); + } + + return true; + } + + return false; + } + + // return a value to halt execution + protected function compileChild($child, $out) { + $this->sourcePos = isset($child[-1]) ? $child[-1] : -1; + $this->sourceParser = isset($child[-2]) ? $child[-2] : $this->parser; + + switch ($child[0]) { + case "import": + list(,$rawPath) = $child; + $rawPath = $this->reduce($rawPath); + if (!$this->compileImport($rawPath, $out)) { + $out->lines[] = "@import " . $this->compileValue($rawPath) . ";"; + } + break; + case "directive": + list(, $directive) = $child; + $s = "@" . $directive->name; + if (!empty($directive->value)) { + $s .= " " . $this->compileValue($directive->value); + } + $this->compileNestedBlock($directive, array($s)); + break; + case "media": + $this->compileMedia($child[1]); + break; + case "block": + $this->compileBlock($child[1]); + break; + case "charset": + $out->lines[] = "@charset ".$this->compileValue($child[1]).";"; + break; + case "assign": + list(,$name, $value) = $child; + if ($name[0] == "var") { + $isDefault = !empty($child[3]); + + if ($isDefault) { + $existingValue = $this->get($name[1], true); + $shouldSet = $existingValue === true || $existingValue == self::$null; + } + + if (!$isDefault || $shouldSet) { + $this->set($name[1], $this->reduce($value)); + } + break; + } + + // if the value reduces to null from something else then + // the property should be discarded + if ($value[0] != "null") { + $value = $this->reduce($value); + if ($value[0] == "null") { + break; + } + } + + $compiledValue = $this->compileValue($value); + $out->lines[] = $this->formatter->property( + $this->compileValue($name), + $compiledValue); + break; + case "comment": + $out->lines[] = $child[1]; + break; + case "mixin": + case "function": + list(,$block) = $child; + $this->set(self::$namespaces[$block->type] . $block->name, $block); + break; + case "extend": + list(, $selectors) = $child; + foreach ($selectors as $sel) { + // only use the first one + $sel = current($this->evalSelector($sel)); + $this->pushExtends($sel, $out->selectors); + } + break; + case "if": + list(, $if) = $child; + if ($this->isTruthy($this->reduce($if->cond, true))) { + return $this->compileChildren($if->children, $out); + } else { + foreach ($if->cases as $case) { + if ($case->type == "else" || + $case->type == "elseif" && $this->isTruthy($this->reduce($case->cond))) + { + return $this->compileChildren($case->children, $out); + } + } + } + break; + case "return": + return $this->reduce($child[1], true); + case "each": + list(,$each) = $child; + $list = $this->coerceList($this->reduce($each->list)); + foreach ($list[2] as $item) { + $this->pushEnv(); + $this->set($each->var, $item); + // TODO: allow return from here + $this->compileChildren($each->children, $out); + $this->popEnv(); + } + break; + case "while": + list(,$while) = $child; + while ($this->isTruthy($this->reduce($while->cond, true))) { + $ret = $this->compileChildren($while->children, $out); + if ($ret) return $ret; + } + break; + case "for": + list(,$for) = $child; + $start = $this->reduce($for->start, true); + $start = $start[1]; + $end = $this->reduce($for->end, true); + $end = $end[1]; + $d = $start < $end ? 1 : -1; + + while (true) { + if ((!$for->until && $start - $d == $end) || + ($for->until && $start == $end)) + { + break; + } + + $this->set($for->var, array("number", $start, "")); + $start += $d; + + $ret = $this->compileChildren($for->children, $out); + if ($ret) return $ret; + } + + break; + case "nestedprop": + list(,$prop) = $child; + $prefixed = array(); + $prefix = $this->compileValue($prop->prefix) . "-"; + foreach ($prop->children as $child) { + if ($child[0] == "assign") { + array_unshift($child[1][2], $prefix); + } + if ($child[0] == "nestedprop") { + array_unshift($child[1]->prefix[2], $prefix); + } + $prefixed[] = $child; + } + $this->compileChildren($prefixed, $out); + break; + case "include": // including a mixin + list(,$name, $argValues, $content) = $child; + $mixin = $this->get(self::$namespaces["mixin"] . $name, false); + if (!$mixin) { + $this->throwError("Undefined mixin $name"); + } + + $callingScope = $this->env; + + // push scope, apply args + $this->pushEnv(); + if ($this->env->depth > 0) { + $this->env->depth--; + } + + if (isset($content)) { + $content->scope = $callingScope; + $this->setRaw(self::$namespaces["special"] . "content", $content); + } + + if (isset($mixin->args)) { + $this->applyArguments($mixin->args, $argValues); + } + + foreach ($mixin->children as $child) { + $this->compileChild($child, $out); + } + + $this->popEnv(); + + break; + case "mixin_content": + $content = $this->get(self::$namespaces["special"] . "content"); + if (!isset($content)) { + $this->throwError("Expected @content inside of mixin"); + } + + $strongTypes = array('include', 'block', 'for', 'while'); + foreach ($content->children as $child) { + $this->storeEnv = (in_array($child[0], $strongTypes)) + ? null + : $content->scope; + + $this->compileChild($child, $out); + } + + unset($this->storeEnv); + break; + case "debug": + list(,$value, $pos) = $child; + $line = $this->parser->getLineNo($pos); + $value = $this->compileValue($this->reduce($value, true)); + fwrite(STDERR, "Line $line DEBUG: $value\n"); + break; + default: + $this->throwError("unknown child type: $child[0]"); + } + } + + protected function expToString($exp) { + list(, $op, $left, $right, $inParens, $whiteLeft, $whiteRight) = $exp; + $content = array($this->reduce($left)); + if ($whiteLeft) $content[] = " "; + $content[] = $op; + if ($whiteRight) $content[] = " "; + $content[] = $this->reduce($right); + return array("string", "", $content); + } + + protected function isTruthy($value) { + return $value != self::$false && $value != self::$null; + } + + // should $value cause its operand to eval + protected function shouldEval($value) { + switch ($value[0]) { + case "exp": + if ($value[1] == "/") { + return $this->shouldEval($value[2], $value[3]); + } + case "var": + case "fncall": + return true; + } + return false; + } + + protected function reduce($value, $inExp = false) { + list($type) = $value; + switch ($type) { + case "exp": + list(, $op, $left, $right, $inParens) = $value; + $opName = isset(self::$operatorNames[$op]) ? self::$operatorNames[$op] : $op; + + $inExp = $inExp || $this->shouldEval($left) || $this->shouldEval($right); + + $left = $this->reduce($left, true); + $right = $this->reduce($right, true); + + // only do division in special cases + if ($opName == "div" && !$inParens && !$inExp) { + if ($left[0] != "color" && $right[0] != "color") { + return $this->expToString($value); + } + } + + $left = $this->coerceForExpression($left); + $right = $this->coerceForExpression($right); + + $ltype = $left[0]; + $rtype = $right[0]; + + // this tries: + // 1. op_[op name]_[left type]_[right type] + // 2. op_[left type]_[right type] (passing the op as first arg + // 3. op_[op name] + $fn = "op_${opName}_${ltype}_${rtype}"; + if (is_callable(array($this, $fn)) || + (($fn = "op_${ltype}_${rtype}") && + is_callable(array($this, $fn)) && + $passOp = true) || + (($fn = "op_${opName}") && + is_callable(array($this, $fn)) && + $genOp = true)) + { + $unitChange = false; + if (!isset($genOp) && + $left[0] == "number" && $right[0] == "number") + { + if ($opName == "mod" && $right[2] != "") { + $this->throwError("Cannot modulo by a number with units: $right[1]$right[2]."); + } + + $unitChange = true; + $emptyUnit = $left[2] == "" || $right[2] == ""; + $targetUnit = "" != $left[2] ? $left[2] : $right[2]; + + if ($opName != "mul") { + $left[2] = "" != $left[2] ? $left[2] : $targetUnit; + $right[2] = "" != $right[2] ? $right[2] : $targetUnit; + } + + if ($opName != "mod") { + $left = $this->normalizeNumber($left); + $right = $this->normalizeNumber($right); + } + + if ($opName == "div" && !$emptyUnit && $left[2] == $right[2]) { + $targetUnit = ""; + } + + if ($opName == "mul") { + $left[2] = "" != $left[2] ? $left[2] : $right[2]; + $right[2] = "" != $right[2] ? $right[2] : $left[2]; + } elseif ($opName == "div" && $left[2] == $right[2]) { + $left[2] = ""; + $right[2] = ""; + } + } + + $shouldEval = $inParens || $inExp; + if (isset($passOp)) { + $out = $this->$fn($op, $left, $right, $shouldEval); + } else { + $out = $this->$fn($left, $right, $shouldEval); + } + + if (isset($out)) { + if ($unitChange && $out[0] == "number") { + $out = $this->coerceUnit($out, $targetUnit); + } + return $out; + } + } + + return $this->expToString($value); + case "unary": + list(, $op, $exp, $inParens) = $value; + $inExp = $inExp || $this->shouldEval($exp); + + $exp = $this->reduce($exp); + if ($exp[0] == "number") { + switch ($op) { + case "+": + return $exp; + case "-": + $exp[1] *= -1; + return $exp; + } + } + + if ($op == "not") { + if ($inExp || $inParens) { + if ($exp == self::$false) { + return self::$true; + } else { + return self::$false; + } + } else { + $op = $op . " "; + } + } + + return array("string", "", array($op, $exp)); + case "var": + list(, $name) = $value; + return $this->reduce($this->get($name)); + case "list": + foreach ($value[2] as &$item) { + $item = $this->reduce($item); + } + return $value; + case "string": + foreach ($value[2] as &$item) { + if (is_array($item)) { + $item = $this->reduce($item); + } + } + return $value; + case "interpolate": + $value[1] = $this->reduce($value[1]); + return $value; + case "fncall": + list(,$name, $argValues) = $value; + + // user defined function? + $func = $this->get(self::$namespaces["function"] . $name, false); + if ($func) { + $this->pushEnv(); + + // set the args + if (isset($func->args)) { + $this->applyArguments($func->args, $argValues); + } + + // throw away lines and children + $tmp = (object)array( + "lines" => array(), + "children" => array() + ); + $ret = $this->compileChildren($func->children, $tmp); + $this->popEnv(); + + return !isset($ret) ? self::$defaultValue : $ret; + } + + // built in function + if ($this->callBuiltin($name, $argValues, $returnValue)) { + return $returnValue; + } + + // need to flatten the arguments into a list + $listArgs = array(); + foreach ((array)$argValues as $arg) { + if (empty($arg[0])) { + $listArgs[] = $this->reduce($arg[1]); + } + } + return array("function", $name, array("list", ",", $listArgs)); + default: + return $value; + } + } + + public function normalizeValue($value) { + $value = $this->coerceForExpression($this->reduce($value)); + list($type) = $value; + + switch ($type) { + case "list": + $value = $this->extractInterpolation($value); + if ($value[0] != "list") { + return array("keyword", $this->compileValue($value)); + } + foreach ($value[2] as $key => $item) { + $value[2][$key] = $this->normalizeValue($item); + } + return $value; + case "number": + return $this->normalizeNumber($value); + default: + return $value; + } + } + + // just does physical lengths for now + protected function normalizeNumber($number) { + list(, $value, $unit) = $number; + if (isset(self::$unitTable["in"][$unit])) { + $conv = self::$unitTable["in"][$unit]; + return array("number", $value / $conv, "in"); + } + return $number; + } + + // $number should be normalized + protected function coerceUnit($number, $unit) { + list(, $value, $baseUnit) = $number; + if (isset(self::$unitTable[$baseUnit][$unit])) { + $value = $value * self::$unitTable[$baseUnit][$unit]; + } + + return array("number", $value, $unit); + } + + protected function op_add_number_number($left, $right) { + return array("number", $left[1] + $right[1], $left[2]); + } + + protected function op_mul_number_number($left, $right) { + return array("number", $left[1] * $right[1], $left[2]); + } + + protected function op_sub_number_number($left, $right) { + return array("number", $left[1] - $right[1], $left[2]); + } + + protected function op_div_number_number($left, $right) { + return array("number", $left[1] / $right[1], $left[2]); + } + + protected function op_mod_number_number($left, $right) { + return array("number", $left[1] % $right[1], $left[2]); + } + + // adding strings + protected function op_add($left, $right) { + if ($strLeft = $this->coerceString($left)) { + if ($right[0] == "string") { + $right[1] = ""; + } + $strLeft[2][] = $right; + return $strLeft; + } + + if ($strRight = $this->coerceString($right)) { + if ($left[0] == "string") { + $left[1] = ""; + } + array_unshift($strRight[2], $left); + return $strRight; + } + } + + protected function op_and($left, $right, $shouldEval) { + if (!$shouldEval) return; + if ($left != self::$false) return $right; + return $left; + } + + protected function op_or($left, $right, $shouldEval) { + if (!$shouldEval) return; + if ($left != self::$false) return $left; + return $right; + } + + protected function op_color_color($op, $left, $right) { + $out = array('color'); + foreach (range(1, 3) as $i) { + $lval = isset($left[$i]) ? $left[$i] : 0; + $rval = isset($right[$i]) ? $right[$i] : 0; + switch ($op) { + case '+': + $out[] = $lval + $rval; + break; + case '-': + $out[] = $lval - $rval; + break; + case '*': + $out[] = $lval * $rval; + break; + case '%': + $out[] = $lval % $rval; + break; + case '/': + if ($rval == 0) { + $this->throwError("color: Can't divide by zero"); + } + $out[] = $lval / $rval; + break; + case "==": + return $this->op_eq($left, $right); + case "!=": + return $this->op_neq($left, $right); + default: + $this->throwError("color: unknown op $op"); + } + } + + if (isset($left[4])) $out[4] = $left[4]; + elseif (isset($right[4])) $out[4] = $right[4]; + + return $this->fixColor($out); + } + + protected function op_color_number($op, $left, $right) { + $value = $right[1]; + return $this->op_color_color($op, $left, + array("color", $value, $value, $value)); + } + + protected function op_number_color($op, $left, $right) { + $value = $left[1]; + return $this->op_color_color($op, + array("color", $value, $value, $value), $right); + } + + protected function op_eq($left, $right) { + if (($lStr = $this->coerceString($left)) && ($rStr = $this->coerceString($right))) { + $lStr[1] = ""; + $rStr[1] = ""; + return $this->toBool($this->compileValue($lStr) == $this->compileValue($rStr)); + } + + return $this->toBool($left == $right); + } + + protected function op_neq($left, $right) { + return $this->toBool($left != $right); + } + + protected function op_gte_number_number($left, $right) { + return $this->toBool($left[1] >= $right[1]); + } + + protected function op_gt_number_number($left, $right) { + return $this->toBool($left[1] > $right[1]); + } + + protected function op_lte_number_number($left, $right) { + return $this->toBool($left[1] <= $right[1]); + } + + protected function op_lt_number_number($left, $right) { + return $this->toBool($left[1] < $right[1]); + } + + public function toBool($thing) { + return $thing ? self::$true : self::$false; + } + + /** + * Compiles a primitive value into a CSS property value. + * + * Values in scssphp are typed by being wrapped in arrays, their format is + * typically: + * + * array(type, contents [, additional_contents]*) + * + * The input is expected to be reduced. This function will not work on + * things like expressions and variables. + * + * @param array $value + */ + protected function compileValue($value) { + $value = $this->reduce($value); + + list($type) = $value; + switch ($type) { + case "keyword": + return $value[1]; + case "color": + // [1] - red component (either number for a %) + // [2] - green component + // [3] - blue component + // [4] - optional alpha component + list(, $r, $g, $b) = $value; + + $r = round($r); + $g = round($g); + $b = round($b); + + 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; + case "number": + return round($value[1], $this->numberPrecision) . $value[2]; + case "string": + return $value[1] . $this->compileStringContent($value) . $value[1]; + case "function": + $args = !empty($value[2]) ? $this->compileValue($value[2]) : ""; + return "$value[1]($args)"; + case "list": + $value = $this->extractInterpolation($value); + if ($value[0] != "list") return $this->compileValue($value); + + list(, $delim, $items) = $value; + + $filtered = array(); + foreach ($items as $item) { + if ($item[0] == "null") continue; + $filtered[] = $this->compileValue($item); + } + + return implode("$delim ", $filtered); + case "interpolated": # node created by extractInterpolation + list(, $interpolate, $left, $right) = $value; + list(,, $whiteLeft, $whiteRight) = $interpolate; + + $left = count($left[2]) > 0 ? + $this->compileValue($left).$whiteLeft : ""; + + $right = count($right[2]) > 0 ? + $whiteRight.$this->compileValue($right) : ""; + + return $left.$this->compileValue($interpolate).$right; + + case "interpolate": # raw parse node + list(, $exp) = $value; + + // strip quotes if it's a string + $reduced = $this->reduce($exp); + switch ($reduced[0]) { + case "string": + $reduced = array("keyword", + $this->compileStringContent($reduced)); + break; + case "null": + $reduced = array("keyword", ""); + } + + return $this->compileValue($reduced); + case "null": + return "null"; + default: + $this->throwError("unknown value type: $type"); + } + } + + protected function compileStringContent($string) { + $parts = array(); + foreach ($string[2] as $part) { + if (is_array($part)) { + $parts[] = $this->compileValue($part); + } else { + $parts[] = $part; + } + } + + return implode($parts); + } + + // doesn't need to be recursive, compileValue will handle that + protected function extractInterpolation($list) { + $items = $list[2]; + foreach ($items as $i => $item) { + if ($item[0] == "interpolate") { + $before = array("list", $list[1], array_slice($items, 0, $i)); + $after = array("list", $list[1], array_slice($items, $i + 1)); + return array("interpolated", $item, $before, $after); + } + } + return $list; + } + + // find the final set of selectors + protected function multiplySelectors($env) { + $envs = array(); + while (null !== $env) { + if (!empty($env->selectors)) { + $envs[] = $env; + } + $env = $env->parent; + }; + + $selectors = array(); + $parentSelectors = array(array()); + while ($env = array_pop($envs)) { + $selectors = array(); + foreach ($env->selectors as $selector) { + foreach ($parentSelectors as $parent) { + $selectors[] = $this->joinSelectors($parent, $selector); + } + } + $parentSelectors = $selectors; + } + + return $selectors; + } + + // looks for & to replace, or append parent before child + protected function joinSelectors($parent, $child) { + $setSelf = false; + $out = array(); + foreach ($child as $part) { + $newPart = array(); + foreach ($part as $p) { + if ($p == self::$selfSelector) { + $setSelf = true; + foreach ($parent as $i => $parentPart) { + if ($i > 0) { + $out[] = $newPart; + $newPart = array(); + } + + foreach ($parentPart as $pp) { + $newPart[] = $pp; + } + } + } else { + $newPart[] = $p; + } + } + + $out[] = $newPart; + } + + return $setSelf ? $out : array_merge($parent, $child); + } + + protected function multiplyMedia($env, $childQueries = null) { + if (!isset($env) || + !empty($env->block->type) && $env->block->type != "media") + { + return $childQueries; + } + + // plain old block, skip + if (empty($env->block->type)) { + return $this->multiplyMedia($env->parent, $childQueries); + } + + $parentQueries = $env->block->queryList; + if ($childQueries == null) { + $childQueries = $parentQueries; + } else { + $originalQueries = $childQueries; + $childQueries = array(); + + foreach ($parentQueries as $parentQuery){ + foreach ($originalQueries as $childQuery) { + $childQueries []= array_merge($parentQuery, $childQuery); + } + } + } + + return $this->multiplyMedia($env->parent, $childQueries); + } + + // convert something to list + protected function coerceList($item, $delim = ",") { + if (isset($item) && $item[0] == "list") { + return $item; + } + + return array("list", $delim, !isset($item) ? array(): array($item)); + } + + protected function applyArguments($argDef, $argValues) { + $hasVariable = false; + $args = array(); + foreach ($argDef as $i => $arg) { + list($name, $default, $isVariable) = $argDef[$i]; + $args[$name] = array($i, $name, $default, $isVariable); + $hasVariable |= $isVariable; + } + + $keywordArgs = array(); + $deferredKeywordArgs = array(); + $remaining = array(); + // assign the keyword args + foreach ((array) $argValues as $arg) { + if (!empty($arg[0])) { + if (!isset($args[$arg[0][1]])) { + if ($hasVariable) { + $deferredKeywordArgs[$arg[0][1]] = $arg[1]; + } else { + $this->throwError("Mixin or function doesn't have an argument named $%s.", $arg[0][1]); + } + } elseif ($args[$arg[0][1]][0] < count($remaining)) { + $this->throwError("The argument $%s was passed both by position and by name.", $arg[0][1]); + } else { + $keywordArgs[$arg[0][1]] = $arg[1]; + } + } elseif (count($keywordArgs)) { + $this->throwError('Positional arguments must come before keyword arguments.'); + } elseif ($arg[2] == true) { + $val = $this->reduce($arg[1], true); + if ($val[0] == "list") { + foreach ($val[2] as $name => $item) { + if (!is_numeric($name)) { + $keywordArgs[$name] = $item; + } else { + $remaining[] = $item; + } + } + } else { + $remaining[] = $val; + } + } else { + $remaining[] = $arg[1]; + } + } + + foreach ($args as $arg) { + list($i, $name, $default, $isVariable) = $arg; + if ($isVariable) { + $val = array("list", ",", array()); + for ($count = count($remaining); $i < $count; $i++) { + $val[2][] = $remaining[$i]; + } + foreach ($deferredKeywordArgs as $itemName => $item) { + $val[2][$itemName] = $item; + } + } elseif (isset($remaining[$i])) { + $val = $remaining[$i]; + } elseif (isset($keywordArgs[$name])) { + $val = $keywordArgs[$name]; + } elseif (!empty($default)) { + $val = $default; + } else { + $this->throwError("Missing argument $name"); + } + + $this->set($name, $this->reduce($val, true), true); + } + } + + protected function pushEnv($block=null) { + $env = new stdClass; + $env->parent = $this->env; + $env->store = array(); + $env->block = $block; + $env->depth = isset($this->env->depth) ? $this->env->depth + 1 : 0; + + $this->env = $env; + return $env; + } + + protected function normalizeName($name) { + return str_replace("-", "_", $name); + } + + protected function getStoreEnv() { + return isset($this->storeEnv) ? $this->storeEnv : $this->env; + } + + protected function set($name, $value, $shadow=false) { + $name = $this->normalizeName($name); + + if ($shadow) { + $this->setRaw($name, $value); + } else { + $this->setExisting($name, $value); + } + } + + protected function setExisting($name, $value, $env = null) { + if (!isset($env)) $env = $this->getStoreEnv(); + + if (isset($env->store[$name]) || !isset($env->parent)) { + $env->store[$name] = $value; + } else { + $this->setExisting($name, $value, $env->parent); + } + } + + protected function setRaw($name, $value) { + $env = $this->getStoreEnv(); + $env->store[$name] = $value; + } + + public function get($name, $defaultValue = null, $env = null) { + $name = $this->normalizeName($name); + + if (!isset($env)) $env = $this->getStoreEnv(); + if (!isset($defaultValue)) $defaultValue = self::$defaultValue; + + if (isset($env->store[$name])) { + return $env->store[$name]; + } elseif (isset($env->parent)) { + return $this->get($name, $defaultValue, $env->parent); + } + + return $defaultValue; // found nothing + } + + protected function injectVariables(array $args) + { + if (empty($args)) { + return; + } + + $parser = new scss_parser(__METHOD__, false); + + foreach ($args as $name => $strValue) { + if ($name[0] === '$') { + $name = substr($name, 1); + } + + $parser->env = null; + $parser->count = 0; + $parser->buffer = (string) $strValue; + $parser->inParens = false; + $parser->eatWhiteDefault = true; + $parser->insertComments = true; + + if ( ! $parser->valueList($value)) { + throw new Exception("failed to parse passed in variable $name: $strValue"); + } + + $this->set($name, $value); + } + } + + /** + * Set variables + * + * @param array $variables + */ + public function setVariables(array $variables) + { + $this->registeredVars = array_merge($this->registeredVars, $variables); + } + + /** + * Unset variable + * + * @param string $name + */ + public function unsetVariable($name) + { + unset($this->registeredVars[$name]); + } + + protected function popEnv() { + $env = $this->env; + $this->env = $this->env->parent; + return $env; + } + + public function getParsedFiles() { + return $this->parsedFiles; + } + + public function addImportPath($path) { + $this->importPaths[] = $path; + } + + public function setImportPaths($path) { + $this->importPaths = (array)$path; + } + + public function setNumberPrecision($numberPrecision) { + $this->numberPrecision = $numberPrecision; + } + + public function setFormatter($formatterName) { + $this->formatter = $formatterName; + } + + public function registerFunction($name, $func) { + $this->userFunctions[$this->normalizeName($name)] = $func; + } + + public function unregisterFunction($name) { + unset($this->userFunctions[$this->normalizeName($name)]); + } + + protected function importFile($path, $out) { + // see if tree is cached + $realPath = realpath($path); + if (isset($this->importCache[$realPath])) { + $tree = $this->importCache[$realPath]; + } else { + $code = file_get_contents($path); + $parser = new scss_parser($path, false); + $tree = $parser->parse($code); + $this->parsedFiles[] = $path; + + $this->importCache[$realPath] = $tree; + } + + $pi = pathinfo($path); + array_unshift($this->importPaths, $pi['dirname']); + $this->compileChildren($tree->children, $out); + array_shift($this->importPaths); + } + + // results the file path for an import url if it exists + public function findImport($url) { + $urls = array(); + + // for "normal" scss imports (ignore vanilla css and external requests) + if (!preg_match('/\.css|^http:\/\/$/', $url)) { + // try both normal and the _partial filename + $urls = array($url, preg_replace('/[^\/]+$/', '_\0', $url)); + } + + foreach ($this->importPaths as $dir) { + if (is_string($dir)) { + // check urls for normal import paths + foreach ($urls as $full) { + $full = $dir . + (!empty($dir) && substr($dir, -1) != '/' ? '/' : '') . + $full; + + if ($this->fileExists($file = $full.'.scss') || + $this->fileExists($file = $full)) + { + return $file; + } + } + } else { + // check custom callback for import path + $file = call_user_func($dir,$url,$this); + if ($file !== null) { + return $file; + } + } + } + + return null; + } + + protected function fileExists($name) { + return is_file($name); + } + + protected function callBuiltin($name, $args, &$returnValue) { + // try a lib function + $name = $this->normalizeName($name); + $libName = "lib_".$name; + $f = array($this, $libName); + if (is_callable($f)) { + $prototype = isset(self::$$libName) ? self::$$libName : null; + $sorted = $this->sortArgs($prototype, $args); + foreach ($sorted as &$val) { + $val = $this->reduce($val, true); + } + $returnValue = call_user_func($f, $sorted, $this); + } elseif (isset($this->userFunctions[$name])) { + // see if we can find a user function + $fn = $this->userFunctions[$name]; + + foreach ($args as &$val) { + $val = $this->reduce($val[1], true); + } + + $returnValue = call_user_func($fn, $args, $this); + } + + if (isset($returnValue)) { + // coerce a php value into a scss one + if (is_numeric($returnValue)) { + $returnValue = array('number', $returnValue, ""); + } elseif (is_bool($returnValue)) { + $returnValue = $returnValue ? self::$true : self::$false; + } elseif (!is_array($returnValue)) { + $returnValue = array('keyword', $returnValue); + } + + return true; + } + + return false; + } + + // sorts any keyword arguments + // TODO: merge with apply arguments + protected function sortArgs($prototype, $args) { + $keyArgs = array(); + $posArgs = array(); + + foreach ($args as $arg) { + list($key, $value) = $arg; + $key = $key[1]; + if (empty($key)) { + $posArgs[] = $value; + } else { + $keyArgs[$key] = $value; + } + } + + if (!isset($prototype)) return $posArgs; + + $finalArgs = array(); + foreach ($prototype as $i => $names) { + if (isset($posArgs[$i])) { + $finalArgs[] = $posArgs[$i]; + continue; + } + + $set = false; + foreach ((array)$names as $name) { + if (isset($keyArgs[$name])) { + $finalArgs[] = $keyArgs[$name]; + $set = true; + break; + } + } + + if (!$set) { + $finalArgs[] = null; + } + } + + return $finalArgs; + } + + protected function coerceForExpression($value) { + if ($color = $this->coerceColor($value)) { + return $color; + } + + return $value; + } + + protected function coerceColor($value) { + switch ($value[0]) { + case "color": return $value; + case "keyword": + $name = $value[1]; + if (isset(self::$cssColors[$name])) { + $rgba = explode(',', self::$cssColors[$name]); + return isset($rgba[3]) + ? array('color', (int) $rgba[0], (int) $rgba[1], (int) $rgba[2], (int) $rgba[3]) + : array('color', (int) $rgba[0], (int) $rgba[1], (int) $rgba[2]); + } + return null; + } + + return null; + } + + protected function coerceString($value) { + switch ($value[0]) { + case "string": + return $value; + case "keyword": + return array("string", "", array($value[1])); + } + return null; + } + + public function assertList($value) { + if ($value[0] != "list") + $this->throwError("expecting list"); + return $value; + } + + public function assertColor($value) { + if ($color = $this->coerceColor($value)) return $color; + $this->throwError("expecting color"); + } + + public function assertNumber($value) { + if ($value[0] != "number") + $this->throwError("expecting number"); + return $value[1]; + } + + protected function coercePercent($value) { + if ($value[0] == "number") { + if ($value[2] == "%") { + return $value[1] / 100; + } + return $value[1]; + } + return 0; + } + + // make sure a color's components don't go out of bounds + protected function fixColor($c) { + foreach (range(1, 3) as $i) { + if ($c[$i] < 0) $c[$i] = 0; + if ($c[$i] > 255) $c[$i] = 255; + } + + return $c; + } + + public function toHSL($red, $green, $blue) { + $min = min($red, $green, $blue); + $max = max($red, $green, $blue); + + $l = $min + $max; + + if ($min == $max) { + $s = $h = 0; + } else { + $d = $max - $min; + + if ($l < 255) + $s = $d / $l; + else + $s = $d / (510 - $l); + + if ($red == $max) + $h = 60 * ($green - $blue) / $d; + elseif ($green == $max) + $h = 60 * ($blue - $red) / $d + 120; + elseif ($blue == $max) + $h = 60 * ($red - $green) / $d + 240; + } + + return array('hsl', fmod($h, 360), $s * 100, $l / 5.1); + } + + public function hueToRGB($m1, $m2, $h) { + if ($h < 0) + $h += 1; + elseif ($h > 1) + $h -= 1; + + if ($h * 6 < 1) + return $m1 + ($m2 - $m1) * $h * 6; + + if ($h * 2 < 1) + return $m2; + + if ($h * 3 < 2) + return $m1 + ($m2 - $m1) * (2/3 - $h) * 6; + + return $m1; + } + + // H from 0 to 360, S and L from 0 to 100 + public function toRGB($hue, $saturation, $lightness) { + if ($hue < 0) { + $hue += 360; + } + + $h = $hue / 360; + $s = min(100, max(0, $saturation)) / 100; + $l = min(100, max(0, $lightness)) / 100; + + $m2 = $l <= 0.5 ? $l * ($s + 1) : $l + $s - $l * $s; + $m1 = $l * 2 - $m2; + + $r = $this->hueToRGB($m1, $m2, $h + 1/3) * 255; + $g = $this->hueToRGB($m1, $m2, $h) * 255; + $b = $this->hueToRGB($m1, $m2, $h - 1/3) * 255; + + $out = array('color', $r, $g, $b); + return $out; + } + + // Built in functions + + protected static $lib_if = array("condition", "if-true", "if-false"); + protected function lib_if($args) { + list($cond,$t, $f) = $args; + if (!$this->isTruthy($cond)) return $f; + return $t; + } + + protected static $lib_index = array("list", "value"); + protected function lib_index($args) { + list($list, $value) = $args; + $list = $this->assertList($list); + + $values = array(); + foreach ($list[2] as $item) { + $values[] = $this->normalizeValue($item); + } + $key = array_search($this->normalizeValue($value), $values); + + return false === $key ? false : $key + 1; + } + + protected static $lib_rgb = array("red", "green", "blue"); + protected function lib_rgb($args) { + list($r,$g,$b) = $args; + return array("color", $r[1], $g[1], $b[1]); + } + + protected static $lib_rgba = array( + array("red", "color"), + "green", "blue", "alpha"); + protected function lib_rgba($args) { + if ($color = $this->coerceColor($args[0])) { + $num = !isset($args[1]) ? $args[3] : $args[1]; + $alpha = $this->assertNumber($num); + $color[4] = $alpha; + return $color; + } + + list($r,$g,$b, $a) = $args; + return array("color", $r[1], $g[1], $b[1], $a[1]); + } + + // helper function for adjust_color, change_color, and scale_color + protected function alter_color($args, $fn) { + $color = $this->assertColor($args[0]); + + foreach (array(1,2,3,7) as $i) { + if (isset($args[$i])) { + $val = $this->assertNumber($args[$i]); + $ii = $i == 7 ? 4 : $i; // alpha + $color[$ii] = + $this->$fn(isset($color[$ii]) ? $color[$ii] : 0, $val, $i); + } + } + + if (isset($args[4]) || isset($args[5]) || isset($args[6])) { + $hsl = $this->toHSL($color[1], $color[2], $color[3]); + foreach (array(4,5,6) as $i) { + if (isset($args[$i])) { + $val = $this->assertNumber($args[$i]); + $hsl[$i - 3] = $this->$fn($hsl[$i - 3], $val, $i); + } + } + + $rgb = $this->toRGB($hsl[1], $hsl[2], $hsl[3]); + if (isset($color[4])) $rgb[4] = $color[4]; + $color = $rgb; + } + + return $color; + } + + protected static $lib_adjust_color = array( + "color", "red", "green", "blue", + "hue", "saturation", "lightness", "alpha" + ); + protected function adjust_color_helper($base, $alter, $i) { + return $base += $alter; + } + protected function lib_adjust_color($args) { + return $this->alter_color($args, "adjust_color_helper"); + } + + protected static $lib_change_color = array( + "color", "red", "green", "blue", + "hue", "saturation", "lightness", "alpha" + ); + protected function change_color_helper($base, $alter, $i) { + return $alter; + } + protected function lib_change_color($args) { + return $this->alter_color($args, "change_color_helper"); + } + + protected static $lib_scale_color = array( + "color", "red", "green", "blue", + "hue", "saturation", "lightness", "alpha" + ); + protected function scale_color_helper($base, $scale, $i) { + // 1,2,3 - rgb + // 4, 5, 6 - hsl + // 7 - a + switch ($i) { + case 1: + case 2: + case 3: + $max = 255; break; + case 4: + $max = 360; break; + case 7: + $max = 1; break; + default: + $max = 100; + } + + $scale = $scale / 100; + if ($scale < 0) { + return $base * $scale + $base; + } else { + return ($max - $base) * $scale + $base; + } + } + protected function lib_scale_color($args) { + return $this->alter_color($args, "scale_color_helper"); + } + + protected static $lib_ie_hex_str = array("color"); + protected function lib_ie_hex_str($args) { + $color = $this->coerceColor($args[0]); + $color[4] = isset($color[4]) ? round(255*$color[4]) : 255; + + return sprintf('#%02X%02X%02X%02X', $color[4], $color[1], $color[2], $color[3]); + } + + protected static $lib_red = array("color"); + protected function lib_red($args) { + $color = $this->coerceColor($args[0]); + return $color[1]; + } + + protected static $lib_green = array("color"); + protected function lib_green($args) { + $color = $this->coerceColor($args[0]); + return $color[2]; + } + + protected static $lib_blue = array("color"); + protected function lib_blue($args) { + $color = $this->coerceColor($args[0]); + return $color[3]; + } + + protected static $lib_alpha = array("color"); + protected function lib_alpha($args) { + if ($color = $this->coerceColor($args[0])) { + return isset($color[4]) ? $color[4] : 1; + } + + // this might be the IE function, so return value unchanged + return null; + } + + protected static $lib_opacity = array("color"); + protected function lib_opacity($args) { + $value = $args[0]; + if ($value[0] === 'number') return null; + return $this->lib_alpha($args); + } + + // mix two colors + protected static $lib_mix = array("color-1", "color-2", "weight"); + protected function lib_mix($args) { + list($first, $second, $weight) = $args; + $first = $this->assertColor($first); + $second = $this->assertColor($second); + + if (!isset($weight)) { + $weight = 0.5; + } else { + $weight = $this->coercePercent($weight); + } + + $firstAlpha = isset($first[4]) ? $first[4] : 1; + $secondAlpha = isset($second[4]) ? $second[4] : 1; + + $w = $weight * 2 - 1; + $a = $firstAlpha - $secondAlpha; + + $w1 = (($w * $a == -1 ? $w : ($w + $a)/(1 + $w * $a)) + 1) / 2.0; + $w2 = 1.0 - $w1; + + $new = array('color', + $w1 * $first[1] + $w2 * $second[1], + $w1 * $first[2] + $w2 * $second[2], + $w1 * $first[3] + $w2 * $second[3], + ); + + if ($firstAlpha != 1.0 || $secondAlpha != 1.0) { + $new[] = $firstAlpha * $weight + $secondAlpha * ($weight - 1); + } + + return $this->fixColor($new); + } + + protected static $lib_hsl = array("hue", "saturation", "lightness"); + protected function lib_hsl($args) { + list($h, $s, $l) = $args; + return $this->toRGB($h[1], $s[1], $l[1]); + } + + protected static $lib_hsla = array("hue", "saturation", + "lightness", "alpha"); + protected function lib_hsla($args) { + list($h, $s, $l, $a) = $args; + $color = $this->toRGB($h[1], $s[1], $l[1]); + $color[4] = $a[1]; + return $color; + } + + protected static $lib_hue = array("color"); + protected function lib_hue($args) { + $color = $this->assertColor($args[0]); + $hsl = $this->toHSL($color[1], $color[2], $color[3]); + return array("number", $hsl[1], "deg"); + } + + protected static $lib_saturation = array("color"); + protected function lib_saturation($args) { + $color = $this->assertColor($args[0]); + $hsl = $this->toHSL($color[1], $color[2], $color[3]); + return array("number", $hsl[2], "%"); + } + + protected static $lib_lightness = array("color"); + protected function lib_lightness($args) { + $color = $this->assertColor($args[0]); + $hsl = $this->toHSL($color[1], $color[2], $color[3]); + return array("number", $hsl[3], "%"); + } + + protected function adjustHsl($color, $idx, $amount) { + $hsl = $this->toHSL($color[1], $color[2], $color[3]); + $hsl[$idx] += $amount; + $out = $this->toRGB($hsl[1], $hsl[2], $hsl[3]); + if (isset($color[4])) $out[4] = $color[4]; + return $out; + } + + protected static $lib_adjust_hue = array("color", "degrees"); + protected function lib_adjust_hue($args) { + $color = $this->assertColor($args[0]); + $degrees = $this->assertNumber($args[1]); + return $this->adjustHsl($color, 1, $degrees); + } + + protected static $lib_lighten = array("color", "amount"); + protected function lib_lighten($args) { + $color = $this->assertColor($args[0]); + $amount = 100*$this->coercePercent($args[1]); + return $this->adjustHsl($color, 3, $amount); + } + + protected static $lib_darken = array("color", "amount"); + protected function lib_darken($args) { + $color = $this->assertColor($args[0]); + $amount = 100*$this->coercePercent($args[1]); + return $this->adjustHsl($color, 3, -$amount); + } + + protected static $lib_saturate = array("color", "amount"); + protected function lib_saturate($args) { + $value = $args[0]; + if ($value[0] === 'number') return null; + $color = $this->assertColor($value); + $amount = 100*$this->coercePercent($args[1]); + return $this->adjustHsl($color, 2, $amount); + } + + protected static $lib_desaturate = array("color", "amount"); + protected function lib_desaturate($args) { + $color = $this->assertColor($args[0]); + $amount = 100*$this->coercePercent($args[1]); + return $this->adjustHsl($color, 2, -$amount); + } + + protected static $lib_grayscale = array("color"); + protected function lib_grayscale($args) { + $value = $args[0]; + if ($value[0] === 'number') return null; + return $this->adjustHsl($this->assertColor($value), 2, -100); + } + + protected static $lib_complement = array("color"); + protected function lib_complement($args) { + return $this->adjustHsl($this->assertColor($args[0]), 1, 180); + } + + protected static $lib_invert = array("color"); + protected function lib_invert($args) { + $value = $args[0]; + if ($value[0] === 'number') return null; + $color = $this->assertColor($value); + $color[1] = 255 - $color[1]; + $color[2] = 255 - $color[2]; + $color[3] = 255 - $color[3]; + return $color; + } + + // increases opacity by amount + protected static $lib_opacify = array("color", "amount"); + protected function lib_opacify($args) { + $color = $this->assertColor($args[0]); + $amount = $this->coercePercent($args[1]); + + $color[4] = (isset($color[4]) ? $color[4] : 1) + $amount; + $color[4] = min(1, max(0, $color[4])); + return $color; + } + + protected static $lib_fade_in = array("color", "amount"); + protected function lib_fade_in($args) { + return $this->lib_opacify($args); + } + + // decreases opacity by amount + protected static $lib_transparentize = array("color", "amount"); + protected function lib_transparentize($args) { + $color = $this->assertColor($args[0]); + $amount = $this->coercePercent($args[1]); + + $color[4] = (isset($color[4]) ? $color[4] : 1) - $amount; + $color[4] = min(1, max(0, $color[4])); + return $color; + } + + protected static $lib_fade_out = array("color", "amount"); + protected function lib_fade_out($args) { + return $this->lib_transparentize($args); + } + + protected static $lib_unquote = array("string"); + protected function lib_unquote($args) { + $str = $args[0]; + if ($str[0] == "string") $str[1] = ""; + return $str; + } + + protected static $lib_quote = array("string"); + protected function lib_quote($args) { + $value = $args[0]; + if ($value[0] == "string" && !empty($value[1])) + return $value; + return array("string", '"', array($value)); + } + + protected static $lib_percentage = array("value"); + protected function lib_percentage($args) { + return array("number", + $this->coercePercent($args[0]) * 100, + "%"); + } + + protected static $lib_round = array("value"); + protected function lib_round($args) { + $num = $args[0]; + $num[1] = round($num[1]); + return $num; + } + + protected static $lib_floor = array("value"); + protected function lib_floor($args) { + $num = $args[0]; + $num[1] = floor($num[1]); + return $num; + } + + protected static $lib_ceil = array("value"); + protected function lib_ceil($args) { + $num = $args[0]; + $num[1] = ceil($num[1]); + return $num; + } + + protected static $lib_abs = array("value"); + protected function lib_abs($args) { + $num = $args[0]; + $num[1] = abs($num[1]); + return $num; + } + + protected function lib_min($args) { + $numbers = $this->getNormalizedNumbers($args); + $min = null; + foreach ($numbers as $key => $number) { + if (null === $min || $number[1] <= $min[1]) { + $min = array($key, $number[1]); + } + } + + return $args[$min[0]]; + } + + protected function lib_max($args) { + $numbers = $this->getNormalizedNumbers($args); + $max = null; + foreach ($numbers as $key => $number) { + if (null === $max || $number[1] >= $max[1]) { + $max = array($key, $number[1]); + } + } + + return $args[$max[0]]; + } + + protected function getNormalizedNumbers($args) { + $unit = null; + $originalUnit = null; + $numbers = array(); + foreach ($args as $key => $item) { + if ('number' != $item[0]) { + $this->throwError("%s is not a number", $item[0]); + } + $number = $this->normalizeNumber($item); + + if (null === $unit) { + $unit = $number[2]; + $originalUnit = $item[2]; + } elseif ($unit !== $number[2]) { + $this->throwError('Incompatible units: "%s" and "%s".', $originalUnit, $item[2]); + } + + $numbers[$key] = $number; + } + + return $numbers; + } + + protected static $lib_length = array("list"); + protected function lib_length($args) { + $list = $this->coerceList($args[0]); + return count($list[2]); + } + + protected static $lib_nth = array("list", "n"); + protected function lib_nth($args) { + $list = $this->coerceList($args[0]); + $n = $this->assertNumber($args[1]) - 1; + return isset($list[2][$n]) ? $list[2][$n] : self::$defaultValue; + } + + protected function listSeparatorForJoin($list1, $sep) { + if (!isset($sep)) return $list1[1]; + switch ($this->compileValue($sep)) { + case "comma": + return ","; + case "space": + return ""; + default: + return $list1[1]; + } + } + + protected static $lib_join = array("list1", "list2", "separator"); + protected function lib_join($args) { + list($list1, $list2, $sep) = $args; + $list1 = $this->coerceList($list1, " "); + $list2 = $this->coerceList($list2, " "); + $sep = $this->listSeparatorForJoin($list1, $sep); + return array("list", $sep, array_merge($list1[2], $list2[2])); + } + + protected static $lib_append = array("list", "val", "separator"); + protected function lib_append($args) { + list($list1, $value, $sep) = $args; + $list1 = $this->coerceList($list1, " "); + $sep = $this->listSeparatorForJoin($list1, $sep); + return array("list", $sep, array_merge($list1[2], array($value))); + } + + protected function lib_zip($args) { + foreach ($args as $arg) { + $this->assertList($arg); + } + + $lists = array(); + $firstList = array_shift($args); + foreach ($firstList[2] as $key => $item) { + $list = array("list", "", array($item)); + foreach ($args as $arg) { + if (isset($arg[2][$key])) { + $list[2][] = $arg[2][$key]; + } else { + break 2; + } + } + $lists[] = $list; + } + + return array("list", ",", $lists); + } + + protected static $lib_type_of = array("value"); + protected function lib_type_of($args) { + $value = $args[0]; + switch ($value[0]) { + case "keyword": + if ($value == self::$true || $value == self::$false) { + return "bool"; + } + + if ($this->coerceColor($value)) { + return "color"; + } + + return "string"; + default: + return $value[0]; + } + } + + protected static $lib_unit = array("number"); + protected function lib_unit($args) { + $num = $args[0]; + if ($num[0] == "number") { + return array("string", '"', array($num[2])); + } + return ""; + } + + protected static $lib_unitless = array("number"); + protected function lib_unitless($args) { + $value = $args[0]; + return $value[0] == "number" && empty($value[2]); + } + + protected static $lib_comparable = array("number-1", "number-2"); + protected function lib_comparable($args) { + list($number1, $number2) = $args; + if (!isset($number1[0]) || $number1[0] != "number" || !isset($number2[0]) || $number2[0] != "number") { + $this->throwError('Invalid argument(s) for "comparable"'); + } + + $number1 = $this->normalizeNumber($number1); + $number2 = $this->normalizeNumber($number2); + + return $number1[2] == $number2[2] || $number1[2] == "" || $number2[2] == ""; + } + + /** + * Workaround IE7's content counter bug. + * + * @param array $args + */ + protected function lib_counter($args) { + $list = array_map(array($this, 'compileValue'), $args); + return array('string', '', array('counter(' . implode(',', $list) . ')')); + } + + public function throwError($msg = null) { + if (func_num_args() > 1) { + $msg = call_user_func_array("sprintf", func_get_args()); + } + + if ($this->sourcePos >= 0 && isset($this->sourceParser)) { + $this->sourceParser->throwParseError($msg, $this->sourcePos); + } + + throw new Exception($msg); + } + + /** + * CSS Colors + * + * @see http://www.w3.org/TR/css3-color + */ + static protected $cssColors = array( + 'aliceblue' => '240,248,255', + 'antiquewhite' => '250,235,215', + 'aqua' => '0,255,255', + 'aquamarine' => '127,255,212', + 'azure' => '240,255,255', + 'beige' => '245,245,220', + 'bisque' => '255,228,196', + 'black' => '0,0,0', + 'blanchedalmond' => '255,235,205', + 'blue' => '0,0,255', + 'blueviolet' => '138,43,226', + 'brown' => '165,42,42', + 'burlywood' => '222,184,135', + 'cadetblue' => '95,158,160', + 'chartreuse' => '127,255,0', + 'chocolate' => '210,105,30', + 'coral' => '255,127,80', + 'cornflowerblue' => '100,149,237', + 'cornsilk' => '255,248,220', + 'crimson' => '220,20,60', + 'cyan' => '0,255,255', + 'darkblue' => '0,0,139', + 'darkcyan' => '0,139,139', + 'darkgoldenrod' => '184,134,11', + 'darkgray' => '169,169,169', + 'darkgreen' => '0,100,0', + 'darkgrey' => '169,169,169', + 'darkkhaki' => '189,183,107', + 'darkmagenta' => '139,0,139', + 'darkolivegreen' => '85,107,47', + 'darkorange' => '255,140,0', + 'darkorchid' => '153,50,204', + 'darkred' => '139,0,0', + 'darksalmon' => '233,150,122', + 'darkseagreen' => '143,188,143', + 'darkslateblue' => '72,61,139', + 'darkslategray' => '47,79,79', + 'darkslategrey' => '47,79,79', + 'darkturquoise' => '0,206,209', + 'darkviolet' => '148,0,211', + 'deeppink' => '255,20,147', + 'deepskyblue' => '0,191,255', + 'dimgray' => '105,105,105', + 'dimgrey' => '105,105,105', + 'dodgerblue' => '30,144,255', + 'firebrick' => '178,34,34', + 'floralwhite' => '255,250,240', + 'forestgreen' => '34,139,34', + 'fuchsia' => '255,0,255', + 'gainsboro' => '220,220,220', + 'ghostwhite' => '248,248,255', + 'gold' => '255,215,0', + 'goldenrod' => '218,165,32', + 'gray' => '128,128,128', + 'green' => '0,128,0', + 'greenyellow' => '173,255,47', + 'grey' => '128,128,128', + 'honeydew' => '240,255,240', + 'hotpink' => '255,105,180', + 'indianred' => '205,92,92', + 'indigo' => '75,0,130', + 'ivory' => '255,255,240', + 'khaki' => '240,230,140', + 'lavender' => '230,230,250', + 'lavenderblush' => '255,240,245', + 'lawngreen' => '124,252,0', + 'lemonchiffon' => '255,250,205', + 'lightblue' => '173,216,230', + 'lightcoral' => '240,128,128', + 'lightcyan' => '224,255,255', + 'lightgoldenrodyellow' => '250,250,210', + 'lightgray' => '211,211,211', + 'lightgreen' => '144,238,144', + 'lightgrey' => '211,211,211', + 'lightpink' => '255,182,193', + 'lightsalmon' => '255,160,122', + 'lightseagreen' => '32,178,170', + 'lightskyblue' => '135,206,250', + 'lightslategray' => '119,136,153', + 'lightslategrey' => '119,136,153', + 'lightsteelblue' => '176,196,222', + 'lightyellow' => '255,255,224', + 'lime' => '0,255,0', + 'limegreen' => '50,205,50', + 'linen' => '250,240,230', + 'magenta' => '255,0,255', + 'maroon' => '128,0,0', + 'mediumaquamarine' => '102,205,170', + 'mediumblue' => '0,0,205', + 'mediumorchid' => '186,85,211', + 'mediumpurple' => '147,112,219', + 'mediumseagreen' => '60,179,113', + 'mediumslateblue' => '123,104,238', + 'mediumspringgreen' => '0,250,154', + 'mediumturquoise' => '72,209,204', + 'mediumvioletred' => '199,21,133', + 'midnightblue' => '25,25,112', + 'mintcream' => '245,255,250', + 'mistyrose' => '255,228,225', + 'moccasin' => '255,228,181', + 'navajowhite' => '255,222,173', + 'navy' => '0,0,128', + 'oldlace' => '253,245,230', + 'olive' => '128,128,0', + 'olivedrab' => '107,142,35', + 'orange' => '255,165,0', + 'orangered' => '255,69,0', + 'orchid' => '218,112,214', + 'palegoldenrod' => '238,232,170', + 'palegreen' => '152,251,152', + 'paleturquoise' => '175,238,238', + 'palevioletred' => '219,112,147', + 'papayawhip' => '255,239,213', + 'peachpuff' => '255,218,185', + 'peru' => '205,133,63', + 'pink' => '255,192,203', + 'plum' => '221,160,221', + 'powderblue' => '176,224,230', + 'purple' => '128,0,128', + 'red' => '255,0,0', + 'rosybrown' => '188,143,143', + 'royalblue' => '65,105,225', + 'saddlebrown' => '139,69,19', + 'salmon' => '250,128,114', + 'sandybrown' => '244,164,96', + 'seagreen' => '46,139,87', + 'seashell' => '255,245,238', + 'sienna' => '160,82,45', + 'silver' => '192,192,192', + 'skyblue' => '135,206,235', + 'slateblue' => '106,90,205', + 'slategray' => '112,128,144', + 'slategrey' => '112,128,144', + 'snow' => '255,250,250', + 'springgreen' => '0,255,127', + 'steelblue' => '70,130,180', + 'tan' => '210,180,140', + 'teal' => '0,128,128', + 'thistle' => '216,191,216', + 'tomato' => '255,99,71', + 'transparent' => '0,0,0,0', + 'turquoise' => '64,224,208', + 'violet' => '238,130,238', + 'wheat' => '245,222,179', + 'white' => '255,255,255', + 'whitesmoke' => '245,245,245', + 'yellow' => '255,255,0', + 'yellowgreen' => '154,205,50' + ); +} + +/** + * SCSS parser + * + * @author Leaf Corcoran + */ +class scss_parser { + static protected $precedence = array( + "or" => 0, + "and" => 1, + + '==' => 2, + '!=' => 2, + '<=' => 2, + '>=' => 2, + '=' => 2, + '<' => 3, + '>' => 2, + + '+' => 3, + '-' => 3, + '*' => 4, + '/' => 4, + '%' => 4, + ); + + static protected $operators = array("+", "-", "*", "/", "%", + "==", "!=", "<=", ">=", "<", ">", "and", "or"); + + static protected $operatorStr; + static protected $whitePattern; + static protected $commentMulti; + + static protected $commentSingle = "//"; + static protected $commentMultiLeft = "/*"; + static protected $commentMultiRight = "*/"; + + /** + * Constructor + * + * @param string $sourceName + * @param boolean $rootParser + */ + public function __construct($sourceName = null, $rootParser = true) { + $this->sourceName = $sourceName; + $this->rootParser = $rootParser; + + if (empty(self::$operatorStr)) { + self::$operatorStr = $this->makeOperatorStr(self::$operators); + + $commentSingle = $this->preg_quote(self::$commentSingle); + $commentMultiLeft = $this->preg_quote(self::$commentMultiLeft); + $commentMultiRight = $this->preg_quote(self::$commentMultiRight); + self::$commentMulti = $commentMultiLeft.'.*?'.$commentMultiRight; + self::$whitePattern = '/'.$commentSingle.'[^\n]*\s*|('.self::$commentMulti.')\s*|\s+/Ais'; + } + } + + static protected function makeOperatorStr($operators) { + return '('.implode('|', array_map(array('scss_parser','preg_quote'), + $operators)).')'; + } + + /** + * Parser buffer + * + * @param string $buffer; + * + * @return \StdClass + */ + public function parse($buffer) + { + $this->count = 0; + $this->env = null; + $this->inParens = false; + $this->eatWhiteDefault = true; + $this->insertComments = true; + $this->buffer = $buffer; + + $this->pushBlock(null); // root block + $this->whitespace(); + + while (false !== $this->parseChunk()) + ; + + if ($this->count != strlen($this->buffer)) { + $this->throwParseError(); + } + + if (!empty($this->env->parent)) { + $this->throwParseError("unclosed block"); + } + + $this->env->isRoot = true; + + return $this->env; + } + + /** + * Parse a single chunk off the head of the buffer and append it to the + * current parse environment. + * + * Returns false when the buffer is empty, or when there is an error. + * + * This function is called repeatedly until the entire document is + * parsed. + * + * This parser is most similar to a recursive descent parser. Single + * functions represent discrete grammatical rules for the language, and + * they are able to capture the text that represents those rules. + * + * Consider the function scssc::keyword(). (All parse functions are + * structured the same.) + * + * The function takes a single reference argument. When calling the + * function it will attempt to match a keyword on the head of the buffer. + * If it is successful, it will place the keyword in the referenced + * argument, advance the position in the buffer, and return true. If it + * fails then it won't advance the buffer and it will return false. + * + * All of these parse functions are powered by scssc::match(), which behaves + * the same way, but takes a literal regular expression. Sometimes it is + * more convenient to use match instead of creating a new function. + * + * Because of the format of the functions, to parse an entire string of + * grammatical rules, you can chain them together using &&. + * + * But, if some of the rules in the chain succeed before one fails, then + * the buffer position will be left at an invalid state. In order to + * avoid this, scssc::seek() is used to remember and set buffer positions. + * + * Before parsing a chain, use $s = $this->seek() to remember the current + * position into $s. Then if a chain fails, use $this->seek($s) to + * go back where we started. + * + * @return boolean + */ + protected function parseChunk() { + $s = $this->seek(); + + // the directives + if (isset($this->buffer[$this->count]) && $this->buffer[$this->count] == "@") { + if ($this->literal("@media") && $this->mediaQueryList($mediaQueryList) && $this->literal("{")) { + $media = $this->pushSpecialBlock("media"); + $media->queryList = $mediaQueryList[2]; + return true; + } else { + $this->seek($s); + } + + if ($this->literal("@mixin") && + $this->keyword($mixinName) && + ($this->argumentDef($args) || true) && + $this->literal("{")) + { + $mixin = $this->pushSpecialBlock("mixin"); + $mixin->name = $mixinName; + $mixin->args = $args; + return true; + } else { + $this->seek($s); + } + + if ($this->literal("@include") && + $this->keyword($mixinName) && + ($this->literal("(") && + ($this->argValues($argValues) || true) && + $this->literal(")") || true) && + ($this->end() || + $this->literal("{") && $hasBlock = true)) + { + $child = array("include", + $mixinName, isset($argValues) ? $argValues : null, null); + + if (!empty($hasBlock)) { + $include = $this->pushSpecialBlock("include"); + $include->child = $child; + } else { + $this->append($child, $s); + } + + return true; + } else { + $this->seek($s); + } + + if ($this->literal("@import") && + $this->valueList($importPath) && + $this->end()) + { + $this->append(array("import", $importPath), $s); + return true; + } else { + $this->seek($s); + } + + if ($this->literal("@extend") && + $this->selectors($selector) && + $this->end()) + { + $this->append(array("extend", $selector), $s); + return true; + } else { + $this->seek($s); + } + + if ($this->literal("@function") && + $this->keyword($fnName) && + $this->argumentDef($args) && + $this->literal("{")) + { + $func = $this->pushSpecialBlock("function"); + $func->name = $fnName; + $func->args = $args; + return true; + } else { + $this->seek($s); + } + + if ($this->literal("@return") && $this->valueList($retVal) && $this->end()) { + $this->append(array("return", $retVal), $s); + return true; + } else { + $this->seek($s); + } + + if ($this->literal("@each") && + $this->variable($varName) && + $this->literal("in") && + $this->valueList($list) && + $this->literal("{")) + { + $each = $this->pushSpecialBlock("each"); + $each->var = $varName[1]; + $each->list = $list; + return true; + } else { + $this->seek($s); + } + + if ($this->literal("@while") && + $this->expression($cond) && + $this->literal("{")) + { + $while = $this->pushSpecialBlock("while"); + $while->cond = $cond; + return true; + } else { + $this->seek($s); + } + + if ($this->literal("@for") && + $this->variable($varName) && + $this->literal("from") && + $this->expression($start) && + ($this->literal("through") || + ($forUntil = true && $this->literal("to"))) && + $this->expression($end) && + $this->literal("{")) + { + $for = $this->pushSpecialBlock("for"); + $for->var = $varName[1]; + $for->start = $start; + $for->end = $end; + $for->until = isset($forUntil); + return true; + } else { + $this->seek($s); + } + + if ($this->literal("@if") && $this->valueList($cond) && $this->literal("{")) { + $if = $this->pushSpecialBlock("if"); + $if->cond = $cond; + $if->cases = array(); + return true; + } else { + $this->seek($s); + } + + if (($this->literal("@debug") || $this->literal("@warn")) && + $this->valueList($value) && + $this->end()) { + $this->append(array("debug", $value, $s), $s); + return true; + } else { + $this->seek($s); + } + + if ($this->literal("@content") && $this->end()) { + $this->append(array("mixin_content"), $s); + return true; + } else { + $this->seek($s); + } + + $last = $this->last(); + if (isset($last) && $last[0] == "if") { + list(, $if) = $last; + if ($this->literal("@else")) { + if ($this->literal("{")) { + $else = $this->pushSpecialBlock("else"); + } elseif ($this->literal("if") && $this->valueList($cond) && $this->literal("{")) { + $else = $this->pushSpecialBlock("elseif"); + $else->cond = $cond; + } + + if (isset($else)) { + $else->dontAppend = true; + $if->cases[] = $else; + return true; + } + } + + $this->seek($s); + } + + if ($this->literal("@charset") && + $this->valueList($charset) && $this->end()) + { + $this->append(array("charset", $charset), $s); + return true; + } else { + $this->seek($s); + } + + // doesn't match built in directive, do generic one + if ($this->literal("@", false) && $this->keyword($dirName) && + ($this->openString("{", $dirValue) || true) && + $this->literal("{")) + { + $directive = $this->pushSpecialBlock("directive"); + $directive->name = $dirName; + if (isset($dirValue)) $directive->value = $dirValue; + return true; + } + + $this->seek($s); + return false; + } + + // property shortcut + // captures most properties before having to parse a selector + if ($this->keyword($name, false) && + $this->literal(": ") && + $this->valueList($value) && + $this->end()) + { + $name = array("string", "", array($name)); + $this->append(array("assign", $name, $value), $s); + return true; + } else { + $this->seek($s); + } + + // variable assigns + if ($this->variable($name) && + $this->literal(":") && + $this->valueList($value) && $this->end()) + { + // check for !default + $defaultVar = $value[0] == "list" && $this->stripDefault($value); + $this->append(array("assign", $name, $value, $defaultVar), $s); + return true; + } else { + $this->seek($s); + } + + // misc + if ($this->literal("-->")) { + return true; + } + + // opening css block + $oldComments = $this->insertComments; + $this->insertComments = false; + if ($this->selectors($selectors) && $this->literal("{")) { + $this->pushBlock($selectors); + $this->insertComments = $oldComments; + return true; + } else { + $this->seek($s); + } + $this->insertComments = $oldComments; + + // property assign, or nested assign + if ($this->propertyName($name) && $this->literal(":")) { + $foundSomething = false; + if ($this->valueList($value)) { + $this->append(array("assign", $name, $value), $s); + $foundSomething = true; + } + + if ($this->literal("{")) { + $propBlock = $this->pushSpecialBlock("nestedprop"); + $propBlock->prefix = $name; + $foundSomething = true; + } elseif ($foundSomething) { + $foundSomething = $this->end(); + } + + if ($foundSomething) { + return true; + } + + $this->seek($s); + } else { + $this->seek($s); + } + + // closing a block + if ($this->literal("}")) { + $block = $this->popBlock(); + if (isset($block->type) && $block->type == "include") { + $include = $block->child; + unset($block->child); + $include[3] = $block; + $this->append($include, $s); + } elseif (empty($block->dontAppend)) { + $type = isset($block->type) ? $block->type : "block"; + $this->append(array($type, $block), $s); + } + return true; + } + + // extra stuff + if ($this->literal(";") || + $this->literal("