Adds source map stringifier.
authorJakub Pawlowicz <contact@jakubpawlowicz.com>
Thu, 6 Nov 2014 13:45:48 +0000 (13:45 +0000)
committerJakub Pawlowicz <contact@jakubpawlowicz.com>
Mon, 8 Dec 2014 09:39:15 +0000 (09:39 +0000)
* Stringifying with source maps "on" requires doing it in different order.
* Basically restoring escaped data has to be done on every token rather than at the end.

README.md
lib/clean.js
lib/selectors/optimizer.js
lib/selectors/source-map-stringifier.js [new file with mode: 0644]
package.json
test/module-test.js
test/source-map-test.js [new file with mode: 0644]

index 9bc5de1..5e444a9 100644 (file)
--- a/README.md
+++ b/README.md
@@ -148,6 +148,7 @@ CleanCSS constructor accepts a hash as a parameter, i.e.,
 * `relativeTo` - path to __resolve__ relative `@import` rules and URLs
 * `root` - path to __resolve__ absolute `@import` rules and __rebase__ relative URLs
 * `roundingPrecision` - rounding precision; defaults to `2`; `-1` disables rounding
+* `sourceMap` - exposes source map under `sourceMap` property, e.g. `new CleanCSS().minify(source).sourceMap` (default is false)
 * `target` - path to a folder or an output file to which __rebase__ all URLs
 
 ### How to use clean-css with build tools?
index 703fb5a..1db575b 100644 (file)
@@ -9,6 +9,7 @@ var ImportInliner = require('./imports/inliner');
 var UrlRebase = require('./images/url-rebase');
 var SelectorsOptimizer = require('./selectors/optimizer');
 var Stringifier = require('./selectors/stringifier');
+var SourceMapStringifier = require('./selectors/source-map-stringifier');
 
 var CommentsProcessor = require('./text/comments-processor');
 var ExpressionsProcessor = require('./text/expressions-processor');
@@ -34,6 +35,7 @@ var CleanCSS = module.exports = function CleanCSS(options) {
     relativeTo: options.relativeTo,
     root: options.root,
     roundingPrecision: options.roundingPrecision,
+    sourceMap: !!options.sourceMap,
     target: options.target
   };
 
