Source: ClassWithPlugins.js

import events from 'events';

import _ from 'lodash';

import promiseUtil from 'promise-util';
import _plugins from '../singletons/plugins';

const EventEmitter = events.EventEmitter;

/**
 *
 * @classdesc A basic class that has can be extended using plugins
 *
 * @property plugins {Array<String>} Array of plugin names to use in this class
 *
 * @param options {Object} the object the instance will be extended with
 *
 * @class ClassWithPlugins
 * @global
 *
 */
class ClassWithPlugins {

  /**
   * Registers a plugin
   * @method registerPlugin
   * @memberof ClassWithPlugins
   * @static
   * @param type {String} The type this is a plugin for, mapped onto a Class using the static get _type property on the class
   * @param name {String} Name of the plugin, this is how the plugin is identified in the system, the name is local to its type
   * @param plugin {Object} The plugin itself
   * @returns {*}
   */
  static registerPlugin(type, name, plugin) {
    const pluginGroup = _plugins[type] = _plugins[type] || {};

    if (pluginGroup[name]) {
      throw new Error(`Can't register ${name} plugin for ${type}, plugin with that name already exists`);
    }

    return pluginGroup[name] = plugin;
  }

  /**
   * Retrieves a plugin
   * @method retrievePlugin
   * @memberof ClassWithPlugins
   * @static
   * @param type {String} The type this is a plugin for
   * @param name {String} Name of the plugin
   * @returns {Object|undefined}
   */
  static retrievePlugin(type, name) {
    if (_plugins[type]) {
      return _plugins[type][name];
    }
  }

  /**
   * Object containing all plugins for all types, in plugins[type][name] hierarchy
   * @name plugins
   * @type Object
   * @memberof ClassWithPlugins
   * @instance
   */
  get plugins() {
    return _plugins;
  }

  constructor(options = {}) {
    options._plugins = options.plugins || [];
    delete options.plugins;

    _.extend(this, options);
    this._emitter = new EventEmitter();
    this._bindThisContextForAllMethodsInOptions(options);

    if (this.autoInitialize) {
      this._initializePlugins();
    }
  }


  /**
   * Public API
   */

  /**
   * Listens for an event trigger by the {@link ClassWithPlugins#trigger} method
   * @instance
   * @method on
   * @memberof ClassWithPlugins
   * @param event {String} Event to listen to.
   * @param cb {Function} Function that should be called when this event is triggered.
   */
  on(event, cb) {
    return this._emitter.on(event, cb);
  }

  /**
   * Triggers an event with data that can be listed to using the {@link ClassWithPlugins#on} method
   * @instance
   * @method trigger
   * @memberof ClassWithPlugins
   * @param event {String} Event to trigger.
   * @param data {*} Data the event should trigger with.
   */
  trigger(event, data) {
    return this._emitter.emit(event, data);
  }

  /**
   * Checks whether this instance has a certain plugin provided using the plugin parameter as a string.
   *
   * @param plugin {String} Plugin to look for
   * @returns {Boolean}
   * @instance
   * @method hasPlugin
   * @memberof ClassWithPlugins
   */
  hasPlugin(plugin) {
    return this._plugins.indexOf(plugin) !== -1;
  }

  /**
   * This methods registers a hook callback on an instance of a {@link ClassWithPlugins},
   * it is called by the framework. This should never be called manually, if you want to register a hook,
   * create a plugin and add it to the plugins of the class.
   * If you want to fire a hook, use that instance (non static) hook method.
   * @memberof ClassWithPlugins
   * @method hook
   * @static
   * @param event {String} The hook event to hook into
   * @param cb {Function} Callback function, gets ran when this hook executes
   * @param context {Object} Context the callbacks should be called with
   * @param instance {ClassWithPlugins} The instance to hook plugins for
   */
  static hook(event, cb, context, instance) {
    instance.hooks[event] = instance.hooks[event] || [];
    if (typeof cb !== 'function') throw new Error(`Can't hook to ${event}, callback is not a function, this is likely caused by a missing hook handler.`);
    instance.hooks[event].push({cb, context});
  }

  /**
   * Runs all hook listeners from all plugins active on the class,
   * returns a promise so plugins can do async stuff and you can wait for the plugins to finish.
   *
   * @memberof ClassWithPlugins
   * @method hook
   * @instance
   * @param event {String} Hook event to trigger
   * @param data {Object} Data to supply the hook callback with, in addition to the instance it's called from
   * @returns {Promise}
   */
  hook(event, data) {
    const promises = _.map(this.hooks[event], hook => {
      let val = hook.cb.call(hook.context, this, data);

      return promiseUtil.ensurePromise(val);
    });

    return Promise.all(promises);
  }

  /**
   * Private API
   */

  _bindThisContextForAllMethodsInOptions(options) {
    // get all method names from the options object
    const bindAllArguments = _.methods(options);

    if (bindAllArguments.length) {
      // add this as context argument (the first argument)
      bindAllArguments.unshift(this);

      // actually bind the context
      _.bindAll.apply(_, bindAllArguments);
    }
  }

  /**
   *
   * @private
   */
  _initializePlugins() {
    _.bindAll(
      this,
      '_initializePlugin',
      'hook'
    );

    this.hooks = this.hooks || {};
    this._plugins = this._plugins || [];
    this._loadedPlugins = [];

    _.each(this._plugins, (pluginName) => {
      this._initializePlugin(pluginName);
    });
  }


  _initializePlugin(pluginName, isDependency) {
    const plugin = this._getPlugin(pluginName);

    // dependency already loaded, we don't have to initialize it again
    if (isDependency && this._dependencyIsLoaded(plugin)) return;

    if (!plugin) throw new Error(`Plugin '${pluginName}' not found`);

    this._initializePluginDependencies(plugin);

    if (this._loadedPlugins.indexOf(plugin) === -1) {
      this._loadPlugin(plugin);
    }
  }

  _dependencyIsLoaded(plugin) {
    return ClassWithPlugins._allLoadedPlugins.indexOf(plugin) !== -1;
  }

  _loadPlugin(plugin) {
    const namespace = this._addPluginNamespace(plugin);
    this._initializeHooks(plugin, namespace);
    this._exposeProperties(plugin);

    this._loadedPlugins.push(plugin);
    ClassWithPlugins._allLoadedPlugins.push(plugin);
  }

  _getPlugin(pluginName) {
    const [type, plugin] = pluginName.split('.');

    if (!plugin) {
      return this.plugins[this.constructor._type][pluginName];
    } else {
      return this.plugins[type][plugin];
    }
  }

  _initializePluginDependencies(plugin) {
    _.each(plugin.dependencies, (pluginName) => {
      this._initializePlugin(pluginName, true);
    });
  }

  _initializeHooks(plugin, namespace) {
    _.each(plugin.hooks, (methodName, event) => {
      this._initializeHook(event, methodName, plugin, namespace);
    });
  }

  _initializeHook(event, methodName, plugin, namespace) {
    this.constructor.hook(event, plugin[methodName], namespace || plugin, this);
  }

  _exposeProperties(plugin) {
    _.extend(this, _.pick(plugin, plugin.expose));
  }

  _addPluginNamespace(plugin) {
    if (plugin.namespace) {
      return this[plugin.namespace] = _.clone(plugin);
    }
  }

}

ClassWithPlugins.prototype.autoInitialize = true;
ClassWithPlugins._allLoadedPlugins = [];

export default ClassWithPlugins;