• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

MyIntervals / emogrifier / 24951927786

26 Apr 2026 08:10AM UTC coverage: 97.666% (+0.2%) from 97.448%
24951927786

Pull #1618

github

web-flow
Merge ab18a054a into 6494ae250
Pull Request #1618: [TASK] Drop some unnecessary type checks

2 of 2 new or added lines in 1 file covered. (100.0%)

3 existing lines in 1 file now uncovered.

837 of 857 relevant lines covered (97.67%)

258.77 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

98.32
/src/CssInliner.php
1
<?php
2

3
declare(strict_types=1);
4

5
namespace Pelago\Emogrifier;
6

7
use Pelago\Emogrifier\Css\CssDocument;
8
use Pelago\Emogrifier\HtmlProcessor\AbstractHtmlProcessor;
9
use Pelago\Emogrifier\Utilities\CssConcatenator;
10
use Pelago\Emogrifier\Utilities\DeclarationBlockParser;
11
use Symfony\Component\CssSelector\CssSelectorConverter;
12
use Symfony\Component\CssSelector\Exception\ParseException;
13

14
use function Safe\preg_match;
15
use function Safe\preg_replace;
16
use function Safe\preg_replace_callback;
17
use function Safe\preg_split;
18

19
/**
20
 * This class provides functions for converting CSS styles into inline style attributes in your HTML code.
21
 */
