Adds support for remote source maps.
authorJakub Pawlowicz <contact@jakubpawlowicz.com>
Sun, 16 Nov 2014 12:33:21 +0000 (12:33 +0000)
committerJakub Pawlowicz <contact@jakubpawlowicz.com>
Mon, 8 Dec 2014 09:39:15 +0000 (09:39 +0000)
* http, https, and same protocol (//) are supported.
* Options from `inliner` hash are used for timeouts / extra request options.
* Errors & timeouts are handled gracefully.
* Apparently nock.restore() should be nock.cleanAll().

lib/clean.js
lib/imports/inliner.js
lib/utils/input-source-map-tracker.js
test/protocol-imports-test.js
test/source-map-test.js

index be1a4f8..4374f57 100644 (file)
@@ -78,10 +78,7 @@ CleanCSS.prototype.minify = function(data, callback) {
 };
 
 function runMinifier(callback, self) {
-  return function (data) {
-    if (self.options.sourceMap)
-      self.inputSourceMapTracker = new InputSourceMapTracker(self.options).track(data);
-
+  function whenSourceMapReady (data) {
     data = self.options.debug ?
       minifyWithDebug(self, data) :
       minify.call(self, data);
@@ -89,6 +86,15 @@ function runMinifier(callback, self) {
     return callback ?
       callback.call(self, self.context.errors.length > 0 ? self.context.errors : null, data) :
       data;
+  }
+
+  return function (data) {
+    if (self.options.sourceMap) {
+      self.inputSourceMapTracker = new InputSourceMapTracker(self.options, self.context);
+      return self.inputSourceMapTracker.track(data, function () { return whenSourceMapReady(data); });
+    } else {
+      return whenSourceMapReady(data);
+    }
   };
 }
 
index c6541f2..1d124a4 100644 (file)
@@ -7,6 +7,9 @@ var url = require('url');
 var UrlRewriter = require('../images/url-rewriter');
 var Splitter = require('../utils/splitter.js');
 
+var MAP_MARKER = /\/\*# sourceMappingURL=(\S+) \*\//;
+var REMOTE_RESOURCE = /^(https?:)?\/\//;
+
 var merge = function(source1, source2) {
   var target = {};
   for (var key1 in source1)
@@ -17,6 +20,14 @@ var merge = function(source1, source2) {
   return target;
 };
 
+function rebaseMap(data, source) {
+  return data.replace(MAP_MARKER, function (match, sourceMapUrl) {
+    return REMOTE_RESOURCE.test(sourceMapUrl) ?
+      match :
+      match.replace(sourceMapUrl, url.resolve(source, sourceMapUrl));
+  });
+}
+
 function wrap(data, source) {
   return '__ESCAPED_SOURCE_CLEAN_CSS(' + source + ')__' +
     data +
@@ -195,9 +206,7 @@ module.exports = function Inliner(context, options) {
       .replace(/^\)/, '')
       .trim();
 
-    var isRemote = options.isRemote ||
-      /^(http|https):\/\//.test(importedFile) ||
-      /^\/\//.test(importedFile);
+    var isRemote = options.isRemote || REMOTE_RESOURCE.test(importedFile);
 
     if (options.localOnly && isRemote) {
       context.warnings.push('Ignoring remote @import declaration of "' + importedFile + '" as no callback given.');
@@ -211,7 +220,7 @@ module.exports = function Inliner(context, options) {
   };
 
   var inlineRemoteResource = function(importedFile, mediaQuery, options) {
-    var importedUrl = /^https?:\/\//.test(importedFile) ?
+    var importedUrl = REMOTE_RESOURCE.test(importedFile) ?
       importedFile :
       url.resolve(options.relativeTo, importedFile);
 
@@ -255,7 +264,7 @@ module.exports = function Inliner(context, options) {
       res.on('end', function() {
         var importedData = chunks.join('');
         importedData = UrlRewriter.process(importedData, { toBase: importedUrl });
-        importedData = wrap(importedData, url);
+        importedData = rebaseMap(wrap(importedData, importedUrl), importedUrl);
 
         if (mediaQuery.length > 0)
           importedData = '@media ' + mediaQuery + '{' + importedData + '}';
index faf52a4..f2f200f 100644 (file)
@@ -2,31 +2,57 @@ var SourceMapConsumer = require('source-map').SourceMapConsumer;
 
 var fs = require('fs');
 var path = require('path');
+var http = require('http');
+var https = require('https');
+var url = require('url');
 
 var SOURCE_MARKER_START = /__ESCAPED_SOURCE_CLEAN_CSS\(([^~][^\)]+)\)__/;
 var SOURCE_MARKER_END = /__ESCAPED_SOURCE_END_CLEAN_CSS__/;
 var MAP_MARKER = /\/\*# sourceMappingURL=(\S+) \*\//;
 
-function InputSourceMapStore(options) {
+var DEFAULT_TIMEOUT = 5000;
+
+
+function InputSourceMapStore(options, outerContext) {
   this.options = options;
+  this.errors = outerContext.errors;
+  this.timeout = (options.inliner && options.inliner.timeout) || DEFAULT_TIMEOUT;
+  this.requestOptions = (options.inliner && options.inliner.request) || {};
+
   this.maps = {};
 }
 
-InputSourceMapStore.prototype.track = function (data) {
-  if (typeof this.options.sourceMap == 'string') {
-    this.maps[undefined] = new SourceMapConsumer(this.options.sourceMap);
-    return this;
+function merge(source1, source2) {
+  var target = {};
+  for (var key1 in source1)
+    target[key1] = source1[key1];
+  for (var key2 in source2)
+    target[key2] = source2[key2];
+
+  return target;
+}
+
+function fromString(self, data, whenDone) {
+  self.maps[undefined] = new SourceMapConsumer(self.options.sourceMap);
+  return whenDone();
+}
+
+function fromSource(self, data, whenDone, context) {
+  var nextAt = 0;
+
+  function proceedToNext() {
+    context.cursor += nextAt + 1;
+    fromSource(self, data, whenDone, context);
   }
 
-  var files = [];
-  for (var cursor = 0, len = data.length; cursor < len; ) {
-    var fragment = data.substring(cursor, len);
+  while (context.cursor < data.length) {
+    var fragment = data.substring(context.cursor);
 
     var markerStartMatch = SOURCE_MARKER_START.exec(fragment) || { index: -1 };
     var markerEndMatch = SOURCE_MARKER_END.exec(fragment) || { index: -1 };
     var mapMatch = MAP_MARKER.exec(fragment) || { index: -1 };
 
-    var nextAt = len;
+    nextAt = data.length;
     if (markerStartMatch.index > -1)
       nextAt = markerStartMatch.index;
     if (markerEndMatch.index > -1 && markerEndMatch.index < nextAt)
@@ -34,22 +60,65 @@ InputSourceMapStore.prototype.track = function (data) {
     if (mapMatch.index > -1 && mapMatch.index < nextAt)
       nextAt = mapMatch.index;
 
-    if (nextAt == len)
+    if (nextAt == data.length)
       break;
 
     if (nextAt == markerStartMatch.index) {
-      files.push(markerStartMatch[1]);
+      context.files.push(markerStartMatch[1]);
     } else if (nextAt == markerEndMatch.index) {
-      files.pop();
+      context.files.pop();
     } else if (nextAt == mapMatch.index) {
-      var inputMapData = fs.readFileSync(path.join(this.options.root || '', mapMatch[1]), 'utf-8');
-      this.maps[files[files.length - 1] || undefined] = new SourceMapConsumer(inputMapData);
+      var isRemote = /^https?:\/\//.test(mapMatch[1]) || /^\/\//.test(mapMatch[1]);
+      if (isRemote) {
+        return fetchMapFile(self, mapMatch[1], context, proceedToNext);
+      } else {
+        var inputMapData = fs.readFileSync(path.join(self.options.root || '', mapMatch[1]), 'utf-8');
+        self.maps[context.files[context.files.length - 1] || undefined] = new SourceMapConsumer(inputMapData);
+      }
     }
 
-    cursor += nextAt + 1;
+    context.cursor += nextAt + 1;
   }
 
-  return this;
+  return whenDone();
+}
+
+function fetchMapFile(self, mapSource, context, done) {
+  function handleError(status) {
+    context.errors.push('Broken source map at "' + mapSource + '" - ' + status);
+    return done();
+  }
+
+  var method = mapSource.indexOf('https') === 0 ? https : http;
+  var requestOptions = merge(url.parse(mapSource), self.requestOptions);
+
+  method
+    .get(requestOptions, function (res) {
+      if (res.statusCode < 200 || res.statusCode > 299)
+        return handleError(res.statusCode);
+
+      var chunks = [];
+      res.on('data', function (chunk) {
+        chunks.push(chunk.toString());
+      });
+      res.on('end', function () {
+        self.maps[context.files[context.files.length - 1] || undefined] = new SourceMapConsumer(chunks.join(''));
+        done();
+      });
+    })
+    .on('error', function(res) {
+      handleError(res.message);
+    })
+    .on('timeout', function() {
+      handleError('timeout');
+    })
+    .setTimeout(self.timeout);
+}
+
+InputSourceMapStore.prototype.track = function (data, whenDone) {
+  return typeof this.options.sourceMap == 'string' ?
+    fromString(this, data, whenDone) :
+    fromSource(this, data, whenDone, { files: [], cursor: 0, errors: this.errors });
 };
 
 InputSourceMapStore.prototype.isTracking = function () {
index 32a2fe0..ad25a53 100644 (file)
@@ -28,7 +28,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of an existing file': {
@@ -47,7 +47,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of an existing file with spaces in path': {
@@ -66,7 +66,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of an existing file via HTTPS': {
@@ -85,7 +85,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of an existing file with media': {
@@ -104,7 +104,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of an existing file with dependencies': {
@@ -129,7 +129,7 @@ vows.describe('protocol imports').addBatch({
     teardown: function() {
       assert.equal(this.reqMocks1.isDone(), true);
       assert.equal(this.reqMocks2.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of an existing file with relative dependencies': {
@@ -150,7 +150,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of an existing file missing relative dependency': {
@@ -172,7 +172,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of an existing file with URLs to rebase': {
@@ -191,7 +191,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of an existing file with relative URLs to rebase': {
@@ -212,7 +212,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of a non-resolvable domain': {
@@ -245,7 +245,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of a 30x response with relative URL': {
@@ -266,7 +266,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of a timed out response': {
@@ -284,7 +284,7 @@ vows.describe('protocol imports').addBatch({
         }).minify('@import url(http://localhost:' + port + '/timeout.css);a{color:red}', self.callback);
       });
     },
-    'should not raise errors': function(errors, minified) {
+    'should raise errors': function(errors, minified) {
       assert.equal(errors.length, 1);
       assert.equal(errors[0], 'Broken @import declaration of "http://localhost:' + port + '/timeout.css" - timeout');
     },
@@ -313,7 +313,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of a resource without protocol': {
@@ -332,7 +332,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of a resource available via POST only': {
@@ -357,7 +357,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of a remote resource mixed with local ones': {
@@ -377,7 +377,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), true);
-      nock.restore();
+      nock.cleanAll();
     }
   },
   'of a remote resource mixed with local ones but no callback': {
@@ -403,7 +403,7 @@ vows.describe('protocol imports').addBatch({
     },
     teardown: function() {
       assert.equal(this.reqMocks.isDone(), false);
-      nock.restore();
+      nock.cleanAll();
     }
   }
 }).export(module);
index 3c655e6..535f5bb 100644 (file)
@@ -1,3 +1,5 @@
+/* jshint unused: false */
+
 var vows = require('vows');
 var assert = require('assert');
 var CleanCSS = require('../index');
@@ -7,6 +9,11 @@ var path = require('path');
 var inputMapPath = path.join('test', 'data', 'source-maps', 'styles.css.map');
 var inputMap = fs.readFileSync(inputMapPath, 'utf-8');
 
+var nock = require('nock');
+var http = require('http');
+
+var port = 24682;
+
 vows.describe('source-map')
   .addBatch({
     'module #1': {
@@ -335,4 +342,133 @@ vows.describe('source-map')
       }
     }
   })
+  .addBatch({
+    'invalid response for external source map': {
+      topic: function () {
+        this.reqMocks = nock('http://127.0.0.1')
+          .get('/remote.css')
+          .reply(200, '/*# sourceMappingURL=http://127.0.0.1/remote.css.map */')
+          .get('/remote.css.map')
+          .reply(404);
+
+        new CleanCSS({ sourceMap: true }).minify('@import url(http://127.0.0.1/remote.css);', this.callback);
+      },
+      'has mapping': function (errors, minified) {
+        assert.isDefined(minified.sourceMap);
+      },
+      'raises an error': function(errors, _) {
+        assert.equal(errors.length, 1);
+        assert.equal(errors[0], 'Broken source map at "http://127.0.0.1/remote.css.map" - 404');
+      },
+      teardown: function () {
+        assert.equal(this.reqMocks.isDone(), true);
+        nock.cleanAll();
+      }
+    },
+    'timed out response for external source map': {
+      topic: function() {
+        var self = this;
+        var timeout = 100;
+
+        this.server = http.createServer(function(req, res) {
+          switch (req.url) {
+            case '/remote.css':
+              res.writeHead(200);
+              res.write('/*# sourceMappingURL=http://127.0.0.1:' + port + '/remote.css.map */');
+              res.end();
+              break;
+            case '/remote.css.map':
+              setTimeout(function() {}, timeout * 2);
+          }
+        });
+        this.server.listen(port, '127.0.0.1', function() {
+          new CleanCSS({ sourceMap: true, inliner: { timeout: timeout } })
+            .minify('@import url(http://127.0.0.1:' + port + '/remote.css);', self.callback);
+        });
+      },
+      'has mapping': function (errors, minified) {
+        assert.isDefined(minified.sourceMap);
+      },
+      'raises an error': function(errors, _) {
+        assert.equal(errors.length, 1);
+        assert.equal(errors[0], 'Broken source map at "http://127.0.0.1:' + port + '/remote.css.map" - timeout');
+      },
+      teardown: function () {
+        this.server.close();
+      }
+    },
+    'absolute source map from external host via http': {
+      topic: function () {
+        this.reqMocks = nock('http://127.0.0.1')
+          .get('/remote.css')
+          .reply(200, '/*# sourceMappingURL=http://127.0.0.1/remote.css.map */')
+          .get('/remote.css.map')
+          .reply(200, inputMap);
+
+        new CleanCSS({ sourceMap: true }).minify('@import url(http://127.0.0.1/remote.css);', this.callback);
+      },
+      'has mapping': function (errors, minified) {
+        assert.isDefined(minified.sourceMap);
+      },
+      teardown: function () {
+        assert.equal(this.reqMocks.isDone(), true);
+        nock.cleanAll();
+      }
+    },
+    'absolute source map from external host via https': {
+      topic: function () {
+        this.reqMocks = nock('https://127.0.0.1')
+          .get('/remote.css')
+          .reply(200, '/*# sourceMappingURL=https://127.0.0.1/remote.css.map */')
+          .get('/remote.css.map')
+          .reply(200, inputMap);
+
+        new CleanCSS({ sourceMap: true }).minify('@import url(https://127.0.0.1/remote.css);', this.callback);
+      },
+      'has mapping': function (errors, minified) {
+        assert.isDefined(minified.sourceMap);
+      },
+      teardown: function () {
+        assert.equal(this.reqMocks.isDone(), true);
+        nock.cleanAll();
+      }
+    },
+    'relative source map from external host': {
+      topic: function () {
+        this.reqMocks = nock('http://127.0.0.1')
+          .get('/remote.css')
+          .reply(200, '/*# sourceMappingURL=remote.css.map */')
+          .get('/remote.css.map')
+          .reply(200, inputMap);
+
+        new CleanCSS({ sourceMap: true }).minify('@import url(http://127.0.0.1/remote.css);', this.callback);
+      },
+      'has mapping': function (errors, minified) {
+        assert.isDefined(minified.sourceMap);
+      },
+      teardown: function () {
+        assert.equal(this.reqMocks.isDone(), true);
+        nock.cleanAll();
+      }
+    },
+    'available via POST only': {
+      topic: function () {
+        this.reqMocks = nock('http://127.0.0.1')
+          .post('/remote.css')
+          .reply(200, '/*# sourceMappingURL=remote.css.map */')
+          .post('/remote.css.map')
+          .reply(200, inputMap);
+
+        new CleanCSS({ sourceMap: true, inliner: { request: { method: 'POST' } } })
+          .minify('@import url(http://127.0.0.1/remote.css);', this.callback);
+      },
+      'has mapping': function (errors, minified) {
+        assert.isDefined(minified.sourceMap);
+      },
+      teardown: function () {
+        assert.equal(this.reqMocks.isDone(), true);
+        nock.cleanAll();
+      }
+    }
+  })
   .export(module);