lib_build-page.js

import path from 'path'
import { printLink, printScript, injectElements } from './helper.js'

/**
 * Print warning message in page
 *
 * @memberof module:Helper
 * @param {string} text
 * @param {string} message
 * @param {Object} [opts={}]
 * @returns {string}
 */
function alert (text, message, opts = {}) {
  if (opts.partial) throw this.error(message)
  if (!this.config.page.insertWarning) return text
  const error = `<p class="warning">${message}</p>`
  return `${text}\n${error}`
}

const reqAsset = {}

/**
 * Replace unknown component tag with div or comment
 *
 * @async
 * @memberof module:Helper
 * @param {Object} options
 * @param {Object} options.el - Cheerio element
 * @param {Object} options.cmp - Component instance
 * @param {Object} [options.opts={}] - Options
 * @returns {Promise<void>}
 */
async function replace (options = {}) {
  const { el, cmp = {}, opts = {} } = options
  const { parseAttribs, stringifyAttribs } = this.app.waibuMpa
  const { isEmpty, camelCase } = this.app.lib._

  const tag = camelCase(el.name.slice(cmp.namespace.length))
  const html = cmp.$(el).html()
  const attr = parseAttribs(el.attribs)
  attr.octag = tag
  const params = { tag, attr, html, el, opts }
  const result = await cmp.buildTag(params)
  if (result) {
    cmp.$(el).replaceWith(result)
    return
  }
  if (isEmpty(attr.class)) delete attr.class
  if (isEmpty(attr.style)) delete attr.style
  switch (this.config.theme.component.unknownTag) {
    case 'remove': cmp.$(el).remove(); break
    case 'comment': {
      const ohtml = cmp.$(el).html().replaceAll('<!-- unknown component ', '').replaceAll(' -->', '')
      let attrs = stringifyAttribs(attr)
      if (!isEmpty(attrs)) attrs = ' ' + attrs
      cmp.$(el).replaceWith(`<!-- unknown component <${el.name}${attrs}>${ohtml}</${el.name}> -->`)
      break
    }
    default: {
      const result = await cmp.render({ tag: 'div', attr, html: params.html })
      cmp.$(el).replaceWith(result)
    }
  }
}

/**
 * Replace unknown component tag with div or comment
 *
 * @async
 * @memberof module:Helper
 * @param {Object} options
 * @param {Object} options.el - Cheerio element
 * @param {Object} options.cmp - Component instance
 * @param {Object} [options.opts={}] - Options
 * @returns {Promise<void>}
 */
async function replaceTag (options = {}) {
  const { el, cmp = {}, opts = {} } = options
  const me = this
  const children = cmp.$(el).children()
  if (children.length > 0) {
    for (const child of children) {
      await replaceTag.call(this, { el: child, cmp, opts })
      if (child.name.startsWith(cmp.namespace)) {
        await replace.call(me, { el: child, cmp, opts })
      }
    }
  }
}

/**
 * Apply include directive in the page
 *
 * @memberof module:Helper
 * @param {Object} $ - Cheerio instance
 * @returns {string}
 */
function applyInclude ($) {
  const { unescape } = this.app.waibu
  const { forOwn } = this.app.lib._
  // c:include hacks
  const incs = {}
  $('div[c=include], div[c=page-start], div[c=page-end]').each(function () {
    incs[this.attribs.id] = {
      prepend: this.attribs.prepend,
      append: this.attribs.append,
      html: $(this).html()
    }
    $(this).replaceWith(this.attribs.id)
  })
  let text = $.root().html()
  forOwn(incs, (v, k) => {
    text = text.replace(k, `${unescape(v.prepend ?? '')}${v.html}${unescape(v.append ?? '')}`)
  })
  return text
}

/**
 * Parse attributes of an element and apply transformations
 *
 * @async
 * @memberof module:Helper
 * @param {Object} options
 * @param {Object} options.$ - Cheerio instance
 * @param {Object} options.el - Cheerio element
 * @param {Object} options.req - Request object
 */
