lib_helper.js

import path from 'path'
import iconsetFactory from './class/iconset.js'
import viewEngineFactory from './class/view-engine.js'
import themeFactory from './class/theme.js'
import componentFactory from './class/component.js'

const omitted = ['css', 'style', 'links', 'scripts', 'ns', 'appTitle', 'title', 'fullTitle']
const names = ['description', 'keywords', 'robots', 'viewport', 'author', 'publisher',
  'application-name', 'generator', 'referrer', 'theme-color', 'googlebot'
]

/**
 * @module Helper
 */

/**
 * Print a link element
 *
 * @memberof module:Helper
 * @param {Object} link - Link object
 * @returns {string} - HTML string for the link element
 */
export function printLink (link) {
  const { routePath } = this.app.waibu
  link.rel = link.rel ?? 'stylesheet'
  link.type = link.type ?? 'text/css'
  link.href = routePath(link.href)
  const attrs = this.stringifyAttribs(link)
  return `<link ${attrs} />`
}

/**
 * Print a script element
 *
 * @memberof module:Helper
 * @param {Object} script - Script object
 * @returns {string} - HTML string for the script element
 */
export function printScript (script) {
  const { routePath } = this.app.waibu
  const { stringifyAttribs } = this.app.waibuMpa
  script.src = routePath(script.src)
  const result = `<script ${stringifyAttribs(script)}></script>`
  return result
}

/**
 * Collect regular resources (css, scripts, links) from theme/iconset, frontmatter, and options
 *
 * @memberof module:Helper
 * @async
 * @param {string} type - Type of resource ('css', 'scripts', 'links')
 * @param {Function} transformer - Function to transform each resource item
 * @param {Object} [options={}] - Additional options for resource collection
 * @returns {Promise<Array>} - Array of collected and transformed resource items
 */
async function collectRegular (type, transformer, options = {}) {
  const { runHook } = this.app.bajo
  const { map, get, set, isString, camelCase, uniq } = this.app.lib._
  const { arrangeArray } = this.app.lib.aneka
  const { req } = options

  let items = []
  // find in theme/iconset
  for (const item of ['theme', 'iconset']) {
    if (this.config[item] && this.config[item].autoInsert[type]) {
      const rsc = get(options, `${item}.${type}`, [])
      const opts = { items, req }
      opts[type] = rsc
      await runHook(`${this.ns}.${item}:${camelCase(`before inject ${type}`)}`, opts)
      if (rsc) items.push(...rsc)
      await runHook(`${this.ns}.${item}:${camelCase(`after inject ${type}`)}`, opts)
    }
  }
  // find in frontmatter
  let fm = get(options, `locals.page.${type}`, [])
  if (isString(fm)) fm = [fm]
  items.push(...fm)
  // find in options
  items.push(...options[type])
  const key = type === 'scripts' ? 'src' : 'href'
  items = items.map(item => {
    if (isString(item)) return set({}, key, item)
    return item
  })
  // arrange
  let tmp = items.map(item => item[key])
  tmp = arrangeArray(tmp)
  const newItems = []
  for (const t of tmp) {
    const item = items.find(i => i[key].endsWith(t))
    item[key] = t
    newItems.push(item)
  }
  return uniq(map(newItems, i => transformer.call(this, i)))
}

/**
 * Collect inline resources (css, scripts) from theme/iconset and options
 *
 * @memberof module:Helper
 * @async
 * @param {string} type - Type of resource ('inlineCss', 'inlineScript')
 * @param {Object} [options={}] - Additional options for resource collection
 * @returns {Promise<Array>} - Array of collected inline resources
 */
async function collectInline (type, options = {}) {
  const { runHook } = this.app.bajo
  const { isEmpty, get, camelCase, uniq, trim } = this.app.lib._
  const { $, req } = options
  const items = []
  for (const item of ['theme', 'iconset']) {
    if (this.config[item] && this.config[item].autoInsert[type]) {
      const rsc = get(options, `${item}.${camelCase(type)}`)
      const opts = { items, req }
      opts[type] = rsc
      await runHook(`${this.ns}.${item}:${camelCase(`before inject ${type}`)}`, opts)
      if (rsc) items.push(rsc)
      await runHook(`${this.ns}.${item}:${camelCase(`after inject ${type}`)}`, opts)
    }
  }
  // find in options
  if (!isEmpty(options[type])) items.push(...options[type])
  $(type === 'inlineCss' ? 'style' : 'script').each(function () {
    const item = trim($(this).html())
    if (!isEmpty(item)) {
      items.push(item)
      $(this).remove()
    }
  })
  return uniq(items)
}

/**
 * Inject scripts into the page
 *
 * @memberof module:Helper
 * @async
 * @param {Object} [options={}] - Options for script injection
 * @param {Object} [options.$] - Cheerio instance for DOM manipulation
 * @param {Object} [options.cmp] - Component instance
 * @param {Object} [options.locals] - Local variables for rendering
 */
