Add theme to the stylesheet if set and ...

... revamp interface of LessCompiler and StyleSheet

refs #10705
This commit is contained in:
Eric Lippmann 2015-11-27 16:40:17 +01:00
parent e7262b7d14
commit 1f467ecfaa
5 changed files with 288 additions and 191 deletions

View File

@ -15,7 +15,7 @@ if ($moduleName !== 'default') {
<html> <html>
<head> <head>
<style> <style>
<?= StyleSheet::compileForPdf() ?> <?= StyleSheet::forPdf() ?>
</style> </style>
</head> </head>

View File

@ -21,6 +21,7 @@ use Icinga\Web\Navigation\Navigation;
use Icinga\Web\Notification; use Icinga\Web\Notification;
use Icinga\Web\Session; use Icinga\Web\Session;
use Icinga\Web\Session\Session as BaseSession; use Icinga\Web\Session\Session as BaseSession;
use Icinga\Web\StyleSheet;
use Icinga\Web\View; use Icinga\Web\View;
/** /**
@ -34,13 +35,6 @@ use Icinga\Web\View;
*/ */
class Web extends EmbeddedWeb class Web extends EmbeddedWeb
{ {
/**
* The name of the default theme
*
* @var string
*/
const DEFAULT_THEME = 'Icinga';
/** /**
* View object * View object
* *
@ -113,7 +107,7 @@ class Web extends EmbeddedWeb
*/ */
public function getThemes() public function getThemes()
{ {
$themes = array(static::DEFAULT_THEME); $themes = array(StyleSheet::DEFAULT_THEME);
$applicationThemePath = $this->getBaseDir('public/css/themes'); $applicationThemePath = $this->getBaseDir('public/css/themes');
if (DirectoryIterator::isReadable($applicationThemePath)) { if (DirectoryIterator::isReadable($applicationThemePath)) {
foreach (new DirectoryIterator($applicationThemePath, 'less') as $name => $theme) { foreach (new DirectoryIterator($applicationThemePath, 'less') as $name => $theme) {

View File

@ -61,7 +61,7 @@ if (in_array($path, $special)) {
Stylesheet::send(); Stylesheet::send();
exit; exit;
case 'css/icinga.min.css': case 'css/icinga.min.css':
Stylesheet::sendMinified(); Stylesheet::send(true);
exit; exit;
case 'js/icinga.dev.js': case 'js/icinga.dev.js':

View File

@ -3,167 +3,167 @@
namespace Icinga\Web; namespace Icinga\Web;
use Exception; use Icinga\Application\Logger;
use RecursiveDirectoryIterator; use RecursiveArrayIterator;
use RecursiveIteratorIterator; use RecursiveIteratorIterator;
use RegexIterator;
use RecursiveRegexIterator;
use Icinga\Application\Icinga;
use lessc; 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 class LessCompiler
{ {
/**
* Collection of items: File or directories
*
* @var array
*/
private $items = array();
/** /**
* lessphp compiler * lessphp compiler
* *
* @var \lessc * @var lessc
*/ */
private $lessc; protected $lessc;
private $source;
/** /**
* 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() public function __construct()
{ {
require_once 'lessphp/lessc.inc.php'; require_once 'lessphp/lessc.inc.php';
$this->lessc = new lessc(); $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 * @return $this
*/ */
public function disableExtendedImport() public function addLessFile($lessFile)
{ {
$this->lessc->importDisabled = true; $this->lessFiles[] = $lessFile;
return $this; 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() public function compress()
{ {
$this->lessc->setPreserveComments(false);
$this->lessc->setFormatter('compressed'); $this->lessc->setFormatter('compressed');
return $this; 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; foreach ($this->lessFiles as $lessFile) {
} $this->source .= file_get_contents($lessFile);
public function addLoadedModules()
{
foreach (Icinga::app()->getModuleManager()->getLoadedModules() as $name => $module) {
$this->addModule($name, $module);
} }
return $this;
}
public function addFile($filename) $moduleCss = '';
{ foreach ($this->moduleLessFiles as $moduleName => $moduleLessFiles) {
$this->source .= "\n/* CSS: $filename */\n" $moduleCss .= '.icinga-module.module-' . $moduleName . ' {';
. file_get_contents($filename) foreach ($moduleLessFiles as $moduleLessFile) {
. "\n\n"; $moduleCss .= file_get_contents($moduleLessFile);
return $this; }
} $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); 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);
}
}
}
} }

View File

@ -3,12 +3,28 @@
namespace Icinga\Web; namespace Icinga\Web;
use Exception;
use Icinga\Application\Icinga; use Icinga\Application\Icinga;
use Icinga\Web\FileCache; use Icinga\Application\Logger;
use Icinga\Web\LessCompiler; use Icinga\Exception\IcingaException;
/**
* Send CSS for Web 2 and all loaded modules to the client
*/
class StyleSheet 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( protected static $lessFiles = array(
'../application/fonts/fontello-ifont/css/ifont-embedded.css', '../application/fonts/fontello-ifont/css/ifont-embedded.css',
'css/vendor/normalize.css', 'css/vendor/normalize.css',
@ -40,89 +56,176 @@ class StyleSheet
'css/icinga/compat.less' 'css/icinga/compat.less'
); );
public static function compileForPdf() /**
{ * Application instance
self::checkPhp(); *
$less = new LessCompiler(); * @var \Icinga\Application\EmbeddedWeb
$basedir = Icinga::app()->getBootstrapDirectory(); */
foreach (self::$lessFiles as $file) { protected $app;
$less->addFile($basedir . '/' . $file);
}
$less->addLoadedModules();
$less->addFile($basedir . '/css/pdf/pdfprint.less');
return $less->compile();
}
public static function sendMinified() /**
{ * Less compiler
self::send(true); *
} * @var LessCompiler
*/
protected $lessCompiler;
protected static function fixModuleLayoutCss($css) /**
{ * Path to the public directory
return preg_replace( *
'/(\.icinga-module\.module-[^\s]+) (#layout\.[^\s]+)/m', * @var string
'\2 \1', */
$css protected $pubPath;
);
}
protected static function checkPhp() /**
* Create the StyleSheet
*/
public function __construct()
{ {
// PHP had a rather conservative PCRE backtrack limit unless 5.3.7 // PHP had a rather conservative PCRE backtrack limit unless 5.3.7
if (version_compare(PHP_VERSION, '5.3.7') <= 0) { if (version_compare(PHP_VERSION, '5.3.7') <= 0) {
ini_set('pcre.backtrack_limit', 1000000); 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(); foreach (self::$lessFiles as $lessFile) {
$app = Icinga::app(); $this->lessCompiler->addLessFile($this->pubPath . '/' . $lessFile);
$basedir = $app->getBootstrapDirectory();
foreach (self::$lessFiles as $file) {
$lessFiles[] = $basedir . '/' . $file;
} }
$files = $lessFiles;
foreach ($app->getModuleManager()->getLoadedModules() as $name => $module) { $mm = $this->app->getModuleManager();
foreach ($mm->getLoadedModules() as $moduleName => $module) {
if ($module->hasCss()) { if ($module->hasCss()) {
foreach ($module->getCssFiles() as $path) { foreach ($module->getCssFiles() as $lessFilePath) {
if (file_exists($path)) { $this->lessCompiler->addModuleLessFile($moduleName, $lessFilePath);
$files[] = $path;
}
} }
} }
} }
if ($etag = FileCache::etagMatchesFiles($files)) { $themingConfig = $this->app->getConfig()->getSection('theming');
header("HTTP/1.1 304 Not Modified"); $defaultTheme = $themingConfig->get('default', self::DEFAULT_THEME);
return; $theme = null;
if ((bool) $themingConfig->get('disabled', false)) {
if ($defaultTheme !== self::DEFAULT_THEME) {
$theme = $defaultTheme;
}
} else { } 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' : ''; if ($theme) {
$cacheFile = 'icinga-' . $etag . $min . '.css'; if (($pos = strpos($theme, '/')) !== false) {
$cache = FileCache::instance(); $moduleName = substr($theme, 0, $pos);
if ($cache->has($cacheFile)) { $theme = substr($theme, $pos + 1);
$cache->send($cacheFile); 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; return;
} }
$less = new LessCompiler(); $etag = FileCache::etagForFiles($styleSheet->lessCompiler->getLessFiles());
$less->disableExtendedImport();
foreach ($lessFiles as $file) { $response
$less->addFile($file); ->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) { $response->sendResponse();
$less->compress(); }
/**
* 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;
} }
} }