async function parseAttr (options = {}) {
  const { $, el, req } = options
  const { routePath } = this.app.waibu
  const { map, isString, isEmpty } = this.app.lib._
  const { attrToArray } = this.app.waibu

  // const ns = get(req, 'routeOptions.config.ns')
  const deleted = []
  el.attribs = el.attribs ?? {}
  const parent = $(el).parent()
  for (const key in el.attribs) {
    const val = el.attribs[key]
    if (val === 'undefined') {
      deleted.push(key)
      continue
    }
    // parse urls
    if (!(parent.length > 0 && ['code', 'pre'].includes(parent[0].name)) &&
      (['href', 'src', 'action'].includes(key) || key.includes('-href')) &&
      !(val.slice(1, 3) === '%=' && val[val.length - 2] === '%')) {
      el.attribs['o' + key] = val
      try {
        let [link, params] = val.split('|')
        if (!isEmpty(params)) params = this.parseAttribs(params, { trimValue: false, delimiter: ';' })
        el.attribs[key] = routePath(link, { params })
      } catch (err) {
      }
    }
    // translate
    if (key.slice(0, 2) === 't:') {
      const value = map(attrToArray(val, '|'), (v, idx) => {
        if (idx > 0) v = req.t(v)
        return v
      })
      el.attribs[key.slice(2)] = req.t(...value)
      delete el.attribs[key]
    }
    // open modal/offcanvas etc
    if (isString(el.attribs[key])) {
      const val = el.attribs[key]
      if (key === 'open') {
        const [id, type = 'modal'] = val.split(':')
        el.attribs['data-bs-target'] = `#${id}`
        el.attribs['data-bs-toggle'] = type === 'drawer' ? 'offcanvas' : type
        el.attribs['aria-controls'] = id
        delete el.attribs[key]
      } else if (key === 'open-tab') {
        el.attribs.onclick = `window.open('${val}', '_blank')`
      } else if (key === 'open-url') {
        el.attribs.onclick = `window.location.href = '${val}'`
      } else if (key === 'open-fn') {
        let [action, ...args] = val.split(':')
        let [ns, method] = action.split('.')
        if (!method) {
          method = ns
          ns = 'window'
        }
        args = args.map(item => `'${item}'`)
        el.attribs.onclick = `${ns}['${method}'](${args.join(', ')}); document.body.click()`
      }
    }
  }
  if (deleted.length > 0) {
    for (const d of deleted) {
      delete el.attribs[d]
    }
  }
}

/**
 * Mutate attributes of an element and its children
 *
 * @async
 * @memberof module:Helper
 * @param {Object} options
 * @param {Object} options.$ - Cheerio instance
 * @param {Object} options.el - Cheerio element
 * @param {Object} options.req - Request object
 */
async function attrsMutation (options = {}) {
  const { $, el, req } = options
  const me = this
  const children = $(el).children()
  if (children.length > 0) {
    for (const child of children) {
      await attrsMutation.call(me, { $, el: child, req })
      await parseAttr.call(me, { $, el: child, req })
    }
  }
}

/**
 * Manipulate the DOM based on specific attributes and move elements as needed
 *
 * @async
 * @memberof module:Helper
 * @param {Object} options
 * @param {Object} options.$ - Cheerio instance
 * @param {Object} options.theme - Theme object
 */
async function manipulation (options = {}) {
  const { $, theme } = options
  const { isEmpty } = this.app.lib._
  const actions = ['move-to:replaceWith', 'append-to:append', 'prepend-to:prepend', 'before', 'after']
  for (const action of actions) {
    const [key, method] = action.split(':')
    $(`[${key}]`).each(function () {
      const target = this.attribs[key]
      delete this.attribs[key]
      $(`${target}`)[method ?? key]($(this))
    })
  }
  // force move all drawer, modal before </body>
  if (!isEmpty(theme.moveToEnd)) {
    $(theme.moveToEnd).each(function () {
      $('body').append(this)
    })
  }
  // force all alpine scripts to move
  const allScripts = []
  for (const item of ['initializing', 'init']) {
    const scripts = []
    $(`script[type="alpine:${item}"]`).each(function () {
      scripts.push($(this).prop('innerHTML'))
      $(this).remove()
    })
    if (scripts.length > 0) {
      scripts.unshift(`document.addEventListener("alpine:${item}", () => {`)
      scripts.push('})')
      allScripts.push(...scripts)
    }
  }
  if (allScripts.length > 0) {
    allScripts.unshift('<script>')
    allScripts.push('</script>')
    $('body').append(allScripts.join('\n'))
  }
}

/**
 * Apply concatenation of resources (CSS, JS) based on configuration and cache results
 *
 * @async
 * @memberof module:Helper
 * @param {Object} options
 * @param {Object} options.$ - Cheerio instance
 * @param {Object} options.req - Request object
 * @param {string} options.tag - HTML tag
 * @param {string} options.attr - Attribute name
 * @param {string} options.type - Resource type
 * @returns {Promise<void>}
 */
