Skip to content

Commit

Permalink
[FEATURE] Add HtmlPruner::removeRedundantClassesAfterCssInlined
Browse files Browse the repository at this point in the history
Also added section for `HtmlPruner` to the README.

Closes #380.
  • Loading branch information
JakeQZ committed Sep 27, 2019
1 parent 18b2f1f commit 3c7f8a5
Show file tree
Hide file tree
Showing 4 changed files with 242 additions and 2 deletions.
3 changes: 3 additions & 0 deletions CHANGELOG.md
Expand Up @@ -6,6 +6,9 @@ This project adheres to [Semantic Versioning](https://semver.org/).
## x.y.z

### Added
- Add `HtmlPruner::removeRedundantClassesAfterCssInlined`
([#380](https://github.com/MyIntervals/emogrifier/issues/380),
[#724](https://github.com/MyIntervals/emogrifier/pull/724))
- Add `HtmlPruner::removeRedundantClasses`
([#380](https://github.com/MyIntervals/emogrifier/issues/380),
[#708](https://github.com/MyIntervals/emogrifier/pull/708))
Expand Down
37 changes: 35 additions & 2 deletions README.md
Expand Up @@ -106,8 +106,10 @@ use Pelago\Emogrifier\HtmlProcessor\HtmlPruner;


$domDocument = CssInliner::fromHtml($html)->inlineCss($css)->getDomDocument();
HtmlPruner::fromDomDocument($domDocument)->removeElementsWithDisplayNone();
$cssInliner = CssInliner::fromHtml($html)->inlineCss($css);
$domDocument = $cssInliner->getDomDocument();
HtmlPruner::fromDomDocument($domDocument)->removeElementsWithDisplayNone()
->removeRedundantClassesAfterCssInlined($cssInliner);
$finalHtml = CssToAttributeConverter::fromDomDocument($domDocument)
->convertCssToVisualAttributes()->render();
```
Expand Down Expand Up @@ -156,6 +158,37 @@ $visualHtml = CssToAttributeConverter::fromDomDocument($domDocument)
->convertCssToVisualAttributes()->render();
```

### Removing redundant content and attributes from the HTML

The `HtmlPruner` class can reduce the size of the HTML by removing elements with
a `display: none` style declaration, and/or removing classes from `class`
attributes that are not required.

It can be used like this:

```php
use Pelago\Emogrifier\HtmlProcessor\HtmlPruner;


$prunedHtml = HtmlPruner::fromHtml($html)->removeElementsWithDisplayNone()
->removeRedundantClasses($classesToKeep)->render();
```

The `removeRedundantClasses` method accepts a whitelist of names of classes that
should be retained. If this is a post-processing step after inlining CSS, you
can alternatively use `removeRedundantClassesAfterCssInlined`, passing it the
`CssInliner` instance that has inlined the CSS (and having the `HtmlPruner` work
on the `DOMDocument`). This will use information from the `CssInliner` to
determine which classes are still required (namely, those used in uninlinable
rules that have been copied to a `<style>` element):

```php
$prunedHtml = HtmlPruner::fromDomDocument($cssInliner->getDomDocument())
->removeElementsWithDisplayNone()
->removeRedundantClassesAfterCssInlined($cssInliner)->render();
```

### Options

There are several options that you can set on the `CssInliner` instance before
Expand Down
27 changes: 27 additions & 0 deletions src/Emogrifier/HtmlProcessor/HtmlPruner.php
Expand Up @@ -2,6 +2,7 @@

namespace Pelago\Emogrifier\HtmlProcessor;

use Pelago\Emogrifier\CssInliner;
use Pelago\Emogrifier\Utilities\ArrayIntersector;

/**
Expand Down Expand Up @@ -109,4 +110,30 @@ private function removeClassAttributeFromElements(\DOMNodeList $elements)
$element->removeAttribute('class');
}
}

/**
* After CSS has been inlined, there will likely be some classes in `class` attributes that are no longer referenced
* by any remaining (uninlinable) CSS. This method removes such classes.
*
* Note that it does not inspect the remaining CSS, but uses information readily available from the `CssInliner`
* instance about the CSS rules that could not be inlined.
*
* @param CssInliner $cssInliner object instance that performed the CSS inlining
*
* @return self fluent interface
*
* @throws \BadMethodCallException if `inlineCss` has not first been called on `$cssInliner`
*/
public function removeRedundantClassesAfterCssInlined(CssInliner $cssInliner)
{
$classesToKeepAsKeys = [];
foreach ($cssInliner->getMatchingUninlinableSelectors() as $selector) {
\preg_match_all('/\\.(-?+[_a-zA-Z][\\w\\-]*+)/', $selector, $matches);
$classesToKeepAsKeys += \array_fill_keys($matches[1], true);
}

$this->removeRedundantClasses(\array_keys($classesToKeepAsKeys));

return $this;
}
}
177 changes: 177 additions & 0 deletions tests/Unit/Emogrifier/HtmlProcessor/HtmlPrunerTest.php
Expand Up @@ -2,6 +2,7 @@

namespace Pelago\Tests\Unit\Emogrifier\HtmlProcessor;

use Pelago\Emogrifier\CssInliner;
use Pelago\Emogrifier\HtmlProcessor\AbstractHtmlProcessor;
use Pelago\Emogrifier\HtmlProcessor\HtmlPruner;
use PHPUnit\Framework\TestCase;
Expand Down Expand Up @@ -359,6 +360,182 @@ public function removeRedundantClassesMinifiesClassAttributes($html, array $clas
}
}

/**
* Builds a `CssInliner` fixture with the given HTML in a state where the given CSS has been inlined, and an
* `HtmlPruner` subject sharing the same `DOMDocument`.
*
* @param string $html
* @param string $css
*
* @return (CssInliner|HtmlPruner)[] The `CssInliner` fixture is in the `'cssInliner'` key and the `HtmlPruner`
* subject is in the `'subject'` key.
*/
private function buildSubjectAndCssInlinerWithCssInlined($html, $css)
{
$cssInliner = CssInliner::fromHtml($html);
$cssInliner->inlineCss($css);

$subject = HtmlPruner::fromDomDocument($cssInliner->getDomDocument());

return \compact('subject', 'cssInliner');
}

/**
* @test
*/
public function removeRedundantClassesAfterCssInlinedProvidesFluentInterface()
{
\extract($this->buildSubjectAndCssInlinerWithCssInlined('<html></html>', ''));

$result = $subject->removeRedundantClassesAfterCssInlined($cssInliner);

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

/**
* @test
*/
public function removeRedundantClassesAfterCssInlinedThrowsExceptionIfInlineCssNotCalled()
{
$this->expectException(\BadMethodCallException::class);

$cssInliner = CssInliner::fromHtml('<html></html>');
$subject = HtmlPruner::fromDomDocument($cssInliner->getDomDocument());

$subject->removeRedundantClassesAfterCssInlined($cssInliner);
}

/**
* @return (string|string[])[][]
*/
public function provideClassesNotInUninlinableRules()
{
return [
'inlinable rule with different class' => [
'HTML' => '<p class="foo">hello</p>',
'CSS' => '.bar { color: red; }',
'classes expected to be removed' => ['foo'],
],
'uninlinable rule with different class' => [
'HTML' => '<p class="foo">hello</p>',
'CSS' => '.bar:hover { color: red; }',
'classes expected to be removed' => ['foo'],
],
'inlinable rule with matching class' => [
'HTML' => '<p class="foo">hello</p>',
'CSS' => '.foo { color: red; }',
'classes expected to be removed' => ['foo'],
],
'2 instances of class to be removed' => [
'HTML' => '<p class="foo">hello</p><p class="foo">world</p>',
'CSS' => '.foo { color: red; }',
'classes expected to be removed' => ['foo'],
],
'2 different classes to be removed' => [
'HTML' => '<p class="foo">hello</p><p class="bar">world</p>',
'CSS' => '.foo { color: red; }',
'classes expected to be removed' => ['foo', 'bar'],
],
'2 different classes, 1 in inlinable rule, 1 in uninlinable rule' => [
'HTML' => '<p class="foo bar">hello</p>',
'CSS' => '.foo { color: red; } .bar:hover { color: green; }',
'classes expected to be removed' => ['foo'],
],
'class with hyphen, underscore, uppercase letter and number in name' => [
'HTML' => '<p class="foo-2_A">hello</p>',
'CSS' => '.foo-2_A { color: red; }',
'classes expected to be removed' => ['foo-2_A'],
],
];
}

/**
* @test
*
* @param string $html
* @param string $css
* @param string[] $classesExpectedToBeRemoved
*
* @dataProvider provideClassesNotInUninlinableRules
*/
public function removeRedundantClassesAfterCssInlinedRemovesClassesNotInUninlinableRules(
$html,
$css,
array $classesExpectedToBeRemoved = []
) {
\extract($this->buildSubjectAndCssInlinerWithCssInlined('<html>' . $html . '</html>', $css));

$subject->removeRedundantClassesAfterCssInlined($cssInliner);

$result = $subject->render();
foreach ($classesExpectedToBeRemoved as $class) {
self::assertNotContains($class, $result);
}
}

/**
* @return (string|string[])[][]
*/
public function provideClassesInUninlinableRules()
{
return [
'media rule' => [
'HTML' => '<p class="foo">hello</p>',
'CSS' => '@media (max-width: 640px) { .foo { color: green; } }',
'classes to be kept' => ['foo'],
],
'dynamic pseudo-class' => [
'HTML' => '<p class="foo">hello</p>',
'CSS' => '.foo:hover { color: green; }',
'classes to be kept' => ['foo'],
],
'2 classes, in different uninlinable rules' => [
'HTML' => '<p class="foo bar">hello</p>',
'CSS' => '.foo:hover { color: green; } @media (max-width: 640px) { .bar { color: green; } }',
'classes to be kept' => ['foo', 'bar'],
],
'1 class in uninlinable rule, 1 in inlinable rule' => [
'HTML' => '<p class="foo bar">hello</p>',
'CSS' => '.foo { color: red; } .bar:hover { color: green; }',
'classes to be kept' => ['bar'],
],
'2 classes in same selector' => [
'HTML' => '<p class="foo bar">hello</p>',
'CSS' => '.foo.bar:hover { color: green; }',
'classes to be kept' => ['foo', 'bar'],
],
'class with hyphen, underscore, uppercase letter and number in name' => [
'HTML' => '<p class="foo-2_A">hello</p>',
'CSS' => '.foo-2_A:hover { color: green; }',
'classes to be kept' => ['foo-2_A'],
],
];
}

/**
* @test
*
* @param string $html
* @param string $css
* @param string[] $classesToBeKept
*
* @dataProvider provideClassesInUninlinableRules
*/
public function removeRedundantClassesAfterCssInlinedNotRemovesClassesInUninlinableRules(
$html,
$css,
array $classesToBeKept = []
) {
\extract($this->buildSubjectAndCssInlinerWithCssInlined('<html>' . $html . '</html>', $css));

$subject->removeRedundantClassesAfterCssInlined($cssInliner);

$result = $subject->render();
foreach ($classesToBeKept as $class) {
self::assertContains($class, $result);
}
}

/**
* Asserts that the number of occurrences of `$needle` within the string `$haystack` is as expected.
*
Expand Down

0 comments on commit 3c7f8a5

Please sign in to comment.