From 9e67fd92f51bb8d5e118f48efb3d33509253753c Mon Sep 17 00:00:00 2001 From: Jake Hotson Date: Thu, 5 Apr 2018 19:32:55 +0100 Subject: [PATCH] [FEATURE] Selectors with states & pseudo-elements Copy CSS for selectors involving pseudo-elements and dynamic (state-based) pseudo-classes to the style element added to the document (along with the media queries). `parseCssRules`: - Include rules for such selectors in the "uninlineable" rules array (instead of discarding them), flagging them with "hasUnmatchablePseudo"; - Don't consider selectors with both an unsupported and a supported pseudo-class as matchable for inlining styles. `copyUninlineableCssToStyleNode`: - Strip the unmatchable pseudo-components from selectors before calling `existsMatchForCssSelector`. Various unit tests added. This addresses #280 for the `Emogrifier` class. --- CHANGELOG.md | 4 + src/Emogrifier.php | 48 +++++-- tests/Unit/EmogrifierTest.php | 256 ++++++++++++++++++++++++++++++++-- 3 files changed, 284 insertions(+), 24 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 74418840..90362bfa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,10 @@ This project adheres to [Semantic Versioning](https://semver.org/). ## x.y.z ### Added +- Copy matching rules with dynamic pseudo-classes or pseudo-elements in + selectors to the style element + ([#280](https://github.com/MyIntervals/emogrifier/issues/280), + [#562](https://github.com/MyIntervals/emogrifier/pull/562)) - Add a CssToAttributeConverter ([#546](https://github.com/jjriv/emogrifier/pull/546)) - Expose the DOMDocument in AbstractHtmlProcessor diff --git a/src/Emogrifier.php b/src/Emogrifier.php index 4f93fcae..70c37796 100644 --- a/src/Emogrifier.php +++ b/src/Emogrifier.php @@ -67,6 +67,16 @@ class Emogrifier */ const CLASS_ATTRIBUTE_MATCHER = '/(\\w+|[\\*\\]])?((\\.[\\w\\-]+)+)/'; + /** + * Regular expression component matching a static pseudo class in a selector, without the preceding ":", + * for which the applicable elements can be determined (by converting the selector to an XPath expression). + * (Contains alternation without a group and is intended to be placed within a capturing, non-capturing or lookahead + * group, as appropriate for the usage context.) + * + * @var string + */ + const PSEUDO_CLASS_MATCHER = '\\S+\\-(?:child|type\\()|not\\([[:ascii:]]*\\)'; + /** * @var string */ @@ -753,23 +763,21 @@ private function parseCssRules($css) // don't process pseudo-elements and behavioral (dynamic) pseudo-classes; // only allow structural pseudo-classes $hasPseudoElement = strpos($selector, '::') !== false; - $hasAnyPseudoClass = (bool)preg_match('/:[a-zA-Z]/', $selector); - $hasSupportedPseudoClass = (bool)preg_match( - '/:(\\S+\\-(child|type\\()|not\\([[:ascii:]]*\\))/i', + $hasUnsupportedPseudoClass = (bool)preg_match( + '/:(?!' . static::PSEUDO_CLASS_MATCHER . ')[\\w-]/i', $selector ); - if ($hasPseudoElement || ($hasAnyPseudoClass && !$hasSupportedPseudoClass)) { - continue; - } + $hasUnmatchablePseudo = $hasPseudoElement || $hasUnsupportedPseudoClass; $parsedCssRule = [ 'media' => $cssRule['media'], 'selector' => trim($selector), + 'hasUnmatchablePseudo' => $hasUnmatchablePseudo, 'declarationsBlock' => $cssDeclaration, // keep track of where it appears in the file, since order is important 'line' => $key, ]; - $ruleType = ($cssRule['media'] === '') ? 'inlineable' : 'uninlineable'; + $ruleType = ($cssRule['media'] === '' && !$hasUnmatchablePseudo) ? 'inlineable' : 'uninlineable'; $cssRules[$ruleType][] = $parsedCssRule; } } @@ -1186,8 +1194,12 @@ private function copyUninlineableCssToStyleNode(\DOMDocument $xmlDocument, \DOMX { $cssRulesRelevantForDocument = array_filter( $cssRules, - function ($cssRule) use ($xPath) { - return $this->existsMatchForCssSelector($xPath, $cssRule['selector']); + function (array $cssRule) use ($xPath) { + $selector = $cssRule['selector']; + if ($cssRule['hasUnmatchablePseudo']) { + $selector = $this->removeUnmatchablePseudoComponents($selector); + } + return $this->existsMatchForCssSelector($xPath, $selector); } ); @@ -1209,6 +1221,24 @@ function ($cssRule) use ($xPath) { $this->addStyleElementToDocument($xmlDocument, $cssConcatenator->getCss()); } + /** + * Removes pseudo-elements and dynamic pseudo-classes from a CSS selector, replacing them with "*" if necessary. + * + * @param string $selector + * + * @return string Selector which will match the relevant DOM elements if the pseudo-classes are assumed to apply, + * or in the case of pseudo-elements will match their originating element. + */ + private function removeUnmatchablePseudoComponents($selector) + { + $pseudoComponentMatcher = ':(?!' . static::PSEUDO_CLASS_MATCHER . '):?+[\\w-]++(?:\\([^\\)]*+\\))?+'; + return preg_replace( + ['/(\\s|^)' . $pseudoComponentMatcher . '/i', '/' . $pseudoComponentMatcher . '/i'], + ['$1*', ''], + $selector + ); + } + /** * Checks whether there is at least one matching element for $cssSelector. * When not in debug mode, it returns true also for invalid selectors (because they may be valid, diff --git a/tests/Unit/EmogrifierTest.php b/tests/Unit/EmogrifierTest.php index 0a26e271..a950142c 100644 --- a/tests/Unit/EmogrifierTest.php +++ b/tests/Unit/EmogrifierTest.php @@ -1361,7 +1361,7 @@ public function emogrifyKeepsMediaRules($css) /** * @return string[][] */ - public function surroundingCssForOrderedMediaRulesDataProvider() + public function orderedRulesAndSurroundingCssDataProvider() { $possibleSurroundingCss = [ 'nothing' => '', @@ -1373,13 +1373,14 @@ public function surroundingCssForOrderedMediaRulesDataProvider() 'other matching CSS' => 'p { color: #f00; }', 'disallowed media rule' => '@media tv { p { color: #f00; } }', 'allowed but non-matching media rule' => '@media screen { h6 { color: #f00; } }', + 'non-matching CSS with pseudo-component' => 'h6:hover { color: #f00; }', ]; $possibleCssBefore = $possibleSurroundingCss + [ '@import' => '@import "foo.css";', '@charset' => '@charset "UTF-8";', ]; - $datasets = []; + $datasetsSurroundingCss = []; foreach ($possibleCssBefore as $descriptionBefore => $cssBefore) { foreach ($possibleSurroundingCss as $descriptionBetween => $cssBetween) { foreach ($possibleSurroundingCss as $descriptionAfter => $cssAfter) { @@ -1393,36 +1394,63 @@ public function surroundingCssForOrderedMediaRulesDataProvider() // test with each possible CSS in all three positions || ($cssBefore === $cssBetween && $cssBetween === $cssAfter) ) { - $description = $descriptionBefore . ' before, ' + $description = ' with ' . $descriptionBefore . ' before, ' . $descriptionBetween . ' between, ' . $descriptionAfter . ' after'; - $datasets[$description] = [$cssBefore, $cssBetween, $cssAfter]; + $datasetsSurroundingCss[$description] = [$cssBefore, $cssBetween, $cssAfter]; } } } } + + $datasets = []; + foreach ($datasetsSurroundingCss as $description => $datasetSurroundingCss) { + $datasets += [ + 'two media rules' . $description => array_merge( + ['@media all { p { color: #333; } }', '@media print { p { color: #000; } }'], + $datasetSurroundingCss + ), + 'two rules involving pseudo-components' . $description => array_merge( + ['a:hover { color: blue; }', 'a:active { color: green; }'], + $datasetSurroundingCss + ), + 'media rule followed by rule involving pseudo-components' . $description => array_merge( + ['@media screen { p { color: #000; } }', 'a:hover { color: green; }'], + $datasetSurroundingCss + ), + 'rule involving pseudo-components followed by media rule' . $description => array_merge( + ['a:hover { color: green; }', '@media screen { p { color: #000; } }'], + $datasetSurroundingCss + ), + ]; + } return $datasets; } /** * @test * - * @param string $cssBefore CSS to insert before the first @media rule - * @param string $cssBetween CSS to insert between the @media rules - * @param string $cssAfter CSS to insert after the second @media rules + * @param string $rule1 + * @param string $rule2 + * @param string $cssBefore CSS to insert before the first rule + * @param string $cssBetween CSS to insert between the rules + * @param string $cssAfter CSS to insert after the second rule * - * @dataProvider surroundingCssForOrderedMediaRulesDataProvider + * @dataProvider orderedRulesAndSurroundingCssDataProvider */ - public function emogrifyKeepsMediaRulesInSpecifiedOrder($cssBefore, $cssBetween, $cssAfter) - { - $this->subject->setHtml('

foo

'); - $mediaRule1 = '@media all {p {color: #333;}}'; - $mediaRule2 = '@media print {p {color: #000;}}'; - $this->subject->setCss($cssBefore . $mediaRule1 . $cssBetween . $mediaRule2 . $cssAfter); + public function emogrifyKeepsRulesCopiedToStyleElementInSpecifiedOrder( + $rule1, + $rule2, + $cssBefore, + $cssBetween, + $cssAfter + ) { + $this->subject->setHtml('

foo

'); + $this->subject->setCss($cssBefore . $rule1 . $cssBetween . $rule2 . $cssAfter); $result = $this->subject->emogrify(); - static::assertContainsCss($mediaRule1 . $mediaRule2, $result); + static::assertContainsCss($rule1 . $rule2, $result); } /** @@ -1750,6 +1778,204 @@ public function emogrifyNotKeepsUnneededMediaRuleAfterEmptyMediaRule($emptyRuleM static::assertNotContains('@media', $result); } + /** + * @param string[] $precedingSelectorComponents Array of selectors to which each type of pseudo-component is + * appended to create a selector for a CSS rule. + * Keys are human-readable descriptions. + * + * @return string[][] + */ + private function getCssRuleDatasetsWithSelectorPseudoComponents($precedingSelectorComponents) + { + $rulesComponents = [ + 'pseudo-element' => [ + 'selectorPseudoComponent' => '::after', + 'declarationsBlock' => 'content: "bar";', + ], + 'CSS2 pseudo-element' => [ + 'selectorPseudoComponent' => ':after', + 'declarationsBlock' => 'content: "bar";', + ], + 'hyphenated pseudo-element' => [ + 'selectorPseudoComponent' => '::first-letter', + 'declarationsBlock' => 'color: green;', + ], + 'pseudo-class' => [ + 'selectorPseudoComponent' => ':hover', + 'declarationsBlock' => 'color: green;', + ], + 'hyphenated pseudo-class' => [ + 'selectorPseudoComponent' => ':read-only', + 'declarationsBlock' => 'color: green;', + ], + 'pseudo-class with parameter' => [ + 'selectorPseudoComponent' => ':lang(en)', + 'declarationsBlock' => 'color: green;', + ], + ]; + + $datasets = []; + foreach ($precedingSelectorComponents as $precedingComponentDescription => $precedingSelectorComponent) { + foreach ($rulesComponents as $pseudoComponentDescription => $ruleComponents) { + $datasets[$precedingComponentDescription . ' ' . $pseudoComponentDescription] = [ + $precedingSelectorComponent . $ruleComponents['selectorPseudoComponent'] + . ' { ' . $ruleComponents['declarationsBlock'] . ' }' + ]; + } + } + return $datasets; + } + + /** + * @return string[][] + */ + public function matchingSelectorWithPseudoComponentCssRuleDataProvider() + { + return $this->getCssRuleDatasetsWithSelectorPseudoComponents( + [ + 'lone' => '', + 'type &' => 'a', + 'class &' => '.a', + 'ID &' => '#a', + 'attribute &' => 'a[href="a"]', + 'static pseudo-class &' => 'a:first-child', + 'ancestor &' => 'p ', + 'ancestor & type &' => 'p a', + ] + ) + [ + 'pseudo-class & descendant' => ['p:hover a { color: green; }'], + 'pseudo-class & pseudo-element' => ['a:hover::after { content: "bar"; }'], + 'pseudo-element & pseudo-class' => ['a::after:hover { content: "bar"; }'], + 'two pseudo-classes' => ['a:focus:hover { color: green; }'], + ]; + } + + /** + * @test + * + * @param string $css + * + * @dataProvider matchingSelectorWithPseudoComponentCssRuleDataProvider + */ + public function emogrifyKeepsRuleWithPseudoComponentInMatchingSelector($css) + { + $this->subject->setHtml('

foo

'); + $this->subject->setCss($css); + + $result = $this->subject->emogrify(); + + self::assertContainsCss($css, $result); + } + + /** + * @return string[][] + */ + public function nonMatchingSelectorWithPseudoComponentCssRuleDataProvider() + { + return $this->getCssRuleDatasetsWithSelectorPseudoComponents( + [ + 'type &' => 'b', + 'class &' => '.b', + 'ID &' => '#b', + 'attribute &' => 'a[href="b"]', + 'static pseudo-class &' => 'a:not(.a)', + 'ancestor &' => 'ul ', + 'ancestor & type &' => 'p b', + ] + ) + [ + 'pseudo-class & descendant' => ['ul:hover a { color: green; }'], + 'pseudo-class & pseudo-element' => ['b:hover::after { content: "bar"; }'], + 'pseudo-element & pseudo-class' => ['b::after:hover { content: "bar"; }'], + 'two pseudo-classes' => ['input:focus:hover { color: green; }'], + ]; + } + + /** + * @test + * + * @param string $css + * + * @dataProvider nonMatchingSelectorWithPseudoComponentCssRuleDataProvider + */ + public function emogrifyNotKeepsRuleWithPseudoComponentInNonMatchingSelector($css) + { + $this->subject->setHtml('

foo

'); + $this->subject->setCss($css); + + $result = $this->subject->emogrify(); + + self::assertNotContainsCss($css, $result); + } + + /** + * @test + */ + public function emogrifyKeepsRuleInMediaQueryWithPseudoComponentInMatchingSelector() + { + $this->subject->setHtml('foo'); + $css = '@media screen { a:hover { color: green; } }'; + $this->subject->setCss($css); + + $result = $this->subject->emogrify(); + + self::assertContainsCss($css, $result); + } + + /** + * @test + */ + public function emogrifyNotKeepsRuleInMediaQueryWithPseudoComponentInNonMatchingSelector() + { + $this->subject->setHtml('foo'); + $css = '@media screen { b:hover { color: green; } }'; + $this->subject->setCss($css); + + $result = $this->subject->emogrify(); + + self::assertNotContainsCss($css, $result); + } + + /** + * @test + */ + public function emogrifyKeepsRuleWithPseudoComponentInMulitipleMatchingSelectorsFromSingleRule() + { + $this->subject->setHtml('

foo

bar'); + $css = 'p:hover, a:hover { color: green; }'; + $this->subject->setCss($css); + + $result = $this->subject->emogrify(); + + static::assertContainsCss($css, $result); + } + + /** + * @test + */ + public function emogrifyKeepsOnlyMatchingSelectorsWithPseudoComponentFromSingleRule() + { + $this->subject->setHtml('foo'); + $this->subject->setCss('p:hover, a:hover { color: green; }'); + + $result = $this->subject->emogrify(); + + static::assertContainsCss('', $result); + } + + /** + * @test + */ + public function emogrifyAppliesCssToMatchingElementsAndKeepsRuleWithPseudoComponentFromSingleRule() + { + $this->subject->setHtml('

foo

bar'); + $this->subject->setCss('p, a:hover { color: green; }'); + + $result = $this->subject->emogrify(); + + static::assertContains('

', $result); + static::assertContainsCss('', $result); + } + /** * @return string[][] */