or \n"; } return $output; } /** * Build the JavaScript script tags. * * @param string $group name of the group * @param array $attributes * * @return string */ public function js($group = 'head', $attributes = []) { if (!$this->js && !$this->inline_js) { return null; } // Sort array by priorities (larger priority first) uasort($this->js, array($this, 'sortAssetsByPriorityThenOrder')); uasort($this->inline_js, array($this, 'sortAssetsByPriorityThenOrder')); $inlineGroup = array_key_exists('loading', $attributes) && $attributes['loading'] === 'inline'; $attributes = $this->attributes($attributes); $output = ''; $inline_js = ''; if ($this->js_pipeline) { $pipeline_result = $this->pipelineJs($group, !$inlineGroup); $pipeline_html = ($inlineGroup ? '' : '' . "\n"); if ($this->js_pipeline_before_excludes && $pipeline_result) { if ($inlineGroup) { $inline_js .= $pipeline_result; } else { $output .= $pipeline_html; } } foreach ($this->js_no_pipeline as $file) { if ($group && $file['group'] == $group) { if ($file['loading'] === 'inline') { $inline_js .= $this->gatherLinks([$file], JS_ASSET) . "\n"; } else { $output .= '' . "\n"; } } } if (!$this->js_pipeline_before_excludes && $pipeline_result) { if ($inlineGroup) { $inline_js .= $pipeline_result; } else { $output .= $pipeline_html; } } } else { foreach ($this->js as $file) { if ($group && $file['group'] == $group) { if ($inlineGroup || $file['loading'] === 'inline') { $inline_js .= $this->gatherLinks([$file], JS_ASSET) . "\n"; } else { $output .= '' . "\n"; } } } } // Render Inline JS foreach ($this->inline_js as $inline) { if ($group && $inline['group'] == $group) { $inline_js .= $inline['asset'] . "\n"; } } if ($inline_js) { $attribute_string = isset($inline) && $inline['type'] ? " type=\"" . $inline['type'] . "\"" : ''; $output .= "\n\n" . $inline_js . "\n\n"; } return $output; } /** * Minify and concatenate CSS * * @param string $group * @param bool $returnURL true if pipeline should return the URL, otherwise the content * * @return bool|string URL or generated content if available, else false */ protected function pipelineCss($group = 'head', $returnURL = true) { // temporary list of assets to pipeline $temp_css = []; // clear no-pipeline assets lists $this->css_no_pipeline = []; // Compute uid based on assets and timestamp $uid = md5(json_encode($this->css) . $this->css_minify . $this->css_rewrite . $group); $file = $uid . '.css'; $inline_file = $uid . '-inline.css'; $relative_path = "{$this->base_url}{$this->assets_url}/{$file}"; // If inline files exist set them on object if (file_exists($this->assets_dir . $inline_file)) { $this->css_no_pipeline = json_decode(file_get_contents($this->assets_dir . $inline_file), true); } // If pipeline exist return its URL or content if (file_exists($this->assets_dir . $file)) { if ($returnURL) { return $relative_path . $this->getTimestamp(); } else { return file_get_contents($this->assets_dir . $file) . "\n"; } } // Remove any non-pipeline files foreach ($this->css as $id => $asset) { if ($asset['group'] == $group) { if (!$asset['pipeline'] || ($asset['remote'] && $this->css_pipeline_include_externals === false)) { $this->css_no_pipeline[$id] = $asset; } else { $temp_css[$id] = $asset; } } } //if nothing found get out of here! if (count($temp_css) == 0) { return false; } // Write non-pipeline files out if (!empty($this->css_no_pipeline)) { file_put_contents($this->assets_dir . $inline_file, json_encode($this->css_no_pipeline)); } $css_minify = $this->css_minify; // If this is a Windows server, and minify_windows is false (default value) skip the // minification process because it will cause Apache to die/crash due to insufficient // ThreadStackSize in httpd.conf - See: https://bugs.php.net/bug.php?id=47689 if (strtoupper(substr(php_uname('s'), 0, 3)) === 'WIN' && !$this->css_minify_windows) { $css_minify = false; } // Concatenate files $buffer = $this->gatherLinks($temp_css, CSS_ASSET); if ($css_minify) { $minifier = new \MatthiasMullie\Minify\CSS(); $minifier->add($buffer); $buffer = $minifier->minify(); } // Write file if (strlen(trim($buffer)) > 0) { file_put_contents($this->assets_dir . $file, $buffer); if ($returnURL) { return $relative_path . $this->getTimestamp(); } else { return $buffer . "\n"; } } else { return false; } } /** * Minify and concatenate JS files. * * @param string $group * @param bool $returnURL true if pipeline should return the URL, otherwise the content * * @return bool|string URL or generated content if available, else false */ protected function pipelineJs($group = 'head', $returnURL = true) { // temporary list of assets to pipeline $temp_js = []; // clear no-pipeline assets lists $this->js_no_pipeline = []; // Compute uid based on assets and timestamp $uid = md5(json_encode($this->js) . $this->js_minify . $group); $file = $uid . '.js'; $inline_file = $uid . '-inline.js'; $relative_path = "{$this->base_url}{$this->assets_url}/{$file}"; // If inline files exist set them on object if (file_exists($this->assets_dir . $inline_file)) { $this->js_no_pipeline = json_decode(file_get_contents($this->assets_dir . $inline_file), true); } // If pipeline exist return its URL or content if (file_exists($this->assets_dir . $file)) { if ($returnURL) { return $relative_path . $this->getTimestamp(); } else { return file_get_contents($this->assets_dir . $file) . "\n"; } } // Remove any non-pipeline files foreach ($this->js as $id => $asset) { if ($asset['group'] == $group) { if (!$asset['pipeline'] || ($asset['remote'] && $this->js_pipeline_include_externals === false)) { $this->js_no_pipeline[] = $asset; } else { $temp_js[$id] = $asset; } } } //if nothing found get out of here! if (count($temp_js) == 0) { return false; } // Write non-pipeline files out if (!empty($this->js_no_pipeline)) { file_put_contents($this->assets_dir . $inline_file, json_encode($this->js_no_pipeline)); } // Concatenate files $buffer = $this->gatherLinks($temp_js, JS_ASSET); if ($this->js_minify) { $minifier = new \MatthiasMullie\Minify\JS(); $minifier->add($buffer); $buffer = $minifier->minify(); } // Write file if (strlen(trim($buffer)) > 0) { file_put_contents($this->assets_dir . $file, $buffer); if ($returnURL) { return $relative_path . $this->getTimestamp(); } else { return $buffer . "\n"; } } else { return false; } } /** * Return the array of all the registered CSS assets * If a $key is provided, it will try to return only that asset * else it will return null * * @param null|string $key the asset key * @return array */ public function getCss($key = null) { if (!empty($key)) { $asset_key = md5($key); if (isset($this->css[$asset_key])) { return $this->css[$asset_key]; } else { return null; } } return $this->css; } /** * Return the array of all the registered JS assets * If a $key is provided, it will try to return only that asset * else it will return null * * @param null|string $key the asset key * @return array */ public function getJs($key = null) { if (!empty($key)) { $asset_key = md5($key); if (isset($this->js[$asset_key])) { return $this->js[$asset_key]; } else { return null; } } return $this->js; } /** * Set the whole array of CSS assets * * @param $css */ public function setCss($css) { $this->css = $css; } /** * Set the whole array of JS assets * * @param $js */ public function setJs($js) { $this->js = $js; } /** * Removes an item from the CSS array if set * * @param string $key The asset key */ public function removeCss($key) { $asset_key = md5($key); if (isset($this->css[$asset_key])) { unset($this->css[$asset_key]); } } /** * Removes an item from the JS array if set * * @param string $key The asset key */ public function removeJs($key) { $asset_key = md5($key); if (isset($this->js[$asset_key])) { unset($this->js[$asset_key]); } } /** * Return the array of all the registered collections * * @return array */ public function getCollections() { return $this->collections; } /** * Set the array of collections explicitly * * @param $collections */ public function setCollection($collections) { $this->collections = $collections; } /** * Determines if an asset exists as a collection, CSS or JS reference * * @param $asset * * @return bool */ public function exists($asset) { if (isset($this->collections[$asset]) || isset($this->css[$asset]) || isset($this->js[$asset])) { return true; } else { return false; } } /** * Add/replace collection. * * @param string $collectionName * @param array $assets * @param bool $overwrite * * @return $this */ public function registerCollection($collectionName, Array $assets, $overwrite = false) { if ($overwrite || !isset($this->collections[$collectionName])) { $this->collections[$collectionName] = $assets; } return $this; } /** * Reset all assets. * * @return $this */ public function reset() { return $this->resetCss()->resetJs(); } /** * Reset JavaScript assets. * * @return $this */ public function resetJs() { $this->js = []; $this->inline_js = []; return $this; } /** * Reset CSS assets. * * @return $this */ public function resetCss() { $this->css = []; $this->inline_css = []; return $this; } /** * Add all JavaScript assets within $directory * * @param string $directory Relative to the Grav root path, or a stream identifier * * @return $this */ public function addDirJs($directory) { return $this->addDir($directory, self::JS_REGEX); } /** * Add all CSS assets within $directory * * @param string $directory Relative to the Grav root path, or a stream identifier * * @return $this */ public function addDirCss($directory) { return $this->addDir($directory, self::CSS_REGEX); } /** * Add all assets matching $pattern within $directory. * * @param string $directory Relative to the Grav root path, or a stream identifier * @param string $pattern (regex) * * @return $this * @throws Exception */ public function addDir($directory, $pattern = self::DEFAULT_REGEX) { $root_dir = rtrim(ROOT_DIR, '/'); // Check if $directory is a stream. if (strpos($directory, '://')) { $directory = Grav::instance()['locator']->findResource($directory, null); } // Get files $files = $this->rglob($root_dir . DIRECTORY_SEPARATOR . $directory, $pattern, $root_dir . '/'); // No luck? Nothing to do if (!$files) { return $this; } // Add CSS files if ($pattern === self::CSS_REGEX) { foreach ($files as $file) { $this->addCss($file); } return $this; } // Add JavaScript files if ($pattern === self::JS_REGEX) { foreach ($files as $file) { $this->addJs($file); } return $this; } // Unknown pattern. foreach ($files as $asset) { $this->add($asset); } return $this; } /** * Determine whether a link is local or remote. * * Understands both "http://" and "https://" as well as protocol agnostic links "//" * * @param string $link * * @return bool */ protected function isRemoteLink($link) { $base = Grav::instance()['uri']->rootUrl(true); // sanity check for local URLs with absolute URL's enabled if (Utils::startsWith($link, $base)) { return false; } return ('http://' === substr($link, 0, 7) || 'https://' === substr($link, 0, 8) || '//' === substr($link, 0, 2)); } /** * Build local links including grav asset shortcodes * * @param string $asset the asset string reference * @param bool $absolute build absolute asset link * * @return string the final link url to the asset */ protected function buildLocalLink($asset, $absolute = false) { try { $asset = Grav::instance()['locator']->findResource($asset, $absolute); } catch (\Exception $e) { } $uri = $absolute ? $asset : $this->base_url . ltrim($asset, '/'); return $asset ? $uri : false; } /** * Get the last modification time of asset * * @param string $asset the asset string reference * * @return string the last modifcation time or false on error */ protected function getLastModificationTime($asset) { $file = GRAV_ROOT . $asset; if (Grav::instance()['locator']->isStream($asset)) { $file = $this->buildLocalLink($asset, true); } return file_exists($file) ? filemtime($file) : false; } /** * Build an HTML attribute string from an array. * * @param array $attributes * * @return string */ protected function attributes(array $attributes) { $html = ''; $no_key = ['loading']; foreach ($attributes as $key => $value) { // For numeric keys we will assume that the key and the value are the same // as this will convert HTML attributes such as "required" to a correct // form like required="required" instead of using incorrect numerics. if (is_numeric($key)) { $key = $value; } if (is_array($value)) { $value = implode(' ', $value); } if (in_array($key, $no_key)) { $element = htmlentities($value, ENT_QUOTES, 'UTF-8', false); } else { $element = $key . '="' . htmlentities($value, ENT_QUOTES, 'UTF-8', false) . '"'; } $html .= ' ' . $element; } return $html; } /** * Download and concatenate the content of several links. * * @param array $links * @param bool $css * * @return string */ protected function gatherLinks(array $links, $css = true) { $buffer = ''; foreach ($links as $asset) { $relative_dir = ''; $local = true; $link = $asset['asset']; $relative_path = $link; if ($this->isRemoteLink($link)) { $local = false; if ('//' === substr($link, 0, 2)) { $link = 'http:' . $link; } } else { // Fix to remove relative dir if grav is in one if (($this->base_url != '/') && (strpos($this->base_url, $link) == 0)) { $base_url = '#' . preg_quote($this->base_url, '#') . '#'; $relative_path = ltrim(preg_replace($base_url, '/', $link, 1), '/'); } $relative_dir = dirname($relative_path); $link = ROOT_DIR . $relative_path; } $file = ($this->fetch_command instanceof Closure) ? @$this->fetch_command->__invoke($link) : @file_get_contents($link); // No file found, skip it... if ($file === false) { continue; } // Double check last character being if (!$css) { $file = rtrim($file, ' ;') . ';'; } // If this is CSS + the file is local + rewrite enabled if ($css && $local && $this->css_rewrite) { $file = $this->cssRewrite($file, $relative_dir); } $file = rtrim($file) . PHP_EOL; $buffer .= $file; } // Pull out @imports and move to top if ($css) { $buffer = $this->moveImports($buffer); } return $buffer; } /** * Finds relative CSS urls() and rewrites the URL with an absolute one * * @param string $file the css source file * @param string $relative_path relative path to the css file * * @return mixed */ protected function cssRewrite($file, $relative_path) { // Strip any sourcemap comments $file = preg_replace(self::CSS_SOURCEMAP_REGEX, '', $file); // Find any css url() elements, grab the URLs and calculate an absolute path // Then replace the old url with the new one $file = preg_replace_callback(self::CSS_URL_REGEX, function ($matches) use ($relative_path) { $old_url = $matches[2]; // Ensure link is not rooted to webserver, a data URL, or to a remote host if (Utils::startsWith($old_url, '/') || Utils::startsWith($old_url, 'data:') || $this->isRemoteLink($old_url)) { return $matches[0]; } $new_url = $this->base_url . ltrim(Utils::normalizePath($relative_path . '/' . $old_url), '/'); return str_replace($old_url, $new_url, $matches[0]); }, $file); return $file; } /** * Moves @import statements to the top of the file per the CSS specification * * @param string $file the file containing the combined CSS files * * @return string the modified file with any @imports at the top of the file */ protected function moveImports($file) { $this->imports = []; $file = preg_replace_callback(self::CSS_IMPORT_REGEX, function ($matches) { $this->imports[] = $matches[0]; return ''; }, $file); return implode("\n", $this->imports) . "\n\n" . $file; } /** * Recursively get files matching $pattern within $directory. * * @param string $directory * @param string $pattern (regex) * @param string $ltrim Will be trimmed from the left of the file path * * @return array */ protected function rglob($directory, $pattern, $ltrim = null) { $iterator = new RegexIterator(new RecursiveIteratorIterator(new RecursiveDirectoryIterator($directory, FilesystemIterator::SKIP_DOTS)), $pattern); $offset = strlen($ltrim); $files = []; foreach ($iterator as $file) { $files[] = substr($file->getPathname(), $offset); } return $files; } /** * Sets the state of CSS Pipeline * * @param boolean $value */ public function setCssPipeline($value) { $this->css_pipeline = (bool)$value; } /** * Sets the state of JS Pipeline * * @param boolean $value */ public function setJsPipeline($value) { $this->js_pipeline = (bool)$value; } /** * Explicitly set's a timestamp for assets * * @param $value */ public function setTimestamp($value) { $this->timestamp = $value; } /** * Get the timestamp for assets * * @return string */ public function getTimestamp($include_join = true) { if ($this->timestamp) { $timestamp = $include_join ? '?' . $this->timestamp : $this->timestamp; return $timestamp; } return; } /** * * * @param $asset * @return string */ public function getQuerystring($asset) { $querystring = ''; if (!empty($asset['query'])) { if (Utils::contains($asset['asset'], '?')) { $querystring .= '&' . $asset['query']; } else { $querystring .= '?' . $asset['query']; } } if ($this->timestamp) { if (Utils::contains($asset['asset'], '?') || $querystring) { $querystring .= '&' . $this->timestamp; } else { $querystring .= '?' . $this->timestamp; } } return $querystring; } /** * @return string */ public function __toString() { return ''; } /** * @param $a * @param $b * * @return mixed */ protected function sortAssetsByPriorityThenOrder($a, $b) { if ($a['priority'] == $b['priority']) { return $a['order'] - $b['order']; } return $b['priority'] - $a['priority']; } }