Skip to content

Commit

Permalink
[FEATURE] Add the ability to exclude CSS selectors from being inlined
Browse files Browse the repository at this point in the history
- Add unit tests
- Normalize selectors spaces/line breaks
- Add CHANGELOG entry
- Add README documentation
  • Loading branch information
nlemoine committed Jan 15, 2023
1 parent fa57b09 commit c60ce2f
Show file tree
Hide file tree
Showing 4 changed files with 113 additions and 10 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## x.y.z

### Added
- Add CSS selectors exclusion feature (#1202)

### Changed

Expand Down
10 changes: 10 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,16 @@ calling the `inlineCss` method:
$cssInliner->addExcludedSelector('.message-preview');
$cssInliner->addExcludedSelector('.message-preview *');
```
* `->addExcludedCssSelector(string $selector)` - Contrary to `addExcludedSelector`
which excludes HTML nodes, this method excludes CSS selectors from
being inlined. This is for example useful if you don't want your CSS reset rules
to be inlined on each HTML node (e.g. `* { margin: 0; padding: 0; font-size: 100% }`).
Note that these selectors must precisely match the selectors you wish to exclude.
Meaning that excluding `.example` will not exclude `p .example`.
```php
$cssInliner->addExcludedCssSelector('*');
$cssInliner->addExcludedCssSelector('form');
```

### Migrating from the dropped `Emogrifier` class to the `CssInliner` class

Expand Down
32 changes: 27 additions & 5 deletions src/CssInliner.php
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,15 @@ class CssInliner extends AbstractHtmlProcessor
*/
private const COMBINATOR_MATCHER = '(?:\\s++|\\s*+[>+~]\\s*+)(?=[[:alpha:]_\\-.#*:\\[])';

/**
* regular expression component to match all kinds of whitespace
*
* @see https://github.com/jolicode/JoliTypo/blob/8799bae6cda2280b319750d2fe764bfd8e8f6ba2/src/JoliTypo/Fixer.php#L37
*
* @var string
*/
private const ALL_SPACES = '\\xE2\\x80\\xAF|\\xC2\\xAD|\\xC2\\xA0|\\s';

/**
* @var array<string, bool>
*/
Expand Down Expand Up @@ -327,7 +336,7 @@ public function addExcludedCssSelector(string $selector): self
*
* @return $this
*/
public function removeExcludedCssSelectorRule(string $selector): self
public function removeExcludedCssSelector(string $selector): self
{
if (isset($this->excludedCssSelectors[$selector])) {
unset($this->excludedCssSelectors[$selector]);
Expand Down Expand Up @@ -618,13 +627,26 @@ private function collateCssRules(CssDocument $parsedCss): array
if (!$cssRule->hasAtLeastOneDeclaration()) {
continue;
}
if (\count(\array_intersect($cssRule->getSelectors(), \array_keys($this->excludedCssSelectors)))) {
continue;
}

$mediaQuery = $cssRule->getContainingAtRule();
$declarationsBlock = $cssRule->getDeclarationAsText();
foreach ($cssRule->getSelectors() as $selector) {
$selectors = $cssRule->getSelectors();

// Maybe exclude CSS selectors
if (!empty($this->excludedCssSelectors)) {
// Normalize spaces & line breaks
$selectorsNormalized = \array_map(static function ($selector) {
$selector = \str_replace(["\r", "\n"], ' ', $selector);
$selector = \preg_replace('@[ ' . self::ALL_SPACES . ']+@mu', ' ', $selector);
return $selector;
}, $selectors);

$selectors = \array_filter($selectorsNormalized, function ($selector) {
return !isset($this->excludedCssSelectors[$selector]);
});
}

foreach ($selectors as $selector) {
// don't process pseudo-elements and behavioral (dynamic) pseudo-classes;
// only allow structural pseudo-classes
$hasPseudoElement = \strpos($selector, '::') !== false;
Expand Down
80 changes: 75 additions & 5 deletions tests/Unit/CssInlinerTest.php
Original file line number Diff line number Diff line change
Expand Up @@ -3142,18 +3142,88 @@ public function inlineCssNotInDebugModeIgnoresOnlyInvalidExcludedSelector(): voi

/**
* @test
*
* @dataProvider excludedCssRuleDataProvider
*
* @param string $css
* @param array<string> $exludedSelectors
* @param string $expectedInlineCss
*/
public function addExcludedCssSelector(): void
public function addExcludedCssSelectorCanExcludeCssSelectors(string $css, array $exludedSelectors, string $expectedInlineCss): void
{
$subject = $this->buildDebugSubject('<html><body><div class="green"></div></body></html>');
$subject->addExcludedCssSelector('*');
$subject->inlineCss('* { margin: 0 } .green { color: green }');
$subject = $this->buildDebugSubject('<html><body><section><div class="green"></div></section></body></html>');

foreach ($exludedSelectors as $exludedSelector) {
$subject->addExcludedCssSelector($exludedSelector);
}
$subject->inlineCss($css);

self::assertStringContainsString(
'<div class="green" style="color: green;"></div>',
'<section><div class="green" style="' . $expectedInlineCss . '"></div></section>',
$subject->renderBodyContent()
);
}

/**
* @return array<array{string, array<string>, string}>
*/
public function excludedCssRuleDataProvider(): array
{
return [
'simple selector' => [
'* { margin: 0; } .green { color: green; }', // CSS
['*'], // Exclude
'color: green;', // Expected inline CSS
],
'multiple selectors' => [
'*, div { border: solid 1px red; } .green { color: green; }',
['*'],
'border: solid 1px red; color: green;',
],
'descendant selector' => [
'section .green { color: green; } .green { border-color: green; }',
['section .green'],
'border-color: green;',
],
'descendant selector with line break' => [
"section\n.green { color: green; } .green { border-color: green; }",
['section .green'],
'border-color: green;',
],
'descendant selector with non standard space' => [
"section\u{a0}.green { color: green; } .green { border-color: green; }",
['section .green'],
'border-color: green;',
],
];
}

/**
* @test
*/
public function removeExcludedCssSelectorProvidesFluentInterface(): void
{
$subject = CssInliner::fromHtml('<html></html>');

$result = $subject->removeExcludedCssSelector('p.x');

self::assertSame($subject, $result);
}

/**
* @test
*/
public function removeExcludedCssSelectorGetsMatchingElementsToBeInlinedAgain(): void
{
$subject = $this->buildDebugSubject('<html><body><p class="x"></p></body></html>');
$subject->addExcludedCssSelector('p.x');

$subject->removeExcludedCssSelector('p.x');
$subject->inlineCss('p.x { margin: 0; }');

self::assertStringContainsString('<p class="x" style="margin: 0;"></p>', $subject->renderBodyContent());
}

/**
* @test
*/
Expand Down

0 comments on commit c60ce2f

Please sign in to comment.