index.js 3.93 KB
'use strict';
const path = require('path');
const valueParser = require('postcss-value-parser');
const normalize = require('normalize-url');

const multiline = /\\[\r\n]/;
// eslint-disable-next-line no-useless-escape
const escapeChars = /([\s\(\)"'])/g;

// Scheme: https://tools.ietf.org/html/rfc3986#section-3.1
// Absolute URL: https://tools.ietf.org/html/rfc3986#section-4.3
const ABSOLUTE_URL_REGEX = /^[a-zA-Z][a-zA-Z\d+\-.]*?:/;
// Windows paths like `c:\`
const WINDOWS_PATH_REGEX = /^[a-zA-Z]:\\/;

/**
 * Originally in sindresorhus/is-absolute-url
 *
 * @param {string} url
 */
function isAbsolute(url) {
  if (WINDOWS_PATH_REGEX.test(url)) {
    return false;
  }
  return ABSOLUTE_URL_REGEX.test(url);
}

/**
 * @param {string} url
 * @param {normalize.Options} options
 * @return {string}
 */
function convert(url, options) {
  if (isAbsolute(url) || url.startsWith('//')) {
    let normalizedURL;

    try {
      normalizedURL = normalize(url, options);
    } catch (e) {
      normalizedURL = url;
    }

    return normalizedURL;
  }

  // `path.normalize` always returns backslashes on Windows, need replace in `/`
  return path.normalize(url).replace(new RegExp('\\' + path.sep, 'g'), '/');
}

/**
 * @param {import('postcss').AtRule} rule
 * @return {void}
 */
function transformNamespace(rule) {
  rule.params = valueParser(rule.params)
    .walk((node) => {
      if (
        node.type === 'function' &&
        node.value.toLowerCase() === 'url' &&
        node.nodes.length
      ) {
        /** @type {valueParser.Node} */ (node).type = 'string';
        /** @type {any} */ (node).quote =
          node.nodes[0].type === 'string' ? node.nodes[0].quote : '"';
        node.value = node.nodes[0].value;
      }
      if (node.type === 'string') {
        node.value = node.value.trim();
      }
      return false;
    })
    .toString();
}

/**
 * @param {import('postcss').Declaration} decl
 * @param {normalize.Options} opts
 * @return {void}
 */
function transformDecl(decl, opts) {
  decl.value = valueParser(decl.value)
    .walk((node) => {
      if (node.type !== 'function' || node.value.toLowerCase() !== 'url') {
        return false;
      }

      node.before = node.after = '';

      if (!node.nodes.length) {
        return false;
      }
      let url = node.nodes[0];
      let escaped;

      url.value = url.value.trim().replace(multiline, '');

      // Skip empty URLs
      // Empty URL function equals request to current stylesheet where it is declared
      if (url.value.length === 0) {
        /** @type {any} */ (url).quote = '';

        return false;
      }

      if (/^data:(.*)?,/i.test(url.value)) {
        return false;
      }

      if (!/^.+-extension:\//i.test(url.value)) {
        url.value = convert(url.value, opts);
      }

      if (escapeChars.test(url.value) && url.type === 'string') {
        escaped = url.value.replace(escapeChars, '\\$1');

        if (escaped.length < url.value.length + 2) {
          url.value = escaped;
          /** @type {valueParser.Node} */ (url).type = 'word';
        }
      } else {
        url.type = 'word';
      }

      return false;
    })
    .toString();
}

/** @typedef {normalize.Options} Options */
/**
 * @type {import('postcss').PluginCreator<Options>}
 * @param {Options} opts
 * @return {import('postcss').Plugin}
 */
function pluginCreator(opts) {
  opts = Object.assign(
    {},
    {
      normalizeProtocol: false,
      sortQueryParameters: false,
      stripHash: false,
      stripWWW: false,
      stripTextFragment: false,
    },
    opts
  );

  return {
    postcssPlugin: 'postcss-normalize-url',

    OnceExit(css) {
      css.walk((node) => {
        if (node.type === 'decl') {
          return transformDecl(node, opts);
        } else if (
          node.type === 'atrule' &&
          node.name.toLowerCase() === 'namespace'
        ) {
          return transformNamespace(node);
        }
      });
    },
  };
}

pluginCreator.postcss = true;
module.exports = pluginCreator;