import { stripHtml } from 'string-strip-html'
import iconsetMappings from './lib/iconset-mappings.js'
import toolsFactory from './lib/class/tools.js'
import widgetFactory from './lib/class/widget.js'
import Store from './lib/class/store.js'
import { config, configDev, configProd } from './lib/config.js'
import path from 'path'
import { minify } from 'html-minifier-terser'
import * as prettier from 'prettier'
import * as emoji from 'node-emoji'
import buildPage from './lib/build-page.js'
// taken from: https://stackoverflow.com/questions/52928550/js-get-list-of-all-available-standard-html-tags
const tags = 'a,abbr,address,area,article,aside,audio,b,base,bdi,bdo,blockquote,body,br,button,canvas,caption,cite,code,col,colgroup,data,datalist,dd,del,details,dfn,dialog,div,dl,dt,em,embed,fieldset,figcaption,figure,footer,form,h1,h2,h3,h4,h5,h6,head,header,hgroup,hr,html,i,iframe,img,input,ins,kbd,label,legend,li,link,main,map,mark,menu,meta,meter,nav,noscript,object,ol,optgroup,option,output,p,param,picture,pre,progress,q,rp,rt,ruby,s,samp,script,section,select,slot,small,source,span,strong,style,sub,summary,sup,table,tbody,td,template,textarea,tfoot,th,thead,time,title,tr,track,u,ul,var,video,wbr'
/**
* Plugin factory
*
* @param {string} pkgName - NPM package name
* @returns {WaibuMpa} - WaibuMpa class
*/
async function factory (pkgName) {
const me = this
const {
trim, forOwn, omit, isEmpty, get, isString, filter, kebabCase, camelCase,
map, last, without, isPlainObject, isFunction, isArray, cloneDeep,
trimEnd, pick, groupBy, isNumber, isBoolean, isUndefined, isSymbol,
isNull, isDate
} = this.app.lib._
const { dayjs, outmatch, parseObject, fs, fastGlob } = this.app.lib
const { defaultsDeep, isSet, titleize } = this.app.lib.aneka
const {
breakNsPath, importPkg, importModule, readConfig, readJson, runHook, eachPlugins
} = this.app.bajo
/**
* WaibuMpa class definition.
*
* **Never** call this function directly!!! It's only-meant to be called by the {@link https://ardhi.github.io/bajo|Bajo framework} during plugin initialization.
*
* @class
*/
class WaibuMpa extends this.app.baseClass.Base {
/**
* All supported HTML tags
*
* @type {string[]}
*/
static htmlTags = tags.split(',')
/**
* Iconset mappings
*
* @type {Object}
*/
static iconsetMappings = iconsetMappings
/**
* Constructor
*/
constructor () {
super(pkgName, me.app)
/**
* @property {TConfig} - Configuration object
*/
this.config = config
/**
* @property {TConfigDev} - Development configuration object
*/
this.configDev = configDev
/**
* @property {TConfigProd} - Production configuration object
*/
this.configProd = configProd
}
/**
* Initialize the plugin
*
* @async
* @method
*/
init = async () => {
this.config.waibu.prefix = trim(this.config.waibu.prefix, '/')
await toolsFactory.call(this)
await widgetFactory.call(this)
}
/**
* Build a URL based on the provided options.
*
* @method
* @param {Object} [options={}] - Options for building the URL
* @param {Array<string>} [options.exclude=[]] - Keys to exclude from the query string
* @param {string} [options.prefix='?'] - Prefix for the query string
* @param {string} [options.base] - Base path for the URL
* @param {string} [options.url=''] - URL to build upon
* @param {Object} [options.params={}] - Parameters to include in the query string
* @param {boolean} [options.prettyUrl] - Whether to use a pretty URL format
* @returns {string} - The constructed URL
*/
buildUrl = (options = {}) => {
const { exclude = [], prefix = '?', base, url = '', params = {}, prettyUrl } = options
const { qs } = this.app.waibu
const qsKey = this.app.waibu.config.qsKey
let path
let hash
let query
[path = '', hash = ''] = url.split('#')
if (hash.includes('?')) [hash, query] = hash.split('?')
else [path, query] = path.split('?')
query = parseObject(qs.parse(query) ?? {})
forOwn(params, (v, k) => {
const key = qsKey[k] ?? k
query[key] = v
})
const id = query.id
if (prettyUrl) delete query.id
query = prefix + qs.stringify(omit(query, exclude))
if (!isEmpty(hash)) hash = '#' + hash
if (!base) return path + query + hash
const parts = path.split('/')
if (base) {
parts.pop()
parts.push(base)
}
if (prettyUrl && id) parts.push(id)
return parts.join('/') + query + hash
}
/**
* Get the title of a plugin.
*
* @method
* @param {string} name - The name of the plugin
* @param {Object} req - The request object
* @returns {string} - The title of the plugin
*/
getPluginTitle = (name, req) => {
const plugin = this.app.getPlugin(name, true)
if (!plugin) return
const text = get(plugin, 'config.waibuMpa.title', plugin.ns)
return this.t(text, { lang: req.lang })
}
/**
* Get the title of the application.
*
* @method
* @param {Object} req - The request object
* @returns {string} - The title of the application
*/
getAppTitle = (req) => {
const text = get(req, 'site.title', get(this, 'config.appTitle', this.ns))
return this.t(text, { lang: req.lang })
}
/**
* Get the resource information for a given name.
*
* @method
* @param {string} name - The name of the resource
* @returns {Object} - The resource information
*/
getResource (name) {
const subNses = ['layout', 'template', 'partial']
const { ns, path, subNs, subSubNs, qs } = breakNsPath(name)
const plugin = this.app.getPlugin(ns)
const dir = `${plugin.dir.pkg}/extend/waibuMpa`
if (!subNses.includes(subNs)) throw this.error('unknownResource%s', name)
const fullPath = subSubNs ? `${dir}/${subSubNs}/${subNs}${path}` : `${dir}/${subNs}${path}`
return { ns, subNs, subSubNs, path, qs, fullPath }
}
/**
* Get the session ID from the raw cookie.
*
* @method
* @param {string} rawCookie - The raw cookie string
* @param {boolean} secure - Whether the cookie is secure
* @returns {string} - The session ID
*/
getSessionId = (rawCookie, secure) => {
const cookieName = this.config.session.cookieName
return this.webAppCtx.parseCookie(rawCookie)[cookieName]
}
/**
* Get the view engine for a given file extension.
*
* @method
* @param {string} ext - The file extension
* @returns {Object} - The view engine
*/
getViewEngine = (ext) => {
const ve = this.viewEngines.find(v => v.fileExts.includes(ext))
return ve ?? this.viewEngines.find(v => v.name === 'default')
}
/**
* Group attributes by keys.
*
* @method
* @param {Object} [attribs={}] - The attributes to group
* @param {Array|string} [keys=[]] - The keys to group by
* @param {boolean} [removeEmpty=true] - Whether to remove empty groups
* @returns {Object} - The grouped attributes
*/
groupAttrs = (attribs = {}, keys = [], removeEmpty = true) => {
const { attrToArray, attrToObject } = this.app.waibu
if (isString(keys)) keys = [keys]
const attr = { _: {} }
for (const a in attribs) {
for (const k of keys) {
if (a === k) {
attr._[k] = attribs[a]
continue
}
attr[k] = attr[k] ?? {}
attr[k].class = attr[k].class ?? []
attr[k].style = attr[k].style ?? {}
const _k = kebabCase(k)
let name = camelCase(kebabCase(a).slice(_k.length + 1))
if (a.includes('@') || a.includes(':')) name = a.slice(_k.length + 1)
if (!kebabCase(a).startsWith(k + '-')) {
if (!keys.includes(a)) {
attr._[a] = attribs[a]
if (a === 'class' && isString(attribs[a])) attr._.class = attrToArray(attr._.class)
if (a === 'style' && isString(attribs[a])) attr._.style = attrToObject(attr._.style)
}
continue
}
attr[k][name] = attribs[a]
if (name === 'class' && isString(attribs[a])) attr[k].class = attrToArray(attr[k].class)
if (name === 'style' && isString(attribs[a])) attr[k].style = attrToObject(attr[k].style)
}
}
const deleted = filter(Object.keys(attr._), m => {
let match
m = kebabCase(m)
for (const k of keys) {
if (m.startsWith(k + '-')) match = true
}
return match
})
attr._ = omit(attr._, deleted)
for (const k of keys) {
const item = attr[k]
if (removeEmpty && !attr._[k] && Object.keys(item).length === 2 && isEmpty(item.class) && isEmpty(item.style)) delete attr[k]
}
return attr
}
/**
* Minify HTML text.
*
* @method
* @param {string} text - The HTML text to minify
* @returns {string} - The minified HTML
*/
minify = async (text) => {
const minifier = await importPkg('waibuMpa:html-minifier-terser')
return await minifier.minify(text, {
collapseWhitespace: true
})
}
/**
* Custom JSON stringify function.
*
* @method
* @param {any} obj - The object to stringify
* @param {boolean|function} [replacer] - The replacer function or true for custom handling
* @param {number|string} [space] - The space argument for JSON.stringify
* @returns {string} - The JSON string
* @see {@link https://github.com/siddharth-sunchu/native-methods/blob/master/JSONStringfy.js}
*/
jsonStringify = (obj, replacer, space) => {
if (replacer !== true) return JSON.stringify(obj, replacer, space)
const isNotNumber = (value) => {
return isNumber(value) && isNaN(value)
}
const isInfinity = (value) => {
return isNumber(value) && !isFinite(value)
}
const restOfDataTypes = (value) => {
return isNumber(value) || isString(value) || isBoolean(value)
}
const ignoreDataTypes = (value) => {
return isUndefined(value) || isFunction(value) || isSymbol(value)
}
const nullDataTypes = (value) => {
return isNotNumber(value) || isInfinity(value) || isNull(value)
}
const arrayValuesNullTypes = (value) => {
return isNotNumber(value) || isInfinity(value) || isNull(value) || ignoreDataTypes(value)
}
const removeComma = (str) => {
const tempArr = str.split('')
tempArr.pop()
return tempArr.join('')
}
if (ignoreDataTypes(obj)) {
return undefined
}
if (isDate(obj)) {
return `"${obj.toISOString()}"`
}
if (nullDataTypes(obj)) {
return `${null}`
}
if (isSymbol(obj)) {
return undefined
}
if (restOfDataTypes(obj)) {
const passQuotes = isString(obj) ? "'" : ''
const item = isString(obj) ? obj.replaceAll("'", "\\'") : obj
return `${passQuotes}${item}${passQuotes}`
}
if (isArray(obj)) {
let arrStr = ''
obj.forEach((eachValue) => {
arrStr += arrayValuesNullTypes(eachValue) ? this.jsonStringify(null, replacer, space) : this.jsonStringify(eachValue, replacer, space)
arrStr += ','
})
return '[' + removeComma(arrStr) + ']'
}
if (isPlainObject(obj)) {
let objStr = ''
const objKeys = Object.keys(obj)
objKeys.forEach((eachKey) => {
const eachValue = obj[eachKey]
if (eachKey.includes('-')) eachKey = `'${eachKey}'`
objStr += (!ignoreDataTypes(eachValue)) ? `${eachKey}:${this.jsonStringify(eachValue, replacer, space)},` : ''
})
return '{' + removeComma(objStr) + '}'
}
}
/**
* Generate pagination layout.
*
* @method
* @param {number|Object} totalItems - The total number of items or an object containing pagination info
* @param {number} [itemsPerPage] - The number of items per page
* @param {number} [currentPage] - The current page number
* @returns {Array} - The pagination layout
* @see {@link https://github.com/kyleparisi/pagination-layout/blob/master/pagination-layout-be.js}
*/
paginationLayout = (totalItems, itemsPerPage, currentPage) => {
if (isPlainObject(totalItems)) {
currentPage = totalItems.page
itemsPerPage = totalItems.limit
totalItems = totalItems.count
}
function last (array) {
const length = array == null ? 0 : array.length
return length ? array[length - 1] : undefined
}
const pages = Math.ceil(totalItems / itemsPerPage)
if (!totalItems) return []
// default pages when we only have <= 4 pages
if ([1, 2, 3, 4, 5, 6, 7].indexOf(pages) !== -1) {
const defaultView = []
for (let i = 1; i <= pages; i++) {
defaultView.push(i)
}
return defaultView
}
currentPage = currentPage || 1
const boundary = 2
let boundaryMiddle = false
// if current page is sufficiently in the middle, boundary is +1 and -1
if (
currentPage > 3 &&
(pages - currentPage >= 3 ||
pages - currentPage === 1)
) {
boundaryMiddle = true
}
if (currentPage > pages) {
currentPage = pages
}
if (currentPage < 1) {
currentPage = 1
}
const output = []
if (!boundaryMiddle) {
// count up to boundary amount from current page
for (let i = currentPage; i <= pages; i++) {
if (output.length === boundary) {
break
}
output.push(i)
}
// if we do not fill the boundary count, count down from current page
if (output.length < boundary) {
for (let i = currentPage - 1; i > pages - boundary; i--) {
output.unshift(i)
}
}
} else {
// count up 1 and down 1 from current page
output.push(currentPage - 1)
output.push(currentPage)
output.push(currentPage + 1)
}
// attach last page to nav when only 1 away
if (pages - last(output) === 1) {
output.push(pages)
}
// attach first page to when only 1 away
if (output[0] === 2) {
output.unshift(1)
}
// attach first page to when only 2 away
if (currentPage === 3) {
output.unshift(2)
output.unshift(1)
}
// put lowest page and ... when we exceed the boundary
if (
currentPage > 3 &&
pages > boundary &&
pages > 7
) {
output.unshift(1, '...')
}
if (output.length < 7) {
let need = 7 - output.length
// should count down
if (pages === last(output)) {
for (let i = 1; i <= need; i++) {
output.splice(2, 0, output[2] - 1)
}
} else if (!boundaryMiddle) { // should count up
// remove "...", [last page]
need = need - 2
for (let i = 1; i <= need; i++) {
output.push(last(output) + 1)
}
}
}
// done if the final page is in view
if (!(pages - last(output) > 1)) {
return output
}
// attach final page to view
output.push('...')
output.push(pages)
return output
}
/**
* Render a string using the appropriate view engine.
*
* @async
* @method
* @param {string} text - The string to render
* @param {Object} [locals={}] - The local variables for the template
* @param {Object} [opts={}] - The options for rendering
* @returns {Promise<string>} - The rendered string
*/
renderString = async (text, locals = {}, opts = {}) => {
const ve = this.getViewEngine(opts.ext)
return await ve.renderString(text, locals, opts)
}
/**
* Render a template using the appropriate view engine.
*
* @async
* @method
* @param {string} tpl - The template file path
* @param {Object} [locals={}] - The local variables for the template
* @param {Object} [opts={}] - The options for rendering
* @returns {Promise<string>} - The rendered template
*/
render = async (tpl, locals = {}, opts = {}) => {
const ext = path.extname(tpl)
if (['.json', '.js', '.css'].includes(ext)) opts.partial = true
opts.ext = ext
const ve = this.getViewEngine(ext)
return await ve.render(tpl, locals, opts)
}
/**
* Strip HTML tags from a string.
*
* @method
* @param {string} html - The HTML string to strip
* @param {Object} [options={}] - The options for stripping
* @returns {string} - The stripped string
*/
stripHtmlTags = (html, options = {}) => {
const { result } = stripHtml(html, options)
return result
}
/**
* Convert a URL to a breadcrumb array.
*
* @method
* @param {string} url - The URL to convert
* @param {Object} [options={}] - The options for conversion
* @param {string} [options.base=''] - The base URL to remove
* @param {string} [options.delimiter='/'] - The delimiter for splitting the URL
* @param {boolean} [options.returnParts=false] - Whether to return the parts array
* @param {Function} [options.handler] - The handler function for each breadcrumb
* @param {Object} [options.handlerScope] - The scope for the handler function
* @param {Object} [options.handlerOpts={}] - The options for the handler function
* @returns {Array} - The breadcrumb array
*/
urlToBreadcrumb = (url, options = {}) => {
let { base = '', delimiter = '/', returnParts, handler, handlerScope, handlerOpts = {} } = options
const { routePath } = this.app.waibu
function defHandler (item) {
return item
}
function breakPath (route, delimiter = '/') {
route = trim(route, delimiter)
const parts = without(route.split(delimiter), '')
const routes = []
for (const p of parts) {
const l = last(routes)
routes.push(l ? `${l}${delimiter}${p}` : p)
}
return routes
}
url = routePath(url)
const route = trim(url.replace(base, ''), '/')
const parts = breakPath.call(this, route, delimiter)
if (returnParts) return parts
if (!handler) handler = defHandler
if (!handlerScope) handlerScope = this
const result = map(parts, (r, idx) => {
const f = `${base}/${r}`
const opts = parts.length > 2 && (idx === parts.length - 2) && handlerOpts.hrefRebuild ? { hrefRebuild: handlerOpts.hrefRebuild } : {}
return handler.call(handlerScope, f, url, opts)
})
return result
}
/**
* Get the pages from a menu based on the path and subPath.
*
* @method
* @param {Object} menu - The menu object
* @param {string} [path] - The path to filter the pages
* @param {string|Function} [subPath] - The subPath to filter the pages or a function to handle the filtering
* @returns {Array} - The filtered pages
*/
getMenuPages = (menu, path, subPath) => {
const all = get(menu, 'pages', [])
if (!path) return all
const pages = filter(all, a => {
return a.children && (a.title === path || a.href === path)
})
if (!isFunction(subPath)) {
return filter(pages, p => {
return p.title === subPath || p.href === subPath
})
}
return subPath(pages, subPath)
}
/**
* Parse attributes from a string or object.
*
* @method
* @param {string|Object} text - The text or object to parse
* @param {Object} [options={}] - The options for parsing
* @param {string} [options.delimiter=' '] - The delimiter for splitting the text
* @param {string} [options.kvDelimiter='='] - The delimiter for key-value pairs
* @param {boolean} [options.camelCasedKey=true] - Whether to camelCase the keys
* @param {boolean} [options.trimValue=true] - Whether to trim the values
* @returns {Object} - The parsed attributes
*/
parseAttribs = (text, options = {}) => {
const { delimiter = ' ', kvDelimiter = '=', camelCasedKey = true, trimValue = true } = options
let attrs = []
if (isPlainObject(text)) {
forOwn(text, (v, k) => {
attrs.push(`${k}${kvDelimiter}"${v}"`)
})
} else attrs = map(text.split(delimiter), t => trim(t))
const result = {}
const names = this.app.getAllNs()
for (const attr of attrs) {
let [k, ...v] = map(attr.split(kvDelimiter), a => trim(a))
v = v.join(kvDelimiter)
if (trimValue) v = v.slice(1, v.length - 1)
if (v === 'undefined') continue
if (k !== 'content' && (v === '' || v === 'true')) v = true
// check for retainAttrKey on ALL plugins
let retain = false
for (const name of names) {
const plugin = this.app[name]
if (plugin && plugin.retainAttrKey && plugin.retainAttrKey(k)) retain = true
}
if (!retain && camelCasedKey) k = camelCase(k)
result[k] = v
}
return result
}
/**
* Stringify attributes from an object.
*
* @method
* @param {Object} obj - The object to stringify
* @param {boolean} [kebabCasedKey=true] - Whether to kebab-case the keys
* @returns {string} - The stringified attributes
*/
stringifyAttribs = (obj = {}, kebabCasedKey = true) => {
const { objectToAttr, arrayToAttr } = this.app.waibu
const attrs = []
const names = this.app.getAllNs()
forOwn(obj, (v, k) => {
let retain = false
for (const name of names) {
const plugin = this.app[name]
if (plugin && plugin.retainAttrKey && plugin.retainAttrKey(k)) retain = true
}
if (retain) {
if (v === true) attrs.push(k)
else attrs.push(`${k}="${v}"`)
return undefined
}
if (kebabCasedKey) k = kebabCase(k)
if (!isSet(v)) return undefined
if (['class', 'style'].includes(k) && isEmpty(v)) return undefined
if (isArray(v)) v = arrayToAttr(v)
if (isPlainObject(v)) v = objectToAttr(v)
if (k !== 'content' && v === true) attrs.push(k)
else attrs.push(`${k}="${v}"`)
})
return attrs.join(' ')
}
/**
* Get a theme by name.
*
* @method
* @param {string} name - The name of the theme
* @param {boolean} [nameOnly=false] - Whether to return only the name
* @returns {Object|string|undefined} - The theme object, name, or undefined if not found
*/
getTheme = (name, nameOnly) => {
const theme = this.themes.find(item => item.name === name)
if (!theme) return theme
return nameOnly ? theme.name : theme
}
/**
* Get an iconset by name.
*
* @method
* @param {string} name - The name of the iconset
* @param {boolean} [nameOnly=false] - Whether to return only the name
* @returns {Object|string|undefined} - The iconset object, name, or undefined if not found
*/
getIconset = (name, nameOnly) => {
const iconset = this.iconsets.find(item => item.name === name)
if (!iconset) return iconset
return nameOnly ? iconset.name : iconset
}
/**
* Render a view with the given options.
*
* @async
* @method
* @param {Object} options - The options for rendering the view
* @param {string} options.tpl - The template to render
* @param {Object} [options.params={}] - The parameters for the template
* @param {Object} [options.opts={}] - The options for rendering
* @param {Object} options.reply - The reply object
* @returns {Promise<string>} - The rendered view
*/
renderView = async (options = {}) => {
const { tpl, params = {}, opts = {}, reply } = options
const buildLocals = await importModule('waibu:/lib/build-locals.js')
const { get: getCache, set: setCache } = this.app.bajoCache ?? {}
const { routePath } = this.app.waibu
async function isCacheable (req, cachedUrls) {
const { hash } = this.app.bajoExtra
const ns = get(req, 'routeOptions.config.ns')
let cache = get(req, 'routeOptions.config.cache', omit(this.config.page.cache, ['urls']))
cache.methods = cache.methods ?? ['GET']
if (!ns || (!this.app.bajoCache) || (!req.site) || !cache.methods.includes(req.method)) return { ttl: 0 }
if (isFunction(cache)) {
cache = await cache.call(this.app[ns], req, cachedUrls)
}
const url = req.url.split('?')[0].split('#')[0]
for (const item of cachedUrls) {
if (item.isMatch(url)) cache.ttlDur = item.ttlDur ?? cache.ttlDur
}
const key = `waibu-mpa-page-${req.site.id}-${cache.key ?? (await hash(req.url))}`
return { key, ttl: cache.ttlDur }
}
const mime = await importPkg('waibu:mime')
const cfg = this.config
const cachedUrls = cloneDeep(this.config.page.cache.urls).map(item => {
if (isString(item)) item = { url: item }
item.url = routePath(item.url)
item.isMatch = outmatch(item.url)
return item
})
let ext = path.extname(tpl)
if (ext === '.md') ext = '.html'
let mimeType = isEmpty(ext) ? 'text/html' : mime.getType(ext)
mimeType += `; charset=${cfg.page.charset}`
reply.header('Content-Type', mimeType)
reply.header('Content-Language', reply.request.lang)
reply.header('X-Req-Id', reply.request.id)
opts.req = reply.request
opts.reply = reply
for (const item of ['theme', 'iconset']) {
if (!reply.request[item]) reply.request[item] = this[item + 's'][0].name
if (this[item + 's'].length === 1) reply.request[item] = this[item + 's'][0].name
}
const { key, ttl } = await isCacheable.call(this, reply.request, cachedUrls)
if (ttl > 0) {
const cached = await getCache({ key })
if (cached) {
reply.header('X-Wmpa-Cached', true)
return cached
}
}
const locals = await buildLocals.call(this, { tpl, params, opts })
if (!get(reply.request, 'routeOptions.config.noCacheReq')) {
await this.app.cache.save(`${this.ns}.req:/${opts.req.id}-locals.json`, locals, this.config.reqTtlDur)
}
const result = await this.render(tpl, locals, opts)
if (ttl > 0) await setCache({ key, value: result, ttl })
if (reply.request.session) {
ext = path.extname(reply.request.url)
if (isEmpty(ext) || ['.html'].includes(ext)) {
if (reply.request.session.prevUrl !== reply.request.url) reply.request.session.prevUrl = reply.request.url
else reply.request.session.prevUrl = ''
}
}
return result
}
/**
* Apply formatting to the given text based on the options.
*
* @async
* @method
* @param {Object} options - The options for formatting
* @param {string} options.text - The text to format
* @param {Object} [options.locals={}] - The local variables for the template
* @param {Object} [options.opts={}] - The options for formatting
* @returns {Promise<string>} - The formatted text
*/
_applyFormat = async (options = {}) => {
let { text, locals = {}, opts = {} } = options
const { ext } = opts
const isDev = this.app.bajo.config.env === 'dev'
const viewEngine = this.getViewEngine(ext)
const exts = without(['.html', ...viewEngine.fileExts], '.js', '.css')
if (this.config.emoji && exts.includes(ext)) text = emoji.emojify(text)
if (exts.includes(ext)) text = await buildPage.call(this, { text, locals, opts })
if (!isDev && !opts.partial && this.config.prettier && exts.includes(ext)) text = await prettier.format(text, this.config.prettier)
if (!isDev && !opts.partial && this.config.minifier && ['.js', '.css', ...exts].includes(ext)) text = minify(text, this.config.minifier)
return text
}
/**
* Decorate the request and reply objects with additional properties and methods.
*
* @method
*/
_decorate = () => {
const cfg = this.config
const me = this
this.webAppCtx.decorateRequest('theme', cfg.theme.set)
this.webAppCtx.decorateRequest('iconset', cfg.iconset.set)
this.webAppCtx.decorateRequest('darkMode', cfg.darkMode.set)
this.webAppCtx.decorateRequest('referer', '')
this.webAppCtx.decorateReply('ctags', null)
this.webAppCtx.decorateReply('view', async function (tpl, params = {}, opts = {}) {
return await me.renderView({ reply: this, tpl, params, opts })
})
}
/**
* Load resources for the given module and item.
*
* @async
* @method
* @param {Object} mod - The module object
* @param {string} item - The item key to load resources for
* @returns {Promise<Array>} - The loaded resources
*/
async _loadResource (mod = [], item) {
if (isEmpty(mod[item])) return []
if (!isArray(mod[item])) mod[item] = [mod[item]]
const items = []
const extItems = []
for (const i in mod[item]) {
if (isString(mod[item][i])) {
if (mod[item][i].startsWith('/')) items.push(mod.css[i])
else {
let name = mod[item][i]
if (['$', '^'].includes(name[0])) name = name.slice(1)
const { ns, path, subNs } = breakNsPath(name, undefined, false)
if (subNs === 'load') extItems.push({ ns, path })
else items.push(mod[item][i])
}
} else items.push(mod[item][i])
}
for (const c of extItems) {
let emod = await readConfig(`${c.ns}:${c.path}`, { ns: c.ns, ignoreError: false })
if (!isArray(emod)) emod = [emod]
for (const m of emod) {
items.push(m)
}
}
return items
}
/**
* Handle not found errors.
*
* @method
* @param {Error} err - The error object
* @param {Object} req - The request object
* @param {Object} reply - The reply object
* @returns {Promise<string>} - The rendered view
* @async
*/
_handleNotFound = async (err, req, reply) => {
const welcome = req.url.split('?')[0] === '/'
const msg = req.t('routeNotFound%s%s', req.url, req.method)
const error = err ?? this.error(msg)
if (err) error.message = msg
if (!welcome) {
error.statusCode = 404
reply.code(404)
}
reply.header('Content-Type', `text/html; charset=${this.config.page.charset}`)
reply.header('Content-Language', req.lang)
if (error.noContent) return ''
const tpl = welcome ? `${this.ns}.template:/welcome.html` : `${this.ns}.template:/404.html`
req.webApp = this.ns
return await this.renderView({ reply, tpl, params: { error } })
}
/**
* Handle general errors.
*
* @method
* @param {Error} err - The error object
* @param {Object} req - The request object
* @param {Object} reply - The reply object
* @returns {Promise<string>} - The rendered view
* @async
*/
_handleError = async (err, req, reply) => {
const { resolveTemplate, compile } = this.app.bajoTemplate
err.statusCode = err.statusCode ?? 500
reply.code(err.statusCode)
reply.header('Content-Type', `text/html; charset=${this.config.page.charset}`)
reply.header('Content-Language', req.lang)
if (err.message === '_notFound' || err.statusCode === 404) {
return await this._handleNotFound(err, req, reply)
}
if (err.noContent) return ''
let tpl = `${this.ns}.template:/${err.statusCode}.html`
try {
resolveTemplate(tpl)
} catch (err) {
tpl = `${this.ns}.template:/500.html`
}
try {
req.webApp = this.ns
return await this.renderView({ reply, tpl, params: { error: err }, opts: { noFlash: true } })
} catch (err) {
// only going here if something happened in reply.view
const content = fs.readFileSync(resolveTemplate(tpl).file, 'utf8')
return await compile(content, { error: err })
}
}
/**
* Handle sub-applications.
*
* @method
* @returns {Promise<void>}
* @async
*/
_handleSubApp = async () => {
const { collectWebApps } = await importModule('waibu:/lib/helper.js', { asDefaultImport: false })
await runHook(`${this.ns}:beforeSubApp`, this.webAppCtx)
const mods = await collectWebApps.call(this.app[this.ns], 'boot.js', 'waibuMpa')
for (const m of mods) {
this.log.debug('bootSubApp%s', m.ns)
await this.webAppCtx.register(async (subCtx) => {
this.app[m.ns].instance = subCtx
await runHook(`${this.ns}.${m.alias}:afterCreateContext`, subCtx, m.prefix)
await m.handler.call(this.app[m.ns], subCtx, m.prefix)
}, { prefix: m.prefix })
}
await runHook(`${this.ns}:afterSubApp`, this.webAppCtx)
}
/**
* Build routes based on the provided options.
*
* @method
* @param {Object} options - The options for building routes
* @param {Array} options.files - The route files
* @param {string} options.pathPrefix - The path prefix for the routes
* @param {string} options.dir - The directory containing route files
* @param {string} options.ns - The namespace for the routes
* @param {Object} options.cfg - The configuration object
* @param {Object} options.parent - The parent route object
* @param {string} options.urlPrefix - The URL prefix for the routes
* @param {boolean} options.subRoute - Whether the route is a sub-route
* @returns {Promise<void>}
* @async
*/
async _buildRoute (options = {}) {
const { files, pathPrefix, dir, ns, cfg, parent, urlPrefix, subRoute } = options
const { getPluginPrefix } = this.app.waibu
const { mergeRouteHooks } = await importModule('waibu:/lib/webapp.js', { asDefaultImport: false })
const mods = []
const me = this
for (const f of files) {
const ext = path.extname(f)
const urls = f.slice(0, f.length - ext.length).replace(`${dir}/extend/${pathPrefix}`, '').replaceAll('@', ':').split('/')
if (last(urls) === 'index') urls.pop()
const url = urls.join('/')
let mod
if (ext === '.js') mod = await importModule(f)
else if (ext === '.json') mod = await readJson(f)
else if (['.html', '.md'].includes(ext)) mod = [{ view: f }]
if (!mod) continue
if (isFunction(mod)) mod = [{ handler: mod }]
else if (isPlainObject(mod)) mod = [mod]
for (let m of mod) {
const mhandler = m.handler // parseObject has problem with function
const murl = m.url // same as above
m = parseObject(omit(m, ['handler', 'url'], { parseValue: true }))
m.url = murl
m.handler = mhandler
m.url = m.url ?? url
if (isFunction(m.url)) m.url = await m.url.call(this)
if (m.redirect) {
m.handler = async function (req, reply) {
return reply.redirectTo(m.redirect)
}
}
if (!m.handler) {
m.handler = async function (req, reply) {
const params = {}
let tpl = m.view ?? `${ns}.template:${m.url}.html`
if (m.tbd) {
params.page = { title: titleize(last(m.url.split('/'))) }
tpl = 'waibuMpa.template:/tbd.html'
}
return await reply.view(tpl, params)
}
}
if (urlPrefix) m.url = `/${urlPrefix}/${m.url}`
m.url = trimEnd(m.url, '/')
m.method = m.method ?? 'GET'
await mergeRouteHooks.call(me, m)
m.config = m.config ?? {}
m.config.prefix = getPluginPrefix(ns)
m.config.pathSrc = m.url
m.config.webApp = parent ?? ns
m.config.xSite = m.xSite
m.config.mainSiteEdit = m.mainSiteEdit
m.config.ns = ns
m.config.subNs = ''
m.config.noCacheReq = m.noCacheReq
m.config.title = m.title ?? camelCase(last(m.url.split('/')))
m.config.subRoute = subRoute
if (m.cache === true) m.cache = omit(me.config.page.cache, ['urls'])
m.config.cache = defaultsDeep(m.cache ?? {}, { ttlDur: 0 })
delete m.title
m = defaultsDeep(pick(cfg, ['exposeHeadRoute', 'bodyLimit']), m)
mods.push(m)
}
}
return mods
}
/**
* Add routes to the application.
*
* @method
* @param {Object} options - The options for adding routes
* @param {string} options.appPrefix - The application prefix
* @param {string} options.prefix - The route prefix
* @param {Array} options.mods - The route modules
* @param {Object} options.appCtx - The application context
* @param {Object} options.cfg - The configuration object
* @returns {Promise<void>}
* @async
*/
_addRoutes = async (options = {}) => {
const { appPrefix, prefix, mods, appCtx, cfg } = options
const { isRouteDisabled } = this.app.waibu
const { reroutedPath } = await importModule('waibu:/lib/webapp.js', { asDefaultImport: false })
for (const mod of mods) {
const fullPath = appPrefix === '' ? mod.url : `/${appPrefix}${mod.url}`
if (isRouteDisabled(`${prefix === '' ? '' : `/${prefix}`}${fullPath}`)) continue
const rpath = await reroutedPath.call(this, fullPath, cfg.rerouted)
if (rpath) {
this.log.warn('rerouted%s%s', `${prefix}${fullPath}`, `${prefix}${rpath}`)
mod.url = rpath
mod.pathReroutedTo = rpath
this.webAppCtx.route(mod)
} else appCtx.route(mod)
}
}
/**
* Build routes for the application.
*
* @method
* @param {string} prefix - The route prefix
* @returns {Promise<void>}
* @async
*/
_buildRoutes = async (prefix) => {
const { getPluginPrefix } = this.app.waibu
const cfg = this.config
const pathPrefix = 'waibuMpa/route'
const me = this
const appCtxs = {}
let subRoutes = []
const names = this.app.getAllNs()
await runHook(`${this.ns}:beforeBuildRoutes`, this.webAppCtx)
await eachPlugins(async function ({ dir }) {
const { ns } = this
let appPrefix = getPluginPrefix(ns)
if (ns === me.ns || (ns === me.app.mainNs && cfg.mountMainAsRoot)) appPrefix = ''
const pattern = `${dir}/extend/${pathPrefix}/**/*.{js,json,html,md}`
const files = await fastGlob(pattern)
// subRoutes
const spattern = `${dir}/extend/waibuMpa/extend/{${names.join(',')}}/route/**/*.{js,json,html,md}`
const sfiles = await fastGlob(spattern)
for (const file of sfiles) {
const [sns] = file.replace(`${dir}/extend/waibuMpa/extend/`, '').split('/')
subRoutes.push({ file, ns: sns, sns: ns, dir })
}
if (files.length === 0) return undefined
await me.webAppCtx.register(async (appCtx) => {
appCtxs[ns] = appCtx
await runHook(`${me.ns}.${this.ns}:beforeBuildRoutes`, appCtx, appPrefix)
const mods = await me._buildRoute.call(this, { appCtx, files, appPrefix, pathPrefix, dir, ns, cfg, parent: me.ns })
await me._addRoutes({ appPrefix, prefix, mods, appCtx, cfg })
await runHook(`${me.ns}.${this.ns}:afterBuildRoutes`, appCtx, appPrefix)
}, { prefix: appPrefix })
})
if (subRoutes.length > 0) {
subRoutes = groupBy(subRoutes, 'ns')
for (const k in subRoutes) {
const items = subRoutes[k]
const appCtx = appCtxs[k]
if (!appCtx) throw this.error('cantHaveSubroutesWithoutContext%s', k)
for (const item of items) {
let appPrefix = getPluginPrefix(k)
if (item.sns === me.ns || (item.sns === me.app.mainNs && cfg.mountMainAsRoot)) appPrefix = ''
await runHook(`${me.ns}.${k}:beforeBuildSubRoutes`, appCtx, appPrefix)
const scope = this.app[item.sns]
const pathPrefix = `waibuMpa/extend/${item.ns}/route/`
const urlPrefix = getPluginPrefix(item.sns)
const mods = await me._buildRoute.call(scope, { appCtx, files: [item.file], pathPrefix, dir: item.dir, ns: item.ns, cfg, parent: me.ns, urlPrefix, subRoute: item.sns })
await me._addRoutes({ appPrefix, prefix, mods, appCtx, cfg })
await runHook(`${me.ns}.${k}:afterBuildSubRoutes`, appCtx, appPrefix)
}
}
}
await runHook(`${this.ns}:afterBuildRoutes`, this.webAppCtx)
}
/**
* Trash old sessions.
*
* @method
* @returns {Promise<void>}
* @async
*/
_trashOldSession = async () => {
if (!this.app.dobo) return
const model = this.app.dobo.getModel('WmpaSession')
const recs = await model.findAllRecord({ sort: { createdAt: 1 } }, { noHook: true, noModelHook: true })
for (const rec of recs) {
let expires = get(rec, 'session.cookie.expires')
if (!expires) expires = dayjs(rec.createdAt).add(this.config.session.cookie.maxAge, 'ms').toISOString()
const diff = dayjs(expires).diff(dayjs())
if (diff < 0) await model.removeRecord(rec.id, { noReturn: true })
}
}
/**
* Handle session setup.
*
* @method
* @returns {Promise<void>}
* @async
*/
_handleSession = async () => {
const [cookie, session, flash] = await importPkg('waibu:@fastify/cookie',
'waibu:@fastify/session', 'waibu:@fastify/flash')
const cfg = this.getConfig('session')
if (!cfg) return
delete cfg.trashOldDur
await runHook(`${this.ns}:beforeSessionSetup`)
if (this.app.dobo) {
this.sessionStore = new Store(this)
cfg.store = this.sessionStore
}
this.webAppCtx.register(cookie)
this.webAppCtx.register(session, cfg)
this.webAppCtx.register(flash)
await runHook(`${this.ns}:afterSessionSetup`, this.webAppCtx)
this._trashOldSession()
setInterval(() => {
this._trashOldSession()
}, this.config.session.trashOldDur)
}
}
return WaibuMpa
}
export default factory