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;