Adds tracking input source maps in imported files.
authorJakub Pawlowicz <contact@jakubpawlowicz.com>
Wed, 12 Nov 2014 23:10:57 +0000 (23:10 +0000)
committerJakub Pawlowicz <contact@jakubpawlowicz.com>
Mon, 8 Dec 2014 09:39:15 +0000 (09:39 +0000)
* InputSourceMapTracker tracks source maps on per-file basis.

12 files changed:
lib/clean.js
lib/imports/inliner.js
lib/selectors/source-map-stringifier.js
lib/selectors/tokenizer.js
lib/utils/input-source-map-tracker.js [new file with mode: 0644]
test/data/source-maps/import.css [new file with mode: 0644]
test/data/source-maps/some.css [new file with mode: 0644]
test/data/source-maps/some.css.map [new file with mode: 0644]
test/data/source-maps/styles.css [new file with mode: 0644]
test/data/source-maps/styles.css.map [moved from test/data/source-maps/sample.map with 62% similarity]
test/selectors/tokenizer-source-maps-test.js
test/source-map-test.js

index 7be19d3..29b111b 100644 (file)
@@ -17,11 +17,7 @@ var FreeTextProcessor = require('./text/free-text-processor');
 var UrlsProcessor = require('./text/urls-processor');
 
 var Compatibility = require('./utils/compatibility');
