UNPKG

waibu-mpa

Version:

MPA support for Waibu Framework

350 lines (327 loc) 14.6 kB
import path from 'path' async function componentFactory () { class MpaComponent extends this.app.baseClass.MpaTools { constructor ({ plugin, $, theme, iconset, locals, reply, req, scriptBlock, styleBlock } = {}) { super(plugin) const { get } = plugin.app.lib._ this.$ = $ this.theme = theme this.iconset = iconset this.locals = locals this.reply = reply this.req = req this.ttlDur = get(plugin, 'app.waibuMpa.config.theme.component.ttlDur', 0) this.namespace = 'c:' this.noTags = [] this.scriptBlock = scriptBlock this.styleBlock = styleBlock this.widget = {} } 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 */ loadBaseWidgets = 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/widget/*.js` const files = await fastGlob(pattern) for (const file of files) { const mod = await importModule(file) const name = camelCase(path.basename(file, '.js')) this.widget[name] = await mod.call(this.plugin) } } /** * Retrieves a widget 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. */ getWidget = async (method) => { const { isClass } = this.app.lib.aneka let Widget = this.widget[method] if (!isClass(Widget)) Widget = await Widget.call(this.plugin) return Widget } /** * 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.widget[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 this.reply.ctags = this.reply.ctags ?? [] if (!this.reply.ctags.includes(params.ctag)) this.reply.ctags.push(params.ctag) 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 || params.attr.noEscape) ? html : escape(html) await this.iconAttr(params, method) await this.beforeBuildTag(method, params) const Widget = await this.getWidget(method) const widget = new Widget({ component: this, params, options: opts }) const resp = await widget.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, opts) const merged = merge({}, params.locals, { attr: params.attr }) const result = await compile(params.html, merged, { ttl: this.ttlDur }) params.html = result return await this.render(params, opts) } /** * 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 const { attrToArray, attrToObject } = this.app.waibu params.attr = params.attr ?? {} params.attr.class = attrToArray(params.attr.class) params.attr.style = 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.widget[method]) { // static method on Widget const Widget = await this.getWidget(method) if (Widget.after) { html = (await Widget.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 Widget = this.widget.icon const widget = new Widget({ component: this, params: _params }) await widget.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 = async (params) => { const { isString, has, omit, find, isPlainObject, isArray, pick, camelCase, get, isEmpty } = this.app.lib._ const { attrToArray } = this.app.waibu const { getMethod, callHandler } = this.app.bajo let prop = {} const schema = get(this, 'locals.schema') if (schema) prop = find(schema.properties, { name: params.attr.name }) ?? {} if (!has(params.attr, 'options')) return const items = [] let input = params.attr.options let values = attrToArray(params.attr.dataValue ?? params.attr.value, '|') if (!has(params.attr, 'multiple')) { if (values.length > 0) values = [values[0]] else if (prop.default) values = [prop.default] } let options = [] if (isString(input)) { if (getMethod(input, false)) { input = await callHandler(input) } else { const separator = isString(params.attr.optionsSeparator) ? params.attr.optionsSeparator : ';' input = attrToArray(input, separator).map(item => { const [value, text] = item.split(':') return { value, text } }) } } if (isPlainObject(input)) { for (const key in input) { options.push({ value: key, text: (input[key] + '') }) } } else if (isArray(input)) { options = input.map(item => { if (isPlainObject(item)) return pick(item, ['value', 'text']) return { value: item, text: (item + '') } }) } if (!isEmpty(prop)) options.unshift({ value: '', text: '_blank_' }) for (const opt of options) { const sel = find(values, v => opt.value === v) const ttext = camelCase(`${prop.name} ${opt.text}`) items.push(`<option value="${opt.value}"${sel ? ' selected' : ''}>${this.req.te(ttext) ? this.req.t(ttext) : this.req.t(opt.text)}</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 = {}) => { const { get, merge } = this.app.lib._ 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, theme: get(this, 'theme.name', 'default'), iconset: get(this, 'iconset.name', 'default') } let html = await renderString(sentence, merge({}, this.locals, 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 = async (tag, params) => {} /** * Hook executed after building a tag. * @param {string} tag - The tag name. * @param {Object} params - The parameters for the tag. */ afterBuildTag = async (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) } } } this.app.baseClass.MpaComponent = MpaComponent return MpaComponent } export default componentFactory