Fixes #862 - allows removing unused at rules.
authorJakub Pawlowicz <contact@jakubpawlowicz.com>
Fri, 24 Mar 2017 11:24:17 +0000 (12:24 +0100)
committerJakub Pawlowicz <contact@jakubpawlowicz.com>
Thu, 20 Apr 2017 06:27:05 +0000 (08:27 +0200)
Why:

* When an at-rule, i.e. `@counter-style`, `@font-face`, `@keyframes`, or
  `@namespace`, is not referenced anywhere in a stylsheet it can be
  safely removed;
* inspired by https://www.npmjs.com/package/postcss-discard-unused

History.md
README.md
lib/optimizer/level-2/optimize.js
lib/optimizer/level-2/remove-unused-at-rules.js [new file with mode: 0644]
lib/options/optimization-level.js
test/optimizer/level-2/remove-unused-at-rules-test.js [new file with mode: 0644]
test/options/optimization-level-test.js

index 7223f16..90542c6 100644 (file)
@@ -9,6 +9,7 @@
 * Fixed issue [#893](https://github.com/jakubpawlowicz/clean-css/issues/893) - `inline: false` as alias to `inline: 'none'`.
 * Fixed issue [#890](https://github.com/jakubpawlowicz/clean-css/issues/890) - adds toggle to disable empty tokens removal.
 * Fixed issue [#886](https://github.com/jakubpawlowicz/clean-css/issues/886) - better multi pseudo class / element merging.
+* Fixed issue [#862](https://github.com/jakubpawlowicz/clean-css/issues/862) - allows removing unused at rules.
 * Fixed issue [#860](https://github.com/jakubpawlowicz/clean-css/issues/860) - adds `animation` property optimizer.
 * Fixed issue [#755](https://github.com/jakubpawlowicz/clean-css/issues/755) - adds custom handling of remote requests.
 * Fixed issue [#254](https://github.com/jakubpawlowicz/clean-css/issues/254) - adds `font` property optimizer.
index 480fd7b..bd9ae35 100644 (file)
--- a/README.md
+++ b/README.md
@@ -109,6 +109,7 @@ Once released clean-css 4.1 will introduce the following changes / features:
 * removal of `optimizeFont` flag in level 1 optimizations due to new `font` shorthand optimizer;
 * `skipProperties` flag in level 2 optimizations controlling which properties won't be optimized;
 * new `animation` shorthand and `animation-*` longhand optimizers;
+* `removeUnusedAtRules` level 2 optimization controlling removal of unused `@counter-style`, `@font-face`, `@keyframes`, and `@namespace` at rules;
 
 ## Constructor options
 
@@ -379,7 +380,8 @@ new CleanCSS({
       removeDuplicateFontRules: true, // controls duplicate `@font-face` removing; defaults to true
       removeDuplicateMediaBlocks: true, // controls duplicate `@media` removing; defaults to true
       removeDuplicateRules: true, // controls duplicate rules removing; defaults to true
-      restructureRules: false, // controls rule restructuring; defaults to false`
+      removeUnusedAtRules: false, // controls unused at rule removing; defaults to false (available since 4.1.0-pre)
+      restructureRules: false, // controls rule restructuring; defaults to false
       skipProperties: [] // controls which properties won't be optimized, defaults to `[]` which means all will be optimized (since 4.1.0-pre)
     }
   }
index df05dc4..9be961d 100644 (file)
@@ -6,6 +6,7 @@ var reduceNonAdjacent = require('./reduce-non-adjacent');
 var removeDuplicateFontAtRules = require('./remove-duplicate-font-at-rules');
 var removeDuplicateMediaQueries = require('./remove-duplicate-media-queries');
 var removeDuplicates = require('./remove-duplicates');
+var removeUnusedAtRules = require('./remove-unused-at-rules');
 var restructure = require('./restructure');
 
 var optimizeProperties = require('./properties/optimize');
@@ -27,6 +28,9 @@ function removeEmpty(tokens) {
         removeEmpty(token[2]);
         isEmpty = token[2].length === 0;
         break;
+      case Token.AT_RULE:
+        isEmpty = token[1].length === 0;
+        break;
       case Token.AT_RULE_BLOCK:
         isEmpty = token[2].length === 0;
     }
@@ -109,6 +113,10 @@ function level2Optimize(tokens, context, withRestructuring) {
     removeDuplicateMediaQueries(tokens, context);
   }
 
+  if (levelOptions.removeUnusedAtRules) {
+    removeUnusedAtRules(tokens, context);
+  }
+
   if (levelOptions.mergeMedia) {
     reduced = mergeMediaQueries(tokens, context);
     for (i = reduced.length - 1; i >= 0; i--) {
diff --git a/lib/optimizer/level-2/remove-unused-at-rules.js b/lib/optimizer/level-2/remove-unused-at-rules.js
new file mode 100644 (file)
index 0000000..bb6e5a3
--- /dev/null
@@ -0,0 +1,225 @@
+var populateComponents = require('./properties/populate-components');
+
+var wrapForOptimizing = require('../wrap-for-optimizing').single;
+
+var Token = require('../../tokenizer/token');
+
+var animationNameRegex = /^(\-moz\-|\-o\-|\-webkit\-)?animation-name$/;
+var animationRegex = /^(\-moz\-|\-o\-|\-webkit\-)?animation$/;
+var keyframeRegex = /^@(\-moz\-|\-o\-|\-webkit\-)?keyframes /;
+
+function removeUnusedAtRules(tokens, context) {
+  removeUnusedAtRule(tokens, matchCounterStyle, markCounterStylesAsUsed, context);
+  removeUnusedAtRule(tokens, matchFontFace, markFontFacesAsUsed, context);
+  removeUnusedAtRule(tokens, matchKeyframe, markKeyframesAsUsed, context);
+  removeUnusedAtRule(tokens, matchNamespace, markNamespacesAsUsed, context);
+}
+
+function removeUnusedAtRule(tokens, matchCallback, markCallback, context) {
+  var atRules = {};
+  var atRule;
+  var token;
+  var zeroAt;
+  var i, l;
+
+  for (i = 0, l = tokens.length; i < l; i++) {
+    matchCallback(tokens[i], atRules);
+  }
+
+  if (Object.keys(atRules).length === 0) {
+    return;
+  }
+
+  markUsedAtRules(tokens, markCallback, atRules, context);
+
+  for (atRule in atRules) {
+    token = atRules[atRule];
+    zeroAt = token[0] == Token.AT_RULE ? 1 : 2;
+    token[zeroAt] = [];
+  }
+}
+
+function markUsedAtRules(tokens, markCallback, atRules, context) {
+  var boundMarkCallback = markCallback(atRules);
+  var i, l;
+
+  for (i = 0, l = tokens.length; i < l; i++) {
+    switch (tokens[i][0]) {
+      case Token.RULE:
+        boundMarkCallback(tokens[i], context);
+        break;
+      case Token.NESTED_BLOCK:
+        markUsedAtRules(tokens[i][2], markCallback, atRules, context);
+    }
+  }
+}
+
+function matchCounterStyle(token, atRules) {
+  var match;
+
+  if (token[0] == Token.AT_RULE_BLOCK && token[1][0][1].indexOf('@counter-style') === 0) {
+    match = token[1][0][1].split(' ')[1];
+    atRules[match] = token;
+  }
+}
+
+function markCounterStylesAsUsed(atRules) {
+  return function (token, context) {
+    var property;
+    var wrappedProperty;
+    var i, l;
+
+    for (i = 0, l = token[2].length; i < l; i++) {
+      property = token[2][i];
+
+      if (property[1][1] == 'list-style') {
+        wrappedProperty = wrapForOptimizing(property);
+        populateComponents([wrappedProperty], context.validator, context.warnings);
+
+        if (wrappedProperty.components[0].value[0][1] in atRules) {
+          delete atRules[property[2][1]];
+        }
+      }
+
+      if (property[1][1] == 'list-style-type' && property[2][1] in atRules) {
+        delete atRules[property[2][1]];
+      }
+    }
+  };
+}
+
+function matchFontFace(token, atRules) {
+  var property;
+  var match;
+  var i, l;
+
+  if (token[0] == Token.AT_RULE_BLOCK && token[1][0][1] == '@font-face') {
+    for (i = 0, l = token[2].length; i < l; i++) {
+      property = token[2][i];
+
+      if (property[1][1] == 'font-family') {
+        match = property[2][1].toLowerCase();
+        atRules[match] = token;
+        break;
+      }
+    }
+  }
+}
+
+function markFontFacesAsUsed(atRules) {
+  return function (token, context) {
+    var property;
+    var wrappedProperty;
+    var component;
+    var normalizedMatch;
+    var i, l;
+    var j, m;
+
+    for (i = 0, l = token[2].length; i < l; i++) {
+      property = token[2][i];
+
+      if (property[1][1] == 'font') {
+        wrappedProperty = wrapForOptimizing(property);
+        populateComponents([wrappedProperty], context.validator, context.warnings);
+        component = wrappedProperty.components[6];
+
+        for (j = 0, m = component.value.length; j < m; j++) {
+          normalizedMatch = component.value[j][1].toLowerCase();
+
+          if (normalizedMatch in atRules) {
+            delete atRules[normalizedMatch];
+          }
+        }
+      }
+
+      if (property[1][1] == 'font-family') {
+        for (j = 2, m = property.length; j < m; j++) {
+          normalizedMatch = property[j][1].toLowerCase();
+
+          if (normalizedMatch in atRules) {
+            delete atRules[normalizedMatch];
+          }
+        }
+      }
+    }
+  };
+}
+
+function matchKeyframe(token, atRules) {
+  var match;
+
+  if (token[0] == Token.NESTED_BLOCK && keyframeRegex.test(token[1][0][1])) {
+    match = token[1][0][1].split(' ')[1];
+    atRules[match] = token;
+  }
+}
+
+function markKeyframesAsUsed(atRules) {
+  return function (token, context) {
+    var property;
+    var wrappedProperty;
+    var component;
+    var i, l;
+    var j, m;
+
+    for (i = 0, l = token[2].length; i < l; i++) {
+      property = token[2][i];
+
+      if (animationRegex.test(property[1][1])) {
+        wrappedProperty = wrapForOptimizing(property);
+        populateComponents([wrappedProperty], context.validator, context.warnings);
+        component = wrappedProperty.components[7];
+
+        for (j = 0, m = component.value.length; j < m; j++) {
+          if (component.value[j][1] in atRules) {
+            delete atRules[component.value[j][1]];
+          }
+        }
+      }
+
+      if (animationNameRegex.test(property[1][1])) {
+        for (j = 2, m = property.length; j < m; j++) {
+          if (property[j][1] in atRules) {
+            delete atRules[property[j][1]];
+          }
+        }
+      }
+    }
+  };
+}
+
+function matchNamespace(token, atRules) {
+  var match;
+
+  if (token[0] == Token.AT_RULE && token[1].indexOf('@namespace') === 0) {
+    match = token[1].split(' ')[1];
+    atRules[match] = token;
+  }
+}
+
+function markNamespacesAsUsed(atRules) {
+  var namespaceRegex = new RegExp(Object.keys(atRules).join('\\\||') + '\\\|', 'g');
+
+  return function (token) {
+    var match;
+    var scope;
+    var normalizedMatch;
+    var i, l;
+    var j, m;
+
+    for (i = 0, l = token[1].length; i < l; i++) {
+      scope = token[1][i];
+      match = scope[1].match(namespaceRegex);
+
+      for (j = 0, m = match.length; j < m; j++) {
+        normalizedMatch = match[j].substring(0, match[j].length - 1);
+
+        if (normalizedMatch in atRules) {
+          delete atRules[normalizedMatch];
+        }
+      }
+    }
+  };
+}
+
+module.exports = removeUnusedAtRules;
index 6b4ae69..0d3ad73 100644 (file)
@@ -46,6 +46,7 @@ DEFAULTS[OptimizationLevel.Two] = {
   removeDuplicateFontRules: true,
   removeDuplicateMediaBlocks: true,
   removeDuplicateRules: true,
+  removeUnusedAtRules: false,
   restructureRules: false,
   skipProperties: []
 };
diff --git a/test/optimizer/level-2/remove-unused-at-rules-test.js b/test/optimizer/level-2/remove-unused-at-rules-test.js
new file mode 100644 (file)
index 0000000..35083eb
--- /dev/null
@@ -0,0 +1,137 @@
+var vows = require('vows');
+var optimizerContext = require('../../test-helper').optimizerContext;
+
+vows.describe('remove unused at rules')
+  .addBatch(
+    optimizerContext('@counter-style', {
+      'one unused declaration': [
+        '@counter-style test{system:fixed;symbols:url(one.png) url(two.png);suffix:" "}',
+        ''
+      ],
+      'one used declaration in list-style': [
+        '@counter-style test{system:fixed;symbols:url(one.png) url(two.png);suffix:" "}.block{list-style:test}',
+        '@counter-style test{system:fixed;symbols:url(one.png) url(two.png);suffix:" "}.block{list-style:test}'
+      ],
+      'one used declaration in list-style-type': [
+        '@counter-style test{system:fixed;symbols:url(one.png) url(two.png);suffix:" "}.block{list-style-type:test}',
+        '@counter-style test{system:fixed;symbols:url(one.png) url(two.png);suffix:" "}.block{list-style-type:test}'
+      ],
+      'one used declaration in nested list-style-type': [
+        '@counter-style test{system:fixed;symbols:url(one.png) url(two.png);suffix:" "}@media screen{.block{list-style-type:test}}',
+        '@counter-style test{system:fixed;symbols:url(one.png) url(two.png);suffix:" "}@media screen{.block{list-style-type:test}}'
+      ],
+      'one used declaration and one unused': [
+        '@counter-style test{system:fixed;symbols:url(one.png) url(two.png);suffix:" "}@counter-style test2{system:fixed;symbols:url(one.png) url(two.png);suffix:" "}.block{list-style-type:test}',
+        '@counter-style test{system:fixed;symbols:url(one.png) url(two.png);suffix:" "}.block{list-style-type:test}'
+      ]
+    }, { level: { 2: { removeUnusedAtRules: true } } })
+  )
+  .addBatch(
+    optimizerContext('@font-face', {
+      'one unused declaration': [
+        '@font-face{font-family:test}',
+        ''
+      ],
+      'one used declaration in font-family': [
+        '@font-face{font-family:test}.block{font-family:test}',
+        '@font-face{font-family:test}.block{font-family:test}'
+      ],
+      'one used declaration in font-family with different case': [
+        '@font-face{font-family:test}.block{font-family:Test}',
+        '@font-face{font-family:test}.block{font-family:Test}'
+      ],
+      'one used declaration in multi-valued font-family': [
+        '@font-face{font-family:test}.block{font-family:Arial,test,sans-serif}',
+        '@font-face{font-family:test}.block{font-family:Arial,test,sans-serif}'
+      ],
+      'one used declaration in font': [
+        '@font-face{font-family:test}.block{font:16px test}',
+        '@font-face{font-family:test}.block{font:16px test}'
+      ],
+      'one used declaration in multi-valued font': [
+        '@font-face{font-family:test}.block{font:16px Arial,test,sans-serif}',
+        '@font-face{font-family:test}.block{font:16px Arial,test,sans-serif}'
+      ],
+      'one used declaration in nested font': [
+        '@font-face{font-family:test}@media screen{.block{font:16px test}}',
+        '@font-face{font-family:test}@media screen{.block{font:16px test}}'
+      ],
+      'one used declaration in multi-valued font with different case': [
+        '@font-face{font-family:test}.block{font:16px Arial,Test,sans-serif}',
+        '@font-face{font-family:test}.block{font:16px Arial,Test,sans-serif}'
+      ],
+      'one used declaration and one unused': [
+        '@font-face{font-family:test}@font-face{font-family:test2}.block{font:16px test}',
+        '@font-face{font-family:test}.block{font:16px test}'
+      ]
+    }, { level: { 2: { removeUnusedAtRules: true } } })
+  )
+  .addBatch(
+    optimizerContext('@keyframes', {
+      'one unused declaration': [
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}',
+        ''
+      ],
+      'one used declaration in animation-name': [
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}.block{animation-name:test}',
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}.block{animation-name:test}'
+      ],
+      'one used declaration in multi-value animation-name': [
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}.block{animation-name:custom,test}',
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}.block{animation-name:custom,test}'
+      ],
+      'one used declaration in animation': [
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}.block{animation:1s ease-in test}',
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}.block{animation:1s ease-in test}'
+      ],
+      'one used declaration in multi-value animation': [
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}.block{animation:1s ease-in custom,2s ease-out test}',
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}.block{animation:1s ease-in custom,2s ease-out test}'
+      ],
+      'one used declaration in vendor prefixed animation': [
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}.block{-moz-animation:1s ease-in test}',
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}.block{-moz-animation:1s ease-in test}'
+      ],
+      'one used vendor prefixed declaration in animation': [
+        '@-webkit-keyframes test{0%{opacity:0}100%{opacity:1}}.block{animation:1s ease-in test}',
+        '@-webkit-keyframes test{0%{opacity:0}100%{opacity:1}}.block{animation:1s ease-in test}'
+      ],
+      'one used in nested animation': [
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}@media screen{.block{animation:1s ease-in test}}',
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}@media screen{.block{animation:1s ease-in test}}'
+      ],
+      'one used declaration and one unused': [
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}@keyframes test2{0%{opacity:0}100%{opacity:1}}.block{animation-name:test}',
+        '@keyframes test{0%{opacity:0}100%{opacity:1}}.block{animation-name:test}'
+      ]
+    }, { level: { 2: { removeUnusedAtRules: true } } })
+  )
+  .addBatch(
+    optimizerContext('@namespace', {
+      'one unused declaration': [
+        '@namespace svg url(http://www.w3.org/2000/svg);',
+        ''
+      ],
+      'one used declaration in scope': [
+        '@namespace svg url(http://www.w3.org/2000/svg);svg|.block{color:red}',
+        '@namespace svg url(http://www.w3.org/2000/svg);svg|.block{color:red}'
+      ],
+      'one used declaration in attribute': [
+        '@namespace svg url(http://www.w3.org/2000/svg);.block[svg|title=test]{color:red}',
+        '@namespace svg url(http://www.w3.org/2000/svg);.block[svg|title=test]{color:red}'
+      ],
+      'one used declaration in nested attribute': [
+        '@namespace svg url(http://www.w3.org/2000/svg);@media screen{.block[svg|title=test]{color:red}}',
+        '@namespace svg url(http://www.w3.org/2000/svg);@media screen{.block[svg|title=test]{color:red}}'
+      ],
+      'many declaration in one rule': [
+        '@namespace svg url(http://www.w3.org/2000/svg);@namespace xlink url(http://www.w3.org/2000/xlink);.block[svg|title=test],xlink|.block{color:red}',
+        '@namespace svg url(http://www.w3.org/2000/svg);@namespace xlink url(http://www.w3.org/2000/xlink);.block[svg|title=test],xlink|.block{color:red}'
+      ],
+      'one used declaration and one unused': [
+        '@namespace svg url(http://www.w3.org/2000/svg);@namespace xlink url(http://www.w3.org/2000/xlink);svg|.block{color:red}',
+        '@namespace svg url(http://www.w3.org/2000/svg);svg|.block{color:red}'
+      ]
+    }, { level: { 2: { removeUnusedAtRules: true } } })
+  )
+  .export(module);
index 9b739a7..f5981a5 100644 (file)
@@ -144,6 +144,7 @@ vows.describe(optimizationLevelFrom)
           removeDuplicateFontRules: true,
           removeDuplicateMediaBlocks: true,
           removeDuplicateRules: true,
