lib/Converter.js

const fs = require('fs');
const path = require('path');
const sass = require('node-sass');
const utils = require('../utils');
const bd = require('./BetterDiscord');
const bdv2 = require('./BDv2');
const stylus = require('./Stylus');

/**
 * @const
 * @name Converter~_EOL
 * @description The operating system's default end of line string.
 * @private
 * @type {string}
 */
const {EOL: _EOL} = require('os');

/**
 * @function
 * @name Converter~_fullPath
 * @description Returns the absolute path of a given key.
 * @param {Converter~Paths} paths - A Paths object.
 * @param {string} key - The path key.
 * @private
 */
const _fullPath = (paths, key) => {
  const val = (key === 'src') ?
    paths.src : paths.dest[key];
  if(path.isAbsolute(val)) return val;
  return path.resolve(paths.root, val);
};

/** @classdesc Class representing a Converter. */
class Converter {

  /**
   * @param {BDv2~Config} config - The BDv2 configuration file.
   * @param {Converter~Paths} [paths={}] - The file paths.
   * @param {string} [eol={@link https://nodejs.org/api/os.html#os_os_eol|os.EOL}]
   * An end of line string.
   */
  constructor(config, paths={}, eol=_EOL) {
    bdv2.validateConfig(config);

    /**
     * @name Converter#info
     * @type {BDv2~Info}
     * @description The BDv2 config's info object.
     */
    this.info = config.info;

    /**
     * @name Converter#file
     * @type {string}
     * @description The BDv2 theme's main file.
     */
    this.file = config.main;

    /**
     * @name Converter#paths
     * @type {Converter~Paths}
     * @description The file paths.
     * @default {}
     * @see {@link Converter#_defaultPath}
     */
    this.paths = paths;

    /**
     * @name Converter#EOL
     * @type {string}
     * @description An end of line string.
     * @default {@link https://nodejs.org/api/os.html#os_os_eol|os.EOL}
     * @readonly
     */
    this.EOL = eol;
  }

  /**
   * @name Converter#name
   * @type {string}
   * @description The theme's name.
   */
  get name() { return this.info.name }

  /**
   * @name Converter#meta
   * @type {BetterDiscord~Meta}
   * @description The theme's legacy metadata.
   */
  get meta() { return bd.meta(this.info) }

  set paths(paths) {
    const defaults = {root: './', src: this.file};
    defaults.dest = {
      legacyTheme: this._defaultPath(defaults.root, 'theme'),
      userStyle: this._defaultPath(defaults.root, 'user'),
      minified: this._defaultPath(defaults.root, 'min')
    };
    Object.defineProperty(this, 'paths', {
      enumerable: true,
      configurable: true,
      writable: true,
      value: utils.merge(defaults, paths)
    });
  }

  set EOL(eol) {
    Object.defineProperty(this, 'EOL', {
      enumerable: true,
      configurable: true,
      writable: false,
      value: (/\n(\r)?|\r(\n)?/.test(eol)) ? eol : _EOL
    });
  }

  /**
   * Compiles the source file.
   *
   * @param {Converter~CompileCallback} callback
   * Callback function to execute after compilation.
   * @param {Object} [options={}]
   * Any of node-sass's <code>{@link https://github.com/sass/node-sass#options|options}</code>.
   * @throws Will throw an error when the callback is missing or isn't a function.
   * @see {@link Converter~Paths}, {@link Converter#file}
   */
  compile(callback, options={}) {
    if(typeof callback !== 'function')
      throw new TypeError('Missing callback function');
    const defaults = {
      linefeed: this.EOL
        .replace(/\n/g, 'lf')
        .replace(/\r/g, 'cr'),
      indentType: 'space',
      indentWidth: 2,
      file: _fullPath(this.paths, 'src')
    };
    const opts = utils.merge(defaults, options);
    sass.render(opts, (err, res) => {
      const ind = utils.indent(opts.indentType, opts.indentWidth);
      callback(err, (res ? res.css : null), ind);
    });
  }

