You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

251 lines
8.7 KiB

/*
* 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 <code>url()</code> 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;