diff options
Diffstat (limited to 'library/Icinga/Less')
-rw-r--r-- | library/Icinga/Less/Call.php | 77 | ||||
-rw-r--r-- | library/Icinga/Less/ColorProp.php | 109 | ||||
-rw-r--r-- | library/Icinga/Less/ColorPropOrVariable.php | 71 | ||||
-rw-r--r-- | library/Icinga/Less/DeferredColorProp.php | 136 | ||||
-rw-r--r-- | library/Icinga/Less/LightMode.php | 128 | ||||
-rw-r--r-- | library/Icinga/Less/LightModeCall.php | 38 | ||||
-rw-r--r-- | library/Icinga/Less/LightModeDefinition.php | 75 | ||||
-rw-r--r-- | library/Icinga/Less/LightModeTrait.php | 30 | ||||
-rw-r--r-- | library/Icinga/Less/LightModeVisitor.php | 26 | ||||
-rw-r--r-- | library/Icinga/Less/Visitor.php | 243 |
10 files changed, 933 insertions, 0 deletions
diff --git a/library/Icinga/Less/Call.php b/library/Icinga/Less/Call.php new file mode 100644 index 0000000..0a78cb5 --- /dev/null +++ b/library/Icinga/Less/Call.php @@ -0,0 +1,77 @@ +<?php + +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_Tree_Call; +use Less_Tree_Color; +use Less_Tree_Value; +use Less_Tree_Variable; + +class Call extends Less_Tree_Call +{ + public static function fromCall(Less_Tree_Call $call) + { + return new static($call->name, $call->args, $call->index, $call->currentFileInfo); + } + + public function compile($env = null) + { + if (! $env) { + // Not sure how to trigger this, but if there is no $env, there is nothing we can do + return parent::compile($env); + } + + foreach ($this->args as $arg) { + if (! is_array($arg->value)) { + continue; + } + + $name = null; + if ($arg->value[0] instanceof Less_Tree_Variable) { + // This is the case when defining a variable with a callable LESS rules such as fade, fadeout.. + // Example: `@foo: #fff; @foo-bar: fade(@foo, 10);` + $name = $arg->value[0]->name; + } elseif ($arg->value[0] instanceof ColorPropOrVariable) { + // This is the case when defining a CSS rule using the LESS functions and passing + // a variable as an argument to them. Example: `... { color: fade(@foo, 10%); }` + $name = $arg->value[0]->getVariable()->name; + } + + if ($name) { + foreach ($env->frames as $frame) { + if (($v = $frame->variable($name))) { + // Variables from the frame stack are always of type LESS Tree Rule + $vr = $v->value; + if ($vr instanceof Less_Tree_Value) { + // Get the actual color prop, otherwise this may cause an invalid argument error + $vr = $vr->compile($env); + } + + if ($vr instanceof DeferredColorProp) { + if (! $vr->hasReference()) { + // Should never happen, though just for safety's sake + $vr->compile($env); + } + + // Get the uppermost variable of the variable references + while (! $vr instanceof ColorProp) { + $vr = $vr->getRef(); + } + } elseif ($vr instanceof Less_Tree_Color) { + $vr = ColorProp::fromColor($vr); + $vr->setName($name); + } + + $arg->value[0] = $vr; + + break; + } + } + } + } + + return parent::compile($env); + } +} diff --git a/library/Icinga/Less/ColorProp.php b/library/Icinga/Less/ColorProp.php new file mode 100644 index 0000000..3f83c5e --- /dev/null +++ b/library/Icinga/Less/ColorProp.php @@ -0,0 +1,109 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_Tree_Call; +use Less_Tree_Color; +use Less_Tree_Keyword; + +/** + * ColorProp renders Less colors as CSS var() function calls + * + * It extends {@link Less_Tree_Color} so that Less functions that take a Less_Tree_Color as an argument do not fail. + */ +class ColorProp extends Less_Tree_Color +{ + /** @var Less_Tree_Color Color with which we created the ColorProp */ + protected $color; + + /** @var int */ + protected $index; + + /** @var string Color variable name */ + protected $name; + + public function __construct() + { + } + + /** + * @param Less_Tree_Color $color + * + * @return static + */ + public static function fromColor(Less_Tree_Color $color) + { + $self = new static(); + $self->color = $color; + + foreach ($color as $k => $v) { + if ($k === 'name') { + $self->setName($v); // Removes the @ char from the name + } else { + $self->$k = $v; + } + } + + return $self; + } + + /** + * @return int + */ + public function getIndex() + { + return $this->index; + } + + /** + * @param int $index + * + * @return $this + */ + public function setIndex($index) + { + $this->index = $index; + + return $this; + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @param string $name + * + * @return $this + */ + public function setName($name) + { + if ($name[0] === '@') { + $name = substr($name, 1); + } + + $this->name = $name; + + return $this; + } + + public function genCSS($output) + { + $css = (new Less_Tree_Call( + 'var', + [ + new Less_Tree_Keyword('--' . $this->getName()), + // Use the Less_Tree_Color with which we created the ColorProp so that we don't get into genCSS() loops. + $this->color + ], + $this->getIndex() + ))->toCSS(); + + $output->add($css); + } +} diff --git a/library/Icinga/Less/ColorPropOrVariable.php b/library/Icinga/Less/ColorPropOrVariable.php new file mode 100644 index 0000000..7918674 --- /dev/null +++ b/library/Icinga/Less/ColorPropOrVariable.php @@ -0,0 +1,71 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_Tree; +use Less_Tree_Color; +use Less_Tree_Variable; + +/** + * Compile a Less variable to {@link ColorProp} if it is a color + */ +class ColorPropOrVariable extends Less_Tree +{ + public $type = 'Variable'; + + /** @var Less_Tree_Variable */ + protected $variable; + + /** + * @return Less_Tree_Variable + */ + public function getVariable() + { + return $this->variable; + } + + /** + * @param Less_Tree_Variable $variable + * + * @return $this + */ + public function setVariable(Less_Tree_Variable $variable) + { + $this->variable = $variable; + + return $this; + } + + public function compile($env) + { + $v = $this->getVariable(); + + if ($v->name[1] === '@') { + // Evaluate variable variable as in Less_Tree_Variable:28. + $vv = new Less_Tree_Variable(substr($v->name, 1), $v->index + 1, $v->currentFileInfo); + // Overwrite the name so that the variable variable is not evaluated again. + $result = $vv->compile($env); + if ($result instanceof DeferredColorProp) { + $v->name = $result->name; + } else { + $v->name = '@' . $result->value; + } + } + + $compiled = $v->compile($env); + + if ($compiled instanceof ColorProp) { + // We may already have a ColorProp, which is the case with mixin calls. + return $compiled; + } + + if ($compiled instanceof Less_Tree_Color) { + return ColorProp::fromColor($compiled) + ->setIndex($v->index) + ->setName($v->name); + } + + return $compiled; + } +} diff --git a/library/Icinga/Less/DeferredColorProp.php b/library/Icinga/Less/DeferredColorProp.php new file mode 100644 index 0000000..c9c39ad --- /dev/null +++ b/library/Icinga/Less/DeferredColorProp.php @@ -0,0 +1,136 @@ +<?php + +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_Exception_Compiler; +use Less_Tree_Call; +use Less_Tree_Color; +use Less_Tree_Keyword; +use Less_Tree_Value; +use Less_Tree_Variable; + +class DeferredColorProp extends Less_Tree_Variable +{ + /** @var DeferredColorProp|ColorProp */ + protected $reference; + + protected $resolved = false; + + public function __construct($name, $variable, $index = null, $currentFileInfo = null) + { + parent::__construct($name, $index, $currentFileInfo); + + if ($variable instanceof Less_Tree_Variable) { + $this->reference = self::fromVariable($variable); + } + } + + public function isResolved() + { + return $this->resolved; + } + + public function getName() + { + $name = $this->name; + if ($this->name[0] === '@') { + $name = substr($this->name, 1); + } + + return $name; + } + + public function hasReference() + { + return $this->reference !== null; + } + + public function getRef() + { + return $this->reference; + } + + public function setReference($ref) + { + $this->reference = $ref; + + return $this; + } + + public static function fromVariable(Less_Tree_Variable $variable) + { + $static = new static($variable->name, $variable->index, $variable->currentFileInfo); + $static->evaluating = $variable->evaluating; + $static->type = $variable->type; + + return $static; + } + + public function compile($env) + { + if (! $this->hasReference()) { + // This is never supposed to happen, however, we might have a deferred color prop + // without a reference. In this case we can simply use the parent method. + return parent::compile($env); + } + + if ($this->isResolved()) { + // The dependencies are already resolved, no need to traverse the frame stack over again! + return $this; + } + + if ($this->evaluating) { // Just like the parent method + throw new Less_Exception_Compiler( + "Recursive variable definition for " . $this->name, + null, + $this->index, + $this->currentFileInfo + ); + } + + $this->evaluating = true; + + foreach ($env->frames as $frame) { + if (($v = $frame->variable($this->getRef()->name))) { + $rv = $v->value; + if ($rv instanceof Less_Tree_Value) { + $rv = $rv->compile($env); + } + + // As we are at it anyway, let's cast the tree color to our color prop as well! + if ($rv instanceof Less_Tree_Color) { + $rv = ColorProp::fromColor($rv); + $rv->setName($this->getRef()->getName()); + } + + $this->evaluating = false; + $this->resolved = true; + $this->setReference($rv); + + break; + } + } + + return $this; + } + + public function genCSS($output) + { + if (! $this->hasReference()) { + return; // Nothing to generate + } + + $css = (new Less_Tree_Call( + 'var', + [ + new Less_Tree_Keyword('--' . $this->getName()), + $this->getRef() // Each of the references will be generated recursively + ], + $this->index + ))->toCSS(); + + $output->add($css); + } +} diff --git a/library/Icinga/Less/LightMode.php b/library/Icinga/Less/LightMode.php new file mode 100644 index 0000000..b4b72a0 --- /dev/null +++ b/library/Icinga/Less/LightMode.php @@ -0,0 +1,128 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use ArrayIterator; +use InvalidArgumentException; +use IteratorAggregate; +use Less_Environment; +use Traversable; + +/** + * Registry for light modes and the environments in which they are defined + */ +class LightMode implements IteratorAggregate +{ + /** @var array Mode environments as mode-environment pairs */ + protected $envs = []; + + /** @var array Assoc list of modes */ + protected $modes = []; + + /** @var array Mode selectors as mode-selector pairs */ + protected $selectors = []; + + /** + * @param string $mode + * + * @return $this + * + * @throws InvalidArgumentException If the mode already exists + */ + public function add($mode) + { + if (array_key_exists($mode, $this->modes)) { + throw new InvalidArgumentException("$mode already exists"); + } + + $this->modes[$mode] = true; + + return $this; + } + + /** + * @param string $mode + * + * @return Less_Environment + * + * @throws InvalidArgumentException If there is no environment for the given mode + */ + public function getEnv($mode) + { + if (! isset($this->envs[$mode])) { + throw new InvalidArgumentException("$mode does not exist"); + } + + return $this->envs[$mode]; + } + + /** + * @param string $mode + * @param Less_Environment $env + * + * @return $this + * + * @throws InvalidArgumentException If an environment for given the mode already exists + */ + public function setEnv($mode, Less_Environment $env) + { + if (array_key_exists($mode, $this->envs)) { + throw new InvalidArgumentException("$mode already exists"); + } + + $this->envs[$mode] = $env; + + return $this; + } + + /** + * @param string $mode + * + * @return bool + */ + public function hasSelector($mode) + { + return isset($this->selectors[$mode]); + } + + /** + * @param string $mode + * + * @return string + * + * @throws InvalidArgumentException If there is no selector for the given mode + */ + public function getSelector($mode) + { + if (! isset($this->selectors[$mode])) { + throw new InvalidArgumentException("$mode does not exist"); + } + + return $this->selectors[$mode]; + } + + /** + * @param string $mode + * @param string $selector + * + * @return $this + * + * @throws InvalidArgumentException If a selector for given the mode already exists + */ + public function setSelector($mode, $selector) + { + if (array_key_exists($mode, $this->selectors)) { + throw new InvalidArgumentException("$mode already exists"); + } + + $this->selectors[$mode] = $selector; + + return $this; + } + + public function getIterator(): Traversable + { + return new ArrayIterator(array_keys($this->modes)); + } +} diff --git a/library/Icinga/Less/LightModeCall.php b/library/Icinga/Less/LightModeCall.php new file mode 100644 index 0000000..d899e3c --- /dev/null +++ b/library/Icinga/Less/LightModeCall.php @@ -0,0 +1,38 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_Environment; +use Less_Tree_Ruleset; +use Less_Tree_RulesetCall; + +/** + * Use the environment where the light mode was defined to evaluate the call + */ +class LightModeCall extends Less_Tree_RulesetCall +{ + use LightModeTrait; + + /** + * @param Less_Tree_RulesetCall $c + * + * @return static + */ + public static function fromRulesetCall(Less_Tree_RulesetCall $c) + { + return new static($c->variable); + } + + /** + * @param Less_Environment $env + * + * @return Less_Tree_Ruleset + */ + public function compile($env) + { + return parent::compile( + $env->copyEvalEnv(array_merge($env->frames, $this->getLightMode()->getEnv($this->variable)->frames)) + ); + } +} diff --git a/library/Icinga/Less/LightModeDefinition.php b/library/Icinga/Less/LightModeDefinition.php new file mode 100644 index 0000000..929e95c --- /dev/null +++ b/library/Icinga/Less/LightModeDefinition.php @@ -0,0 +1,75 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_Environment; +use Less_Exception_Compiler; +use Less_Tree_DetachedRuleset; +use Less_Tree_Ruleset; + +/** + * Register the environment in which the light mode is defined + */ +class LightModeDefinition extends Less_Tree_DetachedRuleset +{ + use LightModeTrait; + + /** @var string */ + protected $name; + + /** + * @param Less_Tree_DetachedRuleset $drs + * + * @return static + */ + public static function fromDetachedRuleset(Less_Tree_DetachedRuleset $drs) + { + return new static($drs->ruleset, $drs->frames); + } + + /** + * @return string + */ + public function getName() + { + return $this->name; + } + + /** + * @param string $name + * + * @return $this + */ + public function setName($name) + { + $this->name = $name; + + return $this; + } + + /** + * @param Less_Environment $env + * + * @return Less_Tree_DetachedRuleset + */ + public function compile($env) + { + $drs = parent::compile($env); + + /** @var $frame Less_Tree_Ruleset */ + foreach ($env->frames as $frame) { + if ($frame->variable($this->getName())) { + if (! empty($frame->first_oelements) && ! isset($frame->first_oelements['.icinga-module'])) { + throw new Less_Exception_Compiler('Light mode definition not allowed in selectors'); + } + + break; + } + } + + $this->getLightMode()->setEnv($this->getName(), $env->copyEvalEnv($env->frames)); + + return $drs; + } +} diff --git a/library/Icinga/Less/LightModeTrait.php b/library/Icinga/Less/LightModeTrait.php new file mode 100644 index 0000000..d328265 --- /dev/null +++ b/library/Icinga/Less/LightModeTrait.php @@ -0,0 +1,30 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +trait LightModeTrait +{ + /** @var LightMode */ + private $lightMode; + + /** + * @return LightMode + */ + public function getLightMode() + { + return $this->lightMode; + } + + /** + * @param LightMode $lightMode + * + * @return $this + */ + public function setLightMode(LightMode $lightMode) + { + $this->lightMode = $lightMode; + + return $this; + } +} diff --git a/library/Icinga/Less/LightModeVisitor.php b/library/Icinga/Less/LightModeVisitor.php new file mode 100644 index 0000000..35758b4 --- /dev/null +++ b/library/Icinga/Less/LightModeVisitor.php @@ -0,0 +1,26 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_VisitorReplacing; + +/** + * Ensure that light mode calls have access to the environment in which the mode was defined + */ +class LightModeVisitor extends Less_VisitorReplacing +{ + use LightModeTrait; + + public $isPreVisitor = true; + + public function visitRulesetCall($c) + { + return LightModeCall::fromRulesetCall($c)->setLightMode($this->getLightMode()); + } + + public function run($node) + { + return $this->visitObj($node); + } +} diff --git a/library/Icinga/Less/Visitor.php b/library/Icinga/Less/Visitor.php new file mode 100644 index 0000000..48417c8 --- /dev/null +++ b/library/Icinga/Less/Visitor.php @@ -0,0 +1,243 @@ +<?php +/* Icinga Web 2 | (c) 2022 Icinga GmbH | GPLv2+ */ + +namespace Icinga\Less; + +use Less_Parser; +use Less_Tree_Expression; +use Less_Tree_Rule; +use Less_Tree_Value; +use Less_Tree_Variable; +use Less_VisitorReplacing; +use LogicException; +use ReflectionProperty; + +/** + * Replace compiled Less colors with CSS var() function calls and inject light mode calls + * + * Color replacing basically works by replacing every visited Less variable with {@link ColorPropOrVariable}, + * which is later compiled to {@link ColorProp} if it is a color. + * + * Light mode calls are generated from light mode definitions. + */ +class Visitor extends Less_VisitorReplacing +{ + const LIGHT_MODE_CSS = <<<'CSS' +@media (min-height: @prefer-light-color-scheme), print, +(prefers-color-scheme: light) and (min-height: @enable-color-preference) { + %s +} +CSS; + + const LIGHT_MODE_NAME = 'light-mode'; + + public $isPreEvalVisitor = true; + + /** + * Whether calling var() CSS function + * + * If that's the case, don't try to replace compiled Less colors with CSS var() function calls. + * + * @var bool|string + */ + protected $callingVar = false; + + /** + * Whether defining a variable + * + * If that's the case, don't try to replace compiled Less colors with CSS var() function calls. + * + * @var false|string + */ + protected $definingVariable = false; + + /** @var Less_Tree_Rule If defining a variable, determines the origin rule of the variable */ + protected $variableOrigin; + + /** @var LightMode Light mode registry */ + protected $lightMode; + + /** @var false|string Whether parsing module Less */ + protected $moduleScope = false; + + /** @var null|string CSS module selector if any */ + protected $moduleSelector; + + public function visitCall($c) + { + if ($c->name !== 'var') { + // We need to use our own tree call class , so that we can precompile the arguments before making + // the actual LESS function calls. Otherwise, it will produce lots of invalid argument exceptions! + $c = Call::fromCall($c); + } + + return $c; + } + + public function visitDetachedRuleset($drs) + { + if ($this->variableOrigin->name === '@' . static::LIGHT_MODE_NAME) { + $this->variableOrigin->name .= '-' . substr(sha1(uniqid(mt_rand(), true)), 0, 7); + + $this->lightMode->add($this->variableOrigin->name); + + if ($this->moduleSelector !== false) { + $this->lightMode->setSelector($this->variableOrigin->name, $this->moduleSelector); + } + + $drs = LightModeDefinition::fromDetachedRuleset($drs) + ->setLightMode($this->lightMode) + ->setName($this->variableOrigin->name); + } + + // Since a detached ruleset is a variable definition in the first place, + // just reset that we define a variable. + $this->definingVariable = false; + + return $drs; + } + + public function visitMixinCall($c) + { + // Less_Tree_Mixin_Call::accept() does not visit arguments, but we have to replace them if necessary. + foreach ($c->arguments as $a) { + $a['value'] = $this->visitObj($a['value']); + } + + return $c; + } + + public function visitMixinDefinition($m) + { + // Less_Tree_Mixin_Definition::accept() does not visit params, but we have to replace them if necessary. + foreach ($m->params as $p) { + if (! isset($p['value'])) { + continue; + } + + $p['value'] = $this->visitObj($p['value']); + } + + return $m; + } + + public function visitRule($r) + { + if ($r->name[0] === '@' && $r->variable) { + if ($this->definingVariable !== false) { + throw new LogicException('Already defining a variable'); + } + + $this->definingVariable = spl_object_hash($r); + $this->variableOrigin = $r; + + if ($r->value instanceof Less_Tree_Value) { + if ($r->value->value[0] instanceof Less_Tree_Expression) { + if ($r->value->value[0]->value[0] instanceof Less_Tree_Variable) { + // Transform the variable definition rule into our own class + $r->value->value[0]->value[0] = new DeferredColorProp($r->name, $r->value->value[0]->value[0]); + } + } + } + } + + return $r; + } + + public function visitRuleOut($r) + { + if ($this->definingVariable !== false && $this->definingVariable === spl_object_hash($r)) { + $this->definingVariable = false; + $this->variableOrigin = null; + } + } + + public function visitRuleset($rs) + { + // Method is required, otherwise visitRulesetOut will not be called. + return $rs; + } + + public function visitRulesetOut($rs) + { + if ($this->moduleScope !== false + && isset($rs->selectors) + && spl_object_hash($rs->selectors[0]) === $this->moduleScope + ) { + $this->moduleSelector = null; + $this->moduleScope = false; + } + } + + public function visitSelector($s) + { + if ($s->_oelements_len === 2 && $s->_oelements[0] === '.icinga-module') { + $this->moduleSelector = implode('', $s->_oelements); + $this->moduleScope = spl_object_hash($s); + } + + return $s; + } + + public function visitVariable($v) + { + if ($this->definingVariable !== false) { + return $v; + } + + return (new ColorPropOrVariable()) + ->setVariable($v); + } + + public function visitColor($c) + { + if ($this->definingVariable !== false) { + // Make sure that all less tree colors do have a proper name + $c->name = $this->variableOrigin->name; + } + + return $c; + } + + public function run($node) + { + $this->lightMode = new LightMode(); + + $evald = $this->visitObj($node); + + // The visitor has registered all light modes in visitDetachedRuleset, but has not called them yet. + // Now the light mode calls are prepared with the appropriate CSS selectors. + $calls = []; + foreach ($this->lightMode as $mode) { + if ($this->lightMode->hasSelector($mode)) { + $calls[] = "{$this->lightMode->getSelector($mode)} {\n$mode();\n}"; + } else { + $calls[] = "$mode();"; + } + } + + if (! empty($calls)) { + // Place and parse light mode calls into a new anonymous file, + // leaving the original Less in which the light modes were defined untouched. + $parser = (new Less_Parser()) + ->parse(sprintf(static::LIGHT_MODE_CSS, implode("\n", $calls))); + + // Because Less variables are block scoped, + // we can't just access the light mode definitions in the calls above. + // The LightModeVisitor ensures that all calls have access to the environment in which the mode was defined. + // Finally, the rules are merged so that the light mode calls are also rendered to CSS. + $rules = new ReflectionProperty(get_class($parser), 'rules'); + $rules->setAccessible(true); + $evald->rules = array_merge( + $evald->rules, + (new LightModeVisitor()) + ->setLightMode($this->lightMode) + ->visitArray($rules->getValue($parser)) + ); + // The LightModeVisitor is used explicitly here instead of using it as a plugin + // since we only need to process the newly created rules for the light mode calls. + } + + return $evald; + } +} |