Repository: Automattic/juice
Pull request #473 (2023-10-19 19:39:05)

Resolve CSS variables
Resolves 
```html
<style>
:root{
--color:blue;
}
p{
 color:var(--color);
}
</style>
<p>woot</p>
```
to
```html
<p style="color: blue;">woot</p>
```
See also #312


Patch:
diff --git a/README.md b/README.md
index 7269491..81e500c 100644
--- a/README.md
+++ b/README.md
@@ -64,6 +64,8 @@ All juice methods take an options object that can contain any of these propertie
 
 * `removeStyleTags` - whether to remove the original `<style></style>` tags after (possibly) inlining the css from them. Defaults to `true`.
 
+* `resolveCSSVariables` - whether to resolve CSS variables. Defaults to `true`.
+
 * `webResources` - An options object that will be passed to [web-resource-inliner](https://www.npmjs.com/package/web-resource-inliner) for juice functions that will get remote resources (`juiceResources` and `juiceFile`). Defaults to `{}`.
 
 * `xmlMode` - whether to output XML/XHTML with all tags closed. Note that the input *must* also be valid XML/XHTML or you will get undesirable results. Defaults to `false`.
diff --git a/juice.d.ts b/juice.d.ts
index 618ef21..4900106 100644
--- a/juice.d.ts
+++ b/juice.d.ts
@@ -49,6 +49,7 @@ declare namespace juice {
     inlinePseudoElements?: boolean;
     xmlMode?: boolean;
     preserveImportant?: boolean;
+    resolveCSSVariables?: boolean;
   }
 
   interface WebResourcesOptions {
diff --git a/lib/cli.js b/lib/cli.js
index d71fae7..89b9377 100644
--- a/lib/cli.js
+++ b/lib/cli.js
@@ -81,6 +81,10 @@ cli.options = {
     pMap: 'xmlMode',
     def: 'generate output with tags closed?  input must be valid XML',
     coercion: JSON.parse },
+  'resolve-css-variables': {
+    pMap: 'resolveCSSVariables',
+    def: 'resolve CSS variables',
+    coercion: JSON.parse },
   'web-resources-inline-attribute': {
     pMap: 'webResourcesInlineAttribute',
     map: 'inlineAttribute',
diff --git a/lib/inline.js b/lib/inline.js
index 475a62e..a271591 100644
--- a/lib/inline.js
+++ b/lib/inline.js
@@ -2,6 +2,7 @@
 
 var utils = require('./utils');
 var numbers = require('./numbers');
+var variables = require('./variables');
 
 module.exports = function makeJuiceClient(juiceClient) {
 
@@ -250,13 +251,23 @@ function inlineDocument($, css, options) {
     props.sort(function(a, b) {
       return a.compareFunc(b);
     });
+    	
     var string = props
       .filter(function(prop) {
+
+        // don't add css variables if we're resolving their values
+        if (options.resolveCSSVariables && (prop.prop.indexOf('--') === 0) ) {
+          return false;
+        }
+        
         // Content becomes the innerHTML of pseudo elements, not used as a
         // style property
-        return prop.prop !== 'content';
+        return (prop.prop !== 'content');
       })
       .map(function(prop) {
+        if (options.resolveCSSVariables) {
+          prop.value = variables.replaceVariables(el,prop.value);
+        }
         return prop.prop + ': ' + prop.value.replace(/["]/g, '\'') + ';';
       })
       .join(' ');
@@ -343,15 +354,7 @@ function removeImportant(value) {
   return value.replace(/\s*!important$/, '')
 }
 
-function findVariableValue(el, variable) {
-  while (el) {
-    if (variable in el.styleProps) {
-      return el.styleProps[variable].value;
-    }
 
-    var el = el.pseudoElementParent || el.parent;
-  }
-}
 
 function applyCounterStyle(counter, style) {
   switch (style) {
@@ -392,7 +395,7 @@ function parseContent(el) {
 
     var varMatch = tokens[i].match(/var\s*\(\s*(.*?)\s*(,\s*(.*?)\s*)?\s*\)/i);
     if (varMatch) {
-      var variable = findVariableValue(el, varMatch[1]) || varMatch[2];
+      var variable = variables.findVariableValue(el, varMatch[1]) || varMatch[2];
       parsed.push(variable.replace(/^['"]|['"]$/g, ''));
       continue;
     }
diff --git a/lib/utils.js b/lib/utils.js
index 2f8c32c..f5cca30 100644
--- a/lib/utils.js
+++ b/lib/utils.js
@@ -44,7 +44,6 @@ exports.extract = function extract(selectorText) {
   if (sel.length) {
     sels.push(sel);
   }
-
   return sels;
 };
 
@@ -158,6 +157,7 @@ exports.getDefaultOptions = function(options) {
     applyWidthAttributes: true,
     applyHeightAttributes: true,
     applyAttributesTableElements: true,
+    resolveCSSVariables: true,
     url: ''
   }, options);
 
diff --git a/lib/variables.js b/lib/variables.js
new file mode 100644
index 0000000..554c504
--- /dev/null
+++ b/lib/variables.js
@@ -0,0 +1,70 @@
+'use strict';
+
+const uniqueString = (string) => {
+  let str = '';
+  do{
+    str = (Math.random() + 1).toString(36).substring(2);
+
+  }while(string.indexOf(str) !== -1);
+
+  return str;
+}
+
+/**
+ * Replace css variables with their value
+ */
+const replaceVariables = (el,value) => {
+
+  // find non-nested css function calls
+  // eg: rgb(...), drop-shadow(...)
+	let funcReg = /([a-z\-]+)\s*\(\s*([^\(\)]*?)\s*(?:,\s*([^\(\)]*?)\s*)?\s*\)/i;
+  let replacements = [];
+  let match;
+  let uniq = uniqueString(value);
+
+	while( (match = funcReg.exec(value)) !== null ){
+    let i = `${replacements.length}`;
+
+
+    // attempt to resolve variables
+    if( match[1].toLowerCase() == 'var' ){
+      const varValue = findVariableValue(el, match[2]);
+      
+      // found variable value
+      if( varValue ){
+        value = value.replace(match[0],varValue);
+        continue;
+      }
+
+      // use default value
+      // var(--name , default-value)
+      if( match[3] ){
+        value = value.replace(match[0],match[3]);
+        continue;
+      }
+    }
+    
+    let placeholder = `${uniq}${i.padStart(5,'-')}`;
+    value = value.replace(match[0],placeholder);
+    replacements.push({placeholder,replace:match[0]});
+  }
+
+  for( var i = replacements.length-1; i >=0; i--){
+    const replacement = replacements[i];
+		value = value.replace(replacement.placeholder,replacement.replace);
+  }
+  
+  return value;
+}
+
+const findVariableValue = (el, variable) => {
+  while (el) {
+    if (el.styleProps && variable in el.styleProps) {
+      return el.styleProps[variable].value;
+    }
+
+    var el = el.pseudoElementParent || el.parent;
+  }
+}
+
+module.exports = { replaceVariables, findVariableValue };
\ No newline at end of file
diff --git a/package-lock.json b/package-lock.json
index 0c6982a..a7adec0 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,12 +1,12 @@
 {
   "name": "juice",
-  "version": "9.0.0",
+  "version": "9.1.0",
   "lockfileVersion": 2,
   "requires": true,
   "packages": {
     "": {
       "name": "juice",
-      "version": "9.0.0",
+      "version": "9.1.0",
       "license": "MIT",
       "dependencies": {
         "cheerio": "^1.0.0-rc.12",


Test patch:
diff --git a/test/cases/css-variables.css b/test/cases/css-variables.css
new file mode 100644
index 0000000..fdc273f
--- /dev/null
+++ b/test/cases/css-variables.css
@@ -0,0 +1,15 @@
+:root{
+	--border-width:1px;
+	--border-color:red;
+	--border-style-default:solid;
+}
+p {
+  --border-width:2px;
+  --border-style:var(--border-style-default);
+  border: VAR(--border-width) var(--border-style) var(--border-color);
+  background: var(--bg);
+}
+
+td{
+  background-color: var(--bg,var(--border-color,rgb(255,0,0)));
+}
diff --git a/test/cases/css-variables.html b/test/cases/css-variables.html
new file mode 100644
index 0000000..b68348c
--- /dev/null
+++ b/test/cases/css-variables.html
@@ -0,0 +1,6 @@
+
+<html>
+<body>
+<p>woot</p>
+<td>woot</td>
+</body></html>
diff --git a/test/cases/css-variables.json b/test/cases/css-variables.json
new file mode 100644
index 0000000..9e47e6f
--- /dev/null
+++ b/test/cases/css-variables.json
@@ -0,0 +1,4 @@
+{
+  "resolveCSSVariables": true,
+  "applyAttributesTableElements": true
+}
\ No newline at end of file
diff --git a/test/cases/css-variables.out b/test/cases/css-variables.out
new file mode 100644
index 0000000..3c2326c
--- /dev/null
+++ b/test/cases/css-variables.out
@@ -0,0 +1,5 @@
+<html>
+<body>
+<p style="border: 2px solid red; background: var(--bg);">woot</p>
+<td style="background-color: red;" bgcolor="red">woot</td>
+</body></html>
diff --git a/test/cases/juice-content/pseudo-elements.json b/test/cases/juice-content/pseudo-elements.json
index 1120fc0..0678b26 100644
--- a/test/cases/juice-content/pseudo-elements.json
+++ b/test/cases/juice-content/pseudo-elements.json
@@ -1,5 +1,6 @@
 {
   "url": "./",
   "removeStyleTags": true,
-  "inlinePseudoElements": true
+  "inlinePseudoElements": true,
+  "resolveCSSVariables": false
 }
diff --git a/test/cli.js b/test/cli.js
index 2b7bbfd..046a0f7 100644
--- a/test/cli.js
+++ b/test/cli.js
@@ -26,6 +26,7 @@ it('cli parses options', function(done) {
   assert.strictEqual(cli.argsToOptions({'applyHeightAttributes': 'true'}).applyHeightAttributes, true);
   assert.strictEqual(cli.argsToOptions({'applyAttributesTableElements': 'true'}).applyAttributesTableElements, true);
   assert.strictEqual(cli.argsToOptions({'xmlMode': 'true'}).xmlMode, true);
+  assert.strictEqual(cli.argsToOptions({'resolveCSSVariables': 'true'}).resolveCSSVariables, true);
   assert.strictEqual(cli.argsToOptions({'webResourcesInlineAttribute': 'true'}).webResources.inlineAttribute, true);
   assert.strictEqual(cli.argsToOptions({'webResourcesImages': '12'}).webResources.images, 12);
   assert.strictEqual(cli.argsToOptions({'webResourcesLinks': 'true'}).webResources.links, true);
diff --git a/test/run.js b/test/run.js
index 06d23ae..7d1063b 100644
--- a/test/run.js
+++ b/test/run.js
@@ -47,16 +47,18 @@ optionFiles.forEach(function(file) {
 });
 
 function read(file) {
-  return fs.readFileSync(file, 'utf8');
+  try{
+    return fs.readFileSync(file, 'utf8');
+  }catch(err){}
 }
 
 function test(testName, options) {
   var base = __dirname + '/cases/' + testName;
   var html =  read(base + '.html');
   var css = read(base + '.css');
-  var config = options ? JSON.parse(read(base + '.json')) : null;
+  var config = read(base + '.json');
+  config = config ? JSON.parse(config) : null;
 
-  options = {};
 
   return function(done) {
     var onJuiced = function(err, actual) {
@@ -68,8 +70,8 @@ function test(testName, options) {
       done();
     };
 
-    if (config === null) {
-      onJuiced(null, juice.inlineContent(html, css, options));
+    if( !options ) {
+      onJuiced(null, juice.inlineContent(html, css, config));
     } else {
       juice.juiceResources(html, config, onJuiced);
     }