Source

features.js

/**
 * Features module.
 * @module base/features
 */

import invariant from 'tiny-invariant'
import observable from 'riot-observable'
import { isElement } from './utils/check'
import eventHub from './eventHub'
import { supportsPassiveEvents } from './utils/device'

import {
  ATTR_FEATURES_SEPARATOR,
  ATTR_FEATURES,
  ATTR_FEATURES_IGNORE,
  FEATURES_MAIN_BUNDLE,
  ATTR_EXTERNAL_SCRIPT,
} from './variables'

let data = {
  features: {},
  lazyFeaturesLoaded: {},
  lazyFeaturesLoading: {},
  sharedOptions: {},
}

const globalWindowVariable = '_goldFeatures' // window._goldFeatures

if (window[globalWindowVariable]) {
  data = window[globalWindowVariable]
} else {
  window[globalWindowVariable] = data
}

/**
 * Default initialization options.
 *
 * @type {Object}
 * @property {Boolean} justChildNodes=false
 *   Set to true if you don't want to initialize the features of the container node.
 * @property {Boolean} lazy=true
 *   Set to false if you don't want to initialize any features lazy
 * @property {Object} lazyBundles={}
 *   Add object with all the bundles
 * @property {String} assetPath=null
 *   Add path to the feature-init files ("assets/")
 */
export const defaultInitOptions = {
  justChildNodes: false,
  lazy: true,
  lazyBundles: {},
  assetPath: null
}

/**
 * Default destroy options.
 *
 * @type {Object}
 * @property {Boolean} justChildNodes=false
 *   Set to true if you don't want to destroy the features of the container node.
 */
export const defaultDestroyOptions = {
  justChildNodes: false,
}

/**
 * Save shared option to data.sharedOptions.
 *
 * @param {String} [name]
 *   String with name under which the option will be saved
 *
 * @param {Any} [value]
 *   Value that the option controls (can be of any type)
 */
export function setSharedOption(name, value) {
  if (typeof name !== 'string') {
    throw Error('"name" needs to be a string!')
  }
  data.sharedOptions[name] = value
}

/**
 * Return an entry from data.sharedOptions
 *
 * @param {String} [name]
 *   String with name under which the option was saved
 *
 * @example
 * // get shared option
 * const deviceOption = base.features.getSharedOption('device')
 *
 * @returns {Any} Shared option
 */
export function getSharedOption(name) {
  if (name && data.sharedOptions.hasOwnProperty(name)) {
    return data.sharedOptions[name]
  }
  return null
}

/**
 * Lazyloads features based on the bundles provided.
 *
 * @param {Object} [bundles={}]
 *  Object containing all the feature-bundles
 * @param {String} [assetPath=null]
 */
export function lazyload(bundles, assetPath) {
  if (!bundles || !assetPath) {
    throw Error('Cannot lazyload features without a bundles file or a path!')
  }

  let optimizedFeatureBundles = {}
  let bundlesToLoad = []
  let features = getFeatures()

  for (const key of Object.keys(bundles)) {
    if (!data.lazyFeaturesLoaded.hasOwnProperty(key)) {
      data.lazyFeaturesLoaded[key] = key === FEATURES_MAIN_BUNDLE ? true : false
    }
    if (!data.lazyFeaturesLoading.hasOwnProperty(key)) {
      data.lazyFeaturesLoading[key] = false
    }
    bundles[key].forEach((item) => {
      optimizedFeatureBundles[item] = key
    })
  }

  features.forEach(feature => {
    let bundle = optimizedFeatureBundles[feature]
    if (
      optimizedFeatureBundles.hasOwnProperty(feature) &&
      !data.lazyFeaturesLoaded[bundle] &&
      !data.lazyFeaturesLoading[bundle]
    ) {
      data.lazyFeaturesLoading[bundle] = true
      bundlesToLoad.push(bundle)
    }
  })

  bundlesToLoad.forEach((bundle) => {
    let el = document.createElement('script')
    el.setAttribute('src', assetPath + bundle + '.js')
    document.head.appendChild(el)
    el.onload = () => {
      data.lazyFeaturesLoaded[bundle] = true
      data.lazyFeaturesLoading[bundle] = false
    }
  })
}

/**
 * Splits up bundles into thos with and without external dependencies, and only returns those with features on the current page
 */
