lib_class_component.js

import baseFactory from './base-factory.js'
import path from 'path'

class Component {
  constructor ({ plugin, $, theme, iconset, locals, reply, req, scriptBlock, styleBlock } = {}) {
    const { get } = plugin.app.lib._
    this.baseFactory = baseFactory
    this.plugin = plugin
    this.app = plugin.app
    this.$ = $
    this.theme = theme
    this.iconset = iconset
    this.locals = locals
    this.reply = reply
    this.req = req
    this.cacheMaxAge = get(plugin, 'app.waibuMpa.config.theme.component.cacheMaxAgeDur', 0)
    this.namespace = 'c:'
    this.noTags = []
    this.scriptBlock = scriptBlock
    this.styleBlock = styleBlock
    this.factory = {}
  }

  addScriptBlock = (type, block) => {
    if (!block) {
      block = type
      type = 'root'
    }
    if (typeof block === 'string') block = [block]
    this.scriptBlock[type] = this.scriptBlock[type] ?? []
    this.scriptBlock[type].push(...block)
  }

  addStyleBlock = (type, block) => {
    if (!block) {
      block = type
      type = 'root'
    }
    if (typeof block === 'string') block = [block]
    this.styleBlock[type] = this.styleBlock[type] ?? []
    this.styleBlock[type].push(...block)
  }

  /**
   * Loads base factories dynamically from the file system.
   * @async
   */

  loadBaseFactories = async () => {
    const { camelCase } = this.app.lib._
    const { importModule } = this.app.bajo
    const { fastGlob } = this.app.lib
    const pattern = `${this.app.waibuMpa.dir.pkg}/lib/class/factory/*.js`
    const files = await fastGlob(pattern)
    for (const file of files) {
      const mod = await importModule(file)
      const name = camelCase(path.basename(file, '.js'))
      this.factory[name] = mod
    }
  }

  /**
   * Retrieves a builder class or instance for a specific method.
   * @async
   * @param {string} method - The method name to retrieve the builder for.
   * @returns {Promise<Function|Object>} The builder class or instance.
   */
  getBuilder = async (method) => {
    const { isClass } = this.app.lib.aneka
    let Builder = this.factory[method]
    if (!isClass(Builder)) Builder = await Builder.call(this)
    return Builder
  }

  /**
   * Determines the method to use based on the provided parameters.
   * @param {Object} params - The parameters containing the method information.
   * @returns {string} The determined method name.
   */
  getMethod = (params) => {
    let method = params.ctag
    if (!this.factory[method]) method = 'any'
    return method
  }

  /**
   * Builds a tag with the given parameters and options.
   * @async
   * @param {Object} [params={}] - The parameters for the tag.
   * @param {Object} [opts={}] - Additional options for building the tag.
   * @returns {Promise<string|boolean>} The rendered HTML or `false` if the build fails.
   */
  buildTag = async (params = {}, opts = {}) => {
    const { sprintf } = this.app.lib
    const { compile } = this.app.bajoTemplate
    const { escape } = this.app.waibu
    const { isEmpty, merge, uniq, without } = this.app.lib._
    params.ctag = params.tag
    const method = this.getMethod(params)
    if (opts.attr) params.attr = merge({}, opts.attr, params.attr)
    this.normalizeAttr(params)
    params.attr.content = params.attr.content ?? ''
    let html
    if (params.attr.content.includes('%s')) html = sprintf(params.attr.content, params.html)
    else if (isEmpty(params.html)) html = params.attr.content
    if (!isEmpty(html)) params.html = params.noEscape ? html : escape(html)
    await this.iconAttr(params, method)
    await this.beforeBuildTag(method, params)
    const Builder = await this.getBuilder(method)
    const builder = new Builder({ component: this, params })
    const resp = await builder.build()
    if (resp === false) {
      return false
    }

    await this.afterBuildTag(method, params)
    params.attr.class = without(uniq(params.attr.class), undefined, null, '')
    if (isEmpty(params.attr.class)) delete params.attr.class
    if (isEmpty(params.attr.style)) delete params.attr.style
    if (isEmpty(params.html)) return await this.render(params)

    const merged = merge({}, params.locals, { attr: params.attr })
    const result = await compile(params.html, merged, { ttl: this.cacheMaxAge })
    params.html = result
    return await this.render(params)
  }

