class_helper_base.js

import semver from 'semver'
import lodash from 'lodash'
import Print from '../misc/print.js'
import path from 'path'
import resolvePath from '../../lib/resolve-path.js'

const {
  isFunction,
  merge,
  forOwn,
  groupBy,
  find,
  reduce,
  map,
  trim,
  keys,
  intersection,
  each,
  camelCase,
  get
} = lodash

/**
 * Internal helpers called by Bajo & plugins that only used once for bootstrapping purpose.
 * It should remains hidden and not to be imported by any program.
 *
 * @module Helper/Base
 */

/**
 * Scan plugins ```method``` directories, and turn + attach its found files as methods dynamically.
 *
 * @async
 */
export async function attachMethods () {
  const { fastGlob } = this.lib

  async function createMethod (dir) {
    dir = resolvePath(dir)
    const files = await fastGlob([`!${dir}/**/_*.{js,json}`, `${dir}/**/*.{js,json}`])
    for (const f of files) {
      const ext = path.extname(f)
      const base = f.replace(dir, '').slice(0, -ext.length)
      const name = camelCase(base)
      let mod
      if (ext === '.json') mod = this.app.bajo.readJson(f)
      else mod = await this.app.bajo.importModule(f)
      if (isFunction(mod)) mod = mod.bind(this)
      this[name] = mod
    }
    return files.length
  }

  const { eachPlugins } = this.bajo
  const me = this // the app
  me.bajo.log.debug('attachMethods')
  await eachPlugins(async function () {
    const { ns, pkgName } = this
    const dir = ns === me.mainNs ? (`${me.bajo.dir.base}/${me.mainNs}`) : me.bajo.getModuleDir(pkgName)
    const num = await createMethod.call(me[ns], `${dir}/method`, pkgName)
    me.bajo.log.trace('- %s (%d)', ns, num)
  })
}

/**
 * Build configurations
 *
 * @async
 */
export async function buildConfigs () {
  this.bajo.log.debug('readConfigs')
  for (const ns of this.getAllNs()) {
    await this[ns].loadConfig()
    this[ns].print = new Print(this[ns])
    this.loadIntl(ns)
  }
}

/**
 * Ensure for names and aliases to be unique and no clashes with other plugins
 *
 * @async
 */
export async function checkNameAliases () {
  const { eachPlugins } = this.bajo
  this.bajo.log.debug('checkAliasNameClash')
  const refs = []
  await eachPlugins(async function () {
    const { ns, pkgName } = this
    const { alias } = this.constructor
    let item = find(refs, { ns })
    if (item) throw this.error('pluginNameClash%s%s%s%s', ns, pkgName, item.ns, item.pkgName, { code: 'BAJO_NAME_CLASH' })
    item = find(refs, { alias })
    if (item) throw this.error('pluginNameClash%s%s%s%s', alias, pkgName, item.alias, item.pkgName, { code: 'BAJO_ALIAS_CLASH' })
    refs.push({ ns, alias, pkgName })
  })
}

/**
 * Ensure dependencies are met
 *
 * @async
 */
export async function checkDependencies () {
  async function runner () {
    const { ns, pkgName } = this
    const { join } = this.app.bajo
    this.app.bajo.log.trace('- %s', ns)
    const { dependencies } = this.app.pluginClass[this.ns]
    const odep = reduce(dependencies, (o, k) => {
      const item = map(k.split('@'), m => trim(m))
      if (k[0] === '@') o['@' + item[1]] = item[2]
      else o[item[0]] = item[1]
      return o
    }, {})
    const deps = keys(odep)
    if (deps.length > 0) {
      if (intersection(this.app.pluginPkgs, deps).length !== deps.length) {
        throw this.error('dependencyUnfulfilled%s%s', pkgName, join(deps), { code: 'BAJO_DEPENDENCY' })
      }
      each(deps, d => {
        if (!odep[d]) return
        const ver = get(this.app[camelCase(d)], 'config.pkg.version')
        if (!ver) return
        if (!semver.satisfies(ver, odep[d])) {
          throw this.error('semverCheckFailed%s%s', pkgName, `${d}@${odep[d]}`, { code: 'BAJO_DEPENDENCY_SEMVER' })
        }
      })
    }
  }

  const { eachPlugins } = this.bajo
  this.bajo.log.debug('checkDeps')
  await eachPlugins(async function () {
    await runner.call(this)
  })
}

