Repository: svg/svgo
Pull request #640 (2017-01-09 11:05:37)

Remove unused styles based on usage data
It's initial implementation of `removeUnusedStyles` plugin ([feature request](https://twitter.com/SaraSoueidan/status/817820467067293696)). Plugin collects usage data and removes styles from `<style>` having selectors that contain id, tag or class name never used in document. [CSSO usage data](https://github.com/css/csso#usage-data) filtering feature is using.

Plugin can be disabled through settings:

```json
{
    "removeUnusedStyles":false
}
```

or disable a part of filtering

```json
{
    "removeUnusedStyles": {
        "ids": false,
        "tags": false,
        "classes": true
    }
}
```

Issues for discussion:
- should this plugin to be merged with `minifyStyles` or separate plugin is better?
- default settings:
  - should be active by default?
  - should we enable or disable (like now) filter options
- should plugin detects dangerous situations when filtering might to be unsafe and warn about it (i.e. `<script>` elements or `on*` attributes in document)?
- naming


Patch:
diff --git a/plugins/minifyStyles.js b/plugins/minifyStyles.js
index 3977b36..d240900 100755
--- a/plugins/minifyStyles.js
+++ b/plugins/minifyStyles.js
@@ -1,45 +1,160 @@
 'use strict';
 
-exports.type = 'perItem';
+exports.type = 'full';
 
 exports.active = true;
 
+exports.description = 'minifies styles and removes unused styles based on usage data';
+
 exports.params = {
-    svgo: {}
-};
+    // ... CSSO options goes here
 
-exports.description = 'minifies existing styles in svg';
+    // additional 
+    usage: {
+        force: false,  // force to use usage data even if it unsafe (document contains <script> or on* attributes)
+        ids: true,
+        classes: true,
+        tags: true
+    }
+};
 
 var csso = require('csso');
 
 /**
- * Minifies styles (<style> element + style attribute) using svgo
- *
- * @param {Object} item current iteration item
- * @return {Boolean} if false, item will be filtered out
+ * Minifies styles (<style> element + style attribute) using CSSO
  *
  * @author strarsis <strarsis@gmail.com>
  */
