import crypto from 'crypto'
import _path from 'path'
const cache = {}
/**
* Plugin factory.
*
* **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.
*
* @async
* @param {string} pkgName - NPM package name
* @returns {Promise<class>} Promise that resolves to the BajoTemplate class
*/
async function factory (pkgName) {
const me = this
/**
* BajoTemplate class definition.
*
* @class BajoTemplate
*/
class BajoTemplate extends this.app.baseClass.Base {
/**
* Constructor
*/
constructor () {
super(pkgName, me.app)
/**
* @property {object} config - Configuration object
* @property {object} [config.layout={}] - Layout configuration
* @property {boolean} [config.layout.fallback=true] - Whether to fallback to default layout if not found
* @property {Number|string} [config.loopDetectorDur='1m'] - Duration for loop detection
* @property {object} [config.log={}] - Logging configuration
* @property {boolean} [config.log.resolver=false] - Whether to log resolver actions
* @property {object} [config.cache={}] - Cache configuration
* @property {Number|string} [config.cache.ttlDur='1s'] - Time-to-live duration for cache
*/
this.config = {
layout: {
fallback: true
},
loopDetectorDur: '1m',
log: {
resolver: false
},
cache: {
ttlDur: '1s'
}
}
/**
* @property {object} loopDetector - Loop detection object
*/
this.loopDetector = {}
}
/**
* Build compile imports. Used to provide utility functions to templates.
*
* @method
* @param {object} locals - Locals object
* @param {object} req - Request object
* @returns {TImportsResult} Imports object
*/
buildCompileImports = (locals = {}, req = {}) => {
const _ = this.app.lib._
return {
_,
_t: (text, ...args) => {
const params = [...args, { lang: req.lang }]
return this.t(text, ...params)
},
_format: (val, type, opts = {}) => {
opts.lang = opts.lang ?? req.lang
return this.app.bajo.format(val, type, opts)
},
_findRoute: (input) => {
if (!this.app.waibu) return false
return this.app.waibu.findRoute(input)
},
_routePath: (input, opts) => {
if (!this.app.waibu) return input
return this.app.waibu.routePath(input, opts)
},
_titleize: this.app.lib.aneka.titleize,
_hasPlugin: name => this.app.getAllNs().includes(name),
_jsonStringify: this.app.waibuMpa.jsonStringify,
_parseMarkdown: content => {
if (!this.app.bajoMarkdown) return content
return this.app.bajoMarkdown.parseContent(content)
},
_excerpt: (content, words) => {
return this.getExcerpt(content, words)
},
_dump: (value, noPre) => {
return (noPre ? '' : '<pre>') + JSON.stringify(value, null, 2) + (noPre ? '' : '</pre>')
},
_getSetting: (key, defValue) => {
if (!this.app.waibu) return _.get(locals, `schema.view.${key}`, defValue)
const cfg = this.app.waibu.getSetting(key, { defValue, req })
const { path } = this.app.bajo.breakNsPath(key)
const paths = path.replaceAll('/', '.').split('.')
if (paths[0] === '') paths.shift()
return _.get(locals, `schema.view.${paths.join('.')}`, cfg)
}
}
}
/**
* Clear loop detector by removing entries that have expired based on the configured duration.
*
* @method
* @private
*/
_clearLoopDetector = () => {
const { omit } = this.app.lib._
const now = Date.now()
const omitted = []
for (const groupId in this.loopDetector) {
const history = this.loopDetector[groupId]
if ((history.ts + this.config.loopDetectorDur) < now) omitted.push(groupId)
}
this.loopDetector = omit(this.loopDetector, omitted)
}
/**
* Detect loop in template rendering.
*
* @method
* @private
* @param {string} tpl - Template name
* @param {string} file - File path
* @param {object} opts - Options object
*/
_detectLoop = (tpl, file, opts) => {
const { last } = this.app.lib._
if (opts.groupId) {
if (this.loopDetector[opts.groupId]) {
if (last(this.loopDetector[opts.groupId].file) === file && _path.basename(file)[0] !== '~') {
throw this.error('loopDetected%s%s', tpl, file)
}
this.loopDetector[opts.groupId].file.push(file)
} else {
this.loopDetector[opts.groupId] = {
ts: Date.now(),
file: [file]
}
}
}
}
/**
* Render a template.
*
* @async
* @method
* @private
* @param {string} tpl - Template name
* @param {object} locals - Locals object
* @param {object} opts - Options object
* @returns {Promise<string>} Rendered content
*/
_render = async (tpl, locals = {}, opts = {}) => {
this._clearLoopDetector()
const { trim, isEmpty, last } = this.app.lib._
const { fs } = this.app.lib
const { breakNsPath } = this.app.bajo
let resp
let subNs
if (_path.isAbsolute(tpl)) resp = { file: tpl }
else {
subNs = breakNsPath(tpl).subNs
if (subNs === 'template') {
resp = this.resolveTemplate(tpl, opts)
} else if (subNs === 'partial') {
resp = this.resolvePartial(tpl, opts)
}
}
if (!resp) throw this.error('resourceNotFound%s', tpl)
const { file } = resp
this._detectLoop(tpl, file, opts)
const fileContent = trim(fs.readFileSync(file, 'utf8'))
let { content, frontMatter } = this.splitContent(fileContent)
if (isEmpty(content)) {
const sep = '/waibuMpa/route/'
if (_path.isAbsolute(tpl) && tpl.includes(sep)) { // for direct waibuMpa's route
const parts = tpl.split(sep)
const ns = last(parts[0].split('/'))
content = `<!-- include ${ns}.partial:/${parts[1]} -->`
} else content = '<!-- include ' + tpl.replace('.template', '.partial') + ' -->'
}
opts.ext = _path.extname(file)
opts.frontMatter = frontMatter
opts.partial = opts.partial ?? subNs === 'partial'
return await this._renderString(content, locals, opts)
}
/**
* Handle include directives in the content.
*
* @async
* @method
* @private
* @param {string} content - Content string
* @param {object} locals - Locals object
* @param {object} opts - Options object
* @returns {Promise<string>} Processed content
*/
_handleInclude = async (content, locals = {}, opts = {}) => {
const { isEmpty, omit, template, merge } = this.app.lib._
const { extractText } = this.app.lib.aneka
const { breakNsPath } = this.app.bajo
const start = '<!-- include '
const end = ' -->'
const imports = this.buildCompileImports(locals, opts.req)
while (content.includes(start) && content.includes(end)) {
const { pattern, result: rsc } = extractText(content, start, end)
if (!isEmpty(rsc)) {
let attr = {}
let [resource, sattr] = rsc.split('|')
if (!isEmpty(sattr)) {
try {
attr = JSON.parse(sattr)
} catch (err) {}
}
const fn = template(resource, { imports })
resource = fn(locals)
const { subNs } = breakNsPath(resource)
let result = ''
if (subNs === 'partial') {
const { req, reply } = opts
const nopts = omit(opts, ['req', 'reply', 'postProcessor'])
nopts.partial = true
nopts.req = req
nopts.reply = reply
const nlocals = merge({}, locals, { attr })
result = await this.render(resource, nlocals, nopts)
}
content = content.replace(pattern, result)
}
}
return content
}
/**
* Render a string with the given locals and options.
*
* @async
* @method
* @private
* @param {string} content - Content string
* @param {object} locals - Locals object
* @param {object} opts - Options object
* @returns {Promise<string>} Rendered content
*/
_renderString = async (content, locals = {}, opts = {}) => {
const { merge, without, isString, omit, kebabCase, get } = this.app.lib._
if (opts.ext === '.md' && this.app.bajoMarkdown) {
content = await this.compile(content, locals, { ttl: -1, req: opts.req }) // markdown can't process template tags, hence preprocess here
content = this.app.bajoMarkdown.parse(content)
}
let layout
if (!opts.partial) {
const pageFm = await this.parseFrontMatter(opts.frontMatter, opts.lang)
if (pageFm.layout) opts.layout = pageFm.layout
if (pageFm.scriptBlock) opts.scriptBlock = pageFm.scriptBlock
if (pageFm.styleBlock) opts.styleBlock = pageFm.styleBlock
locals.page = merge({}, locals.page, omit(pageFm, ['layout']))
layout = opts.layout ?? locals.page.layout ?? (locals.page.ns ? `${locals.page.ns}.layout:/default.html` : 'main.layout:/default.html')
for (const b of ['scriptBlock', 'styleBlock']) {
locals.page[b] = pageFm[b] ?? opts[b] ?? (locals.page.ns ? `${locals.page.ns}.partial:/${kebabCase(b)}.html` : `bajoTemplate.partial:/${kebabCase(b)}.html`)
}
const ext = _path.extname(layout)
const { file } = this.resolveLayout(layout, opts)
let { content: layoutContent, frontMatter: layoutFm } = this.splitContent(file, true)
layoutFm = await this.parseFrontMatter(layoutFm, opts.lang)
const keys = without(Object.keys(layoutFm), 'css', 'scripts')
if (['.html'].includes(ext)) {
for (const item of ['css', 'scripts']) {
locals.page[item] = locals.page[item] ?? []
if (isString(locals.page[item])) locals.page[item] = [locals.page[item]]
layoutFm[item] = layoutFm[item] ?? []
if (isString(layoutFm[item])) layoutFm[item] = [layoutFm[item]]
locals.page[item].unshift(...layoutFm[item])
}
}
for (const key of keys) {
locals.page[key] = locals.page[key] ?? layoutFm[key]
}
if (layoutFm.title && !locals.page.title) locals.page.title = layoutFm.title
content = layoutContent.replace('<!-- body -->', content)
const usePluginTitle = get(this, 'app.waibuMpa.config.page.usePluginTitle')
const fullTitle = usePluginTitle ? `${locals.page.title} - ${this.app.waibuMpa.getPluginTitle(locals.page.ns, opts.lang)}` : locals.page.title
locals.page.fullTitle = locals.page.fullTitle ?? fullTitle
}
content = await this.compile(content, locals, { ttl: this.config.cache.maxAgeDu, req: opts.req })
return await this._handleInclude(content, locals, opts)
}
/**
* Find a file based on the given options.
*
* @method
* @private
* @param {object} options - Options object
* @param {string} options.type - Type of the file
* @param {string} options.ns - Namespace
* @param {string} options.subSubNs - Sub-sub namespace
* @param {string} options.dir - Directory
* @param {string} options.base - Base name
* @param {string[]} options.exts - Array of extensions
* @param {object} options.theme - Theme object
* @param {object} options.req - Request object
* @returns {string|null} - Found file path or null
*/
_findFile = (options = {}) => {
const { type, ns, subSubNs, dir, base, exts, theme, req } = options
const checking = ({ check, info }) => {
const { fs } = this.app.lib
let file
for (const ext of exts) {
let path = `${check}/${dir}/${base}${ext}`
if (['', '.'].includes(dir)) path = `${check}/${base}${ext}`
if (fs.existsSync(path)) {
file = path
break
}
}
if (this.config.log.resolver) {
this.log.trace('Checking %s for "%s" -> %s', info, base, check)
if (file) this.log.trace('OK: %s', file)
else this.log.trace('Failed')
}
return file
}
const extension = subSubNs ? `${subSubNs}/` : ''
let file
if (this.app.waibu && req) {
const hostname = this.app.waibu.getHostname(req)
const check = `${this.app.main.dir.pkg}/extend/${this.ns}/${hostname}/${ns}/${extension}${type}`
file = checking({ check, info: '1. main override: domain specific' })
}
if (theme && !file) {
const check = `${this.app.main.dir.pkg}/extend/${this.ns}/${theme.name}/${ns}/${extension}${type}`
file = checking({ check, info: '2. main override: theme specific' })
}
if (!file) {
const check = `${this.app.main.dir.pkg}/extend/${this.ns}/${ns}/${extension}${type}`
file = checking({ check, info: '3. main override: common' })
}
if (theme && !file) {
const check = `${theme.plugin.dir.pkg}/extend/${this.ns}/${theme.name}/${ns}/${extension}${type}`
file = checking({ check, info: '4. override: theme specific' })
}
if (theme && !file) {
const check = `${theme.plugin.dir.pkg}/extend/${this.ns}/_common/${ns}/${extension}${type}`
file = checking({ check, info: '5. override: theme specific (common)' })
}
if (theme && !file) {
const check = `${this.app[ns].dir.pkg}/extend/${this.ns}/${theme.name}/${extension}${type}`
file = checking({ check, info: '6. real: theme specific' })
}
if (!file) {
const check = `${this.app[ns].dir.pkg}/extend/${this.ns}/${extension}${type}`
file = checking({ check, info: '7. real: common' })
}
return file
}
/**
* Get a resource by name.
*
* @method
* @param {string} name - Resource name
* @returns {object} Resource object
*/
getResource = (name) => {
const subNses = ['layout', 'template', 'partial']
const { ns, path, subNs, subSubNs, qs } = this.app.bajo.breakNsPath(name)
const plugin = this.app.getPlugin(ns)
const dir = `${plugin.dir.pkg}/extend/bajoTemplate`
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 }
}
/**
* Parse front matter from the input string.
*
* @async
* @method
* @param {string} input - Input string
* @param {string} lang - Language
* @returns {Promise<object>} Parsed front matter
*/
parseFrontMatter = async (input = '', lang) => {
const { isEmpty } = this.app.lib._
const { readAsConfig } = this.app.bajo
const { parseObject } = this.app.lib
const result = await readAsConfig(input)
if (isEmpty(result)) return {}
return parseObject(result, { parseValue: false, lang }) ?? {}
}
/**
* Compile a template with the given content and locals.
*
* @async
* @method
* @param {string} content - Template content
* @param {object} locals - Locals object
* @param {object} options - Options object
* @param {number} options.ttl - Time to live for cache
* @param {object} options.req - Request object
* @returns {Promise<Function>} Compiled template function
*/
compile = async (content, locals, { ttl = 0, req = {} } = {}) => {
const { get: getCache, set: setCache } = this.app.bajoCache ?? {}
const { template } = this.app.lib._
locals.attr = locals.attr ?? {}
const opts = {
imports: this.buildCompileImports(locals, req)
}
const key = 'fn:' + crypto.createHash('md5').update(content).digest('hex')
let item
if (getCache) {
item = await getCache({ key })
if (item) return item(locals)
}
item = template(content, opts)
if (setCache) await setCache({ key, value: item, ttl })
return item(locals)
}
/**
* Render a string with the given locals and options.
*
* @async
* @method
* @param {string} content - Content string
* @param {object} locals - Locals object
* @param {object} opts - Options object
* @returns {Promise<string>} Rendered content
*/
renderString = async (content, locals = {}, opts = {}) => {
let text = await this._renderString(content, locals, opts)
if (opts.postProcessor) text = await opts.postProcessor({ text, locals, opts })
return text
}
/**
* Render a template with the given locals and options.
*
* @async
* @method
* @param {string} tpl - Template name
* @param {object} locals - Locals object
* @param {object} opts - Options object
* @returns {Promise<string>} Rendered template
*/
render = async (tpl, locals = {}, opts = {}) => {
const { runHook, breakNsPath } = this.app.bajo
const { upperFirst } = this.app.lib._
const { get, set } = this.app.bajoCache ?? {}
const key = crypto.createHash('md5').update(`${tpl}:${JSON.stringify(locals)}`).digest('hex')
let subNs
const isAbsolute = _path.isAbsolute(tpl)
if (!isAbsolute) subNs = breakNsPath(tpl).subNs
const canCache = (isAbsolute || subNs === 'template') && this.config.cache !== false && get && set
if (canCache) {
const item = await get({ key })
if (item) return item
}
if (subNs) await runHook(`${this.ns}:beforeRender${upperFirst(subNs)}`, { tpl, locals, opts })
let text = await this._render(tpl, locals, opts)
if (opts.postProcessor) text = await opts.postProcessor({ text, locals, opts })
if (subNs) await runHook(`${this.ns}:afterRender${upperFirst(subNs)}`, { tpl, locals, opts, text })
if (canCache) await set({ key, value: text, ttl: opts.ttlDur ?? this.config.cache.ttlDur })
return text
}
/**
* Resolve a resource by type and name.
*
* @method
* @param {string} type - Resource type
* @param {string} item - Resource name
* @param {object} opts - Options object
* @param {Function} fallbackHandler - Fallback handler function
* @returns {object} Resolved resource
*/
resolveResource = (type, item = '', opts = {}, fallbackHandler) => {
const { trim, find, uniq } = this.app.lib._
const { fs } = this.app.lib
const env = this.app.bajo.config.env
if (env !== 'dev' && cache[item]) return cache[item]
let { ns, subSubNs, path } = this.getResource(item)
const ext = _path.extname(path)
path = trim(path, '/')
const dir = _path.dirname(path)
const base = _path.basename(path, ext)
const fallbackLang = this.app.bajo.config.intl.fallback
const exts = uniq([`.${fallbackLang}${ext}`, ext])
if (opts.lang) exts.unshift(`.${opts.lang}${ext}`)
let theme
if (opts.theme && this.app.waibuMpa && opts.req) theme = find(this.app.waibuMpa.themes, { name: opts.theme })
let file = this._findFile({ type, ns, subSubNs, dir, base, exts, theme, req: opts.req })
if (!file) {
if (fallbackHandler) file = fallbackHandler.call(this, { dir, base, exts, ns, subSubNs, type, theme, req: opts.req })
if (opts.default) {
const fname = this.getResource(opts.default).fullPath
if (fs.existsSync(fname)) file = fname
}
if (!file) throw this.error('cantFind%s%s', type, item)
}
const result = { file, ns, layout: opts.layout }
if (env !== 'dev') cache[item] = result
return result
}
/**
* Resolve a layout by name.
*
* @method
* @param {string} item - Layout name
* @param {object} opts - Options object
* @returns {object} Resolved layout
*/
resolveLayout = (item = '', opts = {}) => {
const fallbackHandler = ({ type, ns, subSubNs, dir, exts, theme, req }) => {
if (!this.config.layout.fallback) return false
const base = 'default'
return this._findFile({ type, ns, subSubNs, dir, base, exts, theme, req })
}
return this.resolveResource('layout', item, opts, fallbackHandler)
}
/**
* Resolve a partial by name.
*
* @method
* @param {string} item - Partial name
* @param {object} opts - Options object
* @returns {object} Resolved partial
*/
resolvePartial = (item = '', opts = {}) => {
return this.resolveResource('partial', item, opts)
}
/**
* Resolve a template by name.
*
* @method
* @param {string} item - Template name
* @param {object} opts - Options object
* @returns {object} Resolved template
*/
resolveTemplate = (item = '', opts = {}) => {
return this.resolveResource('template', item, opts)
}
/**
* Split content into front matter and body.
*
* @method
* @param {string} input - Input string or file path
* @param {boolean} readFile - Whether to read the file
* @returns {object} Object containing frontMatter and content
*/
splitContent = (input, readFile) => {
const { fs } = this.app.lib
const start = '---\n'
const end = '\n---'
let content = readFile ? fs.readFileSync(input, 'utf8') : input
let text = content.replaceAll('\r\n', '\n')
const open = text.indexOf(start)
let frontMatter
if (open > -1) {
text = text.slice(open + start.length)
const close = text.indexOf(end)
if (close > -1) {
frontMatter = text.slice(0, close)
content = text.slice(close + end.length)
}
}
frontMatter = frontMatter ?? ''
content = content ?? ''
return { frontMatter, content }
}
/**
* Get an excerpt from the content.
*
* @method
* @param {string} content - Content string
* @param {number} maxWords - Maximum number of words
* @param {string} trailChars - Trailing characters
* @returns {string} Excerpt
* @see {@link https://medium.com/@paulohfev/problem-solving-how-to-create-an-excerpt-fdb048687928|Problem Solving: How to Create an Excerpt}
*/
getExcerpt = (content = '', maxWords = 50, trailChars = '...') => {
const listOfWords = content.trim().split(' ')
const truncatedContent = listOfWords.slice(0, maxWords).join(' ')
const excerpt = truncatedContent + trailChars
return listOfWords.length > maxWords ? excerpt : content
}
/**
* Get a truncated version of the content.
*
* @method
* @param {string} content - Content string
* @param {number} maxChars - Maximum number of characters
* @param {string} trailChars - Trailing characters
* @returns {string} Truncated content
*/
getTruncated = (content = '', maxChars = 50, trailChars = '...') => {
const truncated = content.slice(0, maxChars)
return truncated.length !== content.length ? (truncated + trailChars) : content
}
}
return BajoTemplate
}
export default factory