22
final class CssInliner extends AbstractHtmlProcessor
23
{
24
    private const CACHE_KEY_SELECTOR = 0;
25
    private const CACHE_KEY_COMBINED_STYLES = 1;
26

27
    /**
28
     * Regular expression component matching a static pseudo class in a selector, without the preceding ":",
29
     * for which the applicable elements can be determined (by converting the selector to an XPath expression).
30
     * (Contains alternation without a group and is intended to be placed within a capturing, non-capturing or lookahead
31
     * group, as appropriate for the usage context.)
32
     */
33
    private const PSEUDO_CLASS_MATCHER
34
        = 'empty|(?:first|last|nth(?:-last)?+|only)-(?:child|of-type)|not\\([[:ascii:]]*\\)|root';
35

36
    /**
37
     * This regular expression component matches an `...of-type` pseudo class name, without the preceding ":".  These
38
     * pseudo-classes can currently online be inlined if they have an associated type in the selector expression.
39
     */
40
    private const OF_TYPE_PSEUDO_CLASS_MATCHER = '(?:first|last|nth(?:-last)?+|only)-of-type';
41

42
    /**
43
     * regular expression component to match a selector combinator
44
     */
45
    private const COMBINATOR_MATCHER = '(?:\\s++|\\s*+[>+~]\\s*+)(?=[[:alpha:]_\\-.#*:\\[])';
46

47
    /**
48
     * options array key for `querySelectorAll`
49
     */
50
    private const QSA_ALWAYS_THROW_PARSE_EXCEPTION = 'alwaysThrowParseException';
51

52
    /**
53
     * @var array<non-empty-string, true>
54
     */
55
    private $excludedSelectors = [];
56

57
    /**
58
     * @var array<non-empty-string, true>
59
     */
60
    private $excludedCssSelectors = [];
61

62
    /**
63
     * @var array<non-empty-string, true>
64
     */
65
    private $allowedMediaTypes = ['all' => true, 'screen' => true, 'print' => true];
66

67
    /**
68
     * @var array{
69
     *         0: array<non-empty-string, int<0, max>>,
70
     *         1: array<non-empty-string, string>
71
     *      }
72
     */
73
    private $caches = [
74
        self::CACHE_KEY_SELECTOR => [],
75
        self::CACHE_KEY_COMBINED_STYLES => [],
76
    ];
77

78
    /**
79
     * @var CssSelectorConverter|null
80
     */
81
    private $cssSelectorConverter = null;
82

83
    /**
84
     * the visited nodes with the XPath paths as array keys
85
     *
86
     * @var array<non-empty-string, \DOMElement>
87
     */
88
    private $visitedNodes = [];
89

90
    /**
91
     * the styles to apply to the nodes with the XPath paths as array keys for the outer array
92
     * and the attribute names/values as key/value pairs for the inner array
93
     *
94
     * @var array<non-empty-string, array<string, string>>
95
     */
96
    private $styleAttributesForNodes = [];
97

98
    /**
99
     * Determines whether the "style" attributes of tags in the the HTML passed to this class should be preserved.
100
     * If set to false, the value of the style attributes will be discarded.
101
     *
102
     * @var bool
103
     */
104
    private $isInlineStyleAttributesParsingEnabled = true;
105

106
    /**
107
     * Determines whether the `<style>` blocks in the HTML passed to this class should be parsed.
108
     *
109
     * If set to true, the `<style>` blocks will be removed from the HTML and their contents will be applied to the HTML
110
     * via inline styles.
111
     *
112
     * If set to false, the `<style>` blocks will be left as they are in the HTML.
113
     *
114
     * @var bool
115
     */
116
    private $isStyleBlocksParsingEnabled = true;
117

118
    /**
119
     * For calculating selector precedence order.
120
     * Keys are a regular expression part to match before a CSS name.
121
     * Values are a multiplier factor per match to weight specificity.
122
     *
123
     * @var array<string, int<1, max>>
124
     */
125
    private $selectorPrecedenceMatchers = [
126
        // IDs: worth 10000
127
        '\\#' => 10000,
128
        // classes, attributes, pseudo-classes (not pseudo-elements) except `:not`: worth 100
129
        '(?:\\.|\\[|(?<!:):(?!not\\())' => 100,
130
        // elements (not attribute values or `:not`), pseudo-elements: worth 1
131
        '(?:(?<![="\':\\w\\-])|::)' => 1,
132
    ];
133

134
    /**
135
     * array of data describing CSS rules which apply to the document but cannot be inlined, in the format returned by
136
     * {@see collateCssRules}
137
     *
138
     * @var array<array-key, array{
139
     *          media: string,
140
     *          selector: non-empty-string,
141
     *          hasUnmatchablePseudo: bool,
142
     *          declarationsBlock: string,
143
     *          line: int<0, max>
144
     *      }>|null
145
     */
146
    private $matchingUninlinableCssRules = null;
147

148
    /**
149
     * Emogrifier will throw Exceptions when it encounters an error instead of silently ignoring them.
150
     *
151
     * @var bool
152
     */
153
    private $debug = false;
154

155
    /**
156
     * Inlines the given CSS into the existing HTML.
157
     *
158
     * @param string $css the CSS to inline, must be UTF-8-encoded
159
     *
160
     * @return $this
161
     *
162
     * @throws ParseException in debug mode, if an invalid selector is encountered
163
     * @throws \RuntimeException
164
     *         in debug mode, if an internal PCRE error occurs
165
     *         or `CssSelectorConverter::toXPath` returns an invalid XPath expression
166
     */
167
    public function inlineCss(string $css = ''): self
1,171✔
168
    {
169
        $this->clearAllCaches();
1,171✔
170
        $this->purgeVisitedNodes();
1,171✔
171

172
        $this->normalizeStyleAttributesOfAllNodes();
1,171✔
173

174
        $combinedCss = $css;
1,171✔
175
        // grab any existing style blocks from the HTML and append them to the existing CSS
176
        // (these blocks should be appended so as to have precedence over conflicting styles in the existing CSS)
177
        if ($this->isStyleBlocksParsingEnabled) {
1,171✔
178
            $combinedCss .= $this->getCssFromAllStyleNodes();
1,169✔
179
        }
180
        $parsedCss = new CssDocument($combinedCss, $this->debug);
1,171✔
181

182
        $excludedNodes = $this->getNodesToExclude();
1,167✔
183
        $cssRules = $this->collateCssRules($parsedCss);
1,166✔
184
        foreach ($cssRules['inlinable'] as $cssRule) {
1,166✔
185
            foreach ($this->querySelectorAll($cssRule['selector']) as $node) {
519✔
186
                if (\in_array($node, $excludedNodes, true)) {
466✔
187
                    continue;
4✔
188
                }
189
                $this->copyInlinableCssToStyleAttribute($node, $cssRule);
464✔
190
            }
191
        }
192

193
        if ($this->isInlineStyleAttributesParsingEnabled) {
1,166✔
194
            $this->fillStyleAttributesWithMergedStyles();
1,164✔
195
        }
196

197
        $this->removeImportantAnnotationFromAllInlineStyles();
1,166✔
198

199
        $this->determineMatchingUninlinableCssRules($cssRules['uninlinable']);
1,166✔
200
        $this->copyUninlinableCssToStyleNode($parsedCss);
1,165✔
201

202
        return $this;
1,165✔
203
    }
204

205
    /**
206
     * Disables the parsing of inline styles.
207
     *
208
     * @return $this
209
     */
210
    public function disableInlineStyleAttributesParsing(): self
3✔
211
    {
212
        $this->isInlineStyleAttributesParsingEnabled = false;
3✔
213

214
        return $this;
3✔
215
    }
216

217
    /**
218
     * Disables the parsing of `<style>` blocks.
219
     *
220
     * @return $this
221
     */
222
    public function disableStyleBlocksParsing(): self
3✔
223
    {
224
        $this->isStyleBlocksParsingEnabled = false;
3✔
225

226
        return $this;
3✔
227
    }
228

229
    /**
230
     * Marks a media query type to keep.
231
     *
232
     * @param non-empty-string $mediaName the media type name, e.g., "braille"
233
     *
234
     * @return $this
235
     */
236
    public function addAllowedMediaType(string $mediaName): self
2✔
237
    {
238
        $this->allowedMediaTypes[$mediaName] = true;
2✔
239

240
        return $this;
2✔
241
    }
242

243
    /**
244
     * Drops a media query type from the allowed list.
245
     *
246
     * @param non-empty-string $mediaName the tag name, e.g., "braille"
247
     *
248
     * @return $this
249
     */
250
    public function removeAllowedMediaType(string $mediaName): self
2✔
251
    {
252
        if (isset($this->allowedMediaTypes[$mediaName])) {
2✔
253
            unset($this->allowedMediaTypes[$mediaName]);
2✔
254
        }
255

256
        return $this;
2✔
257
    }
258

259
    /**
260
     * Adds a selector to exclude nodes from emogrification.
261
     *
262
     * Any nodes that match the selector will not have their style altered.
263
     *
264
     * @param non-empty-string $selector the selector to exclude, e.g., ".editor"
265
     *
266
     * @return $this
267
     */
268
    public function addExcludedSelector(string $selector): self
9✔
269
    {
270
        $this->excludedSelectors[$selector] = true;
9✔
271

272
        return $this;
9✔
273
    }
274

275
    /**
276
     * No longer excludes the nodes matching this selector from emogrification.
277
     *
278
     * @param non-empty-string $selector the selector to no longer exclude, e.g., ".editor"
279
     *
280
     * @return $this
281
     */
282
    public function removeExcludedSelector(string $selector): self
2✔
283
    {
284
        if (isset($this->excludedSelectors[$selector])) {
2✔
285
            unset($this->excludedSelectors[$selector]);
1✔
286
        }
287

288
        return $this;
2✔
289
    }
290

291
    /**
292
     * Adds a selector to exclude CSS selector from emogrification.
293
     *
294
     * @param non-empty-string $selector the selector to exclude, e.g., `.editor`
295
     *
296
     * @return $this
297
     */
298
    public function addExcludedCssSelector(string $selector): self
6✔
299
    {
300
        $this->excludedCssSelectors[$selector] = true;
6✔
301

302
        return $this;
6✔
303
    }
304

305
    /**
306
     * No longer excludes the CSS selector from emogrification.
307
     *
308
     * @param non-empty-string $selector the selector to no longer exclude, e.g., `.editor`
309
     *
310
     * @return $this
311
     */
312
    public function removeExcludedCssSelector(string $selector): self
2✔
313
    {
314
        if (isset($this->excludedCssSelectors[$selector])) {
2✔
315
            unset($this->excludedCssSelectors[$selector]);
1✔
316
        }
317

318
        return $this;
2✔
319
    }
320

321
    /**
322
     * Sets the debug mode.
323
     *
324
     * @param bool $debug set to true to enable debug mode
325
     *
326
     * @return $this
327
     */
328
    public function setDebug(bool $debug): self
1,167✔
329
    {
330
        $this->debug = $debug;
1,167✔
331

332
        return $this;
1,167✔
333
    }
334

335
    /**
336
     * Gets the array of selectors present in the CSS provided to `inlineCss()` for which the declarations could not be
337
     * applied as inline styles, but which may affect elements in the HTML.  The relevant CSS will have been placed in a
338
     * `<style>` element.  The selectors may include those used within `@media` rules or those involving dynamic
339
     * pseudo-classes (such as `:hover`) or pseudo-elements (such as `::after`).
340
     *
341
     * @return array<array-key, string>
342
     *
343
     * @throws \BadMethodCallException if `inlineCss` has not been called first
344
     */
345
    public function getMatchingUninlinableSelectors(): array
6✔
346
    {
347
        return \array_column($this->getMatchingUninlinableCssRules(), 'selector');
6✔
348
    }
349

350
    /**
351
     * @return array<array-key, array{
352
     *             media: string,
353
     *             selector: non-empty-string,
354
     *             hasUnmatchablePseudo: bool,
355
     *             declarationsBlock: string,
356
     *             line: int<0, max>
357
     *         }>
358
     *
359
     * @throws \BadMethodCallException if `inlineCss` has not been called first
360
     */
361
    private function getMatchingUninlinableCssRules(): array
1,167✔
362
    {
363
        if (!\is_array($this->matchingUninlinableCssRules)) {
1,167✔
364
            throw new \BadMethodCallException('inlineCss must be called first', 1568385221);
1✔
365
        }
366

367
        return $this->matchingUninlinableCssRules;
1,166✔
368
    }
369

370
    /**
371
     * Clears all caches.
372
     */
373
    private function clearAllCaches(): void
1,171✔
374
    {
375
        $this->caches = [
1,171✔
376
            self::CACHE_KEY_SELECTOR => [],
1,171✔
377
            self::CACHE_KEY_COMBINED_STYLES => [],
1,171✔
378
        ];
1,171✔
379

380
        DeclarationBlockParser::clearCache();
1,171✔
381
    }
382

383
    /**
384
     * Purges the visited nodes.
385
     */
386
    private function purgeVisitedNodes(): void
1,171✔
387
    {
388
        $this->visitedNodes = [];
1,171✔
389
        $this->styleAttributesForNodes = [];
1,171✔
390
    }
391

392
    /**
393
     * Parses the document and normalizes all existing CSS attributes.
394
     * This changes 'DISPLAY: none' to 'display: none'.
395
     * We wouldn't have to do this if DOMXPath supported XPath 2.0.
396
     * Also stores a reference of nodes with existing inline styles so we don't overwrite them.
397
     */
398
    private function normalizeStyleAttributesOfAllNodes(): void
1,171✔
399
    {
400
        foreach ($this->getAllNodesWithStyleAttribute() as $node) {
1,171✔
401
            if ($this->isInlineStyleAttributesParsingEnabled) {
44✔
402
                $this->normalizeStyleAttributes($node);
42✔
403
            }
404
            // Remove style attribute in every case, so we can add them back (if inline style attributes
405
            // parsing is enabled) to the end of the style list, thus keeping the right priority of CSS rules;
406
            // else original inline style rules may remain at the beginning of the final inline style definition
407
            // of a node, which may give not the desired results
408
            $node->removeAttribute('style');
44✔
409
        }
410
    }
411

412
    /**
413
     * Returns a list with all DOM nodes that have a style attribute.
414
     *
415
     * @return \DOMNodeList<\DOMElement>
416
     */
417
    private function getAllNodesWithStyleAttribute(): \DOMNodeList
1,171✔
418
    {
419
        $query = '//*[@style]';
1,171✔
420
        $matches = $this->getXPath()->query($query);
1,171✔
421
        \assert($matches instanceof \DOMNodeList);
1,171✔
422
        /** @var \DOMNodeList<\DOMElement> $matches */
423

424
        return $matches;
1,171✔
425
    }
426

427
    /**
428
     * Normalizes the value of the "style" attribute and saves it.
429
     */
430
    private function normalizeStyleAttributes(\DOMElement $node): void
42✔
431
    {
432
        $pattern = '/-{0,2}+[_a-zA-Z][\\w\\-]*+(?=:)/S';
42✔
433
        $callback = \Closure::fromCallable([self::class, 'normalizePropertyNameCallback']);
42✔
434
        if (\function_exists('Safe\\preg_replace_callback')) {
42✔
435
            $normalizedOriginalStyle = preg_replace_callback($pattern, $callback, $node->getAttribute('style'));
42✔
436
        } else {
437
            // @phpstan-ignore-next-line The safe version is only available in "thecodingmachine/safe" for PHP >= 8.1.
438
            $normalizedOriginalStyle = \preg_replace_callback($pattern, $callback, $node->getAttribute('style'));
×
439
            \assert(\is_string($normalizedOriginalStyle));
×
440
        }
441

442
        // In order to not overwrite existing style attributes in the HTML, we have to save the original HTML styles.
443
        $nodePath = $node->getNodePath();
42✔
444
        if (\is_string($nodePath) && ($nodePath !== '') && !isset($this->styleAttributesForNodes[$nodePath])) {
42✔
445
            $this->styleAttributesForNodes[$nodePath] = DeclarationBlockParser::parse($normalizedOriginalStyle);
42✔
446
            $this->visitedNodes[$nodePath] = $node;
42✔
447
        }
448

449
        $node->setAttribute('style', $normalizedOriginalStyle);
42✔
450
    }
451

452
    /**
453
     * @param array<mixed> $matches
454
     *        A narrower type cannot be specified because it's a callback that may be passed different types in the
455
     *        array, depending on the flags provided to `preg_replace_callback()` (which are not actually used),
456
     *        and `Safe\preg_replace_callback()` does not have type annotations to cater for this.
457
     */
458
    private static function normalizePropertyNameCallback(array $matches): string
42✔
459
    {
460
        \assert(\is_string($matches[0] ?? null));
42✔
461
        \assert($matches[0] !== '');
42✔
462
        return DeclarationBlockParser::normalizePropertyName($matches[0]);
42✔
463
    }
464

465
    /**
466
     * Returns CSS content.
467
     */
468
    private function getCssFromAllStyleNodes(): string
1,169✔
469
    {
470
        $styleNodes = $this->getXPath()->query('//style');
1,169✔
471
        if ($styleNodes === false) {
1,169✔
472
            return '';
×
473
        }
474

475
        $css = '';
1,169✔
476
        foreach ($styleNodes as $styleNode) {
1,169✔
477
            \assert($styleNode instanceof \DOMNode);
39✔
478
            if (\is_string($styleNode->nodeValue)) {
39✔
479
                $css .= "\n\n" . $styleNode->nodeValue;
39✔
480
            }
481
            $parentNode = $styleNode->parentNode;
39✔
482
            if ($parentNode instanceof \DOMNode) {
39✔
483
                $parentNode->removeChild($styleNode);
39✔
484
            }
485
        }
486

487
        return $css;
1,169✔
488
    }
489

490
    /**
491
     * Find the nodes that are not to be emogrified.
492
     *
493
     * @return list<\DOMElement>
494
     *
495
     * @throws ParseException in debug mode, if an invalid selector is encountered
496
     * @throws \RuntimeException in debug mode, if `CssSelectorConverter::toXPath` returns an invalid XPath expression
497
     */
498
    private function getNodesToExclude(): array
1,167✔
499
    {
500
        $excludedNodes = [];
1,167✔
501
        foreach (\array_keys($this->excludedSelectors) as $selectorToExclude) {
1,167✔
502
            foreach ($this->querySelectorAll($selectorToExclude) as $node) {
7✔
503
                $excludedNodes[] = $node;
4✔
504
            }
505
        }
506

507
        return $excludedNodes;
1,166✔
508
    }
509

510
    /**
511
     * @param array{alwaysThrowParseException?: bool} $options
512
     *        This is an array of option values to control behaviour:
513
     *        - `QSA_ALWAYS_THROW_PARSE_EXCEPTION` - `bool` - throw any `ParseException` regardless of debug setting.
514
     *
515
     * @return \DOMNodeList<\DOMElement> the HTML elements that match the provided CSS `$selectors`
516
     *
517
     * @throws ParseException
518
     *         in debug mode (or with `QSA_ALWAYS_THROW_PARSE_EXCEPTION` option), if an invalid selector is encountered
519
     */
520
    private function querySelectorAll(string $selectors, array $options = []): \DOMNodeList
1,065✔
521
    {
522
        try {
523
            $result = $this->getXPath()->query($this->getCssSelectorConverter()->toXPath($selectors));
1,065✔
524
            \assert($result instanceof \DOMNodeList);
1,061✔
525
        } catch (ParseException $exception) {
5✔
526
            $alwaysThrowParseException = $options[self::QSA_ALWAYS_THROW_PARSE_EXCEPTION] ?? false;
5✔
527
            if ($this->debug || $alwaysThrowParseException) {
5✔
528
                throw $exception;
3✔
529
            }
530
            $result = new \DOMNodeList();
2✔
531
        }
532

533
        /** @var \DOMNodeList<\DOMElement> $result */
534
        return $result;
1,062✔
535
    }
536

537
    private function getCssSelectorConverter(): CssSelectorConverter
1,065✔
538
    {
539
        if (!$this->cssSelectorConverter instanceof CssSelectorConverter) {
1,065✔
540
            $this->cssSelectorConverter = new CssSelectorConverter();
1,065✔
541
        }
542

543
        return $this->cssSelectorConverter;
1,065✔
544
    }
545

546
    /**
547
     * Collates the individual rules from a `CssDocument` object.
548
     *
549
     * @return array<string, array<array-key, array{
550
     *           media: string,
551
     *           selector: non-empty-string,
552
     *           hasUnmatchablePseudo: bool,
553
     *           declarationsBlock: string,
554
     *           line: int<0, max>
555
     *         }>>
556
     *         This 2-entry array has the key "inlinable" containing rules which can be inlined as `style` attributes
557
     *         and the key "uninlinable" containing rules which cannot.  Each value is an array of sub-arrays with the
558
     *         following keys:
559
     *         - "media" (the media query string, e.g. "@media screen and (max-width: 480px)",
560
     *           or an empty string if not from a `@media` rule);
561
     *         - "selector" (the CSS selector, e.g., "*" or "header h1");
562
     *         - "hasUnmatchablePseudo" (`true` if that selector contains pseudo-elements or dynamic pseudo-classes such
563
     *           that the declarations cannot be applied inline);
564
     *         - "declarationsBlock" (the semicolon-separated CSS declarations for that selector,
565
     *           e.g., `color: red; height: 4px;`);
566
     *         - "line" (the line number, e.g. 42).
567
     */
568
    private function collateCssRules(CssDocument $parsedCss): array
1,166✔
569
    {
570
        $matches = $parsedCss->getStyleRulesData(\array_keys($this->allowedMediaTypes));
1,166✔
571

572
        $cssRules = [
1,166✔
573
            'inlinable' => [],
1,166✔
574
            'uninlinable' => [],
1,166✔
575
        ];
1,166✔
576
        foreach ($matches as $key => $cssRule) {
1,166✔
577
            if (!$cssRule->hasAtLeastOneDeclaration()) {
1,066✔
578
                continue;
3✔
579
            }
580

581
            $mediaQuery = $cssRule->getContainingAtRule();
1,063✔
582
            $declarationsBlock = $cssRule->getDeclarationsAsText();
1,063✔
583
            $selectors = $cssRule->getSelectors();
1,063✔
584

585
            // Maybe exclude CSS selectors
586
            if (\count($this->excludedCssSelectors) > 0) {
1,063✔
587
                // Normalize spaces, line breaks & tabs
588
                $selectorsNormalized = \array_map(static function (string $selector): string {
5✔
589
                    return preg_replace('@\\s++@u', ' ', $selector);
5✔
590
                }, $selectors);
5✔
591
                /** @var array<non-empty-string> $selectors */
592
                $selectors = \array_filter($selectorsNormalized, function (string $selector): bool {
5✔
593
                    return !isset($this->excludedCssSelectors[$selector]);
5✔
594
                });
5✔
595
            }
596

597
            foreach ($selectors as $selector) {
1,063✔
598
                // don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
599
                // only allow structural pseudo-classes
600
                $hasPseudoElement = \strpos($selector, '::') !== false;
1,063✔
601
                $hasUnmatchablePseudo = $hasPseudoElement || $this->hasUnsupportedPseudoClass($selector);
1,063✔
602

603
                $parsedCssRule = [
1,063✔
604
                    'media' => $mediaQuery,
1,063✔
605
                    'selector' => $selector,
1,063✔
606
                    'hasUnmatchablePseudo' => $hasUnmatchablePseudo,
1,063✔
607
                    'declarationsBlock' => $declarationsBlock,
1,063✔
608
                    // keep track of where it appears in the file, since order is important
609
                    'line' => $key,
1,063✔
610
                ];
1,063✔
611
                $ruleType = (!$cssRule->hasContainingAtRule() && !$hasUnmatchablePseudo) ? 'inlinable' : 'uninlinable';
1,063✔
612
                $cssRules[$ruleType][] = $parsedCssRule;
1,063✔
613
            }
614
        }
615

616
        \usort(
1,166✔
617
            $cssRules['inlinable'],
1,166✔
618
            /**
619
             * @param array{selector: non-empty-string, line: int<0, max>} $first
620
             * @param array{selector: non-empty-string, line: int<0, max>} $second
621
             */
622
            function (array $first, array $second): int {
1,166✔
623
                return $this->sortBySelectorPrecedence($first, $second);
75✔
624
            }
1,166✔
625
        );
1,166✔
626

627
        return $cssRules;
1,166✔
628
    }
629

630
    /**
631
     * Tests if a selector contains a pseudo-class which would mean it cannot be converted to an XPath expression for
632
     * inlining CSS declarations.
633
     *
634
     * Any pseudo class that does not match {@see PSEUDO_CLASS_MATCHER} cannot be converted.  Additionally, `...of-type`
635
     * pseudo-classes cannot be converted if they are not associated with a type selector.
636
     */
637
    private function hasUnsupportedPseudoClass(string $selector): bool
1,006✔
638
    {
639
        if (preg_match('/:(?!' . self::PSEUDO_CLASS_MATCHER . ')[\\w\\-]/i', $selector) !== 0) {
1,006✔
640
            return true;
323✔
641
        }
642

643
        if (preg_match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selector) === 0) {
788✔
644
            return false;
668✔
645
        }
646

647
        foreach (preg_split('/' . self::COMBINATOR_MATCHER . '/', $selector) as $selectorPart) {
120✔
648
            \assert(\is_string($selectorPart));
120✔
649
            if ($this->selectorPartHasUnsupportedOfTypePseudoClass($selectorPart)) {
120✔
650
                return true;
67✔
651
            }
652
        }
653

654
        return false;
53✔
655
    }
