index.js 9.18 KB
/*
 * MIT License http://opensource.org/licenses/MIT
 * Author: Ben Holloway @bholloway
 */
'use strict';

var os                = require('os'),
    path              = require('path'),
    fs                = require('fs'),
    util              = require('util'),
    loaderUtils       = require('loader-utils'),
    SourceMapConsumer = require('source-map').SourceMapConsumer;

var adjustSourceMap = require('adjust-sourcemap-loader/lib/process');

var valueProcessor   = require('./lib/value-processor'),
    joinFn           = require('./lib/join-function'),
    logToTestHarness = require('./lib/log-to-test-harness');

const DEPRECATED_OPTIONS = {
  engine: [
    'DEP_RESOLVE_URL_LOADER_OPTION_ENGINE',
    'the "engine" option is deprecated, "postcss" engine is the default, using "rework" engine is not advised'
  ],
  keepQuery: [
    'DEP_RESOLVE_URL_LOADER_OPTION_KEEP_QUERY',
    '"keepQuery" option has been removed, the query and/or hash are now always retained'
  ],
  absolute: [
    'DEP_RESOLVE_URL_LOADER_OPTION_ABSOLUTE',
    '"absolute" option has been removed, consider the "join" option if absolute paths must be processed'
  ],
  attempts: [
    'DEP_RESOLVE_URL_LOADER_OPTION_ATTEMPTS',
    '"attempts" option has been removed, consider the "join" option if search is needed'
  ],
  includeRoot: [
    'DEP_RESOLVE_URL_LOADER_OPTION_INCLUDE_ROOT',
    '"includeRoot" option has been removed, consider the "join" option if search is needed'
  ],
  fail: [
    'DEP_RESOLVE_URL_LOADER_OPTION_FAIL',
    '"fail" option has been removed'
  ]
};