async function applyConcatRsc (options = {}) {
  const { $, req, tag, attr, type } = options
  const { includes } = this.app.lib.aneka
  const { routePath } = this.app.waibu
  const { hash, fetch } = this.app.bajoExtra
  const { isEmpty, map, without } = this.app.lib._
  const { get: getCache, set: setCache } = this.app.bajoCache ?? {}
  const prefix = this.app.bajoCache.config.exportPrefix
  const excluded = map(this.config.concatResource.excluded, item => routePath(item))
  const baseUrl = `${req.protocol}://${req.hostname}${req.port ? `:${req.port}` : ''}` // TODO: auth, if any
  const items = []
  $(`${tag}[${attr}]`).each(function () {
    items.push(this.attribs[attr])
  })
  if (items.length === 0) return
  let keys = []
  const notKeys = []
  const key = `${prefix}-waibu-mpa-concat-resource-${await hash(items)}`
  const cached = await getCache({ key })
  if (!cached) {
    const contents = []
    for (const item of items) {
      try {
        if (excluded.includes(item)) throw this.error('excludedFromRscConcat%s', item)
        let url = item
        if (!item.startsWith('http')) {
          const u = new URL(req.url, baseUrl)
          if (item[0] === '/') url = u.origin + url
          else url = u.origin + path.dirname(u.pathname) + '/' + url
        }
        const resp = await fetch(url, undefined, { rawResponse: true })
        if (!resp.ok) throw this.error('respError%s', resp.status)
        const text = await resp.text()
        if (type === 'css' && includes(['url(".', 'url(.', 'url(\'.'], text)) throw this.error('cssContainsRelPath%s', item)
        contents.push(`/* waibu resource: ${item} */`, text)
        keys.push(item)
      } catch (err) {
        notKeys.push(item)
      }
    }
    if (isEmpty(contents)) return
    $(map(keys, k => `${tag}[${attr}="${k}"]`).join(',')).remove()
    const value = { contents, notKeys, type, tag, attr }
    await setCache({ key, value, ttl: this.config.concatResource.ttlDur })
  } else {
    keys = without(items, ...cached.notKeys)
    $(map(keys, k => `${tag}[${attr}="${k}"]`).join(',')).remove()
  }
  const rsc = `bajoCache:/external/${key.slice(prefix.length + 1)}.${type}`
  if (tag === 'link') $('head').prepend(printLink.call(this, rsc))
  else if (tag === 'script') $('body').append(printScript.call(this, rsc))
}

/**
 * Concatenate resources (CSS, JS) based on configuration and cache results
 *
 * @async
 * @memberof module:Helper
 * @param {Object} options
 * @param {Object} options.$ - Cheerio instance
 * @param {Object} options.req - Request object
 * @returns {Promise<void>}
 */
async function concatResources (options = {}) {
  const { $, req } = options
  if (!(this.app.bajoExtra && this.app.bajoCache)) return
  if (this.config.concatResource.ttlDur === 0) return
  if (this.config.concatResource.css) await applyConcatRsc.call(this, { $, req, tag: 'link', attr: 'href', type: 'css' })
  if (this.config.concatResource.links) await applyConcatRsc.call(this, { $, req, tag: 'link', attr: 'href', type: 'css' })
  if (this.config.concatResource.scripts) await applyConcatRsc.call(this, { $, req, tag: 'script', attr: 'src', type: 'js' })
}

/**
 * Build the final HTML page by processing the template, applying themes, iconsets, and manipulating the DOM as needed
 *
 * @async
 * @memberof module:Helper
 * @param {Object} options
 * @param {string} options.text - HTML content
 * @param {Object} options.locals - Local variables
 * @param {Object} options.opts - Options
 * @returns {Promise<string>}
 */
async function buildPage (options = {}) {
  let { text, locals = {}, opts = {} } = options
  const { find, cloneDeep } = this.app.lib._
  const { runHook, importPkg } = this.app.bajo
  const cheerio = await importPkg('bajoExtra:cheerio')
  const { req, reply } = opts
  if (!reqAsset[req.id]) {
    reqAsset[req.id] = {
      scriptBlock: { root: [] },
      styleBlock: { root: [] }
    }
  }
  const theme = find(this.themes, { name: opts.theme })
  if (!theme) text = alert.call(this, text, req.t('unknown%s%s', req.t('theme'), req.theme), opts)
  const iconset = find(this.iconsets, { name: opts.iconset })
  if (!iconset) text = alert.call(this, text, req.t('unknown%s%s', req.t('iconset'), req.iconset), opts)
  const partial = opts.partial
  const ns = req.ns
  let $ = cheerio.load(text, this.config.cheerio.loadOptions, false)
  if (partial) $('c\\:page-start, c\\:page-end').remove()
  await runHook(`${this.ns}${partial ? '.partial' : ''}:beforeBuildPage`, { $, theme, iconset, req, reply, locals, ns, text })
  await attrsMutation.call(this, { $, el: $.root(), req })
  const cmp = await theme.createComponent({ $, iconset, req, reply, locals: cloneDeep(locals), scriptBlock: reqAsset[req.id].scriptBlock, styleBlock: reqAsset[req.id].styleBlock })
  await replaceTag.call(this, { el: $.root(), cmp, opts })
  text = applyInclude.call(this, $)
  if (!opts.partial) {
    $ = cheerio.load(text, this.config.cheerio.loadOptions, false)
    await manipulation.call(this, { $, el: $.root(), req, theme })
    await injectElements.call(this, { $, cmp, theme, iconset, req, locals, reply, opts })
    await concatResources.call(this, { $, theme, iconset, req })
    await runHook(`${this.ns}:afterBuildPage`, { $, theme, iconset, req, reply, locals, ns, text })
    text = $.root().html()
    delete reqAsset[req.id]
  }
  return text
}

export default buildPage