diff --git a/composer.json b/composer.json index b56fda0d72e44203d017f351e1ffe2d72f4d2e1d..d138f989cfd986c396e43337211e1ebcd0c8e09a 100644 --- a/composer.json +++ b/composer.json @@ -14,6 +14,7 @@ "cap60552/php-sip2": "1.0.0", "los/losrecaptcha": "1.0.0", "ahand/mobileesp": "dev-master", + "matthiasmullie/minify": "1.3.35", "ocramius/proxy-manager": "1.0.2", "oyejorge/less.php": "1.7.0.9", "pear/file_marc": "1.1.2", diff --git a/config/vufind/config.ini b/config/vufind/config.ini index bfb9ceaa587d7928dab249236ab3ce94fee4e264..3f32949cdb3e09278b083d15cf75331adc11fa14 100644 --- a/config/vufind/config.ini +++ b/config/vufind/config.ini @@ -33,6 +33,22 @@ title = "Library Catalog" ; bootprint3 = bootstrap3 theme with more attractive default styling applied ; (named after the earlier, now-deprecated blueprint theme) theme = bootprint3 + +; Automatic asset minification and concatenation setting. When active, HeadScript +; and HeadLink will concatenate and minify all viable files to reduce requests and +; load times. This setting is off by default. +; +; This configuration takes the form of a semi-colon separated list of +; environment:configuration pairs where "environment" is a possible APPLICATION_ENV +; value (e.g. 'production' or 'development') or '*'/no prefix for all contexts. +; Possible values for 'configuration' within each environment are 'js', 'css', +; 'off'/false, 'on'/true/'*'. This allows global enabling/disabling of the pipeline +; or separate configurations for different types of resources. Multiple configuration +; values may be comma-separated -- e.g. 'js,css'. +; +; Example: "development:off; production:js,css" +;asset_pipeline = "production:js" + ; Uncomment the following line to use a different default theme for mobile devices. ; You may not wish to use this setting if you are using one of the Bootstrap-based ; standard themes since they support responsive design. Available mobile theme: diff --git a/config/vufind/httpd-vufind.conf b/config/vufind/httpd-vufind.conf index fd86c0032b1e252917473c1b8e0b960a9691d4b4..ddbb03a287824c630bafcf56dc6a3a9fc1da8cfe 100644 --- a/config/vufind/httpd-vufind.conf +++ b/config/vufind/httpd-vufind.conf @@ -13,6 +13,19 @@ AliasMatch ^/vufind/themes/([0-9a-zA-Z-_]*)/js/(.*)$ /usr/local/vufind/themes/$1 AllowOverride All </Directory> +# Configuration for public cache (used for asset pipeline minification) +AliasMatch ^/vufind/cache/(.*)$ /usr/local/vufind/local/cache/public/$1 +<Directory /usr/local/vufind/local/cache/public/> + <IfModule !mod_authz_core.c> + Order allow,deny + Allow from all + </IfModule> + <IfModule mod_authz_core.c> + Require all granted + </IfModule> + AllowOverride All +</Directory> + # Configuration for general VuFind base: Alias /vufind /usr/local/vufind/public <Directory /usr/local/vufind/public/> diff --git a/module/VuFind/src/VuFind/Cache/Manager.php b/module/VuFind/src/VuFind/Cache/Manager.php index 8947930118ce520f4ec75f2a39dec9a796f56632..7dcd720a2db4eee499c377ac438ea6f7c5b30df6 100644 --- a/module/VuFind/src/VuFind/Cache/Manager.php +++ b/module/VuFind/src/VuFind/Cache/Manager.php @@ -92,6 +92,7 @@ class Manager foreach (['config', 'cover', 'language', 'object'] as $cache) { $this->createFileCache($cache, $cacheBase . $cache . 's'); } + $this->createFileCache('public', $cacheBase . 'public'); // Set up search specs cache based on config settings: $searchCacheType = isset($searchConfig->Cache->type) diff --git a/module/VuFindTheme/src/VuFindTheme/View/Helper/ConcatTrait.php b/module/VuFindTheme/src/VuFindTheme/View/Helper/ConcatTrait.php new file mode 100644 index 0000000000000000000000000000000000000000..ca338c105219d9b143bf5394f27ed0b750c55fe9 --- /dev/null +++ b/module/VuFindTheme/src/VuFindTheme/View/Helper/ConcatTrait.php @@ -0,0 +1,328 @@ +<?php +/** + * Trait to add asset pipeline functionality (concatenation / minification) to + * a HeadLink/HeadScript-style view helper. + * + * PHP version 5 + * + * Copyright (C) Villanova University 2016. + * + * This program is free software; you can redistribute it and/or modify + * it under the terms of the GNU General Public License version 2, + * as published by the Free Software Foundation. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program; if not, write to the Free Software + * Foundation, Inc., 59 Temple Place, Suite 330, Boston, MA 02111-1307 USA + * + * @category VuFind + * @package View_Helpers + * @author Demian Katz <demian.katz@villanova.edu> + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +namespace VuFindTheme\View\Helper; +use VuFindTheme\ThemeInfo; + +/** + * Trait to add asset pipeline functionality (concatenation / minification) to + * a HeadLink/HeadScript-style view helper. + * + * @category VuFind + * @package View_Helpers + * @author Demian Katz <demian.katz@villanova.edu> + * @license http://opensource.org/licenses/gpl-2.0.php GNU General Public License + * @link https://vufind.org/wiki/development:testing:unit_tests Wiki + */ +trait ConcatTrait +{ + /** + * Returns true if file should not be included in the compressed concat file + * + * @param stdClass $item Element object + * + * @return bool + */ + abstract protected function isExcludedFromConcat($item); + + /** + * Get the folder name and file extension + * + * @return string + */ + abstract protected function getFileType(); + + /** + * Get the file path from the element object + * + * @param stdClass $item Element object + * + * @return string + */ + abstract protected function getResourceFilePath($item); + + /** + * Set the file path of the element object + * + * @param stdClass $item Element object + * @param string $path New path string + + * @return void + */ + abstract protected function setResourceFilePath($item, $path); + + /** + * Get the minifier that can handle these file types + * + * @return minifying object like \MatthiasMullie\Minify\JS + */ + abstract protected function getMinifier(); + + /** + * Set the file path of the link object + * + * @param stdClass $item Link element object + * + * @return string + */ + public function getType($item) + { + return 'default'; + } + + /** + * Should we use the asset pipeline to join files together and minify them? + * + * @var bool + */ + protected $usePipeline = false; + + /** + * Array of resource items by type, contains key as well + * + * @var array + */ + protected $groups = []; + + /** + * Future order of the concatenated file + * + * @var number + */ + protected $concatIndex = null; + + /** + * Check if config is enamled for this file type + * + * @param string $config Config for current application environment + * + * @return boolean + */ + protected function enabledInConfig($config) + { + if ($config === false || $config == 'off') { + return false; + } + if ($config == '*' || $config == 'on' + || $config == 'true' || $config === true + ) { + return true; + } + $settings = array_map('trim', explode(',', $config)); + return in_array($this->getFileType(), $settings); + } + + /** + * Initialize class properties related to concatenation of resources. + * All of the elements to be concatenated into ($this->concatItems) + * and those that need to remain on their own ($this->otherItems). + * + * @return void + */ + protected function filterItems() + { + $this->groups = []; + $groupTypes = []; + + $this->getContainer()->ksort(); + + foreach ($this as $key => $item) { + if ($this->isExcludedFromConcat($item)) { + $this->groups[] = [ + 'other' => true, + 'item' => $item + ]; + $groupTypes[] = 'other'; + continue; + } + + $details = $this->themeInfo->findContainingTheme( + $this->getFileType() . '/' . $this->getResourceFilePath($item), + ThemeInfo::RETURN_ALL_DETAILS + ); + + $type = $this->getType($item); + $index = array_search($type, $groupTypes); + if ($index === false) { + $this->groups[] = [ + 'items' => [$item], + 'key' => $details['path'] . filemtime($details['path']) + ]; + $groupTypes[] = $type; + } else { + $this->groups[$index]['items'][] = $item; + $this->groups[$index]['key'] .= + $details['path'] . filemtime($details['path']); + } + } + + return count($groupTypes) > 0; + } + + /** + * Get the path to the directory where we can cache files generated by + * this trait. + * + * @return string + */ + protected function getResourceCacheDir() + { + return $this->themeInfo->getBaseDir() . '/../local/cache/public/'; + } + + /** + * Using the concatKey, return the path of the concatenated file. + * Generate if it does not yet exist. + * + * @param array $group Object containing 'key' and stdobj file 'items' + * + * @return string + */ + protected function getConcatenatedFilePath($group) + { + $urlHelper = $this->getView()->plugin('url'); + + // Don't recompress individual files + if (count($group['items']) === 1) { + $path = $this->getResourceFilePath($group['items'][0]); + $details = $this->themeInfo->findContainingTheme( + $this->getFileType() . '/' . $path, + ThemeInfo::RETURN_ALL_DETAILS + ); + return $urlHelper('home') . 'themes/' . $details['theme'] + . '/' . $this->getFileType() . '/' . $path; + } + // Locate/create concatenated asset file + $filename = md5($group['key']) . '.min.' . $this->getFileType(); + $concatPath = $this->getResourceCacheDir() . $filename; + if (!file_exists($concatPath)) { + $minifier = $this->getMinifier(); + foreach ($group['items'] as $item) { + $details = $this->themeInfo->findContainingTheme( + $this->getFileType() . '/' . $this->getResourceFilePath($item), + ThemeInfo::RETURN_ALL_DETAILS + ); + $minifier->add($details['path']); + } + $minifier->minify($concatPath); + } + + return $urlHelper('home') . 'cache/' . $filename; + } + + /** + * Process and return items in index order + * + * @param string|int $indent Amount of whitespace/string to use for indention + * + * @return string + */ + protected function outputInOrder($indent) + { + // Some of this logic was copied from HeadScript; it does not all apply + // when incorporated into HeadLink, but it has no harmful side effects. + $indent = (null !== $indent) + ? $this->getWhitespace($indent) + : $this->getIndent(); + + if ($this->view) { + $useCdata = $this->view->plugin('doctype')->isXhtml(); + } else { + $useCdata = $this->useCdata; + } + + $escapeStart = ($useCdata) ? '//<![CDATA[' : '//<!--'; + $escapeEnd = ($useCdata) ? '//]]>' : '//-->'; + + $output = []; + foreach ($this->groups as $group) { + if (isset($group['other'])) { + $output[] = $this->itemToString( + $group['item'], $indent, $escapeStart, $escapeEnd + ); + } else { + // Note that we use parent::itemToString() below instead of + // $this->itemToString() to bypass VuFind logic that determines + // file paths within the theme (not appropriate for concatenated + // files, which are stored in a theme-independent cache). + $path = $this->getConcatenatedFilePath($group); + $item = $this->setResourceFilePath($group['items'][0], $path); + $output[] = parent::itemToString( + $item, $indent, $escapeStart, $escapeEnd + ); + } + } + + return $indent . implode( + $this->escape($this->getSeparator()) . $indent, $output + ); + } + + /** + * Can we use the asset pipeline? + * + * @return bool + */ + protected function isPipelineActive() + { + if ($this->usePipeline) { + $cacheDir = $this->getResourceCacheDir(); + if (!is_writable($cacheDir)) { + $this->usePipeline = false; + error_log("Cannot write to $cacheDir; disabling asset pipeline."); + } + } + return $this->usePipeline; + } + + /** + * Render link elements as string + * Customized to minify and concatenate + * + * @param string|int $indent Amount of whitespace or string to use for indention + * + * @return string + */ + public function toString($indent = null) + { + // toString must not throw exception + try { + if (!$this->isPipelineActive() || !$this->filterItems() + || count($this) == 1 + ) { + return parent::toString($indent); + } + + return $this->outputInOrder($indent); + } catch (\Exception $e) { + error_log($e->getMessage()); + } + + return ''; + } +} diff --git a/module/VuFindTheme/src/VuFindTheme/View/Helper/Factory.php b/module/VuFindTheme/src/VuFindTheme/View/Helper/Factory.php index 7570fb5d03dc8cb79a76ed079fc027d54c52ffff..761b6776a55424aeea59ded4bd1c43c9579f2d7e 100644 --- a/module/VuFindTheme/src/VuFindTheme/View/Helper/Factory.php +++ b/module/VuFindTheme/src/VuFindTheme/View/Helper/Factory.php @@ -41,6 +41,36 @@ use Zend\ServiceManager\ServiceManager; */ class Factory { + /** + * Split config and return prefixed setting with current environment. + * + * @param ServiceManager $sm Service manager. + * + * @return HeadLink + */ + protected static function getPipelineConfig(ServiceManager $sm) + { + $config = $sm->getServiceLocator()->get('VuFind\Config')->get('config'); + $default = false; + if (isset($config['Site']['asset_pipeline'])) { + $settings = array_map( + 'trim', + explode(';', $config['Site']['asset_pipeline']) + ); + foreach ($settings as $setting) { + $parts = array_map('trim', explode(':', $setting)); + if (APPLICATION_ENV === $parts[0]) { + return $parts[1]; + } else if (count($parts) == 1) { + $default = $parts[0]; + } else if ($parts[0] === '*') { + $default = $parts[1]; + } + } + } + return $default; + } + /** * Construct the HeadLink helper. * @@ -51,7 +81,8 @@ class Factory public static function getHeadLink(ServiceManager $sm) { return new HeadLink( - $sm->getServiceLocator()->get('VuFindTheme\ThemeInfo') + $sm->getServiceLocator()->get('VuFindTheme\ThemeInfo'), + Factory::getPipelineConfig($sm) ); } @@ -65,7 +96,8 @@ class Factory public static function getHeadScript(ServiceManager $sm) { return new HeadScript( - $sm->getServiceLocator()->get('VuFindTheme\ThemeInfo') + $sm->getServiceLocator()->get('VuFindTheme\ThemeInfo'), + Factory::getPipelineConfig($sm) ); } @@ -107,7 +139,8 @@ class Factory public static function getInlineScript(ServiceManager $sm) { return new InlineScript( - $sm->getServiceLocator()->get('VuFindTheme\ThemeInfo') + $sm->getServiceLocator()->get('VuFindTheme\ThemeInfo'), + Factory::getPipelineConfig($sm) ); } diff --git a/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadLink.php b/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadLink.php index 95344400b6dfc92660a21fd985e2036f1ddcd0ae..7eb38fb4fcddff2198e71d21ba91d4f2922284ba 100644 --- a/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadLink.php +++ b/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadLink.php @@ -39,6 +39,8 @@ use VuFindTheme\ThemeInfo; */ class HeadLink extends \Zend\View\Helper\HeadLink { + use ConcatTrait; + /** * Theme information service * @@ -50,11 +52,23 @@ class HeadLink extends \Zend\View\Helper\HeadLink * Constructor * * @param ThemeInfo $themeInfo Theme information service + * @param string $plconfig Config for current application environment */ - public function __construct(ThemeInfo $themeInfo) + public function __construct(ThemeInfo $themeInfo, $plconfig = false) { parent::__construct(); $this->themeInfo = $themeInfo; + $this->usePipeline = $this->enabledInConfig($plconfig); + } + + /** + * Folder name and file extension for trait + * + * @return string + */ + protected function getFileType() + { + return 'css'; } /** @@ -85,15 +99,12 @@ class HeadLink extends \Zend\View\Helper\HeadLink /** * Compile a less file to css and add to css folder * - * @param string $file Path to less file - * @param string $media Media type - * @param string $conditionalStylesheet Load condition for file + * @param string $file Path to less file * - * @return void + * @return string */ - public function addLessStylesheet($file, $media = 'all', - $conditionalStylesheet = false - ) { + public function addLessStylesheet($file) + { $relPath = 'less/' . $file; $urlHelper = $this->getView()->plugin('url'); $currentTheme = $this->themeInfo->findContainingTheme($relPath); @@ -122,15 +133,76 @@ class HeadLink extends \Zend\View\Helper\HeadLink 'output' => str_replace('.less', '.css', $file) ] ); - $this->prependStylesheet( - $cssDirectory . $css_file_name, $media, $conditionalStylesheet - ); + return $cssDirectory . $css_file_name; } catch (\Exception $e) { error_log($e->getMessage()); list($fileName, ) = explode('.', $file); - $this->prependStylesheet( - $urlHelper('home') . "themes/{$currentTheme}/css/{$fileName}.css" - ); + return $urlHelper('home') . "themes/{$currentTheme}/css/{$fileName}.css"; } } + + /** + * Returns true if file should not be included in the compressed concat file + * Required by ConcatTrait + * + * @param stdClass $item Link element object + * + * @return bool + */ + protected function isExcludedFromConcat($item) + { + return !isset($item->rel) || $item->rel != 'stylesheet' + || strpos($item->href, '://'); + } + + /** + * Get the file path from the link object + * Required by ConcatTrait + * + * @param stdClass $item Link element object + * + * @return string + */ + protected function getResourceFilePath($item) + { + return $item->href; + } + + /** + * Set the file path of the link object + * Required by ConcatTrait + * + * @param stdClass $item Link element object + * @param string $path New path string + * + * @return stdClass + */ + protected function setResourceFilePath($item, $path) + { + $item->href = $path; + return $item; + } + + /** + * Get the file type + * + * @param stdClass $item Link element object + * + * @return string + */ + public function getType($item) + { + return isset($item->media) ? $item->media : 'all'; + } + + /** + * Get the minifier that can handle these file types + * Required by ConcatTrait + * + * @return \MatthiasMullie\Minify\JS + */ + protected function getMinifier() + { + return new \MatthiasMullie\Minify\CSS(); + } } diff --git a/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadScript.php b/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadScript.php index e97b7caccf8ff2d1fca72b3ea0e138f2c26c2f3e..9ee6fdd0914264e74cffe7696f464264e91d7412 100644 --- a/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadScript.php +++ b/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadScript.php @@ -39,6 +39,8 @@ use VuFindTheme\ThemeInfo; */ class HeadScript extends \Zend\View\Helper\HeadScript { + use ConcatTrait; + /** * Theme information service * @@ -50,11 +52,23 @@ class HeadScript extends \Zend\View\Helper\HeadScript * Constructor * * @param ThemeInfo $themeInfo Theme information service + * @param boolean $plconfig Whether or not to concatenate */ - public function __construct(ThemeInfo $themeInfo) + public function __construct(ThemeInfo $themeInfo, $plconfig = false) { parent::__construct(); $this->themeInfo = $themeInfo; + $this->usePipeline = $this->enabledInConfig($plconfig); + } + + /** + * Folder name and file extension for trait + * + * @return string + */ + protected function getFileType() + { + return 'js'; } /** @@ -86,4 +100,58 @@ class HeadScript extends \Zend\View\Helper\HeadScript return parent::itemToString($item, $indent, $escapeStart, $escapeEnd); } + + /** + * Returns true if file should not be included in the compressed concat file + * Required by ConcatTrait + * + * @param stdClass $item Script element object + * + * @return bool + */ + protected function isExcludedFromConcat($item) + { + return empty($item->attributes['src']) + || isset($item->attributes['conditional']) + || strpos($item->attributes['src'], '://'); + } + + /** + * Get the file path from the script object + * Required by ConcatTrait + * + * @param stdClass $item Script element object + * + * @return string + */ + protected function getResourceFilePath($item) + { + return $item->attributes['src']; + } + + /** + * Set the file path of the script object + * Required by ConcatTrait + * + * @param stdClass $item Script element object + * @param string $path New path string + * + * @return stdClass + */ + protected function setResourceFilePath($item, $path) + { + $item->attributes['src'] = $path; + return $item; + } + + /** + * Get the minifier that can handle these file types + * Required by ConcatTrait + * + * @return \MatthiasMullie\Minify\JS + */ + protected function getMinifier() + { + return new \MatthiasMullie\Minify\JS(); + } } diff --git a/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadThemeResources.php b/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadThemeResources.php index d8995883b8e75f623d715e389008da79a6944ad4..078398183ba9a4d58be644658c904f17f062a1f2 100644 --- a/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadThemeResources.php +++ b/module/VuFindTheme/src/VuFindTheme/View/Helper/HeadThemeResources.php @@ -113,8 +113,8 @@ class HeadThemeResources extends \Zend\View\Helper\AbstractHelper // theme resources should load before extras added by individual templates): foreach (array_reverse($this->container->getLessCss()) as $current) { $parts = explode(':', $current); - $headLink()->addLessStylesheet( - trim($parts[0]), + $headLink()->prependStylesheet( + $headLink()->addLessStylesheet(trim($parts[0])), isset($parts[1]) ? trim($parts[1]) : 'all', isset($parts[2]) ? trim($parts[2]) : false );