From ebf5dd5c43ee0cb29deb4cd0cf9e06d2f2c4c1c3 Mon Sep 17 00:00:00 2001 From: Jake Hotson Date: Fri, 23 Feb 2018 01:16:34 +0000 Subject: [PATCH] [CLEANUP] Add a CssConcatenator class This abstracts the (re-)combining of CSS rules for various media queries and with various selectors and declaration blocks, merging adjacent rule blocks where possible (i.e. for the same media query, with the same selectors, or with the same declarations block). Although not yet utilized, it will be required for #280 to simplify the code. --- src/Emogrifier/CssConcatenator.php | 137 +++++++++ tests/Unit/Emogrifier/CssConcatenatorTest.php | 267 ++++++++++++++++++ 2 files changed, 404 insertions(+) create mode 100644 src/Emogrifier/CssConcatenator.php create mode 100644 tests/Unit/Emogrifier/CssConcatenatorTest.php diff --git a/src/Emogrifier/CssConcatenator.php b/src/Emogrifier/CssConcatenator.php new file mode 100644 index 00000000..a6b6fd79 --- /dev/null +++ b/src/Emogrifier/CssConcatenator.php @@ -0,0 +1,137 @@ + + */ +class CssConcatenator +{ + /** + * CSS under construction. + * + * @var string + */ + private $css = ''; + + /** + * Current media query string, e.g. "@media screen and (max-width:639px)" in the currently open media query block, + * or an empty string if not currently within a media query block. + * + * @var string + */ + private $currentMedia = ''; + + /** + * Array whose keys are selectors for the rule block currently under construction (values are of no significance), + * or an empty array if no rule block under construction. + * + * @var int[] + */ + private $currentSelectorsAsKeys = []; + + /** + * Declarations for the rule block currently under construction, + * or an empty string if no rule block under construction. + * + * @var string + */ + private $currentDeclarationsBlock = ''; + + /** + * Allow extending classes to call `parent::__construct()`. + */ + public function __construct() + { + } + + /** + * Append a declaration block to the CSS. + * + * @param string[]|string $selectors Array of selectors for the rule, e.g. ["ul", "ol", "p:first-child"], + * or a single selector, e.g. "ul". + * @param string $declarationsBlock The property declarations, e.g. "margin-top: 0.5em; padding: 0". + * @param string $media The media query for the rule, e.g. "@media screen and (max-width:639px)", + * or an empty string if none. + */ + public function append($selectors, $declarationsBlock, $media = '') + { + $selectorsAsKeys = array_flip((array)$selectors); + + if ($media !== $this->currentMedia) { + $this->closeBlocks(); + if ($media !== '') { + $this->css .= $media . '{'; + $this->currentMedia = $media; + } + } + + if ($declarationsBlock === $this->currentDeclarationsBlock) { + $this->currentSelectorsAsKeys += $selectorsAsKeys; + } elseif ($this->hasEquivalentCurrentSelectors($selectorsAsKeys)) { + $this->currentDeclarationsBlock + = rtrim(rtrim($this->currentDeclarationsBlock), ';') . ';' . $declarationsBlock; + } else { + $this->closeRuleBlock(); + $this->currentSelectorsAsKeys = $selectorsAsKeys; + $this->currentDeclarationsBlock = $declarationsBlock; + } + } + + /** + * Close any open rule or media blocks and return the CSS. + * + * @return string + */ + public function getCss() + { + $this->closeBlocks(); + return $this->css; + } + + /** + * Close any open rule or media blocks. + * + * @return void + */ + private function closeBlocks() + { + $this->closeRuleBlock(); + if ($this->currentMedia !== '') { + $this->css .= '}'; + $this->currentMedia = ''; + } + } + + /** + * Close any rule block under construction, appending its contents to the CSS. + * + * @return void + */ + private function closeRuleBlock() + { + if ($this->currentSelectorsAsKeys !== [] && $this->currentDeclarationsBlock !== '') { + $this->css .= implode(',', array_keys($this->currentSelectorsAsKeys)) + . '{' . $this->currentDeclarationsBlock . '}'; + } + $this->currentSelectorsAsKeys = []; + $this->currentDeclarationsBlock = ''; + } + + /** + * Test if a set of selectors is equivalent to that for the rule block currently under construction + * (i.e. the same selectors, possibly in a different order). + * + * @param int[] $selectorsAsKeys Array in which the selectors are the keys, and the values are of no significance + * + * @return bool + */ + private function hasEquivalentCurrentSelectors(array $selectorsAsKeys) + { + return count($selectorsAsKeys) === count($this->currentSelectorsAsKeys) + && count($selectorsAsKeys) === count($this->currentSelectorsAsKeys + $selectorsAsKeys); + } +} diff --git a/tests/Unit/Emogrifier/CssConcatenatorTest.php b/tests/Unit/Emogrifier/CssConcatenatorTest.php new file mode 100644 index 00000000..3254309d --- /dev/null +++ b/tests/Unit/Emogrifier/CssConcatenatorTest.php @@ -0,0 +1,267 @@ + + */ +class CssConcatenatorTest extends \PHPUnit_Framework_TestCase +{ + /** + * @var CssConcatenator + */ + private $subject = null; + + /** + * @return void + */ + protected function setUp() + { + $this->subject = new CssConcatenator(); + } + + /** + * @test + */ + public function getCssInitiallyReturnsEmptyString() + { + $result = $this->subject->getCss(); + + static::assertSame('', $result); + } + + /** + * @test + */ + public function getCssReturnsSingleAppendedRule() + { + $this->subject->append('p', 'color: green;'); + + $result = $this->subject->getCss(); + + static::assertSame('p{color: green;}', $result); + } + + /** + * @test + */ + public function getCssReturnsSingleAppendedMediaRule() + { + $this->subject->append('p', 'color: green;', '@media screen'); + + $result = $this->subject->getCss(); + + static::assertSame('@media screen{p{color: green;}}', $result); + } + + /** + * @return mixed[][] + */ + public function equivalentSelectorsDataProvider() + { + return [ + 'one selector' => ['p', 'p'], + 'two selectors' => [ + ['p', 'ul'], + ['p', 'ul'], + ], + 'two selectors in different order' => [ + ['p', 'ul'], + ['ul', 'p'], + ], + ]; + } + + /** + * @test + * + * @param string[]|string $selectors1 + * @param string[]|string $selectors2 + * + * @dataProvider equivalentSelectorsDataProvider + */ + public function appendCombinesRulesWithEquivalentSelectors($selectors1, $selectors2) + { + $this->subject->append($selectors1, 'color: green;'); + $this->subject->append($selectors2, 'font-size: 16px;'); + + $result = $this->subject->getCss(); + + $expectedResult = implode(',', (array)$selectors1) . '{color: green;font-size: 16px;}'; + + static::assertSame($expectedResult, $result); + } + + /** + * @test + */ + public function appendInsertsSemicolonCombiningRulesWithoutTrailingSemicolon() + { + $this->subject->append('p', 'color: green'); + $this->subject->append('p', 'font-size: 16px'); + + $result = $this->subject->getCss(); + + static::assertSame('p{color: green;font-size: 16px}', $result); + } + + /** + * @return mixed[][] + */ + public function differentSelectorsDataProvider() + { + return [ + 'single selectors' => [ + 'p', + 'ul', + ['p', 'ul'], + ], + 'single selector and an entirely different pair' => [ + 'p', + ['ul', 'ol'], + ['p', 'ul', 'ol'], + ], + 'single selector and a superset pair' => [ + 'p', + ['p', 'ul'], + ['p', 'ul'], + ], + 'pair of selectors and an entirely different single' => [ + ['p', 'ul'], + 'ol', + ['p', 'ul', 'ol'], + ], + 'pair of selectors and a subset single' => [ + ['p', 'ul'], + 'ul', + ['p', 'ul'], + ], + 'entirely different pairs of selectors' => [ + ['p', 'ul'], + ['ol', 'h1'], + ['p', 'ul', 'ol', 'h1'], + ], + 'pairs of selectors with one common' => [ + ['p', 'ul'], + ['ul', 'ol'], + ['p', 'ul', 'ol'], + ], + ]; + } + + /** + * @test + * + * @param string[]|string $selectors1 + * @param string[]|string $selectors2 + * @param string[] $combinedSelectors + * + * @dataProvider differentSelectorsDataProvider + */ + public function appendCombinesSameRulesWithDifferentSelectors($selectors1, $selectors2, array $combinedSelectors) + { + $this->subject->append($selectors1, 'color: green;'); + $this->subject->append($selectors2, 'color: green;'); + + $result = $this->subject->getCss(); + + $expectedResult = implode(',', $combinedSelectors) . '{color: green;}'; + + static::assertSame($expectedResult, $result); + } + + /** + * @test + * + * @param string[]|string $selectors1 + * @param string[]|string $selectors2 + * + * @dataProvider differentSelectorsDataProvider + */ + public function appendNotCombinesDifferentRulesWithDifferentSelectors($selectors1, $selectors2) + { + $this->subject->append($selectors1, 'color: green;'); + $this->subject->append($selectors2, 'font-size: 16px;'); + + $result = $this->subject->getCss(); + + $expectedResult = implode(',', (array)$selectors1) . '{color: green;}' + . implode(',', (array)$selectors2) . '{font-size: 16px;}'; + + static::assertSame($expectedResult, $result); + } + + /** + * @test + */ + public function appendCombinesRulesForSameMediaQueryInMediaRule() + { + $this->subject->append('p', 'color: green;', '@media screen'); + $this->subject->append('ul', 'font-size: 16px;', '@media screen'); + + $result = $this->subject->getCss(); + + static::assertSame('@media screen{p{color: green;}ul{font-size: 16px;}}', $result); + } + + /** + * @test + * + * @param string[]|string $selectors1 + * @param string[]|string $selectors2 + * + * @dataProvider equivalentSelectorsDataProvider + */ + public function appendCombinesRulesWithEquivalentSelectorsWithinMediaRule($selectors1, $selectors2) + { + $this->subject->append($selectors1, 'color: green;', '@media screen'); + $this->subject->append($selectors2, 'font-size: 16px;', '@media screen'); + + $result = $this->subject->getCss(); + + $expectedResult = '@media screen{' . implode(',', (array)$selectors1) . '{color: green;font-size: 16px;}}'; + + static::assertSame($expectedResult, $result); + } + + /** + * @test + * + * @param string[]|string $selectors1 + * @param string[]|string $selectors2 + * @param string[] $combinedSelectors + * + * @dataProvider differentSelectorsDataProvider + */ + public function appendCombinesSameRulesWithDifferentSelectorsWithinMediaRule( + $selectors1, + $selectors2, + $combinedSelectors + ) { + $this->subject->append($selectors1, 'color: green;', '@media screen'); + $this->subject->append($selectors2, 'color: green;', '@media screen'); + + $result = $this->subject->getCss(); + + $expectedResult = '@media screen{' . implode(',', $combinedSelectors) . '{color: green;}}'; + + static::assertSame($expectedResult, $result); + } + + /** + * @test + */ + public function appendNotCombinesRulesForDifferentMediaQueryInMediaRule() + { + $this->subject->append('p', 'color: green;', '@media screen'); + $this->subject->append('p', 'color: green;', '@media print'); + + $result = $this->subject->getCss(); + + static::assertSame('@media screen{p{color: green;}}@media print{p{color: green;}}', $result); + } +}