waibu-mpa
Version:
MPA support for Waibu Framework
326 lines (304 loc) • 13 kB
JavaScript
import baseFactory from './base-factory.js'
import path from 'path'
/**
* Represents a Component that handles dynamic tag building, rendering, and other utilities.
*/
class Component {
/**
* Creates a new Component instance.
* @param {Object} [options={}] - The options for the component.
* @param {Object} options.plugin - The plugin instance.
* @param {Object} options.$ - The jQuery-like instance.
* @param {Object} options.theme - The theme configuration.
* @param {Object} options.iconset - The iconset configuration.
* @param {Object} options.locals - The local variables.
* @param {Object} options.reply - The reply object.
* @param {Object} options.req - The request object.
*/
constructor ({ plugin, $, theme, iconset, locals, reply, req, scriptBlock, styleBlock } = {}) {
const { get } = plugin.app.bajo.lib._
this.baseFactory = baseFactory
this.plugin = plugin
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.plugin.lib._
const { importModule } = this.plugin.app.bajo
const { fastGlob } = this.plugin.lib
const pattern = `${this.plugin.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.plugin.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.plugin.lib
const { compile } = this.plugin.app.bajoTemplate
const { isEmpty, merge, uniq, without } = this.plugin.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 ?? ''
if (params.attr.content.includes('%s')) params.html = sprintf(params.attr.content, params.html)
else if (isEmpty(params.html)) params.html = params.attr.content
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.plugin.lib._
const { generateId } = this.plugin.app.bajo
params.attr = params.attr ?? {}
params.attr.class = this.plugin.app.waibuMpa.attrToArray(params.attr.class)
params.attr.style = this.plugin.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.plugin.lib._
const { attribsStringify } = this.plugin.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 = attribsStringify(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.plugin.app.waibuMpa
const { merge } = this.plugin.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.plugin.lib._
const { attrToArray } = this.plugin.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 { buildUrl } = this.plugin.app.waibuMpa
url = url ?? this.req.referer ?? this.req.url
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.plugin.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.plugin.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