Reimplements `@import` inlining on top of the new tokenizer.
authorJakub Pawlowicz <contact@jakubpawlowicz.com>
Tue, 29 Nov 2016 09:48:00 +0000 (10:48 +0100)
committerJakub Pawlowicz <contact@jakubpawlowicz.com>
Fri, 16 Dec 2016 10:49:31 +0000 (11:49 +0100)
It works roughly the same as the old inliner but at a different
step: not before tokenization but during it.

Thanks to it there's no need to match import URLs in sources,
deal with comments, etc.

History.md
lib/imports/inliner.js [deleted file]
lib/optimizer/basic.js
lib/optimizer/tidy-at-rule.js
lib/urls/rewrite-url.js
lib/utils/read-sources.js
test/integration-test.js
test/protocol-imports-test.js

index 9338edb..1adab48 100644 (file)
@@ -3,6 +3,7 @@
 
 * Requires Node.js 4.0+ to run.
 * Replaces the old tokenizer with a new one which doesn't use any escaping.
+* Replaces the old `@import` inlining with one on top of the new tokenizer.
 
 [3.4.22 / 2016-12-12](https://github.com/jakubpawlowicz/clean-css/compare/v3.4.21...v3.4.22)
 ==================
diff --git a/lib/imports/inliner.js b/lib/imports/inliner.js
deleted file mode 100644 (file)
index 041d733..0000000
+++ /dev/null
@@ -1,399 +0,0 @@
-var fs = require('fs');
-var path = require('path');
-var http = require('http');
-var https = require('https');
-var url = require('url');
-
-var rewriteUrls = require('../urls/rewrite');
-var split = require('../utils/split');
-var override = require('../utils/object.js').override;
-
-var MAP_MARKER = /\/\*# sourceMappingURL=(\S+) \*\//;
-var REMOTE_RESOURCE = /^(https?:)?\/\//;
-var NO_PROTOCOL_RESOURCE = /^\/\//;
-
-function ImportInliner (context) {
-  this.outerContext = context;
-}
-
-ImportInliner.prototype.process = function (data, context) {
-  var root = this.outerContext.options.root;
-
-  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,
-    sourceReader: this.outerContext.sourceReader,
-    sourceTracker: this.outerContext.sourceTracker,
-    warnings: this.outerContext.warnings,
-    visited: []
-  });
-
-  return importFrom(data, context);
-};
-
-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);
-
-  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;
-    }
-
-    var noImportPart = data.substring(0, nextStart);
-    context.done.push(noImportPart);
-    context.left.unshift([data.substring(nextEnd + 1), override(context, { shallow: false })]);
-    context.afterContent = hasContent(noImportPart);
-    return inline(data, nextStart, nextEnd, context);
-  }
-
-  // no @import matched in current data
-  context.done.push(data);
-  return processNext(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));
-  });
-}
-
-function nextImportAt(data, cursor) {
-  var nextLowerCase = data.indexOf('@import', cursor);
-  var nextUpperCase = data.indexOf('@IMPORT', cursor);
-
-  if (nextLowerCase > -1 && nextUpperCase == -1)
-    return nextLowerCase;
-  else if (nextLowerCase == -1 && nextUpperCase > -1)
-    return nextUpperCase;
-  else
-    return Math.min(nextLowerCase, nextUpperCase);
-}
-
-function processNext(context) {
-  return context.left.length > 0 ?
-    importFrom.apply(null, context.left.shift()) :
-    context.whenDone(context.done.join(''));
-}
-
-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;
-
-    do {
-      // 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;
-      }
-
-      // get the indexes relative to the current data chunk
-      lastStartIndex = localStartIndex = comment.index;
-      localEndIndex = localStartIndex + comment[0].length;
-
-      // calculate the indexes relative to the full original data
-      globalEndIndex = localEndIndex + lastEndIndex;
-      globalStartIndex = globalEndIndex - comment[0].length;
-
-      // chop off data up to and including current comment block
-      data = data.substring(localEndIndex);
-      lastEndIndex = globalEndIndex;
-    } while (globalEndIndex < idx);
-
-    return globalEndIndex > idx && idx > globalStartIndex;
-  };
-}
-
-function hasContent(data) {
-  var isComment = commentScanner(data);
-  var firstContentIdx = -1;
-  while (true) {
-    firstContentIdx = data.indexOf('{', firstContentIdx + 1);
-    if (firstContentIdx == -1 || !isComment(firstContentIdx))
-      break;
-  }
-
-  return firstContentIdx > -1;
-}
-
-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 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) :
-    split(importDeclaration, ' ')[0].length - (viaUrl ? 1 : 0);
-
-  var importedFile = importDeclaration
-    .substring(urlStartsAt, urlEndsAt)
-    .replace(/['"]/g, '')
-    .replace(/\)$/, '')
-    .trim();
-
-  var mediaQuery = importDeclaration
-    .substring(urlEndsAt + 1)
-    .replace(/^\)/, '')
-    .trim();
-
-  var isRemote = context.isRemote || REMOTE_RESOURCE.test(importedFile);
-
-  if (isRemote && (context.localOnly || !allowedResource(importedFile, true, context.imports))) {
-    if (context.afterContent || hasContent(context.done.join('')))
-      context.warnings.push('Ignoring remote @import of "' + importedFile + '" as no callback given.');
-    else
-      restoreImport(importedFile, mediaQuery, context);
-
-    return processNext(context);
-  }
-
-  if (!isRemote && !allowedResource(importedFile, false, context.imports)) {
-    if (context.afterImport)
-      context.warnings.push('Ignoring local @import of "' + importedFile + '" as after other inlined content.');
-    else
-      restoreImport(importedFile, mediaQuery, context);
-    return processNext(context);
-  }
-
-  if (!isRemote && context.afterContent) {
-    context.warnings.push('Ignoring local @import of "' + importedFile + '" as after other CSS content.');
-    return processNext(context);
-  }
-
-  var method = isRemote ? inlineRemoteResource : inlineLocalResource;
-  return method(importedFile, mediaQuery, context);
-}
-
-function allowedResource(importedFile, isRemote, rules) {
-  if (rules.length === 0)
-    return false;
-
-  if (isRemote && NO_PROTOCOL_RESOURCE.test(importedFile))
-    importedFile = 'http:' + importedFile;
-
-  var match = isRemote ?
-    url.parse(importedFile).host :
-    importedFile;
-  var allowed = true;
-
-  for (var i = 0; i < rules.length; i++) {
-    var rule = rules[i];
-
-    if (rule == 'all')
-      allowed = true;
-    else if (isRemote && rule == 'local')
-      allowed = false;
-    else if (isRemote && rule == 'remote')
-      allowed = true;
-    else if (!isRemote && rule == 'remote')
-      allowed = false;
-    else if (!isRemote && rule == 'local')
-      allowed = true;
-    else if (rule[0] == '!' && rule.substring(1) === match)
-      allowed = false;
-  }
-
-  return allowed;
-}
-
-function inlineRemoteResource(importedFile, mediaQuery, context) {
-  var importedUrl = REMOTE_RESOURCE.test(importedFile) ?
-    importedFile :
-    url.resolve(context.relativeTo, importedFile);
-  var originalUrl = importedUrl;
-
-  if (NO_PROTOCOL_RESOURCE.test(importedUrl))
-    importedUrl = 'http:' + importedUrl;
-
-  if (context.visited.indexOf(importedUrl) > -1)
-    return processNext(context);
-
-
-  if (context.debug)
-    console.error('Inlining remote stylesheet: ' + importedUrl);
-
-  context.visited.push(importedUrl);
-
-  var proxyProtocol = context.inliner.request.protocol || context.inliner.request.hostname;
-  var get =
-    ((proxyProtocol && proxyProtocol.indexOf('https://') !== 0 ) ||
-     importedUrl.indexOf('http://') === 0) ?
-    http.get :
-    https.get;
-
-  var errorHandled = false;
-  function handleError(message) {
-    if (errorHandled)
-      return;
-
-    errorHandled = true;
-    context.errors.push('Broken @import declaration of "' + importedUrl + '" - ' + message);
-    restoreImport(importedUrl, mediaQuery, context);
-
-    process.nextTick(function () {
-      processNext(context);
-    });
-  }
-
-  var requestOptions = override(url.parse(importedUrl), context.inliner.request);
-  if (context.inliner.request.hostname !== undefined) {
-
-    //overwrite as we always expect a http proxy currently
-    requestOptions.protocol = context.inliner.request.protocol || 'http:';
-    requestOptions.path = requestOptions.href;
-  }
-
-
-  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 (context.rebase)
-        importedData = rewriteUrls(importedData, { toBase: originalUrl }, context);
-      context.sourceReader.trackSource(importedUrl, importedData);
-      importedData = context.sourceTracker.store(importedUrl, importedData);
-      importedData = rebaseMap(importedData, importedUrl);
-
-      if (mediaQuery.length > 0)
-        importedData = '@media ' + mediaQuery + '{' + importedData + '}';
-
-      context.afterImport = true;
-
-      var newContext = override(context, {
-        isRemote: true,
-        relativeTo: parsedUrl.protocol + '//' + parsedUrl.host + parsedUrl.pathname
-      });
-
-      process.nextTick(function () {
-        importFrom(importedData, newContext);
-      });
-    });
-  })
-  .on('error', function (res) {
-    handleError(res.message);
-  })
-  .on('timeout', function () {
-    handleError('timeout');
-  })
-  .setTimeout(context.inliner.timeout);
-}
-
-function inlineLocalResource(importedFile, mediaQuery, context) {
-  var relativeTo = importedFile[0] == '/' ?
-    context.root :
-    context.relativeTo;
-
-  var fullPath = path.resolve(path.join(relativeTo, importedFile));
-
-  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);
-
-  context.visited.push(fullPath);
-
-  var importRelativeTo = path.dirname(fullPath);
-  var importedData = fs.readFileSync(fullPath, 'utf8');
-  if (context.rebase) {
-    var rewriteOptions = {
-      relative: true,
-      fromBase: importRelativeTo,
-      toBase: context.baseRelativeTo
-    };
-    importedData = rewriteUrls(importedData, rewriteOptions, context);
-  }
-
-  var relativePath = path.relative(context.root, fullPath);
-  context.sourceReader.trackSource(relativePath, importedData);
-  importedData = context.sourceTracker.store(relativePath, importedData);
-
-  if (mediaQuery.length > 0)
-    importedData = '@media ' + mediaQuery + '{' + importedData + '}';
-
-  context.afterImport = true;
-
-  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;
index ec95bda..248106a 100644 (file)
@@ -30,6 +30,7 @@ var FONT_NAME_WEIGHTS_WITHOUT_NORMAL = ['bold', 'bolder', 'lighter'];
 var WHOLE_PIXEL_VALUE = /(?:^|\s|\()(-?\d+)px/;
 var TIME_VALUE = /^(\-?[\d\.]+)(m?s)$/;
 
+var IMPORT_PREFIX_PATTERN = /^@import/i;
 var QUOTED_PATTERN = /^('.*'|".*")$/;
 var QUOTED_BUT_SAFE_PATTERN = /^['"][a-zA-Z][a-zA-Z\d\-_]+['"]$/;
 var URL_PREFIX_PATTERN = /^url\(/i;
@@ -507,12 +508,17 @@ function buildPrecision(options) {
   return precision;
 }
 
+function isImport(token) {
+  return IMPORT_PREFIX_PATTERN.test(token[1]);
+}
+
 function basicOptimize(tokens, context) {
   var options = context.options;
   var ie7Hack = options.compatibility.selectors.ie7Hack;
   var adjacentSpace = options.compatibility.selectors.adjacentSpace;
   var spaceAfterClosingBrace = options.compatibility.properties.spaceAfterClosingBrace;
   var mayHaveCharset = false;
+  var afterRules = false;
 
   options.unitsRegexp = options.unitsRegexp || buildUnitRegexp(options);
   options.precision = options.precision || buildPrecision(options);
@@ -524,15 +530,17 @@ function basicOptimize(tokens, context) {
 
     switch (token[0]) {
       case Token.AT_RULE:
-        token[1] = tidyAtRule(token[1]);
+        token[1] = isImport(token) && afterRules ? '' : tidyAtRule(token[1]);
         mayHaveCharset = true;
         break;
       case Token.AT_RULE_BLOCK:
         optimizeBody(token[2], context);
+        afterRules = true;
         break;
       case Token.BLOCK:
         token[1] = tidyBlock(token[1], spaceAfterClosingBrace);
         basicOptimize(token[2], context);
+        afterRules = true;
         break;
       case Token.COMMENT:
         optimizeComment(token, options);
@@ -540,6 +548,7 @@ function basicOptimize(tokens, context) {
       case Token.RULE:
         token[1] = tidyRules(token[1], !ie7Hack, adjacentSpace);
         optimizeBody(token[2], context);
+        afterRules = true;
         break;
     }
 
index 06f49d9..a7b149f 100644 (file)
@@ -1,6 +1,8 @@
 function tidyAtRule(value) {
   return value
     .replace(/\s+/g, ' ')
+    .replace(/url\(\s+/g, 'url(')
+    .replace(/\s+\)/g, ')')
     .trim();
 }
 
index e524216..04e4ae4 100644 (file)
@@ -20,23 +20,19 @@ function rebase(uri, rebaseConfig) {
     return uri;
   }
 
-  if (isAbsolute(uri) || isSVGMarker(uri) || isInternal(uri)) {
+  if (isAbsolute(uri) && !isRemote(rebaseConfig.toBase)) {
     return uri;
   }
 
-  if (isData(uri)) {
-    return '\'' + uri + '\'';
-  }
-
-  if (isRemote(uri) && !isRemote(rebaseConfig.toBase)) {
+  if (isRemote(uri) || isSVGMarker(uri) || isInternal(uri)) {
     return uri;
   }
 
-  if (isRemote(uri) && !isSameOrigin(uri, rebaseConfig.toBase)) {
-    return uri;
+  if (isData(uri)) {
+    return '\'' + uri + '\'';
   }
 
-  if (!isRemote(uri) && isRemote(rebaseConfig.toBase)) {
+  if (isRemote(rebaseConfig.toBase)) {
     return url.resolve(rebaseConfig.toBase, uri);
   }
 
@@ -61,11 +57,6 @@ function isRemote(uri) {
   return /^[^:]+?:\/\//.test(uri) || uri.indexOf('//') === 0;
 }
 
-function isSameOrigin(uri1, uri2) {
-  return url.parse(uri1).protocol == url.parse(uri2).protocol &&
-    url.parse(uri1).host == url.parse(uri2).host;
-}
-
 function isData(uri) {
   return uri.indexOf('data:') === 0;
 }
index 03be623..3de64f9 100644 (file)
@@ -1,23 +1,48 @@
+var fs = require('fs');
+var http = require('http');
+var https = require('https');
 var path = require('path');
+var url = require('url');
 
 var tokenize = require('../tokenizer/tokenize');
 var Token = require('../tokenizer/token');
 
 var rewriteUrl = require('../urls/rewrite-url');
 
-function readSources(input, context, callback) {
-  var tokens;
+var override = require('../utils/override');
+var split = require('../utils/split');
+
+var IMPORT_PREFIX_PATTERN = /^@import/i;
+var BRACE_PREFIX = /^\(/;
+var BRACE_SUFFIX = /\)$/;
+var QUOTE_PREFIX_PATTERN = /['"]\s*/;
+var QUOTE_SUFFIX_PATTERN = /\s*['"]/;
+var URL_PREFIX_PATTERN = /^url\(\s*/i;
+var URL_SUFFIX_PATTERN = /\s*\)/i;
 
+var HTTP_PROTOCOL = 'http:';
+var HTTP_RESOURCE_PATTERN = /^http:\/\//;
+var HTTPS_RESOURCE_PATTERN = /^https:\/\//;
+var NO_PROTOCOL_RESOURCE_PATTERN = /^\/\//;
+var REMOTE_RESOURCE_PATTERN = /^(https?:)?\/\//;
+
+function readSources(input, context, callback) {
   if (typeof input == 'string') {
-    tokens = tokenize(input, context);
+    return fromString(input, context, {}, callback);
   } else if (typeof input == 'object') {
-    tokens = fromHash(input, context);
+    return fromHash(input, context, {}, callback);
   }
+}
+
+function fromString(input, context, parentInlinerContext, callback) {
+  var tokens = tokenize(input, context);
 
-  return callback(tokens);
+  return context.options.processImport ?
+    inlineImports(tokens, context, parentInlinerContext, callback) :
+    callback(tokens);
 }
 
-function fromHash(input, context) {
+function fromHash(input, context, parentInlinerContext, callback) {
   var tokens = [];
   var sourcePath;
   var source;
@@ -28,35 +53,54 @@ function fromHash(input, context) {
 
   for (sourcePath in input) {
     source = input[sourcePath];
-    relativeTo = sourcePath[0] == '/' ?
-      context.options.root :
-      baseRelativeTo;
-    fullPath = path.resolve(
-      path.join(
-        relativeTo,
-        sourcePath
-      )
-    );
 
-    config = {
-      relative: true,
-      fromBase: path.dirname(fullPath),
-      toBase: baseRelativeTo
-    };
+    if (isRemote(sourcePath)) {
+      config = {
+        relative: true,
+        fromBase: sourcePath,
+        toBase: sourcePath
+      };
+    } else {
+      relativeTo = sourcePath[0] == '/' ?
+        context.options.root :
+        baseRelativeTo;
+
+      fullPath = path.resolve(
+        path.join(
+          relativeTo,
+          sourcePath
+        )
+      );
+
+      config = {
+        relative: true,
+        fromBase: path.dirname(fullPath),
+        toBase: baseRelativeTo
+      };
+    }
 
     tokens = tokens.concat(
       rebase(
         tokenize(source.styles, context),
+        context.options.rebase,
         context.validator,
         config
       )
     );
   }
 
-  return tokens;
+  return context.options.processImport ?
+    inlineImports(tokens, context, parentInlinerContext, callback) :
+    callback(tokens);
 }
 
-function rebase(tokens, validator, config) {
+function rebase(tokens, rebaseAll, validator, config) {
+  return rebaseAll ?
+    rebaseEverything(tokens, validator, config) :
+    rebaseAtRules(tokens, validator, config);
+}
+
+function rebaseEverything(tokens, validator, config) {
   var token;
   var i, l;
 
@@ -65,13 +109,13 @@ function rebase(tokens, validator, config) {
 
     switch (token[0]) {
       case Token.AT_RULE:
-        //
+        rebaseAtRule(token, validator, config);
         break;
       case Token.AT_RULE_BLOCK:
         //
         break;
       case Token.BLOCK:
-        rebase(token[2], validator, config);
+        rebaseEverything(token[2], validator, config);
         break;
       case Token.PROPERTY:
         //
@@ -85,6 +129,35 @@ function rebase(tokens, validator, config) {
   return tokens;
 }
 
+function rebaseAtRules(tokens, validator, config) {
+  var token;
+  var i, l;
+
+  for (i = 0, l = tokens.length; i < l; i++) {
+    token = tokens[i];
+
+    switch (token[0]) {
+      case Token.AT_RULE:
+        rebaseAtRule(token, validator, config);
+        break;
+    }
+  }
+
+  return tokens;
+}
+
+function rebaseAtRule(token, validator, config) {
+  var uriAndMediaQuery = extractUrlAndMedia(token[1]);
+  var newUrl = rewriteUrl(uriAndMediaQuery[0], config);
+  var mediaQuery = uriAndMediaQuery[1];
+
+  token[1] = restoreImport(newUrl, mediaQuery);
+}
+
+function restoreImport(uri, mediaQuery) {
+  return ('@import ' + uri + ' ' + mediaQuery).trim();
+}
+
 function rebaseProperties(properties, validator, config) {
   var property;
   var value;
@@ -104,4 +177,274 @@ function rebaseProperties(properties, validator, config) {
   }
 }
 
+function inlineImports(tokens, externalContext, parentInlinerContext, callback) {
+  var inlinerContext = {
+    afterContent: false,
+    callback: callback,
+    externalContext: externalContext,
+    imported: parentInlinerContext.imported || [],
+    isRemote: parentInlinerContext.isRemote || false,
+    outputTokens: [],
+    sourceTokens: tokens
+  };
+
+  return doInlineImports(inlinerContext);
+}
+
+function doInlineImports(inlinerContext) {
+  var token;
+  var i, l;
+
+  for (i = 0, l = inlinerContext.sourceTokens.length; i < l; i++) {
+    token = inlinerContext.sourceTokens[i];
+
+    if (token[0] == Token.AT_RULE && IMPORT_PREFIX_PATTERN.test(token[1])) {
+      inlinerContext.sourceTokens.splice(0, i);
+      return inlineStylesheet(token, inlinerContext);
+    } else if (token[0] == Token.AT_RULE || token[0] == Token.COMMENT) {
+      inlinerContext.outputTokens.push(token);
+    } else {
+      inlinerContext.outputTokens.push(token);
+      inlinerContext.afterContent = true;
+    }
+  }
+
+  inlinerContext.sourceTokens = [];
+  return inlinerContext.callback(inlinerContext.outputTokens);
+}
+
+function inlineStylesheet(token, inlinerContext) {
+  var uriAndMediaQuery = extractUrlAndMedia(token[1]);
+  var uri = uriAndMediaQuery[0];
+  var mediaQuery = uriAndMediaQuery[1];
+
+  return isRemote(uri) ?
+    inlineRemoteStylesheet(uri, mediaQuery, inlinerContext) :
+    inlineLocalStylesheet(uri, mediaQuery, inlinerContext);
+}
+
+function extractUrlAndMedia(atRuleValue) {
+  var uri;
+  var mediaQuery;
+  var stripped;
+  var parts;
+
+  stripped = atRuleValue
+    .replace(IMPORT_PREFIX_PATTERN, '')
+    .trim()
+    .replace(URL_PREFIX_PATTERN, '(')
+    .replace(URL_SUFFIX_PATTERN, ')')
+    .replace(QUOTE_PREFIX_PATTERN, '')
+    .replace(QUOTE_SUFFIX_PATTERN, '');
+
+  parts = split(stripped, ' ');
+
+  uri = parts[0]
+    .replace(BRACE_PREFIX, '')
+    .replace(BRACE_SUFFIX, '');
+  mediaQuery = parts.slice(1).join(' ');
+
+  return [uri, mediaQuery];
+}
+
+function isRemote(uri) {
+  return REMOTE_RESOURCE_PATTERN.test(uri);
+}
+
+function allowedResource(uri, isRemote, rules) {
+  var match;
+  var allowed = true;
+  var rule;
+  var i;
+
+  if (rules.length === 0) {
+    return false;
+  }
+
+  if (isRemote && NO_PROTOCOL_RESOURCE_PATTERN.test(uri)) {
+    uri = 'http:' + uri;
+  }
+
+  match = isRemote ?
+    url.parse(uri).host :
+    uri;
+
+  for (i = 0; i < rules.length; i++) {
+    rule = rules[i];
+
+    if (rule == 'all') {
+      allowed = true;
+    } else if (isRemote && rule == 'local') {
+      allowed = false;
+    } else if (isRemote && rule == 'remote') {
+      allowed = true;
+    } else if (!isRemote && rule == 'remote') {
+      allowed = false;
+    } else if (!isRemote && rule == 'local') {
+      allowed = true;
+    } else if (rule[0] == '!' && rule.substring(1) === match) {
+      allowed = false;
+    }
+  }
+
+  return allowed;
+}
+
+function inlineRemoteStylesheet(uri, mediaQuery, inlinerContext) {
+  var inliner = inlinerContext.externalContext.options.inliner;
+  var errorHandled = false;
+  var fetch;
+  var isAllowed = allowedResource(uri, true, inlinerContext.externalContext.options.processImportFrom);
+  var onError;
+  var options;
+  var originalUri = uri;
+  var proxyProtocol = inliner.request.protocol || inliner.request.hostname;
+
+  if (inlinerContext.imported.indexOf(uri) > -1) {
+    inlinerContext.externalContext.warnings.push('Ignoring remote @import of "' + uri + '" as it has already been imported.');
+    inlinerContext.sourceTokens = inlinerContext.sourceTokens.slice(1);
+    return doInlineImports(inlinerContext);
+  } else if (inlinerContext.externalContext.localOnly && inlinerContext.afterContent) {
+    inlinerContext.externalContext.warnings.push('Ignoring remote @import of "' + uri + '" as no callback given and after other content.');
+    inlinerContext.sourceTokens = inlinerContext.sourceTokens.slice(1);
+    return doInlineImports(inlinerContext);
+  } else if (inlinerContext.externalContext.localOnly) {
+    inlinerContext.externalContext.warnings.push('Skipping remote @import of "' + uri + '" as no callback given.');
+    inlinerContext.outputTokens = inlinerContext.outputTokens.concat(inlinerContext.sourceTokens.slice(0, 1));
+    inlinerContext.sourceTokens = inlinerContext.sourceTokens.slice(1);
+    return doInlineImports(inlinerContext);
+  } else if (!isAllowed && inlinerContext.afterContent) {
+    inlinerContext.externalContext.warnings.push('Ignoring remote @import of "' + uri + '" as resource not allowed and after other content.');
+    inlinerContext.sourceTokens = inlinerContext.sourceTokens.slice(1);
+    return doInlineImports(inlinerContext);
+  } else if (!isAllowed) {
+    inlinerContext.externalContext.warnings.push('Skipping remote @import of "' + uri + '" as resource not allowed.');
+    inlinerContext.outputTokens = inlinerContext.outputTokens.concat(inlinerContext.sourceTokens.slice(0, 1));
+    inlinerContext.sourceTokens = inlinerContext.sourceTokens.slice(1);
+    return doInlineImports(inlinerContext);
+  }
+
+  if (NO_PROTOCOL_RESOURCE_PATTERN.test(uri)) {
+    uri = 'http:' + uri;
+  }
+
+  fetch = (proxyProtocol && !HTTPS_RESOURCE_PATTERN.test(proxyProtocol)) || HTTP_RESOURCE_PATTERN.test(uri) ?
+    http.get :
+    https.get;
+
+  options = override(url.parse(uri), inliner.request);
+  if (inliner.request.hostname !== undefined) {
+    // overwrite as we always expect a http proxy currently
+    options.protocol = inliner.request.protocol || HTTP_PROTOCOL;
+    options.path = options.href;
+  }
+
+  onError = function(message) {
+    if (errorHandled)
+      return;
+
+    errorHandled = true;
+    inlinerContext.externalContext.errors.push('Broken @import declaration of "' + uri + '" - ' + message);
+
+    process.nextTick(function () {
+      inlinerContext.outputTokens = inlinerContext.outputTokens.concat(inlinerContext.sourceTokens.slice(0, 1));
+      inlinerContext.sourceTokens = inlinerContext.sourceTokens.slice(1);
+      doInlineImports(inlinerContext);
+    });
+  };
+
+  inlinerContext.imported.push(uri);
+
+  fetch(options, function (res) {
+    var chunks = [];
+    var movedUri;
+
+    if (res.statusCode < 200 || res.statusCode > 399) {
+      return onError('error ' + res.statusCode);
+    } else if (res.statusCode > 299) {
+      movedUri = url.resolve(uri, res.headers.location);
+      return inlineRemoteStylesheet(movedUri, mediaQuery, inlinerContext);
+    }
+
+    res.on('data', function (chunk) {
+      chunks.push(chunk.toString());
+    });
+    res.on('end', function () {
+      var importedStyles;
+      var sourceHash = {};
+
+      importedStyles = chunks.join('');
+      sourceHash[originalUri] = {
+        styles: importedStyles
+      };
+
+      inlinerContext.isRemote = true;
+      fromHash(sourceHash, inlinerContext.externalContext, inlinerContext, function (importedTokens) {
+        importedTokens = wrapInMedia(importedTokens, mediaQuery);
+
+        inlinerContext.outputTokens = inlinerContext.outputTokens.concat(importedTokens);
+        inlinerContext.sourceTokens = inlinerContext.sourceTokens.slice(1);
+
+        doInlineImports(inlinerContext);
+      });
+    });
+
+  })
+  .on('error', function (res) {
+    onError(res.message);
+  })
+  .on('timeout', function () {
+    onError('timeout');
+  })
+  .setTimeout(inliner.timeout);
+}
+
+function inlineLocalStylesheet(uri, mediaQuery, inlinerContext) {
+  var relativeTo = uri[0] == '/' ?
+    inlinerContext.externalContext.options.root :
+    inlinerContext.externalContext.options.relativeTo || inlinerContext.externalContext.options.root;
+  var absolutePath = path.join(relativeTo, uri);
+  var resolvedPath = path.resolve(absolutePath);
+  var importedStyles;
+  var importedTokens;
+  var isAllowed = allowedResource(uri, false, inlinerContext.externalContext.options.processImportFrom);
+  var sourceHash = {};
+
+  if (inlinerContext.imported.indexOf(resolvedPath) > -1) {
+    inlinerContext.externalContext.warnings.push('Ignoring local @import of "' + uri + '" as it has already beeb imported.');
+  } else if (!fs.existsSync(resolvedPath) || !fs.statSync(resolvedPath).isFile()) {
+    inlinerContext.externalContext.errors.push('Ignoring local @import of "' + uri + '" as resource is missing.');
+  } else if (!isAllowed && inlinerContext.afterContent) {
+    inlinerContext.externalContext.warnings.push('Ignoring local @import of "' + uri + '" as resource not allowed and after other content.');
+  } else if (inlinerContext.afterContent) {
+    inlinerContext.externalContext.warnings.push('Ignoring local @import of "' + uri + '" as after other content.');
+  } else if (!isAllowed) {
+    inlinerContext.externalContext.warnings.push('Skipping local @import of "' + uri + '" as resource not allowed.');
+    inlinerContext.outputTokens = inlinerContext.outputTokens.concat(inlinerContext.sourceTokens.slice(0, 1));
+  } else {
+    importedStyles = fs.readFileSync(resolvedPath, 'utf-8');
+    inlinerContext.imported.push(resolvedPath);
+
+    sourceHash[uri] = {
+      styles: importedStyles
+    };
+    importedTokens = fromHash(sourceHash, inlinerContext.externalContext, inlinerContext, function (tokens) { return tokens; });
+    importedTokens = wrapInMedia(importedTokens, mediaQuery);
+
+    inlinerContext.outputTokens = inlinerContext.outputTokens.concat(importedTokens);
+  }
+
+  inlinerContext.sourceTokens = inlinerContext.sourceTokens.slice(1);
+
+  return doInlineImports(inlinerContext);
+}
+
+function wrapInMedia(tokens, mediaQuery) {
+  if (mediaQuery) {
+    return [[Token.BLOCK, [['@media ' + mediaQuery]], tokens]];
+  } else {
+    return tokens;
+  }
+}
+
 module.exports = readSources;
index a19d5e4..28bfe7d 100644 (file)
@@ -2087,7 +2087,7 @@ vows.describe('integration tests')
         '@import url(test/fixtures/partials/comment.css);',
         'a{display:block}'
       ],
-      'of a file (with media) with a comment': [
+      'of a file with media having a comment': [
         '@import url(test/fixtures/partials/comment.css) screen and (device-height: 600px);',
         '@media screen and (device-height:600px){a{display:block}}'
       ],
index 4564878..ad28bbf 100644 (file)
@@ -454,6 +454,30 @@ vows.describe('protocol imports').addBatch({
       nock.cleanAll();
     }
   },
+  'of a remote resource after content and no callback': {
+    topic: function () {
+      var source = '.one{color:red}@import url(http://127.0.0.1/remote.css);';
+      this.reqMocks = nock('http://127.0.0.1')
+        .get('/remote.css')
+        .reply(200, 'div{padding:0}');
+
+      return new CleanCSS().minify(source);
+    },
+    'should not raise errors': function (error, minified) {
+      assert.isEmpty(minified.errors);
+    },
+    'should raise warnings': function (error, minified) {
+      assert.lengthOf(minified.warnings, 1);
+      assert.match(minified.warnings[0], /no callback given/);
+    },
+    'should process @import': function (error, minified) {
+      assert.equal(minified.styles, '.one{color:red}');
+    },
+    teardown: function () {
+      assert.isFalse(this.reqMocks.isDone());
+      nock.cleanAll();
+    }
+  },
   'of a remote resource mixed with local ones but no callback': {
     topic: function () {
       var source = '@import url(test/fixtures/partials/one.css);@import url(http://127.0.0.1/remote.css);';
@@ -486,8 +510,9 @@ vows.describe('protocol imports').addBatch({
     'should not raise errors': function (error, minified) {
       assert.isEmpty(minified.errors);
     },
-    'should not raise warnings': function (error, minified) {
-      assert.isEmpty(minified.warnings);
+    'should raise warnings': function (error, minified) {
+      assert.lengthOf(minified.warnings, 1);
+      assert.equal(minified.warnings[0], 'Skipping remote @import of "http://127.0.0.1/skipped.css" as resource not allowed.');
     },
     'should keep imports': function (error, minified) {
       assert.equal(minified.styles, '@import url(http://127.0.0.1/skipped.css);.one{color:red}');
@@ -768,8 +793,10 @@ vows.describe('protocol imports').addBatch({
     'should not raise errors': function (error, minified) {
       assert.isEmpty(minified.errors);
     },
-    'should not raise warnings': function (error, minified) {
-      assert.isEmpty(minified.warnings);
+    'should raise warnings': function (error, minified) {
+      assert.lengthOf(minified.warnings, 2);
+      assert.equal(minified.warnings[0], 'Skipping remote @import of "http://127.0.0.1/remote.css" as resource not allowed.');
+      assert.equal(minified.warnings[1], 'Skipping remote @import of "http://assets.127.0.0.1/remote.css" as resource not allowed.');
     },
     'should keeps imports': function (error, minified) {
       assert.equal(minified.styles, '@import url(http://127.0.0.1/remote.css);@import url(http://assets.127.0.0.1/remote.css);.one{color:red}');
@@ -836,7 +863,10 @@ vows.describe('protocol imports').addBatch({
       assert.isEmpty(minified.errors);
     },
     'should raise a warning': function (error, minified) {
-      assert.isEmpty(minified.warnings);
+      assert.lengthOf(minified.warnings, 3);
+      assert.equal(minified.warnings[0], 'Skipping remote @import of "http://127.0.0.1/remote.css" as resource not allowed.');
+      assert.equal(minified.warnings[1], 'Skipping remote @import of "http://assets.127.0.0.1/remote.css" as resource not allowed.');
+      assert.equal(minified.warnings[2], 'Skipping local @import of "test/fixtures/partials/one.css" as resource not allowed.');
     },
     'should process first imports': function (error, minified) {
       assert.equal(minified.styles, '@import url(http://127.0.0.1/remote.css);@import url(http://assets.127.0.0.1/remote.css);@import url(test/fixtures/partials/one.css);');
@@ -851,7 +881,8 @@ vows.describe('protocol imports').addBatch({
       assert.isEmpty(minified.errors);
     },
     'should raise a warning': function (error, minified) {
-      assert.isEmpty(minified.warnings);
+      assert.lengthOf(minified.warnings, 1);
+      assert.equal(minified.warnings[0], 'Skipping remote @import of "//127.0.0.1/remote.css" as resource not allowed.');
     },
     'should process first imports': function (error, minified) {
       assert.equal(minified.styles, '@import url(//127.0.0.1/remote.css);.one{color:red}');