Adds line/column tracking in tokenization.
authorJakub Pawlowicz <contact@jakubpawlowicz.com>
Wed, 29 Oct 2014 20:26:21 +0000 (20:26 +0000)
committerJakub Pawlowicz <contact@jakubpawlowicz.com>
Mon, 8 Dec 2014 09:39:14 +0000 (09:39 +0000)
lib/properties/optimizer.js
lib/properties/token.js
lib/selectors/optimizers/clean-up.js
lib/selectors/optimizers/simple.js
lib/selectors/tokenizer.js
lib/utils/extractors.js
lib/utils/source-maps.js [new file with mode: 0644]
test/selectors/tokenizer-source-maps-test.js [new file with mode: 0644]

index c44eb32..df83347 100644 (file)
@@ -123,11 +123,10 @@ module.exports = function Optimizer(compatibility, aggressiveMerging, context) {
     var keyValues = [];
 
     for (var i = 0, l = body.length; i < l; i++) {
-      var token = body[i].value;
-
-      var firstColon = token.indexOf(':');
-      var property = token.substring(0, firstColon);
-      var value = token.substring(firstColon + 1);
+      var token = body[i];
+      var firstColon = token.value.indexOf(':');
+      var property = token.value.substring(0, firstColon);
+      var value = token.value.substring(firstColon + 1);
       if (value === '') {
         context.warnings.push('Empty property \'' + property + '\' inside \'' + selector.map(valueMapper).join(',') + '\' selector. Ignoring.');
         continue;
@@ -136,8 +135,9 @@ module.exports = function Optimizer(compatibility, aggressiveMerging, context) {
       keyValues.push([
         property,
         value,
-        token.indexOf('!important') > -1,
-        token.indexOf(IE_BACKSLASH_HACK, firstColon + 1) === token.length - IE_BACKSLASH_HACK.length
+        token.value.indexOf('!important') > -1,
+        token.value.indexOf(IE_BACKSLASH_HACK, firstColon + 1) === token.value.length - IE_BACKSLASH_HACK.length,
+        token.metadata
       ]);
     }
 
@@ -246,7 +246,7 @@ module.exports = function Optimizer(compatibility, aggressiveMerging, context) {
         eligibleForCompacting = true;
 
       var property = tokens[i][0] + ':' + tokens[i][1];
-      tokenized.push({ value: property });
+      tokenized.push({ value: property, metadata: tokens[i][4] });
       list.push(property);
     }
 
index 333d43f..0260476 100644 (file)
@@ -29,6 +29,7 @@ module.exports = (function() {
     Token.prototype.isIrrelevant = false;
     Token.prototype.isReal = true;
     Token.prototype.isMarkedForDeletion = false;
+    Token.prototype.metadata = null;
 
     // Tells if this token is a component of the other one
     Token.prototype.isComponentOf = function (other) {
@@ -93,6 +94,8 @@ module.exports = (function() {
         result.isDirty = true;
       }
 
+      result.metadata = fullProp.metadata;
+
       return result;
     };
 
@@ -126,7 +129,7 @@ module.exports = (function() {
         }
 
         var property = t.prop + ':' + t.value + (t.isImportant ? important : '');
-        tokenized.push({ value: property });
+        tokenized.push({ value: property, metadata: t.metadata || {} });
         list.push(property);
       }
 
index 0e3781d..3474971 100644 (file)
@@ -2,37 +2,44 @@ function removeWhitespace(match, value) {
   return '[' + value.replace(/ /g, '') + ']';
 }
 
+function selectorSorter(s1, s2) {
+  return s1.value > s2.value ? 1 : -1;
+}
+
 var CleanUp = {
   selectors: function (selectors) {
     var plain = [];
+    var tokenized = [];
 
     for (var i = 0, l = selectors.length; i < l; i++) {
-      var selector = selectors[i].value;
-      var reduced = selector
+      var selector = selectors[i];
+      var value = selector.value;
+      var reduced = value
         .replace(/\s/g, ' ')
         .replace(/\s{2,}/g, ' ')
         .replace(/ ?, ?/g, ',')
         .replace(/\s*([>\+\~])\s*/g, '$1')
         .trim();
 
-      if (selector.indexOf('*') > -1) {
+      if (value.indexOf('*') > -1) {
         reduced = reduced
           .replace(/\*([:#\.\[])/g, '$1')
           .replace(/^(\:first\-child)?\+html/, '*$1+html');
       }
 
-      if (selector.indexOf('[') > -1)
+      if (value.indexOf('[') > -1)
         reduced = reduced.replace(/\[([^\]]+)\]/g, removeWhitespace);
 
-      if (plain.indexOf(reduced) == -1)
+      if (plain.indexOf(reduced) == -1) {
         plain.push(reduced);
+        selector.value = reduced;
+        tokenized.push(selector);
+      }
     }
 
-    var sorted = plain.sort();
-
     return {
-      list: sorted,
-      tokenized: sorted.map(function (selector) { return { value: selector }; })
+      list: plain.sort(),
+      tokenized: tokenized.sort(selectorSorter)
     };
   },
 
index a29ca93..6dee63a 100644 (file)
@@ -183,10 +183,10 @@ function reduce(body, options) {
   var newProperty;
 
   for (var i = 0, l = body.length; i < l; i++) {
-    var token = body[i].value;
-    var firstColon = token.indexOf(':');
-    var property = token.substring(0, firstColon);
-    var value = token.substring(firstColon + 1);
+    var token = body[i];
+    var firstColon = token.value.indexOf(':');
+    var property = token.value.substring(0, firstColon);
+    var value = token.value.substring(firstColon + 1);
     var important = false;
 
     if (!options.compatibility.properties.iePrefixHack && (property[0] == '_' || property[0] == '*'))
@@ -210,7 +210,7 @@ function reduce(body, options) {
     value = colorMininifier(property, value, options.compatibility);
 
     newProperty = property + ':' + value + (important ? '!important' : '');
-    reduced.push({ value: newProperty });
+    reduced.push({ value: newProperty, metadata: token.metadata });
     properties.push(newProperty);
   }
 
index 879470b..568fbb8 100644 (file)
@@ -1,11 +1,13 @@
 var Chunker = require('../utils/chunker');
 var Extract = require('../utils/extractors');
+var SourceMaps = require('../utils/source-maps');
 
 var flatBlock = /(^@(font\-face|page|\-ms\-viewport|\-o\-viewport|viewport|counter\-style)|\\@.+?)/;
 
-function Tokenizer(minifyContext, addMetadata) {
+function Tokenizer(minifyContext, addMetadata, addSourceMap) {
   this.minifyContext = minifyContext;
   this.addMetadata = addMetadata;
+  this.addSourceMap = addSourceMap;
 }
 
 Tokenizer.prototype.toTokens = function (data) {
@@ -21,7 +23,10 @@ Tokenizer.prototype.toTokens = function (data) {
     chunker: chunker,
     chunk: chunker.next(),
     outer: this.minifyContext,
-    addMetadata: this.addMetadata
+    addMetadata: this.addMetadata,
+    addSourceMap: this.addSourceMap,
+    line: 1,
+    column: 1
   };
 
   return tokenize(context);
@@ -78,6 +83,9 @@ function whatsNext(context) {
 function tokenize(context) {
   var chunk = context.chunk;
   var tokenized = [];
+  var newToken;
+  var value;
+  var addSourceMap = context.addSourceMap;
 
   while (true) {
     var next = whatsNext(context);
@@ -108,44 +116,63 @@ function tokenize(context) {
       } else if (isSingle) {
         nextEnd = chunk.indexOf(';', nextSpecial + 1);
 
-        var single = chunk.substring(context.cursor, nextEnd + 1);
-        tokenized.push({ kind: 'at-rule', value: single });
+        value = chunk.substring(context.cursor, nextEnd + 1);
+        newToken = { kind: 'at-rule', value: value };
+        tokenized.push(newToken);
+
+        if (addSourceMap)
+          newToken.metadata = SourceMaps.saveAndTrack(value, context, true);
 
         context.cursor = nextEnd + 1;
       } else {
         nextEnd = chunk.indexOf('{', nextSpecial + 1);
-        var block = chunk.substring(context.cursor, nextEnd).trim();
+        value = chunk.substring(context.cursor, nextEnd).trim();
 
-        var isFlat = flatBlock.test(block);
+        var isFlat = flatBlock.test(value);
         oldMode = context.mode;
         context.cursor = nextEnd + 1;
         context.mode = isFlat ? 'body' : 'block';
-        var specialBody = tokenize(context);
 
-        if (typeof specialBody == 'string')
-          specialBody = Extract.properties(specialBody).tokenized;
+        newToken = { kind: 'block', value: value, isFlatBlock: isFlat };
+
+        if (addSourceMap)
+          newToken.metadata = SourceMaps.saveAndTrack(value, context, true);
+
+        newToken.body = tokenize(context);
+        if (typeof newToken.body == 'string')
+          newToken.body = Extract.properties(newToken.body, context).tokenized;
 
         context.mode = oldMode;
 
-        tokenized.push({ kind: 'block', value: block, body: specialBody, isFlatBlock: isFlat });
+        if (addSourceMap)
+          SourceMaps.suffix(context);
+
+        tokenized.push(newToken);
       }
     } else if (what == 'escape') {
       nextEnd = chunk.indexOf('__', nextSpecial + 1);
       var escaped = chunk.substring(context.cursor, nextEnd + 2);
       tokenized.push({ kind: 'text', value: escaped });
 
+      if (addSourceMap)
+        SourceMaps.track(escaped, context);
+
       context.cursor = nextEnd + 2;
     } else if (what == 'bodyStart') {
-      var selectorData = Extract.selectors(chunk.substring(context.cursor, nextSpecial));
+      var selectorData = Extract.selectors(chunk.substring(context.cursor, nextSpecial), context);
 
       oldMode = context.mode;
       context.cursor = nextSpecial + 1;
       context.mode = 'body';
-      var bodyData = Extract.properties(tokenize(context));
+
+      var bodyData = Extract.properties(tokenize(context), context);
+
+      if (addSourceMap)
+        SourceMaps.suffix(context);
 
       context.mode = oldMode;
 
-      var newToken = {
+      newToken = {
         kind: 'selector',
         value: selectorData.tokenized,
         body: bodyData.tokenized
index 4067fbc..40d1cc9 100644 (file)
@@ -1,14 +1,32 @@
 var Splitter = require('./splitter');
+var SourceMaps = require('../utils/source-maps');
 
-function valueMapper(property) {
-  return { value: property };
+function tokenMetadata(value, context, addExtra) {
+  var withoutContent;
+  var total;
+  var split = value.split('\n');
+  var shift = 0;
+  for (withoutContent = 0, total = split.length; withoutContent < total; withoutContent++) {
+    var part = split[withoutContent];
+    if (/\S/.test(part))
+      break;
+
+    shift += part.length + 1;
+  }
+
+  context.line += withoutContent;
+  context.column = withoutContent > 0 ? 1 : context.column;
+  context.column += /^(\s)*/.exec(split[withoutContent])[0].length;
+
+  return SourceMaps.saveAndTrack(value.substring(shift).trimLeft(), context, addExtra);
 }
 
 var Extractors = {
-  properties: function (string) {
+  properties: function (string, context) {
     var tokenized = [];
     var list = [];
     var buffer = [];
+    var all = [];
     var property;
     var isWhitespace;
     var wasWhitespace;
@@ -16,19 +34,34 @@ var Extractors = {
     var wasSpecial;
     var current;
     var wasCloseParenthesis;
+    var isEscape;
+    var token;
+    var addSourceMap = context.addSourceMap;
 
     for (var i = 0, l = string.length; i < l; i++) {
       current = string[i];
 
-      if (current === ';') {
+      isEscape = current == '_' && buffer.length < 2 && string.indexOf('__ESCAPED_', i) === 0;
+      if (isEscape) {
+        var endOfEscape = string.indexOf('__', i + 1) + 2;
+        buffer = all = [string.substring(i, endOfEscape)];
+        i = endOfEscape - 1;
+      }
+
+      if (current === ';' || isEscape) {
         if (wasWhitespace && buffer[buffer.length - 1] === ' ')
           buffer.pop();
         if (buffer.length > 0) {
           property = buffer.join('');
-          tokenized.push({ value: property });
+          token = { value: property };
+          tokenized.push(token);
           list.push(property);
+
+          if (addSourceMap)
+            token.metadata = tokenMetadata(all.join(''), context, !isEscape);
         }
         buffer = [];
+        all = [];
       } else {
         isWhitespace = current === ' ' || current === '\t' || current === '\n';
         isSpecial = current === ':' || current === '[' || current === ']' || current === ',' || current === '(' || current === ')';
@@ -44,6 +77,8 @@ var Extractors = {
         } else {
           buffer.push(isWhitespace ? ' ' : current);
         }
+
+        all.push(current);
       }
 
       wasSpecial = isSpecial;
@@ -55,8 +90,14 @@ var Extractors = {
       buffer.pop();
     if (buffer.length > 0) {
       property = buffer.join('');
-      tokenized.push({ value: property });
+      token = { value: property };
+      tokenized.push(token);
       list.push(property);
+
+      if (addSourceMap)
+        token.metadata = tokenMetadata(all.join(''), context, false);
+    } else if (all.indexOf('\n') > -1) {
+      SourceMaps.track(all.join('\n'), context);
     }
 
     return {
@@ -65,12 +106,27 @@ var Extractors = {
     };
   },
 
-  selectors: function (string) {
+  selectors: function (string, context) {
+    var tokenized = [];
+    var list = [];
     var selectors = new Splitter(',').split(string);
+    var addSourceMap = context.addSourceMap;
+
+    for (var i = 0, l = selectors.length; i < l; i++) {
+      var selector = selectors[i];
+
+      list.push(selector);
+
+      var token = { value: selector };
+      tokenized.push(token);
+
+      if (addSourceMap)
+        token.metadata = tokenMetadata(selector, context, true);
+    }
 
     return {
-      list: selectors,
-      tokenized: selectors.map(valueMapper)
+      list: list,
+      tokenized: tokenized
     };
   }
 };
diff --git a/lib/utils/source-maps.js b/lib/utils/source-maps.js
new file mode 100644 (file)
index 0000000..1413c7a
--- /dev/null
@@ -0,0 +1,53 @@
+var SourceMaps = {
+  saveAndTrack: function (data, context, hasSuffix) {
+    var metadata = {
+      line: context.line,
+      column: context.column
+    };
+
+    this.track(data, context);
+
+    if (hasSuffix)
+      context.column++;
+
+    return metadata;
+  },
+
+  suffix: function (context) {
+    context.column++;
+  },
+
+  track: function (data, context) {
+    var parts = data.split('\n');
+
+    for (var i = 0, l = parts.length; i < l; i++) {
+      var part = parts[i];
+      var cursor = 0;
+
+      if (i > 0) {
+        context.line++;
+        context.column = 1;
+      }
+
+      while (true) {
+        var next = part.indexOf('__ESCAPED_', cursor);
+
+        if (next == -1) {
+          context.column += part.substring(cursor).length;
+          break;
+        }
+
+        context.column += next - cursor;
+        cursor += next - cursor;
+
+        var escaped = part.substring(next, part.indexOf('__', next + 1) + 2);
+        var encodedValues = escaped.substring(escaped.indexOf('(') + 1, escaped.indexOf(')')).split(',');
+        context.line += ~~encodedValues[0];
+        context.column = (~~encodedValues[0] === 0 ? context.column : 1) + ~~encodedValues[1];
+        cursor += escaped.length;
+      }
+    }
+  }
+};
+
+module.exports = SourceMaps;
diff --git a/test/selectors/tokenizer-source-maps-test.js b/test/selectors/tokenizer-source-maps-test.js
new file mode 100644 (file)
index 0000000..6a36d36
--- /dev/null
@@ -0,0 +1,402 @@
+var vows = require('vows');
+var assert = require('assert');
+var Tokenizer = require('../../lib/selectors/tokenizer');
+
+function sourceMapContext(group, specs) {
+  var ctx = {};
+
+  function tokenizedContext(target, index) {
+    return function (tokenized) {
+      assert.deepEqual(tokenized[index], target);
+    };
+  }
+
+  for (var test in specs) {
+    for (var i = 0; i < specs[test][1].length; i++) {
+      var target = specs[test][1][i];
+
+      ctx[group + ' ' + test + ' - #' + (i + 1)] = {
+        topic: new Tokenizer({}, false, true).toTokens(specs[test][0]),
+        tokenized: tokenizedContext(target, i)
+      };
+    }
+  }
+
+  return ctx;
+}
+
+vows.describe('source-maps/analyzer')
+  .addBatch(
+    sourceMapContext('selectors', {
+      'single': [
+        'a{}',
+        [{
+          kind: 'selector',
+          value: [{ value: 'a', metadata: { line: 1, column: 1 } }],
+          body: []
+        }]
+      ],
+      'double': [
+        'a,div{}',
+        [{
+          kind: 'selector',
+          value: [
+            { value: 'a', metadata: { line: 1, column: 1 } },
+            { value: 'div', metadata: { line: 1, column: 3 } }
+          ],
+          body: []
+        }]
+      ],
+      'double with whitespace': [
+        ' a,\n\ndiv{}',
+        [{
+          kind: 'selector',
+          value: [
+            { value: ' a', metadata: { line: 1, column: 2 } },
+            { value: '\n\ndiv', metadata: { line: 3, column: 1 } }
+          ],
+          body: []
+        }]
+      ],
+      'triple': [
+        'a,div,p{}',
+        [{
+          kind: 'selector',
+          value: [
+            { value: 'a', metadata: { line: 1, column: 1 } },
+            { value: 'div', metadata: { line: 1, column: 3 } },
+            { value: 'p', metadata: { line: 1, column: 7 } }
+          ],
+          body: []
+        }]
+      ],
+      'triple with whitespace': [
+        ' a,\n\ndiv\na,\n p{}',
+        [{
+          kind: 'selector',
+          value: [
+            { value: ' a', metadata: { line: 1, column: 2 } },
+            { value: '\n\ndiv\na', metadata: { line: 3, column: 1 } },
+            { value: '\n p', metadata: { line: 5, column: 2 } }
+          ],
+          body: []
+        }]
+      ],
+      'two': [
+        'a{}div{}',
+        [
+          {
+            kind: 'selector',
+            value: [{ value: 'a', metadata: { line: 1, column: 1 } }],
+            body: []
+          },
+          {
+            kind: 'selector',
+            value: [{ value: 'div', metadata: { line: 1, column: 4 } }],
+            body: []
+          }
+        ]
+      ],
+      'three with whitespace and breaks': [
+        'a {}\n\ndiv{}\n \n  p{}',
+        [
+          {
+            kind: 'selector',
+            value: [{ value: 'a ', metadata: { line: 1, column: 1 } }],
+            body: []
+          },
+          {
+            kind: 'selector',
+            value: [{ value: '\n\ndiv', metadata: { line: 3, column: 1 } }],
+            body: []
+          },
+          {
+            kind: 'selector',
+            value: [{ value: '\n \n  p', metadata: { line: 5, column: 3 } }],
+            body: []
+          }
+        ]
+      ]
+    })
+  )
+  .addBatch(
+    sourceMapContext('properties', {
+      'single': [
+        'a{color:red}',
+        [{
+          kind: 'selector',
+          value: [{ value: 'a', metadata: { line: 1, column: 1 } }],
+          body: [{ value: 'color:red', metadata: { line: 1, column: 3 } }]
+        }]
+      ],
+      'double': [
+        'a{color:red;border:none}',
+        [{
+          kind: 'selector',
+          value: [{ value: 'a', metadata: { line: 1, column: 1 } }],
+          body: [
+            { value: 'color:red', metadata: { line: 1, column: 3 } },
+            { value: 'border:none', metadata: { line: 1, column: 13 } }
+          ]
+        }]
+      ],
+      'triple with whitespace': [
+        'a{color:red;\nborder:\nnone;\n\n  display:block}',
+        [{
+          kind: 'selector',
+          value: [{ value: 'a', metadata: { line: 1, column: 1 } }],
+          body: [
+            { value: 'color:red', metadata: { line: 1, column: 3 } },
+            { value: 'border:none', metadata: { line: 2, column: 1 } },
+            { value: 'display:block', metadata: { line: 5, column: 3 } }
+          ]
+        }]
+      ],
+      'two declarations': [
+        'a{color:red}div{color:blue}',
+        [
+          {
+            kind: 'selector',
+            value: [{ value: 'a', metadata: { line: 1, column: 1 } }],
+            body: [{ value: 'color:red', metadata: { line: 1, column: 3 } }]
+          },
+          {
+            kind: 'selector',
+            value: [{ value: 'div', metadata: { line: 1, column: 13 } }],
+            body: [{ value: 'color:blue', metadata: { line: 1, column: 17 } }]
+          }
+        ]
+      ],
+      'two declarations with whitespace': [
+        'a{color:red}\n div{color:blue}',
+        [
+          {
+            kind: 'selector',
+            value: [{ value: 'a', metadata: { line: 1, column: 1 } }],
+            body: [{ value: 'color:red', metadata: { line: 1, column: 3 } }]
+          },
+          {
+            kind: 'selector',
+            value: [{ value: '\n div', metadata: { line: 2, column: 2 } }],
+            body: [{ value: 'color:blue', metadata: { line: 2, column: 6 } }]
+          }
+        ]
+      ],
+      'two declarations with whitespace and ending semicolon': [
+        'a{color:red;\n}\n div{color:blue}',
+        [
+          {
+            kind: 'selector',
+            value: [{ value: 'a', metadata: { line: 1, column: 1 } }],
+            body: [{ value: 'color:red', metadata: { line: 1, column: 3 } }]
+          },
+          {
+            kind: 'selector',
+            value: [{ value: '\n div', metadata: { line: 3, column: 2 } }],
+            body: [{ value: 'color:blue', metadata: { line: 3, column: 6 } }]
+          }
+        ]
+      ]
+    })
+  )
+  .addBatch(
+    sourceMapContext('at rules', {
+      '@import': [
+        'a{}@import \n"test.css";\n\na{color:red}',
+        [
+          {
+            kind: 'selector',
+            value: [{ value: 'a', metadata: { line: 1, column: 1 } }],
+            body: []
+          },
+          {
+            kind: 'at-rule',
+            value: '@import \n"test.css";',
+            metadata: { line: 1, column: 4 }
+          },
+          {
+            kind: 'selector',
+            value: [{ value: '\n\na', metadata: { line: 4, column: 1 } }],
+            body: [{ value: 'color:red', metadata: { line: 4, column: 3 } }]
+          }
+        ]
+      ],
+      '@charset': [
+        '@charset "utf-8";a{color:red}',
+        [
+          {
+            kind: 'at-rule',
+            value: '@charset "utf-8";',
+            metadata: { line: 1, column: 1 }
+          },
+          {
+            kind: 'selector',
+            value: [{ value: 'a', metadata: { line: 1, column: 19 } }],
+            body: [{ value: 'color:red', metadata: { line: 1, column: 21 } }]
+          }
+        ]
+      ]
+    })
+  )
+  .addBatch(
+    sourceMapContext('blocks', {
+      '@media - simple': [
+        '@media (min-width:980px){a{color:red}}',
+        [
+          {
+            kind: 'block',
+            value: '@media (min-width:980px)',
+            metadata: { line: 1, column: 1 },
+            isFlatBlock: false,
+            body: [{
+              kind: 'selector',
+              value: [{ value: 'a', metadata: { line: 1, column: 26 } }],
+              body: [{ value: 'color:red', metadata: { line: 1, column: 28 } }]
+            }]
+          }
+        ]
+      ],
+      '@media - with whitespace': [
+        '@media (\nmin-width:980px)\n{\na{\ncolor:\nred}p{}}',
+        [
+          {
+            kind: 'block',
+            value: '@media (\nmin-width:980px)',
+            metadata: { line: 1, column: 1 },
+            isFlatBlock: false,
+            body: [
+              {
+                kind: 'selector',
+                value: [{ value: '\na', metadata: { line: 3, column: 1 } }],
+                body: [{ value: 'color:red', metadata: { line: 4, column: 1 } }]
+              },
+              {
+                kind: 'selector',
+                value: [{ value: 'p', metadata: { line: 5, column: 5 } }],
+                body: []
+              }
+            ]
+          }
+        ]
+      ],
+      '@font-face': [
+        '@font-face{font-family: "Font";\nsrc: url("font.ttf");\nfont-weight: normal;font-style: normal}a{}',
+        [
+          {
+            kind: 'block',
+            value: '@font-face',
+            metadata: { line: 1, column: 1 },
+            isFlatBlock: true,
+            body: [
+              { value: 'font-family:"Font"', metadata: { line: 1, column: 12 } },
+              { value: 'src:url("font.ttf")', metadata: { line: 2, column: 1 } },
+              { value: 'font-weight:normal', metadata: { line: 3, column: 1 } },
+              { value: 'font-style:normal', metadata: { line: 3, column: 21 } }
+            ]
+          },
+          {
+            kind: 'selector',
+            value: [{ value: 'a', metadata: { line: 3, column: 40 } }],
+            body: []
+          }
+        ]
+      ]
+    })
+  )
+  .addBatch(
+    sourceMapContext('escaped content', {
+      'top-level': [
+        '__ESCAPED_COMMENT_CLEAN_CSS0(0, 5)__a{}',
+        [
+          {
+            kind: 'text',
+            value: '__ESCAPED_COMMENT_CLEAN_CSS0(0, 5)__'
+          },
+          {
+            kind: 'selector',
+            value: [{ value: 'a', metadata: { line: 1, column: 6 } }],
+            body: []
+          }
+        ]
+      ],
+      'top-level with line breaks': [
+        '__ESCAPED_COMMENT_CLEAN_CSS0(2, 5)__a{}',
+        [
+          {
+            kind: 'text',
+            value: '__ESCAPED_COMMENT_CLEAN_CSS0(2, 5)__'
+          },
+          {
+            kind: 'selector',
+            value: [{ value: 'a', metadata: { line: 3, column: 6 } }],
+            body: []
+          }
+        ]
+      ],
+      'in selectors': [
+        'div[data-type=__ESCAPED_FREE_TEXT_CLEAN_CSS0(1,3)__],div[data-id=__ESCAPED_FREE_TEXT_CLEAN_CSS1(0,7)__]{color:red}',
+        [{
+          kind: 'selector',
+          value: [
+            { value: 'div[data-type=__ESCAPED_FREE_TEXT_CLEAN_CSS0(1,3)__]', metadata: { line: 1, column: 1 } },
+            { value: 'div[data-id=__ESCAPED_FREE_TEXT_CLEAN_CSS1(0,7)__]', metadata: { line: 2, column: 6 } }
+          ],
+          body: [{ value: 'color:red', metadata: { line: 2, column: 27 } }]
+        }]
+      ],
+      'in properties': [
+        'div{__ESCAPED_COMMENT_CLEAN_CSS0(2,5)__background:url(__ESCAPED_URL_CLEAN_CSS0(0,20)__);color:blue}a{font-family:__ESCAPED_FREE_TEXT_CLEAN_CSS0(1,3)__;color:red}',
+        [
+          {
+            kind: 'selector',
+            value: [{ value: 'div', metadata: { line: 1, column: 1 } }],
+            body: [
+              { value: '__ESCAPED_COMMENT_CLEAN_CSS0(2,5)__', metadata: { line: 1, column: 5 }},
+              { value: 'background:url(__ESCAPED_URL_CLEAN_CSS0(0,20)__)', metadata: { line: 3, column: 6 } },
+              { value: 'color:blue', metadata: { line: 3, column: 43 } }
+            ]
+          },
+          {
+            kind: 'selector',
+            value: [{ value: 'a', metadata: { line: 3, column: 54 } }],
+            body: [
+              { value: 'font-family:__ESCAPED_FREE_TEXT_CLEAN_CSS0(1,3)__', metadata: { line: 3, column: 56 } },
+              { value: 'color:red', metadata: { line: 4, column: 5 } }
+            ]
+          }
+        ]
+      ],
+      'in at-rules': [
+        '@charset __ESCAPED_FREE_TEXT_CLEAN_CSS0(1, 5)__;div{}',
+        [
+          {
+            kind: 'at-rule',
+            value: '@charset __ESCAPED_FREE_TEXT_CLEAN_CSS0(1, 5)__;',
+            metadata: { line: 1, column: 1 }
+          },
+          {
+            kind: 'selector',
+            value: [{ value: 'div', metadata: { line: 2, column: 8 } }],
+            body: []
+          }
+        ]
+      ],
+      'in blocks': [
+        '@media (__ESCAPED_COMMENT_CLEAN_CSS0(2, 1)__min-width:980px){a{color:red}}',
+        [
+          {
+            kind: 'block',
+            value: '@media (__ESCAPED_COMMENT_CLEAN_CSS0(2, 1)__min-width:980px)',
+            metadata: { line: 1, column: 1 },
+            isFlatBlock: false,
+            body: [{
+              kind: 'selector',
+              value: [{ value: 'a', metadata: { line: 3, column: 19 } }],
+              body: [{ value: 'color:red', metadata: { line: 3, column: 21 } }]
+            }]
+          }
+        ]
+      ]
+    })
+  )
+  .export(module);