Home Reference Source

src/plugins/stylesheetAssets/index.js

const url = require('url');
const path = require('path');
const rollupUtils = require('rollup-pluginutils');
const extend = require('extend');
const fs = require('fs-extra');
const ProjextRollupUtils = require('../utils');
const { stylesheetAssetsHelper } = require('./helper');
/**
 * This is a Rollup plugin that reads stylesheets and CSS blocks on JS files in order to find
 * paths for files relative to the styles original definition file, then it copies them to the
 * a given directory and fixes the URL on the stylesheet/CSS block.
 */
class ProjextRollupStylesheetAssetsPlugin {
  /**
   * Returns the helper plugin, which allows to wrap CSS styles being exported by ES modules on
   * an specific function so this plugin can find them and fix their paths.
   * @type {Function}
   * @static
   */
  static get helper() {
    return stylesheetAssetsHelper;
  }
  /**
   * @param {ProjextRollupStylesheetAssetsPluginOptions} [options={}]
   * The options to customize the plugin behaviour.
   * @param {string} [name='projext-rollup-plugin-stylesheet-assets']
   * The name of the plugin's instance.
   */
  constructor(options, name = 'projext-rollup-plugin-stylesheet-assets') {
    /**
     * The plugin options.
     * @type {ProjextRollupStylesheetAssetsPluginOptions}
     * @access protected
     * @ignore
     */
    this._options = extend(
      true,
      {
        stylesheet: '',
        insertFnNames: options.insertFnNames || [
          '___$insertCSSBlocks',
          '___$insertStyle',
          '___$styleHelper',
        ],
        urls: [],
        stats: () => {},
      },
      options
    );
    /**
     * The name of the plugin's instance.
     * @type {string}
     */
    this.name = name;
    // Validate the received options before doing anything else.
    this._validateOptions();
    /**
     * Loop all `urls` options and create a filter function with their `include` and `exclude`
     * properties.
     */
    this._options.urls = this._options.urls.map((urlSettings) => Object.assign(
      urlSettings,
      {
        filter: rollupUtils.createFilter(
          urlSettings.include,
          urlSettings.exclude
        ),
      }
    ));
    /**
     * A dictionary of common expressions the plugin uses while parsing files.
     * @type {Object}
     * @property {RegExp} url     Find URLs definitions (`url(...)`) on a style block.
     * @property {RegExp} js      Validates if a file path is for a JS file.
     * @property {RegExp} fullMap Validates a source map.
     * @access protected
     * @ignore
     */
    this._expressions = {
      url: /url\s*\(\s*(?:['|"])?(\.\.?\/.*?)(?:['|"])?\)/ig,
      js: /\.[jt]sx?$/i,
      fullMap: /(\/\*# sourceMappingURL=[\w:/]+;base64,((?:[A-Za-z0-9+/]{4})*(?:[A-Za-z0-9+/]{2}==|[A-Za-z0-9+/]{3}=)?) \*\/)/ig,
    };
    /**
     * A dictionary with information of the fragments of a source map. This is used to parse and to
     * re generate source maps.
     * @type {Object}
     * @property {string} prefix How a source map starts.
     * @property {string} header The source map type information.
     * @property {string} sufix  How a source map ends.
     * @access protected
     * @ignore
     */
    this._mapFragments = {
      prefix: '/*# sourceMappingURL=',
      header: 'data:application/json;base64,',
      sufix: '*/',
    };
    /**
     * A _"cache dictionary"_ for files the plugin read while parsing source maps.
     * @type {Object}
     * @access protected
     * @ignore
     */
    this._sourcesCache = {};
    /**
     * A list of the directories the plugin created while copying files. This list exists in order
     * to prevent the plugin from trying to create the same directory more than once.
     * @type {Array}
     * @access protected
     * @ignore
     */
    this._createdDirectoriesCache = [];
    /**
     * @ignore
     */
    this.writeBundle = this.writeBundle.bind(this);
  }
  /**
   * Gets the plugin options
   * @return {ProjextRollupStatsPluginOptions}
   */
  getOptions() {
    return this._options;
  }
  /**
   * This is called after Rollup finishes writing the files on the file system. This is where
   * the plugin opens the file and process the stylesheet/CSS blocks.
   */
  writeBundle() {
    const { stylesheet } = this._options;
    // Validate that the target file exists.
    if (fs.pathExistsSync(stylesheet)) {
      // Reset the _"caches"_.
      this._sourcesCache = {};
      this._createdDirectoriesCache = [];
      // Get the file contents.
      const code = fs.readFileSync(stylesheet, 'utf-8');
      // Based on the file type, process it with the right method.
      const processed = stylesheet.match(this._expressions.js) ?
        this._processJS(code) :
        this._processCSS(code);
      // Write the processed result back on the file.
      fs.writeFileSync(stylesheet, processed);
    }
  }
  /**
   * Valiates the plugin options.
   * @throws {Error} If no `stylesheet` was defined.
   * @throws {Error} If no `url`s were defined.
   * @access protected
   * @ignore
   */
  _validateOptions() {
    if (!this._options.stylesheet) {
      throw new Error(`${this.name}: You need to define the stylesheet path`);
    } else if (!this._options.urls.length) {
      throw new Error(`${this.name}: You need to define the URLs`);
    }
  }
  /**
   * Parses a file as a JS file with CSS blocks.
   * @param {string} code The contents of the file.
   * @return {string} The processed code.
   * @access protected
   * @ignore
   */
  _processJS(code) {
    // Define a new reference for the code.
    let newCode = code;
    // Extract all the blocks with CSS on the file.
    const blocks = this._extractJSBlocks(code);
    // Loop all the blocks.
    blocks.forEach((block) => {
      /**
       * Update all the CSS defintions on the block. For JS blocks there's only one, but this
       * plugin handles CSS defintions as Array on all the methods.
       */
      const css = block.css
      .map((cssBlock) => this._updateCSSBlock(cssBlock).css)
      .join('\n');
      // Define the new block code for the file.
      const escaped = JSON.stringify(css);
      const newBlock = `${block.fn}(${escaped});`;
      // Replace the old block.
      newCode = newCode.replace(block.match, newBlock);
    });
    // Return the updated code.
    return newCode;
  }
  /**
   * Parses a file as a stylesheet.
   * @param {string} code The contents of the file.
   * @return {string} The processed code.
   * @access protected
   * @ignore
   */
  _processCSS(code) {
    const blocks = this._extractCSSBlocks(code);
    const updated = blocks.map((cssBlock) => this._updateCSSBlock(cssBlock).css);
    return updated.join('\n\n');
  }
  /**
   * Extracts all the blocks that inject CSS from a JS file.
   * @param {string} code The contents of the JS file.
   * @return {Array} The list of extracted blocks.
   * @access protected
   * @ignore
   */
  _extractJSBlocks(code) {
    // Define the list to be returned.
    const result = [];
    /**
     * Generates part of a RegExp that would match the names of an inject function:
     * `fn1|fn2|fn3`.
     */
    const fns = this._options.insertFnNames
    .map((name) => ProjextRollupUtils.escapeRegex(name))
    .join('|');
    // Get the current timestamp to be used on unique strings during the process.
    const time = Date.now();
    // Define strings that will separate the parts of CSS block.
    const separators = {
      block: `___STYLE-BLOCK-SEPARATOR-${time}__`,
      map: `__STYLE-MAP-SEPARATOR-${time}__`,
    };
    // Define the RegExp that will find where a CSS blocks starts in order to insert a separator.
    const fnsRegexStr = `(${fns}\\s*\\(\\s*['|"])`;
    const fnsRegex = new RegExp(fnsRegexStr, 'ig');
    // Define the RegExp that will find an entire CSS block using the separator.
    const blockRegexStr = `^(${fns})\\s*\\(\\s*['|"](.*?)${separators.map}(?:\\\\n)?\\s*['|"]\\s*(?:,\\s*(\\{.*?\\}|['|"].*?['|"]|null))?\\s*\\);`;
    const blockRegex = new RegExp(blockRegexStr, 'ig');

    /**
     * Quick note: Yes, all this _"separators magic"_ could be done with a few more RegExp, the
     * thing is that using expressions on a big file KILLS the memory, no matter how basic the
     * expression is.
     */

    // Let's start the parsing!
    code
    // Add the separators before each CSS block.
    .replace(fnsRegex, `${separators.block}$1`)
    // Split the code using the seprators.
    .split(separators.block)
    // Loop each part...
    .forEach((part) => {
      // ...get the block map and replace it with a separator.
      let map;
      const partCode = part.replace(this._expressions.fullMap, (match) => {
        map = match;
        return separators.map;
      });
      // If a map was found, which means that the block can be parsed...
      if (map) {
        // Extract the block parts.
        let match = blockRegex.exec(partCode);
        while (match) {
          const [fullMatch, fn, css] = match;
          // This removes escaped quotes.
          const parsed = JSON.parse(`{"css": "${css}"}`).css;
          // Push the block to the final list.
          result.push({
            // Return the full match back to how it was so it can later be found and replaced.
            match: fullMatch.replace(separators.map, map),
            // Format the CSS block as an array in order to match all the other methods.
            css: [{
              // Include the CSS code.
              css: parsed.trim(),
              // Include the source map
              map,
            }],
            // The name of the inject function.
            fn,
          });
          // Execute the expression again to keep the loop.
          match = blockRegex.exec(partCode);
        }
      }
    });

    return result;
  }
  /**
   * Extracts the blocks from a CSS stylesheet. A block starts with CSS code and ends with a
   * source map.
   * @param {string} code The contents of the JS file.
   * @return {Array} The list of extracted blocks.
   * @access protected
   * @ignore
   */
  _extractCSSBlocks(code) {
    // Define the list to be returned.
    const result = [];
    // Get the source map fragments information.
    const { prefix, header, sufix } = this._mapFragments;
    code
    // Split the code using the source map prefix.
    .split(prefix)
    // Loop each block.
    .forEach((block, index) => {
      // If the block starts with the map header, it means that a CSS block was previously added.
      if (block.startsWith(header)) {
        // Find where the map ends and get the entire map.
        const mapEnd = block.indexOf(sufix);
        const mapEndLength = mapEnd + sufix.length;
        const map = block.substr(0, mapEndLength);
        const previousIndex = index - 1;
        // Put the map together and assign it to the previous block.
        result[previousIndex].map = `${prefix}${map}`;
        // Assume everything after the map ended is another CSS block.
        const css = block.substr(mapEndLength).trim();
        // If there was a CSS block, push it to the list with an empty map.
        if (css) {
          result.push({
            css,
            map: '',
          });
        }
      } else {
        /**
         * If it doesn't start with map header, it means this is the first CSS block, so push
         * it to the list with an empty map.
         */
        result.push({
          css: block.trim(),
          map: '',
        });
      }
    });
    // Return the list of blocks.
    return result;
  }
  /**
   * Updates a CSS block code. The method will search for files linked inside the block,
   * copy them to a designated location and replace it URL.
   * @param {Object} block     The CSS block information.
   * @param {string} block.css The block CSS code.
   * @return {Object} The updated block.
   * @access protected
   * @ignore
   */
  _updateCSSBlock(block) {
    let result;
    /**
     * If there's a map on the block (because on watch mode Rollup caches the files'
     * transformations), then do the processing, otherwise, just set to return the same block.
     */
    if (block.map) {
      // Get all the linked files on the block.
      const paths = this._getPathsForCSSBlock(block);
      let { css } = block;
      // Loop all the files.
      paths
      // Filter those which absolute path couldn't be found.
      .filter((pathChange) => !!pathChange.absPath)
      // Loop the filtered list.
      .forEach((pathChange) => {
        const {
          absPath,
          line,
          query,
          info,
        } = pathChange;
        // Try to find a URL setting which filter matches a file absolute path.
        const settings = this._options.urls.find((setting) => setting.filter(absPath));
        // If a URL setting was found...
        if (settings) {
          // Generate the output path where the file will be copied.
          const output = ProjextRollupUtils.formatPlaceholder(settings.output, info);
          // Get the directory where the file will be copied.
          const outputDir = path.dirname(output);
          // Generate the new URL for the file.
          const urlBase = ProjextRollupUtils.formatPlaceholder(settings.url, info);
          // Append any existing query the file originally had.
          const newURL = `${urlBase}${query}`;
          // Generate the new statement for the CSS.
          const newLine = `url('${newURL}')`;
          // Generate a RegExp that matches the old statement.
          const lineRegex = new RegExp(ProjextRollupUtils.escapeRegex(line.trim()), 'ig');
          // if the directory wasn't already created, create it.
          if (!this._createdDirectoriesCache.includes(outputDir)) {
            fs.ensureDirSync(outputDir);
            this._createdDirectoriesCache.push(outputDir);
          }
          // Copy the file.
          fs.copySync(absPath, output);
          // Add an stats entry that the file was copied.
          this._options.stats(this.name, output);
          // Replace the old statement with the new one.
          css = css.replace(lineRegex, newLine);
        }
      });
      // set to return the updated block with the new CSS code.
      result = Object.assign({}, block, { css });
    } else {
      result = block;
    }

    return result;
  }
  /**
   * Gets a list of dictionaries with the information of all the files linked on a CSS block.
   * @param {Object} block The CSS block information.
   * @param {string} block.map The CSS block source map.
   * @param {string} block.css The actual CSS code.
   * @return {Array}
   * @access protected
   * @ignore
   */
  _getPathsForCSSBlock(block) {
    // Get the list of sources on the block source map.
    const { sources } = this._parseMap(block.map);
    // Load the source contents.
    const files = this._loadSources(sources);
    // Get all the `url(...)` statements on the CSS block.
    return this._extractPaths(block.css)
    // Loop all the statements.
    .map((pathInfo) => {
      let absPath;
      // Loop all the source.
      files.find((file) => {
        /**
         * Validate that the file exists relative to the source and that the statement is also
         * present on the source.
         */
        const pathFromFile = path.join(file.info.dir, pathInfo.file);
        const found = pathInfo.lines.some((line) => file.code.includes(line)) &&
          fs.pathExistsSync(pathFromFile);

        // If the file exists, define its absolute path.
        if (found) {
          absPath = path.resolve(pathFromFile.replace(/\/\.\//ig, '/'));
        }

        return found;
      });
      // Return the statement information plus the absolute path for it.
      return Object.assign({}, pathInfo, { absPath });
    });
  }
  /**
   * Parse a source map.
   * @param {string} map The map comment.
   * @return {Object}
   * @access protected
   * @ignore
   */
  _parseMap(map) {
    const { prefix, header, sufix } = this._mapFragments;
    const fullPrefix = `${prefix}${header}`;
    const codeRange = (map.length - fullPrefix.length - sufix.length);
    const code = map.substr(fullPrefix.length, codeRange).trim();
    const decoded = Buffer.from(code, 'base64').toString('ascii');
    return JSON.parse(decoded.trim());
  }
  /**
   * Loads a list of source files. They are used while parsing blocks in order to find if certain
   * files exists relative to them and if they include the same statements being parsed.
   * @param {Array} sources The list of files.
   * @return {Array} A list of dictionaries with the sources `file`, `code` and `info`rmation about
   *                 their paths.
   * @access protected
   * @ignore
   */
  _loadSources(sources) {
    // Loop all the sources.
    return sources.map((source) => {
      // Make sure the file wasn't already loaded.
      if (!this._sourcesCache[source]) {
        // Get the file contents.
        const code = fs.readFileSync(source, 'utf-8');
        // Add it to the cache.
        this._sourcesCache[source] = {
          file: source,
          code,
          info: path.parse(source),
        };
      }
      // Return the file information from the cache.
      return this._sourcesCache[source];
    });
  }
  /**
   * Extracts all the `url(...)` statements from a CSS block code.
   * @param {string} code The CSS block code.
   * @return {Array}
   * @access protected
   * @ignore
   */
  _extractPaths(code) {
    // Define the list to be removed.
    const result = [];
    // Define a list to prevent the method from parsing the same statement more than once.
    const saved = [];
    // Loop all the statements.
    let match = this._expressions.url.exec(code);
    while (match) {
      // Get the full line and the actual URL.
      const [line, urlPath] = match;
      // Make sure it wasn't already processed.
      if (!saved.includes(line)) {
        // Push the line to the list of processed lines.
        saved.push(line);
        // Get the URL information.
        const urlInfo = this._parseURL(urlPath);
        // Push all the information to the return list.
        result.push(Object.assign(
          // The base information about the URL.
          urlInfo,
          {
            // The line found on the code.
            line,
            // Variations of the same line as the bundle process may have changed quote types.
            lines: [
              line,
              line.replace(/"/g, '\''),
            ],
            // The information of the URL path.
            info: path.parse(urlInfo.file),
          }
        ));
      }
      // Execute the expression again to keep the loop.
      match = this._expressions.url.exec(code);
    }

    return result;
  }
  /**
   * Parse a URL in order to separate a file from a query.
   * @param {string} urlPath The URL to parse.
   * @return {Object} A dictionary with the keys `file` and `query`.
   * @access protected
   * @ignore
   */
  _parseURL(urlPath) {
    // eslint-disable-next-line node/no-deprecated-api
    const parsed = url.parse(urlPath);
    const urlQuery = parsed.search || '';
    const urlHash = parsed.hash || '';
    const query = `${urlQuery}${urlHash}`;

    return {
      file: parsed.pathname,
      query,
    };
  }
}
/**
 * Shorthand method to create an instance of {@link ProjextRollupStylesheetAssetsPlugin}.
 * @param {ProjextRollupStylesheetAssetsPluginOptions} options
 * The options to customize the plugin behaviour.
 * @param {string} name
 * The name of the plugin's instance.
 * @return {ProjextRollupStylesheetAssetsPlugin}
 */
const stylesheetAssets = (
  options,
  name
) => new ProjextRollupStylesheetAssetsPlugin(options, name);
stylesheetAssets.helper = ProjextRollupStylesheetAssetsPlugin.helper;

module.exports = {
  ProjextRollupStylesheetAssetsPlugin,
  stylesheetAssets,
};