Home Reference Source

src/plugins/template/index.js

const path = require('path');
const rollupUtils = require('rollup-pluginutils');
const extend = require('extend');
const fs = require('fs-extra');
const ProjextRollupUtils = require('../utils');
/**
 * This is a Rollup plugin that generates an HTML file and injects a given list of scripts and
 * stylesheets.
 */
class ProjextRollupTemplatePlugin {
  /**
   * @param {ProjextRollupTemplatePluginOptions} [options={}]
   * The options to customize the plugin behaviour.
   * @param {string} [name='projext-rollup-plugin-template']
   * The name of the plugin's instance.
   */
  constructor(options = {}, name = 'projext-rollup-plugin-template') {
    /**
     * The plugin options.
     * @type {ProjextRollupTemplatePluginOptions}
     * @access protected
     * @ignore
     */
    this._options = extend(
      true,
      {
        template: '',
        output: '',
        scripts: [],
        scriptsAsync: true,
        scriptsOnBody: true,
        stylesheets: [],
        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
        ),
      }
    ));
    /**
     * The base directory where the template file is located.
     * @type {string}
     * @access protected
     * @ignore
     */
    this._base = path.dirname(this._options.template);
    /**
     * The base directory where the final file is going to be created.
     * @type {string}
     * @access protected
     * @ignore
     */
    this._path = path.dirname(this._options.output);
    /**
     * A dictionary of common expressions the plugin uses while parsing files.
     * @type {Object}
     * @property {RegExp} url  Matches `require` statements.
     * @property {RexExp} head Matches the end of the template `<head />` tag.
     * @property {RegExp} body Matches the end of the template `<body />` tag.
     * @access protected
     * @ignore
     */
    this._expressions = {
      url: /<%=\s*require\s*\(\s*['|"](.*?)['|"]\s*\).*?%>/ig,
      head: /([\t ]*)(<\/head>)/i,
      body: /([\t ]*)(<\/body>)/i,
    };
    /**
     * 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 {ProjextRollupTemplatePluginOptions}
   */
  getOptions() {
    return this._options;
  }
  /**
   * This is called by Rollup after writing the files on the file system. This is where the plugin
   * parses the template and generates the HTML file.
   */
  writeBundle() {
    // Reset the directories cache.
    this._createdDirectoriesCache = [];
    // Define the async attribute.
    const async = this._options.scriptsAsync ? ' async="async"' : '';
    // Create all the script tags.
    const scripts = this._options.scripts
    .map((url) => {
      let script;
      if (typeof url === 'string') {
        script = `<script type="text/javascript" src="${url}"${async}></script>`;
      } else if (!url.src) {
        throw new Error(`${this.name}: Missing 'src' property on script object`);
      } else {
        const attributes = this._toHTMLAttributes(url);
        script = `<script ${attributes}></script>`;
      }

      return script;
    });
    // Create all the stylesheet links.
    const stylesheets = this._options.stylesheets
    .map((url) => `<link href="${url}" rel="stylesheet" />`);
    // Define the list for tags that are going to go on the `<head />`.
    const head = [];
    // Push the links by default.
    head.push(...stylesheets);
    // Define the list for tags that are going to go on the `<body />`.
    const body = [];
    /**
     * If the scripts should go on the `<body />`, push them on its list, otherwise push them to
     * the list for the `<head />`
     */
    if (this._options.scriptsOnBody) {
      body.push(...scripts);
    } else {
      head.push(...scripts);
    }
    // Read the contents of the template.
    let template = fs.readFileSync(this._options.template, 'utf-8');
    // Parse the template `require` expressions.
    template = this._parseTemplateExpressions(template);
    // If there are scripts for the `<head />`, add them to the template.
    if (head.length) {
      const headStr = head.join('\n');
      template = template.replace(this._expressions.head, `$1${headStr}\n$1$2`);
    }
    // If there are scripts for the `<body />`, add them to the template.
    if (body.length) {
      const bodyStr = body.join('\n');
      template = template.replace(this._expressions.body, `$1${bodyStr}\n$1$2`);
    }
    // Make sure the output directory exists.
    fs.ensureDirSync(this._path);
    // Write the HTML file.
    fs.writeFileSync(this._options.output, template.trim());
    // Send the information to the stats callback.
    this._options.stats(this.name, this._options.output);
  }
  /**
   * Validate the plugin options.
   * @throws {Error} If no template path was defined.
   * @throws {Error} If no output path was defined.
   * @access protected
   * @ignore
   */
  _validateOptions() {
    if (!this._options.template) {
      throw new Error(`${this.name}: You need to define the template file`);
    } else if (!this._options.output) {
      throw new Error(`${this.name}: You need to define an output file`);
    }
  }
  /**
   * Parses `require` statements on the template, copy the files and replaces the URLs.
   * @param {string} template The template code.
   * @return {string} The updated template.
   * @access protected
   * @ignore
   */
  _parseTemplateExpressions(template) {
    // Define the new template.
    let newTemplate = template;
    // Get all the `require` expressions information.
    this._extractPaths(template)
    // Loop them...
    .forEach((pathChange) => {
      const {
        file,
        line,
        info,
      } = pathChange;
      // Try to find a URL setting which filter matches a file absolute path.
      const settings = this._options.urls.find((setting) => setting.filter(file));
      // 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 url = ProjextRollupUtils.formatPlaceholder(settings.url, info);
        // Generate a RegExp that matches the old statement.
        const lineRegex = new RegExp(ProjextRollupUtils.escapeRegex(line), '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(file, output);
        // Add an stats entry that the file was copied.
        this._options.stats(this.name, output);
        // Replace the old statement with the new URL.
        newTemplate = newTemplate.replace(lineRegex, url);
      }
    });
    // Return the new template
    return newTemplate;
  }
  /**
   * Extracts `require` statements from a given code.
   * @param {string} code The code to parse.
   * @return {Array} A list of dictionaries with information about the `require` statements.
   * @access protected
   * @ignore
   */
  _extractPaths(code) {
    // Define the list to return.
    const result = [];
    /**
     * Define a list to save already processed lines, to avoid parsing the same line more than
     * once.
     */
    const saved = [];
    // Loop all the statements.
    let match = this._expressions.url.exec(code);
    while (match) {
      // Get the line and the URL of the `require`.
      const [line, url] = match;
      // If the line wasn't parsed already.
      if (!saved.includes(line)) {
        // Flag the line.
        saved.push(line);
        // Build the full path for the file.
        const file = path.join(this._base, url);
        // If the file exists, push it to the return list.
        if (fs.pathExistsSync(file)) {
          result.push({
            line,
            file,
            info: path.parse(file),
          });
        }
      }
      // Execute the expression again to keep the loop.
      match = this._expressions.url.exec(code);
    }
    // Return the final list.
    return result;
  }
  /**
   * Transform a dictionary into a string of HTML attributes.
   * @example
   * _toHTMLAttributes({ w: 'x', y: 'z' });
   * // w="x" y="z"
   *
   * @param {Object} obj The dictionary to transform.
   * @return {String}
   * @access protected
   * @ignore
   */
  _toHTMLAttributes(obj) {
    return Object.keys(obj)
    .reduce(
      (acc, name) => {
        const value = `${obj[name]}`.replace(/"/, '\\"');
        return [...acc, `${name}="${value}"`];
      },
      []
    )
    .join(' ');
  }
}
/**
 * Shorthand method to create an instance of {@link ProjextRollupTemplatePlugin}.
 * @param {ProjextRollupTemplatePluginOptions} options
 * The options to customize the plugin behaviour.
 * @param {string} name
 * The name of the plugin's instance.
 * @return {ProjextRollupTemplatePlugin}
 */
const template = (options, name) => new ProjextRollupTemplatePlugin(options, name);

module.exports = {
  ProjextRollupTemplatePlugin,
  template,
};