  /**
   * Normalizes the attributes of the given parameters.
   * @param {Object} [params={}] - The parameters containing attributes to normalize.
   * @param {Object} [opts={}] - Additional options for normalization.
   */
  normalizeAttr = (params = {}, opts = {}) => {
    const { without, keys, isString, isArray } = this.app.lib._
    const { generateId } = this.app.lib.aneka
    params.attr = params.attr ?? {}
    params.attr.class = this.app.waibuMpa.attrToArray(params.attr.class)
    params.attr.style = this.app.waibuMpa.attrToObject(params.attr.style)
    if (isString(opts.cls)) params.attr.class.push(opts.cls)
    else if (isArray(opts.cls)) params.attr.class.push(...opts.cls)
    if (opts.tag) params.tag = opts.tag
    if (opts.autoId) params.attr.id = isString(params.attr.id) ? params.attr.id : generateId('alpha')
    for (const k of without(keys(opts), 'cls', 'tag', 'autoId')) {
      params.attr[k] = opts[k]
    }
    params.html = params.html ?? ''
  }

  /**
   * Renders the final HTML for the given parameters.
   * @async
   * @param {Object} [params={}] - The parameters for rendering.
   * @returns {Promise<string>} The rendered HTML.
   */
  render = async (params = {}) => {
    const { omit, isEmpty, merge, kebabCase, isArray } = this.app.lib._
    const { stringifyAttribs } = this.app.waibuMpa
    params.attr = params.attr ?? {}

    params.tag = params.attr.tag ?? ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'].includes(params.tag) ? params.tag : kebabCase(params.tag)
    params.attr = omit(params.attr, ['tag', 'content'])
    params.html = params.html ?? ''
    params.attrs = stringifyAttribs(params.attr)
    if (!isEmpty(params.attrs)) params.attrs = ' ' + params.attrs
    let html = isArray(params.html) ? params.html.join('\n') : params.html
    if (!params.noTag) {
      html = params.selfClosing ? `<${params.tag}${params.attrs}/>` : `<${params.tag}${params.attrs}>${params.html}</${params.tag}>`
      const method = this.getMethod(params)
      if (this.factory[method]) { // static method on Builder
        const Builder = await this.getBuilder(method)
        if (Builder.after) {
          html = (await Builder.after.call(this, merge({}, params, { result: html }))) ?? html
        }
      }
    } else if (!this.noTags.includes(params.attr.octag)) this.noTags.push(params.attr.octag)
    if (params.prepend) html = `${params.prepend}${html}`
    if (params.append) html += params.append
    return html
  }

  /**
   * Processes icon-related attributes and appends them to the HTML.
   * @async
   * @param {Object} [params={}] - The parameters containing icon attributes.
   * @param {string} method - The method name.
   */
  iconAttr = async (params = {}, method) => {
    if (['modal', 'toast'].includes(method)) return
    const { groupAttrs } = this.app.waibuMpa
    const { merge } = this.app.lib._
    for (const k in params.attr) {
      const v = params.attr[k]
      if (!['icon', 'iconEnd'].includes(k)) continue
      const group = groupAttrs(params.attr, ['icon', 'iconEnd'])
      const _params = { attr: merge(k.endsWith('End') ? group.iconEnd : group.icon, { name: v }) }
      const Builder = this.factory.icon
      const builder = new Builder({ component: this, params: _params })
      await builder.build()
      const icon = await this.render(_params)
      params.html = k.endsWith('End') ? `${params.html} ${icon}` : `${icon} ${params.html}`
      delete params.attr[k]
    }
  }