export async function getRelevantBundles(bundles) {
  const features = getFeatures()
  let extElements = document.querySelectorAll(`[${ATTR_EXTERNAL_SCRIPT}]`)
  let extFeatures = []
  let optimizedBundles = {}
  let internalBundles = {}
  let externalBundles = {}

  // Create array of all features in need of external scripts
  extElements.forEach(el => {
    el.dataset.featureDependency
      .split(ATTR_FEATURES_SEPARATOR)
      .forEach(feature => {
        if (!extFeatures.includes(feature)) extFeatures.push(feature)
      })
  })

  // Remove bundles without any features on the page
  Object.entries(bundles).forEach(bundle => {
    features.forEach(feature => {
      if (bundle[1].includes(feature) && !optimizedBundles[bundle[0]]) {
        optimizedBundles[bundle[0]] = bundle[1]
      }
    })
  })

  // Split all bundles into internal and external Bundles
  Object.entries(optimizedBundles).forEach(bundle => {
    extFeatures.forEach(feature => {
      if (bundle[1].includes(feature) && !externalBundles[bundle[0]]) {
        externalBundles[bundle[0]] = bundle[1]
      }
    })
    if (!externalBundles[bundle[0]]) {
      internalBundles[bundle[0]] = bundle[1]
    }
  })

  return { internalBundles, externalBundles }
}

/**
 * Load any external scripts that are needed by the currently loaded features.
 */
export async function loadExternals(bundles, assetPath) {
  let elements = document.querySelectorAll(`[${ATTR_EXTERNAL_SCRIPT}]`)
  let features = getFeatures()
  let scripts = []
  let scriptsLoaded = []

  const returnScriptPromise = script => {
    return new Promise((resolve, reject) => {
      script._instance.async = false
      script._instance.setAttribute('src', script.url)
      script._instance.onload = resolve
      script._instance.onerror = reject
    })
  }

  const loadScript = async script => {
    const loaded = await returnScriptPromise(script)
      .then(() => true)
      .catch(() => {
        console.error('Could not load external script: ', script)
        return false
      })
    return loaded
  }

  elements.forEach(el => {
    let item = {}
    item.initialized = false
    item.url = el.dataset.url
    item._instance = el
    item.features = el.dataset.featureDependency.split(ATTR_FEATURES_SEPARATOR)
    scripts.push(item)
  })

  for (let f = 0; f < features.length; f++) {
    for (let s = 0; s < scripts.length; s++) {
      if (
        scripts[s].features.includes(features[f]) &&
        !scripts[s].initialized
      ) {
        const scriptLoaded = await loadScript(scripts[s])
        scriptsLoaded.push(scriptLoaded)
      }
    }
  }

  if (
    scriptsLoaded.length === 0 ||
    scriptsLoaded.every(value => value === true)
  ) {
    lazyload(bundles, assetPath)
  }
}

/**
 * Reinitializes features.
 *
 * @param {Node} [container=document.body]
 *   Container element to filter where features should be reinitialized.
 * @param {String} [name=null]
 *   Comma separated string with names of the features
 *   (used by the `data-feature` attribute) which should be reinitialized.
 */
export function reinit(container = document.body, name = null, options = {}) {
  options = Object.assign({}, defaultInitOptions, options)
  destroy(container, name)
  init(container, name, options)
}

/**
 * Initializes features.
 *
 * @example
 * // initialize all features
 * base.features.init()
 * @example
 * // initialize `feature1` and `feature2` instances inside #wrapper
 * base.features.init(document.getElementById('wrapper'), 'feature1,feature2')
 *
 * @param {Node} [container=document.body] Container element
 *   Container element to filter where features should be initialized.
 * @param {String} [name=null]
 *   Comma separated string with names of the features
 *   (used by the `data-feature` attribute) which should be initialized.
 * @param {Object} [options={}]
 *   Further initialize options to overwrite the [default ones]{@link module:base/features.defaultInitOptions}.
 *
 * @returns {Array} Initialized feature instances.
 */
