Home Reference Source

src/plugins/stylesheetModulesFixer/index.js

const rollupUtils = require('rollup-pluginutils');
const extend = require('extend');
/**
 * This is a Rollup plugin that replaces the default export statements of stylesheet modules with
 * the CSS modules local names.
 * This is for web apps that either inject CSS using functions or bundle all on separated files,
 * because it searches for functions (inject) or empty strings (bundle) on exports.
 */
class ProjextRollupStylesheetModulesFixerPlugin {
  /**
   * @param {ProjextRollupStylesheetModulesFixerPluginOptions} [options={}]
   * The options to customize the plugin behaviour.
   * @param {string} [name='projext-rollup-plugin-stylesheet-modules-fixer']
   * The name of the plugin's instance.
   */
  constructor(options = {}, name = 'projext-rollup-plugin-stylesheet-modules-fixer') {
    /**
     * The plugin options.
     * @type {ProjextRollupStylesheetModulesFixerPluginOptions}
     * @access protected
     * @ignore
     */
    this._options = extend(
      true,
      {
        include: [],
        exclude: [],
        modulesExportName: 'locals',
      },
      options
    );
    /**
     * The name of the plugin's instance.
     * @type {string}
     */
    this.name = name;
    /**
     * The filter to decide which files will be processed and which won't.
     * @type {RollupFilter}
     */
    this.filter = rollupUtils.createFilter(
      this._options.include,
      this._options.exclude
    );
    /**
     * A dictionary of common expressions the plugin uses while parsing files.
     * @type {Object}
     * @property {RegExp} firstExport Finds the first export statement.
     * @property {RexExp} line        Matches an export statement line.
     * @property {RegExp} definition  Gets the parts of a named export.
     * @property {RegExp} empty       Matches an empty export.
     * @access protected
     * @ignore
     */
    this._expressions = {
      firstExport: /^(export\s*)/mi,
      line: /^((?:\w+(?: \w+)?)\s*=|default)\s*([\s\S]*?);$/ig,
      definition: /^(\w+)\s*([\S\s]*?)\s*=/,
      empty: /['|"]{2}/,
    };
    /**
     * @ignore
     */
    this.transform = this.transform.bind(this);
  }
  /**
   * Gets the plugin options
   * @return {ProjextRollupStylesheetModulesFixerPluginOptions}
   */
  getOptions() {
    return this._options;
  }
  /**
   * This is called by Rollup when is parsing a file.
   * @param {string} code     The file contents.
   * @param {string} filepath The file path.
   * @return {?RollupFileDefinition} If the file matches the plugin filter, it will parse it and
   *                                 return a new definition, otherwise it will just return `null`.
   */
  transform(code, filepath) {
    // Define the variable to return.
    let result = null;
    // Make sure the file matches the filter and that it has an export statement.
    if (this.filter(filepath)) {
      // Separate the file into _"contents and exports"_.
      const parts = this._getFileParts(code);
      // Parse the export statements.
      const fileExports = this._getFileExports(parts.exports);
      // Update the export statements.
      const updatedFileExports = this._updateFileExports(fileExports);
      // Generate the export lines.
      const statements = this._generateFileExports(updatedFileExports);
      // Update the file definition.
      result = {
        code: `${parts.contents}\n${statements}`,
        map: {
          mappings: '',
        },
      };
    }

    return result;
  }
  /**
   * Seprates the file into two parts: `content`, everything before the first export, and export
   * statements.
   * @param {string} code The file code.
   * @return {Object}
   * @access protected
   * @ignore
   */
  _getFileParts(code) {
    // Generate a unique separator.
    const time = Date.now();
    const separator = `__EXPORTS-BLOCK-SEPARATOR-${time}__`;
    const [contents, exports] = code
    // Add the separator before the first export.
    .replace(this._expressions.firstExport, `${separator}$1`)
    // Split the code using the separator.
    .split(separator);
    // Return the file parts.
    return {
      contents,
      exports,
    };
  }
  /**
   * Parses the exports statements of a file.
   * @param {string} code The part of the file contents with the export statements.
   * @return {Object} A dictionary with the keys `default`, for the default export, `modules`, for
   *                  the CSS modules locals export, and `all`, with all the statements found.
   * @access protected
   * @ignore
   */
  _getFileExports(code) {
    // Define the variables that will hold the indexes for the default and locals exports.
    let defaultExportIndex = -1;
    let modulesExportIndex = -1;
    const fileExports = code
    // Split the code using the statements.
    .split('export')
    /**
     * Remove extra spaces from all the statements (because with the `split`, `export` was also
     * removed, leaving a leading space on each line).
     */
    .map((line) => line.trim())
    // Filter empty lines and lines that don't match an export statement.
    .filter((line) => !!line && line.match(this._expressions.line))
    // Loop all the filtered lines.
    .map((line, index) => {
      // Define a dictionary with the export information.
      const info = {
        isDefault: false,
        name: '',
        value: '',
        type: null,
      };
      // Get the name of the export and its value.
      let [, name, value] = this._expressions.line.exec(line);
      this._expressions.line.lastIndex = 0;

      // Remove any trailing semicolon, as the final method will add them.
      let endsWithSemi = value.endsWith(';');
      while (endsWithSemi) {
        value = value.substr(0, value.length - 1);
        endsWithSemi = value.endsWith(';');
      }
      // Define a variable to hold the export type.
      let type;
      // If the statement is the default export.
      if (name === 'default') {
        // Save its index.
        defaultExportIndex = index;
        // Set the flag to true on its properties.
        info.isDefault = true;
      } else {
        // If is not the default export, parse the type and name.
        [, type, name] = this._expressions.definition.exec(line);
        this._expressions.definition.lastIndex = 0;
      }
      // If the name of the export matches the one for CSS modules, save its index.
      if (name === this._options.modulesExportName) {
        modulesExportIndex = index;
      }
      // Assign the found properties.
      info.name = name;
      info.value = value;
      info.type = type;
      // Return the object information.
      return info;
    });
    // Return the dictionary with all the findings.
    return {
      // The default export, or `null`.
      default: defaultExportIndex > -1 ? fileExports[defaultExportIndex] : null,
      // The export for the CSS modules locals, or `null`.
      modules: modulesExportIndex > -1 ? fileExports[modulesExportIndex] : null,
      // All the exports.
      all: fileExports,
    };
  }
  /**
   * Gets the updated export statements for a file.
   * @param {Object} fileExports The result of `_getFileExports`, with all the export statements
   *                             information.
   * @return {Array} A list of the final export statements.
   * @access protected
   * @ignore
   */
  _updateFileExports(fileExports) {
    // Define the variable to return.
    let result;
    // Get the export for CSS modules locals.
    const modulesExport = fileExports.modules;
    // If there's an export for the CSS modules locals...
    if (modulesExport) {
      // ...then get the default export information.
      const defaultExport = fileExports.default;
      /**
       * If the default export matches an empty string, it means that its code was sent to a
       * different bundle, so the method can just switch the default export value with the one from
       * the CSS Modules locals.
       * But if is not an empty string, then there's a function to inject the CSS code, so the
       * method will wrap it with another function that calls it and then returns the CSS
       * Modules locals.
       */
      if (defaultExport.value.match(this._expressions.empty)) {
        defaultExport.value = modulesExport.value;
      } else {
        defaultExport.value = [
          '(() => {',
          `  ${defaultExport.value};`,
          `  return ${modulesExport.value};`,
          '})()',
        ]
        .join('\n');
      }
      /**
       * Finally, as the CSS Modules export is already the new default export, set to return a
       * list of all the export statements, except for that one.
       */
      result = fileExports.all.filter((info) => info.name !== modulesExport.name);
    } else {
      // If no CSS Module export was found, set to return all the exports.
      result = fileExports.all;
    }

    return result;
  }
  /**
   * Generates the code for a list of export statements.
   * @param {Array} fileExports The list of export statements information generated by
   *                            `_updateFileExports`.
   * @return {string}
   * @access protected
   * @ignore
   */
  _generateFileExports(fileExports) {
    return fileExports
    .map((info) => {
      const name = info.isDefault ? 'default' : `${info.type} ${info.name} =`;
      return `export ${name} ${info.value};`;
    })
    .join('\n');
  }
}
/**
 * Shorthand method to create an instance of {@link ProjextRollupStylesheetModulesFixerPlugin}.
 * @param {ProjextRollupStylesheetModulesFixerPluginOptions} options
 * The options to customize the plugin behaviour.
 * @param {string} name
 * The name of the plugin's instance.
 * @return {ProjextRollupStylesheetModulesFixerPlugin}
 */
const stylesheetModulesFixer = (
  options,
  name
) => new ProjextRollupStylesheetModulesFixerPlugin(options, name);

module.exports = {
  ProjextRollupStylesheetModulesFixerPlugin,
  stylesheetModulesFixer,
};