-exports.fn = function(item, svgoOptions) {
-
-    if(item.elem) {
-        if(item.isElem('style') && !item.isEmpty()) {
-            var styleCss = item.content[0].text || item.content[0].cdata || [],
-                DATA = styleCss.indexOf('>') >= 0 || styleCss.indexOf('<') >= 0 ? 'cdata' : 'text';
-            if(styleCss.length > 0) {
-                var styleCssMinified = csso.minify(styleCss, svgoOptions);
-                item.content[0][DATA] = styleCssMinified.css;
+exports.fn = function(ast, options) {
+    options = options || {};
+
+    var minifyOptionsForStylesheet = cloneObject(options);
+    var minifyOptionsForAttribute = cloneObject(options);
+    var elems = findStyleElems(ast);
+
+    minifyOptionsForStylesheet.usage = collectUsageData(ast, options);
+    minifyOptionsForAttribute.usage = null;
+
+    elems.forEach(function(elem) {
+        if (elem.isElem('style')) {
+            // <style> element
+            var styleCss = elem.content[0].text || elem.content[0].cdata || [];
+            var DATA = styleCss.indexOf('>') >= 0 || styleCss.indexOf('<') >= 0 ? 'cdata' : 'text';
+
+            elem.content[0][DATA] = csso.minify(styleCss, minifyOptionsForStylesheet).css;
+        } else {
+            // style attribute
+            var elemStyle = elem.attr('style').value;
+
+            elem.attr('style').value = csso.minifyBlock(elemStyle, minifyOptionsForAttribute).css;
+        }
+    });
+
+    return ast;
+};
+
+function cloneObject(obj) {
+    var result = {};
+
+    for (var key in obj) {
+        result[key] = obj[key];
+    }
+
+    return result;
+}
+
+function findStyleElems(ast) {
+
+    function walk(items, styles) {
+        for (var i = 0; i < items.content.length; i++) {
+            var item = items.content[i];
+
+            // go deeper
+            if (item.content) {
+                walk(item, styles);
+            }
+
+            if (item.isElem('style') && !item.isEmpty()) {
+                styles.push(item);
+            } else if (item.isElem() && item.hasAttr('style')) {
+                styles.push(item);
             }
-      }
+        }
 
-      if(item.hasAttr('style')) {
-          var itemCss = item.attr('style').value;
-          if(itemCss.length > 0) {
-              var itemCssMinified = csso.minifyBlock(itemCss, svgoOptions);
-              item.attr('style').value = itemCssMinified.css;
-          }
-      }
+        return styles;
     }
 
-    return item;
-};
+    return walk(ast, []);
+}
+
+function shouldFilter(options, name) {
+    if ('usage' in options === false) {
+        return true;
+    }
+
+    if (options.usage && name in options.usage === false) {
+        return true;
+    }
+
+    return Boolean(options.usage && options.usage[name]);
+}
+
+function collectUsageData(ast, options) {
+
+    function walk(items, usageData) {
+        for (var i = 0; i < items.content.length; i++) {
+            var item = items.content[i];
+
+            // go deeper
+            if (item.content) {
+                walk(item, usageData);
+            }
+
+            if (item.isElem('script')) {
+                safe = false;
+            }
+
+            if (item.isElem()) {
+                usageData.tags[item.elem] = true;
+
+                if (item.hasAttr('id')) {
+                    usageData.ids[item.attr('id').value] = true;
+                }
+
+                if (item.hasAttr('class')) {
+                    item.attr('class').value.replace(/^\s+|\s+$/g, '').split(/\s+/).forEach(function(className) {
+                        usageData.classes[className] = true;
+                    });
+                }
+
+                if (item.attrs && Object.keys(item.attrs).some(function(name) { return /^on/i.test(name); })) {
+                    safe = false;
+                }
+            }
+        }
+
+        return usageData;
+    }
+
+    var safe = true;
+    var usageData = {};
+    var hasData = false;
+    var rawData = walk(ast, {
+        ids: Object.create(null),
+        classes: Object.create(null),
+        tags: Object.create(null)
+    });
+
+    if (!safe && options.usage && options.usage.force) {
+        safe = true;
+    }
+
+    for (var key in rawData) {
+        if (shouldFilter(options, key)) {
+            usageData[key] = Object.keys(rawData[key]);
+            hasData = true;
+        }
+    }
+
+    return safe && hasData ? usageData : null;
+}


Test patch:
diff --git a/test/plugins/minifyStyles.01.svg b/test/plugins/minifyStyles.01.svg
index 28fa6cb..9e8b029 100755
--- a/test/plugins/minifyStyles.01.svg
+++ b/test/plugins/minifyStyles.01.svg
@@ -1,6 +1,6 @@
 <svg id="test" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
     <style>
-        .st0{ fill:red; padding-top: 1em; padding-right: 1em; padding-bottom: 1em; padding-left: 1em; } @media screen and (max-width: 200px) { .st1 { display: none; } }
+        .st0{ fill:red; padding-top: 1em; padding-right: 1em; padding-bottom: 1em; padding-left: 1em; } @media screen and (max-width: 200px) { .st0 { display: none; } }
     </style>
     <rect width="100" height="100" class="st0" style="stroke-width:3; margin-top: 1em; margin-right: 1em; margin-bottom: 1em; margin-left: 1em;"/>
 </svg>
@@ -9,7 +9,7 @@
 
 <svg id="test" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
     <style>
-        .st0{fill:red;padding:1em}@media screen and (max-width:200px){.st1{display:none}}
+        .st0{fill:red;padding:1em}@media screen and (max-width:200px){.st0{display:none}}
     </style>
     <rect width="100" height="100" class="st0" style="stroke-width:3;margin:1em"/>
 </svg>
diff --git a/test/plugins/minifyStyles.02.svg b/test/plugins/minifyStyles.02.svg
index 5cd3969..484e379 100644
--- a/test/plugins/minifyStyles.02.svg
+++ b/test/plugins/minifyStyles.02.svg
@@ -1,7 +1,7 @@
 <svg id="test" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
     <style>
         <![CDATA[
-            .st0{ fill:red; padding-top: 1em; padding-right: 1em; padding-bottom: 1em; padding-left: 1em; } @media screen and (max-width: 200px) { .st1 { display: none; } }
+            .st0{ fill:red; padding-top: 1em; padding-right: 1em; padding-bottom: 1em; padding-left: 1em; } @media screen and (max-width: 200px) { .st0 { display: none; } }
         ]]>
     </style>
     <style></style>
@@ -12,7 +12,7 @@
 
 <svg id="test" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
     <style>
-        .st0{fill:red;padding:1em}@media screen and (max-width:200px){.st1{display:none}}
+        .st0{fill:red;padding:1em}@media screen and (max-width:200px){.st0{display:none}}
     </style>
     <style/>
     <rect width="100" height="100" class="st0" style="stroke-width:3;margin:1em"/>
diff --git a/test/plugins/minifyStyles.03.svg b/test/plugins/minifyStyles.03.svg
index 993218d..1bf5c98 100644
--- a/test/plugins/minifyStyles.03.svg
+++ b/test/plugins/minifyStyles.03.svg
@@ -1,7 +1,7 @@
 <svg id="test" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
     <style>
         <![CDATA[
-            .st0{ fill:red; padding-top: 1em; padding-right: 1em; padding-bottom: 1em; padding-left: 1em; background-image: url('data:image/svg,<svg width="16" height="16"/>') } @media screen and (max-width: 200px) { .st1 { display: none; } }
+            .st0{ fill:red; padding-top: 1em; padding-right: 1em; padding-bottom: 1em; padding-left: 1em; background-image: url('data:image/svg,<svg width="16" height="16"/>') } @media screen and (max-width: 200px) { .st0 { display: none; } }
         ]]>
     </style>
     <rect width="100" height="100" class="st0" style="stroke-width:3; margin-top: 1em; margin-right: 1em; margin-bottom: 1em; margin-left: 1em;"/>
@@ -11,7 +11,7 @@
 
 <svg id="test" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100">
     <style>
-        <![CDATA[.st0{fill:red;padding:1em;background-image:url('data:image/svg,<svg width="16" height="16"/>')}@media screen and (max-width:200px){.st1{display:none}}]]>
+        <![CDATA[.st0{fill:red;padding:1em;background-image:url('data:image/svg,<svg width="16" height="16"/>')}@media screen and (max-width:200px){.st0{display:none}}]]>
     </style>
     <rect width="100" height="100" class="st0" style="stroke-width:3;margin:1em"/>
 </svg>
diff --git a/test/plugins/minifyStyles.04.svg b/test/plugins/minifyStyles.04.svg
new file mode 100644
index 0000000..cfa8d57
--- /dev/null
+++ b/test/plugins/minifyStyles.04.svg
@@ -0,0 +1,24 @@
+<svg xmlns="http://www.w3.org/2000/svg">
+    <style>
+        .used { p: 1 }
+        .unused { p: 2 }
+        #used { p: 3 }
+        #unused { p: 4 }
+        g { p: 5 }
+        unused { p: 6 }
+    </style>
+    <g id="used" class="used">
+        test
+    </g>
+</svg>
+
+@@@
+
+<svg xmlns="http://www.w3.org/2000/svg">
+    <style>
+        .used{p:1}#used{p:3}g{p:5}
+    </style>
+    <g id="used" class="used">
+        test
+    </g>
+</svg>
diff --git a/test/plugins/minifyStyles.05.svg b/test/plugins/minifyStyles.05.svg
new file mode 100644
index 0000000..5abe7b8
--- /dev/null
+++ b/test/plugins/minifyStyles.05.svg
@@ -0,0 +1,28 @@
+<svg xmlns="http://www.w3.org/2000/svg">
+    <style>
+        .used { p: 1 }
+        .unused { p: 2 }
+        #used { p: 3 }
+        #unused { p: 4 }
+        g { p: 5 }
+        unused { p: 6 }
+    </style>
+    <g id="used" class="used">
+        test
+    </g>
+</svg>
+
+@@@
+
+<svg xmlns="http://www.w3.org/2000/svg">
+    <style>
+        .used{p:1}#used{p:3}#unused{p:4}g{p:5}unused{p:6}
+    </style>
+    <g id="used" class="used">
+        test
+    </g>
+</svg>
+
+@@@
+
+{"usage":{"ids":false,"tags":false}}
diff --git a/test/plugins/minifyStyles.06.svg b/test/plugins/minifyStyles.06.svg
new file mode 100644
index 0000000..4986aab
--- /dev/null
+++ b/test/plugins/minifyStyles.06.svg
@@ -0,0 +1,28 @@
+<svg xmlns="http://www.w3.org/2000/svg">
+    <style>
+        .used { p: 1 }
+        .unused { p: 2 }
+        #used { p: 3 }
+        #unused { p: 4 }
+        g { p: 5 }
+        unused { p: 6 }
+    </style>
+    <g id="used" class="used">
+        test
+    </g>
+</svg>
+
+@@@
+
+<svg xmlns="http://www.w3.org/2000/svg">
+    <style>
+        .used{p:1}.unused{p:2}#used{p:3}#unused{p:4}g{p:5}unused{p:6}
+    </style>
+    <g id="used" class="used">
+        test
+    </g>
+</svg>
+
+@@@
+
+{"usage":false}
diff --git a/test/plugins/minifyStyles.07.svg b/test/plugins/minifyStyles.07.svg
new file mode 100644
index 0000000..5d2d7a9
--- /dev/null
+++ b/test/plugins/minifyStyles.07.svg
@@ -0,0 +1,26 @@
+<svg xmlns="http://www.w3.org/2000/svg">
+    <style>
+        .used { p: 1 }
+        .unused { p: 2 }
+    </style>
+    <script>
+        /* script element prevents removing unused styles */
+    </script>
+    <g class="used">
+        test
+    </g>
+</svg>
+
+@@@
+
+<svg xmlns="http://www.w3.org/2000/svg">
+    <style>
+        .used{p:1}.unused{p:2}
+    </style>
+    <script>
+        /* script element prevents removing unused styles */
+    </script>
+    <g class="used">
+        test
+    </g>
+</svg>
diff --git a/test/plugins/minifyStyles.08.svg b/test/plugins/minifyStyles.08.svg
new file mode 100644
index 0000000..d0f6e03
--- /dev/null
+++ b/test/plugins/minifyStyles.08.svg
@@ -0,0 +1,20 @@
+<svg xmlns="http://www.w3.org/2000/svg">
+    <style>
+        .used { p: 1 }
+        .unused { p: 2 }
+    </style>
+    <g class="used" onclick="/* on* attributes prevents removing unused styles */">
+        test
+    </g>
+</svg>
+
+@@@
+
+<svg xmlns="http://www.w3.org/2000/svg">
+    <style>
+        .used{p:1}.unused{p:2}
+    </style>
+    <g class="used" onclick="/* on* attributes prevents removing unused styles */">
+        test
+    </g>
+</svg>
diff --git a/test/plugins/minifyStyles.09.svg b/test/plugins/minifyStyles.09.svg
new file mode 100644
index 0000000..49e4b65
--- /dev/null
+++ b/test/plugins/minifyStyles.09.svg
@@ -0,0 +1,29 @@
+<svg xmlns="http://www.w3.org/2000/svg">
+    <style>
+        .used { p: 1 }
+        .unused { p: 2 }
+    </style>
+    <script>
+        /* with usage.force=true script element does not prevent removing unused styles */
+    </script>
+    <g class="used" onclick="/* with usage.force=true on* attributes doesn't prevent removing unused styles */">
+        test
+    </g>
+</svg>
+
+@@@
+
+<svg xmlns="http://www.w3.org/2000/svg">
+    <style>
+        .used{p:1}
+    </style>
+    <script>
+        /* with usage.force=true script element does not prevent removing unused styles */
+    </script>
+    <g class="used" onclick="/* with usage.force=true on* attributes doesn't prevent removing unused styles */">
+        test
+    </g>
+</svg>
+
+@@@
+{ "usage": { "force": true } }
\ No newline at end of file