Fixes #632 - adds disabling remote imports.
authorJakub Pawlowicz <contact@jakubpawlowicz.com>
Tue, 18 Aug 2015 06:45:52 +0000 (07:45 +0100)
committerJakub Pawlowicz <contact@jakubpawlowicz.com>
Tue, 25 Aug 2015 07:01:51 +0000 (08:01 +0100)
So far we only had an option to skip inlining of all imports, but
with this commit a fine-grained control is added, e.g. import from
all but fonts.googleapis.com:

API: `new CleanCSS({ processImportFrom: ['!fonts.googleapis.com'] })`
CLI: `cleancss --skip-import-from fonts.googleapis.com`

To skip all local imports:

API: `new CleanCSS({ processImportFrom: ['remote'] })`
CLI: `cleancss --skip-import-from local`

To skip all remote and certain local imports:

API: `new CleanCSS({ processImportFrom: ['local', '!path/to/file'] })`
CLI: `cleancss --skip-import-from remote,path/to/file`

History.md
README.md
bin/cleancss
lib/clean.js
lib/imports/inliner.js
test/binary-test.js
test/protocol-imports-test.js

index c4db416..6f5d104 100644 (file)
@@ -1,6 +1,7 @@
 [3.4.0 / 2015-xx-xx](https://github.com/jakubpawlowicz/clean-css/compare/v3.3.7...master)
 ==================
 
+* Adds an option for a fine-grained `@import` control.
 * Adds unit compatibility switches to disable length optimizations.
 * Adds inferring proxy settings from HTTP_PROXY environment variable.
 * Adds support for Polymer / Web Components special selectors.
@@ -12,6 +13,7 @@
 * Fixed issue [#612](https://github.com/jakubpawlowicz/clean-css/issues/612) - adds HTTP proxy support.
 * Fixed issue [#618](https://github.com/jakubpawlowicz/clean-css/issues/618) - adds safer function validation.
 * Fixed issue [#625](https://github.com/jakubpawlowicz/clean-css/issues/625) - adds length unit optimizations.
+* Fixed issue [#632](https://github.com/jakubpawlowicz/clean-css/issues/632) - adds disabling remote `import`s.
 * Fixed issue [#635](https://github.com/jakubpawlowicz/clean-css/issues/635) - adds safer `0%` optimizations.
 * Fixed issue [#644](https://github.com/jakubpawlowicz/clean-css/issues/644) - adds time unit optimizations.
 * Fixed issue [#645](https://github.com/jakubpawlowicz/clean-css/issues/645) - adds bottom to top `media` merging.
index d129319..05e2504 100644 (file)
--- a/README.md
+++ b/README.md
@@ -48,6 +48,7 @@ cleancss [options] source-file, [source-file, ...]
 --semantic-merging             Enables unsafe mode by assuming BEM-like semantic stylesheets (warning, this may break your styling!)
 --skip-advanced                Disable advanced optimizations - ruleset reordering & merging
 --skip-aggressive-merging      Disable properties merging based on their order
+--skip-import-from [rules]     Disable @import processing for specified rules
 --skip-media-merging           Disable @media merging
 --skip-rebase                  Disable URLs rebasing
 --skip-restructuring           Disable restructuring optimizations
@@ -110,6 +111,7 @@ CleanCSS constructor accepts a hash as a parameter, i.e.,
 * `keepSpecialComments` - `*` for keeping all (default), `1` for keeping first one only, `0` for removing all
 * `mediaMerging` - whether to merge `@media` at-rules (default is true)
 * `processImport` - whether to process `@import` rules
+* `processImportFrom` - a list of `@import` rules, can be `['all']` (default), `['local']`, `['remote']`, or a blacklisted path e.g. `['!fonts.googleapis.com']`
 * `rebase` - set to false to skip URL rebasing
 * `relativeTo` - path to **resolve** relative `@import` rules and URLs
 * `restructuring` - set to false to disable restructuring in advanced optimizations
index 0ff74f7..5f95b7e 100755 (executable)
@@ -28,6 +28,7 @@ commands
   .option('--semantic-merging', 'Enables unsafe mode by assuming BEM-like semantic stylesheets (warning, this may break your styling!)')
   .option('--skip-advanced', 'Disable advanced optimizations - ruleset reordering & merging')
   .option('--skip-aggressive-merging', 'Disable properties merging based on their order')
+  .option('--skip-import-from [rules]', 'Disable @import processing for specified rules', function (val) { return val.split(','); }, [])
   .option('--skip-media-merging', 'Disable @media merging')
   .option('--skip-rebase', 'Disable URLs rebasing')
   .option('--skip-restructuring', 'Disable restructuring optimizations')
@@ -69,6 +70,7 @@ var options = {
   keepSpecialComments: commands.s0 ? 0 : (commands.s1 ? 1 : '*'),
   mediaMerging: commands.skipMediaMerging ? false : true,
   processImport: commands.skipImport ? false : true,
+  processImportFrom: processImportFrom(commands.skipImportFrom),
   rebase: commands.skipRebase ? false : true,
   restructuring: commands.skipRestructuring ? false : true,
   root: commands.root,
@@ -103,6 +105,23 @@ if (commands.args.length > 0) {
   });
 }
 
+function processImportFrom(rules) {
+  if (rules.length === 0) {
+    return ['all'];
+  } else if (rules.length == 1 && rules[0] == 'all') {
+    return [];
+  } else {
+    return rules.map(function (rule) {
+      if (rule == 'local')
+        return 'remote';
+      else if (rule == 'remote')
+        return 'local';
+      else
+        return '!' + rule;
+    });
+  }
+}
+
 function minify(data) {
   new CleanCSS(options).minify(data, function (errors, minified) {
     if (options.debug) {
index 1ef29cb..8b8db74 100644 (file)
@@ -50,6 +50,7 @@ var CleanCSS = module.exports = function CleanCSS(options) {
     keepSpecialComments: 'keepSpecialComments' in options ? options.keepSpecialComments : '*',
     mediaMerging: undefined === options.mediaMerging ? true : !!options.mediaMerging,
     processImport: undefined === options.processImport ? true : !!options.processImport,
+    processImportFrom: importOptionsFrom(options.processImportFrom),
     rebase: undefined === options.rebase ? true : !!options.rebase,
     relativeTo: options.relativeTo,
     restructuring: undefined === options.restructuring ? true : !!options.restructuring,
@@ -70,6 +71,10 @@ var CleanCSS = module.exports = function CleanCSS(options) {
   );
 };
 
+function importOptionsFrom(rules) {
+  return undefined === rules ? ['all'] : rules;
+}
+
 function missingDirectory(filepath) {
   return !fs.existsSync(filepath) && !/\.css$/.test(filepath);
 }
@@ -114,6 +119,7 @@ CleanCSS.prototype.minify = function (data, callback) {
     return runner(function () {
       return new ImportInliner(context).process(data, {
         localOnly: context.localOnly,
+        imports: context.options.processImportFrom,
         whenDone: runMinifier(callback, context)
       });
     });
index e4ffad3..9a58651 100644 (file)
@@ -10,6 +10,7 @@ var override = require('../utils/object.js').override;
 
 var MAP_MARKER = /\/\*# sourceMappingURL=(\S+) \*\//;
 var REMOTE_RESOURCE = /^(https?:)?\/\//;
+var NO_PROTOCOL_RESOURCE = /^\/\//;
 
 function ImportInliner (context) {
   this.outerContext = context;
@@ -193,7 +194,7 @@ function inline(data, nextStart, nextEnd, context) {
 
   var isRemote = context.isRemote || REMOTE_RESOURCE.test(importedFile);
 
-  if (context.localOnly && isRemote) {
+  if (isRemote && (context.localOnly || !allowedResource(importedFile, true, context.imports))) {
     if (context.afterContent || hasContent(context.done.join('')))
       context.warnings.push('Ignoring remote @import of "' + importedFile + '" as no callback given.');
     else
@@ -202,6 +203,14 @@ function inline(data, nextStart, nextEnd, context) {
     return processNext(context);
   }
 
+  if (!isRemote && !allowedResource(importedFile, false, context.imports)) {
+    if (context.afterImport)
+      context.warnings.push('Ignoring local @import of "' + importedFile + '" as after other inlined content.');
+    else
+      restoreImport(importedFile, mediaQuery, context);
+    return processNext(context);
+  }
+
   if (!isRemote && context.afterContent) {
     context.warnings.push('Ignoring local @import of "' + importedFile + '" as after other CSS content.');
     return processNext(context);
@@ -211,13 +220,45 @@ function inline(data, nextStart, nextEnd, context) {
   return method(importedFile, mediaQuery, context);
 }
 
+function allowedResource(importedFile, isRemote, rules) {
+  if (rules.length === 0)
+    return false;
+
+  if (isRemote && NO_PROTOCOL_RESOURCE.test(importedFile))
+    importedFile = 'http:' + importedFile;
+
+  var match = isRemote ?
+    url.parse(importedFile).host :
+    importedFile;
+  var allowed = true;
+
+  for (var i = 0; i < rules.length; i++) {
+    var rule = rules[i];
+
+    if (rule == 'all')
+      allowed = true;
+    else if (isRemote && rule == 'local')
+      allowed = false;
+    else if (isRemote && rule == 'remote')
+      allowed = true;
+    else if (!isRemote && rule == 'remote')
+      allowed = false;
+    else if (!isRemote && rule == 'local')
+      allowed = true;
+    else if (rule[0] == '!' && rule.substring(1) === match)
+      allowed = false;
+  }
+
+  return allowed;
+}
+
 function inlineRemoteResource(importedFile, mediaQuery, context) {
   var importedUrl = REMOTE_RESOURCE.test(importedFile) ?
     importedFile :
     url.resolve(context.relativeTo, importedFile);
   var originalUrl = importedUrl;
 
-  if (importedUrl.indexOf('//') === 0)
+  if (NO_PROTOCOL_RESOURCE.test(importedUrl))
     importedUrl = 'http:' + importedUrl;
 
   if (context.visited.indexOf(importedUrl) > -1)
@@ -275,6 +316,8 @@ function inlineRemoteResource(importedFile, mediaQuery, context) {
       if (mediaQuery.length > 0)
         importedData = '@media ' + mediaQuery + '{' + importedData + '}';
 
+      context.afterImport = true;
+
       var newContext = override(context, {
         isRemote: true,
         relativeTo: parsedUrl.protocol + '//' + parsedUrl.host + parsedUrl.pathname
@@ -333,6 +376,8 @@ function inlineLocalResource(importedFile, mediaQuery, context) {
   if (mediaQuery.length > 0)
     importedData = '@media ' + mediaQuery + '{' + importedData + '}';
 
+  context.afterImport = true;
+
   var newContext = override(context, {
     relativeTo: importRelativeTo
   });
index b60c9ac..b743354 100644 (file)
@@ -214,6 +214,23 @@ vows.describe('./bin/cleancss')
       }
     })
   })
+  .addBatch({
+    'disable all @import': pipedContext('@import url(http://127.0.0.1/remote.css);@import url(test/fixtures/partials/one.css);', '--skip-import-from all', {
+      'should disable the remote import processing': function (error, stdout) {
+        assert.equal(stdout, '@import url(http://127.0.0.1/remote.css);@import url(test/fixtures/partials/one.css);');
+      }
+    }),
+    'disable remote @import': pipedContext('@import url(http://127.0.0.1/remote.css);@import url(test/fixtures/partials/one.css);', '--skip-import-from remote', {
+      'should disable the remote import processing': function (error, stdout) {
+        assert.equal(stdout, '@import url(http://127.0.0.1/remote.css);.one{color:red}');
+      }
+    }),
+    'disable remote @import by host': pipedContext('@import url(http://127.0.0.1/remote.css);@import url(test/fixtures/partials/one.css);', '--skip-import-from 127.0.0.1', {
+      'should disable the remote import processing': function (error, stdout) {
+        assert.equal(stdout, '@import url(http://127.0.0.1/remote.css);.one{color:red}');
+      }
+    })
+  })
   .addBatch({
     'relative image paths': {
       'no root & output': binaryContext('./test/fixtures/partials-relative/base.css', {
index d20cf95..502a4c5 100644 (file)
@@ -459,6 +459,21 @@ vows.describe('protocol imports').addBatch({
       nock.cleanAll();
     }
   },
+  'of a remote resource mixed with local ones and disabled remote imports': {
+    topic: function () {
+      var source = '@import url(http://127.0.0.1/skipped.css);@import url(test/fixtures/partials/one.css);';
+      new CleanCSS({ processImportFrom: ['local'] }).minify(source, this.callback);
+    },
+    'should not raise errors': function (error, minified) {
+      assert.isEmpty(minified.errors);
+    },
+    'should not raise warnings': function (error, minified) {
+      assert.isEmpty(minified.warnings);
+    },
+    'should keep imports': function (error, minified) {
+      assert.equal(minified.styles, '@import url(http://127.0.0.1/skipped.css);.one{color:red}');
+    }
+  },
   'of a remote file that imports relative stylesheets': {
     topic: function () {
       var source = '@import url(http://127.0.0.1/test/folder/remote.css);';
@@ -636,4 +651,144 @@ vows.describe('protocol imports').addBatch({
       delete process.env.http_proxy;
     }
   }
+}).addBatch({
+  'allowed imports - not set': {
+    topic: function () {
+      var source = '@import url(http://127.0.0.1/remote.css);@import url(http://assets.127.0.0.1/remote.css);@import url(test/fixtures/partials/one.css);';
+      this.reqMocks1 = nock('http://127.0.0.1')
+        .get('/remote.css')
+        .reply(200, 'div{border:0}');
+      this.reqMocks2 = nock('http://assets.127.0.0.1')
+        .get('/remote.css')
+        .reply(200, 'p{width:100%}');
+
+      new CleanCSS().minify(source, this.callback);
+    },
+    'should not raise errors': function (error, minified) {
+      assert.isEmpty(minified.errors);
+    },
+    'should not raise warnings': function (error, minified) {
+      assert.isEmpty(minified.warnings);
+    },
+    'should process imports': function (error, minified) {
+      assert.equal(minified.styles, 'div{border:0}p{width:100%}.one{color:red}');
+    },
+    teardown: function () {
+      assert.isTrue(this.reqMocks1.isDone());
+      assert.isTrue(this.reqMocks2.isDone());
+      nock.cleanAll();
+    }
+  },
+  'allowed imports - not set and disabled by processImport': {
+    topic: function () {
+      var source = '@import url(http://127.0.0.1/remote.css);@import url(http://assets.127.0.0.1/remote.css);@import url(test/fixtures/partials/one.css);';
+      new CleanCSS({ processImport: false }).minify(source, this.callback);
+    },
+    'should not raise errors': function (error, minified) {
+      assert.isEmpty(minified.errors);
+    },
+    'should not raise warnings': function (error, minified) {
+      assert.isEmpty(minified.warnings);
+    },
+    'should process imports': function (error, minified) {
+      assert.equal(minified.styles, '@import url(http://127.0.0.1/remote.css);@import url(http://assets.127.0.0.1/remote.css);@import url(test/fixtures/partials/one.css);');
+    }
+  },
+  'allowed imports - local': {
+    topic: function () {
+      var source = '@import url(http://127.0.0.1/remote.css);@import url(http://assets.127.0.0.1/remote.css);@import url(test/fixtures/partials/one.css);';
+      new CleanCSS({ processImportFrom: ['local'] }).minify(source, this.callback);
+    },
+    'should not raise errors': function (error, minified) {
+      assert.isEmpty(minified.errors);
+    },
+    'should not raise warnings': function (error, minified) {
+      assert.isEmpty(minified.warnings);
+    },
+    'should keeps imports': function (error, minified) {
+      assert.equal(minified.styles, '@import url(http://127.0.0.1/remote.css);@import url(http://assets.127.0.0.1/remote.css);.one{color:red}');
+    }
+  },
+  'allowed imports - remote': {
+    topic: function () {
+      var source = '@import url(http://127.0.0.1/remote.css);@import url(http://assets.127.0.0.1/remote.css);@import url(test/fixtures/partials/one.css);';
+      this.reqMocks1 = nock('http://127.0.0.1')
+        .get('/remote.css')
+        .reply(200, 'div{border:0}');
+      this.reqMocks2 = nock('http://assets.127.0.0.1')
+        .get('/remote.css')
+        .reply(200, 'p{width:100%}');
+      new CleanCSS({ processImportFrom: ['remote'] }).minify(source, this.callback);
+    },
+    'should not raise errors': function (error, minified) {
+      assert.isEmpty(minified.errors);
+    },
+    'should not raise warnings': function (error, minified) {
+      assert.lengthOf(minified.warnings, 1);
+    },
+    'should process imports': function (error, minified) {
+      assert.equal(minified.styles, 'div{border:0}p{width:100%}');
+    },
+    teardown: function () {
+      assert.isTrue(this.reqMocks1.isDone());
+      assert.isTrue(this.reqMocks2.isDone());
+      nock.cleanAll();
+    }
+  },
+  'allowed imports - all': {
+    topic: function () {
+      var source = '@import url(http://127.0.0.1/remote.css);@import url(http://assets.127.0.0.1/remote.css);@import url(test/fixtures/partials/one.css);';
+      this.reqMocks1 = nock('http://127.0.0.1')
+        .get('/remote.css')
+        .reply(200, 'div{border:0}');
+      this.reqMocks2 = nock('http://assets.127.0.0.1')
+        .get('/remote.css')
+        .reply(200, 'p{width:100%}');
+      new CleanCSS({ processImportFrom: ['all'] }).minify(source, this.callback);
+    },
+    'should not raise errors': function (error, minified) {
+      assert.isEmpty(minified.errors);
+    },
+    'should not raise warnings': function (error, minified) {
+      assert.isEmpty(minified.warnings);
+    },
+    'should process imports': function (error, minified) {
+      assert.equal(minified.styles, 'div{border:0}p{width:100%}.one{color:red}');
+    },
+    teardown: function () {
+      assert.isTrue(this.reqMocks1.isDone());
+      assert.isTrue(this.reqMocks2.isDone());
+      nock.cleanAll();
+    }
+  },
+  'allowed imports - blacklisted': {
+    topic: function () {
+      var source = '@import url(http://127.0.0.1/remote.css);@import url(http://assets.127.0.0.1/remote.css);@import url(test/fixtures/partials/one.css);';
+      new CleanCSS({ processImportFrom: ['remote', 'local', '!assets.127.0.0.1', '!127.0.0.1', '!test/fixtures/partials/one.css'] }).minify(source, this.callback);
+    },
+    'should not raise errors': function (error, minified) {
+      assert.isEmpty(minified.errors);
+    },
+    'should raise a warning': function (error, minified) {
+      assert.isEmpty(minified.warnings);
+    },
+    'should process first imports': function (error, minified) {
+      assert.equal(minified.styles, '@import url(http://127.0.0.1/remote.css);@import url(http://assets.127.0.0.1/remote.css);@import url(test/fixtures/partials/one.css);');
+    }
+  },
+  'allowed imports - blacklisted & no-protocol': {
+    topic: function () {
+      var source = '@import url(//127.0.0.1/remote.css);@import url(test/fixtures/partials/one.css);';
+      new CleanCSS({ processImportFrom: ['!127.0.0.1'] }).minify(source, this.callback);
+    },
+    'should not raise errors': function (error, minified) {
+      assert.isEmpty(minified.errors);
+    },
+    'should raise a warning': function (error, minified) {
+      assert.isEmpty(minified.warnings);
+    },
+    'should process first imports': function (error, minified) {
+      assert.equal(minified.styles, '@import url(//127.0.0.1/remote.css);.one{color:red}');
+    }
+  }
 }).export(module);