Code Coverage
 
Lines
Functions and Methods
Classes and Traits
Total
100.00% covered (success)
100.00%
177 / 177
100.00% covered (success)
100.00%
23 / 23
CRAP
100.00% covered (success)
100.00%
1 / 1
Css
100.00% covered (success)
100.00%
177 / 177
100.00% covered (success)
100.00%
23 / 23
85
100.00% covered (success)
100.00%
1 / 1
 __construct
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 loadArgument
100.00% covered (success)
100.00%
9 / 9
100.00% covered (success)
100.00%
1 / 1
7
 addMedia
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 getMedia
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 getAllMedia
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 removeMedia
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 removeAllMedia
100.00% covered (success)
100.00%
2 / 2
100.00% covered (success)
100.00%
1 / 1
1
 parseString
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 parseFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 parseUri
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
1
 parseCss
100.00% covered (success)
100.00%
63 / 63
100.00% covered (success)
100.00%
1 / 1
25
 normalizeComment
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
2
 extractSelectorNameAfterComment
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 detectMediaType
100.00% covered (success)
100.00%
4 / 4
100.00% covered (success)
100.00%
1 / 1
3
 detectMediaCondition
100.00% covered (success)
100.00%
5 / 5
100.00% covered (success)
100.00%
1 / 1
3
 parseMediaFeatures
100.00% covered (success)
100.00%
10 / 10
100.00% covered (success)
100.00%
1 / 1
6
 parseCssFile
100.00% covered (success)
100.00%
3 / 3
100.00% covered (success)
100.00%
1 / 1
2
 parseCssUri
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 writeToFile
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 render
100.00% covered (success)
100.00%
15 / 15
100.00% covered (success)
100.00%
1 / 1
7
 __toString
100.00% covered (success)
100.00%
1 / 1
100.00% covered (success)
100.00%
1 / 1
1
 parseSelectors
100.00% covered (success)
100.00%
22 / 22
100.00% covered (success)
100.00%
1 / 1
7
 splitDeclarations