+          removeUnusedAtRules: false,
           restructureRules: false,
           skipProperties: []
         });
@@ -210,6 +211,7 @@ vows.describe(optimizationLevelFrom)
           removeDuplicateFontRules: true,
           removeDuplicateMediaBlocks: true,
           removeDuplicateRules: true,
+          removeUnusedAtRules: false,
           restructureRules: false,
           skipProperties: []
         });
@@ -341,6 +343,7 @@ vows.describe(optimizationLevelFrom)
           removeDuplicateFontRules: false,
           removeDuplicateMediaBlocks: false,
           removeDuplicateRules: false,
+          removeUnusedAtRules: false,
           restructureRules: false,
           skipProperties: []
         });
@@ -396,6 +399,7 @@ vows.describe(optimizationLevelFrom)
           removeDuplicateFontRules: false,
           removeDuplicateMediaBlocks: false,
           removeDuplicateRules: false,
+          removeUnusedAtRules: false,
           restructureRules: false,
           skipProperties: []
         });
@@ -489,6 +493,7 @@ vows.describe(optimizationLevelFrom)
           removeDuplicateFontRules: true,
           removeDuplicateMediaBlocks: true,
           removeDuplicateRules: true,
+          removeUnusedAtRules: false,
           restructureRules: false,
           skipProperties: []
         });
@@ -544,6 +549,7 @@ vows.describe(optimizationLevelFrom)
           removeDuplicateFontRules: true,
           removeDuplicateMediaBlocks: true,
           removeDuplicateRules: true,
+          removeUnusedAtRules: false,
           restructureRules: false,
           skipProperties: []
         });
@@ -599,6 +605,7 @@ vows.describe(optimizationLevelFrom)
           removeDuplicateFontRules: false,
           removeDuplicateMediaBlocks: false,
           removeDuplicateRules: false,
+          removeUnusedAtRules: false,
           restructureRules: false,
           skipProperties: []
         });
@@ -654,6 +661,7 @@ vows.describe(optimizationLevelFrom)
           removeDuplicateFontRules: false,
           removeDuplicateMediaBlocks: false,
           removeDuplicateRules: false,
+          removeUnusedAtRules: false,
           restructureRules: false,
           skipProperties: []
         });