_cfg = $config; } public function getAcceptedWorkUnits($tags) { $work_units = []; foreach ($tags as $tag) { $r = $this->workUnitFromTag($tag['tag'], $tag['attrdata'], $tag['content']); if ($r === false) { continue; } // handler has declined $r = array_merge($r, [ 'page_order' => $tag['page_order'], 'position' => $tag['index'], 'length' => strlen($tag['tagdata']), 'tag' => $tag['tag'], ]); $work_units[] = $r; } return $work_units; } public function workUnitFromTag($tag, $attrdata, $content) { switch ($tag) { case 'link': case 'style': $fn = 'extract_style_unit'; break; case 'script': $fn = 'extract_script_unit'; break; default: throw new Exception("Cannot handle tag: ({$tag})"); } return $this->$fn($tag, $attrdata, $content); } private function extract_attrs($attstr) { // The attribute name regex is too relaxed, but let's // compromise and keep it simple. $attextract = '#([a-z\-]+)\s*=\s*(["\'])\s*(.*?)\s*\2#'; if (!preg_match_all($attextract, $attstr, $m)) { return false; } $res = []; foreach ($m[1] as $idx => $name) { $res[strtolower($name)] = $m[3][$idx]; } return $res; } private function urlToFile($ref) { $u = parse_url($ref); if ($u === false) { return false; } if (isset($u['host']) || isset($u['scheme'])) { return $ref; } if ($this->_cfg->get('query_strings') == 'ignore') { if (isset($u['query'])) { return false; } } $ref = $u['path']; $path = [$_SERVER['DOCUMENT_ROOT']]; if (isAdministration()) { $cfg = \KupShop\KupShopBundle\Config::get(); $path[] = $cfg['Path']['admin']; } $path[] = $ref; return realpath(implode(DIRECTORY_SEPARATOR, $path)); } private function extract_style_unit($tag, $attrdata, $content) { $attrs = $this->extract_attrs($attrdata); $attrs['type'] = strtolower($attrs['type'] ?? ''); // invalid markup if ($tag == 'link' && !empty($content)) { return false; } if ($tag == 'style' && empty($content)) { return false; } if ($tag == 'link' && empty($attrs['href'])) { return false; } // not a stylesheet if ($tag == 'link' && strtolower($attrs['rel']) != 'stylesheet') { return false; } // type attribute required if (!isset($attrs['type'])) { return false; } // not one of the supported types if (!in_array(strtolower($attrs['type']), CssRenderHandler::supportedTransformations())) { return false; } // in debug mode 3, only transform if ($this->_cfg->getDebugMode() == 3 && !CssRenderHandler::willTransformType($attrs['type']) ) { return false; } if (!isset($attrs['media'])) { $attrs['media'] = ''; } $include = null; if (isset($attrs['include'])) { $include = explode(';', $attrs['include']); } $path = null; if (empty($content)) { $path = $this->urlToFile($attrs['href']); if ($path === false) { return false; } } $group = serialize($this->_cfg->get('merge_tags') ? [$attrs['media'], $attrs['type']] : [$attrs['media']]); return [ 'group' => $group, 'file' => $path, 'content' => $content, 'type' => $attrs['type'], 'paths' => $include, ]; } private function validTag($attrs) { $types = array_merge(['text/javascript', 'application/javascript'], JavaScriptRenderHandler::supportedTransformations()); return in_array($attrs['type'], $types); } private function extract_script_unit($tag, $attrdata, $content) { $attrs = $this->extract_attrs($attrdata); if (empty($attrs['type'])) { $attrs['type'] = 'text/javascript'; } $attrs['type'] = strtolower($attrs['type']); if ($this->_cfg->getDebugMode() == 3 && !JavaScriptRenderHandler::willTransformType($attrs['type']) ) { return false; } if ($this->validTag($attrs)) { $path = null; if (!$content) { $path = $this->urlToFile($attrs['src']); if ($path === false) { return false; } } return [ 'group' => '', 'content' => $content, 'file' => $path, 'data' => $this->parseDataAttrs($attrs), 'type' => $attrs['type'], ]; } return false; } private function parseDataAttrs($attrs) { $data = []; foreach ($attrs as $key => $value) { // Compromising again here on the valid // format of the attr key, to keep the // regex simple. if (preg_match('#^data-([a-z\-]+)$#', $key, $match)) { $name = $match[1]; $data[$name] = $value; } } return $data; } } class Config { private $params; public function get($key) { return $this->params[$key] ?? null; } public function __construct($params = null) { $this->params['query_strings'] = defined('SACY_QUERY_STRINGS') ? SACY_QUERY_STRINGS : 'ignore'; $this->params['write_headers'] = defined('SACY_WRITE_HEADERS') ? SACY_WRITE_HEADERS : true; $this->params['debug_toggle'] = defined('SACY_DEBUG_TOGGLE') ? SACY_DEBUG_TOGGLE : '_sacy_debug'; $this->params['merge_tags'] = false; if (is_array($params)) { $this->setParams($params); } } public function getDebugMode() { if ($this->params['debug_toggle'] === false) { return 0; } if (isset($_GET[$this->params['debug_toggle']])) { return intval($_GET[$this->params['debug_toggle']]); } if (isset($_COOKIE[$this->params['debug_toggle']])) { return intval($_COOKIE[$this->params['debug_toggle']]); } if (isDevelopment()) { return 3; } return 0; } public function setParams($params) { foreach ($params as $key => $value) { if (!in_array($key, ['merge_tags', 'query_strings', 'write_headers', 'debug_toggle', 'block_ref'])) { throw new Exception("Invalid option: {$key}"); } } if (isset($params['query_strings']) && !in_array($params['query_strings'], ['force-handle', 'ignore'])) { throw new Exception('Invalid setting for query_strings: '.$params['query_strings']); } if (isset($params['write_headers']) && !in_array($params['write_headers'], [true, false], true)) { throw new Exception('Invalid setting for write_headers: '.$params['write_headers']); } $params['merge_tags'] = (bool) getVal('merge_tags', $params); $this->params = array_merge($this->params, $params); } } class CacheRenderer { private $_cfg; private $_source_file; /** @var FileCache */ private $fragment_cache; private $rendered_bits; public function __construct(Config $config, $source_file) { $this->_cfg = $config; $this->_source_file = $source_file; $this->rendered_bits = []; $class = defined('SACY_FRAGMENT_CACHE_CLASS') ? SACY_FRAGMENT_CACHE_CLASS : 'sacy\FileCache'; $this->fragment_cache = new $class(); foreach (['get', 'set'] as $m) { if (!method_exists($this->fragment_cache, $m)) { throw new Exception('Invalid fragment cache class specified'); } } } public function allowMergedTransformOnly($tag) { return $tag == 'script'; } public function renderWorkUnits($tag, $cat, $work_units) { switch ($tag) { case 'link': case 'style': $fn = 'render_style_units'; break; case 'script': $fn = 'render_script_units'; break; default: throw new Exception("Cannot handle tag: {$tag}"); } return $this->$fn($work_units, $cat); } public function getRenderedAssets() { return array_reverse($this->rendered_bits); } private function render_style_units($work_units, $cat) { // we can do this because tags are grouped by the presence of a file or not $cs = ''; if ($cat) { $c = unserialize($cat); $cs = $cat ? sprintf(' media="%s"', htmlspecialchars($c[0], ENT_QUOTES)) : ''; } if ($work_units[0]['file']) { if ($res = $this->generate_file_cache($work_units, new CssRenderHandler($this->_cfg, $this->_source_file))) { $res = sprintf(''."\n", $cs, htmlspecialchars($res, ENT_QUOTES)); } } else { $res = $this->generate_content_cache($work_units, new CssRenderHandler($this->_cfg, $this->_source_file)); $res = sprintf(''."\n", $cs, $res); } return $res; } private function render_script_units($work_units, $cat) { if ($work_units[0]['file']) { if ($res = $this->generate_file_cache($work_units, new JavaScriptRenderHandler($this->_cfg, $this->_source_file))) { $this->rendered_bits[] = ['type' => 'file', 'src' => $res]; return sprintf(''."\n", htmlspecialchars($res, ENT_QUOTES)); } } else { $res = $this->generate_content_cache($work_units, new JavaScriptRenderHandler($this->_cfg, $this->_source_file)); if ($res) { $this->rendered_bits[] = ['type' => 'string', 'content' => $res]; } return sprintf(''."\n", $res); } return ''; } private function generate_content_cache($work_units, CacheRenderHandler $rh) { $content = implode("\n", array_map(function ($u) { return $u['content']; }, $work_units)); $key = md5($content.$this->_cfg->getDebugMode()); if ($d = $this->fragment_cache->get($key)) { return $d; } $output = []; foreach ($work_units as $w) { $output[] = $rh->getOutput($w); } $output = implode("\n", $output); $this->fragment_cache->set($key, $output); return $output; } private function content_key_for_mtime_key($key, $work_units) { if (!(defined('SACY_USE_CONTENT_BASED_CACHE') && SACY_USE_CONTENT_BASED_CACHE)) { return $key; } $cache_key = 'ck-for-mkey-'.$key; $ck = $this->fragment_cache->get($cache_key); if (!$ck) { $ck = ''; foreach ($work_units as $f) { $ck = md5($ck.md5_file($f['file'])); foreach ($f['additional_files'] as $af) { $ck = md5($ck.md5_file($af)); } } $ck = "{$ck}-content"; $this->fragment_cache->set($cache_key, $ck); } return $ck; } private function generate_file_cache($work_units, CacheRenderHandler $rh) { if (!is_dir(ASSET_COMPILE_OUTPUT_DIR)) { if (!@mkdir(ASSET_COMPILE_OUTPUT_DIR, 0755, true)) { throw new Exception('Failed to create output directory'); } } $f = function ($f) { return createScriptURL_Text(pathinfo($f['file'], PATHINFO_FILENAME)); }; $ident = implode('-', array_map($f, $work_units)); if (strlen($ident) > 120) { $ident = 'many-files-'.md5($ident); } $max = 0; $idents = []; foreach ($work_units as &$f) { $idents[] = [ $f['group'], $f['file'], $f['type'], $f['tag'], ]; $f['additional_files'] = $rh->getAdditionalFiles($f); $max = max($max, filemtime($f['file'])); foreach ($f['additional_files'] as $af) { $max = max($max, filemtime($af)); } unset($f); } // not using the actual content for quicker access $key = md5($max.serialize($idents).$rh->getConfig()->getDebugMode()); $key = $this->content_key_for_mtime_key($key, $work_units); $cfile = ASSET_COMPILE_OUTPUT_DIR.DIRECTORY_SEPARATOR."{$ident}-{$key}".$rh->getFileExtension(); $pub = ASSET_COMPILE_URL_ROOT."/{$ident}-{$key}".$rh->getFileExtension(); if (file_exists($cfile) && ($rh->getConfig()->getDebugMode() != 2)) { return $pub; } $this->write_cache($cfile, $work_units, $rh); return $pub; } private function write_cache($cfile, $files, CacheRenderHandler $rh) { $tmpfile = $this->write_cache_tmpfile($cfile, $files, $rh); if ($tmpfile) { $ts = time(); $this->write_compressed_cache($tmpfile, $cfile, $ts); if (rename($tmpfile, $cfile)) { chmod($cfile, 0644); touch($cfile, $ts); } else { trigger_error("Cannot write file: {$cfile}", E_USER_WARNING); } } return (bool) $tmpfile; } private function write_compressed_cache($tmpfile, $cfile, $ts) { if (!function_exists('gzencode')) { return; } $tmp_compressed = "{$tmpfile}.gz"; file_put_contents($tmp_compressed, gzencode(file_get_contents($tmpfile), 9)); $compressed = "{$cfile}.gz"; if (rename($tmp_compressed, $compressed)) { touch($compressed, $ts); } else { trigger_error("Cannot write compressed file: {$compressed}", E_USER_WARNING); } } private function write_cache_tmpfile($cfile, $files, CacheRenderHandler $rh) { $tmpfile = tempnam(dirname($cfile), $cfile); $fhc = @fopen($tmpfile, 'w+'); if (!$fhc) { trigger_error("Cannot write to temporary file: {$tmpfile}", E_USER_WARNING); return null; } if ($rh->getConfig()->get('write_headers')) { $rh->writeHeader($fhc, $files); } $res = true; $merge = (bool) $rh->getConfig()->get('merge_tags'); if ($merge) { $rh->startWrite(); } foreach ($files as $file) { try { $rh->processFile($fhc, $file); } catch (\Exception $e) { getRaven()->captureException($e); throw $e; } } if ($merge) { $rh->endWrite($fhc); } fclose($fhc); return $res ? $tmpfile : null; } } interface CacheRenderHandler { public function __construct(Config $cfg, $source_file); public function getFileExtension(); public static function willTransformType($type); public function writeHeader($fh, $work_units); public function getAdditionalFiles($work_unit); public function processFile($fh, $work_unit); public function startWrite(); public function endWrite($fh); public function getOutput($work_unit); public function getConfig(); } abstract class ConfiguredRenderHandler implements CacheRenderHandler { private $_cfg; private $_source_file; public function __construct(Config $cfg, $source_file) { $this->_cfg = $cfg; $this->_source_file = $source_file; } protected function getSourceFile() { return $this->_source_file; } public function getConfig() { return $this->_cfg; } public static function willTransformType($type) { return false; } public function startWrite() { } public function endWrite($fh) { } } class JavaScriptRenderHandler extends ConfiguredRenderHandler { public static function supportedTransformations() { $supported = []; if (function_exists('CoffeeScript\compile') || ExternalProcessorRegistry::typeIsSupported('text/coffeescript')) { $supported[] = 'text/coffeescript'; } if (ExternalProcessorRegistry::typeIsSupported('text/x-eco')) { $supported[] = 'text/x-eco'; } if (ExternalProcessorRegistry::typeIsSupported('text/x-jsx')) { $supported[] = 'text/x-jsx'; } return $supported; } public static function willTransformType($type) { // transforming everything but plain old CSS return in_array($type, self::supportedTransformations()); } public function getFileExtension() { return '.js'; } public function writeHeader($fh, $work_units) { fwrite($fh, "/*\nsacy javascript cache dump \n\n"); fwrite($fh, "This dump has been created from the following files:\n"); foreach ($work_units as $file) { fprintf($fh, " - %s\n", str_replace($_SERVER['DOCUMENT_ROOT'], '', $file['file'])); } fwrite($fh, "*/\n\n"); } public function getOutput($work_unit) { $debug = $this->getConfig()->getDebugMode() == 3; if ($work_unit['file']) { $js = @file_get_contents($work_unit['file']); if (!$js) { return '/* error accessing file */'; } $source_file = $work_unit['file']; } else { $js = $work_unit['content']; $source_file = $this->getSourceFile(); } if ($work_unit['type'] == 'text/coffeescript') { $js = ExternalProcessorRegistry::typeIsSupported('text/coffeescript') ? ExternalProcessorRegistry::getTransformerForType('text/coffeescript')->transform($js, $source_file) : \Coffeescript::build($js); } elseif ($work_unit['type'] == 'text/x-eco') { $eco = ExternalProcessorRegistry::getTransformerForType('text/x-eco'); $js = $eco->transform($js, $source_file, $work_unit['data']); } elseif ($work_unit['type'] == 'text/x-jsx') { $jsx = ExternalProcessorRegistry::getTransformerForType('text/x-jsx'); $js = $jsx->transform($js, $source_file, $work_unit['data']); } if ($debug) { return $js; } else { return ExternalProcessorRegistry::typeIsSupported('text/javascript') ? ExternalProcessorRegistry::getCompressorForType('text/javascript')->transform($js, $source_file) : \JSMin::minify($js); } } public function processFile($fh, $work_unit) { if ($this->getConfig()->get('write_headers')) { fprintf($fh, "\n/* %s */\n", str_replace($_SERVER['DOCUMENT_ROOT'], '', $work_unit['file'])); } fwrite($fh, $this->getOutput($work_unit)); } public function getAdditionalFiles($work_unit) { return []; } } class CssRenderHandler extends ConfiguredRenderHandler { private $to_process = []; private $collecting = false; public static function supportedTransformations() { $res = ['', 'text/css']; if (class_exists('lessc') || ExternalProcessorRegistry::typeIsSupported('text/x-less')) { $res[] = 'text/x-less'; } if (class_exists('SassParser') || ExternalProcessorRegistry::typeIsSupported('text/x-sass')) { $res = array_merge($res, ['text/x-sass', 'text/x-scss']); } if (PhpSassSacy::isAvailable()) { $res[] = 'text/x-scss'; } if (ExternalProcessorRegistry::typeIsSupported('image/svg+xml')) { $res[] = 'image/svg+xml'; } return array_unique($res); } public function getFileExtension() { return '.css'; } public static function willTransformType($type) { // transforming everything but plain old CSS return !in_array($type, ['', 'text/css']); } public function writeHeader($fh, $work_units) { fwrite($fh, "/*\nsacy css cache dump \n\n"); fwrite($fh, "This dump has been created from the following files:\n"); foreach ($work_units as $file) { fprintf($fh, " - %s\n", str_replace($_SERVER['DOCUMENT_ROOT'], '', $file['file'])); } fwrite($fh, "*/\n\n"); } public function processFile($fh, $work_unit) { // for now: only support collecting for scss and sass if (!in_array($work_unit['type'], ['text/x-scss', 'text/x-sass'])) { $this->collecting = false; } if ($this->collecting) { $content = @file_get_contents($work_unit['file']); if (!$content) { $content = "/* error accessing file {$work_unit['file']} */"; } $content = \Minify_CSS_UriRewriter::rewrite( $content, dirname($work_unit['file']), $_SERVER['DOCUMENT_ROOT'], [], true ); $this->to_process[] = [ 'file' => $work_unit['file'], 'content' => $content, 'type' => $work_unit['type'], ]; } else { if ($this->getConfig()->get('write_headers')) { fprintf($fh, "\n/* %s */\n", str_replace($_SERVER['DOCUMENT_ROOT'], '', $work_unit['file'])); } fwrite($fh, $this->getOutput($work_unit)); } } public function endWrite($fh) { if (!$this->collecting) { return; } $content = ''; $incpath = []; foreach ($this->to_process as $job) { $content .= $job['content']; $incpath[] = dirname($job['file']); } fwrite($fh, $this->getOutput([ 'content' => $content, 'type' => $this->to_process[0]['type'], 'paths' => $incpath, ])); } public function getOutput($work_unit) { $debug = $this->getConfig()->getDebugMode() == 3; if ($work_unit['file']) { $css = @file_get_contents($work_unit['file']); if (!$css) { return '/* error accessing file */'; } $source_file = $work_unit['file']; } else { $css = $work_unit['content']; $source_file = $this->getSourceFile(); } if (ExternalProcessorRegistry::typeIsSupported($work_unit['type'])) { $opts = []; if ($work_unit['paths']) { $opts['library_path'] = $work_unit['paths']; } $css = ExternalProcessorRegistry::getTransformerForType($work_unit['type']) ->transform($css, $source_file, $opts); } else { if ($work_unit['type'] == 'text/x-less') { $less = new \lessc(); $less->importDir = dirname($source_file).'/'; // lessphp concatenates without a / $css = $less->parse($css); } if (PhpSassSacy::isAvailable() && $work_unit['type'] == 'text/x-scss') { $css = PhpSassSacy::compile($work_unit['file'], $css, $work_unit['paths'] ?: [dirname($source_file)]); } elseif (in_array($work_unit['type'], ['text/x-scss', 'text/x-sass'])) { $config = [ 'cache' => false, // no need. WE are the cache! 'debug_info' => $debug, 'line' => $debug, 'load_paths' => $work_unit['paths'] ?: [dirname($source_file)], 'filename' => $source_file, 'quiet' => true, 'style' => $debug ? 'nested' : 'compressed', ]; $sass = new \SassParser($config); $css = $sass->toCss($css, false); // isFile? } } if ($debug) { return \Minify_CSS_UriRewriter::rewrite( $css, dirname($source_file), $_SERVER['DOCUMENT_ROOT'], [] ); } else { return \Minify_CSS::minify($css, [ 'currentDir' => dirname($source_file), ]); } } private function extract_import_file($parent_type, $parent_file, $cssdata) { $f = null; if (preg_match('#^\s*url\((["\'])([^\1]+)\1#', $cssdata, $matches)) { $f = $matches[2]; } elseif (preg_match('#^\s*(["\'])([^\1]+)\1#', $cssdata, $matches)) { $f = $matches[2]; } $path_info = pathinfo($parent_file); if (in_array($parent_type, ['text/x-scss', 'text/x-sass'])) { $ext = preg_quote($path_info['extension'], '#'); if (!preg_match("#.{$ext}\$#", $f)) { $f .= '.'.$path_info['extension']; } $mixin = $path_info['dirname'].DIRECTORY_SEPARATOR."_{$f}"; if (file_exists($mixin)) { return $mixin; } } elseif ($parent_type == 'text/x-less') { // less only inlines @import's of .less files (see: http://lesscss.org/#-importing) if (!preg_match('#\.less$', $f)) { return null; } } else { return null; } $f = $path_info['dirname'].DIRECTORY_SEPARATOR.$f; return file_exists($f) ? $f : null; } private function find_imports($type, $file, $level) { $level++; if (!in_array($type, ['text/x-scss', 'text/x-sass', 'text/x-less'])) { return []; } if ($level > 10) { throw new Exception("CSS Include nesting level of {$level} too deep"); } $fh = fopen($file, 'r'); $res = []; while (false !== ($line = fgets($fh))) { if (preg_match('#^\s*$#', $line)) { continue; } if (preg_match('#^\s*@import(.*)$#', $line, $matches)) { $f = $this->extract_import_file($type, $file, $matches[1]); if ($f) { $res[] = $f; $res = array_merge($res, $this->find_imports($type, $f, $level)); } } } fclose($fh); return $res; } public function getAdditionalFiles($work_unit) { $level = 0; return $this->find_imports($work_unit['type'], $work_unit['file'], $level); } public function startWrite() { $this->to_process = []; $this->collecting = true; } }