services/extender.js

const path = require('path');
const { provider } = require('@homer0/jimple');
/**
 * This is the class that merges single file components (SFCs).
 */
class Extender {
  /**
   * @param {JSMerger}       jsMerger  To merge the JS scripts and remove duplicated
   *                                   declarations.
   * @param {Class<SFCData>} sfcData   To create a "final" SFC with the merged
   *                                   information.
   */
  constructor(jsMerger, sfcData) {
    /**
     * A local reference for the `jsMerger` service.
     *
     * @type {JSMerger}
     * @access protected
     * @ignore
     */
    this._jsMerger = jsMerger;
    /**
     * The class used to create the objects with the SFC merged information.
     *
     * @type {Class<SFCData>}
     * @access protected
     * @ignore
     */
    this._sfcData = sfcData;
    /**
     * A dictionary of regular expression the class uses.
     *
     * @type {Object}
     * @property {RegExp} htmlSrc  A expression the class will use to find `src`
     *                             attributes on HTML code in order to update relative
     *                             paths when merging two SFCs.
     * @property {RegExp} cssUrl   A expression the class will use to find `url()`
     *                             properties on CSS code in order to update relative
     *                             paths when merging two SFCs.
     * @property {RegExp} jsPaths  A expression the class will use to find `import`
     *                             statements on JS code in order to update relative paths
     *                             when merging two SFCs.
     * @access protected
     * @ignore
     */
    this._expressions = {
      htmlSrc: /\s+(?:src="(\.[^"]+)"|src='(\.[^']+)')/gi,
      cssUrl: /url\s*\(\s*(?:['"])?(\.[^"']+)(?:['"])?\)/gi,
      jsPaths:
        /(?: |^)(?:(?:from|import)\s+(?:["'](\.[^"']+)["'])|require\s*\(\s*["'](\.[^"']+)["']\s*\))/gim,
    };
    /**
     * A list of private attributes used by the application and that should be removed
     * from tags.
     *
     * @type {string[]}
     * @access protected
     * @ignore
     */
    this._privateAttributes = ['extend'];
  }
  /**
   * Takes an SFC data object, check if it extends from another and then does a recursive
   * merge in order to generate a final SFC data object. It's recursive in case an SFC
   * extends from an SFC that then extends from another...
   *
   * @param {SFCData} sfc           The SFC information.
   * @param {number}  [maxDepth=0]  How many components can be extended. For example, if a
   *                                file extends from one that extends from another and
   *                                the parameter is set to `1`, the parsing will fail.
   * @returns {SFCData}
   * @throws {Error} If the "extend chain" goes beyond the `maxDepth` limit.
   */
  generate(sfc, maxDepth = 0) {
    return this._generate(sfc, maxDepth, 1);
  }
  /**
   * Removes the {@link Extender#_privateAttributes} from a dictionary of attributes.
   *
   * @param {Object} attributes  The dictionary of attributes to clean.
   * @returns {Object} A new dictionary without the private attributes.
   * @access protected
   * @ignore
   */
  _cleanAttributes(attributes) {
    const result = { ...attributes };
    this._privateAttributes.forEach((name) => {
      delete result[name];
    });

    return result;
  }
  /**
   * Utility method to remove empty lines from the beginning and end of a block of code.
   * This method exists because is common for a block to end up like this when merging its
   * contents.
   *
   * @param {string} text  The text to clean.
   * @returns {string}
   * @access protected
   * @ignore
   */
  _cleanTextBlock(text) {
    const newText = text.replace(/^\n/, '').replace(/\n$/, '');

    return newText.trim() ? newText : '';
  }
  /**
   * Generates a single SFC data object by merging a base SFC and one that extends it.
   *
   * @param {SFCData} base    The data of the base SFC.
   * @param {SFCData} target  The data of the SFC that extends the base.
   * @returns {SFCData}
   * @access protected
   * @ignore
   */
  _extend(base, target) {
    const relative = path.relative(target.directory, base.directory);
    const absolute = path.join(target.directory, relative);
    const directory = path.relative(target.directory, absolute);

    const sfc = this._sfcData.new(target.filepath);
    sfc.addMarkup(this._extendMarkup(base, target, directory));
    const moduleScript = this._extendModuleScript(base, target, directory);
    if (moduleScript.content) {
      sfc.addScript(moduleScript.content, moduleScript.attributes);
    }
    const script = this._extendScript(base, target, directory);
    if (script.content) {
      sfc.addScript(script.content, script.attributes);
    }
    const style = this._extendStyle(base, target, directory);
    if (style.content) {
      sfc.addStyle(style.content, style.attributes);
    }

    return sfc;
  }
  /**
   * This is a utility method used to merge script {@link SFCTag}s. It's used by both
   * {@link Extender#_extendScript} and {@link Extender#_extendModuleScript}.
   * If the extended SFC doesn't have any scripts, it will use the one from the base; but
   * if there's a script tag, it will use that instead; and if the extended script tag
   * uses the `extend` attribute, then the content of both tags will be merged.
   *
   * @param {SFCTag}  baseJS       The tag that represents all the scripts from the base
   *                               SFC.
   * @param {SFCTag}  targetJS     The tag that represents all the scripts from the
   *                               extended SFC.
   * @param {boolean} targetHasJS  Whether or not the extended SFC has any scripts.
   * @param {string}  directory    The relative directory path between the SFC that
   *                               extends and the base one; this is used to update the
   *                               relative paths on the code.
   * @returns {SFCTag}
   * @access protected
   * @ignore
   */
  _extendJSBlock(baseJS, targetJS, targetHasJS, directory) {
    let attributes;
    let content;
    if (targetHasJS) {
      if (targetJS.attributes.extend) {
        if (baseJS.content) {
          content = this._jsMerger.mergeCode(
            this._updateJSPaths(baseJS.content, directory),
            targetJS.content,
          );
        } else {
          ({ content } = targetJS);
        }

        attributes = { ...baseJS.attributes, ...targetJS.attributes };
      } else {
        ({ attributes, content } = targetJS);
      }
    } else {
      ({ attributes } = baseJS);
      content = this._updateJSPaths(baseJS.content, directory);
    }

    return {
      attributes: this._cleanAttributes(attributes),
      content: this._cleanTextBlock(content),
    };
  }
  /**
   * Generates the markup of the merge of two SFCs. If the extended SFC doesn't have the
   * `html`
   * attribute on its `<extend />` tag, the returned markup won't contain the one from the
   * base SFC.
   *
   * @param {SFCData} base       The data of the base SFC.
   * @param {SFCData} target     The data of the SFC that extends the base.
   * @param {string}  directory  The relative directory path between the SFC that extends
   *                             and the base one; this is used to update the relative
   *                             paths on the code.
   * @returns {string}
   * @access protected
   * @ignore
   */
  _extendMarkup(base, target, directory) {
    let result;
    const htmlPosition = this._getMergePosition(target.extendTagAttributes.html);
    if (htmlPosition === null) {
      result = target.markup;
    } else {
      const baseMarkup = this._updateMarkupPaths(base.markup, directory);
      if (htmlPosition === 'after') {
        result = `${baseMarkup}\n${target.markup}`;
      } else {
        result = `${target.markup}\n${baseMarkup}`;
      }
    }

    return this._cleanTextBlock(result);
  }
  /**
   * Generates a module script {@link SFCTag} (the ones with the `context="module"`
   * attribute) of the merge of two SFCs. If the extended SFC doesn't have any scripts, it
   * will use the one from the base; but if there's a script tag, it will use that
   * instead; and if the extended script tag uses the `extend` attribute, then the content
   * of both tags will be merged.
   *
   * @param {SFCData} base       The data of the base SFC.
   * @param {SFCData} target     The data of the SFC that extends the base.
   * @param {string}  directory  The relative directory path between the SFC that extends
   *                             and the base one; this is used to update the relative
   *                             paths on the code.
   * @returns {SFCTag}
   * @access protected
   * @ignore
   */
  _extendModuleScript(base, target, directory) {
    const mScript = this._extendJSBlock(
      base.moduleScript,
      target.moduleScript,
      target.hasModuleScripts,
      directory,
    );
    mScript.attributes.context = 'module';
    return mScript;
  }
  /**
   * Generates an script {@link SFCTag} of the merge of two SFCs. If the extended SFC
   * doesn't have any scripts, it will use the one from the base; but if there's a script
   * tag, it will use that instead; and if the extended script tag uses the `extend`
   * attribute, then the content of both tags will be merged.
   *
   * @param {SFCData} base       The data of the base SFC.
   * @param {SFCData} target     The data of the SFC that extends the base.
   * @param {string}  directory  The relative directory path between the SFC that extends
   *                             and the base one; this is used to update the relative
   *                             paths on the code.
   * @returns {SFCTag}
   * @access protected
   * @ignore
   */
  _extendScript(base, target, directory) {
    return this._extendJSBlock(base.script, target.script, target.hasScripts, directory);
  }
  /**
   * Generates an style {@link SFCTag} of the merge of two SFCs. If the extended SFC
   * doesn't have any styling, it will use the one from the base; but if there's a style
   * tag, it will use that instead; and if the extended style tag uses the `extend`
   * attribute, then the content of both tags will be merged.
   *
   * @param {SFCData} base       The data of the base SFC.
   * @param {SFCData} target     The data of the SFC that extends the base.
   * @param {string}  directory  The relative directory path between the SFC that extends
   *                             and the base one; this is used to update the relative
   *                             paths on the code.
   * @returns {SFCTag}
   * @access protected
   * @ignore
   */
  _extendStyle(base, target, directory) {
    const baseStyle = base.style;
    const targetStyle = target.style;
    let attributes;
    let content;
    if (target.hasStyles) {
      const stylePosition = this._getMergePosition(targetStyle.attributes.extend);
      if (stylePosition === null) {
        ({ attributes, content } = targetStyle);
      } else {
        attributes = { ...baseStyle.attributes, ...targetStyle.attributes };
        const newBaseStyle = this._updateCSSPaths(baseStyle.content, directory);
        if (stylePosition === 'after') {
          content = `${newBaseStyle}\n${targetStyle.content}`;
        } else {
          content = `${targetStyle.content}\n${newBaseStyle}`;
        }
      }
    } else {
      ({ attributes } = baseStyle);
      content = this._updateCSSPaths(baseStyle.content, directory);
    }

    return {
      attributes: this._cleanAttributes(attributes),
      content: this._cleanTextBlock(content),
    };
  }
  /**
   * The method that actually generates the "final SFC".
   *
   * @param {SFCData} sfc           The SFC information.
   * @param {number}  maxDepth      How many components can be extended. For example, if a
   *                                file extends from one that extends from another and
   *                                the parameter is set to `1`, the parsing will fail.
   * @param {number}  currentDepth  The level of depth in which a file is currently being
   *                                extended.
   * @returns {SFCData}
   * @throws {Error} If the "extend chain" goes beyond the `maxDepth` limit.
   * @access protected
   * @ignore
   */
  _generate(sfc, maxDepth, currentDepth) {
    let result;
    if (sfc.hasBaseFileData) {
      const newCurrentDepth = currentDepth + 1;
      if (maxDepth && newCurrentDepth > maxDepth) {
        throw new Error(
          `The file '${sfc.filepath}' can't extend from another file, the max depth ` +
            `limit is set to ${maxDepth}`,
        );
      }

      const base = this._generate(sfc.baseFileData, maxDepth, newCurrentDepth);
      result = this._extend(base, sfc);
    } else {
      result = sfc;
    }

    return result;
  }
  /**
   * A utility method that parses the value of an `extend` HTML attribute the class uses
   * to determine the position of the base code in relation with the extended one:
   * - `undefined` or `'false'`: `null` - the code won't be merged.
   * - no value, `'true'` or `'after'`: first the base code and then the extended one.
   * - `'before'`: first the extended code and then the base one.
   *
   * @param {string} [value]  The value of the `extend` HTML attribute.
   * @returns {?string} If the attribute is not defined or if it's value is `'false'`, it
   *                    will return `null`, indicating that the code shouldn't be merged.
   * @access protected
   * @ignore
   */
  _getMergePosition(value) {
    const defaultValue = 'after';
    let result;
    const valueType = typeof value;
    if (valueType === 'undefined' || !value) {
      result = null;
    } else if (valueType === 'string') {
      result = value.match(/(?:before|after)/i) ? value.toLowerCase() : defaultValue;
    } else {
      result = defaultValue;
    }

    return result;
  }
  /**
   * Utility method that updates paths on a given code to make them relative to a new
   * directory.
   * This is used to update the contents of an SFC before they are added to one that
   * extends it.
   *
   * @param {string} code        The code to update.
   * @param {RegExp} expression  The expression to extract the relative paths.
   * @param {string} directory   The relative path to the directory in which the extended
   *                             SFC is located.
   * @returns {string}
   * @access protected
   * @ignore
   */
  _updateCodePaths(code, expression, directory) {
    const items = [];
    let match = expression.exec(code);
    while (match) {
      const [statement, itemPath, alternativeItemPath] = match;
      items.push({
        statement,
        itemPath: itemPath || alternativeItemPath,
      });

      match = expression.exec(code);
    }

    const newCode = items.reduce((currentCode, item) => {
      const newItemPath = path.join(directory, item.itemPath).replace(/^(\w)/, './$1');
      const newStatement = item.statement.replace(item.itemPath, newItemPath);
      return currentCode.replace(item.statement, newStatement);
    }, code);

    return newCode;
  }
  /**
   * Updates relative paths on a block of CSS code to be relative for a give directory.
   * This is used when a block of CSS code is going to be added on a extended SFC.
   *
   * @param {string} css        The code to update.
   * @param {string} directory  The relative path to the directory in which the extended
   *                            SFC is located.
   * @returns {string}
   * @access protected
   * @ignore
   */
  _updateCSSPaths(css, directory) {
    return this._updateCodePaths(css, this._expressions.cssUrl, directory);
  }
  /**
   * Updates relative paths on a block of JS code to be relative for a give directory.
   * This is used when a block of JS code is going to be added on a extended SFC.
   *
   * @param {string} js         The code to update.
   * @param {string} directory  The relative path to the directory in which the extended
   *                            SFC is located.
   * @returns {string}
   * @access protected
   * @ignore
   */
  _updateJSPaths(js, directory) {
    return this._updateCodePaths(js, this._expressions.jsPaths, directory);
  }
  /**
   * Updates relative paths on a block of HTML code to be relative for a give directory.
   * This is used when a block of HTML code is going to be added on a extended SFC.
   *
   * @param {string} markup     The code to update.
   * @param {string} directory  The relative path to the directory in which the extended
   *                            SFC is located.
   * @returns {string}
   * @access protected
   * @ignore
   */
  _updateMarkupPaths(markup, directory) {
    return this._updateCSSPaths(
      this._updateCodePaths(markup, this._expressions.htmlSrc, directory),
      directory,
    );
  }
}
/**
 * The service provider that once registered on {@link SvelteExtend} will save the an
 * instance of {@link JSMerger} as the `jsMerger` service.
 *
 * @type {Provider}
 */
const extender = provider((app) => {
  app.set('extender', () => new Extender(app.get('jsMerger'), app.get('sfcData')));
});

module.exports = {
  Extender,
  extender,
};