From 1f467ecfaafbf4d54b3a7f5017c255ab11b326f7 Mon Sep 17 00:00:00 2001 From: Eric Lippmann Date: Fri, 27 Nov 2015 16:40:17 +0100 Subject: [PATCH] Add theme to the stylesheet if set and ... ... revamp interface of LessCompiler and StyleSheet refs #10705 --- application/layouts/scripts/pdf.phtml | 2 +- library/Icinga/Application/Web.php | 10 +- library/Icinga/Application/webrouter.php | 2 +- library/Icinga/Web/LessCompiler.php | 240 +++++++++++------------ library/Icinga/Web/StyleSheet.php | 225 +++++++++++++++------ 5 files changed, 288 insertions(+), 191 deletions(-) diff --git a/application/layouts/scripts/pdf.phtml b/application/layouts/scripts/pdf.phtml index ed8e12780..68c341e01 100644 --- a/application/layouts/scripts/pdf.phtml +++ b/application/layouts/scripts/pdf.phtml @@ -15,7 +15,7 @@ if ($moduleName !== 'default') { diff --git a/library/Icinga/Application/Web.php b/library/Icinga/Application/Web.php index d49a06f55..5bebde085 100644 --- a/library/Icinga/Application/Web.php +++ b/library/Icinga/Application/Web.php @@ -21,6 +21,7 @@ use Icinga\Web\Navigation\Navigation; use Icinga\Web\Notification; use Icinga\Web\Session; use Icinga\Web\Session\Session as BaseSession; +use Icinga\Web\StyleSheet; use Icinga\Web\View; /** @@ -34,13 +35,6 @@ use Icinga\Web\View; */ class Web extends EmbeddedWeb { - /** - * The name of the default theme - * - * @var string - */ - const DEFAULT_THEME = 'Icinga'; - /** * View object * @@ -113,7 +107,7 @@ class Web extends EmbeddedWeb */ public function getThemes() { - $themes = array(static::DEFAULT_THEME); + $themes = array(StyleSheet::DEFAULT_THEME); $applicationThemePath = $this->getBaseDir('public/css/themes'); if (DirectoryIterator::isReadable($applicationThemePath)) { foreach (new DirectoryIterator($applicationThemePath, 'less') as $name => $theme) { diff --git a/library/Icinga/Application/webrouter.php b/library/Icinga/Application/webrouter.php index 5c8cbb6ea..5ce782889 100644 --- a/library/Icinga/Application/webrouter.php +++ b/library/Icinga/Application/webrouter.php @@ -61,7 +61,7 @@ if (in_array($path, $special)) { Stylesheet::send(); exit; case 'css/icinga.min.css': - Stylesheet::sendMinified(); + Stylesheet::send(true); exit; case 'js/icinga.dev.js': diff --git a/library/Icinga/Web/LessCompiler.php b/library/Icinga/Web/LessCompiler.php index eae3f565c..e1ec06e50 100644 --- a/library/Icinga/Web/LessCompiler.php +++ b/library/Icinga/Web/LessCompiler.php @@ -3,167 +3,167 @@ namespace Icinga\Web; -use Exception; -use RecursiveDirectoryIterator; +use Icinga\Application\Logger; +use RecursiveArrayIterator; use RecursiveIteratorIterator; -use RegexIterator; -use RecursiveRegexIterator; -use Icinga\Application\Icinga; use lessc; /** - * Less compiler prints files or directories to stdout + * Compile LESS into CSS + * + * Comments will be removed always. lessc is messing them up. */ class LessCompiler { - /** - * Collection of items: File or directories - * - * @var array - */ - private $items = array(); - /** * lessphp compiler * - * @var \lessc + * @var lessc */ - private $lessc; - - private $source; + protected $lessc; /** - * Create a new instance + * Array of LESS files + * + * @var string[] + */ + protected $lessFiles = array(); + + /** + * Array of module LESS files indexed by module names + * + * @var array[] + */ + protected $moduleLessFiles = array(); + + /** + * LESS source + * + * @var string + */ + protected $source; + + /** + * Path of the LESS theme + * + * @var string + */ + protected $theme; + + /** + * Create a new LESS compiler */ public function __construct() { require_once 'lessphp/lessc.inc.php'; $this->lessc = new lessc(); + // Discourage usage of import because we're caching based on an explicit list of LESS files to compile + $this->lessc->importDisabled = true; } /** - * Disable the extendend import functionality + * Add a Web 2 LESS file + * + * @param string $lessFile Path to the LESS file * * @return $this */ - public function disableExtendedImport() + public function addLessFile($lessFile) { - $this->lessc->importDisabled = true; + $this->lessFiles[] = $lessFile; return $this; } + /** + * Add a module LESS file + * + * @param string $moduleName Name of the module + * @param string $lessFile Path to the LESS file + * + * @return $this + */ + public function addModuleLessFile($moduleName, $lessFile) + { + if (! isset($this->moduleLessFiles[$moduleName])) { + $this->moduleLessFiles[$moduleName] = array(); + } + $this->moduleLessFiles[$moduleName][] = $lessFile; + return $this; + } + + /** + * Get the list of LESS files added to the compiler + * + * @return string[] + */ + public function getLessFiles() + { + $lessFiles = iterator_to_array(new RecursiveIteratorIterator(new RecursiveArrayIterator( + $this->lessFiles + $this->moduleLessFiles + ))); + if ($this->theme !== null) { + $lessFiles[] = $this->theme; + } + return $lessFiles; + } + + /** + * Set the path to the LESS theme + * + * @param string $theme Path to the LESS theme + * + * @return $this + */ + public function setTheme($theme) + { + Logger::debug($theme); + $this->theme = $theme; + return $this; + } + + /** + * Instruct the compiler to minify CSS + * + * @return $this + */ public function compress() { - $this->lessc->setPreserveComments(false); $this->lessc->setFormatter('compressed'); return $this; } /** - * Add usable style item to stack + * Render to CSS * - * @param string $item File or directory + * @return string */ - public function addItem($item) + public function render() { - $this->items[] = $item; - } - - public function addLoadedModules() - { - foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $name => $module) { - $this->addModule($name, $module); + foreach ($this->lessFiles as $lessFile) { + $this->source .= file_get_contents($lessFile); } - return $this; - } - public function addFile($filename) - { - $this->source .= "\n/* CSS: $filename */\n" - . file_get_contents($filename) - . "\n\n"; - return $this; - } + $moduleCss = ''; + foreach ($this->moduleLessFiles as $moduleName => $moduleLessFiles) { + $moduleCss .= '.icinga-module.module-' . $moduleName . ' {'; + foreach ($moduleLessFiles as $moduleLessFile) { + $moduleCss .= file_get_contents($moduleLessFile); + } + $moduleCss .= '}'; + } + + $moduleCss = preg_replace( + '/(\.icinga-module\.module-[^\s]+) (#layout\.[^\s]+)/m', + '\2 \1', + $moduleCss + ); + + $this->source .= $moduleCss; + + if ($this->theme !== null) { + $this->source .= file_get_contents($this->theme); + } - public function compile() - { return $this->lessc->compile($this->source); } - - public function addModule($name, $module) - { - if ($module->hasCss()) { - $contents = array(); - foreach ($module->getCssFiles() as $path) { - if (file_exists($path)) { - $contents[] = "/* CSS: modules/$name/$path */\n" . file_get_contents($path); - } - } - - $this->source .= '' - . '.icinga-module.module-' - . $name - . " {\n" - . join("\n\n", $contents) - . "}\n\n"; - } - - return $this; - } - - /** - * Compile and print a single file - * - * @param string $file - */ - public function printFile($file) - { - $ext = pathinfo($file, PATHINFO_EXTENSION); - echo PHP_EOL. '/* CSS: ' . $file . ' */' . PHP_EOL; - - if ($ext === 'css') { - readfile($file); - } elseif ($ext === 'less') { - try { - echo $this->lessc->compileFile($file); - } catch (Exception $e) { - echo '/* ' . PHP_EOL . ' ===' . PHP_EOL; - echo ' Error in file ' . $file . PHP_EOL; - echo ' ' . $e->getMessage() . PHP_EOL . PHP_EOL; - echo ' ' . 'This file was dropped cause of errors.' . PHP_EOL; - echo ' ===' . PHP_EOL . '*/' . PHP_EOL; - } - } - - echo PHP_EOL; - } - - /** - * Compile and print a path content (recursive) - * - * @param string $path - */ - public function printPathRecursive($path) - { - $directoryInterator = new RecursiveDirectoryIterator($path); - $iterator = new RecursiveIteratorIterator($directoryInterator); - $filteredIterator = new RegexIterator($iterator, '/\.(css|less)$/', RecursiveRegexIterator::GET_MATCH); - foreach ($filteredIterator as $file => $extension) { - $this->printFile($file); - } - } - - /** - * Compile and print the whole item stack - */ - public function printStack() - { - foreach ($this->items as $item) { - if (is_dir($item)) { - $this->printPathRecursive($item); - } elseif (is_file($item)) { - $this->printFile($item); - } - } - } } diff --git a/library/Icinga/Web/StyleSheet.php b/library/Icinga/Web/StyleSheet.php index 5ce438e74..9e83e2f4d 100644 --- a/library/Icinga/Web/StyleSheet.php +++ b/library/Icinga/Web/StyleSheet.php @@ -3,12 +3,28 @@ namespace Icinga\Web; +use Exception; use Icinga\Application\Icinga; -use Icinga\Web\FileCache; -use Icinga\Web\LessCompiler; +use Icinga\Application\Logger; +use Icinga\Exception\IcingaException; +/** + * Send CSS for Web 2 and all loaded modules to the client + */ class StyleSheet { + /** + * The name of the default theme + * + * @var string + */ + const DEFAULT_THEME = 'Icinga'; + + /** + * Array of core LESS files Web 2 sends to the client + * + * @var string[] + */ protected static $lessFiles = array( '../application/fonts/fontello-ifont/css/ifont-embedded.css', 'css/vendor/normalize.css', @@ -40,89 +56,176 @@ class StyleSheet 'css/icinga/compat.less' ); - public static function compileForPdf() - { - self::checkPhp(); - $less = new LessCompiler(); - $basedir = Icinga::app()->getBootstrapDirectory(); - foreach (self::$lessFiles as $file) { - $less->addFile($basedir . '/' . $file); - } - $less->addLoadedModules(); - $less->addFile($basedir . '/css/pdf/pdfprint.less'); - return $less->compile(); - } + /** + * Application instance + * + * @var \Icinga\Application\EmbeddedWeb + */ + protected $app; - public static function sendMinified() - { - self::send(true); - } + /** + * Less compiler + * + * @var LessCompiler + */ + protected $lessCompiler; - protected static function fixModuleLayoutCss($css) - { - return preg_replace( - '/(\.icinga-module\.module-[^\s]+) (#layout\.[^\s]+)/m', - '\2 \1', - $css - ); - } + /** + * Path to the public directory + * + * @var string + */ + protected $pubPath; - protected static function checkPhp() + /** + * Create the StyleSheet + */ + public function __construct() { // PHP had a rather conservative PCRE backtrack limit unless 5.3.7 if (version_compare(PHP_VERSION, '5.3.7') <= 0) { ini_set('pcre.backtrack_limit', 1000000); } + $app = Icinga::app(); + $this->app = $app; + $this->lessCompiler = new LessCompiler(); + $this->pubPath = $app->getBootstrapDirectory(); + $this->collect(); } - public static function send($minified = false) + /** + * Collect Web 2 and module LESS files and add them to the LESS compiler + */ + protected function collect() { - self::checkPhp(); - $app = Icinga::app(); - $basedir = $app->getBootstrapDirectory(); - foreach (self::$lessFiles as $file) { - $lessFiles[] = $basedir . '/' . $file; + foreach (self::$lessFiles as $lessFile) { + $this->lessCompiler->addLessFile($this->pubPath . '/' . $lessFile); } - $files = $lessFiles; - foreach ($app->getModuleManager()->getLoadedModules() as $name => $module) { + + $mm = $this->app->getModuleManager(); + + foreach ($mm->getLoadedModules() as $moduleName => $module) { if ($module->hasCss()) { - foreach ($module->getCssFiles() as $path) { - if (file_exists($path)) { - $files[] = $path; - } + foreach ($module->getCssFiles() as $lessFilePath) { + $this->lessCompiler->addModuleLessFile($moduleName, $lessFilePath); } } } - if ($etag = FileCache::etagMatchesFiles($files)) { - header("HTTP/1.1 304 Not Modified"); - return; + $themingConfig = $this->app->getConfig()->getSection('theming'); + $defaultTheme = $themingConfig->get('default', self::DEFAULT_THEME); + $theme = null; + + if ((bool) $themingConfig->get('disabled', false)) { + if ($defaultTheme !== self::DEFAULT_THEME) { + $theme = $defaultTheme; + } } else { - $etag = FileCache::etagForFiles($files); + if (($userTheme = $this->app->getRequest()->getCookie('theme', $defaultTheme)) + && $userTheme !== $defaultTheme + ) { + $theme = $userTheme; + } } - header('Cache-Control: public'); - header('ETag: "' . $etag . '"'); - header('Content-Type: text/css'); - $min = $minified ? '.min' : ''; - $cacheFile = 'icinga-' . $etag . $min . '.css'; - $cache = FileCache::instance(); - if ($cache->has($cacheFile)) { - $cache->send($cacheFile); + if ($theme) { + if (($pos = strpos($theme, '/')) !== false) { + $moduleName = substr($theme, 0, $pos); + $theme = substr($theme, $pos + 1); + if ($mm->hasLoaded($moduleName)) { + $module = $mm->getModule($moduleName); + $this->lessCompiler->setTheme($module->getCssDir() . '/themes/' . $theme . '.less'); + } + } else { + $this->lessCompiler->setTheme($this->pubPath . '/css/themes/' . $theme . '.less'); + } + } + } + + /** + * Get the stylesheet for PDF export + * + * @return $this + */ + public static function forPdf() + { + $styleSheet = new self(); + $styleSheet->lessCompiler->addLessFile($styleSheet->pubPath . '/css/pdf/pdfprint.less'); + // TODO(el): Caching + return $styleSheet; + } + + /** + * Render the stylesheet + * + * @param bool $minified Whether to compress the stylesheet + * + * @return string CSS + */ + public function render($minified = false) + { + if ($minified) { + $this->lessCompiler->compress(); + } + return $this->lessCompiler->render(); + } + + /** + * Send the stylesheet to the client + * + * Does not cache the stylesheet if the HTTP header Cache-Control or Pragma is set to no-cache. + * + * @param bool $minified Whether to compress the stylesheet + */ + public static function send($minified = false) + { + $styleSheet = new self(); + + $request = $styleSheet->app->getRequest(); + $response = $styleSheet->app->getResponse(); + + $noCache = $request->getHeader('Cache-Control') === 'no-cache' || $request->getHeader('Pragma') === 'no-cache'; + + if (! $noCache && FileCache::etagMatchesFiles($styleSheet->lessCompiler->getLessFiles())) { + $response + ->setHttpResponseCode(304) + ->sendHeaders(); return; } - $less = new LessCompiler(); - $less->disableExtendedImport(); - foreach ($lessFiles as $file) { - $less->addFile($file); + $etag = FileCache::etagForFiles($styleSheet->lessCompiler->getLessFiles()); + + $response + ->setHeader('Cache-Control', 'public', true) + ->setHeader('ETag', $etag, true) + ->setHeader('Content-Type', 'text/css', true); + + $cacheFile = 'icinga-' . $etag . ($minified ? '.min' : '') . '.css'; + $cache = FileCache::instance(); + + if (! $noCache && $cache->has($cacheFile)) { + $response->setBody($cache->get($cacheFile)); + } else { + $css = $styleSheet->render($minified); + $response->setBody($css); + $cache->store($cacheFile, $css); } - $less->addLoadedModules(); - if ($minified) { - $less->compress(); + + $response->sendResponse(); + } + + /** + * Render the stylesheet + * + * @return string + */ + public function __toString() + { + try { + return $this->render(); + } catch (Exception $e) { + Logger::error($e); + return IcingaException::describe($e); } - $out = self::fixModuleLayoutCss($less->compile()); - $cache->store($cacheFile, $out); - echo $out; } }