-
-var fs = require('fs');
-var path = require('path');
-
-var SOURCE_MAP_MARKER = '/*# sourceMappingURL=';
+var InputSourceMapTracker = require('./utils/input-source-map-tracker');
 
 var CleanCSS = module.exports = function CleanCSS(options) {
   options = options || {};
@@ -62,9 +58,6 @@ CleanCSS.prototype.minify = function(data, callback) {
   if (Buffer.isBuffer(data))
     data = data.toString();
 
-  if (data.indexOf(SOURCE_MAP_MARKER) > 0)
-    overrideSourceMap(self.options, data);
-
   if (options.processImport || data.indexOf('@shallow') > 0) {
     // inline all imports
     var runner = callback ?
@@ -84,14 +77,10 @@ CleanCSS.prototype.minify = function(data, callback) {
   }
 };
 
-function overrideSourceMap(options, source) {
-  var markerAt = source.indexOf(SOURCE_MAP_MARKER);
-  var inputMapPath = source.substring(markerAt + SOURCE_MAP_MARKER.length, source.indexOf('*/', markerAt)).trim();
-  options.sourceMap = fs.readFileSync(path.join(options.root || '', inputMapPath), 'utf-8');
-}
-
 function runMinifier(callback, self) {
   return function (data) {
+    self.inputSourceMapTracker = new InputSourceMapTracker(self.options).track(data);
+
     data = self.options.debug ?
       minifyWithDebug(self, data) :
       minify.call(self, data);
@@ -104,7 +93,7 @@ function runMinifier(callback, self) {
 
 function minifyWithDebug(self, data) {
   var startedAt = process.hrtime();
-  self.stats.originalSize = data.replace(/__ESCAPED_SOURCE_CLEAN_CSS\(.+\)__/g, '').length;
+  self.stats.originalSize = data.replace(/__ESCAPED_SOURCE_CLEAN_CSS\(.+\)__/g, '').replace(/__ESCAPED_SOURCE_END_CLEAN_CSS__/g, '').length;
 
   data = minify.call(self, data);
 
@@ -129,6 +118,7 @@ function benchmark(runner) {
 function minify(data) {
   var options = this.options;
   var context = this.context;
+  var sourceMapTracker = this.inputSourceMapTracker;
 
   var commentsProcessor = new CommentsProcessor(context, options.keepSpecialComments, options.keepBreaks, options.sourceMap);
   var expressionsProcessor = new ExpressionsProcessor(options.sourceMap);
@@ -160,7 +150,7 @@ function minify(data) {
       data = options.rebase ? urlRebase.process(data) : data;
       data = expressionsProcessor.restore(data);
       return commentsProcessor.restore(data);
-    });
+    }, sourceMapTracker);
 
     return selectorsOptimizer.process(data, stringifier);
   });
index 0a64165..c6541f2 100644 (file)
@@ -17,6 +17,12 @@ var merge = function(source1, source2) {
   return target;
 };
 
+function wrap(data, source) {
+  return '__ESCAPED_SOURCE_CLEAN_CSS(' + source + ')__' +
+    data +
+    '__ESCAPED_SOURCE_END_CLEAN_CSS__';
+}
+
 module.exports = function Inliner(context, options) {
   var defaultOptions = {
     timeout: 5000,
@@ -249,7 +255,7 @@ module.exports = function Inliner(context, options) {
       res.on('end', function() {
         var importedData = chunks.join('');
         importedData = UrlRewriter.process(importedData, { toBase: importedUrl });
-        importedData = '__ESCAPED_SOURCE_CLEAN_CSS(' + url + ')__' + importedData;
+        importedData = wrap(importedData, url);
 
         if (mediaQuery.length > 0)
           importedData = '@media ' + mediaQuery + '{' + importedData + '}';
@@ -301,7 +307,7 @@ module.exports = function Inliner(context, options) {
       fromBase: importRelativeTo,
       toBase: options._baseRelativeTo
     });
-    importedData = '__ESCAPED_SOURCE_CLEAN_CSS(' + path.relative(options.root, fullPath) + ')__' + importedData;
+    importedData = wrap(importedData, path.relative(options.root, fullPath));
 
     if (mediaQuery.length > 0)
       importedData = '@media ' + mediaQuery + '{' + importedData + '}';
index 86e4603..ba2c4fb 100644 (file)
@@ -1,15 +1,12 @@
-var SourceMapConsumer = require('source-map').SourceMapConsumer;
 var SourceMapGenerator = require('source-map').SourceMapGenerator;
 
 var lineBreak = require('os').EOL;
 
-function SourceMapStringifier(options, restoreCallback) {
+function SourceMapStringifier(options, restoreCallback, inputMapTracker) {
   this.keepBreaks = options.keepBreaks;
   this.restoreCallback = restoreCallback;
   this.outputMap = new SourceMapGenerator();
-  this.inputMap = typeof options.sourceMap == 'string' ?
-    new SourceMapConsumer(options.sourceMap) :
-    null;
+  this.inputMapTracker = inputMapTracker;
 }
 
 function valueRebuilder(list, store, separator) {
@@ -57,8 +54,8 @@ function rebuild(tokens, store, keepBreaks, isFlatBlock) {
 
 function track(context, value, metadata) {
   if (metadata) {
-    var original = context.inputMap ?
-      context.inputMap.originalPositionFor(metadata) :
+    var original = context.inputMapTracker.isTracking() ?
+      context.inputMapTracker.originalPositionFor(metadata) :
       {};
 
     context.outputMap.addMapping({
@@ -86,7 +83,7 @@ SourceMapStringifier.prototype.toString = function (tokens) {
   var context = {
     column: 1,
     line: 1,
-    inputMap: this.inputMap,
+    inputMapTracker: this.inputMapTracker,
     outputMap: this.outputMap
   };
 
index 2a45316..a72053a 100644 (file)
@@ -25,6 +25,7 @@ Tokenizer.prototype.toTokens = function (data) {
     outer: this.minifyContext,
     addMetadata: this.addMetadata,
     addSourceMap: this.addSourceMap,
+    state: [],
     line: 1,
     column: 1,
     source: undefined
@@ -154,16 +155,35 @@ function tokenize(context) {
     } else if (what == 'escape') {
       nextEnd = chunk.indexOf('__', nextSpecial + 1);
       var escaped = chunk.substring(context.cursor, nextEnd + 2);
-      var isSourceMarker = escaped.indexOf('__ESCAPED_SOURCE_CLEAN_CSS') > -1;
+      var isStartSourceMarker = escaped.indexOf('__ESCAPED_SOURCE_CLEAN_CSS') > -1;
+      var isEndSourceMarker = escaped.indexOf('__ESCAPED_SOURCE_END_CLEAN_CSS') > -1;
 
-      if (isSourceMarker) {
+      if (isStartSourceMarker) {
+        if (addSourceMap)
+          SourceMaps.track(escaped, context);
+
+        context.state.push({
+          source: context.source,
+          line: context.line,
+          column: context.column
+        });
         context.source = escaped.substring(escaped.indexOf('(') + 1, escaped.indexOf(')'));
+        context.line = 1;
+        context.column = 1;
+      } else if (isEndSourceMarker) {
+        var oldState = context.state.pop();
+        context.source = oldState.source;
+        context.line = oldState.line;
+        context.column = oldState.column;
+
+        if (addSourceMap)
+          SourceMaps.track(escaped, context);
       } else {
         tokenized.push({ kind: 'text', value: escaped });
-      }
 
-      if (addSourceMap)
-        SourceMaps.track(escaped, context);
+        if (addSourceMap)
+          SourceMaps.track(escaped, context);
+      }
 
       context.cursor = nextEnd + 2;
     } else if (what == 'bodyStart') {
diff --git a/lib/utils/input-source-map-tracker.js b/lib/utils/input-source-map-tracker.js
new file mode 100644 (file)
index 0000000..15ebe08
--- /dev/null
@@ -0,0 +1,65 @@
+var SourceMapConsumer = require('source-map').SourceMapConsumer;
+
+var fs = require('fs');
+var path = require('path');
+
+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) {
+  this.options = options;
+  this.maps = {};
+}
+
+InputSourceMapStore.prototype.track = function (data) {
+  if (typeof this.options.sourceMap == 'string') {
+    this.maps[undefined] = new SourceMapConsumer(this.options.sourceMap);
+    this.options.sourceMap = true;
+    return this;
+  }
+
+  var files = [];
+  for (var cursor = 0, len = data.length; cursor < len; ) {
+    var fragment = data.substring(cursor, len);
+
+    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;
+    if (markerStartMatch.index > -1)
+      nextAt = markerStartMatch.index;
+    if (markerEndMatch.index > -1 && markerEndMatch.index < nextAt)
+      nextAt = markerEndMatch.index;
+    if (mapMatch.index > -1 && mapMatch.index < nextAt)
+      nextAt = mapMatch.index;
+
+    if (nextAt == len)
+      break;
+
+    if (nextAt == markerStartMatch.index) {
+      files.push(markerStartMatch[1]);
+    } else if (nextAt == markerEndMatch.index) {
+      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);
+      this.options.sourceMap = true;
+    }
+
+    cursor += nextAt + 1;
+  }
+
+  return this;
+};
+
+InputSourceMapStore.prototype.isTracking = function () {
+  return Object.keys(this.maps).length > 0;
+};
+
+InputSourceMapStore.prototype.originalPositionFor = function (sourceInfo) {
+  return this.maps[sourceInfo.source].originalPositionFor(sourceInfo);
+};
+
+module.exports = InputSourceMapStore;
diff --git a/test/data/source-maps/import.css b/test/data/source-maps/import.css
new file mode 100644 (file)
index 0000000..58e84d6
--- /dev/null
@@ -0,0 +1,2 @@
+@import url(some.css);
+@import url(styles.css);
diff --git a/test/data/source-maps/some.css b/test/data/source-maps/some.css
new file mode 100644 (file)
index 0000000..a10316e
--- /dev/null
@@ -0,0 +1,4 @@
+div {
+  color: red;
+}
+/*# sourceMappingURL=some.css.map */
\ No newline at end of file
diff --git a/test/data/source-maps/some.css.map b/test/data/source-maps/some.css.map
new file mode 100644 (file)
index 0000000..0465e28
--- /dev/null
@@ -0,0 +1 @@
+{"version":3,"sources":["some.less"],"names":[],"mappings":"AAAA;EACE,UAAA","file":"some.css"}
\ No newline at end of file
diff --git a/test/data/source-maps/styles.css b/test/data/source-maps/styles.css
new file mode 100644 (file)
index 0000000..3ce538a
--- /dev/null
@@ -0,0 +1,4 @@
+div > a {
+  color: blue;
+}
+/*# sourceMappingURL=styles.css.map */
similarity index 62%
rename from test/data/source-maps/sample.map
rename to test/data/source-maps/styles.css.map
index dab8bff..868cb57 100644 (file)
@@ -1 +1 @@
-{"version":3,"sources":["styles.less"],"names":[],"mappings":"AAAA,GACE;EACE,UAAA","file":"styles.css"}
\ No newline at end of file
+{"version":3,"sources":["styles.less"],"names":[],"mappings":"AAAA,GACE;EACE,WAAA","file":"styles.css"}
\ No newline at end of file
index 99d5643..e556ef8 100644 (file)
@@ -439,7 +439,7 @@ vows.describe('source-maps/analyzer')
   .addBatch(
     sourceMapContext('sources', {
       'one': [
-        '__ESCAPED_SOURCE_CLEAN_CSS(one.css)__a{}',
+        '__ESCAPED_SOURCE_CLEAN_CSS(one.css)__a{}__ESCAPED_SOURCE_END_CLEAN_CSS__',
         [{
           kind: 'selector',
           value: [{ value: 'a', metadata: { line: 1, column: 1, source: 'one.css' } }],
@@ -447,7 +447,7 @@ vows.describe('source-maps/analyzer')
         }]
       ],
       'two': [
-        '__ESCAPED_SOURCE_CLEAN_CSS(one.css)__a{}\n__ESCAPED_SOURCE_CLEAN_CSS(two.css)__a{color:red}',
+        '__ESCAPED_SOURCE_CLEAN_CSS(one.css)__a{}__ESCAPED_SOURCE_END_CLEAN_CSS____ESCAPED_SOURCE_CLEAN_CSS(two.css)__\na{color:red}__ESCAPED_SOURCE_END_CLEAN_CSS__',
         [
           {
             kind: 'selector',
@@ -459,7 +459,7 @@ vows.describe('source-maps/analyzer')
           {
             kind: 'selector',
             value: [
-              { value: 'a', metadata: { line: 2, column: 1, source: 'two.css' } }
+              { value: '\na', metadata: { line: 2, column: 1, source: 'two.css' } }
             ],
             body: [{ value: 'color:red', metadata: { line: 2, column: 3, source: 'two.css' } }]
           }
index e5e9fb7..a9089ca 100644 (file)
@@ -4,7 +4,7 @@ var CleanCSS = require('../index');
 
 var fs = require('fs');
 var path = require('path');
-var inputMapPath = path.join('test', 'data', 'source-maps', 'sample.map');
+var inputMapPath = path.join('test', 'data', 'source-maps', 'styles.css.map');
 var inputMap = fs.readFileSync(inputMapPath, 'utf-8');
 
 vows.describe('source-map')
@@ -257,7 +257,7 @@ vows.describe('source-map')
       }
     },
     'input map from source with root': {
-      'topic': new CleanCSS({ root: path.dirname(inputMapPath) }).minify('div > a {\n  color: red;\n}/*# sourceMappingURL=sample.map */'),
+      'topic': new CleanCSS({ root: path.dirname(inputMapPath) }).minify('div > a {\n  color: red;\n}/*# sourceMappingURL=styles.css.map */'),
       'should have 2 mappings': function (minified) {
         assert.equal(2, minified.sourceMap._mappings.length);
       },
@@ -283,6 +283,56 @@ vows.describe('source-map')
         };
         assert.deepEqual(mapping, minified.sourceMap._mappings[1]);
       }
+    },
+    'complex input map': {
+      'topic': new CleanCSS({ root: path.dirname(inputMapPath) }).minify('@import url(import.css);'),
+      'should have 4 mappings': function (minified) {
+        assert.equal(4, minified.sourceMap._mappings.length);
+      },
+      'should have first selector mapping': function (minified) {
+        var mapping = {
+          generatedLine: 1,
+          generatedColumn: 1,
+          originalLine: 1,
+          originalColumn: 1,
+          source: 'some.less',
+          name: 'div'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[0]);
+      },
+      'should have _color:red_ mapping': function (minified) {
+        var mapping = {
+          generatedLine: 1,
+          generatedColumn: 5,
+          originalLine: 2,
+          originalColumn: 2,
+          source: 'some.less',
+          name: 'color:red'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[1]);
+      },
+      'should have second selector mapping': function (minified) {
+        var mapping = {
+          generatedLine: 2,
+          generatedColumn: 1,
+          originalLine: 1,
+          originalColumn: 1,
+          source: 'styles.less',
+          name: 'div>a'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[2]);
+      },
+      'should have _color:blue_ mapping': function (minified) {
+        var mapping = {
+          generatedLine: 2,
+          generatedColumn: 7,
+          originalLine: 3,
+          originalColumn: 4,
+          source: 'styles.less',
+          name: 'color:#00f'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[3]);
+      }
     }
   })
   .export(module);