From 2e5356526945774f76c6a83c12da74d818f050db Mon Sep 17 00:00:00 2001 From: Jakub Pawlowicz Date: Sat, 13 Dec 2014 20:21:35 +0000 Subject: [PATCH] Rewrites ImportInliner for full OO. * It's also a bit easier to understand now. --- lib/clean.js | 11 +- lib/imports/inliner.js | 536 ++++++++++++++++++++--------------------- lib/utils/object.js | 11 + 3 files changed, 279 insertions(+), 279 deletions(-) create mode 100644 lib/utils/object.js diff --git a/lib/clean.js b/lib/clean.js index 3f129fce..d736e995 100644 --- a/lib/clean.js +++ b/lib/clean.js @@ -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) }); }); diff --git a/lib/imports/inliner.js b/lib/imports/inliner.js index 8c135f60..20b291f7 100644 --- a/lib/imports/inliner.js +++ b/lib/imports/inliner.js @@ -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 index 00000000..8e94886c --- /dev/null +++ b/lib/utils/object.js @@ -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; + } +}; -- 2.34.1