  /**
   * Converts the BDv2 theme to a legacy BD theme and
   * writes it to the <code>legacyTheme</code> destination file.
   *
   * @param {Object} [options={}]
   * Any of node-sass's <code>{@link https://github.com/sass/node-sass#options|options}</code>.
   * @see {@link Converter~Paths}
   */
  toLegacyTheme(options={}) {
    this.compile((err, css) => {
      if(err) return console.error(err.message);
      const dest = _fullPath(this.paths, 'legacyTheme');
      const meta = bd.format(this.meta) + this.EOL;
      utils.mkdirRecursive(path.dirname(dest));
      fs.writeFile(dest, meta + this.EOL + css, (error) => {
        if(error) throw error;
        const msg = 'Wrote to ' + dest;
        console.log(utils.checkMark(msg));
      });
    }, options);
  }

  /**
   * Converts the BDv2 theme to a UserStyle and
   * writes it to the <code>userStyle</code> destination file.
   *
   * @param {Object} [options={}]
   * Any of node-sass's <code>{@link https://github.com/sass/node-sass#options|options}</code>.
   * @param {Object} [extras={}] - Extra configuration for the UserStyle.
   * Will override the BDv2 config.
   * @see {@link Converter~Paths}
   */
  toUserStyle(options={}, extras={}) {
    this.compile((err, css, indent) => {
      if(err) return console.error(err.message);
      css = css.toString()
        .replace(/^/gm, indent)
        .replace(/^[\s\t]+$/gm, '');
      const dest = _fullPath(this.paths, 'userStyle');
      const info = utils.renameKeys(this.meta, {
        source: 'namespace', website: 'homepageURL'});
      if(typeof info.author === 'string')
        info.author = info.author.split(', ');
      const meta = stylus.format(utils.merge(info, extras));
      const doc = stylus.mozDocument('domain', 'discordapp.com');
      const full = meta + this.EOL + this.EOL + doc +
        ` {${this.EOL}${css}${this.EOL}}${this.EOL}`;
      utils.mkdirRecursive(path.dirname(dest));
      fs.writeFile(dest, full, (error) => {
        if(error) throw error;
        const msg = 'Wrote to ' + dest;
        console.log(utils.checkMark(msg));
      });
    }, options);
  }

  /**
   * Minifies the BDv2 theme and writes it to the <code>minified</code> destination file.
   *
   * @param {Object} [options={}]
   * Any of node-sass's <code>{@link https://github.com/sass/node-sass#options|options}</code>
   * except for <code>outputStyle</code> which is set to <code>compressed</code>.
   * @param {Boolean} [meta=false] - Whether to add a theme META line to the file.
   * @see {@link Converter~Paths}
   */
  toMinified(options={}, meta=false) {
    options.outputStyle = 'compressed';
    this.compile((err, css) => {
      if(err) return console.error(err.message);
      let dest = _fullPath(this.paths, 'minified');
      if(meta) {
        css = bd.format(this.meta) + this.EOL + css;
        if(dest.indexOf('theme.css') < 0)
          dest = dest.replace(/\.css$/, '.theme.css');
      }
      utils.mkdirRecursive(path.dirname(dest));
      fs.writeFile(dest, css, (error) => {
        if(error) throw error;
        const msg = 'Wrote to ' + dest;
        console.log(utils.checkMark(msg));
      });
    }, options);
  }

  /**
   * Creates the paths of the destination files when not provided.
   * Format: <code>dir/ThemeName.suffix.css</code>.
   *
   * @protected
   * @param {string} dir - The root directory of the files.
   * @param {string} suffix - The suffix of the destination file.
   * @return {string} The default path of the destination file.
   * @see {@link Converter~Paths}
   */
  _defaultPath(dir, suffix) {
    return `${path.resolve(dir)}/${this.name}.${suffix}.\css`;
  }

  toString() { return '[object Converter]' }
}

module.exports = Converter;

/**
 * Object representing the files' paths.
 *
 * @typedef {Object} Converter~Paths
 * @property {string} root - The root directory.
 * @property {string} src - The source file relative to the root.
 * @property {string} dest - The destination files relative to the root.
 * @property {string} dest.legacyTheme - The legacy BD theme file.
 * @property {string} dest.userStyle - The UserStyle theme file.
 * @property {string} dest.minified - The minified theme file.
 */

/**
 * Function that's called after compilation.
 *
 * @callback Converter~CompileCallback
 * @param {?Error} err - An error that may be thrown by sass.
 * @param {?string} css - The compiled css.
 * @param {string} [indent] - The indent string used.
 */