Fixes #863 - adds `transform` callback for custom optimizations.
authorJakub Pawlowicz <contact@jakubpawlowicz.com>
Thu, 19 Jan 2017 19:50:59 +0000 (20:50 +0100)
committerJakub Pawlowicz <contact@jakubpawlowicz.com>
Fri, 20 Jan 2017 06:52:29 +0000 (07:52 +0100)
Why:

* So users can apply custom optimizations without forking clean-css
  and learning how to plug such optimization in;
* may also aid in rewriting URLs after introduction of `rebaseTo`.

History.md
README.md
lib/optimizer/level-1/optimize.js
lib/options/optimization-level.js
lib/utils/override.js
test/module-test.js
test/options/optimization-level-test.js

index 8baf029..7ae329e 100644 (file)
@@ -45,6 +45,7 @@
 * Fixed issue [#847](https://github.com/jakubpawlowicz/clean-css/issues/847) - regression in handling invalid selectors.
 * Fixed issue [#849](https://github.com/jakubpawlowicz/clean-css/issues/849) - disables inlining protocol-less resources.
 * Fixed issue [#857](https://github.com/jakubpawlowicz/clean-css/issues/857) - normalizes CleanCSS API interface.
+* Fixed issue [#863](https://github.com/jakubpawlowicz/clean-css/issues/863) - adds `transform` callback for custom optimizations.
 
 [3.4.23 / 2016-12-17](https://github.com/jakubpawlowicz/clean-css/compare/v3.4.22...v3.4.23)
 ==================
index 24d84e7..c8e95f5 100644 (file)
--- a/README.md
+++ b/README.md
@@ -284,7 +284,8 @@ new CleanCSS({
       specialComments: 'all', // denotes a number of /*! ... */ comments preserved; defaults to `all`
       tidyAtRules: true, // controls at-rules (e.g. `@charset`, `@import`) optimizing; defaults to `true`
       tidyBlockScopes: true, // controls block scopes (e.g. `@media`) optimizing; defaults to `true`
-      tidySelectors: true // controls selectors optimizing; defaults to `true`
+      tidySelectors: true, // controls selectors optimizing; defaults to `true`,
+      transform: function () {} // defines a callback for fine-grained property optimization; defaults to no-op
     }
   }
 });
@@ -359,6 +360,30 @@ new CleanCSS({ returnPromise: true })
   .catch(function (error) { // deal with errors });
 ```
 
+#### How to apply arbitrary transformations to CSS properties
+
+If clean-css doesn't perform a specific property optimization, you can use `transform` callback to apply it:
+
+```js
+var CleanCSS = require('clean-css');
+var source = '.block{background-image:url(/path/to/image.png)}';
+var output = new CleanCSS({
+  level: {
+    1: {
+      transform: function (propertyName, propertyValue) {
+        if (propertyName == 'background-image' && propertyValue.indexOf('/path/to') > -1) {
+          return propertyValue.replace('/path/to', '../valid/path/to');
+        }
+      }
+    }
+  }
+}).minify(source);
+
+console.log(output.styles); # => .block{background-image:url(../valid/path/to/image.png)}
+```
+
+Note: returning `false` from `transform` callback will drop a property.
+
 ### How to use clean-css with build tools?
 
 * [Broccoli](https://github.com/broccolijs/broccoli#broccoli): [broccoli-clean-css](https://github.com/shinnn/broccoli-clean-css)
index 808e94a..2970a8a 100644 (file)
@@ -19,6 +19,8 @@ var Marker = require('../../tokenizer/marker');
 var formatPosition = require('../../utils/format-position');
 var split = require('../../utils/split');
 
+var IgnoreProperty = 'ignore-property';
+
 var CHARSET_TOKEN = '@charset';
 var CHARSET_REGEXP = new RegExp('^' + CHARSET_TOKEN, 'i');
 
@@ -403,6 +405,18 @@ function removeUrlQuotes(value) {
     value;
 }
 
+function transformValue(propertyName, propertyValue, transformCallback) {
+  var transformedValue = transformCallback(propertyName, propertyValue);
+
+  if (transformedValue === undefined) {
+    return propertyValue;
+  } else if (transformedValue === false) {
+    return IgnoreProperty;
+  } else {
+    return transformedValue;
+  }
+}
+
 //
 
 function optimizeBody(properties, context) {
@@ -413,6 +427,7 @@ function optimizeBody(properties, context) {
   var propertyToken;
   var _properties = wrapForOptimizing(properties, true);
 
+  propertyLoop:
   for (var i = 0, l = _properties.length; i < l; i++) {
     property = _properties[i];
     name = property.name;
@@ -504,6 +519,13 @@ function optimizeBody(properties, context) {
         }
       }
 
+      value = transformValue(name, value, levelOptions.transform);
+
+      if (value === IgnoreProperty) {
+        property.unused = true;
+        continue propertyLoop;
+      }
+
       property.value[j][1] = value;
     }
 
index 33948bb..70d45c8 100644 (file)
@@ -30,7 +30,8 @@ DEFAULTS[OptimizationLevel.One] = {
   specialComments: 'all',
   tidyAtRules: true,
   tidyBlockScopes: true,
-  tidySelectors: true
+  tidySelectors: true,
+  transform: noop
 };
 DEFAULTS[OptimizationLevel.Two] = {
   mergeAdjacentRules: true,
@@ -56,6 +57,8 @@ var TRUE_KEYWORD_2 = 'on';
 var OPTION_SEPARATOR = ';';
 var OPTION_VALUE_SEPARATOR = ':';
 
+function noop() {}
+
 function optimizationLevelFrom(source) {
   var level = override(DEFAULTS, {});
   var Zero = OptimizationLevel.Zero;
index 86bf94c..e7f8494 100644 (file)
@@ -1,17 +1,30 @@
 function override(source1, source2) {
   var target = {};
+  var key1;
+  var key2;
+  var item;
 
-  for (var key1 in source1) {
-    target[key1] = source1[key1];
+  for (key1 in source1) {
+    item = source1[key1];
+
+    if (Array.isArray(item)) {
+      target[key1] = item.slice(0);
+    } else if (typeof item == 'object' && item !== null) {
+      target[key1] = override(item, {});
+    } else {
+      target[key1] = item;
+    }
   }
 
-  for (var key2 in source2) {
-    if (key2 in target && typeof source2[key2] == 'object') {
-      target[key2] = override(target[key2], source2[key2]);
-    } else if (Array.isArray(source2[key2])) {
-      target[key2] = source2[key2].slice(0);
+  for (key2 in source2) {
+    item = source2[key2];
+
+    if (key2 in target && Array.isArray(item)) {
+      target[key2] = item.slice(0);
+    } else if (key2 in target && typeof item == 'object' && item !== null) {
+      target[key2] = override(target[key2], item);
     } else {
-      target[key2] = source2[key2];
+      target[key2] = item;
     }
   }
 
index f5aee06..357a121 100644 (file)
@@ -410,6 +410,63 @@ vows.describe('module tests').addBatch({
       assert.instanceOf(minified.sourceMap, SourceMapGenerator);
     }
   },
+  'arbitrary property transformations': {
+    'allows changing property value': {
+      'topic': function () {
+        return new CleanCSS({
+          level: {
+            1: {
+              transform: function (propertyName, propertyValue) {
+                if (propertyName == 'background-image' && propertyValue.indexOf('/path/to') > -1) {
+                  return propertyValue.replace('/path/to', '../valid/path/to');
+                }
+              }
+            }
+          }
+        }).minify('.block{background-image:url(/path/to/image.png);border-image:url(image.png)}');
+      },
+      'gives right output': function (error, output) {
+        assert.equal(output.styles, '.block{background-image:url(../valid/path/to/image.png);border-image:url(image.png)}');
+      }
+    },
+    'allows dropping properties': {
+      'topic': function () {
+        return new CleanCSS({
+          level: {
+            1: {
+              transform: function (propertyName) {
+                if (propertyName.indexOf('-o-') === 0) {
+                  return false;
+                }
+              }
+            }
+          }
+        }).minify('.block{-o-border-radius:2px;border-image:url(image.png)}');
+      },
+      'gives right output': function (error, output) {
+        assert.equal(output.styles, '.block{border-image:url(image.png)}');
+      }
+    },
+    'combined with level 2 optimization': {
+      'topic': function () {
+        return new CleanCSS({
+          level: {
+            1: {
+              transform: function (propertyName) {
+                if (propertyName == 'margin-bottom') {
+                  return false;
+                }
+              }
+            },
+            2: true
+          }
+        }).minify('.block{-o-border-radius:2px;margin:0 12px;margin-bottom:5px}');
+      },
+      'drops property before level 2 optimizations': function (error, output) {
+        assert.equal(output.styles, '.block{-o-border-radius:2px;margin:0 12px}');
+      }
+    }
+  },
   'accepts a list of source files as array': {
     'relative': {
       'with rebase to the current directory': {
index 0907256..ae3805b 100644 (file)
@@ -18,6 +18,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: true,
@@ -64,6 +67,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: true,
@@ -99,6 +105,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: true,
@@ -160,6 +169,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: true,
@@ -210,6 +222,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: false,
@@ -245,6 +260,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: false,
@@ -280,6 +298,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: true,
@@ -330,6 +351,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: true,
@@ -380,6 +404,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: true,
@@ -415,6 +442,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: true,
@@ -465,6 +495,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: true,
@@ -515,6 +548,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: true,
@@ -565,6 +601,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: true,
@@ -615,6 +654,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: true,
@@ -650,6 +692,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: true,
@@ -702,6 +747,9 @@ vows.describe(optimizationLevelFrom)
         assert.deepEqual(levelOptions['0'], {});
       },
       'has level 1 options': function (levelOptions) {
+        assert.isTrue(typeof levelOptions['1'].transform == 'function');
+        delete levelOptions['1'].transform;
+
         assert.deepEqual(levelOptions['1'], {
           cleanupCharsets: true,
           normalizeUrls: true,