656

657
    /**
658
     * Tests if part of a selector contains an `...of-type` pseudo-class such that it cannot be converted to an XPath
659
     * expression.
660
     *
661
     * @param string $selectorPart part of a selector which has been split up at combinators
662
     *
663
     * @return bool `true` if the selector part does not have a type but does have an `...of-type` pseudo-class
664
     */
665
    private function selectorPartHasUnsupportedOfTypePseudoClass(string $selectorPart): bool
120✔
666
    {
667
        if (preg_match('/^[\\w\\-]/', $selectorPart) !== 0) {
120✔
668
            return false;
97✔
669
        }
670

671
        return preg_match('/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i', $selectorPart) !== 0;
67✔
672
    }
673

674
    /**
675
     * @param array{selector: non-empty-string, line: int<0, max>} $first
676
     * @param array{selector: non-empty-string, line: int<0, max>} $second
677
     */
678
    private function sortBySelectorPrecedence(array $first, array $second): int
75✔
679
    {
680
        $precedenceOfFirst = $this->getCssSelectorPrecedence($first['selector']);
75✔
681
        $precedenceOfSecond = $this->getCssSelectorPrecedence($second['selector']);
75✔
682

683
        // We want these sorted in ascending order so selectors with lesser precedence get processed first and
684
        // selectors with greater precedence get sorted last.
685
        $precedenceForEquals = $first['line'] < $second['line'] ? -1 : 1;
75✔
686
        $precedenceForNotEquals = $precedenceOfFirst < $precedenceOfSecond ? -1 : 1;
75✔
687
        return ($precedenceOfFirst === $precedenceOfSecond) ? $precedenceForEquals : $precedenceForNotEquals;
75✔
688
    }
