index.js

import { stripHtml } from 'string-strip-html'
import path from 'path'

/**
 * Plugin factory
 *
 * @param {string} pkgName - NPM package name
 * @returns {class}
 */
async function factory (pkgName) {
  const me = this

  /**
   * WaibuMpa class
   *
   * @class
   */
  class WaibuMpa extends this.app.pluginClass.base {
    static alias = 'wmpa'
    static dependencies = ['waibu', 'waibu-static', 'bajo-template']

    constructor () {
      super(pkgName, me.app)
      this.config = {
        title: 'Multi Page Webapp',
        waibu: {
          prefix: ''
        },
        waibuAdmin: {
          modelDisabled: ['session']
        },
        mountMainAsRoot: true,
        page: {
          charset: 'utf-8',
          cacheMaxAge: 0,
          insertWarning: false
        },
        darkMode: {
          set: null,
          qsKey: 'dark-mode'
        },
        intl: {
          detectors: ['qs']
        },
        session: {
          secret: 'f703a74b884b539e78c642a5369fe538',
          cookieName: 'sid',
          cookie: {
            secure: 'auto'
          },
          saveUninitialized: false
        },
        emoji: true,
        viewEngine: {
          cacheMaxAge: 0,
          layout: {
            default: 'waibuMpa:/default.html',
            fallback: true
          }
        },
        theme: {
          set: null,
          autoInsert: {
            css: true,
            meta: true,
            scripts: true,
            inlineScript: true,
            inlineCss: true
          },
          component: {
            unknownTag: 'replaceWithDiv',
            cacheMaxAgeDur: '1m'
          }
        },
        iconset: {
          set: null,
          autoInsert: {
            css: true,
            scripts: true,
            inlineScript: true,
            inlineCss: true
          }
        },
        concatResource: {
          cacheMaxAge: 0,
          excluded: [],
          css: false,
          scripts: false
        },
        cheerio: {
          loadOptions: {
            xml: {
              xmlMode: false,
              decodeEntities: false,
              recognizeSelfClosing: true
            }
          }
        },
        prettier: {
          parser: 'html',
          printWidth: 120
        },
        minifier: {
          removeAttributeQuotes: true,
          removeComments: true,
          removeCommentsFromCDATA: true,
          removeCDATASectionsFromCDATA: true,
          collapseWhitespace: true,
          conservativeCollapse: true,
          decodeEntities: true,
          collapseBooleanAttributes: true,
          removeRedundantAttributes: true,
          removeEmptyAttributes: true
        },
        multipart: {},
        cors: {},
        helmet: {
          contentSecurityPolicy: false
        },
        compress: false,
        rateLimit: false,
        disabled: []
      }
    }

    init = async () => {
      const { trim } = this.app.lib._
      this.config.waibu.prefix = trim(this.config.waibu.prefix, '/')
    }

    arrayToAttr = (array = [], delimiter = ' ') => {
      return array.join(delimiter)
    }

    attrToArray = (text = '', delimiter = ' ') => {
      const { map, trim, without, isArray } = this.app.lib._
      if (text === true) text = ''
      if (isArray(text)) text = text.join(delimiter)
      return without(map(text.split(delimiter), i => trim(i)), '', undefined, null)
    }

    attrToObject = (text = '', delimiter = ';', kvDelimiter = ':') => {
      const { camelCase, isPlainObject } = this.app.lib._
      const result = {}
      if (isPlainObject(text)) text = this.objectToAttr(text)
      if (typeof text !== 'string') return text
      if (text.slice(1, 3) === '%=') return text
      const array = this.attrToArray(text, delimiter)
      array.forEach(item => {
        const [key, val] = this.attrToArray(item, kvDelimiter)
        result[camelCase(key)] = val
      })
      return result
    }

    base64JsonDecode = (data = 'e30=') => {
      return JSON.parse(Buffer.from(data, 'base64'))
    }

    base64JsonEncode = (data) => {
      return Buffer.from(JSON.stringify(data)).toString('base64')
    }

    buildUrl = ({ exclude = [], prefix = '?', base, url = '', params = {}, prettyUrl }) => {
      const { parseObject } = this.app.bajo
      const { qs } = this.app.waibu
      const { forOwn, omit, isEmpty } = this.app.lib._
      const qsKey = this.app.waibu.config.qsKey
      let path
      let hash
      let query
      [path = '', hash = ''] = url.split('#')
      if (hash.includes('?')) [hash, query] = hash.split('?')
      else [path, query] = path.split('?')
      query = parseObject(qs.parse(query) ?? {})
      forOwn(params, (v, k) => {
        const key = qsKey[k] ?? k
        query[key] = v
      })
      const id = query.id
      if (prettyUrl) delete query.id
      query = prefix + qs.stringify(omit(query, exclude))
      if (!isEmpty(hash)) hash = '#' + hash
      if (!base) return path + query + hash
      const parts = path.split('/')
      if (base) {
        parts.pop()
        parts.push(base)
      }
      if (prettyUrl && id) parts.push(id)
      return parts.join('/') + query + hash
    }

    getAppTitle = (name) => {
      const { getPlugin } = this.app.bajo
      const { get } = this.app.lib._
      const plugin = getPlugin(name, true)
      if (!plugin) return
      return get(plugin, 'config.waibu.title', get(plugin, 'config.waibu.prefix', plugin.title, plugin.title ?? plugin.ns))
    }

    getResource (name) {
      const subNses = ['layout', 'template', 'partial']
      const { ns, path, subNs, subSubNs, qs } = this.app.bajo.breakNsPath(name)
      const plugin = this.app.bajo.getPlugin(ns)
      const dir = `${plugin.dir.pkg}/extend/waibuMpa`
      if (!subNses.includes(subNs)) throw this.error('unknownResource%s', name)
      const fullPath = subSubNs ? `${dir}/${subSubNs}/${subNs}${path}` : `${dir}/${subNs}${path}`
      return { ns, subNs, subSubNs, path, qs, fullPath }
    }

    getSessionId = (rawCookie, secure) => {
      const cookieName = this.config.session.cookieName
      return this.ctx.parseCookie(rawCookie)[cookieName]
    }

    getViewEngine = (ext) => {
      const { find } = this.app.lib._
      const ve = find(this.viewEngines, v => v.fileExts.includes(ext))
      return ve ?? find(this.viewEngines, v => v.name === 'default')
    }

    groupAttrs = (attribs = {}, keys = [], removeEmpty = true) => {
      const { isString, filter, omit, kebabCase, camelCase, isEmpty } = this.app.lib._
      if (isString(keys)) keys = [keys]
      const attr = { _: {} }
      for (const a in attribs) {
        for (const k of keys) {
          if (a === k) {
            attr._[k] = attribs[a]
            continue
          }
          attr[k] = attr[k] ?? {}
          attr[k].class = attr[k].class ?? []
          attr[k].style = attr[k].style ?? {}
          const _k = kebabCase(k)
          let name = camelCase(kebabCase(a).slice(_k.length + 1))
          if (a.includes('@') || a.includes(':')) name = a.slice(_k.length + 1)
          if (!kebabCase(a).startsWith(k + '-')) {
            if (!keys.includes(a)) {
              attr._[a] = attribs[a]
              if (a === 'class' && isString(attribs[a])) attr._.class = this.attrToArray(attr._.class)
              if (a === 'style' && isString(attribs[a])) attr._.style = this.attrToObject(attr._.style)
            }
            continue
          }
          attr[k][name] = attribs[a]
          if (name === 'class' && isString(attribs[a])) attr[k].class = this.attrToArray(attr[k].class)
          if (name === 'style' && isString(attribs[a])) attr[k].style = this.attrToObject(attr[k].style)
        }
      }
      const deleted = filter(Object.keys(attr._), m => {
        let match
        m = kebabCase(m)
        for (const k of keys) {
          if (m.startsWith(k + '-')) match = true
        }
        return match
      })
      attr._ = omit(attr._, deleted)
      for (const k of keys) {
        const item = attr[k]
        if (removeEmpty && !attr._[k] && Object.keys(item).length === 2 && isEmpty(item.class) && isEmpty(item.style)) delete attr[k]
      }
      return attr
    }

    minify = async (text) => {
      const { importPkg } = this.app.bajo
      const minifier = await importPkg('waibuMpa:html-minifier-terser')
      return await minifier.minify(text, {
        collapseWhitespace: true
      })
    }

    // Based on: https://github.com/siddharth-sunchu/native-methods/blob/master/JSONStringfy.js
    jsonStringify = (obj, replacer, space) => {
      const {
        isNumber, isString, isBoolean, isUndefined, isFunction, isSymbol,
        isNull, isDate, isArray, isPlainObject
      } = this.app.lib._

      if (replacer !== true) return JSON.stringify(obj, replacer, space)

      const isNotNumber = (value) => {
        return isNumber(value) && isNaN(value)
      }

      const isInfinity = (value) => {
        return isNumber(value) && !isFinite(value)
      }

      const restOfDataTypes = (value) => {
        return isNumber(value) || isString(value) || isBoolean(value)
      }

      const ignoreDataTypes = (value) => {
        return isUndefined(value) || isFunction(value) || isSymbol(value)
      }

      const nullDataTypes = (value) => {
        return isNotNumber(value) || isInfinity(value) || isNull(value)
      }

      const arrayValuesNullTypes = (value) => {
        return isNotNumber(value) || isInfinity(value) || isNull(value) || ignoreDataTypes(value)
      }

      const removeComma = (str) => {
        const tempArr = str.split('')
        tempArr.pop()
        return tempArr.join('')
      }

      if (ignoreDataTypes(obj)) {
        return undefined
      }

      if (isDate(obj)) {
        return `"${obj.toISOString()}"`
      }

      if (nullDataTypes(obj)) {
        return `${null}`
      }

      if (isSymbol(obj)) {
        return undefined
      }

      if (restOfDataTypes(obj)) {
        const passQuotes = isString(obj) ? "'" : ''
        return `${passQuotes}${obj}${passQuotes}`
      }

      if (isArray(obj)) {
        let arrStr = ''
        obj.forEach((eachValue) => {
          arrStr += arrayValuesNullTypes(eachValue) ? this.jsonStringify(null, replacer, space) : this.jsonStringify(eachValue, replacer, space)
          arrStr += ','
        })

        return '[' + removeComma(arrStr) + ']'
      }

      if (isPlainObject(obj)) {
        let objStr = ''

        const objKeys = Object.keys(obj)

        objKeys.forEach((eachKey) => {
          const eachValue = obj[eachKey]
          if (eachKey.includes('-')) eachKey = `'${eachKey}'`
          objStr += (!ignoreDataTypes(eachValue)) ? `${eachKey}:${this.jsonStringify(eachValue, replacer, space)},` : ''
        })
        return '{' + removeComma(objStr) + '}'
      }
    }

    objectToAttr = (obj = {}, delimiter = ';', kvDelimiter = ':') => {
      const { forOwn, kebabCase } = this.app.lib._
      const result = []
      forOwn(obj, (v, k) => {
        result.push(`${kebabCase(k)}${kvDelimiter} ${v ?? ''}`)
      })
      return result.join(delimiter + ' ')
    }

    // based on: https://github.com/kyleparisi/pagination-layout/blob/master/pagination-layout-be.js
    paginationLayout = (totalItems, itemsPerPage, currentPage) => {
      const { isPlainObject } = this.app.lib._
      if (isPlainObject(totalItems)) {
        currentPage = totalItems.page
        itemsPerPage = totalItems.limit
        totalItems = totalItems.count
      }
      function last (array) {
        const length = array == null ? 0 : array.length
        return length ? array[length - 1] : undefined
      }

      const pages = Math.ceil(totalItems / itemsPerPage)

      if (!totalItems) return []

      // default pages when we only have <= 4 pages
      if ([1, 2, 3, 4, 5, 6, 7].indexOf(pages) !== -1) {
        const defaultView = []
        for (let i = 1; i <= pages; i++) {
          defaultView.push(i)
        }
        return defaultView
      }

      currentPage = currentPage || 1

      const boundary = 2
      let boundaryMiddle = false

      // if current page is sufficiently in the middle, boundary is +1 and -1
      if (
        currentPage > 3 &&
        (pages - currentPage >= 3 ||
        pages - currentPage === 1)
      ) {
        boundaryMiddle = true
      }

      if (currentPage > pages) {
        currentPage = pages
      }

      if (currentPage < 1) {
        currentPage = 1
      }

      const output = []

      if (!boundaryMiddle) {
        // count up to boundary amount from current page
        for (let i = currentPage; i <= pages; i++) {
          if (output.length === boundary) {
            break
          }
          output.push(i)
        }

        // if we do not fill the boundary count, count down from current page
        if (output.length < boundary) {
          for (let i = currentPage - 1; i > pages - boundary; i--) {
            output.unshift(i)
          }
        }
      } else {
        // count up 1 and down 1 from current page
        output.push(currentPage - 1)
        output.push(currentPage)
        output.push(currentPage + 1)
      }

      // attach last page to nav when only 1 away
      if (pages - last(output) === 1) {
        output.push(pages)
      }

      // attach first page to when only 1 away
      if (output[0] === 2) {
        output.unshift(1)
      }

      // attach first page to when only 2 away
      if (currentPage === 3) {
        output.unshift(2)
        output.unshift(1)
      }

      // put lowest page and ... when we exceed the boundary
      if (
        currentPage > 3 &&
        pages > boundary &&
        pages > 7
      ) {
        output.unshift(1, '...')
      }

      if (output.length < 7) {
        let need = 7 - output.length
        // should count down
        if (pages === last(output)) {
          for (let i = 1; i <= need; i++) {
            output.splice(2, 0, output[2] - 1)
          }
        } else if (!boundaryMiddle) { // should count up
          // remove "...", [last page]
          need = need - 2
          for (let i = 1; i <= need; i++) {
            output.push(last(output) + 1)
          }
        }
      }

      // done if the final page is in view
      if (!(pages - last(output) > 1)) {
        return output
      }

      // attach final page to view
      output.push('...')
      output.push(pages)
      return output
    }

    renderString = async (text, params = {}, opts = {}) => {
      const { importModule } = this.app.bajo
      const buildLocals = await importModule('waibu:/lib/build-locals.js')
      const locals = await buildLocals.call(this, { tpl: null, params, opts })
      const ve = this.getViewEngine(opts.ext)
      return await ve.renderString(text, locals, opts)
    }

    render = async (tpl, params = {}, opts = {}) => {
      const { importModule } = this.app.bajo
      const buildLocals = await importModule('waibu:/lib/build-locals.js')
      const locals = await buildLocals.call(this, { tpl, params, opts })
      const ext = path.extname(tpl)
      if (['.json', '.js', '.css'].includes(ext)) opts.partial = true
      opts.ext = ext
      opts.cacheMaxAge = this.config.page.cacheMaxAgeDur
      const viewEngine = this.getViewEngine(ext)
      return await viewEngine.render(tpl, locals, opts)
    }

    stripHtmlTags = (html, options = {}) => {
      const { result } = stripHtml(html, options)
      return result
    }

    urlToBreadcrumb = (url, { delimiter, returnParts, base = '', handler, handlerScope, handlerOpts } = {}) => {
      const { trim, map, last, without } = this.app.lib._
      const { routePath } = this.app.waibu

      function defHandler (item) {
        return item
      }

      function breakPath (route, delimiter = '/') {
        route = trim(route, delimiter)
        const parts = without(route.split(delimiter), '')
        const routes = []
        for (const p of parts) {
          const l = last(routes)
          routes.push(l ? `${l}${delimiter}${p}` : p)
        }
        return routes
      }

      url = routePath(url)
      const route = trim(url.replace(base, ''), '/')
      const parts = breakPath.call(this, route, delimiter)
      if (returnParts) return parts
      if (!handler) handler = defHandler
      if (!handlerScope) handlerScope = this
      const result = map(parts, (r, idx) => {
        const f = `${base}/${r}`
        const opts = parts.length > 2 && (idx === parts.length - 2) && handlerOpts.hrefRebuild ? { hrefRebuild: handlerOpts.hrefRebuild } : {}
        return handler.call(handlerScope, f, url, opts)
      })
      return result
    }

    getMenuPages = (menu, path, subPath) => {
      const { get, filter, isFunction } = this.app.lib._
      const all = get(menu, 'pages', [])
      if (!path) return all
      const pages = filter(all, a => {
        return a.children && (a.title === path || a.href === path)
      })
      if (!isFunction(subPath)) {
        return filter(pages, p => {
          return p.title === subPath || p.href === subPath
        })
      }
      return subPath(pages, subPath)
    }
  }

  return WaibuMpa
}

export default factory