/**
 * 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;

  // a relative loader.context is a problem
  if (/^\./.test(loader.context)) {
    return handleAsError(
      'webpack misconfiguration',
      'loader.context is relative, expected absolute'
    );
  }

  // infer webpack version from new loader features
  var isWebpackGte5 = 'getOptions' in loader && typeof loader.getOptions === 'function';

  // 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 rawOptions = loaderUtils.getOptions(loader),
      options    = Object.assign(
        {
          sourceMap: loader.sourceMap,
          engine   : 'postcss',
          silent   : false,
          removeCR : os.EOL.includes('\r'),
          root     : false,
          debug    : false,
          join     : joinFn.defaultJoin
        },
        rawOptions
      );

  // maybe log options for the test harness
  if (process.env.RESOLVE_URL_LOADER_TEST_HARNESS) {
    logToTestHarness(
      process[process.env.RESOLVE_URL_LOADER_TEST_HARNESS],
      options
    );
  }

  // deprecated options
  var deprecatedItems = Object.entries(DEPRECATED_OPTIONS).filter(([key]) => key in rawOptions);
  if (deprecatedItems.length) {
    deprecatedItems.forEach(([, value]) => handleAsDeprecated(...value));
  }

  // validate join option
  if (typeof options.join !== 'function') {
    return handleAsError(
      'loader misconfiguration',
      '"join" option must be a Function'
    );
  } else if (options.join.length !== 2) {
    return handleAsError(
      'loader misconfiguration',
      '"join" Function must take exactly 2 arguments (options, loader)'
    );
  }

  // validate the result of calling the join option
  var joinProper = options.join(options, loader);
  if (typeof joinProper !== 'function') {
    return handleAsError(
      'loader misconfiguration',
      '"join" option must itself return a Function when it is called'
    );
  } else if (joinProper.length !== 1) {
    return handleAsError(
      'loader misconfiguration',
      '"join" Function must create a function that takes exactly 1 arguments (item)'
    );
  }

  // validate root option
  if (typeof options.root === 'string') {
    var isValid = (options.root === '') ||
      (path.isAbsolute(options.root) && fs.existsSync(options.root) && fs.statSync(options.root).isDirectory());

    if (!isValid) {
      return handleAsError(
        'loader misconfiguration',
        '"root" option must be an empty string or an absolute path to an existing directory'
      );
    }
  } else if (options.root !== false) {
    handleAsWarning(
      'loader misconfiguration',
      '"root" option must be string where used or false where unused'
    );
  }

  // loader result is cacheable
  loader.cacheable();

  // incoming source-map
  var sourceMapConsumer, absSourceMap;
  if (sourceMap) {

    // support non-standard string encoded source-map (per less-loader)
    if (typeof sourceMap === 'string') {
      try {
        sourceMap = JSON.parse(sourceMap);
      }
      catch (exception) {
        return handleAsError(
          'source-map error',
          'cannot parse source-map string (from less-loader?)'
        );
      }
    }

    // leverage adjust-sourcemap-loader's codecs to avoid having to make any assumptions about the sourcemap
    //  historically this is a regular source of breakage
    try {
      absSourceMap = adjustSourceMap(loader, {format: 'absolute'}, sourceMap);
    }
    catch (exception) {
      return handleAsError(
        'source-map error',
        exception.message
      );
    }

    // prepare the adjusted sass source-map for later look-ups
    sourceMapConsumer = new SourceMapConsumer(absSourceMap);
  } else {
    handleAsWarning(
      'webpack misconfiguration',
      'webpack or the upstream loader did not supply a source-map'
    );
  }

  // choose a CSS engine
  var enginePath    = /^[\w-]+$/.test(options.engine) && path.join(__dirname, 'lib', 'engine', options.engine + '.js');
  var isValidEngine = fs.existsSync(enginePath);
  if (!isValidEngine) {
    return handleAsError(
      'loader misconfiguration',
      '"engine" option is not valid'
    );
  }

  // allow engine to throw at initialisation
  var engine;
  try {
    engine = require(enginePath);
  } catch (error) {
    return handleAsError(
      'error initialising',
      error
    );
  }

  // process async
  var callback = loader.async();
  Promise
    .resolve(engine(loader.resourcePath, content, {
      outputSourceMap     : !!options.sourceMap,
      absSourceMap        : absSourceMap,
      sourceMapConsumer   : sourceMapConsumer,
      removeCR            : options.removeCR,
      transformDeclaration: valueProcessor({
        join     : joinProper,
        root     : options.root,
        directory: path.dirname(loader.resourcePath)
      })
    }))
    .catch(onFailure)
    .then(onSuccess);

  function onFailure(error) {
    callback(encodeError('error processing CSS', error));
  }

  function onSuccess(result) {
    if (result) {
      // complete with source-map
      //  webpack4 and earlier: source-map sources are relative to the file being processed
      //  webpack5: source-map sources are relative to the project root but without a leading slash
      if (options.sourceMap) {
        var finalMap = adjustSourceMap(loader, {
          format: isWebpackGte5 ? 'projectRelative' : 'sourceRelative'
        }, result.map);
        callback(null, result.content, finalMap);
      }
      // complete without source-map
      else {
        callback(null, result.content);
      }
    }
  }

  /**
   * Trigger a node deprecation message for the given exception and return the original content.
   * @param {string} code Deprecation code
   * @param {string} message Deprecation message
   * @returns {string} The original CSS content
   */
  function handleAsDeprecated(code, message) {
    if (!options.silent) {
      util.deprecate(() => undefined, message, code)();
    }
    return content;
  }

  /**
   * Push a warning 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 handleAsWarning(label, exception) {
    if (!options.silent) {
      loader.emitWarning(encodeError(label, exception));
    }
    return content;
  }

  /**
   * Push a warning 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 handleAsError(label, exception) {
    loader.emitError(encodeError(label, exception));
    return content;
  }

  function encodeError(label, exception) {
    return new Error(
      [
        'resolve-url-loader',
        ': ',
        [label]
          .concat(
            (typeof exception === 'string') && exception ||
            Array.isArray(exception) && exception ||
            (exception instanceof Error) && [exception.message, exception.stack.split('\n')[1].trim()] ||
            []
          )
          .filter(Boolean)
          .join('\n  ')
      ].join('')
    );
  }
}

module.exports = Object.assign(resolveUrlLoader, joinFn);