Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[FEATURE] Support CSS custom properties #1336

Merged
merged 6 commits into from
Sep 25, 2024
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
60 changes: 58 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,58 @@ $visualHtml = CssToAttributeConverter::fromDomDocument($domDocument)
->convertCssToVisualAttributes()->render();
```

### Evaluating CSS custom properties (variables)

The `CssVariableEvaluator` class can be used to apply the values of CSS
variables 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 +448,15 @@ They will, however, be preserved and copied to a `<style>` element in the HTML:
}
}
```
Any CSS custom properties (variables) defined in `@media` rules cannot be
applied to CSS property values that have been inlined and evaluated. However,
`@media` rules using custom properties (with `var()`) would still be able to
obtain their values (from the inlined definitions or `@media` rules) in email
clients that support custom properties.
* 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
197 changes: 197 additions & 0 deletions src/HtmlProcessor/CssVariableEvaluator.php
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
<?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 {
oliverklee marked this conversation as resolved.
Show resolved Hide resolved
$fallbackValueSeparator = $matches[2] ?? '';
if ($fallbackValueSeparator !== '') {
$fallbackValue = $matches[3];
// The fallback value may use other CSS variables, so recurse
return $this->replaceVariablesInPropertyValue($fallbackValue);
} else {
oliverklee marked this conversation as resolved.
Show resolved Hide resolved
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)
)*+
)
)?+
\\)
oliverklee marked this conversation as resolved.
Show resolved Hide resolved
/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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would it make sense to return null instead of false? Then we can make this a nullable type instead of a union type.

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, changed.

*/
private function replaceVariablesInDeclarations(array $declarations)
{
$substitutionsMade = false;
$result = \array_map(
function (string $propertyValue) use (&$substitutionsMade): string {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Using all these used array functions feels to me like we maybe could use another class structure to have more type safety and to make the code more communicative. What do you think?

Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Not sure what you mean or are suggesting. The method performs a transformation on each element of an array, for which array_map does the job. The reason to keep track of whether this transformation made any changes is an optimization. PHP's copy-on-write handling of arrays helps, but if a change is made, comparing $newArray !== $oldArray means checking each element (until the difference is found). (If the array is unmodified, the test is instant, since internally they'll still be referencing the same array.)

$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(
oliverklee marked this conversation as resolved.
Show resolved Hide resolved
\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;
}

foreach ($element->childNodes as $child) {
if ($child instanceof \DOMElement) {
$this->evaluateVaraiblesInElementAndDescendants($child, $variableDefinitions);
}
}

return $this;
}
}
Loading