689

690
    /**
691
     * @param non-empty-string $selector
692
     *
693
     * @return int<0, max>
694
     */
695
    private function getCssSelectorPrecedence(string $selector): int
75✔
696
    {
697
        $selectorKey = $selector;
75✔
698
        if (isset($this->caches[self::CACHE_KEY_SELECTOR][$selectorKey])) {
75✔
699
            return $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey];
68✔
700
        }
701

702
        $precedence = 0;
75✔
703
        foreach ($this->selectorPrecedenceMatchers as $matcher => $value) {
75✔
704
            if (\trim($selector) === '') {
75✔
705
                break;
8✔
706
            }
707
            $count = 0;
75✔
708
            $selector = preg_replace('/' . $matcher . '\\w+/', '', $selector, -1, $count);
75✔
709
            $precedence += ($value * $count);
75✔
710
            \assert($precedence >= 0);
75✔
711
        }
712
        $this->caches[self::CACHE_KEY_SELECTOR][$selectorKey] = $precedence;
75✔
713

714
        return $precedence;
75✔
715
    }
716

717
    /**
718
     * Copies `$cssRule` into the style attribute of `$node`.
719
     *
720
     * Note: This method does not check whether $cssRule matches $node.
721
     *
722
     * @param array{
723
     *            media: string,
724
     *            selector: non-empty-string,
725
     *            hasUnmatchablePseudo: bool,
726
     *            declarationsBlock: string,
727
     *            line: int<0, max>
728
     *        } $cssRule
729
     */
