From: Jakub Pawlowicz Date: Tue, 29 Nov 2016 09:48:00 +0000 (+0100) Subject: Reimplements `@import` inlining on top of the new tokenizer. X-Git-Url: https://git.ndcode.org/public/gitweb.cgi?a=commitdiff_plain;h=cacca0d960b04b78ba349f911cba7b752344c03b;p=clean-css.git Reimplements `@import` inlining on top of the new tokenizer. 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. --- diff --git a/History.md b/History.md index 9338edbf..1adab484 100644 --- a/History.md +++ b/History.md @@ -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 index 041d7335..00000000 --- a/lib/imports/inliner.js +++ /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; diff --git a/lib/optimizer/basic.js b/lib/optimizer/basic.js index ec95bdad..248106a2 100644 --- a/lib/optimizer/basic.js +++ b/lib/optimizer/basic.js @@ -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; } diff --git a/lib/optimizer/tidy-at-rule.js b/lib/optimizer/tidy-at-rule.js index 06f49d9b..a7b149fb 100644 --- a/lib/optimizer/tidy-at-rule.js +++ b/lib/optimizer/tidy-at-rule.js @@ -1,6 +1,8 @@ function tidyAtRule(value) { return value .replace(/\s+/g, ' ') + .replace(/url\(\s+/g, 'url(') + .replace(/\s+\)/g, ')') .trim(); } diff --git a/lib/urls/rewrite-url.js b/lib/urls/rewrite-url.js index e524216b..04e4ae48 100644 --- a/lib/urls/rewrite-url.js +++ b/lib/urls/rewrite-url.js @@ -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; } diff --git a/lib/utils/read-sources.js b/lib/utils/read-sources.js index 03be6230..3de64f98 100644 --- a/lib/utils/read-sources.js +++ b/lib/utils/read-sources.js @@ -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; diff --git a/test/integration-test.js b/test/integration-test.js index a19d5e49..28bfe7d5 100644 --- a/test/integration-test.js +++ b/test/integration-test.js @@ -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}}' ], diff --git a/test/protocol-imports-test.js b/test/protocol-imports-test.js index 45648784..ad28bbf2 100644 --- a/test/protocol-imports-test.js +++ b/test/protocol-imports-test.js @@ -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}');