)+(.*)(<\/\2>)#is';
/**
* Closure used by the pipeline to fetch assets.
*
* Useful when file_get_contents() function is not available in your PHP
* installation or when you want to apply any kind of preprocessing to
* your assets before they get pipelined.
*
* The closure will receive as the only parameter a string with the path/URL of the asset and
* it should return the content of the asset file as a string.
*
* @var Closure
*/
protected $fetch_command;
// Configuration toggles to enable/disable the pipelining feature
protected $css_pipeline = false;
protected $js_pipeline = false;
// The asset holding arrays
protected $collections = array();
protected $css = array();
protected $js = array();
protected $inline_css = array();
protected $inline_js = array();
// Some configuration variables
protected $config;
protected $base_url;
protected $timestamp = '';
protected $assets_dir;
protected $assets_url;
// Default values for pipeline settings
protected $css_minify = true;
protected $css_minify_windows = false;
protected $css_rewrite = true;
protected $js_minify = true;
// Arrays to hold assets that should NOT be pipelined
protected $css_no_pipeline = array();
protected $js_no_pipeline = array();
public function __construct(array $options = array())
{
// Forward config options
if ($options) {
$this->config((array)$options);
}
}
/**
* Set up configuration options.
*
* All the class properties except 'js' and 'css' are accepted here.
* Also, an extra option 'autoload' may be passed containing an array of
* assets and/or collections that will be automatically added on startup.
*
* @param array $config Configurable options.
*
* @return $this
* @throws \Exception
*/
public function config(array $config)
{
// Set pipeline modes
if (isset($config['css_pipeline'])) {
$this->css_pipeline = $config['css_pipeline'];
}
if (isset($config['js_pipeline'])) {
$this->js_pipeline = $config['js_pipeline'];
}
// Pipeline requires public dir
if (($this->js_pipeline || $this->css_pipeline) && !is_dir($this->assets_dir)) {
throw new \Exception('Assets: Public dir not found');
}
// Set custom pipeline fetch command
if (isset($config['fetch_command']) && ($config['fetch_command'] instanceof Closure)) {
$this->fetch_command = $config['fetch_command'];
}
// Set CSS Minify state
if (isset($config['css_minify'])) {
$this->css_minify = $config['css_minify'];
}
if (isset($config['css_minify_windows'])) {
$this->css_minify_windows = $config['css_minify_windows'];
}
if (isset($config['css_rewrite'])) {
$this->css_rewrite = $config['css_rewrite'];
}
// Set JS Minify state
if (isset($config['js_minify'])) {
$this->js_minify = $config['js_minify'];
}
// Set collections
if (isset($config['collections']) && is_array($config['collections'])) {
$this->collections = $config['collections'];
}
// Autoload assets
if (isset($config['autoload']) && is_array($config['autoload'])) {
foreach ($config['autoload'] as $asset) {
$this->add($asset);
}
}
// Set timestamp
if (isset($config['enable_asset_timestamp']) && $config['enable_asset_timestamp'] === true) {
$this->timestamp = '?' . self::getGrav()['cache']->getKey();
}
return $this;
}
/**
* Initialization called in the Grav lifecycle to initialize the Assets with appropriate configuration
*/
public function init()
{
/** @var Config $config */
$config = self::getGrav()['config'];
$base_url = self::getGrav()['base_url'];
$asset_config = (array)$config->get('system.assets');
/** @var Locator $locator */
$locator = self::$grav['locator'];
$this->assets_dir = self::getGrav()['locator']->findResource('asset://') . DS;
$this->assets_url = self::getGrav()['locator']->findResource('asset://', false);
$this->config($asset_config);
$this->base_url = $base_url . '/';
// Register any preconfigured collections
foreach ($config->get('system.assets.collections') as $name => $collection) {
$this->registerCollection($name, (array)$collection);
}
}
/**
* Add an asset or a collection of assets.
*
* It automatically detects the asset type (JavaScript, CSS or collection).
* You may add more than one asset passing an array as argument.
*
* @param mixed $asset
* @param int $priority the priority, bigger comes first
* @param bool $pipeline false if this should not be pipelined
*
* @return $this
*/
public function add($asset, $priority = null, $pipeline = null)
{
// More than one asset
if (is_array($asset)) {
foreach ($asset as $a) {
$this->add($a, $priority, $pipeline);
}
} elseif (isset($this->collections[$asset])) {
$this->add($this->collections[$asset], $priority, $pipeline);
} else {
// Get extension
$extension = pathinfo(parse_url($asset, PHP_URL_PATH), PATHINFO_EXTENSION);
// JavaScript or CSS
if (strlen($extension) > 0) {
$extension = strtolower($extension);
if ($extension === 'css') {
$this->addCss($asset, $priority, $pipeline);
} elseif ($extension === 'js') {
$this->addJs($asset, $priority, $pipeline);
}
}
}
return $this;
}
/**
* Add a CSS asset.
*
* It checks for duplicates.
* You may add more than one asset passing an array as argument.
*
* @param mixed $asset
* @param int $priority the priority, bigger comes first
* @param bool $pipeline false if this should not be pipelined
* @param null $group
*
* @return $this
*/
public function addCss($asset, $priority = null, $pipeline = null, $group = null)
{
if (is_array($asset)) {
foreach ($asset as $a) {
$this->addCss($a, $priority, $pipeline, $group);
}
return $this;
} elseif (isset($this->collections[$asset])) {
$this->add($this->collections[$asset], $priority, $pipeline, $group);
return $this;
}
if (!$this->isRemoteLink($asset)) {
$asset = $this->buildLocalLink($asset);
}
$data = [
'asset' => $asset,
'priority' => intval($priority ?: 10),
'order' => count($this->css),
'pipeline' => $pipeline ?: true,
'group' => $group ?: 'head'
];
// check for dynamic array and merge with defaults
$count_args = func_num_args();
if (func_num_args() == 2) {
$dynamic_arg = func_get_arg(1);
if (is_array($dynamic_arg)) {
$data = array_merge($data, $dynamic_arg);
}
}
$key = md5($asset);
if ($asset) {
$this->css[$key] = $data;
}
return $this;
}
/**
* Add a JavaScript asset.
*
* It checks for duplicates.
* You may add more than one asset passing an array as argument.
*
* @param mixed $asset
* @param int $priority the priority, bigger comes first
* @param bool $pipeline false if this should not be pipelined
* @param string $loading how the asset is loaded (async/defer)
* @param string $group name of the group
* @return $this
*/
public function addJs($asset, $priority = null, $pipeline = null, $loading = null, $group = null)
{
if (is_array($asset)) {
foreach ($asset as $a) {
$this->addJs($a, $priority, $pipeline, $loading, $group);
}
return $this;
} elseif (isset($this->collections[$asset])) {
$this->add($this->collections[$asset], $priority, $pipeline, $loading, $group);
return $this;
}
if (!$this->isRemoteLink($asset)) {
$asset = $this->buildLocalLink($asset);
}
$data = [
'asset' => $asset,
'priority' => intval($priority ?: 10),
'order' => count($this->js),
'pipeline' => $pipeline ?: true,
'loading' => $loading ?: '',
'group' => $group ?: 'head'
];
// check for dynamic array and merge with defaults
$count_args = func_num_args();
if (func_num_args() == 2) {
$dynamic_arg = func_get_arg(1);
if (is_array($dynamic_arg)) {
$data = array_merge($data, $dynamic_arg);
}
}
$key = md5($asset);
if ($asset) {
$this->js[$key] = $data;
}
return $this;
}
/**
* Convenience wrapper for async loading of JavaScript
*
* @param $asset
* @param int $priority
* @param bool $pipeline
* @param string $group name of the group
*
* @deprecated Please use dynamic method with ['loading' => 'async']
*
* @return \Grav\Common\Assets
*/
public function addAsyncJs($asset, $priority = null, $pipeline = null, $group = null)
{
return $this->addJs($asset, $priority, $pipeline, 'async', $group);
}
/**
* Convenience wrapper for deferred loading of JavaScript
*
* @param $asset
* @param int $priority
* @param bool $pipeline
* @param string $group name of the group
*
* @deprecated Please use dynamic method with ['loading' => 'defer']
*
* @return \Grav\Common\Assets
*/
public function addDeferJs($asset, $priority = null, $pipeline = null, $group = null)
{
return $this->addJs($asset, $priority, $pipeline, 'defer', $group);
}
/**
* Add an inline CSS asset.
*
* It checks for duplicates.
* For adding chunks of string-based inline CSS
*
* @param mixed $asset
* @param int $priority the priority, bigger comes first
* @param null $group
*
* @return $this
*/
public function addInlineCss($asset, $priority = null, $group = null)
{
$asset = trim($asset);
if (is_a($asset, 'Twig_Markup')) {
preg_match(self::HTML_TAG_REGEX, $asset, $matches );
if (isset($matches[3])) {
$asset = $matches[3];
}
}
$data = [
'priority' => intval($priority ?: 10),
'order' => count($this->inline_css),
'asset' => $asset,
'group' => $group ?: 'head'
];
// check for dynamic array and merge with defaults
$count_args = func_num_args();
if (func_num_args() == 2) {
$dynamic_arg = func_get_arg(1);
if (is_array($dynamic_arg)) {
$data = array_merge($data, $dynamic_arg);
}
}
$key = md5($asset);
if (is_string($asset) && !array_key_exists($key, $this->inline_css)) {
$this->inline_css[$key] = $data;
}
return $this;
}
/**
* Add an inline JS asset.
*
* It checks for duplicates.
* For adding chunks of string-based inline JS
*
* @param mixed $asset
* @param int $priority the priority, bigger comes first
* @param string $group name of the group
*
* @return $this
*/
public function addInlineJs($asset, $priority = null, $group = null)
{
$asset = trim($asset);
if (is_a($asset, 'Twig_Markup')) {
preg_match(self::HTML_TAG_REGEX, $asset, $matches );
if (isset($matches[3])) {
$asset = $matches[3];
}
}
$data = [
'asset' => $asset,
'priority' => intval($priority ?: 10),
'order' => count($this->js),
'group' => $group ?: 'head'
];
// check for dynamic array and merge with defaults
$count_args = func_num_args();
if (func_num_args() == 2) {
$dynamic_arg = func_get_arg(1);
if (is_array($dynamic_arg)) {
$data = array_merge($data, $dynamic_arg);
}
}
$key = md5($asset);
if (is_string($asset) && !array_key_exists($key, $this->inline_js)) {
$this->inline_js[$key] = $data;
}
return $this;
}
/**
* Build the CSS link tags.
*
* @param string $group name of the group
* @param array $attributes
*
* @return string
*/
public function css($group = 'head', $attributes = [])
{
if (!$this->css) {
return null;
}
// Sort array by priorities (larger priority first)
if (self::getGrav()) {
usort($this->css, function ($a, $b) {
if ($a['priority'] == $b['priority']) {
return $b['order'] - $a['order'];
}
return $a['priority'] - $b['priority'];
});
usort($this->inline_css, function ($a, $b) {
if ($a['priority'] == $b['priority']) {
return $b['order'] - $a['order'];
}
return $a['priority'] - $b['priority'];
});
}
$this->css = array_reverse($this->css);
$this->inline_css = array_reverse($this->inline_css);
$attributes = $this->attributes(array_merge(['type' => 'text/css', 'rel' => 'stylesheet'], $attributes));
$output = '';
$inline_css = '';
if ($this->css_pipeline) {
$pipeline_result = $this->pipelineCss($group);
if ($pipeline_result) {
$output .= '' . "\n";
}
foreach ($this->css_no_pipeline as $file) {
if ($group && $file['group'] == $group) {
$media = isset($file['media']) ? sprintf(' media="%s"', $file['media']) : '';
$output .= '' . "\n";
}
}
} else {
foreach ($this->css as $file) {
if ($group && $file['group'] == $group) {
$media = isset($file['media']) ? sprintf(' media="%s"', $file['media']) : '';
$output .= '' . "\n";
}
}
}
// Render Inline CSS
foreach ($this->inline_css as $inline) {
if ($group && $inline['group'] == $group) {
$inline_css .= $inline['asset'] . "\n";
}
}
if ($inline_css) {
$output .= "\n\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) {
return null;
}
// Sort array by priorities (larger priority first)
usort($this->js, function ($a, $b) {
if ($a['priority'] == $b['priority']) {
return $b['order'] - $a['order'];
}
return $a['priority'] - $b['priority'];
});
usort($this->inline_js, function ($a, $b) {
if ($a['priority'] == $b['priority']) {
return $b['order'] - $a['order'];
}
return $a['priority'] - $b['priority'];
});
$this->js = array_reverse($this->js);
$this->inline_js = array_reverse($this->inline_js);
$attributes = $this->attributes(array_merge(['type' => 'text/javascript'], $attributes));
$output = '';
$inline_js = '';
if ($this->js_pipeline) {
$pipeline_result = $this->pipelineJs($group);
if ($pipeline_result) {
$output .= '' . "\n";
}
foreach ($this->js_no_pipeline as $file) {
if ($group && $file['group'] == $group) {
$output .= '' . "\n";
}
}
} else {
foreach ($this->js as $file) {
if ($group && $file['group'] == $group) {
$output .= '' . "\n";
}
}
}
// Render Inline JS
foreach ($this->inline_js as $inline) {
if ($group && $inline['group'] == $group) {
$inline_js .= $inline['asset'] . "\n";
}
}
if ($inline_js) {
$output .= "\n\n";
}
return $output;
}
/**
* Minify and concatenate CSS.
*
* @return string
*/
protected function pipelineCss($group = 'head')
{
/** @var Cache $cache */
$cache = self::getGrav()['cache'];
$key = '?' . $cache->getKey();
// temporary list of assets to pipeline
$temp_css = [];
// clear no-pipeline assets lists
$this->css_no_pipeline = [];
$file = md5(json_encode($this->css) . $this->css_minify . $this->css_rewrite . $group) . '.css';
$relative_path = "{$this->base_url}{$this->assets_url}/{$file}";
$absolute_path = $this->assets_dir . $file;
// If pipeline exist return it
if (file_exists($absolute_path)) {
return $relative_path . $key;
}
// Remove any non-pipeline files
foreach ($this->css as $id => $asset) {
if ($asset['group'] == $group) {
if (!$asset['pipeline']) {
$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;
}
$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) {
$min = new \CSSmin();
$buffer = $min->run($buffer);
}
// Write file
if (strlen(trim($buffer)) > 0) {
file_put_contents($absolute_path, $buffer);
return $relative_path . $key;
} else {
return false;
}
}
/**
* Minify and concatenate JS files.
*
* @return string
*/
protected function pipelineJs($group = 'head')
{
/** @var Cache $cache */
$cache = self::getGrav()['cache'];
$key = '?' . $cache->getKey();
// temporary list of assets to pipeline
$temp_js = [];
// clear no-pipeline assets lists
$this->js_no_pipeline = [];
$file = md5(json_encode($this->js) . $this->js_minify . $group) . '.js';
$relative_path = "{$this->base_url}{$this->assets_url}/{$file}";
$absolute_path = $this->assets_dir . $file;
// If pipeline exist return it
if (file_exists($absolute_path)) {
return $relative_path . $key;
}
// Remove any non-pipeline files
foreach ($this->js as $id => $asset) {
if ($asset['group'] == $group) {
if (!$asset['pipeline']) {
$this->js_no_pipeline[] = $asset;
} else {
$temp_js[$id] = $asset;
}
}
}
//if nothing found get out of here!
if (count($temp_js) == 0) {
return false;
}
// Concatenate files
$buffer = $this->gatherLinks($temp_js, JS_ASSET);
if ($this->js_minify) {
$buffer = \JSMin::minify($buffer);
}
// Write file
if (strlen(trim($buffer)) > 0) {
file_put_contents($absolute_path, $buffer);
return $relative_path . $key;
} else {
return false;
}
}
/**
* Return the array of all the registered CSS assets
*
* @return array
*/
public function getCss()
{
return $this->css;
}
/**
* Return the array of all the registered JS assets
*
* @return array
*/
public function getJs()
{
return $this->js;
}
/**
* Return the array of all the registered collections
*
* @return array
*/
public function getCollections()
{
return $this->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 = array();
return $this;
}
/**
* Reset CSS assets.
*
* @return $this
*/
public function resetCss()
{
$this->css = array();
return $this;
}
/**
* Add all CSS assets within $directory (relative to public dir).
*
* @param string $directory Relative to $this->public_dir
*
* @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 $this->public_dir
* @param string $pattern (regex)
*
* @return $this
* @throws Exception
*/
public function addDir($directory, $pattern = self::DEFAULT_REGEX)
{
// Check if public_dir exists
if (!is_dir($this->assets_dir)) {
throw new Exception('Assets: Public dir not found');
}
// Get files
$files = $this->rglob($this->assets_dir . DIRECTORY_SEPARATOR . $directory, $pattern, $this->assets_dir);
// No luck? Nothing to do
if (!$files) {
return $this;
}
// Add CSS files
if ($pattern === self::CSS_REGEX) {
$this->css = array_unique(array_merge($this->css, $files));
return $this;
}
// Add JavaScript files
if ($pattern === self::JS_REGEX) {
$this->js = array_unique(array_merge($this->js, $files));
return $this;
}
// Unknown pattern. We must poll to know the extension :(
foreach ($files as $asset) {
$info = pathinfo($asset);
if (isset($info['extension'])) {
$ext = strtolower($info['extension']);
if ($ext === 'css' && !in_array($asset, $this->css)) {
$this->css[] = $asset;
} elseif ($ext === 'js' && !in_array($asset, $this->js)) {
$this->js[] = $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)
{
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
*
* @return string the final link url to the asset
*/
protected function buildLocalLink($asset)
{
try {
$asset = self::getGrav()['locator']->findResource($asset, false);
} catch (\Exception $e) {
}
return $asset ? $this->base_url . ltrim($asset, '/') : false;
}
/**
* Build an HTML attribute string from an array.
*
* @param array $attributes
*
* @return string
*/
protected function attributes(array $attributes)
{
$html = '';
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);
}
$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 = '';
$local = true;
foreach ($links as $asset) {
$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);
}
$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 $file the css source file
* @param $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[1];
// ensure this is not a data url
if (strpos($old_url, 'data:') === 0) {
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 = array();
$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 = array();
foreach ($iterator as $file) {
$files[] = substr($file->getPathname(), $offset);
}
return $files;
}
/**
* Add all JavaScript assets within $directory.
*
* @param string $directory Relative to $this->public_dir
*
* @return $this
*/
public function addDirJs($directory)
{
return $this->addDir($directory, self::JS_REGEX);
}
public function __toString()
{
return '';
}
/**
* @param $a
* @param $b
*
* @return mixed
*/
protected function priorityCompare($a, $b)
{
return $a ['priority'] - $b ['priority'];
}
}