730
    private function copyInlinableCssToStyleAttribute(\DOMElement $node, array $cssRule): void
464✔
731
    {
732
        $declarationsBlock = $cssRule['declarationsBlock'];
464✔
733
        $newStyleDeclarations = DeclarationBlockParser::parse($declarationsBlock);
464✔
734
        if ($newStyleDeclarations === []) {
464✔
735
            return;
1✔
736
        }
737

738
        // if it has a style attribute, get it, process it, and append (overwrite) new stuff
739
        if ($node->hasAttribute('style')) {
463✔
740
            // break it up into an associative array
741
            $oldStyleDeclarations = DeclarationBlockParser::parse($node->getAttribute('style'));
67✔
742
        } else {
743
            $oldStyleDeclarations = [];
463✔
744
        }
745
        $node->setAttribute(
463✔
746
            'style',
463✔
747
            $this->generateStyleStringFromDeclarationsArrays($oldStyleDeclarations, $newStyleDeclarations)
463✔
748
        );
463✔
749
    }
750

751
    /**
752
     * This method merges old or existing name/value array with new name/value array
753
     * and then generates a string of the combined style suitable for placing inline.
754
     * This becomes the single point for CSS string generation allowing for consistent
755
     * CSS output no matter where the CSS originally came from.
756
     *
757
     * @param array<string, string> $oldStyles
758
     * @param array<string, string> $newStyles
759
     *
760
     * @throws \UnexpectedValueException if an empty property name is encountered (which should not happen)
761
     */
