Rewrites ImportInliner for full OO.
authorJakub Pawlowicz <contact@jakubpawlowicz.com>
Sat, 13 Dec 2014 20:21:35 +0000 (20:21 +0000)
committerJakub Pawlowicz <contact@jakubpawlowicz.com>
Sat, 13 Dec 2014 20:21:35 +0000 (20:21 +0000)
* It's also a bit easier to understand now.

lib/clean.js
lib/imports/inliner.js
lib/utils/object.js [new file with mode: 0644]

index 3f129fc..d736e99 100644 (file)
@@ -20,6 +20,8 @@ var Compatibility = require('./utils/compatibility');
 var InputSourceMapTracker = require('./utils/input-source-map-tracker');
 var SourceTracker = require('./utils/source-tracker');
 
+var DEFAULT_TIMEOUT = 5000;
+
 var CleanCSS = module.exports = function CleanCSS(options) {
   options = options || {};
 
@@ -29,7 +31,7 @@ var CleanCSS = module.exports = function CleanCSS(options) {
     benchmark: options.benchmark,
     compatibility: new Compatibility(options.compatibility).toOptions(),
     debug: options.debug,
-    inliner: options.inliner,
+    inliner: options.inliner || {},
     keepBreaks: options.keepBreaks || false,
     keepSpecialComments: 'keepSpecialComments' in options ? options.keepSpecialComments : '*',
     processImport: undefined === options.processImport ? true : !!options.processImport,
@@ -41,6 +43,9 @@ var CleanCSS = module.exports = function CleanCSS(options) {
     sourceMap: options.sourceMap,
     target: options.target
   };
+
+  this.options.inliner.timeout = this.options.inliner.timeout || DEFAULT_TIMEOUT;
+  this.options.inliner.request = this.options.inliner.request || {};
 };
 
 CleanCSS.prototype.minify = function(data, callback) {
@@ -63,10 +68,8 @@ CleanCSS.prototype.minify = function(data, callback) {
       function (callback) { return callback(); };
 
     return runner(function () {
-      return new ImportInliner(context, context.options.inliner, context.options.rebase).process(data, {
+      return new ImportInliner(context).process(data, {
         localOnly: !callback,
-        root: context.options.root || process.cwd(),
-        relativeTo: context.options.relativeTo,
         whenDone: runMinifier(callback, context)
       });
     });
index 8c135f6..20b291f 100644 (file)
@@ -6,337 +6,323 @@ var url = require('url');
 
 var UrlRewriter = require('../images/url-rewriter');
 var Splitter = require('../utils/splitter.js');
+var override = require('../utils/object.js').override;
 
 var MAP_MARKER = /\/\*# sourceMappingURL=(\S+) \*\//;
 var REMOTE_RESOURCE = /^(https?:)?\/\//;
 
-var merge = function(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 ImportInliner (context) {
+  this.outerContext = context;
+}
 
-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));
+ImportInliner.prototype.process = function (data, context) {
+  var root = this.outerContext.options.root || process.cwd();
+
+  context = override(context, {
+    baseRelativeTo: this.outerContext.options.relativeTo || root,
+    debug: this.outerContext.options.debug,
+    done: [],
+    errors: this.outerContext.errors,
+    left: [],
+    inliner: this.outerContext.options.inliner,
+    rebase: this.outerContext.options.rebase,
+    relativeTo: this.outerContext.options.relativeTo || root,
+    root: root,
+    sourceTracker: this.outerContext.sourceTracker,
+    warnings: this.outerContext.warnings,
+    visited: []
   });
-}
 
-module.exports = function Inliner(context, options, rebase) {
-  var defaultOptions = {
-    timeout: 5000,
-    request: {}
-  };
-  var inlinerOptions = merge(defaultOptions, options || {});
+  return importFrom(data, context);
+};
 
-  var process = function(data, options) {
-    if (options.shallow) {
-      options.shallow = false;
-      options._shared.done.push(data);
-      return processNext(options);
+function importFrom(data, context) {
+  if (context.shallow) {
+    context.shallow = false;
+    context.done.push(data);
+    return processNext(context);
+  }
+
+  var nextStart = 0;
+  var nextEnd = 0;
+  var cursor = 0;
+  var isComment = commentScanner(data);
+  var afterContent = contentScanner(data);
+
+  for (; nextEnd < data.length;) {
+    nextStart = nextImportAt(data, cursor);
+    if (nextStart == -1)
+      break;
+
+    if (isComment(nextStart)) {
+      cursor = nextStart + 1;
+      continue;
     }
 
-    options._shared = options._shared || {
-      done: [],
-      left: []
-    };
-    var shared = options._shared;
-
-    var nextStart = 0;
-    var nextEnd = 0;
-    var cursor = 0;
-    var isComment = commentScanner(data);
-    var afterContent = contentScanner(data);
-
-    options.rebase = rebase;
-    options.relativeTo = options.relativeTo || options.root;
-    options._baseRelativeTo = options._baseRelativeTo || options.relativeTo;
-    options.visited = options.visited || [];
-
-    for (; nextEnd < data.length;) {
-      nextStart = nextImportAt(data, cursor);
-      if (nextStart == -1)
-        break;
-
-      if (isComment(nextStart)) {
-        cursor = nextStart + 1;
-        continue;
-      }
-
-      nextEnd = data.indexOf(';', nextStart);
-      if (nextEnd == -1) {
-        cursor = data.length;
-        data = '';
-        break;
-      }
-
-      shared.done.push(data.substring(0, nextStart));
-      shared.left.unshift([data.substring(nextEnd + 1), options]);
-
-      return afterContent(nextStart) ?
-        processNext(options) :
-        inline(data, nextStart, nextEnd, options);
+    nextEnd = data.indexOf(';', nextStart);
+    if (nextEnd == -1) {
+      cursor = data.length;
+      data = '';
+      break;
     }
 
-    // no @import matched in current data
-    shared.done.push(data);
-    return processNext(options);
-  };
-
-  var nextImportAt = function (data, cursor) {
-    var nextLowerCase = data.indexOf('@import', cursor);
-    var nextUpperCase = data.indexOf('@IMPORT', cursor);
+    context.done.push(data.substring(0, nextStart));
+    context.left.unshift([data.substring(nextEnd + 1), context]);
 
-    if (nextLowerCase > -1 && nextUpperCase == -1)
-      return nextLowerCase;
-    else if (nextLowerCase == -1 && nextUpperCase > -1)
-      return nextUpperCase;
-    else
-      return Math.min(nextLowerCase, nextUpperCase);
-  };
+    return afterContent(nextStart) ?
+      processNext(context) :
+      inline(data, nextStart, nextEnd, context);
+  }
 
-  var processNext = function(options) {
-    if (options._shared.left.length > 0)
-      return process.apply(null, options._shared.left.shift());
-    else
-      return options.whenDone(options._shared.done.join(''));
-  };
-
-  var commentScanner = function(data) {
-    var commentRegex = /(\/\*(?!\*\/)[\s\S]*?\*\/)/;
-    var lastStartIndex = 0;
-    var lastEndIndex = 0;
-    var noComments = false;
-
-    // test whether an index is located within a comment
-    var scanner = function(idx) {
-      var comment;
-      var localStartIndex = 0;
-      var localEndIndex = 0;
-      var globalStartIndex = 0;
-      var globalEndIndex = 0;
+  // no @import matched in current data
+  context.done.push(data);
+  return processNext(context);
+}
 
-      // return if we know there are no more comments
-      if (noComments)
-        return false;
+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));
+  });
+}
 
-      // idx can be still within last matched comment (many @import statements inside one comment)
-      if (idx > lastStartIndex && idx < lastEndIndex)
-        return true;
+function nextImportAt(data, cursor) {
+  var nextLowerCase = data.indexOf('@import', cursor);
+  var nextUpperCase = data.indexOf('@IMPORT', cursor);
 
-      comment = data.match(commentRegex);
+  if (nextLowerCase > -1 && nextUpperCase == -1)
+    return nextLowerCase;
+  else if (nextLowerCase == -1 && nextUpperCase > -1)
+    return nextUpperCase;
+  else
+    return Math.min(nextLowerCase, nextUpperCase);
+}
 
-      if (!comment) {
-        noComments = true;
-        return false;
-      }
+function processNext(context) {
+  return context.left.length > 0 ?
+    importFrom.apply(null, context.left.shift()) :
+    context.whenDone(context.done.join(''));
+}
 
-      // get the indexes relative to the current data chunk
-      lastStartIndex = localStartIndex = comment.index;
-      localEndIndex = localStartIndex + comment[0].length;
+function commentScanner(data) {
+  var commentRegex = /(\/\*(?!\*\/)[\s\S]*?\*\/)/;
+  var lastStartIndex = 0;
+  var lastEndIndex = 0;
+  var noComments = false;
+
+  // test whether an index is located within a comment
+  return function scanner(idx) {
+    var comment;
+    var localStartIndex = 0;
+    var localEndIndex = 0;
+    var globalStartIndex = 0;
+    var globalEndIndex = 0;
+
+    // return if we know there are no more comments
+    if (noComments)
+      return false;
+
+    // idx can be still within last matched comment (many @import statements inside one comment)
+    if (idx > lastStartIndex && idx < lastEndIndex)
+      return true;
+
+    comment = data.match(commentRegex);
+
+    if (!comment) {
+      noComments = true;
+      return false;
+    }
 
-      // calculate the indexes relative to the full original data
-      globalEndIndex = localEndIndex + lastEndIndex;
-      globalStartIndex = globalEndIndex - comment[0].length;
+    // get the indexes relative to the current data chunk
+    lastStartIndex = localStartIndex = comment.index;
+    localEndIndex = localStartIndex + comment[0].length;
 
-      // chop off data up to and including current comment block
-      data = data.substring(localEndIndex);
-      lastEndIndex = globalEndIndex;
+    // calculate the indexes relative to the full original data
+    globalEndIndex = localEndIndex + lastEndIndex;
+    globalStartIndex = globalEndIndex - comment[0].length;
 
-      // re-run scan if comment ended before the idx
-      if (globalEndIndex < idx)
-        return scanner(idx);
+    // chop off data up to and including current comment block
+    data = data.substring(localEndIndex);
+    lastEndIndex = globalEndIndex;
 
-      return globalEndIndex > idx && idx > globalStartIndex;
-    };
+    // re-run scan if comment ended before the idx
+    if (globalEndIndex < idx)
+      return scanner(idx);
 
-    return scanner;
+    return globalEndIndex > idx && idx > globalStartIndex;
   };
+}
 
-  var contentScanner = function(data) {
-    var isComment = commentScanner(data);
-    var firstContentIdx = -1;
-    while (true) {
-      firstContentIdx = data.indexOf('{', firstContentIdx + 1);
-      if (firstContentIdx == -1 || !isComment(firstContentIdx))
-        break;
-    }
-
-    return function(idx) {
-      return firstContentIdx > -1 ?
-        idx > firstContentIdx :
-        false;
-    };
+function contentScanner(data) {
+  var isComment = commentScanner(data);
+  var firstContentIdx = -1;
+  while (true) {
+    firstContentIdx = data.indexOf('{', firstContentIdx + 1);
+    if (firstContentIdx == -1 || !isComment(firstContentIdx))
+      break;
+  }
+
+  return function(idx) {
+    return firstContentIdx > -1 ?
+      idx > firstContentIdx :
+      false;
   };
+}
 
-  var inline = function(data, nextStart, nextEnd, options) {
-    options.shallow = data.indexOf('@shallow') > 0;
+function inline(data, nextStart, nextEnd, context) {
+  context.shallow = data.indexOf('@shallow') > 0;
 
-    var importDeclaration = data
-      .substring(nextImportAt(data, nextStart) + '@import'.length + 1, nextEnd)
-      .replace(/@shallow\)$/, ')')
-      .trim();
+  var importDeclaration = data
+    .substring(nextImportAt(data, nextStart) + '@import'.length + 1, nextEnd)
+    .replace(/@shallow\)$/, ')')
+    .trim();
 
-    var viaUrl = importDeclaration.indexOf('url(') === 0;
-    var urlStartsAt = viaUrl ? 4 : 0;
-    var isQuoted = /^['"]/.exec(importDeclaration.substring(urlStartsAt, urlStartsAt + 2));
-    var urlEndsAt = isQuoted ?
-      importDeclaration.indexOf(isQuoted[0], urlStartsAt + 1) :
-      new Splitter(' ').split(importDeclaration)[0].length - (viaUrl ? 1 : 0);
+  var viaUrl = importDeclaration.indexOf('url(') === 0;
+  var urlStartsAt = viaUrl ? 4 : 0;
+  var isQuoted = /^['"]/.exec(importDeclaration.substring(urlStartsAt, urlStartsAt + 2));
+  var urlEndsAt = isQuoted ?
+    importDeclaration.indexOf(isQuoted[0], urlStartsAt + 1) :
+    new Splitter(' ').split(importDeclaration)[0].length - (viaUrl ? 1 : 0);
 
-    var importedFile = importDeclaration
-      .substring(urlStartsAt, urlEndsAt)
-      .replace(/['"]/g, '')
-      .replace(/\)$/, '')
-      .trim();
+  var importedFile = importDeclaration
+    .substring(urlStartsAt, urlEndsAt)
+    .replace(/['"]/g, '')
+    .replace(/\)$/, '')
+    .trim();
 
-    var mediaQuery = importDeclaration
-      .substring(urlEndsAt + 1)
-      .replace(/^\)/, '')
-      .trim();
+  var mediaQuery = importDeclaration
+    .substring(urlEndsAt + 1)
+    .replace(/^\)/, '')
+    .trim();
 
-    var isRemote = options.isRemote || REMOTE_RESOURCE.test(importedFile);
+  var isRemote = context.isRemote || REMOTE_RESOURCE.test(importedFile);
 
-    if (options.localOnly && isRemote) {
-      context.warnings.push('Ignoring remote @import declaration of "' + importedFile + '" as no callback given.');
-      restoreImport(importedFile, mediaQuery, options);
+  if (context.localOnly && isRemote) {
+    context.warnings.push('Ignoring remote @import declaration of "' + importedFile + '" as no callback given.');
+    restoreImport(importedFile, mediaQuery, context);
 
-      return processNext(options);
-    }
+    return processNext(context);
+  }
 
-    var method = isRemote ? inlineRemoteResource : inlineLocalResource;
-    return method(importedFile, mediaQuery, options);
-  };
+  var method = isRemote ? inlineRemoteResource : inlineLocalResource;
+  return method(importedFile, mediaQuery, context);
+}
 
-  var inlineRemoteResource = function(importedFile, mediaQuery, options) {
-    var importedUrl = REMOTE_RESOURCE.test(importedFile) ?
-      importedFile :
-      url.resolve(options.relativeTo, importedFile);
+function inlineRemoteResource(importedFile, mediaQuery, context) {
+  var importedUrl = REMOTE_RESOURCE.test(importedFile) ?
+    importedFile :
+    url.resolve(context.relativeTo, importedFile);
 
-    if (importedUrl.indexOf('//') === 0)
-      importedUrl = 'http:' + importedUrl;
+  if (importedUrl.indexOf('//') === 0)
+    importedUrl = 'http:' + importedUrl;
 
-    if (options.visited.indexOf(importedUrl) > -1)
-      return processNext(options);
+  if (context.visited.indexOf(importedUrl) > -1)
+    return processNext(context);
 
 
-    if (context.debug)
-      console.error('Inlining remote stylesheet: ' + importedUrl);
+  if (context.debug)
+    console.error('Inlining remote stylesheet: ' + importedUrl);
 
-    options.visited.push(importedUrl);
+  context.visited.push(importedUrl);
 
-    var get = importedUrl.indexOf('http://') === 0 ?
-      http.get :
-      https.get;
+  var get = importedUrl.indexOf('http://') === 0 ?
+    http.get :
+    https.get;
 
-    var handleError = function(message) {
-      context.errors.push('Broken @import declaration of "' + importedUrl + '" - ' + message);
-      restoreImport(importedUrl, mediaQuery, options);
+  function handleError(message) {
+    context.errors.push('Broken @import declaration of "' + importedUrl + '" - ' + message);
+    restoreImport(importedUrl, mediaQuery, context);
 
-      processNext(options);
-    };
-    var requestOptions = merge(url.parse(importedUrl), inlinerOptions.request);
+    processNext(context);
+  }
 
-    get(requestOptions, function(res) {
-      if (res.statusCode < 200 || res.statusCode > 399) {
-        return handleError('error ' + res.statusCode);
-      } else if (res.statusCode > 299) {
-        var movedUrl = url.resolve(importedUrl, res.headers.location);
-        return inlineRemoteResource(movedUrl, mediaQuery, options);
-      }
+  var requestOptions = override(url.parse(importedUrl), context.inliner.request);
+  get(requestOptions, function(res) {
+    if (res.statusCode < 200 || res.statusCode > 399) {
+      return handleError('error ' + res.statusCode);
+    } else if (res.statusCode > 299) {
+      var movedUrl = url.resolve(importedUrl, res.headers.location);
+      return inlineRemoteResource(movedUrl, mediaQuery, context);
+    }
 
-      var chunks = [];
-      var parsedUrl = url.parse(importedUrl);
-      res.on('data', function(chunk) {
-        chunks.push(chunk.toString());
-      });
-      res.on('end', function() {
-        var importedData = chunks.join('');
-        if (options.rebase)
-          importedData = UrlRewriter.process(importedData, { toBase: importedUrl });
-        importedData = context.sourceTracker.store(importedUrl, importedData);
-        importedData = rebaseMap(importedData, importedUrl);
-
-        if (mediaQuery.length > 0)
-          importedData = '@media ' + mediaQuery + '{' + importedData + '}';
-
-        process(importedData, {
-          isRemote: true,
-          relativeTo: parsedUrl.protocol + '//' + parsedUrl.host,
-          _shared: options._shared,
-          whenDone: options.whenDone,
-          visited: options.visited,
-          shallow: options.shallow
-        });
+    var chunks = [];
+    var parsedUrl = url.parse(importedUrl);
+    res.on('data', function(chunk) {
+      chunks.push(chunk.toString());
+    });
+    res.on('end', function() {
+      var importedData = chunks.join('');
+      if (context.rebase)
+        importedData = UrlRewriter.process(importedData, { toBase: importedUrl });
+      importedData = context.sourceTracker.store(importedUrl, importedData);
+      importedData = rebaseMap(importedData, importedUrl);
+
+      if (mediaQuery.length > 0)
+        importedData = '@media ' + mediaQuery + '{' + importedData + '}';
+
+      var newContext = override(context, {
+        isRemote: true,
+        relativeTo: parsedUrl.protocol + '//' + parsedUrl.host
       });
-    })
-    .on('error', function(res) {
-      handleError(res.message);
-    })
-    .on('timeout', function() {
-      handleError('timeout');
-    })
-    .setTimeout(inlinerOptions.timeout);
-  };
 
-  var inlineLocalResource = function(importedFile, mediaQuery, options) {
-    var relativeTo = importedFile[0] == '/' ?
-      options.root :
-      options.relativeTo;
+      importFrom(importedData, newContext);
+    });
+  })
+  .on('error', function(res) {
+    handleError(res.message);
+  })
+  .on('timeout', function() {
+    handleError('timeout');
+  })
+  .setTimeout(context.inliner.timeout);
+}
 
-    var fullPath = path.resolve(path.join(relativeTo, importedFile));
+function inlineLocalResource(importedFile, mediaQuery, context) {
+  var relativeTo = importedFile[0] == '/' ?
+    context.root :
+    context.relativeTo;
 
-    if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
-      context.errors.push('Broken @import declaration of "' + importedFile + '"');
-      return processNext(options);
-    }
+  var fullPath = path.resolve(path.join(relativeTo, importedFile));
 
-    if (options.visited.indexOf(fullPath) > -1)
-      return processNext(options);
+  if (!fs.existsSync(fullPath) || !fs.statSync(fullPath).isFile()) {
+    context.errors.push('Broken @import declaration of "' + importedFile + '"');
+    return processNext(context);
+  }
 
+  if (context.visited.indexOf(fullPath) > -1)
+    return processNext(context);
 
-    if (context.debug)
-      console.error('Inlining local stylesheet: ' + fullPath);
 
-    options.visited.push(fullPath);
+  if (context.debug)
+    console.error('Inlining local stylesheet: ' + fullPath);
 
-    var importRelativeTo = path.dirname(fullPath);
-    var importedData = fs.readFileSync(fullPath, 'utf8');
-    if (options.rebase) {
-      importedData = UrlRewriter.process(importedData, {
-        relative: true,
-        fromBase: importRelativeTo,
-        toBase: options._baseRelativeTo
-      });
-    }
-    importedData = context.sourceTracker.store(path.resolve(options.relativeTo, fullPath), importedData);
-
-    if (mediaQuery.length > 0)
-      importedData = '@media ' + mediaQuery + '{' + importedData + '}';
-
-    return process(importedData, {
-      root: options.root,
-      relativeTo: importRelativeTo,
-      _baseRelativeTo: options._baseRelativeTo,
-      _shared: options._shared,
-      visited: options.visited,
-      whenDone: options.whenDone,
-      localOnly: options.localOnly,
-      shallow: options.shallow
+  context.visited.push(fullPath);
+
+  var importRelativeTo = path.dirname(fullPath);
+  var importedData = fs.readFileSync(fullPath, 'utf8');
+  if (context.rebase) {
+    importedData = UrlRewriter.process(importedData, {
+      relative: true,
+      fromBase: importRelativeTo,
+      toBase: context.baseRelativeTo
     });
-  };
+  }
+  importedData = context.sourceTracker.store(path.resolve(context.relativeTo, fullPath), importedData);
 
-  var restoreImport = function(importedUrl, mediaQuery, options) {
-    var restoredImport = '@import url(' + importedUrl + ')' + (mediaQuery.length > 0 ? ' ' + mediaQuery : '') + ';';
-    options._shared.done.push(restoredImport);
-  };
+  if (mediaQuery.length > 0)
+    importedData = '@media ' + mediaQuery + '{' + importedData + '}';
 
-  // Inlines all imports taking care of repetitions, unknown files, and circular dependencies
-  return { process: process };
-};
+  var newContext = override(context, {
+    relativeTo: importRelativeTo,
+  });
+
+  return importFrom(importedData, newContext);
+}
+
+function restoreImport(importedUrl, mediaQuery, context) {
+  var restoredImport = '@import url(' + importedUrl + ')' + (mediaQuery.length > 0 ? ' ' + mediaQuery : '') + ';';
+  context.done.push(restoredImport);
+}
+
+module.exports = ImportInliner;
diff --git a/lib/utils/object.js b/lib/utils/object.js
new file mode 100644 (file)
index 0000000..8e94886
--- /dev/null
@@ -0,0 +1,11 @@
+module.exports = {
+  override: function (source1, source2) {
+    var target = {};
+    for (var key1 in source1)
+      target[key1] = source1[key1];
+    for (var key2 in source2)
+      target[key2] = source2[key2];
+
+    return target;
+  }
+};