/* * MIT License http://opensource.org/licenses/MIT * Author: Ben Holloway @bholloway */ 'use strict'; var path = require('path'), fs = require('fs'), loaderUtils = require('loader-utils'), rework = require('rework'), visit = require('rework-visit'), convert = require('convert-source-map'), camelcase = require('camelcase'), defaults = require('lodash.defaults'), SourceMapConsumer = require('source-map').SourceMapConsumer; var findFile = require('./lib/find-file'), absoluteToRelative = require('./lib/sources-absolute-to-relative'), adjustSourceMap = require('adjust-sourcemap-loader/lib/process'); var PACKAGE_NAME = require('./package.json').name; /** * A webpack loader that resolves absolute url() paths relative to their original source file. * Requires source-maps to do any meaningful work. * @param {string} content Css content * @param {object} sourceMap The source-map * @returns {string|String} */ function resolveUrlLoader(content, sourceMap) { /* jshint validthis:true */ // details of the file being processed var loader = this, filePath = path.dirname(loader.resourcePath); // webpack 1: prefer loader query, else options object // webpack 2: prefer loader options // webpack 3: deprecate loader.options object // webpack 4: loader.options no longer defined var options = defaults( loaderUtils.getOptions(loader), loader.options && loader.options[camelcase(PACKAGE_NAME)], { absolute : false, sourceMap : loader.sourceMap, fail : false, silent : false, keepQuery : false, attempts : 0, debug : false, root : null, includeRoot: false } ); // validate root directory var resolvedRoot = (typeof options.root === 'string') && path.resolve(options.root) || undefined, isValidRoot = resolvedRoot && fs.existsSync(resolvedRoot); if (options.root && !isValidRoot) { return handleException('"root" option does not resolve to a valid path'); } // loader result is cacheable loader.cacheable(); // incoming source-map var sourceMapConsumer, contentWithMap, sourceRoot; if (sourceMap) { // support non-standard string encoded source-map (per less-loader) if (typeof sourceMap === 'string') { try { sourceMap = JSON.parse(sourceMap); } catch (exception) { return handleException('source-map error', 'cannot parse source-map string (from less-loader?)'); } } // Note the current sourceRoot before it is removed // later when we go back to relative paths, we need to add it again sourceRoot = sourceMap.sourceRoot; // leverage adjust-sourcemap-loader's codecs to avoid having to make any assumptions about the sourcemap // historically this is a regular source of breakage var absSourceMap; try { absSourceMap = adjustSourceMap(this, {format: 'absolute'}, sourceMap); } catch (exception) { return handleException('source-map error', exception.message); } // prepare the adjusted sass source-map for later look-ups sourceMapConsumer = new SourceMapConsumer(absSourceMap); // embed source-map in css for rework-css to use contentWithMap = content + convert.fromObject(absSourceMap).toComment({multiline: true}); } // absent source map else { contentWithMap = content; } // process // rework-css will throw on css syntax errors var reworked; try { reworked = rework(contentWithMap, {source: loader.resourcePath}) .use(reworkPlugin) .toString({ sourcemap : options.sourceMap, sourcemapAsObject: options.sourceMap }); } // fail gracefully catch (exception) { return handleException('CSS error', exception); } // complete with source-map if (options.sourceMap) { // source-map sources seem to be relative to the file being processed absoluteToRelative(reworked.map.sources, path.resolve(filePath, sourceRoot || '.')); // Set source root again reworked.map.sourceRoot = sourceRoot; // need to use callback when there are multiple arguments loader.callback(null, reworked.code, reworked.map); } // complete without source-map else { return reworked; } /** * Push an error for the given exception and return the original content. * @param {string} label Summary of the error * @param {string|Error} [exception] Optional extended error details * @returns {string} The original CSS content */ function handleException(label, exception) { var rest = (typeof exception === 'string') ? [exception] : (exception instanceof Error) ? [exception.message, exception.stack.split('\n')[1].trim()] : []; var message = ' resolve-url-loader cannot operate: ' + [label].concat(rest).filter(Boolean).join('\n '); if (options.fail) { loader.emitError(message); } else if (!options.silent) { loader.emitWarning(message); } return content; } /** * Plugin for css rework that follows SASS transpilation * @param {object} stylesheet AST for the CSS output from SASS */ function reworkPlugin(stylesheet) { var URL_STATEMENT_REGEX = /(url\s*\()\s*(?:(['"])((?:(?!\2).)*)(\2)|([^'"](?:(?!\)).)*[^'"]))\s*(\))/g; // visit each node (selector) in the stylesheet recursively using the official utility method // each node may have multiple declarations visit(stylesheet, function visitor(declarations) { if (declarations) { declarations .forEach(eachDeclaration); } }); /** * Process a declaration from the syntax tree. * @param declaration */ function eachDeclaration(declaration) { var isValid = declaration.value && (declaration.value.indexOf('url') >= 0), directory; if (isValid) { // reverse the original source-map to find the original sass file var startPosApparent = declaration.position.start, startPosOriginal = sourceMapConsumer && sourceMapConsumer.originalPositionFor(startPosApparent); // we require a valid directory for the specified file directory = startPosOriginal && startPosOriginal.source && path.dirname(startPosOriginal.source); if (directory) { // allow multiple url() values in the declaration // split by url statements and process the content // additional capture groups are needed to match quotations correctly // escaped quotations are not considered declaration.value = declaration.value .split(URL_STATEMENT_REGEX) .map(eachSplitOrGroup) .join(''); } // source-map present but invalid entry else if (sourceMapConsumer) { throw new Error('source-map information is not available at url() declaration'); } } /** * Encode the content portion of url() statements. * There are 4 capture groups in the split making every 5th unmatched. * @param {string} token A single split item * @param i The index of the item in the split * @returns {string} Every 3 or 5 items is an encoded url everything else is as is */ function eachSplitOrGroup(token, i) { var BACKSLASH_REGEX = /\\/g; // we can get groups as undefined under certain match circumstances var initialised = token || ''; // the content of the url() statement is either in group 3 or group 5 var mod = i % 7; if ((mod === 3) || (mod === 5)) { // split into uri and query/hash and then find the absolute path to the uri var split = initialised.split(/([?#])/g), uri = split[0], absolute = uri && findFile(options).absolute(directory, uri, resolvedRoot), query = options.keepQuery ? split.slice(1).join('') : ''; // use the absolute path (or default to initialised) if (options.absolute) { return absolute && absolute.replace(BACKSLASH_REGEX, '/').concat(query) || initialised; } // module relative path (or default to initialised) else { var relative = absolute && path.relative(filePath, absolute), rootRelative = relative && loaderUtils.urlToRequest(relative, '~'); return (rootRelative) ? rootRelative.replace(BACKSLASH_REGEX, '/').concat(query) : initialised; } } // everything else, including parentheses and quotation (where present) and media statements else { return initialised; } } } } } module.exports = resolveUrlLoader;