icingaweb2/library/Icinga/Less/Visitor.php

234 lines
7.4 KiB
PHP

<?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 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;
}
}