export async function init(container = document.body, name = null, options = {}) {
  options = Object.assign({}, defaultInitOptions, options)

  const instances = []
  const names = name ? name.split(ATTR_FEATURES_SEPARATOR) : null
  const featureNodes = [...container.querySelectorAll(`[${ATTR_FEATURES}]`)]

  if (options.lazy && options.lazyBundles) {
    const { internalBundles, externalBundles } = await getRelevantBundles(
      options.lazyBundles
    )
    lazyload(internalBundles, options.assetPath)
    await loadExternals(externalBundles, options.assetPath)
  }

  if (!options.justChildNodes && container.getAttribute(ATTR_FEATURES)) {
    featureNodes.push(container)
  }

  eventHub.trigger('features:initialize', {
    container: container,
    names: names,
    nodes: featureNodes
  })

  featureNodes.forEach(featureNode => {
    const nodeInstances = []
    const dataFeatures = featureNode
      .getAttribute(ATTR_FEATURES)
      .split(ATTR_FEATURES_SEPARATOR)
    const ignoreFeatures = (
      featureNode.getAttribute(ATTR_FEATURES_IGNORE) || ''
    ).split(ATTR_FEATURES_SEPARATOR)

    dataFeatures.forEach(function(featureName) {
      featureName = featureName.trim()
      const feature = data.features[featureName]

      if (
        !feature || // feature has not been added yet
        (ignoreFeatures && ignoreFeatures.indexOf(featureName) > -1) || // feature is ignored on this node
        (name && names.indexOf(featureName) < 0) || // name is not whitelisted
        (featureNode._baseFeatureInstances && // feature has already been initalized on this node
          featureNode._baseFeatureInstances[featureName])
      )
        return

      const instance = new feature.featureClass(
        featureName,
        featureNode,
        feature.options
      )

      instance.init()
      instances.push(instance)
      nodeInstances.push(instance)
    })

    // trigger event on all instances
    nodeInstances.forEach(function(nodeInstance) {
      nodeInstance.trigger('featuresInitialized', nodeInstances)
    })
  })

  eventHub.trigger('features:initialized', {
    container: container,
    names: names,
    nodes: featureNodes,
    instances: instances
  })

  return instances
}

/**
 * Destroy feature instances.
 *
 * @example
 * // destroy all feature instances
 * base.features.destroy()
 * @example
 * // destroy `feature1` and `feature2` instances inside #wrapper
 * base.features.destroy(document.getElementById('wrapper'), 'feature1,feature2')
 *
 * @param {Node} [container=document.body] Container element
 *   Container element to filter where features should be destroyed.
 * @param {String} [name=null]
 *   Comma separated string with names of the features
 *   (used by the `data-feature` attribute) which should be initialized.
 * @param {Object} [options={}]
 *   Further destroy options to overwrite the [default ones]{@link module:base/features.defaultDestroyOptions}.
 */
export function destroy(container = document.body, name = null, options = {}) {
  options = Object.assign({}, defaultDestroyOptions, options)

  const names = name ? name.split(ATTR_FEATURES_SEPARATOR) : null
  const featureNodes = [...container.querySelectorAll(`[${ATTR_FEATURES}]`)]

  if (!options.justChildNodes && container.getAttribute(ATTR_FEATURES)) {
    featureNodes.push(container)
  }

  eventHub.trigger('features:destroy', {
    container: container,
    names: names,
    nodes: featureNodes
  })

  featureNodes.forEach(featureNode => {
    const nodeInstances = getInstancesByNode(featureNode)
    const ignoreFeatures = (
      featureNode.getAttribute(ATTR_FEATURES_IGNORE) || ''
    ).split(ATTR_FEATURES_SEPARATOR)

    for (let featureName in nodeInstances) {
      if (
        nodeInstances.hasOwnProperty(featureName) &&
        (!name || names.indexOf(featureName) > -1) && // name is whitelisted
        (!ignoreFeatures || ignoreFeatures.indexOf(featureName) < 0) && // feature is ignore on this node
        (featureNode._baseFeatureInstances && // feature instance exists
          featureNode._baseFeatureInstances[featureName])
      ) {
        nodeInstances[featureName].destroy()
        nodeInstances[featureName] = null
      }
    }
  })

  eventHub.trigger('features:destroyed', {
    container: container,
    names: names,
    nodes: featureNodes
  })
}

/**
 * Add feature
 *
 * @example
 * // add feature `deathStar`
 * base.features.add('deathStar', DeathStar, { destroyAlderaan: true })
 *
 * @param {String} name
 *   Name of the feature used by the `data-feature` attribute.
 * @param {Feature} featureClass
 *   Feature class to initiate.
 * @param {Object} options
 *   Any options to initialize the feature with.
 */
export function add(name, featureClass, options = {}) {
  const isFeatureNameAvailable = !data.features[name]
  invariant(isFeatureNameAvailable, `Feature "${name}" has been already added!`)

  data.features[name] = { featureClass, options }
}