762
    private function generateStyleStringFromDeclarationsArrays(array $oldStyles, array $newStyles): string
482✔
763
    {
764
        $cacheKey = \serialize([$oldStyles, $newStyles]);
482✔
765
        \assert($cacheKey !== '');
482✔
766
        if (isset($this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey])) {
482✔
767
            return $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey];
397✔
768
        }
769

770
        // Unset the overridden styles to preserve order, important if shorthand and individual properties are mixed
771
        foreach ($oldStyles as $attributeName => $attributeValue) {
482✔
772
            if (!isset($newStyles[$attributeName])) {
87✔
773
                continue;
64✔
774
            }
775

776
            $newAttributeValue = $newStyles[$attributeName];
74✔
777
            if (
778
                $this->attributeValueIsImportant($attributeValue)
74✔
779
                && !$this->attributeValueIsImportant($newAttributeValue)
74✔
780
            ) {
781
                unset($newStyles[$attributeName]);
11✔
782
            } else {
783
                unset($oldStyles[$attributeName]);
63✔
784
            }
785
        }
786

787
        $combinedStyles = \array_merge($oldStyles, $newStyles);
482✔
788

789
        $style = '';
482✔
790
        foreach ($combinedStyles as $attributeName => $attributeValue) {
482✔
791
            $trimmedAttributeName = \trim($attributeName);
482✔
792
            if ($trimmedAttributeName === '') {
482✔
UNCOV
793
                throw new \UnexpectedValueException('An empty property name was encountered.', 1727046078);
×
794
            }
795
            $propertyName = DeclarationBlockParser::normalizePropertyName($trimmedAttributeName);
482✔
796
            $propertyValue = \trim($attributeValue);
482✔
797
            $style .= $propertyName . ': ' . $propertyValue . '; ';
482✔
798
        }
799
        $trimmedStyle = \rtrim($style);
482✔
800

801
        $this->caches[self::CACHE_KEY_COMBINED_STYLES][$cacheKey] = $trimmedStyle;
482✔
802

803
        return $trimmedStyle;
482✔
804
    }
805

806
    /**
807
     * Checks whether `$attributeValue` is marked as `!important`.
808
     */
809
    private function attributeValueIsImportant(string $attributeValue): bool
482✔
810
    {
811
        return preg_match('/!\\s*+important$/i', $attributeValue) !== 0;
482✔
812
    }
813

814
    /**
815
     * Merges styles from styles attributes and style nodes and applies them to the attribute nodes
816
     */
817
    private function fillStyleAttributesWithMergedStyles(): void
1,164✔
818
    {
819
        foreach ($this->styleAttributesForNodes as $nodePath => $styleAttributesForNode) {
1,164✔
820
            $node = $this->visitedNodes[$nodePath];
42✔
821
            $currentStyleAttributes = DeclarationBlockParser::parse($node->getAttribute('style'));
42✔
822
            $node->setAttribute(
42✔
823
                'style',
42✔
824
                $this->generateStyleStringFromDeclarationsArrays(
42✔
825
                    $currentStyleAttributes,
42✔
826
                    $styleAttributesForNode
42✔
827
                )
42✔
828
            );
42✔
829
        }
830
    }
831

832
    /**
833
     * Searches for all nodes with a style attribute and removes the "!important" annotations out of
834
     * the inline style declarations, eventually by rearranging declarations.
835
     *
836
     * @throws \RuntimeException
837
     */
838
    private function removeImportantAnnotationFromAllInlineStyles(): void
1,166✔
839
    {
840
        foreach ($this->getAllNodesWithStyleAttribute() as $node) {
1,166✔
841
            $this->removeImportantAnnotationFromNodeInlineStyle($node);
482✔
842
        }
843
    }
844

845
    /**
846
     * Removes the "!important" annotations out of the inline style declarations,
847
     * eventually by rearranging declarations.
848
     * Rearranging needed when !important shorthand properties are followed by some of their
849
     * not !important expanded-version properties.
850
     * For example "font: 12px serif !important; font-size: 13px;" must be reordered
851
     * to "font-size: 13px; font: 12px serif;" in order to remain correct.
852
     *
853
     * @throws \RuntimeException
854
     */
855
    private function removeImportantAnnotationFromNodeInlineStyle(\DOMElement $node): void
482✔
856
    {
857
        $style = $node->getAttribute('style');
482✔
858
        $inlineStyleDeclarations = DeclarationBlockParser::parse((bool) $style ? $style : '');
482✔
859
        /** @var array<string, string> $regularStyleDeclarations */
860
        $regularStyleDeclarations = [];
482✔
861
        /** @var array<string, string> $importantStyleDeclarations */
862
        $importantStyleDeclarations = [];
482✔
863
        foreach ($inlineStyleDeclarations as $property => $value) {
482✔
864
            if ($this->attributeValueIsImportant($value)) {
482✔
865
                $declaration = preg_replace('/\\s*+!\\s*+important$/i', '', $value);
38✔
866
                $importantStyleDeclarations[$property] = $declaration;
38✔
867
            } else {
868
                $regularStyleDeclarations[$property] = $value;
458✔
869
            }
870
        }
871
        $inlineStyleDeclarationsInNewOrder = \array_merge($regularStyleDeclarations, $importantStyleDeclarations);
482✔
872
        $node->setAttribute(
482✔
873
            'style',
482✔
874
            $this->generateStyleStringFromSingleDeclarationsArray($inlineStyleDeclarationsInNewOrder)
482✔
875
        );
482✔
876
    }
877

878
    /**
879
     * Generates a CSS style string suitable to be used inline from the $styleDeclarations property => value array.
880
     *
881
     * @param array<string, string> $styleDeclarations
882
     */
