summaryrefslogtreecommitdiffstats
path: root/library/Icinga/Less/Visitor.php
blob: c04a0eb729111a893815c3babd1cff69b368f10f (plain)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
<?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;
    }
}