index.js 8.37 KB
'use strict';

const postcss = require('postcss');
const selectorParser = require('postcss-selector-parser');

const hasOwnProperty = Object.prototype.hasOwnProperty;

function getSingleLocalNamesForComposes(root) {
  return root.nodes.map(node => {
    if (node.type !== 'selector' || node.nodes.length !== 1) {
      throw new Error(
        `composition is only allowed when selector is single :local class name not in "${root}"`
      );
    }

    node = node.nodes[0];

    if (
      node.type !== 'pseudo' ||
      node.value !== ':local' ||
      node.nodes.length !== 1
    ) {
      throw new Error(
        'composition is only allowed when selector is single :local class name not in "' +
          root +
          '", "' +
          node +
          '" is weird'
      );
    }

    node = node.first;

    if (node.type !== 'selector' || node.length !== 1) {
      throw new Error(
        'composition is only allowed when selector is single :local class name not in "' +
          root +
          '", "' +
          node +
          '" is weird'
      );
    }

    node = node.first;

    if (node.type !== 'class') {
      // 'id' is not possible, because you can't compose ids
      throw new Error(
        'composition is only allowed when selector is single :local class name not in "' +
          root +
          '", "' +
          node +
          '" is weird'
      );
    }

    return node.value;
  });
}

const whitespace = '[\\x20\\t\\r\\n\\f]';
const unescapeRegExp = new RegExp(
  '\\\\([\\da-f]{1,6}' + whitespace + '?|(' + whitespace + ')|.)',
  'ig'
);

function unescape(str) {
  return str.replace(unescapeRegExp, (_, escaped, escapedWhitespace) => {
    const high = '0x' + escaped - 0x10000;

    // NaN means non-codepoint
    // Workaround erroneous numeric interpretation of +"0x"
    return high !== high || escapedWhitespace
      ? escaped
      : high < 0
        ? // BMP codepoint
          String.fromCharCode(high + 0x10000)
        : // Supplemental Plane codepoint (surrogate pair)
          String.fromCharCode((high >> 10) | 0xd800, (high & 0x3ff) | 0xdc00);
  });
}