@@ -113,13 +115,14 @@ function minify(data) {
   var options = this.options;
   var context = this.context;
 
-  var commentsProcessor = new CommentsProcessor(context, options.keepSpecialComments, options.keepBreaks);
-  var expressionsProcessor = new ExpressionsProcessor();
-  var freeTextProcessor = new FreeTextProcessor();
-  var urlsProcessor = new UrlsProcessor(context);
+  var commentsProcessor = new CommentsProcessor(context, options.keepSpecialComments, options.keepBreaks, options.sourceMap);
+  var expressionsProcessor = new ExpressionsProcessor(options.sourceMap);
+  var freeTextProcessor = new FreeTextProcessor(options.sourceMap);
+  var urlsProcessor = new UrlsProcessor(context, options.sourceMap);
 
   var urlRebase = new UrlRebase(options, context);
   var selectorsOptimizer = new SelectorsOptimizer(options, context);
+  var stringifierClass = options.sourceMap ? SourceMapStringifier : Stringifier;
 
   var run = function (processor, action) {
     data = typeof processor == 'function' ?
@@ -136,7 +139,7 @@ function minify(data) {
   run(freeTextProcessor, 'escape');
 
   run(function() {
-    var stringifier = new Stringifier(options.keepBreaks, function (data) {
+    var stringifier = new stringifierClass(options.keepBreaks, function (data) {
       data = freeTextProcessor.restore(data);
       data = urlsProcessor.restore(data);
       data = options.rebase ? urlRebase.process(data) : data;
index 3f6abf4..051d5e9 100644 (file)
@@ -8,7 +8,7 @@ function SelectorsOptimizer(options, context) {
 }
 
 SelectorsOptimizer.prototype.process = function (data, stringifier) {
-  var tokens = new Tokenizer(this.context, this.options.advanced).toTokens(data);
+  var tokens = new Tokenizer(this.context, this.options.advanced, this.options.sourceMap).toTokens(data);
 
   new SimpleOptimizer(this.options).optimize(tokens);
   if (this.options.advanced)
diff --git a/lib/selectors/source-map-stringifier.js b/lib/selectors/source-map-stringifier.js
new file mode 100644 (file)
index 0000000..ea18539
--- /dev/null
@@ -0,0 +1,103 @@
+var SourceMapGenerator = require('source-map').SourceMapGenerator;
+
+var lineBreak = require('os').EOL;
+
+function SourceMapStringifier(keepBreaks, restoreCallback) {
+  this.keepBreaks = keepBreaks;
+  this.restoreCallback = restoreCallback;
+  this.sourceMap = new SourceMapGenerator();
+}
+
+function valueRebuilder(list, store, separator) {
+  for (var i = 0, l = list.length; i < l; i++) {
+    store(list[i]);
+    store(i < l - 1 ? separator : '');
+  }
+}
+
+function rebuild(tokens, store, keepBreaks, isFlatBlock) {
+  var joinCharacter = isFlatBlock ? ';' : (keepBreaks ? lineBreak : '');
+
+  for (var i = 0, l = tokens.length; i < l; i++) {
+    var token = tokens[i];
+
+    if (token.kind === 'text' || token.kind == 'at-rule') {
+      store(token);
+      continue;
+    }
+
+    // FIXME: broken due to joining/splitting
+    if (token.body && (token.body.length === 0 || (token.body.length == 1 && token.body[0].value === '')))
+      continue;
+
+    if (token.kind == 'block') {
+      if (token.body.length > 0) {
+        valueRebuilder([{ value: token.value, metadata: token.metadata }], store, '');
+        store('{');
+        if (token.isFlatBlock)
+          valueRebuilder(token.body, store, ';');
+        else
+          rebuild(token.body, store, keepBreaks, false);
+        store('}');
+      }
+    } else {
+      valueRebuilder(token.value, store, ',');
+      store('{');
+      valueRebuilder(token.body, store, ';');
+      store('}');
+    }
+
+    store(joinCharacter);
+  }
+}
+
+function track(context, value, metadata) {
+  if (metadata) {
+    context.sourceMap.addMapping({
+      generated: {
+        line: context.line,
+        column: context.column,
+      },
+      source: metadata.source || '__stdin__.css',
+      original: {
+        line: metadata.line,
+        column: metadata.column
+      },
+      name: value
+    });
+  }
+
+  var parts = value.split('\n');
+  context.line += parts.length - 1;
+  context.column = parts.length > 1 ? 1 : (context.column + parts.pop().length);
+}
+
+SourceMapStringifier.prototype.toString = function (tokens) {
+  var self = this;
+  var output = [];
+  var context = {
+    column: 1,
+    line: 1,
+    sourceMap: this.sourceMap
+  };
+
+  function store(token) {
+    if (typeof token == 'string') {
+      track(context, token);
+      output.push(token);
+    } else {
+      var val = self.restoreCallback(token.value);
+      track(context, val, token.metadata);
+      output.push(val);
+    }
+  }
+
+  rebuild(tokens, store, this.keepBreaks, false);
+
+  return {
+    sourceMap: this.sourceMap,
+    styles: output.join('').trim()
+  };
+};
+
+module.exports = SourceMapStringifier;
index 3e59c9a..5f1df2e 100644 (file)
@@ -35,7 +35,8 @@
     "test": "vows"
   },
   "dependencies": {
-    "commander": "2.5.x"
+    "commander": "2.5.x",
+    "source-map": "0.1.x"
   },
   "devDependencies": {
     "browserify": "6.x",
index f478c7e..be02e4d 100644 (file)
@@ -2,6 +2,7 @@ var vows = require('vows');
 var assert = require('assert');
 var path = require('path');
 var CleanCSS = require('../index');
+var SourceMapGenerator = require('source-map').SourceMapGenerator;
 
 vows.describe('module tests').addBatch({
   'imported as a function': {
@@ -162,7 +163,7 @@ vows.describe('module tests').addBatch({
       this.callback(null, minified, minifier);
     },
     'should output correct content': function(error, minified) {
-      assert.equal(minified, 'a{background:url(image/}');
+      assert.equal(minified.styles, 'a{background:url(image/}');
     },
     'should raise no errors': function(error, minified, minifier) {
       assert.equal(minifier.errors.length, 0);
@@ -179,7 +180,7 @@ vows.describe('module tests').addBatch({
       this.callback(null, minified, minifier);
     },
     'should output correct content': function(error, minified) {
-      assert.equal(minified, '');
+      assert.equal(minified.styles, '');
     },
     'should raise no errors': function(error, minified, minifier) {
       assert.equal(minifier.errors.length, 0);
@@ -196,7 +197,7 @@ vows.describe('module tests').addBatch({
       this.callback(null, minified, minifier);
     },
     'should output correct content': function(error, minified) {
-      assert.equal(minified, '');
+      assert.equal(minified.styles, '');
     },
     'should raise no errors': function(error, minified, minifier) {
       assert.equal(minifier.errors.length, 0);
@@ -262,5 +263,14 @@ vows.describe('module tests').addBatch({
         assert.include(minified.styles, 'url(/test/data/dummy.png)');
       }
     }
+  },
+  'source map': {
+    'topic': new CleanCSS({ sourceMap: true }).minify('/*! a */div[data-id=" abc "] { color:red; }'),
+    'should minify correctly': function (minified) {
+      assert.equal('/*! a */div[data-id=" abc "]{color:red}', minified.styles);
+    },
+    'should include source map': function (minified) {
+      assert.instanceOf(minified.sourceMap, SourceMapGenerator);
+    }
   }
 }).export(module);
diff --git a/test/source-map-test.js b/test/source-map-test.js
new file mode 100644 (file)
index 0000000..0654acf
--- /dev/null
@@ -0,0 +1,158 @@
+var vows = require('vows');
+var assert = require('assert');
+var CleanCSS = require('../index');
+
+vows.describe('source-map')
+  .addBatch({
+    'module #1': {
+      'topic': new CleanCSS({ sourceMap: true }).minify('/*! a */div[data-id=" abc "] { color:red; }'),
+      'should have 2 mappings': function(minified) {
+        assert.equal(2, minified.sourceMap._mappings.length);
+      },
+      'should have selector mapping': function (minified) {
+        var mapping = {
+          generatedLine: 1,
+          generatedColumn: 9,
+          originalLine: 1,
+          originalColumn: 9,
+          source: '__stdin__.css',
+          name: 'div[data-id=" abc "]'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[0]);
+      },
+      'should have body mapping': function (minified) {
+        var mapping = {
+          generatedLine: 1,
+          generatedColumn: 30,
+          originalLine: 1,
+          originalColumn: 32,
+          source: '__stdin__.css',
+          name: 'color:red'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[1]);
+      }
+    },
+    'module #2': {
+      'topic': new CleanCSS({ sourceMap: true }).minify('@media screen {\n@font-face \n{ \nfont-family: test; } }'),
+      'should have 3 mappings': function(minified) {
+        assert.equal(3, minified.sourceMap._mappings.length);
+      },
+      'should have @media mapping': function (minified) {
+        var mapping = {
+          generatedLine: 1,
+          generatedColumn: 1,
+          originalLine: 1,
+          originalColumn: 1,
+          source: '__stdin__.css',
+          name: '@media screen'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[0]);
+      },
+      'should have @font-face mapping': function (minified) {
+        var mapping = {
+          generatedLine: 1,
+          generatedColumn: 15,
+          originalLine: 2,
+          originalColumn: 1,
+          source: '__stdin__.css',
+          name: '@font-face'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[1]);
+      },
+      'should have font-family mapping': function (minified) {
+        var mapping = {
+          generatedLine: 1,
+          generatedColumn: 26,
+          originalLine: 4,
+          originalColumn: 1,
+          source: '__stdin__.css',
+          name: 'font-family:test'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[2]);
+      }
+    },
+    'with keepBreaks': {
+      'topic': new CleanCSS({ sourceMap: true, keepBreaks: true }).minify('@media screen { a{color:red} p {color:blue} }div{color:pink}'),
+      'should have 7 mappings': function(minified) {
+        assert.equal(7, minified.sourceMap._mappings.length);
+      },
+      'should have @media mapping': function (minified) {
+        var mapping = {
+          generatedLine: 1,
+          generatedColumn: 1,
+          originalLine: 1,
+          originalColumn: 1,
+          source: '__stdin__.css',
+          name: '@media screen'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[0]);
+      },
+      'should have _a_ mapping': function (minified) {
+        var mapping = {
+          generatedLine: 1,
+          generatedColumn: 15,
+          originalLine: 1,
+          originalColumn: 17,
+          source: '__stdin__.css',
+          name: 'a'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[1]);
+      },
+      'should have _color:red_ mapping': function (minified) {
+        var mapping = {
+          generatedLine: 1,
+          generatedColumn: 17,
+          originalLine: 1,
+          originalColumn: 19,
+          source: '__stdin__.css',
+          name: 'color:red'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[2]);
+      },
+      'should have _p_ mapping': function (minified) {
+        var mapping = {
+          generatedLine: 2,
+          generatedColumn: 1,
+          originalLine: 1,
+          originalColumn: 30,
+          source: '__stdin__.css',
+          name: 'p'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[3]);
+      },
+      'should have _color:blue_ mapping': function (minified) {
+        var mapping = {
+          generatedLine: 2,
+          generatedColumn: 3,
+          originalLine: 1,
+          originalColumn: 33,
+          source: '__stdin__.css',
+          name: 'color:#00f'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[4]);
+      },
+      'should have _div_ mapping': function (minified) {
+        var mapping = {
+          generatedLine: 4,
+          generatedColumn: 1,
+          originalLine: 1,
+          originalColumn: 46,
+          source: '__stdin__.css',
+          name: 'div'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[5]);
+      },
+      'should have _color:pink_ mapping': function (minified) {
+        var mapping = {
+          generatedLine: 4,
+          generatedColumn: 5,
+          originalLine: 1,
+          originalColumn: 50,
+          source: '__stdin__.css',
+          name: 'color:pink'
+        };
+        assert.deepEqual(mapping, minified.sourceMap._mappings[6]);
+      }
+    }
+  })
+  .export(module);