Repository: Automattic/juice (js)

Issue:
Error when encoded quotes are used in html attribute
I have some HTML which is a result of other processing and already contains some inline styles. I'm then using juice to inline some global CSS, as the output must be used as an email content. I'm using Google Fonts loaded by my CMS, some of them are quoted - produced CSS looks like this: `font-family: "One Sans", sans-serif;` which is a valid CSS. When inlined into a div style attribute, it gets encoded:
```html
<div style="font-family:&quot;Open Sans&quot;, sans-serif;"></div>
```
This causes error in Juice, as it does not decode the HTML entities and instead tries to parse it as css rules. Here are the objects from juice parsing:
```bash
{
  prop: Property {
    prop: 'font-family',
    value: '&quot',
    selector: Selector {
      text: '<style>',
      spec: [Array],
      styleAttribute: true,
      tokens: [Expression]
    },
    priority: 0,
    additionalPriority: [ 1, 5 ]
  }
}
{
  prop: Property {
    prop: undefined,
    value: 'OpenSans&quot',
    selector: Selector {
      text: '<style>',
      spec: [Array],
      styleAttribute: true,
      tokens: [Expression]
    },
    priority: 0,
    additionalPriority: [ 1, 23 ]
  }
}
```
And it fails with the error: `Cannot read properties of undefined (reading 'indexOf')`.
![2p84jCbJ3V](https://github.com/user-attachments/assets/f8a60637-1848-4085-bcb5-955117984c13)

## Minimal reproduction
Just create an `input.html` file and paste this code:
```html
<!DOCTYPE html>
<html lang="en">
	<head>
		<style>
			div {
				background-color: #fff;
			}
		</style>
	</head>
	<body>
		<div style="font-family:&quot;Open Sans&quot;, sans-serif;"></div>
	</body>
</html>
```
Then run:
```shell
npm run juice input.html output.html
```
And see the error.

## Solution
Juice should decode HTML entities after reading an attribute value and before parsing it to CSS rules. This way it could convert `&quot;` (and other such strings) back to the proper double-quote character (`"`) so that it woudn't contain any semi-colons and could be properly parsed into CSS.

Pull request description:
Fix #482: "Error when encoded quotes are used in html attribute"
Fix #482 

This PR adds an option to decode HTML entities in the style attribute before it is handed off to mensch for parsing. The problem is that although HTML entities are valid in `style` attribute values, they are not valid in CSS strings, resulting in a mensch parse that contains invalid properties/values, and ultimately causes juice to fail.  

Patch:
diff --git a/README.md b/README.md
index 81e500c..b266ff5 100644
--- a/README.md
+++ b/README.md
@@ -46,6 +46,8 @@ All juice methods take an options object that can contain any of these propertie
 
 * `applyWidthAttributes` - whether to use any CSS pixel widths to create `width` attributes on elements set in `juice.widthElements`. Defaults to `true`.
 
+* `decodeStyleAttributes` - whether to decode the value of `style=` attributes. Defaults to `false`.
+
 * `extraCss` - extra css to apply to the file. Defaults to `""`.
 
 * `insertPreservedExtraCss` - whether to insert into the document any preserved `@media` or `@font-face` content from `extraCss` when using `preserveMediaQueries`, `preserveFontFaces` or `preserveKeyFrames`. When `true` order of preference to append the `<style>` element is into `head`, then `body`, then at the end of the document. When a `string` the value is treated as a CSS/jQuery/cheerio selector, and when found, the `<style>` tag will be appended to the end of the first match. Defaults to `true`.
diff --git a/juice.d.ts b/juice.d.ts
index 4900106..0e633f1 100644
--- a/juice.d.ts
+++ b/juice.d.ts
@@ -50,6 +50,7 @@ declare namespace juice {
     xmlMode?: boolean;
     preserveImportant?: boolean;
     resolveCSSVariables?: boolean;
+    decodeStyleAttributes?: boolean;
   }
 
   interface WebResourcesOptions {
diff --git a/lib/cli.js b/lib/cli.js
index bfb2110..38d3130 100644
--- a/lib/cli.js
+++ b/lib/cli.js
@@ -118,6 +118,10 @@ cli.options = {
     pMap: 'webResourcesStrict',
     map: 'strict',
     def: 'see docs for web-resource-inliner strict',
+    coercion: JSON.parse },
+  'decode-style-attributes': {
+    pMap: 'decodeStyleAttributes',
+    def: 'decode the value of `style=` attributes?',
     coercion: JSON.parse }
 };
 
diff --git a/lib/inline.js b/lib/inline.js
index 0b2ad26..9a743fd 100644
--- a/lib/inline.js
+++ b/lib/inline.js
@@ -1,5 +1,7 @@
 'use strict';
 
+var { decode } = require('entities');
+
 var utils = require('./utils');
 var numbers = require('./numbers');
 var variables = require('./variables');
@@ -149,7 +151,11 @@ function inlineDocument($, css, options) {
 
         // if the element has inline styles, fake selector with topmost specificity
         if ($(el).attr(styleAttributeName)) {
-          var cssText = '* { ' + $(el).attr(styleAttributeName) + ' } ';
+          var styleAttributeValue = $(el).attr(styleAttributeName);
+          var cssStyleAttributeValue = options.decodeStyleAttributes
+            ? decode(styleAttributeValue)
+            : styleAttributeValue;
+          var cssText = '* { ' + cssStyleAttributeValue + ' } ';
           addProps(utils.parseCSS(cssText)[0][1], new utils.Selector('<style>', true));
         }
 
diff --git a/package-lock.json b/package-lock.json
index a8a3905..f00cf03 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -11,6 +11,7 @@
       "dependencies": {
         "cheerio": "^1.0.0",
         "commander": "^12.1.0",
+        "entities": "^4.5.0",
         "mensch": "^0.3.4",
         "slick": "^1.12.2",
         "web-resource-inliner": "^7.0.0"
@@ -1151,7 +1152,6 @@
       "version": "4.5.0",
       "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz",
       "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==",
-      "license": "BSD-2-Clause",
       "engines": {
         "node": ">=0.12"
       },
diff --git a/package.json b/package.json
index 5b9d2d8..cce86c5 100644
--- a/package.json
+++ b/package.json
@@ -37,6 +37,7 @@
   "dependencies": {
     "cheerio": "^1.0.0",
     "commander": "^12.1.0",
+    "entities": "^4.5.0",
     "mensch": "^0.3.4",
     "slick": "^1.12.2",
     "web-resource-inliner": "^7.0.0"


Test patch:
diff --git a/test/cli.js b/test/cli.js
index decdbcb..f224513 100644
--- a/test/cli.js
+++ b/test/cli.js
@@ -27,6 +27,7 @@ it('cli parses options', function(done) {
   assert.strictEqual(parseArgs({'applyAttributesTableElements': 'true'}).applyAttributesTableElements, true);
   assert.strictEqual(parseArgs({'xmlMode': 'true'}).xmlMode, true);
   assert.strictEqual(parseArgs({'resolveCSSVariables': 'true'}).resolveCSSVariables, true);
+  assert.strictEqual(parseArgs({'decodeStyleAttributes': 'true'}).decodeStyleAttributes, true);
   assert.strictEqual(parseArgs({'webResourcesInlineAttribute': 'true'}).webResources.inlineAttribute, true);
   assert.strictEqual(parseArgs({'webResourcesImages': '12'}).webResources.images, 12);
   assert.strictEqual(parseArgs({'webResourcesLinks': 'true'}).webResources.links, true);
diff --git a/test/juice.test.js b/test/juice.test.js
index a8ebc9f..c7eb465 100644
--- a/test/juice.test.js
+++ b/test/juice.test.js
@@ -192,3 +192,21 @@ it('test that preserved text order is stable', function() {
       utils.getPreservedText('div { color: red; } @media (min-width: 320px) { div { color: green; } } @media (max-width: 640px) { div { color: blue; } }', { mediaQueries: true }, juice.ignoredPseudos).replace(/\s+/g, ' '),
       ' @media (min-width: 320px) { div { color: green; } } @media (max-width: 640px) { div { color: blue; } } ');
 });
+
+it('can handle style attributes with html entities', function () {
+  // Throws without decodeStyleAttributes: true
+  assert.throws(function () {
+    juice(
+      '<style type="text/css">div {color: red;}</style><div style="font-family:&quot;Open Sans&quot;, sans-serif;"></div>'
+    );
+  });
+
+  // Expected results with decodeStyleAttributes: true
+  assert.deepEqual(
+    juice(
+      '<style type="text/css">div {color: red;}</style><div style="font-family:&quot;Open Sans&quot;, sans-serif;"></div>',
+      { decodeStyleAttributes: true }
+    ),
+    "<div style=\"color: red; font-family: 'Open Sans', sans-serif;\"></div>"
+  );
+});