/**
 * Collect and build hooks and push them to the bajo's hook system
 *
 * @async
 * @fires bajo:afterCollectHooks
 */
export async function collectHooks () {
  const { eachPlugins, runHook, isLogInRange, importModule, breakNsPathFromFile } = this.bajo
  const me = this
  me.bajo.hooks = this.bajo.hooks ?? []
  me.bajo.log.debug('collectHooks')
  // collects
  await eachPlugins(async function ({ dir, file }) {
    const { ns } = this
    const { fullNs, path } = breakNsPathFromFile({ file, dir, baseNs: ns, suffix: '/hook/' })
    const mod = await importModule(file, { asHandler: true })
    if (!mod) return undefined
    merge(mod, { ns: fullNs, path, src: ns })
    me.bajo.hooks.push(mod)
  }, { glob: 'hook/**/*.js', prefix: me.bajo.ns })
  // for log trace purpose only
  if (!isLogInRange('trace')) return
  const items = groupBy(me.bajo.hooks, 'ns')
  forOwn(items, (v, k) => {
    const hooks = groupBy(v, 'path')
    forOwn(hooks, (v1, k1) => {
      me.bajo.log.trace('- %s:%s (%d)', k, k1, v1.length)
    })
  })

  /**
   * Run after hooks are collected
   *
   * @global
   * @event bajo:afterCollectHooks
   * @param {Object[]} hooks - Array of hook objects
   * @see {@tutorial hook}
   * @see module:Helper/Base.collectHooks
   */
  await runHook('bajo:afterCollectHooks', this.bajo.hooks)
}

/**
 * Finally, run all plugins
 *
 * @async
 * @fires bajo:beforeAll{method}
 * @fires {ns}:before{method}
 * @fires {ns}:after{method}
 * @fires bajo:afterAll{method}
 */
export async function run () {
  const me = this
  const { runHook, eachPlugins, join } = me.bajo
  const { freeze } = me.bajo
  const methods = ['init']
  if (!me.applet) methods.push('start')
  me.bajo.log.debug('loadedPlugins%s', join(map(me.bajo.app.pluginPkgs, b => camelCase(b))))
  for (const method of methods) {
    /**
     * Run before all ```{method}``` executed. Accepted ```{method}```: ```Init``` or ```Start```
     *
     * @global
     * @event bajo:beforeAll{method}
     * @param {string} method - Accepted methods: ```Init```, ```Start```
     * @see module:Helper/Base.run
     */
    await runHook(`bajo:${camelCase(`before all ${method}`)}`)
    await eachPlugins(async function () {
      const { ns } = this
      if (method === 'start') freeze(me[ns].config)
      /**
       * Run before ```{method}``` is executed within ```{ns}``` context
       *
       * - ```{ns}``` - namespace
       * - ```{method}``` - Accepted methods: ```Init``` or ```Start```
       *
       * @global
       * @event {ns}:before{method}
       * @see module:Helper/Base.run
       */
      await runHook(`${ns}:${camelCase(`before ${method}`)}`)
      await me[ns][method]()
      /**
       * Run after ```{method}``` is executed within ```{ns}``` context
       *
       * - ```{ns}``` - namespace
       * - ```{method}``` - Accepted methods: ```Init``` or ```Start```
       *
       * @global
       * @event {ns}:after{method}
       * @see module:Helper/Base.run
       */
      await runHook(`${ns}:${camelCase(`after ${method}`)}`)
    })
    /**
     * Run after all ```{method}``` executed. Accepted ```{method}```: ```Init``` or ```Start```
     *
     * @global
     * @event bajo:afterAll{method}
     * @see module:Helper/Base.run
     */
    await runHook(`bajo:${camelCase(`after all ${method}`)}`)
  }
}