100.00% covered (success)
100.00%
14 / 14
100.00% covered (success)
100.00%
1 / 1
7
1<?php
2declare(strict_types=1);
3/**
4 * Pop PHP Framework (https://www.popphp.org/)
5 *
6 * @link       https://github.com/popphp/popphp-framework
7 * @author     Nick Sagona, III <nick@popphp.org>
8 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
9 * @license    https://www.popphp.org/license     New BSD License
10 */
11
12/**
13 * @namespace
14 */
15namespace Pop\Css;
16
17/**
18 * Pop CSS class
19 *
20 * @category   Pop
21 * @package    Pop\Css
22 * @author     Nick Sagona, III <nick@popphp.org>
23 * @copyright  Copyright (c) 2009-2026 Nick Sagona, III
24 * @license    https://www.popphp.org/license     New BSD License
25 * @version    3.0.0
26 */
27class Css extends AbstractCss
28{
29
30    /**
31     * Media queries
32     * @var array
33     */
34    protected array $media = [];
35
36    /**
37     * CSS object constructor
38     *
39     */
40    public function __construct()
41    {
42        $args = func_get_args();
43
44        foreach ($args as $arg) {
45            $this->loadArgument($arg);
46        }
47    }
48
49    /**
50     * Load argument
51     *
52     * @param  mixed $arg
53     * @return void
54     */
55    protected function loadArgument(mixed $arg): void
56    {
57        if ($arg instanceof Selector) {
58            $this->addSelector($arg);
59        } else if (($arg instanceof Comment) || is_string($arg)) {
60            $this->addComment($arg);
61        } else if ($arg instanceof Media) {
62            $this->addMedia($arg);
63        } else if (is_array($arg)) {
64            foreach ($arg as $a) {
65                $this->loadArgument($a);
66            }
67        }
68    }
69
70    /**
71     * Add media query
72     *
73     * @param  Media $media
74     * @return Css
75     */
76    public function addMedia(Media $media): Css
77    {
78        $this->media[] = $media;
79        return $this;
80    }
81
82    /**
83     * Get media query by index
84     *
85     * @param  int $i
86     * @return Media|null
87     */
88    public function getMedia(int $i): Media|null
89    {
90        return $this->media[$i] ?? null;
91    }
92
93    /**
94     * Get all media queries
95     *
96     * @return array
97     */
98    public function getAllMedia(): array
99    {
100        return $this->media;
101    }
102
103    /**
104     * Remove media query by index
105     *
106     * @param  int $i
107     * @return Css
108     */
109    public function removeMedia(int $i): Css
110    {
111        if (isset($this->media[$i])) {
112            unset($this->media[$i]);
113            $this->media = array_values($this->media);
114        }
115        return $this;
116    }
117
118    /**
119     * Remove all media queries
120     *
121     * @return Css
122     */
123    public function removeAllMedia(): Css
124    {
125        $this->media = [];
126        return $this;
127    }
128
129    /**
130     * Parse CSS string
131     *
132     * @param  string $cssString
133     * @return Css
134     */
135    public static function parseString(string $cssString): Css
136    {
137        $css = new self();
138        $css->parseCss($cssString);
139
140        return $css;
141    }
142
143    /**
144     * Parse CSS from file
145     *
146     * @param  string $cssFile
147     * @throws Exception
148     * @return Css
149     */
150    public static function parseFile(string $cssFile): Css
151    {
152        $css = new self();
153        $css->parseCssFile($cssFile);
154
155        return $css;
156    }
157
158    /**
159     * Parse CSS from URI
160     *
161     * @param  string $cssUri
162     * @return Css
163     */
164    public static function parseUri(string $cssUri): Css
165    {
166        $css = new self();
167        $css->parseCssUri($cssUri);
168
169        return $css;
170    }
171
172    /**
173     * Parse CSS string
174     *
175     * @param  string $cssString
176     * @return Css
177     */
178    public function parseCss(string $cssString): Css
179    {
180
181        // Parse media queries
182        $origCssString = $cssString;
183        $mediaComments = [];
184        $matches       = [];
185        preg_match_all('~@media\b[^{]*({((?:[^{}]+|(?1))*)})~', $cssString, $matches, PREG_OFFSET_CAPTURE);
186
187        if (isset($matches[0][0])) {
188            foreach ($matches[0] as $match) {
189                // See if media query has a top-level comment
190                $mediaComment = null;
191                $char         = null;
192                $pos          = $match[1];
193                while (($char != '/') && ($char != '}') && ($pos != 0)) {
194                    $pos--;
195                    $char = $origCssString[$pos];
196                }
197                if ($char == '/') {
198                    $mediaComment = substr($origCssString, 0, ($pos + 1));
199                    $mediaComment = substr($mediaComment, strrpos($mediaComment, '/*') ?: 0);
200                }
201
202                $cssString     = str_replace($match[0], '', $cssString);
203                $mediaQuery    = substr($match[0], 6);
204                $mediaQuery    = trim(substr($mediaQuery, 0, strpos($mediaQuery, ' {') ?: 0));
205                $mediaQueryCss = substr($match[0], (strpos($match[0], '{') + 1));
206                $mediaQueryCss = trim(substr($mediaQueryCss, 0, strrpos($match[0], '}') ?: 0));
207
208                $mediaType      = $this->detectMediaType($mediaQuery);
209                $mediaCondition = $this->detectMediaCondition($mediaQuery);
210                $mediaFeatures  = $this->parseMediaFeatures($mediaQuery);
211
212                $media = new Media($mediaType, $mediaFeatures, $mediaCondition);
213                if ($mediaComment !== null) {
214                    $media->addComment($this->normalizeComment($mediaComment));
215                }
216
217                $commentsMatches = [];
218                preg_match_all('!/\*.*?\*/!s', $mediaQueryCss, $commentsMatches, PREG_OFFSET_CAPTURE);
219
220                if (isset($commentsMatches[0][0])) {
221                    foreach ($commentsMatches[0] as $match) {
222                        $selectorName = $this->extractSelectorNameAfterComment($mediaQueryCss, $match[1]);
223                        $mediaComments[$selectorName] = $this->normalizeComment($match[0]);
224                    }
225                }
226
227                $mediaQueryCss = preg_replace('!/\*.*?\*/!s', '', $mediaQueryCss);
228                $mediaQueryCss = preg_replace('/\n\s*\n/', "\n", $mediaQueryCss);
229
230                $selectors = $this->parseSelectors($mediaQueryCss);
231                foreach ($selectors as $selector) {
232                    $media->addSelector($selector);
233                }
234
235                if (count($mediaComments) > 0) {
236                    foreach ($mediaComments as $selectorName => $comment) {
237                        if ($media->hasSelector($selectorName)) {
238                            $media->getSelector($selectorName)->addComment($comment);
239                        }
240                    }
241                }
242
243                $this->addMedia($media);
244            }
245        }
246
247        // Parse comments
248        $comments = [];
249        $matches  = [];
250        preg_match_all('!/\*.*?\*/!s', $cssString, $matches, PREG_OFFSET_CAPTURE);
251
252        if (isset($matches[0][0])) {
253            foreach ($matches[0] as $match) {
254                $selectorName = null;
255                if ($match[1] != 0) {
256                    $selectorName = $this->extractSelectorNameAfterComment($cssString, $match[1]);
257                }
258                if ($selectorName === null) {
259                    $this->addComment($this->normalizeComment($match[0]));
260                } else {
261                    $comments[$selectorName] = $this->normalizeComment($match[0]);
262                }
263            }
264        }
265
266        $cssString = preg_replace('!/\*.*?\*/!s', '', $cssString);
267        $cssString = preg_replace('/\n\s*\n/', "\n", $cssString);
268
269        // Parse everything else
270        $selectors = $this->parseSelectors($cssString);
271        foreach ($selectors as $selector) {
272            $this->addSelector($selector);
273        }
274
275        if (count($comments) > 0) {
276            foreach ($comments as $selectorName => $comment) {
277                if ($this->hasSelector($selectorName)) {
278                    $this->getSelector($selectorName)->addComment($comment);
279                }
280            }
281        }
282
283        return $this;
284    }
285
286    /**
287     * Normalize a raw comment block into a Comment object, stripping the /*, *&#47; and leading *
288     * markers from each line
289     *
290     * @param  string $rawComment
291     * @return Comment
292     */
293    protected function normalizeComment(string $rawComment): Comment
294    {
295        $lines = explode(PHP_EOL, $rawComment);
296        foreach ($lines as $key => $line) {
297            $lines[$key] = trim(str_replace(['/*', '*/', '*'], ['', '', ''], $line));
298        }
299
300        return new Comment(trim(implode(PHP_EOL, $lines)));
301    }
302
303    /**
304     * Extract the selector name that immediately follows a comment's closing '*​/' marker
305     *
306     * @param  string $source
307     * @param  int    $pos
308     * @return string
309     */
310    protected function extractSelectorNameAfterComment(string $source, int $pos): string
311    {
312        $selectorName = substr($source, $pos);
313        $selectorName = substr($selectorName, 0, strpos($selectorName, '{') ?: 0);
314
315        return trim(substr($selectorName, (strpos($selectorName, '*/') + 2)));
316    }
317
318    /**
319     * Detect the media type (all, print, screen, speech) from a media query string
320     *
321     * @param  string $mediaQuery
322     * @return string|null
323     */
324    protected function detectMediaType(string $mediaQuery): string|null
325    {
326        foreach (['all', 'print', 'screen', 'speech'] as $type) {
327            if (str_contains($mediaQuery, $type)) {
328                return $type;
329            }
330        }
331
332        return null;
333    }
334
335    /**
336     * Detect the media condition (not, only) from a media query string
337     *
338     * @param  string $mediaQuery
339     * @return string|null
340     */
341    protected function detectMediaCondition(string $mediaQuery): string|null
342    {
343        if (str_contains($mediaQuery, 'not')) {
344            return 'not';
345        }
346        if (str_contains($mediaQuery, 'only')) {
347            return 'only';
348        }
349
350        return null;
351    }
352
353    /**
354     * Parse the (feature: value) pairs out of a media query string
355     *
356     * @param  string $mediaQuery
357     * @return array
358     */
359    protected function parseMediaFeatures(string $mediaQuery): array
360    {
361        $mediaFeatures = [];
362
363        if (str_contains($mediaQuery, '(') && str_contains($mediaQuery, ')')) {
364            $features = substr($mediaQuery, strpos($mediaQuery, '(') ?: 0);
365            $features = substr($features, 0, (strrpos($features, ')') + 1));
366            foreach (explode('and', $features) as $feature) {
367                $feature = str_replace(['(', ')'], ['', ''], $feature);
368                $feature = explode(':', $feature);
369                if (count($feature) == 2) {
370                    $mediaFeatures[trim($feature[0])] = trim($feature[1]);
371                }
372            }
373        }
374
375        return $mediaFeatures;
376    }
377
378    /**
379     * Parse CSS string from file
380     *
381     * @param  string $cssFile
382     * @throws Exception
383     * @return Css
384     */
385    public function parseCssFile(string $cssFile): Css
386    {
387        if (!file_exists($cssFile)) {
388            throw new Exception("Error: That file '" . $cssFile . "' does not exist.");
389        }
390        return $this->parseCss(file_get_contents($cssFile));
391    }
392
393    /**
394     * Parse CSS string from URI
395     *
396     * @param  string $cssUri
397     * @return Css
398     */
399    public function parseCssUri(string $cssUri): Css
400    {
401        return $this->parseCss(file_get_contents($cssUri));
402    }
403
404    /**
405     * Method to write CSS to file
406     *
407     * @param  string $filename
408     * @return void
409     */
410    public function writeToFile(string $filename): void
411    {
412        file_put_contents($filename, $this->render());
413    }
414
415    /**
416     * Method to render the selector CSS
417     *
418     * @return string
419     */
420    public function render(): string
421    {
422        $css = '';
423
424        if (!$this->minify) {
425            foreach ($this->comments as $comment) {
426                $css .= (string)$comment . PHP_EOL;
427            }
428        }
429        foreach ($this->selectors as $selector) {
430            $selector->minify($this->minify);
431            $css .= (string)$selector;
432            if (!$this->minify) {
433                $css .= PHP_EOL;
434            }
435        }
436        foreach ($this->media as $media) {
437            $media->minify($this->minify);
438            $css .= (string)$media;
439            if (!$this->minify) {
440                $css .= PHP_EOL;
441            }
442        }
443
444        return $css;
445    }
446
447    /**
448     * To string method
449     *
450     * @return string
451     */
452    public function __toString(): string
453    {
454        return $this->render();
455    }
456
457    /**
458     * Method to parse the CSS selectors from a string
459     *
460     * @param  string $cssString
461     * @return array
462     */
463    protected function parseSelectors(string $cssString): array
464    {
465        $selectors = [];
466
467        $matches = [];
468        preg_match_all('/\{\s*([^}]*?)\s*}/m', $cssString, $matches, PREG_OFFSET_CAPTURE);
469
470        if (isset($matches[0][0])) {
471            $curPos = 0;
472            foreach ($matches[0] as $match) {
473                $selectorName = trim(substr($cssString, $curPos, $match[1]));
474                if (strpos($selectorName, '{') !== false) {
475                    $selectorName = trim(substr($selectorName, 0, strpos($selectorName, '{')));
476                }
477                $rules    = $this->splitDeclarations(trim(str_replace(['{', '}'], ['', ''], trim($match[0]))));
478                $cssRules = [];
479                foreach ($rules as $key => $value) {
480                    if (!empty($value)) {
481                        $value = trim($value);
482                        $v = explode(':', $value, 2);
483                        if (count($v) == 2) {
484                            $cssRules[trim($v[0])] = trim($v[1]);
485                        }
486                    }
487                }
488
489                $selector = new Selector($selectorName);
490                $selector->setProperties($cssRules);
491                $selectors[] = $selector;
492                $curPos = $match[1] + strlen($match[0]);
493            }
494        }
495
496        return $selectors;
497    }
498
499    /**
500     * Split a rule block into individual declarations, respecting parentheses so a semicolon
501     * inside a value like url(data:image/png;base64,...) doesn't split one declaration into two
502     *
503     * @param  string $rulesBlock
504     * @return array
505     */
506    protected function splitDeclarations(string $rulesBlock): array
507    {
508        $declarations = [];
509        $current      = '';
510        $depth        = 0;
511
512        foreach (str_split($rulesBlock) as $char) {
513            if ($char === '(') {
514                $depth++;
515            } else if (($char === ')') && ($depth > 0)) {
516                $depth--;
517            }
518
519            if (($char === ';') && ($depth === 0)) {
520                $declarations[] = $current;
521                $current        = '';
522            } else {
523                $current .= $char;
524            }
525        }
526
527        $declarations[] = $current;
528
529        return $declarations;
530    }
531
532}