async function injectScript (options = {}) {
  const { $, cmp, locals } = options
  const { render } = this.app.bajoTemplate
  const parent = this.config.page.scriptsAtEndOfBody ? 'body' : 'head'
  const regular = await collectRegular.call(this, 'scripts', printScript, options)
  if (regular.length > 0) $(parent).append(regular.join('\n'))
  const inline = await collectInline.call(this, 'inlineScript', options)
  if (inline.length > 0) cmp.scriptBlock.root.push(...inline)
  const script = await render(locals.page.scriptBlock, { block: cmp.scriptBlock }, { default: 'bajoTemplate.partial:/script-block.html' })
  $(parent).append(script)
}

/**
 * Inject meta tags into the page
 *
 * @memberof module:Helper
 * @async
 * @param {Object} [options={}] - Options for meta injection
 * @param {Object} [options.$] - Cheerio instance for DOM-like manipulation
 * @param {Object} [options.theme] - Theme instance
 * @param {Object} [options.req] - Request object
 * @param {Object} [options.locals] - Local variables for rendering
 */
async function injectMeta (options = {}) {
  const { runHook, importPkg } = this.app.bajo
  const { map, uniq, isArray, keys, omit, get, isFunction } = this.app.lib._
  const { sprintf } = this.app.lib
  const { $, theme, req, locals } = options
  const { page = {} } = locals
  let items = [{
    tag: 'meta',
    name: 'req-id',
    content: req.id
  }]
  const refreshDur = get(req, 'routeOptions.config.refreshDur', get(this, 'config.page.refreshDur', 0))
  if (refreshDur > 0) {
    items.push({
      tag: 'meta',
      httpEquiv: 'Refresh',
      content: Math.floor(refreshDur / 60) + ''
    })
  }
  // meta
  for (const attr of keys(omit(locals.page, [...omitted]))) {
    if (!page[attr]) continue
    if (!names.includes(attr)) continue
    items.push({
      name: attr,
      content: isArray(page[attr]) ? page[attr].join(', ') : page[attr]
    })
  }
  if (this.config.theme && this.config.theme.autoInsert.meta) {
    await runHook(`${this.ns}.${theme.name}:beforeInjectMeta`, { meta: theme.meta, items, req })
    items.push(...(theme.meta ?? []))
    await runHook(`${this.ns}.${theme.name}:afterInjectMeta`, { meta: theme.meta, items, req })
  }
  // title
  const format = get(this, 'app.waibuMpa.pageTitleFormat', '%s : %s')
  const title = page.fullTitle ?? page.title
  let pageTitle
  if (isFunction(format)) pageTitle = await format.call(this, locals)
  else pageTitle = sprintf(format, title, this.app.waibuMpa.getAppTitle(req))
  $('head').append(`<title>${pageTitle}</title>`)
  // favicon
  const favicon = this.app.waibu.config.favicon
  if (favicon) {
    const mime = await importPkg('waibu:mime')
    const ext = favicon === true ? '.png' : path.extname(favicon)
    const type = mime.getType(ext)
    items.push({ tag: 'link', href: `/favicon${ext}`, rel: 'icon', type })
  }
  // meta
  items = map(items, m => {
    const tag = m.tag ?? 'meta'
    delete m.tag
    const attrs = this.stringifyAttribs(m)
    return `<${tag} ${attrs} />`
  })
  $('head').append(uniq(items).join('\n'))
}

/**
 * Inject CSS into the page
 *
 * @memberof module:Helper
 * @async
 * @param {Object} [options={}] - Options for CSS injection
 * @param {Object} [options.$] - Cheerio instance for DOM-like manipulation
 */
async function injectCss (options = {}) {
  const { $ } = options
  const regular = await collectRegular.call(this, 'css', printLink, options)
  if (regular.length > 0) $('head').append(regular.join('\n'))
  const inline = await collectInline.call(this, 'inlineCss', options)
  if (inline.length > 0) $('head').append(`<style>\n${inline.join('\n')}\n</style>`)
}

/**
 * Inject link elements into the page
 *
 * @memberof module:Helper
 * @async
 * @param {Object} [options={}] - Options for link injection
 * @param {Object} [options.$] - Cheerio instance for DOM-like manipulation
 */
async function injectLink (options = {}) {
  const { $ } = options
  const regular = await collectRegular.call(this, 'links', printLink, options)
  if (regular.length > 0) $('head').append(regular.join('\n'))
}

/**
 * Inject elements into the page
 *
 * @memberof module:Helper
 * @async
 * @param {Object} [options={}] - Options for element injection
 * @param {Object} [options.$] - Cheerio instance for DOM-like manipulation
 * @param {Object} [options.req] - Request object
 * @param {Object} [options.cmp] - Component instance
 * @param {Object} [options.reply] - Reply object
 */