883
    private function generateStyleStringFromSingleDeclarationsArray(array $styleDeclarations): string
482✔
884
    {
885
        return $this->generateStyleStringFromDeclarationsArrays([], $styleDeclarations);
482✔
886
    }
887

888
    /**
889
     * Determines which of `$cssRules` actually apply to `$this->domDocument`, and sets them in
890
     * `$this->matchingUninlinableCssRules`.
891
     *
892
     * @param array<array-key, array{
893
     *            media: string,
894
     *            selector: non-empty-string,
895
     *            hasUnmatchablePseudo: bool,
896
     *            declarationsBlock: string,
897
     *            line: int<0, max>
898
     *        }> $cssRules
899
     *        the "uninlinable" array of CSS rules returned by `collateCssRules`
900
     */
901
    private function determineMatchingUninlinableCssRules(array $cssRules): void
1,166✔
902
    {
903
        $this->matchingUninlinableCssRules = \array_filter(
1,166✔
904
            $cssRules,
1,166✔
905
            function (array $cssRule): bool {
1,166✔
906
                return $this->existsMatchForSelectorInCssRule($cssRule);
581✔
907
            }
1,166✔
908
        );
1,166✔
909
    }
910

911
    /**
912
     * Checks whether there is at least one matching element for the CSS selector contained in the `selector` element
913
     * of the provided CSS rule.
914
     *
915
     * Any dynamic pseudo-classes will be assumed to apply. If the selector matches a pseudo-element,
916
     * it will test for a match with its originating element.
917
     *
918
     * @param array{
919
     *            media: string,
920
     *            selector: non-empty-string,
921
     *            hasUnmatchablePseudo: bool,
922
     *            declarationsBlock: string,
923
     *            line: int<0, max>
924
     *        } $cssRule
925
     *
926
     * @throws ParseException
927
     */
928
    private function existsMatchForSelectorInCssRule(array $cssRule): bool
581✔
929
    {
930
        $selector = $cssRule['selector'];
581✔
931
        if ($cssRule['hasUnmatchablePseudo']) {
581✔
932
            $selector = $this->removeUnmatchablePseudoComponents($selector);
449✔
933
        }
934
        return $this->existsMatchForCssSelector($selector);
581✔
935
    }
936

937
    /**
938
     * Checks whether there is at least one matching element for $cssSelector.
939
     * When not in debug mode, it returns true also for invalid selectors (because they may be valid,
940
     * just not implemented/recognized yet by Emogrifier).
941
     *
942
     * @throws ParseException in debug mode, if an invalid selector is encountered
943
     */
944
    private function existsMatchForCssSelector(string $cssSelector): bool
581✔
945
    {
946
        try {
947
            $nodesMatchingSelector
581✔
948
                = $this->querySelectorAll($cssSelector, [self::QSA_ALWAYS_THROW_PARSE_EXCEPTION => true]);
581✔
949
        } catch (ParseException $e) {
2✔
950
            if ($this->debug) {
2✔
951
                throw $e;
1✔
952
            }
953
            return true;
1✔
954
        }
955

956
        return $nodesMatchingSelector->length !== 0;
579✔
957
    }
958

959
    /**
960
     * Removes pseudo-elements and dynamic pseudo-classes from a CSS selector, replacing them with "*" if necessary.
961
     * If such a pseudo-component is within the argument of `:not`, the entire `:not` component is removed or replaced.
962
     *
963
     * @return string
964
     *         selector which will match the relevant DOM elements if the pseudo-classes are assumed to apply, or in the
965
     *         case of pseudo-elements will match their originating element
966
     */
967
    private function removeUnmatchablePseudoComponents(string $selector): string
449✔
968
    {
969
        // The regex allows nested brackets via `(?2)`.
970
        // A space is temporarily prepended because the callback can't determine if the match was at the very start.
971
        $pattern = '/([\\s>+~]?+):not(\\([^()]*+(?:(?2)[^()]*+)*+\\))/i';
449✔
972
        $callback = \Closure::fromCallable([$this, 'replaceUnmatchableNotComponent']);
449✔
973
        if (\function_exists('Safe\\preg_replace_callback')) {
449✔
974
            $untrimmedSelectorWithoutNots = preg_replace_callback($pattern, $callback, ' ' . $selector);
449✔
975
        } else {
976
            // @phpstan-ignore-next-line The safe version is only available in "thecodingmachine/safe" for PHP >= 8.1.
UNCOV
977
            $untrimmedSelectorWithoutNots = \preg_replace_callback($pattern, $callback, ' ' . $selector);
×
UNCOV
978
            \assert(\is_string($untrimmedSelectorWithoutNots));
×
979
        }
980
        $selectorWithoutNots = \ltrim($untrimmedSelectorWithoutNots);
449✔
981

982
        $selectorWithoutUnmatchablePseudoComponents = $this->removeSelectorComponents(
449✔
983
            ':(?!' . self::PSEUDO_CLASS_MATCHER . '):?+[\\w\\-]++(?:\\([^\\)]*+\\))?+',
449✔
984
            $selectorWithoutNots
449✔
985
        );
449✔
986

987
        if (preg_match(
449✔
988
            '/:(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')/i',
449✔
989
            $selectorWithoutUnmatchablePseudoComponents
449✔
990
        ) === 0) {
449✔
991
            return $selectorWithoutUnmatchablePseudoComponents;
382✔
992
        }
993

994
        $selectorParts = preg_split(
67✔
995
            '/(' . self::COMBINATOR_MATCHER . ')/',
67✔
996
            $selectorWithoutUnmatchablePseudoComponents,
67✔
997
            -1,
67✔
998
            PREG_SPLIT_DELIM_CAPTURE | PREG_SPLIT_NO_EMPTY
67✔
999
        );
67✔
1000
        /** @var list<string> $selectorParts */
1001

1002
        return \implode('', \array_map(
67✔
1003
            function (string $selectorPart): string {
67✔
1004
                return $this->removeUnsupportedOfTypePseudoClasses($selectorPart);
67✔
1005
            },
67✔
1006
            $selectorParts
67✔
1007
        ));
67✔
1008
    }
1009