/**
 * Return all initialized feature instances from given node.
 *
 * @example
 * // get all the feature instances
 * const features = base.features.getInstancesByNode(document.getElementById('deathstar'))
 * // do something with one of the features
 * features.deathStar.destroy()
 *
 * @param {Node} node
 *   Node to return the instances from.
 * @returns {Object|null}
 *   Feature instances indexed by name (used by `data-feature` attribute).
 */
export function getInstancesByNode(node) {
  return node._baseFeatureInstances || null
}

/**
 * Return initialized feature instance from given node and name.
 *
 * @example
 * // get feature instance
 * const deathStar = base.features.getInstancesByNode(document.getElementById('deathstar'), 'deathStar')
 * // do something with the feature
 * deathStar.destroy()
 *
 * @param {Node} node
 *   Node to return the instance from.
 * @param {String} name
 *   Name used by `data-feature` attribute.
 *
 * @returns {module:base/features~Feature|null} Feature instance.
 */
export function getInstanceByNode(node, name) {
  if (!node._baseFeatureInstances) {
    return null
  }

  return node._baseFeatureInstances[name] || null
}

/**
 * Return array of all the data-feature identifiers found in the DOM (removes duplicates)
 *
 * @example
 * // get features
 * const featureList = base.features.getFeatures()
 *
 * @returns {Array|null} Array of feature identifiers.
 */
export function getFeatures() {
  let features = []
  let elements = document.querySelectorAll(`[${ATTR_FEATURES}]`)

  elements.forEach(item => {
    let feature = item.getAttribute(ATTR_FEATURES)
    let featureArray = feature.replace(' ', '').split(ATTR_FEATURES_SEPARATOR)
    featureArray.forEach(ftr => {
      if (!features.includes(ftr)) {
        features.push(ftr)
      }
    })
  })

  return features
}

/**
 * Abstract Feature class.
 * @abstract
 */
export class Feature {
  /**
   * Constructor.
   *
   * @param {String} name
   *   Name of the feature used by the `data-feature` attribute.
   * @param {Node} node
   *   Node the feature belongs to.
   * @param {Object} options
   *   Feature options which can be used for anything.
   */
  constructor(name, node, options) {
    invariant(this.constructor !== Feature, "Can't instantiate abstract class!")

    observable(this)

    const defaultOptions = this.constructor.defaultOptions || {}

    this._name = name
    this._node = node
    this._options = Object.assign({}, defaultOptions, options)

    this._hubEvents = {}
    this._eventListener = {}

    if (!this._node._baseFeatureInstances) {
      this._node._baseFeatureInstances = {}
    }

    this._node._baseFeatureInstances[name] = this
  }

  /**
   * Return name the feature has been initialized with.
   * @returns {String}
   */
  get name() {
    return this._name
  }

  /**
   * Return node the feature belongs to.
   * @returns {Node}
   */
  get node() {
    return this._node
  }

  /**
   * Replaces current feature node with given one.
   * @param {Node} node - Replacement node.
   * @returns {Node}
   */
  replaceNode(node) {
    const replacedNode = this._node.parentElement.replaceChild(node, this._node)
    this._node = node
    return replacedNode
  }

  /**
   * Return given options the feature has been initialized with.
   * @returns {Object}
   */
  get options() {
    return this._options
  }

  /**
   * Return first element by given selector inside the feature node.
   *
   * @param   {String} selector - CSS selector
   * @returns {Element}
   */
  $(selector) {
    return this._node.querySelector(selector)
  }

  /**
   * Return all elements by given selector inside the feature node as array.
   *
   * @param   {String} selector - CSS selector
   * @returns {Element[]}
   */
  $$(selector) {
    return [...this._node.querySelectorAll(selector)]
  }

  /**
   * Add event listener to given node.
   *
   * @param {Node|NodeList} node - Node to add event listener to.
   * @param {String} type - Event type to add.
   * @param {Function} fn - Event handler
   * @param {Object|Boolean} options - Event handler options
   */
  addEventListener(node, type, fn, options = {}) {
    if (!isElement(node) && node !== window) {
      let currentNode = node.length
      while (currentNode--) {
        this.addEventListener(node[currentNode], type, fn, options)
      }
      return
    }

    options = Object.assign({}, Feature.defaultEventListenerOptions, options)

    if (supportsPassiveEvents()) {
      node.addEventListener(type, fn, options)
    } else {
      node.addEventListener(type, fn, options.capture)
    }

    if (!this._eventListener[type]) {
      this._eventListener[type] = []
    }

    this._eventListener[type].push({ node, fn })
  }