export async function injectElements (options = {}) {
  const { $, req, cmp, reply } = options
  const { isClass } = this.app.lib.aneka
  const { runHook } = this.app.bajo
  const { get, isString, isFunction, isEmpty } = this.app.lib._
  $('html').attr('lang', req.lang)
  if (req.darkMode) $('body').attr('data-bs-theme', 'dark')
  const rsc = {}
  for (const tag of reply.ctags ?? []) {
    let Builder = get(cmp, `widget.${tag}`)
    if (!isFunction(Builder)) continue
    if (!isClass(Builder)) Builder = await Builder.call(cmp)
    for (const key of ['links', 'scripts', 'css']) {
      let item = Builder[key] ?? []
      if (isString(item)) item = [item]
      if (isFunction(item)) item = await item.call(cmp, Builder)
      if (item.length > 0) {
        rsc[key] = rsc[key] ?? []
        rsc[key].push(...item)
      }
    }
    for (const key of ['inlineScript', 'inlineCss']) {
      let item = Builder[key] ?? ''
      if (isFunction(item)) item = await item.call(cmp, Builder)
      if (!isEmpty(item)) {
        rsc[key] = rsc[key] ?? []
        rsc[key].push(item)
      }
    }
  }
  for (const key of ['links', 'scripts', 'css']) {
    options[key] = rsc[key] ?? []
  }
  options.inlineScript = rsc.inlineScript
  options.inlineCss = rsc.inlineCss
  await runHook(`${this.ns}.injectElement:beforeBuildPage`, options)
  await injectMeta.call(this, options)
  await injectLink.call(this, options)
  await injectCss.call(this, options)
  await injectScript.call(this, options)
}

/**
 * Add an item to the container
 *
 * @memberof module:Helper
 * @async
 * @param {Object} [options={}] - Options for adding an item
 * @param {string} [options.file] - File path of the item
 * @param {Object} [options.container] - Container to store the item
 * @param {string} [options.prefix] - Prefix for the item key
 */
async function addItem ({ file, container, prefix = '' } = {}) {
  const { importModule } = this.app.bajo
  const { camelCase } = this.app.lib._
  const baseName = path.basename(file, path.extname(file))
  const key = camelCase(`${prefix} ${baseName}`)
  const mod = await importModule(file)
  container[key] = mod
}

/**
 * Load items into the theme
 *
 * @memberof module:Helper
 * @async
 * @param {string} type - Type of items to load
 * @param {Object} theme - Theme object to load items into
 */
async function loadItems (type, theme) {
  const { eachPlugins } = this.app.bajo
  const { fastGlob } = this.app.lib
  const me = this

  const container = {}
  // main
  const files = await fastGlob(`${this.dir.pkg}/extend/waibuMpa/theme/component/${type}/*.js`)
  for (const file of files) {
    await addItem.call(this, { file, container })
  }
  // extender
  await eachPlugins(async function ({ file }) {
    await addItem.call(me, { file, container, prefix: this.alias })
  }, { glob: `theme/component/${type}/*.js`, prefix: this.ns })
  theme[type] = container
}

/**
 * Build a theme
 *
 * @memberof module:Helper
 * @async
 * @param {Object} mod - Module object
 * @param {Object} fw - Framework object
 */
async function buildTheme (mod, fw) {
  const { runHook } = this.app.bajo
  const { omit, merge, find } = this.app.lib._
  const me = this

  await runHook(`${this.ns}.${mod.name}:beforeCollectTheme`, mod, fw)
  const Theme = await themeFactory.call(this)
  const theme = new Theme(this, mod.name)
  Object.assign(theme, omit(mod, ['ns']))
  await loadItems.call(mod.plugin, 'widget', theme)
  await loadItems.call(mod.plugin, 'method', theme)
  theme.createComponent = async function ({ $, iconset, req, reply, locals, scriptBlock, styleBlock }) {
    const Cls = await componentFactory.call(me)
    const cmp = new Cls({ plugin: fw ? fw.plugin : mod.plugin, $, theme, iconset, req, reply, locals, scriptBlock, styleBlock })
    await cmp.loadBaseWidgets()
    const framework = theme.framework ? find(me.themes, { name: theme.framework }) : undefined
    merge(cmp.widget, framework ? framework.widget : {}, theme.widget)
    Object.assign(cmp, merge({}, framework ? framework.method : {}, theme.method))
    return cmp
  }
  this.themes.push(theme)
  this.log.trace('- %s@%s', theme.name, mod.name)
  await runHook(`${this.ns}.${mod.name}:afterCollectTheme`, theme)
}

/**
 * Collect themes
 *
 * @memberof module:Helper
 * @async
 * @returns {Promise<void>}
 */