  /**
   * Builds the `<option>` elements for a `<select>` tag.
   * @param {Object} params - The parameters containing options and values.
   * @returns {string} The generated `<option>` elements.
   */
  buildOptions = (params) => {
    const { has, omit, find, isPlainObject, isArray } = this.app.lib._
    const { attrToArray } = this.app.waibuMpa
    const items = []
    if (!has(params.attr, 'options')) return
    let values = attrToArray(params.attr.value)
    if (!has(params.attr, 'multiple') && values.length > 0) values = [values[0]]
    const options = isArray(params.attr.options) ? params.attr.options : attrToArray(params.attr.options)
    for (const opt of options) {
      let val
      let text
      if (isPlainObject(opt)) {
        val = opt.value
        text = opt.text
      } else [val, text] = opt.split('|')
      const sel = find(values, v => val === v)
      items.push(`<option value="${val}"${sel ? ' selected' : ''}>${this.req.t(text ?? val)}</option>`)
    }
    params.attr = omit(params.attr, ['options'])
    return items.join('\n')
  }

  /**
   * Builds a URL with the given parameters.
   * @param {Object} options - The options for building the URL.
   * @param {Array<string>} [options.exclude] - Keys to exclude from the URL.
   * @param {string} [options.prefix] - The prefix for the URL.
   * @param {string} [options.base] - The base URL.
   * @param {string} [options.url] - The URL to modify.
   * @param {Object} [options.params={}] - Query parameters to include.
   * @param {boolean} [options.prettyUrl] - Whether to generate a pretty URL.
   * @returns {string} The built URL.
   */
  buildUrl = ({ exclude, prefix, base, url, params = {}, prettyUrl }) => {
    const { isEmpty } = this.app.lib._
    const { buildUrl } = this.app.waibuMpa
    url = url ?? (isEmpty(this.req.referer) ? this.req.url : this.req.referer)
    return buildUrl({ exclude, prefix, base, url, params, prettyUrl })
  }

  /**
   * Builds a sentence with optional rendering and minification.
   * @async
   * @param {string|string[]} sentence - The sentence or array of sentences to build.
   * @param {Object} [params={}] - Parameters for rendering the sentence.
   * @param {Object} [extra={}] - Extra options such as wrapping or minification.
   * @returns {Promise<string>} The built sentence.
   */
  buildSentence = async (sentence, params = {}, extra = {}) => {
    if (Array.isArray(sentence)) sentence = sentence.join(' ')
    const { minify, renderString } = this.app.waibuMpa
    if (extra.wrapped) sentence = '<w>' + sentence + '</w>'
    const opts = {
      partial: true,
      ext: params.ext ?? '.html',
      req: this.req,
      reply: this.reply
    }
    let html = await renderString(sentence, params, opts)
    if (extra.wrapped) html = html.slice(3, html.length - 4)
    if (extra.minify) html = await minify(html)
    return html
  }

  /**
   * Hook executed before building a tag.
   * @param {string} tag - The tag name.
   * @param {Object} params - The parameters for the tag.
   */
  beforeBuildTag = (tag, params) => {}

  /**
   * Hook executed after building a tag.
   * @param {string} tag - The tag name.
   * @param {Object} params - The parameters for the tag.
   */
  afterBuildTag = (tag, params) => {}

  /**
   * Builds a child tag based on a detector attribute.
   * @async
   * @param {string} detector - The attribute to detect child tags.
   * @param {Object} options - Options for building the child tag.
   * @param {string} [options.tag] - The tag name.
   * @param {Object} options.params - The parameters for the child tag.
   * @param {string} [options.inner] - Inner HTML for the child tag.
   */
  buildChildTag = async (detector, { tag, params, inner }) => {
    const { has, pickBy, omit, keys } = this.app.lib._
    if (has(params.attr, detector)) {
      const [prefix] = detector.split('-')
      const attr = {}
      const html = tag ? params.attr[detector] : undefined
      tag = tag ?? prefix
      const picked = pickBy(params.attr, (v, k) => k.startsWith(`${prefix}-`))
      for (const k in picked) {
        attr[k.slice(prefix.length + 1)] = picked[k]
      }
      const child = await this.buildTag({ tag, params: { attr, html } })
      params.html += `\n${child}`
      const excluded = [detector, ...keys(picked)]
      params.attr = omit(params.attr, excluded)
    }
  }
}

export default Component