const processor = postcss.plugin('postcss-modules-scope', function(options) {
  return css => {
    const generateScopedName =
      (options && options.generateScopedName) || processor.generateScopedName;
    const generateExportEntry =
      (options && options.generateExportEntry) || processor.generateExportEntry;
    const exportGlobals = options && options.exportGlobals;

    const exports = Object.create(null);

    function exportScopedName(name, rawName) {
      const scopedName = generateScopedName(
        rawName ? rawName : name,
        css.source.input.from,
        css.source.input.css
      );
      const exportEntry = generateExportEntry(
        rawName ? rawName : name,
        scopedName,
        css.source.input.from,
        css.source.input.css
      );
      const { key, value } = exportEntry;

      exports[key] = exports[key] || [];

      if (exports[key].indexOf(value) < 0) {
        exports[key].push(value);
      }

      return scopedName;
    }

    function localizeNode(node) {
      switch (node.type) {
        case 'selector':
          node.nodes = node.map(localizeNode);
          return node;
        case 'class':
          return selectorParser.className({
            value: exportScopedName(
              node.value,
              node.raws && node.raws.value ? node.raws.value : null
            ),
          });
        case 'id': {
          return selectorParser.id({
            value: exportScopedName(
              node.value,
              node.raws && node.raws.value ? node.raws.value : null
            ),
          });
        }
      }

      throw new Error(
        `${node.type} ("${node}") is not allowed in a :local block`
      );
    }

    function traverseNode(node) {
      switch (node.type) {
        case 'pseudo':
          if (node.value === ':local') {
            if (node.nodes.length !== 1) {
              throw new Error('Unexpected comma (",") in :local block');
            }

            const selector = localizeNode(node.first, node.spaces);
            // move the spaces that were around the psuedo selector to the first
            // non-container node
            selector.first.spaces = node.spaces;

            const nextNode = node.next();

            if (
              nextNode &&
              nextNode.type === 'combinator' &&
              nextNode.value === ' ' &&
              /\\[A-F0-9]{1,6}$/.test(selector.last.value)
            ) {
              selector.last.spaces.after = ' ';
            }

            node.replaceWith(selector);

            return;
          }
        /* falls through */
        case 'root':
        case 'selector': {
          node.each(traverseNode);
          break;
        }
        case 'id':
        case 'class': 
          if (exportGlobals) {
            exports[node.value] = [node.value];
          }
          break;
      }
      return node;
    }

    // Find any :import and remember imported names
    const importedNames = {};

    css.walkRules(rule => {
      if (/^:import\(.+\)$/.test(rule.selector)) {
        rule.walkDecls(decl => {
          importedNames[decl.prop] = true;
        });
      }
    });

    // Find any :local classes
    css.walkRules(rule => {
      if (
        rule.nodes &&
        rule.selector.slice(0, 2) === '--' &&
        rule.selector.slice(-1) === ':'
      ) {
        // ignore custom property set
        return;
      }

      let parsedSelector = selectorParser().astSync(rule);

      rule.selector = traverseNode(parsedSelector.clone()).toString();

      rule.walkDecls(/composes|compose-with/, decl => {
        const localNames = getSingleLocalNamesForComposes(parsedSelector);
        const classes = decl.value.split(/\s+/);

        classes.forEach(className => {
          const global = /^global\(([^\)]+)\)$/.exec(className);

          if (global) {
            localNames.forEach(exportedName => {
              exports[exportedName].push(global[1]);
            });
          } else if (hasOwnProperty.call(importedNames, className)) {
            localNames.forEach(exportedName => {
              exports[exportedName].push(className);
            });
          } else if (hasOwnProperty.call(exports, className)) {
            localNames.forEach(exportedName => {
              exports[className].forEach(item => {
                exports[exportedName].push(item);
              });
            });
          } else {
            throw decl.error(
              `referenced class name "${className}" in ${decl.prop} not found`
            );
          }
        });

        decl.remove();
      });

      rule.walkDecls(decl => {
        let tokens = decl.value.split(/(,|'[^']*'|"[^"]*")/);

        tokens = tokens.map((token, idx) => {
          if (idx === 0 || tokens[idx - 1] === ',') {
            const localMatch = /^(\s*):local\s*\((.+?)\)/.exec(token);

            if (localMatch) {
              return (
                localMatch[1] +
                exportScopedName(localMatch[2]) +
                token.substr(localMatch[0].length)
              );
            } else {
              return token;
            }
          } else {
            return token;
          }
        });

        decl.value = tokens.join('');
      });
    });

    // Find any :local keyframes
    css.walkAtRules(atrule => {
      if (/keyframes$/i.test(atrule.name)) {
        const localMatch = /^\s*:local\s*\((.+?)\)\s*$/.exec(atrule.params);

        if (localMatch) {
          atrule.params = exportScopedName(localMatch[1]);
        }
      }
    });

    // If we found any :locals, insert an :export rule
    const exportedNames = Object.keys(exports);

    if (exportedNames.length > 0) {
      const exportRule = postcss.rule({ selector: ':export' });

      exportedNames.forEach(exportedName =>
        exportRule.append({
          prop: exportedName,
          value: exports[exportedName].join(' '),
          raws: { before: '\n  ' },
        })
      );

      css.append(exportRule);
    }
  };
});

processor.generateScopedName = function(name, path) {
  const sanitisedPath = path
    .replace(/\.[^\.\/\\]+$/, '')
    .replace(/[\W_]+/g, '_')
    .replace(/^_|_$/g, '');

  return `_${sanitisedPath}__${name}`.trim();
};

processor.generateExportEntry = function(name, scopedName) {
  return {
    key: unescape(name),
    value: unescape(scopedName),
  };
};

module.exports = processor;