Skip to content

Commit

Permalink
[FEATURE] Support CSS custom properties
Browse files Browse the repository at this point in the history
Add a new `HtmlProcessor` class that can evaluate CSS custom properties
(variables) after CSS which uses them has been inlined.

Resolves #1276, though an additional feature to remove unused variable
definitions after evaluation could be provided.
  • Loading branch information
JakeQZ committed Sep 23, 2024
1 parent 642d2c7 commit 9a010a3
Show file tree
Hide file tree
Showing 5 changed files with 649 additions and 3 deletions.
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ Please also have a look at our

### Added

- Support CSS custom properties (variables) (#1336)
- Support `:root` pseudo-class (#1306)
- Add CSS selectors exclusion feature (#1236)

Expand Down
55 changes: 53 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,56 @@ $visualHtml = CssToAttributeConverter::fromDomDocument($domDocument)
->convertCssToVisualAttributes()->render();
```

### Evaluating CSS custom properties (variables)

The `CssVariableEvaluator` class can be used to apply the values of CSS
variables which are defined in inline style attributes to inline style
properties which use them.

For example, the following CSS defines and uses a custom property:

```css
:root {
--text-color: green;
}
p {
color: var(--text-color);
}
```

After `CssInliner` has inlined that CSS on the (contrived) HTML
`<html><body><p></p></body></html>`, it will look like this:

```html
<html style="--text-color: green;">
<body>
<p style="color: var(--text-color);">
<p>
</body>
</htm>
```

The `CssVariableEvaluator` method `evaluateVariables` will apply the value of
`--text-color` so that the paragraph `style` attribute becomes `color: green`.

It can be used like this:

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


$evaluatedHtml = CssVariableEvaluator::fromHtml($html)
->evaluateVariables ->render();
```

You can also have the ` CssVariableEvaluator ` work on a `DOMDocument`:

```php
$evaluatedHtml = CssVariableEvaluator::fromDomDocument($domDocument)
->evaluateVariables ()->render();
```

### Removing redundant content and attributes from the HTML

The `HtmlPruner` class can reduce the size of the HTML by removing elements with
Expand Down Expand Up @@ -396,11 +446,12 @@ They will, however, be preserved and copied to a `<style>` element in the HTML:
}
}
```
Any CSS variables defined in `@media` rules will not be applied to CSS
property values that have been inlined and evaluated.
* Emogrifier cannot inline CSS rules involving selectors with pseudo-elements
(such as `::after`) or dynamic pseudo-classes (such as `:hover`) – it is
impossible. However, such rules will be preserved and copied to a `<style>`
element, as for `@media` rules. The same caveat about the possible need for
the `!important` directive also applies with pseudo-classes.
element, as for `@media` rules, with the same caveats applying.
* Emogrifier will grab existing inline style attributes _and_ will
grab `<style>` blocks from your HTML, but it will not grab CSS files
referenced in `<link>` elements or `@import` rules (though it will leave them
Expand Down
7 changes: 6 additions & 1 deletion config/phpmd.xml
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,12 @@
<!-- The commented-out rules will be enabled once the code does not generate any warnings anymore. -->

<rule ref="rulesets/cleancode.xml/BooleanArgumentFlag"/>
<rule ref="rulesets/cleancode.xml/StaticAccess"/>
<rule ref="rulesets/cleancode.xml/StaticAccess">
<properties>
<!-- Avoid false positives when calling factory methods -->
<property name="ignorepattern" value="/^from/" />
</properties>
</rule>

<rule ref="rulesets/codesize.xml/CyclomaticComplexity"/>
<rule ref="rulesets/codesize.xml/NPathComplexity"/>
Expand Down
195 changes: 195 additions & 0 deletions src/HtmlProcessor/CssVariableEvaluator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,195 @@
<?php

declare(strict_types=1);

namespace Pelago\Emogrifier\HtmlProcessor;

use Pelago\Emogrifier\Utilities\DeclarationBlockParser;
use Pelago\Emogrifier\Utilities\Preg;

/**
* This class can evaluate CSS custom properties that are defined and used in inline style attributes.
*/
class CssVariableEvaluator extends AbstractHtmlProcessor
{
/**
* temporary collection used by {@see replaceVariablesInDeclarations} and callee methods
*
* @var array<non-empty-string, string>
*/
private $currentVariableDefinitions = [];

/**
* Replaces all CSS custom property references in inline style attributes with their corresponding values where
* defined in inline style attributes (either from the element itself or the nearest ancestor).
*
* @throws \UnexpectedValueException
*
* @return $this
*/
public function evaluateVariables(): self
{
return $this->evaluateVaraiblesInElementAndDescendants($this->getHtmlElement(), []);
}

/**
* @param array<non-empty-string, string> $declarations
*
* @return array<non-empty-string, string>
*/
private function getVaraibleDefinitionsFromDeclarations(array $declarations): array
{
return \array_filter(
$declarations,
static function (string $key): bool {
return \substr($key, 0, 2) === '--';
},
ARRAY_FILTER_USE_KEY
);
}

/**
* Callback function for {@see replaceVariablesInPropertyValue} performing regular expression replacement.
*
* @param array<int, string> $matches
*/
private function getPropertyValueReplacement(array $matches): string
{
$variableName = $matches[1];
if (isset($this->currentVariableDefinitions[$variableName])) {
return $this->currentVariableDefinitions[$variableName];
} else {
$fallbackValueSeparator = $matches[2] ?? '';
if ($fallbackValueSeparator !== '') {
$fallbackValue = $matches[3];
// The fallback value may use other CSS variables, so recurse
return $this->replaceVariablesInPropertyValue($fallbackValue);
} else {
return $matches[0];
}
}
}

/**
* Regular expression based on {@see https://stackoverflow.com/a/54143883/2511031 a StackOverflow answer}.
*/
private function replaceVariablesInPropertyValue(string $propertyValue): string
{
return (new Preg())->replaceCallback(
'/
var\\(
\\s*+
# capture variable name including `--` prefix
(
--[^\\s\\),]++
)
\\s*+
# capture optional fallback value
(?:
# capture separator to confirm there is a fallback value
(,)\\s*
# begin capture with named group that can be used recursively
(?<recursable>
# begin named group to match sequence without parentheses, except in strings
(?<noparentheses>
# repeated zero or more times:
(?:
# sequence without parentheses or quotes
[^\\(\\)\'"]++
|
# string in double quotes
"(?>[^"\\\\]++|\\\\.)*"
|
# string in single quotes
\'(?>[^\'\\\\]++|\\\\.)*\'
)*+
)
# repeated zero or more times:
(?:
# sequence in parentheses
\\(
# using the named recursable pattern
(?&recursable)
\\)
# sequence without parentheses, except in strings
(?&noparentheses)
)*+
)
)?+
\\)
/x',
\Closure::fromCallable([$this, 'getPropertyValueReplacement']),
$propertyValue
);
}

/**
* @param array<non-empty-string, string> $declarations
*
* @return array<non-empty-string, string>|false `false` is returned if no substitutions were made.
*/
private function replaceVariablesInDeclarations(array $declarations)
{
$substitutionsMade = false;
$result = \array_map(
function (string $propertyValue) use (&$substitutionsMade): string {
$newPropertyValue = $this->replaceVariablesInPropertyValue($propertyValue);
if ($newPropertyValue !== $propertyValue) {
$substitutionsMade = true;
}
return $newPropertyValue;
},
$declarations
);

return $substitutionsMade ? $result : false;
}

/**
* @param array<non-empty-string, string> $declarations;
*/
private function getDeclarationsAsString(array $declarations): string
{
$declarationStrings = \array_map(
static function (string $key, string $value): string {
return $key . ': ' . $value;
},
\array_keys($declarations),
\array_values($declarations)
);

return \implode('; ', $declarationStrings) . ';';
}

/**
* @param array<non-empty-string, string> $ancestorVariableDefinitions
*
* @return $this
*/
private function evaluateVaraiblesInElementAndDescendants(
\DOMElement $element,
array $ancestorVariableDefinitions
): self {
$style = $element->getAttribute('style');

// Avoid parsing declarations if none use or define a variable
if ((new Preg())->match('/(?<![\\w\\-])--[\\w\\-]/', $style) !== 0) {
$declarations = (new DeclarationBlockParser())->parse($style);
$variableDefinitions = $this->currentVariableDefinitions
= $this->getVaraibleDefinitionsFromDeclarations($declarations) + $ancestorVariableDefinitions;

$newDeclarations = $this->replaceVariablesInDeclarations($declarations);
if ($newDeclarations !== false) {
$element->setAttribute('style', $this->getDeclarationsAsString($newDeclarations));
}
} else {
$variableDefinitions = $ancestorVariableDefinitions;
}

for ($child = $element->firstElementChild; $child !== null; $child = $child->nextElementSibling) {
$this->evaluateVaraiblesInElementAndDescendants($child, $variableDefinitions);
}

return $this;
}
}
Loading

0 comments on commit 9a010a3

Please sign in to comment.