export async function collectThemes () {
  const { eachPlugins, importModule } = this.app.bajo
  const { isFunction, isArray, find, without } = this.app.lib._

  this.themes = []
  if (this.config.theme === false) {
    this.log.warn('supportDisabled%s', this.t('Theme'))
    return
  }
  this.log.debug('collect%s', this.t('themes'))
  const modso = []
  const modsf = []
  const me = this
  await eachPlugins(async function ({ file }) {
    const plugin = this
    let mod = await importModule(file)
    if (isFunction(mod)) mod = await mod.call(this, me.webAppCtx)
    if (!isArray(mod)) mod = [mod]
    mod = mod.map(m => Object.assign(m, { plugin }))
    for (const m of mod) {
      m.meta = m.meta ?? []
      if (!isArray(m.meta)) m.meta = [m.meta]
      m.metaExcludes = m.metaExcludes ?? []
      if (!isArray(m.metaExcludes)) m.metaExcludes = [m.metaExcludes]
      for (const key of ['css', 'links', 'scripts', 'cssExcludes', 'linksExcludes', 'scriptsExcludes']) {
        m[key] = await me._loadResource.call(this, m, key)
      }
      m.component = {}
      if (m.framework) modsf.push(m) // module needs framework
      else modso.push(m) // independent module
    }
  }, { glob: 'theme.js', prefix: this.ns })
  for (const mod of modso) {
    await buildTheme.call(this, mod)
  }
  for (const mod of modsf) {
    const fw = find(modso, { name: mod.framework })
    if (!fw) throw this.error('cantFindThemeFramework%s', mod.framework)
    for (const key of ['meta', 'css', 'links', 'scripts']) {
      mod[key].push(...without(fw[key], ...mod[`${key}Excludes`]))
    }
    mod.moveToEnd = fw.moveToEnd
    await buildTheme.call(this, mod, fw)
  }
}

/**
 * Collect view engines
 *
 * @memberof module:Helper
 * @async
 * @returns {Promise<void>}
 */
export async function collectViewEngines () {
  const { eachPlugins, importModule, join, runHook } = this.app.bajo
  const { isFunction, omit, isArray } = this.app.lib._
  this.viewEngines = []
  this.log.debug('collect%s', 'view engines')
  const me = this
  await eachPlugins(async function ({ file }) {
    const { ns } = this
    let mod = await importModule(file)
    if (isFunction(mod)) mod = await mod.call(this, me.webAppCtx)
    if (!isArray(mod)) mod = [mod]
    for (const m of mod) {
      await runHook(`${me.ns}.${mod.name}:beforeCollectViewEngine`, m)
      const Cls = await viewEngineFactory.call(me)
      const ve = new Cls(this, m.name, m.fileExts)
      Object.assign(ve, omit(m, ['name', 'fileExts']))
      me.viewEngines.push(ve)
      me.log.trace('- %s@%s (%s)', ve.name, ns, join(ve.fileExts))
      await runHook(`${me.ns}.${mod.name}:afterCollectViewEngine`, ve)
    }
  }, { glob: 'view-engine.js', prefix: this.ns })
}

/**
 * Collect iconsets
 *
 * @memberof module:Helper
 * @async
 * @returns {Promise<void>}
 */
export async function collectIconsets () {
  const { eachPlugins, importModule, runHook } = this.app.bajo
  const { omit, isFunction, isArray, pullAt, findIndex, cloneDeep } = this.app.lib._

  this.iconsets = []
  if (this.config.iconset === false) {
    this.log.warn('supportDisabled%s', this.t('Iconset'))
    return
  }
  this.log.debug('collect%s', this.t('iconsets'))
  const me = this
  const all = []
  await eachPlugins(async function ({ file }) {
    const { ns } = this
    let mod = await importModule(file)
    if (isFunction(mod)) mod = await mod.call(this, me.webAppCtx)
    if (!isArray(mod)) mod = [mod]
    for (const m of mod) {
      for (const key of ['css', 'links', 'scripts']) {
        m[key] = await me._loadResource(m, key)
      }
      m.ns = ns
      all.push(m)
    }
  }, { glob: 'iconset.js', prefix: this.ns })
  const defIdx = findIndex(all, { name: this.config.iconset.default })
  if (defIdx > -1) {
    const def = cloneDeep(all[defIdx])
    pullAt(all, defIdx)
    all.unshift(def)
  }
  for (const a of all) {
    await runHook(`${this.ns}.${a.ns}:beforeCollectIconset`, a)
    const Cls = await iconsetFactory.call(this)
    const iconset = new Cls(this.app[a.ns], omit(a, ['ns']))
    this.iconsets.push(iconset)
    this.log.trace('- %s@%s', iconset.name, iconset.plugin.ns)
    await runHook(`${this.ns}.${a.ns}:afterCollectIconset`, iconset)
  }
}