  /**
   * Remove event listener from given node.
   *
   * @param {Node|NodeList} node
   *   Node to remove the event listener from.
   * @param {String|null} [type=null]
   *   Event type to remove (leave empty to remove listeners of all event types).
   * @param {Function|null} [fn=null]
   *   Handler to remove (leave empty to remove all listeners).
   */
  removeEventListener(node, type = null, fn = null) {
    if (!isElement(node) && node !== window) {
      let currentNode = node.length
      while (currentNode--) {
        this.removeEventListener(node[currentNode], type, fn)
      }
      return
    }

    if (type && fn) {
      node.removeEventListener(type, fn)

      this._eventListener[type].forEach((listener, i) => {
        if (node == listener.node && fn == listener.fn) {
          this._eventListener[type].splice(i, 1)
        }
      })
    } else if (type) {
      this._eventListener[type].forEach((listener, i) => {
        if (node == listener.node) {
          node.removeEventListener(type, listener.fn)
          this._eventListener[type].splice(i, 1)
        }
      })
    } else if (fn) {
      this.removeAllEventListener(node, fn)
    } else {
      this.removeAllEventListener(node)
    }
  }

  /**
   * Remove all listeners added by this feature.
   *
   * @param {Node|null} [node=null]
   *   Limit removing event listeners on given node.
   * @param {Function|null} [fn=null]
   *   Limit removing event listeners on given handler.
   */
  removeAllEventListener(node = null, fn = null) {
    if (node && !isElement(node) && node !== window) {
      let currentNode = node.length
      while (currentNode--) {
        this.removeAllEventListener(node[currentNode], fn)
      }
      return
    }

    for (let type in this._eventListener) {
      if (this._eventListener.hasOwnProperty(type)) {
        this._eventListener[type].forEach(listener => {
          if ((!node || node == listener.node) && (!fn || fn == listener.fn)) {
            listener.node.removeEventListener(type, listener.fn)
          }
        })
      }
    }

    // reset internal references to event listeners
    this._eventListener = {}
  }

  /**
   * Emit event to global event hub.
   * @param {string} event name
   * @param  {...any} args arguments to pass to event hub
   */
  triggerHub(event, ...args) {
    eventHub.trigger(event, ...args)
  }

  /**
   * Add event to global event hub.
   * @param {string} event name
   * @param {function} fn function to call
   */
  onHub(event, fn) {
    eventHub.on(event, fn)

    if (!this._hubEvents[event]) {
      this._hubEvents[event] = []
    }

    this._hubEvents[event].push(fn)
  }

  /**
   * Remove event from global event hub.
   * @param {string} event name
   * @param {function} fn function to remove
   */
  offHub(event, fn = null) {
    if (event && fn) {
      eventHub.off(event, fn)

      this._hubEvents[event].forEach((listener, i) => {
        if (fn == listener) {
          this._hubEvents[event].splice(i, 1)
        }
      })
    } else if (event) {
      this._hubEvents[event].forEach((listener, i) => {
        eventHub.off(event, listener)
        this._hubEvents[event].splice(i, 1)
      })
    }
  }

  /**
   * Remove all events from global event hub added by this feature.
   */
  offAllHub() {
    for (let event in this._hubEvents) {
      if (this._hubEvents.hasOwnProperty(event)) {
        this._hubEvents[event].forEach(listener => {
          eventHub.off(event, listener)
        })
      }
    }

    // reset internal referencens to hub events
    this._hubEvents = {}
  }

  /**
   * Initialize feature instance.
   */
  init() {}

  /**
   * Destroy feature instance.
   */
  destroy() {
    this.trigger('destroy')

    // remove all registered event listeners
    this.removeAllEventListener()

    // remove all events from global event hub
    this.offAllHub()

    // remove feature instance from node
    this._node._baseFeatureInstances[name] = null
    delete this._node._baseFeatureInstances[name]

    // clean up properties
    this._name = null
    this._node = null
    this._options = null

    this.trigger('destroyed')
  }
}

Feature.defaultEventListenerOptions = {
  passive: false,
  capture: false,
  once: false
}

const features = data.features

export default {
  /**
   * Feature class.
   * @type {Class}
   * @see module:base/features~Feature
   */
  Feature,

  init,
  destroy,
  reinit,
  add,
  lazyload,
  getInstanceByNode,
  getInstancesByNode,
  setSharedOption,
  getSharedOption,
  getFeatures,

  /**
   * All relevant data (added features, relevant shared options, loading-status of bundles).
   * @type {Object}
   */
  data,

  /**
   * Features added to current site.
   * @type {Object}
   */
  features,
}