1010
    /**
1011
     * Helps `removeUnmatchablePseudoComponents()` replace or remove a selector `:not(...)` component if its argument
1012
     * contains pseudo-elements or dynamic pseudo-classes.
1013
     *
1014
     * @param array<mixed> $matches
1015
     *        This is an array of elements matched by the regular expression.
1016
     *        A narrower type cannot be specified because it's a callback that may be passed different types in the
1017
     *        array, depending on the flags provided to `preg_replace_callback()` (which are not actually used),
1018
     *        and `Safe\preg_replace_callback()` does not have type annotations to cater for this.
1019
     *
1020
     * @return string
1021
     *         the full match if there were no unmatchable pseudo components within; otherwise, any preceding combinator
1022
     *         followed by "*", or an empty string if there was no preceding combinator
1023
     */
1024
    private function replaceUnmatchableNotComponent(array $matches): string
60✔
1025
    {
1026
        [$notComponentWithAnyPrecedingCombinator, $anyPrecedingCombinator, $notArgumentInBrackets] = $matches;
60✔
1027
        \assert(\is_string($notComponentWithAnyPrecedingCombinator));
60✔
1028
        \assert(\is_string($anyPrecedingCombinator));
60✔
1029
        \assert(\is_string($notArgumentInBrackets));
60✔
1030

1031
        if ($this->hasUnsupportedPseudoClass($notArgumentInBrackets)) {
60✔
1032
            return $anyPrecedingCombinator !== '' ? $anyPrecedingCombinator . '*' : '';
54✔
1033
        }
1034
        return $notComponentWithAnyPrecedingCombinator;
8✔
1035
    }
1036

1037
    /**
1038
     * Removes components from a CSS selector, replacing them with "*" if necessary.
1039
     *
1040
     * @param string $matcher regular expression part to match the components to remove
1041
     *
1042
     * @return string
1043
     *         selector which will match the relevant DOM elements if the removed components are assumed to apply (or in
1044
     *         the case of pseudo-elements will match their originating element)
1045
     */
1046
    private function removeSelectorComponents(string $matcher, string $selector): string
449✔
1047
    {
1048
        return preg_replace(
449✔
1049
            ['/([\\s>+~]|^)' . $matcher . '/i', '/' . $matcher . '/i'],
449✔
1050
            ['$1*', ''],
449✔
1051
            $selector
449✔
1052
        );
449✔
1053
    }
1054

1055
    /**
1056
     * Removes any `...-of-type` pseudo-classes from part of a CSS selector, if it does not have a type, replacing them
1057
     * with "*" if necessary.
1058
     *
1059
     * @param string $selectorPart part of a selector which has been split up at combinators
1060
     *
1061
     * @return string
1062
     *         selector part which will match the relevant DOM elements if the pseudo-classes are assumed to apply
1063
     */
1064
    private function removeUnsupportedOfTypePseudoClasses(string $selectorPart): string
67✔
1065
    {
1066
        if (!$this->selectorPartHasUnsupportedOfTypePseudoClass($selectorPart)) {
67✔
1067
            return $selectorPart;
44✔
1068
        }
1069

1070
        return $this->removeSelectorComponents(
67✔
1071
            ':(?:' . self::OF_TYPE_PSEUDO_CLASS_MATCHER . ')(?:\\([^\\)]*+\\))?+',
67✔
1072
            $selectorPart
67✔
1073
        );
67✔
1074
    }
1075

1076
    /**
1077
     * Applies `$this->matchingUninlinableCssRules` to `$this->domDocument` by placing them as CSS in a `<style>`
1078
     * element.
1079
     * If there are no uninlinable CSS rules to copy there, a `<style>` element will be created containing only the
1080
     * applicable at-rules from `$parsedCss`.
1081
     * If there are none of either, an empty `<style>` element will not be created.
1082
     *
1083
     * @param CssDocument $parsedCss
1084
     *        This may contain various at-rules whose content `CssInliner` does not currently attempt to inline or
1085
     *        process in any other way, such as `@import`, `@font-face`, `@keyframes`, etc., and which should precede
1086
     *        the processed but found-to-be-uninlinable CSS placed in the `<style>` element.
1087
     *        Note that `CssInliner` processes `@media` rules so that they can be ordered correctly with respect to
1088
     *        other uninlinable rules; these will not be duplicated from `$parsedCss`.
1089
     */
1090
    private function copyUninlinableCssToStyleNode(CssDocument $parsedCss): void
1,166✔
1091
    {
1092
        $css = $parsedCss->renderNonConditionalAtRules();
1,166✔
1093

1094
        // avoid including unneeded class dependency if there are no rules
1095
        if ($this->getMatchingUninlinableCssRules() !== []) {
1,166✔
1096
            $cssConcatenator = new CssConcatenator();
435✔
1097
            foreach ($this->getMatchingUninlinableCssRules() as $cssRule) {
435✔
1098
                $cssConcatenator->append([$cssRule['selector']], $cssRule['declarationsBlock'], $cssRule['media']);
435✔
1099
            }
1100
            $css .= $cssConcatenator->getCss();
435✔
1101
        }
1102

1103
        // avoid adding empty style element
1104
        if ($css !== '') {
1,166✔
1105
            $this->addStyleElementToDocument($css);
470✔
1106
        }
1107
    }
1108

1109
    /**
1110
     * Adds a style element with `$css` to `$this->domDocument`.
1111
     *
1112
     * This method is protected to allow overriding.
1113
     *
1114
     * @see https://github.com/MyIntervals/emogrifier/issues/103
1115
     */
1116
    protected function addStyleElementToDocument(string $css): void
470✔
1117
    {
1118
        $domDocument = $this->getDomDocument();
470✔
1119
        $styleElement = $domDocument->createElement('style', $css);
470✔
1120
        $styleAttribute = $domDocument->createAttribute('type');
470✔
1121
        $styleAttribute->value = 'text/css';
470✔
1122
        $styleElement->appendChild($styleAttribute);
470✔
1123

1124
        $headElement = $this->getHeadElement();
470✔
1125
        $headElement->appendChild($styleElement);
470✔
1126
    }
1127

1128
    /**
1129
     * Returns the `HEAD` element.
1130
     *
1131
     * This method assumes that there always is a HEAD element.
1132
     */
1133
    private function getHeadElement(): \DOMElement
470✔
1134
    {
1135
        $node = $this->getDomDocument()->getElementsByTagName('head')->item(0);
470✔
1136
        \assert($node instanceof \DOMElement);
470✔
1137

1138
        return $node;
470✔
1139
    }
1140
}
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc